This commit is contained in:
@@ -10,6 +10,13 @@ from .serializers import (
|
||||
ScoreSerializer, CommentSerializer, ScoreDimensionSerializer
|
||||
)
|
||||
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 100
|
||||
|
||||
class CompetitionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
比赛视图集
|
||||
@@ -17,9 +24,23 @@ class CompetitionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = Competition.objects.filter(is_active=True).order_by('-created_at')
|
||||
serializer_class = CompetitionSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
pagination_class = StandardResultsSetPagination
|
||||
filter_backends = [filters.SearchFilter]
|
||||
search_fields = ['title', 'description']
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
获取比赛查询集,支持根据查询参数进行动态过滤
|
||||
"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# 状态过滤
|
||||
status_param = self.request.query_params.get('status')
|
||||
if status_param and status_param != 'all':
|
||||
queryset = queryset.filter(status=status_param)
|
||||
|
||||
return queryset
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def enroll(self, request, pk=None):
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,6 @@ import { CalendarOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -65,9 +64,16 @@ const CompetitionCard = ({ competition }) => {
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
marginBottom: 0
|
||||
marginBottom: 0,
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'text', 'strong', 'em', 'del']}
|
||||
unwrapDisallowed={true}
|
||||
components={{
|
||||
p: (props) => <span {...props} />,
|
||||
}}
|
||||
>
|
||||
{competition.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -134,33 +134,66 @@ const CompetitionDetail = () => {
|
||||
</Descriptions>
|
||||
|
||||
<Title level={4} style={{ marginTop: 32, color: '#fff' }}>比赛简介</Title>
|
||||
<div style={{ color: '#ccc', lineHeight: '1.8' }}>
|
||||
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: '16px' }} className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{ code: CodeBlock }}
|
||||
components={{
|
||||
code: CodeBlock,
|
||||
img: (props) => <img {...props} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
|
||||
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
||||
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
||||
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
|
||||
a: (props) => <a {...props} style={{ color: '#00b96b' }} target="_blank" rel="noopener noreferrer" />,
|
||||
blockquote: (props) => <blockquote {...props} style={{ borderLeft: '4px solid #00b96b', color: '#999', paddingLeft: '1em', margin: '1em 0' }} />,
|
||||
table: (props) => <table {...props} style={{ borderCollapse: 'collapse', width: '100%', margin: '1em 0' }} />,
|
||||
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333' }} />,
|
||||
td: (props) => <td {...props} style={{ border: '1px solid #444', padding: '8px' }} />,
|
||||
}}
|
||||
>
|
||||
{competition.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<Title level={4} style={{ marginTop: 32, color: '#fff' }}>规则说明</Title>
|
||||
<div style={{ color: '#ccc', lineHeight: '1.8' }}>
|
||||
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: '16px' }} className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{ code: CodeBlock }}
|
||||
components={{
|
||||
code: CodeBlock,
|
||||
img: (props) => <img {...props} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
|
||||
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
||||
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
||||
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
|
||||
a: (props) => <a {...props} style={{ color: '#00b96b' }} target="_blank" rel="noopener noreferrer" />,
|
||||
blockquote: (props) => <blockquote {...props} style={{ borderLeft: '4px solid #00b96b', color: '#999', paddingLeft: '1em', margin: '1em 0' }} />,
|
||||
table: (props) => <table {...props} style={{ borderCollapse: 'collapse', width: '100%', margin: '1em 0' }} />,
|
||||
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333' }} />,
|
||||
td: (props) => <td {...props} style={{ border: '1px solid #444', padding: '8px' }} />,
|
||||
}}
|
||||
>
|
||||
{competition.rule_description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<Title level={4} style={{ marginTop: 32, color: '#fff' }}>参赛条件</Title>
|
||||
<div style={{ color: '#ccc', lineHeight: '1.8' }}>
|
||||
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: '16px' }} className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{ code: CodeBlock }}
|
||||
components={{
|
||||
code: CodeBlock,
|
||||
img: (props) => <img {...props} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
|
||||
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
||||
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
||||
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
|
||||
a: (props) => <a {...props} style={{ color: '#00b96b' }} target="_blank" rel="noopener noreferrer" />,
|
||||
blockquote: (props) => <blockquote {...props} style={{ borderLeft: '4px solid #00b96b', color: '#999', paddingLeft: '1em', margin: '1em 0' }} />,
|
||||
table: (props) => <table {...props} style={{ borderCollapse: 'collapse', width: '100%', margin: '1em 0' }} />,
|
||||
th: (props) => <th {...props} style={{ border: '1px solid #444', padding: '8px', background: '#333' }} />,
|
||||
td: (props) => <td {...props} style={{ border: '1px solid #444', padding: '8px' }} />,
|
||||
}}
|
||||
>
|
||||
{competition.condition_description}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -56,6 +56,7 @@ const CompetitionList = () => {
|
||||
onChange={handleStatusChange}
|
||||
>
|
||||
<Option value="all">全部状态</Option>
|
||||
<Option value="published">即将开始</Option>
|
||||
<Option value="registration">报名中</Option>
|
||||
<Option value="submission">提交中</Option>
|
||||
<Option value="judging">评审中</Option>
|
||||
@@ -73,7 +74,7 @@ const CompetitionList = () => {
|
||||
{data?.data?.results?.length > 0 ? (
|
||||
<Row gutter={[24, 24]}>
|
||||
{data.data.results.map((comp) => (
|
||||
<Col key={comp.id} xs={24} sm={12} md={8} lg={6}>
|
||||
<Col key={comp.id} xs={24} sm={12} md={8} lg={6} xl={6} xxl={4}>
|
||||
<CompetitionCard competition={comp} />
|
||||
</Col>
|
||||
))}
|
||||
|
||||
102
miniprogram/src/pages/competition/detail.scss
Normal file
102
miniprogram/src/pages/competition/detail.scss
Normal file
@@ -0,0 +1,102 @@
|
||||
.comp-detail {
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 80px;
|
||||
|
||||
.banner {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
background: #111;
|
||||
border-radius: 16px 16px 0 0;
|
||||
margin-top: -24px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
margin-left: 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.registration { background: #07c160; color: #fff; }
|
||||
&.submission { background: #1890ff; color: #fff; }
|
||||
&.judging { background: #faad14; color: #fff; }
|
||||
&.ended { background: #ff4d4f; color: #fff; }
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
border-left: 4px solid #00b96b;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
rich-text {
|
||||
font-size: 16px;
|
||||
color: #ccc;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-action {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1f1f1f;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #333;
|
||||
z-index: 100;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
border-radius: 24px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
background: #00b96b;
|
||||
border: none;
|
||||
|
||||
&.disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&.enrolled {
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
111
miniprogram/src/pages/competition/detail.tsx
Normal file
111
miniprogram/src/pages/competition/detail.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { View, Text, Button, Image, ScrollView, RichText } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment } from '../../api'
|
||||
import './detail.scss'
|
||||
|
||||
export default function CompetitionDetail() {
|
||||
const [detail, setDetail] = useState<any>(null)
|
||||
const [enrollment, setEnrollment] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useLoad((options) => {
|
||||
const { id } = options
|
||||
if (id) {
|
||||
fetchDetail(id)
|
||||
fetchEnrollment(id)
|
||||
}
|
||||
})
|
||||
|
||||
const fetchDetail = async (id) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getCompetitionDetail(id)
|
||||
setDetail(res)
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: '加载详情失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchEnrollment = async (id) => {
|
||||
try {
|
||||
const res = await getMyCompetitionEnrollment(id)
|
||||
setEnrollment(res)
|
||||
} 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
|
||||
}
|
||||
|
||||
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='section'>
|
||||
<Text className='section-title'>简介</Text>
|
||||
<RichText nodes={detail.description} />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>规则</Text>
|
||||
<RichText nodes={detail.rule_description} />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>参赛条件</Text>
|
||||
<RichText nodes={detail.condition_description} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='footer-action'>
|
||||
{enrollment ? (
|
||||
<Button disabled className='btn enrolled'>
|
||||
{enrollment.status === 'approved' ? '已报名' : '审核中'}
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import './index.scss'
|
||||
export default function CompetitionList() {
|
||||
const [competitions, setCompetitions] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [debugMsg, setDebugMsg] = useState('')
|
||||
|
||||
useLoad(() => {
|
||||
fetchCompetitions()
|
||||
@@ -14,12 +15,21 @@ export default function CompetitionList() {
|
||||
|
||||
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)
|
||||
@@ -32,6 +42,7 @@ export default function CompetitionList() {
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const map = {
|
||||
'published': '即将开始',
|
||||
'registration': '报名中',
|
||||
'submission': '作品提交中',
|
||||
'judging': '评审中',
|
||||
@@ -66,7 +77,12 @@ export default function CompetitionList() {
|
||||
</View>
|
||||
))}
|
||||
{!loading && competitions.length === 0 && (
|
||||
<View className='empty'>暂无比赛</View>
|
||||
<View className='empty'>
|
||||
<Text>暂无比赛</Text>
|
||||
<View style={{ marginTop: 20, color: '#666', fontSize: 12, wordBreak: 'break-all', padding: 20 }}>
|
||||
调试信息: {debugMsg}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function UserIndex() {
|
||||
const goWithdraw = () => Taro.navigateTo({ url: '/subpackages/distributor/withdraw' })
|
||||
|
||||
const goActivityList = (tab = 'all') => Taro.navigateTo({ url: `/subpackages/forum/activity/index?tab=${tab}` })
|
||||
const goCompetitionList = () => Taro.navigateTo({ url: '/pages/competition/index' })
|
||||
|
||||
const handleAddress = async () => {
|
||||
try {
|
||||
@@ -260,6 +261,7 @@ export default function UserIndex() {
|
||||
{ title: '我的订单', icon: '📦', action: goOrders },
|
||||
{ title: '地址管理', icon: '📝', action: handleAddress },
|
||||
{ title: '活动管理', icon: '⌚️', action: () => goActivityList('mine') },
|
||||
{ title: '赛事中心', icon: '🏆', action: goCompetitionList },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user