Compare commits

...

4 Commits

Author SHA1 Message Date
jeremygan2021
6361b7a522 比赛
All checks were successful
Deploy to Server / deploy (push) Successful in 28s
2026-03-10 14:25:04 +08:00
jeremygan2021
03297f3d07 比赛 2026-03-10 14:11:43 +08:00
jeremygan2021
b74d0826ee 比赛 2026-03-10 14:11:39 +08:00
jeremygan2021
880192c358 比赛 2026-03-10 14:05:37 +08:00
21 changed files with 888 additions and 151 deletions

View File

@@ -25,8 +25,8 @@ jobs:
# 2. 停止并移除 Docker 容器及镜像
echo -e "\n===== 停止并清理 Docker ====="
# 合并停止容器和删除镜像的操作,避免重复执行导致"already in progress"错误
echo $SUDO_PASSWORD | sudo -S docker compose down --rmi all
# 移除 --rmi all保留镜像缓存加快构建速度同时避免误删基础镜像
echo $SUDO_PASSWORD | sudo -S docker compose down
# 3. 拉取 Git 最新代码
echo -e "\n===== 拉取 Git 代码 ====="
@@ -42,6 +42,6 @@ jobs:
# 4. 重新启动 Docker 容器
echo -e "\n===== 启动 Docker 容器 ====="
echo $SUDO_PASSWORD | sudo -S docker compose up -d
echo $SUDO_PASSWORD | sudo -S docker compose up -d --build
echo -e "\n===== 操作完成!====="

View File

@@ -18,5 +18,8 @@ COPY . /app/
# Expose port
EXPOSE 8000
# Volume for media files
VOLUME ["/app/media"]
# Run the application with gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "config.wsgi:application"]

View File

@@ -28,7 +28,7 @@ class CompetitionAdmin(ModelAdmin):
'description': '封面图可以上传本地图片,也可以填写外部链接,优先显示本地上传的图片'
}),
('时间和状态', {
'fields': ('start_time', 'end_time', 'status', 'is_active')
'fields': ('start_time', 'end_time', 'status', 'project_visibility', 'is_active')
}),
)

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-10 06:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0002_competition_cover_image_url_project_cover_image_url'),
]
operations = [
migrations.AddField(
model_name='competition',
name='project_visibility',
field=models.CharField(choices=[('public', '公开可见'), ('contestant', '选手及以上可见'), ('guest', '嘉宾及评委可见'), ('judge', '仅评委可见')], default='public', max_length=20, verbose_name='项目可见性'),
),
]

View File

@@ -14,6 +14,13 @@ class Competition(models.Model):
('ended', '已结束'),
)
PROJECT_VISIBILITY_CHOICES = (
('public', '公开可见'),
('contestant', '选手及以上可见'),
('guest', '嘉宾及评委可见'),
('judge', '仅评委可见'),
)
title = models.CharField(max_length=200, verbose_name="比赛名称")
description = models.TextField(verbose_name="比赛简介")
rule_description = models.TextField(verbose_name="规则说明")
@@ -26,6 +33,7 @@ class Competition(models.Model):
end_time = models.DateTimeField(verbose_name="结束时间")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态")
project_visibility = models.CharField(max_length=20, choices=PROJECT_VISIBILITY_CHOICES, default='public', verbose_name="项目可见性")
is_active = models.BooleanField(default=True, verbose_name="是否启用")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")

View File

@@ -16,7 +16,7 @@ class CompetitionSerializer(serializers.ModelSerializer):
model = Competition
fields = ['id', 'title', 'description', 'rule_description', 'condition_description',
'cover_image', 'cover_image_url', 'display_cover_image',
'start_time', 'end_time', 'status', 'status_display', 'is_active',
'start_time', 'end_time', 'status', 'project_visibility', 'status_display', 'is_active',
'score_dimensions', 'created_at']
def get_display_cover_image(self, obj):

View File

@@ -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()
@@ -111,30 +112,39 @@ class ProjectViewSet(viewsets.ModelViewSet):
if contestant_id:
queryset = queryset.filter(contestant_id=contestant_id)
# 如果是普通用户,只能看到已提交的项目,或者自己草稿的项目
user = get_current_wechat_user(self.request)
# 1. 基础条件:公开可见且已提交的项目
q = Q(competition__project_visibility='public', status='submitted')
if user:
# 查找用户在这个比赛中的角色
# 如果是评委,可以看到所有项目(包括草稿吗?通常评委只看提交的)
# 这里简化评委看所有submitted用户看所有submitted + 自己的draft
# 找到用户参与的所有比赛角色
enrollments = CompetitionEnrollment.objects.filter(user=user)
judge_competitions = enrollments.filter(role='judge').values_list('competition_id', flat=True)
# 基本查询:所有已提交的项目
q = Q(status='submitted')
# 加上自己创建的项目 (即使是draft)
# 2. 用户自己的项目(始终可见,包括草稿)
q |= Q(contestant__user=user)
# 加上自己是评委的比赛的所有项目 (通常评委只看submitted但如果需要预审可以看draft这里假设只看submitted)
# q |= Q(competition__in=judge_competitions)
# 3. 基于角色的可见性
# 获取用户已通过审核的报名信息
enrollments = CompetitionEnrollment.objects.filter(user=user, status='approved')
queryset = queryset.filter(q)
else:
# 未登录用户只能看已提交
queryset = queryset.filter(status='submitted')
# 获取各角色的比赛ID集合
judge_comp_ids = set(enrollments.filter(role='judge').values_list('competition_id', flat=True))
guest_comp_ids = set(enrollments.filter(role='guest').values_list('competition_id', flat=True))
contestant_comp_ids = set(enrollments.filter(role='contestant').values_list('competition_id', flat=True))
# 'judge' 可见性:仅评委可见
if judge_comp_ids:
q |= Q(competition__project_visibility='judge', competition__in=judge_comp_ids, status='submitted')
# 'guest' 可见性:嘉宾及评委可见
guest_access_ids = judge_comp_ids | guest_comp_ids
if guest_access_ids:
q |= Q(competition__project_visibility='guest', competition__in=guest_access_ids, status='submitted')
# 'contestant' 可见性:选手及以上可见(包括评委、嘉宾)
contestant_access_ids = judge_comp_ids | guest_comp_ids | contestant_comp_ids
if contestant_access_ids:
q |= Q(competition__project_visibility='contestant', competition__in=contestant_access_ids, status='submitted')
queryset = queryset.filter(q)
return queryset.order_by('-final_score', '-created_at')

View File

@@ -5,6 +5,7 @@ services:
command: sh -c "python manage.py collectstatic --noinput && python manage.py migrate && gunicorn --bind 0.0.0.0:8000 --access-logfile - --error-logfile - config.wsgi:application"
volumes:
- ./backend:/app
- ./backend/media:/app/media
ports:
- "8000:8000"
environment:

View File

@@ -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';
@@ -17,6 +18,7 @@ import ForumDetail from './pages/ForumDetail';
import ActivityDetail from './pages/activity/Detail';
import CompetitionList from './components/competition/CompetitionList';
import CompetitionDetail from './components/competition/CompetitionDetail';
import ProjectDetail from './components/competition/ProjectDetail';
import 'antd/dist/reset.css';
import './App.css';
@@ -25,9 +27,10 @@ const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<Layout>
<AntdApp>
<AuthProvider>
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/services" element={<AIServices />} />
@@ -38,14 +41,16 @@ function App() {
<Route path="/forum/:id" element={<ForumDetail />} />
<Route path="/competitions" element={<CompetitionList />} />
<Route path="/competitions/:id" element={<CompetitionDetail />} />
<Route path="/projects/:id" element={<ProjectDetail />} />
<Route path="/activity/:id" element={<ActivityDetail />} />
<Route path="/my-orders" element={<MyOrders />} />
<Route path="/product/:id" element={<ProductDetail />} />
<Route path="/payment/:orderId" element={<Payment />} />
</Routes>
</Layout>
</BrowserRouter>
</AuthProvider>
</BrowserRouter>
</AuthProvider>
</AntdApp>
</QueryClientProvider>
)
}

View File

@@ -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;

View File

@@ -1,19 +1,25 @@
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, Grid } 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';
/**
* Get the full URL for an image.
* Handles relative paths and ensures correct API base URL is used.
* @param {string} url - The image URL path
* @returns {string} The full absolute URL
*/
const getImageUrl = (url) => {
if (!url) return '';
if (url.startsWith('http') || url.startsWith('//')) return url;
@@ -24,7 +30,11 @@ const getImageUrl = (url) => {
};
const { Title, Paragraph } = Typography;
const { useBreakpoint } = Grid;
/**
* Code block component for markdown rendering with syntax highlighting and copy functionality.
*/
const CodeBlock = ({ inline, className, children, ...props }) => {
const [copied, setCopied] = useState(false);
const match = /language-(\w+)/.exec(className || '');
@@ -76,6 +86,11 @@ const CodeBlock = ({ inline, className, children, ...props }) => {
);
};
/**
* Main component for displaying competition details.
* Includes tabs for overview, projects, and leaderboard.
* Responsive design for mobile and desktop.
*/
const CompetitionDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
@@ -84,6 +99,12 @@ 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);
const screens = useBreakpoint();
const isMobile = !screens.md;
// Fetch competition details
const { data: competition, isLoading: loadingDetail } = useQuery({
@@ -94,7 +115,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
@@ -114,6 +135,10 @@ const CompetitionDetail = () => {
const myProject = myProjects?.results?.[0];
/**
* Handle competition enrollment.
* Checks login status and submits enrollment request.
*/
const handleEnroll = async () => {
if (!user) {
showLoginModal();
@@ -128,6 +153,26 @@ const CompetitionDetail = () => {
}
};
/**
* Fetch and display judge comments for a project.
* @param {Object} project - The project object
*/
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 (!competition) return <Empty description="比赛不存在" />;
@@ -138,8 +183,8 @@ const CompetitionDetail = () => {
key: 'details',
label: '比赛详情',
children: (
<div style={{ padding: 24, background: '#1f1f1f', borderRadius: 8 }}>
<Descriptions title="基本信息" bordered column={{ xs: 1, sm: 2, md: 3 }}>
<div style={{ padding: isMobile ? 12 : 24, background: '#1f1f1f', borderRadius: 8 }}>
<Descriptions title="基本信息" bordered column={{ xs: 1, sm: 2, md: 3 }} size={isMobile ? 'small' : 'default'}>
<Descriptions.Item label="状态">
<Tag color={competition.status === 'registration' ? 'green' : 'default'}>
{competition.status_display}
@@ -153,21 +198,21 @@ const CompetitionDetail = () => {
</Descriptions.Item>
</Descriptions>
<Title level={4} style={{ marginTop: 32, color: '#fff' }}>比赛简介</Title>
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: '16px' }} className="markdown-body">
<Title level={isMobile ? 5 : 4} style={{ marginTop: isMobile ? 24 : 32, color: '#fff' }}>比赛简介</Title>
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: isMobile ? '14px' : '16px' }} className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
code: CodeBlock,
img: (props) => <img {...props} src={getImageUrl(props.src)} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.5em' : '2em' }} />,
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.3em' : '1.5em' }} />,
h3: (props) => <h3 {...props} style={{ color: '#eee', fontSize: isMobile ? '1.1em' : '1.25em' }} />,
a: (props) => <a {...props} style={{ color: '#00b96b' }} target="_blank" rel="noopener noreferrer" />,
blockquote: (props) => <blockquote {...props} style={{ borderLeft: '4px solid #00b96b', color: '#999', paddingLeft: '1em', margin: '1em 0' }} />,
table: (props) => <table {...props} style={{ borderCollapse: 'collapse', width: '100%', margin: '1em 0' }} />,
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333' }} />,
table: (props) => <table {...props} style={{ borderCollapse: 'collapse', width: '100%', margin: '1em 0', display: 'block', overflowX: 'auto' }} />,
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333', whiteSpace: 'nowrap' }} />,
td: (props) => <td {...props} style={{ border: '1px solid #444', padding: '8px' }} />,
}}
>
@@ -175,21 +220,21 @@ const CompetitionDetail = () => {
</ReactMarkdown>
</div>
<Title level={4} style={{ marginTop: 32, color: '#fff' }}>规则说明</Title>
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: '16px' }} className="markdown-body">
<Title level={isMobile ? 5 : 4} style={{ marginTop: isMobile ? 24 : 32, color: '#fff' }}>规则说明</Title>
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: isMobile ? '14px' : '16px' }} className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
code: CodeBlock,
img: (props) => <img {...props} src={getImageUrl(props.src)} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.5em' : '2em' }} />,
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.3em' : '1.5em' }} />,
h3: (props) => <h3 {...props} style={{ color: '#eee', fontSize: isMobile ? '1.1em' : '1.25em' }} />,
a: (props) => <a {...props} style={{ color: '#00b96b' }} target="_blank" rel="noopener noreferrer" />,
blockquote: (props) => <blockquote {...props} style={{ borderLeft: '4px solid #00b96b', color: '#999', paddingLeft: '1em', margin: '1em 0' }} />,
table: (props) => <table {...props} style={{ borderCollapse: 'collapse', width: '100%', margin: '1em 0' }} />,
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333' }} />,
table: (props) => <table {...props} style={{ borderCollapse: 'collapse', width: '100%', margin: '1em 0', display: 'block', overflowX: 'auto' }} />,
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333', whiteSpace: 'nowrap' }} />,
td: (props) => <td {...props} style={{ border: '1px solid #444', padding: '8px' }} />,
}}
>
@@ -197,21 +242,21 @@ const CompetitionDetail = () => {
</ReactMarkdown>
</div>
<Title level={4} style={{ marginTop: 32, color: '#fff' }}>参赛条件</Title>
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: '16px' }} className="markdown-body">
<Title level={isMobile ? 5 : 4} style={{ marginTop: isMobile ? 24 : 32, color: '#fff' }}>参赛条件</Title>
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: isMobile ? '14px' : '16px' }} className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
code: CodeBlock,
img: (props) => <img {...props} src={getImageUrl(props.src)} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.5em' : '2em' }} />,
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.3em' : '1.5em' }} />,
h3: (props) => <h3 {...props} style={{ color: '#eee', fontSize: isMobile ? '1.1em' : '1.25em' }} />,
a: (props) => <a {...props} style={{ color: '#00b96b' }} target="_blank" rel="noopener noreferrer" />,
blockquote: (props) => <blockquote {...props} style={{ borderLeft: '4px solid #00b96b', color: '#999', paddingLeft: '1em', margin: '1em 0' }} />,
table: (props) => <table {...props} style={{ borderCollapse: 'collapse', width: '100%', margin: '1em 0' }} />,
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333' }} />,
table: (props) => <table {...props} style={{ borderCollapse: 'collapse', width: '100%', margin: '1em 0', display: 'block', overflowX: 'auto' }} />,
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333', whiteSpace: 'nowrap' }} />,
td: (props) => <td {...props} style={{ border: '1px solid #444', padding: '8px' }} />,
}}
>
@@ -225,14 +270,15 @@ const CompetitionDetail = () => {
key: 'projects',
label: '参赛项目',
children: (
<Row gutter={[24, 24]}>
<Row gutter={[isMobile ? 16 : 24, isMobile ? 16 : 24]}>
{projects?.results?.map(project => (
<Col key={project.id} xs={24} sm={12} md={8}>
<Card
hoverable
cover={<img alt={project.title} src={getImageUrl(project.display_cover_image) || 'placeholder.jpg'} style={{ height: 180, objectFit: 'cover' }} />}
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
@@ -257,18 +303,18 @@ const CompetitionDetail = () => {
key: 'leaderboard',
label: '排行榜',
children: (
<Card title="实时排名" bordered={false} style={{ background: 'transparent' }}>
<Card title="实时排名" variant="borderless" style={{ background: 'transparent' }} headStyle={{ color: '#fff', fontSize: isMobile ? '16px' : '18px' }} bodyStyle={{ padding: isMobile ? '0 12px' : '24px' }}>
{/* 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' }}>
{[...(projects?.results || [])].sort((a, b) => b.final_score - a.final_score).map((project, index) => (
<div key={project.id} style={{ display: 'flex', alignItems: 'center', padding: isMobile ? '8px 0' : '12px 0', borderBottom: '1px solid #333' }}>
<div style={{ width: isMobile ? 30 : 40, fontSize: isMobile ? 16 : 20, fontWeight: 'bold', color: index < 3 ? '#ffd700' : '#fff' }}>
#{index + 1}
</div>
<div style={{ flex: 1 }}>
<div style={{ color: '#fff', fontSize: 16 }}>{project.title}</div>
<div style={{ flex: 1, paddingRight: 8 }}>
<div style={{ color: '#fff', fontSize: isMobile ? 14 : 16, wordBreak: 'break-all' }}>{project.title}</div>
<div style={{ color: '#888', fontSize: 12 }}>{project.contestant_info?.nickname}</div>
</div>
<div style={{ fontSize: 24, color: '#00b96b', fontWeight: 'bold' }}>
<div style={{ fontSize: isMobile ? 18 : 24, color: '#00b96b', fontWeight: 'bold' }}>
{enrollment && project.contestant === enrollment.id ? project.final_score : '**'}
</div>
</div>
@@ -279,44 +325,58 @@ const CompetitionDetail = () => {
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '24px' }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: isMobile ? '12px' : '24px' }}>
<div style={{
height: 300,
height: isMobile ? 240 : 300,
backgroundImage: `url(${getImageUrl(competition.display_cover_image)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: 16,
marginBottom: 32,
position: 'relative'
marginBottom: isMobile ? 16 : 32,
position: 'relative',
overflow: 'hidden'
}}>
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 32,
background: 'linear-gradient(to top, rgba(0,0,0,0.8), transparent)'
padding: isMobile ? 16 : 32,
background: 'linear-gradient(to top, rgba(0,0,0,0.9), transparent)'
}}>
<Title style={{ color: '#fff', margin: 0 }}>{competition.title}</Title>
<div style={{ marginTop: 16, display: 'flex', gap: 16 }}>
<Title level={isMobile ? 3 : 1} style={{ color: '#fff', margin: 0, fontSize: isMobile ? '24px' : undefined }}>{competition.title}</Title>
<div style={{ marginTop: isMobile ? 12 : 16, display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 8 : 16, flexWrap: 'wrap' }}>
{enrollment ? (
<Button type="primary" disabled>{enrollment.status === 'approved' ? '已报名' : '审核中'}</Button>
<Button type="primary" disabled size={isMobile ? 'middle' : 'large'}>{enrollment.status === 'approved' ? '已报名' : '审核中'}</Button>
) : (
<Button type="primary" size="large" onClick={handleEnroll} disabled={competition.status !== 'registration'}>
<Button type="primary" size={isMobile ? 'middle' : 'large'} onClick={handleEnroll} disabled={competition.status !== 'registration'}>
{competition.status === 'registration' ? '立即报名' : '报名未开始/已结束'}
</Button>
)}
{isContestant && (
<Button
icon={<CloudUploadOutlined />}
loading={loadingMyProject}
onClick={() => {
setEditingProject(myProject || null);
setSubmissionModalVisible(true);
}}
>
{myProject ? '管理/修改作品' : '提交作品'}
</Button>
<>
<Button
icon={<CloudUploadOutlined />}
loading={loadingMyProject}
size={isMobile ? 'middle' : 'large'}
onClick={() => {
setEditingProject(myProject || null);
setSubmissionModalVisible(true);
}}
>
{myProject ? '管理/修改作品' : '提交作品'}
</Button>
{myProject && (
<Button
icon={<MessageOutlined />}
style={{ marginLeft: isMobile ? 0 : 8 }}
size={isMobile ? 'middle' : 'large'}
onClick={() => handleViewComments(myProject)}
>
查看评语
</Button>
)}
</>
)}
</div>
</div>
@@ -327,7 +387,8 @@ const CompetitionDetail = () => {
onChange={setActiveTab}
items={items}
type="card"
size="large"
size={isMobile ? 'small' : 'large'}
tabBarGutter={isMobile ? 8 : undefined}
/>
{submissionModalVisible && (
@@ -347,6 +408,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>
);
};

View File

@@ -0,0 +1,188 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Typography, Card, Button, Row, Col, Tag, Descriptions, Empty, Spin, Avatar, List, Image, Grid } from 'antd';
import { UserOutlined, ArrowLeftOutlined, LinkOutlined, FileTextOutlined, TrophyOutlined, 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 { getProjectDetail, getComments } from '../../api';
import 'github-markdown-css/github-markdown-dark.css';
const { Title, Paragraph, Text } = Typography;
const { useBreakpoint } = Grid;
const getImageUrl = (url) => {
if (!url) return '';
if (url.startsWith('http') || url.startsWith('//')) return url;
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
const baseUrl = apiUrl.replace(/\/api\/?$/, '');
return `${baseUrl}${url}`;
};
const CodeBlock = ({ inline, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
};
const ProjectDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data: project, isLoading } = useQuery({
queryKey: ['project', id],
queryFn: () => getProjectDetail(id).then(res => res.data)
});
const { data: comments } = useQuery({
queryKey: ['comments', id],
queryFn: () => getComments({ project: id }).then(res => res.data?.results || res.data || []),
enabled: !!project
});
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!project) return <Empty description="项目不存在或无权访问" />;
return (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: isMobile ? '12px' : '24px' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
style={{ marginBottom: 16 }}
>
返回
</Button>
<Card bordered={false} style={{ background: '#1f1f1f', borderRadius: 8 }}>
<Row gutter={[24, 24]}>
<Col xs={24} md={8}>
<Image
src={getImageUrl(project.display_cover_image) || 'placeholder.jpg'}
alt={project.title}
style={{ width: '100%', borderRadius: 8, objectFit: 'cover', aspectRatio: '16/9' }}
/>
<Card style={{ marginTop: 24, background: '#141414', border: '1px solid #303030' }}>
<Descriptions title="项目信息" column={1} size="small">
<Descriptions.Item label="参赛者">
<Avatar icon={<UserOutlined />} size="small" style={{ marginRight: 8 }} />
{project.contestant_info?.nickname || '匿名用户'}
</Descriptions.Item>
<Descriptions.Item label="提交时间">
{dayjs(project.created_at).format('YYYY-MM-DD HH:mm')}
</Descriptions.Item>
<Descriptions.Item label="最终得分">
<span style={{ color: '#00b96b', fontSize: 18, fontWeight: 'bold' }}>
{project.final_score ?? '待定'}
</span>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={project.status === 'submitted' ? 'green' : 'default'}>
{project.status === 'submitted' ? '已提交' : '草稿'}
</Tag>
</Descriptions.Item>
</Descriptions>
</Card>
{project.link && (
<Button
type="primary"
block
icon={<LinkOutlined />}
href={project.link}
target="_blank"
style={{ marginTop: 16 }}
>
访问项目链接
</Button>
)}
{project.file && (
<Button
block
icon={<FileTextOutlined />}
href={getImageUrl(project.file)}
target="_blank"
style={{ marginTop: 16 }}
>
下载项目文件
</Button>
)}
</Col>
<Col xs={24} md={16}>
<Title level={isMobile ? 3 : 2} style={{ color: '#fff', marginTop: 0 }}>{project.title}</Title>
<Text type="secondary" style={{ fontSize: 16 }}>{project.subtitle}</Text>
<div style={{ marginTop: 24 }}>
<Title level={4} style={{ color: '#fff' }}>项目详情</Title>
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: isMobile ? '14px' : '16px', background: '#141414', padding: 16, borderRadius: 8 }} className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
code: CodeBlock,
img: (props) => <img {...props} src={getImageUrl(props.src)} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.5em' : '2em' }} />,
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em', fontSize: isMobile ? '1.3em' : '1.5em' }} />,
h3: (props) => <h3 {...props} style={{ color: '#eee', fontSize: isMobile ? '1.1em' : '1.25em' }} />,
a: (props) => <a {...props} style={{ color: '#00b96b' }} target="_blank" rel="noopener noreferrer" />,
}}
>
{project.description || '暂无描述'}
</ReactMarkdown>
</div>
</div>
{comments && comments.length > 0 && (
<div style={{ marginTop: 32 }}>
<Title level={4} style={{ color: '#fff' }}>评委评语</Title>
<List
itemLayout="horizontal"
dataSource={comments}
renderItem={item => (
<List.Item style={{ background: '#141414', padding: 16, borderRadius: 8, marginBottom: 12, border: '1px solid #303030' }}>
<List.Item.Meta
avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#00b96b' }} />}
title={<span style={{ color: '#fff' }}>{item.judge_name || '评委'}</span>}
description={
<div>
<div style={{ color: '#ccc', marginTop: 8 }}>{item.content}</div>
<div style={{ fontSize: 12, color: '#666', marginTop: 8 }}>
{dayjs(item.created_at).format('YYYY-MM-DD HH:mm')}
</div>
</div>
}
/>
</List.Item>
)}
/>
</div>
)}
</Col>
</Row>
</Card>
</div>
);
};
export default ProjectDetail;

View File

@@ -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 (

View File

@@ -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')}`
}

View File

@@ -16,7 +16,8 @@ export default defineAppConfig({
'pages/user/index',
'pages/competition/index',
'pages/competition/detail',
'pages/competition/project'
'pages/competition/project',
'pages/competition/project-detail'
],
subPackages: [
{

View File

@@ -44,43 +44,25 @@ export default function CompetitionDetail() {
const fetchMyProject = async (competitionId) => {
try {
// 获取当前用户的所有项目,然后筛选出当前比赛的
// 或者直接调用 getProjects 并传入 contestant__user=me (如果后端支持)
// 目前后端 ProjectViewSet 默认返回所有submitted + 自己的draft/submitted
// 所以我们直接调 getProjects({ competition: competitionId }) 然后在前端找自己的
// 更好的方式:后端 ProjectViewSet 应该已经过滤了,返回列表中如果有一条是自己的,那就是自己的
// 但这里我们还是显式地请求一下,或者在 fetchProjects 的结果里找
const userInfo = Taro.getStorageSync('userInfo')
if (!userInfo) return
const res = await getProjects({ competition: competitionId })
const list = res.results || res
const myProj = list.find((p: any) => p.contestant_info?.nickname === userInfo.nickname) // 这是一个简化的判断,最好用 ID
// 由于 API 返回的 contestant_info 没有 user_id我们可能需要在 project 对象里加一个 is_mine 字段
// 或者,我们可以依赖后端返回的 contestant.user.id 与当前 user.id 比对。
// 但前端拿不到 contestant.user.id (ProjectSerializer 没返回)。
// 尝试通过 enrollment.id 匹配,或者通过 user nickname 匹配(不够严谨但暂时可用)
// 既然我们之前做了一个 getMyEnrollments我们可以通过 enrollment id 来匹配
// 但这里为了简便,我们可以假设 getProjects 返回的数据里,如果 contestant_info 匹配当前用户昵称... 不太靠谱
// 让我们修改 API 或者用另一种方式:
// 直接请求 getProjects带上一个特殊参数 mine=true ? 后端 ProjectViewSet 逻辑比较复杂
// 让我们回顾一下 ProjectViewSet:
// q |= Q(contestant__user=user)
// 所以返回的列表里肯定包含我的项目。
// 既然我们已经有 enrollment 信息,我们可以用 enrollment.id 来匹配 project.contestant
if (enrollment) {
const mine = list.find((p: any) => p.contestant === enrollment.id)
setMyProject(mine)
} else {
// 如果 enrollment 还没加载完,先不管,等 enrollment 加载完再匹配?
// 或者我们再次 fetchEnrollment 后再 fetchProjects
if (mine) {
setMyProject(mine)
return
}
}
// Fallback: use nickname match if enrollment not ready or failed
const myProj = list.find((p: any) => p.contestant_info?.nickname === userInfo.nickname)
if (myProj) setMyProject(myProj)
} catch (e) {
console.error(e)
}
@@ -94,9 +76,6 @@ export default function CompetitionDetail() {
if (projects.length > 0) {
const mine = projects.find((p: any) => p.contestant === res.id)
setMyProject(mine)
} else {
// 如果 projects 还没加载,重新加载一次 projects 或者等待 fetchProjects 完成
// 其实 fetchProjects 也在运行,它完成后也会设置 projects
}
} catch (e) {
// 没报名则无数据,忽略
@@ -113,11 +92,6 @@ export default function CompetitionDetail() {
// 过滤出 submitted 的给列表显示
const submittedProjects = allProjects.filter(p => p.status === 'submitted')
setProjects(submittedProjects)
// 尝试找自己的项目 (Draft or Submitted)
// 需要 enrollment 信息
// 这里暂时没法直接 setMyProject因为 enrollment 可能还没回来
// 我们在 useEffect 里监听 enrollment 和 projects 的变化来设置 myProject
} catch (e) {
console.error('Fetch projects failed', e)
}
@@ -126,11 +100,6 @@ export default function CompetitionDetail() {
// 监听变化设置 myProject
useEffect(() => {
if (enrollment && projects.length >= 0) { // projects could be empty
// 重新获取一次所有项目以包含 draft?
// 上面的 fetchProjects 已经把 submitted 过滤给 setProjects 了。
// 所以我们需要在 fetchProjects 里就把 allProjects 存下来?或者单独存 myProject
// 让我们重构 fetchProjects专门获取一次“我的项目”
fetchMySpecificProject(detail?.id, enrollment.id)
}
}, [enrollment])
@@ -166,6 +135,24 @@ export default function CompetitionDetail() {
return map[status] || status
}
const getEmptyMessage = (visibility, enrollment) => {
const role = enrollment?.status === 'approved' ? enrollment.role : null;
if (visibility === 'judge') {
if (role === 'judge') return '暂无参赛项目';
return '该比赛项目仅评委可见';
}
if (visibility === 'guest') {
if (role === 'judge' || role === 'guest') return '暂无参赛项目';
return '该比赛项目仅嘉宾/评委可见';
}
if (visibility === 'contestant') {
if (role) return '暂无参赛项目';
return '该比赛项目仅参赛选手可见,请先报名';
}
return '暂无参赛项目';
}
if (loading || !detail) return <View className='loading'>...</View>
return (
@@ -216,7 +203,7 @@ export default function CompetitionDetail() {
{activeTab === 1 && (
<View className='project-list'>
{projects.map(project => (
<View className='project-card' key={project.id}>
<View className='project-card' key={project.id} onClick={() => Taro.navigateTo({ url: `/pages/competition/project-detail?id=${project.id}` })}>
<Image
className='cover'
mode='aspectFill'
@@ -234,7 +221,7 @@ export default function CompetitionDetail() {
</View>
</View>
))}
{projects.length === 0 && <View className='empty'></View>}
{projects.length === 0 && <View className='empty'>{getEmptyMessage(detail.project_visibility, enrollment)}</View>}
</View>
)}
@@ -244,7 +231,7 @@ export default function CompetitionDetail() {
.filter(p => p.final_score > 0)
.sort((a, b) => b.final_score - a.final_score)
.map((project, index) => (
<View className='rank-item' key={project.id}>
<View className='rank-item' key={project.id} onClick={() => Taro.navigateTo({ url: `/pages/competition/project-detail?id=${project.id}` })}>
<Text className={`rank-num top${index + 1}`}>{index + 1}</Text>
<View className='info'>
<Image className='avatar' src={project.contestant_info?.avatar_url || ''} />
@@ -264,12 +251,22 @@ export default function CompetitionDetail() {
<View className='footer-action'>
{enrollment ? (
myProject ? (
<Button
className='btn enrolled'
onClick={() => Taro.navigateTo({ url: `/pages/competition/project?id=${myProject.id}` })}
>
({myProject.status === 'submitted' ? '已提交' : '草稿'})
</Button>
<View style={{ display: 'flex', width: '100%', gap: '10px' }}>
<Button
className='btn enrolled'
style={{ flex: 1 }}
onClick={() => Taro.navigateTo({ url: `/pages/competition/project?id=${myProject.id}` })}
>
({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={() => Taro.navigateTo({ url: `/pages/competition/project-detail?id=${myProject.id}` })}
>
</Button>
</View>
) : (
enrollment.status === 'approved' ? (
<Button

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '项目详情'
})

View File

@@ -0,0 +1,158 @@
.project-detail {
background-color: #000;
min-height: 100vh;
padding-bottom: 40px;
box-sizing: border-box;
.cover {
width: 100%;
height: 240px;
display: block;
}
.content {
padding: 24px;
background: #111;
border-radius: 16px 16px 0 0;
margin-top: -24px;
position: relative;
z-index: 10;
min-height: 60vh;
.header {
margin-bottom: 32px;
.title {
font-size: 24px;
font-weight: bold;
color: #fff;
margin-bottom: 16px;
line-height: 1.4;
display: block;
}
.author {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.05);
padding: 8px 12px;
border-radius: 20px;
display: inline-flex;
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 8px;
background: #333;
}
.name {
font-size: 14px;
color: #ccc;
}
}
}
.section {
margin-bottom: 32px;
.section-title {
font-size: 18px;
font-weight: bold;
color: #fff;
margin-bottom: 16px;
display: block;
border-left: 4px solid #00b96b;
padding-left: 12px;
}
.text-content {
font-size: 15px;
color: #ccc;
line-height: 1.8;
background: #1f1f1f;
padding: 16px;
border-radius: 12px;
}
.empty {
font-size: 14px;
color: #666;
text-align: center;
display: block;
padding: 20px 0;
background: #1f1f1f;
border-radius: 12px;
}
.file-list {
background: #1f1f1f;
border-radius: 12px;
overflow: hidden;
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #333;
&:last-child {
border-bottom: none;
}
.file-name {
font-size: 14px;
color: #ddd;
flex: 1;
margin-right: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-action {
font-size: 12px;
color: #00b96b;
padding: 4px 12px;
border: 1px solid #00b96b;
border-radius: 14px;
}
}
}
.comment-list {
.comment-item {
background: #1f1f1f;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
.judge-name {
font-size: 14px;
font-weight: bold;
color: #00b96b;
}
.comment-time {
font-size: 12px;
color: #666;
}
}
.comment-content {
font-size: 14px;
color: #ccc;
line-height: 1.6;
display: block;
text-align: justify;
}
}
}
}
}
}

View File

@@ -0,0 +1,158 @@
import { View, Text, Image, Button, ScrollView } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { getProjectDetail, getComments } from '../../api'
import MarkdownReader from '../../components/MarkdownReader'
import './project-detail.scss'
export default function ProjectDetail() {
const [project, setProject] = useState<any>(null)
const [comments, setComments] = useState<any[]>([])
const [loading, setLoading] = useState(false)
useLoad((options) => {
const { id } = options
if (id) {
fetchProject(id)
fetchComments(id)
}
})
/**
* 获取项目详情
* @param id 项目ID
*/
const fetchProject = async (id) => {
setLoading(true)
try {
const res = await getProjectDetail(id)
setProject(res)
} catch (e) {
Taro.showToast({ title: '加载项目详情失败', icon: 'none' })
} finally {
setLoading(false)
}
}
/**
* 获取项目评语
* @param id 项目ID
*/
const fetchComments = async (id) => {
try {
const res = await getComments({ project: id })
const list = res.results || res.data || res || []
setComments(Array.isArray(list) ? list : [])
} catch (e) {
console.error('获取评语失败', e)
}
}
/**
* 打开/下载附件
* @param file 文件对象
*/
const handleOpenFile = (file) => {
if (!file.file) return
// 如果是图片,预览
if (file.file.match(/\.(jpg|jpeg|png|gif)$/i)) {
Taro.previewImage({ urls: [file.file] })
return
}
// 其他文件尝试下载打开
Taro.showLoading({ title: '下载中...' })
Taro.downloadFile({
url: file.file,
success: (res) => {
const filePath = res.tempFilePath
Taro.openDocument({
filePath,
success: () => console.log('打开文档成功'),
fail: (err) => {
console.error(err)
Taro.showToast({ title: '打开文件失败', icon: 'none' })
}
})
},
fail: () => {
Taro.showToast({ title: '下载文件失败', icon: 'none' })
},
complete: () => {
Taro.hideLoading()
}
})
}
if (loading || !project) return <View className='loading'>...</View>
return (
<ScrollView scrollY className='project-detail'>
<Image
className='cover'
mode='aspectFill'
src={project.display_cover_image || project.cover_image_url || 'https://via.placeholder.com/400x200'}
/>
<View className='content'>
<View className='header'>
<Text className='title'>{project.title}</Text>
<View className='author'>
<Image className='avatar' src={project.contestant_info?.avatar_url || ''} />
<Text className='name'>{project.contestant_info?.nickname || '参赛者'}</Text>
</View>
</View>
<View className='section'>
<Text className='section-title'></Text>
<View className='text-content'>
{project.description ? <MarkdownReader content={project.description} /> : <Text className='empty'></Text>}
</View>
</View>
<View className='section'>
<Text className='section-title'></Text>
<View className='text-content'>
<Text>{project.team_info || '暂无团队信息'}</Text>
</View>
</View>
<View className='section'>
<Text className='section-title'></Text>
{project.files && project.files.length > 0 ? (
<View className='file-list'>
{project.files.map((file, index) => (
<View key={index} className='file-item' onClick={() => handleOpenFile(file)}>
<Text className='file-name'>{file.name || '附件 ' + (index + 1)}</Text>
<Text className='file-action'></Text>
</View>
))}
</View>
) : (
<Text className='empty'></Text>
)}
</View>
<View className='section comments-section'>
<Text className='section-title'></Text>
{comments.length > 0 ? (
<View className='comment-list'>
{comments.map((c) => (
<View key={c.id} className='comment-item'>
<View className='comment-header'>
<Text className='judge-name'>{c.judge_name || '评委'}</Text>
<Text className='comment-time'>{c.created_at?.substring(0, 16)}</Text>
</View>
<Text className='comment-content'>{c.content}</Text>
</View>
))}
</View>
) : (
<Text className='empty'></Text>
)}
</View>
</View>
</ScrollView>
)
}

View File

@@ -16,7 +16,7 @@
color: #ccc;
}
.input, .textarea {
.input, .textarea, .picker {
background: #1f1f1f;
border-radius: 8px;
padding: 12px;

View File

@@ -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<string>('')
const [competitions, setCompetitions] = useState<any[]>([])
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 (
<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'>
<Text className='label'></Text>
<Input
@@ -151,7 +227,21 @@ export default function ProjectEdit() {
/>
</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'>
<Button className='btn save' onClick={() => handleSave(false)}>稿</Button>