This commit is contained in:
288
frontend/src/components/competition/CompetitionDetail.jsx
Normal file
288
frontend/src/components/competition/CompetitionDetail.jsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Typography, Tabs, Button, Row, Col, Card, Statistic, Tag, Descriptions, Empty, message, Spin } from 'antd';
|
||||
import { CalendarOutlined, TrophyOutlined, UserOutlined, FileTextOutlined, CloudUploadOutlined, CopyOutlined, CheckOutlined } 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 } from '../../api';
|
||||
import ProjectSubmission from './ProjectSubmission';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const CompetitionDetail = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, showLoginModal } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
const [submissionModalVisible, setSubmissionModalVisible] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState(null);
|
||||
|
||||
// Fetch competition details
|
||||
const { data: competition, isLoading: loadingDetail } = useQuery({
|
||||
queryKey: ['competition', id],
|
||||
queryFn: () => getCompetitionDetail(id)
|
||||
});
|
||||
|
||||
// Fetch projects (for leaderboard/display)
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: ['projects', id],
|
||||
queryFn: () => getProjects({ competition: id, status: 'submitted' })
|
||||
});
|
||||
|
||||
// Check enrollment status
|
||||
const { data: enrollment, refetch: refetchEnrollment } = useQuery({
|
||||
queryKey: ['enrollment', id],
|
||||
queryFn: () => getMyCompetitionEnrollment(id),
|
||||
enabled: !!user,
|
||||
retry: false
|
||||
});
|
||||
|
||||
const handleEnroll = async () => {
|
||||
if (!user) {
|
||||
showLoginModal();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await enrollCompetition(id, { role: 'contestant' });
|
||||
message.success('报名申请已提交,请等待审核');
|
||||
refetchEnrollment();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.detail || '报名失败');
|
||||
}
|
||||
};
|
||||
|
||||
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: 24, background: '#1f1f1f', borderRadius: 8 }}>
|
||||
<Descriptions title="基本信息" bordered column={{ xs: 1, sm: 2, md: 3 }}>
|
||||
<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={4} style={{ marginTop: 32, color: '#fff' }}>比赛简介</Title>
|
||||
<div style={{ color: '#ccc', lineHeight: '1.8' }}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{ code: CodeBlock }}
|
||||
>
|
||||
{competition.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<Title level={4} style={{ marginTop: 32, color: '#fff' }}>规则说明</Title>
|
||||
<div style={{ color: '#ccc', lineHeight: '1.8' }}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{ code: CodeBlock }}
|
||||
>
|
||||
{competition.rule_description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<Title level={4} style={{ marginTop: 32, color: '#fff' }}>参赛条件</Title>
|
||||
<div style={{ color: '#ccc', lineHeight: '1.8' }}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{ code: CodeBlock }}
|
||||
>
|
||||
{competition.condition_description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
label: '参赛项目',
|
||||
children: (
|
||||
<Row gutter={[24, 24]}>
|
||||
{projects?.results?.map(project => (
|
||||
<Col key={project.id} xs={24} sm={12} md={8}>
|
||||
<Card
|
||||
hoverable
|
||||
cover={<img alt={project.title} src={project.display_cover_image || 'placeholder.jpg'} style={{ height: 180, objectFit: 'cover' }} />}
|
||||
actions={[
|
||||
<Button type="link" onClick={() => navigate(`/projects/${project.id}`)}>查看详情</Button>
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
title={project.title}
|
||||
description={`得分: ${project.final_score || '待定'}`}
|
||||
avatar={<UserOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
{(!projects?.results || projects.results.length === 0) && (
|
||||
<Col span={24}><Empty description="暂无已提交项目" /></Col>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'leaderboard',
|
||||
label: '排行榜',
|
||||
children: (
|
||||
<Card title="实时排名" bordered={false} style={{ background: 'transparent' }}>
|
||||
{/* 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: '12px 0', borderBottom: '1px solid #333' }}>
|
||||
<div style={{ width: 40, fontSize: 20, fontWeight: 'bold', color: index < 3 ? '#gold' : '#fff' }}>
|
||||
#{index + 1}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: '#fff', fontSize: 16 }}>{project.title}</div>
|
||||
<div style={{ color: '#888', fontSize: 12 }}>{project.contestant_info?.nickname}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 24, color: '#00b96b', fontWeight: 'bold' }}>
|
||||
{project.final_score}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '24px' }}>
|
||||
<div style={{
|
||||
height: 300,
|
||||
backgroundImage: `url(${competition.display_cover_image})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
borderRadius: 16,
|
||||
marginBottom: 32,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 32,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.8), transparent)'
|
||||
}}>
|
||||
<Title style={{ color: '#fff', margin: 0 }}>{competition.title}</Title>
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 16 }}>
|
||||
{enrollment ? (
|
||||
<Button type="primary" disabled>{enrollment.status === 'approved' ? '已报名' : '审核中'}</Button>
|
||||
) : (
|
||||
<Button type="primary" size="large" onClick={handleEnroll} disabled={competition.status !== 'registration'}>
|
||||
{competition.status === 'registration' ? '立即报名' : '报名未开始/已结束'}
|
||||
</Button>
|
||||
)}
|
||||
{isContestant && (
|
||||
<Button icon={<CloudUploadOutlined />} onClick={() => setSubmissionModalVisible(true)}>
|
||||
提交/管理作品
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={items}
|
||||
type="card"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
{submissionModalVisible && (
|
||||
<ProjectSubmission
|
||||
competitionId={id}
|
||||
initialValues={editingProject}
|
||||
onCancel={() => {
|
||||
setSubmissionModalVisible(false);
|
||||
setEditingProject(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setSubmissionModalVisible(false);
|
||||
setEditingProject(null);
|
||||
// Refetch projects
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitionDetail;
|
||||
Reference in New Issue
Block a user