diff --git a/backend/competition/admin.py b/backend/competition/admin.py index 47d2504..d5da7b6 100644 --- a/backend/competition/admin.py +++ b/backend/competition/admin.py @@ -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') }), ) diff --git a/backend/competition/migrations/0003_competition_project_visibility.py b/backend/competition/migrations/0003_competition_project_visibility.py new file mode 100644 index 0000000..519c512 --- /dev/null +++ b/backend/competition/migrations/0003_competition_project_visibility.py @@ -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='项目可见性'), + ), + ] diff --git a/backend/competition/models.py b/backend/competition/models.py index 53289d8..8cebb6c 100644 --- a/backend/competition/models.py +++ b/backend/competition/models.py @@ -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="创建时间") diff --git a/backend/competition/serializers.py b/backend/competition/serializers.py index 0ca91cf..889a12b 100644 --- a/backend/competition/serializers.py +++ b/backend/competition/serializers.py @@ -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): diff --git a/backend/competition/views.py b/backend/competition/views.py index 60dbceb..34c1dd0 100644 --- a/backend/competition/views.py +++ b/backend/competition/views.py @@ -112,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') diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3824b2d..be541d0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -18,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'; @@ -40,6 +41,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/competition/ProjectDetail.jsx b/frontend/src/components/competition/ProjectDetail.jsx index 0674322..6b792c1 100644 --- a/frontend/src/components/competition/ProjectDetail.jsx +++ b/frontend/src/components/competition/ProjectDetail.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +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'; @@ -59,7 +59,7 @@ const ProjectDetail = () => { }); if (isLoading) return ; - if (!project) return ; + if (!project) return ; return (
diff --git a/miniprogram/src/app.config.ts b/miniprogram/src/app.config.ts index d2f9e64..1ac8bb5 100644 --- a/miniprogram/src/app.config.ts +++ b/miniprogram/src/app.config.ts @@ -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: [ { diff --git a/miniprogram/src/pages/competition/detail.tsx b/miniprogram/src/pages/competition/detail.tsx index d5ebdd3..45f5d88 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, PageContainer } from '@tarojs/components' +import { View, Text, Button, Image, ScrollView } from '@tarojs/components' import Taro, { useLoad, useDidShow } from '@tarojs/taro' import { useState, useEffect } from 'react' -import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment, getProjects, getComments } from '../../api' +import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment, getProjects } from '../../api' import MarkdownReader from '../../components/MarkdownReader' import './detail.scss' @@ -12,8 +12,6 @@ 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 @@ -46,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) } @@ -96,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) { // 没报名则无数据,忽略 @@ -115,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) } @@ -128,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]) @@ -147,20 +114,6 @@ 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 { @@ -182,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 加载中... return ( @@ -232,7 +203,7 @@ export default function CompetitionDetail() { {activeTab === 1 && ( {projects.map(project => ( - + Taro.navigateTo({ url: `/pages/competition/project-detail?id=${project.id}` })}> {project.final_score > 0 && {project.final_score}分} - ))} - {projects.length === 0 && 暂无参赛项目} + {projects.length === 0 && {getEmptyMessage(detail.project_visibility, enrollment)}} )} @@ -264,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) => ( - + Taro.navigateTo({ url: `/pages/competition/project-detail?id=${project.id}` })}> {index + 1} @@ -295,7 +262,7 @@ export default function CompetitionDetail() { @@ -324,24 +291,6 @@ export default function CompetitionDetail() { )} - - setShowComments(false)} position='bottom' round> - - 评委评语 - - {comments.length > 0 ? comments.map((c: any) => ( - - - {c.judge_name || '评委'} - {c.created_at?.substring(0, 16)} - - {c.content} - - )) : 暂无评语} - - - - ) } diff --git a/miniprogram/src/pages/competition/project-detail.config.ts b/miniprogram/src/pages/competition/project-detail.config.ts new file mode 100644 index 0000000..b12cd41 --- /dev/null +++ b/miniprogram/src/pages/competition/project-detail.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '项目详情' +}) diff --git a/miniprogram/src/pages/competition/project-detail.scss b/miniprogram/src/pages/competition/project-detail.scss new file mode 100644 index 0000000..6803c86 --- /dev/null +++ b/miniprogram/src/pages/competition/project-detail.scss @@ -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; + } + } + } + } + } +} diff --git a/miniprogram/src/pages/competition/project-detail.tsx b/miniprogram/src/pages/competition/project-detail.tsx new file mode 100644 index 0000000..d341cc7 --- /dev/null +++ b/miniprogram/src/pages/competition/project-detail.tsx @@ -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(null) + const [comments, setComments] = useState([]) + 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 加载中... + + return ( + + + + + + {project.title} + + + {project.contestant_info?.nickname || '参赛者'} + + + + + 项目介绍 + + {project.description ? : 暂无介绍} + + + + + 团队介绍 + + {project.team_info || '暂无团队信息'} + + + + + 项目附件 + {project.files && project.files.length > 0 ? ( + + {project.files.map((file, index) => ( + handleOpenFile(file)}> + {file.name || '附件 ' + (index + 1)} + 查看 + + ))} + + ) : ( + 暂无附件 + )} + + + + 评委评语 + {comments.length > 0 ? ( + + {comments.map((c) => ( + + + {c.judge_name || '评委'} + {c.created_at?.substring(0, 16)} + + {c.content} + + ))} + + ) : ( + 暂无评语 + )} + + + + ) +}