Day5: 公众端响应式修复 + 点赞收藏功能 + 报名作品合并 + 菜单同步

- 公众端桌面端新增顶部导航菜单,修复横屏模式菜单消失问题
- 实现点赞/收藏 toggle API(含批量状态查询、我的收藏列表)
- 作品详情页新增互动栏(点赞/收藏按钮,乐观更新+动效)
- 广场卡片支持点赞交互
- 报名列表合并展示参赛作品,移除独立的「我的作品」页面
- 个人中心新增「我的收藏」入口
- menus.json 与数据库完整同步,更新初始化脚本租户分配逻辑
- Vite 开启局域网访问

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
aid 2026-03-31 13:56:20 +08:00
parent 4466e28b3b
commit 66827c0199
18 changed files with 1118 additions and 302 deletions

View File

@ -1,36 +1,133 @@
[ [
{
"name": "工作台",
"path": "/workbench",
"icon": "DashboardOutlined",
"component": "workbench/Index",
"sort": 1
},
{ {
"name": "我的评审", "name": "我的评审",
"path": "/activities", "path": "/activities",
"icon": "FlagOutlined", "icon": "AuditOutlined",
"component": null, "sort": 2,
"parentId": null,
"sort": 1,
"permission": "activity:read",
"children": [ "children": [
{ {
"name": "活动列表", "name": "评审任务",
"path": "/activities", "path": "/activities/review",
"icon": "UnorderedListOutlined", "icon": "FileSearchOutlined",
"component": "contests/Activities", "component": "activities/Review",
"sort": 1, "sort": 1,
"permission": "activity:read" "permission": "review:score"
}, },
{ {
"name": "我的报名", "name": "预设评语",
"path": "/activities/registrations", "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", "icon": "UserAddOutlined",
"component": "contests/registrations/Index", "component": "contests/registrations/Index",
"sort": 2, "sort": 2,
"permission": "registration:create" "permission": "contest:registration:read"
}, },
{ {
"name": "我的作品", "name": "作品数据",
"path": "/activities/works", "path": "/contests/works",
"icon": "FileTextOutlined", "icon": "FileTextOutlined",
"component": "contests/works/Index", "component": "contests/works/Index",
"sort": 3, "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": "学校管理", "name": "学校管理",
"path": "/school", "path": "/school",
"icon": "BankOutlined", "icon": "BankOutlined",
"component": null, "sort": 5,
"parentId": null,
"sort": 2,
"permission": "school:read",
"children": [ "children": [
{ {
"name": "学校信息", "name": "学校信息",
@ -97,23 +191,20 @@
"name": "活动管理", "name": "活动管理",
"path": "/contests", "path": "/contests",
"icon": "TrophyOutlined", "icon": "TrophyOutlined",
"component": null, "sort": 6,
"parentId": null,
"sort": 3,
"permission": "contest:create",
"children": [ "children": [
{ {
"name": "活动列表", "name": "活动列表",
"path": "/contests", "path": "/contests/list",
"icon": "UnorderedListOutlined", "icon": "UnorderedListOutlined",
"component": "contests/Index", "component": "contests/Index",
"sort": 1, "sort": 1,
"permission": "contest:create" "permission": "contest:read"
}, },
{ {
"name": "评委管理", "name": "评委管理",
"path": "/contests/judges", "path": "/contests/judges",
"icon": "SolutionOutlined", "icon": "UserSwitchOutlined",
"component": "contests/judges/Index", "component": "contests/judges/Index",
"sort": 2, "sort": 2,
"permission": "judge:read" "permission": "judge:read"
@ -121,10 +212,10 @@
{ {
"name": "报名管理", "name": "报名管理",
"path": "/contests/registrations", "path": "/contests/registrations",
"icon": "UserAddOutlined", "icon": "FormOutlined",
"component": "contests/registrations/Index", "component": "contests/registrations/Index",
"sort": 3, "sort": 3,
"permission": "registration:approve" "permission": "contest:registration:read"
}, },
{ {
"name": "作品管理", "name": "作品管理",
@ -132,23 +223,23 @@
"icon": "FileTextOutlined", "icon": "FileTextOutlined",
"component": "contests/works/Index", "component": "contests/works/Index",
"sort": 4, "sort": 4,
"permission": "contest:read" "permission": "contest:work:read"
}, },
{ {
"name": "评审进度", "name": "评审进度",
"path": "/contests/review-progress", "path": "/contests/review-progress",
"icon": "AuditOutlined", "icon": "DashboardOutlined",
"component": "contests/reviews/Progress", "component": "contests/reviews/Progress",
"sort": 5, "sort": 5,
"permission": "review-rule:read" "permission": "review:progress:read"
}, },
{ {
"name": "评审规则", "name": "评审规则",
"path": "/contests/reviews", "path": "/contests/review-rules",
"icon": "CheckCircleOutlined", "icon": "SettingOutlined",
"component": "contests/reviews/Index", "component": "contests/ReviewRules",
"sort": 6, "sort": 6,
"permission": "review-rule:read" "permission": "review:rule:read"
}, },
{ {
"name": "成果发布", "name": "成果发布",
@ -156,61 +247,63 @@
"icon": "TrophyOutlined", "icon": "TrophyOutlined",
"component": "contests/results/Index", "component": "contests/results/Index",
"sort": 7, "sort": 7,
"permission": "contest:create" "permission": "result:read"
}, },
{ {
"name": "通知管理", "name": "活动公告",
"path": "/contests/notices", "path": "/contests/notices",
"icon": "BellOutlined", "icon": "NotificationOutlined",
"component": "contests/notices/Index", "component": "contests/notices/Index",
"sort": 8, "sort": 8,
"permission": "notice:create" "permission": "contest:notice:read"
} }
] ]
}, },
{ {
"name": "作业管理", "name": "机构管理",
"path": "/homework", "path": "/organization",
"icon": "FormOutlined", "icon": "BankOutlined",
"component": null, "sort": 7,
"parentId": null,
"sort": 4,
"permission": "homework:read",
"children": [ "children": [
{ {
"name": "作业列表", "name": "机构管理",
"path": "/homework", "path": "/system/tenants",
"icon": "FileTextOutlined", "icon": "UnorderedListOutlined",
"component": "homework/Index", "component": "system/tenants/Index",
"sort": 1, "sort": 1,
"permission": "homework:create" "permission": "tenant:read"
},
{
"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"
} }
] ]
}, },
{ {
"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", "path": "/system",
"icon": "SettingOutlined", "icon": "SettingOutlined",
"component": null,
"parentId": null,
"sort": 9, "sort": 9,
"permission": "user:read",
"children": [ "children": [
{ {
"name": "用户管理", "name": "用户管理",
@ -265,35 +358,14 @@
"path": "/system/permissions", "path": "/system/permissions",
"icon": "SafetyOutlined", "icon": "SafetyOutlined",
"component": "system/permissions/Index", "component": "system/permissions/Index",
"sort": 7, "sort": 7
"permission": "permission:read"
}, },
{ {
"name": "租户管理", "name": "租户管理",
"path": "/system/tenants", "path": "/system/tenants",
"icon": "TeamOutlined", "icon": "BankOutlined",
"component": "system/tenants/Index", "component": "system/tenants/Index",
"sort": 8, "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"
} }
] ]
} }

View File

@ -44,17 +44,17 @@ if (!fs.existsSync(menusFilePath)) {
const menus = JSON.parse(fs.readFileSync(menusFilePath, 'utf-8')); 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_SYSTEM_MENUS = ['租户管理', '数据字典', '系统配置', '日志记录', '菜单管理', '权限管理'];
// 普通租户在我的评审下排除的子菜单(只保留活动列表 // 普通租户在我的评审下排除的子菜单(只保留评审任务
const NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS = ['我的报名', '我的作品']; const NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS = ['预设评语'];
async function initMenus() { async function initMenus() {
try { try {
@ -215,13 +215,13 @@ async function initMenus() {
if (menu.parentId) { if (menu.parentId) {
const parentMenu = allMenus.find(m => m.id === menu.parentId); const parentMenu = allMenus.find(m => m.id === menu.parentId);
if (parentMenu && NORMAL_TENANT_MENUS.includes(parentMenu.name)) { if (parentMenu && NORMAL_TENANT_MENUS.includes(parentMenu.name)) {
// 系统管理下排除部分子菜单 // 系统设置下排除部分子菜单
if (parentMenu.name === '系统管理' && NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS.includes(menu.name)) { if (parentMenu.name === '系统设置' && NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS.includes(menu.name)) {
continue; // 跳过排除的菜单 continue;
} }
// 我的评审下排除部分子菜单(只保留活动列表) // 我的评审下排除部分子菜单
if (parentMenu.name === '我的评审' && NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS.includes(menu.name)) { if (parentMenu.name === '我的评审' && NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS.includes(menu.name)) {
continue; // 跳过排除的菜单 continue;
} }
normalTenantMenuIds.add(menu.id); normalTenantMenuIds.add(menu.id);
} }

View File

@ -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<number, { liked: boolean; favorited: boolean }> = {};
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('作品不存在或未发布');
}
}

View File

@ -17,6 +17,7 @@ import { UserWorksService } from './user-works.service';
import { CreationService } from './creation.service'; import { CreationService } from './creation.service';
import { TagsService } from './tags.service'; import { TagsService } from './tags.service';
import { GalleryService } from './gallery.service'; import { GalleryService } from './gallery.service';
import { InteractionService } from './interaction.service';
import { PublicRegisterDto, PublicLoginDto } from './dto/register.dto'; import { PublicRegisterDto, PublicLoginDto } from './dto/register.dto';
import { CreateChildDto, UpdateChildDto } from './dto/child.dto'; import { CreateChildDto, UpdateChildDto } from './dto/child.dto';
import { PublicRegisterActivityDto } from './dto/registration.dto'; import { PublicRegisterActivityDto } from './dto/registration.dto';
@ -30,6 +31,7 @@ export class PublicController {
private readonly creationService: CreationService, private readonly creationService: CreationService,
private readonly tagsService: TagsService, private readonly tagsService: TagsService,
private readonly galleryService: GalleryService, private readonly galleryService: GalleryService,
private readonly interactionService: InteractionService,
) {} ) {}
// ==================== 注册 & 登录(公开接口) ==================== // ==================== 注册 & 登录(公开接口) ====================
@ -423,4 +425,43 @@ export class PublicController {
pageSize: pageSize ? parseInt(pageSize) : 12, 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,
});
}
} }

View File

@ -10,6 +10,7 @@ import { CreationService } from './creation.service';
import { TagsService } from './tags.service'; import { TagsService } from './tags.service';
import { GalleryService } from './gallery.service'; import { GalleryService } from './gallery.service';
import { ContentReviewService } from './content-review.service'; import { ContentReviewService } from './content-review.service';
import { InteractionService } from './interaction.service';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
@Module({ @Module({
@ -24,7 +25,7 @@ import { PrismaModule } from '../prisma/prisma.module';
}), }),
], ],
controllers: [PublicController, TagsController, ContentReviewController], controllers: [PublicController, TagsController, ContentReviewController],
providers: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService], providers: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService, InteractionService],
exports: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService], exports: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService, InteractionService],
}) })
export class PublicModule {} export class PublicModule {}

View File

@ -629,6 +629,16 @@ export class PublicService {
child: { child: {
select: { id: true, name: true }, select: { id: true, name: true },
}, },
works: {
where: { validState: 1 },
select: {
id: true,
title: true,
previewUrl: true,
createTime: true,
},
orderBy: { createTime: 'desc' },
},
}, },
}); });
return registration; return registration;
@ -902,6 +912,25 @@ export class PublicService {
grade: true, 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 }), this.prisma.contestRegistration.count({ where }),

View File

@ -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 }),
}
```
## 交互细节
- 未登录用户点击点赞/收藏 → 跳转登录页
- 乐观更新:点击后立即更新 UIAPI 失败时回滚
- 点赞按钮动效:心形图标缩放弹跳
- 自己的作品也可以点赞/收藏(不做限制)

View File

@ -243,14 +243,26 @@ export const publicActivitiesApi = {
) => publicApi.post(`/public/activities/${id}/submit-work`, data), ) => publicApi.post(`/public/activities/${id}/submit-work`, data),
} }
// ==================== 我的报名 & 作品 ==================== // ==================== 我的报名 ====================
export const publicMineApi = { export const publicMineApi = {
registrations: (params?: { page?: number; pageSize?: number }) => registrations: (params?: { page?: number; pageSize?: number }) =>
publicApi.get("/public/mine/registrations", { params }), 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 }),
} }
// ==================== 用户作品库 ==================== // ==================== 用户作品库 ====================

View File

@ -7,6 +7,42 @@
<img src="@/assets/images/logo-icon.png" alt="乐绘世界" class="header-logo" /> <img src="@/assets/images/logo-icon.png" alt="乐绘世界" class="header-logo" />
<span class="header-title">乐绘世界</span> <span class="header-title">乐绘世界</span>
</div> </div>
<!-- 桌面端导航菜单 -->
<nav class="header-nav">
<div
class="nav-item"
:class="{ active: currentTab === 'home' }"
@click="goHome"
>
<home-outlined />
<span>发现</span>
</div>
<div
class="nav-item"
:class="{ active: currentTab === 'activity' }"
@click="goActivity"
>
<trophy-outlined />
<span>活动</span>
</div>
<div
class="nav-item"
:class="{ active: currentTab === 'create' }"
@click="goCreate"
>
<plus-circle-outlined />
<span>创作</span>
</div>
<div
class="nav-item"
:class="{ active: currentTab === 'works' }"
@click="goWorks"
>
<appstore-outlined />
<span>作品库</span>
</div>
</nav>
<div class="header-actions"> <div class="header-actions">
<template v-if="isLoggedIn"> <template v-if="isLoggedIn">
<div class="user-menu" @click="goMine"> <div class="user-menu" @click="goMine">
@ -170,6 +206,45 @@ $primary: #6366f1;
} }
} }
// ========== ==========
.header-nav {
display: none;
align-items: center;
gap: 4px;
.nav-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
border-radius: 20px;
font-size: 14px;
color: #6b7280;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
:deep(.anticon) {
font-size: 15px;
}
span {
font-weight: 500;
}
&:hover {
color: $primary;
background: rgba($primary, 0.06);
}
&.active {
color: $primary;
background: rgba($primary, 0.08);
font-weight: 600;
}
}
}
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@ -245,6 +320,10 @@ $primary: #6366f1;
// ========== ========== // ========== ==========
@media (min-width: 768px) { @media (min-width: 768px) {
.header-nav {
display: flex;
}
.public-tabbar { .public-tabbar {
display: none; display: none;
} }

View File

@ -67,10 +67,10 @@ const baseRoutes: RouteRecordRaw[] = [
meta: { title: "我的报名" }, meta: { title: "我的报名" },
}, },
{ {
path: "mine/works", path: "mine/favorites",
name: "PublicMyWorks", name: "PublicMyFavorites",
component: () => import("@/views/public/mine/Works.vue"), component: () => import("@/views/public/mine/Favorites.vue"),
meta: { title: "我的作品" }, meta: { title: "我的收藏" },
}, },
{ {
path: "mine/children", path: "mine/children",

View File

@ -288,11 +288,8 @@ const checkRegistrationStatus = async () => {
if (reg) { if (reg) {
hasRegistered.value = true hasRegistered.value = true
myRegistration.value = reg myRegistration.value = reg
// // works
const worksRes = await import('@/api/public').then(m => m.publicMineApi.works({ page: 1, pageSize: 100 })) hasSubmittedWork.value = reg.works && reg.works.length > 0
if (worksRes?.list) {
hasSubmittedWork.value = worksRes.list.some((w: any) => w.contest?.id === activity.value.id)
}
} }
} catch { /* not registered */ } } catch { /* not registered */ }
} }

View File

@ -67,7 +67,14 @@
<span>{{ work.creator?.nickname }}</span> <span>{{ work.creator?.nickname }}</span>
</div> </div>
<div class="card-stats"> <div class="card-stats">
<span><heart-outlined /> {{ work.likeCount || 0 }}</span> <span
:class="['like-btn', { liked: likedSet.has(work.id) }]"
@click.stop="handleLike(work)"
>
<heart-filled v-if="likedSet.has(work.id)" />
<heart-outlined v-else />
{{ work.likeCount || 0 }}
</span>
<span><eye-outlined /> {{ work.viewCount || 0 }}</span> <span><eye-outlined /> {{ work.viewCount || 0 }}</span>
</div> </div>
</div> </div>
@ -82,10 +89,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { SearchOutlined, PictureOutlined, HeartOutlined, EyeOutlined } from '@ant-design/icons-vue' import { useRouter } from 'vue-router'
import { publicGalleryApi, publicTagsApi, type UserWork, type WorkTag } from '@/api/public' import { message } from 'ant-design-vue'
import { SearchOutlined, PictureOutlined, HeartOutlined, HeartFilled, EyeOutlined } from '@ant-design/icons-vue'
import { publicGalleryApi, publicTagsApi, publicInteractionApi, type UserWork, type WorkTag } from '@/api/public'
const router = useRouter()
const works = ref<UserWork[]>([]) const works = ref<UserWork[]>([])
const hotTags = ref<WorkTag[]>([]) const hotTags = ref<WorkTag[]>([])
const loading = ref(false) const loading = ref(false)
@ -95,7 +105,9 @@ const sortBy = ref('latest')
const page = ref(1) const page = ref(1)
const total = ref(0) const total = ref(0)
const pageSize = 12 const pageSize = 12
const likedSet = reactive(new Set<number>())
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
const hasMore = computed(() => works.value.length < total.value) const hasMore = computed(() => works.value.length < total.value)
const fetchTags = async () => { const fetchTags = async () => {
@ -103,7 +115,7 @@ const fetchTags = async () => {
} }
const fetchWorks = async (reset = false) => { const fetchWorks = async (reset = false) => {
if (reset) { page.value = 1; works.value = [] } if (reset) { page.value = 1; works.value = []; likedSet.clear() }
loading.value = true loading.value = true
try { try {
const res = await publicGalleryApi.list({ const res = await publicGalleryApi.list({
@ -119,10 +131,38 @@ const fetchWorks = async (reset = false) => {
works.value.push(...res.list) works.value.push(...res.list)
} }
total.value = res.total total.value = res.total
//
if (isLoggedIn.value && res.list.length > 0) {
try {
const ids = res.list.map((w: any) => w.id)
const statuses = await publicInteractionApi.batchStatus(ids)
for (const [id, status] of Object.entries(statuses)) {
if ((status as any).liked) likedSet.add(Number(id))
}
} catch { /* 忽略 */ }
}
} catch { /* */ } } catch { /* */ }
finally { loading.value = false } finally { loading.value = false }
} }
const handleLike = async (work: any) => {
if (!isLoggedIn.value) { router.push('/p/login'); return }
const wasLiked = likedSet.has(work.id)
//
if (wasLiked) { likedSet.delete(work.id) } else { likedSet.add(work.id) }
work.likeCount = (work.likeCount || 0) + (wasLiked ? -1 : 1)
try {
const res = await publicInteractionApi.like(work.id)
if (res.liked) { likedSet.add(work.id) } else { likedSet.delete(work.id) }
work.likeCount = res.likeCount
} catch {
//
if (wasLiked) { likedSet.add(work.id) } else { likedSet.delete(work.id) }
work.likeCount = (work.likeCount || 0) + (wasLiked ? 1 : -1)
message.error('操作失败')
}
}
const handleSearch = () => fetchWorks(true) const handleSearch = () => fetchWorks(true)
const selectTag = (tagId: number) => { const selectTag = (tagId: number) => {
selectedTagId.value = selectedTagId.value === tagId ? null : tagId selectedTagId.value = selectedTagId.value === tagId ? null : tagId
@ -245,6 +285,16 @@ $primary: #6366f1;
.card-stats { .card-stats {
display: flex; gap: 12px; display: flex; gap: 12px;
span { font-size: 11px; color: #9ca3af; display: flex; align-items: center; gap: 3px; } span { font-size: 11px; color: #9ca3af; display: flex; align-items: center; gap: 3px; }
.like-btn {
cursor: pointer;
transition: color 0.2s;
&:hover { color: #ec4899; }
&.liked {
color: #ec4899;
:deep(.anticon) { animation: pop 0.3s ease; }
}
}
} }
} }
} }
@ -253,4 +303,10 @@ $primary: #6366f1;
text-align: center; text-align: center;
padding: 20px 0; padding: 20px 0;
} }
@keyframes pop {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}
</style> </style>

View File

@ -0,0 +1,138 @@
<template>
<div class="favorites-page">
<div class="page-header">
<h2>我的收藏</h2>
</div>
<div v-if="loading" class="loading-wrap"><a-spin /></div>
<div v-else-if="list.length === 0" class="empty-wrap">
<a-empty description="还没有收藏任何作品">
<a-button type="primary" shape="round" @click="$router.push('/p/gallery')">
去发现作品
</a-button>
</a-empty>
</div>
<div v-else class="works-grid">
<div
v-for="item in list"
:key="item.id"
class="work-card"
@click="$router.push(`/p/works/${item.work.id}`)"
>
<div class="card-cover">
<img v-if="item.work.coverUrl" :src="item.work.coverUrl" :alt="item.work.title" />
<div v-else class="cover-placeholder">
<picture-outlined />
</div>
</div>
<div class="card-body">
<h3>{{ item.work.title }}</h3>
<div class="card-author">
<a-avatar :size="20" :src="item.work.creator?.avatar">
{{ item.work.creator?.nickname?.charAt(0) }}
</a-avatar>
<span>{{ item.work.creator?.nickname }}</span>
</div>
<div class="card-stats">
<span><heart-outlined /> {{ item.work.likeCount || 0 }}</span>
<span><eye-outlined /> {{ item.work.viewCount || 0 }}</span>
</div>
</div>
</div>
</div>
<div v-if="total > pageSize" class="pagination-wrap">
<a-pagination
v-model:current="page"
:total="total"
:page-size="pageSize"
simple
@change="fetchList"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PictureOutlined, HeartOutlined, EyeOutlined } from '@ant-design/icons-vue'
import { publicInteractionApi } from '@/api/public'
const list = ref<any[]>([])
const loading = ref(true)
const page = ref(1)
const pageSize = 12
const total = ref(0)
const fetchList = async () => {
loading.value = true
try {
const res = await publicInteractionApi.myFavorites({ page: page.value, pageSize })
list.value = res.list
total.value = res.total
} catch {
message.error('获取收藏列表失败')
} finally {
loading.value = false
}
}
onMounted(fetchList)
</script>
<style scoped lang="scss">
$primary: #6366f1;
.favorites-page { max-width: 700px; margin: 0 auto; }
.page-header {
margin-bottom: 16px;
h2 { font-size: 20px; font-weight: 700; color: #1e1b4b; margin: 0; }
}
.loading-wrap, .empty-wrap { padding: 60px 0; display: flex; justify-content: center; }
.works-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
@media (min-width: 640px) { grid-template-columns: repeat(3, 1fr); }
}
.work-card {
background: #fff;
border-radius: 14px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
border: 1px solid rgba($primary, 0.04);
&:hover { box-shadow: 0 4px 20px rgba($primary, 0.1); transform: translateY(-2px); }
.card-cover {
aspect-ratio: 3/4;
background: #f5f3ff;
img { width: 100%; height: 100%; object-fit: cover; }
.cover-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 28px; color: #d1d5db; }
}
.card-body {
padding: 10px 12px;
h3 { font-size: 13px; font-weight: 600; color: #1e1b4b; margin: 0 0 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-author {
display: flex; align-items: center; gap: 6px; margin-bottom: 6px;
span { font-size: 11px; color: #6b7280; }
}
.card-stats {
display: flex; gap: 12px;
span { font-size: 11px; color: #9ca3af; display: flex; align-items: center; gap: 3px; }
}
}
}
.pagination-wrap { display: flex; justify-content: center; padding: 24px 0; }
</style>

View File

@ -33,13 +33,13 @@
<right-outlined class="menu-arrow" /> <right-outlined class="menu-arrow" />
</div> </div>
<div class="menu-item" @click="$router.push('/p/mine/works')"> <div class="menu-item" @click="$router.push('/p/mine/favorites')">
<div class="menu-icon" style="background: #fdf2f8; color: #ec4899"> <div class="menu-icon" style="background: #fef3c7; color: #f59e0b">
<picture-outlined /> <star-outlined />
</div> </div>
<div class="menu-content"> <div class="menu-content">
<span class="menu-label">我的作品</span> <span class="menu-label">我的收藏</span>
<span class="menu-desc">{{ workCount > 0 ? `${workCount} 个作品` : '管理提交的作品' }}</span> <span class="menu-desc">收藏的绘本作品</span>
</div> </div>
<right-outlined class="menu-arrow" /> <right-outlined class="menu-arrow" />
</div> </div>
@ -98,7 +98,7 @@ import { useRouter } from "vue-router"
import { message } from "ant-design-vue" import { message } from "ant-design-vue"
import { import {
FileTextOutlined, FileTextOutlined,
PictureOutlined, StarOutlined,
TeamOutlined, TeamOutlined,
RightOutlined, RightOutlined,
LogoutOutlined, LogoutOutlined,
@ -111,7 +111,6 @@ const user = ref<any>(null)
const showEditModal = ref(false) const showEditModal = ref(false)
const editLoading = ref(false) const editLoading = ref(false)
const regCount = ref(0) const regCount = ref(0)
const workCount = ref(0)
// //
const isChildMode = computed(() => { const isChildMode = computed(() => {
@ -161,12 +160,8 @@ const fetchProfile = async () => {
const fetchCounts = async () => { const fetchCounts = async () => {
try { try {
const [regs, works] = await Promise.all([ const regs = await publicMineApi.registrations({ page: 1, pageSize: 1 })
publicMineApi.registrations({ page: 1, pageSize: 1 }),
publicMineApi.works({ page: 1, pageSize: 1 }),
])
regCount.value = regs?.total || 0 regCount.value = regs?.total || 0
workCount.value = works?.total || 0
} catch { /* ignore */ } } catch { /* ignore */ }
} }

View File

@ -15,34 +15,65 @@
</div> </div>
<div v-else class="reg-list"> <div v-else class="reg-list">
<div v-for="item in list" :key="item.id" class="reg-card" @click="goDetail(item.contest?.id)"> <div v-for="item in list" :key="item.id" class="reg-card">
<div class="reg-cover"> <!-- 报名信息区 -->
<img v-if="item.contest?.coverUrl" :src="item.contest.coverUrl" /> <div class="reg-main" @click="goDetail(item.contest?.id)">
<div v-else class="cover-placeholder"> <div class="reg-cover">
{{ item.contest?.contestName?.charAt(0) }} <img v-if="item.contest?.coverUrl" :src="item.contest.coverUrl" />
<div v-else class="cover-placeholder">
{{ item.contest?.contestName?.charAt(0) }}
</div>
</div>
<div class="reg-info">
<h3>{{ item.contest?.contestName }}</h3>
<div class="reg-meta">
<a-tag :color="statusColor(item.registrationState)">
{{ statusLabel(item.registrationState) }}
</a-tag>
<span class="participant-label">
{{ item.participantType === 'child' ? `子女:${item.child?.name}` : '本人参与' }}
</span>
</div>
<div class="reg-bottom">
<span class="reg-time">{{ formatDate(item.registrationTime) }}</span>
<a-button
v-if="item.registrationState === 'passed' && isInSubmitPhase(item.contest) && (!item.works || item.works.length === 0)"
type="primary"
size="small"
shape="round"
@click.stop="goSubmit(item.contest?.id)"
>
提交作品
</a-button>
</div>
</div> </div>
</div> </div>
<div class="reg-info">
<h3>{{ item.contest?.contestName }}</h3> <!-- 参赛作品区 -->
<div class="reg-meta"> <div v-if="item.works && item.works.length > 0" class="work-section">
<a-tag :color="statusColor(item.registrationState)"> <div class="work-section-title">
{{ statusLabel(item.registrationState) }} <picture-outlined />
</a-tag> <span>参赛作品</span>
<span class="participant-label">
{{ item.participantType === 'child' ? `子女:${item.child?.name}` : '本人参与' }}
</span>
</div> </div>
<div class="reg-bottom"> <div
<span class="reg-time">{{ formatDate(item.registrationTime) }}</span> v-for="work in item.works"
<a-button :key="work.id"
v-if="item.registrationState === 'passed' && isInSubmitPhase(item.contest)" class="work-item"
type="primary" @click.stop="goDetail(item.contest?.id)"
size="small" >
shape="round" <div class="work-thumb">
@click.stop="goSubmit(item.contest?.id)" <img v-if="work.previewUrl" :src="work.previewUrl" />
> <div v-else-if="work.attachments?.[0]?.fileUrl" class="thumb-file">
提交作品 <file-image-outlined />
</a-button> </div>
<div v-else class="thumb-empty">
<picture-outlined />
</div>
</div>
<div class="work-detail">
<span class="work-title">{{ work.title || '未命名作品' }}</span>
<span class="work-time">提交于 {{ formatDate(work.createTime) }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -63,6 +94,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { PictureOutlined, FileImageOutlined } from '@ant-design/icons-vue'
import { publicMineApi } from '@/api/public' import { publicMineApi } from '@/api/public'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -128,16 +160,20 @@ $primary: #6366f1;
.reg-list { display: flex; flex-direction: column; gap: 12px; } .reg-list { display: flex; flex-direction: column; gap: 12px; }
.reg-card { .reg-card {
display: flex;
gap: 14px;
padding: 16px;
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba($primary, 0.06); border: 1px solid rgba($primary, 0.06);
cursor: pointer; overflow: hidden;
transition: all 0.2s; transition: all 0.2s;
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.08); transform: translateY(-1px); } &:hover { box-shadow: 0 4px 16px rgba($primary, 0.08); transform: translateY(-1px); }
}
.reg-main {
display: flex;
gap: 14px;
padding: 16px;
cursor: pointer;
.reg-cover { .reg-cover {
width: 80px; height: 60px; border-radius: 10px; overflow: hidden; flex-shrink: 0; width: 80px; height: 60px; border-radius: 10px; overflow: hidden; flex-shrink: 0;
@ -160,5 +196,66 @@ $primary: #6366f1;
} }
} }
// ========== ==========
.work-section {
border-top: 1px dashed rgba($primary, 0.08);
padding: 12px 16px;
background: rgba($primary, 0.015);
.work-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: #6b7280;
margin-bottom: 10px;
:deep(.anticon) {
font-size: 13px;
color: $primary;
}
}
}
.work-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: #fff;
border-radius: 10px;
cursor: pointer;
transition: background 0.2s;
&:hover { background: rgba($primary, 0.04); }
& + .work-item { margin-top: 8px; }
.work-thumb {
width: 44px; height: 44px; border-radius: 8px; overflow: hidden; flex-shrink: 0;
background: #f5f3ff;
img { width: 100%; height: 100%; object-fit: cover; }
.thumb-file, .thumb-empty {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
font-size: 18px; color: #c7d2fe;
}
}
.work-detail {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: 2px;
.work-title {
font-size: 13px; font-weight: 500; color: #1e1b4b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.work-time { font-size: 11px; color: #9ca3af; }
}
}
.pagination-wrap { display: flex; justify-content: center; margin-top: 24px; } .pagination-wrap { display: flex; justify-content: center; margin-top: 24px; }
</style> </style>

View File

@ -1,123 +0,0 @@
<template>
<div class="works-page">
<div class="page-header">
<h2>我的作品</h2>
</div>
<div v-if="loading" class="loading-wrap"><a-spin /></div>
<div v-else-if="list.length === 0" class="empty-wrap">
<a-empty description="还没有提交过作品" />
</div>
<div v-else class="works-grid">
<div v-for="item in list" :key="item.id" class="work-card">
<div class="work-cover">
<img v-if="item.coverUrl" :src="item.coverUrl" />
<div v-else class="cover-placeholder">
<picture-outlined style="font-size: 32px" />
</div>
</div>
<div class="work-body">
<h3>{{ item.title || '未命名作品' }}</h3>
<p class="work-contest">{{ item.contest?.contestName }}</p>
<div class="work-meta">
<span v-if="item.registration?.participantType === 'child'">
创作者{{ item.registration?.child?.name }}
</span>
<span class="work-time">{{ formatDate(item.createTime) }}</span>
</div>
</div>
</div>
</div>
<div v-if="total > pageSize" class="pagination-wrap">
<a-pagination v-model:current="page" :total="total" :page-size="pageSize" size="small" @change="fetchList" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { PictureOutlined } from '@ant-design/icons-vue'
import { publicMineApi } from '@/api/public'
import dayjs from 'dayjs'
const list = ref<any[]>([])
const loading = ref(true)
const page = ref(1)
const pageSize = ref(12)
const total = ref(0)
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
const fetchList = async () => {
loading.value = true
try {
const res = await publicMineApi.works({ page: page.value, pageSize: pageSize.value })
list.value = res.list
total.value = res.total
} catch { /* */ } finally {
loading.value = false
}
}
onMounted(fetchList)
</script>
<style scoped lang="scss">
$primary: #6366f1;
.works-page { max-width: 800px; margin: 0 auto; }
.page-header {
margin-bottom: 20px;
h2 { font-size: 20px; font-weight: 700; color: #1e1b4b; margin: 0; }
}
.loading-wrap, .empty-wrap { padding: 60px 0; display: flex; justify-content: center; }
.works-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.work-card {
background: #fff;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba($primary, 0.06);
transition: all 0.2s;
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.08); transform: translateY(-2px); }
.work-cover {
height: 140px; overflow: hidden;
img { width: 100%; height: 100%; object-fit: cover; }
.cover-placeholder {
width: 100%; height: 100%;
background: #f5f3ff;
display: flex; align-items: center; justify-content: center;
color: #c7d2fe;
}
}
.work-body {
padding: 14px 16px;
h3 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.work-contest { font-size: 12px; color: #9ca3af; margin: 0 0 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.work-meta {
display: flex; justify-content: space-between; align-items: center;
span { font-size: 11px; color: #9ca3af; }
}
}
}
.pagination-wrap { display: flex; justify-content: center; margin-top: 24px; }
@media (max-width: 640px) {
.works-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
.work-card .work-cover { height: 110px; }
}
</style>

View File

@ -54,10 +54,29 @@
<div v-if="work.tags?.length" class="tags-row"> <div v-if="work.tags?.length" class="tags-row">
<a-tag v-for="t in work.tags" :key="t.tag.id" color="purple">{{ t.tag.name }}</a-tag> <a-tag v-for="t in work.tags" :key="t.tag.id" color="purple">{{ t.tag.name }}</a-tag>
</div> </div>
<div class="stats-row"> </div>
<span>{{ work.viewCount || 0 }} 浏览</span>
<span>{{ work.likeCount || 0 }} 点赞</span> <!-- 互动栏 -->
<span>{{ work.favoriteCount || 0 }} 收藏</span> <div class="interaction-bar">
<div
:class="['action-btn', { active: interaction.liked }]"
@click="handleLike"
>
<heart-filled v-if="interaction.liked" />
<heart-outlined v-else />
<span>{{ displayLikeCount }}</span>
</div>
<div
:class="['action-btn', { active: interaction.favorited }]"
@click="handleFavorite"
>
<star-filled v-if="interaction.favorited" />
<star-outlined v-else />
<span>{{ displayFavoriteCount }}</span>
</div>
<div class="action-btn">
<eye-outlined />
<span>{{ work.viewCount || 0 }}</span>
</div> </div>
</div> </div>
</template> </template>
@ -69,27 +88,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { ArrowLeftOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue' import {
import { publicUserWorksApi, type UserWork } from '@/api/public' ArrowLeftOutlined, LeftOutlined, RightOutlined,
HeartOutlined, HeartFilled, StarOutlined, StarFilled, EyeOutlined,
} from '@ant-design/icons-vue'
import { publicUserWorksApi, publicGalleryApi, publicInteractionApi, type UserWork } from '@/api/public'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const route = useRoute() const route = useRoute()
const router = useRouter()
const workId = Number(route.params.id) const workId = Number(route.params.id)
const work = ref<UserWork | null>(null) const work = ref<UserWork | null>(null)
const loading = ref(true) const loading = ref(true)
const currentPageIndex = ref(0) const currentPageIndex = ref(0)
const interaction = ref({ liked: false, favorited: false })
const actionLoading = ref(false)
const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.value] || null) const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.value] || null)
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
const isOwner = computed(() => { const isOwner = computed(() => {
const u = localStorage.getItem('public_user') const u = localStorage.getItem('public_user')
if (!u || !work.value) return false if (!u || !work.value) return false
try { return JSON.parse(u).id === work.value.userId } catch { return false } try { return JSON.parse(u).id === work.value.userId } catch { return false }
}) })
const displayLikeCount = computed(() => work.value?.likeCount || 0)
const displayFavoriteCount = computed(() => work.value?.favoriteCount || 0)
const statusTextMap: Record<string, string> = { const statusTextMap: Record<string, string> = {
draft: '草稿', pending_review: '审核中', published: '已发布', rejected: '被拒绝', taken_down: '已下架', draft: '草稿', pending_review: '审核中', published: '已发布', rejected: '被拒绝', taken_down: '已下架',
} }
@ -102,10 +132,63 @@ const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
const prevPage = () => { if (currentPageIndex.value > 0) currentPageIndex.value-- } const prevPage = () => { if (currentPageIndex.value > 0) currentPageIndex.value-- }
const nextPage = () => { if (work.value?.pages && currentPageIndex.value < work.value.pages.length - 1) currentPageIndex.value++ } const nextPage = () => { if (work.value?.pages && currentPageIndex.value < work.value.pages.length - 1) currentPageIndex.value++ }
const handleLike = async () => {
if (!isLoggedIn.value) { router.push('/p/login'); return }
if (actionLoading.value) return
actionLoading.value = true
//
const wasLiked = interaction.value.liked
interaction.value.liked = !wasLiked
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? -1 : 1)
try {
const res = await publicInteractionApi.like(workId)
interaction.value.liked = res.liked
if (work.value) work.value.likeCount = res.likeCount
} catch {
//
interaction.value.liked = wasLiked
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? 1 : -1)
message.error('操作失败')
} finally {
actionLoading.value = false
}
}
const handleFavorite = async () => {
if (!isLoggedIn.value) { router.push('/p/login'); return }
if (actionLoading.value) return
actionLoading.value = true
const wasFavorited = interaction.value.favorited
interaction.value.favorited = !wasFavorited
if (work.value) work.value.favoriteCount = (work.value.favoriteCount || 0) + (wasFavorited ? -1 : 1)
try {
const res = await publicInteractionApi.favorite(workId)
interaction.value.favorited = res.favorited
if (work.value) work.value.favoriteCount = res.favoriteCount
} catch {
interaction.value.favorited = wasFavorited
if (work.value) work.value.favoriteCount = (work.value.favoriteCount || 0) + (wasFavorited ? 1 : -1)
message.error('操作失败')
} finally {
actionLoading.value = false
}
}
const fetchWork = async () => { const fetchWork = async () => {
loading.value = true loading.value = true
try { try {
work.value = await publicUserWorksApi.detail(workId) // 广
try {
work.value = await publicGalleryApi.detail(workId)
} catch {
work.value = await publicUserWorksApi.detail(workId)
}
//
if (isLoggedIn.value) {
try {
interaction.value = await publicInteractionApi.getInteraction(workId)
} catch { /* 忽略 */ }
}
} catch { } catch {
message.error('获取作品详情失败') message.error('获取作品详情失败')
} finally { } finally {
@ -187,11 +270,59 @@ $primary: #6366f1;
} }
.description { font-size: 13px; color: #4b5563; line-height: 1.6; margin-bottom: 10px; } .description { font-size: 13px; color: #4b5563; line-height: 1.6; margin-bottom: 10px; }
.tags-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; } .tags-row { display: flex; gap: 6px; flex-wrap: wrap; }
.stats-row { }
// ========== ==========
.interaction-bar {
display: flex;
justify-content: space-around;
margin-top: 16px;
background: #fff;
border-radius: 16px;
padding: 14px 0;
border: 1px solid rgba($primary, 0.06);
.action-btn {
display: flex; display: flex;
gap: 16px; align-items: center;
span { font-size: 12px; color: #9ca3af; } gap: 6px;
padding: 8px 20px;
border-radius: 24px;
font-size: 18px;
color: #9ca3af;
cursor: pointer;
transition: all 0.2s;
user-select: none;
span {
font-size: 13px;
font-weight: 500;
}
&:hover {
background: rgba($primary, 0.04);
color: #6b7280;
}
&.active {
color: #ec4899;
&:hover {
background: rgba(236, 72, 153, 0.06);
}
}
//
&.active :deep(.anticon) {
animation: pop 0.3s ease;
}
} }
} }
@keyframes pop {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}
</style> </style>

View File

@ -25,6 +25,7 @@ export default defineConfig(({ mode }) => {
}, },
}, },
server: { server: {
host: '0.0.0.0',
port: 3000, port: 3000,
proxy: { proxy: {
"/api": { "/api": {