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 ? (
{children}
);
};
/**
* 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