Day5: 超管端内容管理模块全面优化 + 广场推荐作品展示

作品审核:
- 批量通过/批量拒绝 + 撤销审核机制
- 默认筛选待审核,表格加描述预览+审核时间列
- 详情Drawer加上一个/下一个导航,审核后自动跳下一个
- 操作日志时间线展示,筛选下拉自动查询

作品管理:
- 修复筛选/排序失效,新增推荐中筛选
- 下架改为弹窗选择原因,取消推荐二次确认
- 详情Drawer补全描述/标签/操作按钮/操作日志
- 统计卡片可点击筛选,下架自动取消推荐

标签管理:
- 按分类分组卡片式展示,分类改为下拉选择
- 新增标签颜色字段(预设色+自定义)
- 上移/下移排序按钮,使用次数可点击跳转作品管理
- 新增/编辑时实时预览用户端标签效果

广场推荐:
- 新增推荐作品列表接口 GET /public/gallery/recommended
- 广场顶部新增「编辑推荐」横向滚动栏

文档更新:内容管理设计文档补充实施记录,UGC开发计划P1-1标记已完成

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
aid 2026-03-31 15:21:21 +08:00
parent 66827c0199
commit f246b38fc1
16 changed files with 1339 additions and 164 deletions

View File

@ -1271,6 +1271,7 @@ model WorkTag {
id Int @id @default(autoincrement())
name String @unique @db.VarChar(50) /// 标签名称
category String? @db.VarChar(50) /// 所属分类(如:主题/风格/情感)
color String? @db.VarChar(20) /// 标签颜色(如:#6366f1
sort Int @default(0) /// 排序权重
status String @default("enabled") /// 状态enabled/disabled
usageCount Int @default(0) @map("usage_count") /// 使用次数(冗余)

View File

@ -25,6 +25,8 @@ export class ContentReviewController {
@Query('keyword') keyword?: string,
@Query('startTime') startTime?: string,
@Query('endTime') endTime?: string,
@Query('sortBy') sortBy?: string,
@Query('isRecommended') isRecommended?: string,
) {
return this.reviewService.getWorkQueue({
page: page ? parseInt(page) : 1,
@ -33,6 +35,8 @@ export class ContentReviewController {
keyword,
startTime,
endTime,
sortBy,
isRecommended: isRecommended === '1',
});
}
@ -41,6 +45,16 @@ export class ContentReviewController {
return this.reviewService.getWorkDetail(id);
}
@Post('works/batch-approve')
batchApprove(@Request() req, @Body() dto: { ids: number[] }) {
return this.reviewService.batchApprove(dto.ids || [], req.user.userId);
}
@Post('works/batch-reject')
batchReject(@Request() req, @Body() dto: { ids: number[]; reason: string }) {
return this.reviewService.batchReject(dto.ids || [], req.user.userId, dto.reason);
}
@Post('works/:id/approve')
approveWork(
@Param('id', ParseIntPipe) id: number,
@ -59,6 +73,11 @@ export class ContentReviewController {
return this.reviewService.reject(id, req.user.userId, dto.reason, dto.note);
}
@Post('works/:id/revoke')
revokeWork(@Param('id', ParseIntPipe) id: number, @Request() req) {
return this.reviewService.revoke(id, req.user.userId);
}
@Post('works/:id/takedown')
takedownWork(
@Param('id', ParseIntPipe) id: number,

View File

@ -28,17 +28,27 @@ export class ContentReviewService {
keyword?: string;
startTime?: string;
endTime?: string;
sortBy?: string;
isRecommended?: boolean;
}) {
const { page = 1, pageSize = 10, status, keyword, startTime, endTime } = params;
const { page = 1, pageSize = 10, status, keyword, startTime, endTime, sortBy, isRecommended } = params;
const skip = (page - 1) * pageSize;
const where: any = { isDeleted: 0 };
if (status) {
if (status === 'published,taken_down') {
where.status = { in: ['published', 'taken_down'] };
} else {
where.status = status;
}
} else {
where.status = { in: ['pending_review', 'published', 'rejected', 'taken_down'] };
}
if (isRecommended) {
where.isRecommended = true;
}
if (keyword) {
where.OR = [
{ title: { contains: keyword } },
@ -49,12 +59,18 @@ export class ContentReviewService {
if (startTime) where.createTime = { ...where.createTime, gte: new Date(startTime) };
if (endTime) where.createTime = { ...where.createTime, lte: new Date(endTime + ' 23:59:59') };
// 排序
let orderBy: any = { createTime: 'desc' };
if (sortBy === 'hot') orderBy = [{ likeCount: 'desc' }, { viewCount: 'desc' }];
else if (sortBy === 'views') orderBy = { viewCount: 'desc' };
else if (sortBy === 'latest') orderBy = { publishTime: 'desc' };
const [list, total] = await Promise.all([
this.prisma.userWork.findMany({
where,
skip,
take: pageSize,
orderBy: { createTime: 'desc' },
orderBy,
include: {
creator: { select: { id: true, nickname: true, avatar: true, username: true, userType: true } },
tags: { include: { tag: { select: { id: true, name: true } } } },
@ -115,6 +131,73 @@ export class ContentReviewService {
});
}
/** 批量通过 */
async batchApprove(workIds: number[], operatorId: number) {
const works = await this.prisma.userWork.findMany({
where: { id: { in: workIds }, status: 'pending_review', isDeleted: 0 },
select: { id: true },
});
if (works.length === 0) return { success: true, count: 0 };
const ids = works.map(w => w.id);
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.userWork.updateMany({
where: { id: { in: ids } },
data: { status: 'published', reviewTime: now, reviewerId: operatorId, publishTime: now },
});
await tx.contentReviewLog.createMany({
data: ids.map(id => ({
targetType: 'work',
targetId: id,
workId: id,
action: 'approve',
note: '批量审核通过',
operatorId,
})),
});
});
return { success: true, count: ids.length };
}
/** 批量拒绝 */
async batchReject(workIds: number[], operatorId: number, reason: string) {
const works = await this.prisma.userWork.findMany({
where: { id: { in: workIds }, status: 'pending_review', isDeleted: 0 },
select: { id: true },
});
if (works.length === 0) return { success: true, count: 0 };
const ids = works.map(w => w.id);
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.userWork.updateMany({
where: { id: { in: ids } },
data: { status: 'rejected', reviewTime: now, reviewerId: operatorId, reviewNote: reason },
});
await tx.contentReviewLog.createMany({
data: ids.map(id => ({
targetType: 'work',
targetId: id,
workId: id,
action: 'reject',
reason,
note: '批量审核拒绝',
operatorId,
})),
});
});
return { success: true, count: ids.length };
}
/** 拒绝 */
async reject(workId: number, operatorId: number, reason: string, note?: string) {
const work = await this.prisma.userWork.findUnique({ where: { id: workId } });
@ -155,7 +238,7 @@ export class ContentReviewService {
return this.prisma.$transaction(async (tx) => {
await tx.userWork.update({
where: { id: workId },
data: { status: 'taken_down', reviewNote: reason },
data: { status: 'taken_down', reviewNote: reason, isRecommended: false },
});
await tx.contentReviewLog.create({
@ -209,6 +292,41 @@ export class ContentReviewService {
});
}
/** 撤销审核(恢复为待审核) */
async revoke(workId: number, operatorId: number) {
const work = await this.prisma.userWork.findUnique({ where: { id: workId } });
if (!work) throw new NotFoundException('作品不存在');
if (!['published', 'rejected'].includes(work.status)) {
throw new NotFoundException('当前状态不支持撤销');
}
return this.prisma.$transaction(async (tx) => {
await tx.userWork.update({
where: { id: workId },
data: {
status: 'pending_review',
reviewTime: null,
reviewerId: null,
reviewNote: null,
publishTime: work.status === 'published' ? null : work.publishTime,
},
});
await tx.contentReviewLog.create({
data: {
targetType: 'work',
targetId: workId,
workId,
action: 'revoke',
note: '撤销审核操作',
operatorId,
},
});
return { success: true };
});
}
/** 作品管理统计 */
async getManagementStats() {
const today = new Date();

View File

@ -88,6 +88,23 @@ export class GalleryService {
return work;
}
/** 推荐作品列表 */
async getRecommendedWorks(limit = 10) {
return this.prisma.userWork.findMany({
where: {
isRecommended: true,
status: 'published',
visibility: 'public',
isDeleted: 0,
},
take: limit,
orderBy: [{ likeCount: 'desc' }, { publishTime: 'desc' }],
include: {
creator: { select: { id: true, nickname: true, avatar: true } },
},
});
}
/** 某用户的公开作品列表 */
async getUserPublicWorks(userId: number, params: { page?: number; pageSize?: number }) {
const { page = 1, pageSize = 12 } = params;

View File

@ -407,6 +407,12 @@ export class PublicController {
});
}
@Public()
@Get('gallery/recommended')
async getRecommendedWorks() {
return this.galleryService.getRecommendedWorks();
}
@Public()
@Get('gallery/:id')
async getGalleryDetail(@Param('id', ParseIntPipe) id: number) {

View File

@ -16,18 +16,23 @@ export class TagsController {
}
@Post()
create(@Body() dto: { name: string; category?: string; sort?: number }) {
create(@Body() dto: { name: string; category?: string; color?: string; sort?: number }) {
return this.tagsService.create(dto);
}
@Put(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: { name?: string; category?: string; sort?: number; status?: string },
@Body() dto: { name?: string; category?: string; color?: string; sort?: number; status?: string },
) {
return this.tagsService.update(id, dto);
}
@Post('batch-sort')
batchSort(@Body() dto: { items: { id: number; sort: number }[] }) {
return this.tagsService.batchUpdateSort(dto.items || []);
}
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.tagsService.remove(id);

View File

@ -36,7 +36,7 @@ export class TagsService {
}
/** 创建标签 */
async create(dto: { name: string; category?: string; sort?: number }) {
async create(dto: { name: string; category?: string; color?: string; sort?: number }) {
const existing = await this.prisma.workTag.findFirst({ where: { name: dto.name } });
if (existing) throw new BadRequestException('标签名已存在');
@ -44,13 +44,14 @@ export class TagsService {
data: {
name: dto.name,
category: dto.category || null,
color: dto.color || null,
sort: dto.sort || 0,
},
});
}
/** 编辑标签 */
async update(id: number, dto: { name?: string; category?: string; sort?: number; status?: string }) {
async update(id: number, dto: { name?: string; category?: string; color?: string; sort?: number; status?: string }) {
const tag = await this.prisma.workTag.findUnique({ where: { id } });
if (!tag) throw new NotFoundException('标签不存在');
@ -64,12 +65,23 @@ export class TagsService {
data: {
name: dto.name ?? tag.name,
category: dto.category !== undefined ? dto.category : tag.category,
color: dto.color !== undefined ? dto.color : tag.color,
sort: dto.sort !== undefined ? dto.sort : tag.sort,
status: dto.status ?? tag.status,
},
});
}
/** 批量更新排序 */
async batchUpdateSort(items: { id: number; sort: number }[]) {
await this.prisma.$transaction(
items.map(item =>
this.prisma.workTag.update({ where: { id: item.id }, data: { sort: item.sort } }),
),
);
return { success: true };
}
/** 删除标签 */
async remove(id: number) {
const tag = await this.prisma.workTag.findUnique({ where: { id } });

View File

@ -4,13 +4,13 @@
| 文档 | 模块 | 状态 | 日期 |
|------|------|------|------|
| [统一用户管理](./super-admin/unified-user-management.md) | 用户中心 | 已实现(待验收 | 2026-03-27 |
| [统一用户管理](./super-admin/unified-user-management.md) | 用户中心 | 已实现(迭代中 | 2026-03-27 |
| [全部活动优化](./super-admin/activity-list-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
| [报名数据优化](./super-admin/registration-data-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
| [作品数据优化](./super-admin/works-data-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
| [评审进度优化](./super-admin/review-progress-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
| [成果发布优化](./super-admin/results-publish-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
| [内容管理模块](./super-admin/content-management.md) | 内容管理(新增) | P0 已实现 | 2026-03-27 |
| [内容管理模块](./super-admin/content-management.md) | 内容管理 | P0 已实现并优化 | 2026-03-31 |
## 机构管理端
@ -21,7 +21,8 @@
| 文档 | 模块 | 状态 | 日期 |
|------|------|------|------|
| [UGC社区升级](./public/ugc-platform-upgrade.md) | 整体架构 | 需求已确认 | 2026-03-27 |
| [UGC开发计划](./public/ugc-development-plan.md) | 开发排期 | P0 已实现 | 2026-03-27 |
| [UGC开发计划](./public/ugc-development-plan.md) | 开发排期 | P0已完成P1进行中 | 2026-03-31 |
| [点赞&收藏](./public/like-favorite.md) | 社区互动 | 已实现 | 2026-03-31 |
## 评委端

View File

@ -1,5 +1,10 @@
# 点赞 & 收藏功能设计
> 所属端:公众端 + 超管端联动
> 状态:已实现
> 创建日期2026-03-31
> 最后更新2026-03-31
## 概述
为 UGC 绘本创作社区的作品添加点赞和收藏交互能力,提升用户参与感和内容发现效率。

View File

@ -320,22 +320,25 @@ P0-4 + P0-6 → P0-12活动联动
目标:用户可对作品点赞、收藏、评论;有消息通知;可举报不当内容。
#### P1-1. 点赞/收藏(后端+前端)
#### P1-1. 点赞/收藏(后端+前端)✅ 已实现 (2026-03-31)
```
后端 API
├── POST /api/public/works/:id/like — 点赞/取消点赞
├── POST /api/public/works/:id/favorite — 收藏/取消收藏
后端 API已实现
├── POST /api/public/works/:id/like — 点赞/取消点赞toggle
├── POST /api/public/works/:id/favorite — 收藏/取消收藏toggle
├── GET /api/public/works/:id/interaction — 查询交互状态
├── POST /api/public/works/batch-interaction — 批量查询交互状态
├── GET /api/public/mine/favorites — 我的收藏列表
前端改动:
├── 广场作品详情页 — 增加点赞/收藏按钮和计数
├── 作品卡片组件 — 显示点赞数
├── /p/mine/favorites — 我的收藏页面
前端改动(已实现):
├── 作品详情页 — 底部互动栏:点赞(心形)/收藏(星形)/浏览数,乐观更新+弹跳动效
├── 广场卡片 — 心形可点击点赞,已点赞显示实心粉色
├── /p/mine/favorites — 我的收藏页面(网格展示)
├── 个人中心 — 新增「我的收藏」菜单入口
```
**依赖**P0 完成
**数据库**user_work_likes + user_work_favorites
**数据库**user_work_likes + user_work_favorites已有
**设计文档**[点赞收藏设计](../public/like-favorite.md)
---

View File

@ -1,9 +1,9 @@
# 超管端内容管理模块 — 设计方案
> 所属端:超管端
> 状态:待开发
> 状态:P0 已实现并优化
> 创建日期2026-03-27
> 最后更新2026-03-27
> 最后更新2026-03-31
---
@ -349,4 +349,41 @@ POST /api/content-review/reports/:id/handle — 处理举报
## 8. 实施记录
(开发过程中记录)
### Day5 (2026-03-31) — P0 全面优化
#### 作品审核
- [x] 基础功能:统计卡片、筛选、审核队列表格、拒绝弹窗(预设理由+自定义)、详情 Drawer绘本翻页预览
- [x] 批量审核:支持勾选待审核作品批量通过/批量拒绝
- [x] 撤销机制:已通过/已拒绝的作品支持撤销恢复为待审核(操作列常驻按钮+二次确认)
- [x] 操作日志:详情 Drawer 底部展示审核操作时间线(通过/拒绝/下架/恢复/撤销)
- [x] 体验优化:默认筛选待审核、表格加描述预览列+审核时间列、详情加「上一个/下一个」导航(审核完自动跳下一个)、统计卡片点击筛选、筛选下拉自动查询
#### 作品管理
- [x] 基础功能:统计卡片、筛选(关键词+状态+排序)、作品表格、推荐/下架/恢复操作
- [x] 筛选修复:状态筛选支持 published+taken_down+推荐中,排序参数传后端真正生效
- [x] 下架原因下架改为弹窗选择原因4个预设+自定义),取代写死的「管理员下架」
- [x] 详情 Drawer补全作品描述、标签、绘本预览、操作按钮推荐/下架/恢复)、操作日志
- [x] 推荐联动:推荐作品在公众端广场顶部「编辑推荐」横栏展示,下架时自动取消推荐
- [x] 体验优化:统计卡片可点击筛选、表格加描述预览列、取消推荐二次确认、筛选自动查询
#### 标签管理
- [x] 基础功能:标签 CRUD、启用/禁用、删除保护(已使用不可删)
- [x] 分类分组:标签按分类分组展示(每组有颜色标识+计数),未分类单独一组
- [x] 分类下拉:新增/编辑时分类改为下拉选择(支持选已有+创建新分类),杜绝手动输入不一致
- [x] 标签颜色:数据库新增 color 字段10个预设色+自定义 hex卡片左侧颜色条+用户端预览
- [x] 排序按钮:每个标签有上/下箭头,点击交换排序值并持久化
- [x] 使用次数可点击:跳转作品管理页带标签名搜索
- [x] 实时预览:新增/编辑弹窗底部实时渲染用户端标签效果
#### 新增后端 API
```
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/public/gallery/recommended — 推荐作品列表(公众端)
POST /api/tags/batch-sort — 标签批量排序
```
#### 数据库变更
- `work_tags` 表新增 `color` 字段VARCHAR(20),标签颜色)

View File

@ -396,6 +396,9 @@ export const publicTagsApi = {
// ==================== 作品广场 ====================
export const publicGalleryApi = {
recommended: (): Promise<UserWork[]> =>
publicApi.get("/public/gallery/recommended"),
list: (params?: {
page?: number
pageSize?: number

View File

@ -10,76 +10,213 @@
</template>
</a-card>
<a-table
:columns="columns"
:data-source="tags"
:loading="loading"
:pagination="false"
row-key="id"
class="data-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'category'">
<a-tag v-if="record.category">{{ record.category }}</a-tag>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'enabled' ? 'green' : 'red'">
{{ record.status === 'enabled' ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="openModal(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleToggle(record)">
{{ record.status === 'enabled' ? '禁用' : '启用' }}
</a-button>
<a-popconfirm title="确定删除?" @confirm="handleDelete(record.id)">
<a-button type="link" danger size="small" :disabled="record.usageCount > 0">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<!-- 按分类分组展示 -->
<div v-if="loading" class="loading-wrap"><a-spin /></div>
<a-modal v-model:open="modalVisible" :title="editingId ? '编辑标签' : '新增标签'" :footer="null" :width="400">
<template v-else>
<div v-for="group in groupedTags" :key="group.category" class="category-group">
<div class="category-header">
<span class="category-name">
<span class="category-dot" :style="{ background: group.color }"></span>
{{ group.category }}
</span>
<span class="category-count">{{ group.tags.length }} 个标签</span>
</div>
<div class="tag-grid">
<div
v-for="(tag, idx) in group.tags"
:key="tag.id"
class="tag-card"
:class="{ disabled: tag.status === 'disabled' }"
>
<!-- 预览色条 -->
<div class="tag-color-bar" :style="{ background: tag.color || group.color }"></div>
<div class="tag-body">
<div class="tag-main">
<span class="tag-name">{{ tag.name }}</span>
<a-tag v-if="tag.status === 'disabled'" color="red" style="margin-left: 6px; font-size: 10px">已禁用</a-tag>
</div>
<div class="tag-meta">
<span
class="tag-usage"
:class="{ clickable: tag.usageCount > 0 }"
@click="tag.usageCount > 0 && goToWorks(tag.name)"
>
{{ tag.usageCount }} 次使用
</span>
<span class="tag-sort">排序: {{ tag.sort }}</span>
</div>
<!-- 预览效果 -->
<div class="tag-preview">
<span class="preview-label">用户端预览</span>
<a-tag :color="tag.color || group.color">{{ tag.name }}</a-tag>
</div>
<div class="tag-actions">
<!-- 排序按钮 -->
<a-button type="text" size="small" :disabled="idx === 0" @click="moveTag(group, idx, -1)">
<up-outlined />
</a-button>
<a-button type="text" size="small" :disabled="idx === group.tags.length - 1" @click="moveTag(group, idx, 1)">
<down-outlined />
</a-button>
<a-button type="link" size="small" @click="openModal(tag)">编辑</a-button>
<a-button type="link" size="small" @click="handleToggle(tag)">
{{ tag.status === 'enabled' ? '禁用' : '启用' }}
</a-button>
<a-popconfirm title="确定删除?" @confirm="handleDelete(tag.id)">
<a-button type="link" danger size="small" :disabled="tag.usageCount > 0">删除</a-button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</div>
<!-- 未分类 -->
<div v-if="uncategorizedTags.length > 0" class="category-group">
<div class="category-header">
<span class="category-name">
<span class="category-dot" style="background: #9ca3af"></span>
未分类
</span>
<span class="category-count">{{ uncategorizedTags.length }} 个标签</span>
</div>
<div class="tag-grid">
<div
v-for="tag in uncategorizedTags"
:key="tag.id"
class="tag-card"
:class="{ disabled: tag.status === 'disabled' }"
>
<div class="tag-color-bar" :style="{ background: tag.color || '#9ca3af' }"></div>
<div class="tag-body">
<div class="tag-main">
<span class="tag-name">{{ tag.name }}</span>
<a-tag v-if="tag.status === 'disabled'" color="red" style="margin-left: 6px; font-size: 10px">已禁用</a-tag>
</div>
<div class="tag-meta">
<span class="tag-usage" :class="{ clickable: tag.usageCount > 0 }" @click="tag.usageCount > 0 && goToWorks(tag.name)">{{ tag.usageCount }} 次使用</span>
</div>
<div class="tag-preview">
<span class="preview-label">用户端预览</span>
<a-tag :color="tag.color || '#9ca3af'">{{ tag.name }}</a-tag>
</div>
<div class="tag-actions">
<a-button type="link" size="small" @click="openModal(tag)">编辑</a-button>
<a-button type="link" size="small" @click="handleToggle(tag)">{{ tag.status === 'enabled' ? '禁用' : '启用' }}</a-button>
<a-popconfirm title="确定删除?" @confirm="handleDelete(tag.id)">
<a-button type="link" danger size="small" :disabled="tag.usageCount > 0">删除</a-button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</div>
<div v-if="tags.length === 0" class="empty-wrap">
<a-empty description="暂无标签" />
</div>
</template>
<!-- 新增/编辑弹窗 -->
<a-modal v-model:open="modalVisible" :title="editingId ? '编辑标签' : '新增标签'" :footer="null" :width="440">
<a-form :model="form" layout="vertical" @finish="handleSubmit" style="margin-top: 16px">
<a-form-item label="标签名称" :rules="[{ required: true, message: '请输入' }]">
<a-input v-model:value="form.name" placeholder="如:童话、科幻、自然" />
</a-form-item>
<a-form-item label="所属分类">
<a-input v-model:value="form.category" placeholder="如:主题、风格、情感" />
<a-form-item label="所属分类" :rules="[{ required: true, message: '请选择分类' }]">
<a-select
v-model:value="form.category"
placeholder="选择或输入新分类"
:options="categoryOptions"
show-search
allow-clear
:filter-option="false"
@search="onCategorySearch"
>
<template #notFoundContent>
<div v-if="categorySearchVal" style="padding: 4px 8px; font-size: 12px; color: #6b7280; cursor: pointer" @click="form.category = categorySearchVal">
创建分类{{ categorySearchVal }}
</div>
<span v-else style="color: #9ca3af">请输入分类名</span>
</template>
</a-select>
</a-form-item>
<a-form-item label="标签颜色">
<div class="color-picker-row">
<span
v-for="c in presetColors"
:key="c"
:class="['color-dot', { active: form.color === c }]"
:style="{ background: c }"
@click="form.color = c"
></span>
<a-input v-model:value="form.color" placeholder="#6366f1" style="width: 100px; margin-left: 8px" size="small" />
</div>
</a-form-item>
<a-form-item label="排序权重">
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" />
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" placeholder="数字越小越靠前" />
</a-form-item>
<a-button type="primary" html-type="submit" block :loading="submitting">保存</a-button>
<!-- 实时预览 -->
<div class="form-preview">
<span class="preview-label">用户端预览效果</span>
<a-tag :color="form.color || '#6366f1'">{{ form.name || '标签名称' }}</a-tag>
</div>
<a-button type="primary" html-type="submit" block :loading="submitting" style="margin-top: 16px">保存</a-button>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import { PlusOutlined, UpOutlined, DownOutlined } from '@ant-design/icons-vue'
import request from '@/utils/request'
const router = useRouter()
const tags = ref<any[]>([])
const categories = ref<string[]>([])
const loading = ref(false)
const modalVisible = ref(false)
const submitting = ref(false)
const editingId = ref<number | null>(null)
const form = reactive({ name: '', category: '', sort: 0 })
const form = reactive({ name: '', category: '', color: '', sort: 0 })
const categorySearchVal = ref('')
const columns = [
{ title: '标签名称', dataIndex: 'name', key: 'name', width: 150 },
{ title: '分类', key: 'category', width: 120 },
{ title: '使用次数', dataIndex: 'usageCount', key: 'usageCount', width: 100 },
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
{ title: '状态', key: 'status', width: 80 },
{ title: '操作', key: 'action', width: 180, fixed: 'right' as const },
]
const presetColors = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#ef4444', '#3b82f6', '#8b5cf6', '#14b8a6', '#f97316', '#64748b']
// +
const categoryOptions = computed(() => {
const opts = categories.value.map(c => ({ label: c, value: c }))
if (categorySearchVal.value && !categories.value.includes(categorySearchVal.value)) {
opts.push({ label: `创建「${categorySearchVal.value}`, value: categorySearchVal.value })
}
return opts
})
const onCategorySearch = (val: string) => { categorySearchVal.value = val }
// #1
const categoryColorMap: Record<string, string> = {}
const groupedTags = computed(() => {
const groups: Record<string, { category: string; color: string; tags: any[] }> = {}
for (const tag of tags.value) {
if (!tag.category) continue
if (!groups[tag.category]) {
//
const color = tag.color || '#6366f1'
groups[tag.category] = { category: tag.category, color, tags: [] }
}
groups[tag.category].tags.push(tag)
}
return Object.values(groups)
})
const uncategorizedTags = computed(() => tags.value.filter(t => !t.category))
const fetchTags = async () => {
loading.value = true
@ -87,11 +224,17 @@ const fetchTags = async () => {
finally { loading.value = false }
}
const fetchCategories = async () => {
try { categories.value = await request.get('/tags/categories') as any } catch { /* */ }
}
const openModal = (record?: any) => {
editingId.value = record?.id || null
form.name = record?.name || ''
form.category = record?.category || ''
form.color = record?.color || ''
form.sort = record?.sort || 0
categorySearchVal.value = ''
modalVisible.value = true
}
@ -107,6 +250,7 @@ const handleSubmit = async () => {
}
modalVisible.value = false
fetchTags()
fetchCategories()
} catch (e: any) { message.error(e?.response?.data?.message || '操作失败') }
finally { submitting.value = false }
}
@ -124,7 +268,47 @@ const handleDelete = async (id: number) => {
catch (e: any) { message.error(e?.response?.data?.message || '删除失败') }
}
onMounted(fetchTags)
// #4 /
const moveTag = async (group: { tags: any[] }, idx: number, direction: number) => {
const targetIdx = idx + direction
if (targetIdx < 0 || targetIdx >= group.tags.length) return
//
const a = group.tags[idx]
const b = group.tags[targetIdx]
const tmpSort = a.sort
a.sort = b.sort
b.sort = tmpSort
//
if (a.sort === b.sort) {
a.sort = targetIdx
b.sort = idx
}
//
;[group.tags[idx], group.tags[targetIdx]] = [group.tags[targetIdx], group.tags[idx]]
try {
await request.post('/tags/batch-sort', {
items: [
{ id: a.id, sort: a.sort },
{ id: b.id, sort: b.sort },
],
})
} catch {
message.error('排序保存失败')
fetchTags()
}
}
// #5 使
const goToWorks = (tagName: string) => {
//
router.push({ path: '/content/management', query: { keyword: tagName } })
}
onMounted(() => { fetchTags(); fetchCategories() })
</script>
<style scoped lang="scss">
@ -133,9 +317,96 @@ $primary: #6366f1;
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
:deep(.ant-card-body) { padding: 0; }
}
.data-table { :deep(.ant-table-wrapper) { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
} }
.text-muted { color: #d1d5db; }
.loading-wrap { padding: 60px 0; display: flex; justify-content: center; }
.empty-wrap { padding: 60px 0; display: flex; justify-content: center; }
//
.category-group {
margin-bottom: 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
overflow: hidden;
}
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
.category-name {
display: flex; align-items: center; gap: 8px;
font-size: 15px; font-weight: 600; color: #1e1b4b;
}
.category-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.category-count { font-size: 12px; color: #9ca3af; }
}
.tag-grid { padding: 12px 16px; display: flex; flex-direction: column; gap: 8px; }
.tag-card {
display: flex;
border: 1px solid rgba($primary, 0.06);
border-radius: 10px;
overflow: hidden;
transition: all 0.2s;
&:hover { box-shadow: 0 2px 12px rgba($primary, 0.08); }
&.disabled { opacity: 0.55; }
.tag-color-bar { width: 4px; flex-shrink: 0; }
.tag-body {
flex: 1; padding: 10px 14px;
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
.tag-main { display: flex; align-items: center; min-width: 100px; }
.tag-name { font-size: 14px; font-weight: 600; color: #1e1b4b; }
.tag-meta {
display: flex; gap: 12px;
.tag-usage { font-size: 12px; color: #9ca3af;
&.clickable { color: $primary; cursor: pointer; &:hover { text-decoration: underline; } }
}
.tag-sort { font-size: 12px; color: #d1d5db; }
}
.tag-preview {
display: flex; align-items: center; gap: 6px;
.preview-label { font-size: 11px; color: #d1d5db; }
}
.tag-actions {
margin-left: auto;
display: flex; align-items: center; gap: 2px;
}
}
}
//
.color-picker-row {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
.color-dot {
width: 24px; height: 24px; border-radius: 50%; cursor: pointer;
border: 2px solid transparent; transition: all 0.2s;
&:hover { transform: scale(1.15); }
&.active { border-color: #1e1b4b; box-shadow: 0 0 0 2px rgba(0,0,0,0.1); }
}
}
//
.form-preview {
display: flex; align-items: center; gap: 8px;
padding: 12px 16px;
background: #fafafa;
border-radius: 8px;
.preview-label { font-size: 12px; color: #9ca3af; }
}
</style>

View File

@ -4,11 +4,21 @@
<template #title>作品管理</template>
</a-card>
<!-- 统计 -->
<!-- 统计卡片可点击筛选 -->
<div class="stats-row">
<div class="stat-card" v-for="item in mgmtStats" :key="item.label">
<div class="stat-count">{{ item.value }}</div>
<div class="stat-label">{{ item.label }}</div>
<div
v-for="item in statsItems"
:key="item.key"
:class="['stat-card', { active: activeStatKey === item.key }]"
@click="handleStatClick(item.key)"
>
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
<div class="stat-info">
<span class="stat-count">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
</div>
@ -18,8 +28,16 @@
<a-form-item label="作品/作者">
<a-input v-model:value="keyword" placeholder="作品名称或作者" allow-clear style="width: 180px" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option value="">全部</a-select-option>
<a-select-option value="published">正常</a-select-option>
<a-select-option value="taken_down">已下架</a-select-option>
<a-select-option value="recommended">推荐中</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="排序">
<a-select v-model:value="sortBy" style="width: 120px">
<a-select v-model:value="sortBy" style="width: 120px" @change="handleSearch">
<a-select-option value="latest">最新发布</a-select-option>
<a-select-option value="hot">最多点赞</a-select-option>
<a-select-option value="views">最多浏览</a-select-option>
@ -40,6 +58,13 @@
<template v-if="column.key === 'index'">{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</template>
<template v-else-if="column.key === 'cover'">
<img v-if="record.coverUrl" :src="record.coverUrl" class="cover-thumb" />
<div v-else class="cover-empty"></div>
</template>
<template v-else-if="column.key === 'titleDesc'">
<div class="title-cell">
<span class="work-title">{{ record.title }}</span>
<span v-if="record.description" class="work-desc">{{ record.description }}</span>
</div>
</template>
<template v-else-if="column.key === 'author'">{{ record.creator?.nickname || '-' }}</template>
<template v-else-if="column.key === 'status'">
@ -51,22 +76,137 @@
<template v-else-if="column.key === 'publishTime'">{{ formatDate(record.publishTime) }}</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="showDetail(record.id)">查看</a-button>
<a-button type="link" size="small" @click="handleRecommend(record)">
{{ record.isRecommended ? '取消推荐' : '推荐' }}
</a-button>
<a-button v-if="record.status === 'published'" type="link" danger size="small" @click="handleTakedown(record)">下架</a-button>
<a-button v-else type="link" size="small" @click="handleRestore(record)">恢复</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-space>
</template>
</template>
</a-table>
<!-- 下架弹窗填写原因 -->
<a-modal v-model:open="takedownVisible" title="下架作品" @ok="handleTakedown" :confirm-loading="takedownLoading">
<p style="margin-bottom: 12px; color: #6b7280; font-size: 13px">
下架后作品{{ takedownTarget?.title }}将不再公开展示请填写下架原因
</p>
<a-radio-group v-model:value="takedownReason" style="display: flex; flex-direction: column; gap: 8px">
<a-radio value="含不适宜内容">含不适宜内容</a-radio>
<a-radio value="涉嫌抄袭/侵权">涉嫌抄袭/侵权</a-radio>
<a-radio value="用户投诉/举报">用户投诉/举报</a-radio>
<a-radio value="违反平台规范">违反平台规范</a-radio>
<a-radio value="other">其他</a-radio>
</a-radio-group>
<a-input v-if="takedownReason === 'other'" v-model:value="takedownCustom" placeholder="请输入下架原因" style="margin-top: 12px" />
</a-modal>
<!-- 详情 Drawer -->
<a-drawer v-model:open="detailVisible" title="作品详情" :width="580" :destroy-on-close="true">
<template v-if="detailData">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="标题" :span="2">{{ detailData.title }}</a-descriptions-item>
<a-descriptions-item label="作者">{{ detailData.creator?.nickname }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="detailData.status === 'published' ? 'green' : 'red'">{{ detailData.status === 'published' ? '正常' : '已下架' }}</a-tag>
<a-tag v-if="detailData.isRecommended" color="blue">推荐</a-tag>
</a-descriptions-item>
<a-descriptions-item label="浏览">{{ detailData.viewCount || 0 }}</a-descriptions-item>
<a-descriptions-item label="点赞">{{ detailData.likeCount || 0 }}</a-descriptions-item>
<a-descriptions-item label="收藏">{{ detailData.favoriteCount || 0 }}</a-descriptions-item>
<a-descriptions-item label="发布时间">{{ formatDate(detailData.publishTime) }}</a-descriptions-item>
</a-descriptions>
<!-- 作品描述 -->
<div v-if="detailData.description" class="detail-section">
<h4>作品简介</h4>
<p class="detail-desc">{{ detailData.description }}</p>
</div>
<!-- 标签 -->
<div v-if="detailData.tags?.length" class="detail-section">
<h4>标签</h4>
<div style="display: flex; gap: 6px; flex-wrap: wrap">
<a-tag v-for="t in detailData.tags" :key="t.tag?.id" color="purple">{{ t.tag?.name }}</a-tag>
</div>
</div>
<!-- 绘本翻页预览 -->
<div v-if="detailData.pages?.length" class="preview-section">
<h4>绘本内容预览</h4>
<div class="page-preview">
<img v-if="detailData.pages[previewPage]?.imageUrl" :src="detailData.pages[previewPage].imageUrl" class="preview-img" />
<p v-if="detailData.pages[previewPage]?.text" class="preview-text">{{ detailData.pages[previewPage].text }}</p>
<div class="preview-nav">
<a-button :disabled="previewPage === 0" size="small" @click="previewPage--">上一页</a-button>
<span>{{ previewPage + 1 }} / {{ detailData.pages.length }}</span>
<a-button :disabled="previewPage === detailData.pages.length - 1" size="small" @click="previewPage++">下一页</a-button>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="drawer-actions">
<a-space style="width: 100%">
<a-button
v-if="detailData.status === 'published'"
@click="handleRecommendInDrawer"
:style="{ flex: 1, color: detailData.isRecommended ? undefined : '#1677ff', borderColor: detailData.isRecommended ? undefined : '#1677ff' }"
>
{{ detailData.isRecommended ? '取消推荐' : '设为推荐' }}
</a-button>
<a-button
v-if="detailData.status === 'published'"
danger
style="flex: 1"
@click="openTakedown(detailData); detailVisible = false"
>
下架
</a-button>
<a-button
v-if="detailData.status === 'taken_down'"
type="primary"
style="flex: 1"
@click="handleRestoreInDrawer"
>
恢复上架
</a-button>
</a-space>
</div>
<!-- 操作日志 -->
<div class="log-section">
<h4>操作日志</h4>
<div v-if="detailLogs.length === 0" class="log-empty">暂无操作记录</div>
<a-timeline v-else>
<a-timeline-item
v-for="log in detailLogs"
:key="log.id"
:color="logActionColor[log.action] || 'gray'"
>
<div class="log-item">
<span class="log-action">{{ logActionText[log.action] || log.action }}</span>
<span class="log-operator">{{ log.operator?.nickname || '系统' }}</span>
<span class="log-time">{{ formatDate(log.createTime) }}</span>
</div>
<div v-if="log.reason" class="log-reason">原因{{ log.reason }}</div>
<div v-if="log.note" class="log-note">备注{{ log.note }}</div>
</a-timeline-item>
</a-timeline>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import {
SearchOutlined, ReloadOutlined,
AppstoreOutlined, PlusCircleOutlined, EyeOutlined, StopOutlined,
} from '@ant-design/icons-vue'
import request from '@/utils/request'
import dayjs from 'dayjs'
@ -75,50 +215,68 @@ const dataSource = ref<any[]>([])
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `${t}` })
const keyword = ref('')
const sortBy = ref('latest')
const mgmtStats = ref([
{ label: '总作品数', value: 0 },
{ label: '今日新增', value: 0 },
{ label: '累计浏览', value: 0 },
{ label: '已下架', value: 0 },
const filterStatus = ref('')
const activeStatKey = ref('')
//
const statsRaw = ref({ total: 0, todayNew: 0, totalViews: 0, takenDown: 0 })
const statsItems = computed(() => [
{ key: 'total', label: '总作品数', value: statsRaw.value.total, icon: AppstoreOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
{ key: 'todayNew', label: '今日新增', value: statsRaw.value.todayNew, icon: PlusCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
{ key: 'totalViews', label: '累计浏览', value: statsRaw.value.totalViews, icon: EyeOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
{ key: 'takenDown', label: '已下架', value: statsRaw.value.takenDown, icon: StopOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)' },
])
//
const takedownVisible = ref(false)
const takedownLoading = ref(false)
const takedownTarget = ref<any>(null)
const takedownReason = ref('')
const takedownCustom = ref('')
// +
const detailVisible = ref(false)
const detailData = ref<any>(null)
const detailLogs = ref<any[]>([])
const previewPage = ref(0)
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' }
// #6
const columns = [
{ title: '序号', key: 'index', width: 50 },
{ title: '封面', key: 'cover', width: 70 },
{ title: '作品名称', dataIndex: 'title', key: 'title', width: 180 },
{ title: '作者', key: 'author', width: 100 },
{ title: '浏览', dataIndex: 'viewCount', key: 'viewCount', width: 70 },
{ title: '点赞', dataIndex: 'likeCount', key: 'likeCount', width: 70 },
{ title: '收藏', dataIndex: 'favoriteCount', key: 'favoriteCount', width: 70 },
{ title: '作品名称', key: 'titleDesc', width: 220 },
{ title: '作者', key: 'author', width: 90 },
{ title: '浏览', dataIndex: 'viewCount', key: 'viewCount', width: 65 },
{ title: '点赞', dataIndex: 'likeCount', key: 'likeCount', width: 65 },
{ title: '收藏', dataIndex: 'favoriteCount', key: 'favoriteCount', width: 65 },
{ title: '状态', key: 'status', width: 110 },
{ title: '发布时间', key: 'publishTime', width: 140 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
{ title: '发布时间', key: 'publishTime', width: 130 },
{ title: '操作', key: 'action', width: 210, fixed: 'right' as const },
]
const formatDate = (d: string) => d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-'
const fetchStats = async () => {
try {
const s: any = await request.get('/content-review/management/stats')
mgmtStats.value = [
{ label: '总作品数', value: s.total },
{ label: '今日新增', value: s.todayNew },
{ label: '累计浏览', value: s.totalViews },
{ label: '已下架', value: s.takenDown },
]
statsRaw.value = await request.get('/content-review/management/stats') as any
} catch { /* */ }
}
const fetchList = async () => {
loading.value = true
try {
// +
const isRecommendedFilter = filterStatus.value === 'recommended'
const res: any = await request.get('/content-review/works', {
params: {
page: pagination.current,
pageSize: pagination.pageSize,
status: 'published', //
status: isRecommendedFilter ? 'published' : (filterStatus.value || 'published,taken_down'),
keyword: keyword.value || undefined,
sortBy: sortBy.value,
isRecommended: isRecommendedFilter ? '1' : undefined,
},
})
dataSource.value = res.list
@ -127,25 +285,94 @@ const fetchList = async () => {
finally { loading.value = false }
}
const handleSearch = () => { pagination.current = 1; fetchList() }
const handleReset = () => { keyword.value = ''; sortBy.value = 'latest'; pagination.current = 1; fetchList(); fetchStats() }
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
const handleRecommend = async (record: any) => {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success(record.isRecommended ? '已取消推荐' : '已推荐'); fetchList() }
catch { message.error('操作失败') }
// #7
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()
}
const handleTakedown = (record: any) => {
// #1
const handleSearch = () => { activeStatKey.value = ''; pagination.current = 1; fetchList() }
const handleReset = () => { keyword.value = ''; sortBy.value = 'latest'; filterStatus.value = ''; activeStatKey.value = ''; pagination.current = 1; fetchList(); fetchStats() }
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
//
const showDetail = async (id: number) => {
previewPage.value = 0
detailLogs.value = []
try {
const [work, logs]: any[] = await Promise.all([
request.get(`/content-review/works/${id}`),
request.get('/content-review/logs', { params: { workId: id, pageSize: 50 } }),
])
detailData.value = work
detailLogs.value = logs.list || []
detailVisible.value = true
} catch { message.error('获取详情失败') }
}
// #3 /
const handleRecommend = async (record: any) => {
if (record.isRecommended) {
Modal.confirm({
title: '确定下架?',
content: `下架后作品「${record.title}」将不再公开展示`,
okType: 'danger',
title: '确定取消推荐?',
content: `作品「${record.title}」将不再显示在推荐位`,
onOk: async () => {
try { await request.post(`/content-review/works/${record.id}/takedown`, { reason: '管理员下架' }); message.success('已下架'); fetchList(); fetchStats() }
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success('已取消推荐'); fetchList() }
catch { message.error('操作失败') }
},
})
} else {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success('已推荐'); fetchList() }
catch { message.error('操作失败') }
}
}
// #4
const handleRecommendInDrawer = async () => {
const record = detailData.value
if (!record) return
if (record.isRecommended) {
Modal.confirm({
title: '确定取消推荐?',
onOk: async () => {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success('已取消推荐'); fetchList(); showDetail(record.id) }
catch { message.error('操作失败') }
},
})
} else {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success('已推荐'); fetchList(); showDetail(record.id) }
catch { message.error('操作失败') }
}
}
// #2
const openTakedown = (record: any) => {
takedownTarget.value = record
takedownReason.value = ''
takedownCustom.value = ''
takedownVisible.value = true
}
const handleTakedown = async () => {
const reason = takedownReason.value === 'other' ? takedownCustom.value : takedownReason.value
if (!reason) { message.warning('请选择下架原因'); return }
takedownLoading.value = true
try {
await request.post(`/content-review/works/${takedownTarget.value.id}/takedown`, { reason })
message.success('已下架')
takedownVisible.value = false
fetchList(); fetchStats()
} catch { message.error('操作失败') }
finally { takedownLoading.value = false }
}
const handleRestore = async (record: any) => {
@ -153,18 +380,73 @@ const handleRestore = async (record: any) => {
catch { message.error('操作失败') }
}
// #4
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) }
catch { message.error('操作失败') }
}
onMounted(() => { fetchStats(); fetchList() })
</script>
<style scoped lang="scss">
$primary: #6366f1;
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); :deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } } :deep(.ant-card-body) { padding: 0; } }
// #7
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; }
.stat-card { flex: 1; background: #fff; border-radius: 12px; padding: 16px 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); text-align: center;
.stat-count { font-size: 22px; font-weight: 700; color: #1e1b4b; }
.stat-label { font-size: 12px; color: #9ca3af; margin-top: 2px; }
.stat-card { flex: 1; display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); cursor: pointer; border: 2px solid transparent; transition: all 0.2s;
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); } &.active { border-color: $primary; background: rgba($primary, 0.02); }
.stat-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
.stat-info { display: flex; flex-direction: column; .stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; } .stat-label { font-size: 12px; color: #9ca3af; } }
}
.filter-bar { padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px; }
.data-table { :deep(.ant-table-wrapper) { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden; .ant-table-thead > tr > th { background: #fafafa; font-weight: 600; } .ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); } .ant-table-pagination { padding: 16px; margin: 0; } } }
// #6 +
.title-cell {
display: flex; flex-direction: column; gap: 2px;
.work-title { font-size: 13px; font-weight: 600; color: #1e1b4b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-desc { font-size: 11px; color: #9ca3af; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
}
.cover-thumb { width: 48px; height: 64px; object-fit: cover; border-radius: 6px; }
.cover-empty { width: 48px; height: 64px; background: #f3f4f6; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #d1d5db; }
//
.detail-section {
margin-top: 16px;
h4 { font-size: 13px; font-weight: 600; color: #374151; margin: 0 0 8px; }
.detail-desc { font-size: 13px; color: #6b7280; line-height: 1.6; margin: 0; }
}
.preview-section { margin-top: 20px; h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; } }
.page-preview {
.preview-img { width: 100%; max-height: 300px; object-fit: contain; border-radius: 8px; margin-bottom: 8px; }
.preview-text { font-size: 13px; color: #374151; line-height: 1.6; padding: 8px 0; }
.preview-nav { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; span { font-size: 12px; color: #6b7280; } }
}
// #4
.drawer-actions {
margin-top: 20px; padding-top: 16px; border-top: 1px solid #f0ecf9;
:deep(.ant-space) { display: flex; .ant-space-item { flex: 1; .ant-btn { width: 100%; } } }
}
//
.log-section {
margin-top: 24px;
h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
.log-empty { font-size: 13px; color: #9ca3af; text-align: center; padding: 20px 0; }
}
.log-item {
display: flex; align-items: center; gap: 8px;
.log-action { font-size: 13px; font-weight: 600; color: #1e1b4b; }
.log-operator { font-size: 12px; color: #6b7280; }
.log-time { font-size: 11px; color: #9ca3af; margin-left: auto; }
}
.log-reason, .log-note { font-size: 12px; color: #6b7280; margin-top: 2px; }
</style>

View File

@ -21,7 +21,8 @@
<div class="filter-bar">
<a-form layout="inline" @finish="handleSearch">
<a-form-item label="审核状态">
<a-select v-model:value="searchStatus" placeholder="全部" allow-clear style="width: 120px">
<a-select v-model:value="searchStatus" style="width: 120px" @change="handleSearch">
<a-select-option value="">全部</a-select-option>
<a-select-option value="pending_review">待审核</a-select-option>
<a-select-option value="published">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
@ -39,26 +40,61 @@
</a-form>
</div>
<!-- 批量操作栏 -->
<div class="batch-bar">
<template v-if="selectedRowKeys.length > 0">
<span>已选择 <strong>{{ selectedRowKeys.length }}</strong> </span>
<a-button type="primary" size="small" :loading="batchLoading" @click="handleBatchApprove">
<template #icon><CheckCircleOutlined /></template>
批量通过
</a-button>
<a-button danger size="small" @click="openBatchReject">
<template #icon><CloseCircleOutlined /></template>
批量拒绝
</a-button>
<a-button size="small" @click="selectedRowKeys = []">取消选择</a-button>
</template>
<span v-else class="batch-tip">勾选表格中的待审核作品可进行批量操作</span>
</div>
<!-- 表格 -->
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id" @change="handleTableChange" class="data-table">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
:row-selection="{ selectedRowKeys, onChange: onSelectChange, getCheckboxProps: (r: any) => ({ disabled: r.status !== 'pending_review' }) }"
row-key="id"
@change="handleTableChange"
class="data-table"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</template>
<template v-else-if="column.key === 'cover'">
<img v-if="record.coverUrl" :src="record.coverUrl" class="cover-thumb" />
<div v-else class="cover-empty"></div>
</template>
<template v-else-if="column.key === 'titleDesc'">
<div class="title-cell">
<span class="work-title">{{ record.title }}</span>
<span v-if="record.description" class="work-desc">{{ record.description }}</span>
</div>
</template>
<template v-else-if="column.key === 'author'">{{ record.creator?.nickname || '-' }}</template>
<template v-else-if="column.key === 'tags'">
<a-tag v-for="t in (record.tags || []).slice(0, 3)" :key="t.tag?.id" size="small">{{ t.tag?.name }}</a-tag>
<span v-if="!record.tags?.length" style="color: #d1d5db">-</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusColor[record.status] || 'default'">{{ statusText[record.status] || record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'createTime'">{{ formatDate(record.createTime) }}</template>
<template v-else-if="column.key === 'reviewTime'">{{ record.reviewTime ? formatDate(record.reviewTime) : '-' }}</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button v-if="record.status === 'pending_review'" type="link" size="small" style="color: #10b981" @click="quickApprove(record.id)">通过</a-button>
<a-button v-if="record.status === 'pending_review'" type="link" size="small" danger @click="openReject(record.id)">拒绝</a-button>
<a-button v-if="record.status === 'published' || record.status === 'rejected'" type="link" size="small" style="color: #f59e0b" @click="handleRevoke(record.id)">撤销</a-button>
<a-button type="link" size="small" @click="showDetail(record.id)">详情</a-button>
</a-space>
</template>
@ -66,7 +102,7 @@
</a-table>
<!-- 拒绝弹窗 -->
<a-modal v-model:open="rejectVisible" title="拒绝作品" @ok="handleReject" :confirm-loading="rejectLoading">
<a-modal v-model:open="rejectVisible" :title="isBatchReject ? `批量拒绝${selectedRowKeys.length}个作品` : '拒绝作品'" @ok="handleReject" :confirm-loading="rejectLoading">
<a-radio-group v-model:value="rejectReason" style="display: flex; flex-direction: column; gap: 8px">
<a-radio value="含不适宜未成年人的内容">含不适宜未成年人的内容</a-radio>
<a-radio value="含个人隐私信息">含个人隐私信息</a-radio>
@ -78,18 +114,44 @@
</a-modal>
<!-- 详情 Drawer -->
<a-drawer v-model:open="detailVisible" title="作品审核详情" :width="560" :destroy-on-close="true">
<a-drawer v-model:open="detailVisible" title="作品审核详情" :width="580" :destroy-on-close="true">
<template v-if="detailData">
<a-descriptions title="作品信息" :column="2" bordered size="small">
<a-descriptions-item label="标题" :span="2">{{ detailData.title }}</a-descriptions-item>
<!-- 顶部快速导航 -->
<div class="detail-nav">
<a-button :disabled="!hasPrevPending" size="small" @click="gotoPrevPending">
<left-outlined /> 上一个
</a-button>
<span class="detail-nav-info">
{{ detailData.title }}
<a-tag :color="statusColor[detailData.status]" style="margin-left: 8px">{{ statusText[detailData.status] }}</a-tag>
</span>
<a-button :disabled="!hasNextPending" size="small" @click="gotoNextPending">
下一个 <right-outlined />
</a-button>
</div>
<a-descriptions :column="2" bordered size="small" style="margin-top: 16px">
<a-descriptions-item label="作者">{{ detailData.creator?.nickname }}</a-descriptions-item>
<a-descriptions-item label="用户类型">{{ detailData.creator?.userType === 'child' ? '子女' : '成人' }}</a-descriptions-item>
<a-descriptions-item label="页数">{{ detailData._count?.pages || 0 }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor[detailData.status]">{{ statusText[detailData.status] }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ formatDate(detailData.createTime) }}</a-descriptions-item>
<a-descriptions-item v-if="detailData.reviewTime" label="审核时间" :span="2">{{ formatDate(detailData.reviewTime) }}</a-descriptions-item>
</a-descriptions>
<!-- 作品描述 -->
<div v-if="detailData.description" class="detail-section">
<h4>作品简介</h4>
<p class="detail-desc">{{ detailData.description }}</p>
</div>
<!-- 标签 -->
<div v-if="detailData.tags?.length" class="detail-section">
<h4>标签</h4>
<div style="display: flex; gap: 6px; flex-wrap: wrap">
<a-tag v-for="t in detailData.tags" :key="t.tag?.id" color="purple">{{ t.tag?.name }}</a-tag>
</div>
</div>
<!-- 绘本翻页预览 -->
<div v-if="detailData.pages?.length" class="preview-section">
<h4>绘本内容预览</h4>
@ -106,8 +168,34 @@
<!-- 审核操作 -->
<div v-if="detailData.status === 'pending_review'" class="review-actions">
<a-button type="primary" block @click="quickApprove(detailData.id); detailVisible = false" style="margin-bottom: 8px">通过</a-button>
<a-button danger block @click="openReject(detailData.id); detailVisible = false">拒绝</a-button>
<a-space style="width: 100%">
<a-button type="primary" style="flex: 1" @click="approveInDrawer">通过</a-button>
<a-button danger style="flex: 1" @click="rejectInDrawer">拒绝</a-button>
</a-space>
</div>
<div v-else-if="detailData.status === 'published' || detailData.status === 'rejected'" class="review-actions">
<a-button block style="color: #f59e0b; border-color: #f59e0b" @click="handleRevoke(detailData.id)">撤销审核</a-button>
</div>
<!-- 操作日志 -->
<div class="log-section">
<h4>操作日志</h4>
<div v-if="detailLogs.length === 0" class="log-empty">暂无操作记录</div>
<a-timeline v-else>
<a-timeline-item
v-for="log in detailLogs"
:key="log.id"
:color="logActionColor[log.action] || 'gray'"
>
<div class="log-item">
<span class="log-action">{{ logActionText[log.action] || log.action }}</span>
<span class="log-operator">{{ log.operator?.nickname || '系统' }}</span>
<span class="log-time">{{ formatDate(log.createTime) }}</span>
</div>
<div v-if="log.reason" class="log-reason">原因{{ log.reason }}</div>
<div v-if="log.note" class="log-note">备注{{ log.note }}</div>
</a-timeline-item>
</a-timeline>
</div>
</template>
</a-drawer>
@ -116,17 +204,26 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { SearchOutlined, ReloadOutlined, ClockCircleOutlined, CheckCircleOutlined, CloseCircleOutlined, FileTextOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import {
SearchOutlined, ReloadOutlined, ClockCircleOutlined, CheckCircleOutlined,
CloseCircleOutlined, FileTextOutlined, LeftOutlined, RightOutlined,
} from '@ant-design/icons-vue'
import request from '@/utils/request'
import dayjs from 'dayjs'
const loading = ref(false)
const dataSource = ref<any[]>([])
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `${t}` })
const searchStatus = ref<string | undefined>(undefined)
// #1
const searchStatus = ref('pending_review')
const searchKeyword = ref('')
const activeFilter = ref('')
const activeFilter = ref('pending_review')
//
const selectedRowKeys = ref<number[]>([])
const batchLoading = ref(false)
const onSelectChange = (keys: number[]) => { selectedRowKeys.value = keys }
const stats = ref({ pending: 0, todayReviewed: 0, todayApproved: 0, todayRejected: 0 })
const statsItems = computed(() => [
@ -138,18 +235,22 @@ const statsItems = computed(() => [
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 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') : '-'
// #2 + #4
const columns = [
{ title: '序号', key: 'index', width: 50 },
{ title: '封面', key: 'cover', width: 70 },
{ title: '作品名称', dataIndex: 'title', key: 'title', width: 180 },
{ title: '页数', dataIndex: ['_count', 'pages'], key: 'pages', width: 60 },
{ title: '作者', key: 'author', width: 100 },
{ title: '标签', key: 'tags', width: 140 },
{ title: '作品名称', key: 'titleDesc', width: 220 },
{ title: '页数', dataIndex: ['_count', 'pages'], key: 'pages', width: 50 },
{ title: '作者', key: 'author', width: 90 },
{ title: '标签', key: 'tags', width: 130 },
{ title: '状态', key: 'status', width: 80 },
{ title: '提交时间', key: 'createTime', width: 140 },
{ title: '操作', key: 'action', width: 150, fixed: 'right' as const },
{ title: '提交时间', key: 'createTime', width: 130 },
{ title: '审核时间', key: 'reviewTime', width: 130 },
{ title: '操作', key: 'action', width: 190, fixed: 'right' as const },
]
//
@ -159,10 +260,86 @@ const rejectTargetId = ref<number | null>(null)
const rejectReason = ref('')
const rejectCustom = ref('')
//
// +
const detailVisible = ref(false)
const detailData = ref<any>(null)
const detailLogs = ref<any[]>([])
const previewPage = ref(0)
const currentDetailIndex = ref(-1)
// #3 /
const pendingItems = computed(() => dataSource.value.filter(d => d.status === 'pending_review'))
const hasPrevPending = computed(() => {
if (currentDetailIndex.value < 0) return false
//
const currentId = detailData.value?.id
const idx = dataSource.value.findIndex(d => d.id === currentId)
for (let i = idx - 1; i >= 0; i--) {
if (dataSource.value[i].status === 'pending_review') return true
}
return false
})
const hasNextPending = computed(() => {
const currentId = detailData.value?.id
const idx = dataSource.value.findIndex(d => d.id === currentId)
for (let i = idx + 1; i < dataSource.value.length; i++) {
if (dataSource.value[i].status === 'pending_review') return true
}
return false
})
const gotoPrevPending = () => {
const currentId = detailData.value?.id
const idx = dataSource.value.findIndex(d => d.id === currentId)
for (let i = idx - 1; i >= 0; i--) {
if (dataSource.value[i].status === 'pending_review') {
showDetail(dataSource.value[i].id)
return
}
}
}
const gotoNextPending = () => {
const currentId = detailData.value?.id
const idx = dataSource.value.findIndex(d => d.id === currentId)
for (let i = idx + 1; i < dataSource.value.length; i++) {
if (dataSource.value[i].status === 'pending_review') {
showDetail(dataSource.value[i].id)
return
}
}
}
//
const approveInDrawer = async () => {
const id = detailData.value?.id
if (!id) return
try {
await request.post(`/content-review/works/${id}/approve`, {})
message.success('已通过')
fetchList(); fetchStats()
//
const idx = dataSource.value.findIndex(d => d.id === id)
let nextId: number | null = null
for (let i = idx + 1; i < dataSource.value.length; i++) {
if (dataSource.value[i].status === 'pending_review') { nextId = dataSource.value[i].id; break }
}
if (!nextId) {
for (let i = idx - 1; i >= 0; i--) {
if (dataSource.value[i].status === 'pending_review') { nextId = dataSource.value[i].id; break }
}
}
if (nextId) {
showDetail(nextId)
} else {
detailVisible.value = false
}
} catch { message.error('操作失败') }
}
const rejectInDrawer = () => {
openReject(detailData.value?.id)
}
const fetchStats = async () => {
try { stats.value = await request.get('/content-review/works/stats') as any } catch { /* */ }
@ -172,7 +349,7 @@ 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, keyword: searchKeyword.value || undefined },
params: { page: pagination.current, pageSize: pagination.pageSize, status: searchStatus.value || undefined, keyword: searchKeyword.value || undefined },
})
dataSource.value = res.list
pagination.total = res.total
@ -180,16 +357,28 @@ const fetchList = async () => {
finally { loading.value = false }
}
// #6 +
const handleStatClick = (key: string) => {
if (activeFilter.value === key) {
activeFilter.value = ''
searchStatus.value = ''
} else {
activeFilter.value = key
if (key === 'pending_review') searchStatus.value = 'pending_review'
else searchStatus.value = undefined
else if (key === 'approved') searchStatus.value = 'published'
else if (key === 'rejected') searchStatus.value = 'rejected'
else searchStatus.value = ''
}
pagination.current = 1
fetchList()
}
const handleSearch = () => { pagination.current = 1; fetchList() }
const handleReset = () => { searchStatus.value = undefined; searchKeyword.value = ''; activeFilter.value = ''; pagination.current = 1; fetchList(); fetchStats() }
const handleSearch = () => { activeFilter.value = ''; pagination.current = 1; fetchList() }
const handleReset = () => {
// #1
searchStatus.value = 'pending_review'; searchKeyword.value = ''; activeFilter.value = 'pending_review'
selectedRowKeys.value = []; pagination.current = 1; fetchList(); fetchStats()
}
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
const quickApprove = async (id: number) => {
@ -197,20 +386,79 @@ const quickApprove = async (id: number) => {
catch { message.error('操作失败') }
}
const openReject = (id: number) => { rejectTargetId.value = id; rejectReason.value = ''; rejectCustom.value = ''; rejectVisible.value = true }
const handleBatchApprove = async () => {
if (selectedRowKeys.value.length === 0) return
batchLoading.value = true
try {
const res: any = await request.post('/content-review/works/batch-approve', { ids: selectedRowKeys.value })
message.success(`已批量通过 ${res.count} 个作品`)
selectedRowKeys.value = []
fetchList(); fetchStats()
} catch { message.error('批量操作失败') }
finally { batchLoading.value = false }
}
const handleRevoke = (id: number) => {
Modal.confirm({
title: '确定撤销审核?',
content: '撤销后作品将恢复为待审核状态',
okText: '确定撤销',
onOk: async () => {
try {
await request.post(`/content-review/works/${id}/revoke`)
message.success('已撤销')
fetchList(); fetchStats()
//
if (detailVisible.value && detailData.value?.id === id) {
showDetail(id)
}
} catch { message.error('撤销失败') }
},
})
}
const isBatchReject = ref(false)
const openReject = (id: number) => { rejectTargetId.value = id; isBatchReject.value = false; rejectReason.value = ''; rejectCustom.value = ''; rejectVisible.value = true }
const openBatchReject = () => { rejectTargetId.value = null; isBatchReject.value = true; rejectReason.value = ''; rejectCustom.value = ''; rejectVisible.value = true }
const handleReject = async () => {
const reason = rejectReason.value === 'other' ? rejectCustom.value : rejectReason.value
if (!reason) { message.warning('请选择拒绝原因'); return }
rejectLoading.value = true
try { await request.post(`/content-review/works/${rejectTargetId.value}/reject`, { reason }); message.success('已拒绝'); rejectVisible.value = false; fetchList(); fetchStats() }
catch { message.error('操作失败') }
try {
if (isBatchReject.value) {
const res: any = await request.post('/content-review/works/batch-reject', { ids: selectedRowKeys.value, reason })
message.success(`已批量拒绝 ${res.count} 个作品`)
selectedRowKeys.value = []
} else {
await request.post(`/content-review/works/${rejectTargetId.value}/reject`, { reason })
message.success('已拒绝')
}
rejectVisible.value = false; fetchList(); fetchStats()
// Drawer
if (detailVisible.value && !isBatchReject.value) {
const idx = dataSource.value.findIndex(d => d.id === rejectTargetId.value)
let nextId: number | null = null
for (let i = idx + 1; i < dataSource.value.length; i++) {
if (dataSource.value[i].status === 'pending_review') { nextId = dataSource.value[i].id; break }
}
if (nextId) { showDetail(nextId) } else { detailVisible.value = false }
}
} catch { message.error('操作失败') }
finally { rejectLoading.value = false }
}
const showDetail = async (id: number) => {
previewPage.value = 0
try { detailData.value = await request.get(`/content-review/works/${id}`); detailVisible.value = true }
catch { message.error('获取详情失败') }
detailLogs.value = []
try {
const [work, logs]: any[] = await Promise.all([
request.get(`/content-review/works/${id}`),
request.get('/content-review/logs', { params: { workId: id, pageSize: 50 } }),
])
detailData.value = work
detailLogs.value = logs.list || []
detailVisible.value = true
} catch { message.error('获取详情失败') }
}
onMounted(() => { fetchStats(); fetchList() })
@ -226,14 +474,76 @@ $primary: #6366f1;
.stat-info { display: flex; flex-direction: column; .stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; } .stat-label { font-size: 12px; color: #9ca3af; } }
}
.filter-bar { padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px; }
.batch-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: rgba($primary, 0.03);
border: 1px dashed rgba($primary, 0.15);
border-radius: 10px;
margin-bottom: 16px;
min-height: 42px;
span { font-size: 13px; color: #374151; strong { color: $primary; } }
.batch-tip { font-size: 12px; color: #9ca3af; }
}
.data-table { :deep(.ant-table-wrapper) { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden; .ant-table-thead > tr > th { background: #fafafa; font-weight: 600; } .ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); } .ant-table-pagination { padding: 16px; margin: 0; } } }
// #2 +
.title-cell {
display: flex; flex-direction: column; gap: 2px;
.work-title { font-size: 13px; font-weight: 600; color: #1e1b4b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-desc { font-size: 11px; color: #9ca3af; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
}
.cover-thumb { width: 48px; height: 64px; object-fit: cover; border-radius: 6px; }
.cover-empty { width: 48px; height: 64px; background: #f3f4f6; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #d1d5db; }
.preview-section { margin-top: 24px; h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; } }
// #3
.detail-nav {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 16px;
background: #fafafa;
border-radius: 10px;
.detail-nav-info {
flex: 1; text-align: center;
font-size: 14px; font-weight: 600; color: #1e1b4b;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
}
// #5
.detail-section {
margin-top: 16px;
h4 { font-size: 13px; font-weight: 600; color: #374151; margin: 0 0 8px; }
.detail-desc { font-size: 13px; color: #6b7280; line-height: 1.6; margin: 0; }
}
.preview-section { margin-top: 20px; h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; } }
.page-preview {
.preview-img { width: 100%; max-height: 300px; object-fit: contain; border-radius: 8px; margin-bottom: 8px; }
.preview-text { font-size: 13px; color: #374151; line-height: 1.6; padding: 8px 0; }
.preview-nav { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; span { font-size: 12px; color: #6b7280; } }
}
.review-actions { margin-top: 24px; padding-top: 16px; border-top: 1px solid #f0ecf9; }
.review-actions { margin-top: 20px; padding-top: 16px; border-top: 1px solid #f0ecf9;
:deep(.ant-space) { display: flex; .ant-space-item { flex: 1; .ant-btn { width: 100%; } } }
}
//
.log-section {
margin-top: 24px;
h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
.log-empty { font-size: 13px; color: #9ca3af; text-align: center; padding: 20px 0; }
}
.log-item {
display: flex; align-items: center; gap: 8px;
.log-action { font-size: 13px; font-weight: 600; color: #1e1b4b; }
.log-operator { font-size: 12px; color: #6b7280; }
.log-time { font-size: 11px; color: #9ca3af; margin-left: auto; }
}
.log-reason, .log-note { font-size: 12px; color: #6b7280; margin-top: 2px; }
</style>

View File

@ -12,6 +12,30 @@
</a-input>
</div>
<!-- 推荐作品 -->
<div class="recommend-section" v-if="recommendedWorks.length > 0">
<div class="section-header">
<span class="section-title"><fire-outlined /> 编辑推荐</span>
</div>
<div class="recommend-scroll">
<div
v-for="rw in recommendedWorks"
:key="rw.id"
class="recommend-card"
@click="$router.push(`/p/works/${rw.id}`)"
>
<div class="recommend-cover">
<img v-if="rw.coverUrl" :src="rw.coverUrl" :alt="rw.title" />
<div v-else class="recommend-cover-empty"><picture-outlined /></div>
</div>
<div class="recommend-info">
<span class="recommend-title">{{ rw.title }}</span>
<span class="recommend-author">{{ rw.creator?.nickname }}</span>
</div>
</div>
</div>
</div>
<!-- 热门标签 -->
<div class="tags-scroll" v-if="hotTags.length > 0">
<span
@ -92,12 +116,13 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { SearchOutlined, PictureOutlined, HeartOutlined, HeartFilled, EyeOutlined } from '@ant-design/icons-vue'
import { SearchOutlined, PictureOutlined, HeartOutlined, HeartFilled, EyeOutlined, FireOutlined } from '@ant-design/icons-vue'
import { publicGalleryApi, publicTagsApi, publicInteractionApi, type UserWork, type WorkTag } from '@/api/public'
const router = useRouter()
const works = ref<UserWork[]>([])
const hotTags = ref<WorkTag[]>([])
const recommendedWorks = ref<UserWork[]>([])
const loading = ref(false)
const keyword = ref('')
const selectedTagId = ref<number | null>(null)
@ -114,6 +139,10 @@ const fetchTags = async () => {
try { hotTags.value = await publicTagsApi.hot() } catch { /* */ }
}
const fetchRecommended = async () => {
try { recommendedWorks.value = await publicGalleryApi.recommended() } catch { /* */ }
}
const fetchWorks = async (reset = false) => {
if (reset) { page.value = 1; works.value = []; likedSet.clear() }
loading.value = true
@ -172,6 +201,7 @@ const changeSort = (s: string) => { sortBy.value = s; fetchWorks(true) }
const loadMore = () => { page.value++; fetchWorks() }
onMounted(() => {
fetchRecommended()
fetchTags()
fetchWorks()
})
@ -195,6 +225,61 @@ $primary: #6366f1;
}
}
//
.recommend-section {
margin-bottom: 16px;
.section-header {
margin-bottom: 10px;
.section-title {
font-size: 15px;
font-weight: 700;
color: #1e1b4b;
display: flex;
align-items: center;
gap: 6px;
:deep(.anticon) { color: #f59e0b; }
}
}
}
.recommend-scroll {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 8px;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar { display: none; }
}
.recommend-card {
flex-shrink: 0;
width: 120px;
background: #fff;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
border: 1px solid rgba($primary, 0.06);
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); transform: translateY(-2px); }
.recommend-cover {
width: 120px; height: 160px; background: #f5f3ff;
img { width: 100%; height: 100%; object-fit: cover; }
.recommend-cover-empty { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 24px; color: #d1d5db; }
}
.recommend-info {
padding: 8px 10px;
display: flex; flex-direction: column; gap: 2px;
.recommend-title { font-size: 12px; font-weight: 600; color: #1e1b4b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recommend-author { font-size: 10px; color: #9ca3af; }
}
}
.tags-scroll {
display: flex;
gap: 8px;