This commit is contained in:
378
miniprogram/src/pages/competition/detail.scss
Normal file
378
miniprogram/src/pages/competition/detail.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
318
miniprogram/src/pages/competition/detail.tsx
Normal file
318
miniprogram/src/pages/competition/detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
miniprogram/src/pages/competition/index.scss
Normal file
85
miniprogram/src/pages/competition/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
110
miniprogram/src/pages/competition/index.tsx
Normal file
110
miniprogram/src/pages/competition/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '项目详情'
|
||||
})
|
||||
288
miniprogram/src/pages/competition/project-detail.scss
Normal file
288
miniprogram/src/pages/competition/project-detail.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
miniprogram/src/pages/competition/project-detail.tsx
Normal file
191
miniprogram/src/pages/competition/project-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/competition/project.config.ts
Normal file
3
miniprogram/src/pages/competition/project.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '参赛作品'
|
||||
})
|
||||
93
miniprogram/src/pages/competition/project.scss
Normal file
93
miniprogram/src/pages/competition/project.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
279
miniprogram/src/pages/competition/project.tsx
Normal file
279
miniprogram/src/pages/competition/project.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user