比赛
This commit is contained in:
@@ -100,6 +100,7 @@ class ProjectViewSet(viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
serializer_class = ProjectSerializer
|
serializer_class = ProjectSerializer
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
pagination_class = StandardResultsSetPagination
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = Project.objects.all()
|
queryset = Project.objects.all()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { App as AntdApp } from 'antd';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
@@ -25,9 +26,10 @@ const queryClient = new QueryClient();
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<AntdApp>
|
||||||
<BrowserRouter>
|
<AuthProvider>
|
||||||
<Layout>
|
<BrowserRouter>
|
||||||
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/services" element={<AIServices />} />
|
<Route path="/services" element={<AIServices />} />
|
||||||
@@ -44,8 +46,9 @@ function App() {
|
|||||||
<Route path="/payment/:orderId" element={<Payment />} />
|
<Route path="/payment/:orderId" element={<Payment />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</AntdApp>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,5 +94,6 @@ export const uploadProjectFile = (data) => {
|
|||||||
|
|
||||||
export const createScore = (data) => api.post('/competition/scores/', data);
|
export const createScore = (data) => api.post('/competition/scores/', data);
|
||||||
export const createComment = (data) => api.post('/competition/comments/', data);
|
export const createComment = (data) => api.post('/competition/comments/', data);
|
||||||
|
export const getComments = (params) => api.get('/competition/comments/', { params });
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Typography, Tabs, Button, Row, Col, Card, Statistic, Tag, Descriptions, Empty, message, Spin } from 'antd';
|
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 } from '@ant-design/icons';
|
import { CalendarOutlined, TrophyOutlined, UserOutlined, FileTextOutlined, CloudUploadOutlined, CopyOutlined, CheckOutlined, MessageOutlined } from '@ant-design/icons';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { getCompetitionDetail, getProjects, getMyCompetitionEnrollment, enrollCompetition } from '../../api';
|
import { getCompetitionDetail, getProjects, getMyCompetitionEnrollment, enrollCompetition, getComments } from '../../api';
|
||||||
import ProjectSubmission from './ProjectSubmission';
|
import ProjectSubmission from './ProjectSubmission';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import 'github-markdown-css/github-markdown-dark.css';
|
import 'github-markdown-css/github-markdown-dark.css';
|
||||||
@@ -84,6 +84,9 @@ const CompetitionDetail = () => {
|
|||||||
const [activeTab, setActiveTab] = useState('details');
|
const [activeTab, setActiveTab] = useState('details');
|
||||||
const [submissionModalVisible, setSubmissionModalVisible] = useState(false);
|
const [submissionModalVisible, setSubmissionModalVisible] = useState(false);
|
||||||
const [editingProject, setEditingProject] = useState(null);
|
const [editingProject, setEditingProject] = useState(null);
|
||||||
|
const [commentsModalVisible, setCommentsModalVisible] = useState(false);
|
||||||
|
const [currentProjectComments, setCurrentProjectComments] = useState([]);
|
||||||
|
const [commentsLoading, setCommentsLoading] = useState(false);
|
||||||
|
|
||||||
// Fetch competition details
|
// Fetch competition details
|
||||||
const { data: competition, isLoading: loadingDetail } = useQuery({
|
const { data: competition, isLoading: loadingDetail } = useQuery({
|
||||||
@@ -94,7 +97,7 @@ const CompetitionDetail = () => {
|
|||||||
// Fetch projects (for leaderboard/display)
|
// Fetch projects (for leaderboard/display)
|
||||||
const { data: projects } = useQuery({
|
const { data: projects } = useQuery({
|
||||||
queryKey: ['projects', id],
|
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
|
// 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 <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
if (loadingDetail) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||||
if (!competition) return <Empty description="比赛不存在" />;
|
if (!competition) return <Empty description="比赛不存在" />;
|
||||||
|
|
||||||
@@ -232,7 +251,8 @@ const CompetitionDetail = () => {
|
|||||||
hoverable
|
hoverable
|
||||||
cover={<img alt={project.title} src={getImageUrl(project.display_cover_image) || 'placeholder.jpg'} style={{ height: 180, objectFit: 'cover' }} />}
|
cover={<img alt={project.title} src={getImageUrl(project.display_cover_image) || 'placeholder.jpg'} style={{ height: 180, objectFit: 'cover' }} />}
|
||||||
actions={[
|
actions={[
|
||||||
<Button type="link" onClick={() => navigate(`/projects/${project.id}`)}>查看详情</Button>
|
<Button type="link" onClick={() => navigate(`/projects/${project.id}`)}>查看详情</Button>,
|
||||||
|
<Button type="link" icon={<MessageOutlined />} onClick={() => handleViewComments(project)}>评语</Button>
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Card.Meta
|
<Card.Meta
|
||||||
@@ -257,9 +277,9 @@ const CompetitionDetail = () => {
|
|||||||
key: 'leaderboard',
|
key: 'leaderboard',
|
||||||
label: '排行榜',
|
label: '排行榜',
|
||||||
children: (
|
children: (
|
||||||
<Card title="实时排名" bordered={false} style={{ background: 'transparent' }}>
|
<Card title="实时排名" variant="borderless" style={{ background: 'transparent' }}>
|
||||||
{/* Leaderboard Logic: sort by final_score descending */}
|
{/* 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) => (
|
||||||
<div key={project.id} style={{ display: 'flex', alignItems: 'center', padding: '12px 0', borderBottom: '1px solid #333' }}>
|
<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' }}>
|
<div style={{ width: 40, fontSize: 20, fontWeight: 'bold', color: index < 3 ? '#gold' : '#fff' }}>
|
||||||
#{index + 1}
|
#{index + 1}
|
||||||
@@ -307,16 +327,27 @@ const CompetitionDetail = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isContestant && (
|
{isContestant && (
|
||||||
<Button
|
<>
|
||||||
icon={<CloudUploadOutlined />}
|
<Button
|
||||||
loading={loadingMyProject}
|
icon={<CloudUploadOutlined />}
|
||||||
onClick={() => {
|
loading={loadingMyProject}
|
||||||
setEditingProject(myProject || null);
|
onClick={() => {
|
||||||
setSubmissionModalVisible(true);
|
setEditingProject(myProject || null);
|
||||||
}}
|
setSubmissionModalVisible(true);
|
||||||
>
|
}}
|
||||||
{myProject ? '管理/修改作品' : '提交作品'}
|
>
|
||||||
</Button>
|
{myProject ? '管理/修改作品' : '提交作品'}
|
||||||
|
</Button>
|
||||||
|
{myProject && (
|
||||||
|
<Button
|
||||||
|
icon={<MessageOutlined />}
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
onClick={() => handleViewComments(myProject)}
|
||||||
|
>
|
||||||
|
查看评语
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,6 +378,36 @@ const CompetitionDetail = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="评委评语"
|
||||||
|
open={commentsModalVisible}
|
||||||
|
onCancel={() => setCommentsModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
loading={commentsLoading}
|
||||||
|
itemLayout="horizontal"
|
||||||
|
dataSource={currentProjectComments}
|
||||||
|
renderItem={item => (
|
||||||
|
<List.Item>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={<Avatar icon={<UserOutlined />} />}
|
||||||
|
title={item.judge_name || '评委'}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'inherit' }}>{item.content}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||||
|
{dayjs(item.created_at).format('YYYY-MM-DD HH:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
locale={{ emptyText: '暂无评语' }}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { UploadOutlined, CloudUploadOutlined, LinkOutlined } from '@ant-design/icons';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { createProject, updateProject, submitProject, uploadProjectFile } from '../../api';
|
import { createProject, updateProject, submitProject, uploadProjectFile } from '../../api';
|
||||||
@@ -8,6 +8,7 @@ const { TextArea } = Input;
|
|||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }) => {
|
const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }) => {
|
||||||
|
const { message } = App.useApp();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [fileList, setFileList] = useState([]);
|
const [fileList, setFileList] = useState([]);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -82,15 +83,14 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('project', initialValues?.id || ''); // Need project ID first usually
|
formData.append('project', initialValues?.id || ''); // Need project ID first usually
|
||||||
|
|
||||||
// Upload logic might need adjustment: create project first, then upload files?
|
uploadMutation.mutate(formData, {
|
||||||
// Or upload to temp storage then link?
|
onSuccess: (data) => {
|
||||||
// For simplicity, let's assume we create project first if not exists
|
onSuccess(data);
|
||||||
if (!initialValues?.id) {
|
},
|
||||||
// Already handled above
|
onError: (error) => {
|
||||||
return;
|
onError(error);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
uploadMutation.mutate(formData);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 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 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 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'
|
const BASE_URL = (typeof process !== 'undefined' && process.env && process.env.TARO_APP_API_URL) || 'https://market.quant-speed.com/api'
|
||||||
return Taro.uploadFile({
|
return Taro.uploadFile({
|
||||||
url: `${BASE_URL}/competition/files/`,
|
url: `${BASE_URL}/competition/files/`,
|
||||||
filePath,
|
filePath,
|
||||||
name: 'file',
|
name: 'file',
|
||||||
|
formData: {
|
||||||
|
project: projectId,
|
||||||
|
name: fileName || ''
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
'Authorization': `Bearer ${Taro.getStorageSync('token')}`
|
'Authorization': `Bearer ${Taro.getStorageSync('token')}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Taro, { useLoad, useDidShow } from '@tarojs/taro'
|
||||||
import { useState, useEffect } from 'react'
|
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 MarkdownReader from '../../components/MarkdownReader'
|
||||||
import './detail.scss'
|
import './detail.scss'
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ export default function CompetitionDetail() {
|
|||||||
const [myProject, setMyProject] = useState<any>(null)
|
const [myProject, setMyProject] = useState<any>(null)
|
||||||
const [activeTab, setActiveTab] = useState(0)
|
const [activeTab, setActiveTab] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showComments, setShowComments] = useState(false)
|
||||||
|
const [comments, setComments] = useState<any[]>([])
|
||||||
|
|
||||||
useLoad((options) => {
|
useLoad((options) => {
|
||||||
const { id } = options
|
const { id } = options
|
||||||
@@ -145,6 +147,20 @@ export default function CompetitionDetail() {
|
|||||||
} catch (e) {}
|
} 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 () => {
|
const handleEnroll = async () => {
|
||||||
if (!detail) return
|
if (!detail) return
|
||||||
try {
|
try {
|
||||||
@@ -231,6 +247,10 @@ export default function CompetitionDetail() {
|
|||||||
</View>
|
</View>
|
||||||
{project.final_score > 0 && <Text className='score'>{project.final_score}分</Text>}
|
{project.final_score > 0 && <Text className='score'>{project.final_score}分</Text>}
|
||||||
</View>
|
</View>
|
||||||
|
<Button size='mini' style={{ marginTop: '8px', fontSize: '12px', background: 'transparent', color: '#666', border: '1px solid #ddd' }} onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
fetchComments(project.id)
|
||||||
|
}}>查看评语</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -264,12 +284,22 @@ export default function CompetitionDetail() {
|
|||||||
<View className='footer-action'>
|
<View className='footer-action'>
|
||||||
{enrollment ? (
|
{enrollment ? (
|
||||||
myProject ? (
|
myProject ? (
|
||||||
<Button
|
<View style={{ display: 'flex', width: '100%', gap: '10px' }}>
|
||||||
className='btn enrolled'
|
<Button
|
||||||
onClick={() => Taro.navigateTo({ url: `/pages/competition/project?id=${myProject.id}` })}
|
className='btn enrolled'
|
||||||
>
|
style={{ flex: 1 }}
|
||||||
我的作品 ({myProject.status === 'submitted' ? '已提交' : '草稿'})
|
onClick={() => Taro.navigateTo({ url: `/pages/competition/project?id=${myProject.id}` })}
|
||||||
</Button>
|
>
|
||||||
|
我的作品 ({myProject.status === 'submitted' ? '已提交' : '草稿'})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className='btn'
|
||||||
|
style={{ width: '80px', background: '#fff', color: '#333', border: '1px solid #ddd', fontSize: '12px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onClick={() => fetchComments(myProject.id)}
|
||||||
|
>
|
||||||
|
评语
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
enrollment.status === 'approved' ? (
|
enrollment.status === 'approved' ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -294,6 +324,24 @@ export default function CompetitionDetail() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<PageContainer show={showComments} onClickOverlay={() => setShowComments(false)} position='bottom' round>
|
||||||
|
<View className='comments-container' style={{ padding: '20px', maxHeight: '60vh', background: '#fff', borderTopLeftRadius: '16px', borderTopRightRadius: '16px' }}>
|
||||||
|
<Text style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '16px', display: 'block', textAlign: 'center' }}>评委评语</Text>
|
||||||
|
<ScrollView scrollY style={{ height: '300px' }}>
|
||||||
|
{comments.length > 0 ? comments.map((c: any) => (
|
||||||
|
<View key={c.id} style={{ marginBottom: '16px', borderBottom: '1px solid #eee', paddingBottom: '8px' }}>
|
||||||
|
<View style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||||
|
<Text style={{ fontWeight: 'bold', fontSize: '14px' }}>{c.judge_name || '评委'}</Text>
|
||||||
|
<Text style={{ fontSize: '12px', color: '#999' }}>{c.created_at?.substring(0, 16)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ display: 'block', color: '#333', fontSize: '14px', lineHeight: '1.5' }}>{c.content}</Text>
|
||||||
|
</View>
|
||||||
|
)) : <Text style={{ color: '#999', textAlign: 'center', display: 'block', marginTop: '20px' }}>暂无评语</Text>}
|
||||||
|
</ScrollView>
|
||||||
|
<Button onClick={() => setShowComments(false)} style={{ marginTop: '16px' }}>关闭</Button>
|
||||||
|
</View>
|
||||||
|
</PageContainer>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input, .textarea {
|
.input, .textarea, .picker {
|
||||||
background: #1f1f1f;
|
background: #1f1f1f;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|||||||
@@ -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 Taro, { useLoad } from '@tarojs/taro'
|
||||||
import { useState } from 'react'
|
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'
|
import './project.scss'
|
||||||
|
|
||||||
export default function ProjectEdit() {
|
export default function ProjectEdit() {
|
||||||
@@ -12,10 +12,12 @@ export default function ProjectEdit() {
|
|||||||
files: []
|
files: []
|
||||||
})
|
})
|
||||||
const [competitionId, setCompetitionId] = useState<string>('')
|
const [competitionId, setCompetitionId] = useState<string>('')
|
||||||
|
const [competitions, setCompetitions] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [isEdit, setIsEdit] = useState(false)
|
const [isEdit, setIsEdit] = useState(false)
|
||||||
|
|
||||||
useLoad((options) => {
|
useLoad((options) => {
|
||||||
|
fetchCompetitions()
|
||||||
const { id, competitionId } = options
|
const { id, competitionId } = options
|
||||||
if (id) {
|
if (id) {
|
||||||
setIsEdit(true)
|
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) => {
|
const fetchProject = async (id) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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) => {
|
const handleSave = async (submit = false) => {
|
||||||
if (!project.title) {
|
if (!project.title) {
|
||||||
Taro.showToast({ title: '请输入项目标题', icon: 'none' })
|
Taro.showToast({ title: '请输入项目标题', icon: 'none' })
|
||||||
@@ -105,6 +161,26 @@ export default function ProjectEdit() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='project-edit'>
|
<View className='project-edit'>
|
||||||
|
<View className='form-item'>
|
||||||
|
<Text className='label'>所属比赛</Text>
|
||||||
|
<Picker
|
||||||
|
mode='selector'
|
||||||
|
range={competitions}
|
||||||
|
rangeKey='title'
|
||||||
|
onChange={e => {
|
||||||
|
const idx = Number(e.detail.value)
|
||||||
|
const selected = competitions[idx]
|
||||||
|
if (selected) {
|
||||||
|
setCompetitionId(String(selected.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='picker'>
|
||||||
|
{competitions.find(c => String(c.id) === String(competitionId))?.title || '请选择比赛'}
|
||||||
|
</View>
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='form-item'>
|
<View className='form-item'>
|
||||||
<Text className='label'>项目标题</Text>
|
<Text className='label'>项目标题</Text>
|
||||||
<Input
|
<Input
|
||||||
@@ -151,7 +227,21 @@ export default function ProjectEdit() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 附件列表略,暂不支持上传非图片附件 */}
|
<View className='form-item'>
|
||||||
|
<View className='label-row' style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
||||||
|
<Text className='label' style={{ marginBottom: 0 }}>项目附件</Text>
|
||||||
|
<Button size='mini' style={{ margin: 0, fontSize: '12px' }} onClick={handleUploadFile}>上传附件</Button>
|
||||||
|
</View>
|
||||||
|
<View className='file-list'>
|
||||||
|
{project.files && project.files.map((file, index) => (
|
||||||
|
<View key={index} className='file-item' style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', background: '#f8f8f8', marginBottom: '8px', borderRadius: '4px' }}>
|
||||||
|
<Text className='file-name' style={{ flex: 1, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name || '未知文件'}</Text>
|
||||||
|
{/* <Text className='delete' style={{ color: 'red', marginLeft: '10px' }} onClick={() => handleDeleteFile(file.id)}>删除</Text> */}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{(!project.files || project.files.length === 0) && <Text style={{ color: '#999', fontSize: '12px' }}>暂无附件 (PDF/PPT/视频)</Text>}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='footer-btns'>
|
<View className='footer-btns'>
|
||||||
<Button className='btn save' onClick={() => handleSave(false)}>保存草稿</Button>
|
<Button className='btn save' onClick={() => handleSave(false)}>保存草稿</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user