diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestResultService.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestResultService.java index 856019c..1e75b61 100644 --- a/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestResultService.java +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestResultService.java @@ -25,5 +25,10 @@ public interface IContestResultService { PageResult> getResults(Long contestId, Long page, Long pageSize, String workNo, String accountNo); + /** + * 公众端公示列表:仅活动公开且成果已发布;不含报名账号等敏感字段。 + */ + PageResult> getPublicResults(Long contestId, Long page, Long pageSize); + Map getResultsSummary(Long contestId); } diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestResultServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestResultServiceImpl.java index b648fdc..8f4188c 100644 --- a/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestResultServiceImpl.java +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestResultServiceImpl.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.competition.common.enums.ErrorCode; import com.competition.common.enums.PublishStatus; +import com.competition.common.enums.Visibility; import com.competition.common.exception.BusinessException; import com.competition.common.result.PageResult; import com.competition.modules.biz.contest.entity.BizContest; @@ -368,6 +369,81 @@ public class ContestResultServiceImpl implements IContestResultService { return PageResult.from(result, voList); } + @Override + public PageResult> getPublicResults(Long contestId, Long page, Long pageSize) { + BizContest contest = contestMapper.selectById(contestId); + if (contest == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在"); + } + if (!Visibility.PUBLIC.getValue().equals(contest.getVisibility())) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在"); + } + if (!PublishStatus.PUBLISHED.getValue().equals(contest.getResultState())) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "成果未发布"); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestWork::getContestId, contestId); + wrapper.eq(BizContestWork::getIsLatest, true); + wrapper.eq(BizContestWork::getValidState, 1); + wrapper.isNotNull(BizContestWork::getFinalScore); + wrapper.orderByDesc(BizContestWork::getFinalScore); + + Page pageObj = new Page<>(page, pageSize); + Page result = workMapper.selectPage(pageObj, wrapper); + + List records = result.getRecords(); + Set registrationIds = records.stream() + .map(BizContestWork::getRegistrationId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map registrationById = new HashMap<>(); + if (!registrationIds.isEmpty()) { + for (BizContestRegistration reg : contestRegistrationMapper.selectBatchIds(registrationIds)) { + registrationById.put(reg.getId(), reg); + } + } + + List> voList = records.stream().map(w -> { + Map map = new LinkedHashMap<>(); + map.put("id", w.getId()); + map.put("workNo", w.getWorkNo()); + map.put("title", w.getTitle()); + map.put("finalScore", w.getFinalScore()); + map.put("rank", w.getRank()); + map.put("awardName", w.getAwardName()); + BizContestRegistration reg = w.getRegistrationId() != null + ? registrationById.get(w.getRegistrationId()) + : null; + map.put("participantName", buildPublicParticipantName(contest, reg)); + return map; + }).collect(Collectors.toList()); + + return PageResult.from(result, voList); + } + + /** + * 公众展示用作者名:团队赛优先队名,否则为报名展示名(非登录账号)。 + */ + private String buildPublicParticipantName(BizContest contest, BizContestRegistration reg) { + if (reg == null) { + return "-"; + } + if ("team".equals(contest.getContestType())) { + if (StringUtils.hasText(reg.getTeamName())) { + return reg.getTeamName(); + } + return StringUtils.hasText(reg.getAccountName()) ? reg.getAccountName() : "-"; + } + if (StringUtils.hasText(reg.getAccountName())) { + return reg.getAccountName(); + } + if (StringUtils.hasText(reg.getTeamName())) { + return reg.getTeamName(); + } + return "-"; + } + @Override public Map getResultsSummary(Long contestId) { log.info("查询赛果概览,赛事ID:{}", contestId); diff --git a/backend-java/src/main/java/com/competition/modules/pub/controller/PublicActivityController.java b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicActivityController.java index c99cfdd..04455de 100644 --- a/backend-java/src/main/java/com/competition/modules/pub/controller/PublicActivityController.java +++ b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicActivityController.java @@ -6,6 +6,7 @@ import com.competition.common.util.SecurityUtil; import com.competition.modules.biz.contest.entity.BizContest; import com.competition.modules.biz.contest.entity.BizContestRegistration; import com.competition.modules.biz.contest.entity.BizContestWork; +import com.competition.modules.biz.review.service.IContestResultService; import com.competition.modules.pub.dto.PublicRegisterActivityDto; import com.competition.modules.pub.service.PublicActivityService; import com.competition.security.annotation.Public; @@ -23,6 +24,7 @@ import java.util.Map; public class PublicActivityController { private final PublicActivityService publicActivityService; + private final IContestResultService contestResultService; @Public @GetMapping @@ -42,6 +44,16 @@ public class PublicActivityController { return Result.success(publicActivityService.getActivityDetail(id)); } + @Public + @GetMapping("/{id}/results") + @Operation(summary = "公示成果列表(仅公开活动且成果已发布,无需登录)") + public Result>> getPublishedResults( + @PathVariable Long id, + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize) { + return Result.success(contestResultService.getPublicResults(id, page, pageSize)); + } + @GetMapping("/{id}/my-registration") @Operation(summary = "查询我的报名信息") public Result> getMyRegistration(@PathVariable Long id) { diff --git a/docs/design/README.md b/docs/design/README.md index c8dbac3..29c5994 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -28,6 +28,7 @@ | [UGC社区升级](./public/ugc-platform-upgrade.md) | 整体架构 | 需求已确认 | 2026-03-27 | | [UGC开发计划](./public/ugc-development-plan.md) | 开发排期 | P0已完成,P1进行中 | 2026-03-31 | | [点赞&收藏](./public/like-favorite.md) | 社区互动 | 已实现 | 2026-03-31 | +| [公众端活动成果展示](./public/activity-results-public-display.md) | 活动详情 / 成果 Tab | 需求已补充,待开发 | 2026-04-08 | ## 评委端 diff --git a/docs/design/public/activity-results-public-display.md b/docs/design/public/activity-results-public-display.md new file mode 100644 index 0000000..163a9a3 --- /dev/null +++ b/docs/design/public/activity-results-public-display.md @@ -0,0 +1,156 @@ +# 公众端活动详情 —「活动成果」Tab 设计方案 + +> 所属端:用户端(公众端 `/p/`) +> 状态:需求已补充,待开发实现 +> 创建日期:2026-04-08 +> 关联需求:[总需求 US-105](../../project/01-requirements.md)、[开发计划 P1 成果查看详情](../../project/05-development-plan.md) + +--- + +## 1. 背景与目标 + +### 1.1 问题 + +机构在管理端「成果发布」中发布活动成果后,参与者与公众需要在**公众端活动详情页**查看评审结果。当前实现仅在「活动成果」Tab 内展示奖杯图标与提示语「活动成果已发布,敬请查看!」,**未展示具体获奖名单与得分**,与总需求中「查看活动成果和评审结果」不一致。 + +### 1.2 目标 + +- 在**不降低隐私安全**的前提下,使公众端 Tab 内容与管理端已发布成果的**业务口径一致**(排名、奖项、分数来源同一套计算与数据)。 +- 将「活动成果已发布,敬请查看!」定位为**辅助提示**(如副标题或加载态提示),**主内容**为可浏览的成果列表及必要元信息。 + +### 1.3 关联文档 + +| 文档 | 说明 | +|------|------| +| [成果发布优化(超管)](../super-admin/results-publish-optimization.md) | 活动维度发布状态、列表筛选 | +| [租户端全面优化 — 成果发布 Detail](../org-admin/tenant-portal-optimization.md) | 统计摘要、排名、设奖、发布流程 | +| [UGC 社区升级](./ugc-platform-upgrade.md) | 公众端整体信息架构,本设计细化「活动」模块下成果 Tab | + +--- + +## 2. 入口与可见性 + +### 2.1 Tab 显示条件 + +- 仅当活动 **`resultState === 'published'`** 时显示「活动成果」Tab。 +- 未发布:不展示该 Tab(与当前 [ActivityDetail.vue](../../../frontend/src/views/public/ActivityDetail.vue) 逻辑一致)。 + +### 2.2 主操作区「查看成果」 + +- 活动阶段为「已结束」时,主按钮「查看成果」将 **`activeTab` 切换为 `results`**,用户在同一页查看「活动成果」Tab 内容。 +- 文档约定:**默认不跳转独立路由**;若未来产品改为「成果专页」,需单独迭代路由与返回行为。 + +### 2.3 是否需要登录 + +| 策略 | 说明 | +|------|------| +| **默认(推荐)** | **无需登录**即可查看已发布活动的成果列表,与公开浏览活动信息一致。 | +| 可选 | 若合规或机构要求「仅报名用户可见」,可在活动级增加配置,此时未登录用户看到引导登录或模糊提示;**需在实现阶段单独评审**,本文档以默认策略为验收基线。 | + +--- + +## 3. Tab 内信息架构 + +### 3.1 文案「活动成果已发布,敬请查看!」的定位 + +- **不作为唯一内容**:数据加载完成后,主区域应为成果列表。 +- 可用作:**副文案**、**骨架屏/加载中的简短说明**,或列表上方的温和提示。 + +### 3.2 主内容:成果列表 + +与管理端活动详情「活动结果」Tab([contests/Detail.vue](../../../frontend/src/views/contests/Detail.vue))**同一业务数据口径**,公众端字段可裁剪如下: + +| 字段 | 是否展示(默认) | 说明 | +|------|------------------|------| +| 名次(排名) | 是 | 与管理端排名一致 | +| 作品编号 | 是 | 若有业务编号则展示,便于用户核对 | +| 奖项 | 是 | 如一等奖、二等奖等 | +| 最终得分 | 是 | 与机构发布策略一致;若机构侧不公开分数,后端可不返回或由配置控制 | +| 姓名/展示名 | 是 | 个人:用户昵称;团队:队名 | +| 报名账号 | **否** | 见第 4 节 | + +### 3.3 元信息 + +- **成果发布时间**:展示活动级 **`resultPublishTime`**(公众活动详情接口已返回该字段,见前端 `PublicActivity` 类型)。 + +### 3.4 空状态 + +- 当 `resultState === 'published'` 但公示列表为空时:展示明确空状态文案,例如「暂无公示信息」,避免长时间加载无反馈。 + +--- + +## 4. 隐私与脱敏 + +- **默认不展示**:登录账号、手机号、邮箱等可直接识别身份或联系方式的字段。 +- **展示名**:优先使用昵称、队名等已在业务中用于对外的名称。 +- **少儿场景**:不展示精确联系方式;仅展示机构允许公示的字段。 +- 若需展示账号类信息,须为**脱敏**形式(如仅保留前后若干位),且需产品/合规确认。 + +--- + +## 5. 交互与体验 + +### 5.1 列表与分页 + +- 移动端优先:采用 **分页** 或 **加载更多** 之一,避免单次加载数据量过大。 +- 列表项在窄屏下可用卡片式排布,保证名次、奖项、得分可读。 + +### 5.2 行内「查看详情」(分期) + +| 阶段 | 范围 | +|------|------| +| **P1** | 完成公示**列表**(排名、奖项、得分、展示名、作品编号等)。 | +| **P2(可选)** | 点击行进入参赛作品/绘本预览详情,依赖作品公开范围与权限策略。 | + +### 5.3 数据一致性 + +- 排名、奖项、最终得分的**计算与更新**与租户端「成果发布」模块一致,避免公众端与后台两套数据。参见 [租户端成果发布 Detail](../org-admin/tenant-portal-optimization.md)。 + +--- + +## 6. 后端与接口(需求说明) + +### 6.1 现状 + +- 管理端使用 `GET /contests/results/{contestId}`(需 **`contest:read`**),**不适合**匿名公众直接调用。 + +### 6.2 建议新增(实现时) + +- 提供公众专用只读接口,例如: + **`GET /public/activities/{id}/results`** + - 查询参数:分页(`page` / `pageSize`),可选关键词(若需按作品编号搜索可后续扩展)。 + - **仅当**该活动 `resultState === 'published'` 时返回公示字段;否则返回空列表或 **404**,具体策略在实现时二选一并写清(推荐与详情接口状态一致:未发布则列表接口返回空列表 + 前端不展示 Tab,减少歧义)。 + +### 6.3 多租户与安全 + +- 服务端必须按活动 **`tenant_id`** 与 **`contest_id`** 限定查询,仅返回该活动下已发布结果数据,防止跨租户泄露。 +- 不依赖前端传 `tenantId` 作为信任来源。 + +--- + +## 7. 验收标准 + +| 编号 | 场景 | 预期 | +|------|------|------| +| AC-1 | 成果已发布 | 公众端「活动成果」Tab 展示与文档一致的列表字段,不含未授权敏感信息 | +| AC-2 | 成果未发布 | 不展示「活动成果」Tab | +| AC-3 | 机构撤回发布 | 活动 `resultState` 非 `published` 后,公众端不再展示该 Tab 或列表内容 | +| AC-4 | 匿名用户 | 默认策略下,无需登录即可查看已发布列表 | +| AC-5 | 发布时间 | 可看到与活动一致的成果发布时间(若接口与 UI 已接通) | + +--- + +## 8. 前端实现参考(待开发) + +| 位置 | 说明 | +|------|------| +| [frontend/src/views/public/ActivityDetail.vue](../../../frontend/src/views/public/ActivityDetail.vue) | 替换纯占位为列表 + 加载/空状态;接入公众成果 API | +| [frontend/src/api/public.ts](../../../frontend/src/api/public.ts) | 新增 `publicActivitiesApi.getPublishedResults(contestId, params)` 等 | + +--- + +## 9. 修订记录 + +| 日期 | 说明 | +|------|------| +| 2026-04-08 | 初稿:入口、字段、隐私、接口、验收与分期 | diff --git a/frontend/src/api/public.ts b/frontend/src/api/public.ts index 3ba0436..a849152 100644 --- a/frontend/src/api/public.ts +++ b/frontend/src/api/public.ts @@ -262,6 +262,17 @@ export interface PublicActivityDetail extends PublicActivity { targetCities?: string[] } +/** 公众端公示成果行(无报名账号等敏感字段) */ +export interface PublicActivityResultItem { + id: number + workNo: string | null + title: string | null + rank: number | null + finalScore: number | string | null + awardName: string | null + participantName: string +} + export const publicActivitiesApi = { list: (params?: { page?: number @@ -303,6 +314,17 @@ export const publicActivitiesApi = { attachments?: { fileName: string; fileUrl: string; fileType?: string; size?: string }[] }, ) => publicApi.post(`/public/activities/${id}/submit-work`, data), + + /** 公示成果分页(仅 resultState=published 的活动;无需登录) */ + getPublishedResults: ( + id: number, + params?: { page?: number; pageSize?: number }, + ): Promise<{ + list: PublicActivityResultItem[] + total: number + page: number + pageSize: number + }> => publicApi.get(`/public/activities/${id}/results`, { params }), } // ==================== 我的报名 ==================== diff --git a/frontend/src/views/public/ActivityDetail.vue b/frontend/src/views/public/ActivityDetail.vue index 62481b3..e397fbe 100644 --- a/frontend/src/views/public/ActivityDetail.vue +++ b/frontend/src/views/public/ActivityDetail.vue @@ -131,9 +131,71 @@ -
- -

活动成果已发布,敬请查看!

+
+
+ +

活动成果已发布

+
+

+ 发布时间:{{ formatDateTime(activity.resultPublishTime) }} +

+

以下排名与得分以主办方发布为准。

+ + +
+ +
+
@@ -208,7 +270,7 @@