diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d208491..af288d3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1271,6 +1271,7 @@ model WorkTag { id Int @id @default(autoincrement()) name String @unique @db.VarChar(50) /// 标签名称 category String? @db.VarChar(50) /// 所属分类(如:主题/风格/情感) + color String? @db.VarChar(20) /// 标签颜色(如:#6366f1) sort Int @default(0) /// 排序权重 status String @default("enabled") /// 状态:enabled/disabled usageCount Int @default(0) @map("usage_count") /// 使用次数(冗余) diff --git a/backend/src/public/content-review.controller.ts b/backend/src/public/content-review.controller.ts index 0cfdf11..492592b 100644 --- a/backend/src/public/content-review.controller.ts +++ b/backend/src/public/content-review.controller.ts @@ -25,6 +25,8 @@ export class ContentReviewController { @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, + @Query('sortBy') sortBy?: string, + @Query('isRecommended') isRecommended?: string, ) { return this.reviewService.getWorkQueue({ page: page ? parseInt(page) : 1, @@ -33,6 +35,8 @@ export class ContentReviewController { keyword, startTime, endTime, + sortBy, + isRecommended: isRecommended === '1', }); } @@ -41,6 +45,16 @@ export class ContentReviewController { return this.reviewService.getWorkDetail(id); } + @Post('works/batch-approve') + batchApprove(@Request() req, @Body() dto: { ids: number[] }) { + return this.reviewService.batchApprove(dto.ids || [], req.user.userId); + } + + @Post('works/batch-reject') + batchReject(@Request() req, @Body() dto: { ids: number[]; reason: string }) { + return this.reviewService.batchReject(dto.ids || [], req.user.userId, dto.reason); + } + @Post('works/:id/approve') approveWork( @Param('id', ParseIntPipe) id: number, @@ -59,6 +73,11 @@ export class ContentReviewController { return this.reviewService.reject(id, req.user.userId, dto.reason, dto.note); } + @Post('works/:id/revoke') + revokeWork(@Param('id', ParseIntPipe) id: number, @Request() req) { + return this.reviewService.revoke(id, req.user.userId); + } + @Post('works/:id/takedown') takedownWork( @Param('id', ParseIntPipe) id: number, diff --git a/backend/src/public/content-review.service.ts b/backend/src/public/content-review.service.ts index a41f7c6..4de6682 100644 --- a/backend/src/public/content-review.service.ts +++ b/backend/src/public/content-review.service.ts @@ -28,17 +28,27 @@ export class ContentReviewService { keyword?: string; startTime?: string; endTime?: string; + sortBy?: string; + isRecommended?: boolean; }) { - const { page = 1, pageSize = 10, status, keyword, startTime, endTime } = params; + const { page = 1, pageSize = 10, status, keyword, startTime, endTime, sortBy, isRecommended } = params; const skip = (page - 1) * pageSize; const where: any = { isDeleted: 0 }; if (status) { - where.status = status; + if (status === 'published,taken_down') { + where.status = { in: ['published', 'taken_down'] }; + } else { + where.status = status; + } } else { where.status = { in: ['pending_review', 'published', 'rejected', 'taken_down'] }; } + if (isRecommended) { + where.isRecommended = true; + } + if (keyword) { where.OR = [ { title: { contains: keyword } }, @@ -49,12 +59,18 @@ export class ContentReviewService { if (startTime) where.createTime = { ...where.createTime, gte: new Date(startTime) }; if (endTime) where.createTime = { ...where.createTime, lte: new Date(endTime + ' 23:59:59') }; + // 排序 + let orderBy: any = { createTime: 'desc' }; + if (sortBy === 'hot') orderBy = [{ likeCount: 'desc' }, { viewCount: 'desc' }]; + else if (sortBy === 'views') orderBy = { viewCount: 'desc' }; + else if (sortBy === 'latest') orderBy = { publishTime: 'desc' }; + const [list, total] = await Promise.all([ this.prisma.userWork.findMany({ where, skip, take: pageSize, - orderBy: { createTime: 'desc' }, + orderBy, include: { creator: { select: { id: true, nickname: true, avatar: true, username: true, userType: true } }, tags: { include: { tag: { select: { id: true, name: true } } } }, @@ -115,6 +131,73 @@ export class ContentReviewService { }); } + /** 批量通过 */ + async batchApprove(workIds: number[], operatorId: number) { + const works = await this.prisma.userWork.findMany({ + where: { id: { in: workIds }, status: 'pending_review', isDeleted: 0 }, + select: { id: true }, + }); + + if (works.length === 0) return { success: true, count: 0 }; + + const ids = works.map(w => w.id); + const now = new Date(); + + await this.prisma.$transaction(async (tx) => { + await tx.userWork.updateMany({ + where: { id: { in: ids } }, + data: { status: 'published', reviewTime: now, reviewerId: operatorId, publishTime: now }, + }); + + await tx.contentReviewLog.createMany({ + data: ids.map(id => ({ + targetType: 'work', + targetId: id, + workId: id, + action: 'approve', + note: '批量审核通过', + operatorId, + })), + }); + }); + + return { success: true, count: ids.length }; + } + + /** 批量拒绝 */ + async batchReject(workIds: number[], operatorId: number, reason: string) { + const works = await this.prisma.userWork.findMany({ + where: { id: { in: workIds }, status: 'pending_review', isDeleted: 0 }, + select: { id: true }, + }); + + if (works.length === 0) return { success: true, count: 0 }; + + const ids = works.map(w => w.id); + const now = new Date(); + + await this.prisma.$transaction(async (tx) => { + await tx.userWork.updateMany({ + where: { id: { in: ids } }, + data: { status: 'rejected', reviewTime: now, reviewerId: operatorId, reviewNote: reason }, + }); + + await tx.contentReviewLog.createMany({ + data: ids.map(id => ({ + targetType: 'work', + targetId: id, + workId: id, + action: 'reject', + reason, + note: '批量审核拒绝', + operatorId, + })), + }); + }); + + return { success: true, count: ids.length }; + } + /** 拒绝 */ async reject(workId: number, operatorId: number, reason: string, note?: string) { const work = await this.prisma.userWork.findUnique({ where: { id: workId } }); @@ -155,7 +238,7 @@ export class ContentReviewService { return this.prisma.$transaction(async (tx) => { await tx.userWork.update({ where: { id: workId }, - data: { status: 'taken_down', reviewNote: reason }, + data: { status: 'taken_down', reviewNote: reason, isRecommended: false }, }); await tx.contentReviewLog.create({ @@ -209,6 +292,41 @@ export class ContentReviewService { }); } + /** 撤销审核(恢复为待审核) */ + async revoke(workId: number, operatorId: number) { + const work = await this.prisma.userWork.findUnique({ where: { id: workId } }); + if (!work) throw new NotFoundException('作品不存在'); + if (!['published', 'rejected'].includes(work.status)) { + throw new NotFoundException('当前状态不支持撤销'); + } + + return this.prisma.$transaction(async (tx) => { + await tx.userWork.update({ + where: { id: workId }, + data: { + status: 'pending_review', + reviewTime: null, + reviewerId: null, + reviewNote: null, + publishTime: work.status === 'published' ? null : work.publishTime, + }, + }); + + await tx.contentReviewLog.create({ + data: { + targetType: 'work', + targetId: workId, + workId, + action: 'revoke', + note: '撤销审核操作', + operatorId, + }, + }); + + return { success: true }; + }); + } + /** 作品管理统计 */ async getManagementStats() { const today = new Date(); diff --git a/backend/src/public/gallery.service.ts b/backend/src/public/gallery.service.ts index 172b7bd..ce62b1d 100644 --- a/backend/src/public/gallery.service.ts +++ b/backend/src/public/gallery.service.ts @@ -88,6 +88,23 @@ export class GalleryService { return work; } + /** 推荐作品列表 */ + async getRecommendedWorks(limit = 10) { + return this.prisma.userWork.findMany({ + where: { + isRecommended: true, + status: 'published', + visibility: 'public', + isDeleted: 0, + }, + take: limit, + orderBy: [{ likeCount: 'desc' }, { publishTime: 'desc' }], + include: { + creator: { select: { id: true, nickname: true, avatar: true } }, + }, + }); + } + /** 某用户的公开作品列表 */ async getUserPublicWorks(userId: number, params: { page?: number; pageSize?: number }) { const { page = 1, pageSize = 12 } = params; diff --git a/backend/src/public/public.controller.ts b/backend/src/public/public.controller.ts index f1f339e..9367855 100644 --- a/backend/src/public/public.controller.ts +++ b/backend/src/public/public.controller.ts @@ -407,6 +407,12 @@ export class PublicController { }); } + @Public() + @Get('gallery/recommended') + async getRecommendedWorks() { + return this.galleryService.getRecommendedWorks(); + } + @Public() @Get('gallery/:id') async getGalleryDetail(@Param('id', ParseIntPipe) id: number) { diff --git a/backend/src/public/tags.controller.ts b/backend/src/public/tags.controller.ts index b8eba0b..921d6cb 100644 --- a/backend/src/public/tags.controller.ts +++ b/backend/src/public/tags.controller.ts @@ -16,18 +16,23 @@ export class TagsController { } @Post() - create(@Body() dto: { name: string; category?: string; sort?: number }) { + create(@Body() dto: { name: string; category?: string; color?: string; sort?: number }) { return this.tagsService.create(dto); } @Put(':id') update( @Param('id', ParseIntPipe) id: number, - @Body() dto: { name?: string; category?: string; sort?: number; status?: string }, + @Body() dto: { name?: string; category?: string; color?: string; sort?: number; status?: string }, ) { return this.tagsService.update(id, dto); } + @Post('batch-sort') + batchSort(@Body() dto: { items: { id: number; sort: number }[] }) { + return this.tagsService.batchUpdateSort(dto.items || []); + } + @Delete(':id') remove(@Param('id', ParseIntPipe) id: number) { return this.tagsService.remove(id); diff --git a/backend/src/public/tags.service.ts b/backend/src/public/tags.service.ts index 53f8a3b..9ecba9f 100644 --- a/backend/src/public/tags.service.ts +++ b/backend/src/public/tags.service.ts @@ -36,7 +36,7 @@ export class TagsService { } /** 创建标签 */ - async create(dto: { name: string; category?: string; sort?: number }) { + async create(dto: { name: string; category?: string; color?: string; sort?: number }) { const existing = await this.prisma.workTag.findFirst({ where: { name: dto.name } }); if (existing) throw new BadRequestException('标签名已存在'); @@ -44,13 +44,14 @@ export class TagsService { data: { name: dto.name, category: dto.category || null, + color: dto.color || null, sort: dto.sort || 0, }, }); } /** 编辑标签 */ - async update(id: number, dto: { name?: string; category?: string; sort?: number; status?: string }) { + async update(id: number, dto: { name?: string; category?: string; color?: string; sort?: number; status?: string }) { const tag = await this.prisma.workTag.findUnique({ where: { id } }); if (!tag) throw new NotFoundException('标签不存在'); @@ -64,12 +65,23 @@ export class TagsService { data: { name: dto.name ?? tag.name, category: dto.category !== undefined ? dto.category : tag.category, + color: dto.color !== undefined ? dto.color : tag.color, sort: dto.sort !== undefined ? dto.sort : tag.sort, status: dto.status ?? tag.status, }, }); } + /** 批量更新排序 */ + async batchUpdateSort(items: { id: number; sort: number }[]) { + await this.prisma.$transaction( + items.map(item => + this.prisma.workTag.update({ where: { id: item.id }, data: { sort: item.sort } }), + ), + ); + return { success: true }; + } + /** 删除标签 */ async remove(id: number) { const tag = await this.prisma.workTag.findUnique({ where: { id } }); diff --git a/docs/design/README.md b/docs/design/README.md index ffb2973..a749151 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -4,13 +4,13 @@ | 文档 | 模块 | 状态 | 日期 | |------|------|------|------| -| [统一用户管理](./super-admin/unified-user-management.md) | 用户中心 | 已实现(待验收) | 2026-03-27 | +| [统一用户管理](./super-admin/unified-user-management.md) | 用户中心 | 已实现(迭代中) | 2026-03-27 | | [全部活动优化](./super-admin/activity-list-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 | | [报名数据优化](./super-admin/registration-data-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 | | [作品数据优化](./super-admin/works-data-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 | | [评审进度优化](./super-admin/review-progress-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 | | [成果发布优化](./super-admin/results-publish-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 | -| [内容管理模块](./super-admin/content-management.md) | 内容管理(新增) | P0 已实现 | 2026-03-27 | +| [内容管理模块](./super-admin/content-management.md) | 内容管理 | P0 已实现并优化 | 2026-03-31 | ## 机构管理端 @@ -21,7 +21,8 @@ | 文档 | 模块 | 状态 | 日期 | |------|------|------|------| | [UGC社区升级](./public/ugc-platform-upgrade.md) | 整体架构 | 需求已确认 | 2026-03-27 | -| [UGC开发计划](./public/ugc-development-plan.md) | 开发排期 | P0 已实现 | 2026-03-27 | +| [UGC开发计划](./public/ugc-development-plan.md) | 开发排期 | P0已完成,P1进行中 | 2026-03-31 | +| [点赞&收藏](./public/like-favorite.md) | 社区互动 | 已实现 | 2026-03-31 | ## 评委端 diff --git a/docs/design/public/like-favorite.md b/docs/design/public/like-favorite.md index 68d846f..ff77d38 100644 --- a/docs/design/public/like-favorite.md +++ b/docs/design/public/like-favorite.md @@ -1,5 +1,10 @@ # 点赞 & 收藏功能设计 +> 所属端:公众端 + 超管端联动 +> 状态:已实现 +> 创建日期:2026-03-31 +> 最后更新:2026-03-31 + ## 概述 为 UGC 绘本创作社区的作品添加点赞和收藏交互能力,提升用户参与感和内容发现效率。 diff --git a/docs/design/public/ugc-development-plan.md b/docs/design/public/ugc-development-plan.md index 8dbe340..ba33d69 100644 --- a/docs/design/public/ugc-development-plan.md +++ b/docs/design/public/ugc-development-plan.md @@ -320,22 +320,25 @@ P0-4 + P0-6 → P0-12(活动联动) 目标:用户可对作品点赞、收藏、评论;有消息通知;可举报不当内容。 -#### P1-1. 点赞/收藏(后端+前端) +#### P1-1. 点赞/收藏(后端+前端)✅ 已实现 (2026-03-31) ``` -后端 API: -├── POST /api/public/works/:id/like — 点赞/取消点赞 -├── POST /api/public/works/:id/favorite — 收藏/取消收藏 -├── GET /api/public/mine/favorites — 我的收藏列表 +后端 API(已实现): +├── POST /api/public/works/:id/like — 点赞/取消点赞(toggle) +├── POST /api/public/works/:id/favorite — 收藏/取消收藏(toggle) +├── GET /api/public/works/:id/interaction — 查询交互状态 +├── POST /api/public/works/batch-interaction — 批量查询交互状态 +├── GET /api/public/mine/favorites — 我的收藏列表 -前端改动: -├── 广场作品详情页 — 增加点赞/收藏按钮和计数 -├── 作品卡片组件 — 显示点赞数 -├── /p/mine/favorites — 我的收藏页面 +前端改动(已实现): +├── 作品详情页 — 底部互动栏:点赞(心形)/收藏(星形)/浏览数,乐观更新+弹跳动效 +├── 广场卡片 — 心形可点击点赞,已点赞显示实心粉色 +├── /p/mine/favorites — 我的收藏页面(网格展示) +├── 个人中心 — 新增「我的收藏」菜单入口 ``` -**依赖**:P0 完成 -**数据库**:user_work_likes + user_work_favorites +**数据库**:user_work_likes + user_work_favorites(已有) +**设计文档**:[点赞收藏设计](../public/like-favorite.md) --- diff --git a/docs/design/super-admin/content-management.md b/docs/design/super-admin/content-management.md index a3b7cf7..ffa3fc7 100644 --- a/docs/design/super-admin/content-management.md +++ b/docs/design/super-admin/content-management.md @@ -1,9 +1,9 @@ # 超管端内容管理模块 — 设计方案 > 所属端:超管端 -> 状态:待开发 +> 状态:P0 已实现并优化 > 创建日期:2026-03-27 -> 最后更新:2026-03-27 +> 最后更新:2026-03-31 --- @@ -349,4 +349,41 @@ POST /api/content-review/reports/:id/handle — 处理举报 ## 8. 实施记录 -(开发过程中记录) +### Day5 (2026-03-31) — P0 全面优化 + +#### 作品审核 +- [x] 基础功能:统计卡片、筛选、审核队列表格、拒绝弹窗(预设理由+自定义)、详情 Drawer(绘本翻页预览) +- [x] 批量审核:支持勾选待审核作品批量通过/批量拒绝 +- [x] 撤销机制:已通过/已拒绝的作品支持撤销恢复为待审核(操作列常驻按钮+二次确认) +- [x] 操作日志:详情 Drawer 底部展示审核操作时间线(通过/拒绝/下架/恢复/撤销) +- [x] 体验优化:默认筛选待审核、表格加描述预览列+审核时间列、详情加「上一个/下一个」导航(审核完自动跳下一个)、统计卡片点击筛选、筛选下拉自动查询 + +#### 作品管理 +- [x] 基础功能:统计卡片、筛选(关键词+状态+排序)、作品表格、推荐/下架/恢复操作 +- [x] 筛选修复:状态筛选支持 published+taken_down+推荐中,排序参数传后端真正生效 +- [x] 下架原因:下架改为弹窗选择原因(4个预设+自定义),取代写死的「管理员下架」 +- [x] 详情 Drawer:补全作品描述、标签、绘本预览、操作按钮(推荐/下架/恢复)、操作日志 +- [x] 推荐联动:推荐作品在公众端广场顶部「编辑推荐」横栏展示,下架时自动取消推荐 +- [x] 体验优化:统计卡片可点击筛选、表格加描述预览列、取消推荐二次确认、筛选自动查询 + +#### 标签管理 +- [x] 基础功能:标签 CRUD、启用/禁用、删除保护(已使用不可删) +- [x] 分类分组:标签按分类分组展示(每组有颜色标识+计数),未分类单独一组 +- [x] 分类下拉:新增/编辑时分类改为下拉选择(支持选已有+创建新分类),杜绝手动输入不一致 +- [x] 标签颜色:数据库新增 color 字段,10个预设色+自定义 hex,卡片左侧颜色条+用户端预览 +- [x] 排序按钮:每个标签有上/下箭头,点击交换排序值并持久化 +- [x] 使用次数可点击:跳转作品管理页带标签名搜索 +- [x] 实时预览:新增/编辑弹窗底部实时渲染用户端标签效果 + +#### 新增后端 API +``` +POST /api/content-review/works/batch-approve — 批量通过 +POST /api/content-review/works/batch-reject — 批量拒绝 +POST /api/content-review/works/:id/revoke — 撤销审核 +GET /api/content-review/works (新增参数) — sortBy 排序 + isRecommended 筛选 +GET /api/public/gallery/recommended — 推荐作品列表(公众端) +POST /api/tags/batch-sort — 标签批量排序 +``` + +#### 数据库变更 +- `work_tags` 表新增 `color` 字段(VARCHAR(20),标签颜色) diff --git a/frontend/src/api/public.ts b/frontend/src/api/public.ts index d8744b0..826f91a 100644 --- a/frontend/src/api/public.ts +++ b/frontend/src/api/public.ts @@ -396,6 +396,9 @@ export const publicTagsApi = { // ==================== 作品广场 ==================== export const publicGalleryApi = { + recommended: (): Promise => + publicApi.get("/public/gallery/recommended"), + list: (params?: { page?: number pageSize?: number diff --git a/frontend/src/views/content/TagManagement.vue b/frontend/src/views/content/TagManagement.vue index e6607cd..3797fb5 100644 --- a/frontend/src/views/content/TagManagement.vue +++ b/frontend/src/views/content/TagManagement.vue @@ -10,76 +10,213 @@ - - - + +
- + + + + - - + + + + + + +
+ + +
- + - 保存 + + +
+ 用户端预览效果: + {{ form.name || '标签名称' }} +
+ + 保存
diff --git a/frontend/src/views/content/WorkManagement.vue b/frontend/src/views/content/WorkManagement.vue index b789a48..1dbe71b 100644 --- a/frontend/src/views/content/WorkManagement.vue +++ b/frontend/src/views/content/WorkManagement.vue @@ -4,11 +4,21 @@ - +
-
-
{{ item.value }}
-
{{ item.label }}
+
+
+ +
+
+ {{ item.value }} + {{ item.label }} +
@@ -18,8 +28,16 @@ + + + 全部 + 正常 + 已下架 + 推荐中 + + - + 最新发布 最多点赞 最多浏览 @@ -40,6 +58,13 @@ + + + + +

+ 下架后作品「{{ takedownTarget?.title }}」将不再公开展示,请填写下架原因: +

+ + 含不适宜内容 + 涉嫌抄袭/侵权 + 用户投诉/举报 + 违反平台规范 + 其他 + + +
+ + + + +
diff --git a/frontend/src/views/content/WorkReview.vue b/frontend/src/views/content/WorkReview.vue index 0b6a479..89bdecb 100644 --- a/frontend/src/views/content/WorkReview.vue +++ b/frontend/src/views/content/WorkReview.vue @@ -21,7 +21,8 @@
- + + 全部 待审核 已通过 已拒绝 @@ -39,26 +40,61 @@
+ +
+ + 勾选表格中的待审核作品可进行批量操作 +
+ - +