This commit is contained in:
@@ -28,7 +28,7 @@ class CompetitionAdmin(ModelAdmin):
|
|||||||
'description': '封面图可以上传本地图片,也可以填写外部链接,优先显示本地上传的图片'
|
'description': '封面图可以上传本地图片,也可以填写外部链接,优先显示本地上传的图片'
|
||||||
}),
|
}),
|
||||||
('时间和状态', {
|
('时间和状态', {
|
||||||
'fields': ('start_time', 'end_time', 'status', 'is_active')
|
'fields': ('start_time', 'end_time', 'status', 'project_visibility', 'is_active')
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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='项目可见性'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -14,6 +14,13 @@ class Competition(models.Model):
|
|||||||
('ended', '已结束'),
|
('ended', '已结束'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PROJECT_VISIBILITY_CHOICES = (
|
||||||
|
('public', '公开可见'),
|
||||||
|
('contestant', '选手及以上可见'),
|
||||||
|
('guest', '嘉宾及评委可见'),
|
||||||
|
('judge', '仅评委可见'),
|
||||||
|
)
|
||||||
|
|
||||||
title = models.CharField(max_length=200, verbose_name="比赛名称")
|
title = models.CharField(max_length=200, verbose_name="比赛名称")
|
||||||
description = models.TextField(verbose_name="比赛简介")
|
description = models.TextField(verbose_name="比赛简介")
|
||||||
rule_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="结束时间")
|
end_time = models.DateTimeField(verbose_name="结束时间")
|
||||||
|
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', 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="是否启用")
|
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class CompetitionSerializer(serializers.ModelSerializer):
|
|||||||
model = Competition
|
model = Competition
|
||||||
fields = ['id', 'title', 'description', 'rule_description', 'condition_description',
|
fields = ['id', 'title', 'description', 'rule_description', 'condition_description',
|
||||||
'cover_image', 'cover_image_url', 'display_cover_image',
|
'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']
|
'score_dimensions', 'created_at']
|
||||||
|
|
||||||
def get_display_cover_image(self, obj):
|
def get_display_cover_image(self, obj):
|
||||||
|
|||||||
@@ -112,30 +112,39 @@ class ProjectViewSet(viewsets.ModelViewSet):
|
|||||||
if contestant_id:
|
if contestant_id:
|
||||||
queryset = queryset.filter(contestant_id=contestant_id)
|
queryset = queryset.filter(contestant_id=contestant_id)
|
||||||
|
|
||||||
# 如果是普通用户,只能看到已提交的项目,或者自己草稿的项目
|
|
||||||
user = get_current_wechat_user(self.request)
|
user = get_current_wechat_user(self.request)
|
||||||
|
|
||||||
|
# 1. 基础条件:公开可见且已提交的项目
|
||||||
|
q = Q(competition__project_visibility='public', status='submitted')
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
# 查找用户在这个比赛中的角色
|
# 2. 用户自己的项目(始终可见,包括草稿)
|
||||||
# 如果是评委,可以看到所有项目(包括草稿吗?通常评委只看提交的)
|
|
||||||
# 这里简化:评委看所有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)
|
|
||||||
q |= Q(contestant__user=user)
|
q |= Q(contestant__user=user)
|
||||||
|
|
||||||
# 加上自己是评委的比赛的所有项目 (通常评委只看submitted,但如果需要预审可以看draft,这里假设只看submitted)
|
# 3. 基于角色的可见性
|
||||||
# q |= Q(competition__in=judge_competitions)
|
# 获取用户已通过审核的报名信息
|
||||||
|
enrollments = CompetitionEnrollment.objects.filter(user=user, status='approved')
|
||||||
|
|
||||||
queryset = queryset.filter(q)
|
# 获取各角色的比赛ID集合
|
||||||
else:
|
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))
|
||||||
queryset = queryset.filter(status='submitted')
|
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')
|
return queryset.order_by('-final_score', '-created_at')
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import ForumDetail from './pages/ForumDetail';
|
|||||||
import ActivityDetail from './pages/activity/Detail';
|
import ActivityDetail from './pages/activity/Detail';
|
||||||
import CompetitionList from './components/competition/CompetitionList';
|
import CompetitionList from './components/competition/CompetitionList';
|
||||||
import CompetitionDetail from './components/competition/CompetitionDetail';
|
import CompetitionDetail from './components/competition/CompetitionDetail';
|
||||||
|
import ProjectDetail from './components/competition/ProjectDetail';
|
||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ function App() {
|
|||||||
<Route path="/forum/:id" element={<ForumDetail />} />
|
<Route path="/forum/:id" element={<ForumDetail />} />
|
||||||
<Route path="/competitions" element={<CompetitionList />} />
|
<Route path="/competitions" element={<CompetitionList />} />
|
||||||
<Route path="/competitions/:id" element={<CompetitionDetail />} />
|
<Route path="/competitions/:id" element={<CompetitionDetail />} />
|
||||||
|
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||||
<Route path="/activity/:id" element={<ActivityDetail />} />
|
<Route path="/activity/:id" element={<ActivityDetail />} />
|
||||||
<Route path="/my-orders" element={<MyOrders />} />
|
<Route path="/my-orders" element={<MyOrders />} />
|
||||||
<Route path="/product/:id" element={<ProductDetail />} />
|
<Route path="/product/:id" element={<ProductDetail />} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Typography, Card, Button, Row, Col, Tag, Descriptions, Empty, Spin, Avatar, List, Image, Grid } from 'antd';
|
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 <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||||
if (!project) return <Empty description="项目不存在" />;
|
if (!project) return <Empty description="项目不存在或无权访问" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: isMobile ? '12px' : '24px' }}>
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: isMobile ? '12px' : '24px' }}>
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export default defineAppConfig({
|
|||||||
'pages/user/index',
|
'pages/user/index',
|
||||||
'pages/competition/index',
|
'pages/competition/index',
|
||||||
'pages/competition/detail',
|
'pages/competition/detail',
|
||||||
'pages/competition/project'
|
'pages/competition/project',
|
||||||
|
'pages/competition/project-detail'
|
||||||
],
|
],
|
||||||
subPackages: [
|
subPackages: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 Taro, { useLoad, useDidShow } from '@tarojs/taro'
|
||||||
import { useState, useEffect } from 'react'
|
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 MarkdownReader from '../../components/MarkdownReader'
|
||||||
import './detail.scss'
|
import './detail.scss'
|
||||||
|
|
||||||
@@ -12,8 +12,6 @@ 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
|
||||||
@@ -46,43 +44,25 @@ export default function CompetitionDetail() {
|
|||||||
|
|
||||||
const fetchMyProject = async (competitionId) => {
|
const fetchMyProject = async (competitionId) => {
|
||||||
try {
|
try {
|
||||||
// 获取当前用户的所有项目,然后筛选出当前比赛的
|
|
||||||
// 或者直接调用 getProjects 并传入 contestant__user=me (如果后端支持)
|
|
||||||
// 目前后端 ProjectViewSet 默认返回:所有submitted + 自己的draft/submitted
|
|
||||||
// 所以我们直接调 getProjects({ competition: competitionId }) 然后在前端找自己的
|
|
||||||
|
|
||||||
// 更好的方式:后端 ProjectViewSet 应该已经过滤了,返回列表中如果有一条是自己的,那就是自己的
|
|
||||||
// 但这里我们还是显式地请求一下,或者在 fetchProjects 的结果里找
|
|
||||||
|
|
||||||
const userInfo = Taro.getStorageSync('userInfo')
|
const userInfo = Taro.getStorageSync('userInfo')
|
||||||
if (!userInfo) return
|
if (!userInfo) return
|
||||||
|
|
||||||
const res = await getProjects({ competition: competitionId })
|
const res = await getProjects({ competition: competitionId })
|
||||||
const list = res.results || res
|
const list = res.results || res
|
||||||
const myProj = list.find((p: any) => p.contestant_info?.nickname === userInfo.nickname) // 这是一个简化的判断,最好用 ID
|
// 尝试通过 enrollment.id 匹配,或者通过 user nickname 匹配(不够严谨但暂时可用)
|
||||||
// 由于 API 返回的 contestant_info 没有 user_id,我们可能需要在 project 对象里加一个 is_mine 字段
|
|
||||||
// 或者,我们可以依赖后端返回的 contestant.user.id 与当前 user.id 比对。
|
|
||||||
// 但前端拿不到 contestant.user.id (ProjectSerializer 没返回)。
|
|
||||||
|
|
||||||
// 既然我们之前做了一个 getMyEnrollments,我们可以通过 enrollment id 来匹配
|
|
||||||
// 但这里为了简便,我们可以假设 getProjects 返回的数据里,如果 contestant_info 匹配当前用户昵称... 不太靠谱
|
|
||||||
|
|
||||||
// 让我们修改 API 或者用另一种方式:
|
|
||||||
// 直接请求 getProjects,带上一个特殊参数 mine=true ? 后端 ProjectViewSet 逻辑比较复杂
|
|
||||||
|
|
||||||
// 让我们回顾一下 ProjectViewSet:
|
|
||||||
// q |= Q(contestant__user=user)
|
|
||||||
// 所以返回的列表里肯定包含我的项目。
|
|
||||||
|
|
||||||
// 既然我们已经有 enrollment 信息,我们可以用 enrollment.id 来匹配 project.contestant
|
|
||||||
if (enrollment) {
|
if (enrollment) {
|
||||||
const mine = list.find((p: any) => p.contestant === enrollment.id)
|
const mine = list.find((p: any) => p.contestant === enrollment.id)
|
||||||
setMyProject(mine)
|
if (mine) {
|
||||||
} else {
|
setMyProject(mine)
|
||||||
// 如果 enrollment 还没加载完,先不管,等 enrollment 加载完再匹配?
|
return
|
||||||
// 或者我们再次 fetchEnrollment 后再 fetchProjects
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
@@ -96,9 +76,6 @@ export default function CompetitionDetail() {
|
|||||||
if (projects.length > 0) {
|
if (projects.length > 0) {
|
||||||
const mine = projects.find((p: any) => p.contestant === res.id)
|
const mine = projects.find((p: any) => p.contestant === res.id)
|
||||||
setMyProject(mine)
|
setMyProject(mine)
|
||||||
} else {
|
|
||||||
// 如果 projects 还没加载,重新加载一次 projects 或者等待 fetchProjects 完成
|
|
||||||
// 其实 fetchProjects 也在运行,它完成后也会设置 projects
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 没报名则无数据,忽略
|
// 没报名则无数据,忽略
|
||||||
@@ -115,11 +92,6 @@ export default function CompetitionDetail() {
|
|||||||
// 过滤出 submitted 的给列表显示
|
// 过滤出 submitted 的给列表显示
|
||||||
const submittedProjects = allProjects.filter(p => p.status === 'submitted')
|
const submittedProjects = allProjects.filter(p => p.status === 'submitted')
|
||||||
setProjects(submittedProjects)
|
setProjects(submittedProjects)
|
||||||
|
|
||||||
// 尝试找自己的项目 (Draft or Submitted)
|
|
||||||
// 需要 enrollment 信息
|
|
||||||
// 这里暂时没法直接 setMyProject,因为 enrollment 可能还没回来
|
|
||||||
// 我们在 useEffect 里监听 enrollment 和 projects 的变化来设置 myProject
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Fetch projects failed', e)
|
console.error('Fetch projects failed', e)
|
||||||
}
|
}
|
||||||
@@ -128,11 +100,6 @@ export default function CompetitionDetail() {
|
|||||||
// 监听变化设置 myProject
|
// 监听变化设置 myProject
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (enrollment && projects.length >= 0) { // projects could be empty
|
if (enrollment && projects.length >= 0) { // projects could be empty
|
||||||
// 重新获取一次所有项目以包含 draft?
|
|
||||||
// 上面的 fetchProjects 已经把 submitted 过滤给 setProjects 了。
|
|
||||||
// 所以我们需要在 fetchProjects 里就把 allProjects 存下来?或者单独存 myProject
|
|
||||||
|
|
||||||
// 让我们重构 fetchProjects,专门获取一次“我的项目”
|
|
||||||
fetchMySpecificProject(detail?.id, enrollment.id)
|
fetchMySpecificProject(detail?.id, enrollment.id)
|
||||||
}
|
}
|
||||||
}, [enrollment])
|
}, [enrollment])
|
||||||
@@ -147,20 +114,6 @@ 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 {
|
||||||
@@ -182,6 +135,24 @@ export default function CompetitionDetail() {
|
|||||||
return map[status] || status
|
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>
|
if (loading || !detail) return <View className='loading'>加载中...</View>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -232,7 +203,7 @@ export default function CompetitionDetail() {
|
|||||||
{activeTab === 1 && (
|
{activeTab === 1 && (
|
||||||
<View className='project-list'>
|
<View className='project-list'>
|
||||||
{projects.map(project => (
|
{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
|
<Image
|
||||||
className='cover'
|
className='cover'
|
||||||
mode='aspectFill'
|
mode='aspectFill'
|
||||||
@@ -247,14 +218,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>
|
||||||
))}
|
))}
|
||||||
{projects.length === 0 && <View className='empty'>暂无参赛项目</View>}
|
{projects.length === 0 && <View className='empty'>{getEmptyMessage(detail.project_visibility, enrollment)}</View>}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -264,7 +231,7 @@ export default function CompetitionDetail() {
|
|||||||
.filter(p => p.final_score > 0)
|
.filter(p => p.final_score > 0)
|
||||||
.sort((a, b) => b.final_score - a.final_score)
|
.sort((a, b) => b.final_score - a.final_score)
|
||||||
.map((project, index) => (
|
.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>
|
<Text className={`rank-num top${index + 1}`}>{index + 1}</Text>
|
||||||
<View className='info'>
|
<View className='info'>
|
||||||
<Image className='avatar' src={project.contestant_info?.avatar_url || ''} />
|
<Image className='avatar' src={project.contestant_info?.avatar_url || ''} />
|
||||||
@@ -295,7 +262,7 @@ export default function CompetitionDetail() {
|
|||||||
<Button
|
<Button
|
||||||
className='btn'
|
className='btn'
|
||||||
style={{ width: '80px', background: '#fff', color: '#333', border: '1px solid #ddd', fontSize: '12px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
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)}
|
onClick={() => Taro.navigateTo({ url: `/pages/competition/project-detail?id=${myProject.id}` })}
|
||||||
>
|
>
|
||||||
评语
|
评语
|
||||||
</Button>
|
</Button>
|
||||||
@@ -324,24 +291,6 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '项目详情'
|
||||||
|
})
|
||||||
158
miniprogram/src/pages/competition/project-detail.scss
Normal file
158
miniprogram/src/pages/competition/project-detail.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
miniprogram/src/pages/competition/project-detail.tsx
Normal file
158
miniprogram/src/pages/competition/project-detail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user