Files
market_page/frontend/src/components/competition/CompetitionDetail.jsx
jeremygan2021 d28ecf98ea
All checks were successful
Deploy to Server / deploy (push) Successful in 27s
commit
2026-03-11 23:04:37 +08:00

445 lines
19 KiB
JavaScript

import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Typography, Tabs, Button, Row, Col, Card, Statistic, Tag, Descriptions, Empty, message, Spin, Modal, List, Avatar, Grid } from 'antd';
import { CalendarOutlined, TrophyOutlined, UserOutlined, FileTextOutlined, CloudUploadOutlined, CopyOutlined, CheckOutlined, MessageOutlined } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import dayjs from 'dayjs';
import { getCompetitionDetail, getProjects, getMyCompetitionEnrollment, enrollCompetition, getComments } from '../../api';
import ProjectSubmission from './ProjectSubmission';
import { useAuth } from '../../context/AuthContext';
import 'github-markdown-css/github-markdown-dark.css';
/**
* Get the full URL for an image.
* Handles relative paths and ensures correct API base URL is used.
* @param {string} url - The image URL path
* @returns {string} The full absolute URL
*/
const getImageUrl = (url) => {
if (!url) return '';
if (url.startsWith('http') || url.startsWith('//')) return url;
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
// Remove /api suffix if present to get the root URL for media files
const baseUrl = apiUrl.replace(/\/api\/?$/, '');
return `${baseUrl}${url}`;
};
const { Title, Paragraph } = Typography;
const { useBreakpoint } = Grid;
/**
* Code block component for markdown rendering with syntax highlighting and copy functionality.
*/
const CodeBlock = ({ inline, className, children, ...props }) => {
const [copied, setCopied] = useState(false);
const match = /language-(\w+)/.exec(className || '');
const codeString = String(children).replace(/\n$/, '');
const handleCopy = () => {
navigator.clipboard.writeText(codeString);
setCopied(true);
message.success('代码已复制');
setTimeout(() => setCopied(false), 2000);
};
return !inline && match ? (
<div style={{ position: 'relative', margin: '1em 0' }}>
<div
onClick={handleCopy}
style={{
position: 'absolute',
top: 8,
right: 8,
color: '#ccc',
cursor: 'pointer',
zIndex: 1,
background: 'rgba(0,0,0,0.5)',
padding: '4px 8px',
borderRadius: 4,
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 4
}}
>
{copied ? <CheckOutlined /> : <CopyOutlined />}
<span>{copied ? '已复制' : '复制'}</span>
</div>
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
{...props}
>
{codeString}
</SyntaxHighlighter>
</div>
) : (
<code className={className} {...props}>
{children}
</code>
);
};
/**
* Main component for displaying competition details.
* Includes tabs for overview, projects, and leaderboard.
* Responsive design for mobile and desktop.
*/
const CompetitionDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user, showLoginModal } = useAuth();
const [activeTab, setActiveTab] = useState('details');
const [submissionModalVisible, setSubmissionModalVisible] = useState(false);
const [editingProject, setEditingProject] = useState(null);
const [commentsModalVisible, setCommentsModalVisible] = useState(false);
const [currentProjectComments, setCurrentProjectComments] = useState([]);
const [commentsLoading, setCommentsLoading] = useState(false);
const screens = useBreakpoint();
const isMobile = !screens.md;
// Fetch competition details
const { data: competition, isLoading: loadingDetail } = useQuery({
queryKey: ['competition', id],
queryFn: () => getCompetitionDetail(id).then(res => res.data)
});
// Fetch projects (for leaderboard/display)
const { data: projects } = useQuery({
queryKey: ['projects', id],
queryFn: () => getProjects({ competition: id, status: 'submitted', page_size: 100 }).then(res => res.data)
});
// Check enrollment status
const { data: enrollment, refetch: refetchEnrollment } = useQuery({
queryKey: ['enrollment', id],
queryFn: () => getMyCompetitionEnrollment(id).then(res => res.data),
enabled: !!user,
retry: false
});
// Fetch my project if enrolled
const { data: myProjects, isLoading: loadingMyProject } = useQuery({
queryKey: ['myProject', id, enrollment?.id],
queryFn: () => getProjects({ competition: id, contestant: enrollment.id }).then(res => res.data),
enabled: !!enrollment?.id
});
const myProject = myProjects?.results?.[0];
/**
* Handle competition enrollment.
* Checks login status and submits enrollment request.
*/
const handleEnroll = async () => {
if (!user) {
showLoginModal();
return;
}
try {
await enrollCompetition(id, { role: 'contestant' });
message.success('报名申请已提交,请等待审核');
refetchEnrollment();
} catch (error) {
message.error(error.response?.data?.detail || '报名失败');
}
};
/**
* Fetch and display judge comments for a project.
* @param {Object} project - The project object
*/
const handleViewComments = async (project) => {
if (!project) return;
setCommentsLoading(true);
setCommentsModalVisible(true);
try {
const res = await getComments({ project: project.id });
// Support pagination result or list result
setCurrentProjectComments(res.data?.results || res.data || []);
} catch (error) {
console.error(error);
message.error('获取评语失败');
} finally {
setCommentsLoading(false);
}
};
if (loadingDetail) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!competition) return <Empty description="比赛不存在" />;
const isContestant = enrollment?.role === 'contestant' && enrollment?.status === 'approved';
const items = [
{
key: 'details',
label: '比赛详情',
children: (
<div style={{ padding: isMobile ? 12 : 24, background: '#1f1f1f', borderRadius: 8 }}>
<Descriptions title="基本信息" bordered column={{ xs: 1, sm: 2, md: 3 }} size={isMobile ? 'small' : 'default'}>
<Descriptions.Item label="状态">
<Tag color={competition.status === 'registration' ? 'green' : 'default'}>
{competition.status_display}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="报名时间">
{dayjs(competition.start_time).format('YYYY-MM-DD')}
</Descriptions.Item>
<Descriptions.Item label="结束时间">
{dayjs(competition.end_time).format('YYYY-MM-DD')}
</Descriptions.Item>
</Descriptions>
<Title level={isMobile ? 5 : 4} style={{ marginTop: isMobile ? 24 : 32, color: '#fff' }}>比赛简介</Title>
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: isMobile ? '14px' : '16px' }} className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
code: CodeBlock,
img: (props) => <img {...props} src={getImageUrl(props.src)} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.5em' : '2em' }} />,
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.3em' : '1.5em' }} />,
h3: (props) => <h3 {...props} style={{ color: '#eee', fontSize: isMobile ? '1.1em' : '1.25em' }} />,
a: (props) => <a {...props} style={{ color: '#00b96b' }} target="_blank" rel="noopener noreferrer" />,
blockquote: (props) => <blockquote {...props} style={{ borderLeft: '4px solid #00b96b', color: '#999', paddingLeft: '1em', margin: '1em 0' }} />,
table: (props) => <table {...props} style={{ borderCollapse: 'collapse', width: '100%', margin: '1em 0', display: 'block', overflowX: 'auto' }} />,
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333', whiteSpace: 'nowrap' }} />,
td: (props) => <td {...props} style={{ border: '1px solid #444', padding: '8px' }} />,
}}
>
{competition.description}
</ReactMarkdown>
</div>
<Title level={isMobile ? 5 : 4} style={{ marginTop: isMobile ? 24 : 32, color: '#fff' }}>规则说明</Title>
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: isMobile ? '14px' : '16px' }} className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
code: CodeBlock,
img: (props) => <img {...props} src={getImageUrl(props.src)} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.5em' : '2em' }} />,
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.3em' : '1.5em' }} />,
h3: (props) => <h3 {...props} style={{ color: '#eee', fontSize: isMobile ? '1.1em' : '1.25em' }} />,
a: (props) => <a {...props} style={{ color: '#00b96b' }} target="_blank" rel="noopener noreferrer" />,
blockquote: (props) => <blockquote {...props} style={{ borderLeft: '4px solid #00b96b', color: '#999', paddingLeft: '1em', margin: '1em 0' }} />,
table: (props) => <table {...props} style={{ borderCollapse: 'collapse', width: '100%', margin: '1em 0', display: 'block', overflowX: 'auto' }} />,
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333', whiteSpace: 'nowrap' }} />,
td: (props) => <td {...props} style={{ border: '1px solid #444', padding: '8px' }} />,
}}
>
{competition.rule_description}
</ReactMarkdown>
</div>
<Title level={isMobile ? 5 : 4} style={{ marginTop: isMobile ? 24 : 32, color: '#fff' }}>参赛条件</Title>
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: isMobile ? '14px' : '16px' }} className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
code: CodeBlock,
img: (props) => <img {...props} src={getImageUrl(props.src)} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.5em' : '2em' }} />,
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.3em' : '1.5em' }} />,
h3: (props) => <h3 {...props} style={{ color: '#eee', fontSize: isMobile ? '1.1em' : '1.25em' }} />,
a: (props) => <a {...props} style={{ color: '#00b96b' }} target="_blank" rel="noopener noreferrer" />,
blockquote: (props) => <blockquote {...props} style={{ borderLeft: '4px solid #00b96b', color: '#999', paddingLeft: '1em', margin: '1em 0' }} />,
table: (props) => <table {...props} style={{ borderCollapse: 'collapse', width: '100%', margin: '1em 0', display: 'block', overflowX: 'auto' }} />,
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333', whiteSpace: 'nowrap' }} />,
td: (props) => <td {...props} style={{ border: '1px solid #444', padding: '8px' }} />,
}}
>
{competition.condition_description}
</ReactMarkdown>
</div>
</div>
)
},
{
key: 'projects',
label: '参赛项目',
children: (
<Row gutter={[isMobile ? 16 : 24, isMobile ? 16 : 24]}>
{projects?.results?.map(project => (
<Col key={project.id} xs={24} sm={12} md={8}>
<Card
hoverable
cover={<img alt={project.title} src={getImageUrl(project.display_cover_image) || 'placeholder.jpg'} style={{ height: 180, objectFit: 'cover' }} />}
actions={[
<Button type="link" onClick={() => navigate(`/projects/${project.id}`)}>查看详情</Button>,
<Button type="link" icon={<MessageOutlined />} onClick={() => handleViewComments(project)}>评语</Button>
]}
>
<Card.Meta
title={project.title}
description={
enrollment && project.contestant === enrollment.id
? `得分: ${project.final_score || '待定'}`
: null
}
avatar={<UserOutlined />}
/>
</Card>
</Col>
))}
{(!projects?.results || projects.results.length === 0) && (
<Col span={24}><Empty description="暂无已提交项目" /></Col>
)}
</Row>
)
},
{
key: 'leaderboard',
label: '排行榜',
children: (
<Card title="实时排名" variant="borderless" style={{ background: 'transparent' }} headStyle={{ color: '#fff', fontSize: isMobile ? '16px' : '18px' }} bodyStyle={{ padding: isMobile ? '0 12px' : '24px' }}>
{/* Leaderboard Logic: sort by final_score descending */}
{[...(projects?.results || [])].sort((a, b) => b.final_score - a.final_score).map((project, index) => (
<div key={project.id} style={{ display: 'flex', alignItems: 'center', padding: isMobile ? '8px 0' : '12px 0', borderBottom: '1px solid #333' }}>
<div style={{ width: isMobile ? 30 : 40, fontSize: isMobile ? 16 : 20, fontWeight: 'bold', color: index < 3 ? '#ffd700' : '#fff' }}>
#{index + 1}
</div>
<div style={{ flex: 1, paddingRight: 8 }}>
<div style={{ color: '#fff', fontSize: isMobile ? 14 : 16, wordBreak: 'break-all' }}>{project.title}</div>
<div style={{ color: '#888', fontSize: 12 }}>{project.contestant_info?.nickname}</div>
</div>
<div style={{ fontSize: isMobile ? 18 : 24, color: '#00b96b', fontWeight: 'bold' }}>
{project.final_score || 0}
</div>
</div>
))}
</Card>
)
}
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: isMobile ? '12px' : '24px' }}>
<div style={{
height: isMobile ? 240 : 300,
backgroundImage: `url(${getImageUrl(competition.display_cover_image)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: 16,
marginBottom: isMobile ? 16 : 32,
position: 'relative',
overflow: 'hidden'
}}>
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: isMobile ? 16 : 32,
background: 'linear-gradient(to top, rgba(0,0,0,0.9), transparent)'
}}>
<Title level={isMobile ? 3 : 1} style={{ color: '#fff', margin: 0, fontSize: isMobile ? '24px' : undefined }}>{competition.title}</Title>
<div style={{ marginTop: isMobile ? 12 : 16, display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 8 : 16, flexWrap: 'wrap' }}>
{enrollment ? (
<Button type="primary" disabled size={isMobile ? 'middle' : 'large'}>{enrollment.status === 'approved' ? '已报名' : '审核中'}</Button>
) : (
<Button type="primary" size={isMobile ? 'middle' : 'large'} onClick={handleEnroll} disabled={competition.status !== 'registration'}>
{competition.status === 'registration' ? '立即报名' : '报名未开始/已结束'}
</Button>
)}
{isContestant && (
<>
<Button
icon={<CloudUploadOutlined />}
loading={loadingMyProject}
size={isMobile ? 'middle' : 'large'}
onClick={() => {
setEditingProject(myProject || null);
setSubmissionModalVisible(true);
}}
>
{myProject ? '管理/修改作品' : '提交作品'}
</Button>
{myProject && (
<Button
icon={<MessageOutlined />}
style={{ marginLeft: isMobile ? 0 : 8 }}
size={isMobile ? 'middle' : 'large'}
onClick={() => handleViewComments(myProject)}
>
查看评语
</Button>
)}
</>
)}
</div>
</div>
</div>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={items}
type="card"
size={isMobile ? 'small' : 'large'}
tabBarGutter={isMobile ? 8 : undefined}
/>
{submissionModalVisible && (
<ProjectSubmission
competitionId={id}
initialValues={editingProject}
onCancel={() => {
setSubmissionModalVisible(false);
setEditingProject(null);
}}
onSuccess={() => {
setSubmissionModalVisible(false);
setEditingProject(null);
// Refetch projects
queryClient.invalidateQueries(['projects']);
queryClient.invalidateQueries(['myProject']);
}}
/>
)}
<Modal
title="评委评语"
open={commentsModalVisible}
onCancel={() => setCommentsModalVisible(false)}
footer={null}
>
<List
loading={commentsLoading}
itemLayout="horizontal"
dataSource={currentProjectComments}
renderItem={item => (
<List.Item>
<List.Item.Meta
avatar={<Avatar icon={<UserOutlined />} />}
title={item.judge_name || '评委'}
description={
<div>
<div style={{ color: 'inherit' }}>{item.content}</div>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
{dayjs(item.created_at).format('YYYY-MM-DD HH:mm')}
</div>
</div>
}
/>
</List.Item>
)}
locale={{ emptyText: '暂无评语' }}
/>
</Modal>
</div>
);
};
export default CompetitionDetail;