From af763b1beed8755d59ff3b8392662dbc2904e4c1 Mon Sep 17 00:00:00 2001 From: jeremygan2021 Date: Tue, 10 Mar 2026 13:32:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AF=94=E8=B5=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/competition/serializers.py | 3 +- backend/competition/views.py | 11 + .../competition/CompetitionDetail.jsx | 36 ++- miniprogram/src/api/index.ts | 1 + miniprogram/src/pages/competition/detail.scss | 284 +++++++++++++++++- miniprogram/src/pages/competition/detail.tsx | 104 ++++++- miniprogram/src/pages/user/index.tsx | 39 ++- 7 files changed, 447 insertions(+), 31 deletions(-) diff --git a/backend/competition/serializers.py b/backend/competition/serializers.py index b0411ec..0ca91cf 100644 --- a/backend/competition/serializers.py +++ b/backend/competition/serializers.py @@ -10,12 +10,13 @@ class ScoreDimensionSerializer(serializers.ModelSerializer): class CompetitionSerializer(serializers.ModelSerializer): score_dimensions = ScoreDimensionSerializer(many=True, read_only=True) display_cover_image = serializers.SerializerMethodField() + status_display = serializers.CharField(source='get_status_display', read_only=True) class Meta: model = Competition fields = ['id', 'title', 'description', 'rule_description', 'condition_description', 'cover_image', 'cover_image_url', 'display_cover_image', - 'start_time', 'end_time', 'status', 'is_active', + 'start_time', 'end_time', 'status', 'status_display', 'is_active', 'score_dimensions', 'created_at'] def get_display_cover_image(self, obj): diff --git a/backend/competition/views.py b/backend/competition/views.py index 65f941b..d01cedd 100644 --- a/backend/competition/views.py +++ b/backend/competition/views.py @@ -82,6 +82,17 @@ class CompetitionViewSet(viewsets.ReadOnlyModelViewSet): except CompetitionEnrollment.DoesNotExist: return Response({"detail": "未报名"}, status=status.HTTP_404_NOT_FOUND) + @action(detail=False, methods=['get']) + def my_enrollments(self, request): + """ + 获取我的所有报名信息 + """ + user = get_current_wechat_user(request) + if not user: + return Response([]) + enrollments = CompetitionEnrollment.objects.filter(user=user) + return Response(CompetitionEnrollmentSerializer(enrollments, many=True).data) + class ProjectViewSet(viewsets.ModelViewSet): """ diff --git a/frontend/src/components/competition/CompetitionDetail.jsx b/frontend/src/components/competition/CompetitionDetail.jsx index 1abc7a7..8f2d251 100644 --- a/frontend/src/components/competition/CompetitionDetail.jsx +++ b/frontend/src/components/competition/CompetitionDetail.jsx @@ -12,6 +12,16 @@ import dayjs from 'dayjs'; import { getCompetitionDetail, getProjects, getMyCompetitionEnrollment, enrollCompetition } from '../../api'; import ProjectSubmission from './ProjectSubmission'; import { useAuth } from '../../context/AuthContext'; +import 'github-markdown-css/github-markdown-dark.css'; + +const getImageUrl = (url) => { + if (!url) return ''; + if (url.startsWith('http') || url.startsWith('//')) return url; + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'; + // Remove /api suffix if present to get the root URL for media files + const baseUrl = apiUrl.replace(/\/api\/?$/, ''); + return `${baseUrl}${url}`; +}; const { Title, Paragraph } = Typography; @@ -77,19 +87,19 @@ const CompetitionDetail = () => { // Fetch competition details const { data: competition, isLoading: loadingDetail } = useQuery({ queryKey: ['competition', id], - queryFn: () => getCompetitionDetail(id) + queryFn: () => getCompetitionDetail(id).then(res => res.data) }); // Fetch projects (for leaderboard/display) const { data: projects } = useQuery({ queryKey: ['projects', id], - queryFn: () => getProjects({ competition: id, status: 'submitted' }) + queryFn: () => getProjects({ competition: id, status: 'submitted' }).then(res => res.data) }); // Check enrollment status const { data: enrollment, refetch: refetchEnrollment } = useQuery({ queryKey: ['enrollment', id], - queryFn: () => getMyCompetitionEnrollment(id), + queryFn: () => getMyCompetitionEnrollment(id).then(res => res.data), enabled: !!user, retry: false }); @@ -140,7 +150,7 @@ const CompetitionDetail = () => { rehypePlugins={[rehypeRaw]} components={{ code: CodeBlock, - img: (props) => , + img: (props) => , h1: (props) =>

, h2: (props) =>

, h3: (props) =>

, @@ -162,7 +172,7 @@ const CompetitionDetail = () => { rehypePlugins={[rehypeRaw]} components={{ code: CodeBlock, - img: (props) => , + img: (props) => , h1: (props) =>

, h2: (props) =>

, h3: (props) =>

, @@ -184,7 +194,7 @@ const CompetitionDetail = () => { rehypePlugins={[rehypeRaw]} components={{ code: CodeBlock, - img: (props) => , + img: (props) => , h1: (props) =>

, h2: (props) =>

, h3: (props) =>

, @@ -210,14 +220,18 @@ const CompetitionDetail = () => { } + cover={{project.title}} actions={[ ]} > } /> @@ -245,8 +259,8 @@ const CompetitionDetail = () => {
{project.contestant_info?.nickname}
- {project.final_score} -
+ {enrollment && project.contestant === enrollment.id ? project.final_score : '**'} + ))} @@ -258,7 +272,7 @@ const CompetitionDetail = () => {
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 }] : []) ] }, {