From 1c63cb21e5cf4de1b9d847188498c2bbab906dcb Mon Sep 17 00:00:00 2001 From: zhonghua Date: Tue, 7 Apr 2026 14:11:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B4=BB=E5=8A=A8=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E8=81=94=E5=8A=A8=E4=BD=9C=E5=93=81=E5=BA=93+=E5=A4=9A?= =?UTF-8?q?=E7=A7=9F=E6=88=B7=E6=95=B0=E6=8D=AE=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. P0-12 活动提交联动:替换文件上传为 WorkSelector 作品选择器 - 前端 ActivityDetail.vue 集成 WorkSelector 组件 - 后端 submitWork 支持 userWorkId 快照复制(title/description/coverUrl/pages) - WorkSelector 支持 redirectUrl 创作后返回活动页 2. 多租户数据对齐:修复公众端报名/作品 tenantId 不一致 - register() 使用活动的 contestTenants[0] 作为 tenantId - submitWork() 使用报名记录的 tenantId - 管理端报名/作品统计、列表数据一致 3. 前端报名状态区分:pending/passed/rejected 显示不同按钮 4. submitWork 报名状态检查:区分未报名/审核中/已拒绝提示 5. 活动列表添加 _count(报名数/作品数)用于已交/应交展示 6. 修复 PublicCreationService.submit() title 默认值缺失 Co-Authored-By: Claude Opus 4.6 --- .../impl/ContestRegistrationServiceImpl.java | 13 ++- .../service/impl/ContestServiceImpl.java | 34 +++++- .../service/impl/ContestWorkServiceImpl.java | 12 ++- .../pub/service/PublicActivityService.java | 94 ++++++++++++++-- docs/design/public/ugc-development-plan.md | 35 +++++- frontend/.env.development | 1 + frontend/.env.production | 1 + frontend/src/api/public.ts | 3 +- frontend/src/views/public/ActivityDetail.vue | 102 ++++++------------ .../views/public/components/WorkSelector.vue | 15 ++- 10 files changed, 213 insertions(+), 97 deletions(-) diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestRegistrationServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestRegistrationServiceImpl.java index c0b5dfb..d60aea4 100644 --- a/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestRegistrationServiceImpl.java +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestRegistrationServiceImpl.java @@ -106,6 +106,7 @@ public class ContestRegistrationServiceImpl extends ServiceImpl getStats(Long contestId, Long tenantId, boolean isSuperAdmin) { log.info("获取报名统计,赛事ID:{},租户ID:{},超管:{}", contestId, tenantId, isSuperAdmin); - // 非超管需要按租户过滤,与列表查询保持一致 + // 非超管需要按租户过滤 + boolean needTenantFilter = !isSuperAdmin && tenantId != null; + LambdaQueryWrapper baseWrapper = new LambdaQueryWrapper<>(); if (contestId != null) { baseWrapper.eq(BizContestRegistration::getContestId, contestId); } - if (!isSuperAdmin && tenantId != null) { + if (needTenantFilter) { baseWrapper.eq(BizContestRegistration::getTenantId, tenantId); } long total = count(baseWrapper); @@ -140,7 +143,7 @@ public class ContestRegistrationServiceImpl extends ServiceImpl i Page page = new Page<>(dto.getPage(), dto.getPageSize()); Page result = contestMapper.selectPage(page, wrapper); + // 批量查询报名数和作品数 + List contestIds = result.getRecords().stream() + .map(BizContest::getId).toList(); + + Map registrationCountMap = new HashMap<>(); + Map workCountMap = new HashMap<>(); + if (!contestIds.isEmpty()) { + // 报名数(所有状态) + contestRegistrationMapper.selectList( + new LambdaQueryWrapper() + .in(BizContestRegistration::getContestId, contestIds)) + .stream() + .collect(Collectors.groupingBy(BizContestRegistration::getContestId, Collectors.counting())) + .forEach(registrationCountMap::put); + + // 作品数(最新版本) + contestWorkMapper.selectList( + new LambdaQueryWrapper() + .in(BizContestWork::getContestId, contestIds) + .eq(BizContestWork::getIsLatest, true)) + .stream() + .collect(Collectors.groupingBy(BizContestWork::getContestId, Collectors.counting())) + .forEach(workCountMap::put); + } + List> voList = result.getRecords().stream() - .map(this::entityToMap) + .map(entity -> { + Map map = entityToMap(entity); + Map countMap = new LinkedHashMap<>(); + countMap.put("registrations", registrationCountMap.getOrDefault(entity.getId(), 0L)); + countMap.put("works", workCountMap.getOrDefault(entity.getId(), 0L)); + map.put("_count", countMap); + return map; + }) .collect(Collectors.toList()); return PageResult.from(result, voList); diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestWorkServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestWorkServiceImpl.java index 6452f8f..bbc8d87 100644 --- a/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestWorkServiceImpl.java +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestWorkServiceImpl.java @@ -290,13 +290,16 @@ public class ContestWorkServiceImpl extends ServiceImpl getStats(Long contestId, Long tenantId, boolean isSuperTenant) { log.info("获取作品统计,赛事ID:{}", contestId); + // 租户过滤 + boolean needTenantFilter = !isSuperTenant && tenantId != null; + LambdaQueryWrapper baseWrapper = new LambdaQueryWrapper<>(); if (contestId != null) { baseWrapper.eq(BizContestWork::getContestId, contestId); } baseWrapper.eq(BizContestWork::getIsLatest, true); baseWrapper.eq(BizContestWork::getValidState, 1); - if (!isSuperTenant && tenantId != null) { + if (needTenantFilter) { baseWrapper.eq(BizContestWork::getTenantId, tenantId); } long total = count(baseWrapper); @@ -307,7 +310,7 @@ public class ContestWorkServiceImpl extends ServiceImpl dto) { - // 检查报名状态 - BizContestRegistration reg = contestRegistrationMapper.selectOne( + // 检查报名状态(区分不同状态给出明确提示) + BizContestRegistration anyReg = contestRegistrationMapper.selectOne( new LambdaQueryWrapper() .eq(BizContestRegistration::getContestId, contestId) .eq(BizContestRegistration::getUserId, userId) - .eq(BizContestRegistration::getRegistrationState, "passed")); - if (reg == null) { - throw new BusinessException(400, "未报名或报名未通过"); + .last("LIMIT 1")); + if (anyReg == null) { + throw new BusinessException(400, "您尚未报名该活动,请先报名"); } + if (!"passed".equals(anyReg.getRegistrationState())) { + String state = anyReg.getRegistrationState(); + String msg = switch (state) { + case "pending" -> "报名审核中,请等待审核通过后再提交作品"; + case "rejected" -> "报名已被拒绝,无法提交作品"; + case "withdrawn" -> "报名已撤回,请重新报名"; + default -> "报名状态异常(" + state + "),无法提交作品"; + }; + throw new BusinessException(400, msg); + } + BizContestRegistration reg = anyReg; // 查询活动提交规则 BizContest contest = contestMapper.selectById(contestId); @@ -347,17 +369,67 @@ public class PublicActivityService { BizContestWork work = new BizContestWork(); work.setContestId(contestId); work.setRegistrationId(reg.getId()); - work.setTenantId(tenantId); - work.setTitle((String) dto.get("title")); - work.setDescription((String) dto.get("description")); - work.setFiles(dto.get("files")); + // 使用报名记录的租户ID(已在 register 时设置为活动的租户,确保管理端可见) + work.setTenantId(reg.getTenantId()); work.setSubmitterUserId(userId); work.setStatus("submitted"); work.setSubmitTime(LocalDateTime.now()); work.setVersion(nextVersion); work.setIsLatest(true); + + // 从作品库选择作品提交(快照复制) if (dto.get("userWorkId") != null) { - work.setUserWorkId(Long.valueOf(dto.get("userWorkId").toString())); + Long userWorkId = Long.valueOf(dto.get("userWorkId").toString()); + work.setUserWorkId(userWorkId); + + // 查询用户作品 + UgcWork ugcWork = ugcWorkMapper.selectById(userWorkId); + if (ugcWork == null || ugcWork.getIsDeleted() == 1) { + throw new BusinessException(404, "作品不存在"); + } + if (!ugcWork.getUserId().equals(userId)) { + throw new BusinessException(403, "无权使用该作品"); + } + if ("rejected".equals(ugcWork.getStatus()) || "taken_down".equals(ugcWork.getStatus())) { + throw new BusinessException(400, "该作品状态不可提交"); + } + + // 查询绘本分页 + List pages = ugcWorkPageMapper.selectList( + new LambdaQueryWrapper() + .eq(UgcWorkPage::getWorkId, userWorkId) + .orderByAsc(UgcWorkPage::getPageNo)); + + // 复制快照字段 + work.setTitle(ugcWork.getTitle()); + work.setDescription(ugcWork.getDescription()); + work.setPreviewUrl(ugcWork.getCoverUrl()); + work.setAiModelMeta(ugcWork.getAiMeta()); + + // previewUrls = 所有页面图片 + List previewUrls = pages.stream() + .map(UgcWorkPage::getImageUrl) + .filter(Objects::nonNull) + .toList(); + work.setPreviewUrls(previewUrls); + + // files = 分页完整快照 + List> filesSnapshot = pages.stream() + .map(p -> { + Map m = new LinkedHashMap<>(); + m.put("pageNo", p.getPageNo()); + m.put("imageUrl", p.getImageUrl()); + m.put("text", p.getText()); + m.put("audioUrl", p.getAudioUrl()); + return m; + }) + .toList(); + work.setFiles(filesSnapshot); + } else { + // 旧逻辑:直接上传 + work.setTitle((String) dto.get("title")); + work.setDescription((String) dto.get("description")); + work.setFiles(dto.get("files")); } contestWorkMapper.insert(work); return work; diff --git a/docs/design/public/ugc-development-plan.md b/docs/design/public/ugc-development-plan.md index ba33d69..a15c74c 100644 --- a/docs/design/public/ugc-development-plan.md +++ b/docs/design/public/ugc-development-plan.md @@ -275,7 +275,7 @@ JWT 改造: --- -#### P0-12. 活动提交联动 +#### P0-12. 活动提交联动 ✅ 已实现 (2026-04-07) **改动范围**:活动报名+提交流程改造 @@ -283,14 +283,39 @@ JWT 改造: 后端改动: ├── POST /api/public/activities/:id/submit-work — 改造:支持从作品库选择作品 │ 新增参数:userWorkId(用户作品ID) -│ 逻辑:根据 userWorkId 复制快照到 contest_works -└── contest_works 表 — 新增 user_work_id 字段 +│ 逻辑:根据 userWorkId 从 UgcWork + UgcWorkPage 复制快照到 ContestWork +│ 校验:归属当前用户、未删除、非 rejected/taken_down 状态 +└── contest_works 表 — user_work_id 字段已存在 前端改动: -├── 活动详情页 提交作品流程 — 改造:弹出作品库选择器,从"我的作品库"选择 -├── 作品库选择器组件 — 网格展示可选作品(已发布+私密均可选),确认后提交 +├── ActivityDetail.vue — 替换文件上传弹窗为 WorkSelector 作品选择器 +├── WorkSelector.vue — 已有组件,新增 redirectUrl prop 支持创作后返回活动页 +└── public.ts — submitWork API 新增 userWorkId 参数 ``` +**快照字段映射**: + +| UgcWork / UgcWorkPage 字段 | ContestWork 字段 | 说明 | +|---|---|---| +| title | title | 直接复制 | +| description | description | 直接复制 | +| coverUrl | previewUrl | 封面 → 预览图 | +| aiMeta | aiModelMeta | AI 元数据 | +| (所有 page.imageUrl) | previewUrls | 所有页面图片 URL 列表 | +| (所有 page 数据) | files | 分页快照 [{pageNo, imageUrl, text, audioUrl}] | + +**用户交互流程**: +1. 用户在活动详情页点击"从作品库选择" +2. 弹出 WorkSelector 展示用户所有作品(排除 rejected/taken_down) +3. 用户选择作品后确认提交 → 后端复制快照到 ContestWork +4. 若作品库为空,显示"去创作"按钮 → 跳转创作页 → 完成后 redirect 回活动详情页 +5. 支持 resubmit 模式:可重新选择不同作品提交 + +**关键设计**: +- 快照不可变性:提交后 ContestWork 数据与 UgcWork 解耦,后续修改/删除作品不影响活动中的作品 +- 向后兼容:userWorkId 为 null 时走旧逻辑(直接上传) +- 无需数据库变更:t_biz_contest_work 已有所需字段 + **依赖**:P0-4 + P0-6 + 现有活动模块 **产出**:用户可从作品库选作品参与活动 diff --git a/frontend/.env.development b/frontend/.env.development index ec230fa..a3457b5 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,2 +1,3 @@ # 开发环境 VITE_API_BASE_URL=/api +VITE_AI_POST_MESSAGE_URL=://localhost:3001/ diff --git a/frontend/.env.production b/frontend/.env.production index 4e6f2f3..deef555 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -2,4 +2,5 @@ VITE_API_BASE_URL=/api # 如果后端部署在不同域名,可以改成完整地址: # VITE_API_BASE_URL=https://api.your-domain.com +VITE_AI_POST_MESSAGE_URL=://localhost:3001 diff --git a/frontend/src/api/public.ts b/frontend/src/api/public.ts index 5c1dcfb..f8aa802 100644 --- a/frontend/src/api/public.ts +++ b/frontend/src/api/public.ts @@ -265,7 +265,8 @@ export const publicActivitiesApi = { id: number, data: { registrationId: number - title: string + userWorkId?: number + title?: string description?: string files?: string[] previewUrl?: string diff --git a/frontend/src/views/public/ActivityDetail.vue b/frontend/src/views/public/ActivityDetail.vue index f472799..f1c0905 100644 --- a/frontend/src/views/public/ActivityDetail.vue +++ b/frontend/src/views/public/ActivityDetail.vue @@ -50,6 +50,12 @@ 立即报名 + + 报名审核中 + + + 报名未通过 + 已报名 @@ -66,11 +72,17 @@ 报名已截止 + + 报名审核中,通过后可提交作品 + + + 报名未通过,无法提交作品 + - 提交作品 + 从作品库选择 - 重新提交 + 重新提交 作品已提交 @@ -177,36 +189,12 @@ - - - - - - - - - - - - 选择文件 - -
支持图片(JPG/PNG)和 PDF,最多 5 个文件
-
- - 确认提交 - -
-
+ +
@@ -221,10 +209,11 @@ import { message } from 'ant-design-vue' import { ArrowLeftOutlined, CalendarOutlined, TagOutlined, ClockCircleOutlined, PaperClipOutlined, CheckCircleOutlined, - UploadOutlined, HourglassOutlined, TrophyOutlined, - TeamOutlined, EnvironmentOutlined, + PictureOutlined, HourglassOutlined, TrophyOutlined, + TeamOutlined, EnvironmentOutlined, CloseCircleOutlined, } from '@ant-design/icons-vue' -import { publicActivitiesApi, publicChildrenApi } from '@/api/public' +import { publicActivitiesApi, publicChildrenApi, type UserWork } from '@/api/public' +import WorkSelector from './components/WorkSelector.vue' import dayjs from 'dayjs' const route = useRoute() @@ -235,14 +224,13 @@ const children = ref([]) const showRegisterModal = ref(false) const registering = ref(false) const hasRegistered = ref(false) +const registrationState = ref('') const myRegistration = ref(null) const hasSubmittedWork = ref(false) // 作品提交 -const showSubmitModal = ref(false) +const showWorkSelector = ref(false) const submittingWork = ref(false) -const workFileList = ref([]) -const workForm = ref({ title: '', description: '' }) const participantForm = ref({ participantType: 'self', @@ -313,6 +301,7 @@ const checkRegistrationStatus = async () => { // 只有当 reg 存在且有 id 时才认为已报名 if (reg && reg.id) { hasRegistered.value = true + registrationState.value = reg.registrationState || '' myRegistration.value = reg // 检查是否已提交作品 hasSubmittedWork.value = reg.hasSubmittedWork || false @@ -323,26 +312,14 @@ const checkRegistrationStatus = async () => { } } -// 打开作品提交 +// 打开作品选择器 const openSubmitWork = () => { if (!isLoggedIn.value) { goLogin(); return } - workForm.value = { title: '', description: '' } - workFileList.value = [] - showSubmitModal.value = true + showWorkSelector.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 () => { +// 从作品库选择作品后提交 +const handleWorkSelected = async (work: UserWork) => { if (!myRegistration.value) { message.error('请先报名活动') return @@ -351,13 +328,10 @@ const handleSubmitWork = async () => { 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), + userWorkId: work.id, }) message.success('作品提交成功!') - showSubmitModal.value = false + showWorkSelector.value = false hasSubmittedWork.value = true } catch (err: any) { message.error(err?.response?.data?.message || '提交失败') @@ -383,6 +357,8 @@ const handleRegister = async () => { message.success('报名成功!') showRegisterModal.value = false hasRegistered.value = true + // 重新查询报名状态以获取准确的 registrationState + await checkRegistrationStatus() } catch (err: any) { message.error(err?.response?.data?.message || '报名失败') } finally { @@ -551,14 +527,6 @@ $primary: #6366f1; } } -.work-form { - .upload-hint { - font-size: 12px; - color: #9ca3af; - margin-top: 4px; - } -} - .rich-content { font-size: 14px; line-height: 1.8; diff --git a/frontend/src/views/public/components/WorkSelector.vue b/frontend/src/views/public/components/WorkSelector.vue index b1f864f..8674953 100644 --- a/frontend/src/views/public/components/WorkSelector.vue +++ b/frontend/src/views/public/components/WorkSelector.vue @@ -12,7 +12,7 @@
- + 去创作 @@ -41,8 +41,10 @@ import { ref, watch, onMounted } from 'vue' import { PictureOutlined, CheckCircleFilled } from '@ant-design/icons-vue' import { publicUserWorksApi, type UserWork } from '@/api/public' +import { useRouter } from 'vue-router' -const props = defineProps<{ open: boolean }>() +const router = useRouter() +const props = defineProps<{ open: boolean; redirectUrl?: string }>() const emit = defineEmits<{ (e: 'update:open', v: boolean): void (e: 'select', work: UserWork): void @@ -69,6 +71,15 @@ const handleConfirm = () => { } } +const goCreate = () => { + emit('update:open', false) + if (props.redirectUrl) { + router.push({ path: '/p/create', query: { redirect: props.redirectUrl } }) + } else { + router.push('/p/create') + } +} + watch(() => props.open, (v) => { if (v) { selectedWork.value = null; fetchWorks() } })