feat: 评委标记作品违规、公开参赛与公示列表排除违规作品

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-14 10:42:06 +08:00
parent 7484ddfcb1
commit bda35c6bcd
16 changed files with 809 additions and 90 deletions

View File

@ -47,6 +47,31 @@
避免「列表能进、详情 403」与隐式评委场景不一致。
## 标记作品违规(活动参赛作品)
**接口**`POST /contests/reviews/work/{workId}/violation`
**权限**`review:score`(与提交评分一致)
**请求体JSON**
| 字段 | 必填 | 说明 |
|------|------|------|
| `assignmentId` | 是 | 当前评委在该作品上的分配记录 ID`t_biz_contest_work_judge_assignment.id`),与打分接口一致 |
| `reason` | 否 | 违规说明,写入 `t_biz_contest_work.violation_reason` |
**行为**
- 将 `t_biz_contest_work.status` 置为 `violation`,并写入 `violation_mark_time`、`violation_judge_id`(及可选 `violation_reason`)。
- 将该评委对应的分配记录 `status` 置为 `completed`,与「已处理该评审任务」一致,避免长期占用「待评审」。
- 若作品已是 `violation`,幂等返回成功。
**列表字段**`GET .../works` 响应中增加 `workStatus`(作品表 `status`),便于前端展示「违规」等状态;与分配维度上的 `status``assignment` 是否 completed区分。
**说明**:此为**活动赛事投稿作品**`t_biz_contest_work`)的违规标记,与 UGC 绘本广场超管审核(`/content-review/...`)不同。
**公众端**`GET /public/activities/{id}/my-registration` 返回最新参赛作品 `latestWorkStatus`、`violationReason` 等;若最新作品为 `violation`,参赛者在活动提交期内可重新从作品库提交(`submitRule=once` 时亦允许覆盖违规版本)。
---
## 与租户端「评审进度」的口径对齐

View File

@ -16,7 +16,8 @@ public enum WorkStatus {
REJECTED("rejected", "已拒绝"),
ACCEPTED("accepted", "已采纳"),
AWARDED("awarded", "已获奖"),
TAKEN_DOWN("taken_down", "已下架");
TAKEN_DOWN("taken_down", "已下架"),
VIOLATION("violation", "违规");
private final String value;
private final String description;

View File

@ -51,9 +51,21 @@ public class BizContestWork extends BaseEntity {
@TableField("is_latest")
private Boolean isLatest;
@Schema(description = "作品状态", allowableValues = {"submitted", "locked", "reviewing", "rejected", "accepted", "awarded", "taken_down"})
@Schema(description = "作品状态", allowableValues = {"submitted", "locked", "reviewing", "rejected", "accepted", "awarded", "taken_down", "violation"})
private String status;
@Schema(description = "评委标记违规原因")
@TableField("violation_reason")
private String violationReason;
@Schema(description = "违规标记时间")
@TableField("violation_mark_time")
private LocalDateTime violationMarkTime;
@Schema(description = "标记违规的评委用户ID")
@TableField("violation_judge_id")
private Long violationJudgeId;
@Schema(description = "提交时间")
@TableField("submit_time")
private LocalDateTime submitTime;

View File

@ -729,6 +729,9 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
map.put("version", entity.getVersion());
map.put("isLatest", entity.getIsLatest());
map.put("status", entity.getStatus());
map.put("violationReason", entity.getViolationReason());
map.put("violationMarkTime", entity.getViolationMarkTime());
map.put("violationJudgeId", entity.getViolationJudgeId());
map.put("submitTime", entity.getSubmitTime());
map.put("submitterUserId", entity.getSubmitterUserId());
map.put("submitterAccountNo", entity.getSubmitterAccountNo());

View File

@ -6,6 +6,7 @@ import com.lesingle.common.util.SecurityUtil;
import com.lesingle.modules.biz.review.dto.AssignWorkDto;
import com.lesingle.modules.biz.review.dto.BatchAssignDto;
import com.lesingle.modules.biz.review.dto.CreateScoreDto;
import com.lesingle.modules.biz.review.dto.MarkContestWorkViolationDto;
import com.lesingle.modules.biz.review.service.IContestReviewService;
import com.lesingle.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
@ -62,6 +63,16 @@ public class ContestReviewController {
return Result.success(contestReviewService.score(dto, judgeId, tenantId));
}
@PostMapping("/work/{workId}/violation")
@RequirePermission("review:score")
@Operation(summary = "标记作品违规", description = "将作品 status 置为 violation并结束当前评委分配任务assignment 置为 completed")
public Result<Map<String, Object>> markViolation(
@PathVariable Long workId,
@Valid @RequestBody MarkContestWorkViolationDto dto) {
Long judgeId = SecurityUtil.getCurrentUserId();
return Result.success(contestReviewService.markViolation(workId, dto, judgeId));
}
@PatchMapping("/score/{id}")
@RequirePermission("review:score")
@Operation(summary = "修改评分")

View File

@ -0,0 +1,17 @@
package com.lesingle.modules.biz.review.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "评委标记活动作品违规")
public class MarkContestWorkViolationDto {
@NotNull
@Schema(description = "评委分配记录 ID与打分接口一致")
private Long assignmentId;
@Schema(description = "违规说明(可选)")
private String reason;
}

View File

@ -2,6 +2,7 @@ package com.lesingle.modules.biz.review.service;
import com.lesingle.common.result.PageResult;
import com.lesingle.modules.biz.review.dto.CreateScoreDto;
import com.lesingle.modules.biz.review.dto.MarkContestWorkViolationDto;
import java.util.List;
import java.util.Map;
@ -16,6 +17,8 @@ public interface IContestReviewService {
Map<String, Object> score(CreateScoreDto dto, Long judgeId, Long tenantId);
Map<String, Object> markViolation(Long workId, MarkContestWorkViolationDto dto, Long judgeId);
Map<String, Object> updateScore(Long scoreId, CreateScoreDto dto, Long judgeId);
List<Map<String, Object>> getAssignedWorks(Long judgeId, Long contestId);

View File

@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.lesingle.common.enums.ErrorCode;
import com.lesingle.common.enums.PublishStatus;
import com.lesingle.common.enums.Visibility;
import com.lesingle.common.enums.WorkStatus;
import com.lesingle.common.exception.BusinessException;
import com.lesingle.common.result.PageResult;
import com.lesingle.modules.biz.contest.entity.BizContest;
@ -387,6 +388,9 @@ public class ContestResultServiceImpl implements IContestResultService {
wrapper.eq(BizContestWork::getIsLatest, true);
wrapper.eq(BizContestWork::getValidState, 1);
wrapper.isNotNull(BizContestWork::getFinalScore);
// 评委标记违规或下架的作品不参与公示
wrapper.notIn(BizContestWork::getStatus,
List.of(WorkStatus.VIOLATION.getValue(), WorkStatus.TAKEN_DOWN.getValue()));
wrapper.orderByDesc(BizContestWork::getFinalScore);
Page<BizContestWork> pageObj = new Page<>(page, pageSize);

View File

@ -13,6 +13,7 @@ import com.lesingle.modules.biz.contest.mapper.ContestMapper;
import com.lesingle.modules.biz.contest.mapper.ContestRegistrationMapper;
import com.lesingle.modules.biz.contest.mapper.ContestWorkMapper;
import com.lesingle.modules.biz.review.dto.CreateScoreDto;
import com.lesingle.modules.biz.review.dto.MarkContestWorkViolationDto;
import com.lesingle.modules.biz.review.entity.BizContestJudge;
import com.lesingle.modules.biz.review.entity.BizContestReviewRule;
import com.lesingle.modules.biz.review.entity.BizContestWorkJudgeAssignment;
@ -284,6 +285,60 @@ public class ContestReviewServiceImpl implements IContestReviewService {
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> markViolation(Long workId, MarkContestWorkViolationDto dto, Long judgeId) {
log.info("评委标记违规评委ID{}作品ID{}分配ID{}", judgeId, workId, dto.getAssignmentId());
BizContestWorkJudgeAssignment assignment = assignmentMapper.selectById(dto.getAssignmentId());
if (assignment == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "分配记录不存在");
}
if (!assignment.getJudgeId().equals(judgeId)) {
throw BusinessException.of(ErrorCode.FORBIDDEN, "无权标记该作品违规");
}
if (!assignment.getWorkId().equals(workId)) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "作品ID与分配记录不匹配");
}
BizContestWork work = workMapper.selectById(workId);
if (work == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "作品不存在");
}
if (work.getValidState() != null && work.getValidState() != 1) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "作品不存在");
}
if (!work.getContestId().equals(assignment.getContestId())) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "活动与作品不匹配");
}
if (WorkStatus.VIOLATION.getValue().equals(work.getStatus())) {
Map<String, Object> done = new LinkedHashMap<>();
done.put("workId", workId);
done.put("status", work.getStatus());
return done;
}
work.setStatus(WorkStatus.VIOLATION.getValue());
if (StringUtils.hasText(dto.getReason())) {
work.setViolationReason(dto.getReason().trim());
}
work.setViolationMarkTime(LocalDateTime.now());
work.setViolationJudgeId(judgeId);
work.setModifyTime(LocalDateTime.now());
workMapper.updateById(work);
assignment.setStatus("completed");
assignment.setModifyTime(LocalDateTime.now());
assignmentMapper.updateById(assignment);
log.info("作品已标记违规作品ID{}评委ID{}", workId, judgeId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("workId", workId);
result.put("status", WorkStatus.VIOLATION.getValue());
return result;
}
@Override
public Map<String, Object> updateScore(Long scoreId, CreateScoreDto dto, Long judgeId) {
log.info("更新评分评分ID{}评委ID{}", scoreId, judgeId);
@ -514,6 +569,7 @@ public class ContestReviewServiceImpl implements IContestReviewService {
map.put("previewUrl", work.getPreviewUrl());
map.put("previewUrls", work.getPreviewUrls());
map.put("submitterAccountNo", submitterAcc);
map.put("workStatus", work.getStatus());
BizContestWorkScore scoreRecord = scoreMap.get(a.getId());
if (scoreRecord != null) {
@ -616,7 +672,8 @@ public class ContestReviewServiceImpl implements IContestReviewService {
WorkStatus.LOCKED.getValue(),
WorkStatus.REVIEWING.getValue(),
WorkStatus.REJECTED.getValue(),
WorkStatus.ACCEPTED.getValue()
WorkStatus.ACCEPTED.getValue(),
WorkStatus.VIOLATION.getValue()
};
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", total);

View File

@ -54,6 +54,16 @@ public class PublicActivityController {
return Result.success(contestResultService.getPublicResults(id, page, pageSize));
}
@Public
@GetMapping("/{id}/works")
@Operation(summary = "参赛作品列表(公开活动已发布,无需登录)")
public Result<PageResult<Map<String, Object>>> listPublicWorks(
@PathVariable Long id,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int pageSize) {
return Result.success(publicActivityService.listPublicContestWorks(id, page, pageSize));
}
@GetMapping("/{id}/my-registration")
@Operation(summary = "查询我的报名信息")
public Result<Map<String, Object>> getMyRegistration(@PathVariable Long id) {

View File

@ -35,6 +35,7 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@ -195,6 +196,18 @@ public class PublicActivityService {
result.put("hasSubmittedWork", workCount > 0);
result.put("workCount", workCount);
BizContestWork latest = contestWorkMapper.selectOne(
new LambdaQueryWrapper<BizContestWork>()
.eq(BizContestWork::getRegistrationId, reg.getId())
.eq(BizContestWork::getIsLatest, true)
.last("LIMIT 1"));
if (latest != null) {
result.put("latestWorkId", latest.getId());
result.put("latestWorkStatus", latest.getStatus());
result.put("violationReason", latest.getViolationReason());
result.put("violationMarkTime", latest.getViolationMarkTime());
}
return result;
}
@ -429,10 +442,12 @@ public class PublicActivityService {
.last("LIMIT 1"));
if (existingWork != null) {
if ("once".equals(submitRule)) {
boolean allowReplace = "resubmit".equals(submitRule)
|| WorkStatus.VIOLATION.getValue().equals(existingWork.getStatus());
if (!allowReplace) {
throw new BusinessException(400, "该活动仅允许提交一次作品");
}
// resubmit 模式将旧作品标记为非最新
// resubmit 或评委标记违规后重提将旧作品标记为非最新
existingWork.setIsLatest(false);
contestWorkMapper.updateById(existingWork);
}
@ -509,4 +524,105 @@ public class PublicActivityService {
contestWorkMapper.insert(work);
return work;
}
/**
* 公开活动参赛作品列表本活动下各报名记录的最新版本排除违规下架等不宜公开展示的状态
*/
public PageResult<Map<String, Object>> listPublicContestWorks(Long contestId, int page, int pageSize) {
BizContest contest = contestMapper.selectById(contestId);
if (contest == null) {
throw new BusinessException(404, "活动不存在");
}
if (!Visibility.PUBLIC.getValue().equals(contest.getVisibility())) {
throw new BusinessException(403, "活动不可公开访问");
}
if (!PublishStatus.PUBLISHED.getValue().equals(contest.getContestState())) {
throw new BusinessException(400, "活动未发布");
}
LambdaQueryWrapper<BizContestWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestWork::getContestId, contestId)
.eq(BizContestWork::getIsLatest, true)
.eq(BizContestWork::getValidState, 1)
.notIn(BizContestWork::getStatus,
List.of(WorkStatus.VIOLATION.getValue(), WorkStatus.TAKEN_DOWN.getValue()))
.orderByDesc(BizContestWork::getSubmitTime)
.orderByDesc(BizContestWork::getId);
IPage<BizContestWork> workPage = contestWorkMapper.selectPage(new Page<>(page, pageSize), wrapper);
List<BizContestWork> records = workPage.getRecords();
if (records.isEmpty()) {
return PageResult.from(workPage, Collections.emptyList());
}
Set<Long> ugcIds = records.stream()
.map(BizContestWork::getUserWorkId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, UgcWork> ugcMap = new HashMap<>();
if (!ugcIds.isEmpty()) {
List<UgcWork> ugcList = ugcWorkMapper.selectBatchIds(ugcIds);
for (UgcWork uw : ugcList) {
if (uw != null && (uw.getIsDeleted() == null || uw.getIsDeleted() == 0)) {
ugcMap.put(uw.getId(), uw);
}
}
}
Set<Long> submitterIds = records.stream()
.map(BizContestWork::getSubmitterUserId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, SysUser> userMap = new HashMap<>();
if (!submitterIds.isEmpty()) {
List<SysUser> users = sysUserMapper.selectBatchIds(submitterIds);
for (SysUser u : users) {
userMap.put(u.getId(), u);
}
}
List<Map<String, Object>> list = new ArrayList<>();
for (BizContestWork work : records) {
Map<String, Object> row = new LinkedHashMap<>();
Long userWorkId = work.getUserWorkId();
UgcWork ugc = userWorkId != null ? ugcMap.get(userWorkId) : null;
String coverUrl = work.getPreviewUrl();
String originalImageUrl = null;
int likeCount = 0;
int viewCount = 0;
if (ugc != null) {
if (StringUtils.hasText(ugc.getCoverUrl())) {
coverUrl = ugc.getCoverUrl();
}
originalImageUrl = ugc.getOriginalImageUrl();
likeCount = ugc.getLikeCount() != null ? ugc.getLikeCount() : 0;
viewCount = ugc.getViewCount() != null ? ugc.getViewCount() : 0;
}
// 前端与作品广场一致id UGC 作品 ID点赞跳转 /p/works/:id
row.put("id", userWorkId);
row.put("contestWorkId", work.getId());
row.put("title", work.getTitle());
row.put("coverUrl", coverUrl);
row.put("originalImageUrl", originalImageUrl);
row.put("likeCount", likeCount);
row.put("viewCount", viewCount);
Map<String, Object> creator = new LinkedHashMap<>();
SysUser su = work.getSubmitterUserId() != null ? userMap.get(work.getSubmitterUserId()) : null;
if (su != null) {
creator.put("nickname", StringUtils.hasText(su.getNickname()) ? su.getNickname() : su.getUsername());
creator.put("avatar", su.getAvatar());
} else {
creator.put("nickname", StringUtils.hasText(work.getSubmitterAccountNo()) ? work.getSubmitterAccountNo() : "用户");
creator.put("avatar", null);
}
row.put("creator", creator);
list.add(row);
}
return PageResult.from(workPage, list);
}
}

View File

@ -0,0 +1,5 @@
-- 评委标记活动作品违规:原因、时间、操作评委
ALTER TABLE t_biz_contest_work
ADD COLUMN violation_reason VARCHAR(500) NULL COMMENT '评委标记违规原因' AFTER status,
ADD COLUMN violation_mark_time DATETIME NULL COMMENT '违规标记时间' AFTER violation_reason,
ADD COLUMN violation_judge_id BIGINT NULL COMMENT '标记违规的评委用户ID' AFTER violation_mark_time;

View File

@ -366,7 +366,19 @@ export interface ContestWork {
files?: string[] | Record<string, unknown>[] | string;
version: number;
isLatest: boolean;
status: "submitted" | "locked" | "reviewing" | "rejected" | "accepted";
status:
| "submitted"
| "locked"
| "reviewing"
| "rejected"
| "accepted"
| "awarded"
| "taken_down"
| "violation";
/** 评委标记违规原因status=violation 时可能有值) */
violationReason?: string | null;
violationMarkTime?: string | null;
violationJudgeId?: number | null;
submitTime: string;
submitterUserId?: number;
submitterAccountNo?: string;
@ -1210,6 +1222,18 @@ export const reviewsApi = {
return response;
},
/** 评委标记作品违规(作品 status=violation分配任务 completed */
markViolation: async (
workId: number,
data: { assignmentId: number; reason?: string },
): Promise<{ workId: number; status: string }> => {
const response = await request.post<any, { workId: number; status: string }>(
`/contests/reviews/work/${workId}/violation`,
data,
);
return response;
},
// 更新评分
updateScore: async (
scoreId: number,

View File

@ -318,6 +318,23 @@ export interface PublicActivityDetail extends PublicActivity {
targetCities?: string[];
}
/** 当前用户在某活动的报名 + 最新参赛作品状态GET my-registration */
export interface PublicActivityMyRegistration {
id: number;
contestId: number;
userId: number;
registrationType: string;
registrationState: string;
registrationTime: string;
hasSubmittedWork: boolean;
workCount: number;
latestWorkId?: number;
/** 与 BizContestWork.status 一致,含 violation */
latestWorkStatus?: string;
violationReason?: string | null;
violationMarkTime?: string | null;
}
/** 公众端公示成果行(无报名账号等敏感字段) */
export interface PublicActivityResultItem {
id: number;
@ -329,6 +346,21 @@ export interface PublicActivityResultItem {
participantName: string;
}
/** 活动详情「参赛作品」Tabid 为 UGC 作品 ID无关联作品库时为 null */
export interface PublicActivityContestWorkItem {
id: number | null;
contestWorkId: number;
title: string | null;
coverUrl: string | null;
originalImageUrl: string | null;
likeCount: number;
viewCount: number;
creator?: {
nickname: string;
avatar: string | null;
};
}
export const publicActivitiesApi = {
list: (params?: {
page?: number;
@ -347,16 +379,9 @@ export const publicActivitiesApi = {
) => publicApi.post(`/public/activities/${id}/register`, data),
getMyRegistration: (id: number) =>
publicApi.get<{
id: number;
contestId: number;
userId: number;
registrationType: string;
registrationState: string;
registrationTime: string;
hasSubmittedWork: boolean;
workCount: number;
} | null>(`/public/activities/${id}/my-registration`),
publicApi.get<PublicActivityMyRegistration | null>(
`/public/activities/${id}/my-registration`,
),
submitWork: (
id: number,
@ -386,6 +411,17 @@ export const publicActivitiesApi = {
page: number;
pageSize: number;
}> => publicApi.get(`/public/activities/${id}/results`, { params }),
/** 参赛作品分页(公开且已发布的活动;无需登录) */
getActivityWorks: (
id: number,
params?: { page?: number; pageSize?: number },
): Promise<{
list: PublicActivityContestWorkItem[];
total: number;
page: number;
pageSize: number;
}> => publicApi.get(`/public/activities/${id}/works`, { params }),
};
// ==================== 我的报名 ====================

View File

@ -108,6 +108,15 @@
<div class="scoring-section">
<div class="scoring-title">作品打分</div>
<!-- 已标记违规仅展示说明 -->
<div v-if="isViolation" class="violation-notice">
<span class="violation-label">该作品已标记为违规</span>
<div v-if="workDetail?.violationReason" class="violation-reason">
{{ workDetail.violationReason }}
</div>
</div>
<template v-else>
<!-- 评分标准说明有评审规则时显示 -->
<div class="scoring-standard" v-if="reviewRule">
<div class="standard-title">评分标准</div>
@ -238,17 +247,39 @@
<a-button @click="handleReset">重置</a-button>
<a-button danger @click="handleViolation">违规</a-button>
</div>
</template>
</div>
</div>
</div>
</a-spin>
<a-modal
v-model:open="violationModalOpen"
title="标记违规"
ok-text="确定"
cancel-text="取消"
:confirm-loading="violationSubmitting"
destroy-on-close
@ok="handleViolationConfirm"
>
<p class="violation-modal-hint">
确定标记后作品状态将变为违规并结束您在本作品上的评审任务
</p>
<a-textarea
v-model:value="violationReason"
placeholder="可选:违规说明"
:rows="3"
:maxlength="500"
show-count
/>
</a-modal>
</a-drawer>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { message, Modal } from "ant-design-vue";
import { message } from "ant-design-vue";
import {
PaperClipOutlined,
DownloadOutlined,
@ -313,6 +344,13 @@ const isHovering = ref(false);
//
const isReviewed = computed(() => existingScore.value !== null);
/** 作品已被标记违规(评委端不可再打分) */
const isViolation = computed(() => workDetail.value?.status === "violation");
const violationModalOpen = ref(false);
const violationReason = ref("");
const violationSubmitting = ref(false);
//
const drawerTitle = computed(() => {
const contestName = workDetail.value?.contest?.contestName || "活动";
@ -591,17 +629,37 @@ const handleReset = () => {
//
const handleViolation = () => {
Modal.confirm({
title: "标记违规",
content: "确定要将该作品标记为违规吗?标记后该作品将被记录为违规作品。",
okText: "确定",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: async () => {
// TODO:
message.info("违规标记功能开发中");
},
if (!props.workId || !props.assignmentId) {
message.warning("缺少作品或分配信息");
return;
}
violationReason.value = "";
violationModalOpen.value = true;
};
const handleViolationConfirm = async () => {
if (!props.workId || !props.assignmentId) return;
violationSubmitting.value = true;
try {
await reviewsApi.markViolation(props.workId, {
assignmentId: props.assignmentId,
reason: violationReason.value.trim() || undefined,
});
message.success("已标记为违规");
violationModalOpen.value = false;
if (workDetail.value) {
workDetail.value.status = "violation";
workDetail.value.violationReason =
violationReason.value.trim() || workDetail.value.violationReason;
}
emit("success");
handleClose();
} catch (error: any) {
message.error(error?.response?.data?.message || "标记失败");
throw error;
} finally {
violationSubmitting.value = false;
}
};
// 3D
@ -957,4 +1015,30 @@ $primary: #0958d9;
flex: 1;
}
}
.violation-notice {
margin-bottom: 16px;
padding: 12px;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 6px;
.violation-label {
font-weight: 600;
color: #cf1322;
}
.violation-reason {
margin-top: 8px;
font-size: 13px;
color: #595959;
line-height: 1.5;
}
}
.violation-modal-hint {
margin-bottom: 12px;
color: #595959;
line-height: 1.5;
}
</style>

View File

@ -40,6 +40,10 @@
</div>
</div>
<!-- 参赛作品被评委标记违规 -->
<a-alert v-if="showWorkViolationBanner" type="error" show-icon class="work-violation-alert" message="参赛作品被标记为违规"
:description="violationBannerDescription" />
<!-- 操作按钮根据阶段动态展示 -->
<div class="action-area">
<!-- 报名阶段 -->
@ -47,7 +51,8 @@
<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 v-else-if="!hasRegistered" type="primary" size="large" block class="action-btn"
@click="showRegisterModal = true">
立即报名
</a-button>
<a-button v-else-if="registrationState === 'pending'" size="large" block class="action-btn-info">
@ -66,7 +71,8 @@
<a-button v-if="!isLoggedIn" type="primary" size="large" block class="action-btn" @click="goLogin">
登录后查看作品
</a-button>
<a-button v-else-if="!hasRegistered && isRegisterOpen" type="primary" size="large" block class="action-btn" @click="showRegisterModal = true">
<a-button v-else-if="!hasRegistered && isRegisterOpen" type="primary" size="large" block class="action-btn"
@click="showRegisterModal = true">
立即报名
</a-button>
<a-button v-else-if="!hasRegistered" size="large" block disabled class="action-btn-disabled">
@ -75,13 +81,16 @@
<a-button v-else-if="registrationState === 'pending'" size="large" block class="action-btn-info">
<hourglass-outlined /> 报名审核中通过后可提交作品
</a-button>
<a-button v-else-if="registrationState === 'rejected'" size="large" block disabled class="action-btn-disabled">
<a-button v-else-if="registrationState === 'rejected'" size="large" block disabled
class="action-btn-disabled">
<close-circle-outlined /> 报名未通过无法提交作品
</a-button>
<a-button v-else-if="!hasSubmittedWork" type="primary" size="large" block class="action-btn" @click="openSubmitWork">
<a-button v-else-if="!hasSubmittedWork" type="primary" size="large" block class="action-btn"
@click="openSubmitWork">
<picture-outlined /> 从作品库选择
</a-button>
<a-button v-else-if="canResubmit" type="primary" size="large" block class="action-btn" @click="openSubmitWork">
<a-button v-else-if="canResubmit" type="primary" size="large" block class="action-btn"
@click="openSubmitWork">
<picture-outlined /> 重新提交
</a-button>
<a-button v-else size="large" block class="action-btn-done">
@ -130,6 +139,68 @@
</div>
</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="sanitizeNoticeContent(notice.content)"></div>
<span class="notice-time">{{ formatNoticeTime(notice) }}</span>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="works" tab="参赛作品">
<a-spin :spinning="contestWorksLoading">
<template v-if="contestWorksList.length > 0 || contestWorksLoading">
<div v-if="contestWorksList.length > 0" class="activity-works-panel">
<div class="works-grid">
<div v-for="work in contestWorksList" :key="work.contestWorkId" class="work-card"
:class="{ 'work-card--no-nav': work.id == null }" @click="openContestWork(work)">
<div class="card-cover">
<img v-if="work.coverUrl" :src="work.coverUrl" :alt="work.title || ''" />
<div v-else class="cover-placeholder">
<picture-outlined />
</div>
<div v-if="work.originalImageUrl && work.originalImageUrl !== work.coverUrl" class="cover-pip"
title="原图">
<img :src="work.originalImageUrl" alt="原图" />
</div>
</div>
<div class="card-body">
<h3>{{ work.title || '未命名作品' }}</h3>
<div class="card-author">
<a-avatar :size="20" :src="work.creator?.avatar">
{{ work.creator?.nickname?.charAt(0) }}
</a-avatar>
<span>{{ work.creator?.nickname }}</span>
</div>
<div class="card-stats">
<span :class="['like-btn', { liked: work.id != null && likedSet.has(work.id) }]"
@click.stop="handleContestWorkLike(work)">
<heart-filled v-if="work.id != null && likedSet.has(work.id)" />
<heart-outlined v-else />
{{ work.likeCount || 0 }}
</span>
<span><eye-outlined /> {{ work.viewCount || 0 }}</span>
</div>
</div>
</div>
</div>
<div v-if="contestWorksTotal > contestWorksPageSize" class="results-pagination-wrap">
<a-pagination :current="contestWorksPage" :total="contestWorksTotal" :page-size="contestWorksPageSize"
:show-size-changer="false" show-less-items :show-total="(t: number) => `共 ${t} 条`"
@change="onContestWorksPageChange" />
</div>
</div>
<div v-else class="loading-wrap"><a-spin /></div>
</template>
<div v-else class="empty-tab">
<a-empty description="暂无参赛作品" />
</div>
</a-spin>
</a-tab-pane>
<a-tab-pane key="results" tab="活动成果" v-if="activity.resultState === 'published'">
<div class="results-panel">
<div class="results-hero">
@ -143,16 +214,11 @@
<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="{
<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>
@ -181,15 +247,9 @@
</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"
/>
<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">
@ -198,28 +258,11 @@
</a-spin>
</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="sanitizeNoticeContent(notice.content)"></div>
<span class="notice-time">{{ formatNoticeTime(notice) }}</span>
</div>
</div>
</a-tab-pane>
</a-tabs>
</div>
<!-- 报名弹窗 -->
<a-modal
v-model:open="showRegisterModal"
title="活动报名"
:footer="null"
:width="420"
>
<a-modal v-model:open="showRegisterModal" title="活动报名" :footer="null" :width="420">
<div class="register-modal">
<template v-if="isChildUser">
<p class="modal-desc">将使用当前账号报名确认参加本活动吗</p>
@ -243,25 +286,14 @@
+ 添加新的子女
</a-button>
</template>
<a-button
type="primary"
block
size="large"
:loading="registering"
@click="handleRegister"
class="confirm-btn"
>
<a-button type="primary" block size="large" :loading="registering" @click="handleRegister" class="confirm-btn">
确认报名
</a-button>
</div>
</a-modal>
<!-- 作品选择器弹窗 -->
<WorkSelector
v-model:open="showWorkSelector"
:redirect-url="route.fullPath"
@select="handleWorkSelected"
/>
<WorkSelector v-model:open="showWorkSelector" :redirect-url="route.fullPath" @select="handleWorkSelected" />
</div>
<div v-else class="loading-page">
@ -270,7 +302,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
@ -278,13 +310,17 @@ import {
ClockCircleOutlined, PaperClipOutlined, CheckCircleOutlined,
PictureOutlined, HourglassOutlined, TrophyOutlined,
TeamOutlined, EnvironmentOutlined, CloseCircleOutlined,
HeartOutlined, HeartFilled, EyeOutlined,
} from '@ant-design/icons-vue'
import {
publicActivitiesApi,
publicChildrenApi,
publicInteractionApi,
type PublicActivityDetail,
type PublicActivityMyRegistration,
type PublicActivityNotice,
type PublicActivityResultItem,
type PublicActivityContestWorkItem,
type UserWork,
} from '@/api/public'
import WorkSelector from './components/WorkSelector.vue'
@ -300,7 +336,7 @@ const showRegisterModal = ref(false)
const registering = ref(false)
const hasRegistered = ref(false)
const registrationState = ref('')
const myRegistration = ref<any>(null)
const myRegistration = ref<PublicActivityMyRegistration | null>(null)
const hasSubmittedWork = ref(false)
//
@ -322,6 +358,13 @@ const resultsTotal = ref(0)
const resultsPage = ref(1)
const resultsPageSize = ref(10)
/** 参赛作品 Tab公开列表 */
const contestWorksLoading = ref(false)
const contestWorksList = ref<PublicActivityContestWorkItem[]>([])
const contestWorksTotal = ref(0)
const contestWorksPage = ref(1)
const contestWorksPageSize = ref(10)
const likedSet = reactive(new Set<number>())
const loadPublicResults = async (page = 1) => {
if (!activity.value?.id) return
@ -347,10 +390,94 @@ const onResultsPageChange = (page: number) => {
void loadPublicResults(page)
}
const loadActivityWorks = async (page = 1) => {
if (!activity.value?.id) return
contestWorksLoading.value = true
try {
const res = await publicActivitiesApi.getActivityWorks(activity.value.id, {
page,
pageSize: contestWorksPageSize.value,
})
contestWorksList.value = res.list ?? []
contestWorksTotal.value = Number(res.total ?? 0)
contestWorksPage.value = Number(res.page ?? page)
likedSet.clear()
if (isLoggedIn.value && contestWorksList.value.length > 0) {
const ids = contestWorksList.value
.map((w) => w.id)
.filter((x): x is number => x != null)
if (ids.length > 0) {
try {
const statuses = await publicInteractionApi.batchStatus(ids)
for (const [id, status] of Object.entries(statuses)) {
if ((status as { liked?: boolean }).liked) likedSet.add(Number(id))
}
} catch {
/* 忽略点赞状态 */
}
}
}
} catch (err: any) {
message.error(err?.response?.data?.message || '加载参赛作品失败')
contestWorksList.value = []
contestWorksTotal.value = 0
} finally {
contestWorksLoading.value = false
}
}
const onContestWorksPageChange = (page: number) => {
void loadActivityWorks(page)
}
const openContestWork = (work: PublicActivityContestWorkItem) => {
if (work.id == null) {
message.info('该作品暂无法查看详情')
return
}
router.push(`/p/works/${work.id}`)
}
const handleContestWorkLike = async (work: PublicActivityContestWorkItem) => {
if (work.id == null) return
if (!isLoggedIn.value) {
goLogin()
return
}
const wid = work.id
const wasLiked = likedSet.has(wid)
if (wasLiked) {
likedSet.delete(wid)
} else {
likedSet.add(wid)
}
work.likeCount = (work.likeCount || 0) + (wasLiked ? -1 : 1)
try {
const res = await publicInteractionApi.like(wid)
if (res.liked) {
likedSet.add(wid)
} else {
likedSet.delete(wid)
}
work.likeCount = res.likeCount
} catch {
if (wasLiked) {
likedSet.add(wid)
} else {
likedSet.delete(wid)
}
work.likeCount = (work.likeCount || 0) + (wasLiked ? 1 : -1)
message.error('操作失败')
}
}
watch(activeTab, (k) => {
if (k === 'results' && activity.value?.resultState === 'published') {
void loadPublicResults(1)
}
if (k === 'works') {
void loadActivityWorks(1)
}
})
const formatNoticeTime = (n: PublicActivityNotice) =>
@ -386,9 +513,24 @@ const isRegisterOpen = computed(() => {
return !now.isBefore(activity.value.registerStartTime) && now.isBefore(activity.value.registerEndTime)
})
// submitRule === 'resubmit'
//
const canResubmit = computed(() => {
return hasSubmittedWork.value && activity.value?.submitRule === 'resubmit'
if (!hasSubmittedWork.value || !activity.value) return false
const st = myRegistration.value?.latestWorkStatus
return activity.value.submitRule === 'resubmit' || st === 'violation'
})
/** 最新参赛作品为违规(报名已通过) */
const showWorkViolationBanner = computed(
() =>
hasRegistered.value &&
registrationState.value === 'passed' &&
myRegistration.value?.latestWorkStatus === 'violation',
)
const violationBannerDescription = computed(() => {
const r = myRegistration.value?.violationReason?.trim()
return r || '请根据说明修改作品后,在提交期内重新从作品库选择作品提交。'
})
//
@ -472,6 +614,7 @@ const handleWorkSelected = async (work: UserWork) => {
message.success('作品提交成功!')
showWorkSelector.value = false
hasSubmittedWork.value = true
await checkRegistrationStatus()
} catch (err: any) {
message.error(err?.response?.data?.message || '提交失败')
} finally {
@ -579,7 +722,7 @@ $primary: #6366f1;
.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%);
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;
@ -587,9 +730,9 @@ $primary: #6366f1;
}
.back-btn {
background: rgba(255,255,255,0.2);
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.3);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #fff;
}
@ -634,6 +777,10 @@ $primary: #6366f1;
}
}
.work-violation-alert {
margin-bottom: 20px;
}
.action-area {
.action-btn {
height: 48px !important;
@ -835,6 +982,170 @@ $primary: #6366f1;
}
}
/* 参赛作品 Tab与作品广场 Gallery 网格一致 */
.activity-works-panel {
.results-pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
padding-top: 4px;
}
}
.loading-wrap {
padding: 60px 0;
display: flex;
justify-content: center;
}
.works-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
@media (min-width: 640px) {
grid-template-columns: repeat(3, 1fr);
}
}
.work-card {
background: #fff;
border-radius: 14px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
border: 1px solid rgba($primary, 0.04);
&:hover {
box-shadow: 0 4px 20px rgba($primary, 0.1);
transform: translateY(-2px);
}
&.work-card--no-nav {
cursor: default;
&:hover {
transform: none;
box-shadow: none;
}
}
.card-cover {
position: relative;
aspect-ratio: 3/4;
background: #f5f3ff;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 28px;
color: #d1d5db;
}
.cover-pip {
position: absolute;
right: 6px;
bottom: 6px;
width: 34%;
aspect-ratio: 1 / 1;
border-radius: 7px;
overflow: hidden;
border: 2px solid #fff;
background: #fff;
box-shadow: 0 3px 10px rgba(15, 12, 41, 0.25);
transition: transform 0.2s;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
&:hover .cover-pip {
transform: scale(1.05);
}
.card-body {
padding: 10px 12px;
h3 {
font-size: 13px;
font-weight: 600;
color: #1e1b4b;
margin: 0 0 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-author {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
span {
font-size: 11px;
color: #6b7280;
}
}
.card-stats {
display: flex;
gap: 12px;
span {
font-size: 11px;
color: #9ca3af;
display: flex;
align-items: center;
gap: 3px;
}
.like-btn {
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #ec4899;
}
&.liked {
color: #ec4899;
:deep(.anticon) {
animation: activityWorkPop 0.3s ease;
}
}
}
}
}
}
@keyframes activityWorkPop {
0% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
.rich-content {
font-size: 14px;
line-height: 1.8;