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:
parent
66827c0199
commit
f246b38fc1
@ -1271,6 +1271,7 @@ model WorkTag {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique @db.VarChar(50) /// 标签名称
|
name String @unique @db.VarChar(50) /// 标签名称
|
||||||
category String? @db.VarChar(50) /// 所属分类(如:主题/风格/情感)
|
category String? @db.VarChar(50) /// 所属分类(如:主题/风格/情感)
|
||||||
|
color String? @db.VarChar(20) /// 标签颜色(如:#6366f1)
|
||||||
sort Int @default(0) /// 排序权重
|
sort Int @default(0) /// 排序权重
|
||||||
status String @default("enabled") /// 状态:enabled/disabled
|
status String @default("enabled") /// 状态:enabled/disabled
|
||||||
usageCount Int @default(0) @map("usage_count") /// 使用次数(冗余)
|
usageCount Int @default(0) @map("usage_count") /// 使用次数(冗余)
|
||||||
|
|||||||
@ -25,6 +25,8 @@ export class ContentReviewController {
|
|||||||
@Query('keyword') keyword?: string,
|
@Query('keyword') keyword?: string,
|
||||||
@Query('startTime') startTime?: string,
|
@Query('startTime') startTime?: string,
|
||||||
@Query('endTime') endTime?: string,
|
@Query('endTime') endTime?: string,
|
||||||
|
@Query('sortBy') sortBy?: string,
|
||||||
|
@Query('isRecommended') isRecommended?: string,
|
||||||
) {
|
) {
|
||||||
return this.reviewService.getWorkQueue({
|
return this.reviewService.getWorkQueue({
|
||||||
page: page ? parseInt(page) : 1,
|
page: page ? parseInt(page) : 1,
|
||||||
@ -33,6 +35,8 @@ export class ContentReviewController {
|
|||||||
keyword,
|
keyword,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
|
sortBy,
|
||||||
|
isRecommended: isRecommended === '1',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,6 +45,16 @@ export class ContentReviewController {
|
|||||||
return this.reviewService.getWorkDetail(id);
|
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')
|
@Post('works/:id/approve')
|
||||||
approveWork(
|
approveWork(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@ -59,6 +73,11 @@ export class ContentReviewController {
|
|||||||
return this.reviewService.reject(id, req.user.userId, dto.reason, dto.note);
|
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')
|
@Post('works/:id/takedown')
|
||||||
takedownWork(
|
takedownWork(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
|||||||
@ -28,17 +28,27 @@ export class ContentReviewService {
|
|||||||
keyword?: string;
|
keyword?: string;
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: 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 skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const where: any = { isDeleted: 0 };
|
const where: any = { isDeleted: 0 };
|
||||||
if (status) {
|
if (status) {
|
||||||
where.status = status;
|
if (status === 'published,taken_down') {
|
||||||
|
where.status = { in: ['published', 'taken_down'] };
|
||||||
|
} else {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
where.status = { in: ['pending_review', 'published', 'rejected', 'taken_down'] };
|
where.status = { in: ['pending_review', 'published', 'rejected', 'taken_down'] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRecommended) {
|
||||||
|
where.isRecommended = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ title: { contains: keyword } },
|
{ title: { contains: keyword } },
|
||||||
@ -49,12 +59,18 @@ export class ContentReviewService {
|
|||||||
if (startTime) where.createTime = { ...where.createTime, gte: new Date(startTime) };
|
if (startTime) where.createTime = { ...where.createTime, gte: new Date(startTime) };
|
||||||
if (endTime) where.createTime = { ...where.createTime, lte: new Date(endTime + ' 23:59:59') };
|
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([
|
const [list, total] = await Promise.all([
|
||||||
this.prisma.userWork.findMany({
|
this.prisma.userWork.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { createTime: 'desc' },
|
orderBy,
|
||||||
include: {
|
include: {
|
||||||
creator: { select: { id: true, nickname: true, avatar: true, username: true, userType: true } },
|
creator: { select: { id: true, nickname: true, avatar: true, username: true, userType: true } },
|
||||||
tags: { include: { tag: { select: { id: true, name: 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) {
|
async reject(workId: number, operatorId: number, reason: string, note?: string) {
|
||||||
const work = await this.prisma.userWork.findUnique({ where: { id: workId } });
|
const work = await this.prisma.userWork.findUnique({ where: { id: workId } });
|
||||||
@ -155,7 +238,7 @@ export class ContentReviewService {
|
|||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
await tx.userWork.update({
|
await tx.userWork.update({
|
||||||
where: { id: workId },
|
where: { id: workId },
|
||||||
data: { status: 'taken_down', reviewNote: reason },
|
data: { status: 'taken_down', reviewNote: reason, isRecommended: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.contentReviewLog.create({
|
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() {
|
async getManagementStats() {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|||||||
@ -88,6 +88,23 @@ export class GalleryService {
|
|||||||
return work;
|
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 }) {
|
async getUserPublicWorks(userId: number, params: { page?: number; pageSize?: number }) {
|
||||||
const { page = 1, pageSize = 12 } = params;
|
const { page = 1, pageSize = 12 } = params;
|
||||||
|
|||||||
@ -407,6 +407,12 @@ export class PublicController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('gallery/recommended')
|
||||||
|
async getRecommendedWorks() {
|
||||||
|
return this.galleryService.getRecommendedWorks();
|
||||||
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get('gallery/:id')
|
@Get('gallery/:id')
|
||||||
async getGalleryDetail(@Param('id', ParseIntPipe) id: number) {
|
async getGalleryDetail(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
|||||||
@ -16,18 +16,23 @@ export class TagsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@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);
|
return this.tagsService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
update(
|
update(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@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);
|
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')
|
@Delete(':id')
|
||||||
remove(@Param('id', ParseIntPipe) id: number) {
|
remove(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.tagsService.remove(id);
|
return this.tagsService.remove(id);
|
||||||
|
|||||||
@ -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 } });
|
const existing = await this.prisma.workTag.findFirst({ where: { name: dto.name } });
|
||||||
if (existing) throw new BadRequestException('标签名已存在');
|
if (existing) throw new BadRequestException('标签名已存在');
|
||||||
|
|
||||||
@ -44,13 +44,14 @@ export class TagsService {
|
|||||||
data: {
|
data: {
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
category: dto.category || null,
|
category: dto.category || null,
|
||||||
|
color: dto.color || null,
|
||||||
sort: dto.sort || 0,
|
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 } });
|
const tag = await this.prisma.workTag.findUnique({ where: { id } });
|
||||||
if (!tag) throw new NotFoundException('标签不存在');
|
if (!tag) throw new NotFoundException('标签不存在');
|
||||||
|
|
||||||
@ -64,12 +65,23 @@ export class TagsService {
|
|||||||
data: {
|
data: {
|
||||||
name: dto.name ?? tag.name,
|
name: dto.name ?? tag.name,
|
||||||
category: dto.category !== undefined ? dto.category : tag.category,
|
category: dto.category !== undefined ? dto.category : tag.category,
|
||||||
|
color: dto.color !== undefined ? dto.color : tag.color,
|
||||||
sort: dto.sort !== undefined ? dto.sort : tag.sort,
|
sort: dto.sort !== undefined ? dto.sort : tag.sort,
|
||||||
status: dto.status ?? tag.status,
|
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) {
|
async remove(id: number) {
|
||||||
const tag = await this.prisma.workTag.findUnique({ where: { id } });
|
const tag = await this.prisma.workTag.findUnique({ where: { id } });
|
||||||
|
|||||||
@ -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/activity-list-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
|
||||||
| [报名数据优化](./super-admin/registration-data-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/works-data-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
|
||||||
| [评审进度优化](./super-admin/review-progress-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/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-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 |
|
||||||
|
|
||||||
## 评委端
|
## 评委端
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
# 点赞 & 收藏功能设计
|
# 点赞 & 收藏功能设计
|
||||||
|
|
||||||
|
> 所属端:公众端 + 超管端联动
|
||||||
|
> 状态:已实现
|
||||||
|
> 创建日期:2026-03-31
|
||||||
|
> 最后更新:2026-03-31
|
||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
为 UGC 绘本创作社区的作品添加点赞和收藏交互能力,提升用户参与感和内容发现效率。
|
为 UGC 绘本创作社区的作品添加点赞和收藏交互能力,提升用户参与感和内容发现效率。
|
||||||
|
|||||||
@ -320,22 +320,25 @@ P0-4 + P0-6 → P0-12(活动联动)
|
|||||||
|
|
||||||
目标:用户可对作品点赞、收藏、评论;有消息通知;可举报不当内容。
|
目标:用户可对作品点赞、收藏、评论;有消息通知;可举报不当内容。
|
||||||
|
|
||||||
#### P1-1. 点赞/收藏(后端+前端)
|
#### P1-1. 点赞/收藏(后端+前端)✅ 已实现 (2026-03-31)
|
||||||
|
|
||||||
```
|
```
|
||||||
后端 API:
|
后端 API(已实现):
|
||||||
├── POST /api/public/works/:id/like — 点赞/取消点赞
|
├── POST /api/public/works/:id/like — 点赞/取消点赞(toggle)
|
||||||
├── POST /api/public/works/:id/favorite — 收藏/取消收藏
|
├── POST /api/public/works/:id/favorite — 收藏/取消收藏(toggle)
|
||||||
├── GET /api/public/mine/favorites — 我的收藏列表
|
├── 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
# 超管端内容管理模块 — 设计方案
|
# 超管端内容管理模块 — 设计方案
|
||||||
|
|
||||||
> 所属端:超管端
|
> 所属端:超管端
|
||||||
> 状态:待开发
|
> 状态:P0 已实现并优化
|
||||||
> 创建日期:2026-03-27
|
> 创建日期:2026-03-27
|
||||||
> 最后更新:2026-03-27
|
> 最后更新:2026-03-31
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -349,4 +349,41 @@ POST /api/content-review/reports/:id/handle — 处理举报
|
|||||||
|
|
||||||
## 8. 实施记录
|
## 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),标签颜色)
|
||||||
|
|||||||
@ -396,6 +396,9 @@ export const publicTagsApi = {
|
|||||||
// ==================== 作品广场 ====================
|
// ==================== 作品广场 ====================
|
||||||
|
|
||||||
export const publicGalleryApi = {
|
export const publicGalleryApi = {
|
||||||
|
recommended: (): Promise<UserWork[]> =>
|
||||||
|
publicApi.get("/public/gallery/recommended"),
|
||||||
|
|
||||||
list: (params?: {
|
list: (params?: {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
|
|||||||
@ -10,76 +10,213 @@
|
|||||||
</template>
|
</template>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<a-table
|
<!-- 按分类分组展示 -->
|
||||||
:columns="columns"
|
<div v-if="loading" class="loading-wrap"><a-spin /></div>
|
||||||
: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>
|
|
||||||
|
|
||||||
<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 :model="form" layout="vertical" @finish="handleSubmit" style="margin-top: 16px">
|
||||||
<a-form-item label="标签名称" :rules="[{ required: true, message: '请输入' }]">
|
<a-form-item label="标签名称" :rules="[{ required: true, message: '请输入' }]">
|
||||||
<a-input v-model:value="form.name" placeholder="如:童话、科幻、自然" />
|
<a-input v-model:value="form.name" placeholder="如:童话、科幻、自然" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="所属分类">
|
<a-form-item label="所属分类" :rules="[{ required: true, message: '请选择分类' }]">
|
||||||
<a-input v-model:value="form.category" placeholder="如:主题、风格、情感" />
|
<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>
|
||||||
<a-form-item label="排序权重">
|
<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-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-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { 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'
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const tags = ref<any[]>([])
|
const tags = ref<any[]>([])
|
||||||
|
const categories = ref<string[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const editingId = ref<number | null>(null)
|
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 = [
|
const presetColors = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#ef4444', '#3b82f6', '#8b5cf6', '#14b8a6', '#f97316', '#64748b']
|
||||||
{ title: '标签名称', dataIndex: 'name', key: 'name', width: 150 },
|
|
||||||
{ title: '分类', key: 'category', width: 120 },
|
// 分类下拉选项(已有分类 + 搜索输入)
|
||||||
{ title: '使用次数', dataIndex: 'usageCount', key: 'usageCount', width: 100 },
|
const categoryOptions = computed(() => {
|
||||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
|
const opts = categories.value.map(c => ({ label: c, value: c }))
|
||||||
{ title: '状态', key: 'status', width: 80 },
|
if (categorySearchVal.value && !categories.value.includes(categorySearchVal.value)) {
|
||||||
{ title: '操作', key: 'action', width: 180, fixed: 'right' as const },
|
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 () => {
|
const fetchTags = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@ -87,11 +224,17 @@ const fetchTags = async () => {
|
|||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try { categories.value = await request.get('/tags/categories') as any } catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
const openModal = (record?: any) => {
|
const openModal = (record?: any) => {
|
||||||
editingId.value = record?.id || null
|
editingId.value = record?.id || null
|
||||||
form.name = record?.name || ''
|
form.name = record?.name || ''
|
||||||
form.category = record?.category || ''
|
form.category = record?.category || ''
|
||||||
|
form.color = record?.color || ''
|
||||||
form.sort = record?.sort || 0
|
form.sort = record?.sort || 0
|
||||||
|
categorySearchVal.value = ''
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,6 +250,7 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
modalVisible.value = false
|
modalVisible.value = false
|
||||||
fetchTags()
|
fetchTags()
|
||||||
|
fetchCategories()
|
||||||
} catch (e: any) { message.error(e?.response?.data?.message || '操作失败') }
|
} catch (e: any) { message.error(e?.response?.data?.message || '操作失败') }
|
||||||
finally { submitting.value = false }
|
finally { submitting.value = false }
|
||||||
}
|
}
|
||||||
@ -124,7 +268,47 @@ const handleDelete = async (id: number) => {
|
|||||||
catch (e: any) { message.error(e?.response?.data?.message || '删除失败') }
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<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-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||||
:deep(.ant-card-body) { padding: 0; }
|
: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; }
|
.loading-wrap { padding: 60px 0; display: flex; justify-content: center; }
|
||||||
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
|
.empty-wrap { padding: 60px 0; display: flex; justify-content: center; }
|
||||||
} }
|
|
||||||
.text-muted { color: #d1d5db; }
|
// 分类分组
|
||||||
|
.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>
|
</style>
|
||||||
|
|||||||
@ -4,11 +4,21 @@
|
|||||||
<template #title>作品管理</template>
|
<template #title>作品管理</template>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<!-- 统计 -->
|
<!-- 统计卡片(可点击筛选) -->
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<div class="stat-card" v-for="item in mgmtStats" :key="item.label">
|
<div
|
||||||
<div class="stat-count">{{ item.value }}</div>
|
v-for="item in statsItems"
|
||||||
<div class="stat-label">{{ item.label }}</div>
|
: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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -18,8 +28,16 @@
|
|||||||
<a-form-item label="作品/作者">
|
<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" />
|
||||||
</a-form-item>
|
</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-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="latest">最新发布</a-select-option>
|
||||||
<a-select-option value="hot">最多点赞</a-select-option>
|
<a-select-option value="hot">最多点赞</a-select-option>
|
||||||
<a-select-option value="views">最多浏览</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-if="column.key === 'index'">{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</template>
|
||||||
<template v-else-if="column.key === 'cover'">
|
<template v-else-if="column.key === 'cover'">
|
||||||
<img v-if="record.coverUrl" :src="record.coverUrl" class="cover-thumb" />
|
<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>
|
||||||
<template v-else-if="column.key === 'author'">{{ record.creator?.nickname || '-' }}</template>
|
<template v-else-if="column.key === 'author'">{{ record.creator?.nickname || '-' }}</template>
|
||||||
<template v-else-if="column.key === 'status'">
|
<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 === 'publishTime'">{{ formatDate(record.publishTime) }}</template>
|
||||||
<template v-else-if="column.key === 'action'">
|
<template v-else-if="column.key === 'action'">
|
||||||
<a-space>
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="showDetail(record.id)">查看</a-button>
|
||||||
<a-button type="link" size="small" @click="handleRecommend(record)">
|
<a-button type="link" size="small" @click="handleRecommend(record)">
|
||||||
{{ record.isRecommended ? '取消推荐' : '推荐' }}
|
{{ record.isRecommended ? '取消推荐' : '推荐' }}
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button v-if="record.status === 'published'" type="link" danger size="small" @click="handleTakedown(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" @click="handleRestore(record)">恢复</a-button>
|
<a-button v-else type="link" size="small" style="color: #10b981" @click="handleRestore(record)">恢复</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { 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 request from '@/utils/request'
|
||||||
import dayjs from 'dayjs'
|
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 pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` })
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const sortBy = ref('latest')
|
const sortBy = ref('latest')
|
||||||
const mgmtStats = ref([
|
const filterStatus = ref('')
|
||||||
{ label: '总作品数', value: 0 },
|
const activeStatKey = ref('')
|
||||||
{ label: '今日新增', value: 0 },
|
|
||||||
{ label: '累计浏览', value: 0 },
|
// 统计
|
||||||
{ label: '已下架', value: 0 },
|
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 = [
|
const columns = [
|
||||||
{ title: '序号', key: 'index', width: 50 },
|
{ title: '序号', key: 'index', width: 50 },
|
||||||
{ title: '封面', key: 'cover', width: 70 },
|
{ title: '封面', key: 'cover', width: 70 },
|
||||||
{ title: '作品名称', dataIndex: 'title', key: 'title', width: 180 },
|
{ title: '作品名称', key: 'titleDesc', width: 220 },
|
||||||
{ title: '作者', key: 'author', width: 100 },
|
{ title: '作者', key: 'author', width: 90 },
|
||||||
{ title: '浏览', dataIndex: 'viewCount', key: 'viewCount', width: 70 },
|
{ title: '浏览', dataIndex: 'viewCount', key: 'viewCount', width: 65 },
|
||||||
{ title: '点赞', dataIndex: 'likeCount', key: 'likeCount', width: 70 },
|
{ title: '点赞', dataIndex: 'likeCount', key: 'likeCount', width: 65 },
|
||||||
{ title: '收藏', dataIndex: 'favoriteCount', key: 'favoriteCount', width: 70 },
|
{ title: '收藏', dataIndex: 'favoriteCount', key: 'favoriteCount', width: 65 },
|
||||||
{ title: '状态', key: 'status', width: 110 },
|
{ title: '状态', key: 'status', width: 110 },
|
||||||
{ title: '发布时间', key: 'publishTime', width: 140 },
|
{ title: '发布时间', key: 'publishTime', width: 130 },
|
||||||
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
|
{ title: '操作', key: 'action', width: 210, fixed: 'right' as const },
|
||||||
]
|
]
|
||||||
|
|
||||||
const formatDate = (d: string) => d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-'
|
const formatDate = (d: string) => d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-'
|
||||||
|
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
const s: any = await request.get('/content-review/management/stats')
|
statsRaw.value = await request.get('/content-review/management/stats') as any
|
||||||
mgmtStats.value = [
|
|
||||||
{ label: '总作品数', value: s.total },
|
|
||||||
{ label: '今日新增', value: s.todayNew },
|
|
||||||
{ label: '累计浏览', value: s.totalViews },
|
|
||||||
{ label: '已下架', value: s.takenDown },
|
|
||||||
]
|
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchList = async () => {
|
const fetchList = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
// 复用审核接口查已发布+已下架
|
const isRecommendedFilter = filterStatus.value === 'recommended'
|
||||||
const res: any = await request.get('/content-review/works', {
|
const res: any = await request.get('/content-review/works', {
|
||||||
params: {
|
params: {
|
||||||
page: pagination.current,
|
page: pagination.current,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
status: 'published', // 作品管理只看已发布的
|
status: isRecommendedFilter ? 'published' : (filterStatus.value || 'published,taken_down'),
|
||||||
keyword: keyword.value || undefined,
|
keyword: keyword.value || undefined,
|
||||||
|
sortBy: sortBy.value,
|
||||||
|
isRecommended: isRecommendedFilter ? '1' : undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
dataSource.value = res.list
|
dataSource.value = res.list
|
||||||
@ -127,25 +285,94 @@ const fetchList = async () => {
|
|||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearch = () => { pagination.current = 1; fetchList() }
|
// #7 统计卡片点击筛选
|
||||||
const handleReset = () => { keyword.value = ''; sortBy.value = 'latest'; pagination.current = 1; fetchList(); fetchStats() }
|
const handleStatClick = (key: string) => {
|
||||||
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
|
if (activeStatKey.value === key) {
|
||||||
|
activeStatKey.value = ''
|
||||||
const handleRecommend = async (record: any) => {
|
filterStatus.value = ''
|
||||||
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success(record.isRecommended ? '已取消推荐' : '已推荐'); fetchList() }
|
} else {
|
||||||
catch { message.error('操作失败') }
|
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 筛选自动查询
|
||||||
Modal.confirm({
|
const handleSearch = () => { activeStatKey.value = ''; pagination.current = 1; fetchList() }
|
||||||
title: '确定下架?',
|
const handleReset = () => { keyword.value = ''; sortBy.value = 'latest'; filterStatus.value = ''; activeStatKey.value = ''; pagination.current = 1; fetchList(); fetchStats() }
|
||||||
content: `下架后作品「${record.title}」将不再公开展示`,
|
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
|
||||||
okType: 'danger',
|
|
||||||
onOk: async () => {
|
// 详情
|
||||||
try { await request.post(`/content-review/works/${record.id}/takedown`, { reason: '管理员下架' }); message.success('已下架'); fetchList(); fetchStats() }
|
const showDetail = async (id: number) => {
|
||||||
catch { message.error('操作失败') }
|
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}」将不再显示在推荐位`,
|
||||||
|
onOk: async () => {
|
||||||
|
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) => {
|
const handleRestore = async (record: any) => {
|
||||||
@ -153,18 +380,73 @@ const handleRestore = async (record: any) => {
|
|||||||
catch { message.error('操作失败') }
|
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() })
|
onMounted(() => { fetchStats(); fetchList() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
$primary: #6366f1;
|
$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; } }
|
.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; }
|
.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-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;
|
||||||
.stat-count { font-size: 22px; font-weight: 700; color: #1e1b4b; }
|
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); } &.active { border-color: $primary; background: rgba($primary, 0.02); }
|
||||||
.stat-label { font-size: 12px; color: #9ca3af; margin-top: 2px; }
|
.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; }
|
.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; } } }
|
.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-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>
|
</style>
|
||||||
|
|||||||
@ -21,7 +21,8 @@
|
|||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<a-form layout="inline" @finish="handleSearch">
|
<a-form layout="inline" @finish="handleSearch">
|
||||||
<a-form-item label="审核状态">
|
<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="pending_review">待审核</a-select-option>
|
||||||
<a-select-option value="published">已通过</a-select-option>
|
<a-select-option value="published">已通过</a-select-option>
|
||||||
<a-select-option value="rejected">已拒绝</a-select-option>
|
<a-select-option value="rejected">已拒绝</a-select-option>
|
||||||
@ -39,26 +40,61 @@
|
|||||||
</a-form>
|
</a-form>
|
||||||
</div>
|
</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 #bodyCell="{ column, record, index }">
|
||||||
<template v-if="column.key === 'index'">{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</template>
|
<template v-if="column.key === 'index'">{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</template>
|
||||||
<template v-else-if="column.key === 'cover'">
|
<template v-else-if="column.key === 'cover'">
|
||||||
<img v-if="record.coverUrl" :src="record.coverUrl" class="cover-thumb" />
|
<img v-if="record.coverUrl" :src="record.coverUrl" class="cover-thumb" />
|
||||||
<div v-else class="cover-empty">无</div>
|
<div v-else class="cover-empty">无</div>
|
||||||
</template>
|
</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 === 'author'">{{ record.creator?.nickname || '-' }}</template>
|
||||||
<template v-else-if="column.key === 'tags'">
|
<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>
|
<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>
|
||||||
<template v-else-if="column.key === 'status'">
|
<template v-else-if="column.key === 'status'">
|
||||||
<a-tag :color="statusColor[record.status] || 'default'">{{ statusText[record.status] || record.status }}</a-tag>
|
<a-tag :color="statusColor[record.status] || 'default'">{{ statusText[record.status] || record.status }}</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'createTime'">{{ formatDate(record.createTime) }}</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'">
|
<template v-else-if="column.key === 'action'">
|
||||||
<a-space>
|
<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" 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 === '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-button type="link" size="small" @click="showDetail(record.id)">详情</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
@ -66,7 +102,7 @@
|
|||||||
</a-table>
|
</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-group v-model:value="rejectReason" 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>
|
||||||
@ -78,18 +114,44 @@
|
|||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<!-- 详情 Drawer -->
|
<!-- 详情 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">
|
<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?.nickname }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="用户类型">{{ detailData.creator?.userType === 'child' ? '子女' : '成人' }}</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="页数">{{ detailData._count?.pages || 0 }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="状态">
|
<a-descriptions-item label="提交时间">{{ formatDate(detailData.createTime) }}</a-descriptions-item>
|
||||||
<a-tag :color="statusColor[detailData.status]">{{ statusText[detailData.status] }}</a-tag>
|
<a-descriptions-item v-if="detailData.reviewTime" label="审核时间" :span="2">{{ formatDate(detailData.reviewTime) }}</a-descriptions-item>
|
||||||
</a-descriptions-item>
|
|
||||||
</a-descriptions>
|
</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">
|
<div v-if="detailData.pages?.length" class="preview-section">
|
||||||
<h4>绘本内容预览</h4>
|
<h4>绘本内容预览</h4>
|
||||||
@ -106,8 +168,34 @@
|
|||||||
|
|
||||||
<!-- 审核操作 -->
|
<!-- 审核操作 -->
|
||||||
<div v-if="detailData.status === 'pending_review'" class="review-actions">
|
<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-space style="width: 100%">
|
||||||
<a-button danger block @click="openReject(detailData.id); detailVisible = false">拒绝</a-button>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
@ -116,17 +204,26 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import { SearchOutlined, ReloadOutlined, ClockCircleOutlined, CheckCircleOutlined, CloseCircleOutlined, FileTextOutlined } from '@ant-design/icons-vue'
|
import {
|
||||||
|
SearchOutlined, ReloadOutlined, ClockCircleOutlined, CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined, FileTextOutlined, LeftOutlined, RightOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dataSource = ref<any[]>([])
|
const dataSource = ref<any[]>([])
|
||||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` })
|
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 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 stats = ref({ pending: 0, todayReviewed: 0, todayApproved: 0, todayRejected: 0 })
|
||||||
const statsItems = computed(() => [
|
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 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 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') : '-'
|
const formatDate = (d: string) => d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-'
|
||||||
|
|
||||||
|
// #2 合并标题+描述列 #4 增加审核时间列
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '序号', key: 'index', width: 50 },
|
{ title: '序号', key: 'index', width: 50 },
|
||||||
{ title: '封面', key: 'cover', width: 70 },
|
{ title: '封面', key: 'cover', width: 70 },
|
||||||
{ title: '作品名称', dataIndex: 'title', key: 'title', width: 180 },
|
{ title: '作品名称', key: 'titleDesc', width: 220 },
|
||||||
{ title: '页数', dataIndex: ['_count', 'pages'], key: 'pages', width: 60 },
|
{ title: '页数', dataIndex: ['_count', 'pages'], key: 'pages', width: 50 },
|
||||||
{ title: '作者', key: 'author', width: 100 },
|
{ title: '作者', key: 'author', width: 90 },
|
||||||
{ title: '标签', key: 'tags', width: 140 },
|
{ title: '标签', key: 'tags', width: 130 },
|
||||||
{ title: '状态', key: 'status', width: 80 },
|
{ title: '状态', key: 'status', width: 80 },
|
||||||
{ title: '提交时间', key: 'createTime', width: 140 },
|
{ title: '提交时间', key: 'createTime', width: 130 },
|
||||||
{ title: '操作', key: 'action', width: 150, fixed: 'right' as const },
|
{ 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 rejectReason = ref('')
|
||||||
const rejectCustom = ref('')
|
const rejectCustom = ref('')
|
||||||
|
|
||||||
// 详情
|
// 详情 + 日志
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const detailData = ref<any>(null)
|
const detailData = ref<any>(null)
|
||||||
|
const detailLogs = ref<any[]>([])
|
||||||
const previewPage = ref(0)
|
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 () => {
|
const fetchStats = async () => {
|
||||||
try { stats.value = await request.get('/content-review/works/stats') as any } catch { /* */ }
|
try { stats.value = await request.get('/content-review/works/stats') as any } catch { /* */ }
|
||||||
@ -172,7 +349,7 @@ const fetchList = async () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await request.get('/content-review/works', {
|
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
|
dataSource.value = res.list
|
||||||
pagination.total = res.total
|
pagination.total = res.total
|
||||||
@ -180,16 +357,28 @@ const fetchList = async () => {
|
|||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #6 今日已审点击 → 展示今日审核过的(通过+拒绝)
|
||||||
const handleStatClick = (key: string) => {
|
const handleStatClick = (key: string) => {
|
||||||
activeFilter.value = key
|
if (activeFilter.value === key) {
|
||||||
if (key === 'pending_review') searchStatus.value = 'pending_review'
|
activeFilter.value = ''
|
||||||
else searchStatus.value = undefined
|
searchStatus.value = ''
|
||||||
|
} else {
|
||||||
|
activeFilter.value = key
|
||||||
|
if (key === 'pending_review') searchStatus.value = 'pending_review'
|
||||||
|
else if (key === 'approved') searchStatus.value = 'published'
|
||||||
|
else if (key === 'rejected') searchStatus.value = 'rejected'
|
||||||
|
else searchStatus.value = ''
|
||||||
|
}
|
||||||
pagination.current = 1
|
pagination.current = 1
|
||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearch = () => { pagination.current = 1; fetchList() }
|
const handleSearch = () => { activeFilter.value = ''; pagination.current = 1; fetchList() }
|
||||||
const handleReset = () => { searchStatus.value = undefined; searchKeyword.value = ''; activeFilter.value = ''; pagination.current = 1; fetchList(); fetchStats() }
|
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 handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
|
||||||
|
|
||||||
const quickApprove = async (id: number) => {
|
const quickApprove = async (id: number) => {
|
||||||
@ -197,20 +386,79 @@ const quickApprove = async (id: number) => {
|
|||||||
catch { message.error('操作失败') }
|
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 handleReject = async () => {
|
||||||
const reason = rejectReason.value === 'other' ? rejectCustom.value : rejectReason.value
|
const reason = rejectReason.value === 'other' ? rejectCustom.value : rejectReason.value
|
||||||
if (!reason) { message.warning('请选择拒绝原因'); return }
|
if (!reason) { message.warning('请选择拒绝原因'); return }
|
||||||
rejectLoading.value = true
|
rejectLoading.value = true
|
||||||
try { await request.post(`/content-review/works/${rejectTargetId.value}/reject`, { reason }); message.success('已拒绝'); rejectVisible.value = false; fetchList(); fetchStats() }
|
try {
|
||||||
catch { message.error('操作失败') }
|
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 }
|
finally { rejectLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const showDetail = async (id: number) => {
|
const showDetail = async (id: number) => {
|
||||||
previewPage.value = 0
|
previewPage.value = 0
|
||||||
try { detailData.value = await request.get(`/content-review/works/${id}`); detailVisible.value = true }
|
detailLogs.value = []
|
||||||
catch { message.error('获取详情失败') }
|
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() })
|
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; } }
|
.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; }
|
.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; } } }
|
.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-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; }
|
.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 {
|
.page-preview {
|
||||||
.preview-img { width: 100%; max-height: 300px; object-fit: contain; border-radius: 8px; margin-bottom: 8px; }
|
.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-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; } }
|
.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>
|
</style>
|
||||||
|
|||||||
@ -12,6 +12,30 @@
|
|||||||
</a-input>
|
</a-input>
|
||||||
</div>
|
</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">
|
<div class="tags-scroll" v-if="hotTags.length > 0">
|
||||||
<span
|
<span
|
||||||
@ -92,12 +116,13 @@
|
|||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { message } from 'ant-design-vue'
|
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'
|
import { publicGalleryApi, publicTagsApi, publicInteractionApi, type UserWork, type WorkTag } from '@/api/public'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const works = ref<UserWork[]>([])
|
const works = ref<UserWork[]>([])
|
||||||
const hotTags = ref<WorkTag[]>([])
|
const hotTags = ref<WorkTag[]>([])
|
||||||
|
const recommendedWorks = ref<UserWork[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const selectedTagId = ref<number | null>(null)
|
const selectedTagId = ref<number | null>(null)
|
||||||
@ -114,6 +139,10 @@ const fetchTags = async () => {
|
|||||||
try { hotTags.value = await publicTagsApi.hot() } catch { /* */ }
|
try { hotTags.value = await publicTagsApi.hot() } catch { /* */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchRecommended = async () => {
|
||||||
|
try { recommendedWorks.value = await publicGalleryApi.recommended() } catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
const fetchWorks = async (reset = false) => {
|
const fetchWorks = async (reset = false) => {
|
||||||
if (reset) { page.value = 1; works.value = []; likedSet.clear() }
|
if (reset) { page.value = 1; works.value = []; likedSet.clear() }
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@ -172,6 +201,7 @@ const changeSort = (s: string) => { sortBy.value = s; fetchWorks(true) }
|
|||||||
const loadMore = () => { page.value++; fetchWorks() }
|
const loadMore = () => { page.value++; fetchWorks() }
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
fetchRecommended()
|
||||||
fetchTags()
|
fetchTags()
|
||||||
fetchWorks()
|
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 {
|
.tags-scroll {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user