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 })
diff --git a/miniprogram/src/pages/competition/detail.scss b/miniprogram/src/pages/competition/detail.scss
index 29c4d22..9f423c2 100644
--- a/miniprogram/src/pages/competition/detail.scss
+++ b/miniprogram/src/pages/competition/detail.scss
@@ -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;
+ }
}
}
}
diff --git a/miniprogram/src/pages/competition/detail.tsx b/miniprogram/src/pages/competition/detail.tsx
index 8c3b083..c8f5fb9 100644
--- a/miniprogram/src/pages/competition/detail.tsx
+++ b/miniprogram/src/pages/competition/detail.tsx
@@ -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
(null)
const [enrollment, setEnrollment] = useState(null)
+ const [projects, setProjects] = useState([])
+ 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() {
{getStatusText(detail.status)}
-
- 简介
-
+
+ {['详情', '参赛项目', '排行榜'].map((tab, index) => (
+ setActiveTab(index)}
+ >
+ {tab}
+
+ ))}
-
- 规则
-
-
+ {activeTab === 0 && (
+ <>
+
+ 简介
+
+
-
- 参赛条件
-
-
+
+ 规则
+
+
+
+
+ 参赛条件
+
+
+ >
+ )}
+
+ {activeTab === 1 && (
+
+ {projects.map(project => (
+
+
+
+ {project.title}
+
+
+
+ {project.contestant_info?.nickname || '参赛者'}
+
+ {project.final_score > 0 && {project.final_score}分}
+
+
+
+ ))}
+ {projects.length === 0 && 暂无参赛项目}
+
+ )}
+
+ {activeTab === 2 && (
+
+ {projects
+ .filter(p => p.final_score > 0)
+ .sort((a, b) => b.final_score - a.final_score)
+ .map((project, index) => (
+
+ {index + 1}
+
+
+
+ {project.contestant_info?.nickname || '参赛者'}
+ {project.title}
+
+
+ {project.final_score}
+
+ ))}
+ {projects.filter(p => p.final_score > 0).length === 0 && 暂无排名数据}
+
+ )}
diff --git a/miniprogram/src/pages/user/index.tsx b/miniprogram/src/pages/user/index.tsx
index b05d22d..a63f87f 100644
--- a/miniprogram/src/pages/user/index.tsx
+++ b/miniprogram/src/pages/user/index.tsx
@@ -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([])
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 }] : [])
]
},
{