first commit
All checks were successful
Deploy to Server / deploy (push) Successful in 19s

This commit is contained in:
爽哒哒
2026-03-20 23:30:57 +08:00
commit 290be5d5be
328 changed files with 37215 additions and 0 deletions

View File

@@ -0,0 +1,378 @@
.comp-detail {
background-color: #000;
min-height: 100vh;
padding-bottom: 80px;
.banner {
width: 100%;
height: 300px;
display: block;
}
.content {
padding: 30px;
background: #111;
border-radius: 20px 20px 0 0;
margin-top: -24px;
position: relative;
z-index: 10;
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 30px;
.title {
font-size: 32px;
font-weight: bold;
color: #fff;
line-height: 1.4;
}
.status {
font-size: 16px;
padding: 6px 10px;
border-radius: 6px;
background: #333;
color: #ccc;
margin-left: 16px;
white-space: nowrap;
&.registration { background: #07c160; color: #fff; }
&.submission { background: #1890ff; color: #fff; }
&.judging { background: #faad14; color: #fff; }
&.ended { background: #ff4d4f; color: #fff; }
}
}
.tabs {
display: flex;
margin-bottom: 30px;
border-bottom: 1px solid #333;
.tab-item {
flex: 1;
text-align: center;
padding: 16px 0;
color: #999;
font-size: 18px;
position: relative;
&.active {
color: #fff;
font-weight: bold;
&:after {
content: '';
position: absolute;
bottom: -1px;
left: 50%;
transform: translateX(-50%);
width: 30px;
height: 4px;
background: #00b96b;
border-radius: 2px;
}
}
}
}
.project-list {
.project-card {
background: #1f1f1f;
border-radius: 16px;
overflow: hidden;
margin-bottom: 20px;
display: flex;
.cover {
width: 140px;
height: 105px;
background: #333;
flex-shrink: 0;
}
.info {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
.title {
font-size: 20px;
color: #fff;
font-weight: 500;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.author {
display: flex;
align-items: center;
justify-content: space-between;
.user {
display: flex;
align-items: center;
font-size: 14px;
color: #999;
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 8px;
background: #333;
}
}
.score {
color: #faad14;
font-weight: bold;
font-size: 16px;
}
}
}
}
.empty {
text-align: center;
color: #666;
padding: 50px 0;
font-size: 16px;
}
}
.ranking-list {
.rank-item {
display: flex;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid #222;
.rank-num {
width: 50px;
text-align: center;
font-size: 22px;
font-weight: bold;
color: #666;
&.top1 { color: #ffd700; }
&.top2 { color: #c0c0c0; }
&.top3 { color: #cd7f32; }
}
.info {
flex: 1;
display: flex;
align-items: center;
overflow: hidden;
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
margin-right: 16px;
background: #333;
flex-shrink: 0;
}
.detail {
flex: 1;
overflow: hidden;
.nickname {
color: #fff;
font-size: 18px;
margin-bottom: 6px;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-title {
color: #666;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.score {
font-size: 22px;
font-weight: bold;
color: #00b96b;
margin-left: 16px;
}
}
.empty {
text-align: center;
color: #666;
padding: 50px 0;
font-size: 16px;
}
}
.section {
margin-bottom: 40px;
.section-title {
font-size: 24px;
font-weight: bold;
color: #fff;
margin-bottom: 20px;
display: block;
border-left: 5px solid #00b96b;
padding-left: 16px;
}
/* Markdown styling borrowed from Forum */
font-size: 18px;
line-height: 1.8;
color: #e0e0e0;
letter-spacing: 0.3px;
image {
max-width: 100%;
border-radius: 12px;
margin: 20px 0;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
h1, h2, h3, h4, h5, h6 { margin-top: 30px; margin-bottom: 20px; color: #fff; font-weight: 700; line-height: 1.4; }
h1 { font-size: 32px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 16px; }
h2 { font-size: 28px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 12px; }
h3 { font-size: 24px; }
h4 { font-size: 20px; }
h5 { font-size: 18px; color: #ddd; }
p { margin-bottom: 20px; }
strong { font-weight: 800; color: #fff; }
em { font-style: italic; color: #aaa; }
del { text-decoration: line-through; color: #666; }
ul, ol { margin-bottom: 20px; padding-left: 24px; }
li { margin-bottom: 8px; list-style-position: outside; }
ul li { list-style-type: disc; }
ol li { list-style-type: decimal; }
li input[type="checkbox"] { margin-right: 12px; }
blockquote {
border-left: 5px solid #00b96b;
background: rgba(255, 255, 255, 0.05);
padding: 16px 20px;
margin: 20px 0;
border-radius: 6px;
color: #bbb;
font-size: 16px;
font-style: italic;
p { margin-bottom: 0; }
}
a { color: #00b96b; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
hr {
height: 1px;
background: rgba(255,255,255,0.1);
border: none;
margin: 30px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 16px;
overflow-x: auto;
display: block;
th, td {
border: 1px solid rgba(255,255,255,0.1);
padding: 12px;
text-align: left;
}
th {
background: rgba(255,255,255,0.05);
font-weight: 700;
color: #fff;
}
tr:nth-child(even) {
background: rgba(255,255,255,0.02);
}
}
code {
background: rgba(255,255,255,0.1);
padding: 4px 8px;
border-radius: 6px;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
color: #ff7875;
font-size: 16px;
margin: 0 6px;
}
pre {
background: #161616;
padding: 20px;
border-radius: 16px;
overflow-x: auto;
margin: 20px 0;
border: 1px solid #333;
box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
code {
background: transparent;
color: #a6e22e;
padding: 0;
font-size: 14px;
margin: 0;
white-space: pre;
}
}
}
}
.footer-action {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #1f1f1f;
padding: 20px 30px;
border-top: 1px solid #333;
z-index: 100;
.btn {
width: 100%;
height: 56px;
line-height: 56px;
border-radius: 28px;
font-size: 20px;
font-weight: bold;
color: #fff;
background: #00b96b;
border: none;
&.disabled {
background: #333;
color: #666;
}
&.enrolled {
background: #1890ff;
}
}
}
}

View File

@@ -0,0 +1,318 @@
import { View, Text, Button, Image, ScrollView } from '@tarojs/components'
import Taro, { useLoad, useDidShow, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
import { useState, useEffect } from 'react'
import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment, getProjects } from '../../api'
import MarkdownReader from '../../components/MarkdownReader'
import './detail.scss'
export default function CompetitionDetail() {
const [detail, setDetail] = useState<any>(null)
const [enrollment, setEnrollment] = useState<any>(null)
const [projects, setProjects] = useState<any[]>([])
const [myProject, setMyProject] = useState<any>(null)
const [activeTab, setActiveTab] = useState(0)
const [loading, setLoading] = useState(false)
useLoad((options) => {
const { id } = options
if (id) {
fetchDetail(id)
fetchEnrollment(id)
fetchProjects(id)
}
})
useDidShow(() => {
// 每次显示页面时刷新一下我的项目信息(比如从编辑页返回)
if (detail?.id) {
fetchMyProject(detail.id)
}
})
/**
* 配置并监听分享给朋友的功能
*/
useShareAppMessage(() => {
return {
title: detail?.title || '赛事详情',
path: `/pages/competition/detail?id=${detail?.id || ''}`,
imageUrl: detail?.display_cover_image || ''
}
})
/**
* 配置并监听分享到朋友圈的功能
*/
useShareTimeline(() => {
return {
title: detail?.title || '赛事详情',
query: `id=${detail?.id || ''}`,
imageUrl: detail?.display_cover_image || ''
}
})
const fetchDetail = async (id) => {
setLoading(true)
try {
const res = await getCompetitionDetail(id)
setDetail(res)
fetchMyProject(id)
} catch (e) {
Taro.showToast({ title: '加载详情失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const fetchMyProject = async (competitionId) => {
try {
const userInfo = Taro.getStorageSync('userInfo')
if (!userInfo) return
const res = await getProjects({ competition: competitionId })
const list = res.results || res
// 尝试通过 enrollment.id 匹配,或者通过 user nickname 匹配(不够严谨但暂时可用)
if (enrollment) {
const mine = list.find((p: any) => p.contestant === enrollment.id)
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)
}
}
const fetchEnrollment = async (id) => {
try {
const res = await getMyCompetitionEnrollment(id)
setEnrollment(res)
// 获取到 enrollment 后,去匹配 myProject
if (projects.length > 0) {
const mine = projects.find((p: any) => p.contestant === res.id)
setMyProject(mine)
}
} catch (e) {
// 没报名则无数据,忽略
}
}
const fetchProjects = async (id) => {
try {
// 注意:这里我们去掉了 status='submitted',因为我们要找自己的 draft
const res = await getProjects({ competition: id })
const list = res.results || res
const allProjects = Array.isArray(list) ? list : []
// 过滤出 submitted 的给列表显示
const submittedProjects = allProjects.filter(p => p.status === 'submitted')
setProjects(submittedProjects)
} catch (e) {
console.error('Fetch projects failed', e)
}
}
// 监听变化设置 myProject
useEffect(() => {
if (enrollment && projects.length >= 0) { // projects could be empty
fetchMySpecificProject(detail?.id, enrollment.id)
}
}, [enrollment])
const fetchMySpecificProject = async (compId, enrollId) => {
if (!compId || !enrollId) return
try {
const res = await getProjects({ competition: compId })
const list = res.results || res
const mine = list.find((p: any) => p.contestant === enrollId)
setMyProject(mine)
} catch (e) {}
}
const handleEnroll = async () => {
if (!detail) return
try {
await enrollCompetition(detail.id, { role: 'contestant' })
Taro.showToast({ title: '报名成功', icon: 'success' })
fetchEnrollment(detail.id)
} catch (e) {
Taro.showToast({ title: e.message || '报名失败', icon: 'none' })
}
}
const getStatusText = (status) => {
const map = {
'registration': '报名中',
'submission': '作品提交中',
'judging': '评审中',
'ended': '已结束',
}
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 (
<ScrollView scrollY className='comp-detail'>
<Image
className='banner'
mode='aspectFill'
src={detail.display_cover_image || 'https://via.placeholder.com/400x200'}
/>
<View className='content'>
<View className='header'>
<Text className='title'>{detail.title}</Text>
<Text className={`status ${detail.status}`}>{getStatusText(detail.status)}</Text>
</View>
<View className='tabs'>
{['详情', '参赛项目', '排行榜'].map((tab, index) => (
<View
key={index}
className={`tab-item ${activeTab === index ? 'active' : ''}`}
onClick={() => setActiveTab(index)}
>
{tab}
</View>
))}
</View>
{activeTab === 0 && (
<>
<View className='section'>
<Text className='section-title'></Text>
<MarkdownReader content={detail.description} />
</View>
<View className='section'>
<Text className='section-title'></Text>
<MarkdownReader content={detail.rule_description} />
</View>
<View className='section'>
<Text className='section-title'></Text>
<MarkdownReader content={detail.condition_description} />
</View>
</>
)}
{activeTab === 1 && (
<View className='project-list'>
{projects.map(project => (
<View className='project-card' key={project.id} onClick={() => Taro.navigateTo({ url: `/pages/competition/project-detail?id=${project.id}` })}>
<Image
className='cover'
mode='aspectFill'
src={project.display_cover_image || 'https://via.placeholder.com/120x90'}
/>
<View className='info'>
<Text className='title'>{project.title}</Text>
<View className='author'>
<View className='user'>
<Image className='avatar' src={project.contestant_info?.avatar_url || ''} />
<Text>{project.contestant_info?.nickname || '参赛者'}</Text>
</View>
{project.final_score > 0 && <Text className='score'>{project.final_score}</Text>}
</View>
</View>
</View>
))}
{projects.length === 0 && <View className='empty'>{getEmptyMessage(detail.project_visibility, enrollment)}</View>}
</View>
)}
{activeTab === 2 && (
<View className='ranking-list'>
{projects
.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} 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 || ''} />
<View className='detail'>
<Text className='nickname'>{project.contestant_info?.nickname || '参赛者'}</Text>
<Text className='project-title'>{project.title}</Text>
</View>
</View>
<Text className='score'>{project.final_score}</Text>
</View>
))}
{projects.filter(p => p.final_score > 0).length === 0 && <View className='empty'></View>}
</View>
)}
</View>
<View className='footer-action'>
{enrollment ? (
myProject ? (
<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
className='btn enrolled'
onClick={() => Taro.navigateTo({ url: `/pages/competition/project?competitionId=${detail.id}` })}
>
</Button>
) : (
<Button disabled className='btn enrolled'>
</Button>
)
)
) : (
<Button
className='btn enroll'
onClick={handleEnroll}
disabled={detail.status !== 'registration'}
>
{detail.status === 'registration' ? '立即报名' : '报名未开始/已结束'}
</Button>
)}
</View>
</ScrollView>
)
}

View File

@@ -0,0 +1,85 @@
.competition-page {
background-color: #000;
min-height: 100vh;
padding: 20px;
.comp-list {
.comp-card {
background: #1f1f1f;
border-radius: 12px;
margin-bottom: 24px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
.cover {
width: 100%;
height: 200px;
display: block;
}
.info {
padding: 16px;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.title {
font-size: 18px;
font-weight: bold;
color: #fff;
flex: 1;
margin-right: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status {
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
background: #333;
color: #ccc;
&.registration { background: #07c160; color: #fff; }
&.submission { background: #1890ff; color: #fff; }
&.judging { background: #faad14; color: #fff; }
&.ended { background: #ff4d4f; color: #fff; }
}
}
.desc {
font-size: 14px;
color: #999;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 12px;
}
.footer {
border-top: 1px solid #333;
padding-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
.time {
font-size: 12px;
color: #666;
}
}
}
}
}
.empty {
text-align: center;
color: #666;
margin-top: 40px;
}
}

View File

@@ -0,0 +1,110 @@
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro, { useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
import { useState } from 'react'
import { getCompetitions } from '../../api'
import './index.scss'
export default function CompetitionList() {
const [competitions, setCompetitions] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [debugMsg, setDebugMsg] = useState('')
useLoad(() => {
fetchCompetitions()
})
/**
* 配置并监听分享给朋友的功能
*/
useShareAppMessage(() => {
return {
title: '赛事中心',
path: '/pages/competition/index'
}
})
/**
* 配置并监听分享到朋友圈的功能
*/
useShareTimeline(() => {
return {
title: '赛事中心',
query: ''
}
})
const fetchCompetitions = async () => {
setLoading(true)
setDebugMsg('开始加载...')
try {
console.log('Fetching competitions...')
const res = await getCompetitions()
console.log('Competitions res:', res)
setDebugMsg(`请求成功: 数量 ${res?.results?.length}`)
if (res && res.results) {
setCompetitions(res.results)
} else {
setDebugMsg(`数据格式异常: ${JSON.stringify(res)}`)
}
} catch (e) {
console.error('Fetch failed:', e)
setDebugMsg(`请求失败: ${e.errMsg || JSON.stringify(e)}`)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const goDetail = (id) => {
Taro.navigateTo({ url: `/pages/competition/detail?id=${id}` })
}
const getStatusText = (status) => {
const map = {
'published': '即将开始',
'registration': '报名中',
'submission': '作品提交中',
'judging': '评审中',
'ended': '已结束',
'draft': '草稿'
}
return map[status] || status
}
return (
<View className='competition-page'>
<ScrollView scrollY className='comp-list'>
{competitions.map(item => (
<View key={item.id} className='comp-card' onClick={() => goDetail(item.id)}>
<Image
className='cover'
mode='aspectFill'
src={item.display_cover_image || 'https://via.placeholder.com/400x200'}
/>
<View className='info'>
<View className='header'>
<Text className='title'>{item.title}</Text>
<Text className={`status ${item.status}`}>{getStatusText(item.status)}</Text>
</View>
<Text className='desc'>{item.description}</Text>
<View className='footer'>
<Text className='time'>
{item.start_time?.split('T')[0]} ~ {item.end_time?.split('T')[0]}
</Text>
</View>
</View>
</View>
))}
{!loading && competitions.length === 0 && (
<View className='empty'>
<Text></Text>
<View style={{ marginTop: 20, color: '#666', fontSize: 12, wordBreak: 'break-all', padding: 20 }}>
: {debugMsg}
</View>
</View>
)}
</ScrollView>
</View>
)
}

View File

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

View File

@@ -0,0 +1,288 @@
.project-detail {
background-color: #000;
min-height: 100vh;
padding-bottom: 60px;
box-sizing: border-box;
.cover {
width: 100%;
height: 260px;
display: block;
}
.content {
padding: 30px;
background: #111;
border-radius: 24px 24px 0 0;
margin-top: -30px;
position: relative;
z-index: 10;
min-height: 60vh;
.header {
margin-bottom: 40px;
.title {
font-size: 36px;
font-weight: bold;
color: #fff;
margin-bottom: 24px;
line-height: 1.4;
display: block;
}
.author {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.08);
padding: 12px 20px;
border-radius: 30px;
display: inline-flex;
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
margin-right: 12px;
background: #333;
}
.name {
font-size: 18px;
color: #ddd;
}
}
}
.section {
margin-bottom: 50px;
.section-title {
font-size: 28px;
font-weight: bold;
color: #fff;
margin-bottom: 24px;
display: block;
border-left: 6px solid #00b96b;
padding-left: 18px;
}
.text-content {
font-size: 20px;
color: #ccc;
line-height: 1.8;
background: #1f1f1f;
padding: 24px;
border-radius: 20px;
/* Markdown Styles */
h1, h2, h3, h4, h5, h6 { margin-top: 40px; margin-bottom: 24px; color: #fff; font-weight: 700; line-height: 1.4; }
h1 { font-size: 34px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 16px; }
h2 { font-size: 30px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 12px; }
h3 { font-size: 26px; }
h4 { font-size: 24px; }
h5 { font-size: 22px; color: #ddd; }
p { margin-bottom: 24px; }
strong { font-weight: 800; color: #fff; }
em { font-style: italic; color: #aaa; }
del { text-decoration: line-through; color: #666; }
ul, ol { margin-bottom: 24px; padding-left: 28px; }
li { margin-bottom: 10px; list-style-position: outside; }
ul li { list-style-type: disc; }
ol li { list-style-type: decimal; }
li input[type="checkbox"] { margin-right: 12px; transform: scale(1.2); }
blockquote {
border-left: 6px solid #00b96b;
background: rgba(255, 255, 255, 0.05);
padding: 20px 24px;
margin: 24px 0;
border-radius: 8px;
color: #bbb;
font-size: 18px;
font-style: italic;
p { margin-bottom: 0; }
}
a { color: #00b96b; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
hr {
height: 1px;
background: rgba(255,255,255,0.1);
border: none;
margin: 30px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 24px 0;
font-size: 18px;
overflow-x: auto;
display: block;
th, td {
border: 1px solid rgba(255,255,255,0.1);
padding: 14px;
text-align: left;
}
th {
background: rgba(255,255,255,0.05);
font-weight: 700;
color: #fff;
}
tr:nth-child(even) {
background: rgba(255,255,255,0.02);
}
}
code {
background: rgba(255,255,255,0.1);
padding: 4px 8px;
border-radius: 6px;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
color: #ff7875;
font-size: 18px;
margin: 0 6px;
}
pre {
background: #161616;
padding: 24px;
border-radius: 16px;
overflow-x: auto;
margin: 24px 0;
border: 1px solid #333;
box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
code {
background: transparent;
color: #a6e22e;
padding: 0;
font-size: 16px;
margin: 0;
white-space: pre;
}
}
image {
max-width: 100%;
border-radius: 16px;
margin: 24px 0;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
}
.empty {
font-size: 18px;
color: #666;
text-align: center;
display: block;
padding: 40px 0;
background: #1f1f1f;
border-radius: 16px;
}
.file-list {
background: #1f1f1f;
border-radius: 20px;
overflow: hidden;
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
border-bottom: 1px solid #333;
&:last-child {
border-bottom: none;
}
.file-name {
font-size: 18px;
color: #ddd;
flex: 1;
margin-right: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-action {
font-size: 16px;
color: #00b96b;
padding: 8px 20px;
border: 1px solid #00b96b;
border-radius: 20px;
}
}
}
.comment-list {
.comment-item {
background: #1f1f1f;
border-radius: 20px;
padding: 24px;
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.judge-info {
display: flex;
align-items: baseline;
.judge-name {
font-size: 16px;
font-weight: bold;
color: #00b96b;
margin-right: 8px;
}
.judge-score-box {
display: flex;
align-items: baseline;
.score-num {
font-size: 24px;
font-weight: bold;
color: #fff;
line-height: 1;
margin-right: 2px;
}
.score-unit {
font-size: 14px;
color: #999;
font-weight: normal;
}
}
}
.comment-time {
font-size: 12px;
color: #666;
}
}
.comment-content {
font-size: 20px;
color: #ccc;
line-height: 1.6;
display: block;
text-align: justify;
}
}
}
}
}
}

View File

@@ -0,0 +1,191 @@
import { View, Text, Image, Button, ScrollView } from '@tarojs/components'
import Taro, { useLoad, useShareAppMessage, useShareTimeline, useRouter } 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)
const router = useRouter()
useLoad((options) => {
const { id } = options
if (id) {
fetchProject(id)
fetchComments(id)
}
})
/**
* 配置并监听分享给朋友的功能
*/
useShareAppMessage(() => {
const id = project?.id || router.params.id || ''
return {
title: project?.title || '项目详情',
path: `/pages/competition/project-detail?id=${id}`,
imageUrl: project?.display_cover_image || project?.cover_image_url || ''
}
})
/**
* 配置并监听分享到朋友圈的功能
*/
useShareTimeline(() => {
const id = project?.id || router.params.id || ''
return {
title: project?.title || '项目详情',
query: `id=${id}`,
imageUrl: project?.display_cover_image || project?.cover_image_url || ''
}
})
/**
* 获取项目详情
* @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'>
<View className='judge-info'>
<Text className='judge-name'>{c.judge_name || '评委'}</Text>
{c.score && (
<View className='judge-score-box'>
<Text className='score-num'>{c.score}</Text>
<Text className='score-unit'></Text>
</View>
)}
</View>
<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

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '参赛作品'
})

View File

@@ -0,0 +1,93 @@
.project-edit {
padding: 24px;
background: #000;
min-height: 100vh;
color: #fff;
padding-bottom: 100px;
.form-item {
margin-bottom: 24px;
.label {
font-size: 16px;
font-weight: bold;
margin-bottom: 12px;
display: block;
color: #ccc;
}
.input, .textarea, .picker {
background: #1f1f1f;
border-radius: 8px;
padding: 12px;
color: #fff;
font-size: 16px;
width: 100%;
box-sizing: border-box;
}
.textarea {
height: 200px;
&.small {
height: 100px;
}
}
.upload-box {
width: 100%;
height: 200px;
background: #1f1f1f;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px dashed #333;
.preview {
width: 100%;
height: 100%;
}
.placeholder {
color: #666;
font-size: 14px;
}
}
}
.footer-btns {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px 24px;
background: #1f1f1f;
display: flex;
justify-content: space-between;
z-index: 100;
border-top: 1px solid #333;
.btn {
flex: 1;
height: 48px;
line-height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: bold;
border: none;
margin: 0 8px;
&.save {
background: #333;
color: #fff;
}
&.submit {
background: #00b96b;
color: #fff;
}
}
}
}

View File

@@ -0,0 +1,279 @@
import { View, Text, Button, Image, Input, Textarea, Picker } from '@tarojs/components'
import Taro, { useLoad, useShareAppMessage, useShareTimeline, useRouter } from '@tarojs/taro'
import { useState } from 'react'
import { getProjectDetail, createProject, updateProject, uploadProjectFile, submitProject, uploadMedia, getCompetitions } from '../../api'
import './project.scss'
export default function ProjectEdit() {
const [project, setProject] = useState<any>({
title: '',
description: '',
team_info: '',
files: []
})
const [competitionId, setCompetitionId] = useState<string>('')
const [competitions, setCompetitions] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [isEdit, setIsEdit] = useState(false)
const router = useRouter()
useLoad((options) => {
fetchCompetitions()
const { id, competitionId } = options
if (id) {
setIsEdit(true)
fetchProject(id)
} else if (competitionId) {
setCompetitionId(competitionId)
}
})
/**
* 配置并监听分享给朋友的功能
*/
useShareAppMessage(() => {
const id = project?.id || router.params.id || ''
const compId = competitionId || router.params.competitionId || ''
return {
title: project?.title || '提交作品',
path: `/pages/competition/project?id=${id}&competitionId=${compId}`,
imageUrl: project?.cover_image_url || project?.display_cover_image || ''
}
})
/**
* 配置并监听分享到朋友圈的功能
*/
useShareTimeline(() => {
const id = project?.id || router.params.id || ''
const compId = competitionId || router.params.competitionId || ''
return {
title: project?.title || '提交作品',
query: `id=${id}&competitionId=${compId}`,
imageUrl: project?.cover_image_url || project?.display_cover_image || ''
}
})
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 {
const res = await getProjectDetail(id)
setProject(res)
setCompetitionId(res.competition)
} catch (e) {
Taro.showToast({ title: '加载项目失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const handleInput = (key, value) => {
setProject(prev => ({ ...prev, [key]: value }))
}
const handleUploadCover = async () => {
try {
const { tempFilePaths } = await Taro.chooseImage({ count: 1 })
if (!tempFilePaths.length) return
Taro.showLoading({ title: '上传中...' })
const res = await uploadMedia(tempFilePaths[0], 'image')
handleInput('cover_image_url', res.file) // 假设返回 { file: 'url...' }
Taro.hideLoading()
} catch (e) {
Taro.hideLoading()
Taro.showToast({ title: '上传失败', icon: 'none' })
}
}
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' })
return
}
setLoading(true)
try {
const data = {
competition: competitionId,
title: project.title,
description: project.description,
team_info: project.team_info,
cover_image_url: project.cover_image_url
}
let res
if (isEdit) {
res = await updateProject(project.id, data)
} else {
res = await createProject(data)
}
if (submit) {
await submitProject(res.id)
Taro.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1500)
} else {
Taro.showToast({ title: '保存成功', icon: 'success' })
if (!isEdit) {
// 创建变编辑
setIsEdit(true)
setProject(res)
}
}
} catch (e) {
Taro.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
setLoading(false)
}
}
if (loading && !project.id && isEdit) return <View className='loading'>...</View>
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
className='input'
placeholder='请输入项目标题'
value={project.title}
onInput={e => handleInput('title', e.detail.value)}
/>
</View>
<View className='form-item'>
<Text className='label'></Text>
<View className='upload-box' onClick={handleUploadCover}>
{project.cover_image_url || project.display_cover_image ? (
<Image
className='preview'
mode='aspectFill'
src={project.cover_image_url || project.display_cover_image}
/>
) : (
<Text className='placeholder'></Text>
)}
</View>
</View>
<View className='form-item'>
<Text className='label'></Text>
<Textarea
className='textarea'
placeholder='请输入项目详细介绍'
value={project.description}
onInput={e => handleInput('description', e.detail.value)}
maxlength={2000}
/>
</View>
<View className='form-item'>
<Text className='label'></Text>
<Textarea
className='textarea small'
placeholder='请输入团队成员信息'
value={project.team_info}
onInput={e => handleInput('team_info', e.detail.value)}
/>
</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>
<Button className='btn submit' onClick={() => handleSave(true)}></Button>
</View>
</View>
)
}