|
|
|
|
@@ -1,7 +1,7 @@
|
|
|
|
|
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 } from 'antd';
|
|
|
|
|
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';
|
|
|
|
|
@@ -14,6 +14,12 @@ 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;
|
|
|
|
|
@@ -24,7 +30,11 @@ const getImageUrl = (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 || '');
|
|
|
|
|
@@ -76,6 +86,11 @@ const CodeBlock = ({ inline, className, children, ...props }) => {
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
@@ -88,6 +103,9 @@ const CompetitionDetail = () => {
|
|
|
|
|
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],
|
|
|
|
|
@@ -117,6 +135,10 @@ const CompetitionDetail = () => {
|
|
|
|
|
|
|
|
|
|
const myProject = myProjects?.results?.[0];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle competition enrollment.
|
|
|
|
|
* Checks login status and submits enrollment request.
|
|
|
|
|
*/
|
|
|
|
|
const handleEnroll = async () => {
|
|
|
|
|
if (!user) {
|
|
|
|
|
showLoginModal();
|
|
|
|
|
@@ -131,6 +153,10 @@ const CompetitionDetail = () => {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fetch and display judge comments for a project.
|
|
|
|
|
* @param {Object} project - The project object
|
|
|
|
|
*/
|
|
|
|
|
const handleViewComments = async (project) => {
|
|
|
|
|
if (!project) return;
|
|
|
|
|
setCommentsLoading(true);
|
|
|
|
|
@@ -157,8 +183,8 @@ const CompetitionDetail = () => {
|
|
|
|
|
key: 'details',
|
|
|
|
|
label: '比赛详情',
|
|
|
|
|
children: (
|
|
|
|
|
<div style={{ padding: 24, background: '#1f1f1f', borderRadius: 8 }}>
|
|
|
|
|
<Descriptions title="基本信息" bordered column={{ xs: 1, sm: 2, md: 3 }}>
|
|
|
|
|
<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}
|
|
|
|
|
@@ -172,21 +198,21 @@ const CompetitionDetail = () => {
|
|
|
|
|
</Descriptions.Item>
|
|
|
|
|
</Descriptions>
|
|
|
|
|
|
|
|
|
|
<Title level={4} style={{ marginTop: 32, color: '#fff' }}>比赛简介</Title>
|
|
|
|
|
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: '16px' }} className="markdown-body">
|
|
|
|
|
<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' }} />,
|
|
|
|
|
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
|
|
|
|
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
|
|
|
|
|
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' }} />,
|
|
|
|
|
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333' }} />,
|
|
|
|
|
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' }} />,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
@@ -194,21 +220,21 @@ const CompetitionDetail = () => {
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Title level={4} style={{ marginTop: 32, color: '#fff' }}>规则说明</Title>
|
|
|
|
|
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: '16px' }} className="markdown-body">
|
|
|
|
|
<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' }} />,
|
|
|
|
|
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
|
|
|
|
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
|
|
|
|
|
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' }} />,
|
|
|
|
|
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333' }} />,
|
|
|
|
|
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' }} />,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
@@ -216,21 +242,21 @@ const CompetitionDetail = () => {
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Title level={4} style={{ marginTop: 32, color: '#fff' }}>参赛条件</Title>
|
|
|
|
|
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: '16px' }} className="markdown-body">
|
|
|
|
|
<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' }} />,
|
|
|
|
|
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
|
|
|
|
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
|
|
|
|
|
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' }} />,
|
|
|
|
|
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333' }} />,
|
|
|
|
|
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' }} />,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
@@ -244,7 +270,7 @@ const CompetitionDetail = () => {
|
|
|
|
|
key: 'projects',
|
|
|
|
|
label: '参赛项目',
|
|
|
|
|
children: (
|
|
|
|
|
<Row gutter={[24, 24]}>
|
|
|
|
|
<Row gutter={[isMobile ? 16 : 24, isMobile ? 16 : 24]}>
|
|
|
|
|
{projects?.results?.map(project => (
|
|
|
|
|
<Col key={project.id} xs={24} sm={12} md={8}>
|
|
|
|
|
<Card
|
|
|
|
|
@@ -277,18 +303,18 @@ const CompetitionDetail = () => {
|
|
|
|
|
key: 'leaderboard',
|
|
|
|
|
label: '排行榜',
|
|
|
|
|
children: (
|
|
|
|
|
<Card title="实时排名" variant="borderless" style={{ background: 'transparent' }}>
|
|
|
|
|
<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: '12px 0', borderBottom: '1px solid #333' }}>
|
|
|
|
|
<div style={{ width: 40, fontSize: 20, fontWeight: 'bold', color: index < 3 ? '#gold' : '#fff' }}>
|
|
|
|
|
<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 }}>
|
|
|
|
|
<div style={{ color: '#fff', fontSize: 16 }}>{project.title}</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: 24, color: '#00b96b', fontWeight: 'bold' }}>
|
|
|
|
|
<div style={{ fontSize: isMobile ? 18 : 24, color: '#00b96b', fontWeight: 'bold' }}>
|
|
|
|
|
{enrollment && project.contestant === enrollment.id ? project.final_score : '**'}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@@ -299,30 +325,31 @@ const CompetitionDetail = () => {
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '24px' }}>
|
|
|
|
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: isMobile ? '12px' : '24px' }}>
|
|
|
|
|
<div style={{
|
|
|
|
|
height: 300,
|
|
|
|
|
height: isMobile ? 240 : 300,
|
|
|
|
|
backgroundImage: `url(${getImageUrl(competition.display_cover_image)})`,
|
|
|
|
|
backgroundSize: 'cover',
|
|
|
|
|
backgroundPosition: 'center',
|
|
|
|
|
borderRadius: 16,
|
|
|
|
|
marginBottom: 32,
|
|
|
|
|
position: 'relative'
|
|
|
|
|
marginBottom: isMobile ? 16 : 32,
|
|
|
|
|
position: 'relative',
|
|
|
|
|
overflow: 'hidden'
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
bottom: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
padding: 32,
|
|
|
|
|
background: 'linear-gradient(to top, rgba(0,0,0,0.8), transparent)'
|
|
|
|
|
padding: isMobile ? 16 : 32,
|
|
|
|
|
background: 'linear-gradient(to top, rgba(0,0,0,0.9), transparent)'
|
|
|
|
|
}}>
|
|
|
|
|
<Title style={{ color: '#fff', margin: 0 }}>{competition.title}</Title>
|
|
|
|
|
<div style={{ marginTop: 16, display: 'flex', gap: 16 }}>
|
|
|
|
|
<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>{enrollment.status === 'approved' ? '已报名' : '审核中'}</Button>
|
|
|
|
|
<Button type="primary" disabled size={isMobile ? 'middle' : 'large'}>{enrollment.status === 'approved' ? '已报名' : '审核中'}</Button>
|
|
|
|
|
) : (
|
|
|
|
|
<Button type="primary" size="large" onClick={handleEnroll} disabled={competition.status !== 'registration'}>
|
|
|
|
|
<Button type="primary" size={isMobile ? 'middle' : 'large'} onClick={handleEnroll} disabled={competition.status !== 'registration'}>
|
|
|
|
|
{competition.status === 'registration' ? '立即报名' : '报名未开始/已结束'}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
@@ -331,6 +358,7 @@ const CompetitionDetail = () => {
|
|
|
|
|
<Button
|
|
|
|
|
icon={<CloudUploadOutlined />}
|
|
|
|
|
loading={loadingMyProject}
|
|
|
|
|
size={isMobile ? 'middle' : 'large'}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setEditingProject(myProject || null);
|
|
|
|
|
setSubmissionModalVisible(true);
|
|
|
|
|
@@ -341,7 +369,8 @@ const CompetitionDetail = () => {
|
|
|
|
|
{myProject && (
|
|
|
|
|
<Button
|
|
|
|
|
icon={<MessageOutlined />}
|
|
|
|
|
style={{ marginLeft: 8 }}
|
|
|
|
|
style={{ marginLeft: isMobile ? 0 : 8 }}
|
|
|
|
|
size={isMobile ? 'middle' : 'large'}
|
|
|
|
|
onClick={() => handleViewComments(myProject)}
|
|
|
|
|
>
|
|
|
|
|
查看评语
|
|
|
|
|
@@ -358,7 +387,8 @@ const CompetitionDetail = () => {
|
|
|
|
|
onChange={setActiveTab}
|
|
|
|
|
items={items}
|
|
|
|
|
type="card"
|
|
|
|
|
size="large"
|
|
|
|
|
size={isMobile ? 'small' : 'large'}
|
|
|
|
|
tabBarGutter={isMobile ? 8 : undefined}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{submissionModalVisible && (
|
|
|
|
|
|