feat: 评委标记作品违规、公开参赛与公示列表排除违规作品
Made-with: Cursor
This commit is contained in:
parent
7484ddfcb1
commit
bda35c6bcd
@ -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` 时亦允许覆盖违规版本)。
|
||||
|
||||
---
|
||||
|
||||
## 与租户端「评审进度」的口径对齐
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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 = "修改评分")
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/** 活动详情「参赛作品」Tab(id 为 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 }),
|
||||
};
|
||||
|
||||
// ==================== 我的报名 ====================
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user