This commit is contained in:
@@ -10,12 +10,13 @@ class ScoreDimensionSerializer(serializers.ModelSerializer):
|
|||||||
class CompetitionSerializer(serializers.ModelSerializer):
|
class CompetitionSerializer(serializers.ModelSerializer):
|
||||||
score_dimensions = ScoreDimensionSerializer(many=True, read_only=True)
|
score_dimensions = ScoreDimensionSerializer(many=True, read_only=True)
|
||||||
display_cover_image = serializers.SerializerMethodField()
|
display_cover_image = serializers.SerializerMethodField()
|
||||||
|
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Competition
|
model = Competition
|
||||||
fields = ['id', 'title', 'description', 'rule_description', 'condition_description',
|
fields = ['id', 'title', 'description', 'rule_description', 'condition_description',
|
||||||
'cover_image', 'cover_image_url', 'display_cover_image',
|
'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']
|
'score_dimensions', 'created_at']
|
||||||
|
|
||||||
def get_display_cover_image(self, obj):
|
def get_display_cover_image(self, obj):
|
||||||
|
|||||||
@@ -82,6 +82,17 @@ class CompetitionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
except CompetitionEnrollment.DoesNotExist:
|
except CompetitionEnrollment.DoesNotExist:
|
||||||
return Response({"detail": "未报名"}, status=status.HTTP_404_NOT_FOUND)
|
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):
|
class ProjectViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import dayjs from 'dayjs';
|
|||||||
import { getCompetitionDetail, getProjects, getMyCompetitionEnrollment, enrollCompetition } from '../../api';
|
import { getCompetitionDetail, getProjects, getMyCompetitionEnrollment, enrollCompetition } from '../../api';
|
||||||
import ProjectSubmission from './ProjectSubmission';
|
import ProjectSubmission from './ProjectSubmission';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
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;
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
@@ -77,19 +87,19 @@ const CompetitionDetail = () => {
|
|||||||
// Fetch competition details
|
// Fetch competition details
|
||||||
const { data: competition, isLoading: loadingDetail } = useQuery({
|
const { data: competition, isLoading: loadingDetail } = useQuery({
|
||||||
queryKey: ['competition', id],
|
queryKey: ['competition', id],
|
||||||
queryFn: () => getCompetitionDetail(id)
|
queryFn: () => getCompetitionDetail(id).then(res => res.data)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch projects (for leaderboard/display)
|
// Fetch projects (for leaderboard/display)
|
||||||
const { data: projects } = useQuery({
|
const { data: projects } = useQuery({
|
||||||
queryKey: ['projects', id],
|
queryKey: ['projects', id],
|
||||||
queryFn: () => getProjects({ competition: id, status: 'submitted' })
|
queryFn: () => getProjects({ competition: id, status: 'submitted' }).then(res => res.data)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check enrollment status
|
// Check enrollment status
|
||||||
const { data: enrollment, refetch: refetchEnrollment } = useQuery({
|
const { data: enrollment, refetch: refetchEnrollment } = useQuery({
|
||||||
queryKey: ['enrollment', id],
|
queryKey: ['enrollment', id],
|
||||||
queryFn: () => getMyCompetitionEnrollment(id),
|
queryFn: () => getMyCompetitionEnrollment(id).then(res => res.data),
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
retry: false
|
retry: false
|
||||||
});
|
});
|
||||||
@@ -140,7 +150,7 @@ const CompetitionDetail = () => {
|
|||||||
rehypePlugins={[rehypeRaw]}
|
rehypePlugins={[rehypeRaw]}
|
||||||
components={{
|
components={{
|
||||||
code: CodeBlock,
|
code: CodeBlock,
|
||||||
img: (props) => <img {...props} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
|
img: (props) => <img {...props} src={getImageUrl(props.src)} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
|
||||||
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
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' }} />,
|
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
||||||
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
|
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
|
||||||
@@ -162,7 +172,7 @@ const CompetitionDetail = () => {
|
|||||||
rehypePlugins={[rehypeRaw]}
|
rehypePlugins={[rehypeRaw]}
|
||||||
components={{
|
components={{
|
||||||
code: CodeBlock,
|
code: CodeBlock,
|
||||||
img: (props) => <img {...props} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
|
img: (props) => <img {...props} src={getImageUrl(props.src)} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
|
||||||
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
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' }} />,
|
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
||||||
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
|
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
|
||||||
@@ -184,7 +194,7 @@ const CompetitionDetail = () => {
|
|||||||
rehypePlugins={[rehypeRaw]}
|
rehypePlugins={[rehypeRaw]}
|
||||||
components={{
|
components={{
|
||||||
code: CodeBlock,
|
code: CodeBlock,
|
||||||
img: (props) => <img {...props} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
|
img: (props) => <img {...props} src={getImageUrl(props.src)} style={{ maxWidth: '100%', borderRadius: '8px' }} />,
|
||||||
h1: (props) => <h1 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
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' }} />,
|
h2: (props) => <h2 {...props} style={{ color: '#fff', borderBottom: '1px solid #333', paddingBottom: '0.3em' }} />,
|
||||||
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
|
h3: (props) => <h3 {...props} style={{ color: '#eee' }} />,
|
||||||
@@ -210,14 +220,18 @@ const CompetitionDetail = () => {
|
|||||||
<Col key={project.id} xs={24} sm={12} md={8}>
|
<Col key={project.id} xs={24} sm={12} md={8}>
|
||||||
<Card
|
<Card
|
||||||
hoverable
|
hoverable
|
||||||
cover={<img alt={project.title} src={project.display_cover_image || 'placeholder.jpg'} style={{ height: 180, objectFit: 'cover' }} />}
|
cover={<img alt={project.title} src={getImageUrl(project.display_cover_image) || 'placeholder.jpg'} style={{ height: 180, objectFit: 'cover' }} />}
|
||||||
actions={[
|
actions={[
|
||||||
<Button type="link" onClick={() => navigate(`/projects/${project.id}`)}>查看详情</Button>
|
<Button type="link" onClick={() => navigate(`/projects/${project.id}`)}>查看详情</Button>
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Card.Meta
|
<Card.Meta
|
||||||
title={project.title}
|
title={project.title}
|
||||||
description={`得分: ${project.final_score || '待定'}`}
|
description={
|
||||||
|
enrollment && project.contestant === enrollment.id
|
||||||
|
? `得分: ${project.final_score || '待定'}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
avatar={<UserOutlined />}
|
avatar={<UserOutlined />}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -245,7 +259,7 @@ const CompetitionDetail = () => {
|
|||||||
<div style={{ color: '#888', fontSize: 12 }}>{project.contestant_info?.nickname}</div>
|
<div style={{ color: '#888', fontSize: 12 }}>{project.contestant_info?.nickname}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 24, color: '#00b96b', fontWeight: 'bold' }}>
|
<div style={{ fontSize: 24, color: '#00b96b', fontWeight: 'bold' }}>
|
||||||
{project.final_score}
|
{enrollment && project.contestant === enrollment.id ? project.final_score : '**'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -258,7 +272,7 @@ const CompetitionDetail = () => {
|
|||||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '24px' }}>
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '24px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
height: 300,
|
height: 300,
|
||||||
backgroundImage: `url(${competition.display_cover_image})`,
|
backgroundImage: `url(${getImageUrl(competition.display_cover_image)})`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
|
|||||||
@@ -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 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 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 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 getProjects = (params?: any) => request({ url: '/competition/projects/', data: params })
|
||||||
export const getProjectDetail = (id: number) => request({ url: `/competition/projects/${id}/` })
|
export const getProjectDetail = (id: number) => request({ url: `/competition/projects/${id}/` })
|
||||||
export const createProject = (data: any) => request({ url: '/competition/projects/', method: 'POST', data })
|
export const createProject = (data: any) => request({ url: '/competition/projects/', method: 'POST', data })
|
||||||
|
|||||||
@@ -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 {
|
.section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
|
|
||||||
@@ -59,11 +231,113 @@
|
|||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
rich-text {
|
/* Markdown styling borrowed from Forum */
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #ccc;
|
line-height: 1.8;
|
||||||
line-height: 1.6;
|
color: #e0e0e0;
|
||||||
white-space: pre-wrap;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Taro, { useLoad } from '@tarojs/taro'
|
||||||
import { useState } from 'react'
|
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'
|
import './detail.scss'
|
||||||
|
|
||||||
export default function CompetitionDetail() {
|
export default function CompetitionDetail() {
|
||||||
const [detail, setDetail] = useState<any>(null)
|
const [detail, setDetail] = useState<any>(null)
|
||||||
const [enrollment, setEnrollment] = useState<any>(null)
|
const [enrollment, setEnrollment] = useState<any>(null)
|
||||||
|
const [projects, setProjects] = useState<any[]>([])
|
||||||
|
const [activeTab, setActiveTab] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
useLoad((options) => {
|
useLoad((options) => {
|
||||||
@@ -14,6 +17,7 @@ export default function CompetitionDetail() {
|
|||||||
if (id) {
|
if (id) {
|
||||||
fetchDetail(id)
|
fetchDetail(id)
|
||||||
fetchEnrollment(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 () => {
|
const handleEnroll = async () => {
|
||||||
if (!detail) return
|
if (!detail) return
|
||||||
try {
|
try {
|
||||||
@@ -75,20 +90,83 @@ export default function CompetitionDetail() {
|
|||||||
<Text className={`status ${detail.status}`}>{getStatusText(detail.status)}</Text>
|
<Text className={`status ${detail.status}`}>{getStatusText(detail.status)}</Text>
|
||||||
</View>
|
</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'>
|
<View className='section'>
|
||||||
<Text className='section-title'>简介</Text>
|
<Text className='section-title'>简介</Text>
|
||||||
<RichText nodes={detail.description} />
|
<MarkdownReader content={detail.description} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='section'>
|
<View className='section'>
|
||||||
<Text className='section-title'>规则</Text>
|
<Text className='section-title'>规则</Text>
|
||||||
<RichText nodes={detail.rule_description} />
|
<MarkdownReader content={detail.rule_description} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='section'>
|
<View className='section'>
|
||||||
<Text className='section-title'>参赛条件</Text>
|
<Text className='section-title'>参赛条件</Text>
|
||||||
<RichText nodes={detail.condition_description} />
|
<MarkdownReader content={detail.condition_description} />
|
||||||
</View>
|
</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>
|
||||||
|
|
||||||
<View className='footer-action'>
|
<View className='footer-action'>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { View, Text, Image, Button, Checkbox, CheckboxGroup, RichText } from '@t
|
|||||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'
|
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { login as silentLogin } from '../../utils/request'
|
import { login as silentLogin } from '../../utils/request'
|
||||||
|
import { getMyEnrollments } from '../../api'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
export default function UserIndex() {
|
export default function UserIndex() {
|
||||||
@@ -9,10 +10,14 @@ export default function UserIndex() {
|
|||||||
const [showLoginModal, setShowLoginModal] = useState(false)
|
const [showLoginModal, setShowLoginModal] = useState(false)
|
||||||
const [isAgreed, setIsAgreed] = useState(false)
|
const [isAgreed, setIsAgreed] = useState(false)
|
||||||
const [showAgreement, setShowAgreement] = useState(false) // For showing agreement content
|
const [showAgreement, setShowAgreement] = useState(false) // For showing agreement content
|
||||||
|
const [myEnrollments, setMyEnrollments] = useState<any[]>([])
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
const info = Taro.getStorageSync('userInfo')
|
const info = Taro.getStorageSync('userInfo')
|
||||||
if (info) setUserInfo(info)
|
if (info) {
|
||||||
|
setUserInfo(info)
|
||||||
|
fetchEnrollments()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
usePullDownRefresh(async () => {
|
usePullDownRefresh(async () => {
|
||||||
@@ -20,6 +25,7 @@ export default function UserIndex() {
|
|||||||
const res = await silentLogin()
|
const res = await silentLogin()
|
||||||
if (res) {
|
if (res) {
|
||||||
setUserInfo(res)
|
setUserInfo(res)
|
||||||
|
fetchEnrollments()
|
||||||
}
|
}
|
||||||
Taro.stopPullDownRefresh()
|
Taro.stopPullDownRefresh()
|
||||||
} catch (e) {
|
} 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 goOrders = () => Taro.navigateTo({ url: '/pages/order/list' })
|
||||||
const goDistributor = () => Taro.navigateTo({ url: '/subpackages/distributor/index' })
|
const goDistributor = () => Taro.navigateTo({ url: '/subpackages/distributor/index' })
|
||||||
const goInvite = () => Taro.navigateTo({ url: '/subpackages/distributor/invite' })
|
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 goActivityList = (tab = 'all') => Taro.navigateTo({ url: `/subpackages/forum/activity/index?tab=${tab}` })
|
||||||
const goCompetitionList = () => Taro.navigateTo({ url: '/pages/competition/index' })
|
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 () => {
|
const handleAddress = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await Taro.chooseAddress()
|
const res = await Taro.chooseAddress()
|
||||||
@@ -254,6 +283,8 @@ export default function UserIndex() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isContestant = myEnrollments.some(e => e.role === 'contestant')
|
||||||
|
|
||||||
const serviceGroups = [
|
const serviceGroups = [
|
||||||
{
|
{
|
||||||
title: '基础服务',
|
title: '基础服务',
|
||||||
@@ -261,7 +292,13 @@ export default function UserIndex() {
|
|||||||
{ title: '我的订单', icon: '📦', action: goOrders },
|
{ title: '我的订单', icon: '📦', action: goOrders },
|
||||||
{ title: '地址管理', icon: '📝', action: handleAddress },
|
{ title: '地址管理', icon: '📝', action: handleAddress },
|
||||||
{ title: '活动管理', icon: '⌚️', action: () => goActivityList('mine') },
|
{ title: '活动管理', icon: '⌚️', action: () => goActivityList('mine') },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '比赛服务',
|
||||||
|
items: [
|
||||||
{ title: '赛事中心', icon: '🏆', action: goCompetitionList },
|
{ title: '赛事中心', icon: '🏆', action: goCompetitionList },
|
||||||
|
...(isContestant ? [{ title: '上传比赛资料', icon: '📤', action: goUploadProject }] : [])
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user