feat: 评委标记作品违规、公开参赛与公示列表排除违规作品
Made-with: Cursor
This commit is contained in:
parent
7484ddfcb1
commit
bda35c6bcd
@ -47,6 +47,31 @@
|
|||||||
|
|
||||||
避免「列表能进、详情 403」与隐式评委场景不一致。
|
避免「列表能进、详情 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", "已拒绝"),
|
REJECTED("rejected", "已拒绝"),
|
||||||
ACCEPTED("accepted", "已采纳"),
|
ACCEPTED("accepted", "已采纳"),
|
||||||
AWARDED("awarded", "已获奖"),
|
AWARDED("awarded", "已获奖"),
|
||||||
TAKEN_DOWN("taken_down", "已下架");
|
TAKEN_DOWN("taken_down", "已下架"),
|
||||||
|
VIOLATION("violation", "违规");
|
||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
private final String description;
|
private final String description;
|
||||||
|
|||||||
@ -51,9 +51,21 @@ public class BizContestWork extends BaseEntity {
|
|||||||
@TableField("is_latest")
|
@TableField("is_latest")
|
||||||
private Boolean isLatest;
|
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;
|
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 = "提交时间")
|
@Schema(description = "提交时间")
|
||||||
@TableField("submit_time")
|
@TableField("submit_time")
|
||||||
private LocalDateTime submitTime;
|
private LocalDateTime submitTime;
|
||||||
|
|||||||
@ -729,6 +729,9 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
|
|||||||
map.put("version", entity.getVersion());
|
map.put("version", entity.getVersion());
|
||||||
map.put("isLatest", entity.getIsLatest());
|
map.put("isLatest", entity.getIsLatest());
|
||||||
map.put("status", entity.getStatus());
|
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("submitTime", entity.getSubmitTime());
|
||||||
map.put("submitterUserId", entity.getSubmitterUserId());
|
map.put("submitterUserId", entity.getSubmitterUserId());
|
||||||
map.put("submitterAccountNo", entity.getSubmitterAccountNo());
|
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.AssignWorkDto;
|
||||||
import com.lesingle.modules.biz.review.dto.BatchAssignDto;
|
import com.lesingle.modules.biz.review.dto.BatchAssignDto;
|
||||||
import com.lesingle.modules.biz.review.dto.CreateScoreDto;
|
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.modules.biz.review.service.IContestReviewService;
|
||||||
import com.lesingle.security.annotation.RequirePermission;
|
import com.lesingle.security.annotation.RequirePermission;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@ -62,6 +63,16 @@ public class ContestReviewController {
|
|||||||
return Result.success(contestReviewService.score(dto, judgeId, tenantId));
|
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}")
|
@PatchMapping("/score/{id}")
|
||||||
@RequirePermission("review:score")
|
@RequirePermission("review:score")
|
||||||
@Operation(summary = "修改评分")
|
@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.common.result.PageResult;
|
||||||
import com.lesingle.modules.biz.review.dto.CreateScoreDto;
|
import com.lesingle.modules.biz.review.dto.CreateScoreDto;
|
||||||
|
import com.lesingle.modules.biz.review.dto.MarkContestWorkViolationDto;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -16,6 +17,8 @@ public interface IContestReviewService {
|
|||||||
|
|
||||||
Map<String, Object> score(CreateScoreDto dto, Long judgeId, Long tenantId);
|
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);
|
Map<String, Object> updateScore(Long scoreId, CreateScoreDto dto, Long judgeId);
|
||||||
|
|
||||||
List<Map<String, Object>> getAssignedWorks(Long judgeId, Long contestId);
|
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.ErrorCode;
|
||||||
import com.lesingle.common.enums.PublishStatus;
|
import com.lesingle.common.enums.PublishStatus;
|
||||||
import com.lesingle.common.enums.Visibility;
|
import com.lesingle.common.enums.Visibility;
|
||||||
|
import com.lesingle.common.enums.WorkStatus;
|
||||||
import com.lesingle.common.exception.BusinessException;
|
import com.lesingle.common.exception.BusinessException;
|
||||||
import com.lesingle.common.result.PageResult;
|
import com.lesingle.common.result.PageResult;
|
||||||
import com.lesingle.modules.biz.contest.entity.BizContest;
|
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::getIsLatest, true);
|
||||||
wrapper.eq(BizContestWork::getValidState, 1);
|
wrapper.eq(BizContestWork::getValidState, 1);
|
||||||
wrapper.isNotNull(BizContestWork::getFinalScore);
|
wrapper.isNotNull(BizContestWork::getFinalScore);
|
||||||
|
// 评委标记违规或下架的作品不参与公示
|
||||||
|
wrapper.notIn(BizContestWork::getStatus,
|
||||||
|
List.of(WorkStatus.VIOLATION.getValue(), WorkStatus.TAKEN_DOWN.getValue()));
|
||||||
wrapper.orderByDesc(BizContestWork::getFinalScore);
|
wrapper.orderByDesc(BizContestWork::getFinalScore);
|
||||||
|
|
||||||
Page<BizContestWork> pageObj = new Page<>(page, pageSize);
|
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.ContestRegistrationMapper;
|
||||||
import com.lesingle.modules.biz.contest.mapper.ContestWorkMapper;
|
import com.lesingle.modules.biz.contest.mapper.ContestWorkMapper;
|
||||||
import com.lesingle.modules.biz.review.dto.CreateScoreDto;
|
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.BizContestJudge;
|
||||||
import com.lesingle.modules.biz.review.entity.BizContestReviewRule;
|
import com.lesingle.modules.biz.review.entity.BizContestReviewRule;
|
||||||
import com.lesingle.modules.biz.review.entity.BizContestWorkJudgeAssignment;
|
import com.lesingle.modules.biz.review.entity.BizContestWorkJudgeAssignment;
|
||||||
@ -284,6 +285,60 @@ public class ContestReviewServiceImpl implements IContestReviewService {
|
|||||||
return result;
|
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
|
@Override
|
||||||
public Map<String, Object> updateScore(Long scoreId, CreateScoreDto dto, Long judgeId) {
|
public Map<String, Object> updateScore(Long scoreId, CreateScoreDto dto, Long judgeId) {
|
||||||
log.info("更新评分,评分ID:{},评委ID:{}", scoreId, judgeId);
|
log.info("更新评分,评分ID:{},评委ID:{}", scoreId, judgeId);
|
||||||
@ -514,6 +569,7 @@ public class ContestReviewServiceImpl implements IContestReviewService {
|
|||||||
map.put("previewUrl", work.getPreviewUrl());
|
map.put("previewUrl", work.getPreviewUrl());
|
||||||
map.put("previewUrls", work.getPreviewUrls());
|
map.put("previewUrls", work.getPreviewUrls());
|
||||||
map.put("submitterAccountNo", submitterAcc);
|
map.put("submitterAccountNo", submitterAcc);
|
||||||
|
map.put("workStatus", work.getStatus());
|
||||||
|
|
||||||
BizContestWorkScore scoreRecord = scoreMap.get(a.getId());
|
BizContestWorkScore scoreRecord = scoreMap.get(a.getId());
|
||||||
if (scoreRecord != null) {
|
if (scoreRecord != null) {
|
||||||
@ -616,7 +672,8 @@ public class ContestReviewServiceImpl implements IContestReviewService {
|
|||||||
WorkStatus.LOCKED.getValue(),
|
WorkStatus.LOCKED.getValue(),
|
||||||
WorkStatus.REVIEWING.getValue(),
|
WorkStatus.REVIEWING.getValue(),
|
||||||
WorkStatus.REJECTED.getValue(),
|
WorkStatus.REJECTED.getValue(),
|
||||||
WorkStatus.ACCEPTED.getValue()
|
WorkStatus.ACCEPTED.getValue(),
|
||||||
|
WorkStatus.VIOLATION.getValue()
|
||||||
};
|
};
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.put("total", total);
|
result.put("total", total);
|
||||||
|
|||||||
@ -54,6 +54,16 @@ public class PublicActivityController {
|
|||||||
return Result.success(contestResultService.getPublicResults(id, page, pageSize));
|
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")
|
@GetMapping("/{id}/my-registration")
|
||||||
@Operation(summary = "查询我的报名信息")
|
@Operation(summary = "查询我的报名信息")
|
||||||
public Result<Map<String, Object>> getMyRegistration(@PathVariable Long id) {
|
public Result<Map<String, Object>> getMyRegistration(@PathVariable Long id) {
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import org.springframework.util.StringUtils;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@ -195,6 +196,18 @@ public class PublicActivityService {
|
|||||||
result.put("hasSubmittedWork", workCount > 0);
|
result.put("hasSubmittedWork", workCount > 0);
|
||||||
result.put("workCount", workCount);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -429,10 +442,12 @@ public class PublicActivityService {
|
|||||||
.last("LIMIT 1"));
|
.last("LIMIT 1"));
|
||||||
|
|
||||||
if (existingWork != null) {
|
if (existingWork != null) {
|
||||||
if ("once".equals(submitRule)) {
|
boolean allowReplace = "resubmit".equals(submitRule)
|
||||||
|
|| WorkStatus.VIOLATION.getValue().equals(existingWork.getStatus());
|
||||||
|
if (!allowReplace) {
|
||||||
throw new BusinessException(400, "该活动仅允许提交一次作品");
|
throw new BusinessException(400, "该活动仅允许提交一次作品");
|
||||||
}
|
}
|
||||||
// resubmit 模式:将旧作品标记为非最新
|
// resubmit 或评委标记违规后重提:将旧作品标记为非最新
|
||||||
existingWork.setIsLatest(false);
|
existingWork.setIsLatest(false);
|
||||||
contestWorkMapper.updateById(existingWork);
|
contestWorkMapper.updateById(existingWork);
|
||||||
}
|
}
|
||||||
@ -509,4 +524,105 @@ public class PublicActivityService {
|
|||||||
contestWorkMapper.insert(work);
|
contestWorkMapper.insert(work);
|
||||||
return 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;
|
files?: string[] | Record<string, unknown>[] | string;
|
||||||
version: number;
|
version: number;
|
||||||
isLatest: boolean;
|
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;
|
submitTime: string;
|
||||||
submitterUserId?: number;
|
submitterUserId?: number;
|
||||||
submitterAccountNo?: string;
|
submitterAccountNo?: string;
|
||||||
@ -1210,6 +1222,18 @@ export const reviewsApi = {
|
|||||||
return response;
|
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 (
|
updateScore: async (
|
||||||
scoreId: number,
|
scoreId: number,
|
||||||
|
|||||||
@ -318,6 +318,23 @@ export interface PublicActivityDetail extends PublicActivity {
|
|||||||
targetCities?: string[];
|
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 {
|
export interface PublicActivityResultItem {
|
||||||
id: number;
|
id: number;
|
||||||
@ -329,6 +346,21 @@ export interface PublicActivityResultItem {
|
|||||||
participantName: string;
|
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 = {
|
export const publicActivitiesApi = {
|
||||||
list: (params?: {
|
list: (params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
@ -347,16 +379,9 @@ export const publicActivitiesApi = {
|
|||||||
) => publicApi.post(`/public/activities/${id}/register`, data),
|
) => publicApi.post(`/public/activities/${id}/register`, data),
|
||||||
|
|
||||||
getMyRegistration: (id: number) =>
|
getMyRegistration: (id: number) =>
|
||||||
publicApi.get<{
|
publicApi.get<PublicActivityMyRegistration | null>(
|
||||||
id: number;
|
`/public/activities/${id}/my-registration`,
|
||||||
contestId: number;
|
),
|
||||||
userId: number;
|
|
||||||
registrationType: string;
|
|
||||||
registrationState: string;
|
|
||||||
registrationTime: string;
|
|
||||||
hasSubmittedWork: boolean;
|
|
||||||
workCount: number;
|
|
||||||
} | null>(`/public/activities/${id}/my-registration`),
|
|
||||||
|
|
||||||
submitWork: (
|
submitWork: (
|
||||||
id: number,
|
id: number,
|
||||||
@ -386,6 +411,17 @@ export const publicActivitiesApi = {
|
|||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}> => publicApi.get(`/public/activities/${id}/results`, { params }),
|
}> => 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-section">
|
||||||
<div class="scoring-title">作品打分</div>
|
<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="scoring-standard" v-if="reviewRule">
|
||||||
<div class="standard-title">评分标准</div>
|
<div class="standard-title">评分标准</div>
|
||||||
@ -238,17 +247,39 @@
|
|||||||
<a-button @click="handleReset">重置</a-button>
|
<a-button @click="handleReset">重置</a-button>
|
||||||
<a-button danger @click="handleViolation">违规</a-button>
|
<a-button danger @click="handleViolation">违规</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-spin>
|
</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>
|
</a-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { message, Modal } from "ant-design-vue";
|
import { message } from "ant-design-vue";
|
||||||
import {
|
import {
|
||||||
PaperClipOutlined,
|
PaperClipOutlined,
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
@ -313,6 +344,13 @@ const isHovering = ref(false);
|
|||||||
// 是否已评审
|
// 是否已评审
|
||||||
const isReviewed = computed(() => existingScore.value !== null);
|
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 drawerTitle = computed(() => {
|
||||||
const contestName = workDetail.value?.contest?.contestName || "活动";
|
const contestName = workDetail.value?.contest?.contestName || "活动";
|
||||||
@ -591,17 +629,37 @@ const handleReset = () => {
|
|||||||
|
|
||||||
// 标记违规
|
// 标记违规
|
||||||
const handleViolation = () => {
|
const handleViolation = () => {
|
||||||
Modal.confirm({
|
if (!props.workId || !props.assignmentId) {
|
||||||
title: "标记违规",
|
message.warning("缺少作品或分配信息");
|
||||||
content: "确定要将该作品标记为违规吗?标记后该作品将被记录为违规作品。",
|
return;
|
||||||
okText: "确定",
|
}
|
||||||
cancelText: "取消",
|
violationReason.value = "";
|
||||||
okButtonProps: { danger: true },
|
violationModalOpen.value = true;
|
||||||
onOk: async () => {
|
};
|
||||||
// TODO: 实现违规标记功能
|
|
||||||
message.info("违规标记功能开发中");
|
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模型预览
|
// 3D模型预览
|
||||||
@ -957,4 +1015,30 @@ $primary: #0958d9;
|
|||||||
flex: 1;
|
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>
|
</style>
|
||||||
|
|||||||
@ -40,6 +40,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 参赛作品被评委标记违规 -->
|
||||||
|
<a-alert v-if="showWorkViolationBanner" type="error" show-icon class="work-violation-alert" message="参赛作品被标记为违规"
|
||||||
|
:description="violationBannerDescription" />
|
||||||
|
|
||||||
<!-- 操作按钮(根据阶段动态展示) -->
|
<!-- 操作按钮(根据阶段动态展示) -->
|
||||||
<div class="action-area">
|
<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 v-if="!isLoggedIn" type="primary" size="large" block class="action-btn" @click="goLogin">
|
||||||
登录后报名
|
登录后报名
|
||||||
</a-button>
|
</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>
|
||||||
<a-button v-else-if="registrationState === 'pending'" size="large" block class="action-btn-info">
|
<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 v-if="!isLoggedIn" type="primary" size="large" block class="action-btn" @click="goLogin">
|
||||||
登录后查看作品
|
登录后查看作品
|
||||||
</a-button>
|
</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>
|
||||||
<a-button v-else-if="!hasRegistered" size="large" block disabled class="action-btn-disabled">
|
<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">
|
<a-button v-else-if="registrationState === 'pending'" size="large" block class="action-btn-info">
|
||||||
<hourglass-outlined /> 报名审核中,通过后可提交作品
|
<hourglass-outlined /> 报名审核中,通过后可提交作品
|
||||||
</a-button>
|
</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 /> 报名未通过,无法提交作品
|
<close-circle-outlined /> 报名未通过,无法提交作品
|
||||||
</a-button>
|
</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 /> 从作品库选择
|
<picture-outlined /> 从作品库选择
|
||||||
</a-button>
|
</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 /> 重新提交
|
<picture-outlined /> 重新提交
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button v-else size="large" block class="action-btn-done">
|
<a-button v-else size="large" block class="action-btn-done">
|
||||||
@ -130,6 +139,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-tab-pane>
|
</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'">
|
<a-tab-pane key="results" tab="活动成果" v-if="activity.resultState === 'published'">
|
||||||
<div class="results-panel">
|
<div class="results-panel">
|
||||||
<div class="results-hero">
|
<div class="results-hero">
|
||||||
@ -143,16 +214,11 @@
|
|||||||
<a-spin :spinning="resultsLoading">
|
<a-spin :spinning="resultsLoading">
|
||||||
<template v-if="resultsList.length > 0 || resultsLoading">
|
<template v-if="resultsList.length > 0 || resultsLoading">
|
||||||
<div class="results-cards">
|
<div class="results-cards">
|
||||||
<div
|
<div v-for="record in resultsList" :key="record.id" class="result-card" :class="{
|
||||||
v-for="record in resultsList"
|
|
||||||
:key="record.id"
|
|
||||||
class="result-card"
|
|
||||||
:class="{
|
|
||||||
'result-card--top1': record.rank === 1,
|
'result-card--top1': record.rank === 1,
|
||||||
'result-card--top2': record.rank === 2,
|
'result-card--top2': record.rank === 2,
|
||||||
'result-card--top3': record.rank === 3,
|
'result-card--top3': record.rank === 3,
|
||||||
}"
|
}">
|
||||||
>
|
|
||||||
<div class="result-card__rank">
|
<div class="result-card__rank">
|
||||||
<span v-if="record.rank != null" class="rank-pill">{{ record.rank }}</span>
|
<span v-if="record.rank != null" class="rank-pill">{{ record.rank }}</span>
|
||||||
<span v-else class="rank-pill rank-pill--muted">-</span>
|
<span v-else class="rank-pill rank-pill--muted">-</span>
|
||||||
@ -181,15 +247,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="resultsTotal > resultsPageSize" class="results-pagination-wrap">
|
<div v-if="resultsTotal > resultsPageSize" class="results-pagination-wrap">
|
||||||
<a-pagination
|
<a-pagination :current="resultsPage" :total="resultsTotal" :page-size="resultsPageSize"
|
||||||
:current="resultsPage"
|
:show-size-changer="false" show-less-items :show-total="(t: number) => `共 ${t} 条`"
|
||||||
:total="resultsTotal"
|
@change="onResultsPageChange" />
|
||||||
:page-size="resultsPageSize"
|
|
||||||
:show-size-changer="false"
|
|
||||||
show-less-items
|
|
||||||
:show-total="(t: number) => `共 ${t} 条`"
|
|
||||||
@change="onResultsPageChange"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="empty-tab">
|
<div v-else class="empty-tab">
|
||||||
@ -198,28 +258,11 @@
|
|||||||
</a-spin>
|
</a-spin>
|
||||||
</div>
|
</div>
|
||||||
</a-tab-pane>
|
</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>
|
</a-tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 报名弹窗 -->
|
<!-- 报名弹窗 -->
|
||||||
<a-modal
|
<a-modal v-model:open="showRegisterModal" title="活动报名" :footer="null" :width="420">
|
||||||
v-model:open="showRegisterModal"
|
|
||||||
title="活动报名"
|
|
||||||
:footer="null"
|
|
||||||
:width="420"
|
|
||||||
>
|
|
||||||
<div class="register-modal">
|
<div class="register-modal">
|
||||||
<template v-if="isChildUser">
|
<template v-if="isChildUser">
|
||||||
<p class="modal-desc">将使用当前账号报名,确认参加本活动吗?</p>
|
<p class="modal-desc">将使用当前账号报名,确认参加本活动吗?</p>
|
||||||
@ -243,25 +286,14 @@
|
|||||||
+ 添加新的子女
|
+ 添加新的子女
|
||||||
</a-button>
|
</a-button>
|
||||||
</template>
|
</template>
|
||||||
<a-button
|
<a-button type="primary" block size="large" :loading="registering" @click="handleRegister" class="confirm-btn">
|
||||||
type="primary"
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
:loading="registering"
|
|
||||||
@click="handleRegister"
|
|
||||||
class="confirm-btn"
|
|
||||||
>
|
|
||||||
确认报名
|
确认报名
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<!-- 作品选择器弹窗 -->
|
<!-- 作品选择器弹窗 -->
|
||||||
<WorkSelector
|
<WorkSelector v-model:open="showWorkSelector" :redirect-url="route.fullPath" @select="handleWorkSelected" />
|
||||||
v-model:open="showWorkSelector"
|
|
||||||
:redirect-url="route.fullPath"
|
|
||||||
@select="handleWorkSelected"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="loading-page">
|
<div v-else class="loading-page">
|
||||||
@ -270,7 +302,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import {
|
import {
|
||||||
@ -278,13 +310,17 @@ import {
|
|||||||
ClockCircleOutlined, PaperClipOutlined, CheckCircleOutlined,
|
ClockCircleOutlined, PaperClipOutlined, CheckCircleOutlined,
|
||||||
PictureOutlined, HourglassOutlined, TrophyOutlined,
|
PictureOutlined, HourglassOutlined, TrophyOutlined,
|
||||||
TeamOutlined, EnvironmentOutlined, CloseCircleOutlined,
|
TeamOutlined, EnvironmentOutlined, CloseCircleOutlined,
|
||||||
|
HeartOutlined, HeartFilled, EyeOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import {
|
import {
|
||||||
publicActivitiesApi,
|
publicActivitiesApi,
|
||||||
publicChildrenApi,
|
publicChildrenApi,
|
||||||
|
publicInteractionApi,
|
||||||
type PublicActivityDetail,
|
type PublicActivityDetail,
|
||||||
|
type PublicActivityMyRegistration,
|
||||||
type PublicActivityNotice,
|
type PublicActivityNotice,
|
||||||
type PublicActivityResultItem,
|
type PublicActivityResultItem,
|
||||||
|
type PublicActivityContestWorkItem,
|
||||||
type UserWork,
|
type UserWork,
|
||||||
} from '@/api/public'
|
} from '@/api/public'
|
||||||
import WorkSelector from './components/WorkSelector.vue'
|
import WorkSelector from './components/WorkSelector.vue'
|
||||||
@ -300,7 +336,7 @@ const showRegisterModal = ref(false)
|
|||||||
const registering = ref(false)
|
const registering = ref(false)
|
||||||
const hasRegistered = ref(false)
|
const hasRegistered = ref(false)
|
||||||
const registrationState = ref('')
|
const registrationState = ref('')
|
||||||
const myRegistration = ref<any>(null)
|
const myRegistration = ref<PublicActivityMyRegistration | null>(null)
|
||||||
const hasSubmittedWork = ref(false)
|
const hasSubmittedWork = ref(false)
|
||||||
|
|
||||||
// 作品提交
|
// 作品提交
|
||||||
@ -322,6 +358,13 @@ const resultsTotal = ref(0)
|
|||||||
const resultsPage = ref(1)
|
const resultsPage = ref(1)
|
||||||
const resultsPageSize = ref(10)
|
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) => {
|
const loadPublicResults = async (page = 1) => {
|
||||||
if (!activity.value?.id) return
|
if (!activity.value?.id) return
|
||||||
@ -347,10 +390,94 @@ const onResultsPageChange = (page: number) => {
|
|||||||
void loadPublicResults(page)
|
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) => {
|
watch(activeTab, (k) => {
|
||||||
if (k === 'results' && activity.value?.resultState === 'published') {
|
if (k === 'results' && activity.value?.resultState === 'published') {
|
||||||
void loadPublicResults(1)
|
void loadPublicResults(1)
|
||||||
}
|
}
|
||||||
|
if (k === 'works') {
|
||||||
|
void loadActivityWorks(1)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatNoticeTime = (n: PublicActivityNotice) =>
|
const formatNoticeTime = (n: PublicActivityNotice) =>
|
||||||
@ -386,9 +513,24 @@ const isRegisterOpen = computed(() => {
|
|||||||
return !now.isBefore(activity.value.registerStartTime) && now.isBefore(activity.value.registerEndTime)
|
return !now.isBefore(activity.value.registerStartTime) && now.isBefore(activity.value.registerEndTime)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 是否允许重新提交(submitRule === 'resubmit' 且已提交过作品)
|
// 是否允许重新提交:活动允许多次提交,或最新参赛作品被标记违规需重提
|
||||||
const canResubmit = computed(() => {
|
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('作品提交成功!')
|
message.success('作品提交成功!')
|
||||||
showWorkSelector.value = false
|
showWorkSelector.value = false
|
||||||
hasSubmittedWork.value = true
|
hasSubmittedWork.value = true
|
||||||
|
await checkRegistrationStatus()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err?.response?.data?.message || '提交失败')
|
message.error(err?.response?.data?.message || '提交失败')
|
||||||
} finally {
|
} finally {
|
||||||
@ -579,7 +722,7 @@ $primary: #6366f1;
|
|||||||
.hero-overlay {
|
.hero-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
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;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@ -587,9 +730,9 @@ $primary: #6366f1;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
background: rgba(255,255,255,0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border: 1px solid rgba(255,255,255,0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -634,6 +777,10 @@ $primary: #6366f1;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.work-violation-alert {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.action-area {
|
.action-area {
|
||||||
.action-btn {
|
.action-btn {
|
||||||
height: 48px !important;
|
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 {
|
.rich-content {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user