From 417cda952db794bdf13fa6970fa781d222f2d35a Mon Sep 17 00:00:00 2001 From: jeremygan2021 Date: Tue, 10 Mar 2026 11:09:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AF=94=E8=B5=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.jsx | 4 + frontend/src/api.js | 23 ++ frontend/src/components/Layout.jsx | 7 +- .../competition/CompetitionCard.jsx | 89 ++++++ .../competition/CompetitionDetail.jsx | 288 ++++++++++++++++++ .../competition/CompetitionList.jsx | 90 ++++++ .../competition/ProjectSubmission.jsx | 169 ++++++++++ frontend/src/context/AuthContext.jsx | 6 +- miniprogram/src/api/index.ts | 27 ++ miniprogram/src/app.config.ts | 4 +- miniprogram/src/pages/competition/index.tsx | 74 +++++ 11 files changed, 778 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/competition/CompetitionCard.jsx create mode 100644 frontend/src/components/competition/CompetitionDetail.jsx create mode 100644 frontend/src/components/competition/CompetitionList.jsx create mode 100644 frontend/src/components/competition/ProjectSubmission.jsx create mode 100644 miniprogram/src/pages/competition/index.tsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4714660..2625b0b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,8 @@ import MyOrders from './pages/MyOrders'; import ForumList from './pages/ForumList'; import ForumDetail from './pages/ForumDetail'; import ActivityDetail from './pages/activity/Detail'; +import CompetitionList from './components/competition/CompetitionList'; +import CompetitionDetail from './components/competition/CompetitionDetail'; import 'antd/dist/reset.css'; import './App.css'; @@ -34,6 +36,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index 68d26a2..c5bff03 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -72,4 +72,27 @@ export const getActivityDetail = (id) => api.get(`/community/activities/${id}/`) export const signUpActivity = (id, data) => api.post(`/community/activities/${id}/signup/`, data); export const getMySignups = () => api.get('/community/activities/my_signups/'); +// Competition API +export const getCompetitions = (params) => api.get('/competition/competitions/', { params }); +export const getCompetitionDetail = (id) => api.get(`/competition/competitions/${id}/`); +export const enrollCompetition = (id, data) => api.post(`/competition/competitions/${id}/enroll/`, data); +export const getMyCompetitionEnrollment = (id) => api.get(`/competition/competitions/${id}/my_enrollment/`); + +export const getProjects = (params) => api.get('/competition/projects/', { params }); +export const getProjectDetail = (id) => api.get(`/competition/projects/${id}/`); +export const createProject = (data) => api.post('/competition/projects/', data); +export const updateProject = (id, data) => api.patch(`/competition/projects/${id}/`, data); +export const submitProject = (id) => api.post(`/competition/projects/${id}/submit/`); + +export const uploadProjectFile = (data) => { + return api.post('/competition/files/', data, { + headers: { + 'Content-Type': 'multipart/form-data', + } + }); +}; + +export const createScore = (data) => api.post('/competition/scores/', data); +export const createComment = (data) => api.post('/competition/comments/', data); + export default api; diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 993991b..77da503 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Layout as AntLayout, Menu, ConfigProvider, theme, Drawer, Button, Avatar, Dropdown } from 'antd'; -import { RobotOutlined, MenuOutlined, AppstoreOutlined, EyeOutlined, SearchOutlined, UserOutlined, LogoutOutlined, WechatOutlined, TeamOutlined } from '@ant-design/icons'; +import { RobotOutlined, MenuOutlined, AppstoreOutlined, EyeOutlined, SearchOutlined, UserOutlined, LogoutOutlined, WechatOutlined, TeamOutlined, TrophyOutlined } from '@ant-design/icons'; import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import ParticleBackground from './ParticleBackground'; import LoginModal from './LoginModal'; @@ -61,6 +61,11 @@ const Layout = ({ children }) => { icon: , label: '技术论坛', }, + { + key: '/competitions', + icon: , + label: '赛事中心', + }, { key: '/services', icon: , diff --git a/frontend/src/components/competition/CompetitionCard.jsx b/frontend/src/components/competition/CompetitionCard.jsx new file mode 100644 index 0000000..a89b7ef --- /dev/null +++ b/frontend/src/components/competition/CompetitionCard.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { Card, Tag, Typography, Space, Divider } from 'antd'; +import { CalendarOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import dayjs from 'dayjs'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +const { Title } = Typography; + +const CompetitionCard = ({ competition }) => { + const navigate = useNavigate(); + + const getStatusColor = (status) => { + switch(status) { + case 'published': return 'cyan'; + case 'registration': return 'green'; + case 'submission': return 'blue'; + case 'judging': return 'orange'; + case 'ended': return 'red'; + default: return 'default'; + } + }; + + const getStatusText = (status) => { + switch(status) { + case 'published': return '即将开始'; + case 'registration': return '报名中'; + case 'submission': return '作品提交中'; + case 'judging': return '评审中'; + case 'ended': return '已结束'; + default: return '草稿'; + } + }; + + return ( + + {competition.title} +
+ + {getStatusText(competition.status)} + +
+ + } + style={{ height: '100%', display: 'flex', flexDirection: 'column' }} + bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column' }} + onClick={() => navigate(`/competitions/${competition.id}`)} + > + + {competition.title} + + +
+ + {competition.description} + +
+ + + + + + + + {dayjs(competition.start_time).format('YYYY-MM-DD')} ~ {dayjs(competition.end_time).format('YYYY-MM-DD')} + + + +
+ ); +}; + +export default CompetitionCard; \ No newline at end of file diff --git a/frontend/src/components/competition/CompetitionDetail.jsx b/frontend/src/components/competition/CompetitionDetail.jsx new file mode 100644 index 0000000..021d2b5 --- /dev/null +++ b/frontend/src/components/competition/CompetitionDetail.jsx @@ -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 ? ( +
+
+ {copied ? : } + {copied ? '已复制' : '复制'} +
+ + {codeString} + +
+ ) : ( + + {children} + + ); +}; + +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 ; + if (!competition) return ; + + const isContestant = enrollment?.role === 'contestant' && enrollment?.status === 'approved'; + + const items = [ + { + key: 'details', + label: '比赛详情', + children: ( +
+ + + + {competition.status_display} + + + + {dayjs(competition.start_time).format('YYYY-MM-DD')} + + + {dayjs(competition.end_time).format('YYYY-MM-DD')} + + + + 比赛简介 +
+ + {competition.description} + +
+ + 规则说明 +
+ + {competition.rule_description} + +
+ + 参赛条件 +
+ + {competition.condition_description} + +
+
+ ) + }, + { + key: 'projects', + label: '参赛项目', + children: ( + + {projects?.results?.map(project => ( + + } + actions={[ + + ]} + > + } + /> + + + ))} + {(!projects?.results || projects.results.length === 0) && ( + + )} + + ) + }, + { + key: 'leaderboard', + label: '排行榜', + children: ( + + {/* Leaderboard Logic: sort by final_score descending */} + {projects?.results?.sort((a, b) => b.final_score - a.final_score).map((project, index) => ( +
+
+ #{index + 1} +
+
+
{project.title}
+
{project.contestant_info?.nickname}
+
+
+ {project.final_score} +
+
+ ))} +
+ ) + } + ]; + + return ( +
+
+
+ {competition.title} +
+ {enrollment ? ( + + ) : ( + + )} + {isContestant && ( + + )} +
+
+
+ + + + {submissionModalVisible && ( + { + setSubmissionModalVisible(false); + setEditingProject(null); + }} + onSuccess={() => { + setSubmissionModalVisible(false); + setEditingProject(null); + // Refetch projects + }} + /> + )} +
+ ); +}; + +export default CompetitionDetail; \ No newline at end of file diff --git a/frontend/src/components/competition/CompetitionList.jsx b/frontend/src/components/competition/CompetitionList.jsx new file mode 100644 index 0000000..86e18e3 --- /dev/null +++ b/frontend/src/components/competition/CompetitionList.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Row, Col, Typography, Input, Select, Empty, Spin } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import { getCompetitions } from '../../api'; +import CompetitionCard from './CompetitionCard'; +import { useState } from 'react'; + +const { Title } = Typography; +const { Search } = Input; +const { Option } = Select; + +const CompetitionList = () => { + const [params, setParams] = useState({ page: 1, page_size: 10 }); + const [search, setSearch] = useState(''); + const [status, setStatus] = useState('all'); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['competitions', params, search, status], + queryFn: () => getCompetitions({ + ...params, + search: search || undefined, + status: status !== 'all' ? status : undefined + }), + keepPreviousData: true + }); + + const handleSearch = (value) => { + setSearch(value); + setParams({ ...params, page: 1 }); + }; + + const handleStatusChange = (value) => { + setStatus(value); + setParams({ ...params, page: 1 }); + }; + + if (isError) return ; + + return ( +
+
+ 赛事中心 +
+ + +
+
+ + {isLoading ? ( +
+ +
+ ) : ( + <> + {data?.data?.results?.length > 0 ? ( + + {data.data.results.map((comp) => ( + + + + ))} + + ) : ( + + )} + + )} +
+ ); +}; + +export default CompetitionList; \ No newline at end of file diff --git a/frontend/src/components/competition/ProjectSubmission.jsx b/frontend/src/components/competition/ProjectSubmission.jsx new file mode 100644 index 0000000..45062bd --- /dev/null +++ b/frontend/src/components/competition/ProjectSubmission.jsx @@ -0,0 +1,169 @@ +import React, { useState } from 'react'; +import { Card, Button, Form, Input, Upload, message, Modal, Select } from 'antd'; +import { UploadOutlined, CloudUploadOutlined, LinkOutlined } from '@ant-design/icons'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createProject, updateProject, submitProject, uploadProjectFile } from '../../api'; + +const { TextArea } = Input; +const { Option } = Select; + +const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }) => { + const [form] = Form.useForm(); + const [fileList, setFileList] = useState([]); + const queryClient = useQueryClient(); + + const createMutation = useMutation({ + mutationFn: createProject, + onSuccess: () => { + message.success('项目创建成功'); + queryClient.invalidateQueries(['projects']); + onSuccess(); + }, + onError: (error) => { + message.error(`创建失败: ${error.message}`); + } + }); + + const updateMutation = useMutation({ + mutationFn: (data) => updateProject(initialValues.id, data), + onSuccess: () => { + message.success('项目更新成功'); + queryClient.invalidateQueries(['projects']); + onSuccess(); + }, + onError: (error) => { + message.error(`更新失败: ${error.message}`); + } + }); + + const uploadMutation = useMutation({ + mutationFn: uploadProjectFile, + onSuccess: (data) => { + message.success('文件上传成功'); + setFileList([...fileList, data]); // Add file to list (assuming response format) + }, + onError: (error) => { + message.error(`上传失败: ${error.message}`); + } + }); + + const onFinish = (values) => { + const data = { + ...values, + competition: competitionId, + // Handle file URLs/IDs if needed in create/update + }; + + if (initialValues?.id) { + updateMutation.mutate(data); + } else { + createMutation.mutate(data); + } + }; + + const handleUpload = ({ file, onSuccess, onError }) => { + const formData = new FormData(); + formData.append('file', file); + formData.append('project', initialValues?.id || ''); // Need project ID first usually + + // Upload logic might need adjustment: create project first, then upload files? + // Or upload to temp storage then link? + // For simplicity, let's assume we create project first if not exists + if (!initialValues?.id) { + message.warning('请先保存项目基本信息再上传文件'); + return; + } + + uploadMutation.mutate(formData); + }; + + return ( + +
+ + + + + +