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}
+
+ ))}
+
+ ) : (
+ 暂无评语
+ )}
+
+
+
+ )
+}