This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
from rest_framework import viewsets, permissions, status, filters
|
from rest_framework import viewsets, permissions, status, filters, serializers
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@@ -107,6 +107,10 @@ class ProjectViewSet(viewsets.ModelViewSet):
|
|||||||
if competition_id:
|
if competition_id:
|
||||||
queryset = queryset.filter(competition_id=competition_id)
|
queryset = queryset.filter(competition_id=competition_id)
|
||||||
|
|
||||||
|
contestant_id = self.request.query_params.get('contestant')
|
||||||
|
if contestant_id:
|
||||||
|
queryset = queryset.filter(contestant_id=contestant_id)
|
||||||
|
|
||||||
# 如果是普通用户,只能看到已提交的项目,或者自己草稿的项目
|
# 如果是普通用户,只能看到已提交的项目,或者自己草稿的项目
|
||||||
user = get_current_wechat_user(self.request)
|
user = get_current_wechat_user(self.request)
|
||||||
if user:
|
if user:
|
||||||
@@ -152,6 +156,10 @@ class ProjectViewSet(viewsets.ModelViewSet):
|
|||||||
except CompetitionEnrollment.DoesNotExist:
|
except CompetitionEnrollment.DoesNotExist:
|
||||||
raise serializers.ValidationError("您没有参赛资格或审核未通过")
|
raise serializers.ValidationError("您没有参赛资格或审核未通过")
|
||||||
|
|
||||||
|
# 检查是否已提交过项目
|
||||||
|
if Project.objects.filter(competition=competition, contestant=enrollment).exists():
|
||||||
|
raise serializers.ValidationError("您已提交过该比赛的项目,请勿重复提交")
|
||||||
|
|
||||||
serializer.save(contestant=enrollment)
|
serializer.save(contestant=enrollment)
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Typography, Tabs, Button, Row, Col, Card, Statistic, Tag, Descriptions, Empty, message, Spin } from 'antd';
|
import { Typography, Tabs, Button, Row, Col, Card, Statistic, Tag, Descriptions, Empty, message, Spin } from 'antd';
|
||||||
import { CalendarOutlined, TrophyOutlined, UserOutlined, FileTextOutlined, CloudUploadOutlined, CopyOutlined, CheckOutlined } from '@ant-design/icons';
|
import { CalendarOutlined, TrophyOutlined, UserOutlined, FileTextOutlined, CloudUploadOutlined, CopyOutlined, CheckOutlined } from '@ant-design/icons';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
@@ -79,6 +79,7 @@ const CodeBlock = ({ inline, className, children, ...props }) => {
|
|||||||
const CompetitionDetail = () => {
|
const CompetitionDetail = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { user, showLoginModal } = useAuth();
|
const { user, showLoginModal } = useAuth();
|
||||||
const [activeTab, setActiveTab] = useState('details');
|
const [activeTab, setActiveTab] = useState('details');
|
||||||
const [submissionModalVisible, setSubmissionModalVisible] = useState(false);
|
const [submissionModalVisible, setSubmissionModalVisible] = useState(false);
|
||||||
@@ -104,6 +105,15 @@ const CompetitionDetail = () => {
|
|||||||
retry: false
|
retry: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch my project if enrolled
|
||||||
|
const { data: myProjects, isLoading: loadingMyProject } = useQuery({
|
||||||
|
queryKey: ['myProject', id, enrollment?.id],
|
||||||
|
queryFn: () => getProjects({ competition: id, contestant: enrollment.id }).then(res => res.data),
|
||||||
|
enabled: !!enrollment?.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const myProject = myProjects?.results?.[0];
|
||||||
|
|
||||||
const handleEnroll = async () => {
|
const handleEnroll = async () => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
showLoginModal();
|
showLoginModal();
|
||||||
@@ -297,8 +307,15 @@ const CompetitionDetail = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isContestant && (
|
{isContestant && (
|
||||||
<Button icon={<CloudUploadOutlined />} onClick={() => setSubmissionModalVisible(true)}>
|
<Button
|
||||||
提交/管理作品
|
icon={<CloudUploadOutlined />}
|
||||||
|
loading={loadingMyProject}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingProject(myProject || null);
|
||||||
|
setSubmissionModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{myProject ? '管理/修改作品' : '提交作品'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -325,6 +342,8 @@ const CompetitionDetail = () => {
|
|||||||
setSubmissionModalVisible(false);
|
setSubmissionModalVisible(false);
|
||||||
setEditingProject(null);
|
setEditingProject(null);
|
||||||
// Refetch projects
|
// Refetch projects
|
||||||
|
queryClient.invalidateQueries(['projects']);
|
||||||
|
queryClient.invalidateQueries(['myProject']);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
|
|||||||
const [fileList, setFileList] = useState([]);
|
const [fileList, setFileList] = useState([]);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Reset form when initialValues changes (important for switching between create/edit)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (initialValues) {
|
||||||
|
form.setFieldsValue(initialValues);
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}, [initialValues, form]);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: createProject,
|
mutationFn: createProject,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -62,6 +71,13 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = ({ file, onSuccess, onError }) => {
|
const handleUpload = ({ file, onSuccess, onError }) => {
|
||||||
|
if (!initialValues?.id) {
|
||||||
|
message.warning('请先保存项目基本信息再上传文件');
|
||||||
|
// Prevent default upload
|
||||||
|
onError(new Error('请先保存项目'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('project', initialValues?.id || ''); // Need project ID first usually
|
formData.append('project', initialValues?.id || ''); // Need project ID first usually
|
||||||
@@ -70,7 +86,7 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
|
|||||||
// Or upload to temp storage then link?
|
// Or upload to temp storage then link?
|
||||||
// For simplicity, let's assume we create project first if not exists
|
// For simplicity, let's assume we create project first if not exists
|
||||||
if (!initialValues?.id) {
|
if (!initialValues?.id) {
|
||||||
message.warning('请先保存项目基本信息再上传文件');
|
// Already handled above
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +95,7 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={initialValues ? "编辑项目" : "提交新项目"}
|
title={initialValues?.id ? "修改已提交项目" : "提交新项目"}
|
||||||
open={true}
|
open={true}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
footer={null}
|
footer={null}
|
||||||
@@ -114,6 +130,12 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
|
|||||||
<TextArea rows={3} placeholder="介绍您的团队成员和分工" />
|
<TextArea rows={3} placeholder="介绍您的团队成员和分工" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{initialValues?.status === 'submitted' && (
|
||||||
|
<div style={{ marginBottom: 24, color: '#faad14' }}>
|
||||||
|
注意:您已正式提交该项目,修改后需要重新审核(如适用)。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="cover_image_url"
|
name="cover_image_url"
|
||||||
label="封面图片链接"
|
label="封面图片链接"
|
||||||
@@ -139,7 +161,7 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
|
|||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||||
<Button onClick={onCancel}>取消</Button>
|
<Button onClick={onCancel}>取消</Button>
|
||||||
<Button type="primary" htmlType="submit" loading={createMutation.isLoading || updateMutation.isLoading}>
|
<Button type="primary" htmlType="submit" loading={createMutation.isLoading || updateMutation.isLoading}>
|
||||||
保存草稿
|
{initialValues?.id ? '保存修改' : '保存草稿'}
|
||||||
</Button>
|
</Button>
|
||||||
{initialValues?.id && (
|
{initialValues?.id && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export default defineAppConfig({
|
|||||||
'pages/order/detail',
|
'pages/order/detail',
|
||||||
'pages/user/index',
|
'pages/user/index',
|
||||||
'pages/competition/index',
|
'pages/competition/index',
|
||||||
'pages/competition/detail'
|
'pages/competition/detail',
|
||||||
|
'pages/competition/project'
|
||||||
],
|
],
|
||||||
subPackages: [
|
subPackages: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { View, Text, Button, Image, ScrollView } from '@tarojs/components'
|
import { View, Text, Button, Image, ScrollView } from '@tarojs/components'
|
||||||
import Taro, { useLoad } from '@tarojs/taro'
|
import Taro, { useLoad, useDidShow } from '@tarojs/taro'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment, getProjects } from '../../api'
|
import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment, getProjects } from '../../api'
|
||||||
import MarkdownReader from '../../components/MarkdownReader'
|
import MarkdownReader from '../../components/MarkdownReader'
|
||||||
import './detail.scss'
|
import './detail.scss'
|
||||||
@@ -9,6 +9,7 @@ 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 [projects, setProjects] = useState<any[]>([])
|
||||||
|
const [myProject, setMyProject] = useState<any>(null)
|
||||||
const [activeTab, setActiveTab] = useState(0)
|
const [activeTab, setActiveTab] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
@@ -21,11 +22,19 @@ export default function CompetitionDetail() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
// 每次显示页面时刷新一下我的项目信息(比如从编辑页返回)
|
||||||
|
if (detail?.id) {
|
||||||
|
fetchMyProject(detail.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const fetchDetail = async (id) => {
|
const fetchDetail = async (id) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getCompetitionDetail(id)
|
const res = await getCompetitionDetail(id)
|
||||||
setDetail(res)
|
setDetail(res)
|
||||||
|
fetchMyProject(id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Taro.showToast({ title: '加载详情失败', icon: 'none' })
|
Taro.showToast({ title: '加载详情失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -33,10 +42,62 @@ export default function CompetitionDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchMyProject = async (competitionId) => {
|
||||||
|
try {
|
||||||
|
// 获取当前用户的所有项目,然后筛选出当前比赛的
|
||||||
|
// 或者直接调用 getProjects 并传入 contestant__user=me (如果后端支持)
|
||||||
|
// 目前后端 ProjectViewSet 默认返回:所有submitted + 自己的draft/submitted
|
||||||
|
// 所以我们直接调 getProjects({ competition: competitionId }) 然后在前端找自己的
|
||||||
|
|
||||||
|
// 更好的方式:后端 ProjectViewSet 应该已经过滤了,返回列表中如果有一条是自己的,那就是自己的
|
||||||
|
// 但这里我们还是显式地请求一下,或者在 fetchProjects 的结果里找
|
||||||
|
|
||||||
|
const userInfo = Taro.getStorageSync('userInfo')
|
||||||
|
if (!userInfo) return
|
||||||
|
|
||||||
|
const res = await getProjects({ competition: competitionId })
|
||||||
|
const list = res.results || res
|
||||||
|
const myProj = list.find((p: any) => p.contestant_info?.nickname === userInfo.nickname) // 这是一个简化的判断,最好用 ID
|
||||||
|
// 由于 API 返回的 contestant_info 没有 user_id,我们可能需要在 project 对象里加一个 is_mine 字段
|
||||||
|
// 或者,我们可以依赖后端返回的 contestant.user.id 与当前 user.id 比对。
|
||||||
|
// 但前端拿不到 contestant.user.id (ProjectSerializer 没返回)。
|
||||||
|
|
||||||
|
// 既然我们之前做了一个 getMyEnrollments,我们可以通过 enrollment id 来匹配
|
||||||
|
// 但这里为了简便,我们可以假设 getProjects 返回的数据里,如果 contestant_info 匹配当前用户昵称... 不太靠谱
|
||||||
|
|
||||||
|
// 让我们修改 API 或者用另一种方式:
|
||||||
|
// 直接请求 getProjects,带上一个特殊参数 mine=true ? 后端 ProjectViewSet 逻辑比较复杂
|
||||||
|
|
||||||
|
// 让我们回顾一下 ProjectViewSet:
|
||||||
|
// q |= Q(contestant__user=user)
|
||||||
|
// 所以返回的列表里肯定包含我的项目。
|
||||||
|
|
||||||
|
// 既然我们已经有 enrollment 信息,我们可以用 enrollment.id 来匹配 project.contestant
|
||||||
|
if (enrollment) {
|
||||||
|
const mine = list.find((p: any) => p.contestant === enrollment.id)
|
||||||
|
setMyProject(mine)
|
||||||
|
} else {
|
||||||
|
// 如果 enrollment 还没加载完,先不管,等 enrollment 加载完再匹配?
|
||||||
|
// 或者我们再次 fetchEnrollment 后再 fetchProjects
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchEnrollment = async (id) => {
|
const fetchEnrollment = async (id) => {
|
||||||
try {
|
try {
|
||||||
const res = await getMyCompetitionEnrollment(id)
|
const res = await getMyCompetitionEnrollment(id)
|
||||||
setEnrollment(res)
|
setEnrollment(res)
|
||||||
|
// 获取到 enrollment 后,去匹配 myProject
|
||||||
|
if (projects.length > 0) {
|
||||||
|
const mine = projects.find((p: any) => p.contestant === res.id)
|
||||||
|
setMyProject(mine)
|
||||||
|
} else {
|
||||||
|
// 如果 projects 还没加载,重新加载一次 projects 或者等待 fetchProjects 完成
|
||||||
|
// 其实 fetchProjects 也在运行,它完成后也会设置 projects
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 没报名则无数据,忽略
|
// 没报名则无数据,忽略
|
||||||
}
|
}
|
||||||
@@ -44,15 +105,46 @@ export default function CompetitionDetail() {
|
|||||||
|
|
||||||
const fetchProjects = async (id) => {
|
const fetchProjects = async (id) => {
|
||||||
try {
|
try {
|
||||||
const res = await getProjects({ competition: id, status: 'submitted' })
|
// 注意:这里我们去掉了 status='submitted',因为我们要找自己的 draft
|
||||||
// 如果后端返回了分页结果 { results: [], ... },则取 results,否则直接取 res
|
const res = await getProjects({ competition: id })
|
||||||
const list = res.results || res
|
const list = res.results || res
|
||||||
setProjects(Array.isArray(list) ? list : [])
|
const allProjects = Array.isArray(list) ? list : []
|
||||||
|
|
||||||
|
// 过滤出 submitted 的给列表显示
|
||||||
|
const submittedProjects = allProjects.filter(p => p.status === 'submitted')
|
||||||
|
setProjects(submittedProjects)
|
||||||
|
|
||||||
|
// 尝试找自己的项目 (Draft or Submitted)
|
||||||
|
// 需要 enrollment 信息
|
||||||
|
// 这里暂时没法直接 setMyProject,因为 enrollment 可能还没回来
|
||||||
|
// 我们在 useEffect 里监听 enrollment 和 projects 的变化来设置 myProject
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Fetch projects failed', e)
|
console.error('Fetch projects failed', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听变化设置 myProject
|
||||||
|
useEffect(() => {
|
||||||
|
if (enrollment && projects.length >= 0) { // projects could be empty
|
||||||
|
// 重新获取一次所有项目以包含 draft?
|
||||||
|
// 上面的 fetchProjects 已经把 submitted 过滤给 setProjects 了。
|
||||||
|
// 所以我们需要在 fetchProjects 里就把 allProjects 存下来?或者单独存 myProject
|
||||||
|
|
||||||
|
// 让我们重构 fetchProjects,专门获取一次“我的项目”
|
||||||
|
fetchMySpecificProject(detail?.id, enrollment.id)
|
||||||
|
}
|
||||||
|
}, [enrollment])
|
||||||
|
|
||||||
|
const fetchMySpecificProject = async (compId, enrollId) => {
|
||||||
|
if (!compId || !enrollId) return
|
||||||
|
try {
|
||||||
|
const res = await getProjects({ competition: compId })
|
||||||
|
const list = res.results || res
|
||||||
|
const mine = list.find((p: any) => p.contestant === enrollId)
|
||||||
|
setMyProject(mine)
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
const handleEnroll = async () => {
|
const handleEnroll = async () => {
|
||||||
if (!detail) return
|
if (!detail) return
|
||||||
try {
|
try {
|
||||||
@@ -171,9 +263,27 @@ export default function CompetitionDetail() {
|
|||||||
|
|
||||||
<View className='footer-action'>
|
<View className='footer-action'>
|
||||||
{enrollment ? (
|
{enrollment ? (
|
||||||
<Button disabled className='btn enrolled'>
|
myProject ? (
|
||||||
{enrollment.status === 'approved' ? '已报名' : '审核中'}
|
<Button
|
||||||
|
className='btn enrolled'
|
||||||
|
onClick={() => Taro.navigateTo({ url: `/pages/competition/project?id=${myProject.id}` })}
|
||||||
|
>
|
||||||
|
我的作品 ({myProject.status === 'submitted' ? '已提交' : '草稿'})
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
enrollment.status === 'approved' ? (
|
||||||
|
<Button
|
||||||
|
className='btn enrolled'
|
||||||
|
onClick={() => Taro.navigateTo({ url: `/pages/competition/project?competitionId=${detail.id}` })}
|
||||||
|
>
|
||||||
|
立即提交作品
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button disabled className='btn enrolled'>
|
||||||
|
报名审核中
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className='btn enroll'
|
className='btn enroll'
|
||||||
|
|||||||
3
miniprogram/src/pages/competition/project.config.ts
Normal file
3
miniprogram/src/pages/competition/project.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '参赛作品'
|
||||||
|
})
|
||||||
93
miniprogram/src/pages/competition/project.scss
Normal file
93
miniprogram/src/pages/competition/project.scss
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
.project-edit {
|
||||||
|
padding: 24px;
|
||||||
|
background: #000;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #fff;
|
||||||
|
padding-bottom: 100px;
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: block;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input, .textarea {
|
||||||
|
background: #1f1f1f;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
height: 200px;
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-box {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
background: #1f1f1f;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px dashed #333;
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btns {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #1f1f1f;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
z-index: 100;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 48px;
|
||||||
|
line-height: 48px;
|
||||||
|
border-radius: 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
margin: 0 8px;
|
||||||
|
|
||||||
|
&.save {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.submit {
|
||||||
|
background: #00b96b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
162
miniprogram/src/pages/competition/project.tsx
Normal file
162
miniprogram/src/pages/competition/project.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { View, Text, Button, Image, Input, Textarea } from '@tarojs/components'
|
||||||
|
import Taro, { useLoad } from '@tarojs/taro'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { getProjectDetail, createProject, updateProject, uploadProjectFile, submitProject, uploadMedia } from '../../api'
|
||||||
|
import './project.scss'
|
||||||
|
|
||||||
|
export default function ProjectEdit() {
|
||||||
|
const [project, setProject] = useState<any>({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
team_info: '',
|
||||||
|
files: []
|
||||||
|
})
|
||||||
|
const [competitionId, setCompetitionId] = useState<string>('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [isEdit, setIsEdit] = useState(false)
|
||||||
|
|
||||||
|
useLoad((options) => {
|
||||||
|
const { id, competitionId } = options
|
||||||
|
if (id) {
|
||||||
|
setIsEdit(true)
|
||||||
|
fetchProject(id)
|
||||||
|
} else if (competitionId) {
|
||||||
|
setCompetitionId(competitionId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchProject = async (id) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getProjectDetail(id)
|
||||||
|
setProject(res)
|
||||||
|
setCompetitionId(res.competition)
|
||||||
|
} catch (e) {
|
||||||
|
Taro.showToast({ title: '加载项目失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInput = (key, value) => {
|
||||||
|
setProject(prev => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadCover = async () => {
|
||||||
|
try {
|
||||||
|
const { tempFilePaths } = await Taro.chooseImage({ count: 1 })
|
||||||
|
if (!tempFilePaths.length) return
|
||||||
|
|
||||||
|
Taro.showLoading({ title: '上传中...' })
|
||||||
|
|
||||||
|
const res = await uploadMedia(tempFilePaths[0], 'image')
|
||||||
|
handleInput('cover_image_url', res.file) // 假设返回 { file: 'url...' }
|
||||||
|
|
||||||
|
Taro.hideLoading()
|
||||||
|
} catch (e) {
|
||||||
|
Taro.hideLoading()
|
||||||
|
Taro.showToast({ title: '上传失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (submit = false) => {
|
||||||
|
if (!project.title) {
|
||||||
|
Taro.showToast({ title: '请输入项目标题', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
competition: competitionId,
|
||||||
|
title: project.title,
|
||||||
|
description: project.description,
|
||||||
|
team_info: project.team_info,
|
||||||
|
cover_image_url: project.cover_image_url
|
||||||
|
}
|
||||||
|
|
||||||
|
let res
|
||||||
|
if (isEdit) {
|
||||||
|
res = await updateProject(project.id, data)
|
||||||
|
} else {
|
||||||
|
res = await createProject(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submit) {
|
||||||
|
await submitProject(res.id)
|
||||||
|
Taro.showToast({ title: '提交成功', icon: 'success' })
|
||||||
|
setTimeout(() => Taro.navigateBack(), 1500)
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
if (!isEdit) {
|
||||||
|
// 创建变编辑
|
||||||
|
setIsEdit(true)
|
||||||
|
setProject(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Taro.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !project.id && isEdit) return <View className='loading'>加载中...</View>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='project-edit'>
|
||||||
|
<View className='form-item'>
|
||||||
|
<Text className='label'>项目标题</Text>
|
||||||
|
<Input
|
||||||
|
className='input'
|
||||||
|
placeholder='请输入项目标题'
|
||||||
|
value={project.title}
|
||||||
|
onInput={e => handleInput('title', e.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='form-item'>
|
||||||
|
<Text className='label'>封面图</Text>
|
||||||
|
<View className='upload-box' onClick={handleUploadCover}>
|
||||||
|
{project.cover_image_url || project.display_cover_image ? (
|
||||||
|
<Image
|
||||||
|
className='preview'
|
||||||
|
mode='aspectFill'
|
||||||
|
src={project.cover_image_url || project.display_cover_image}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text className='placeholder'>点击上传封面</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='form-item'>
|
||||||
|
<Text className='label'>项目介绍</Text>
|
||||||
|
<Textarea
|
||||||
|
className='textarea'
|
||||||
|
placeholder='请输入项目详细介绍'
|
||||||
|
value={project.description}
|
||||||
|
onInput={e => handleInput('description', e.detail.value)}
|
||||||
|
maxlength={2000}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='form-item'>
|
||||||
|
<Text className='label'>团队介绍</Text>
|
||||||
|
<Textarea
|
||||||
|
className='textarea small'
|
||||||
|
placeholder='请输入团队成员信息'
|
||||||
|
value={project.team_info}
|
||||||
|
onInput={e => handleInput('team_info', e.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 附件列表略,暂不支持上传非图片附件 */}
|
||||||
|
|
||||||
|
<View className='footer-btns'>
|
||||||
|
<Button className='btn save' onClick={() => handleSave(false)}>保存草稿</Button>
|
||||||
|
<Button className='btn submit' onClick={() => handleSave(true)}>提交作品</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +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 { getMyEnrollments, getProjects } from '../../api'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
export default function UserIndex() {
|
export default function UserIndex() {
|
||||||
@@ -11,12 +11,13 @@ export default function UserIndex() {
|
|||||||
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[]>([])
|
const [myEnrollments, setMyEnrollments] = useState<any[]>([])
|
||||||
|
const [myProjects, setMyProjects] = useState<any[]>([])
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
const info = Taro.getStorageSync('userInfo')
|
const info = Taro.getStorageSync('userInfo')
|
||||||
if (info) {
|
if (info) {
|
||||||
setUserInfo(info)
|
setUserInfo(info)
|
||||||
fetchEnrollments()
|
fetchData()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ export default function UserIndex() {
|
|||||||
const res = await silentLogin()
|
const res = await silentLogin()
|
||||||
if (res) {
|
if (res) {
|
||||||
setUserInfo(res)
|
setUserInfo(res)
|
||||||
fetchEnrollments()
|
fetchData()
|
||||||
}
|
}
|
||||||
Taro.stopPullDownRefresh()
|
Taro.stopPullDownRefresh()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -34,14 +35,28 @@ export default function UserIndex() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const fetchEnrollments = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getMyEnrollments()
|
const [enrollRes, projectRes] = await Promise.all([
|
||||||
if (Array.isArray(res)) {
|
getMyEnrollments(),
|
||||||
setMyEnrollments(res)
|
getProjects()
|
||||||
|
])
|
||||||
|
|
||||||
|
let enrollments: any[] = []
|
||||||
|
if (Array.isArray(enrollRes)) {
|
||||||
|
enrollments = enrollRes
|
||||||
|
setMyEnrollments(enrollRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allProjects = (projectRes.results || projectRes) as any[]
|
||||||
|
if (Array.isArray(allProjects) && enrollments.length > 0) {
|
||||||
|
// 筛选出属于我的项目 (通过 enrollment id 匹配)
|
||||||
|
const myEnrollmentIds = enrollments.map(e => e.id)
|
||||||
|
const mine = allProjects.filter(p => myEnrollmentIds.includes(p.contestant))
|
||||||
|
setMyProjects(mine)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Fetch enrollments failed', e)
|
console.error('Fetch data failed', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +71,21 @@ export default function UserIndex() {
|
|||||||
const goUploadProject = () => {
|
const goUploadProject = () => {
|
||||||
// 找到所有有效的选手报名
|
// 找到所有有效的选手报名
|
||||||
const contestantEnrollments = myEnrollments.filter(e => e.role === 'contestant')
|
const contestantEnrollments = myEnrollments.filter(e => e.role === 'contestant')
|
||||||
|
|
||||||
if (contestantEnrollments.length === 1) {
|
if (contestantEnrollments.length === 1) {
|
||||||
// 如果只有一个,直接去详情页
|
const enrollment = contestantEnrollments[0]
|
||||||
Taro.navigateTo({ url: `/pages/competition/detail?id=${contestantEnrollments[0].competition}` })
|
// 查找该报名对应的项目
|
||||||
|
const project = myProjects.find(p => p.contestant === enrollment.id)
|
||||||
|
|
||||||
|
if (project) {
|
||||||
|
// 已有项目,去编辑
|
||||||
|
Taro.navigateTo({ url: `/pages/competition/project?id=${project.id}` })
|
||||||
} else {
|
} else {
|
||||||
// 否则去列表页
|
// 无项目,去新建
|
||||||
|
Taro.navigateTo({ url: `/pages/competition/project?competitionId=${enrollment.competition}` })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 多个比赛或无比赛,去列表页让用户选
|
||||||
Taro.navigateTo({ url: '/pages/competition/index' })
|
Taro.navigateTo({ url: '/pages/competition/index' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user