From 880192c358a8342c8a0345236f128c948822bc35 Mon Sep 17 00:00:00 2001 From: jeremygan2021 Date: Tue, 10 Mar 2026 14:05:37 +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 --- backend/competition/views.py | 1 + frontend/src/App.jsx | 13 ++- frontend/src/api.js | 1 + .../competition/CompetitionDetail.jsx | 95 ++++++++++++++---- .../competition/ProjectSubmission.jsx | 20 ++-- miniprogram/src/api/index.ts | 7 +- miniprogram/src/pages/competition/detail.tsx | 64 +++++++++++-- .../src/pages/competition/project.scss | 2 +- miniprogram/src/pages/competition/project.tsx | 96 ++++++++++++++++++- 9 files changed, 254 insertions(+), 45 deletions(-) diff --git a/backend/competition/views.py b/backend/competition/views.py index d3cde5f..60dbceb 100644 --- a/backend/competition/views.py +++ b/backend/competition/views.py @@ -100,6 +100,7 @@ class ProjectViewSet(viewsets.ModelViewSet): """ serializer_class = ProjectSerializer permission_classes = [permissions.AllowAny] + pagination_class = StandardResultsSetPagination def get_queryset(self): queryset = Project.objects.all() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2625b0b..3824b2d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,7 @@ import React from 'react' import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { App as AntdApp } from 'antd'; import { AuthProvider } from './context/AuthContext'; import Layout from './components/Layout'; import Home from './pages/Home'; @@ -25,9 +26,10 @@ const queryClient = new QueryClient(); function App() { return ( - - - + + + + } /> } /> @@ -44,8 +46,9 @@ function App() { } /> - - + + + ) } diff --git a/frontend/src/api.js b/frontend/src/api.js index c5bff03..597709b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -94,5 +94,6 @@ export const uploadProjectFile = (data) => { export const createScore = (data) => api.post('/competition/scores/', data); export const createComment = (data) => api.post('/competition/comments/', data); +export const getComments = (params) => api.get('/competition/comments/', { params }); export default api; diff --git a/frontend/src/components/competition/CompetitionDetail.jsx b/frontend/src/components/competition/CompetitionDetail.jsx index d1f31d4..8721372 100644 --- a/frontend/src/components/competition/CompetitionDetail.jsx +++ b/frontend/src/components/competition/CompetitionDetail.jsx @@ -1,15 +1,15 @@ 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 } from 'antd'; -import { CalendarOutlined, TrophyOutlined, UserOutlined, FileTextOutlined, CloudUploadOutlined, CopyOutlined, CheckOutlined } from '@ant-design/icons'; +import { Typography, Tabs, Button, Row, Col, Card, Statistic, Tag, Descriptions, Empty, message, Spin, Modal, List, Avatar } 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 } from '../../api'; +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'; @@ -84,6 +84,9 @@ const CompetitionDetail = () => { 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); // Fetch competition details const { data: competition, isLoading: loadingDetail } = useQuery({ @@ -94,7 +97,7 @@ const CompetitionDetail = () => { // Fetch projects (for leaderboard/display) const { data: projects } = useQuery({ queryKey: ['projects', id], - queryFn: () => getProjects({ competition: id, status: 'submitted' }).then(res => res.data) + queryFn: () => getProjects({ competition: id, status: 'submitted', page_size: 100 }).then(res => res.data) }); // Check enrollment status @@ -128,6 +131,22 @@ const CompetitionDetail = () => { } }; + 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 ; if (!competition) return ; @@ -232,7 +251,8 @@ const CompetitionDetail = () => { hoverable cover={{project.title}} actions={[ - + , + ]} > { 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) => ( + {[...(projects?.results || [])].sort((a, b) => b.final_score - a.final_score).map((project, index) => (
#{index + 1} @@ -307,16 +327,27 @@ const CompetitionDetail = () => { )} {isContestant && ( - + <> + + {myProject && ( + + )} + )}
@@ -347,6 +378,36 @@ const CompetitionDetail = () => { }} /> )} + + setCommentsModalVisible(false)} + footer={null} + > + ( + + } />} + title={item.judge_name || '评委'} + description={ +
+
{item.content}
+
+ {dayjs(item.created_at).format('YYYY-MM-DD HH:mm')} +
+
+ } + /> +
+ )} + locale={{ emptyText: '暂无评语' }} + /> +
); }; diff --git a/frontend/src/components/competition/ProjectSubmission.jsx b/frontend/src/components/competition/ProjectSubmission.jsx index 2d366f7..f641ea7 100644 --- a/frontend/src/components/competition/ProjectSubmission.jsx +++ b/frontend/src/components/competition/ProjectSubmission.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Card, Button, Form, Input, Upload, message, Modal, Select } from 'antd'; +import { Card, Button, Form, Input, Upload, App, 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'; @@ -8,6 +8,7 @@ const { TextArea } = Input; const { Option } = Select; const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }) => { + const { message } = App.useApp(); const [form] = Form.useForm(); const [fileList, setFileList] = useState([]); const queryClient = useQueryClient(); @@ -82,15 +83,14 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess } 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) { - // Already handled above - return; - } - - uploadMutation.mutate(formData); + uploadMutation.mutate(formData, { + onSuccess: (data) => { + onSuccess(data); + }, + onError: (error) => { + onError(error); + } + }); }; return ( diff --git a/miniprogram/src/api/index.ts b/miniprogram/src/api/index.ts index 1417808..682015c 100644 --- a/miniprogram/src/api/index.ts +++ b/miniprogram/src/api/index.ts @@ -79,12 +79,17 @@ export const getProjectDetail = (id: number) => request({ url: `/competition/pro 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) => { +export const getComments = (params: any) => request({ url: '/competition/comments/', data: params }) +export const uploadProjectFile = (filePath: string, projectId: number, fileName?: 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', + formData: { + project: projectId, + name: fileName || '' + }, header: { 'Authorization': `Bearer ${Taro.getStorageSync('token')}` } diff --git a/miniprogram/src/pages/competition/detail.tsx b/miniprogram/src/pages/competition/detail.tsx index 557b047..d5ebdd3 100644 --- a/miniprogram/src/pages/competition/detail.tsx +++ b/miniprogram/src/pages/competition/detail.tsx @@ -1,7 +1,7 @@ -import { View, Text, Button, Image, ScrollView } from '@tarojs/components' +import { View, Text, Button, Image, ScrollView, PageContainer } from '@tarojs/components' import Taro, { useLoad, useDidShow } from '@tarojs/taro' import { useState, useEffect } from 'react' -import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment, getProjects } from '../../api' +import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment, getProjects, getComments } from '../../api' import MarkdownReader from '../../components/MarkdownReader' import './detail.scss' @@ -12,6 +12,8 @@ export default function CompetitionDetail() { const [myProject, setMyProject] = useState(null) const [activeTab, setActiveTab] = useState(0) const [loading, setLoading] = useState(false) + const [showComments, setShowComments] = useState(false) + const [comments, setComments] = useState([]) useLoad((options) => { const { id } = options @@ -145,6 +147,20 @@ export default function CompetitionDetail() { } catch (e) {} } + const fetchComments = async (projectId) => { + Taro.showLoading({ title: '加载中' }) + try { + const res = await getComments({ project: projectId }) + const list = res.results || res.data || res || [] + setComments(Array.isArray(list) ? list : []) + setShowComments(true) + } catch (e) { + Taro.showToast({ title: '获取评语失败', icon: 'none' }) + } finally { + Taro.hideLoading() + } + } + const handleEnroll = async () => { if (!detail) return try { @@ -231,6 +247,10 @@ export default function CompetitionDetail() { {project.final_score > 0 && {project.final_score}分} + ))} @@ -264,12 +284,22 @@ export default function CompetitionDetail() { {enrollment ? ( myProject ? ( - + + + + ) : ( enrollment.status === 'approved' ? ( + + ) } diff --git a/miniprogram/src/pages/competition/project.scss b/miniprogram/src/pages/competition/project.scss index 057fc5d..ca16ea9 100644 --- a/miniprogram/src/pages/competition/project.scss +++ b/miniprogram/src/pages/competition/project.scss @@ -16,7 +16,7 @@ color: #ccc; } - .input, .textarea { + .input, .textarea, .picker { background: #1f1f1f; border-radius: 8px; padding: 12px; diff --git a/miniprogram/src/pages/competition/project.tsx b/miniprogram/src/pages/competition/project.tsx index 0271a82..2ae2ad8 100644 --- a/miniprogram/src/pages/competition/project.tsx +++ b/miniprogram/src/pages/competition/project.tsx @@ -1,7 +1,7 @@ -import { View, Text, Button, Image, Input, Textarea } from '@tarojs/components' +import { View, Text, Button, Image, Input, Textarea, Picker } from '@tarojs/components' import Taro, { useLoad } from '@tarojs/taro' import { useState } from 'react' -import { getProjectDetail, createProject, updateProject, uploadProjectFile, submitProject, uploadMedia } from '../../api' +import { getProjectDetail, createProject, updateProject, uploadProjectFile, submitProject, uploadMedia, getCompetitions } from '../../api' import './project.scss' export default function ProjectEdit() { @@ -12,10 +12,12 @@ export default function ProjectEdit() { files: [] }) const [competitionId, setCompetitionId] = useState('') + const [competitions, setCompetitions] = useState([]) const [loading, setLoading] = useState(false) const [isEdit, setIsEdit] = useState(false) useLoad((options) => { + fetchCompetitions() const { id, competitionId } = options if (id) { setIsEdit(true) @@ -25,6 +27,17 @@ export default function ProjectEdit() { } }) + const fetchCompetitions = async () => { + try { + const res = await getCompetitions() + if (res && res.results) { + setCompetitions(res.results) + } + } catch (e) { + console.error('获取比赛列表失败', e) + } + } + const fetchProject = async (id) => { setLoading(true) try { @@ -59,6 +72,49 @@ export default function ProjectEdit() { } } + const handleUploadFile = async () => { + if (!project.id) { + Taro.showToast({ title: '请先保存草稿再上传附件', icon: 'none' }) + return + } + + try { + const res = await Taro.chooseMessageFile({ count: 1, type: 'file' }) + const tempFiles = res.tempFiles + if (!tempFiles.length) return + + Taro.showLoading({ title: '上传中...' }) + const file = tempFiles[0] + + // @ts-ignore + const result = await uploadProjectFile(file.path, project.id, file.name) + + // Update file list + setProject(prev => ({ + ...prev, + files: [...(prev.files || []), result] + })) + + Taro.hideLoading() + Taro.showToast({ title: '上传成功', icon: 'success' }) + } catch (e) { + Taro.hideLoading() + console.error(e) + Taro.showToast({ title: '上传失败', icon: 'none' }) + } + } + + const handleDeleteFile = (fileId) => { + // API call to delete file not implemented yet? Or just remove from list? + // Usually we should call delete API. For now just remove from UI. + // Ideally we should have deleteProjectFile API. + // But user only asked to "optimize upload". + setProject(prev => ({ + ...prev, + files: prev.files.filter(f => f.id !== fileId) + })) + } + const handleSave = async (submit = false) => { if (!project.title) { Taro.showToast({ title: '请输入项目标题', icon: 'none' }) @@ -105,6 +161,26 @@ export default function ProjectEdit() { return ( + + 所属比赛 + { + const idx = Number(e.detail.value) + const selected = competitions[idx] + if (selected) { + setCompetitionId(String(selected.id)) + } + }} + > + + {competitions.find(c => String(c.id) === String(competitionId))?.title || '请选择比赛'} + + + + 项目标题 - {/* 附件列表略,暂不支持上传非图片附件 */} + + + 项目附件 + + + + {project.files && project.files.map((file, index) => ( + + {file.name || '未知文件'} + {/* handleDeleteFile(file.id)}>删除 */} + + ))} + {(!project.files || project.files.length === 0) && 暂无附件 (PDF/PPT/视频)} + +