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 (
+
+
+
+
+ {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 && (
+ } onClick={() => setSubmissionModalVisible(true)}>
+ 提交/管理作品
+
+ )}
+
+
+
+
+
+
+ {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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } placeholder="https://example.com/image.jpg" />
+
+
+ {/* File Upload Section - Only visible if project exists */}
+ {initialValues?.id && (
+
+
+ }>上传文件 (最大50MB)
+
+
+ )}
+
+
+
+
+
+ {initialValues?.id && (
+
+ )}
+
+
+
+
+ );
+};
+
+export default ProjectSubmission;
\ No newline at end of file
diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx
index 7180a6a..43398b9 100644
--- a/frontend/src/context/AuthContext.jsx
+++ b/frontend/src/context/AuthContext.jsx
@@ -7,6 +7,10 @@ const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
+ const [loginModalVisible, setLoginModalVisible] = useState(false);
+
+ const showLoginModal = () => setLoginModalVisible(true);
+ const hideLoginModal = () => setLoginModalVisible(false);
useEffect(() => {
const initAuth = async () => {
@@ -72,7 +76,7 @@ export const AuthProvider = ({ children }) => {
};
return (
-
+
{children}
);
diff --git a/miniprogram/src/api/index.ts b/miniprogram/src/api/index.ts
index 38f3cd6..5019142 100644
--- a/miniprogram/src/api/index.ts
+++ b/miniprogram/src/api/index.ts
@@ -68,6 +68,33 @@ export const getActivityDetail = (id: number) => request({ url: `/community/acti
export const signupActivity = (id: number, data?: any) => request({ url: `/community/activities/${id}/signup/`, method: 'POST', data })
export const getMySignups = () => request({ url: '/community/activities/my_signups/' })
+// Competitions
+export const getCompetitions = (params?: any) => request({ url: '/competition/competitions/', data: params })
+export const getCompetitionDetail = (id: number) => request({ url: `/competition/competitions/${id}/` })
+export const enrollCompetition = (id: number, data: any) => request({ url: `/competition/competitions/${id}/enroll/`, method: 'POST', data })
+export const getMyCompetitionEnrollment = (id: number) => request({ url: `/competition/competitions/${id}/my_enrollment/` })
+export const getProjects = (params?: any) => request({ url: '/competition/projects/', data: params })
+export const getProjectDetail = (id: number) => request({ url: `/competition/projects/${id}/` })
+export const createProject = (data: any) => request({ url: '/competition/projects/', method: 'POST', data })
+export const updateProject = (id: number, data: any) => request({ url: `/competition/projects/${id}/`, method: 'PATCH', data })
+export const submitProject = (id: number) => request({ url: `/competition/projects/${id}/submit/`, method: 'POST' })
+export const uploadProjectFile = (filePath: string) => {
+ const BASE_URL = (typeof process !== 'undefined' && process.env && process.env.TARO_APP_API_URL) || 'https://market.quant-speed.com/api'
+ return Taro.uploadFile({
+ url: `${BASE_URL}/competition/files/`,
+ filePath,
+ name: 'file',
+ header: {
+ 'Authorization': `Bearer ${Taro.getStorageSync('token')}`
+ }
+ }).then(res => {
+ if (res.statusCode >= 200 && res.statusCode < 300) {
+ return JSON.parse(res.data)
+ }
+ throw new Error('Upload failed')
+ })
+}
+
// Upload Media for Forum
export const uploadMedia = (filePath: string, type: 'image' | 'video') => {
const BASE_URL = (typeof process !== 'undefined' && process.env && process.env.TARO_APP_API_URL) || 'https://market.quant-speed.com/api'
diff --git a/miniprogram/src/app.config.ts b/miniprogram/src/app.config.ts
index 90391e9..4ca631b 100644
--- a/miniprogram/src/app.config.ts
+++ b/miniprogram/src/app.config.ts
@@ -13,7 +13,9 @@ export default defineAppConfig({
'pages/order/payment',
'pages/order/list',
'pages/order/detail',
- 'pages/user/index'
+ 'pages/user/index',
+ 'pages/competition/index',
+ 'pages/competition/detail'
],
subPackages: [
{
diff --git a/miniprogram/src/pages/competition/index.tsx b/miniprogram/src/pages/competition/index.tsx
new file mode 100644
index 0000000..d7d1c35
--- /dev/null
+++ b/miniprogram/src/pages/competition/index.tsx
@@ -0,0 +1,74 @@
+import { View, Text, Image, ScrollView } from '@tarojs/components'
+import Taro, { useLoad } from '@tarojs/taro'
+import { useState } from 'react'
+import { getCompetitions } from '../../api'
+import './index.scss'
+
+export default function CompetitionList() {
+ const [competitions, setCompetitions] = useState([])
+ const [loading, setLoading] = useState(false)
+
+ useLoad(() => {
+ fetchCompetitions()
+ })
+
+ const fetchCompetitions = async () => {
+ setLoading(true)
+ try {
+ const res = await getCompetitions()
+ if (res && res.results) {
+ setCompetitions(res.results)
+ }
+ } catch (e) {
+ Taro.showToast({ title: '加载失败', icon: 'none' })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const goDetail = (id) => {
+ Taro.navigateTo({ url: `/pages/competition/detail?id=${id}` })
+ }
+
+ const getStatusText = (status) => {
+ const map = {
+ 'registration': '报名中',
+ 'submission': '作品提交中',
+ 'judging': '评审中',
+ 'ended': '已结束',
+ 'draft': '草稿'
+ }
+ return map[status] || status
+ }
+
+ return (
+
+
+ {competitions.map(item => (
+ goDetail(item.id)}>
+
+
+
+ {item.title}
+ {getStatusText(item.status)}
+
+ {item.description}
+
+
+ {item.start_time?.split('T')[0]} ~ {item.end_time?.split('T')[0]}
+
+
+
+
+ ))}
+ {!loading && competitions.length === 0 && (
+ 暂无比赛
+ )}
+
+
+ )
+}