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

This commit is contained in:
jeremygan2021
2026-03-10 13:32:04 +08:00
parent 3ada996915
commit af763b1bee
7 changed files with 447 additions and 31 deletions

View File

@@ -73,6 +73,7 @@ export const getCompetitions = (params?: any) => request({ url: '/competition/co
export const getCompetitionDetail = (id: number) => request({ url: `/competition/competitions/${id}/` })
export const enrollCompetition = (id: number, data: any) => request({ url: `/competition/competitions/${id}/enroll/`, method: 'POST', data })
export const getMyCompetitionEnrollment = (id: number) => request({ url: `/competition/competitions/${id}/my_enrollment/` })
export const getMyEnrollments = () => request({ url: '/competition/competitions/my_enrollments/' })
export const getProjects = (params?: any) => request({ url: '/competition/projects/', data: params })
export const getProjectDetail = (id: number) => request({ url: `/competition/projects/${id}/` })
export const createProject = (data: any) => request({ url: '/competition/projects/', method: 'POST', data })

View File

@@ -46,6 +46,178 @@
}
}
.tabs {
display: flex;
margin-bottom: 24px;
border-bottom: 1px solid #333;
.tab-item {
flex: 1;
text-align: center;
padding: 12px 0;
color: #999;
font-size: 16px;
position: relative;
&.active {
color: #fff;
font-weight: bold;
&:after {
content: '';
position: absolute;
bottom: -1px;
left: 50%;
transform: translateX(-50%);
width: 24px;
height: 3px;
background: #00b96b;
border-radius: 2px;
}
}
}
}
.project-list {
.project-card {
background: #1f1f1f;
border-radius: 12px;
overflow: hidden;
margin-bottom: 16px;
display: flex;
.cover {
width: 120px;
height: 90px;
background: #333;
flex-shrink: 0;
}
.info {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
.title {
font-size: 16px;
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: 12px;
color: #999;
.avatar {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 6px;
background: #333;
}
}
.score {
color: #faad14;
font-weight: bold;
font-size: 14px;
}
}
}
}
.empty {
text-align: center;
color: #666;
padding: 40px 0;
}
}
.ranking-list {
.rank-item {
display: flex;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #222;
.rank-num {
width: 40px;
text-align: center;
font-size: 18px;
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: 40px;
height: 40px;
border-radius: 50%;
margin-right: 12px;
background: #333;
flex-shrink: 0;
}
.detail {
flex: 1;
overflow: hidden;
.nickname {
color: #fff;
font-size: 16px;
margin-bottom: 4px;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-title {
color: #666;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.score {
font-size: 18px;
font-weight: bold;
color: #00b96b;
margin-left: 12px;
}
}
.empty {
text-align: center;
color: #666;
padding: 40px 0;
}
}
.section {
margin-bottom: 32px;
@@ -59,11 +231,113 @@
padding-left: 12px;
}
rich-text {
font-size: 16px;
color: #ccc;
line-height: 1.6;
white-space: pre-wrap;
/* Markdown styling borrowed from Forum */
font-size: 16px;
line-height: 1.8;
color: #e0e0e0;
letter-spacing: 0.3px;
image {
max-width: 100%;
border-radius: 12px;
margin: 16px 0;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
h1, h2, h3, h4, h5, h6 { margin-top: 24px; margin-bottom: 16px; color: #fff; font-weight: 700; line-height: 1.4; }
h1 { font-size: 24px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px; }
h2 { font-size: 20px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 8px; }
h3 { font-size: 18px; }
h4 { font-size: 17px; }
h5 { font-size: 16px; color: #ddd; }
p { margin-bottom: 16px; }
strong { font-weight: 800; color: #fff; }
em { font-style: italic; color: #aaa; }
del { text-decoration: line-through; color: #666; }
ul, ol { margin-bottom: 16px; padding-left: 20px; }
li { margin-bottom: 6px; list-style-position: outside; }
ul li { list-style-type: disc; }
ol li { list-style-type: decimal; }
li input[type="checkbox"] { margin-right: 8px; }
blockquote {
border-left: 4px solid #00b96b;
background: rgba(255, 255, 255, 0.05);
padding: 12px 16px;
margin: 16px 0;
border-radius: 4px;
color: #bbb;
font-size: 15px;
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: 24px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 14px;
overflow-x: auto;
display: block;
th, td {
border: 1px solid rgba(255,255,255,0.1);
padding: 10px;
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: 2px 6px;
border-radius: 4px;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
color: #ff7875;
font-size: 14px;
margin: 0 4px;
}
pre {
background: #161616;
padding: 16px;
border-radius: 12px;
overflow-x: auto;
margin: 16px 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: 13px;
margin: 0;
white-space: pre;
}
}
}
}

View File

@@ -1,12 +1,15 @@
import { View, Text, Button, Image, ScrollView, RichText } from '@tarojs/components'
import { View, Text, Button, Image, ScrollView } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment } from '../../api'
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 [activeTab, setActiveTab] = useState(0)
const [loading, setLoading] = useState(false)
useLoad((options) => {
@@ -14,6 +17,7 @@ export default function CompetitionDetail() {
if (id) {
fetchDetail(id)
fetchEnrollment(id)
fetchProjects(id)
}
})
@@ -38,6 +42,17 @@ export default function CompetitionDetail() {
}
}
const fetchProjects = async (id) => {
try {
const res = await getProjects({ competition: id, status: 'submitted' })
// 如果后端返回了分页结果 { results: [], ... },则取 results否则直接取 res
const list = res.results || res
setProjects(Array.isArray(list) ? list : [])
} catch (e) {
console.error('Fetch projects failed', e)
}
}
const handleEnroll = async () => {
if (!detail) return
try {
@@ -75,20 +90,83 @@ export default function CompetitionDetail() {
<Text className={`status ${detail.status}`}>{getStatusText(detail.status)}</Text>
</View>
<View className='section'>
<Text className='section-title'></Text>
<RichText nodes={detail.description} />
<View className='tabs'>
{['详情', '参赛项目', '排行榜'].map((tab, index) => (
<View
key={index}
className={`tab-item ${activeTab === index ? 'active' : ''}`}
onClick={() => setActiveTab(index)}
>
{tab}
</View>
))}
</View>
<View className='section'>
<Text className='section-title'></Text>
<RichText nodes={detail.rule_description} />
</View>
{activeTab === 0 && (
<>
<View className='section'>
<Text className='section-title'></Text>
<MarkdownReader content={detail.description} />
</View>
<View className='section'>
<Text className='section-title'></Text>
<RichText nodes={detail.condition_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}>
<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'></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}>
<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'>

View File

@@ -2,6 +2,7 @@ import { View, Text, Image, Button, Checkbox, CheckboxGroup, RichText } from '@t
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'
import { useState } from 'react'
import { login as silentLogin } from '../../utils/request'
import { getMyEnrollments } from '../../api'
import './index.scss'
export default function UserIndex() {
@@ -9,10 +10,14 @@ export default function UserIndex() {
const [showLoginModal, setShowLoginModal] = useState(false)
const [isAgreed, setIsAgreed] = useState(false)
const [showAgreement, setShowAgreement] = useState(false) // For showing agreement content
const [myEnrollments, setMyEnrollments] = useState<any[]>([])
useDidShow(() => {
const info = Taro.getStorageSync('userInfo')
if (info) setUserInfo(info)
if (info) {
setUserInfo(info)
fetchEnrollments()
}
})
usePullDownRefresh(async () => {
@@ -20,6 +25,7 @@ export default function UserIndex() {
const res = await silentLogin()
if (res) {
setUserInfo(res)
fetchEnrollments()
}
Taro.stopPullDownRefresh()
} catch (e) {
@@ -28,6 +34,17 @@ export default function UserIndex() {
}
})
const fetchEnrollments = async () => {
try {
const res = await getMyEnrollments()
if (Array.isArray(res)) {
setMyEnrollments(res)
}
} catch (e) {
console.error('Fetch enrollments failed', e)
}
}
const goOrders = () => Taro.navigateTo({ url: '/pages/order/list' })
const goDistributor = () => Taro.navigateTo({ url: '/subpackages/distributor/index' })
const goInvite = () => Taro.navigateTo({ url: '/subpackages/distributor/invite' })
@@ -36,6 +53,18 @@ export default function UserIndex() {
const goActivityList = (tab = 'all') => Taro.navigateTo({ url: `/subpackages/forum/activity/index?tab=${tab}` })
const goCompetitionList = () => Taro.navigateTo({ url: '/pages/competition/index' })
const goUploadProject = () => {
// 找到所有有效的选手报名
const contestantEnrollments = myEnrollments.filter(e => e.role === 'contestant')
if (contestantEnrollments.length === 1) {
// 如果只有一个,直接去详情页
Taro.navigateTo({ url: `/pages/competition/detail?id=${contestantEnrollments[0].competition}` })
} else {
// 否则去列表页
Taro.navigateTo({ url: '/pages/competition/index' })
}
}
const handleAddress = async () => {
try {
const res = await Taro.chooseAddress()
@@ -254,6 +283,8 @@ export default function UserIndex() {
}
}
const isContestant = myEnrollments.some(e => e.role === 'contestant')
const serviceGroups = [
{
title: '基础服务',
@@ -261,7 +292,13 @@ export default function UserIndex() {
{ title: '我的订单', icon: '📦', action: goOrders },
{ title: '地址管理', icon: '📝', action: handleAddress },
{ title: '活动管理', icon: '⌚️', action: () => goActivityList('mine') },
]
},
{
title: '比赛服务',
items: [
{ title: '赛事中心', icon: '🏆', action: goCompetitionList },
...(isContestant ? [{ title: '上传比赛资料', icon: '📤', action: goUploadProject }] : [])
]
},
{