Day5: 公众端响应式修复 + 点赞收藏功能 + 报名作品合并 + 菜单同步
- 公众端桌面端新增顶部导航菜单,修复横屏模式菜单消失问题 - 实现点赞/收藏 toggle API(含批量状态查询、我的收藏列表) - 作品详情页新增互动栏(点赞/收藏按钮,乐观更新+动效) - 广场卡片支持点赞交互 - 报名列表合并展示参赛作品,移除独立的「我的作品」页面 - 个人中心新增「我的收藏」入口 - menus.json 与数据库完整同步,更新初始化脚本租户分配逻辑 - Vite 开启局域网访问 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4466e28b3b
commit
66827c0199
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
173
backend/src/public/interaction.service.ts
Normal file
173
backend/src/public/interaction.service.ts
Normal 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('作品不存在或未发布');
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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 }),
|
||||
|
||||
117
docs/design/public/like-favorite.md
Normal file
117
docs/design/public/like-favorite.md
Normal 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 }),
|
||||
}
|
||||
```
|
||||
|
||||
## 交互细节
|
||||
|
||||
- 未登录用户点击点赞/收藏 → 跳转登录页
|
||||
- 乐观更新:点击后立即更新 UI,API 失败时回滚
|
||||
- 点赞按钮动效:心形图标缩放弹跳
|
||||
- 自己的作品也可以点赞/收藏(不做限制)
|
||||
@ -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 }),
|
||||
}
|
||||
|
||||
// ==================== 用户作品库 ====================
|
||||
|
||||
@ -7,6 +7,42 @@
|
||||
<img src="@/assets/images/logo-icon.png" alt="乐绘世界" class="header-logo" />
|
||||
<span class="header-title">乐绘世界</span>
|
||||
</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">
|
||||
<template v-if="isLoggedIn">
|
||||
<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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -245,6 +320,10 @@ $primary: #6366f1;
|
||||
|
||||
// ========== 响应式 ==========
|
||||
@media (min-width: 768px) {
|
||||
.header-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.public-tabbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -67,10 +67,10 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
meta: { title: "我的报名" },
|
||||
},
|
||||
{
|
||||
path: "mine/works",
|
||||
name: "PublicMyWorks",
|
||||
component: () => import("@/views/public/mine/Works.vue"),
|
||||
meta: { title: "我的作品" },
|
||||
path: "mine/favorites",
|
||||
name: "PublicMyFavorites",
|
||||
component: () => import("@/views/public/mine/Favorites.vue"),
|
||||
meta: { title: "我的收藏" },
|
||||
},
|
||||
{
|
||||
path: "mine/children",
|
||||
|
||||
@ -288,11 +288,8 @@ const checkRegistrationStatus = async () => {
|
||||
if (reg) {
|
||||
hasRegistered.value = true
|
||||
myRegistration.value = reg
|
||||
// 检查是否已提交作品
|
||||
const worksRes = await import('@/api/public').then(m => m.publicMineApi.works({ page: 1, pageSize: 100 }))
|
||||
if (worksRes?.list) {
|
||||
hasSubmittedWork.value = worksRes.list.some((w: any) => w.contest?.id === activity.value.id)
|
||||
}
|
||||
// 检查是否已提交作品(报名接口已包含关联的 works)
|
||||
hasSubmittedWork.value = reg.works && reg.works.length > 0
|
||||
}
|
||||
} catch { /* not registered */ }
|
||||
}
|
||||
|
||||
@ -67,7 +67,14 @@
|
||||
<span>{{ work.creator?.nickname }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -82,10 +89,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { SearchOutlined, PictureOutlined, HeartOutlined, EyeOutlined } from '@ant-design/icons-vue'
|
||||
import { publicGalleryApi, publicTagsApi, type UserWork, type WorkTag } from '@/api/public'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { SearchOutlined, PictureOutlined, HeartOutlined, HeartFilled, EyeOutlined } from '@ant-design/icons-vue'
|
||||
import { publicGalleryApi, publicTagsApi, publicInteractionApi, type UserWork, type WorkTag } from '@/api/public'
|
||||
|
||||
const router = useRouter()
|
||||
const works = ref<UserWork[]>([])
|
||||
const hotTags = ref<WorkTag[]>([])
|
||||
const loading = ref(false)
|
||||
@ -95,7 +105,9 @@ const sortBy = ref('latest')
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
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 fetchTags = async () => {
|
||||
@ -103,7 +115,7 @@ const fetchTags = async () => {
|
||||
}
|
||||
|
||||
const fetchWorks = async (reset = false) => {
|
||||
if (reset) { page.value = 1; works.value = [] }
|
||||
if (reset) { page.value = 1; works.value = []; likedSet.clear() }
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await publicGalleryApi.list({
|
||||
@ -119,10 +131,38 @@ const fetchWorks = async (reset = false) => {
|
||||
works.value.push(...res.list)
|
||||
}
|
||||
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 { /* */ }
|
||||
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 selectTag = (tagId: number) => {
|
||||
selectedTagId.value = selectedTagId.value === tagId ? null : tagId
|
||||
@ -245,6 +285,16 @@ $primary: #6366f1;
|
||||
.card-stats {
|
||||
display: flex; gap: 12px;
|
||||
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;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.3); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
|
||||
138
frontend/src/views/public/mine/Favorites.vue
Normal file
138
frontend/src/views/public/mine/Favorites.vue
Normal 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>
|
||||
@ -33,13 +33,13 @@
|
||||
<right-outlined class="menu-arrow" />
|
||||
</div>
|
||||
|
||||
<div class="menu-item" @click="$router.push('/p/mine/works')">
|
||||
<div class="menu-icon" style="background: #fdf2f8; color: #ec4899">
|
||||
<picture-outlined />
|
||||
<div class="menu-item" @click="$router.push('/p/mine/favorites')">
|
||||
<div class="menu-icon" style="background: #fef3c7; color: #f59e0b">
|
||||
<star-outlined />
|
||||
</div>
|
||||
<div class="menu-content">
|
||||
<span class="menu-label">我的作品</span>
|
||||
<span class="menu-desc">{{ workCount > 0 ? `${workCount} 个作品` : '管理提交的作品' }}</span>
|
||||
<span class="menu-label">我的收藏</span>
|
||||
<span class="menu-desc">收藏的绘本作品</span>
|
||||
</div>
|
||||
<right-outlined class="menu-arrow" />
|
||||
</div>
|
||||
@ -98,7 +98,7 @@ import { useRouter } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import {
|
||||
FileTextOutlined,
|
||||
PictureOutlined,
|
||||
StarOutlined,
|
||||
TeamOutlined,
|
||||
RightOutlined,
|
||||
LogoutOutlined,
|
||||
@ -111,7 +111,6 @@ const user = ref<any>(null)
|
||||
const showEditModal = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const regCount = ref(0)
|
||||
const workCount = ref(0)
|
||||
|
||||
// 判断当前是否子女身份
|
||||
const isChildMode = computed(() => {
|
||||
@ -161,12 +160,8 @@ const fetchProfile = async () => {
|
||||
|
||||
const fetchCounts = async () => {
|
||||
try {
|
||||
const [regs, works] = await Promise.all([
|
||||
publicMineApi.registrations({ page: 1, pageSize: 1 }),
|
||||
publicMineApi.works({ page: 1, pageSize: 1 }),
|
||||
])
|
||||
const regs = await publicMineApi.registrations({ page: 1, pageSize: 1 })
|
||||
regCount.value = regs?.total || 0
|
||||
workCount.value = works?.total || 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
|
||||
@ -15,34 +15,65 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="reg-list">
|
||||
<div v-for="item in list" :key="item.id" class="reg-card" @click="goDetail(item.contest?.id)">
|
||||
<div class="reg-cover">
|
||||
<img v-if="item.contest?.coverUrl" :src="item.contest.coverUrl" />
|
||||
<div v-else class="cover-placeholder">
|
||||
{{ item.contest?.contestName?.charAt(0) }}
|
||||
<div v-for="item in list" :key="item.id" class="reg-card">
|
||||
<!-- 报名信息区 -->
|
||||
<div class="reg-main" @click="goDetail(item.contest?.id)">
|
||||
<div class="reg-cover">
|
||||
<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 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 v-if="item.works && item.works.length > 0" class="work-section">
|
||||
<div class="work-section-title">
|
||||
<picture-outlined />
|
||||
<span>参赛作品</span>
|
||||
</div>
|
||||
<div class="reg-bottom">
|
||||
<span class="reg-time">{{ formatDate(item.registrationTime) }}</span>
|
||||
<a-button
|
||||
v-if="item.registrationState === 'passed' && isInSubmitPhase(item.contest)"
|
||||
type="primary"
|
||||
size="small"
|
||||
shape="round"
|
||||
@click.stop="goSubmit(item.contest?.id)"
|
||||
>
|
||||
提交作品
|
||||
</a-button>
|
||||
<div
|
||||
v-for="work in item.works"
|
||||
:key="work.id"
|
||||
class="work-item"
|
||||
@click.stop="goDetail(item.contest?.id)"
|
||||
>
|
||||
<div class="work-thumb">
|
||||
<img v-if="work.previewUrl" :src="work.previewUrl" />
|
||||
<div v-else-if="work.attachments?.[0]?.fileUrl" class="thumb-file">
|
||||
<file-image-outlined />
|
||||
</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>
|
||||
@ -63,6 +94,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { PictureOutlined, FileImageOutlined } from '@ant-design/icons-vue'
|
||||
import { publicMineApi } from '@/api/public'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
@ -128,16 +160,20 @@ $primary: #6366f1;
|
||||
.reg-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.reg-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba($primary, 0.06);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
|
||||
&: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 {
|
||||
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; }
|
||||
</style>
|
||||
|
||||
@ -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>
|
||||
@ -54,10 +54,29 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<span>{{ work.viewCount || 0 }} 浏览</span>
|
||||
<span>{{ work.likeCount || 0 }} 点赞</span>
|
||||
<span>{{ work.favoriteCount || 0 }} 收藏</span>
|
||||
</div>
|
||||
|
||||
<!-- 互动栏 -->
|
||||
<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>
|
||||
</template>
|
||||
@ -69,27 +88,38 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowLeftOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||||
import { publicUserWorksApi, type UserWork } from '@/api/public'
|
||||
import {
|
||||
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'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const workId = Number(route.params.id)
|
||||
|
||||
const work = ref<UserWork | null>(null)
|
||||
const loading = ref(true)
|
||||
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 isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
||||
|
||||
const isOwner = computed(() => {
|
||||
const u = localStorage.getItem('public_user')
|
||||
if (!u || !work.value) 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> = {
|
||||
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 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 () => {
|
||||
loading.value = true
|
||||
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 {
|
||||
message.error('获取作品详情失败')
|
||||
} finally {
|
||||
@ -187,11 +270,59 @@ $primary: #6366f1;
|
||||
}
|
||||
|
||||
.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; }
|
||||
.stats-row {
|
||||
.tags-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
// ========== 互动栏 ==========
|
||||
.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;
|
||||
gap: 16px;
|
||||
span { font-size: 12px; color: #9ca3af; }
|
||||
align-items: center;
|
||||
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>
|
||||
|
||||
@ -25,6 +25,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
"/api": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user