library-picturebook-activity/frontend/src/views/public/ActivityDetail.vue
aid 418aa57ea8 Day4: 超管端设计优化 + UGC绘本创作社区P0实现
一、超管端设计优化
- 文档管理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>
2026-03-27 22:20:25 +08:00

652 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>