This commit is contained in:
@@ -15,6 +15,8 @@ import MyOrders from './pages/MyOrders';
|
|||||||
import ForumList from './pages/ForumList';
|
import ForumList from './pages/ForumList';
|
||||||
import ForumDetail from './pages/ForumDetail';
|
import ForumDetail from './pages/ForumDetail';
|
||||||
import ActivityDetail from './pages/activity/Detail';
|
import ActivityDetail from './pages/activity/Detail';
|
||||||
|
import CompetitionList from './components/competition/CompetitionList';
|
||||||
|
import CompetitionDetail from './components/competition/CompetitionDetail';
|
||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ function App() {
|
|||||||
<Route path="/courses/:id" element={<VCCourseDetail />} />
|
<Route path="/courses/:id" element={<VCCourseDetail />} />
|
||||||
<Route path="/forum" element={<ForumList />} />
|
<Route path="/forum" element={<ForumList />} />
|
||||||
<Route path="/forum/:id" element={<ForumDetail />} />
|
<Route path="/forum/:id" element={<ForumDetail />} />
|
||||||
|
<Route path="/competitions" element={<CompetitionList />} />
|
||||||
|
<Route path="/competitions/:id" element={<CompetitionDetail />} />
|
||||||
<Route path="/activity/:id" element={<ActivityDetail />} />
|
<Route path="/activity/:id" element={<ActivityDetail />} />
|
||||||
<Route path="/my-orders" element={<MyOrders />} />
|
<Route path="/my-orders" element={<MyOrders />} />
|
||||||
<Route path="/product/:id" element={<ProductDetail />} />
|
<Route path="/product/:id" element={<ProductDetail />} />
|
||||||
|
|||||||
@@ -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 signUpActivity = (id, data) => api.post(`/community/activities/${id}/signup/`, data);
|
||||||
export const getMySignups = () => api.get('/community/activities/my_signups/');
|
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;
|
export default api;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Layout as AntLayout, Menu, ConfigProvider, theme, Drawer, Button, Avatar, Dropdown } from 'antd';
|
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 { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import ParticleBackground from './ParticleBackground';
|
import ParticleBackground from './ParticleBackground';
|
||||||
import LoginModal from './LoginModal';
|
import LoginModal from './LoginModal';
|
||||||
@@ -61,6 +61,11 @@ const Layout = ({ children }) => {
|
|||||||
icon: <TeamOutlined />,
|
icon: <TeamOutlined />,
|
||||||
label: '技术论坛',
|
label: '技术论坛',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/competitions',
|
||||||
|
icon: <TrophyOutlined />,
|
||||||
|
label: '赛事中心',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: '/services',
|
key: '/services',
|
||||||
icon: <AppstoreOutlined />,
|
icon: <AppstoreOutlined />,
|
||||||
|
|||||||
89
frontend/src/components/competition/CompetitionCard.jsx
Normal file
89
frontend/src/components/competition/CompetitionCard.jsx
Normal file
@@ -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 (
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
cover={
|
||||||
|
<div style={{ height: 200, overflow: 'hidden', position: 'relative' }}>
|
||||||
|
<img
|
||||||
|
alt={competition.title}
|
||||||
|
src={competition.display_cover_image || 'https://via.placeholder.com/400x200'}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<div style={{ position: 'absolute', top: 10, right: 10 }}>
|
||||||
|
<Tag color={getStatusColor(competition.status)} style={{ marginRight: 0 }}>
|
||||||
|
{getStatusText(competition.status)}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||||
|
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||||
|
onClick={() => navigate(`/competitions/${competition.id}`)}
|
||||||
|
>
|
||||||
|
<Title level={4} ellipsis={{ rows: 2 }} style={{ marginBottom: 8, height: 54 }}>
|
||||||
|
{competition.title}
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
color: 'rgba(255,255,255,0.65)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
marginBottom: 0
|
||||||
|
}}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{competition.description}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
||||||
|
<Space>
|
||||||
|
<CalendarOutlined style={{ color: '#00b96b' }} />
|
||||||
|
<span style={{ fontSize: 12 }}>
|
||||||
|
{dayjs(competition.start_time).format('YYYY-MM-DD')} ~ {dayjs(competition.end_time).format('YYYY-MM-DD')}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompetitionCard;
|
||||||
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;
|
||||||
90
frontend/src/components/competition/CompetitionList.jsx
Normal file
90
frontend/src/components/competition/CompetitionList.jsx
Normal file
@@ -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 <Empty description="加载失败,请稍后重试" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="competition-list-container">
|
||||||
|
<div style={{ marginBottom: 32, textAlign: 'center' }}>
|
||||||
|
<Title level={2} style={{ color: '#fff', marginBottom: 16 }}>赛事中心</Title>
|
||||||
|
<div style={{ maxWidth: 800, margin: '0 auto', display: 'flex', gap: 16 }}>
|
||||||
|
<Search
|
||||||
|
placeholder="搜索比赛名称或简介"
|
||||||
|
allowClear
|
||||||
|
enterButton="搜索"
|
||||||
|
size="large"
|
||||||
|
onSearch={handleSearch}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
defaultValue="all"
|
||||||
|
size="large"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
onChange={handleStatusChange}
|
||||||
|
>
|
||||||
|
<Option value="all">全部状态</Option>
|
||||||
|
<Option value="registration">报名中</Option>
|
||||||
|
<Option value="submission">提交中</Option>
|
||||||
|
<Option value="judging">评审中</Option>
|
||||||
|
<Option value="ended">已结束</Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
||||||
|
<Spin size="large" tip="正在加载赛事..." />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{data?.data?.results?.length > 0 ? (
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
{data.data.results.map((comp) => (
|
||||||
|
<Col key={comp.id} xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<CompetitionCard competition={comp} />
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
) : (
|
||||||
|
<Empty description="暂无相关比赛" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompetitionList;
|
||||||
169
frontend/src/components/competition/ProjectSubmission.jsx
Normal file
169
frontend/src/components/competition/ProjectSubmission.jsx
Normal file
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
title={initialValues ? "编辑项目" : "提交新项目"}
|
||||||
|
open={true}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={null}
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={onFinish}
|
||||||
|
initialValues={initialValues}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="title"
|
||||||
|
label="项目名称"
|
||||||
|
rules={[{ required: true, message: '请输入项目名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入项目名称" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="description"
|
||||||
|
label="项目简介"
|
||||||
|
rules={[{ required: true, message: '请输入项目简介' }]}
|
||||||
|
>
|
||||||
|
<TextArea rows={4} placeholder="简要描述您的项目创意和功能" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="team_info"
|
||||||
|
label="团队介绍"
|
||||||
|
>
|
||||||
|
<TextArea rows={3} placeholder="介绍您的团队成员和分工" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="cover_image_url"
|
||||||
|
label="封面图片链接"
|
||||||
|
rules={[{ type: 'url', message: '请输入有效的URL' }]}
|
||||||
|
>
|
||||||
|
<Input prefix={<LinkOutlined />} placeholder="https://example.com/image.jpg" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* File Upload Section - Only visible if project exists */}
|
||||||
|
{initialValues?.id && (
|
||||||
|
<Form.Item label="项目附件 (PPT/PDF/视频)">
|
||||||
|
<Upload
|
||||||
|
customRequest={handleUpload}
|
||||||
|
listType="picture"
|
||||||
|
maxCount={5}
|
||||||
|
>
|
||||||
|
<Button icon={<CloudUploadOutlined />}>上传文件 (最大50MB)</Button>
|
||||||
|
</Upload>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||||
|
<Button onClick={onCancel}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={createMutation.isLoading || updateMutation.isLoading}>
|
||||||
|
保存草稿
|
||||||
|
</Button>
|
||||||
|
{initialValues?.id && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认提交?',
|
||||||
|
content: '提交后将无法修改,确认提交吗?',
|
||||||
|
onOk: () => submitProject(initialValues.id).then(() => {
|
||||||
|
message.success('提交成功');
|
||||||
|
onSuccess();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
正式提交
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectSubmission;
|
||||||
@@ -7,6 +7,10 @@ const AuthContext = createContext(null);
|
|||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loginModalVisible, setLoginModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const showLoginModal = () => setLoginModalVisible(true);
|
||||||
|
const hideLoginModal = () => setLoginModalVisible(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initAuth = async () => {
|
const initAuth = async () => {
|
||||||
@@ -72,7 +76,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, login, logout, updateUser, loading }}>
|
<AuthContext.Provider value={{ user, login, logout, updateUser, loading, loginModalVisible, showLoginModal, hideLoginModal }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 signupActivity = (id: number, data?: any) => request({ url: `/community/activities/${id}/signup/`, method: 'POST', data })
|
||||||
export const getMySignups = () => request({ url: '/community/activities/my_signups/' })
|
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
|
// Upload Media for Forum
|
||||||
export const uploadMedia = (filePath: string, type: 'image' | 'video') => {
|
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'
|
const BASE_URL = (typeof process !== 'undefined' && process.env && process.env.TARO_APP_API_URL) || 'https://market.quant-speed.com/api'
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ export default defineAppConfig({
|
|||||||
'pages/order/payment',
|
'pages/order/payment',
|
||||||
'pages/order/list',
|
'pages/order/list',
|
||||||
'pages/order/detail',
|
'pages/order/detail',
|
||||||
'pages/user/index'
|
'pages/user/index',
|
||||||
|
'pages/competition/index',
|
||||||
|
'pages/competition/detail'
|
||||||
],
|
],
|
||||||
subPackages: [
|
subPackages: [
|
||||||
{
|
{
|
||||||
|
|||||||
74
miniprogram/src/pages/competition/index.tsx
Normal file
74
miniprogram/src/pages/competition/index.tsx
Normal file
@@ -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<any[]>([])
|
||||||
|
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 (
|
||||||
|
<View className='competition-page'>
|
||||||
|
<ScrollView scrollY className='comp-list'>
|
||||||
|
{competitions.map(item => (
|
||||||
|
<View key={item.id} className='comp-card' onClick={() => goDetail(item.id)}>
|
||||||
|
<Image
|
||||||
|
className='cover'
|
||||||
|
mode='aspectFill'
|
||||||
|
src={item.display_cover_image || 'https://via.placeholder.com/400x200'}
|
||||||
|
/>
|
||||||
|
<View className='info'>
|
||||||
|
<View className='header'>
|
||||||
|
<Text className='title'>{item.title}</Text>
|
||||||
|
<Text className={`status ${item.status}`}>{getStatusText(item.status)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='desc'>{item.description}</Text>
|
||||||
|
<View className='footer'>
|
||||||
|
<Text className='time'>
|
||||||
|
{item.start_time?.split('T')[0]} ~ {item.end_time?.split('T')[0]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{!loading && competitions.length === 0 && (
|
||||||
|
<View className='empty'>暂无比赛</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user