feat: 公众端活动成果卡片展示与公开公示接口

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-08 16:31:48 +08:00
parent 328533e805
commit 593f7977eb
7 changed files with 531 additions and 14 deletions

View File

@ -25,5 +25,10 @@ public interface IContestResultService {
PageResult<Map<String, Object>> getResults(Long contestId, Long page, Long pageSize, String workNo, String accountNo);
/**
* 公众端公示列表仅活动公开且成果已发布不含报名账号等敏感字段
*/
PageResult<Map<String, Object>> getPublicResults(Long contestId, Long page, Long pageSize);
Map<String, Object> getResultsSummary(Long contestId);
}

View File

@ -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<Map<String, Object>> 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<BizContestWork> 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<BizContestWork> pageObj = new Page<>(page, pageSize);
Page<BizContestWork> result = workMapper.selectPage(pageObj, wrapper);
List<BizContestWork> records = result.getRecords();
Set<Long> registrationIds = records.stream()
.map(BizContestWork::getRegistrationId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, BizContestRegistration> registrationById = new HashMap<>();
if (!registrationIds.isEmpty()) {
for (BizContestRegistration reg : contestRegistrationMapper.selectBatchIds(registrationIds)) {
registrationById.put(reg.getId(), reg);
}
}
List<Map<String, Object>> voList = records.stream().map(w -> {
Map<String, Object> 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<String, Object> getResultsSummary(Long contestId) {
log.info("查询赛果概览赛事ID{}", contestId);

View File

@ -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<PageResult<Map<String, Object>>> 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<Map<String, Object>> getMyRegistration(@PathVariable Long id) {

View File

@ -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 |
## 评委端

View File

@ -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 | 初稿:入口、字段、隐私、接口、验收与分期 |

View File

@ -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 }),
}
// ==================== 我的报名 ====================

View File

@ -131,9 +131,71 @@
</div>
</a-tab-pane>
<a-tab-pane key="results" tab="活动成果" v-if="activity.resultState === 'published'">
<div class="results-hint">
<div class="results-panel">
<div class="results-hero">
<trophy-outlined class="results-icon" />
<p>活动成果已发布敬请查看</p>
<p class="results-hero-text">活动成果已发布</p>
</div>
<p v-if="activity.resultPublishTime" class="results-publish-time">
发布时间{{ formatDateTime(activity.resultPublishTime) }}
</p>
<p class="results-hint-line">以下排名与得分以主办方发布为准</p>
<a-spin :spinning="resultsLoading">
<template v-if="resultsList.length > 0 || resultsLoading">
<div class="results-cards">
<div
v-for="record in resultsList"
:key="record.id"
class="result-card"
:class="{
'result-card--top1': record.rank === 1,
'result-card--top2': record.rank === 2,
'result-card--top3': record.rank === 3,
}"
>
<div class="result-card__rank">
<span v-if="record.rank != null" class="rank-pill">{{ record.rank }}</span>
<span v-else class="rank-pill rank-pill--muted">-</span>
</div>
<div class="result-card__body">
<div class="result-card__title-row">
<span class="result-card__name">{{ record.participantName || '-' }}</span>
<a-tag v-if="record.awardName" color="gold" class="result-card__award">
{{ record.awardName }}
</a-tag>
</div>
<div class="result-card__meta">
<span class="result-card__meta-item">
<span class="meta-label">作品编号</span>
{{ record.workNo || '-' }}
</span>
<span class="result-card__meta-item">
<span class="meta-label">得分</span>
<span v-if="record.finalScore != null" class="score-text">
{{ Number(record.finalScore).toFixed(2) }}
</span>
<span v-else class="text-muted">-</span>
</span>
</div>
</div>
</div>
</div>
<div v-if="resultsTotal > resultsPageSize" class="results-pagination-wrap">
<a-pagination
:current="resultsPage"
:total="resultsTotal"
:page-size="resultsPageSize"
:show-size-changer="false"
show-less-items
:show-total="(t: number) => `共 ${t} 条`"
@change="onResultsPageChange"
/>
</div>
</template>
<div v-else class="empty-tab">
<a-empty description="暂无公示信息" />
</div>
</a-spin>
</div>
</a-tab-pane>
<a-tab-pane key="notices" tab="活动公告">
@ -208,7 +270,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
@ -222,6 +284,7 @@ import {
publicChildrenApi,
type PublicActivityDetail,
type PublicActivityNotice,
type PublicActivityResultItem,
type UserWork,
} from '@/api/public'
import WorkSelector from './components/WorkSelector.vue'
@ -249,6 +312,46 @@ const participantForm = ref({
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
const formatDateTime = (d: string) => dayjs(d).format('YYYY-MM-DD HH:mm')
/** 公示成果列表 */
const resultsLoading = ref(false)
const resultsList = ref<PublicActivityResultItem[]>([])
const resultsTotal = ref(0)
const resultsPage = ref(1)
const resultsPageSize = ref(10)
const loadPublicResults = async (page = 1) => {
if (!activity.value?.id) return
resultsLoading.value = true
try {
const res = await publicActivitiesApi.getPublishedResults(activity.value.id, {
page,
pageSize: resultsPageSize.value,
})
resultsList.value = res.list ?? []
resultsTotal.value = Number(res.total ?? 0)
resultsPage.value = Number(res.page ?? page)
} catch (err: any) {
message.error(err?.response?.data?.message || '加载成果列表失败')
resultsList.value = []
resultsTotal.value = 0
} finally {
resultsLoading.value = false
}
}
const onResultsPageChange = (page: number) => {
void loadPublicResults(page)
}
watch(activeTab, (k) => {
if (k === 'results' && activity.value?.resultState === 'published') {
void loadPublicResults(1)
}
})
const formatNoticeTime = (n: PublicActivityNotice) =>
formatDate(n.publishTime || n.createTime || '')
@ -547,20 +650,162 @@ $primary: #6366f1;
}
}
.results-hint {
.results-panel {
.results-hero {
text-align: center;
padding: 40px 0;
padding: 8px 0 16px;
.results-icon {
font-size: 48px;
font-size: 40px;
color: #f59e0b;
margin-bottom: 12px;
margin-bottom: 8px;
display: block;
}
p {
.results-hero-text {
font-size: 15px;
font-weight: 600;
color: #374151;
margin: 0;
}
}
.results-publish-time {
font-size: 13px;
color: #6b7280;
margin: 0 0 8px;
text-align: center;
}
.results-hint-line {
font-size: 12px;
color: #9ca3af;
margin: 0 0 16px;
text-align: center;
}
.results-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.result-card {
display: flex;
gap: 14px;
align-items: stretch;
padding: 14px 16px;
background: #faf9fe;
border-radius: 14px;
border: 1px solid rgba($primary, 0.12);
transition: box-shadow 0.2s, border-color 0.2s;
&:hover {
border-color: rgba($primary, 0.22);
box-shadow: 0 4px 14px rgba($primary, 0.08);
}
&--top1 {
border-color: rgba(234, 179, 8, 0.45);
background: linear-gradient(135deg, #fffbeb 0%, #faf9fe 100%);
}
&--top2 {
border-color: rgba(148, 163, 184, 0.5);
background: linear-gradient(135deg, #f8fafc 0%, #faf9fe 100%);
}
&--top3 {
border-color: rgba(217, 119, 6, 0.35);
background: linear-gradient(135deg, #fff7ed 0%, #faf9fe 100%);
}
}
.result-card__rank {
flex-shrink: 0;
display: flex;
align-items: center;
}
.result-card__body {
flex: 1;
min-width: 0;
}
.result-card__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.result-card__name {
font-size: 15px;
font-weight: 700;
color: #1e1b4b;
line-height: 1.4;
word-break: break-word;
}
.result-card__award {
flex-shrink: 0;
margin: 0 !important;
}
.result-card__meta {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
font-size: 13px;
color: #6b7280;
}
.result-card__meta-item {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.meta-label {
color: #9ca3af;
font-size: 12px;
}
.results-pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
padding-top: 4px;
}
.rank-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
padding: 2px 8px;
border-radius: 8px;
font-weight: 700;
font-size: 13px;
background: linear-gradient(135deg, #eef2ff, #fce7f3);
color: #4f46e5;
&--muted {
background: #f3f4f6;
color: #9ca3af;
font-weight: 600;
}
}
.score-text {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: #1e1b4b;
}
.text-muted {
color: #9ca3af;
}
}