比赛
All checks were successful
Deploy to Server / deploy (push) Successful in 28s

This commit is contained in:
jeremygan2021
2026-03-10 14:25:04 +08:00
parent 03297f3d07
commit 6361b7a522
12 changed files with 414 additions and 108 deletions

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

@@ -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<any>(null)
const [activeTab, setActiveTab] = useState(0)
const [loading, setLoading] = useState(false)
const [showComments, setShowComments] = useState(false)
const [comments, setComments] = useState<any[]>([])
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 <View className='loading'>...</View>
return (
@@ -232,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'
@@ -247,14 +218,10 @@ export default function CompetitionDetail() {
</View>
{project.final_score > 0 && <Text className='score'>{project.final_score}</Text>}
</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>
))}
{projects.length === 0 && <View className='empty'></View>}
{projects.length === 0 && <View className='empty'>{getEmptyMessage(detail.project_visibility, enrollment)}</View>}
</View>
)}
@@ -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) => (
<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 || ''} />
@@ -295,7 +262,7 @@ export default function CompetitionDetail() {
<Button
className='btn'
style={{ width: '80px', background: '#fff', color: '#333', border: '1px solid #ddd', fontSize: '12px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => fetchComments(myProject.id)}
onClick={() => Taro.navigateTo({ url: `/pages/competition/project-detail?id=${myProject.id}` })}
>
</Button>
@@ -324,24 +291,6 @@ export default function CompetitionDetail() {
</Button>
)}
</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>
)
}

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>
)
}