fix: 公众端广场推荐与超管统计Tab查询对齐设计文档
Made-with: Cursor
This commit is contained in:
parent
67de13c29a
commit
7240c543fc
@ -8,6 +8,7 @@ import com.competition.modules.ugc.entity.UgcReviewLog;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
@ -36,10 +37,16 @@ public class ContentReviewController {
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String startTime,
|
||||
@RequestParam(required = false) String endTime,
|
||||
@RequestParam(required = false) String reviewStartTime,
|
||||
@RequestParam(required = false) String reviewEndTime,
|
||||
@RequestParam(required = false) String sortBy,
|
||||
@RequestParam(required = false) Boolean isRecommended) {
|
||||
@RequestParam(required = false) String isRecommended) {
|
||||
Boolean recommended = null;
|
||||
if (StringUtils.hasText(isRecommended)) {
|
||||
recommended = "1".equals(isRecommended) || "true".equalsIgnoreCase(isRecommended);
|
||||
}
|
||||
return Result.success(publicContentReviewService.getWorkQueue(
|
||||
page, pageSize, status, keyword, startTime, endTime, sortBy, isRecommended));
|
||||
page, pageSize, status, keyword, startTime, endTime, reviewStartTime, reviewEndTime, sortBy, recommended));
|
||||
}
|
||||
|
||||
@GetMapping("/works/{id}")
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
package com.competition.modules.pub.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.competition.common.enums.WorkStatus;
|
||||
import com.competition.common.exception.BusinessException;
|
||||
import com.competition.common.result.PageResult;
|
||||
import com.competition.modules.sys.entity.SysUser;
|
||||
@ -18,8 +18,11 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@ -30,15 +33,30 @@ public class PublicContentReviewService {
|
||||
private final UgcReviewLogMapper ugcReviewLogMapper;
|
||||
private final SysUserMapper sysUserMapper;
|
||||
|
||||
/** 与 UgcWork.status 整型及写库逻辑一致 */
|
||||
private static final int ST_DRAFT = 0;
|
||||
private static final int ST_PENDING = 1;
|
||||
private static final int ST_PROCESSING = 2;
|
||||
private static final int ST_COMPLETED = 3;
|
||||
private static final int ST_CATALOGED = 4;
|
||||
private static final int ST_DUBBED = 5;
|
||||
private static final int ST_REJECTED = -1;
|
||||
private static final int ST_TAKEN_DOWN = -2;
|
||||
|
||||
/**
|
||||
* 获取各状态统计
|
||||
* 获取各状态统计(作品审核页:待审核数 + 今日审核维度)
|
||||
*/
|
||||
public Map<String, Long> getStats() {
|
||||
Map<String, Long> stats = new LinkedHashMap<>();
|
||||
stats.put("pending_review", countByStatus("pending_review"));
|
||||
stats.put("published", countByStatus("published"));
|
||||
stats.put("rejected", countByStatus(WorkStatus.REJECTED.getValue()));
|
||||
stats.put("taken_down", countByStatus(WorkStatus.TAKEN_DOWN.getValue()));
|
||||
stats.put("pending", countByStatusInt(ST_PENDING));
|
||||
stats.put("pending_review", countByStatusInt(ST_PENDING));
|
||||
stats.put("published", countPublishedStatuses());
|
||||
stats.put("rejected", countByStatusInt(ST_REJECTED));
|
||||
stats.put("taken_down", countByStatusInt(ST_TAKEN_DOWN));
|
||||
LocalDateTime dayStart = LocalDate.now().atStartOfDay();
|
||||
stats.put("todayReviewed", countTodayReviewLogs(dayStart));
|
||||
stats.put("todayApproved", countTodayLogsByAction("approve", dayStart));
|
||||
stats.put("todayRejected", countTodayLogsByAction("reject", dayStart));
|
||||
return stats;
|
||||
}
|
||||
|
||||
@ -47,14 +65,31 @@ public class PublicContentReviewService {
|
||||
*/
|
||||
public PageResult<Map<String, Object>> getWorkQueue(int page, int pageSize, String status,
|
||||
String keyword, String startTime, String endTime,
|
||||
String reviewStartTime, String reviewEndTime,
|
||||
String sortBy, Boolean isRecommended) {
|
||||
LambdaQueryWrapper<UgcWork> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(UgcWork::getIsDeleted, 0);
|
||||
if (StringUtils.hasText(status)) {
|
||||
wrapper.eq(UgcWork::getStatus, status);
|
||||
List<Integer> codes = parseStatusFilter(status);
|
||||
if (codes.isEmpty()) {
|
||||
wrapper.eq(UgcWork::getStatus, Integer.MIN_VALUE);
|
||||
} else {
|
||||
wrapper.in(UgcWork::getStatus, codes);
|
||||
}
|
||||
}
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
wrapper.like(UgcWork::getTitle, keyword);
|
||||
List<Long> userIds = sysUserMapper.selectList(new LambdaQueryWrapper<SysUser>()
|
||||
.select(SysUser::getId)
|
||||
.like(SysUser::getNickname, keyword))
|
||||
.stream()
|
||||
.map(SysUser::getId)
|
||||
.collect(Collectors.toList());
|
||||
wrapper.and(w -> {
|
||||
w.like(UgcWork::getTitle, keyword);
|
||||
if (!userIds.isEmpty()) {
|
||||
w.or().in(UgcWork::getUserId, userIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (StringUtils.hasText(startTime)) {
|
||||
wrapper.ge(UgcWork::getCreateTime, LocalDateTime.parse(startTime));
|
||||
@ -62,15 +97,17 @@ public class PublicContentReviewService {
|
||||
if (StringUtils.hasText(endTime)) {
|
||||
wrapper.le(UgcWork::getCreateTime, LocalDateTime.parse(endTime));
|
||||
}
|
||||
if (StringUtils.hasText(reviewStartTime)) {
|
||||
wrapper.ge(UgcWork::getReviewTime, LocalDateTime.parse(reviewStartTime));
|
||||
}
|
||||
if (StringUtils.hasText(reviewEndTime)) {
|
||||
wrapper.le(UgcWork::getReviewTime, LocalDateTime.parse(reviewEndTime));
|
||||
}
|
||||
if (isRecommended != null) {
|
||||
wrapper.eq(UgcWork::getIsRecommended, isRecommended);
|
||||
}
|
||||
|
||||
if ("oldest".equals(sortBy)) {
|
||||
wrapper.orderByAsc(UgcWork::getCreateTime);
|
||||
} else {
|
||||
wrapper.orderByDesc(UgcWork::getCreateTime);
|
||||
}
|
||||
applyWorkQueueSort(wrapper, sortBy);
|
||||
|
||||
IPage<UgcWork> result = ugcWorkMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||
|
||||
@ -220,12 +257,20 @@ public class PublicContentReviewService {
|
||||
*/
|
||||
public Map<String, Object> getManagementStats() {
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("total", ugcWorkMapper.selectCount(
|
||||
new LambdaQueryWrapper<UgcWork>().eq(UgcWork::getIsDeleted, 0)));
|
||||
stats.put("pendingReview", countByStatus("pending_review"));
|
||||
stats.put("published", countByStatus("published"));
|
||||
stats.put("rejected", countByStatus(WorkStatus.REJECTED.getValue()));
|
||||
stats.put("takenDown", countByStatus(WorkStatus.TAKEN_DOWN.getValue()));
|
||||
// 与作品管理列表默认筛选 status=published,taken_down 一致(已上架/已编目/已配音 + 已下架),不含草稿/待审等
|
||||
stats.put("total", countPublishedStatuses() + countByStatusInt(ST_TAKEN_DOWN));
|
||||
LocalDateTime dayStart = LocalDate.now().atStartOfDay();
|
||||
// 与作品管理「今日新增」Tab 列表一致:当日创建且为已上架(3/4/5)或已下架(-2)
|
||||
stats.put("todayNew", ugcWorkMapper.selectCount(
|
||||
new LambdaQueryWrapper<UgcWork>()
|
||||
.eq(UgcWork::getIsDeleted, 0)
|
||||
.ge(UgcWork::getCreateTime, dayStart)
|
||||
.in(UgcWork::getStatus, ST_COMPLETED, ST_CATALOGED, ST_DUBBED, ST_TAKEN_DOWN)));
|
||||
stats.put("totalViews", sumAllViewCounts());
|
||||
stats.put("pendingReview", countByStatusInt(ST_PENDING));
|
||||
stats.put("published", countPublishedStatuses());
|
||||
stats.put("rejected", countByStatusInt(ST_REJECTED));
|
||||
stats.put("takenDown", countByStatusInt(ST_TAKEN_DOWN));
|
||||
stats.put("recommended", ugcWorkMapper.selectCount(
|
||||
new LambdaQueryWrapper<UgcWork>()
|
||||
.eq(UgcWork::getIsDeleted, 0)
|
||||
@ -248,13 +293,121 @@ public class PublicContentReviewService {
|
||||
return PageResult.from(result);
|
||||
}
|
||||
|
||||
private long countByStatus(String status) {
|
||||
private void applyWorkQueueSort(LambdaQueryWrapper<UgcWork> wrapper, String sortBy) {
|
||||
if ("hot".equals(sortBy)) {
|
||||
wrapper.orderByDesc(UgcWork::getLikeCount);
|
||||
} else if ("views".equals(sortBy)) {
|
||||
wrapper.orderByDesc(UgcWork::getViewCount);
|
||||
} else if ("oldest".equals(sortBy)) {
|
||||
wrapper.orderByAsc(UgcWork::getCreateTime);
|
||||
} else if ("latest".equals(sortBy) || !StringUtils.hasText(sortBy)) {
|
||||
wrapper.orderByDesc(UgcWork::getPublishTime);
|
||||
wrapper.orderByDesc(UgcWork::getCreateTime);
|
||||
} else {
|
||||
wrapper.orderByDesc(UgcWork::getCreateTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析筛选参数:单状态或逗号分隔多状态(如 published,taken_down)
|
||||
*/
|
||||
private List<Integer> parseStatusFilter(String status) {
|
||||
String[] parts = status.split(",");
|
||||
return Stream.of(parts)
|
||||
.map(String::trim)
|
||||
.filter(StringUtils::hasText)
|
||||
.flatMap(token -> resolveStatusCodes(token).stream())
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<Integer> resolveStatusCodes(String token) {
|
||||
switch (token) {
|
||||
case "pending_review":
|
||||
return List.of(ST_PENDING);
|
||||
case "published":
|
||||
return List.of(ST_COMPLETED, ST_CATALOGED, ST_DUBBED);
|
||||
case "rejected":
|
||||
return List.of(ST_REJECTED);
|
||||
case "taken_down":
|
||||
return List.of(ST_TAKEN_DOWN);
|
||||
case "draft":
|
||||
return List.of(ST_DRAFT);
|
||||
case "processing":
|
||||
return List.of(ST_PROCESSING);
|
||||
default:
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private long countByStatusInt(int status) {
|
||||
return ugcWorkMapper.selectCount(
|
||||
new LambdaQueryWrapper<UgcWork>()
|
||||
.eq(UgcWork::getIsDeleted, 0)
|
||||
.eq(UgcWork::getStatus, status));
|
||||
}
|
||||
|
||||
private long countPublishedStatuses() {
|
||||
return ugcWorkMapper.selectCount(
|
||||
new LambdaQueryWrapper<UgcWork>()
|
||||
.eq(UgcWork::getIsDeleted, 0)
|
||||
.in(UgcWork::getStatus, ST_COMPLETED, ST_CATALOGED, ST_DUBBED));
|
||||
}
|
||||
|
||||
private long countTodayReviewLogs(LocalDateTime dayStart) {
|
||||
return ugcReviewLogMapper.selectCount(
|
||||
new LambdaQueryWrapper<UgcReviewLog>()
|
||||
.eq(UgcReviewLog::getTargetType, "work")
|
||||
.in(UgcReviewLog::getAction, "approve", "reject")
|
||||
.ge(UgcReviewLog::getCreateTime, dayStart));
|
||||
}
|
||||
|
||||
private long countTodayLogsByAction(String action, LocalDateTime dayStart) {
|
||||
return ugcReviewLogMapper.selectCount(
|
||||
new LambdaQueryWrapper<UgcReviewLog>()
|
||||
.eq(UgcReviewLog::getTargetType, "work")
|
||||
.eq(UgcReviewLog::getAction, action)
|
||||
.ge(UgcReviewLog::getCreateTime, dayStart));
|
||||
}
|
||||
|
||||
private long sumAllViewCounts() {
|
||||
List<Map<String, Object>> maps = ugcWorkMapper.selectMaps(
|
||||
new QueryWrapper<UgcWork>()
|
||||
.select("IFNULL(SUM(view_count),0) AS sv")
|
||||
.eq("is_deleted", 0));
|
||||
if (maps.isEmpty() || maps.get(0).get("sv") == null) {
|
||||
return 0L;
|
||||
}
|
||||
return ((Number) maps.get(0).get("sv")).longValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将作品状态整型转为前端使用的字符串码(与 WorkReview / WorkManagement 一致)
|
||||
*/
|
||||
private String mapWorkStatusToString(Integer code) {
|
||||
if (code == null) {
|
||||
return "draft";
|
||||
}
|
||||
switch (code) {
|
||||
case ST_DRAFT:
|
||||
return "draft";
|
||||
case ST_PENDING:
|
||||
return "pending_review";
|
||||
case ST_PROCESSING:
|
||||
return "processing";
|
||||
case ST_COMPLETED:
|
||||
case ST_CATALOGED:
|
||||
case ST_DUBBED:
|
||||
return "published";
|
||||
case ST_REJECTED:
|
||||
return "rejected";
|
||||
case ST_TAKEN_DOWN:
|
||||
return "taken_down";
|
||||
default:
|
||||
return "draft";
|
||||
}
|
||||
}
|
||||
|
||||
private void createLog(Long workId, String action, String reason, String note, Long operatorId) {
|
||||
UgcReviewLog logEntry = new UgcReviewLog();
|
||||
logEntry.setTargetType("work");
|
||||
@ -274,7 +427,9 @@ public class PublicContentReviewService {
|
||||
vo.put("title", work.getTitle());
|
||||
vo.put("coverUrl", work.getCoverUrl());
|
||||
vo.put("description", work.getDescription());
|
||||
vo.put("status", work.getStatus());
|
||||
Integer statusCode = work.getStatus();
|
||||
vo.put("statusCode", statusCode);
|
||||
vo.put("status", mapWorkStatusToString(statusCode));
|
||||
vo.put("visibility", work.getVisibility());
|
||||
vo.put("isRecommended", work.getIsRecommended());
|
||||
vo.put("viewCount", work.getViewCount());
|
||||
@ -285,7 +440,7 @@ public class PublicContentReviewService {
|
||||
vo.put("publishTime", work.getPublishTime());
|
||||
vo.put("createTime", work.getCreateTime());
|
||||
|
||||
// 补充作者信息
|
||||
// 补充作者信息(user 与 creator 同构,便于前端组件复用)
|
||||
if (work.getUserId() != null) {
|
||||
SysUser user = sysUserMapper.selectById(work.getUserId());
|
||||
if (user != null) {
|
||||
@ -295,6 +450,7 @@ public class PublicContentReviewService {
|
||||
userInfo.put("nickname", user.getNickname());
|
||||
userInfo.put("avatar", user.getAvatar());
|
||||
vo.put("user", userInfo);
|
||||
vo.put("creator", userInfo);
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
|
||||
@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.competition.common.enums.PublishStatus;
|
||||
import com.competition.common.enums.Visibility;
|
||||
import com.competition.common.exception.BusinessException;
|
||||
import com.competition.common.result.PageResult;
|
||||
@ -26,6 +25,11 @@ import java.util.*;
|
||||
@RequiredArgsConstructor
|
||||
public class PublicGalleryService {
|
||||
|
||||
/** 与 PublicContentReviewService、UgcWork.status 一致:已上架可对外的作品 */
|
||||
private static final int ST_COMPLETED = 3;
|
||||
private static final int ST_CATALOGED = 4;
|
||||
private static final int ST_DUBBED = 5;
|
||||
|
||||
private final UgcWorkMapper ugcWorkMapper;
|
||||
private final UgcWorkPageMapper ugcWorkPageMapper;
|
||||
private final SysUserMapper sysUserMapper;
|
||||
@ -36,7 +40,7 @@ public class PublicGalleryService {
|
||||
public PageResult<Map<String, Object>> getGalleryList(int page, int pageSize, Long tagId,
|
||||
String category, String sortBy, String keyword) {
|
||||
LambdaQueryWrapper<UgcWork> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(UgcWork::getStatus, PublishStatus.PUBLISHED.getValue())
|
||||
wrapper.in(UgcWork::getStatus, ST_COMPLETED, ST_CATALOGED, ST_DUBBED)
|
||||
.eq(UgcWork::getVisibility, Visibility.PUBLIC.getValue())
|
||||
.eq(UgcWork::getIsDeleted, 0);
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
@ -70,7 +74,7 @@ public class PublicGalleryService {
|
||||
public List<Map<String, Object>> getRecommended() {
|
||||
LambdaQueryWrapper<UgcWork> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(UgcWork::getIsRecommended, true)
|
||||
.eq(UgcWork::getStatus, PublishStatus.PUBLISHED.getValue())
|
||||
.in(UgcWork::getStatus, ST_COMPLETED, ST_CATALOGED, ST_DUBBED)
|
||||
.eq(UgcWork::getVisibility, Visibility.PUBLIC.getValue())
|
||||
.eq(UgcWork::getIsDeleted, 0)
|
||||
.orderByDesc(UgcWork::getPublishTime)
|
||||
@ -111,6 +115,7 @@ public class PublicGalleryService {
|
||||
if (user != null) {
|
||||
userInfo = new LinkedHashMap<>();
|
||||
userInfo.put("id", user.getId());
|
||||
userInfo.put("username", user.getUsername());
|
||||
userInfo.put("nickname", user.getNickname());
|
||||
userInfo.put("avatar", user.getAvatar());
|
||||
}
|
||||
@ -130,6 +135,7 @@ public class PublicGalleryService {
|
||||
result.put("publishTime", work.getPublishTime());
|
||||
result.put("pages", pages);
|
||||
result.put("user", userInfo);
|
||||
result.put("creator", userInfo);
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -139,7 +145,7 @@ public class PublicGalleryService {
|
||||
public PageResult<Map<String, Object>> getUserPublicWorks(Long userId, int page, int pageSize) {
|
||||
LambdaQueryWrapper<UgcWork> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(UgcWork::getUserId, userId)
|
||||
.eq(UgcWork::getStatus, PublishStatus.PUBLISHED.getValue())
|
||||
.in(UgcWork::getStatus, ST_COMPLETED, ST_CATALOGED, ST_DUBBED)
|
||||
.eq(UgcWork::getVisibility, Visibility.PUBLIC.getValue())
|
||||
.eq(UgcWork::getIsDeleted, 0)
|
||||
.orderByDesc(UgcWork::getPublishTime);
|
||||
@ -171,9 +177,11 @@ public class PublicGalleryService {
|
||||
if (user != null) {
|
||||
Map<String, Object> userInfo = new LinkedHashMap<>();
|
||||
userInfo.put("id", user.getId());
|
||||
userInfo.put("username", user.getUsername());
|
||||
userInfo.put("nickname", user.getNickname());
|
||||
userInfo.put("avatar", user.getAvatar());
|
||||
vo.put("user", userInfo);
|
||||
vo.put("creator", userInfo);
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
> 所属端:用户端(公众端)+ 超管端
|
||||
> 状态:需求已确认,待拆分开发计划
|
||||
> 创建日期:2026-03-27
|
||||
> 最后更新:2026-03-27
|
||||
> 最后更新:2026-04-09
|
||||
|
||||
---
|
||||
|
||||
@ -118,6 +118,8 @@ UserWork(用户作品)
|
||||
└── isDeleted
|
||||
```
|
||||
|
||||
**实现说明(与库表对齐)**:当前后端表 `t_ugc_work.status` 为 **整型**(如 `0` 草稿、`1` 待审、`3` 生成完成、`4` 已编目、`5` 已配音、`-1` 已拒绝、`-2` 已下架)。产品文档中的 **`published`(已发布/可对外展示)** 在查询与 API 筛选语义上对应 **`status ∈ {3, 4, 5}`**,且需配合 `visibility=public`、未删除;公众端广场与推荐接口按此过滤。勿与竞赛/作业等业务里使用的字符串型 `PublishStatus` 混用于 UGC 作品表。
|
||||
|
||||
### 2.3 社区互动模型(新增)
|
||||
|
||||
```
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
> 所属端:超管端
|
||||
> 状态:P0 已实现并优化
|
||||
> 创建日期:2026-03-27
|
||||
> 最后更新:2026-03-31
|
||||
> 最后更新:2026-04-09
|
||||
|
||||
---
|
||||
|
||||
@ -357,6 +357,7 @@ POST /api/content-review/reports/:id/handle — 处理举报
|
||||
- [x] 撤销机制:已通过/已拒绝的作品支持撤销恢复为待审核(操作列常驻按钮+二次确认)
|
||||
- [x] 操作日志:详情 Drawer 底部展示审核操作时间线(通过/拒绝/下架/恢复/撤销)
|
||||
- [x] 体验优化:默认筛选待审核、表格加描述预览列+审核时间列、详情加「上一个/下一个」导航(审核完自动跳下一个)、统计卡片点击筛选、筛选下拉自动查询
|
||||
- [x] 统计 Tab 与列表一致:「今日已审/今日通过/今日拒绝」按当日 `review_time` 区间查询(`reviewStartTime`/`reviewEndTime`);「待审核」仅 `pending_review`
|
||||
|
||||
#### 作品管理
|
||||
- [x] 基础功能:统计卡片、筛选(关键词+状态+排序)、作品表格、推荐/下架/恢复操作
|
||||
@ -365,6 +366,7 @@ POST /api/content-review/reports/:id/handle — 处理举报
|
||||
- [x] 详情 Drawer:补全作品描述、标签、绘本预览、操作按钮(推荐/下架/恢复)、操作日志
|
||||
- [x] 推荐联动:推荐作品在公众端广场顶部「编辑推荐」横栏展示,下架时自动取消推荐
|
||||
- [x] 体验优化:统计卡片可点击筛选、表格加描述预览列、取消推荐二次确认、筛选自动查询
|
||||
- [x] 统计 Tab 与列表一致:「今日新增」按当日 `create_time` 区间(`startTime`/`endTime`)且含已上架+已下架;「总作品数」为全量;「累计浏览」列表按浏览量排序;「已下架」仅下架状态
|
||||
|
||||
#### 标签管理
|
||||
- [x] 基础功能:标签 CRUD、启用/禁用、删除保护(已使用不可删)
|
||||
@ -380,10 +382,12 @@ POST /api/content-review/reports/:id/handle — 处理举报
|
||||
POST /api/content-review/works/batch-approve — 批量通过
|
||||
POST /api/content-review/works/batch-reject — 批量拒绝
|
||||
POST /api/content-review/works/:id/revoke — 撤销审核
|
||||
GET /api/content-review/works (新增参数) — sortBy 排序 + isRecommended 筛选
|
||||
GET /api/content-review/works (新增参数) — sortBy 排序 + isRecommended 筛选;startTime/endTime 按创建时间;reviewStartTime/reviewEndTime 按审核时间
|
||||
GET /api/public/gallery/recommended — 推荐作品列表(公众端)
|
||||
POST /api/tags/batch-sort — 标签批量排序
|
||||
```
|
||||
|
||||
**公众端广场与 `status`(实现约定)**:`t_ugc_work.status` 为整型;管理端筛选参数里的 `published` 语义对应 **`status ∈ {3, 4, 5}`**(生成完成 / 已编目 / 已配音)。公众端 `GET /public/gallery`、`/public/gallery/recommended` 仅查询上述状态且 `visibility=public`、未删除;**不得**复用赛事/作业等模块的 `PublishStatus.PUBLISHED`(字符串 `"published"`)与整型列比较。
|
||||
|
||||
#### 数据库变更
|
||||
- `work_tags` 表新增 `color` 字段(VARCHAR(20),标签颜色)
|
||||
|
||||
@ -24,9 +24,15 @@
|
||||
|
||||
<!-- 筛选 -->
|
||||
<div class="filter-bar">
|
||||
<a-form layout="inline" @finish="handleSearch">
|
||||
<a-form layout="inline" :model="filterFormModel" @finish="handleSearch">
|
||||
<a-form-item label="作品/作者">
|
||||
<a-input v-model:value="keyword" placeholder="作品名称或作者" allow-clear style="width: 180px" />
|
||||
<a-input
|
||||
v-model:value="keyword"
|
||||
placeholder="作品名称或作者"
|
||||
allow-clear
|
||||
style="width: 180px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
|
||||
@ -45,7 +51,7 @@
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit"><template #icon><SearchOutlined /></template>搜索</a-button>
|
||||
<a-button type="primary" @click="handleSearch"><template #icon><SearchOutlined /></template>搜索</a-button>
|
||||
<a-button @click="handleReset"><template #icon><ReloadOutlined /></template>重置</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
@ -81,7 +87,7 @@
|
||||
{{ record.isRecommended ? '取消推荐' : '推荐' }}
|
||||
</a-button>
|
||||
<a-button v-if="record.status === 'published'" type="link" danger size="small" @click="openTakedown(record)">下架</a-button>
|
||||
<a-button v-else type="link" size="small" style="color: #10b981" @click="handleRestore(record)">恢复</a-button>
|
||||
<a-button v-else type="link" size="small" style="color: #10b981" @click="handleRestore(record)">恢复上架</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
@ -210,6 +216,9 @@ import {
|
||||
import request from '@/utils/request'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
/** 供 a-form :model 使用,避免无 model 时 @finish/提交不触发 */
|
||||
const filterFormModel = reactive<Record<string, unknown>>({})
|
||||
|
||||
const loading = ref(false)
|
||||
const dataSource = ref<any[]>([])
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` })
|
||||
@ -268,33 +277,52 @@ const fetchStats = async () => {
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const isRecommendedFilter = filterStatus.value === 'recommended'
|
||||
const res: any = await request.get('/content-review/works', {
|
||||
params: {
|
||||
const dayStart = dayjs().startOf('day').format('YYYY-MM-DDTHH:mm:ss')
|
||||
const dayEnd = dayjs().endOf('day').format('YYYY-MM-DDTHH:mm:ss')
|
||||
const params: Record<string, unknown> = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
status: isRecommendedFilter ? 'published' : (filterStatus.value || 'published,taken_down'),
|
||||
keyword: keyword.value || undefined,
|
||||
sortBy: sortBy.value,
|
||||
isRecommended: isRecommendedFilter ? '1' : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
const key = activeStatKey.value
|
||||
// 统计卡片优先:与设计「点击卡片筛选列表」一致
|
||||
if (key === 'todayNew') {
|
||||
params.startTime = dayStart
|
||||
params.endTime = dayEnd
|
||||
params.status = 'published,taken_down'
|
||||
params.sortBy = sortBy.value
|
||||
} else if (key === 'takenDown') {
|
||||
params.status = 'taken_down'
|
||||
params.sortBy = sortBy.value
|
||||
} else if (key === 'totalViews') {
|
||||
params.status = 'published,taken_down'
|
||||
params.sortBy = 'views'
|
||||
} else if (key === 'total') {
|
||||
params.status = 'published,taken_down'
|
||||
params.sortBy = sortBy.value
|
||||
} else {
|
||||
const isRecommendedFilter = filterStatus.value === 'recommended'
|
||||
params.sortBy = sortBy.value
|
||||
if (isRecommendedFilter) {
|
||||
params.status = 'published'
|
||||
params.isRecommended = '1'
|
||||
} else {
|
||||
params.status = filterStatus.value || 'published,taken_down'
|
||||
}
|
||||
}
|
||||
const res: any = await request.get('/content-review/works', { params })
|
||||
dataSource.value = res.list
|
||||
pagination.total = res.total
|
||||
} catch { message.error('获取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
// #7 统计卡片点击筛选
|
||||
// #7 统计卡片点击筛选(与 fetchList 中 activeStatKey 分支联动,不再改写 filterStatus)
|
||||
const handleStatClick = (key: string) => {
|
||||
if (activeStatKey.value === key) {
|
||||
activeStatKey.value = ''
|
||||
filterStatus.value = ''
|
||||
} else {
|
||||
activeStatKey.value = key
|
||||
if (key === 'takenDown') filterStatus.value = 'taken_down'
|
||||
else if (key === 'total' || key === 'todayNew') filterStatus.value = 'published'
|
||||
else filterStatus.value = ''
|
||||
}
|
||||
pagination.current = 1
|
||||
fetchList()
|
||||
@ -384,7 +412,7 @@ const handleRestore = async (record: any) => {
|
||||
const handleRestoreInDrawer = async () => {
|
||||
const id = detailData.value?.id
|
||||
if (!id) return
|
||||
try { await request.post(`/content-review/works/${id}/restore`); message.success('已恢复'); fetchList(); fetchStats(); showDetail(id) }
|
||||
try { await request.post(`/content-review/works/${id}/restore`); message.success('已恢复上架'); fetchList(); fetchStats(); showDetail(id) }
|
||||
catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
<!-- 筛选 -->
|
||||
<div class="filter-bar">
|
||||
<a-form layout="inline" @finish="handleSearch">
|
||||
<a-form layout="inline" :model="filterFormModel" @finish="handleSearch">
|
||||
<a-form-item label="审核状态">
|
||||
<a-select v-model:value="searchStatus" style="width: 120px" @change="handleSearch">
|
||||
<a-select-option value="">全部</a-select-option>
|
||||
@ -29,11 +29,17 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="作者">
|
||||
<a-input v-model:value="searchKeyword" placeholder="作者昵称" allow-clear style="width: 150px" />
|
||||
<a-input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="作者昵称"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit"><template #icon><SearchOutlined /></template>搜索</a-button>
|
||||
<a-button type="primary" @click="handleSearch"><template #icon><SearchOutlined /></template>搜索</a-button>
|
||||
<a-button @click="handleReset"><template #icon><ReloadOutlined /></template>重置</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
@ -212,6 +218,9 @@ import {
|
||||
import request from '@/utils/request'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
/** 供 a-form :model 使用,避免无 model 时 @finish/提交不触发 */
|
||||
const filterFormModel = reactive<Record<string, unknown>>({})
|
||||
|
||||
const loading = ref(false)
|
||||
const dataSource = ref<any[]>([])
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` })
|
||||
@ -233,8 +242,8 @@ const statsItems = computed(() => [
|
||||
{ key: 'rejected', label: '今日拒绝', count: stats.value.todayRejected, icon: CloseCircleOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)' },
|
||||
])
|
||||
|
||||
const statusColor: Record<string, string> = { pending_review: 'orange', published: 'green', rejected: 'red', taken_down: 'default' }
|
||||
const statusText: Record<string, string> = { pending_review: '待审核', published: '已通过', rejected: '已拒绝', taken_down: '已下架', draft: '草稿' }
|
||||
const statusColor: Record<string, string> = { pending_review: 'orange', processing: 'blue', published: 'green', rejected: 'red', taken_down: 'default', draft: 'default' }
|
||||
const statusText: Record<string, string> = { pending_review: '待审核', processing: '生成中', published: '已通过', rejected: '已拒绝', taken_down: '已下架', draft: '草稿' }
|
||||
const logActionText: Record<string, string> = { approve: '审核通过', reject: '审核拒绝', takedown: '下架', restore: '恢复', revoke: '撤销审核' }
|
||||
const logActionColor: Record<string, string> = { approve: 'green', reject: 'red', takedown: 'gray', restore: 'blue', revoke: 'orange' }
|
||||
const formatDate = (d: string) => d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-'
|
||||
@ -348,16 +357,40 @@ const fetchStats = async () => {
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/content-review/works', {
|
||||
params: { page: pagination.current, pageSize: pagination.pageSize, status: searchStatus.value || undefined, keyword: searchKeyword.value || undefined },
|
||||
})
|
||||
const dayStart = dayjs().startOf('day').format('YYYY-MM-DDTHH:mm:ss')
|
||||
const dayEnd = dayjs().endOf('day').format('YYYY-MM-DDTHH:mm:ss')
|
||||
const params: Record<string, unknown> = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
}
|
||||
const af = activeFilter.value
|
||||
// 统计卡片:今日维度按审核时间 review_time,与后端 today* 统计口径一致
|
||||
if (af === 'pending_review') {
|
||||
params.status = 'pending_review'
|
||||
} else if (af === 'today') {
|
||||
params.status = 'published,rejected,taken_down'
|
||||
params.reviewStartTime = dayStart
|
||||
params.reviewEndTime = dayEnd
|
||||
} else if (af === 'approved') {
|
||||
params.status = 'published'
|
||||
params.reviewStartTime = dayStart
|
||||
params.reviewEndTime = dayEnd
|
||||
} else if (af === 'rejected') {
|
||||
params.status = 'rejected'
|
||||
params.reviewStartTime = dayStart
|
||||
params.reviewEndTime = dayEnd
|
||||
} else {
|
||||
params.status = searchStatus.value || undefined
|
||||
}
|
||||
const res: any = await request.get('/content-review/works', { params })
|
||||
dataSource.value = res.list
|
||||
pagination.total = res.total
|
||||
} catch { message.error('获取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
// #6 今日已审点击 → 展示今日审核过的(通过+拒绝)
|
||||
// #6 今日已审 / 今日通过 / 今日拒绝:与 fetchList 中 activeFilter + reviewStartTime/End 联动
|
||||
const handleStatClick = (key: string) => {
|
||||
if (activeFilter.value === key) {
|
||||
activeFilter.value = ''
|
||||
@ -365,6 +398,7 @@ const handleStatClick = (key: string) => {
|
||||
} else {
|
||||
activeFilter.value = key
|
||||
if (key === 'pending_review') searchStatus.value = 'pending_review'
|
||||
else if (key === 'today') searchStatus.value = ''
|
||||
else if (key === 'approved') searchStatus.value = 'published'
|
||||
else if (key === 'rejected') searchStatus.value = 'rejected'
|
||||
else searchStatus.value = ''
|
||||
|
||||
Loading…
Reference in New Issue
Block a user