From 66827c019933a75542eeaee1dbf2f1d863a56255 Mon Sep 17 00:00:00 2001 From: aid Date: Tue, 31 Mar 2026 13:56:20 +0800 Subject: [PATCH] =?UTF-8?q?Day5:=20=E5=85=AC=E4=BC=97=E7=AB=AF=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=BC=8F=E4=BF=AE=E5=A4=8D=20+=20=E7=82=B9=E8=B5=9E?= =?UTF-8?q?=E6=94=B6=E8=97=8F=E5=8A=9F=E8=83=BD=20+=20=E6=8A=A5=E5=90=8D?= =?UTF-8?q?=E4=BD=9C=E5=93=81=E5=90=88=E5=B9=B6=20+=20=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 公众端桌面端新增顶部导航菜单,修复横屏模式菜单消失问题 - 实现点赞/收藏 toggle API(含批量状态查询、我的收藏列表) - 作品详情页新增互动栏(点赞/收藏按钮,乐观更新+动效) - 广场卡片支持点赞交互 - 报名列表合并展示参赛作品,移除独立的「我的作品」页面 - 个人中心新增「我的收藏」入口 - menus.json 与数据库完整同步,更新初始化脚本租户分配逻辑 - Vite 开启局域网访问 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/data/menus.json | 264 +++++++++++------- backend/scripts/init-menus.ts | 22 +- backend/src/public/interaction.service.ts | 173 ++++++++++++ backend/src/public/public.controller.ts | 41 +++ backend/src/public/public.module.ts | 5 +- backend/src/public/public.service.ts | 29 ++ docs/design/public/like-favorite.md | 117 ++++++++ frontend/src/api/public.ts | 18 +- frontend/src/layouts/PublicLayout.vue | 79 ++++++ frontend/src/router/index.ts | 8 +- frontend/src/views/public/ActivityDetail.vue | 7 +- frontend/src/views/public/Gallery.vue | 66 ++++- frontend/src/views/public/mine/Favorites.vue | 138 +++++++++ frontend/src/views/public/mine/Index.vue | 19 +- .../src/views/public/mine/Registrations.vue | 155 ++++++++-- frontend/src/views/public/mine/Works.vue | 123 -------- frontend/src/views/public/works/Detail.vue | 155 +++++++++- frontend/vite.config.ts | 1 + 18 files changed, 1118 insertions(+), 302 deletions(-) create mode 100644 backend/src/public/interaction.service.ts create mode 100644 docs/design/public/like-favorite.md create mode 100644 frontend/src/views/public/mine/Favorites.vue delete mode 100644 frontend/src/views/public/mine/Works.vue diff --git a/backend/data/menus.json b/backend/data/menus.json index 675fa64..babbc45 100644 --- a/backend/data/menus.json +++ b/backend/data/menus.json @@ -1,36 +1,133 @@ [ + { + "name": "工作台", + "path": "/workbench", + "icon": "DashboardOutlined", + "component": "workbench/Index", + "sort": 1 + }, { "name": "我的评审", "path": "/activities", - "icon": "FlagOutlined", - "component": null, - "parentId": null, - "sort": 1, - "permission": "activity:read", + "icon": "AuditOutlined", + "sort": 2, "children": [ { - "name": "活动列表", - "path": "/activities", - "icon": "UnorderedListOutlined", - "component": "contests/Activities", + "name": "评审任务", + "path": "/activities/review", + "icon": "FileSearchOutlined", + "component": "activities/Review", "sort": 1, - "permission": "activity:read" + "permission": "review:score" }, { - "name": "我的报名", - "path": "/activities/registrations", + "name": "预设评语", + "path": "/activities/preset-comments", + "icon": "MessageOutlined", + "component": "activities/PresetComments", + "sort": 2, + "permission": "review:score" + } + ] + }, + { + "name": "活动监管", + "path": "/contests", + "icon": "FundViewOutlined", + "sort": 3, + "children": [ + { + "name": "全部活动", + "path": "/contests/list", + "icon": "UnorderedListOutlined", + "component": "contests/Index", + "sort": 1, + "permission": "contest:read" + }, + { + "name": "报名数据", + "path": "/contests/registrations", "icon": "UserAddOutlined", "component": "contests/registrations/Index", "sort": 2, - "permission": "registration:create" + "permission": "contest:registration:read" }, { - "name": "我的作品", - "path": "/activities/works", + "name": "作品数据", + "path": "/contests/works", "icon": "FileTextOutlined", "component": "contests/works/Index", "sort": 3, - "permission": "work:create" + "permission": "contest:work:read" + }, + { + "name": "评审进度", + "path": "/contests/review-progress", + "icon": "DashboardOutlined", + "component": "contests/reviews/Progress", + "sort": 4, + "permission": "review:progress:read" + }, + { + "name": "评委管理", + "path": "/contests/judges", + "icon": "SolutionOutlined", + "component": "contests/judges/Index", + "sort": 5, + "permission": "judge:read" + }, + { + "name": "评审规则", + "path": "/contests/review-rules", + "icon": "CheckCircleOutlined", + "component": "contests/reviews/Index", + "sort": 6, + "permission": "review:rule:read" + }, + { + "name": "活动成果", + "path": "/contests/results", + "icon": "TrophyOutlined", + "component": "contests/results/Index", + "sort": 7, + "permission": "result:read" + }, + { + "name": "通知管理", + "path": "/contests/notices", + "icon": "BellOutlined", + "component": "contests/notices/Index", + "sort": 8, + "permission": "contest:notice:read" + } + ] + }, + { + "name": "内容管理", + "path": "/content", + "icon": "PictureOutlined", + "sort": 4, + "children": [ + { + "name": "作品审核", + "path": "/content/review", + "component": "content/WorkReview", + "sort": 1, + "permission": "content:review" + }, + { + "name": "作品管理", + "path": "/content/management", + "component": "content/WorkManagement", + "sort": 2, + "permission": "content:manage" + }, + { + "name": "标签管理", + "path": "/content/tags", + "component": "content/TagManagement", + "sort": 3, + "permission": "content:tags" } ] }, @@ -38,10 +135,7 @@ "name": "学校管理", "path": "/school", "icon": "BankOutlined", - "component": null, - "parentId": null, - "sort": 2, - "permission": "school:read", + "sort": 5, "children": [ { "name": "学校信息", @@ -97,23 +191,20 @@ "name": "活动管理", "path": "/contests", "icon": "TrophyOutlined", - "component": null, - "parentId": null, - "sort": 3, - "permission": "contest:create", + "sort": 6, "children": [ { "name": "活动列表", - "path": "/contests", + "path": "/contests/list", "icon": "UnorderedListOutlined", "component": "contests/Index", "sort": 1, - "permission": "contest:create" + "permission": "contest:read" }, { "name": "评委管理", "path": "/contests/judges", - "icon": "SolutionOutlined", + "icon": "UserSwitchOutlined", "component": "contests/judges/Index", "sort": 2, "permission": "judge:read" @@ -121,10 +212,10 @@ { "name": "报名管理", "path": "/contests/registrations", - "icon": "UserAddOutlined", + "icon": "FormOutlined", "component": "contests/registrations/Index", "sort": 3, - "permission": "registration:approve" + "permission": "contest:registration:read" }, { "name": "作品管理", @@ -132,23 +223,23 @@ "icon": "FileTextOutlined", "component": "contests/works/Index", "sort": 4, - "permission": "contest:read" + "permission": "contest:work:read" }, { "name": "评审进度", "path": "/contests/review-progress", - "icon": "AuditOutlined", + "icon": "DashboardOutlined", "component": "contests/reviews/Progress", "sort": 5, - "permission": "review-rule:read" + "permission": "review:progress:read" }, { "name": "评审规则", - "path": "/contests/reviews", - "icon": "CheckCircleOutlined", - "component": "contests/reviews/Index", + "path": "/contests/review-rules", + "icon": "SettingOutlined", + "component": "contests/ReviewRules", "sort": 6, - "permission": "review-rule:read" + "permission": "review:rule:read" }, { "name": "成果发布", @@ -156,61 +247,63 @@ "icon": "TrophyOutlined", "component": "contests/results/Index", "sort": 7, - "permission": "contest:create" + "permission": "result:read" }, { - "name": "通知管理", + "name": "活动公告", "path": "/contests/notices", - "icon": "BellOutlined", + "icon": "NotificationOutlined", "component": "contests/notices/Index", "sort": 8, - "permission": "notice:create" + "permission": "contest:notice:read" } ] }, { - "name": "作业管理", - "path": "/homework", - "icon": "FormOutlined", - "component": null, - "parentId": null, - "sort": 4, - "permission": "homework:read", + "name": "机构管理", + "path": "/organization", + "icon": "BankOutlined", + "sort": 7, "children": [ { - "name": "作业列表", - "path": "/homework", - "icon": "FileTextOutlined", - "component": "homework/Index", + "name": "机构管理", + "path": "/system/tenants", + "icon": "UnorderedListOutlined", + "component": "system/tenants/Index", "sort": 1, - "permission": "homework:create" - }, - { - "name": "评审规则", - "path": "/homework/review-rules", - "icon": "CheckCircleOutlined", - "component": "homework/ReviewRules", - "sort": 2, - "permission": "homework-review-rule:read" - }, - { - "name": "我的作业", - "path": "/homework/my", - "icon": "BookOutlined", - "component": "homework/StudentList", - "sort": 3, - "permission": "homework-submission:create" + "permission": "tenant:read" } ] }, { - "name": "系统管理", + "name": "用户中心", + "path": "/users-center", + "icon": "TeamOutlined", + "sort": 8, + "children": [ + { + "name": "平台用户", + "path": "/system/users", + "icon": "UserSwitchOutlined", + "component": "system/users/Index", + "sort": 2, + "permission": "user:read" + }, + { + "name": "角色管理", + "path": "/system/roles", + "icon": "SafetyOutlined", + "component": "system/roles/Index", + "sort": 3, + "permission": "role:read" + } + ] + }, + { + "name": "系统设置", "path": "/system", "icon": "SettingOutlined", - "component": null, - "parentId": null, "sort": 9, - "permission": "user:read", "children": [ { "name": "用户管理", @@ -265,35 +358,14 @@ "path": "/system/permissions", "icon": "SafetyOutlined", "component": "system/permissions/Index", - "sort": 7, - "permission": "permission:read" + "sort": 7 }, { "name": "租户管理", "path": "/system/tenants", - "icon": "TeamOutlined", + "icon": "BankOutlined", "component": "system/tenants/Index", - "sort": 8, - "permission": "tenant:read" - } - ] - }, - { - "name": "工作台", - "path": "/workbench", - "icon": "DashboardOutlined", - "component": null, - "parentId": null, - "sort": 10, - "permission": "ai-3d:read", - "children": [ - { - "name": "3D建模实验室", - "path": "/workbench/3d-lab", - "icon": "ExperimentOutlined", - "component": "workbench/ai-3d/Index", - "sort": 1, - "permission": "ai-3d:read" + "sort": 8 } ] } diff --git a/backend/scripts/init-menus.ts b/backend/scripts/init-menus.ts index 39c5d77..a472c92 100644 --- a/backend/scripts/init-menus.ts +++ b/backend/scripts/init-menus.ts @@ -44,17 +44,17 @@ if (!fs.existsSync(menusFilePath)) { const menus = JSON.parse(fs.readFileSync(menusFilePath, 'utf-8')); -// 超级租户可见的菜单名称(工作台只对普通租户可见) -const SUPER_TENANT_MENUS = ['我的评审', '活动管理', '系统管理']; +// 超级租户可见的菜单名称 +const SUPER_TENANT_MENUS = ['我的评审', '活动监管', '内容管理', '活动管理', '机构管理', '用户中心', '系统设置']; // 普通租户可见的菜单名称 -const NORMAL_TENANT_MENUS = ['工作台', '学校管理', '我的评审', '作业管理', '系统管理']; +const NORMAL_TENANT_MENUS = ['工作台', '学校管理', '我的评审', '活动管理', '系统设置']; -// 普通租户在系统管理下排除的子菜单(只保留用户管理和角色管理) +// 普通租户在系统设置下排除的子菜单(只保留用户管理和角色管理) const NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS = ['租户管理', '数据字典', '系统配置', '日志记录', '菜单管理', '权限管理']; -// 普通租户在我的评审下排除的子菜单(只保留活动列表) -const NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS = ['我的报名', '我的作品']; +// 普通租户在我的评审下排除的子菜单(只保留评审任务) +const NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS = ['预设评语']; async function initMenus() { try { @@ -215,13 +215,13 @@ async function initMenus() { if (menu.parentId) { const parentMenu = allMenus.find(m => m.id === menu.parentId); if (parentMenu && NORMAL_TENANT_MENUS.includes(parentMenu.name)) { - // 系统管理下排除部分子菜单 - if (parentMenu.name === '系统管理' && NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS.includes(menu.name)) { - continue; // 跳过排除的菜单 + // 系统设置下排除部分子菜单 + if (parentMenu.name === '系统设置' && NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS.includes(menu.name)) { + continue; } - // 我的评审下排除部分子菜单(只保留活动列表) + // 我的评审下排除部分子菜单 if (parentMenu.name === '我的评审' && NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS.includes(menu.name)) { - continue; // 跳过排除的菜单 + continue; } normalTenantMenuIds.add(menu.id); } diff --git a/backend/src/public/interaction.service.ts b/backend/src/public/interaction.service.ts new file mode 100644 index 0000000..7d60ef4 --- /dev/null +++ b/backend/src/public/interaction.service.ts @@ -0,0 +1,173 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class InteractionService { + constructor(private prisma: PrismaService) {} + + /** 点赞/取消点赞(toggle) */ + async toggleLike(userId: number, workId: number) { + // 校验作品存在且已发布 + await this.ensureWorkExists(workId); + + const existing = await this.prisma.userWorkLike.findUnique({ + where: { userId_workId: { userId, workId } }, + }); + + if (existing) { + await this.prisma.$transaction([ + this.prisma.userWorkLike.delete({ + where: { id: existing.id }, + }), + this.prisma.userWork.update({ + where: { id: workId }, + data: { likeCount: { decrement: 1 } }, + }), + ]); + const work = await this.prisma.userWork.findUnique({ + where: { id: workId }, + select: { likeCount: true }, + }); + return { liked: false, likeCount: work.likeCount }; + } else { + await this.prisma.$transaction([ + this.prisma.userWorkLike.create({ + data: { userId, workId }, + }), + this.prisma.userWork.update({ + where: { id: workId }, + data: { likeCount: { increment: 1 } }, + }), + ]); + const work = await this.prisma.userWork.findUnique({ + where: { id: workId }, + select: { likeCount: true }, + }); + return { liked: true, likeCount: work.likeCount }; + } + } + + /** 收藏/取消收藏(toggle) */ + async toggleFavorite(userId: number, workId: number) { + await this.ensureWorkExists(workId); + + const existing = await this.prisma.userWorkFavorite.findUnique({ + where: { userId_workId: { userId, workId } }, + }); + + if (existing) { + await this.prisma.$transaction([ + this.prisma.userWorkFavorite.delete({ + where: { id: existing.id }, + }), + this.prisma.userWork.update({ + where: { id: workId }, + data: { favoriteCount: { decrement: 1 } }, + }), + ]); + const work = await this.prisma.userWork.findUnique({ + where: { id: workId }, + select: { favoriteCount: true }, + }); + return { favorited: false, favoriteCount: work.favoriteCount }; + } else { + await this.prisma.$transaction([ + this.prisma.userWorkFavorite.create({ + data: { userId, workId }, + }), + this.prisma.userWork.update({ + where: { id: workId }, + data: { favoriteCount: { increment: 1 } }, + }), + ]); + const work = await this.prisma.userWork.findUnique({ + where: { id: workId }, + select: { favoriteCount: true }, + }); + return { favorited: true, favoriteCount: work.favoriteCount }; + } + } + + /** 查询当前用户对某作品的交互状态 */ + async getInteractionStatus(userId: number, workId: number) { + const [like, favorite] = await Promise.all([ + this.prisma.userWorkLike.findUnique({ + where: { userId_workId: { userId, workId } }, + }), + this.prisma.userWorkFavorite.findUnique({ + where: { userId_workId: { userId, workId } }, + }), + ]); + return { liked: !!like, favorited: !!favorite }; + } + + /** 我的收藏列表 */ + async getMyFavorites(userId: number, query: { page?: number; pageSize?: number }) { + const page = query.page || 1; + const pageSize = query.pageSize || 12; + const skip = (page - 1) * pageSize; + + const where = { + userId, + work: { status: 'published', isDeleted: 0 }, + }; + + const [list, total] = await Promise.all([ + this.prisma.userWorkFavorite.findMany({ + where, + skip, + take: pageSize, + orderBy: { createTime: 'desc' }, + include: { + work: { + select: { + id: true, + title: true, + coverUrl: true, + likeCount: true, + viewCount: true, + favoriteCount: true, + creator: { select: { id: true, nickname: true, avatar: true } }, + }, + }, + }, + }), + this.prisma.userWorkFavorite.count({ where }), + ]); + + return { list, total, page, pageSize }; + } + + /** 批量查询交互状态(用于列表页) */ + async batchGetInteractionStatus(userId: number, workIds: number[]) { + if (!workIds.length) return {}; + + const [likes, favorites] = await Promise.all([ + this.prisma.userWorkLike.findMany({ + where: { userId, workId: { in: workIds } }, + select: { workId: true }, + }), + this.prisma.userWorkFavorite.findMany({ + where: { userId, workId: { in: workIds } }, + select: { workId: true }, + }), + ]); + + const likedSet = new Set(likes.map(l => l.workId)); + const favoritedSet = new Set(favorites.map(f => f.workId)); + + const result: Record = {}; + for (const id of workIds) { + result[id] = { liked: likedSet.has(id), favorited: favoritedSet.has(id) }; + } + return result; + } + + private async ensureWorkExists(workId: number) { + const work = await this.prisma.userWork.findFirst({ + where: { id: workId, status: 'published', isDeleted: 0 }, + select: { id: true }, + }); + if (!work) throw new NotFoundException('作品不存在或未发布'); + } +} diff --git a/backend/src/public/public.controller.ts b/backend/src/public/public.controller.ts index c673a9b..f1f339e 100644 --- a/backend/src/public/public.controller.ts +++ b/backend/src/public/public.controller.ts @@ -17,6 +17,7 @@ import { UserWorksService } from './user-works.service'; import { CreationService } from './creation.service'; import { TagsService } from './tags.service'; import { GalleryService } from './gallery.service'; +import { InteractionService } from './interaction.service'; import { PublicRegisterDto, PublicLoginDto } from './dto/register.dto'; import { CreateChildDto, UpdateChildDto } from './dto/child.dto'; import { PublicRegisterActivityDto } from './dto/registration.dto'; @@ -30,6 +31,7 @@ export class PublicController { private readonly creationService: CreationService, private readonly tagsService: TagsService, private readonly galleryService: GalleryService, + private readonly interactionService: InteractionService, ) {} // ==================== 注册 & 登录(公开接口) ==================== @@ -423,4 +425,43 @@ export class PublicController { pageSize: pageSize ? parseInt(pageSize) : 12, }); } + + // ==================== 点赞 & 收藏(需要登录) ==================== + + @UseGuards(AuthGuard('jwt')) + @Post('works/batch-interaction') + async batchInteraction(@Request() req, @Body() body: { workIds: number[] }) { + return this.interactionService.batchGetInteractionStatus(req.user.userId, body.workIds || []); + } + + @UseGuards(AuthGuard('jwt')) + @Post('works/:id/like') + async toggleLike(@Request() req, @Param('id', ParseIntPipe) id: number) { + return this.interactionService.toggleLike(req.user.userId, id); + } + + @UseGuards(AuthGuard('jwt')) + @Post('works/:id/favorite') + async toggleFavorite(@Request() req, @Param('id', ParseIntPipe) id: number) { + return this.interactionService.toggleFavorite(req.user.userId, id); + } + + @UseGuards(AuthGuard('jwt')) + @Get('works/:id/interaction') + async getInteraction(@Request() req, @Param('id', ParseIntPipe) id: number) { + return this.interactionService.getInteractionStatus(req.user.userId, id); + } + + @UseGuards(AuthGuard('jwt')) + @Get('mine/favorites') + async getMyFavorites( + @Request() req, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.interactionService.getMyFavorites(req.user.userId, { + page: page ? parseInt(page, 10) : 1, + pageSize: pageSize ? parseInt(pageSize, 10) : 12, + }); + } } diff --git a/backend/src/public/public.module.ts b/backend/src/public/public.module.ts index d107060..d962b3e 100644 --- a/backend/src/public/public.module.ts +++ b/backend/src/public/public.module.ts @@ -10,6 +10,7 @@ import { CreationService } from './creation.service'; import { TagsService } from './tags.service'; import { GalleryService } from './gallery.service'; import { ContentReviewService } from './content-review.service'; +import { InteractionService } from './interaction.service'; import { PrismaModule } from '../prisma/prisma.module'; @Module({ @@ -24,7 +25,7 @@ import { PrismaModule } from '../prisma/prisma.module'; }), ], controllers: [PublicController, TagsController, ContentReviewController], - providers: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService], - exports: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService], + providers: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService, InteractionService], + exports: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService, InteractionService], }) export class PublicModule {} diff --git a/backend/src/public/public.service.ts b/backend/src/public/public.service.ts index f81f0eb..b9ac873 100644 --- a/backend/src/public/public.service.ts +++ b/backend/src/public/public.service.ts @@ -629,6 +629,16 @@ export class PublicService { child: { select: { id: true, name: true }, }, + works: { + where: { validState: 1 }, + select: { + id: true, + title: true, + previewUrl: true, + createTime: true, + }, + orderBy: { createTime: 'desc' }, + }, }, }); return registration; @@ -902,6 +912,25 @@ export class PublicService { grade: true, }, }, + works: { + where: { validState: 1 }, + select: { + id: true, + title: true, + previewUrl: true, + createTime: true, + attachments: { + select: { + id: true, + fileName: true, + fileUrl: true, + fileType: true, + }, + take: 1, + }, + }, + orderBy: { createTime: 'desc' }, + }, }, }), this.prisma.contestRegistration.count({ where }), diff --git a/docs/design/public/like-favorite.md b/docs/design/public/like-favorite.md new file mode 100644 index 0000000..68d846f --- /dev/null +++ b/docs/design/public/like-favorite.md @@ -0,0 +1,117 @@ +# 点赞 & 收藏功能设计 + +## 概述 + +为 UGC 绘本创作社区的作品添加点赞和收藏交互能力,提升用户参与感和内容发现效率。 + +## 现状 + +- 数据库:`user_work_likes` 和 `user_work_favorites` 表已存在(含唯一约束) +- `UserWork` 表已有 `likeCount`、`favoriteCount` 冗余计数字段 +- 前端:Gallery 和 Detail 页面已展示计数数字,但无交互按钮 +- 后端:无任何点赞/收藏 API + +## API 设计 + +### 公众端 API + +| 方法 | 路径 | 说明 | 鉴权 | +|------|------|------|------| +| POST | `/api/public/works/:id/like` | 点赞/取消点赞(toggle) | 需登录 | +| POST | `/api/public/works/:id/favorite` | 收藏/取消收藏(toggle) | 需登录 | +| GET | `/api/public/works/:id/interaction` | 查询当前用户对该作品的交互状态 | 需登录 | +| GET | `/api/public/mine/favorites` | 我的收藏列表 | 需登录 | + +### 请求/响应 + +#### POST /works/:id/like +```json +// Response +{ "liked": true, "likeCount": 42 } +``` + +#### POST /works/:id/favorite +```json +// Response +{ "favorited": true, "favoriteCount": 18 } +``` + +#### GET /works/:id/interaction +```json +// Response +{ "liked": true, "favorited": false } +``` + +#### GET /mine/favorites +```json +// Response +{ + "list": [ + { + "id": 1, + "workId": 10, + "createTime": "2026-03-31T...", + "work": { + "id": 10, "title": "...", "coverUrl": "...", + "likeCount": 42, "viewCount": 100, + "creator": { "id": 1, "nickname": "...", "avatar": "..." } + } + } + ], + "total": 5, "page": 1, "pageSize": 12 +} +``` + +## 后端实现 + +### 新增文件 +- `backend/src/public/interaction.service.ts` — 交互服务 + +### 核心逻辑 + +**点赞 toggle**: +1. 查询 `UserWorkLike` 是否存在该记录 +2. 存在 → 删除记录 + `likeCount` 减 1 +3. 不存在 → 创建记录 + `likeCount` 加 1 +4. 使用事务保证一致性 + +**收藏 toggle**:同上逻辑,操作 `UserWorkFavorite` 和 `favoriteCount` + +**交互状态查询**: +- 批量查询 like + favorite 记录是否存在 +- 广场详情接口中嵌入调用(已登录时返回交互状态) + +## 前端改动 + +### 作品详情页 (Detail.vue) +- stats-row 改为交互按钮栏:点赞按钮 + 收藏按钮 +- 已点赞/收藏时图标实心 + 主题色高亮 +- 点击触发 toggle API,乐观更新计数 + +### 广场页 (Gallery.vue) +- 卡片 stats 区域的心形图标改为可点击 +- 点击点赞(阻止冒泡,不跳转详情) +- 乐观更新 + 简单动效 + +### 新增页面 +- `/p/mine/favorites` — 我的收藏页面 +- 个人中心 Index.vue 增加「我的收藏」菜单入口 + +### API 定义 +```typescript +// api/public.ts +export const publicInteractionApi = { + like: (workId: number) => publicApi.post(`/public/works/${workId}/like`), + favorite: (workId: number) => publicApi.post(`/public/works/${workId}/favorite`), + getInteraction: (workId: number) => publicApi.get(`/public/works/${workId}/interaction`), + myFavorites: (params?: { page?: number; pageSize?: number }) => + publicApi.get('/public/mine/favorites', { params }), +} +``` + +## 交互细节 + +- 未登录用户点击点赞/收藏 → 跳转登录页 +- 乐观更新:点击后立即更新 UI,API 失败时回滚 +- 点赞按钮动效:心形图标缩放弹跳 +- 自己的作品也可以点赞/收藏(不做限制) diff --git a/frontend/src/api/public.ts b/frontend/src/api/public.ts index eddb33d..d8744b0 100644 --- a/frontend/src/api/public.ts +++ b/frontend/src/api/public.ts @@ -243,14 +243,26 @@ export const publicActivitiesApi = { ) => publicApi.post(`/public/activities/${id}/submit-work`, data), } -// ==================== 我的报名 & 作品 ==================== +// ==================== 我的报名 ==================== export const publicMineApi = { registrations: (params?: { page?: number; pageSize?: number }) => publicApi.get("/public/mine/registrations", { params }), +} - works: (params?: { page?: number; pageSize?: number }) => - publicApi.get("/public/mine/works", { params }), +// ==================== 点赞 & 收藏 ==================== + +export const publicInteractionApi = { + like: (workId: number) => + publicApi.post(`/public/works/${workId}/like`), + favorite: (workId: number) => + publicApi.post(`/public/works/${workId}/favorite`), + getInteraction: (workId: number) => + publicApi.get(`/public/works/${workId}/interaction`), + batchStatus: (workIds: number[]) => + publicApi.post("/public/works/batch-interaction", { workIds }), + myFavorites: (params?: { page?: number; pageSize?: number }) => + publicApi.get("/public/mine/favorites", { params }), } // ==================== 用户作品库 ==================== diff --git a/frontend/src/layouts/PublicLayout.vue b/frontend/src/layouts/PublicLayout.vue index 956721b..58f651b 100644 --- a/frontend/src/layouts/PublicLayout.vue +++ b/frontend/src/layouts/PublicLayout.vue @@ -7,6 +7,42 @@ 乐绘世界 + + +
+ + diff --git a/frontend/src/views/public/mine/Index.vue b/frontend/src/views/public/mine/Index.vue index 59312e2..ad8ab40 100644 --- a/frontend/src/views/public/mine/Index.vue +++ b/frontend/src/views/public/mine/Index.vue @@ -33,13 +33,13 @@
-