一、超管端设计优化 - 文档管理SOP体系建立,docs目录重组 - 统一用户管理:跨租户全局视角,合并用户管理+公众用户 - 活动监管全模块重构:全部活动(统计卡片+阶段筛选+SuperDetail详情页)、报名数据/作品数据/评审进度(两层合一扁平列表)、成果发布(去Tab+统计+隐藏写操作) - 菜单精简:移除评委管理/评审规则/通知管理 - Bug修复:租户编辑丢失隐藏菜单、pageSize限制、主色统一 二、UGC绘本创作社区P0 - 数据库:10张新表(user_works/user_work_pages/work_tags等) - 子女账号独立化:Child升级为独立User,家长切换+独立登录 - 用户作品库:CRUD+发布审核,8个API - AI创作流程:提交→生成→保存到作品库,4个API - 作品广场:首页改造为推荐流,标签+搜索+排序 - 内容审核(超管端):作品审核+作品管理+标签管理 - 活动联动:WorkSelector作品选择器 - 布局改造:底部5Tab(发现/创作/活动/作品库/我的) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
652 lines
18 KiB
Vue
652 lines
18 KiB
Vue
<template>
|
||
<div class="detail-page" v-if="activity">
|
||
<!-- 活动头部 -->
|
||
<div class="detail-hero">
|
||
<img v-if="activity.coverUrl" :src="activity.coverUrl" class="hero-cover" />
|
||
<div v-else class="hero-placeholder">
|
||
<span>{{ activity.contestName?.charAt(0) }}</span>
|
||
</div>
|
||
<div class="hero-overlay">
|
||
<a-button shape="round" size="small" @click="$router.back()" class="back-btn">
|
||
<arrow-left-outlined /> 返回
|
||
</a-button>
|
||
<div class="hero-badge">{{ stageLabel }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 活动信息 -->
|
||
<div class="detail-card">
|
||
<h1 class="detail-title">{{ activity.contestName }}</h1>
|
||
<div class="detail-meta">
|
||
<div class="meta-row">
|
||
<calendar-outlined />
|
||
<span>{{ formatDate(activity.startTime) }} - {{ formatDate(activity.endTime) }}</span>
|
||
</div>
|
||
<div class="meta-row">
|
||
<tag-outlined />
|
||
<span>{{ activity.contestType === 'individual' ? '个人参与' : '团队参与' }}</span>
|
||
</div>
|
||
<div class="meta-row" v-if="activity.registerStartTime">
|
||
<clock-circle-outlined />
|
||
<span>报名:{{ formatDate(activity.registerStartTime) }} - {{ formatDate(activity.registerEndTime) }}</span>
|
||
</div>
|
||
<div class="meta-row" v-if="activity.ageMin || activity.ageMax">
|
||
<team-outlined />
|
||
<span>年龄要求:{{ activity.ageMin || 0 }} - {{ activity.ageMax || '不限' }} 岁</span>
|
||
</div>
|
||
<div class="meta-row" v-if="activity.targetCities?.length">
|
||
<environment-outlined />
|
||
<span>面向城市:{{ (activity.targetCities as string[]).join('、') }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮(根据阶段动态展示) -->
|
||
<div class="action-area">
|
||
<!-- 报名阶段 -->
|
||
<template v-if="currentStage === 'register'">
|
||
<a-button v-if="!isLoggedIn" type="primary" size="large" block class="action-btn" @click="goLogin">
|
||
登录后报名
|
||
</a-button>
|
||
<a-button v-else-if="!hasRegistered" type="primary" size="large" block class="action-btn" @click="showRegisterModal = true">
|
||
立即报名
|
||
</a-button>
|
||
<a-button v-else size="large" block class="action-btn-done">
|
||
<check-circle-outlined /> 已报名,等待提交阶段
|
||
</a-button>
|
||
</template>
|
||
|
||
<!-- 提交阶段 -->
|
||
<template v-else-if="currentStage === 'submit'">
|
||
<a-button v-if="hasRegistered && !hasSubmittedWork" type="primary" size="large" block class="action-btn" @click="openSubmitWork">
|
||
<upload-outlined /> 提交作品
|
||
</a-button>
|
||
<a-button v-else-if="hasSubmittedWork" size="large" block class="action-btn-done">
|
||
<check-circle-outlined /> 作品已提交
|
||
</a-button>
|
||
<a-button v-else size="large" block disabled>
|
||
需先报名才能提交作品
|
||
</a-button>
|
||
</template>
|
||
|
||
<!-- 评审阶段 -->
|
||
<template v-else-if="currentStage === 'review'">
|
||
<a-button size="large" block class="action-btn-info">
|
||
<hourglass-outlined /> 评审中,请耐心等待
|
||
</a-button>
|
||
</template>
|
||
|
||
<!-- 已结束 -->
|
||
<template v-else-if="currentStage === 'finished'">
|
||
<a-button type="primary" size="large" block class="action-btn" @click="activeTab = 'results'">
|
||
查看成果
|
||
</a-button>
|
||
</template>
|
||
|
||
<!-- 未开始 -->
|
||
<template v-else>
|
||
<a-button size="large" block disabled>
|
||
活动即将开始
|
||
</a-button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 内容 -->
|
||
<div class="detail-card">
|
||
<a-tabs v-model:activeKey="activeTab">
|
||
<a-tab-pane key="info" tab="活动详情">
|
||
<div class="rich-content" v-html="activity.description"></div>
|
||
<!-- 附件 -->
|
||
<div v-if="activity.attachments?.length" class="attachments">
|
||
<h4>相关附件</h4>
|
||
<div v-for="att in activity.attachments" :key="att.id" class="att-item">
|
||
<a :href="att.fileUrl" target="_blank">
|
||
<paper-clip-outlined /> {{ att.fileName }}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</a-tab-pane>
|
||
<a-tab-pane key="results" tab="活动成果" v-if="activity.resultState === 'published'">
|
||
<div class="results-hint">
|
||
<trophy-outlined class="results-icon" />
|
||
<p>活动成果已发布,敬请查看!</p>
|
||
</div>
|
||
</a-tab-pane>
|
||
<a-tab-pane key="notices" tab="活动公告">
|
||
<div v-if="!activity.notices?.length" class="empty-tab">
|
||
<a-empty description="暂无公告" />
|
||
</div>
|
||
<div v-else>
|
||
<div v-for="notice in activity.notices" :key="notice.id" class="notice-item">
|
||
<h4>{{ notice.title }}</h4>
|
||
<div class="notice-content" v-html="notice.content"></div>
|
||
<span class="notice-time">{{ formatDate(notice.createTime) }}</span>
|
||
</div>
|
||
</div>
|
||
</a-tab-pane>
|
||
</a-tabs>
|
||
</div>
|
||
|
||
<!-- 报名弹窗 -->
|
||
<a-modal
|
||
v-model:open="showRegisterModal"
|
||
title="活动报名"
|
||
:footer="null"
|
||
:width="420"
|
||
>
|
||
<div class="register-modal">
|
||
<p class="modal-desc">请选择参与者:</p>
|
||
<a-radio-group v-model:value="participantForm.participantType" class="participant-options">
|
||
<div class="participant-option">
|
||
<a-radio value="self">我自己</a-radio>
|
||
</div>
|
||
<template v-if="children.length">
|
||
<div v-for="child in children" :key="child.id" class="participant-option">
|
||
<a-radio :value="'child_' + child.id">
|
||
子女:{{ child.name }}
|
||
<span class="child-detail" v-if="child.grade">({{ child.grade }})</span>
|
||
</a-radio>
|
||
</div>
|
||
</template>
|
||
</a-radio-group>
|
||
<a-button type="link" @click="$router.push('/p/mine/children')" class="add-child-link">
|
||
+ 添加新的子女
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
block
|
||
size="large"
|
||
:loading="registering"
|
||
@click="handleRegister"
|
||
class="confirm-btn"
|
||
>
|
||
确认报名
|
||
</a-button>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 作品提交弹窗 -->
|
||
<a-modal
|
||
v-model:open="showSubmitModal"
|
||
title="提交作品"
|
||
:footer="null"
|
||
:width="520"
|
||
>
|
||
<a-form :model="workForm" layout="vertical" @finish="handleSubmitWork" class="work-form">
|
||
<a-form-item label="作品名称" name="title" :rules="[{ required: true, message: '请输入作品名称' }]">
|
||
<a-input v-model:value="workForm.title" placeholder="给你的作品取个名字吧" :maxlength="200" />
|
||
</a-form-item>
|
||
<a-form-item label="作品描述" name="description">
|
||
<a-textarea v-model:value="workForm.description" placeholder="描述一下你的创作思路" :rows="3" :maxlength="2000" />
|
||
</a-form-item>
|
||
<a-form-item label="上传作品文件" name="files">
|
||
<a-upload
|
||
:before-upload="handleFileUpload"
|
||
:file-list="workFileList"
|
||
:max-count="5"
|
||
@remove="handleFileRemove"
|
||
>
|
||
<a-button><upload-outlined /> 选择文件</a-button>
|
||
</a-upload>
|
||
<div class="upload-hint">支持图片(JPG/PNG)和 PDF,最多 5 个文件</div>
|
||
</a-form-item>
|
||
<a-button type="primary" html-type="submit" block size="large" :loading="submittingWork" class="confirm-btn">
|
||
确认提交
|
||
</a-button>
|
||
</a-form>
|
||
</a-modal>
|
||
</div>
|
||
|
||
<div v-else class="loading-page">
|
||
<a-spin size="large" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { message } from 'ant-design-vue'
|
||
import {
|
||
ArrowLeftOutlined, CalendarOutlined, TagOutlined,
|
||
ClockCircleOutlined, PaperClipOutlined, CheckCircleOutlined,
|
||
UploadOutlined, HourglassOutlined, TrophyOutlined,
|
||
TeamOutlined, EnvironmentOutlined,
|
||
} from '@ant-design/icons-vue'
|
||
import { publicActivitiesApi, publicChildrenApi } from '@/api/public'
|
||
import dayjs from 'dayjs'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const activity = ref<any>(null)
|
||
const activeTab = ref('info')
|
||
const children = ref<any[]>([])
|
||
const showRegisterModal = ref(false)
|
||
const registering = ref(false)
|
||
const hasRegistered = ref(false)
|
||
const myRegistration = ref<any>(null)
|
||
const hasSubmittedWork = ref(false)
|
||
|
||
// 作品提交
|
||
const showSubmitModal = ref(false)
|
||
const submittingWork = ref(false)
|
||
const workFileList = ref<any[]>([])
|
||
const workForm = ref({ title: '', description: '' })
|
||
|
||
const participantForm = ref({
|
||
participantType: 'self',
|
||
})
|
||
|
||
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
|
||
|
||
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
||
|
||
// 活动当前阶段
|
||
const currentStage = computed(() => {
|
||
if (!activity.value) return 'pending'
|
||
const now = dayjs()
|
||
const a = activity.value
|
||
if (now.isBefore(a.registerStartTime)) return 'pending'
|
||
if (now.isBefore(a.registerEndTime)) return 'register'
|
||
if (a.submitStartTime && now.isBefore(a.submitEndTime)) return 'submit'
|
||
if (a.reviewStartTime && now.isBefore(a.reviewEndTime)) return 'review'
|
||
if (a.status === 'finished' || a.resultState === 'published') return 'finished'
|
||
return 'review'
|
||
})
|
||
|
||
const stageLabel = computed(() => {
|
||
const map: Record<string, string> = {
|
||
pending: '即将开始', register: '报名中', submit: '提交中',
|
||
review: '评审中', finished: '已结束',
|
||
}
|
||
return map[currentStage.value] || '进行中'
|
||
})
|
||
|
||
const goLogin = () => router.push({ path: '/p/login', query: { redirect: route.fullPath } })
|
||
|
||
const fetchDetail = async () => {
|
||
const id = Number(route.params.id)
|
||
try {
|
||
activity.value = await publicActivitiesApi.detail(id)
|
||
} catch {
|
||
message.error('活动不存在')
|
||
router.push('/p/activities')
|
||
}
|
||
}
|
||
|
||
const fetchChildren = async () => {
|
||
if (!isLoggedIn.value) return
|
||
try {
|
||
children.value = await publicChildrenApi.list()
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
// 检查报名状态和作品提交状态
|
||
const checkRegistrationStatus = async () => {
|
||
if (!isLoggedIn.value || !activity.value) return
|
||
try {
|
||
const reg = await publicActivitiesApi.getMyRegistration(activity.value.id)
|
||
if (reg) {
|
||
hasRegistered.value = true
|
||
myRegistration.value = reg
|
||
// 检查是否已提交作品
|
||
const worksRes = await import('@/api/public').then(m => m.publicMineApi.works({ page: 1, pageSize: 100 }))
|
||
if (worksRes?.list) {
|
||
hasSubmittedWork.value = worksRes.list.some((w: any) => w.contest?.id === activity.value.id)
|
||
}
|
||
}
|
||
} catch { /* not registered */ }
|
||
}
|
||
|
||
// 打开作品提交
|
||
const openSubmitWork = () => {
|
||
if (!isLoggedIn.value) { goLogin(); return }
|
||
workForm.value = { title: '', description: '' }
|
||
workFileList.value = []
|
||
showSubmitModal.value = true
|
||
}
|
||
|
||
// 文件上传(暂时存到 fileList,提交时一起处理)
|
||
const handleFileUpload = (file: any) => {
|
||
workFileList.value = [...workFileList.value, file]
|
||
return false // 阻止自动上传
|
||
}
|
||
|
||
const handleFileRemove = (file: any) => {
|
||
workFileList.value = workFileList.value.filter((f: any) => f.uid !== file.uid)
|
||
}
|
||
|
||
// 提交作品
|
||
const handleSubmitWork = async () => {
|
||
if (!myRegistration.value) {
|
||
message.error('请先报名活动')
|
||
return
|
||
}
|
||
submittingWork.value = true
|
||
try {
|
||
await publicActivitiesApi.submitWork(activity.value.id, {
|
||
registrationId: myRegistration.value.id,
|
||
title: workForm.value.title,
|
||
description: workForm.value.description || undefined,
|
||
// 文件上传需要先上传到 COS,此处简化为记录文件名
|
||
files: workFileList.value.map((f: any) => f.name),
|
||
})
|
||
message.success('作品提交成功!')
|
||
showSubmitModal.value = false
|
||
hasSubmittedWork.value = true
|
||
} catch (err: any) {
|
||
message.error(err?.response?.data?.message || '提交失败')
|
||
} finally {
|
||
submittingWork.value = false
|
||
}
|
||
}
|
||
|
||
const handleRegister = async () => {
|
||
if (!isLoggedIn.value) {
|
||
router.push({ path: '/p/login', query: { redirect: route.fullPath } })
|
||
return
|
||
}
|
||
|
||
registering.value = true
|
||
try {
|
||
const val = participantForm.value.participantType
|
||
const isChild = val.startsWith('child_')
|
||
await publicActivitiesApi.register(activity.value.id, {
|
||
participantType: isChild ? 'child' : 'self',
|
||
childId: isChild ? parseInt(val.replace('child_', '')) : undefined,
|
||
})
|
||
message.success('报名成功!')
|
||
showRegisterModal.value = false
|
||
hasRegistered.value = true
|
||
} catch (err: any) {
|
||
message.error(err?.response?.data?.message || '报名失败')
|
||
} finally {
|
||
registering.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await fetchDetail()
|
||
fetchChildren()
|
||
checkRegistrationStatus()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
$primary: #6366f1;
|
||
|
||
.loading-page {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 100px 0;
|
||
}
|
||
|
||
.detail-hero {
|
||
position: relative;
|
||
height: 220px;
|
||
border-radius: 20px;
|
||
overflow: hidden;
|
||
margin-bottom: 16px;
|
||
|
||
.hero-cover {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.hero-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(135deg, #c7d2fe, #fbcfe8, #a7f3d0);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
span {
|
||
font-size: 60px;
|
||
font-weight: 800;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
}
|
||
}
|
||
|
||
.hero-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(180deg, rgba(0,0,0,0.3) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.3) 100%);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
padding: 16px;
|
||
}
|
||
|
||
.back-btn {
|
||
background: rgba(255,255,255,0.2);
|
||
backdrop-filter: blur(8px);
|
||
border: 1px solid rgba(255,255,255,0.3);
|
||
color: #fff;
|
||
}
|
||
|
||
.hero-badge {
|
||
padding: 4px 16px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
background: linear-gradient(135deg, $primary, #ec4899);
|
||
}
|
||
}
|
||
|
||
.detail-card {
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
padding: 24px;
|
||
margin-bottom: 16px;
|
||
border: 1px solid rgba($primary, 0.06);
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 22px;
|
||
font-weight: 800;
|
||
color: #1e1b4b;
|
||
margin: 0 0 16px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.detail-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
margin-bottom: 20px;
|
||
|
||
.meta-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
}
|
||
}
|
||
|
||
.action-area {
|
||
.action-btn {
|
||
height: 48px !important;
|
||
border-radius: 14px !important;
|
||
font-size: 16px !important;
|
||
font-weight: 700 !important;
|
||
background: linear-gradient(135deg, $primary, #ec4899) !important;
|
||
border: none !important;
|
||
box-shadow: 0 4px 16px rgba($primary, 0.3);
|
||
}
|
||
|
||
.action-btn-done {
|
||
height: 48px !important;
|
||
border-radius: 14px !important;
|
||
font-size: 15px !important;
|
||
font-weight: 600 !important;
|
||
background: #ecfdf5 !important;
|
||
color: #059669 !important;
|
||
border: 1px solid #a7f3d0 !important;
|
||
}
|
||
|
||
.action-btn-info {
|
||
height: 48px !important;
|
||
border-radius: 14px !important;
|
||
font-size: 15px !important;
|
||
font-weight: 600 !important;
|
||
background: #eef2ff !important;
|
||
color: $primary !important;
|
||
border: 1px solid #c7d2fe !important;
|
||
}
|
||
}
|
||
|
||
.results-hint {
|
||
text-align: center;
|
||
padding: 40px 0;
|
||
|
||
.results-icon {
|
||
font-size: 48px;
|
||
color: #f59e0b;
|
||
margin-bottom: 12px;
|
||
display: block;
|
||
}
|
||
|
||
p {
|
||
font-size: 15px;
|
||
color: #6b7280;
|
||
}
|
||
}
|
||
|
||
.work-form {
|
||
.upload-hint {
|
||
font-size: 12px;
|
||
color: #9ca3af;
|
||
margin-top: 4px;
|
||
}
|
||
}
|
||
|
||
.rich-content {
|
||
font-size: 14px;
|
||
line-height: 1.8;
|
||
color: #374151;
|
||
|
||
:deep(img) {
|
||
max-width: 100%;
|
||
border-radius: 8px;
|
||
}
|
||
}
|
||
|
||
.attachments {
|
||
margin-top: 20px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid #f0ecf9;
|
||
|
||
h4 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
margin: 0 0 10px;
|
||
}
|
||
|
||
.att-item a {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: $primary;
|
||
padding: 6px 0;
|
||
}
|
||
}
|
||
|
||
.notice-item {
|
||
padding: 16px;
|
||
background: #faf9fe;
|
||
border-radius: 12px;
|
||
margin-bottom: 12px;
|
||
|
||
h4 {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #1e1b4b;
|
||
margin: 0 0 8px;
|
||
}
|
||
|
||
.notice-content {
|
||
font-size: 14px;
|
||
color: #6b7280;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.notice-time {
|
||
font-size: 12px;
|
||
color: #9ca3af;
|
||
margin-top: 8px;
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
.empty-tab {
|
||
padding: 40px 0;
|
||
}
|
||
|
||
// 报名弹窗
|
||
.register-modal {
|
||
.modal-desc {
|
||
font-size: 14px;
|
||
color: #374151;
|
||
margin: 0 0 16px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.participant-options {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
width: 100%;
|
||
}
|
||
|
||
.participant-option {
|
||
padding: 12px 16px;
|
||
background: #faf9fe;
|
||
border-radius: 12px;
|
||
transition: background 0.2s;
|
||
|
||
&:hover {
|
||
background: #eef2ff;
|
||
}
|
||
|
||
.child-detail {
|
||
color: #9ca3af;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
.add-child-link {
|
||
margin: 12px 0;
|
||
padding: 0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.confirm-btn {
|
||
margin-top: 8px;
|
||
height: 44px !important;
|
||
border-radius: 12px !important;
|
||
font-weight: 600 !important;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.detail-hero {
|
||
height: 180px;
|
||
border-radius: 16px;
|
||
}
|
||
|
||
.detail-card {
|
||
padding: 18px;
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 18px;
|
||
}
|
||
}
|
||
</style>
|