Compare commits

...

5 Commits

Author SHA1 Message Date
aid
9215465bd5 Day5: 租户端全面优化 + 数据统计看板 + 成果发布完善
租户端基础设施:
- 新增工作台首页(欢迎信息/统计/待办/快捷操作/新手引导)
- 新增机构信息管理页(自助查看编辑机构信息)
- 修复403报错(fetchTenants加超管守卫)
- 修复权限(log:read/notice:update/notice:delete/contest:work:read)
- 修复评审规则组件映射

活动管理全模块优化(机构端视角):
- 活动列表:加统计概览+精简列+筛选自动查询+发布弹窗修复+操作逻辑优化
- 创建/编辑活动:重构布局(去card嵌套+栅格响应式+分区卡片)
- 评委管理:统一主色调+冻结确认+导入导出disabled
- 报名管理:去Tab+统计+审核状态列+批量审核接口
- 报名记录:统计概览+去机构列+撤销审核+返回按钮+去参与方式列
- 作品管理:去Tab+统计+递交进度彩色+筛选修复(assignStatus/submitTime)
- 评审进度:去Tab+统计+实际完成率状态+筛选修复
- 评审规则:表格加评委数/计算方式+描述列修复+删除保护
- 成果发布:去Tab+统计+操作文案优化
- 通知公告:统一主色调+发布确认+操作逻辑+状态筛选+时间范围

成果发布详情功能补全:
- 计算得分/排名/设置奖项三步操作流程
- 排名列(金银铜徽章)+奖项列+奖项筛选
- 自定义奖项(动态添加行替代硬编码一二三等奖)
- 后端AutoSetAwardsDto改为awards数组格式

数据统计看板(新模块):
- 后端analytics module(overview+review两个接口)
- 运营概览:6指标卡片+报名转化漏斗+ECharts月度趋势+活动对比表
- 评审分析:4效率卡片+评委工作量表+ECharts奖项分布饼图
- 菜单注册:数据统计→运营概览+评审分析

Bug修复:
- 超管重置其他租户用户密码报"用户不存在"
- gdlib登录快捷标签密码不一致
- 分配评委去掉评审时间限制

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 20:02:24 +08:00
aid
83f007d20e Day5: 机构管理模块优化 — 隐藏内部租户+后端搜索+快捷操作+新建引导
- 后端过滤系统内部租户(super/public/school等),列表只展示真实机构
- 搜索改为后端分页查询(keyword+tenantType参数),去掉前端过滤
- 表格新增登录地址列,一键复制完整URL
- 新增停用/启用快捷按钮(PATCH /tenants/:id/status)
- 新建机构成功后弹出引导,可直接跳转创建管理员账号
- 修复编辑弹窗因模板访问window导致的渲染崩溃

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:39:14 +08:00
aid
f246b38fc1 Day5: 超管端内容管理模块全面优化 + 广场推荐作品展示
作品审核:
- 批量通过/批量拒绝 + 撤销审核机制
- 默认筛选待审核,表格加描述预览+审核时间列
- 详情Drawer加上一个/下一个导航,审核后自动跳下一个
- 操作日志时间线展示,筛选下拉自动查询

作品管理:
- 修复筛选/排序失效,新增推荐中筛选
- 下架改为弹窗选择原因,取消推荐二次确认
- 详情Drawer补全描述/标签/操作按钮/操作日志
- 统计卡片可点击筛选,下架自动取消推荐

标签管理:
- 按分类分组卡片式展示,分类改为下拉选择
- 新增标签颜色字段(预设色+自定义)
- 上移/下移排序按钮,使用次数可点击跳转作品管理
- 新增/编辑时实时预览用户端标签效果

广场推荐:
- 新增推荐作品列表接口 GET /public/gallery/recommended
- 广场顶部新增「编辑推荐」横向滚动栏

文档更新:内容管理设计文档补充实施记录,UGC开发计划P1-1标记已完成

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:21:21 +08:00
aid
66827c0199 Day5: 公众端响应式修复 + 点赞收藏功能 + 报名作品合并 + 菜单同步
- 公众端桌面端新增顶部导航菜单,修复横屏模式菜单消失问题
- 实现点赞/收藏 toggle API(含批量状态查询、我的收藏列表)
- 作品详情页新增互动栏(点赞/收藏按钮,乐观更新+动效)
- 广场卡片支持点赞交互
- 报名列表合并展示参赛作品,移除独立的「我的作品」页面
- 个人中心新增「我的收藏」入口
- menus.json 与数据库完整同步,更新初始化脚本租户分配逻辑
- Vite 开启局域网访问

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:56:20 +08:00
aid
4466e28b3b 超管端用户管理:「平台」更名为「运营团队」+ 子女信息适配独立账号模型
- 统计卡片和用户类型Tag从「平台」改为「运营团队」,避免命名歧义
- 公众用户详情从旧版Child模型(姓名/年级/学校)改为UserParentChild关系,展示子女独立账号信息
- 后端详情接口和列表_count同步从children切换到parentRelations
- 更新统一用户管理设计文档,补充实施记录

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:03:44 +08:00
82 changed files with 7216 additions and 3326 deletions

View File

@ -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
}
]
}

View File

@ -1271,6 +1271,7 @@ model WorkTag {
id Int @id @default(autoincrement())
name String @unique @db.VarChar(50) /// 标签名称
category String? @db.VarChar(50) /// 所属分类(如:主题/风格/情感)
color String? @db.VarChar(20) /// 标签颜色(如:#6366f1
sort Int @default(0) /// 排序权重
status String @default("enabled") /// 状态enabled/disabled
usageCount Int @default(0) @map("usage_count") /// 使用次数(冗余)

View File

@ -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);
}

View File

@ -13,6 +13,7 @@ import { LogsModule } from './logs/logs.module';
import { TenantsModule } from './tenants/tenants.module';
import { SchoolModule } from './school/school.module';
import { ContestsModule } from './contests/contests.module';
import { AnalyticsModule } from './contests/analytics/analytics.module';
import { JudgesManagementModule } from './judges-management/judges-management.module';
import { UploadModule } from './upload/upload.module';
import { HomeworkModule } from './homework/homework.module';
@ -47,6 +48,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
TenantsModule,
SchoolModule,
ContestsModule,
AnalyticsModule,
JudgesManagementModule,
UploadModule,
HomeworkModule,

View File

@ -0,0 +1,36 @@
import { Controller, Get, Query, Request, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
import { AnalyticsService } from './analytics.service';
@Controller('analytics')
@UseGuards(JwtAuthGuard)
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Get('overview')
@RequirePermission('contest:read')
getOverview(
@Request() req,
@Query('timeRange') timeRange?: string,
@Query('contestId') contestId?: string,
) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.analyticsService.getOverview(tenantId, {
timeRange,
contestId: contestId ? parseInt(contestId) : undefined,
});
}
@Get('review')
@RequirePermission('contest:read')
getReviewAnalysis(
@Request() req,
@Query('contestId') contestId?: string,
) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.analyticsService.getReviewAnalysis(tenantId, {
contestId: contestId ? parseInt(contestId) : undefined,
});
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AnalyticsController],
providers: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@ -0,0 +1,296 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AnalyticsService {
constructor(private prisma: PrismaService) {}
/**
*
*/
private isContestVisibleToTenant(contest: any, tenantId: number): boolean {
if (contest.contestState !== 'published') return false;
if (!contest.contestTenants) return true;
try {
const ids = Array.isArray(contest.contestTenants)
? contest.contestTenants
: JSON.parse(contest.contestTenants as string);
return ids.includes(tenantId);
} catch { return false; }
}
/**
*
*/
async getOverview(tenantId: number, params: { timeRange?: string; contestId?: number }) {
const { contestId } = params;
// 获取该租户可见的活动
const allContests = await this.prisma.contest.findMany({
where: { contestState: 'published' },
select: { id: true, contestTenants: true, contestState: true, contestName: true },
});
let visibleContestIds = allContests
.filter(c => this.isContestVisibleToTenant(c, tenantId))
.map(c => c.id);
if (contestId) {
visibleContestIds = visibleContestIds.filter(id => id === contestId);
}
const regWhere: any = { tenantId, contestId: { in: visibleContestIds } };
const workWhere: any = { tenantId, contestId: { in: visibleContestIds }, validState: 1, isLatest: true };
// 核心指标
const [totalRegistrations, passedRegistrations, totalWorks, reviewedWorks, awardedWorks] = await Promise.all([
this.prisma.contestRegistration.count({ where: regWhere }),
this.prisma.contestRegistration.count({ where: { ...regWhere, registrationState: 'passed' } }),
this.prisma.contestWork.count({ where: workWhere }),
this.prisma.contestWork.count({ where: { ...workWhere, status: { in: ['accepted', 'awarded'] } } }),
this.prisma.contestWork.count({ where: { ...workWhere, awardName: { not: null } } }),
]);
// 漏斗数据
const funnel = {
registered: totalRegistrations,
passed: passedRegistrations,
submitted: totalWorks,
reviewed: reviewedWorks,
awarded: awardedWorks,
};
// 月度趋势最近6个月
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5);
sixMonthsAgo.setDate(1);
sixMonthsAgo.setHours(0, 0, 0, 0);
const registrationsByMonth = await this.prisma.$queryRawUnsafe<any[]>(`
SELECT DATE_FORMAT(registration_time, '%Y-%m') as month, COUNT(*) as count
FROM t_contest_registration
WHERE tenant_id = ? AND contest_id IN (${visibleContestIds.join(',') || '0'})
AND registration_time >= ?
GROUP BY month ORDER BY month
`, tenantId, sixMonthsAgo);
const worksByMonth = await this.prisma.$queryRawUnsafe<any[]>(`
SELECT DATE_FORMAT(submit_time, '%Y-%m') as month, COUNT(*) as count
FROM t_contest_work
WHERE tenant_id = ? AND contest_id IN (${visibleContestIds.join(',') || '0'})
AND valid_state = 1 AND is_latest = 1
AND submit_time >= ?
GROUP BY month ORDER BY month
`, tenantId, sixMonthsAgo);
// 构建连续6个月数据
const monthlyTrend: { month: string; registrations: number; works: number }[] = [];
for (let i = 0; i < 6; i++) {
const d = new Date();
d.setMonth(d.getMonth() - 5 + i);
const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const regRow = registrationsByMonth.find((r: any) => r.month === m);
const workRow = worksByMonth.find((r: any) => r.month === m);
monthlyTrend.push({
month: m,
registrations: Number(regRow?.count || 0),
works: Number(workRow?.count || 0),
});
}
// 活动对比
const contestComparison: any[] = [];
for (const cid of visibleContestIds) {
const contest = allContests.find(c => c.id === cid);
if (!contest) continue;
const [regTotal, regPassed, worksTotal, worksReviewed, worksAwarded] = await Promise.all([
this.prisma.contestRegistration.count({ where: { tenantId, contestId: cid } }),
this.prisma.contestRegistration.count({ where: { tenantId, contestId: cid, registrationState: 'passed' } }),
this.prisma.contestWork.count({ where: { tenantId, contestId: cid, validState: 1, isLatest: true } }),
this.prisma.contestWork.count({ where: { tenantId, contestId: cid, validState: 1, isLatest: true, status: { in: ['accepted', 'awarded'] } } }),
this.prisma.contestWork.count({ where: { tenantId, contestId: cid, validState: 1, isLatest: true, awardName: { not: null } } }),
]);
const avgScore = await this.prisma.contestWork.aggregate({
where: { tenantId, contestId: cid, validState: 1, isLatest: true, finalScore: { not: null } },
_avg: { finalScore: true },
});
contestComparison.push({
contestId: cid,
contestName: contest.contestName,
registrations: regTotal,
passRate: regTotal > 0 ? Math.round(regPassed / regTotal * 100) : 0,
submitRate: regPassed > 0 ? Math.round(worksTotal / regPassed * 100) : 0,
reviewRate: worksTotal > 0 ? Math.round(worksReviewed / worksTotal * 100) : 0,
awardRate: worksTotal > 0 ? Math.round(worksAwarded / worksTotal * 100) : 0,
avgScore: avgScore._avg.finalScore ? Number(Number(avgScore._avg.finalScore).toFixed(2)) : null,
});
}
return {
summary: {
totalContests: visibleContestIds.length,
totalRegistrations,
passedRegistrations,
totalWorks,
reviewedWorks,
awardedWorks,
},
funnel,
monthlyTrend,
contestComparison,
};
}
/**
*
*/
async getReviewAnalysis(tenantId: number, params: { contestId?: number }) {
const { contestId } = params;
// 获取可见活动
const allContests = await this.prisma.contest.findMany({
where: { contestState: 'published' },
select: { id: true, contestTenants: true, contestState: true },
});
let visibleContestIds = allContests
.filter(c => this.isContestVisibleToTenant(c, tenantId))
.map(c => c.id);
if (contestId) {
visibleContestIds = visibleContestIds.filter(id => id === contestId);
}
if (visibleContestIds.length === 0) {
return {
efficiency: { avgReviewDays: 0, dailyReviewCount: 0, pendingAssignments: 0, avgScoreStddev: 0 },
judgeWorkload: [],
awardDistribution: [],
};
}
const contestIdList = visibleContestIds.join(',');
// 评审效率
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [pendingAssignments, recentScoreCount] = await Promise.all([
this.prisma.contestWorkJudgeAssignment.count({
where: { contestId: { in: visibleContestIds }, status: 'assigned' },
}),
this.prisma.contestWorkScore.count({
where: { contestId: { in: visibleContestIds }, scoreTime: { gte: thirtyDaysAgo } },
}),
]);
// 平均评审周期:从作品提交到第一次评分的天数
let avgReviewDays = 0;
try {
const reviewDaysResult = await this.prisma.$queryRawUnsafe<any[]>(`
SELECT AVG(DATEDIFF(s.score_time, w.submit_time)) as avg_days
FROM t_contest_work_score s
JOIN t_contest_work w ON s.work_id = w.id
WHERE s.contest_id IN (${contestIdList})
AND w.valid_state = 1
`);
avgReviewDays = reviewDaysResult[0]?.avg_days ? Number(Number(reviewDaysResult[0].avg_days).toFixed(1)) : 0;
} catch { /* */ }
// 评分标准差(评委间一致性)
let avgScoreStddev = 0;
try {
const stddevResult = await this.prisma.$queryRawUnsafe<any[]>(`
SELECT AVG(stddev_score) as avg_stddev
FROM (
SELECT work_id, STDDEV(total_score) as stddev_score
FROM t_contest_work_score
WHERE contest_id IN (${contestIdList}) AND valid_state = 1
GROUP BY work_id
HAVING COUNT(*) > 1
) sub
`);
avgScoreStddev = stddevResult[0]?.avg_stddev ? Number(Number(stddevResult[0].avg_stddev).toFixed(1)) : 0;
} catch { /* */ }
// 评委工作量
const judges = await this.prisma.contestJudge.findMany({
where: { contestId: { in: visibleContestIds }, validState: 1 },
include: {
judge: { select: { id: true, nickname: true, username: true } },
},
});
// 按评委去重
const judgeMap = new Map<number, any>();
for (const j of judges) {
if (!judgeMap.has(j.judgeId)) {
judgeMap.set(j.judgeId, {
judgeId: j.judgeId,
judgeName: j.judge?.nickname || j.judge?.username || '-',
contestIds: new Set<number>(),
});
}
judgeMap.get(j.judgeId).contestIds.add(j.contestId);
}
const judgeWorkload: any[] = [];
for (const [judgeId, info] of judgeMap) {
const [assignedCount, scoredCount, scores] = await Promise.all([
this.prisma.contestWorkJudgeAssignment.count({
where: { judgeId, contestId: { in: visibleContestIds } },
}),
this.prisma.contestWorkScore.count({
where: { judgeId, contestId: { in: visibleContestIds }, validState: 1 },
}),
this.prisma.contestWorkScore.findMany({
where: { judgeId, contestId: { in: visibleContestIds }, validState: 1 },
select: { totalScore: true },
}),
]);
const scoreValues = scores.map(s => Number(s.totalScore));
const avg = scoreValues.length > 0 ? scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length : 0;
const variance = scoreValues.length > 1
? scoreValues.reduce((sum, v) => sum + Math.pow(v - avg, 2), 0) / (scoreValues.length - 1)
: 0;
judgeWorkload.push({
judgeId,
judgeName: info.judgeName,
contestCount: info.contestIds.size,
assignedCount,
scoredCount,
completionRate: assignedCount > 0 ? Math.round(scoredCount / assignedCount * 100) : 0,
avgScore: scoreValues.length > 0 ? Number(avg.toFixed(2)) : null,
scoreStddev: scoreValues.length > 1 ? Number(Math.sqrt(variance).toFixed(2)) : 0,
});
}
// 奖项分布
const awardGroups = await this.prisma.contestWork.groupBy({
by: ['awardName'],
where: { tenantId, contestId: { in: visibleContestIds }, validState: 1, isLatest: true, awardName: { not: null } },
_count: { id: true },
});
const totalAwarded = awardGroups.reduce((sum, g) => sum + g._count.id, 0);
const awardDistribution = awardGroups.map(g => ({
awardName: g.awardName,
count: g._count.id,
percentage: totalAwarded > 0 ? Math.round(g._count.id / totalAwarded * 100) : 0,
}));
return {
efficiency: {
avgReviewDays,
dailyReviewCount: Number((recentScoreCount / 30).toFixed(1)),
pendingAssignments,
avgScoreStddev,
},
judgeWorkload,
awardDistribution,
};
}
}

View File

@ -34,8 +34,16 @@ export class ContestsController {
@Get('stats')
@RequirePermission('contest:read')
getStats() {
return this.contestsService.getStats();
getStats(@Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.contestsService.getStats(tenantId);
}
@Get('dashboard')
@RequirePermission('contest:read')
getDashboard(@Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.contestsService.getTenantDashboard(tenantId);
}
@Get()

View File

@ -217,12 +217,13 @@ export class ContestsService {
/**
*
*/
async getStats() {
async getStats(tenantId?: number) {
const contests = await this.prisma.contest.findMany({
where: { validState: 1 },
select: {
id: true,
contestState: true,
contestTenants: true,
status: true,
registerStartTime: true,
registerEndTime: true,
@ -233,8 +234,13 @@ export class ContestsService {
},
});
const result = { total: contests.length, unpublished: 0, registering: 0, submitting: 0, reviewing: 0, finished: 0 };
for (const c of contests) {
// 如果有 tenantId只统计该租户可见的活动
const filtered = tenantId
? contests.filter(c => this.isContestVisibleToTenant(c, tenantId))
: contests;
const result = { total: filtered.length, unpublished: 0, registering: 0, submitting: 0, reviewing: 0, finished: 0 };
for (const c of filtered) {
const stage = this.getContestStage(c);
if (stage === 'unpublished') result.unpublished++;
else if (stage === 'registering') result.registering++;
@ -1097,4 +1103,96 @@ export class ContestsService {
},
});
}
/** 租户端仪表盘统计 */
async getTenantDashboard(tenantId: number) {
const today = new Date();
today.setHours(0, 0, 0, 0);
// 获取该租户可见的活动 ID 列表
const allContests = await this.prisma.contest.findMany({
where: { contestState: 'published' },
select: { id: true, contestTenants: true, contestState: true, status: true, endTime: true, submitEndTime: true, contestName: true },
});
const visibleContests = allContests.filter(c => this.isContestVisibleToTenant(c, tenantId));
const contestIds = visibleContests.map(c => c.id);
const ongoingCount = visibleContests.filter(c => c.status === 'ongoing').length;
const [totalContests, totalRegistrations, pendingRegistrations, totalWorks, todayRegistrations] = await Promise.all([
Promise.resolve(contestIds.length),
this.prisma.contestRegistration.count({
where: { tenantId, contestId: { in: contestIds } },
}),
this.prisma.contestRegistration.count({
where: { tenantId, contestId: { in: contestIds }, registrationState: 'pending' },
}),
this.prisma.contestWork.count({
where: { tenantId, contestId: { in: contestIds }, validState: 1 },
}),
this.prisma.contestRegistration.count({
where: { tenantId, contestId: { in: contestIds }, registrationTime: { gte: today } },
}),
]);
// 最近活动列表最多5个
const recentContestIds = contestIds.slice(0, 5);
const recentContests = recentContestIds.length > 0
? await this.prisma.contest.findMany({
where: { id: { in: recentContestIds } },
select: {
id: true,
contestName: true,
status: true,
startTime: true,
endTime: true,
submitEndTime: true,
_count: { select: { registrations: true, works: true } },
},
orderBy: { createTime: 'desc' },
})
: [];
// 待办提醒
const todos: { type: string; message: string; link?: string }[] = [];
if (pendingRegistrations > 0) {
todos.push({ type: 'warning', message: `${pendingRegistrations} 个待审核报名`, link: '/contests/registrations' });
}
// 7天内即将结束的活动
const sevenDaysLater = new Date();
sevenDaysLater.setDate(sevenDaysLater.getDate() + 7);
for (const c of recentContests) {
if (c.status === 'ongoing' && c.endTime) {
const end = new Date(c.endTime);
if (end <= sevenDaysLater && end >= today) {
const days = Math.ceil((end.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
todos.push({ type: 'info', message: `活动「${c.contestName}」将在 ${days} 天后结束`, link: `/contests/${c.id}` });
}
}
if (c.submitEndTime) {
const submitEnd = new Date(c.submitEndTime);
if (submitEnd <= sevenDaysLater && submitEnd >= today) {
const days = Math.ceil((submitEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
todos.push({ type: 'warning', message: `活动「${c.contestName}」作品提交将在 ${days} 天后截止`, link: `/contests/${c.id}` });
}
}
}
// 机构信息
const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId },
select: { name: true, code: true, tenantType: true },
});
return {
tenant,
totalContests,
ongoingContests: ongoingCount,
totalRegistrations,
pendingRegistrations,
totalWorks,
todayRegistrations,
recentContests,
todos,
};
}
}

View File

@ -22,5 +22,13 @@ export class QueryNoticeDto {
@IsString()
@IsOptional()
publishDate?: string;
@IsString()
@IsOptional()
publishStartDate?: string;
@IsString()
@IsOptional()
publishEndDate?: string;
}

View File

@ -74,7 +74,9 @@ export class NoticesService {
pageSize = 10,
title,
publishDate,
} = queryDto;
publishStartDate,
publishEndDate,
} = queryDto as any;
const where: any = {
validState: 1,
@ -82,21 +84,20 @@ export class NoticesService {
// 标题搜索
if (title) {
where.title = {
contains: title,
};
where.title = { contains: title };
}
// 发布日期搜索
if (publishDate) {
// 发布日期搜索(兼容单日期和范围)
if (publishStartDate || publishEndDate) {
where.publishTime = {};
if (publishStartDate) where.publishTime.gte = new Date(publishStartDate);
if (publishEndDate) where.publishTime.lte = new Date(publishEndDate + ' 23:59:59');
} else if (publishDate) {
const startDate = new Date(publishDate);
startDate.setHours(0, 0, 0, 0);
const endDate = new Date(publishDate);
endDate.setHours(23, 59, 59, 999);
where.publishTime = {
gte: startDate,
lte: endDate,
};
where.publishTime = { gte: startDate, lte: endDate };
}
const skip = (page - 1) * pageSize;
@ -105,8 +106,7 @@ export class NoticesService {
this.prisma.contestNotice.findMany({
where,
orderBy: [
{ priority: 'desc' },
{ publishTime: 'desc' },
{ createTime: 'desc' },
],
include: {
contest: {

View File

@ -41,8 +41,9 @@ export class RegistrationsController {
@Get('stats')
@RequirePermission('contest:read')
getStats(@Query('contestId') contestId?: string) {
return this.registrationsService.getStats(contestId ? parseInt(contestId) : undefined);
getStats(@Query('contestId') contestId?: string, @Request() req?) {
const tenantId = req?.tenantId || req?.user?.tenantId;
return this.registrationsService.getStats(contestId ? parseInt(contestId) : undefined, tenantId);
}
@Get()
@ -87,6 +88,25 @@ export class RegistrationsController {
return this.registrationsService.review(id, reviewDto, operatorId, tenantId);
}
@Patch(':id/revoke')
@RequirePermission('contest:update')
revokeReview(@Param('id', ParseIntPipe) id: number, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
const operatorId = req.user?.userId;
return this.registrationsService.revokeReview(id, operatorId, tenantId);
}
@Post('batch-review')
@RequirePermission('contest:update')
batchReview(
@Body() dto: { ids: number[]; registrationState: string; reason?: string },
@Request() req,
) {
const tenantId = req.tenantId || req.user?.tenantId;
const operatorId = req.user?.userId;
return this.registrationsService.batchReview(dto.ids, dto.registrationState, operatorId, tenantId, dto.reason);
}
@Post(':id/teachers')
@RequirePermission('contest:update')
addTeacher(

View File

@ -264,12 +264,14 @@ export class RegistrationsService {
}
/**
*
*
*/
async getStats(contestId?: number) {
async getStats(contestId?: number, tenantId?: number) {
const baseWhere: any = {};
if (contestId) {
baseWhere.contestId = contestId;
if (contestId) baseWhere.contestId = contestId;
if (tenantId) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { isSuper: true } });
if (tenant?.isSuper !== 1) baseWhere.tenantId = tenantId;
}
const [total, pending, passed, rejected] = await Promise.all([
@ -282,6 +284,48 @@ export class RegistrationsService {
return { total, pending, passed, rejected };
}
/** 撤销审核(恢复为待审核) */
async revokeReview(id: number, operatorId?: number, tenantId?: number) {
const registration = await this.findOne(id, tenantId);
if (!['passed', 'rejected'].includes(registration.registrationState)) {
throw new BadRequestException('当前状态不支持撤销');
}
return this.prisma.contestRegistration.update({
where: { id },
data: {
registrationState: 'pending',
reason: null,
operator: operatorId,
operationDate: new Date(),
modifier: operatorId,
},
});
}
/** 批量审核 */
async batchReview(ids: number[], registrationState: string, operatorId?: number, tenantId?: number, reason?: string) {
if (!ids?.length) return { success: true, count: 0 };
const where: any = { id: { in: ids } };
if (tenantId) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { isSuper: true } });
if (tenant?.isSuper !== 1) where.tenantId = tenantId;
}
const result = await this.prisma.contestRegistration.updateMany({
where,
data: {
registrationState,
reason: reason || null,
operator: operatorId,
operationDate: new Date(),
},
});
return { success: true, count: result.count };
}
async findAll(queryDto: QueryRegistrationDto, tenantId?: number) {
const {
page = 1,

View File

@ -1,23 +1,18 @@
import { IsNumber, IsOptional, Min } from 'class-validator';
export class AutoSetAwardsDto {
@IsNumber()
@IsOptional()
@Min(0)
first?: number;
@IsNumber()
@IsOptional()
@Min(0)
second?: number;
@IsNumber()
@IsOptional()
@Min(0)
third?: number;
@IsNumber()
@IsOptional()
@Min(0)
excellent?: number;
}
import { IsArray, IsNumber, IsString, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class AwardTierDto {
@IsString()
name: string;
@IsNumber()
@Min(1)
count: number;
}
export class AutoSetAwardsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AwardTierDto)
awards: AwardTierDto[];
}

View File

@ -1,15 +1,14 @@
import { IsString, IsOptional, IsIn } from 'class-validator';
export class SetAwardDto {
@IsString()
@IsIn(['first', 'second', 'third', 'excellent', 'none'])
awardLevel: string;
@IsString()
@IsOptional()
awardName?: string;
@IsString()
@IsOptional()
certificateUrl?: string;
}
import { IsString, IsOptional } from 'class-validator';
export class SetAwardDto {
@IsString()
awardLevel: string; // 自定义奖项标识,如 "gold", "silver" 或自定义
@IsString()
@IsOptional()
awardName?: string; // 奖项显示名称,如 "金奖", "最佳创意奖"
@IsString()
@IsOptional()
certificateUrl?: string;
}

View File

@ -281,15 +281,12 @@ export class ResultsService {
}
/**
*
*
*/
async autoSetAwards(
contestId: number,
awardConfig: {
first?: number;
second?: number;
third?: number;
excellent?: number;
awards: Array<{ name: string; count: number }>;
},
) {
const contest = await this.prisma.contest.findUnique({
@ -317,34 +314,26 @@ export class ResultsService {
throw new BadRequestException('没有已排名的作品,请先计算排名');
}
const firstCount = awardConfig.first || 0;
const secondCount = awardConfig.second || 0;
const thirdCount = awardConfig.third || 0;
const excellentCount = awardConfig.excellent || 0;
// 构建奖项分配表:按顺序展开 [{name, count}] 为 [name, name, ...]
const awardSlots: string[] = [];
for (const tier of (awardConfig.awards || [])) {
for (let i = 0; i < (tier.count || 0); i++) {
awardSlots.push(tier.name);
}
}
let assignedCount = 0;
const awards: { workId: number; awardLevel: string; awardName: string }[] = [];
for (let i = 0; i < works.length; i++) {
const work = works[i];
let awardLevel: string | null = null;
let awardName: string | null = null;
const awardName = i < awardSlots.length ? awardSlots[i] : null;
if (i < firstCount) {
awardLevel = 'first';
awardName = '一等奖';
} else if (i < firstCount + secondCount) {
awardLevel = 'second';
awardName = '二等奖';
} else if (i < firstCount + secondCount + thirdCount) {
awardLevel = 'third';
awardName = '三等奖';
} else if (i < firstCount + secondCount + thirdCount + excellentCount) {
awardLevel = 'excellent';
awardName = '优秀奖';
}
if (awardName) {
// awardLevel 用序号标识tier_0, tier_1...awardName 存自定义名称
const tierIndex = awardConfig.awards.findIndex(t => t.name === awardName);
const awardLevel = `tier_${tierIndex}`;
if (awardLevel) {
await this.prisma.contestWork.update({
where: { id: work.id },
data: {

View File

@ -33,15 +33,6 @@ export class ReviewsService {
throw new BadRequestException('作品不属于该活动');
}
// 检查评审时间
const now = new Date();
if (
now < work.contest.reviewStartTime ||
now > work.contest.reviewEndTime
) {
throw new BadRequestException('不在评审时间范围内');
}
// 验证评委是否存在且是该活动的评委
const judges = await this.prisma.contestJudge.findMany({
where: {

View File

@ -52,5 +52,26 @@ export class QueryWorkDto {
@IsString()
@IsOptional()
keyword?: string; // 搜索作品编号、提交者姓名
@IsString()
@IsOptional()
name?: string; // 选手/队伍名称
@IsString()
@IsOptional()
assignStatus?: string; // assigned / unassigned
@IsInt()
@Type(() => Number)
@IsOptional()
tenantId?: number;
@IsString()
@IsOptional()
submitStartTime?: string;
@IsString()
@IsOptional()
submitEndTime?: string;
}

View File

@ -301,6 +301,34 @@ export class WorksService {
];
}
// 选手/队伍名称搜索
if (queryDto.name) {
const nameWhere = [
{ registration: { user: { nickname: { contains: queryDto.name } } } },
{ registration: { team: { teamName: { contains: queryDto.name } } } },
];
where.OR = where.OR ? [...where.OR, ...nameWhere] : nameWhere;
}
// 分配状态筛选
if (queryDto.assignStatus === 'assigned') {
where.assignments = { some: {} };
} else if (queryDto.assignStatus === 'unassigned') {
where.assignments = { none: {} };
}
// 指定租户筛选(超管用)
if (queryDto.tenantId) {
where.tenantId = queryDto.tenantId;
}
// 递交时间范围
if (queryDto.submitStartTime || queryDto.submitEndTime) {
where.submitTime = {};
if (queryDto.submitStartTime) where.submitTime.gte = new Date(queryDto.submitStartTime);
if (queryDto.submitEndTime) where.submitTime.lte = new Date(queryDto.submitEndTime + ' 23:59:59');
}
const [list, total] = await Promise.all([
this.prisma.contestWork.findMany({
where,

View File

@ -25,6 +25,8 @@ export class ContentReviewController {
@Query('keyword') keyword?: string,
@Query('startTime') startTime?: string,
@Query('endTime') endTime?: string,
@Query('sortBy') sortBy?: string,
@Query('isRecommended') isRecommended?: string,
) {
return this.reviewService.getWorkQueue({
page: page ? parseInt(page) : 1,
@ -33,6 +35,8 @@ export class ContentReviewController {
keyword,
startTime,
endTime,
sortBy,
isRecommended: isRecommended === '1',
});
}
@ -41,6 +45,16 @@ export class ContentReviewController {
return this.reviewService.getWorkDetail(id);
}
@Post('works/batch-approve')
batchApprove(@Request() req, @Body() dto: { ids: number[] }) {
return this.reviewService.batchApprove(dto.ids || [], req.user.userId);
}
@Post('works/batch-reject')
batchReject(@Request() req, @Body() dto: { ids: number[]; reason: string }) {
return this.reviewService.batchReject(dto.ids || [], req.user.userId, dto.reason);
}
@Post('works/:id/approve')
approveWork(
@Param('id', ParseIntPipe) id: number,
@ -59,6 +73,11 @@ export class ContentReviewController {
return this.reviewService.reject(id, req.user.userId, dto.reason, dto.note);
}
@Post('works/:id/revoke')
revokeWork(@Param('id', ParseIntPipe) id: number, @Request() req) {
return this.reviewService.revoke(id, req.user.userId);
}
@Post('works/:id/takedown')
takedownWork(
@Param('id', ParseIntPipe) id: number,

View File

@ -28,17 +28,27 @@ export class ContentReviewService {
keyword?: string;
startTime?: string;
endTime?: string;
sortBy?: string;
isRecommended?: boolean;
}) {
const { page = 1, pageSize = 10, status, keyword, startTime, endTime } = params;
const { page = 1, pageSize = 10, status, keyword, startTime, endTime, sortBy, isRecommended } = params;
const skip = (page - 1) * pageSize;
const where: any = { isDeleted: 0 };
if (status) {
where.status = status;
if (status === 'published,taken_down') {
where.status = { in: ['published', 'taken_down'] };
} else {
where.status = status;
}
} else {
where.status = { in: ['pending_review', 'published', 'rejected', 'taken_down'] };
}
if (isRecommended) {
where.isRecommended = true;
}
if (keyword) {
where.OR = [
{ title: { contains: keyword } },
@ -49,12 +59,18 @@ export class ContentReviewService {
if (startTime) where.createTime = { ...where.createTime, gte: new Date(startTime) };
if (endTime) where.createTime = { ...where.createTime, lte: new Date(endTime + ' 23:59:59') };
// 排序
let orderBy: any = { createTime: 'desc' };
if (sortBy === 'hot') orderBy = [{ likeCount: 'desc' }, { viewCount: 'desc' }];
else if (sortBy === 'views') orderBy = { viewCount: 'desc' };
else if (sortBy === 'latest') orderBy = { publishTime: 'desc' };
const [list, total] = await Promise.all([
this.prisma.userWork.findMany({
where,
skip,
take: pageSize,
orderBy: { createTime: 'desc' },
orderBy,
include: {
creator: { select: { id: true, nickname: true, avatar: true, username: true, userType: true } },
tags: { include: { tag: { select: { id: true, name: true } } } },
@ -115,6 +131,73 @@ export class ContentReviewService {
});
}
/** 批量通过 */
async batchApprove(workIds: number[], operatorId: number) {
const works = await this.prisma.userWork.findMany({
where: { id: { in: workIds }, status: 'pending_review', isDeleted: 0 },
select: { id: true },
});
if (works.length === 0) return { success: true, count: 0 };
const ids = works.map(w => w.id);
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.userWork.updateMany({
where: { id: { in: ids } },
data: { status: 'published', reviewTime: now, reviewerId: operatorId, publishTime: now },
});
await tx.contentReviewLog.createMany({
data: ids.map(id => ({
targetType: 'work',
targetId: id,
workId: id,
action: 'approve',
note: '批量审核通过',
operatorId,
})),
});
});
return { success: true, count: ids.length };
}
/** 批量拒绝 */
async batchReject(workIds: number[], operatorId: number, reason: string) {
const works = await this.prisma.userWork.findMany({
where: { id: { in: workIds }, status: 'pending_review', isDeleted: 0 },
select: { id: true },
});
if (works.length === 0) return { success: true, count: 0 };
const ids = works.map(w => w.id);
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.userWork.updateMany({
where: { id: { in: ids } },
data: { status: 'rejected', reviewTime: now, reviewerId: operatorId, reviewNote: reason },
});
await tx.contentReviewLog.createMany({
data: ids.map(id => ({
targetType: 'work',
targetId: id,
workId: id,
action: 'reject',
reason,
note: '批量审核拒绝',
operatorId,
})),
});
});
return { success: true, count: ids.length };
}
/** 拒绝 */
async reject(workId: number, operatorId: number, reason: string, note?: string) {
const work = await this.prisma.userWork.findUnique({ where: { id: workId } });
@ -155,7 +238,7 @@ export class ContentReviewService {
return this.prisma.$transaction(async (tx) => {
await tx.userWork.update({
where: { id: workId },
data: { status: 'taken_down', reviewNote: reason },
data: { status: 'taken_down', reviewNote: reason, isRecommended: false },
});
await tx.contentReviewLog.create({
@ -209,6 +292,41 @@ export class ContentReviewService {
});
}
/** 撤销审核(恢复为待审核) */
async revoke(workId: number, operatorId: number) {
const work = await this.prisma.userWork.findUnique({ where: { id: workId } });
if (!work) throw new NotFoundException('作品不存在');
if (!['published', 'rejected'].includes(work.status)) {
throw new NotFoundException('当前状态不支持撤销');
}
return this.prisma.$transaction(async (tx) => {
await tx.userWork.update({
where: { id: workId },
data: {
status: 'pending_review',
reviewTime: null,
reviewerId: null,
reviewNote: null,
publishTime: work.status === 'published' ? null : work.publishTime,
},
});
await tx.contentReviewLog.create({
data: {
targetType: 'work',
targetId: workId,
workId,
action: 'revoke',
note: '撤销审核操作',
operatorId,
},
});
return { success: true };
});
}
/** 作品管理统计 */
async getManagementStats() {
const today = new Date();

View File

@ -88,6 +88,23 @@ export class GalleryService {
return work;
}
/** 推荐作品列表 */
async getRecommendedWorks(limit = 10) {
return this.prisma.userWork.findMany({
where: {
isRecommended: true,
status: 'published',
visibility: 'public',
isDeleted: 0,
},
take: limit,
orderBy: [{ likeCount: 'desc' }, { publishTime: 'desc' }],
include: {
creator: { select: { id: true, nickname: true, avatar: true } },
},
});
}
/** 某用户的公开作品列表 */
async getUserPublicWorks(userId: number, params: { page?: number; pageSize?: number }) {
const { page = 1, pageSize = 12 } = params;

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 { 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,
) {}
// ==================== 注册 & 登录(公开接口) ====================
@ -405,6 +407,12 @@ export class PublicController {
});
}
@Public()
@Get('gallery/recommended')
async getRecommendedWorks() {
return this.galleryService.getRecommendedWorks();
}
@Public()
@Get('gallery/:id')
async getGalleryDetail(@Param('id', ParseIntPipe) id: number) {
@ -423,4 +431,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,
});
}
}

View File

@ -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 {}

View File

@ -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 }),

View File

@ -16,18 +16,23 @@ export class TagsController {
}
@Post()
create(@Body() dto: { name: string; category?: string; sort?: number }) {
create(@Body() dto: { name: string; category?: string; color?: string; sort?: number }) {
return this.tagsService.create(dto);
}
@Put(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: { name?: string; category?: string; sort?: number; status?: string },
@Body() dto: { name?: string; category?: string; color?: string; sort?: number; status?: string },
) {
return this.tagsService.update(id, dto);
}
@Post('batch-sort')
batchSort(@Body() dto: { items: { id: number; sort: number }[] }) {
return this.tagsService.batchUpdateSort(dto.items || []);
}
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.tagsService.remove(id);

View File

@ -36,7 +36,7 @@ export class TagsService {
}
/** 创建标签 */
async create(dto: { name: string; category?: string; sort?: number }) {
async create(dto: { name: string; category?: string; color?: string; sort?: number }) {
const existing = await this.prisma.workTag.findFirst({ where: { name: dto.name } });
if (existing) throw new BadRequestException('标签名已存在');
@ -44,13 +44,14 @@ export class TagsService {
data: {
name: dto.name,
category: dto.category || null,
color: dto.color || null,
sort: dto.sort || 0,
},
});
}
/** 编辑标签 */
async update(id: number, dto: { name?: string; category?: string; sort?: number; status?: string }) {
async update(id: number, dto: { name?: string; category?: string; color?: string; sort?: number; status?: string }) {
const tag = await this.prisma.workTag.findUnique({ where: { id } });
if (!tag) throw new NotFoundException('标签不存在');
@ -64,12 +65,23 @@ export class TagsService {
data: {
name: dto.name ?? tag.name,
category: dto.category !== undefined ? dto.category : tag.category,
color: dto.color !== undefined ? dto.color : tag.color,
sort: dto.sort !== undefined ? dto.sort : tag.sort,
status: dto.status ?? tag.status,
},
});
}
/** 批量更新排序 */
async batchUpdateSort(items: { id: number; sort: number }[]) {
await this.prisma.$transaction(
items.map(item =>
this.prisma.workTag.update({ where: { id: item.id }, data: { sort: item.sort } }),
),
);
return { success: true };
}
/** 删除标签 */
async remove(id: number) {
const tag = await this.prisma.workTag.findUnique({ where: { id } });

View File

@ -35,10 +35,29 @@ export class TenantsController {
@RequirePermission('tenant:read')
findAll(
@Query('page', new ParseIntPipe({ optional: true })) page: number = 1,
@Query('pageSize', new ParseIntPipe({ optional: true }))
pageSize: number = 10,
@Query('pageSize', new ParseIntPipe({ optional: true })) pageSize: number = 10,
@Query('keyword') keyword?: string,
@Query('tenantType') tenantType?: string,
) {
return this.tenantsService.findAll(page, pageSize);
return this.tenantsService.findAll({ page, pageSize, keyword, tenantType });
}
@Patch(':id/status')
@RequirePermission('tenant:update')
toggleStatus(@Param('id', ParseIntPipe) id: number, @Request() req) {
return this.tenantsService.toggleStatus(id, req.user?.tenantId);
}
@Get('my-tenant')
getMyTenant(@Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.tenantsService.findOne(tenantId);
}
@Patch('my-tenant')
updateMyTenant(@Request() req, @Body() dto: { name?: string; description?: string }) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.tenantsService.updateTenantInfo(tenantId, dto);
}
@Get(':id')

View File

@ -84,18 +84,33 @@ export class TenantsService {
});
}
async findAll(page: number = 1, pageSize: number = 10) {
// 系统内部租户编码(不在机构列表中展示)
private readonly INTERNAL_TENANT_CODES = ['super', 'public', 'school', 'teacher', 'student', 'judge'];
async findAll(params: { page?: number; pageSize?: number; keyword?: string; tenantType?: string } = {}) {
const { page = 1, pageSize = 10, keyword, tenantType } = params;
const skip = (page - 1) * pageSize;
const where: any = {
code: { notIn: this.INTERNAL_TENANT_CODES },
validState: { not: undefined },
};
if (keyword) {
where.OR = [
{ name: { contains: keyword } },
{ code: { contains: keyword } },
];
}
if (tenantType) {
where.tenantType = tenantType;
}
const [list, total] = await Promise.all([
this.prisma.tenant.findMany({
where,
skip,
take: pageSize,
include: {
menus: {
include: {
menu: true,
},
},
_count: {
select: {
users: true,
@ -107,15 +122,38 @@ export class TenantsService {
createTime: 'desc',
},
}),
this.prisma.tenant.count(),
this.prisma.tenant.count({ where }),
]);
return {
list,
total,
page,
pageSize,
};
return { list, total, page, pageSize };
}
/** 机构管理员自助更新机构信息(仅名称和描述) */
async updateTenantInfo(tenantId: number, dto: { name?: string; description?: string }) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) throw new NotFoundException('租户不存在');
const data: any = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.description !== undefined) data.description = dto.description;
return this.prisma.tenant.update({
where: { id: tenantId },
data,
});
}
/** 切换租户启用/停用状态 */
async toggleStatus(id: number, currentTenantId?: number) {
await this.checkSuperTenant(currentTenantId);
const tenant = await this.prisma.tenant.findUnique({ where: { id } });
if (!tenant) throw new NotFoundException('租户不存在');
if (tenant.isSuper === 1) throw new BadRequestException('不能停用超级租户');
return this.prisma.tenant.update({
where: { id },
data: { validState: tenant.validState === 1 ? 2 : 1 },
});
}
async findOne(id: number) {

View File

@ -86,12 +86,17 @@ export class UsersController {
}
@Patch(':id')
update(
async update(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
@Request() req,
) {
const tenantId = req.tenantId || req.user?.tenantId;
// 超管端可以更新任意租户的用户
const tenant = await this.usersService.getTenant(tenantId);
if (tenant?.isSuper === 1) {
return this.usersService.update(+id, updateUserDto, undefined);
}
return this.usersService.update(+id, updateUserDto, tenantId);
}

View File

@ -12,6 +12,10 @@ import * as bcrypt from 'bcrypt';
export class UsersService {
constructor(private prisma: PrismaService) {}
async getTenant(tenantId: number) {
return this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, isSuper: true } });
}
async create(createUserDto: CreateUserDto, tenantId: number) {
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
const { roleIds, ...userData } = createUserDto;
@ -125,7 +129,7 @@ export class UsersService {
select: { id: true, name: true, code: true, tenantType: true, isSuper: true },
};
include._count = {
select: { children: true, contestRegistrations: true },
select: { parentRelations: true, contestRegistrations: true },
};
}
@ -194,8 +198,18 @@ export class UsersService {
tenant: {
select: { id: true, name: true, code: true, tenantType: true, isSuper: true },
},
children: isSuperTenant
? { where: { isDeleted: 0 }, orderBy: { createTime: 'desc' } }
parentRelations: isSuperTenant
? {
include: {
child: {
select: {
id: true, username: true, nickname: true, avatar: true,
gender: true, birthday: true, city: true, status: true, createTime: true,
},
},
},
orderBy: { createTime: 'desc' },
}
: false,
contestRegistrations: isSuperTenant
? {

View File

@ -4,24 +4,29 @@
| 文档 | 模块 | 状态 | 日期 |
|------|------|------|------|
| [统一用户管理](./super-admin/unified-user-management.md) | 用户中心 | 已实现(待验收 | 2026-03-27 |
| [统一用户管理](./super-admin/unified-user-management.md) | 用户中心 | 已实现(迭代中 | 2026-03-27 |
| [全部活动优化](./super-admin/activity-list-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
| [报名数据优化](./super-admin/registration-data-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
| [作品数据优化](./super-admin/works-data-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
| [评审进度优化](./super-admin/review-progress-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
| [成果发布优化](./super-admin/results-publish-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
| [内容管理模块](./super-admin/content-management.md) | 内容管理(新增) | P0 已实现 | 2026-03-27 |
| [内容管理模块](./super-admin/content-management.md) | 内容管理 | P0 已实现并优化 | 2026-03-31 |
| [机构管理优化](./super-admin/org-management.md) | 机构管理 | 已优化 | 2026-03-31 |
## 机构管理端
## 租户端(机构管理端
(暂无)
| 文档 | 模块 | 状态 | 日期 |
|------|------|------|------|
| [租户端全面优化](./org-admin/tenant-portal-optimization.md) | 全模块 | 已优化 | 2026-03-31 |
| [数据统计看板](./org-admin/data-analytics-dashboard.md) | 数据统计 | 已实现 | 2026-03-31 |
## 用户端(公众端)
| 文档 | 模块 | 状态 | 日期 |
|------|------|------|------|
| [UGC社区升级](./public/ugc-platform-upgrade.md) | 整体架构 | 需求已确认 | 2026-03-27 |
| [UGC开发计划](./public/ugc-development-plan.md) | 开发排期 | P0 已实现 | 2026-03-27 |
| [UGC开发计划](./public/ugc-development-plan.md) | 开发排期 | P0已完成P1进行中 | 2026-03-31 |
| [点赞&收藏](./public/like-favorite.md) | 社区互动 | 已实现 | 2026-03-31 |
## 评委端

View File

@ -0,0 +1,445 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数据统计 — 活动管理平台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,500;0,9..40,700;1,9..40,400&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca' },
surface: '#f8f7fc',
card: '#ffffff',
},
fontFamily: {
display: ['"DM Sans"', '"Noto Sans SC"', 'system-ui'],
body: ['"Noto Sans SC"', '"DM Sans"', 'system-ui'],
mono: ['"DM Sans"', 'monospace'],
},
borderRadius: { 'card': '12px' },
boxShadow: {
'card': '0 2px 12px rgba(0,0,0,0.06)',
'card-hover': '0 8px 24px rgba(99,102,241,0.12)',
}
}
}
}
</script>
<style>
body { background: #f8f7fc; }
.tab-active { color: #6366f1; border-bottom: 2px solid #6366f1; font-weight: 700; }
.tab-inactive { color: #9ca3af; border-bottom: 2px solid transparent; }
.tab-inactive:hover { color: #6b7280; }
.stat-card { transition: all 0.25s cubic-bezier(0.4,0,0.2,1); }
.stat-card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px rgba(99,102,241,0.12); }
.funnel-bar { transition: width 0.8s cubic-bezier(0.4,0,0.2,1); }
.fade-in { animation: fadeIn 0.5s ease both; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.stagger-1 { animation-delay: 0.05s; }
.stagger-2 { animation-delay: 0.1s; }
.stagger-3 { animation-delay: 0.15s; }
.stagger-4 { animation-delay: 0.2s; }
.stagger-5 { animation-delay: 0.25s; }
.stagger-6 { animation-delay: 0.3s; }
table th { font-weight: 600; font-size: 13px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.03em; }
table td { font-size: 14px; }
.rate-pill { display: inline-flex; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; }
select { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; }
</style>
</head>
<body class="font-body text-gray-800 min-h-screen">
<!-- Header -->
<div class="max-w-[1280px] mx-auto px-6 pt-6">
<!-- Title -->
<div class="bg-white rounded-card shadow-card px-6 py-4 mb-5 flex items-center justify-between">
<h1 class="text-xl font-display font-bold text-gray-900 tracking-tight">数据统计</h1>
<div class="flex items-center gap-3">
<button onclick="exportPDF()" class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 text-sm font-medium text-gray-600 hover:bg-gray-50 transition">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M12 16V4m0 12l-4-4m4 4l4-4M4 20h16"/></svg>
导出 PDF
</button>
<button onclick="exportExcel()" class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 text-sm font-medium text-gray-600 hover:bg-gray-50 transition">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M9 17H5a2 2 0 01-2-2V5a2 2 0 012-2h4m6 0h4a2 2 0 012 2v10a2 2 0 01-2 2h-4m-6-8l6 6m0-6l-6 6"/></svg>
导出 Excel
</button>
</div>
</div>
<!-- Tabs + Filters -->
<div class="bg-white rounded-card shadow-card px-6 py-0 mb-5 flex items-center justify-between">
<div class="flex gap-6">
<button id="tab-overview" onclick="switchTab('overview')" class="tab-active py-4 text-sm font-display cursor-pointer transition-colors">运营概览</button>
<button id="tab-review" onclick="switchTab('review')" class="tab-inactive py-4 text-sm font-display cursor-pointer transition-colors">评审分析</button>
</div>
<div class="flex items-center gap-3">
<select class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-gray-700 font-medium focus:outline-none focus:ring-2 focus:ring-primary-200">
<option>本月</option><option>本季度</option><option>本年</option><option>全部</option>
</select>
<select class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-gray-700 font-medium focus:outline-none focus:ring-2 focus:ring-primary-200">
<option>全部活动</option><option>2026年少儿绘本创作大赛</option><option>第三届亲子阅读绘画展</option><option>寒假绘本阅读打卡活动</option>
</select>
</div>
</div>
<!-- Tab Content: Overview -->
<div id="content-overview">
<!-- Stat Cards -->
<div class="grid grid-cols-6 gap-4 mb-5">
<div class="stat-card fade-in stagger-1 bg-white rounded-card shadow-card p-4 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center text-lg" style="background:rgba(99,102,241,0.1);color:#6366f1">
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6z"/></svg>
</div>
<div>
<div class="text-2xl font-display font-bold text-gray-900 leading-none">6</div>
<div class="text-xs text-gray-400 mt-0.5">活动总数</div>
</div>
</div>
</div>
<div class="stat-card fade-in stagger-2 bg-white rounded-card shadow-card p-4 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(59,130,246,0.1);color:#3b82f6">
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/></svg>
</div>
<div>
<div class="text-2xl font-display font-bold text-gray-900 leading-none">12</div>
<div class="text-xs text-gray-400 mt-0.5">累计报名</div>
</div>
</div>
</div>
<div class="stat-card fade-in stagger-3 bg-white rounded-card shadow-card p-4 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(16,185,129,0.1);color:#10b981">
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
</div>
<div>
<div class="text-2xl font-display font-bold text-gray-900 leading-none">10</div>
<div class="text-xs text-gray-400 mt-0.5">报名通过</div>
</div>
</div>
</div>
<div class="stat-card fade-in stagger-4 bg-white rounded-card shadow-card p-4 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(245,158,11,0.1);color:#f59e0b">
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd"/></svg>
</div>
<div>
<div class="text-2xl font-display font-bold text-gray-900 leading-none">8</div>
<div class="text-xs text-gray-400 mt-0.5">作品总数</div>
</div>
</div>
</div>
<div class="stat-card fade-in stagger-5 bg-white rounded-card shadow-card p-4 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(20,184,166,0.1);color:#14b8a6">
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
</div>
<div>
<div class="text-2xl font-display font-bold text-gray-900 leading-none">5</div>
<div class="text-xs text-gray-400 mt-0.5">已完成评审</div>
</div>
</div>
</div>
<div class="stat-card fade-in stagger-6 bg-white rounded-card shadow-card p-4 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(239,68,68,0.1);color:#ef4444">
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5 2a2 2 0 00-2 2v14l3.5-2 3.5 2 3.5-2 3.5 2V4a2 2 0 00-2-2H5zm2.5 3a1.5 1.5 0 100 3 1.5 1.5 0 000-3zm6.207.293a1 1 0 00-1.414 0l-6 6a1 1 0 101.414 1.414l6-6a1 1 0 000-1.414zM12.5 10a1.5 1.5 0 100 3 1.5 1.5 0 000-3z" clip-rule="evenodd"/></svg>
</div>
<div>
<div class="text-2xl font-display font-bold text-gray-900 leading-none">3</div>
<div class="text-xs text-gray-400 mt-0.5">获奖作品</div>
</div>
</div>
</div>
</div>
<!-- Funnel + Trend -->
<div class="grid grid-cols-2 gap-5 mb-5">
<!-- Funnel -->
<div class="bg-white rounded-card shadow-card p-6 fade-in" style="animation-delay:0.35s">
<h3 class="text-sm font-display font-bold text-gray-900 mb-5 tracking-tight">报名转化漏斗</h3>
<div class="space-y-3">
<div>
<div class="flex items-center justify-between mb-1.5">
<span class="text-sm font-medium text-gray-700">报名</span>
<span class="text-sm font-display font-bold text-gray-900">12</span>
</div>
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:100%;background:linear-gradient(90deg,#6366f1,#818cf8)"></div></div>
</div>
<div>
<div class="flex items-center justify-between mb-1.5">
<span class="text-sm font-medium text-gray-700">通过审核</span>
<div class="flex items-center gap-2"><span class="rate-pill bg-green-50 text-green-600">83.3%</span><span class="text-sm font-display font-bold text-gray-900">10</span></div>
</div>
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:83.3%;background:linear-gradient(90deg,#10b981,#34d399)"></div></div>
</div>
<div>
<div class="flex items-center justify-between mb-1.5">
<span class="text-sm font-medium text-gray-700">提交作品</span>
<div class="flex items-center gap-2"><span class="rate-pill bg-blue-50 text-blue-600">80.0%</span><span class="text-sm font-display font-bold text-gray-900">8</span></div>
</div>
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:66.7%;background:linear-gradient(90deg,#3b82f6,#60a5fa)"></div></div>
</div>
<div>
<div class="flex items-center justify-between mb-1.5">
<span class="text-sm font-medium text-gray-700">评审完成</span>
<div class="flex items-center gap-2"><span class="rate-pill bg-amber-50 text-amber-600">62.5%</span><span class="text-sm font-display font-bold text-gray-900">5</span></div>
</div>
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:41.7%;background:linear-gradient(90deg,#f59e0b,#fbbf24)"></div></div>
</div>
<div>
<div class="flex items-center justify-between mb-1.5">
<span class="text-sm font-medium text-gray-700">获奖</span>
<div class="flex items-center gap-2"><span class="rate-pill bg-red-50 text-red-500">60.0%</span><span class="text-sm font-display font-bold text-gray-900">3</span></div>
</div>
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:25%;background:linear-gradient(90deg,#ef4444,#f87171)"></div></div>
</div>
</div>
</div>
<!-- Trend Chart -->
<div class="bg-white rounded-card shadow-card p-6 fade-in" style="animation-delay:0.4s">
<h3 class="text-sm font-display font-bold text-gray-900 mb-4 tracking-tight">月度趋势</h3>
<div id="trendChart" style="height:280px"></div>
</div>
</div>
<!-- Contest Comparison Table -->
<div class="bg-white rounded-card shadow-card p-6 fade-in" style="animation-delay:0.45s">
<h3 class="text-sm font-display font-bold text-gray-900 mb-4 tracking-tight">活动对比</h3>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr class="border-b border-gray-100">
<th class="py-3 px-4">活动名称</th>
<th class="py-3 px-4 text-center">报名数</th>
<th class="py-3 px-4 text-center">通过率</th>
<th class="py-3 px-4 text-center">提交率</th>
<th class="py-3 px-4 text-center">评审完成率</th>
<th class="py-3 px-4 text-center">获奖率</th>
<th class="py-3 px-4 text-center">平均分</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-50 hover:bg-primary-50/30 transition-colors">
<td class="py-3.5 px-4 font-medium text-gray-900">2026年少儿绘本创作大赛</td>
<td class="py-3.5 px-4 text-center font-display font-bold">5</td>
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-amber-50 text-amber-600">60%</span></td>
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
<td class="py-3.5 px-4 text-center font-display font-bold text-primary-500">84.89</td>
</tr>
<tr class="border-b border-gray-50 hover:bg-primary-50/30 transition-colors">
<td class="py-3.5 px-4 font-medium text-gray-900">第三届亲子阅读绘画展</td>
<td class="py-3.5 px-4 text-center font-display font-bold">4</td>
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-amber-50 text-amber-600">75%</span></td>
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-gray-100 text-gray-400">0%</span></td>
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-gray-100 text-gray-400">0%</span></td>
<td class="py-3.5 px-4 text-center text-gray-300">-</td>
</tr>
<tr class="hover:bg-primary-50/30 transition-colors">
<td class="py-3.5 px-4 font-medium text-gray-900">寒假绘本阅读打卡活动</td>
<td class="py-3.5 px-4 text-center font-display font-bold">3</td>
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-amber-50 text-amber-600">67%</span></td>
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-gray-100 text-gray-400">0%</span></td>
<td class="py-3.5 px-4 text-center font-display font-bold text-primary-500">85.33</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Tab Content: Review Analysis -->
<div id="content-review" class="hidden">
<!-- Review Efficiency Cards -->
<div class="grid grid-cols-4 gap-4 mb-5">
<div class="stat-card fade-in stagger-1 bg-white rounded-card shadow-card p-5">
<div class="flex items-center gap-3">
<div class="w-11 h-11 rounded-xl flex items-center justify-center" style="background:rgba(59,130,246,0.1);color:#3b82f6">
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<div>
<div class="text-2xl font-display font-bold text-gray-900 leading-none">3.2<span class="text-sm font-normal text-gray-400 ml-0.5"></span></div>
<div class="text-xs text-gray-400 mt-1">平均评审周期</div>
</div>
</div>
</div>
<div class="stat-card fade-in stagger-2 bg-white rounded-card shadow-card p-5">
<div class="flex items-center gap-3">
<div class="w-11 h-11 rounded-xl flex items-center justify-center" style="background:rgba(16,185,129,0.1);color:#10b981">
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<div>
<div class="text-2xl font-display font-bold text-gray-900 leading-none">1.5<span class="text-sm font-normal text-gray-400 ml-0.5">个/日</span></div>
<div class="text-xs text-gray-400 mt-1">日均评审量</div>
</div>
</div>
</div>
<div class="stat-card fade-in stagger-3 bg-white rounded-card shadow-card p-5">
<div class="flex items-center gap-3">
<div class="w-11 h-11 rounded-xl flex items-center justify-center" style="background:rgba(239,68,68,0.1);color:#ef4444">
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M12 9v2m0 4h.01M5.07 19H19a2.18 2.18 0 001.9-3.2L13.9 4a2.18 2.18 0 00-3.8 0L3.17 15.8A2.18 2.18 0 005.07 19z"/></svg>
</div>
<div>
<div class="text-2xl font-display font-bold text-gray-900 leading-none">2<span class="text-sm font-normal text-gray-400 ml-0.5"></span></div>
<div class="text-xs text-gray-400 mt-1">待评审积压</div>
</div>
</div>
</div>
<div class="stat-card fade-in stagger-4 bg-white rounded-card shadow-card p-5">
<div class="flex items-center gap-3">
<div class="w-11 h-11 rounded-xl flex items-center justify-center" style="background:rgba(99,102,241,0.1);color:#6366f1">
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
</div>
<div>
<div class="text-2xl font-display font-bold text-gray-900 leading-none">2.8<span class="text-sm font-normal text-gray-400 ml-0.5"></span></div>
<div class="text-xs text-gray-400 mt-1">评分一致性</div>
<div class="text-[10px] text-gray-300 mt-0.5">标准差越小越好</div>
</div>
</div>
</div>
</div>
<!-- Judge Workload + Award Distribution -->
<div class="grid grid-cols-5 gap-5 mb-5">
<!-- Judge Table -->
<div class="col-span-3 bg-white rounded-card shadow-card p-6 fade-in" style="animation-delay:0.25s">
<h3 class="text-sm font-display font-bold text-gray-900 mb-4 tracking-tight">评委工作量</h3>
<table class="w-full text-left">
<thead>
<tr class="border-b border-gray-100">
<th class="py-3 px-3">评委姓名</th>
<th class="py-3 px-3 text-center">关联活动</th>
<th class="py-3 px-3 text-center">已分配</th>
<th class="py-3 px-3 text-center">已评分</th>
<th class="py-3 px-3 text-center">完成率</th>
<th class="py-3 px-3 text-center">平均分</th>
<th class="py-3 px-3 text-center">标准差</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-50 hover:bg-primary-50/30 transition-colors">
<td class="py-3.5 px-3">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white text-xs font-bold"></div>
<span class="font-medium text-gray-900">陈评委</span>
</div>
</td>
<td class="py-3.5 px-3 text-center font-display">2</td>
<td class="py-3.5 px-3 text-center font-display">6</td>
<td class="py-3.5 px-3 text-center font-display">6</td>
<td class="py-3.5 px-3 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
<td class="py-3.5 px-3 text-center font-display font-bold text-primary-500">85.67</td>
<td class="py-3.5 px-3 text-center"><span class="text-sm text-amber-500 font-medium">5.89</span></td>
</tr>
<tr class="border-b border-gray-50 hover:bg-primary-50/30 transition-colors">
<td class="py-3.5 px-3">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-xs font-bold"></div>
<span class="font-medium text-gray-900">李评委</span>
</div>
</td>
<td class="py-3.5 px-3 text-center font-display">2</td>
<td class="py-3.5 px-3 text-center font-display">6</td>
<td class="py-3.5 px-3 text-center font-display">6</td>
<td class="py-3.5 px-3 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
<td class="py-3.5 px-3 text-center font-display font-bold text-primary-500">83.00</td>
<td class="py-3.5 px-3 text-center"><span class="text-sm text-green-500 font-medium">5.10</span></td>
</tr>
<tr class="hover:bg-primary-50/30 transition-colors">
<td class="py-3.5 px-3">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center text-white text-xs font-bold"></div>
<span class="font-medium text-gray-900">王评委</span>
</div>
</td>
<td class="py-3.5 px-3 text-center font-display">2</td>
<td class="py-3.5 px-3 text-center font-display">6</td>
<td class="py-3.5 px-3 text-center font-display">6</td>
<td class="py-3.5 px-3 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
<td class="py-3.5 px-3 text-center font-display font-bold text-primary-500">86.00</td>
<td class="py-3.5 px-3 text-center"><span class="text-sm text-green-500 font-medium">3.27</span></td>
</tr>
</tbody>
</table>
</div>
<!-- Award Distribution -->
<div class="col-span-2 bg-white rounded-card shadow-card p-6 fade-in" style="animation-delay:0.3s">
<h3 class="text-sm font-display font-bold text-gray-900 mb-4 tracking-tight">奖项分布</h3>
<div id="awardChart" style="height:260px"></div>
</div>
</div>
</div>
<div class="h-8"></div>
</div>
<script>
// Tab switching
function switchTab(tab) {
document.getElementById('content-overview').classList.toggle('hidden', tab !== 'overview');
document.getElementById('content-review').classList.toggle('hidden', tab !== 'review');
document.getElementById('tab-overview').className = tab === 'overview' ? 'tab-active py-4 text-sm font-display cursor-pointer transition-colors' : 'tab-inactive py-4 text-sm font-display cursor-pointer transition-colors';
document.getElementById('tab-review').className = tab === 'review' ? 'tab-active py-4 text-sm font-display cursor-pointer transition-colors' : 'tab-inactive py-4 text-sm font-display cursor-pointer transition-colors';
if (tab === 'review') { initAwardChart(); }
}
// Trend Chart
const trendChart = echarts.init(document.getElementById('trendChart'));
trendChart.setOption({
tooltip: { trigger: 'axis', backgroundColor: '#fff', borderColor: '#e5e7eb', borderWidth: 1, textStyle: { color: '#374151', fontSize: 13, fontFamily: 'DM Sans, Noto Sans SC' }, boxShadow: '0 4px 12px rgba(0,0,0,0.08)' },
legend: { data: ['报名量', '作品量'], bottom: 0, textStyle: { fontSize: 12, color: '#9ca3af', fontFamily: 'Noto Sans SC' }, itemWidth: 16, itemHeight: 3, itemGap: 24 },
grid: { left: 40, right: 16, top: 16, bottom: 40 },
xAxis: { type: 'category', data: ['10月', '11月', '12月', '1月', '2月', '3月'], axisLine: { lineStyle: { color: '#e5e7eb' } }, axisLabel: { color: '#9ca3af', fontSize: 12 }, axisTick: { show: false } },
yAxis: { type: 'value', axisLine: { show: false }, axisTick: { show: false }, splitLine: { lineStyle: { color: '#f3f4f6', type: 'dashed' } }, axisLabel: { color: '#9ca3af', fontSize: 12 } },
series: [
{ name: '报名量', type: 'line', data: [3, 5, 8, 6, 12, 15], smooth: true, symbol: 'circle', symbolSize: 6, lineStyle: { width: 3, color: '#6366f1' }, itemStyle: { color: '#6366f1', borderWidth: 2, borderColor: '#fff' }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(99,102,241,0.15)' }, { offset: 1, color: 'rgba(99,102,241,0)' }]) } },
{ name: '作品量', type: 'line', data: [1, 3, 5, 4, 8, 10], smooth: true, symbol: 'circle', symbolSize: 6, lineStyle: { width: 3, color: '#f59e0b' }, itemStyle: { color: '#f59e0b', borderWidth: 2, borderColor: '#fff' }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(245,158,11,0.12)' }, { offset: 1, color: 'rgba(245,158,11,0)' }]) } }
]
});
// Award Chart
function initAwardChart() {
const el = document.getElementById('awardChart');
if (!el) return;
const chart = echarts.init(el);
chart.setOption({
tooltip: { trigger: 'item', backgroundColor: '#fff', borderColor: '#e5e7eb', borderWidth: 1, textStyle: { color: '#374151', fontSize: 13 } },
legend: { bottom: 0, textStyle: { fontSize: 12, color: '#9ca3af' }, itemWidth: 12, itemHeight: 12, itemGap: 16 },
series: [{
type: 'pie', radius: ['45%', '72%'], center: ['50%', '45%'],
label: { show: true, formatter: '{b}\n{d}%', fontSize: 12, color: '#6b7280', lineHeight: 18 },
labelLine: { length: 12, length2: 8 },
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 3 },
data: [
{ value: 1, name: '一等奖', itemStyle: { color: '#ef4444' } },
{ value: 1, name: '二等奖', itemStyle: { color: '#f59e0b' } },
{ value: 1, name: '三等奖', itemStyle: { color: '#3b82f6' } }
],
emphasis: { itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,0,0,0.12)' } }
}]
});
}
// Resize
window.addEventListener('resize', () => { trendChart.resize(); });
// Export placeholders
function exportPDF() { alert('PDF 导出功能将在开发时实现'); }
function exportExcel() { alert('Excel 导出功能将在开发时实现'); }
</script>
</body>
</html>

View File

@ -0,0 +1,238 @@
# 租户端数据统计分析看板 — 设计方案
> 所属端:租户端(机构管理端)
> 状态:已实现
> 创建日期2026-03-31
> 最后更新2026-03-31
---
## 1. 需求背景
机构领导需要一个数据统计看板来了解:
- 活动运营情况:活动办得怎么样,报名和参赛情况
- 运营效率:审核速度、评审进度、整体运营时效
- 评委工作量:每位评委评了多少作品、评分质量
## 2. 数据来源盘点
基于现有系统已实现的功能,可用的数据表和字段:
| 数据表 | 可用维度 | 可用指标 |
|--------|----------|----------|
| t_contest | 活动名称、类型、状态(ongoing/finished)、发布状态、各时间节点 | 活动数量、阶段分布 |
| t_contest_registration | 活动ID、审核状态(pending/passed/rejected)、报名时间 | 报名数、通过率、时间分布 |
| t_contest_work | 活动ID、状态(submitted/reviewing/accepted/awarded)、提交时间、最终得分、排名、奖项 | 作品数、评审状态分布、得分分布、获奖分布 |
| t_contest_work_score | 作品ID、评委ID、分数、评分时间 | 评委评分量、评分时间分布 |
| t_contest_work_judge_assignment | 作品ID、评委ID、状态 | 分配完成率 |
| t_contest_judge | 活动ID、评委ID | 评委数量、评委-活动关联 |
| t_contest_notice | 活动ID、发布时间 | 公告数量 |
## 3. 看板设计
### 3.1 整体结构
```
┌──────────────────────────────────────────────────────────────┐
│ 数据统计 时间范围: [本月▾] [活动▾] │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │活动数│ │报名数│ │通过数│ │作品数│ │已评审│ │获奖数│ │
│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │
│ ┌───── 报名转化漏斗 ─────┐ ┌────── 月度趋势 ──────────┐ │
│ │ 报名 → 通过 → 提交 │ │ 📈 报名量/作品量折线图 │ │
│ │ → 评审完成 → 获奖 │ │ │ │
│ └────────────────────────┘ └──────────────────────────┘ │
│ │
│ ┌───── 活动对比 ─────────────────────────────────────────┐ │
│ │ 表格:各活动 报名/通过率/作品提交率/评审完成率/获奖率 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌──── 评委工作量 ────────┐ ┌────── 奖项分布 ──────────┐ │
│ │ 表格:评委 评审量/均分 │ │ 🥧 饼图:各奖项占比 │ │
│ └────────────────────────┘ └──────────────────────────┘ │
│ │
│ ┌──── 评审效率 ──────────────────────────────────────────┐ │
│ │ 平均评审周期 │ 日均评审量 │ 待评审积压 │ 评分标准差 │ │
│ └───────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
### 3.2 模块详细设计
#### 模块A核心指标卡片顶部
6 个数字卡片,一行排列:
| 指标 | 数据来源 | 计算方式 |
|------|----------|----------|
| 活动总数 | t_contest | COUNT WHERE tenant 可见 |
| 累计报名 | t_contest_registration | COUNT WHERE tenant_id |
| 报名通过 | t_contest_registration | COUNT WHERE registration_state='passed' |
| 作品总数 | t_contest_work | COUNT WHERE valid_state=1 |
| 已完成评审 | t_contest_work | COUNT WHERE status IN ('accepted','awarded') |
| 获奖作品 | t_contest_work | COUNT WHERE award_level IS NOT NULL AND award_level != 'none' |
#### 模块B报名转化漏斗
展示从报名到获奖的转化路径和各环节转化率:
```
报名人数 (12) → 通过审核 (10) → 提交作品 (8) → 评审完成 (5) → 获奖 (3)
83.3% 80.0% 62.5% 60.0%
```
数据来源:
- 报名人数registration COUNT
- 通过审核registration COUNT WHERE state='passed'
- 提交作品work COUNT WHERE valid_state=1
- 评审完成work COUNT WHERE status IN ('accepted','awarded')
- 获奖work COUNT WHERE award_name IS NOT NULL
#### 模块C月度趋势图
折线图X轴为月份Y轴双轴
- 左轴:报名数量(按 registration_time 月份分组)
- 右轴:作品数量(按 submit_time 月份分组)
时间范围最近6个月
数据来源:
```sql
-- 月度报名
SELECT DATE_FORMAT(registration_time, '%Y-%m') as month, COUNT(*)
FROM t_contest_registration WHERE tenant_id=?
GROUP BY month ORDER BY month
-- 月度作品
SELECT DATE_FORMAT(submit_time, '%Y-%m') as month, COUNT(*)
FROM t_contest_work WHERE tenant_id=? AND valid_state=1
GROUP BY month ORDER BY month
```
#### 模块D活动对比表
表格形式,每行一个活动:
| 列 | 数据来源 |
|----|----------|
| 活动名称 | t_contest.contest_name |
| 报名数 | registration COUNT |
| 通过率 | passed COUNT / total COUNT × 100% |
| 作品提交率 | work COUNT / passed registration COUNT × 100% |
| 评审完成率 | (accepted+awarded) COUNT / work COUNT × 100% |
| 获奖率 | awarded COUNT / work COUNT × 100% |
| 平均得分 | AVG(final_score) |
#### 模块E评委工作量
表格形式,每行一个评委:
| 列 | 数据来源 |
|----|----------|
| 评委姓名 | users.nickname via t_contest_judge |
| 关联活动数 | t_contest_judge COUNT DISTINCT contest_id |
| 已分配作品数 | t_contest_work_judge_assignment COUNT |
| 已评分作品数 | t_contest_work_score COUNT |
| 评分完成率 | scored / assigned × 100% |
| 平均打分 | AVG(total_score) |
| 评分标准差 | STDDEV(total_score)(衡量评分一致性,越小越一致) |
#### 模块F奖项分布
饼图/环形图,展示获奖作品中各奖项的占比:
数据来源:
```sql
SELECT award_name, COUNT(*)
FROM t_contest_work
WHERE tenant_id=? AND award_name IS NOT NULL AND valid_state=1
GROUP BY award_name
```
#### 模块G评审效率指标
4 个数字卡片:
| 指标 | 计算方式 |
|------|----------|
| 平均评审周期 | AVG(score_time - submit_time),从作品提交到第一次评分的平均天数 |
| 日均评审量 | 最近30天 score COUNT / 30 |
| 待评审积压 | assignment COUNT WHERE status='assigned'(已分配未评分) |
| 评分一致性 | 所有作品的评委间评分标准差的平均值(越小越好) |
### 3.3 筛选条件
顶部全局筛选栏:
| 筛选 | 类型 | 说明 |
|------|------|------|
| 时间范围 | 下拉 | 本月/本季度/本年/全部/自定义时间段 |
| 指定活动 | 下拉 | 全部活动 / 选择特定活动(切换后所有模块联动) |
### 3.4 交互设计
- 数字卡片可点击,跳转到对应管理页面(如点击「累计报名」跳到报名管理)
- 活动对比表的活动名称可点击,切换筛选到该活动
- 评委工作量表的评委名可点击查看评分明细
- 所有图表支持 hover 显示详细数据
- 支持将看板数据导出为 PDF/Excel
## 4. 菜单位置
新增一级菜单「数据统计」,放在「活动管理」之后:
```
工作台
活动管理
├── ...
数据统计(新增)
├── 运营概览 — 核心卡片 + 漏斗 + 趋势 + 活动对比
└── 评审分析 — 评委工作量 + 评审效率 + 奖项分布
系统设置
├── ...
```
## 5. 后端 API 设计
### 5.1 运营概览
```
GET /api/analytics/overview
参数: timeRange(month/quarter/year/all), contestId?(可选)
返回:
{
summary: { totalContests, totalRegistrations, passedRegistrations, totalWorks, reviewedWorks, awardedWorks },
funnel: { registered, passed, submitted, reviewed, awarded },
monthlyTrend: [{ month, registrations, works }],
contestComparison: [{
contestId, contestName,
registrations, passRate, submitRate, reviewRate, awardRate, avgScore
}]
}
```
### 5.2 评审分析
```
GET /api/analytics/review
参数: timeRange, contestId?
返回:
{
efficiency: { avgReviewDays, dailyReviewCount, pendingAssignments, avgScoreStddev },
judgeWorkload: [{
judgeId, judgeName,
contestCount, assignedCount, scoredCount, completionRate, avgScore, scoreStddev
}],
awardDistribution: [{ awardName, count, percentage }]
}
```
## 6. 技术方案
- 前端图表库:使用 ECharts 或 Ant Design Charts@ant-design/charts
- 数据缓存:统计数据变化不频繁,后端可加 5 分钟缓存
- 大数据量:月度趋势等聚合查询用 GROUP BY + 索引优化
- 导出:前端生成 PDFhtml2canvas + jsPDF或 CSV

View File

@ -0,0 +1,180 @@
# 租户端(机构管理端)全面优化记录
> 所属端:租户端(机构管理员视角)
> 状态:已优化
> 创建日期2026-03-31
> 最后更新2026-03-31
---
## 概述
以广东省立中山图书馆gdlib为典型租户从机构管理员/运营人员视角全面审查并优化了租户端的所有模块。
## Day5 (2026-03-31) — 优化内容
### 基础设施
- [x] 数据隔离验证:确认活动/报名/作品查询全部带 tenantId 过滤
- [x] 日志菜单权限修复:补充 log:read 权限
- [x] 公告权限修复:补充 notice:update / notice:delete 权限
- [x] 403 报错修复fetchTenants 调用加 isSuperAdmin 守卫contests/Index, system/users/Index
- [x] 评审规则组件映射修复contests/ReviewRules 指向正确的 reviews/Index.vue
- [x] 作品详情路由权限修复work:read 改为 contest:work:read
### 工作台(新增)
- [x] 新增租户端工作台页面TenantDashboard.vue
- [x] 欢迎信息 + 机构标识(时段问候、管理员姓名、机构名称/类型)
- [x] 6个统计卡片可见活动/进行中/总报名/待审核报名/总作品/今日报名),可点击跳转
- [x] 空数据新手引导(三步:创建活动→添加成员→邀请评委)
- [x] 快捷操作按权限动态显示
- [x] 待办提醒(待审核报名 + 即将截止的活动)
- [x] 最近活动列表 + 查看全部入口
- [x] 后端 GET /contests/dashboard 接口
### 机构信息(新增)
- [x] 新增机构信息管理页面tenant-info/Index.vue
- [x] 查看/编辑机构名称和描述
- [x] 复制登录地址
- [x] 后端 GET/PATCH /tenants/my-tenant 接口
### 活动列表
- [x] 租户端加统计概览6个阶段卡片后端 getStats 加 tenantId 过滤)
- [x] 精简表格列(去掉主办方/可见范围/公开机构,加活动阶段列)
- [x] 筛选自动查询(下拉 @change
- [x] 报名/作品数可点击跳转
- [x] 修复发布弹窗机构选择 bug租户端用 my-tenant 接口获取自己信息)
- [x] 操作按钮逻辑优化(未发布:发布/编辑/删除;已发布:查看/评委/编辑/取消发布)
### 创建/编辑活动
- [x] 重构页面布局:去掉 card 嵌套,改为独立分区卡片
- [x] 修复 form layout 冲突vertical + labelCol
- [x] 去掉固定宽度,改用栅格响应式
- [x] 4 个分区:主办信息、活动信息、图片附件、时间配置
### 评委管理
- [x] 筛选自动查询
- [x] 导入/导出改为 disabled + tooltip
- [x] 主色调统一 #6366f1
- [x] 冻结/解冻二次确认
### 报名管理Index
- [x] 去掉个人/团队 Tab合并展示加类型列
- [x] 统计概览(总报名/待审核/已通过/已拒绝)
- [x] 表格加审核状态分类计数列(并行查询每个活动的统计)
- [x] 去掉手动启动/关闭报名
### 报名记录Records
- [x] 主色调统一
- [x] 统计概览 + 可点击筛选
- [x] 租户端去掉机构列
- [x] 筛选自动查询
- [x] 通过加二次确认
- [x] 批量审核改用后端批量接口 POST /contests/registrations/batch-review
- [x] 返回按钮
- [x] 去掉「参与方式」列(子女已改为独立账号)
- [x] 撤销审核功能 PATCH /contests/registrations/:id/revoke
### 作品管理Index
- [x] 去掉 Tab加统计概览 + 类型筛选
- [x] 递交进度彩色数字(已交/应交)
- [x] 活动名可点击
### 作品详情WorksDetail
- [x] 返回按钮
- [x] 统计概览
- [x] 租户端去掉机构筛选
- [x] 筛选自动查询(分配状态、递交时间、机构下拉)
- [x] 后端支持 assignStatus / name / submitStartTime / submitEndTime 筛选
- [x] 分配评委去掉评审时间限制(任何时候都可分配)
### 评审进度
- [x] 去掉 Tab加统计概览 + 类型筛选
- [x] 评审状态改用实际完成率(无作品/未开始/进行中/已完成)
- [x] 进度数字颜色区分
- [x] 评审进度详情页筛选修复(评审进度前端过滤生效)
### 评审规则
- [x] 组件映射修复
- [x] 主色调统一
- [x] 表格加评委数/计算方式列
- [x] 修复规则描述列数据展示错误
- [x] 已关联活动删除保护提示
- [x] Drawer 标题区分新建/编辑
### 成果发布Index
- [x] 去掉 Tab加统计概览全部/已发布/未发布)
- [x] 加发布状态筛选 + 类型筛选
- [x] 活动名可点击
- [x] 操作按钮文案优化(查看成果/发布成果)
### 成果发布详情Detail— 功能补全
- [x] 统计摘要(总作品/已评分/已排名/已设奖/平均分)
- [x] 三步操作流程(计算得分→计算排名→设置奖项)
- [x] 排名列(金银铜色徽章)
- [x] 奖项列(彩色标签)
- [x] 奖项筛选(动态从数据提取)
- [x] 单个设奖combobox选项来自自动设奖配置 + 已有数据)
- [x] 自动设奖改为自定义奖项(动态添加行:奖项名称+人数)
- [x] 后端 AutoSetAwardsDto 改为 awards 数组格式
- [x] 发布按钮二次确认
### 通知公告
- [x] 主色调统一
- [x] 发布/取消发布二次确认
- [x] 操作逻辑优化(未发布:发布/编辑/删除;已发布:查看/取消发布)
- [x] 发布状态筛选
- [x] 日期改为时间范围选择器
- [x] 创建时间列 + 按创建时间倒序
- [x] 后端支持 publishStartDate / publishEndDate 范围查询
### 新增 API
```
GET /contests/dashboard — 租户端仪表盘
GET /contests/stats (加 tenantId) — 活动统计支持租户过滤
GET /tenants/my-tenant — 获取当前租户信息
PATCH /tenants/my-tenant — 更新当前租户信息
POST /contests/registrations/batch-review — 批量审核报名
PATCH /contests/registrations/:id/revoke — 撤销报名审核
GET /contests/registrations/stats (加 tenantId) — 报名统计支持租户过滤
```
### 成果发布详情Detail— 功能补全
- [x] 统计摘要(总作品/已评分/已排名/已设奖/平均分)
- [x] 三步操作流程(计算得分→计算排名→设置奖项)
- [x] 排名列(金银铜色徽章)+ 奖项列(彩色标签)+ 奖项筛选
- [x] 自定义奖项支持(动态添加奖项名称+人数,替代硬编码一/二/三等奖)
- [x] 单个设奖combobox选项来自自动设奖配置 + 已有数据)
- [x] 后端 AutoSetAwardsDto 改为 awards 数组格式
### 数据统计模块(新增)
- [x] 后端 analytics.module / controller / service
- [x] GET /analytics/overview — 核心指标+漏斗+月度趋势+活动对比
- [x] GET /analytics/review — 评审效率+评委工作量+奖项分布
- [x] 前端安装 echarts + vue-echarts
- [x] analytics/Overview.vue — 6个指标卡片 + 报名转化漏斗 + ECharts月度趋势折线图 + 活动对比表
- [x] analytics/Review.vue — 4个效率卡片 + 评委工作量表 + ECharts奖项分布饼图
- [x] 菜单注册:数据统计(运营概览 + 评审分析)
### Bug 修复
- [x] 超管端重置其他租户用户密码报「用户不存在」— controller 增加超管判断跳过租户过滤
- [x] gdlib 登录快捷标签密码与实际不一致 — 更新为 admin123
### 新增 API完整
```
GET /contests/dashboard — 租户端仪表盘
GET /contests/stats (加 tenantId) — 活动统计支持租户过滤
GET /tenants/my-tenant — 获取当前租户信息
PATCH /tenants/my-tenant — 更新当前租户信息
POST /contests/registrations/batch-review — 批量审核报名
PATCH /contests/registrations/:id/revoke — 撤销报名审核
GET /contests/registrations/stats (加 tenantId) — 报名统计支持租户过滤
GET /analytics/overview — 运营概览统计
GET /analytics/review — 评审分析统计
```
### 数据库变更
- menus 表新增:工作台(id=50)、机构信息(id=51)、数据统计(id=52)、运营概览(id=53)、评审分析(id=54)
- permissions 表新增log:read、notice:update、notice:deletegdlib 租户)
- work_tags 表新增 color 字段
- 前端依赖新增echarts、vue-echarts

View File

@ -0,0 +1,122 @@
# 点赞 & 收藏功能设计
> 所属端:公众端 + 超管端联动
> 状态:已实现
> 创建日期2026-03-31
> 最后更新2026-03-31
## 概述
为 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

@ -320,22 +320,25 @@ P0-4 + P0-6 → P0-12活动联动
目标:用户可对作品点赞、收藏、评论;有消息通知;可举报不当内容。
#### P1-1. 点赞/收藏(后端+前端)
#### P1-1. 点赞/收藏(后端+前端)✅ 已实现 (2026-03-31)
```
后端 API
├── POST /api/public/works/:id/like — 点赞/取消点赞
├── POST /api/public/works/:id/favorite — 收藏/取消收藏
├── GET /api/public/mine/favorites — 我的收藏列表
后端 API已实现
├── POST /api/public/works/:id/like — 点赞/取消点赞toggle
├── POST /api/public/works/:id/favorite — 收藏/取消收藏toggle
├── GET /api/public/works/:id/interaction — 查询交互状态
├── POST /api/public/works/batch-interaction — 批量查询交互状态
├── GET /api/public/mine/favorites — 我的收藏列表
前端改动:
├── 广场作品详情页 — 增加点赞/收藏按钮和计数
├── 作品卡片组件 — 显示点赞数
├── /p/mine/favorites — 我的收藏页面
前端改动(已实现):
├── 作品详情页 — 底部互动栏:点赞(心形)/收藏(星形)/浏览数,乐观更新+弹跳动效
├── 广场卡片 — 心形可点击点赞,已点赞显示实心粉色
├── /p/mine/favorites — 我的收藏页面(网格展示)
├── 个人中心 — 新增「我的收藏」菜单入口
```
**依赖**P0 完成
**数据库**user_work_likes + user_work_favorites
**数据库**user_work_likes + user_work_favorites已有
**设计文档**[点赞收藏设计](../public/like-favorite.md)
---

View File

@ -1,9 +1,9 @@
# 超管端内容管理模块 — 设计方案
> 所属端:超管端
> 状态:待开发
> 状态:P0 已实现并优化
> 创建日期2026-03-27
> 最后更新2026-03-27
> 最后更新2026-03-31
---
@ -349,4 +349,41 @@ POST /api/content-review/reports/:id/handle — 处理举报
## 8. 实施记录
(开发过程中记录)
### Day5 (2026-03-31) — P0 全面优化
#### 作品审核
- [x] 基础功能:统计卡片、筛选、审核队列表格、拒绝弹窗(预设理由+自定义)、详情 Drawer绘本翻页预览
- [x] 批量审核:支持勾选待审核作品批量通过/批量拒绝
- [x] 撤销机制:已通过/已拒绝的作品支持撤销恢复为待审核(操作列常驻按钮+二次确认)
- [x] 操作日志:详情 Drawer 底部展示审核操作时间线(通过/拒绝/下架/恢复/撤销)
- [x] 体验优化:默认筛选待审核、表格加描述预览列+审核时间列、详情加「上一个/下一个」导航(审核完自动跳下一个)、统计卡片点击筛选、筛选下拉自动查询
#### 作品管理
- [x] 基础功能:统计卡片、筛选(关键词+状态+排序)、作品表格、推荐/下架/恢复操作
- [x] 筛选修复:状态筛选支持 published+taken_down+推荐中,排序参数传后端真正生效
- [x] 下架原因下架改为弹窗选择原因4个预设+自定义),取代写死的「管理员下架」
- [x] 详情 Drawer补全作品描述、标签、绘本预览、操作按钮推荐/下架/恢复)、操作日志
- [x] 推荐联动:推荐作品在公众端广场顶部「编辑推荐」横栏展示,下架时自动取消推荐
- [x] 体验优化:统计卡片可点击筛选、表格加描述预览列、取消推荐二次确认、筛选自动查询
#### 标签管理
- [x] 基础功能:标签 CRUD、启用/禁用、删除保护(已使用不可删)
- [x] 分类分组:标签按分类分组展示(每组有颜色标识+计数),未分类单独一组
- [x] 分类下拉:新增/编辑时分类改为下拉选择(支持选已有+创建新分类),杜绝手动输入不一致
- [x] 标签颜色:数据库新增 color 字段10个预设色+自定义 hex卡片左侧颜色条+用户端预览
- [x] 排序按钮:每个标签有上/下箭头,点击交换排序值并持久化
- [x] 使用次数可点击:跳转作品管理页带标签名搜索
- [x] 实时预览:新增/编辑弹窗底部实时渲染用户端标签效果
#### 新增后端 API
```
POST /api/content-review/works/batch-approve — 批量通过
POST /api/content-review/works/batch-reject — 批量拒绝
POST /api/content-review/works/:id/revoke — 撤销审核
GET /api/content-review/works (新增参数) — sortBy 排序 + isRecommended 筛选
GET /api/public/gallery/recommended — 推荐作品列表(公众端)
POST /api/tags/batch-sort — 标签批量排序
```
#### 数据库变更
- `work_tags` 表新增 `color` 字段VARCHAR(20),标签颜色)

View File

@ -0,0 +1,42 @@
# 超管端机构管理 — 优化记录
> 所属端:超管端
> 状态:已优化
> 创建日期2026-03-31
> 最后更新2026-03-31
---
## 模块说明
超管端「机构管理」菜单,管理平台接入的外部机构(图书馆、学校、幼儿园等)。每个机构对应一个租户,拥有独立的用户、角色、菜单权限。
## Day5 (2026-03-31) — 优化内容
### 1. 隐藏系统内部租户
- 后端列表查询过滤 super/public/school/teacher/student/judge 等系统内部编码
- 列表只展示真实外部机构
### 2. 搜索改为后端分页
- keyword名称/编码)和 tenantType 参数传后端查询
- 去掉前端 computed 过滤,支持大数据量
- 类型下拉选择后自动触发查询
### 3. 新增登录地址列
- 表格新增「登录地址」列,显示 `/:code/login`
- 旁边有复制按钮,一键复制完整 URL方便运营发给机构管理员
### 4. 停用/启用快捷操作
- 操作列新增停用/启用按钮(二次确认提示影响)
- 后端新增 `PATCH /tenants/:id/status` 接口
### 5. 新建后引导
- 创建机构成功后弹出引导弹窗
- 提供「为该机构创建管理员账号」按钮,跳转用户管理页
- 避免创建机构后不知道下一步做什么
### 新增 API
```
PATCH /api/tenants/:id/status — 切换租户启用/停用状态
GET /api/tenants (新增参数) — keyword 关键词搜索 + tenantType 类型筛选
```

View File

@ -1,9 +1,9 @@
# 统一用户管理 — 设计方案
> 所属端:超管端
> 状态:已实现(待验收
> 状态:已实现(迭代中
> 创建日期2026-03-27
> 最后更新2026-03-27
> 最后更新2026-03-30
---
@ -101,6 +101,7 @@ GET /api/public/users/:id — 公众用户详情(含子女+报名)
#### 统计卡片
- 5 张卡片横排,每张显示:类型图标 + 类型名称 + 数量
- 类型命名:全部 / **运营团队** / 机构 / 评委 / 公众(~~平台~~ → 运营团队2026-03-30 更名)
- 选中的卡片高亮(主色边框),点击 = 设置 userType 筛选
- 点"全部"清除类型筛选
- 数据来源:`GET /api/users/stats`
@ -151,8 +152,8 @@ GET /api/public/users/:id — 公众用户详情(含子女+报名)
**公众用户额外区域**
```
子女信息N个
├── 姓名 / 年龄 / 年级 / 城市 / 学校
子女账号N个— 基于 UserParentChild 关系,子女为独立 User
├── 头像 / 昵称 / @用户名 / 性别 / 城市 / 关系(父亲/母亲/监护人) / 状态
报名记录近20条
├── 活动名称 / 报名状态 / 参与者(本人/子女名) / 报名时间
@ -188,7 +189,7 @@ GET /api/public/users/:id — 公众用户详情(含子女+报名)
- 返回字段增加:
- `tenant: { id, name, code, tenantType, isSuper }`(用于前端推导用户类型)
- `_count: { children, contestRegistrations }`(公众用户的子女数和报名数)
- `_count: { parentRelations, contestRegistrations }`(公众用户的子女账号数和报名数)
普通租户调用时:保持现有逻辑不变。
@ -220,7 +221,7 @@ public → tenant.code = 'public'
超管调用时:
- 不做 tenantId 过滤
- 公众用户额外返回:`children`(子女列表)、`contestRegistrations`近20条报名记录含活动名和子女名
- 公众用户额外返回:`parentRelations`(子女账号列表,含 child User 信息)、`contestRegistrations`近20条报名记录含活动名和子女名
- 评委额外返回:`contestJudges`(参与的评审活动列表)
#### 3.3.4 新增 PATCH /api/users/:id/status
@ -292,3 +293,18 @@ public → tenant.code = 'public'
**验证结果:**
- 后端 TSC 编译通过NestJS 启动成功,/api/users/stats 和 /api/users/:id/status 路由注册正常
- 前端无新增 TS 错误(原有错误均为已有代码)
### 2026-03-30 — 命名优化 + 子女账号独立化适配
**问题:**
1. 统计卡片和 Tag 中「平台」命名易误解为"平台全部用户",实际指运营管理人员
2. 公众用户详情仍展示旧版 `Child` 模型(姓名/年级/学校),子女已独立为 `User` 后应使用 `UserParentChild` 关系
**改动3 个文件):**
- `backend/src/users/users.service.ts` — findOne 详情查询:`children`(旧 Child 表)→ `parentRelations`UserParentChild + child User列表 `_count.children``_count.parentRelations`
- `frontend/src/api/users.ts` — User 类型定义:`children` 数组 → `parentRelations` 数组(含 child 独立用户信息 + relationship + controlMode
- `frontend/src/views/system/users/Index.vue` — 统计卡片和 Tag 标签:「平台」→「运营团队」;详情 Drawer 子女区域:旧版姓名/年级/学校列表 → 新版子女账号卡片(头像+昵称+用户名+关系+状态)
**验证结果:**
- 后端重启成功,编译无错误
- 前端 HMR 热更新生效,无新增 TS 错误

View File

@ -20,10 +20,12 @@
"ant-design-vue": "^4.1.1",
"axios": "^1.6.7",
"dayjs": "^1.11.10",
"echarts": "^6.0.0",
"pinia": "^2.1.7",
"three": "^0.182.0",
"vee-validate": "^4.12.4",
"vue": "^3.4.21",
"vue-echarts": "^8.0.1",
"vue-router": "^4.3.0",
"zod": "^3.22.4"
},

View File

@ -0,0 +1,62 @@
import request from '@/utils/request'
export interface OverviewData {
summary: {
totalContests: number
totalRegistrations: number
passedRegistrations: number
totalWorks: number
reviewedWorks: number
awardedWorks: number
}
funnel: {
registered: number
passed: number
submitted: number
reviewed: number
awarded: number
}
monthlyTrend: Array<{ month: string; registrations: number; works: number }>
contestComparison: Array<{
contestId: number
contestName: string
registrations: number
passRate: number
submitRate: number
reviewRate: number
awardRate: number
avgScore: number | null
}>
}
export interface ReviewData {
efficiency: {
avgReviewDays: number
dailyReviewCount: number
pendingAssignments: number
avgScoreStddev: number
}
judgeWorkload: Array<{
judgeId: number
judgeName: string
contestCount: number
assignedCount: number
scoredCount: number
completionRate: number
avgScore: number | null
scoreStddev: number
}>
awardDistribution: Array<{
awardName: string
count: number
percentage: number
}>
}
export const analyticsApi = {
getOverview: (params?: { timeRange?: string; contestId?: number }): Promise<OverviewData> =>
request.get('/analytics/overview', { params }),
getReview: (params?: { contestId?: number }): Promise<ReviewData> =>
request.get('/analytics/review', { params }),
}

View File

@ -841,6 +841,16 @@ export const registrationsApi = {
return response;
},
// 撤销报名审核
revokeReview: async (id: number): Promise<ContestRegistration> => {
return await request.patch<any, ContestRegistration>(`/contests/registrations/${id}/revoke`);
},
// 批量审核报名
batchReview: async (data: { ids: number[]; registrationState: string; reason?: string }): Promise<{ success: boolean; count: number }> => {
return await request.post<any, { success: boolean; count: number }>('/contests/registrations/batch-review', data);
},
// 删除报名
delete: async (id: number): Promise<void> => {
return await request.delete<any, void>(`/contests/registrations/${id}`);
@ -1333,7 +1343,7 @@ export interface ResultsSummary {
}
export interface SetAwardForm {
awardLevel: 'first' | 'second' | 'third' | 'excellent' | 'none';
awardLevel: string;
awardName?: string;
certificateUrl?: string;
}
@ -1347,10 +1357,7 @@ export interface BatchSetAwardsForm {
}
export interface AutoSetAwardsForm {
first?: number;
second?: number;
third?: number;
excellent?: number;
awards: Array<{ name: string; count: number }>;
}
// 成果管理

View File

@ -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 }),
}
// ==================== 用户作品库 ====================
@ -384,6 +396,9 @@ export const publicTagsApi = {
// ==================== 作品广场 ====================
export const publicGalleryApi = {
recommended: (): Promise<UserWork[]> =>
publicApi.get("/public/gallery/recommended"),
list: (params?: {
page?: number
pageSize?: number

View File

@ -87,6 +87,11 @@ export async function getTenantMenus(id: number): Promise<Menu[]> {
return response;
}
// 切换租户启用/停用
export async function toggleTenantStatus(id: number): Promise<Tenant> {
return await request.patch<any, Tenant>(`/tenants/${id}/status`);
}
// 兼容性导出:保留 tenantsApi 对象
export const tenantsApi = {
getList: getTenantsList,
@ -95,4 +100,5 @@ export const tenantsApi = {
update: updateTenant,
delete: deleteTenant,
getTenantMenus: getTenantMenus,
toggleStatus: toggleTenantStatus,
};

View File

@ -48,18 +48,25 @@ export interface User {
};
}>;
_count?: {
children: number;
parentRelations: number;
contestRegistrations: number;
};
// 详情接口返回
children?: Array<{
// 详情接口返回 — 子女账号(独立用户)
parentRelations?: Array<{
id: number;
name: string;
gender?: string;
birthday?: string;
grade?: string;
city?: string;
schoolName?: string;
relationship?: string;
controlMode: string;
child: {
id: number;
username: string;
nickname: string;
avatar?: string;
gender?: string;
birthday?: string;
city?: string;
status?: string;
createTime?: string;
};
}>;
contestRegistrations?: Array<{
id: number;

View File

@ -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;
}

View File

@ -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",
@ -254,7 +254,7 @@ const baseRoutes: RouteRecordRaw[] = [
meta: {
title: "参赛作品详情",
requiresAuth: true,
permissions: ["work:read"],
permissions: ["contest:work:read"],
},
},
// 作业提交记录路由

View File

@ -15,6 +15,10 @@ const EmptyLayout = () => import("@/layouts/EmptyLayout.vue")
const componentMap: Record<string, () => Promise<any>> = {
// 工作台模块
"workbench/Index": () => import("@/views/workbench/Index.vue"),
"workbench/TenantDashboard": () => import("@/views/workbench/TenantDashboard.vue"),
"analytics/Overview": () => import("@/views/analytics/Overview.vue"),
"analytics/Review": () => import("@/views/analytics/Review.vue"),
"system/tenant-info/Index": () => import("@/views/system/tenant-info/Index.vue"),
"workbench/ai-3d/Index": () => import("@/views/workbench/ai-3d/Index.vue"),
// 学校管理模块
"school/schools/Index": () => import("@/views/school/schools/Index.vue"),
@ -43,7 +47,7 @@ const componentMap: Record<string, () => Promise<any>> = {
"contests/judges/Index": () => import("@/views/contests/judges/Index.vue"),
"contests/results/Index": () => import("@/views/contests/results/Index.vue"),
"contests/notices/Index": () => import("@/views/contests/notices/Index.vue"),
"contests/ReviewRules": () => import("@/views/contests/Index.vue"), // 评审规则临时使用活动列表
"contests/ReviewRules": () => import("@/views/contests/reviews/Index.vue"),
// 内容管理模块
"content/WorkReview": () => import("@/views/content/WorkReview.vue"),
"content/WorkManagement": () => import("@/views/content/WorkManagement.vue"),

View File

@ -0,0 +1,226 @@
<template>
<div class="analytics-overview">
<a-card class="title-card">
<template #title>运营概览</template>
<template #extra>
<a-space>
<a-select v-model:value="contestFilter" style="width: 200px" placeholder="全部活动" allow-clear @change="fetchData">
<a-select-option v-for="c in contestOptions" :key="c.id" :value="c.id">{{ c.name }}</a-select-option>
</a-select>
</a-space>
</template>
</a-card>
<a-spin :spinning="loading">
<!-- 核心指标卡片 -->
<div class="stats-row">
<div v-for="item in statsItems" :key="item.key" class="stat-card">
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
<div class="stat-info">
<span class="stat-count">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
</div>
<!-- 漏斗 + 趋势 -->
<div class="grid-2">
<!-- 报名转化漏斗 -->
<div class="card-section">
<h3 class="section-title">报名转化漏斗</h3>
<div class="funnel-list">
<div v-for="(item, idx) in funnelItems" :key="item.label" class="funnel-item">
<div class="funnel-header">
<span class="funnel-label">{{ item.label }}</span>
<div class="funnel-values">
<span v-if="idx > 0" class="funnel-rate" :style="{ background: item.rateBg, color: item.rateColor }">{{ item.rate }}</span>
<span class="funnel-count">{{ item.value }}</span>
</div>
</div>
<div class="funnel-bar-bg">
<div class="funnel-bar" :style="{ width: item.width + '%', background: item.gradient }"></div>
</div>
</div>
</div>
</div>
<!-- 月度趋势 -->
<div class="card-section">
<h3 class="section-title">月度趋势</h3>
<v-chart :option="trendOption" autoresize style="height: 280px" />
</div>
</div>
<!-- 活动对比 -->
<div class="card-section" style="margin-top: 16px">
<h3 class="section-title">活动对比</h3>
<a-table :columns="comparisonColumns" :data-source="data?.contestComparison || []" :pagination="false" row-key="contestId" size="small">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'passRate' || column.key === 'submitRate' || column.key === 'reviewRate' || column.key === 'awardRate'">
<span class="rate-pill" :class="getRateClass(record[column.key])">{{ record[column.key] }}%</span>
</template>
<template v-else-if="column.key === 'avgScore'">
<span v-if="record.avgScore" class="score-text">{{ record.avgScore }}</span>
<span v-else class="text-muted">-</span>
</template>
</template>
</a-table>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
TrophyOutlined, TeamOutlined, CheckCircleOutlined,
FileTextOutlined, AuditOutlined, StarOutlined,
} from '@ant-design/icons-vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import { analyticsApi, type OverviewData } from '@/api/analytics'
import { contestsApi } from '@/api/contests'
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent])
const loading = ref(true)
const data = ref<OverviewData | null>(null)
const contestFilter = ref<number | undefined>(undefined)
const contestOptions = ref<{ id: number; name: string }[]>([])
const statsItems = computed(() => {
const s = data.value?.summary
if (!s) return []
return [
{ key: 'contests', label: '活动总数', value: s.totalContests, icon: TrophyOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
{ key: 'reg', label: '累计报名', value: s.totalRegistrations, icon: TeamOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
{ key: 'passed', label: '报名通过', value: s.passedRegistrations, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
{ key: 'works', label: '作品总数', value: s.totalWorks, icon: FileTextOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
{ key: 'reviewed', label: '已完成评审', value: s.reviewedWorks, icon: AuditOutlined, color: '#14b8a6', bgColor: 'rgba(20,184,166,0.1)' },
{ key: 'awarded', label: '获奖作品', value: s.awardedWorks, icon: StarOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)' },
]
})
const funnelItems = computed(() => {
const f = data.value?.funnel
if (!f) return []
const max = f.registered || 1
const calcRate = (cur: number, prev: number) => prev > 0 ? (cur / prev * 100).toFixed(1) + '%' : '0%'
return [
{ label: '报名', value: f.registered, width: 100, gradient: 'linear-gradient(90deg,#6366f1,#818cf8)', rate: '', rateBg: '', rateColor: '' },
{ label: '通过审核', value: f.passed, width: f.passed / max * 100, gradient: 'linear-gradient(90deg,#10b981,#34d399)', rate: calcRate(f.passed, f.registered), rateBg: '#ecfdf5', rateColor: '#10b981' },
{ label: '提交作品', value: f.submitted, width: f.submitted / max * 100, gradient: 'linear-gradient(90deg,#3b82f6,#60a5fa)', rate: calcRate(f.submitted, f.passed), rateBg: '#eff6ff', rateColor: '#3b82f6' },
{ label: '评审完成', value: f.reviewed, width: f.reviewed / max * 100, gradient: 'linear-gradient(90deg,#f59e0b,#fbbf24)', rate: calcRate(f.reviewed, f.submitted), rateBg: '#fffbeb', rateColor: '#f59e0b' },
{ label: '获奖', value: f.awarded, width: f.awarded / max * 100, gradient: 'linear-gradient(90deg,#ef4444,#f87171)', rate: calcRate(f.awarded, f.reviewed), rateBg: '#fef2f2', rateColor: '#ef4444' },
]
})
const trendOption = computed(() => {
const trend = data.value?.monthlyTrend || []
return {
tooltip: { trigger: 'axis' },
legend: { data: ['报名量', '作品量'], bottom: 0, textStyle: { fontSize: 12, color: '#9ca3af' } },
grid: { left: 40, right: 16, top: 16, bottom: 40 },
xAxis: { type: 'category', data: trend.map(t => t.month), axisLine: { lineStyle: { color: '#e5e7eb' } }, axisLabel: { color: '#9ca3af' }, axisTick: { show: false } },
yAxis: { type: 'value', axisLine: { show: false }, axisTick: { show: false }, splitLine: { lineStyle: { color: '#f3f4f6', type: 'dashed' } }, axisLabel: { color: '#9ca3af' } },
series: [
{ name: '报名量', type: 'line', data: trend.map(t => t.registrations), smooth: true, symbol: 'circle', symbolSize: 6, lineStyle: { width: 3, color: '#6366f1' }, itemStyle: { color: '#6366f1' }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(99,102,241,0.15)' }, { offset: 1, color: 'rgba(99,102,241,0)' }] } } },
{ name: '作品量', type: 'line', data: trend.map(t => t.works), smooth: true, symbol: 'circle', symbolSize: 6, lineStyle: { width: 3, color: '#f59e0b' }, itemStyle: { color: '#f59e0b' }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(245,158,11,0.12)' }, { offset: 1, color: 'rgba(245,158,11,0)' }] } } },
],
}
})
const comparisonColumns = [
{ title: '活动名称', dataIndex: 'contestName', key: 'contestName', width: 200 },
{ title: '报名数', dataIndex: 'registrations', key: 'registrations', width: 80, align: 'center' as const },
{ title: '通过率', key: 'passRate', width: 90, align: 'center' as const },
{ title: '提交率', key: 'submitRate', width: 90, align: 'center' as const },
{ title: '评审完成率', key: 'reviewRate', width: 100, align: 'center' as const },
{ title: '获奖率', key: 'awardRate', width: 90, align: 'center' as const },
{ title: '平均分', key: 'avgScore', width: 80, align: 'center' as const },
]
const getRateClass = (rate: number) => {
if (rate >= 80) return 'rate-high'
if (rate >= 50) return 'rate-mid'
if (rate > 0) return 'rate-low'
return 'rate-zero'
}
const fetchContestOptions = async () => {
try {
const res = await contestsApi.getList({ page: 1, pageSize: 100 })
contestOptions.value = res.list.map(c => ({ id: c.id, name: c.contestName }))
} catch { /* */ }
}
const fetchData = async () => {
loading.value = true
try {
data.value = await analyticsApi.getOverview({ contestId: contestFilter.value })
} catch { message.error('获取统计数据失败') }
finally { loading.value = false }
}
onMounted(() => { fetchContestOptions(); fetchData() })
</script>
<style scoped lang="scss">
$primary: #6366f1;
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
:deep(.ant-card-body) { padding: 0; }
}
.stats-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 16px; }
.stat-card {
display: flex; align-items: center; gap: 12px; padding: 16px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); transition: all 0.2s;
&:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba($primary, 0.12); }
.stat-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
.stat-info { display: flex; flex-direction: column;
.stat-count { font-size: 22px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
.stat-label { font-size: 12px; color: #9ca3af; }
}
}
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.card-section {
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 24px;
.section-title { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; }
}
//
.funnel-list { display: flex; flex-direction: column; gap: 12px; }
.funnel-item {
.funnel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.funnel-label { font-size: 13px; font-weight: 500; color: #374151; }
.funnel-values { display: flex; align-items: center; gap: 8px; }
.funnel-count { font-size: 14px; font-weight: 700; color: #1e1b4b; }
.funnel-rate { display: inline-flex; padding: 1px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.funnel-bar-bg { height: 28px; background: #f3f4f6; border-radius: 8px; overflow: hidden; }
.funnel-bar { height: 100%; border-radius: 8px; transition: width 0.8s cubic-bezier(0.4,0,0.2,1); }
}
//
.rate-pill { display: inline-flex; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.rate-high { background: #ecfdf5; color: #10b981; }
.rate-mid { background: #fffbeb; color: #f59e0b; }
.rate-low { background: #fef2f2; color: #ef4444; }
.rate-zero { background: #f3f4f6; color: #d1d5db; }
.score-text { font-weight: 700; color: $primary; }
.text-muted { color: #d1d5db; }
:deep(.ant-table-wrapper) { background: transparent;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; font-size: 13px; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
}
</style>

View File

@ -0,0 +1,218 @@
<template>
<div class="analytics-review">
<a-card class="title-card">
<template #title>评审分析</template>
<template #extra>
<a-select v-model:value="contestFilter" style="width: 200px" placeholder="全部活动" allow-clear @change="fetchData">
<a-select-option v-for="c in contestOptions" :key="c.id" :value="c.id">{{ c.name }}</a-select-option>
</a-select>
</template>
</a-card>
<a-spin :spinning="loading">
<!-- 效率卡片 -->
<div class="stats-row">
<div v-for="item in efficiencyItems" :key="item.key" class="stat-card">
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
<div class="stat-info">
<span class="stat-count">{{ item.value }}<span class="stat-unit">{{ item.unit }}</span></span>
<span class="stat-label">{{ item.label }}</span>
<span v-if="item.hint" class="stat-hint">{{ item.hint }}</span>
</div>
</div>
</div>
<div class="grid-5-2">
<!-- 评委工作量 -->
<div class="card-section col-span-3">
<h3 class="section-title">评委工作量</h3>
<a-table :columns="judgeColumns" :data-source="data?.judgeWorkload || []" :pagination="false" row-key="judgeId" size="small">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'judgeName'">
<div class="judge-cell">
<div class="judge-avatar" :style="{ background: getAvatarColor(record.judgeName) }">{{ record.judgeName?.charAt(0) }}</div>
<span class="judge-name">{{ record.judgeName }}</span>
</div>
</template>
<template v-else-if="column.key === 'completionRate'">
<span class="rate-pill" :class="getRateClass(record.completionRate)">{{ record.completionRate }}%</span>
</template>
<template v-else-if="column.key === 'avgScore'">
<span v-if="record.avgScore" class="score-text">{{ record.avgScore }}</span>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'scoreStddev'">
<span :class="getStddevClass(record.scoreStddev)">{{ record.scoreStddev }}</span>
</template>
</template>
</a-table>
</div>
<!-- 奖项分布 -->
<div class="card-section col-span-2">
<h3 class="section-title">奖项分布</h3>
<div v-if="data?.awardDistribution?.length">
<v-chart :option="awardOption" autoresize style="height: 260px" />
</div>
<a-empty v-else description="暂无奖项数据" style="padding: 60px 0" />
</div>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
ClockCircleOutlined, ThunderboltOutlined, WarningOutlined, BarChartOutlined,
} from '@ant-design/icons-vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import { analyticsApi, type ReviewData } from '@/api/analytics'
import { contestsApi } from '@/api/contests'
use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent])
const loading = ref(true)
const data = ref<ReviewData | null>(null)
const contestFilter = ref<number | undefined>(undefined)
const contestOptions = ref<{ id: number; name: string }[]>([])
const avatarColors = ['#6366f1', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']
const getAvatarColor = (name: string) => {
const idx = name ? name.charCodeAt(0) % avatarColors.length : 0
return `linear-gradient(135deg, ${avatarColors[idx]}, ${avatarColors[(idx + 1) % avatarColors.length]})`
}
const efficiencyItems = computed(() => {
const e = data.value?.efficiency
if (!e) return []
return [
{ key: 'days', label: '平均评审周期', value: e.avgReviewDays, unit: '天', icon: ClockCircleOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)', hint: '' },
{ key: 'daily', label: '日均评审量', value: e.dailyReviewCount, unit: '个/日', icon: ThunderboltOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)', hint: '' },
{ key: 'pending', label: '待评审积压', value: e.pendingAssignments, unit: '个', icon: WarningOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)', hint: '' },
{ key: 'stddev', label: '评分一致性', value: e.avgScoreStddev, unit: '分', icon: BarChartOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)', hint: '标准差越小越好' },
]
})
const awardColors = ['#ef4444', '#f59e0b', '#3b82f6', '#10b981', '#8b5cf6', '#ec4899', '#14b8a6']
const awardOption = computed(() => {
const dist = data.value?.awardDistribution || []
return {
tooltip: { trigger: 'item' },
legend: { bottom: 0, textStyle: { fontSize: 12, color: '#9ca3af' } },
series: [{
type: 'pie', radius: ['45%', '72%'], center: ['50%', '45%'],
label: { show: true, formatter: '{b}\n{d}%', fontSize: 12, color: '#6b7280' },
labelLine: { length: 12, length2: 8 },
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 3 },
data: dist.map((d, i) => ({
value: d.count,
name: d.awardName,
itemStyle: { color: awardColors[i % awardColors.length] },
})),
emphasis: { itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,0,0,0.12)' } },
}],
}
})
const judgeColumns = [
{ title: '评委姓名', key: 'judgeName', width: 140 },
{ title: '关联活动', dataIndex: 'contestCount', key: 'contestCount', width: 80, align: 'center' as const },
{ title: '已分配', dataIndex: 'assignedCount', key: 'assignedCount', width: 70, align: 'center' as const },
{ title: '已评分', dataIndex: 'scoredCount', key: 'scoredCount', width: 70, align: 'center' as const },
{ title: '完成率', key: 'completionRate', width: 80, align: 'center' as const },
{ title: '平均分', key: 'avgScore', width: 80, align: 'center' as const },
{ title: '标准差', key: 'scoreStddev', width: 80, align: 'center' as const },
]
const getRateClass = (rate: number) => {
if (rate >= 80) return 'rate-high'
if (rate >= 50) return 'rate-mid'
return 'rate-low'
}
const getStddevClass = (stddev: number) => {
if (stddev <= 3) return 'stddev-good'
if (stddev <= 6) return 'stddev-ok'
return 'stddev-bad'
}
const fetchContestOptions = async () => {
try {
const res = await contestsApi.getList({ page: 1, pageSize: 100 })
contestOptions.value = res.list.map(c => ({ id: c.id, name: c.contestName }))
} catch { /* */ }
}
const fetchData = async () => {
loading.value = true
try {
data.value = await analyticsApi.getReview({ contestId: contestFilter.value })
} catch { message.error('获取评审分析数据失败') }
finally { loading.value = false }
}
onMounted(() => { fetchContestOptions(); fetchData() })
</script>
<style scoped lang="scss">
$primary: #6366f1;
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
:deep(.ant-card-body) { padding: 0; }
}
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; }
.stat-card {
display: flex; align-items: center; gap: 12px; padding: 18px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); transition: all 0.2s;
&:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba($primary, 0.12); }
.stat-icon { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
.stat-info { display: flex; flex-direction: column;
.stat-count { font-size: 24px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
.stat-unit { font-size: 13px; font-weight: 400; color: #9ca3af; margin-left: 2px; }
.stat-label { font-size: 12px; color: #9ca3af; margin-top: 2px; }
.stat-hint { font-size: 10px; color: #d1d5db; }
}
}
.grid-5-2 { display: grid; grid-template-columns: 3fr 2fr; gap: 16px; }
.col-span-3 { grid-column: 1; }
.col-span-2 { grid-column: 2; }
.card-section {
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 24px;
.section-title { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; }
}
.judge-cell { display: flex; align-items: center; gap: 10px; }
.judge-avatar {
width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 12px; font-weight: 700; flex-shrink: 0;
}
.judge-name { font-weight: 500; color: #1e1b4b; }
.rate-pill { display: inline-flex; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.rate-high { background: #ecfdf5; color: #10b981; }
.rate-mid { background: #fffbeb; color: #f59e0b; }
.rate-low { background: #fef2f2; color: #ef4444; }
.score-text { font-weight: 700; color: $primary; }
.text-muted { color: #d1d5db; }
.stddev-good { font-weight: 600; color: #10b981; }
.stddev-ok { font-weight: 600; color: #f59e0b; }
.stddev-bad { font-weight: 600; color: #ef4444; }
:deep(.ant-table-wrapper) { background: transparent;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; font-size: 13px; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
}
</style>

View File

@ -119,7 +119,7 @@ const isDev = import.meta.env.DEV
//
const tenantTabs = [
{ code: "super", name: "平台超管", icon: SafetyOutlined, username: "admin", password: "admin@super" },
{ code: "gdlib", name: "广东省图", icon: BankOutlined, username: "admin", password: "admin@gdlib" },
{ code: "gdlib", name: "广东省图", icon: BankOutlined, username: "admin", password: "admin123" },
{ code: "judge", name: "评委端", icon: TrophyOutlined, username: "admin", password: "admin@judge" },
]

View File

@ -10,76 +10,213 @@
</template>
</a-card>
<a-table
:columns="columns"
:data-source="tags"
:loading="loading"
:pagination="false"
row-key="id"
class="data-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'category'">
<a-tag v-if="record.category">{{ record.category }}</a-tag>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'enabled' ? 'green' : 'red'">
{{ record.status === 'enabled' ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="openModal(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleToggle(record)">
{{ record.status === 'enabled' ? '禁用' : '启用' }}
</a-button>
<a-popconfirm title="确定删除?" @confirm="handleDelete(record.id)">
<a-button type="link" danger size="small" :disabled="record.usageCount > 0">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<!-- 按分类分组展示 -->
<div v-if="loading" class="loading-wrap"><a-spin /></div>
<a-modal v-model:open="modalVisible" :title="editingId ? '编辑标签' : '新增标签'" :footer="null" :width="400">
<template v-else>
<div v-for="group in groupedTags" :key="group.category" class="category-group">
<div class="category-header">
<span class="category-name">
<span class="category-dot" :style="{ background: group.color }"></span>
{{ group.category }}
</span>
<span class="category-count">{{ group.tags.length }} 个标签</span>
</div>
<div class="tag-grid">
<div
v-for="(tag, idx) in group.tags"
:key="tag.id"
class="tag-card"
:class="{ disabled: tag.status === 'disabled' }"
>
<!-- 预览色条 -->
<div class="tag-color-bar" :style="{ background: tag.color || group.color }"></div>
<div class="tag-body">
<div class="tag-main">
<span class="tag-name">{{ tag.name }}</span>
<a-tag v-if="tag.status === 'disabled'" color="red" style="margin-left: 6px; font-size: 10px">已禁用</a-tag>
</div>
<div class="tag-meta">
<span
class="tag-usage"
:class="{ clickable: tag.usageCount > 0 }"
@click="tag.usageCount > 0 && goToWorks(tag.name)"
>
{{ tag.usageCount }} 次使用
</span>
<span class="tag-sort">排序: {{ tag.sort }}</span>
</div>
<!-- 预览效果 -->
<div class="tag-preview">
<span class="preview-label">用户端预览</span>
<a-tag :color="tag.color || group.color">{{ tag.name }}</a-tag>
</div>
<div class="tag-actions">
<!-- 排序按钮 -->
<a-button type="text" size="small" :disabled="idx === 0" @click="moveTag(group, idx, -1)">
<up-outlined />
</a-button>
<a-button type="text" size="small" :disabled="idx === group.tags.length - 1" @click="moveTag(group, idx, 1)">
<down-outlined />
</a-button>
<a-button type="link" size="small" @click="openModal(tag)">编辑</a-button>
<a-button type="link" size="small" @click="handleToggle(tag)">
{{ tag.status === 'enabled' ? '禁用' : '启用' }}
</a-button>
<a-popconfirm title="确定删除?" @confirm="handleDelete(tag.id)">
<a-button type="link" danger size="small" :disabled="tag.usageCount > 0">删除</a-button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</div>
<!-- 未分类 -->
<div v-if="uncategorizedTags.length > 0" class="category-group">
<div class="category-header">
<span class="category-name">
<span class="category-dot" style="background: #9ca3af"></span>
未分类
</span>
<span class="category-count">{{ uncategorizedTags.length }} 个标签</span>
</div>
<div class="tag-grid">
<div
v-for="tag in uncategorizedTags"
:key="tag.id"
class="tag-card"
:class="{ disabled: tag.status === 'disabled' }"
>
<div class="tag-color-bar" :style="{ background: tag.color || '#9ca3af' }"></div>
<div class="tag-body">
<div class="tag-main">
<span class="tag-name">{{ tag.name }}</span>
<a-tag v-if="tag.status === 'disabled'" color="red" style="margin-left: 6px; font-size: 10px">已禁用</a-tag>
</div>
<div class="tag-meta">
<span class="tag-usage" :class="{ clickable: tag.usageCount > 0 }" @click="tag.usageCount > 0 && goToWorks(tag.name)">{{ tag.usageCount }} 次使用</span>
</div>
<div class="tag-preview">
<span class="preview-label">用户端预览</span>
<a-tag :color="tag.color || '#9ca3af'">{{ tag.name }}</a-tag>
</div>
<div class="tag-actions">
<a-button type="link" size="small" @click="openModal(tag)">编辑</a-button>
<a-button type="link" size="small" @click="handleToggle(tag)">{{ tag.status === 'enabled' ? '禁用' : '启用' }}</a-button>
<a-popconfirm title="确定删除?" @confirm="handleDelete(tag.id)">
<a-button type="link" danger size="small" :disabled="tag.usageCount > 0">删除</a-button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</div>
<div v-if="tags.length === 0" class="empty-wrap">
<a-empty description="暂无标签" />
</div>
</template>
<!-- 新增/编辑弹窗 -->
<a-modal v-model:open="modalVisible" :title="editingId ? '编辑标签' : '新增标签'" :footer="null" :width="440">
<a-form :model="form" layout="vertical" @finish="handleSubmit" style="margin-top: 16px">
<a-form-item label="标签名称" :rules="[{ required: true, message: '请输入' }]">
<a-input v-model:value="form.name" placeholder="如:童话、科幻、自然" />
</a-form-item>
<a-form-item label="所属分类">
<a-input v-model:value="form.category" placeholder="如:主题、风格、情感" />
<a-form-item label="所属分类" :rules="[{ required: true, message: '请选择分类' }]">
<a-select
v-model:value="form.category"
placeholder="选择或输入新分类"
:options="categoryOptions"
show-search
allow-clear
:filter-option="false"
@search="onCategorySearch"
>
<template #notFoundContent>
<div v-if="categorySearchVal" style="padding: 4px 8px; font-size: 12px; color: #6b7280; cursor: pointer" @click="form.category = categorySearchVal">
创建分类{{ categorySearchVal }}
</div>
<span v-else style="color: #9ca3af">请输入分类名</span>
</template>
</a-select>
</a-form-item>
<a-form-item label="标签颜色">
<div class="color-picker-row">
<span
v-for="c in presetColors"
:key="c"
:class="['color-dot', { active: form.color === c }]"
:style="{ background: c }"
@click="form.color = c"
></span>
<a-input v-model:value="form.color" placeholder="#6366f1" style="width: 100px; margin-left: 8px" size="small" />
</div>
</a-form-item>
<a-form-item label="排序权重">
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" />
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" placeholder="数字越小越靠前" />
</a-form-item>
<a-button type="primary" html-type="submit" block :loading="submitting">保存</a-button>
<!-- 实时预览 -->
<div class="form-preview">
<span class="preview-label">用户端预览效果</span>
<a-tag :color="form.color || '#6366f1'">{{ form.name || '标签名称' }}</a-tag>
</div>
<a-button type="primary" html-type="submit" block :loading="submitting" style="margin-top: 16px">保存</a-button>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import { PlusOutlined, UpOutlined, DownOutlined } from '@ant-design/icons-vue'
import request from '@/utils/request'
const router = useRouter()
const tags = ref<any[]>([])
const categories = ref<string[]>([])
const loading = ref(false)
const modalVisible = ref(false)
const submitting = ref(false)
const editingId = ref<number | null>(null)
const form = reactive({ name: '', category: '', sort: 0 })
const form = reactive({ name: '', category: '', color: '', sort: 0 })
const categorySearchVal = ref('')
const columns = [
{ title: '标签名称', dataIndex: 'name', key: 'name', width: 150 },
{ title: '分类', key: 'category', width: 120 },
{ title: '使用次数', dataIndex: 'usageCount', key: 'usageCount', width: 100 },
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
{ title: '状态', key: 'status', width: 80 },
{ title: '操作', key: 'action', width: 180, fixed: 'right' as const },
]
const presetColors = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#ef4444', '#3b82f6', '#8b5cf6', '#14b8a6', '#f97316', '#64748b']
// +
const categoryOptions = computed(() => {
const opts = categories.value.map(c => ({ label: c, value: c }))
if (categorySearchVal.value && !categories.value.includes(categorySearchVal.value)) {
opts.push({ label: `创建「${categorySearchVal.value}`, value: categorySearchVal.value })
}
return opts
})
const onCategorySearch = (val: string) => { categorySearchVal.value = val }
// #1
const categoryColorMap: Record<string, string> = {}
const groupedTags = computed(() => {
const groups: Record<string, { category: string; color: string; tags: any[] }> = {}
for (const tag of tags.value) {
if (!tag.category) continue
if (!groups[tag.category]) {
//
const color = tag.color || '#6366f1'
groups[tag.category] = { category: tag.category, color, tags: [] }
}
groups[tag.category].tags.push(tag)
}
return Object.values(groups)
})
const uncategorizedTags = computed(() => tags.value.filter(t => !t.category))
const fetchTags = async () => {
loading.value = true
@ -87,11 +224,17 @@ const fetchTags = async () => {
finally { loading.value = false }
}
const fetchCategories = async () => {
try { categories.value = await request.get('/tags/categories') as any } catch { /* */ }
}
const openModal = (record?: any) => {
editingId.value = record?.id || null
form.name = record?.name || ''
form.category = record?.category || ''
form.color = record?.color || ''
form.sort = record?.sort || 0
categorySearchVal.value = ''
modalVisible.value = true
}
@ -107,6 +250,7 @@ const handleSubmit = async () => {
}
modalVisible.value = false
fetchTags()
fetchCategories()
} catch (e: any) { message.error(e?.response?.data?.message || '操作失败') }
finally { submitting.value = false }
}
@ -124,7 +268,47 @@ const handleDelete = async (id: number) => {
catch (e: any) { message.error(e?.response?.data?.message || '删除失败') }
}
onMounted(fetchTags)
// #4 /
const moveTag = async (group: { tags: any[] }, idx: number, direction: number) => {
const targetIdx = idx + direction
if (targetIdx < 0 || targetIdx >= group.tags.length) return
//
const a = group.tags[idx]
const b = group.tags[targetIdx]
const tmpSort = a.sort
a.sort = b.sort
b.sort = tmpSort
//
if (a.sort === b.sort) {
a.sort = targetIdx
b.sort = idx
}
//
;[group.tags[idx], group.tags[targetIdx]] = [group.tags[targetIdx], group.tags[idx]]
try {
await request.post('/tags/batch-sort', {
items: [
{ id: a.id, sort: a.sort },
{ id: b.id, sort: b.sort },
],
})
} catch {
message.error('排序保存失败')
fetchTags()
}
}
// #5 使
const goToWorks = (tagName: string) => {
//
router.push({ path: '/content/management', query: { keyword: tagName } })
}
onMounted(() => { fetchTags(); fetchCategories() })
</script>
<style scoped lang="scss">
@ -133,9 +317,96 @@ $primary: #6366f1;
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
:deep(.ant-card-body) { padding: 0; }
}
.data-table { :deep(.ant-table-wrapper) { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
} }
.text-muted { color: #d1d5db; }
.loading-wrap { padding: 60px 0; display: flex; justify-content: center; }
.empty-wrap { padding: 60px 0; display: flex; justify-content: center; }
//
.category-group {
margin-bottom: 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
overflow: hidden;
}
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
.category-name {
display: flex; align-items: center; gap: 8px;
font-size: 15px; font-weight: 600; color: #1e1b4b;
}
.category-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.category-count { font-size: 12px; color: #9ca3af; }
}
.tag-grid { padding: 12px 16px; display: flex; flex-direction: column; gap: 8px; }
.tag-card {
display: flex;
border: 1px solid rgba($primary, 0.06);
border-radius: 10px;
overflow: hidden;
transition: all 0.2s;
&:hover { box-shadow: 0 2px 12px rgba($primary, 0.08); }
&.disabled { opacity: 0.55; }
.tag-color-bar { width: 4px; flex-shrink: 0; }
.tag-body {
flex: 1; padding: 10px 14px;
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
.tag-main { display: flex; align-items: center; min-width: 100px; }
.tag-name { font-size: 14px; font-weight: 600; color: #1e1b4b; }
.tag-meta {
display: flex; gap: 12px;
.tag-usage { font-size: 12px; color: #9ca3af;
&.clickable { color: $primary; cursor: pointer; &:hover { text-decoration: underline; } }
}
.tag-sort { font-size: 12px; color: #d1d5db; }
}
.tag-preview {
display: flex; align-items: center; gap: 6px;
.preview-label { font-size: 11px; color: #d1d5db; }
}
.tag-actions {
margin-left: auto;
display: flex; align-items: center; gap: 2px;
}
}
}
//
.color-picker-row {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
.color-dot {
width: 24px; height: 24px; border-radius: 50%; cursor: pointer;
border: 2px solid transparent; transition: all 0.2s;
&:hover { transform: scale(1.15); }
&.active { border-color: #1e1b4b; box-shadow: 0 0 0 2px rgba(0,0,0,0.1); }
}
}
//
.form-preview {
display: flex; align-items: center; gap: 8px;
padding: 12px 16px;
background: #fafafa;
border-radius: 8px;
.preview-label { font-size: 12px; color: #9ca3af; }
}
</style>

View File

@ -4,11 +4,21 @@
<template #title>作品管理</template>
</a-card>
<!-- 统计 -->
<!-- 统计卡片可点击筛选 -->
<div class="stats-row">
<div class="stat-card" v-for="item in mgmtStats" :key="item.label">
<div class="stat-count">{{ item.value }}</div>
<div class="stat-label">{{ item.label }}</div>
<div
v-for="item in statsItems"
:key="item.key"
:class="['stat-card', { active: activeStatKey === item.key }]"
@click="handleStatClick(item.key)"
>
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
<div class="stat-info">
<span class="stat-count">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
</div>
@ -18,8 +28,16 @@
<a-form-item label="作品/作者">
<a-input v-model:value="keyword" placeholder="作品名称或作者" allow-clear style="width: 180px" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option value="">全部</a-select-option>
<a-select-option value="published">正常</a-select-option>
<a-select-option value="taken_down">已下架</a-select-option>
<a-select-option value="recommended">推荐中</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="排序">
<a-select v-model:value="sortBy" style="width: 120px">
<a-select v-model:value="sortBy" style="width: 120px" @change="handleSearch">
<a-select-option value="latest">最新发布</a-select-option>
<a-select-option value="hot">最多点赞</a-select-option>
<a-select-option value="views">最多浏览</a-select-option>
@ -40,6 +58,13 @@
<template v-if="column.key === 'index'">{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</template>
<template v-else-if="column.key === 'cover'">
<img v-if="record.coverUrl" :src="record.coverUrl" class="cover-thumb" />
<div v-else class="cover-empty"></div>
</template>
<template v-else-if="column.key === 'titleDesc'">
<div class="title-cell">
<span class="work-title">{{ record.title }}</span>
<span v-if="record.description" class="work-desc">{{ record.description }}</span>
</div>
</template>
<template v-else-if="column.key === 'author'">{{ record.creator?.nickname || '-' }}</template>
<template v-else-if="column.key === 'status'">
@ -51,22 +76,137 @@
<template v-else-if="column.key === 'publishTime'">{{ formatDate(record.publishTime) }}</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="showDetail(record.id)">查看</a-button>
<a-button type="link" size="small" @click="handleRecommend(record)">
{{ record.isRecommended ? '取消推荐' : '推荐' }}
</a-button>
<a-button v-if="record.status === 'published'" type="link" danger size="small" @click="handleTakedown(record)">下架</a-button>
<a-button v-else type="link" size="small" @click="handleRestore(record)">恢复</a-button>
<a-button v-if="record.status === 'published'" type="link" danger size="small" @click="openTakedown(record)">下架</a-button>
<a-button v-else type="link" size="small" style="color: #10b981" @click="handleRestore(record)">恢复</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 下架弹窗填写原因 -->
<a-modal v-model:open="takedownVisible" title="下架作品" @ok="handleTakedown" :confirm-loading="takedownLoading">
<p style="margin-bottom: 12px; color: #6b7280; font-size: 13px">
下架后作品{{ takedownTarget?.title }}将不再公开展示请填写下架原因
</p>
<a-radio-group v-model:value="takedownReason" style="display: flex; flex-direction: column; gap: 8px">
<a-radio value="含不适宜内容">含不适宜内容</a-radio>
<a-radio value="涉嫌抄袭/侵权">涉嫌抄袭/侵权</a-radio>
<a-radio value="用户投诉/举报">用户投诉/举报</a-radio>
<a-radio value="违反平台规范">违反平台规范</a-radio>
<a-radio value="other">其他</a-radio>
</a-radio-group>
<a-input v-if="takedownReason === 'other'" v-model:value="takedownCustom" placeholder="请输入下架原因" style="margin-top: 12px" />
</a-modal>
<!-- 详情 Drawer -->
<a-drawer v-model:open="detailVisible" title="作品详情" :width="580" :destroy-on-close="true">
<template v-if="detailData">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="标题" :span="2">{{ detailData.title }}</a-descriptions-item>
<a-descriptions-item label="作者">{{ detailData.creator?.nickname }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="detailData.status === 'published' ? 'green' : 'red'">{{ detailData.status === 'published' ? '正常' : '已下架' }}</a-tag>
<a-tag v-if="detailData.isRecommended" color="blue">推荐</a-tag>
</a-descriptions-item>
<a-descriptions-item label="浏览">{{ detailData.viewCount || 0 }}</a-descriptions-item>
<a-descriptions-item label="点赞">{{ detailData.likeCount || 0 }}</a-descriptions-item>
<a-descriptions-item label="收藏">{{ detailData.favoriteCount || 0 }}</a-descriptions-item>
<a-descriptions-item label="发布时间">{{ formatDate(detailData.publishTime) }}</a-descriptions-item>
</a-descriptions>
<!-- 作品描述 -->
<div v-if="detailData.description" class="detail-section">
<h4>作品简介</h4>
<p class="detail-desc">{{ detailData.description }}</p>
</div>
<!-- 标签 -->
<div v-if="detailData.tags?.length" class="detail-section">
<h4>标签</h4>
<div style="display: flex; gap: 6px; flex-wrap: wrap">
<a-tag v-for="t in detailData.tags" :key="t.tag?.id" color="purple">{{ t.tag?.name }}</a-tag>
</div>
</div>
<!-- 绘本翻页预览 -->
<div v-if="detailData.pages?.length" class="preview-section">
<h4>绘本内容预览</h4>
<div class="page-preview">
<img v-if="detailData.pages[previewPage]?.imageUrl" :src="detailData.pages[previewPage].imageUrl" class="preview-img" />
<p v-if="detailData.pages[previewPage]?.text" class="preview-text">{{ detailData.pages[previewPage].text }}</p>
<div class="preview-nav">
<a-button :disabled="previewPage === 0" size="small" @click="previewPage--">上一页</a-button>
<span>{{ previewPage + 1 }} / {{ detailData.pages.length }}</span>
<a-button :disabled="previewPage === detailData.pages.length - 1" size="small" @click="previewPage++">下一页</a-button>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="drawer-actions">
<a-space style="width: 100%">
<a-button
v-if="detailData.status === 'published'"
@click="handleRecommendInDrawer"
:style="{ flex: 1, color: detailData.isRecommended ? undefined : '#1677ff', borderColor: detailData.isRecommended ? undefined : '#1677ff' }"
>
{{ detailData.isRecommended ? '取消推荐' : '设为推荐' }}
</a-button>
<a-button
v-if="detailData.status === 'published'"
danger
style="flex: 1"
@click="openTakedown(detailData); detailVisible = false"
>
下架
</a-button>
<a-button
v-if="detailData.status === 'taken_down'"
type="primary"
style="flex: 1"
@click="handleRestoreInDrawer"
>
恢复上架
</a-button>
</a-space>
</div>
<!-- 操作日志 -->
<div class="log-section">
<h4>操作日志</h4>
<div v-if="detailLogs.length === 0" class="log-empty">暂无操作记录</div>
<a-timeline v-else>
<a-timeline-item
v-for="log in detailLogs"
:key="log.id"
:color="logActionColor[log.action] || 'gray'"
>
<div class="log-item">
<span class="log-action">{{ logActionText[log.action] || log.action }}</span>
<span class="log-operator">{{ log.operator?.nickname || '系统' }}</span>
<span class="log-time">{{ formatDate(log.createTime) }}</span>
</div>
<div v-if="log.reason" class="log-reason">原因{{ log.reason }}</div>
<div v-if="log.note" class="log-note">备注{{ log.note }}</div>
</a-timeline-item>
</a-timeline>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import {
SearchOutlined, ReloadOutlined,
AppstoreOutlined, PlusCircleOutlined, EyeOutlined, StopOutlined,
} from '@ant-design/icons-vue'
import request from '@/utils/request'
import dayjs from 'dayjs'
@ -75,50 +215,68 @@ const dataSource = ref<any[]>([])
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `${t}` })
const keyword = ref('')
const sortBy = ref('latest')
const mgmtStats = ref([
{ label: '总作品数', value: 0 },
{ label: '今日新增', value: 0 },
{ label: '累计浏览', value: 0 },
{ label: '已下架', value: 0 },
const filterStatus = ref('')
const activeStatKey = ref('')
//
const statsRaw = ref({ total: 0, todayNew: 0, totalViews: 0, takenDown: 0 })
const statsItems = computed(() => [
{ key: 'total', label: '总作品数', value: statsRaw.value.total, icon: AppstoreOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
{ key: 'todayNew', label: '今日新增', value: statsRaw.value.todayNew, icon: PlusCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
{ key: 'totalViews', label: '累计浏览', value: statsRaw.value.totalViews, icon: EyeOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
{ key: 'takenDown', label: '已下架', value: statsRaw.value.takenDown, icon: StopOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)' },
])
//
const takedownVisible = ref(false)
const takedownLoading = ref(false)
const takedownTarget = ref<any>(null)
const takedownReason = ref('')
const takedownCustom = ref('')
// +
const detailVisible = ref(false)
const detailData = ref<any>(null)
const detailLogs = ref<any[]>([])
const previewPage = ref(0)
const logActionText: Record<string, string> = { approve: '审核通过', reject: '审核拒绝', takedown: '下架', restore: '恢复', revoke: '撤销审核' }
const logActionColor: Record<string, string> = { approve: 'green', reject: 'red', takedown: 'gray', restore: 'blue', revoke: 'orange' }
// #6
const columns = [
{ title: '序号', key: 'index', width: 50 },
{ title: '封面', key: 'cover', width: 70 },
{ title: '作品名称', dataIndex: 'title', key: 'title', width: 180 },
{ title: '作者', key: 'author', width: 100 },
{ title: '浏览', dataIndex: 'viewCount', key: 'viewCount', width: 70 },
{ title: '点赞', dataIndex: 'likeCount', key: 'likeCount', width: 70 },
{ title: '收藏', dataIndex: 'favoriteCount', key: 'favoriteCount', width: 70 },
{ title: '作品名称', key: 'titleDesc', width: 220 },
{ title: '作者', key: 'author', width: 90 },
{ title: '浏览', dataIndex: 'viewCount', key: 'viewCount', width: 65 },
{ title: '点赞', dataIndex: 'likeCount', key: 'likeCount', width: 65 },
{ title: '收藏', dataIndex: 'favoriteCount', key: 'favoriteCount', width: 65 },
{ title: '状态', key: 'status', width: 110 },
{ title: '发布时间', key: 'publishTime', width: 140 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
{ title: '发布时间', key: 'publishTime', width: 130 },
{ title: '操作', key: 'action', width: 210, fixed: 'right' as const },
]
const formatDate = (d: string) => d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-'
const fetchStats = async () => {
try {
const s: any = await request.get('/content-review/management/stats')
mgmtStats.value = [
{ label: '总作品数', value: s.total },
{ label: '今日新增', value: s.todayNew },
{ label: '累计浏览', value: s.totalViews },
{ label: '已下架', value: s.takenDown },
]
statsRaw.value = await request.get('/content-review/management/stats') as any
} catch { /* */ }
}
const fetchList = async () => {
loading.value = true
try {
// +
const isRecommendedFilter = filterStatus.value === 'recommended'
const res: any = await request.get('/content-review/works', {
params: {
page: pagination.current,
pageSize: pagination.pageSize,
status: 'published', //
status: isRecommendedFilter ? 'published' : (filterStatus.value || 'published,taken_down'),
keyword: keyword.value || undefined,
sortBy: sortBy.value,
isRecommended: isRecommendedFilter ? '1' : undefined,
},
})
dataSource.value = res.list
@ -127,25 +285,94 @@ const fetchList = async () => {
finally { loading.value = false }
}
const handleSearch = () => { pagination.current = 1; fetchList() }
const handleReset = () => { keyword.value = ''; sortBy.value = 'latest'; pagination.current = 1; fetchList(); fetchStats() }
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
const handleRecommend = async (record: any) => {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success(record.isRecommended ? '已取消推荐' : '已推荐'); fetchList() }
catch { message.error('操作失败') }
// #7
const handleStatClick = (key: string) => {
if (activeStatKey.value === key) {
activeStatKey.value = ''
filterStatus.value = ''
} else {
activeStatKey.value = key
if (key === 'takenDown') filterStatus.value = 'taken_down'
else if (key === 'total' || key === 'todayNew') filterStatus.value = 'published'
else filterStatus.value = ''
}
pagination.current = 1
fetchList()
}
const handleTakedown = (record: any) => {
Modal.confirm({
title: '确定下架?',
content: `下架后作品「${record.title}」将不再公开展示`,
okType: 'danger',
onOk: async () => {
try { await request.post(`/content-review/works/${record.id}/takedown`, { reason: '管理员下架' }); message.success('已下架'); fetchList(); fetchStats() }
catch { message.error('操作失败') }
},
})
// #1
const handleSearch = () => { activeStatKey.value = ''; pagination.current = 1; fetchList() }
const handleReset = () => { keyword.value = ''; sortBy.value = 'latest'; filterStatus.value = ''; activeStatKey.value = ''; pagination.current = 1; fetchList(); fetchStats() }
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
//
const showDetail = async (id: number) => {
previewPage.value = 0
detailLogs.value = []
try {
const [work, logs]: any[] = await Promise.all([
request.get(`/content-review/works/${id}`),
request.get('/content-review/logs', { params: { workId: id, pageSize: 50 } }),
])
detailData.value = work
detailLogs.value = logs.list || []
detailVisible.value = true
} catch { message.error('获取详情失败') }
}
// #3 /
const handleRecommend = async (record: any) => {
if (record.isRecommended) {
Modal.confirm({
title: '确定取消推荐?',
content: `作品「${record.title}」将不再显示在推荐位`,
onOk: async () => {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success('已取消推荐'); fetchList() }
catch { message.error('操作失败') }
},
})
} else {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success('已推荐'); fetchList() }
catch { message.error('操作失败') }
}
}
// #4
const handleRecommendInDrawer = async () => {
const record = detailData.value
if (!record) return
if (record.isRecommended) {
Modal.confirm({
title: '确定取消推荐?',
onOk: async () => {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success('已取消推荐'); fetchList(); showDetail(record.id) }
catch { message.error('操作失败') }
},
})
} else {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success('已推荐'); fetchList(); showDetail(record.id) }
catch { message.error('操作失败') }
}
}
// #2
const openTakedown = (record: any) => {
takedownTarget.value = record
takedownReason.value = ''
takedownCustom.value = ''
takedownVisible.value = true
}
const handleTakedown = async () => {
const reason = takedownReason.value === 'other' ? takedownCustom.value : takedownReason.value
if (!reason) { message.warning('请选择下架原因'); return }
takedownLoading.value = true
try {
await request.post(`/content-review/works/${takedownTarget.value.id}/takedown`, { reason })
message.success('已下架')
takedownVisible.value = false
fetchList(); fetchStats()
} catch { message.error('操作失败') }
finally { takedownLoading.value = false }
}
const handleRestore = async (record: any) => {
@ -153,18 +380,73 @@ const handleRestore = async (record: any) => {
catch { message.error('操作失败') }
}
// #4
const handleRestoreInDrawer = async () => {
const id = detailData.value?.id
if (!id) return
try { await request.post(`/content-review/works/${id}/restore`); message.success('已恢复'); fetchList(); fetchStats(); showDetail(id) }
catch { message.error('操作失败') }
}
onMounted(() => { fetchStats(); fetchList() })
</script>
<style scoped lang="scss">
$primary: #6366f1;
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); :deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } } :deep(.ant-card-body) { padding: 0; } }
// #7
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; }
.stat-card { flex: 1; background: #fff; border-radius: 12px; padding: 16px 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); text-align: center;
.stat-count { font-size: 22px; font-weight: 700; color: #1e1b4b; }
.stat-label { font-size: 12px; color: #9ca3af; margin-top: 2px; }
.stat-card { flex: 1; display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); cursor: pointer; border: 2px solid transparent; transition: all 0.2s;
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); } &.active { border-color: $primary; background: rgba($primary, 0.02); }
.stat-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
.stat-info { display: flex; flex-direction: column; .stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; } .stat-label { font-size: 12px; color: #9ca3af; } }
}
.filter-bar { padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px; }
.data-table { :deep(.ant-table-wrapper) { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden; .ant-table-thead > tr > th { background: #fafafa; font-weight: 600; } .ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); } .ant-table-pagination { padding: 16px; margin: 0; } } }
// #6 +
.title-cell {
display: flex; flex-direction: column; gap: 2px;
.work-title { font-size: 13px; font-weight: 600; color: #1e1b4b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-desc { font-size: 11px; color: #9ca3af; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
}
.cover-thumb { width: 48px; height: 64px; object-fit: cover; border-radius: 6px; }
.cover-empty { width: 48px; height: 64px; background: #f3f4f6; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #d1d5db; }
//
.detail-section {
margin-top: 16px;
h4 { font-size: 13px; font-weight: 600; color: #374151; margin: 0 0 8px; }
.detail-desc { font-size: 13px; color: #6b7280; line-height: 1.6; margin: 0; }
}
.preview-section { margin-top: 20px; h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; } }
.page-preview {
.preview-img { width: 100%; max-height: 300px; object-fit: contain; border-radius: 8px; margin-bottom: 8px; }
.preview-text { font-size: 13px; color: #374151; line-height: 1.6; padding: 8px 0; }
.preview-nav { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; span { font-size: 12px; color: #6b7280; } }
}
// #4
.drawer-actions {
margin-top: 20px; padding-top: 16px; border-top: 1px solid #f0ecf9;
:deep(.ant-space) { display: flex; .ant-space-item { flex: 1; .ant-btn { width: 100%; } } }
}
//
.log-section {
margin-top: 24px;
h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
.log-empty { font-size: 13px; color: #9ca3af; text-align: center; padding: 20px 0; }
}
.log-item {
display: flex; align-items: center; gap: 8px;
.log-action { font-size: 13px; font-weight: 600; color: #1e1b4b; }
.log-operator { font-size: 12px; color: #6b7280; }
.log-time { font-size: 11px; color: #9ca3af; margin-left: auto; }
}
.log-reason, .log-note { font-size: 12px; color: #6b7280; margin-top: 2px; }
</style>

View File

@ -21,7 +21,8 @@
<div class="filter-bar">
<a-form layout="inline" @finish="handleSearch">
<a-form-item label="审核状态">
<a-select v-model:value="searchStatus" placeholder="全部" allow-clear style="width: 120px">
<a-select v-model:value="searchStatus" style="width: 120px" @change="handleSearch">
<a-select-option value="">全部</a-select-option>
<a-select-option value="pending_review">待审核</a-select-option>
<a-select-option value="published">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
@ -39,26 +40,61 @@
</a-form>
</div>
<!-- 批量操作栏 -->
<div class="batch-bar">
<template v-if="selectedRowKeys.length > 0">
<span>已选择 <strong>{{ selectedRowKeys.length }}</strong> </span>
<a-button type="primary" size="small" :loading="batchLoading" @click="handleBatchApprove">
<template #icon><CheckCircleOutlined /></template>
批量通过
</a-button>
<a-button danger size="small" @click="openBatchReject">
<template #icon><CloseCircleOutlined /></template>
批量拒绝
</a-button>
<a-button size="small" @click="selectedRowKeys = []">取消选择</a-button>
</template>
<span v-else class="batch-tip">勾选表格中的待审核作品可进行批量操作</span>
</div>
<!-- 表格 -->
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id" @change="handleTableChange" class="data-table">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
:row-selection="{ selectedRowKeys, onChange: onSelectChange, getCheckboxProps: (r: any) => ({ disabled: r.status !== 'pending_review' }) }"
row-key="id"
@change="handleTableChange"
class="data-table"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</template>
<template v-else-if="column.key === 'cover'">
<img v-if="record.coverUrl" :src="record.coverUrl" class="cover-thumb" />
<div v-else class="cover-empty"></div>
</template>
<template v-else-if="column.key === 'titleDesc'">
<div class="title-cell">
<span class="work-title">{{ record.title }}</span>
<span v-if="record.description" class="work-desc">{{ record.description }}</span>
</div>
</template>
<template v-else-if="column.key === 'author'">{{ record.creator?.nickname || '-' }}</template>
<template v-else-if="column.key === 'tags'">
<a-tag v-for="t in (record.tags || []).slice(0, 3)" :key="t.tag?.id" size="small">{{ t.tag?.name }}</a-tag>
<span v-if="!record.tags?.length" style="color: #d1d5db">-</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusColor[record.status] || 'default'">{{ statusText[record.status] || record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'createTime'">{{ formatDate(record.createTime) }}</template>
<template v-else-if="column.key === 'reviewTime'">{{ record.reviewTime ? formatDate(record.reviewTime) : '-' }}</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button v-if="record.status === 'pending_review'" type="link" size="small" style="color: #10b981" @click="quickApprove(record.id)">通过</a-button>
<a-button v-if="record.status === 'pending_review'" type="link" size="small" danger @click="openReject(record.id)">拒绝</a-button>
<a-button v-if="record.status === 'published' || record.status === 'rejected'" type="link" size="small" style="color: #f59e0b" @click="handleRevoke(record.id)">撤销</a-button>
<a-button type="link" size="small" @click="showDetail(record.id)">详情</a-button>
</a-space>
</template>
@ -66,7 +102,7 @@
</a-table>
<!-- 拒绝弹窗 -->
<a-modal v-model:open="rejectVisible" title="拒绝作品" @ok="handleReject" :confirm-loading="rejectLoading">
<a-modal v-model:open="rejectVisible" :title="isBatchReject ? `批量拒绝${selectedRowKeys.length}个作品` : '拒绝作品'" @ok="handleReject" :confirm-loading="rejectLoading">
<a-radio-group v-model:value="rejectReason" style="display: flex; flex-direction: column; gap: 8px">
<a-radio value="含不适宜未成年人的内容">含不适宜未成年人的内容</a-radio>
<a-radio value="含个人隐私信息">含个人隐私信息</a-radio>
@ -78,18 +114,44 @@
</a-modal>
<!-- 详情 Drawer -->
<a-drawer v-model:open="detailVisible" title="作品审核详情" :width="560" :destroy-on-close="true">
<a-drawer v-model:open="detailVisible" title="作品审核详情" :width="580" :destroy-on-close="true">
<template v-if="detailData">
<a-descriptions title="作品信息" :column="2" bordered size="small">
<a-descriptions-item label="标题" :span="2">{{ detailData.title }}</a-descriptions-item>
<!-- 顶部快速导航 -->
<div class="detail-nav">
<a-button :disabled="!hasPrevPending" size="small" @click="gotoPrevPending">
<left-outlined /> 上一个
</a-button>
<span class="detail-nav-info">
{{ detailData.title }}
<a-tag :color="statusColor[detailData.status]" style="margin-left: 8px">{{ statusText[detailData.status] }}</a-tag>
</span>
<a-button :disabled="!hasNextPending" size="small" @click="gotoNextPending">
下一个 <right-outlined />
</a-button>
</div>
<a-descriptions :column="2" bordered size="small" style="margin-top: 16px">
<a-descriptions-item label="作者">{{ detailData.creator?.nickname }}</a-descriptions-item>
<a-descriptions-item label="用户类型">{{ detailData.creator?.userType === 'child' ? '子女' : '成人' }}</a-descriptions-item>
<a-descriptions-item label="页数">{{ detailData._count?.pages || 0 }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor[detailData.status]">{{ statusText[detailData.status] }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ formatDate(detailData.createTime) }}</a-descriptions-item>
<a-descriptions-item v-if="detailData.reviewTime" label="审核时间" :span="2">{{ formatDate(detailData.reviewTime) }}</a-descriptions-item>
</a-descriptions>
<!-- 作品描述 -->
<div v-if="detailData.description" class="detail-section">
<h4>作品简介</h4>
<p class="detail-desc">{{ detailData.description }}</p>
</div>
<!-- 标签 -->
<div v-if="detailData.tags?.length" class="detail-section">
<h4>标签</h4>
<div style="display: flex; gap: 6px; flex-wrap: wrap">
<a-tag v-for="t in detailData.tags" :key="t.tag?.id" color="purple">{{ t.tag?.name }}</a-tag>
</div>
</div>
<!-- 绘本翻页预览 -->
<div v-if="detailData.pages?.length" class="preview-section">
<h4>绘本内容预览</h4>
@ -106,8 +168,34 @@
<!-- 审核操作 -->
<div v-if="detailData.status === 'pending_review'" class="review-actions">
<a-button type="primary" block @click="quickApprove(detailData.id); detailVisible = false" style="margin-bottom: 8px">通过</a-button>
<a-button danger block @click="openReject(detailData.id); detailVisible = false">拒绝</a-button>
<a-space style="width: 100%">
<a-button type="primary" style="flex: 1" @click="approveInDrawer">通过</a-button>
<a-button danger style="flex: 1" @click="rejectInDrawer">拒绝</a-button>
</a-space>
</div>
<div v-else-if="detailData.status === 'published' || detailData.status === 'rejected'" class="review-actions">
<a-button block style="color: #f59e0b; border-color: #f59e0b" @click="handleRevoke(detailData.id)">撤销审核</a-button>
</div>
<!-- 操作日志 -->
<div class="log-section">
<h4>操作日志</h4>
<div v-if="detailLogs.length === 0" class="log-empty">暂无操作记录</div>
<a-timeline v-else>
<a-timeline-item
v-for="log in detailLogs"
:key="log.id"
:color="logActionColor[log.action] || 'gray'"
>
<div class="log-item">
<span class="log-action">{{ logActionText[log.action] || log.action }}</span>
<span class="log-operator">{{ log.operator?.nickname || '系统' }}</span>
<span class="log-time">{{ formatDate(log.createTime) }}</span>
</div>
<div v-if="log.reason" class="log-reason">原因{{ log.reason }}</div>
<div v-if="log.note" class="log-note">备注{{ log.note }}</div>
</a-timeline-item>
</a-timeline>
</div>
</template>
</a-drawer>
@ -116,17 +204,26 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { SearchOutlined, ReloadOutlined, ClockCircleOutlined, CheckCircleOutlined, CloseCircleOutlined, FileTextOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import {
SearchOutlined, ReloadOutlined, ClockCircleOutlined, CheckCircleOutlined,
CloseCircleOutlined, FileTextOutlined, LeftOutlined, RightOutlined,
} from '@ant-design/icons-vue'
import request from '@/utils/request'
import dayjs from 'dayjs'
const loading = ref(false)
const dataSource = ref<any[]>([])
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `${t}` })
const searchStatus = ref<string | undefined>(undefined)
// #1
const searchStatus = ref('pending_review')
const searchKeyword = ref('')
const activeFilter = ref('')
const activeFilter = ref('pending_review')
//
const selectedRowKeys = ref<number[]>([])
const batchLoading = ref(false)
const onSelectChange = (keys: number[]) => { selectedRowKeys.value = keys }
const stats = ref({ pending: 0, todayReviewed: 0, todayApproved: 0, todayRejected: 0 })
const statsItems = computed(() => [
@ -138,18 +235,22 @@ const statsItems = computed(() => [
const statusColor: Record<string, string> = { pending_review: 'orange', published: 'green', rejected: 'red', taken_down: 'default' }
const statusText: Record<string, string> = { pending_review: '待审核', published: '已通过', rejected: '已拒绝', taken_down: '已下架', draft: '草稿' }
const logActionText: Record<string, string> = { approve: '审核通过', reject: '审核拒绝', takedown: '下架', restore: '恢复', revoke: '撤销审核' }
const logActionColor: Record<string, string> = { approve: 'green', reject: 'red', takedown: 'gray', restore: 'blue', revoke: 'orange' }
const formatDate = (d: string) => d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-'
// #2 + #4
const columns = [
{ title: '序号', key: 'index', width: 50 },
{ title: '封面', key: 'cover', width: 70 },
{ title: '作品名称', dataIndex: 'title', key: 'title', width: 180 },
{ title: '页数', dataIndex: ['_count', 'pages'], key: 'pages', width: 60 },
{ title: '作者', key: 'author', width: 100 },
{ title: '标签', key: 'tags', width: 140 },
{ title: '作品名称', key: 'titleDesc', width: 220 },
{ title: '页数', dataIndex: ['_count', 'pages'], key: 'pages', width: 50 },
{ title: '作者', key: 'author', width: 90 },
{ title: '标签', key: 'tags', width: 130 },
{ title: '状态', key: 'status', width: 80 },
{ title: '提交时间', key: 'createTime', width: 140 },
{ title: '操作', key: 'action', width: 150, fixed: 'right' as const },
{ title: '提交时间', key: 'createTime', width: 130 },
{ title: '审核时间', key: 'reviewTime', width: 130 },
{ title: '操作', key: 'action', width: 190, fixed: 'right' as const },
]
//
@ -159,10 +260,86 @@ const rejectTargetId = ref<number | null>(null)
const rejectReason = ref('')
const rejectCustom = ref('')
//
// +
const detailVisible = ref(false)
const detailData = ref<any>(null)
const detailLogs = ref<any[]>([])
const previewPage = ref(0)
const currentDetailIndex = ref(-1)
// #3 /
const pendingItems = computed(() => dataSource.value.filter(d => d.status === 'pending_review'))
const hasPrevPending = computed(() => {
if (currentDetailIndex.value < 0) return false
//
const currentId = detailData.value?.id
const idx = dataSource.value.findIndex(d => d.id === currentId)
for (let i = idx - 1; i >= 0; i--) {
if (dataSource.value[i].status === 'pending_review') return true
}
return false
})
const hasNextPending = computed(() => {
const currentId = detailData.value?.id
const idx = dataSource.value.findIndex(d => d.id === currentId)
for (let i = idx + 1; i < dataSource.value.length; i++) {
if (dataSource.value[i].status === 'pending_review') return true
}
return false
})
const gotoPrevPending = () => {
const currentId = detailData.value?.id
const idx = dataSource.value.findIndex(d => d.id === currentId)
for (let i = idx - 1; i >= 0; i--) {
if (dataSource.value[i].status === 'pending_review') {
showDetail(dataSource.value[i].id)
return
}
}
}
const gotoNextPending = () => {
const currentId = detailData.value?.id
const idx = dataSource.value.findIndex(d => d.id === currentId)
for (let i = idx + 1; i < dataSource.value.length; i++) {
if (dataSource.value[i].status === 'pending_review') {
showDetail(dataSource.value[i].id)
return
}
}
}
//
const approveInDrawer = async () => {
const id = detailData.value?.id
if (!id) return
try {
await request.post(`/content-review/works/${id}/approve`, {})
message.success('已通过')
fetchList(); fetchStats()
//
const idx = dataSource.value.findIndex(d => d.id === id)
let nextId: number | null = null
for (let i = idx + 1; i < dataSource.value.length; i++) {
if (dataSource.value[i].status === 'pending_review') { nextId = dataSource.value[i].id; break }
}
if (!nextId) {
for (let i = idx - 1; i >= 0; i--) {
if (dataSource.value[i].status === 'pending_review') { nextId = dataSource.value[i].id; break }
}
}
if (nextId) {
showDetail(nextId)
} else {
detailVisible.value = false
}
} catch { message.error('操作失败') }
}
const rejectInDrawer = () => {
openReject(detailData.value?.id)
}
const fetchStats = async () => {
try { stats.value = await request.get('/content-review/works/stats') as any } catch { /* */ }
@ -172,7 +349,7 @@ const fetchList = async () => {
loading.value = true
try {
const res: any = await request.get('/content-review/works', {
params: { page: pagination.current, pageSize: pagination.pageSize, status: searchStatus.value, keyword: searchKeyword.value || undefined },
params: { page: pagination.current, pageSize: pagination.pageSize, status: searchStatus.value || undefined, keyword: searchKeyword.value || undefined },
})
dataSource.value = res.list
pagination.total = res.total
@ -180,16 +357,28 @@ const fetchList = async () => {
finally { loading.value = false }
}
// #6 +
const handleStatClick = (key: string) => {
activeFilter.value = key
if (key === 'pending_review') searchStatus.value = 'pending_review'
else searchStatus.value = undefined
if (activeFilter.value === key) {
activeFilter.value = ''
searchStatus.value = ''
} else {
activeFilter.value = key
if (key === 'pending_review') searchStatus.value = 'pending_review'
else if (key === 'approved') searchStatus.value = 'published'
else if (key === 'rejected') searchStatus.value = 'rejected'
else searchStatus.value = ''
}
pagination.current = 1
fetchList()
}
const handleSearch = () => { pagination.current = 1; fetchList() }
const handleReset = () => { searchStatus.value = undefined; searchKeyword.value = ''; activeFilter.value = ''; pagination.current = 1; fetchList(); fetchStats() }
const handleSearch = () => { activeFilter.value = ''; pagination.current = 1; fetchList() }
const handleReset = () => {
// #1
searchStatus.value = 'pending_review'; searchKeyword.value = ''; activeFilter.value = 'pending_review'
selectedRowKeys.value = []; pagination.current = 1; fetchList(); fetchStats()
}
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
const quickApprove = async (id: number) => {
@ -197,20 +386,79 @@ const quickApprove = async (id: number) => {
catch { message.error('操作失败') }
}
const openReject = (id: number) => { rejectTargetId.value = id; rejectReason.value = ''; rejectCustom.value = ''; rejectVisible.value = true }
const handleBatchApprove = async () => {
if (selectedRowKeys.value.length === 0) return
batchLoading.value = true
try {
const res: any = await request.post('/content-review/works/batch-approve', { ids: selectedRowKeys.value })
message.success(`已批量通过 ${res.count} 个作品`)
selectedRowKeys.value = []
fetchList(); fetchStats()
} catch { message.error('批量操作失败') }
finally { batchLoading.value = false }
}
const handleRevoke = (id: number) => {
Modal.confirm({
title: '确定撤销审核?',
content: '撤销后作品将恢复为待审核状态',
okText: '确定撤销',
onOk: async () => {
try {
await request.post(`/content-review/works/${id}/revoke`)
message.success('已撤销')
fetchList(); fetchStats()
//
if (detailVisible.value && detailData.value?.id === id) {
showDetail(id)
}
} catch { message.error('撤销失败') }
},
})
}
const isBatchReject = ref(false)
const openReject = (id: number) => { rejectTargetId.value = id; isBatchReject.value = false; rejectReason.value = ''; rejectCustom.value = ''; rejectVisible.value = true }
const openBatchReject = () => { rejectTargetId.value = null; isBatchReject.value = true; rejectReason.value = ''; rejectCustom.value = ''; rejectVisible.value = true }
const handleReject = async () => {
const reason = rejectReason.value === 'other' ? rejectCustom.value : rejectReason.value
if (!reason) { message.warning('请选择拒绝原因'); return }
rejectLoading.value = true
try { await request.post(`/content-review/works/${rejectTargetId.value}/reject`, { reason }); message.success('已拒绝'); rejectVisible.value = false; fetchList(); fetchStats() }
catch { message.error('操作失败') }
try {
if (isBatchReject.value) {
const res: any = await request.post('/content-review/works/batch-reject', { ids: selectedRowKeys.value, reason })
message.success(`已批量拒绝 ${res.count} 个作品`)
selectedRowKeys.value = []
} else {
await request.post(`/content-review/works/${rejectTargetId.value}/reject`, { reason })
message.success('已拒绝')
}
rejectVisible.value = false; fetchList(); fetchStats()
// Drawer
if (detailVisible.value && !isBatchReject.value) {
const idx = dataSource.value.findIndex(d => d.id === rejectTargetId.value)
let nextId: number | null = null
for (let i = idx + 1; i < dataSource.value.length; i++) {
if (dataSource.value[i].status === 'pending_review') { nextId = dataSource.value[i].id; break }
}
if (nextId) { showDetail(nextId) } else { detailVisible.value = false }
}
} catch { message.error('操作失败') }
finally { rejectLoading.value = false }
}
const showDetail = async (id: number) => {
previewPage.value = 0
try { detailData.value = await request.get(`/content-review/works/${id}`); detailVisible.value = true }
catch { message.error('获取详情失败') }
detailLogs.value = []
try {
const [work, logs]: any[] = await Promise.all([
request.get(`/content-review/works/${id}`),
request.get('/content-review/logs', { params: { workId: id, pageSize: 50 } }),
])
detailData.value = work
detailLogs.value = logs.list || []
detailVisible.value = true
} catch { message.error('获取详情失败') }
}
onMounted(() => { fetchStats(); fetchList() })
@ -226,14 +474,76 @@ $primary: #6366f1;
.stat-info { display: flex; flex-direction: column; .stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; } .stat-label { font-size: 12px; color: #9ca3af; } }
}
.filter-bar { padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px; }
.batch-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: rgba($primary, 0.03);
border: 1px dashed rgba($primary, 0.15);
border-radius: 10px;
margin-bottom: 16px;
min-height: 42px;
span { font-size: 13px; color: #374151; strong { color: $primary; } }
.batch-tip { font-size: 12px; color: #9ca3af; }
}
.data-table { :deep(.ant-table-wrapper) { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden; .ant-table-thead > tr > th { background: #fafafa; font-weight: 600; } .ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); } .ant-table-pagination { padding: 16px; margin: 0; } } }
// #2 +
.title-cell {
display: flex; flex-direction: column; gap: 2px;
.work-title { font-size: 13px; font-weight: 600; color: #1e1b4b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-desc { font-size: 11px; color: #9ca3af; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
}
.cover-thumb { width: 48px; height: 64px; object-fit: cover; border-radius: 6px; }
.cover-empty { width: 48px; height: 64px; background: #f3f4f6; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #d1d5db; }
.preview-section { margin-top: 24px; h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; } }
// #3
.detail-nav {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 16px;
background: #fafafa;
border-radius: 10px;
.detail-nav-info {
flex: 1; text-align: center;
font-size: 14px; font-weight: 600; color: #1e1b4b;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
}
// #5
.detail-section {
margin-top: 16px;
h4 { font-size: 13px; font-weight: 600; color: #374151; margin: 0 0 8px; }
.detail-desc { font-size: 13px; color: #6b7280; line-height: 1.6; margin: 0; }
}
.preview-section { margin-top: 20px; h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; } }
.page-preview {
.preview-img { width: 100%; max-height: 300px; object-fit: contain; border-radius: 8px; margin-bottom: 8px; }
.preview-text { font-size: 13px; color: #374151; line-height: 1.6; padding: 8px 0; }
.preview-nav { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; span { font-size: 12px; color: #6b7280; } }
}
.review-actions { margin-top: 24px; padding-top: 16px; border-top: 1px solid #f0ecf9; }
.review-actions { margin-top: 20px; padding-top: 16px; border-top: 1px solid #f0ecf9;
:deep(.ant-space) { display: flex; .ant-space-item { flex: 1; .ant-btn { width: 100%; } } }
}
//
.log-section {
margin-top: 24px;
h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
.log-empty { font-size: 13px; color: #9ca3af; text-align: center; padding: 20px 0; }
}
.log-item {
display: flex; align-items: center; gap: 8px;
.log-action { font-size: 13px; font-weight: 600; color: #1e1b4b; }
.log-operator { font-size: 12px; color: #6b7280; }
.log-time { font-size: 11px; color: #9ca3af; margin-left: auto; }
}
.log-reason, .log-note { font-size: 12px; color: #6b7280; margin-top: 2px; }
</style>

File diff suppressed because it is too large Load Diff

View File

@ -15,8 +15,8 @@
</template>
</a-card>
<!-- 超管统计卡片 -->
<div v-if="isSuperAdmin" class="stats-row">
<!-- 统计卡片超管+租户端都显示 -->
<div class="stats-row">
<div
v-for="item in statsItems"
:key="item.stage"
@ -34,38 +34,13 @@
</div>
<!-- 搜索表单 -->
<a-form
:model="searchParams"
layout="inline"
class="search-form"
@finish="handleSearch"
>
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
<a-form-item label="活动名称">
<a-input
v-model:value="searchParams.contestName"
placeholder="请输入活动名称"
allow-clear
style="width: 180px"
/>
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 180px" />
</a-form-item>
<a-form-item v-if="!isSuperAdmin" label="活动状态">
<a-select
v-model:value="searchParams.contestState"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select-option value="published">已发布</a-select-option>
<a-select-option value="unpublished">未发布</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="isSuperAdmin" label="活动阶段">
<a-select
v-model:value="searchParams.stage"
placeholder="全部阶段"
allow-clear
style="width: 120px"
>
<a-form-item label="活动阶段">
<a-select v-model:value="searchParams.stage" placeholder="全部阶段" allow-clear style="width: 120px" @change="handleSearch">
<a-select-option value="">全部</a-select-option>
<a-select-option value="unpublished">未发布</a-select-option>
<a-select-option value="registering">报名中</a-select-option>
<a-select-option value="submitting">征稿中</a-select-option>
@ -74,70 +49,34 @@
</a-select>
</a-form-item>
<a-form-item label="活动类型">
<a-select
v-model:value="searchParams.contestType"
placeholder="请选择类型"
allow-clear
style="width: 120px"
>
<a-select v-model:value="searchParams.contestType" placeholder="全部" allow-clear style="width: 120px" @change="handleSearch">
<a-select-option value="individual">个人参与</a-select-option>
<a-select-option value="team">团队参与</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="可见范围">
<a-select
v-model:value="searchParams.visibility"
placeholder="全部"
allow-clear
style="width: 120px"
>
<a-select-option value="public">公开</a-select-option>
<a-select-option value="targeted">定向推送</a-select-option>
<a-select-option value="designated">指定机构</a-select-option>
<a-select-option value="internal">仅内部</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="isSuperAdmin" label="主办机构">
<a-select
v-model:value="searchParams.creatorTenantId"
placeholder="全部机构"
allow-clear
show-search
:filter-option="filterTenantOption"
style="width: 160px"
:options="tenantOptions"
/>
<a-select v-model:value="searchParams.creatorTenantId" placeholder="全部机构" allow-clear show-search
:filter-option="filterTenantOption" style="width: 160px" :options="tenantOptions" @change="handleSearch" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
<template #icon><SearchOutlined /></template> 搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
<template #icon><ReloadOutlined /></template> 重置
</a-button>
</a-form-item>
</a-form>
<!-- 数据表格 -->
<a-table
:columns="currentColumns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
class="data-table"
>
<a-table :columns="currentColumns" :data-source="dataSource" :loading="loading" :pagination="pagination"
row-key="id" @change="handleTableChange" class="data-table">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'organizer'">
<span v-if="isSuperAdmin && record.creatorTenant">
{{ record.creatorTenant.name }}
</span>
<span v-if="isSuperAdmin && record.creatorTenant">{{ record.creatorTenant.name }}</span>
<span v-else-if="record.organizers">
{{ typeof record.organizers === 'string' ? record.organizers : (Array.isArray(record.organizers) ? record.organizers[0] : '-') }}
</span>
@ -145,7 +84,7 @@
</template>
<template v-else-if="column.key === 'contestType'">
<a-tag :color="record.contestType === 'individual' ? 'blue' : 'green'">
{{ record.contestType === "individual" ? "个人参与" : "团队参与" }}
{{ record.contestType === 'individual' ? '个人' : '团队' }}
</a-tag>
</template>
<template v-else-if="column.key === 'stage'">
@ -153,80 +92,48 @@
{{ stageTagMap[record.stage]?.label || '已发布' }}
</a-tag>
</template>
<template v-else-if="column.key === 'contestState'">
<a-tag :color="record.contestState === 'published' ? 'success' : 'default'">
{{ record.contestState === "published" ? "已发布" : "未发布" }}
</a-tag>
<template v-else-if="column.key === 'regCount'">
<a-button v-if="record._count?.registrations > 0" type="link" size="small" class="count-link"
@click="goToRegistrations(record.id)">
{{ record._count.registrations }}
</a-button>
<span v-else class="text-muted">0</span>
</template>
<template v-else-if="column.key === 'workCount'">
<a-button v-if="record._count?.works > 0" type="link" size="small" class="count-link"
@click="goToWorks(record.id)">
{{ record._count.works }}
</a-button>
<span v-else class="text-muted">0</span>
</template>
<template v-else-if="column.key === 'reviewProgress'">
<span v-if="record.totalWorksCount > 0">
<span :class="{ 'text-success': record.reviewedCount === record.totalWorksCount }">{{ record.reviewedCount }}</span>
<span class="text-muted">/{{ record.totalWorksCount }}</span>
</span>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'judges'">
<a-tag v-if="record._count?.judges > 0" color="blue">{{ record._count.judges }}</a-tag>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'visibility'">
<a-tag v-if="record.visibility === 'public'" color="green">公开</a-tag>
<a-tag v-else-if="record.visibility === 'targeted'" color="orange">
<a-tooltip>
<template #title>
<div v-if="record.targetCities?.length">城市{{ record.targetCities.join('') }}</div>
<div v-if="record.ageMin || record.ageMax">年龄{{ record.ageMin || 0 }}-{{ record.ageMax || '不限' }}</div>
<div v-if="!record.targetCities?.length && !record.ageMin && !record.ageMax">无附加条件</div>
</template>
定向推送
</a-tooltip>
</a-tag>
<a-tag v-else-if="record.visibility === 'internal'" color="default">仅内部</a-tag>
<a-tag v-else-if="record.visibility === 'targeted'" color="orange">定向</a-tag>
<a-tag v-else-if="record.visibility === 'internal'" color="default">内部</a-tag>
<a-tag v-else color="blue">指定机构</a-tag>
</template>
<template v-else-if="column.key === 'publicScope'">
<template v-if="record.contestTenants && record.contestTenants.length > 0">
<a-tooltip>
<template #title>
<div v-for="tenantId in record.contestTenants" :key="tenantId">
{{ getTenantName(tenantId) }}
</div>
<div v-for="tid in record.contestTenants" :key="tid">{{ getTenantName(tid) }}</div>
</template>
<a-tag>{{ record.contestTenants.length }}个机构</a-tag>
</a-tooltip>
</template>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'regCount'">
<a-button
v-if="isSuperAdmin && record._count?.registrations > 0"
type="link"
size="small"
class="count-link"
@click="goToRegistrations(record.id)"
>
{{ record._count.registrations }}
</a-button>
<span v-else-if="record._count?.registrations > 0">{{ record._count.registrations }}</span>
<span v-else class="text-muted">0</span>
</template>
<template v-else-if="column.key === 'workCount'">
<a-button
v-if="isSuperAdmin && record._count?.works > 0"
type="link"
size="small"
class="count-link"
@click="goToWorks(record.id)"
>
{{ record._count.works }}
</a-button>
<span v-else-if="record._count?.works > 0">{{ record._count.works }}</span>
<span v-else class="text-muted">0</span>
</template>
<template v-else-if="column.key === 'reviewProgress'">
<span v-if="record.totalWorksCount > 0">
<span :class="{ 'text-success': record.reviewedCount === record.totalWorksCount }">
{{ record.reviewedCount }}
</span>
<span class="text-muted">/{{ record.totalWorksCount }}</span>
</span>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'judges'">
<a-tag v-if="record._count?.judges > 0" color="blue">
{{ record._count.judges }}
</a-tag>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'contestTime'">
<div v-if="record.startTime || record.endTime">
<div>{{ formatDate(record.startTime) }}</div>
@ -236,90 +143,58 @@
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<!-- 租户端操作 -->
<template v-if="!isSuperAdmin">
<a-button v-permission="'contest:publish'" type="link" size="small" @click="handlePublishClick(record)">
{{ record.contestState === "published" ? "取消发布" : "发布" }}
</a-button>
<a-button v-permission="'contest:update'" type="link" size="small" @click="handleAddJudge(record.id)">
添加评委
</a-button>
<a-button v-permission="'contest:update'" type="link" size="small" @click.stop="handleEdit(record.id)">
编辑
</a-button>
<a-button v-permission="'contest:delete'" type="link" danger size="small" @click="handleDeleteClick(record)">
删除
</a-button>
<!-- 未发布发布编辑删除 -->
<template v-if="record.contestState !== 'published'">
<a-button v-permission="'contest:publish'" type="link" size="small" style="color: #10b981" @click="handlePublishClick(record)">发布</a-button>
<a-button v-permission="'contest:update'" type="link" size="small" @click="handleEdit(record.id)">编辑</a-button>
<a-button v-permission="'contest:delete'" type="link" danger size="small" @click="handleDeleteClick(record)">删除</a-button>
</template>
<!-- 已发布查看添加评委取消发布 -->
<template v-else>
<a-button type="link" size="small" @click="router.push(`/${tenantCode}/contests/${record.id}`)">查看</a-button>
<a-button v-permission="'contest:update'" type="link" size="small" @click="handleAddJudge(record.id)">评委</a-button>
<a-button v-permission="'contest:update'" type="link" size="small" @click="handleEdit(record.id)">编辑</a-button>
<a-button v-permission="'contest:publish'" type="link" size="small" style="color: #f59e0b" @click="handlePublishClick(record)">取消发布</a-button>
</template>
</template>
<a-button
v-if="isSuperAdmin"
type="link"
size="small"
@click="router.push(`/${tenantCode}/contests/${record.id}/overview`)"
>
查看详情
</a-button>
<!-- 超管端操作 -->
<a-button v-if="isSuperAdmin" type="link" size="small"
@click="router.push(`/${tenantCode}/contests/${record.id}/overview`)">查看详情</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 添加评委侧边弹框 -->
<a-drawer
v-model:open="judgeDrawerVisible"
title="添加评委"
placement="right"
width="800px"
@close="handleJudgeDrawerClose"
>
<AddJudgeDrawer
v-if="currentContestId && currentContest"
:contest-id="currentContestId"
:contest="currentContest"
@success="handleJudgeAddSuccess"
/>
<a-drawer v-model:open="judgeDrawerVisible" title="添加评委" placement="right" width="800px" @close="handleJudgeDrawerClose">
<AddJudgeDrawer v-if="currentContestId && currentContest" :contest-id="currentContestId" :contest="currentContest" @success="handleJudgeAddSuccess" />
</a-drawer>
<!-- 发布弹框 -->
<a-modal
v-model:open="publishModalVisible"
title="发布活动"
:confirm-loading="publishLoading"
@ok="handlePublishConfirm"
>
<a-modal v-model:open="publishModalVisible" title="发布活动" :confirm-loading="publishLoading" @ok="handlePublishConfirm">
<a-form layout="vertical">
<a-form-item label="选择公开范围(可见机构)" required>
<a-select
v-model:value="selectedTenants"
mode="multiple"
placeholder="请选择公开范围"
style="width: 100%"
:options="tenantOptions"
:filter-option="filterTenantOption"
show-search
/>
<a-select v-model:value="selectedTenants" mode="multiple" placeholder="请选择公开范围" style="width: 100%"
:options="publishTenantOptions" :filter-option="filterTenantOption" show-search />
<div style="margin-top: 8px">
<a-button type="link" size="small" @click="selectedTenants = publishTenantOptions.map((o: any) => o.value)">全选</a-button>
<a-button type="link" size="small" @click="selectedTenants = []">清空</a-button>
</div>
</a-form-item>
</a-form>
<a-alert type="warning" message="发布后,只有选中的机构可以看到此活动" show-icon class="mt-2" />
</a-modal>
<!-- 取消发布确认弹框 -->
<a-modal
v-model:open="unpublishModalVisible"
title="取消发布"
:confirm-loading="publishLoading"
@ok="handleUnpublishConfirm"
>
<a-modal v-model:open="unpublishModalVisible" title="取消发布" :confirm-loading="publishLoading" @ok="handleUnpublishConfirm">
<p>确定要取消发布活动{{ currentPublishContest?.contestName }}</p>
<a-alert type="warning" message="取消发布后,所有机构将无法看到此活动" show-icon />
</a-modal>
<!-- 删除确认弹框 -->
<a-modal
v-model:open="deleteModalVisible"
title="删除活动"
:confirm-loading="deleteLoading"
@ok="handleDeleteConfirm"
>
<a-modal v-model:open="deleteModalVisible" title="删除活动" :confirm-loading="deleteLoading" @ok="handleDeleteConfirm">
<p>确定要删除活动{{ currentDeleteContest?.contestName }}</p>
<a-alert type="error" message="删除后数据将无法恢复,请谨慎操作!" show-icon />
</a-modal>
@ -332,22 +207,11 @@ import { ref, computed, reactive, onMounted } from "vue"
import { message } from "ant-design-vue"
import { useAuthStore } from "@/stores/auth"
import {
PlusOutlined,
SearchOutlined,
ReloadOutlined,
AppstoreOutlined,
FormOutlined,
EditOutlined,
EyeOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
PlusOutlined, SearchOutlined, ReloadOutlined,
AppstoreOutlined, FormOutlined, EditOutlined, EyeOutlined,
CheckCircleOutlined, CloseCircleOutlined,
} from "@ant-design/icons-vue"
import {
contestsApi,
type Contest,
type QueryContestParams,
type ContestStats,
} from "@/api/contests"
import { contestsApi, type Contest, type QueryContestParams, type ContestStats } from "@/api/contests"
import { tenantsApi, type Tenant } from "@/api/tenants"
import AddJudgeDrawer from "./components/AddJudgeDrawer.vue"
import dayjs from "dayjs"
@ -358,7 +222,7 @@ const tenantCode = route.params.tenantCode as string
const authStore = useAuthStore()
const isSuperAdmin = computed(() => authStore.hasAnyRole(['super_admin']))
// ========== ==========
// ========== #1 + ==========
const stats = ref<ContestStats>({ total: 0, unpublished: 0, registering: 0, submitting: 0, reviewing: 0, finished: 0 })
const activeStage = ref<string>('')
@ -372,68 +236,47 @@ const statsItems = computed(() => [
])
const fetchStats = async () => {
try {
stats.value = await contestsApi.getStats()
} catch { /* 静默 */ }
try { stats.value = await contestsApi.getStats() } catch { /* */ }
}
const handleStatClick = (stage: string) => {
activeStage.value = stage
searchParams.stage = stage || undefined
if (activeStage.value === stage) {
activeStage.value = ''
searchParams.stage = undefined
} else {
activeStage.value = stage
searchParams.stage = stage || undefined
}
handleSearch()
}
// ========== ==========
const loading = ref(false)
const dataSource = ref<Contest[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (t: number) => `${t}`,
})
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `${t}` })
const searchParams = reactive<QueryContestParams>({})
const fetchList = async () => {
loading.value = true
try {
const res = await contestsApi.getList({
page: pagination.current,
pageSize: pagination.pageSize,
...searchParams,
})
const res = await contestsApi.getList({ page: pagination.current, pageSize: pagination.pageSize, ...searchParams })
dataSource.value = res.list
pagination.total = res.total
} catch {
message.error('获取活动列表失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.current = 1
fetchList()
} catch { message.error('获取活动列表失败') }
finally { loading.value = false }
}
const handleSearch = () => { pagination.current = 1; fetchList() }
const handleReset = () => {
Object.keys(searchParams).forEach((key) => {
;(searchParams as any)[key] = undefined
})
Object.keys(searchParams).forEach(k => { (searchParams as any)[k] = undefined })
activeStage.value = ''
pagination.current = 1
fetchList()
fetchStats()
}
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
const handleTableChange = (pag: any) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchList()
}
// ========== ==========
// ========== ==========
const tenants = ref<Tenant[]>([])
const tenantOptions = ref<{ label: string; value: number }[]>([])
@ -441,51 +284,60 @@ const fetchTenants = async () => {
try {
const response = await tenantsApi.getList({ page: 1, pageSize: 100 })
tenants.value = response.list
// 广
const systemCodes = ['super', 'public', 'judge', 'teacher', 'student', 'school']
tenantOptions.value = response.list
.filter((t) => !t.isSuper && !systemCodes.includes(t.code))
.map((t) => ({ label: t.name, value: t.id }))
} catch { /* 静默 */ }
} catch { /* */ }
}
// #5 tenantId
const publishTenantOptions = ref<{ label: string; value: number }[]>([])
const fetchPublishTenants = async () => {
try {
if (isSuperAdmin.value) {
publishTenantOptions.value = tenantOptions.value
} else {
// my-tenant
const myTenant = await (await import('@/utils/request')).default.get('/tenants/my-tenant') as any
publishTenantOptions.value = [{ label: myTenant.name, value: myTenant.id }]
}
} catch { /* */ }
}
const getTenantName = (tenantId: number) => {
const tenant = tenants.value.find((t) => t.id === tenantId)
return tenant?.name || `机构${tenantId}`
}
const filterTenantOption = (input: string, option: any) => option.label?.toLowerCase().includes(input.toLowerCase())
const filterTenantOption = (input: string, option: any) => {
return option.label?.toLowerCase().includes(input.toLowerCase())
}
// ========== ==========
// ========== #2 + #6 ==========
const superColumns = [
{ title: "序号", key: "index", width: 60 },
{ title: "序号", key: "index", width: 50 },
{ title: "活动名称", dataIndex: "contestName", key: "contestName", width: 200 },
{ title: "主办机构", key: "organizer", width: 120 },
{ title: "活动类型", key: "contestType", width: 90 },
{ title: "活动阶段", key: "stage", width: 100 },
{ title: "可见范围", key: "visibility", width: 90 },
{ title: "报名", key: "regCount", width: 70 },
{ title: "作品", key: "workCount", width: 70 },
{ title: "评审", key: "reviewProgress", width: 80 },
{ title: "活动时间", key: "contestTime", width: 160 },
{ title: "操作", key: "action", width: 100, fixed: "right" as const },
{ title: "类型", key: "contestType", width: 70 },
{ title: "阶段", key: "stage", width: 80 },
{ title: "可见范围", key: "visibility", width: 80 },
{ title: "报名", key: "regCount", width: 60 },
{ title: "作品", key: "workCount", width: 60 },
{ title: "评审", key: "reviewProgress", width: 70 },
{ title: "活动时间", key: "contestTime", width: 150 },
{ title: "操作", key: "action", width: 90, fixed: "right" as const },
]
// //
const orgColumns = [
{ title: "序号", key: "index", width: 70 },
{ title: "活动名称", dataIndex: "contestName", key: "contestName", width: 200 },
{ title: "主办方", key: "organizer", width: 140 },
{ title: "活动类型", key: "contestType", width: 100 },
{ title: "活动状态", key: "contestState", width: 100 },
{ title: "可见范围", key: "visibility", width: 100 },
{ title: "公开机构", key: "publicScope", width: 120 },
{ title: "报名", key: "regCount", width: 70 },
{ title: "作品", key: "workCount", width: 70 },
{ title: "评委", key: "judges", width: 70 },
{ title: "活动时间", key: "contestTime", width: 180 },
{ title: "操作", key: "action", width: 260, fixed: "right" as const },
{ title: "序号", key: "index", width: 50 },
{ title: "活动名称", dataIndex: "contestName", key: "contestName", width: 220 },
{ title: "类型", key: "contestType", width: 70 },
{ title: "阶段", key: "stage", width: 80 },
{ title: "报名", key: "regCount", width: 60 },
{ title: "作品", key: "workCount", width: 60 },
{ title: "评委", key: "judges", width: 60 },
{ title: "活动时间", key: "contestTime", width: 150 },
{ title: "操作", key: "action", width: 220, fixed: "right" as const },
]
const currentColumns = computed(() => isSuperAdmin.value ? superColumns : orgColumns)
@ -500,7 +352,7 @@ const stageTagMap: Record<string, { label: string; color: string }> = {
finished: { label: '已结束', color: 'default' },
}
// ========== ==========
// ========== #4 / ==========
const goToRegistrations = (contestId: number) => {
router.push(`/${tenantCode}/contests/registrations?contestId=${contestId}`)
}
@ -508,17 +360,13 @@ const goToWorks = (contestId: number) => {
router.push(`/${tenantCode}/contests/works/${contestId}/list`)
}
// ========== ==========
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
// ========== ==========
const handleAdd = () => {
router.push(`/${tenantCode}/contests/create`)
}
// ========== ==========
const handleAdd = () => { router.push(`/${tenantCode}/contests/create`) }
const handleEdit = (id: number) => {
if (!id) { message.warning("活动ID不存在"); return }
router.push(`/${tenantCode}/contests/${id}/edit`)
@ -531,27 +379,13 @@ const currentContest = ref<Contest | null>(null)
const handleAddJudge = async (id: number) => {
currentContestId.value = id
try {
currentContest.value = await contestsApi.getDetail(id)
judgeDrawerVisible.value = true
} catch (error: any) {
message.error(error?.response?.data?.message || "获取活动信息失败")
}
try { currentContest.value = await contestsApi.getDetail(id); judgeDrawerVisible.value = true }
catch (e: any) { message.error(e?.response?.data?.message || "获取活动信息失败") }
}
const handleJudgeDrawerClose = () => { judgeDrawerVisible.value = false; currentContestId.value = null; currentContest.value = null }
const handleJudgeAddSuccess = () => { message.success("添加评委成功"); fetchList(); handleJudgeDrawerClose() }
const handleJudgeDrawerClose = () => {
judgeDrawerVisible.value = false
currentContestId.value = null
currentContest.value = null
}
const handleJudgeAddSuccess = () => {
message.success("添加评委成功")
fetchList()
handleJudgeDrawerClose()
}
//
// #5 tenantOptions bug
const publishModalVisible = ref(false)
const unpublishModalVisible = ref(false)
const publishLoading = ref(false)
@ -563,15 +397,15 @@ const handlePublishClick = async (record: Contest) => {
if (record.contestState === "published") {
unpublishModalVisible.value = true
} else {
//
if (publishTenantOptions.value.length === 0) await fetchPublishTenants()
try {
const contest = await contestsApi.getDetail(record.id)
selectedTenants.value = Array.isArray(contest.contestTenants)
? contest.contestTenants.map((id) => Number(id)).filter((id) => !isNaN(id))
: []
publishModalVisible.value = true
} catch (error: any) {
message.error(error?.response?.data?.message || "获取活动信息失败")
}
} catch (e: any) { message.error(e?.response?.data?.message || "获取活动信息失败") }
}
}
@ -583,11 +417,9 @@ const handlePublishConfirm = async () => {
await contestsApi.publish(currentPublishContest.value!.id, "published")
message.success("发布成功")
publishModalVisible.value = false
fetchList()
if (isSuperAdmin.value) fetchStats()
} catch (error: any) {
message.error(error?.response?.data?.message || "发布失败")
} finally { publishLoading.value = false }
fetchList(); fetchStats()
} catch (e: any) { message.error(e?.response?.data?.message || "发布失败") }
finally { publishLoading.value = false }
}
const handleUnpublishConfirm = async () => {
@ -596,40 +428,32 @@ const handleUnpublishConfirm = async () => {
await contestsApi.publish(currentPublishContest.value!.id, "unpublished")
message.success("取消发布成功")
unpublishModalVisible.value = false
fetchList()
if (isSuperAdmin.value) fetchStats()
} catch (error: any) {
message.error(error?.response?.data?.message || "取消发布失败")
} finally { publishLoading.value = false }
fetchList(); fetchStats()
} catch (e: any) { message.error(e?.response?.data?.message || "取消发布失败") }
finally { publishLoading.value = false }
}
//
// #7
const deleteModalVisible = ref(false)
const deleteLoading = ref(false)
const currentDeleteContest = ref<Contest | null>(null)
const handleDeleteClick = (record: Contest) => {
currentDeleteContest.value = record
deleteModalVisible.value = true
}
const handleDeleteClick = (record: Contest) => { currentDeleteContest.value = record; deleteModalVisible.value = true }
const handleDeleteConfirm = async () => {
deleteLoading.value = true
try {
await contestsApi.delete(currentDeleteContest.value!.id)
message.success("删除成功")
deleteModalVisible.value = false
fetchList()
if (isSuperAdmin.value) fetchStats()
} catch (error: any) {
message.error(error?.response?.data?.message || "删除失败")
} finally { deleteLoading.value = false }
fetchList(); fetchStats()
} catch (e: any) { message.error(e?.response?.data?.message || "删除失败") }
finally { deleteLoading.value = false }
}
onMounted(() => {
fetchList()
fetchTenants()
if (isSuperAdmin.value) fetchStats()
fetchStats()
if (isSuperAdmin.value) fetchTenants()
})
</script>
@ -638,88 +462,31 @@ $primary: #6366f1;
.contests-page {
.title-card {
margin-bottom: 16px;
border: none;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
:deep(.ant-card-head) {
border-bottom: none;
.ant-card-head-title { font-size: 18px; font-weight: 600; }
}
margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
:deep(.ant-card-body) { padding: 0; }
}
:deep(.ant-btn-primary) {
background: linear-gradient(135deg, $primary 0%, darken($primary, 10%) 100%);
border: none;
box-shadow: 0 4px 12px rgba($primary, 0.35);
&:hover {
box-shadow: 0 6px 16px rgba($primary, 0.45);
transform: translateY(-1px);
}
}
}
//
.stats-row {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; }
.stat-card {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s ease;
flex: 1; display: flex; align-items: center; gap: 12px; padding: 14px 16px;
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
cursor: pointer; border: 2px solid transparent; transition: all 0.2s;
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); }
&.active { border-color: $primary; background: rgba($primary, 0.02); }
.stat-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.stat-info {
display: flex;
flex-direction: column;
.stat-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
.stat-info { display: flex; flex-direction: column;
.stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
.stat-label { font-size: 12px; color: #9ca3af; }
}
}
//
.search-form {
margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.search-form { margin-bottom: 16px; padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
//
.data-table {
:deep(.ant-table-wrapper) {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
overflow: hidden;
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
.ant-table-pagination { padding: 16px; margin: 0; }

View File

@ -12,14 +12,18 @@
<template #icon><PlusOutlined /></template>
新增
</a-button>
<a-button v-permission="'judge:create'">
<template #icon><UploadOutlined /></template>
导入
</a-button>
<a-button v-permission="'judge:read'">
<template #icon><DownloadOutlined /></template>
导出
</a-button>
<a-tooltip title="功能开发中,敬请期待">
<a-button v-permission="'judge:create'" disabled>
<template #icon><UploadOutlined /></template>
导入
</a-button>
</a-tooltip>
<a-tooltip title="功能开发中,敬请期待">
<a-button v-permission="'judge:read'" disabled>
<template #icon><DownloadOutlined /></template>
导出
</a-button>
</a-tooltip>
<a-popconfirm
v-permission="'judge:delete'"
title="确定要删除选中的评委吗?"
@ -72,6 +76,7 @@
placeholder="请选择状态"
allow-clear
style="width: 120px"
@change="handleSearch"
>
<a-select-option value="disabled">停用</a-select-option>
<a-select-option value="enabled">启用</a-select-option>
@ -236,7 +241,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue"
import { useRoute } from "vue-router"
import { message } from "ant-design-vue"
import { message, Modal } from "ant-design-vue"
import type { FormInstance, TableProps } from "ant-design-vue"
import {
PlusOutlined,
@ -438,20 +443,31 @@ const handleEdit = (record: Judge) => {
form.password = ""
}
// /
const handleToggleStatus = async (record: Judge) => {
try {
if (record.status === "enabled") {
await judgesManagementApi.freeze(record.id)
message.success("冻结成功")
} else {
await judgesManagementApi.unfreeze(record.id)
message.success("解冻成功")
}
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
}
// /+
const handleToggleStatus = (record: Judge) => {
const isFreeze = record.status === "enabled"
Modal.confirm({
title: isFreeze ? '确定冻结该评委?' : '确定解冻该评委?',
content: isFreeze
? `冻结后「${record.nickname}」将无法登录评委端,进行中的评审任务将暂停`
: `解冻后「${record.nickname}」将恢复登录和评审功能`,
okText: isFreeze ? '确定冻结' : '确定解冻',
okType: isFreeze ? 'danger' : 'primary',
onOk: async () => {
try {
if (isFreeze) {
await judgesManagementApi.freeze(record.id)
message.success("冻结成功")
} else {
await judgesManagementApi.unfreeze(record.id)
message.success("解冻成功")
}
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
}
},
})
}
//
@ -535,85 +551,31 @@ onMounted(() => {
</script>
<style scoped lang="scss">
//
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
$primary: #6366f1;
.judges-page {
//
:deep(.ant-card) {
border: none;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
margin-bottom: 16px;
.ant-card-head {
border-bottom: none;
padding: 16px 24px;
.ant-card-head-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
}
.ant-card-body {
padding: 0;
.ant-card-head-title { font-size: 18px; font-weight: 600; }
}
.ant-card-body { padding: 0; }
}
//
:deep(.ant-btn-primary) {
background: $gradient-primary;
border: none;
box-shadow: 0 4px 12px rgba($primary, 0.35);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 8%) 100%);
box-shadow: 0 6px 16px rgba($primary, 0.45);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
//
:deep(.ant-table-wrapper) {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
overflow: hidden;
.ant-table {
.ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
border-bottom: 1px solid #f0f0f0;
}
.ant-table-tbody > tr {
transition: all 0.2s ease;
&:hover > td {
background: rgba($primary, 0.04);
}
> td {
border-bottom: 1px solid #f5f5f5;
}
}
}
.ant-table-pagination {
padding: 16px;
margin: 0;
}
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
.ant-table-pagination { padding: 16px; margin: 0; }
}
}
@ -622,6 +584,6 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
</style>

View File

@ -25,12 +25,18 @@
allow-clear
/>
</a-form-item>
<a-form-item label="发布日期">
<a-date-picker
v-model:value="searchForm.publishDate"
placeholder="请选择发布日期"
style="width: 200px"
<a-form-item label="发布状态">
<a-select v-model:value="searchForm.status" placeholder="全部" allow-clear style="width: 110px" @change="handleSearch">
<a-select-option value="published">已发布</a-select-option>
<a-select-option value="unpublished">未发布</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="发布时间">
<a-range-picker
v-model:value="searchForm.publishDateRange"
style="width: 240px"
allow-clear
@change="handleSearch"
/>
</a-form-item>
<a-form-item>
@ -69,34 +75,27 @@
{{ record.publishTime ? "已发布" : "未发布" }}
</a-tag>
</template>
<template v-else-if="column.key === 'createTime'">
{{ formatDateTime(record.createTime) }}
</template>
<template v-else-if="column.key === 'publishTime'">
{{ formatDateTime(record.publishTime) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button
v-permission="'notice:update'"
type="link"
size="small"
@click="handleTogglePublish(record)"
>
{{ record.publishTime ? "取消发布" : "发布" }}
</a-button>
<a-button
v-permission="'notice:update'"
type="link"
size="small"
@click="handleEdit(record)"
>
编辑
</a-button>
<a-popconfirm
v-permission="'notice:delete'"
title="确定要删除这条公告吗?"
@confirm="handleDelete(record.id)"
>
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
<!-- 未发布发布编辑删除 -->
<template v-if="!record.publishTime">
<a-button v-permission="'notice:update'" type="link" size="small" style="color: #10b981" @click="handleTogglePublish(record)">发布</a-button>
<a-button v-permission="'notice:update'" type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm v-permission="'notice:delete'" title="确定要删除这条公告吗?" @confirm="handleDelete(record.id)">
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</template>
<!-- 已发布查看取消发布 -->
<template v-else>
<a-button type="link" size="small" @click="handleView(record)">查看</a-button>
<a-button v-permission="'notice:update'" type="link" size="small" style="color: #f59e0b" @click="handleTogglePublish(record)">取消发布</a-button>
</template>
</a-space>
</template>
</template>
@ -233,7 +232,7 @@
<script setup lang="ts">
import { ref, onMounted, reactive } from "vue"
import { useRoute } from "vue-router"
import { message } from "ant-design-vue"
import { message, Modal } from "ant-design-vue"
import { PlusOutlined, UploadOutlined, SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
import dayjs, { type Dayjs } from "dayjs"
import type { FormInstance, UploadFile } from "ant-design-vue"
@ -261,7 +260,8 @@ const pagination = reactive({
//
const searchForm = reactive({
title: "",
publishDate: null as Dayjs | null,
status: undefined as string | undefined,
publishDateRange: null as [Dayjs, Dayjs] | null,
})
//
@ -319,11 +319,16 @@ const columns = [
width: 100,
align: "center" as const,
},
{
title: "创建时间",
key: "createTime",
width: 160,
},
{
title: "发布时间",
key: "publishTime",
dataIndex: "publishTime",
width: 180,
width: 160,
},
{
title: "操作",
@ -375,13 +380,23 @@ const fetchNotices = async () => {
params.title = searchForm.title
}
if (searchForm.publishDate) {
params.publishDate = searchForm.publishDate.format("YYYY-MM-DD")
if (searchForm.publishDateRange?.[0]) {
params.publishStartDate = searchForm.publishDateRange[0].format("YYYY-MM-DD")
}
if (searchForm.publishDateRange?.[1]) {
params.publishEndDate = searchForm.publishDateRange[1].format("YYYY-MM-DD")
}
const response = await noticesApi.getAll(params)
dataSource.value = response.list || []
pagination.total = response.total || 0
let list = response.list || []
//
if (searchForm.status === 'published') {
list = list.filter((n: any) => !!n.publishTime)
} else if (searchForm.status === 'unpublished') {
list = list.filter((n: any) => !n.publishTime)
}
dataSource.value = list
pagination.total = searchForm.status ? list.length : (response.total || 0)
} catch (error: any) {
message.error(error?.response?.data?.message || "获取公告列表失败")
} finally {
@ -422,7 +437,8 @@ const handleSearch = () => {
//
const handleReset = () => {
searchForm.title = ""
searchForm.publishDate = null
searchForm.status = undefined
searchForm.publishDateRange = null
pagination.current = 1
fetchNotices()
}
@ -472,26 +488,31 @@ const handleDelete = async (id: number) => {
}
}
// /
const handleTogglePublish = async (record: ContestNotice) => {
try {
if (record.publishTime) {
//
await noticesApi.update(record.id, {
publishTime: null,
} as any)
message.success("取消发布成功")
} else {
//
await noticesApi.update(record.id, {
publishTime: new Date().toISOString(),
} as any)
message.success("发布成功")
}
fetchNotices()
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
}
// /
const handleTogglePublish = (record: ContestNotice) => {
const isPublished = !!record.publishTime
Modal.confirm({
title: isPublished ? '确定取消发布?' : '确定发布?',
content: isPublished
? `取消发布后公告「${record.title}」将不再对外展示`
: `发布后公告「${record.title}」将立即对外展示`,
okText: isPublished ? '取消发布' : '确定发布',
okType: isPublished ? 'danger' : 'primary',
onOk: async () => {
try {
if (isPublished) {
await noticesApi.update(record.id, { publishTime: null } as any)
message.success("取消发布成功")
} else {
await noticesApi.update(record.id, { publishTime: new Date().toISOString() } as any)
message.success("发布成功")
}
fetchNotices()
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
}
},
})
}
//
@ -595,106 +616,26 @@ onMounted(() => {
</script>
<style scoped lang="scss">
//
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
$primary: #6366f1;
.notices-page {
//
:deep(.ant-card) {
border: none;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
.ant-card-head {
border-bottom: none;
padding: 16px 24px;
.ant-card-head-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
}
.ant-card-body {
padding: 0;
}
border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px;
.ant-card-head { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
.ant-card-body { padding: 0; }
}
//
:deep(.ant-btn-primary) {
background: $gradient-primary;
border: none;
box-shadow: 0 4px 12px rgba($primary, 0.35);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 8%) 100%);
box-shadow: 0 6px 16px rgba($primary, 0.45);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
//
:deep(.ant-table-wrapper) {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
.ant-table {
.ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
border-bottom: 1px solid #f0f0f0;
}
.ant-table-tbody > tr {
transition: all 0.2s ease;
&:hover > td {
background: rgba($primary, 0.04);
}
> td {
border-bottom: 1px solid #f5f5f5;
}
}
}
.ant-table-pagination {
padding: 16px;
margin: 0;
}
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
.ant-table-pagination { padding: 16px; margin: 0; }
}
}
.search-form {
margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
//
:deep(.ant-form-item) {
margin-bottom: 16px;
margin-right: 16px;
&:last-child {
margin-right: 0;
}
}
margin-bottom: 16px; padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.mb-4 {
margin-bottom: 16px;
}
.mb-4 { margin-bottom: 16px; }
</style>

View File

@ -213,35 +213,51 @@
</a-drawer>
</template>
<!-- ========== 机构端保持原有两层结构 ========== -->
<!-- ========== 机构端活动维度 + 统计概览 ========== -->
<template v-else>
<a-card class="title-card">
<template #title>报名管理</template>
</a-card>
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange" class="org-tabs">
<a-tab-pane key="individual" tab="个人参与" />
<a-tab-pane key="team" tab="团队参与" />
</a-tabs>
<!-- 统计概览 -->
<div class="stats-row">
<div v-for="item in orgStatsItems" :key="item.key" class="stat-card">
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
<div class="stat-info">
<span class="stat-count">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
</div>
<a-form :model="searchParams" layout="inline" class="filter-bar" @finish="handleSearch">
<a-form-item label="活动名称">
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 200px" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-form-item>
</a-form>
<!-- 筛选 -->
<div class="filter-bar">
<a-form layout="inline" @finish="handleSearch">
<a-form-item label="活动名称">
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 200px" />
</a-form-item>
<a-form-item label="活动类型">
<a-select v-model:value="searchParams.contestType" placeholder="全部" allow-clear style="width: 120px" @change="handleSearch">
<a-select-option value="individual">个人参与</a-select-option>
<a-select-option value="team">团队参与</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template> 搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template> 重置
</a-button>
</a-form-item>
</a-form>
</div>
<!-- 活动列表 -->
<a-table
:columns="activeTab === 'individual' ? orgIndividualColumns : orgTeamColumns"
:columns="orgColumns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
@ -254,30 +270,46 @@
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'contestName'">
<a @click="handleViewContest(record.id)">{{ record.contestName }}</a>
<a @click="handleViewRecords(record)">{{ record.contestName }}</a>
</template>
<template v-else-if="column.key === 'organizers'">
{{ formatOrganizers(record.organizers) }}
<template v-else-if="column.key === 'contestType'">
<a-tag :color="record.contestType === 'individual' ? 'blue' : 'green'">
{{ record.contestType === 'individual' ? '个人' : '团队' }}
</a-tag>
</template>
<template v-else-if="column.key === 'registrationCount'">
<template v-else-if="column.key === 'regStats'">
<div class="reg-stats-cell">
<a-tooltip title="待审核">
<span class="reg-stat pending" v-if="record._regPending > 0">
<clock-circle-outlined /> {{ record._regPending }}
</span>
</a-tooltip>
<a-tooltip title="已通过">
<span class="reg-stat passed">
<check-circle-outlined /> {{ record._regPassed || 0 }}
</span>
</a-tooltip>
<a-tooltip title="已拒绝">
<span class="reg-stat rejected" v-if="record._regRejected > 0">
<close-circle-outlined /> {{ record._regRejected }}
</span>
</a-tooltip>
</div>
</template>
<template v-else-if="column.key === 'totalReg'">
{{ record._count?.registrations || 0 }}
</template>
<template v-else-if="column.key === 'teamCount'">
{{ record._count?.teams || 0 }}
</template>
<template v-else-if="column.key === 'registerTime'">
<div v-if="record.registerStartTime || record.registerEndTime">
<div>开始{{ formatDate(record.registerStartTime) }}</div>
<div>结束{{ formatDate(record.registerEndTime) }}</div>
<div v-if="record.registerStartTime">
<div>{{ formatDate(record.registerStartTime) }}</div>
<div class="text-muted"> {{ formatDate(record.registerEndTime) }}</div>
</div>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewRecords(record)">报名记录</a-button>
<a-button v-permission="'contest:update'" type="link" size="small" :disabled="record.registerState === 'open'" @click="handleStartRegistration(record)">启动报名</a-button>
<a-button v-permission="'contest:update'" type="link" danger size="small" :disabled="record.registerState !== 'open'" @click="handleStopRegistration(record)">关闭报名</a-button>
</a-space>
<a-button type="link" size="small" @click="handleViewRecords(record)">
查看报名 <right-outlined />
</a-button>
</template>
</template>
</a-table>
@ -297,6 +329,8 @@ import {
ClockCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
RightOutlined,
FormOutlined,
} from "@ant-design/icons-vue"
import {
contestsApi,
@ -442,76 +476,72 @@ const teamMemberColumns = [
]
// =============================================
//
//
// =============================================
const activeTab = ref<'individual' | 'team'>('individual')
const orgStats = ref({ total: 0, pending: 0, passed: 0, rejected: 0 })
const orgStatsItems = computed(() => [
{ key: 'total', label: '总报名', value: orgStats.value.total, icon: TeamOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
{ key: 'pending', label: '待审核', value: orgStats.value.pending, icon: ClockCircleOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
{ key: 'passed', label: '已通过', value: orgStats.value.passed, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
{ key: 'rejected', label: '已拒绝', value: orgStats.value.rejected, icon: CloseCircleOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)' },
])
const loading = ref(false)
const dataSource = ref<Contest[]>([])
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `${t}` })
const searchParams = reactive<QueryContestParams>({ contestName: '' })
const orgIndividualColumns = [
{ title: '序号', key: 'index', width: 70 },
{ title: '活动名称', key: 'contestName', width: 250 },
{ title: '主办单位', key: 'organizers', width: 200 },
{ title: '报名人数', key: 'registrationCount', width: 120 },
{ title: '报名时间', key: 'registerTime', width: 200 },
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const },
const orgColumns = [
{ title: '序号', key: 'index', width: 50 },
{ title: '活动名称', key: 'contestName', width: 200 },
{ title: '类型', key: 'contestType', width: 70 },
{ title: '报名审核', key: 'regStats', width: 160 },
{ title: '总报名', key: 'totalReg', width: 70 },
{ title: '报名时间', key: 'registerTime', width: 160 },
{ title: '操作', key: 'action', width: 120, fixed: 'right' as const },
]
const orgTeamColumns = [
{ title: '序号', key: 'index', width: 70 },
{ title: '活动名称', key: 'contestName', width: 250 },
{ title: '主办单位', key: 'organizers', width: 200 },
{ title: '报名队伍数', key: 'teamCount', width: 120 },
{ title: '报名时间', key: 'registerTime', width: 200 },
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const },
]
const fetchOrgStats = async () => {
try { orgStats.value = await registrationsApi.getStats() } catch { /* */ }
}
const fetchList = async () => {
loading.value = true
try {
const res = await contestsApi.getList({
...searchParams,
contestType: activeTab.value,
page: pagination.current,
pageSize: pagination.pageSize,
})
dataSource.value = res.list
//
const list = res.list
if (list.length > 0) {
const statsPromises = list.map(c =>
registrationsApi.getStats(c.id).catch(() => ({ total: 0, pending: 0, passed: 0, rejected: 0 }))
)
const statsResults = await Promise.all(statsPromises)
list.forEach((c: any, i: number) => {
c._regPending = statsResults[i].pending
c._regPassed = statsResults[i].passed
c._regRejected = statsResults[i].rejected
})
}
dataSource.value = list
pagination.total = res.total
} catch { message.error('获取列表失败') }
finally { loading.value = false }
}
const handleTabChange = () => { pagination.current = 1; fetchList() }
const handleSearch = () => { pagination.current = 1; fetchList() }
const handleReset = () => { searchParams.contestName = ''; pagination.current = 1; fetchList() }
const handleReset = () => { searchParams.contestName = ''; searchParams.contestType = undefined; pagination.current = 1; fetchList(); fetchOrgStats() }
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
const handleViewContest = (id: number) => router.push({ name: 'ContestsDetail', params: { tenantCode, id } })
const handleViewRecords = (record: Contest) => router.push({ name: 'RegistrationRecords', params: { tenantCode, id: record.id } })
const formatOrganizers = (org: any) => {
if (!org) return '-'
if (Array.isArray(org)) return org.join('、') || '-'
if (typeof org === 'string') { try { const p = JSON.parse(org); return Array.isArray(p) ? p.join('、') : org } catch { return org } }
return '-'
}
const handleStartRegistration = async (record: Contest) => {
try { await contestsApi.update(record.id, { registerState: 'open' } as any); message.success('已启动报名'); fetchList() }
catch (e: any) { message.error(e?.response?.data?.message || '启动报名失败') }
}
const handleStopRegistration = async (record: Contest) => {
try { await contestsApi.update(record.id, { registerState: 'closed' } as any); message.success('已关闭报名'); fetchList() }
catch (e: any) { message.error(e?.response?.data?.message || '关闭报名失败') }
}
// =============================================
//
// =============================================
onMounted(() => {
if (isSuperAdmin.value) {
// URL
const queryContestId = route.query.contestId ? Number(route.query.contestId) : undefined
const queryStatus = route.query.status as string | undefined
if (queryContestId) superSearch.contestId = queryContestId
@ -521,6 +551,7 @@ onMounted(() => {
fetchSuperStats()
fetchSuperList()
} else {
fetchOrgStats()
fetchList()
}
})
@ -587,6 +618,16 @@ $primary: #6366f1;
.text-muted { color: #d1d5db; }
.reg-stats-cell {
display: flex; gap: 10px;
.reg-stat {
display: flex; align-items: center; gap: 3px; font-size: 12px; font-weight: 500;
&.pending { color: #f59e0b; }
&.passed { color: #10b981; }
&.rejected { color: #ef4444; }
}
}
.detail-section {
margin-top: 24px;
h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }

View File

@ -2,12 +2,17 @@
<div class="registration-records-page">
<a-card class="mb-4">
<template #title>
<a-breadcrumb>
<a-breadcrumb-item>
<router-link :to="{ name: 'ContestsRegistrations', params: { tenantCode } }">报名管理</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item>{{ contestName || '报名记录' }}</a-breadcrumb-item>
</a-breadcrumb>
<a-space>
<a-button type="text" style="padding: 0; margin-right: 4px" @click="router.back()">
<template #icon><arrow-left-outlined /></template>
</a-button>
<a-breadcrumb>
<a-breadcrumb-item>
<router-link :to="{ name: 'ContestsRegistrations', params: { tenantCode } }">报名管理</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item>{{ contestName || '报名记录' }}</a-breadcrumb-item>
</a-breadcrumb>
</a-space>
</template>
<template #extra>
<a-space>
@ -27,129 +32,43 @@
</template>
</a-card>
<!-- 个人参与搜索表单 -->
<a-form
v-if="contestType === 'individual'"
:model="searchParams"
layout="inline"
class="search-form"
@finish="handleSearch"
>
<a-form-item label="机构">
<a-input
v-model:value="searchParams.tenantName"
placeholder="请输入机构名称"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="姓名">
<a-input
v-model:value="searchParams.nickname"
placeholder="请输入姓名"
allow-clear
style="width: 120px"
/>
</a-form-item>
<a-form-item label="报名账号">
<a-input
v-model:value="searchParams.accountNo"
placeholder="请输入账号"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="审核状态">
<a-select
v-model:value="searchParams.registrationState"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select-option value="pending">待审核</a-select-option>
<a-select-option value="passed">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="报名时间">
<a-range-picker
v-model:value="dateRange"
style="width: 240px"
@change="handleDateChange"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-form-item>
</a-form>
<!-- #3 统计概览 -->
<div class="stats-row">
<div v-for="item in recordStatsItems" :key="item.key" :class="['stat-card', { active: activeRecordState === item.key }]" @click="handleRecordStatClick(item.key)">
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
<div class="stat-info">
<span class="stat-count">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
</div>
<!-- 团队参与搜索表单 -->
<a-form
v-else
:model="searchParams"
layout="inline"
class="search-form"
@finish="handleSearch"
>
<a-form-item label="机构">
<a-input
v-model:value="searchParams.tenantName"
placeholder="请输入机构名称"
allow-clear
style="width: 150px"
/>
<!-- 搜索表单合并个人/团队 -->
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
<a-form-item v-if="contestType === 'individual'" label="姓名">
<a-input v-model:value="searchParams.nickname" placeholder="请输入姓名" allow-clear style="width: 120px" />
</a-form-item>
<a-form-item label="队伍名称">
<a-input
v-model:value="searchParams.teamName"
placeholder="请输入队伍名称"
allow-clear
style="width: 120px"
/>
<a-form-item v-if="contestType === 'team'" label="队伍名称">
<a-input v-model:value="searchParams.teamName" placeholder="请输入队伍名称" allow-clear style="width: 130px" />
</a-form-item>
<a-form-item label="报名账号">
<a-input
v-model:value="searchParams.accountNo"
placeholder="请输入账号"
allow-clear
style="width: 150px"
/>
<a-input v-model:value="searchParams.accountNo" placeholder="请输入账号" allow-clear style="width: 140px" />
</a-form-item>
<a-form-item label="审核状态">
<a-select
v-model:value="searchParams.registrationState"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select v-model:value="searchParams.registrationState" placeholder="全部" allow-clear style="width: 110px" @change="handleSearch">
<a-select-option value="pending">待审核</a-select-option>
<a-select-option value="passed">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="报名时间">
<a-range-picker
v-model:value="dateRange"
style="width: 240px"
@change="handleDateChange"
/>
<a-range-picker v-model:value="dateRange" style="width: 240px" @change="handleDateChange" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
<a-button type="primary" html-type="submit"><template #icon><SearchOutlined /></template> 搜索</a-button>
<a-button style="margin-left: 8px" @click="handleReset"><template #icon><ReloadOutlined /></template> 重置</a-button>
</a-form-item>
</a-form>
@ -180,13 +99,6 @@
<template v-else-if="column.key === 'nickname'">
{{ record.user?.nickname || record.accountName || "-" }}
</template>
<template v-else-if="column.key === 'participantType'">
<template v-if="record.participantType === 'child'">
<a-tag color="green">代子女报名</a-tag>
<div class="child-name" v-if="record.child">{{ record.child.name }}</div>
</template>
<a-tag v-else color="blue">本人参与</a-tag>
</template>
<template v-else-if="column.key === 'accountNo'">
{{ record.accountNo || record.user?.username || "-" }}
</template>
@ -200,38 +112,10 @@
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button
type="link"
size="small"
@click="handleViewDetail(record)"
>
详细信息
</a-button>
<a-button
v-if="record.registrationState !== 'passed'"
v-permission="'contest:update'"
type="link"
size="small"
@click="handlePass(record)"
>
通过
</a-button>
<a-button
v-if="record.registrationState !== 'rejected'"
v-permission="'contest:update'"
type="link"
size="small"
@click="handleReject(record)"
>
拒绝
</a-button>
<a-popconfirm
v-permission="'contest:delete'"
title="确定要删除该报名记录吗?"
@confirm="handleDelete(record.id)"
>
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
<a-button type="link" size="small" @click="handleViewDetail(record)">详情</a-button>
<a-button v-if="record.registrationState === 'pending'" v-permission="'contest:update'" type="link" size="small" style="color: #10b981" @click="handlePass(record)">通过</a-button>
<a-button v-if="record.registrationState === 'pending'" v-permission="'contest:update'" type="link" size="small" danger @click="handleReject(record)">拒绝</a-button>
<a-button v-if="record.registrationState === 'passed' || record.registrationState === 'rejected'" v-permission="'contest:update'" type="link" size="small" style="color: #f59e0b" @click="handleRevoke(record)">撤销</a-button>
</a-space>
</template>
</template>
@ -277,38 +161,10 @@
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button
type="link"
size="small"
@click="handleViewMembers(record)"
>
成员信息
</a-button>
<a-button
v-if="record.registrationState !== 'passed'"
v-permission="'contest:update'"
type="link"
size="small"
@click="handlePass(record)"
>
通过
</a-button>
<a-button
v-if="record.registrationState !== 'rejected'"
v-permission="'contest:update'"
type="link"
size="small"
@click="handleReject(record)"
>
拒绝
</a-button>
<a-popconfirm
v-permission="'contest:delete'"
title="确定要删除该报名记录吗?"
@confirm="handleDelete(record.id)"
>
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
<a-button type="link" size="small" @click="handleViewMembers(record)">成员</a-button>
<a-button v-if="record.registrationState === 'pending'" v-permission="'contest:update'" type="link" size="small" style="color: #10b981" @click="handlePass(record)">通过</a-button>
<a-button v-if="record.registrationState === 'pending'" v-permission="'contest:update'" type="link" size="small" danger @click="handleReject(record)">拒绝</a-button>
<a-button v-if="record.registrationState === 'passed' || record.registrationState === 'rejected'" v-permission="'contest:update'" type="link" size="small" style="color: #f59e0b" @click="handleRevoke(record)">撤销</a-button>
</a-space>
</template>
</template>
@ -432,12 +288,18 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from "vue"
import { useRouter, useRoute } from "vue-router"
import { message } from "ant-design-vue"
import { message, Modal } from "ant-design-vue"
import { useAuthStore } from "@/stores/auth"
import type { TableProps } from "ant-design-vue"
import {
SearchOutlined,
ReloadOutlined,
DownloadOutlined,
ArrowLeftOutlined,
TeamOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from "@ant-design/icons-vue"
import {
registrationsApi,
@ -455,6 +317,30 @@ const router = useRouter()
const route = useRoute()
const tenantCode = route.params.tenantCode as string
const contestId = Number(route.params.id)
const authStore = useAuthStore()
const isSuperAdmin = computed(() => authStore.hasAnyRole(['super_admin']))
// #3
const recordStats = ref({ total: 0, pending: 0, passed: 0, rejected: 0 })
const activeRecordState = ref('')
const recordStatsItems = computed(() => [
{ key: '', label: '全部', value: recordStats.value.total, icon: TeamOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
{ key: 'pending', label: '待审核', value: recordStats.value.pending, icon: ClockCircleOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
{ key: 'passed', label: '已通过', value: recordStats.value.passed, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
{ key: 'rejected', label: '已拒绝', value: recordStats.value.rejected, icon: CloseCircleOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)' },
])
const fetchRecordStats = async () => {
try { recordStats.value = await registrationsApi.getStats(contestId) } catch { /* */ }
}
const handleRecordStatClick = (key: string) => {
if (activeRecordState.value === key) { activeRecordState.value = ''; searchParams.registrationState = undefined }
else { activeRecordState.value = key; searchParams.registrationState = key || undefined }
pagination.current = 1
fetchList()
}
//
const contestName = ref("")
@ -520,95 +406,37 @@ const rejectLoading = ref(false)
const rejectReason = ref("")
const currentRejectId = ref<number | null>(null)
//
const individualColumns = [
{
title: "序号",
key: "index",
width: 70,
},
{
title: "机构",
key: "tenant",
width: 150,
},
{
title: "姓名",
key: "nickname",
width: 120,
},
{
title: "参与方式",
key: "participantType",
width: 120,
},
{
title: "报名账号",
key: "accountNo",
width: 150,
},
{
title: "审核状态",
key: "registrationState",
width: 100,
},
{
title: "报名时间",
key: "registrationTime",
width: 160,
},
{
title: "操作",
key: "action",
width: 220,
fixed: "right" as const,
},
]
//
const individualColumns = computed(() => {
const cols: any[] = [
{ title: "序号", key: "index", width: 50 },
]
if (isSuperAdmin.value) cols.push({ title: "机构", key: "tenant", width: 140 })
cols.push(
{ title: "姓名", key: "nickname", width: 120 },
{ title: "报名账号", key: "accountNo", width: 140 },
{ title: "审核状态", key: "registrationState", width: 90 },
{ title: "报名时间", key: "registrationTime", width: 150 },
{ title: "操作", key: "action", width: 200, fixed: "right" as const },
)
return cols
})
//
const teamColumns = [
{
title: "序号",
key: "index",
width: 70,
},
{
title: "机构",
key: "tenant",
width: 150,
},
{
title: "队伍名称",
key: "teamName",
width: 150,
},
{
title: "参与方式",
key: "participantType",
width: 120,
},
{
title: "报名账号",
key: "accountNo",
width: 150,
},
{
title: "审核状态",
key: "registrationState",
width: 100,
},
{
title: "报名时间",
key: "registrationTime",
width: 160,
},
{
title: "操作",
key: "action",
width: 220,
fixed: "right" as const,
},
]
const teamColumns = computed(() => {
const cols: any[] = [
{ title: "序号", key: "index", width: 50 },
]
if (isSuperAdmin.value) cols.push({ title: "机构", key: "tenant", width: 140 })
cols.push(
{ title: "队伍名称", key: "teamName", width: 140 },
{ title: "报名账号", key: "accountNo", width: 130 },
{ title: "审核状态", key: "registrationState", width: 90 },
{ title: "报名时间", key: "registrationTime", width: 140 },
{ title: "操作", key: "action", width: 200, fixed: "right" as const },
)
return cols
})
//
const memberColumns = [
@ -814,17 +642,40 @@ const handleViewMembers = async (record: ContestRegistration) => {
}
}
//
const handlePass = async (record: ContestRegistration) => {
try {
await registrationsApi.review(record.id, {
registrationState: "passed",
})
message.success("已通过")
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
}
// #5
const handlePass = (record: ContestRegistration) => {
Modal.confirm({
title: '确定通过?',
content: `通过后「${record.user?.nickname || record.accountName || '该用户'}」将可以提交作品`,
okText: '确定通过',
onOk: async () => {
try {
await registrationsApi.review(record.id, { registrationState: "passed" })
message.success("已通过")
fetchList()
fetchRecordStats()
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
}
},
})
}
//
const handleRevoke = (record: ContestRegistration) => {
Modal.confirm({
title: '确定撤销审核?',
content: '撤销后将恢复为待审核状态',
okText: '确定撤销',
onOk: async () => {
try {
await registrationsApi.revokeReview(record.id)
message.success('已撤销')
fetchList()
fetchRecordStats()
} catch (error: any) { message.error(error?.response?.data?.message || '撤销失败') }
},
})
}
//
@ -846,6 +697,7 @@ const handleRejectSubmit = async () => {
message.success("已拒绝")
rejectModalVisible.value = false
fetchList()
fetchRecordStats()
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
} finally {
@ -859,6 +711,7 @@ const handleDelete = async (id: number) => {
await registrationsApi.delete(id)
message.success("删除成功")
fetchList()
fetchRecordStats()
} catch (error: any) {
message.error(error?.response?.data?.message || "删除失败")
}
@ -871,26 +724,21 @@ const handleBatchReview = () => {
batchReviewModalVisible.value = true
}
//
// #6 使
const handleBatchReviewSubmit = async () => {
if (selectedRowKeys.value.length === 0) {
message.warning("请先选择要审核的记录")
return
}
if (selectedRowKeys.value.length === 0) { message.warning("请先选择要审核的记录"); return }
try {
batchReviewLoading.value = true
await Promise.all(
selectedRowKeys.value.map((id) =>
registrationsApi.review(id, {
registrationState: batchReviewForm.registrationState,
reason: batchReviewForm.reason,
})
)
)
message.success("批量审核成功")
const res = await registrationsApi.batchReview({
ids: selectedRowKeys.value,
registrationState: batchReviewForm.registrationState,
reason: batchReviewForm.reason || undefined,
})
message.success(`批量审核成功,${res.count} 条记录已更新`)
batchReviewModalVisible.value = false
selectedRowKeys.value = []
fetchList()
fetchRecordStats()
} catch (error: any) {
message.error(error?.response?.data?.message || "批量审核失败")
} finally {
@ -963,115 +811,47 @@ const handleExport = async () => {
onMounted(async () => {
await fetchContestInfo()
fetchRecordStats()
fetchList()
})
</script>
<style scoped lang="scss">
//
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
$primary: #6366f1;
.registration-records-page {
//
:deep(.ant-card) {
border: none;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
.ant-card-head {
border-bottom: none;
padding: 16px 24px;
.ant-card-head-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
}
.ant-card-body {
padding: 0;
}
border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px;
.ant-card-head { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
.ant-card-body { padding: 0; }
}
//
:deep(.ant-btn-primary) {
background: $gradient-primary;
border: none;
box-shadow: 0 4px 12px rgba($primary, 0.35);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 8%) 100%);
box-shadow: 0 6px 16px rgba($primary, 0.45);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
//
:deep(.ant-table-wrapper) {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
.ant-table-pagination { padding: 16px; margin: 0; }
}
}
.ant-table {
.ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
border-bottom: 1px solid #f0f0f0;
}
.ant-table-tbody > tr {
transition: all 0.2s ease;
&:hover > td {
background: rgba($primary, 0.04);
}
> td {
border-bottom: 1px solid #f5f5f5;
}
}
}
.ant-table-pagination {
padding: 16px;
margin: 0;
}
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; }
.stat-card {
flex: 1; display: flex; align-items: center; gap: 12px; padding: 14px 16px;
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
cursor: pointer; border: 2px solid transparent; transition: all 0.2s;
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); }
&.active { border-color: $primary; background: rgba($primary, 0.02); }
.stat-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
.stat-info { display: flex; flex-direction: column;
.stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
.stat-label { font-size: 12px; color: #9ca3af; }
}
}
.search-form {
margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
// - 使 flex wrap
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 16px 24px;
:deep(.ant-form-item) {
margin-bottom: 0;
margin-right: 0;
}
margin-bottom: 16px; padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.org-detail {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.org-detail { font-size: 12px; color: #666; margin-top: 2px; }
.child-name { font-size: 11px; color: #10b981; margin-top: 2px; }
</style>

View File

@ -1,397 +1,444 @@
<template>
<div class="results-detail-page">
<!-- 顶部导航 -->
<div class="page-header">
<div class="header-left">
<a-button type="text" @click="handleBack">
<template #icon><ArrowLeftOutlined /></template>
返回
</a-button>
<span class="page-title">{{ contestInfo?.contestName || "成果发布" }}</span>
</div>
<div v-if="!isSuperAdmin" class="header-right">
<a-button
type="primary"
:loading="publishLoading"
@click="handlePublish"
>
{{ contestInfo?.resultState === "published" ? "撤回发布" : "发布成果" }}
</a-button>
</div>
</div>
<!-- 搜索表单 -->
<a-form
:model="searchParams"
layout="inline"
class="search-form"
@finish="handleSearch"
>
<a-form-item label="作品编号">
<a-input
v-model:value="searchParams.workNo"
placeholder="请输入作品编号"
allow-clear
style="width: 180px"
/>
</a-form-item>
<a-form-item label="报名账号">
<a-input
v-model:value="searchParams.accountNo"
placeholder="请输入报名账号"
allow-clear
style="width: 180px"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-form-item>
</a-form>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'workNo'">
<a @click="handleViewWorkDetail(record)">{{ record.workNo || "-" }}</a>
</template>
<template v-else-if="column.key === 'finalScore'">
<span v-if="record.judgeScore !== null && record.judgeScore !== undefined" class="score">
{{ Number(record.judgeScore).toFixed(2) }}
</span>
<span v-else-if="record.finalScore !== null" class="score">
{{ Number(record.finalScore).toFixed(2) }}
</span>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'nickname'">
{{ record.registration?.user?.nickname || "-" }}
</template>
<template v-else-if="column.key === 'username'">
{{ record.registration?.user?.username || "-" }}
</template>
<template v-else-if="column.key === 'org'">
<div>
<div>{{ record.registration?.user?.tenant?.name || "-" }}</div>
<div v-if="record.registration?.user?.student?.class" class="org-detail">
{{ record.registration.user.student.class.grade?.name || "" }}
{{ record.registration.user.student.class.name || "" }}
</div>
</div>
</template>
<template v-else-if="column.key === 'teachers'">
{{ formatTeachers(record.registration?.teachers) }}
</template>
</template>
</a-table>
<!-- 作品详情弹框 -->
<WorkDetailModal
v-model:open="workDetailModalVisible"
:work-id="currentWorkId"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import { message, Modal } from "ant-design-vue"
import {
ArrowLeftOutlined,
SearchOutlined,
ReloadOutlined,
} from "@ant-design/icons-vue"
import { useAuthStore } from "@/stores/auth"
import { resultsApi } from "@/api/contests"
import WorkDetailModal from "../components/WorkDetailModal.vue"
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const isSuperAdmin = computed(() => authStore.hasAnyRole(['super_admin']))
const tenantCode = route.params.tenantCode as string
const contestId = Number(route.params.id)
//
const contestInfo = ref<{
id: number
contestName: string
resultState: string
} | null>(null)
//
const workDetailModalVisible = ref(false)
const currentWorkId = ref<number | null>(null)
//
const loading = ref(false)
const publishLoading = ref(false)
const dataSource = ref<any[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
//
const searchParams = reactive({
workNo: "",
accountNo: "",
})
//
const columns = [
{
title: "序号",
key: "index",
width: 70,
},
{
title: "作品编号",
key: "workNo",
width: 120,
},
{
title: "评委评分",
key: "finalScore",
width: 100,
},
{
title: "姓名",
key: "nickname",
width: 120,
},
{
title: "账号",
key: "username",
width: 120,
},
{
title: "机构信息",
key: "org",
width: 180,
},
{
title: "指导老师",
key: "teachers",
width: 150,
},
]
//
const formatTeachers = (teachers: any[] | undefined) => {
if (!teachers || teachers.length === 0) return "-"
return teachers
.map((t) => t.user?.nickname || t.user?.username)
.filter(Boolean)
.join("、") || "-"
}
//
const fetchList = async () => {
loading.value = true
try {
const response = await resultsApi.getResults(contestId, {
page: pagination.current,
pageSize: pagination.pageSize,
workNo: searchParams.workNo || undefined,
accountNo: searchParams.accountNo || undefined,
})
contestInfo.value = response.contest
dataSource.value = response.list
pagination.total = response.total
} catch (error: any) {
message.error(error?.response?.data?.message || "获取列表失败")
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.current = 1
fetchList()
}
//
const handleReset = () => {
searchParams.workNo = ""
searchParams.accountNo = ""
pagination.current = 1
fetchList()
}
//
const handleTableChange = (pag: any) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchList()
}
//
const handleBack = () => {
router.push(`/${tenantCode}/contests/results`)
}
//
const handleViewWorkDetail = (record: any) => {
currentWorkId.value = record.id
workDetailModalVisible.value = true
}
// /
const handlePublish = () => {
const isPublished = contestInfo.value?.resultState === "published"
Modal.confirm({
title: isPublished ? "确定撤回成果发布吗?" : "确定发布成果吗?",
content: isPublished
? "撤回后,成果将不再对外公开显示"
: "发布后,活动结果将公开显示",
onOk: async () => {
publishLoading.value = true
try {
if (isPublished) {
await resultsApi.unpublish(contestId)
message.success("已撤回发布")
} else {
await resultsApi.publish(contestId)
message.success("发布成功")
}
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
} finally {
publishLoading.value = false
}
},
})
}
onMounted(() => {
fetchList()
})
</script>
<style scoped lang="scss">
$primary: #6366f1;
.results-detail-page {
:deep(.ant-btn-primary) {
background: linear-gradient(135deg, $primary 0%, darken($primary, 10%) 100%);
border: none;
box-shadow: 0 4px 12px rgba($primary, 0.35);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 6px 16px rgba($primary, 0.45);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
:deep(.ant-table-wrapper) {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
.ant-table {
.ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
border-bottom: 1px solid #f0f0f0;
}
.ant-table-tbody > tr {
transition: all 0.2s ease;
&:hover > td {
background: rgba($primary, 0.04);
}
> td {
border-bottom: 1px solid #f5f5f5;
}
}
}
.ant-table-pagination {
padding: 16px;
margin: 0;
}
}
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.search-form {
margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 16px 24px;
:deep(.ant-form-item) {
margin-bottom: 0;
margin-right: 0;
}
}
.org-detail {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.score {
font-weight: bold;
color: #52c41a;
}
</style>
<template>
<div class="results-detail-page">
<!-- 顶部导航 -->
<div class="page-header">
<div class="header-left">
<a-button type="text" @click="handleBack">
<template #icon><arrow-left-outlined /></template>
</a-button>
<span class="page-title">{{ contestInfo?.contestName || '成果发布' }}</span>
<a-tag v-if="contestInfo?.resultState === 'published'" color="green">已发布</a-tag>
<a-tag v-else color="default">未发布</a-tag>
</div>
<div v-if="!isSuperAdmin" class="header-right">
<a-button type="primary" :loading="publishLoading" @click="handlePublish" :disabled="!canPublish">
{{ contestInfo?.resultState === 'published' ? '撤回发布' : '发布成果' }}
</a-button>
</div>
</div>
<!-- 统计摘要 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(99,102,241,0.1)"><file-text-outlined style="color: #6366f1" /></div>
<div class="stat-info"><span class="stat-count">{{ summary.totalWorks }}</span><span class="stat-label">总作品</span></div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(59,130,246,0.1)"><check-circle-outlined style="color: #3b82f6" /></div>
<div class="stat-info"><span class="stat-count">{{ summary.scoredWorks }}</span><span class="stat-label">已评分</span></div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(16,185,129,0.1)"><ordered-list-outlined style="color: #10b981" /></div>
<div class="stat-info"><span class="stat-count">{{ summary.rankedWorks }}</span><span class="stat-label">已排名</span></div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(245,158,11,0.1)"><trophy-outlined style="color: #f59e0b" /></div>
<div class="stat-info"><span class="stat-count">{{ summary.awardedWorks }}</span><span class="stat-label">已设奖</span></div>
</div>
<div class="stat-card" v-if="summary.avgScore">
<div class="stat-icon" style="background: rgba(139,92,246,0.1)"><fund-outlined style="color: #8b5cf6" /></div>
<div class="stat-info"><span class="stat-count">{{ summary.avgScore }}</span><span class="stat-label">平均分</span></div>
</div>
</div>
<!-- 操作步骤未发布时显示 -->
<div v-if="!isSuperAdmin && contestInfo?.resultState !== 'published'" class="action-bar">
<a-space>
<a-button @click="handleCalculateScores" :loading="calcScoreLoading">
<template #icon><calculator-outlined /></template>
第一步计算得分
</a-button>
<a-button @click="handleCalculateRankings" :loading="calcRankLoading" :disabled="summary.scoredWorks === 0">
<template #icon><ordered-list-outlined /></template>
第二步计算排名
</a-button>
<a-button @click="autoAwardVisible = true" :disabled="summary.rankedWorks === 0">
<template #icon><trophy-outlined /></template>
第三步设置奖项
</a-button>
</a-space>
</div>
<!-- 搜索 -->
<div class="filter-bar">
<a-form layout="inline" @finish="handleSearch">
<a-form-item label="作品编号">
<a-input v-model:value="searchParams.workNo" placeholder="请输入作品编号" allow-clear style="width: 150px" />
</a-form-item>
<a-form-item label="报名账号">
<a-input v-model:value="searchParams.accountNo" placeholder="请输入报名账号" allow-clear style="width: 150px" />
</a-form-item>
<a-form-item label="奖项">
<a-select v-model:value="searchParams.awardLevel" placeholder="全部" allow-clear style="width: 120px" @change="handleSearch">
<a-select-option v-for="opt in awardFilterOptions" :key="opt" :value="opt">{{ opt }}</a-select-option>
<a-select-option value="_none">无奖项</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit"><template #icon><search-outlined /></template> 搜索</a-button>
<a-button style="margin-left: 8px" @click="handleReset"><template #icon><reload-outlined /></template> 重置</a-button>
</a-form-item>
</a-form>
</div>
<!-- 数据表格 -->
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id" @change="handleTableChange" class="data-table">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'rank'">
<span v-if="record.rank" class="rank-badge" :class="getRankClass(record.rank)">{{ record.rank }}</span>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'workNo'">
<a @click="handleViewWorkDetail(record)">{{ record.workNo || '-' }}</a>
</template>
<template v-else-if="column.key === 'finalScore'">
<span v-if="record.finalScore != null" class="score">{{ Number(record.finalScore).toFixed(2) }}</span>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'awardLevel'">
<a-tag v-if="record.awardName" :color="getAwardColor(record.awardName)">
{{ record.awardName }}
</a-tag>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'nickname'">
{{ record.registration?.user?.nickname || record.registration?.team?.teamName || '-' }}
</template>
<template v-else-if="column.key === 'username'">
{{ record.registration?.user?.username || '-' }}
</template>
<template v-else-if="column.key === 'action'">
<a-button v-if="!isSuperAdmin && contestInfo?.resultState !== 'published'" type="link" size="small" @click="openSetAward(record)">设奖</a-button>
<a-button type="link" size="small" @click="handleViewWorkDetail(record)">查看</a-button>
</template>
</template>
</a-table>
<!-- 作品详情弹框 -->
<WorkDetailModal v-model:open="workDetailModalVisible" :work-id="currentWorkId" />
<!-- 单个设置奖项弹窗 -->
<a-modal v-model:open="setAwardVisible" title="设置奖项" @ok="handleSetAward" :confirm-loading="setAwardLoading">
<a-form layout="vertical" style="margin-top: 16px">
<a-form-item label="作品">
<span>{{ currentAwardWork?.workNo }} {{ currentAwardWork?.registration?.user?.nickname || currentAwardWork?.registration?.team?.teamName }}</span>
</a-form-item>
<a-form-item label="奖项名称" required>
<a-select v-model:value="awardForm.awardName" placeholder="选择或输入奖项名称" mode="combobox" :options="existingAwardOptions" />
</a-form-item>
</a-form>
</a-modal>
<!-- 自动设置奖项弹窗 -->
<a-modal v-model:open="autoAwardVisible" title="按排名自动设置奖项" @ok="handleAutoSetAwards" :confirm-loading="autoAwardLoading" width="520px">
<p style="color: #6b7280; font-size: 13px; margin-bottom: 16px">
自定义奖项名称和获奖人数系统将按排名从高到低依次分配
</p>
<div class="award-tiers">
<div v-for="(tier, idx) in autoAwardTiers" :key="idx" class="award-tier-row">
<a-input v-model:value="tier.name" placeholder="奖项名称,如:金奖" style="flex: 1" />
<a-input-number v-model:value="tier.count" :min="1" placeholder="人数" style="width: 100px" />
<a-button type="text" danger @click="autoAwardTiers.splice(idx, 1)" :disabled="autoAwardTiers.length <= 1">
<template #icon><delete-outlined /></template>
</a-button>
</div>
<a-button type="dashed" block @click="autoAwardTiers.push({ name: '', count: 1 })" style="margin-top: 8px">
<template #icon><plus-outlined /></template>
添加奖项
</a-button>
</div>
<div style="margin-top: 16px; display: flex; justify-content: space-between; color: #6b7280; font-size: 13px">
<span>已排名作品{{ summary.rankedWorks }} </span>
<span>将分配{{ autoAwardTotal }} </span>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import {
ArrowLeftOutlined, SearchOutlined, ReloadOutlined,
FileTextOutlined, CheckCircleOutlined, OrderedListOutlined,
TrophyOutlined, FundOutlined, CalculatorOutlined,
DeleteOutlined, PlusOutlined,
} from '@ant-design/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { resultsApi, type ContestResult, type ResultsSummary } from '@/api/contests'
import WorkDetailModal from '../components/WorkDetailModal.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const isSuperAdmin = computed(() => authStore.hasAnyRole(['super_admin']))
const tenantCode = route.params.tenantCode as string
const contestId = Number(route.params.id)
//
const awardColors = ['red', 'orange', 'blue', 'green', 'purple', 'cyan', 'magenta']
const getAwardColor = (awardName: string) => {
const names = [...new Set(dataSource.value.filter(w => w.awardName).map(w => w.awardName!))]
const idx = names.indexOf(awardName)
return idx >= 0 ? awardColors[idx % awardColors.length] : 'default'
}
const getRankClass = (rank: number) => {
if (rank === 1) return 'gold'
if (rank === 2) return 'silver'
if (rank === 3) return 'bronze'
return ''
}
//
const contestInfo = ref<any>(null)
const summary = ref({ totalWorks: 0, scoredWorks: 0, rankedWorks: 0, awardedWorks: 0, unscoredWorks: 0, avgScore: null as string | null })
const canPublish = computed(() => summary.value.rankedWorks > 0)
//
const awardFilterOptions = computed(() => {
const names = new Set<string>()
dataSource.value.forEach(w => { if (w.awardName) names.add(w.awardName) })
return Array.from(names)
})
//
const loading = ref(false)
const publishLoading = ref(false)
const dataSource = ref<ContestResult[]>([])
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `${t}` })
const searchParams = reactive({ workNo: '', accountNo: '', awardLevel: undefined as string | undefined })
//
const workDetailModalVisible = ref(false)
const currentWorkId = ref<number | null>(null)
// loading
const calcScoreLoading = ref(false)
const calcRankLoading = ref(false)
//
const setAwardVisible = ref(false)
const setAwardLoading = ref(false)
const currentAwardWork = ref<ContestResult | null>(null)
const awardForm = reactive({ awardLevel: '' as string, awardName: '' })
// +
const existingAwardOptions = computed(() => {
const names = new Set<string>()
//
autoAwardTiers.value.forEach(t => { if (t.name) names.add(t.name) })
//
dataSource.value.forEach(w => { if (w.awardName) names.add(w.awardName) })
return Array.from(names).map(n => ({ value: n, label: n }))
})
//
const autoAwardVisible = ref(false)
const autoAwardLoading = ref(false)
const autoAwardTiers = ref<Array<{ name: string; count: number }>>([
{ name: '一等奖', count: 1 },
{ name: '二等奖', count: 2 },
{ name: '三等奖', count: 3 },
])
const autoAwardTotal = computed(() => autoAwardTiers.value.reduce((sum, t) => sum + (t.count || 0), 0))
const columns = computed(() => {
const cols: any[] = [
{ title: '排名', key: 'rank', width: 70 },
{ title: '作品编号', key: 'workNo', width: 120 },
{ title: '最终得分', key: 'finalScore', width: 90 },
{ title: '奖项', key: 'awardLevel', width: 100 },
{ title: '姓名/队伍', key: 'nickname', width: 120 },
{ title: '账号', key: 'username', width: 120 },
]
cols.push({ title: '操作', key: 'action', width: 120, fixed: 'right' as const })
return cols
})
const fetchSummary = async () => {
try {
const res: ResultsSummary = await resultsApi.getSummary(contestId)
contestInfo.value = res.contest
summary.value = { ...res.summary, avgScore: res.scoreStats?.avgScore || null }
} catch { /* */ }
}
const fetchList = async () => {
loading.value = true
try {
const response = await resultsApi.getResults(contestId, {
page: pagination.current,
pageSize: pagination.pageSize,
workNo: searchParams.workNo || undefined,
accountNo: searchParams.accountNo || undefined,
})
contestInfo.value = response.contest
let list = response.list
//
if (searchParams.awardLevel) {
if (searchParams.awardLevel === '_none') {
list = list.filter(w => !w.awardName)
} else {
list = list.filter(w => w.awardName === searchParams.awardLevel)
}
}
dataSource.value = list
pagination.total = searchParams.awardLevel ? list.length : response.total
} catch (e: any) { message.error(e?.response?.data?.message || '获取列表失败') }
finally { loading.value = false }
}
const handleSearch = () => { pagination.current = 1; fetchList() }
const handleReset = () => { searchParams.workNo = ''; searchParams.accountNo = ''; searchParams.awardLevel = undefined; pagination.current = 1; fetchList() }
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
const handleBack = () => { router.push(`/${tenantCode}/contests/results`) }
const handleViewWorkDetail = (record: any) => { currentWorkId.value = record.id; workDetailModalVisible.value = true }
//
const handleCalculateScores = async () => {
calcScoreLoading.value = true
try {
const res = await resultsApi.calculateScores(contestId)
message.success(res.message)
fetchList()
fetchSummary()
} catch (e: any) { message.error(e?.response?.data?.message || '计算失败') }
finally { calcScoreLoading.value = false }
}
//
const handleCalculateRankings = async () => {
calcRankLoading.value = true
try {
const res = await resultsApi.calculateRankings(contestId)
message.success(res.message)
fetchList()
fetchSummary()
} catch (e: any) { message.error(e?.response?.data?.message || '计算失败') }
finally { calcRankLoading.value = false }
}
//
const openSetAward = (record: ContestResult) => {
currentAwardWork.value = record
awardForm.awardName = record.awardName || ''
awardForm.awardLevel = record.awardLevel || ''
setAwardVisible.value = true
}
const handleSetAward = async () => {
if (!awardForm.awardName) { message.warning('请输入奖项名称'); return }
setAwardLoading.value = true
try {
await resultsApi.setAward(currentAwardWork.value!.id, {
awardLevel: awardForm.awardName, // level
awardName: awardForm.awardName,
})
message.success('设置成功')
setAwardVisible.value = false
fetchList()
fetchSummary()
} catch (e: any) { message.error(e?.response?.data?.message || '设置失败') }
finally { setAwardLoading.value = false }
}
//
const handleAutoSetAwards = async () => {
const validTiers = autoAwardTiers.value.filter(t => t.name && t.count > 0)
if (validTiers.length === 0) { message.warning('请至少设置一个奖项'); return }
autoAwardLoading.value = true
try {
await resultsApi.autoSetAwards(contestId, { awards: validTiers })
message.success('自动设奖完成')
autoAwardVisible.value = false
fetchList()
fetchSummary()
} catch (e: any) { message.error(e?.response?.data?.message || '设奖失败') }
finally { autoAwardLoading.value = false }
}
// /
const handlePublish = () => {
const isPublished = contestInfo.value?.resultState === 'published'
Modal.confirm({
title: isPublished ? '确定撤回成果发布?' : '确定发布成果?',
content: isPublished ? '撤回后成果将不再对外公开' : `将发布 ${summary.value.rankedWorks} 个作品的排名和奖项信息`,
okText: isPublished ? '撤回' : '确定发布',
okType: isPublished ? 'danger' : 'primary',
onOk: async () => {
publishLoading.value = true
try {
if (isPublished) {
await resultsApi.unpublish(contestId)
message.success('已撤回发布')
} else {
await resultsApi.publish(contestId)
message.success('发布成功')
}
fetchList()
fetchSummary()
} catch (e: any) { message.error(e?.response?.data?.message || '操作失败') }
finally { publishLoading.value = false }
},
})
}
onMounted(() => { fetchSummary(); fetchList() })
</script>
<style scoped lang="scss">
$primary: #6366f1;
.page-header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px;
.header-left { display: flex; align-items: center; gap: 8px; }
.page-title { font-size: 18px; font-weight: 600; color: #1e1b4b; }
}
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; }
.stat-card {
flex: 1; display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
.stat-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
.stat-info { display: flex; flex-direction: column;
.stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
.stat-label { font-size: 12px; color: #9ca3af; }
}
}
.action-bar {
padding: 16px 20px; background: rgba($primary, 0.03); border: 1px dashed rgba($primary, 0.15);
border-radius: 12px; margin-bottom: 16px;
}
.filter-bar { padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px; }
.data-table {
:deep(.ant-table-wrapper) { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
.ant-table-pagination { padding: 16px; margin: 0; }
}
}
.rank-badge {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 50%; font-size: 13px; font-weight: 700; background: #f3f4f6; color: #374151;
&.gold { background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #fff; }
&.silver { background: linear-gradient(135deg, #d1d5db, #9ca3af); color: #fff; }
&.bronze { background: linear-gradient(135deg, #f59e0b, #d97706); color: #fff; }
}
.score { font-weight: 700; color: #10b981; }
.text-muted { color: #d1d5db; }
.award-tiers {
.award-tier-row {
display: flex; gap: 8px; align-items: center; margin-bottom: 8px;
}
}
</style>

View File

@ -126,48 +126,60 @@
</a-table>
</template>
<!-- ========== 机构端保持不变 ========== -->
<!-- ========== 机构端 ========== -->
<template v-else>
<a-card class="title-card">
<template #title>成果发布</template>
</a-card>
<a-tabs v-model:activeKey="activeTab" class="org-tabs" @change="handleTabChange">
<a-tab-pane key="individual" tab="个人参与" />
<a-tab-pane key="team" tab="团队参与" />
</a-tabs>
<!-- 统计概览 -->
<div class="stats-row">
<div v-for="item in orgStatsItems" :key="item.key" :class="['stat-card', { active: orgActiveFilter === item.key }]" @click="handleOrgStatClick(item.key)">
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
<div class="stat-info">
<span class="stat-count">{{ item.count }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
</div>
<a-form :model="searchParams" layout="inline" class="filter-bar" @finish="handleSearch">
<a-form-item label="活动名称">
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 200px" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-form-item>
</a-form>
<!-- 筛选 -->
<div class="filter-bar">
<a-form layout="inline" @finish="handleSearch">
<a-form-item label="活动名称">
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 200px" />
</a-form-item>
<a-form-item label="活动类型">
<a-select v-model:value="searchParams.contestType" placeholder="全部" allow-clear style="width: 120px" @change="handleSearch">
<a-select-option value="individual">个人参与</a-select-option>
<a-select-option value="team">团队参与</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="发布状态">
<a-select v-model:value="orgResultState" placeholder="全部" allow-clear style="width: 110px" @change="handleSearch">
<a-select-option value="published">已发布</a-select-option>
<a-select-option value="unpublished">未发布</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit"><template #icon><SearchOutlined /></template> 搜索</a-button>
<a-button style="margin-left: 8px" @click="handleReset"><template #icon><ReloadOutlined /></template> 重置</a-button>
</a-form-item>
</a-form>
</div>
<a-table
:columns="orgColumns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
class="data-table"
>
<a-table :columns="orgColumns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id" @change="handleTableChange" class="data-table">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'organizers'">
{{ record.organizers || '-' }}
<template v-else-if="column.key === 'contestName'">
<a @click="handleViewDetail(record)">{{ record.contestName }}</a>
</template>
<template v-else-if="column.key === 'contestType'">
<a-tag :color="record.contestType === 'individual' ? 'blue' : 'green'">{{ record.contestType === 'individual' ? '个人' : '团队' }}</a-tag>
</template>
<template v-else-if="column.key === 'registrationCount'">
{{ record._count?.registrations || 0 }}
@ -180,7 +192,8 @@
<a-tag v-else color="default">未发布</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleViewDetail(record)">详情</a-button>
<a-button v-if="record.resultState === 'published'" type="link" size="small" @click="handleViewDetail(record)">查看成果</a-button>
<a-button v-else type="link" size="small" style="color: #10b981" @click="handleViewDetail(record)">发布成果</a-button>
</template>
</template>
</a-table>
@ -342,42 +355,73 @@ const superColumns = [
]
// =============================================
//
//
// =============================================
const activeTab = ref<'individual' | 'team'>('individual')
const orgActiveFilter = ref('')
const orgPublishedCount = ref(0)
const orgUnpublishedCount = ref(0)
const orgResultState = ref<string | undefined>(undefined)
const orgStatsItems = computed(() => [
{ key: '', label: '全部', count: orgPublishedCount.value + orgUnpublishedCount.value, icon: TrophyOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
{ key: 'published', label: '已发布', count: orgPublishedCount.value, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
{ key: 'unpublished', label: '未发布', count: orgUnpublishedCount.value, icon: CloseCircleOutlined, color: '#9ca3af', bgColor: 'rgba(156,163,175,0.1)' },
])
const handleOrgStatClick = (key: string) => {
if (orgActiveFilter.value === key) { orgActiveFilter.value = ''; orgResultState.value = undefined }
else { orgActiveFilter.value = key; orgResultState.value = key || undefined }
pagination.current = 1
fetchList()
}
const loading = ref(false)
const dataSource = ref<Contest[]>([])
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `${t}` })
const searchParams = reactive<QueryContestParams>({ contestName: '' })
const orgColumns = [
{ title: '序号', key: 'index', width: 70 },
{ title: '活动名称', key: 'contestName', dataIndex: 'contestName', width: 250 },
{ title: '主办机构', key: 'organizers', width: 140 },
{ title: '报名人数', key: 'registrationCount', width: 100 },
{ title: '提交作品数', key: 'worksCount', width: 100 },
{ title: '发布状态', key: 'resultState', width: 100 },
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
{ title: '序号', key: 'index', width: 50 },
{ title: '活动名称', key: 'contestName', width: 200 },
{ title: '类型', key: 'contestType', width: 70 },
{ title: '报名', key: 'registrationCount', width: 70 },
{ title: '作品', key: 'worksCount', width: 70 },
{ title: '发布状态', key: 'resultState', width: 90 },
{ title: '操作', key: 'action', width: 110, fixed: 'right' as const },
]
const fetchOrgStats = async () => {
try {
const res = await contestsApi.getList({ page: 1, pageSize: 100 })
orgPublishedCount.value = res.list.filter(c => c.resultState === 'published').length
orgUnpublishedCount.value = res.list.filter(c => c.resultState !== 'published').length
} catch { /* */ }
}
const fetchList = async () => {
loading.value = true
try {
const res = await contestsApi.getList({
...searchParams,
contestType: activeTab.value,
contestName: searchParams.contestName || undefined,
contestType: searchParams.contestType || undefined,
page: pagination.current,
pageSize: pagination.pageSize,
})
dataSource.value = res.list
pagination.total = res.total
//
let list = res.list
if (orgResultState.value) {
list = list.filter(c =>
orgResultState.value === 'published' ? c.resultState === 'published' : c.resultState !== 'published'
)
}
dataSource.value = list
pagination.total = orgResultState.value ? list.length : res.total
} catch { message.error('获取列表失败') }
finally { loading.value = false }
}
const handleTabChange = () => { pagination.current = 1; fetchList() }
const handleSearch = () => { pagination.current = 1; fetchList() }
const handleReset = () => { searchParams.contestName = ''; pagination.current = 1; fetchList() }
const handleSearch = () => { orgActiveFilter.value = ''; pagination.current = 1; fetchList() }
const handleReset = () => { searchParams.contestName = ''; searchParams.contestType = undefined; orgResultState.value = undefined; orgActiveFilter.value = ''; pagination.current = 1; fetchList(); fetchOrgStats() }
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
const handleViewDetail = (record: Contest) => { router.push(`/${tenantCode}/contests/results/${record.id}`) }
@ -390,6 +434,7 @@ onMounted(() => {
fetchSuperStats()
fetchSuperList()
} else {
fetchOrgStats()
fetchList()
}
})
@ -450,11 +495,4 @@ $primary: #6366f1;
.contest-link { padding: 0; text-align: left; }
.text-muted { color: #d1d5db; }
.org-tabs {
background: #fff;
padding: 0 24px;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
margin-bottom: 16px;
}
</style>

View File

@ -53,7 +53,19 @@
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'ruleDescription'">
<span>{{ formatDimensions(record.dimensions) }}</span>
<span v-if="record.ruleDescription" class="text-desc">{{ record.ruleDescription }}</span>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'dimensions'">
<span class="text-desc">{{ formatDimensions(record.dimensions) }}</span>
</template>
<template v-else-if="column.key === 'judgeCount'">
<a-tooltip title="每个作品需要几位评委评分">
<a-tag color="blue">{{ record.judgeCount || '-' }}/作品</a-tag>
</a-tooltip>
</template>
<template v-else-if="column.key === 'calculationRule'">
{{ calculationRuleText[record.calculationRule] || record.calculationRule || '-' }}
</template>
<template v-else-if="column.key === 'contests'">
<span v-if="record.contests && record.contests.length > 0">
@ -73,7 +85,7 @@
</a-button>
<a-popconfirm
v-permission="'contest:update'"
title="确定要删除这个评审规则吗?"
:title="record.contests?.length > 0 ? `该规则已关联${record.contests.length}个活动,确定删除吗?` : '确定要删除这个评审规则吗?'"
@confirm="handleDelete(record.id)"
>
<a-button type="link" danger size="small">删除</a-button>
@ -86,7 +98,7 @@
<!-- 新增/编辑评审规则抽屉 -->
<a-drawer
v-model:open="modalVisible"
title="编辑规则"
:title="isEditing ? '编辑规则' : '新建规则'"
placement="right"
width="600px"
:footer-style="{ textAlign: 'right' }"
@ -355,35 +367,24 @@ const rules = {
//
const columns = [
{
title: "序号",
key: "index",
width: 80,
},
{
title: "规则名称",
dataIndex: "ruleName",
key: "ruleName",
width: 200,
},
{
title: "规则描述",
key: "ruleDescription",
width: 300,
},
{
title: "关联活动",
key: "contests",
width: 200,
},
{
title: "操作",
key: "action",
width: 150,
fixed: "right" as const,
},
{ title: "序号", key: "index", width: 50 },
{ title: "规则名称", dataIndex: "ruleName", key: "ruleName", width: 160 },
{ title: "规则说明", key: "ruleDescription", width: 200 },
{ title: "评分维度", key: "dimensions", width: 200 },
{ title: "每作品评委", key: "judgeCount", width: 90 },
{ title: "计算方式", key: "calculationRule", width: 100 },
{ title: "关联活动", key: "contests", width: 150 },
{ title: "操作", key: "action", width: 130, fixed: "right" as const },
]
const calculationRuleText: Record<string, string> = {
average: '全部均值',
remove_max_min: '去最高最低',
removeMaxMin: '去最高最低',
remove_min: '去最低分',
removeMin: '去最低分',
}
//
const formatDimensions = (dimensions: any) => {
if (!dimensions) return "-"
@ -573,99 +574,28 @@ const handleCancel = () => {
</script>
<style scoped lang="scss">
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
$primary: #6366f1;
.review-rules-page {
:deep(.ant-card) {
border: none;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
.ant-card-head {
border-bottom: none;
padding: 16px 24px;
.ant-card-head-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
}
.ant-card-body {
padding: 0;
}
}
:deep(.ant-btn-primary) {
background: $gradient-primary;
border: none;
box-shadow: 0 4px 12px rgba($primary, 0.35);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 8%) 100%);
box-shadow: 0 6px 16px rgba($primary, 0.45);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px;
.ant-card-head { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
.ant-card-body { padding: 0; }
}
:deep(.ant-table-wrapper) {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
.ant-table {
.ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
border-bottom: 1px solid #f0f0f0;
}
.ant-table-tbody > tr {
transition: all 0.2s ease;
&:hover > td {
background: rgba($primary, 0.04);
}
> td {
border-bottom: 1px solid #f5f5f5;
}
}
}
.ant-table-pagination {
padding: 16px;
margin: 0;
}
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
.ant-table-pagination { padding: 16px; margin: 0; }
}
}
.text-desc { font-size: 12px; color: #6b7280; }
.text-muted { color: #d1d5db; }
.search-form {
margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
:deep(.ant-form-item) {
margin-bottom: 16px;
margin-right: 16px;
&:last-child {
margin-right: 0;
}
}
margin-bottom: 16px; padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.scoring-standards {

View File

@ -191,64 +191,74 @@
</a-drawer>
</template>
<!-- ========== 机构端保持原有两层结构 ========== -->
<!-- ========== 机构端 ========== -->
<template v-else>
<a-card class="title-card">
<template #title>评审进度</template>
</a-card>
<a-tabs v-model:activeKey="activeTab" class="org-tabs" @change="handleTabChange">
<a-tab-pane key="individual" tab="个人参与" />
<a-tab-pane key="team" tab="团队参与" />
</a-tabs>
<!-- 统计概览 -->
<div class="stats-row">
<div v-for="item in orgStatsItems" :key="item.key" class="stat-card">
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
<div class="stat-info">
<span class="stat-count">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
</div>
<a-form :model="searchParams" layout="inline" class="filter-bar" @finish="handleSearch">
<a-form-item label="活动名称">
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 200px" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-form-item>
</a-form>
<!-- 筛选 -->
<div class="filter-bar">
<a-form layout="inline" @finish="handleSearch">
<a-form-item label="活动名称">
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 200px" />
</a-form-item>
<a-form-item label="活动类型">
<a-select v-model:value="searchParams.contestType" placeholder="全部" allow-clear style="width: 120px" @change="handleSearch">
<a-select-option value="individual">个人参与</a-select-option>
<a-select-option value="team">团队参与</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit"><template #icon><SearchOutlined /></template> 搜索</a-button>
<a-button style="margin-left: 8px" @click="handleReset"><template #icon><ReloadOutlined /></template> 重置</a-button>
</a-form-item>
</a-form>
</div>
<a-table
:columns="orgColumns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
class="data-table"
>
<a-table :columns="orgColumns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id" @change="handleTableChange" class="data-table">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'organizers'">
{{ record.organizers || '-' }}
<template v-else-if="column.key === 'contestName'">
<a @click="handleViewDetail(record)">{{ record.contestName }}</a>
</template>
<template v-else-if="column.key === 'contestType'">
<a-tag :color="record.contestType === 'individual' ? 'blue' : 'green'">{{ record.contestType === 'individual' ? '个人' : '团队' }}</a-tag>
</template>
<template v-else-if="column.key === 'reviewStatus'">
<a-tag :color="getOrgReviewStatusColor(record)">{{ getOrgReviewStatusText(record) }}</a-tag>
</template>
<template v-else-if="column.key === 'reviewedCount'">
{{ record.reviewedCount || 0 }} / {{ record.totalWorksCount || 0 }}
<template v-else-if="column.key === 'reviewProgress'">
<div class="progress-cell">
<span class="progress-num" :class="getProgressClass(record)">{{ record.reviewedCount || 0 }}</span>
<span class="progress-sep">/</span>
<span class="progress-total">{{ record.totalWorksCount || 0 }}</span>
</div>
</template>
<template v-else-if="column.key === 'reviewTime'">
<div v-if="record.reviewStartTime || record.reviewEndTime">
<div v-if="record.reviewStartTime">
<div>{{ formatDate(record.reviewStartTime) }}</div>
<div> {{ formatDate(record.reviewEndTime) }}</div>
<div class="text-muted"> {{ formatDate(record.reviewEndTime) }}</div>
</div>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleViewDetail(record)">查看详情</a-button>
<a-button type="link" size="small" @click="handleViewDetail(record)">查看详情 <right-outlined /></a-button>
</template>
</template>
</a-table>
@ -268,6 +278,7 @@ import {
MinusCircleOutlined,
SyncOutlined,
CheckCircleOutlined,
RightOutlined,
} from "@ant-design/icons-vue"
import {
contestsApi,
@ -444,39 +455,60 @@ const scoreColumns = [
]
// =============================================
//
//
// =============================================
const activeTab = ref<'individual' | 'team'>('individual')
const orgStats = ref<WorksStats>({ total: 0, submitted: 0, reviewing: 0, reviewed: 0 })
const orgStatsItems = computed(() => [
{ key: 'total', label: '总作品', value: orgStats.value.total, icon: FileTextOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
{ key: 'not_reviewed', label: '未评审', value: orgStats.value.submitted, icon: MinusCircleOutlined, color: '#9ca3af', bgColor: 'rgba(156,163,175,0.1)' },
{ key: 'reviewing', label: '评审中', value: orgStats.value.reviewing, icon: SyncOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
{ key: 'reviewed', label: '已完成', value: orgStats.value.reviewed, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
])
const loading = ref(false)
const dataSource = ref<Contest[]>([])
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
const searchParams = reactive({ contestName: '' })
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `${t}` })
const searchParams = reactive<{ contestName?: string; contestType?: string }>({})
const orgColumns = [
{ title: '序号', key: 'index', width: 70 },
{ title: '活动名称', dataIndex: 'contestName', key: 'contestName', width: 200 },
{ title: '主办机构', key: 'organizers', width: 140 },
{ title: '评审状态', key: 'reviewStatus', width: 100 },
{ title: '已评审/作品数', key: 'reviewedCount', width: 130 },
{ title: '评审时间', key: 'reviewTime', width: 180 },
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
{ title: '序号', key: 'index', width: 50 },
{ title: '活动名称', key: 'contestName', width: 200 },
{ title: '类型', key: 'contestType', width: 70 },
{ title: '评审状态', key: 'reviewStatus', width: 80 },
{ title: '评审进度', key: 'reviewProgress', width: 100 },
{ title: '评审时间', key: 'reviewTime', width: 160 },
{ title: '操作', key: 'action', width: 120, fixed: 'right' as const },
]
const getOrgReviewStatusColor = (record: Contest) => {
const now = new Date()
const start = record.reviewStartTime ? new Date(record.reviewStartTime) : null
const end = record.reviewEndTime ? new Date(record.reviewEndTime) : null
if (!start || now < start) return 'default'
if (end && now > end) return 'success'
return 'processing'
// #3
const getOrgReviewStatusColor = (record: any) => {
const reviewed = record.reviewedCount || 0
const total = record.totalWorksCount || 0
if (total === 0) return 'default'
if (reviewed >= total) return 'success'
if (reviewed > 0) return 'processing'
return 'warning'
}
const getOrgReviewStatusText = (record: Contest) => {
const now = new Date()
const start = record.reviewStartTime ? new Date(record.reviewStartTime) : null
const end = record.reviewEndTime ? new Date(record.reviewEndTime) : null
if (!start || now < start) return '未开始'
if (end && now > end) return '已完成'
return '进行中'
const getOrgReviewStatusText = (record: any) => {
const reviewed = record.reviewedCount || 0
const total = record.totalWorksCount || 0
if (total === 0) return '无作品'
if (reviewed >= total) return '已完成'
if (reviewed > 0) return '进行中'
return '未开始'
}
// #4
const getProgressClass = (record: any) => {
const reviewed = record.reviewedCount || 0
const total = record.totalWorksCount || 0
if (total > 0 && reviewed >= total) return 'complete'
if (reviewed > 0) return 'partial'
return 'empty'
}
const fetchOrgStats = async () => {
try { orgStats.value = await worksApi.getStats() } catch { /* */ }
}
const fetchList = async () => {
@ -486,7 +518,7 @@ const fetchList = async () => {
page: pagination.current,
pageSize: pagination.pageSize,
contestName: searchParams.contestName || undefined,
contestType: activeTab.value,
contestType: searchParams.contestType || undefined,
})
dataSource.value = res.list
pagination.total = res.total
@ -494,12 +526,11 @@ const fetchList = async () => {
finally { loading.value = false }
}
const handleTabChange = () => { pagination.current = 1; fetchList() }
const handleSearch = () => { pagination.current = 1; fetchList() }
const handleReset = () => { searchParams.contestName = ''; pagination.current = 1; fetchList() }
const handleReset = () => { searchParams.contestName = ''; searchParams.contestType = undefined; pagination.current = 1; fetchList(); fetchOrgStats() }
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
const handleViewDetail = (record: Contest) => {
router.push(`/${tenantCode}/contests/reviews/${record.id}/progress?type=${activeTab.value}`)
router.push(`/${tenantCode}/contests/reviews/${record.id}/progress`)
}
// =============================================
@ -514,6 +545,7 @@ onMounted(() => {
fetchSuperStats()
fetchSuperList()
} else {
fetchOrgStats()
fetchList()
}
})
@ -571,6 +603,16 @@ $primary: #6366f1;
}
}
.progress-cell {
.progress-num { font-weight: 700;
&.complete { color: #10b981; }
&.partial { color: #f59e0b; }
&.empty { color: #d1d5db; }
}
.progress-sep { color: #d1d5db; margin: 0 2px; }
.progress-total { color: #9ca3af; }
}
.contest-link { padding: 0; text-align: left; }
.work-link { color: $primary; cursor: pointer; &:hover { text-decoration: underline; } }
.text-muted { color: #d1d5db; }
@ -581,11 +623,4 @@ $primary: #6366f1;
h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
}
.org-tabs {
background: #fff;
padding: 0 24px;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
margin-bottom: 16px;
}
</style>

View File

@ -52,6 +52,7 @@
placeholder="请选择评审进度"
allow-clear
style="width: 150px"
@change="handleSearch"
>
<a-select-option value="not_reviewed">未评审</a-select-option>
<a-select-option value="in_progress">评审中</a-select-option>
@ -412,6 +413,14 @@ const fetchContestInfo = async () => {
}
//
const getWorkReviewState = (record: any): string => {
const reviewed = record.reviewedCount || 0
const total = record.totalJudgesCount || 0
if (reviewed === 0) return 'not_reviewed'
if (total > 0 && reviewed >= total) return 'completed'
return 'in_progress'
}
const fetchList = async () => {
loading.value = true
try {
@ -422,8 +431,13 @@ const fetchList = async () => {
workNo: searchParams.workNo || undefined,
username: searchParams.username || undefined,
})
dataSource.value = response.list
pagination.total = response.total
//
let list = response.list
if (searchParams.reviewProgress) {
list = list.filter((w: any) => getWorkReviewState(w) === searchParams.reviewProgress)
}
dataSource.value = list
pagination.total = searchParams.reviewProgress ? list.length : response.total
} catch (error: any) {
message.error(error?.response?.data?.message || "获取作品列表失败")
} finally {

View File

@ -126,64 +126,73 @@
<WorkDetailModal v-model:open="workModalVisible" :work-id="currentWorkId" />
</template>
<!-- ========== 机构端保持原有两层结构 ========== -->
<!-- ========== 机构端活动维度 + 统计 ========== -->
<template v-else>
<a-card class="title-card">
<template #title>参赛作品</template>
<template #title>作品管理</template>
</a-card>
<a-tabs v-model:activeKey="activeTab" class="org-tabs" @change="handleTabChange">
<a-tab-pane key="individual" tab="个人参与" />
<a-tab-pane key="team" tab="团队参与" />
</a-tabs>
<!-- 统计概览 -->
<div class="stats-row">
<div v-for="item in orgStatsItems" :key="item.key" class="stat-card">
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
<div class="stat-info">
<span class="stat-count">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
</div>
<a-form :model="searchParams" layout="inline" class="filter-bar" @finish="handleSearch">
<a-form-item label="活动名称">
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 200px" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-form-item>
</a-form>
<!-- 筛选 -->
<div class="filter-bar">
<a-form layout="inline" @finish="handleSearch">
<a-form-item label="活动名称">
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 200px" />
</a-form-item>
<a-form-item label="活动类型">
<a-select v-model:value="searchParams.contestType" placeholder="全部" allow-clear style="width: 120px" @change="handleSearch">
<a-select-option value="individual">个人参与</a-select-option>
<a-select-option value="team">团队参与</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit"><template #icon><SearchOutlined /></template> 搜索</a-button>
<a-button style="margin-left: 8px" @click="handleReset"><template #icon><ReloadOutlined /></template> 重置</a-button>
</a-form-item>
</a-form>
</div>
<a-table
:columns="orgColumns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
class="data-table"
>
<a-table :columns="orgColumns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id" @change="handleTableChange" class="data-table">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'organizers'">
{{ record.organizers || '-' }}
<template v-else-if="column.key === 'contestName'">
<a @click="handleViewDetail(record)">{{ record.contestName }}</a>
</template>
<template v-else-if="column.key === 'registrationCount'">
{{ activeTab === 'team' ? (record._count?.teams || 0) : (record._count?.registrations || 0) }}
<template v-else-if="column.key === 'contestType'">
<a-tag :color="record.contestType === 'individual' ? 'blue' : 'green'">{{ record.contestType === 'individual' ? '个人' : '团队' }}</a-tag>
</template>
<template v-else-if="column.key === 'worksCount'">
{{ record._count?.works || 0 }} / {{ activeTab === 'team' ? (record._count?.teams || 0) : (record._count?.registrations || 0) }}
<template v-else-if="column.key === 'worksProgress'">
<div class="works-progress">
<span class="progress-num" :class="{ complete: (record._count?.works || 0) >= (record._count?.registrations || 1) }">
{{ record._count?.works || 0 }}
</span>
<span class="progress-sep">/</span>
<span class="progress-total">{{ record._count?.registrations || 0 }}</span>
</div>
</template>
<template v-else-if="column.key === 'contestTime'">
<div v-if="record.startTime || record.endTime">
<div>{{ formatDate(record.startTime) }}</div>
<div> {{ formatDate(record.endTime) }}</div>
<template v-else-if="column.key === 'submitTime'">
<div v-if="record.submitStartTime">
<div>{{ formatDate(record.submitStartTime) }}</div>
<div class="text-muted"> {{ formatDate(record.submitEndTime) }}</div>
</div>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleViewDetail(record)">查看详情</a-button>
<a-button type="link" size="small" @click="handleViewDetail(record)">查看作品 <right-outlined /></a-button>
</template>
</template>
</a-table>
@ -203,6 +212,8 @@ import {
SendOutlined,
EyeOutlined,
CheckCircleOutlined,
RightOutlined,
InboxOutlined,
} from "@ant-design/icons-vue"
import {
contestsApi,
@ -344,23 +355,33 @@ const superColumns = [
]
// =============================================
//
//
// =============================================
const activeTab = ref<'individual' | 'team'>('individual')
const orgStats = ref({ total: 0, submitted: 0, reviewing: 0, reviewed: 0 })
const orgStatsItems = computed(() => [
{ key: 'total', label: '总作品', value: orgStats.value.total, icon: FileTextOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
{ key: 'submitted', label: '已提交', value: orgStats.value.submitted, icon: SendOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
{ key: 'reviewing', label: '评审中', value: orgStats.value.reviewing, icon: EyeOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
{ key: 'reviewed', label: '已评完', value: orgStats.value.reviewed, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
])
const loading = ref(false)
const dataSource = ref<Contest[]>([])
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
const searchParams = reactive({ contestName: '' })
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `${t}` })
const searchParams = reactive<{ contestName?: string; contestType?: string }>({})
const orgColumns = computed(() => [
{ title: '序号', key: 'index', width: 70 },
{ title: '活动名称', dataIndex: 'contestName', key: 'contestName', width: 200 },
{ title: '主办机构', key: 'organizers', width: 140 },
{ title: activeTab.value === 'team' ? '报名队伍数' : '报名人数', key: 'registrationCount', width: 120 },
{ title: '已递交/应递交作品数', key: 'worksCount', width: 160 },
{ title: '活动时间', key: 'contestTime', width: 180 },
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
])
const orgColumns = [
{ title: '序号', key: 'index', width: 50 },
{ title: '活动名称', key: 'contestName', width: 200 },
{ title: '类型', key: 'contestType', width: 70 },
{ title: '已交/应交', key: 'worksProgress', width: 100 },
{ title: '提交时间', key: 'submitTime', width: 160 },
{ title: '操作', key: 'action', width: 120, fixed: 'right' as const },
]
const fetchOrgStats = async () => {
try { orgStats.value = await worksApi.getStats() } catch { /* */ }
}
const fetchList = async () => {
loading.value = true
@ -369,7 +390,7 @@ const fetchList = async () => {
page: pagination.current,
pageSize: pagination.pageSize,
contestName: searchParams.contestName || undefined,
contestType: activeTab.value,
contestType: searchParams.contestType || undefined,
})
dataSource.value = res.list
pagination.total = res.total
@ -377,12 +398,11 @@ const fetchList = async () => {
finally { loading.value = false }
}
const handleTabChange = () => { pagination.current = 1; fetchList() }
const handleSearch = () => { pagination.current = 1; fetchList() }
const handleReset = () => { searchParams.contestName = ''; pagination.current = 1; fetchList() }
const handleReset = () => { searchParams.contestName = ''; searchParams.contestType = undefined; pagination.current = 1; fetchList(); fetchOrgStats() }
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
const handleViewDetail = (record: Contest) => {
router.push(`/${tenantCode}/contests/works/${record.id}/list?type=${activeTab.value}`)
router.push(`/${tenantCode}/contests/works/${record.id}/list`)
}
// =============================================
@ -397,6 +417,7 @@ onMounted(() => {
fetchSuperStats()
fetchSuperList()
} else {
fetchOrgStats()
fetchList()
}
})
@ -454,6 +475,12 @@ $primary: #6366f1;
}
}
.works-progress {
.progress-num { font-weight: 700; color: #6366f1; &.complete { color: #10b981; } }
.progress-sep { color: #d1d5db; margin: 0 2px; }
.progress-total { color: #9ca3af; }
}
.contest-link { padding: 0; text-align: left; }
.work-link { color: $primary; cursor: pointer; &:hover { text-decoration: underline; } }
.text-muted { color: #d1d5db; }

View File

@ -2,12 +2,17 @@
<div class="works-detail-page">
<a-card class="mb-4">
<template #title>
<a-breadcrumb>
<a-breadcrumb-item>
<router-link :to="`/${tenantCode}/contests/works`">作品管理</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item>{{ contestName || '参赛作品' }}</a-breadcrumb-item>
</a-breadcrumb>
<a-space>
<a-button type="text" style="padding: 0; margin-right: 4px" @click="router.back()">
<template #icon><arrow-left-outlined /></template>
</a-button>
<a-breadcrumb>
<a-breadcrumb-item>
<router-link :to="`/${tenantCode}/contests/works`">作品管理</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item>{{ contestName || '参赛作品' }}</a-breadcrumb-item>
</a-breadcrumb>
</a-space>
</template>
<template #extra>
<a-button
@ -20,6 +25,19 @@
</template>
</a-card>
<!-- 统计概览 -->
<div class="stats-row">
<div v-for="item in detailStatsItems" :key="item.key" class="stat-card">
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
<div class="stat-info">
<span class="stat-count">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
</div>
<!-- 搜索表单 -->
<a-form
:model="searchParams"
@ -59,6 +77,7 @@
placeholder="请选择"
allow-clear
style="width: 120px"
@change="handleSearch"
>
<a-select-option value="assigned">已分配</a-select-option>
<a-select-option value="unassigned">未分配</a-select-option>
@ -68,9 +87,10 @@
<a-range-picker
v-model:value="searchParams.submitTimeRange"
style="width: 240px"
@change="handleSearch"
/>
</a-form-item>
<a-form-item label="机构">
<a-form-item v-if="isSuperAdmin" label="机构">
<a-select
v-model:value="searchParams.tenantId"
placeholder="请选择机构"
@ -78,6 +98,7 @@
style="width: 150px"
show-search
:filter-option="filterOption"
@change="handleSearch"
>
<a-select-option
v-for="tenant in tenants"
@ -315,7 +336,12 @@ import {
ArrowLeftOutlined,
SearchOutlined,
ReloadOutlined,
FileTextOutlined,
SendOutlined,
EyeOutlined,
CheckCircleOutlined,
} from "@ant-design/icons-vue"
import { useAuthStore } from "@/stores/auth"
import dayjs from "dayjs"
import {
contestsApi,
@ -337,6 +363,20 @@ const router = useRouter()
const tenantCode = route.params.tenantCode as string
const contestId = Number(route.params.id)
const contestType = (route.query.type as string) || "individual"
const authStore = useAuthStore()
const isSuperAdmin = computed(() => authStore.hasAnyRole(['super_admin']))
// #5
const detailStats = ref({ total: 0, submitted: 0, reviewing: 0, reviewed: 0 })
const detailStatsItems = computed(() => [
{ key: 'total', label: '总作品', value: detailStats.value.total, icon: FileTextOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
{ key: 'submitted', label: '已提交', value: detailStats.value.submitted, icon: SendOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
{ key: 'reviewing', label: '评审中', value: detailStats.value.reviewing, icon: EyeOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
{ key: 'reviewed', label: '已评完', value: detailStats.value.reviewed, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
])
const fetchDetailStats = async () => {
try { detailStats.value = await worksApi.getStats(contestId) } catch { /* */ }
}
//
const contestName = ref("")
@ -501,6 +541,11 @@ const fetchList = async () => {
contestId,
workNo: searchParams.workNo || undefined,
username: searchParams.username || undefined,
name: searchParams.name || undefined,
assignStatus: searchParams.assignStatus || undefined,
tenantId: searchParams.tenantId || undefined,
submitStartTime: searchParams.submitTimeRange?.[0]?.format('YYYY-MM-DD') || undefined,
submitEndTime: searchParams.submitTimeRange?.[1]?.format('YYYY-MM-DD') || undefined,
})
dataSource.value = response.list
pagination.total = response.total
@ -653,15 +698,25 @@ const handleConfirmAssign = async () => {
onMounted(() => {
fetchContestInfo()
fetchTenants()
fetchDetailStats()
if (isSuperAdmin.value) fetchTenants()
fetchList()
})
</script>
<style scoped lang="scss">
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
$primary: #6366f1;
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; }
.stat-card {
flex: 1; display: flex; align-items: center; gap: 12px; padding: 14px 16px;
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
.stat-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
.stat-info { display: flex; flex-direction: column;
.stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
.stat-label { font-size: 12px; color: #9ca3af; }
}
}
.works-detail-page {
:deep(.ant-card) {
@ -686,23 +741,6 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
}
}
:deep(.ant-btn-primary) {
background: $gradient-primary;
border: none;
box-shadow: 0 4px 12px rgba($primary, 0.35);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 8%) 100%);
box-shadow: 0 6px 16px rgba($primary, 0.45);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
:deep(.ant-table-wrapper) {
background: #fff;
border-radius: 12px;

View File

@ -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 */ }
}

View File

@ -12,6 +12,30 @@
</a-input>
</div>
<!-- 推荐作品 -->
<div class="recommend-section" v-if="recommendedWorks.length > 0">
<div class="section-header">
<span class="section-title"><fire-outlined /> 编辑推荐</span>
</div>
<div class="recommend-scroll">
<div
v-for="rw in recommendedWorks"
:key="rw.id"
class="recommend-card"
@click="$router.push(`/p/works/${rw.id}`)"
>
<div class="recommend-cover">
<img v-if="rw.coverUrl" :src="rw.coverUrl" :alt="rw.title" />
<div v-else class="recommend-cover-empty"><picture-outlined /></div>
</div>
<div class="recommend-info">
<span class="recommend-title">{{ rw.title }}</span>
<span class="recommend-author">{{ rw.creator?.nickname }}</span>
</div>
</div>
</div>
</div>
<!-- 热门标签 -->
<div class="tags-scroll" v-if="hotTags.length > 0">
<span
@ -67,7 +91,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,12 +113,16 @@
</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, FireOutlined } 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 recommendedWorks = ref<UserWork[]>([])
const loading = ref(false)
const keyword = ref('')
const selectedTagId = ref<number | null>(null)
@ -95,15 +130,21 @@ 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 () => {
try { hotTags.value = await publicTagsApi.hot() } catch { /* */ }
}
const fetchRecommended = async () => {
try { recommendedWorks.value = await publicGalleryApi.recommended() } catch { /* */ }
}
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 +160,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
@ -132,6 +201,7 @@ const changeSort = (s: string) => { sortBy.value = s; fetchWorks(true) }
const loadMore = () => { page.value++; fetchWorks() }
onMounted(() => {
fetchRecommended()
fetchTags()
fetchWorks()
})
@ -155,6 +225,61 @@ $primary: #6366f1;
}
}
//
.recommend-section {
margin-bottom: 16px;
.section-header {
margin-bottom: 10px;
.section-title {
font-size: 15px;
font-weight: 700;
color: #1e1b4b;
display: flex;
align-items: center;
gap: 6px;
:deep(.anticon) { color: #f59e0b; }
}
}
}
.recommend-scroll {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 8px;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar { display: none; }
}
.recommend-card {
flex-shrink: 0;
width: 120px;
background: #fff;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
border: 1px solid rgba($primary, 0.06);
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); transform: translateY(-2px); }
.recommend-cover {
width: 120px; height: 160px; background: #f5f3ff;
img { width: 100%; height: 100%; object-fit: cover; }
.recommend-cover-empty { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 24px; color: #d1d5db; }
}
.recommend-info {
padding: 8px 10px;
display: flex; flex-direction: column; gap: 2px;
.recommend-title { font-size: 12px; font-weight: 600; color: #1e1b4b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recommend-author { font-size: 10px; color: #9ca3af; }
}
}
.tags-scroll {
display: flex;
gap: 8px;
@ -245,6 +370,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 +388,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>

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" />
</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 */ }
}

View File

@ -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>

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">
<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>

View File

@ -0,0 +1,171 @@
<template>
<div class="tenant-info-page">
<a-card class="title-card">
<template #title>机构信息</template>
</a-card>
<a-spin :spinning="loading">
<div v-if="tenant" class="info-content">
<!-- 基本信息卡片 -->
<a-card title="基本信息" :bordered="false" class="section-card">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="机构名称" :span="2">
<div class="editable-field">
<span v-if="!editing">{{ tenant.name }}</span>
<a-input v-else v-model:value="editForm.name" style="max-width: 300px" />
</div>
</a-descriptions-item>
<a-descriptions-item label="机构编码">
<a-tag color="blue">{{ tenant.code }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="机构类型">
<a-tag :color="tenantTypeColor(tenant.tenantType)">{{ tenantTypeLabel(tenant.tenantType) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="登录地址" :span="2">
<span class="url-text">/{{ tenant.code }}/login</span>
<a-button type="link" size="small" @click="copyLoginUrl">
<copy-outlined /> 复制
</a-button>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge :status="tenant.validState === 1 ? 'success' : 'error'" :text="tenant.validState === 1 ? '正常' : '停用'" />
</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatDate(tenant.createTime) }}</a-descriptions-item>
<a-descriptions-item label="机构描述" :span="2">
<div class="editable-field">
<span v-if="!editing">{{ tenant.description || '暂无描述' }}</span>
<a-textarea v-else v-model:value="editForm.description" :rows="3" style="max-width: 400px" placeholder="机构描述" />
</div>
</a-descriptions-item>
</a-descriptions>
<div class="edit-actions" style="margin-top: 16px">
<template v-if="!editing">
<a-button type="primary" @click="startEdit">
<template #icon><edit-outlined /></template>
编辑信息
</a-button>
</template>
<template v-else>
<a-space>
<a-button type="primary" :loading="saving" @click="handleSave">保存</a-button>
<a-button @click="cancelEdit">取消</a-button>
</a-space>
</template>
</div>
</a-card>
<!-- 统计信息 -->
<a-card title="数据概况" :bordered="false" class="section-card" style="margin-top: 16px">
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value">{{ tenant._count?.users || 0 }}</span>
<span class="stat-label">用户数</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ tenant._count?.roles || 0 }}</span>
<span class="stat-label">角色数</span>
</div>
</div>
</a-card>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { EditOutlined, CopyOutlined } from '@ant-design/icons-vue'
import request from '@/utils/request'
import dayjs from 'dayjs'
const loading = ref(true)
const saving = ref(false)
const editing = ref(false)
const tenant = ref<any>(null)
const editForm = reactive({ name: '', description: '' })
const tenantTypeLabel = (type: string) => {
const map: Record<string, string> = { library: '图书馆', kindergarten: '幼儿园', school: '学校', institution: '社会机构', other: '其他' }
return map[type] || type
}
const tenantTypeColor = (type: string) => {
const map: Record<string, string> = { library: 'purple', kindergarten: 'green', school: 'blue', institution: 'orange', other: 'default' }
return map[type] || 'default'
}
const formatDate = (d: string) => d ? dayjs(d).format('YYYY-MM-DD') : '-'
const copyLoginUrl = () => {
const url = `${window.location.origin}/${tenant.value.code}/login`
navigator.clipboard.writeText(url).then(() => message.success('已复制')).catch(() => message.info(url))
}
const fetchTenant = async () => {
loading.value = true
try {
tenant.value = await request.get('/tenants/my-tenant')
} catch {
message.error('获取机构信息失败')
} finally {
loading.value = false
}
}
const startEdit = () => {
editForm.name = tenant.value.name
editForm.description = tenant.value.description || ''
editing.value = true
}
const cancelEdit = () => { editing.value = false }
const handleSave = async () => {
if (!editForm.name.trim()) { message.warning('机构名称不能为空'); return }
saving.value = true
try {
await request.patch('/tenants/my-tenant', {
name: editForm.name,
description: editForm.description || undefined,
})
message.success('保存成功')
editing.value = false
fetchTenant()
} catch {
message.error('保存失败')
} finally {
saving.value = false
}
}
onMounted(fetchTenant)
</script>
<style scoped lang="scss">
$primary: #6366f1;
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
:deep(.ant-card-body) { padding: 0; }
}
.section-card {
border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
:deep(.ant-card-head) { .ant-card-head-title { font-size: 15px; font-weight: 600; } }
}
.url-text { font-family: monospace; font-size: 13px; color: #6b7280; }
.editable-field { min-height: 22px; }
.stats-grid {
display: flex; gap: 32px;
.stat-item {
display: flex; flex-direction: column; align-items: center;
.stat-value { font-size: 28px; font-weight: 700; color: #1e1b4b; }
.stat-label { font-size: 13px; color: #9ca3af; }
}
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<div class="tenants-page">
<a-card class="mb-4">
<a-card class="title-card">
<template #title>机构管理</template>
<template #extra>
<a-button v-permission="'tenant:create'" type="primary" @click="handleAdd">
@ -11,31 +11,37 @@
</a-card>
<!-- 搜索 -->
<a-form layout="inline" class="search-form" @finish="handleSearch">
<a-form-item label="机构名称">
<a-input v-model:value="searchKeyword" placeholder="搜索机构名称或编码" allow-clear style="width: 200px"
@press-enter="handleSearch" />
</a-form-item>
<a-form-item label="机构类型">
<a-select v-model:value="searchType" placeholder="全部类型" allow-clear style="width: 130px">
<a-select-option value="library">图书馆</a-select-option>
<a-select-option value="kindergarten">幼儿园</a-select-option>
<a-select-option value="school">学校</a-select-option>
<a-select-option value="institution">社会机构</a-select-option>
<a-select-option value="other">其他</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">搜索</a-button>
<a-button @click="handleResetSearch">重置</a-button>
</a-space>
</a-form-item>
</a-form>
<div class="filter-bar">
<a-form layout="inline" @finish="handleSearch">
<a-form-item label="机构名称">
<a-input v-model:value="searchKeyword" placeholder="搜索机构名称或编码" allow-clear style="width: 200px" />
</a-form-item>
<a-form-item label="机构类型">
<a-select v-model:value="searchType" style="width: 130px" @change="handleSearch">
<a-select-option value="">全部类型</a-select-option>
<a-select-option value="library">图书馆</a-select-option>
<a-select-option value="kindergarten">幼儿园</a-select-option>
<a-select-option value="school">学校</a-select-option>
<a-select-option value="institution">社会机构</a-select-option>
<a-select-option value="other">其他</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">
<template #icon><search-outlined /></template>搜索
</a-button>
<a-button @click="handleResetSearch">
<template #icon><reload-outlined /></template>重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</div>
<!-- 机构列表 -->
<a-table :columns="columns" :data-source="filteredData" :loading="loading" :pagination="pagination"
row-key="id" @change="handleTableChange">
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination"
row-key="id" @change="handleTableChange" class="data-table">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'nameInfo'">
<div class="org-cell">
@ -50,16 +56,22 @@
{{ tenantTypeLabel(record.tenantType) }}
</a-tag>
</template>
<template v-else-if="column.key === 'loginUrl'">
<div class="login-url-cell">
<span class="url-text">/{{ record.code }}/login</span>
<a-button type="link" size="small" class="copy-btn" @click.stop="copyLoginUrl(record.code)">
<copy-outlined />
</a-button>
</div>
</template>
<template v-else-if="column.key === 'statistics'">
<a-space :size="12">
<span class="stat-item">
<user-outlined />
{{ record._count?.users || 0 }}
</span>
<span class="stat-item">
<safety-outlined />
{{ record._count?.roles || 0 }}
</span>
<a-tooltip title="用户数">
<span class="stat-item"><user-outlined /> {{ record._count?.users || 0 }}</span>
</a-tooltip>
<a-tooltip title="角色数">
<span class="stat-item"><safety-outlined /> {{ record._count?.roles || 0 }}</span>
</a-tooltip>
</a-space>
</template>
<template v-else-if="column.key === 'validState'">
@ -75,7 +87,12 @@
@click="handleEdit(record)">
编辑
</a-button>
<a-button v-permission="'tenant:delete'" v-if="record.isSuper !== 1 && record.code !== 'public'"
<a-button v-permission="'tenant:update'" type="link" size="small"
:style="{ color: record.validState === 1 ? '#f59e0b' : '#10b981' }"
@click="handleToggleStatus(record)">
{{ record.validState === 1 ? '停用' : '启用' }}
</a-button>
<a-button v-permission="'tenant:delete'" v-if="record.isSuper !== 1"
type="link" size="small" danger @click="handleDelete(record)">
删除
</a-button>
@ -155,14 +172,27 @@
</a-tab-pane>
</a-tabs>
</a-modal>
<!-- 新建成功引导弹窗 -->
<a-modal v-model:open="guideVisible" title="机构创建成功" :footer="null" width="460px">
<a-result status="success" :title="`「${lastCreatedName}」创建成功`" sub-title="接下来你可以">
<template #extra>
<div style="display: flex; flex-direction: column; gap: 12px; align-items: center">
<a-button type="primary" @click="goCreateAdmin">为该机构创建管理员账号</a-button>
<a-button @click="guideVisible = false">稍后再说</a-button>
</div>
</template>
</a-result>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import type { TableColumnsType, FormInstance } from 'ant-design-vue'
import { PlusOutlined, UserOutlined, SafetyOutlined } from '@ant-design/icons-vue'
import { PlusOutlined, UserOutlined, SafetyOutlined, CopyOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import {
tenantsApi,
type Tenant,
@ -170,11 +200,15 @@ import {
type UpdateTenantForm,
} from '@/api/tenants'
import { menusApi, type Menu } from '@/api/menus'
import { useListRequest } from '@/composables/useListRequest'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const dataSource = ref<Tenant[]>([])
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `${t}` })
const submitLoading = ref(false)
const detailLoading = ref(false)
const menusLoading = ref(false)
@ -184,44 +218,19 @@ const formRef = ref<FormInstance>()
const editingId = ref<number | null>(null)
const activeTab = ref('basic')
//
// #2 #6
const searchKeyword = ref('')
const searchType = ref<string | undefined>(undefined)
const searchType = ref('')
// #6
const guideVisible = ref(false)
const lastCreatedName = ref('')
const lastCreatedId = ref<number | null>(null)
//
const allMenus = ref<Menu[]>([])
const topLevelMenus = computed(() => buildMenuTree(allMenus.value))
const {
loading,
dataSource,
pagination,
handleTableChange,
refresh: refreshList,
} = useListRequest<Tenant>({
requestFn: tenantsApi.getList,
errorMessage: '获取机构列表失败',
})
// +
const filteredData = computed(() => {
let result = dataSource.value
if (searchKeyword.value) {
const kw = searchKeyword.value.toLowerCase()
result = result.filter(
(t) =>
t.name.toLowerCase().includes(kw) ||
t.code.toLowerCase().includes(kw),
)
}
if (searchType.value) {
result = result.filter((t: any) => t.tenantType === searchType.value)
}
// 使
result = result.filter((t) => t.code !== 'public')
return result
})
const form = reactive<CreateTenantForm & { validState?: number; menuIds?: number[]; tenantType?: string }>({
name: '',
code: '',
@ -248,13 +257,15 @@ const rules = {
],
}
// #3 + #6
const columns: TableColumnsType = [
{ title: '机构信息', key: 'nameInfo', width: 260 },
{ title: '类型', key: 'tenantType', width: 100 },
{ title: '用户/角色', key: 'statistics', width: 120 },
{ title: '机构信息', key: 'nameInfo', width: 220 },
{ title: '类型', key: 'tenantType', width: 90 },
{ title: '登录地址', key: 'loginUrl', width: 160 },
{ title: '用户/角色', key: 'statistics', width: 110 },
{ title: '状态', key: 'validState', width: 80 },
{ title: '创建时间', key: 'createTime', width: 160 },
{ title: '操作', key: 'action', width: 140, fixed: 'right' },
{ title: '创建时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 180, fixed: 'right' },
]
const tenantTypeLabel = (type: string) => {
@ -275,18 +286,63 @@ const tenantTypeColor = (type: string) => {
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN')
return new Date(dateStr).toLocaleDateString('zh-CN')
}
const handleSearch = () => { /* 前端过滤computed 自动触发 */ }
const handleResetSearch = () => {
searchKeyword.value = ''
searchType.value = undefined
// #3
const copyLoginUrl = (code: string) => {
const url = `${window.location.origin}/${code}/login`
navigator.clipboard.writeText(url).then(() => {
message.success('登录地址已复制')
}).catch(() => {
message.info(`登录地址:${url}`)
})
}
// ========== ==========
// #2
const fetchList = async () => {
loading.value = true
try {
const res = await tenantsApi.getList({
page: pagination.current,
pageSize: pagination.pageSize,
keyword: searchKeyword.value || undefined,
tenantType: searchType.value || undefined,
} as any)
dataSource.value = res.list
pagination.total = res.total
} catch {
message.error('获取机构列表失败')
} finally {
loading.value = false
}
}
const handleSearch = () => { pagination.current = 1; fetchList() }
const handleResetSearch = () => { searchKeyword.value = ''; searchType.value = ''; pagination.current = 1; fetchList() }
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
// #4 /
const handleToggleStatus = (record: Tenant) => {
const action = record.validState === 1 ? '停用' : '启用'
Modal.confirm({
title: `确定${action}`,
content: record.validState === 1
? `停用后「${record.name}」的所有用户将无法登录`
: `启用后「${record.name}」的用户将恢复登录`,
okText: `确定${action}`,
okType: record.validState === 1 ? 'danger' : 'primary',
onOk: async () => {
try {
await tenantsApi.toggleStatus(record.id)
message.success(`${action}`)
fetchList()
} catch { message.error('操作失败') }
},
})
}
// ========== ==========
const fetchAllMenus = async () => {
menusLoading.value = true
@ -303,13 +359,11 @@ const getTenantMenuIds = (): Set<number> => {
const tenantMenuIds = new Set<number>()
const tenantMenu = findMenuInTree(allMenus.value, '租户管理')
if (tenantMenu) collectMenuIds(tenantMenu, tenantMenuIds)
// ""
const orgMenu = findMenuInTree(allMenus.value, '机构管理')
if (orgMenu) collectMenuIds(orgMenu, tenantMenuIds)
return tenantMenuIds
}
// ID
const preservedExcludedMenuIds = ref<number[]>([])
const fetchTenantMenus = async (tenantId: number) => {
@ -325,7 +379,6 @@ const fetchTenantMenus = async (tenantId: number) => {
}
const allMenuIds = extractMenuIds(tenantMenus)
const excludeIds = getTenantMenuIds()
//
preservedExcludedMenuIds.value = allMenuIds.filter((id) => excludeIds.has(id))
form.menuIds = allMenuIds.filter((id) => !excludeIds.has(id))
} catch {
@ -365,7 +418,6 @@ const collectMenuIds = (menu: Menu, ids: Set<number>) => {
const buildMenuTree = (menus: Menu[]): Menu[] => {
const flatMenus = flattenMenus(menus)
const excludeIds = getTenantMenuIds()
//
const userCenterMenu = findMenuInTree(menus, '用户中心')
if (userCenterMenu) collectMenuIds(userCenterMenu, excludeIds)
@ -477,12 +529,11 @@ const handleDelete = (record: Tenant) => {
content: `确定要删除机构「${record.name}」吗?此操作不可恢复。`,
okText: '确定删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await tenantsApi.delete(record.id)
message.success('删除成功')
refreshList()
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || '删除失败')
}
@ -497,7 +548,6 @@ const handleSubmit = async () => {
const excludeIds = getTenantMenuIds()
const visibleMenuIds = (form.menuIds || []).filter((id) => !excludeIds.has(id))
// ID
const menuIds = editingId.value
? [...visibleMenuIds, ...preservedExcludedMenuIds.value]
: visibleMenuIds
@ -511,19 +561,23 @@ const handleSubmit = async () => {
menuIds,
} as UpdateTenantForm)
message.success('保存成功')
modalVisible.value = false
} else {
await tenantsApi.create({
const created = await tenantsApi.create({
name: form.name, code: form.code,
domain: form.domain || undefined,
description: form.description || undefined,
tenantType: form.tenantType,
menuIds,
} as CreateTenantForm)
message.success('添加成功')
modalVisible.value = false
// #6
lastCreatedName.value = form.name
lastCreatedId.value = created.id
guideVisible.value = true
}
modalVisible.value = false
refreshList()
fetchList()
} catch (error: any) {
if (error?.errorFields) return
message.error(error?.response?.data?.message || '操作失败')
@ -532,6 +586,14 @@ const handleSubmit = async () => {
}
}
// #6
const goCreateAdmin = () => {
guideVisible.value = false
//
const tenantCode = authStore.tenantCode || 'super'
router.push(`/${tenantCode}/system/users`)
}
const handleCancel = () => {
modalVisible.value = false
formRef.value?.resetFields()
@ -539,67 +601,53 @@ const handleCancel = () => {
form.menuIds = []
}
onMounted(() => { fetchAllMenus() })
onMounted(() => { fetchList(); fetchAllMenus() })
</script>
<style scoped lang="scss">
$primary: #6366f1;
.search-form {
margin-bottom: 16px;
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
:deep(.ant-card-body) { padding: 0; }
}
.org-cell {
.org-name {
font-weight: 600;
color: #1e1b4b;
font-size: 14px;
margin-bottom: 4px;
}
.filter-bar { padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px; }
.org-code {
:deep(.ant-tag) {
font-size: 11px;
}
.data-table { :deep(.ant-table-wrapper) { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
.ant-table-pagination { padding: 16px; margin: 0; }
} }
.org-cell {
.org-name { font-weight: 600; color: #1e1b4b; font-size: 14px; margin-bottom: 4px; }
.org-code { :deep(.ant-tag) { font-size: 11px; } }
}
// #3
.login-url-cell {
display: flex; align-items: center; gap: 4px;
.url-text { font-size: 12px; color: #6b7280; font-family: monospace; }
.copy-btn { padding: 0 4px; font-size: 12px; color: #9ca3af;
&:hover { color: $primary; }
}
}
.stat-item {
font-size: 13px;
color: #6b7280;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px; color: #6b7280;
display: inline-flex; align-items: center; gap: 4px;
}
.form-hint {
font-size: 12px;
color: #9ca3af;
}
.form-hint { font-size: 12px; color: #9ca3af; }
//
.menu-config {
.menu-config-hint {
font-size: 13px;
color: #6b7280;
margin-bottom: 16px;
}
.menu-config-hint { font-size: 13px; color: #6b7280; margin-bottom: 16px; }
}
.menu-group {
margin-bottom: 16px;
padding: 16px;
background: #faf9fe;
border-radius: 12px;
.menu-group-header {
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #f0ecf9;
}
.menu-items {
padding-left: 24px;
}
margin-bottom: 16px; padding: 16px; background: #faf9fe; border-radius: 12px;
.menu-group-header { margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
.menu-items { padding-left: 24px; }
}
</style>

View File

@ -194,19 +194,29 @@
<a-descriptions-item v-if="detailData.organization" label="单位">{{ detailData.organization }}</a-descriptions-item>
</a-descriptions>
<!-- 公众用户子女信息 -->
<!-- 公众用户子女账号 -->
<template v-if="getUserTypeKey(detailData) === 'public'">
<div class="detail-section">
<h4>子女信息{{ detailData.children?.length || 0 }}</h4>
<a-empty v-if="!detailData.children?.length" description="暂无子女" :image="simpleImage" />
<h4>子女账号{{ detailData.parentRelations?.length || 0 }}</h4>
<a-empty v-if="!detailData.parentRelations?.length" description="暂无子女账号" :image="simpleImage" />
<div v-else class="children-list">
<div v-for="child in detailData.children" :key="child.id" class="child-item">
<span class="child-name">{{ child.name }}</span>
<div v-for="rel in detailData.parentRelations" :key="rel.id" class="child-item">
<div class="child-info">
<a-avatar :size="28" :src="rel.child.avatar" class="child-avatar">
{{ rel.child.nickname?.charAt(0) }}
</a-avatar>
<div class="child-detail">
<span class="child-name">{{ rel.child.nickname }}</span>
<span class="child-username">@{{ rel.child.username }}</span>
</div>
</div>
<a-space>
<a-tag v-if="child.gender">{{ child.gender === 'male' ? '' : '' }}</a-tag>
<a-tag v-if="child.grade">{{ child.grade }}</a-tag>
<a-tag v-if="child.city">{{ child.city }}</a-tag>
<a-tag v-if="child.schoolName" color="blue">{{ child.schoolName }}</a-tag>
<a-tag v-if="rel.child.gender">{{ rel.child.gender === 'male' ? '' : '' }}</a-tag>
<a-tag v-if="rel.child.city">{{ rel.child.city }}</a-tag>
<a-tag v-if="rel.relationship" color="blue">{{ relationshipLabel(rel.relationship) }}</a-tag>
<a-tag :color="rel.child.status === 'enabled' ? 'green' : 'red'">
{{ rel.child.status === 'enabled' ? '正常' : '禁用' }}
</a-tag>
</a-space>
</div>
</div>
@ -284,6 +294,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h } from 'vue'
import { message, Modal, Empty } from 'ant-design-vue'
import { useAuthStore } from '@/stores/auth'
import type { FormInstance } from 'ant-design-vue'
import {
SearchOutlined,
@ -307,11 +318,13 @@ const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
// ========== ==========
const stats = ref<UserStats>({ total: 0, platform: 0, org: 0, judge: 0, public: 0 })
const authStore = useAuthStore()
const isSuperAdmin = computed(() => authStore.hasAnyRole(['super_admin']))
const activeType = ref<string>('')
const statsItems = computed(() => [
{ type: '', label: '全部', count: stats.value.total, icon: TeamOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
{ type: 'platform', label: '平台', count: stats.value.platform, icon: CrownOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
{ type: 'platform', label: '运营团队', count: stats.value.platform, icon: CrownOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
{ type: 'org', label: '机构', count: stats.value.org, icon: BankOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
{ type: 'judge', label: '评委', count: stats.value.judge, icon: AuditOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
{ type: 'public', label: '公众', count: stats.value.public, icon: UserOutlined, color: '#8b5cf6', bgColor: 'rgba(139,92,246,0.1)' },
@ -433,7 +446,7 @@ function getUserTypeKey(user: User): string {
function getUserTypeTag(user: User) {
const map: Record<string, { label: string; color: string }> = {
platform: { label: '平台', color: 'blue' },
platform: { label: '运营团队', color: 'blue' },
org: { label: '机构', color: 'green' },
judge: { label: '评委', color: 'orange' },
public: { label: '公众', color: 'purple' },
@ -531,6 +544,8 @@ const handlePasswordSubmit = async () => {
// ========== ==========
const formatDate = (d?: string) => (d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-')
const genderLabel = (g?: string) => (g === 'male' ? '男' : g === 'female' ? '女' : '-')
const relationshipLabel = (r?: string) =>
({ father: '父亲', mother: '母亲', guardian: '监护人' }[r || ''] || r || '-')
const regStateLabel = (s: string) =>
({ pending: '待审核', passed: '已通过', rejected: '已拒绝', withdrawn: '已撤回' }[s] || s)
const regStateColor = (s: string) =>
@ -540,7 +555,7 @@ const regStateColor = (s: string) =>
onMounted(() => {
fetchStats()
fetchList()
fetchTenants()
if (isSuperAdmin.value) fetchTenants()
})
</script>
@ -721,14 +736,38 @@ $primary: #6366f1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
padding: 10px 12px;
background: #faf9fe;
border-radius: 8px;
margin-bottom: 8px;
.child-name {
font-weight: 600;
color: #1e1b4b;
.child-info {
display: flex;
align-items: center;
gap: 8px;
.child-avatar {
background: linear-gradient(135deg, #8b5cf6, #ec4899);
font-weight: 700;
font-size: 12px;
flex-shrink: 0;
}
.child-detail {
display: flex;
flex-direction: column;
.child-name {
font-weight: 600;
color: #1e1b4b;
font-size: 13px;
}
.child-username {
font-size: 11px;
color: #9ca3af;
}
}
}
}
}

View File

@ -0,0 +1,333 @@
<template>
<div class="tenant-dashboard">
<!-- #1 欢迎信息 + 机构标识 -->
<div class="welcome-banner">
<div class="welcome-left">
<h1>{{ greetingText }}{{ authStore.user?.nickname || '管理员' }}</h1>
<p v-if="dashboard.tenant">
<bank-outlined /> {{ dashboard.tenant.name }}
<a-tag :color="tenantTypeColor(dashboard.tenant.tenantType)" style="margin-left: 8px">{{ tenantTypeLabel(dashboard.tenant.tenantType) }}</a-tag>
</p>
</div>
<div class="welcome-right">
<span class="date-text">{{ todayText }}</span>
</div>
</div>
<!-- #6 待办提醒 -->
<div v-if="dashboard.todos?.length > 0" class="todo-section">
<div v-for="(todo, idx) in dashboard.todos" :key="idx" :class="['todo-item', todo.type]" @click="todo.link && goTo(todo.link)">
<alert-outlined v-if="todo.type === 'warning'" />
<info-circle-outlined v-else />
<span>{{ todo.message }}</span>
<right-outlined v-if="todo.link" class="todo-arrow" />
</div>
</div>
<!-- #2 空数据引导 -->
<div v-if="!loading && isEmpty" class="empty-guide">
<a-result title="欢迎使用活动管理平台" sub-title="开始配置你的第一个活动吧">
<template #icon>
<trophy-outlined style="font-size: 48px; color: #6366f1" />
</template>
<template #extra>
<div class="guide-steps">
<div class="guide-step" @click="goTo('/contests/list')">
<div class="step-num">1</div>
<div class="step-content">
<strong>创建活动</strong>
<span>配置活动信息报名规则和提交要求</span>
</div>
<right-outlined />
</div>
<div class="guide-step" @click="goTo('/system/users')">
<div class="step-num">2</div>
<div class="step-content">
<strong>添加团队成员</strong>
<span>创建管理员和工作人员账号</span>
</div>
<right-outlined />
</div>
<div class="guide-step" @click="goTo('/contests/judges')">
<div class="step-num">3</div>
<div class="step-content">
<strong>邀请评委</strong>
<span>添加评委并分配评审任务</span>
</div>
<right-outlined />
</div>
</div>
</template>
</a-result>
</div>
<!-- 有数据时的正常视图 -->
<template v-if="!loading && !isEmpty">
<!-- #5 统计卡片可点击 -->
<div class="stats-row">
<div
v-for="item in statsItems"
:key="item.key"
class="stat-card"
:class="{ clickable: !!item.link }"
@click="item.link && goTo(item.link)"
>
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
<div class="stat-info">
<span class="stat-count">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
</div>
<!-- #3 快捷操作按权限动态显示 -->
<a-card title="快捷操作" :bordered="false" class="section-card">
<div class="action-grid">
<div v-for="act in visibleActions" :key="act.label" class="action-item" @click="goTo(act.path)">
<div class="action-icon" :style="{ background: act.bgColor }">
<component :is="act.icon" :style="{ color: act.color }" />
</div>
<span>{{ act.label }}</span>
</div>
</div>
</a-card>
<!-- #4 最近活动 + 查看全部 -->
<a-card :bordered="false" class="section-card" style="margin-top: 16px">
<template #title>最近活动</template>
<template #extra>
<a-button type="link" size="small" @click="goTo('/contests/list')">查看全部 <right-outlined /></a-button>
</template>
<div v-if="dashboard.recentContests?.length === 0" style="text-align: center; padding: 30px; color: #9ca3af">
暂无活动数据
</div>
<div v-else class="contest-list">
<div v-for="contest in dashboard.recentContests" :key="contest.id" class="contest-item" @click="goTo(`/contests/${contest.id}`)">
<div class="contest-info">
<span class="contest-name">{{ contest.contestName }}</span>
<span class="contest-time">{{ formatDateRange(contest.startTime, contest.endTime) }}</span>
</div>
<div class="contest-stats">
<a-tag>{{ contest._count?.registrations || 0 }} 报名</a-tag>
<a-tag>{{ contest._count?.works || 0 }} 作品</a-tag>
<a-badge :status="contest.status === 'ongoing' ? 'processing' : 'default'" :text="contest.status === 'ongoing' ? '进行中' : '已结束'" />
</div>
</div>
</div>
</a-card>
</template>
<!-- loading -->
<div v-if="loading" style="text-align: center; padding: 80px"><a-spin size="large" /></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
TrophyOutlined, UserAddOutlined, FileTextOutlined,
SolutionOutlined, TeamOutlined, BankOutlined,
FundViewOutlined, FormOutlined, AuditOutlined, ClockCircleOutlined,
RightOutlined, AlertOutlined, InfoCircleOutlined,
} from '@ant-design/icons-vue'
import request from '@/utils/request'
import { useAuthStore } from '@/stores/auth'
import dayjs from 'dayjs'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(true)
const dashboard = ref<any>({})
// #1
const greetingText = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 12) return '上午好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
})
const todayText = computed(() => dayjs().format('YYYY年MM月DD日 dddd'))
const tenantTypeLabel = (type: string) => {
const map: Record<string, string> = { library: '图书馆', kindergarten: '幼儿园', school: '学校', institution: '社会机构', other: '其他' }
return map[type] || type
}
const tenantTypeColor = (type: string) => {
const map: Record<string, string> = { library: 'purple', kindergarten: 'green', school: 'blue', institution: 'orange', other: 'default' }
return map[type] || 'default'
}
// #2
const isEmpty = computed(() =>
dashboard.value.totalContests === 0 &&
dashboard.value.totalRegistrations === 0 &&
dashboard.value.totalWorks === 0
)
// #5
const statsItems = computed(() => [
{ key: 'contests', label: '可见活动', value: dashboard.value.totalContests || 0, icon: TrophyOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)', link: '/contests/list' },
{ key: 'ongoing', label: '进行中', value: dashboard.value.ongoingContests || 0, icon: ClockCircleOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)', link: '/contests/list' },
{ key: 'registrations', label: '总报名数', value: dashboard.value.totalRegistrations || 0, icon: FormOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)', link: '/contests/registrations' },
{ key: 'pending', label: '待审核报名', value: dashboard.value.pendingRegistrations || 0, icon: AuditOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)', link: '/contests/registrations' },
{ key: 'works', label: '总作品数', value: dashboard.value.totalWorks || 0, icon: FileTextOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)', link: '/contests/works' },
{ key: 'today', label: '今日报名', value: dashboard.value.todayRegistrations || 0, icon: FundViewOutlined, color: '#8b5cf6', bgColor: 'rgba(139,92,246,0.1)', link: '/contests/registrations' },
])
// #3
const allActions = [
{ label: '活动列表', path: '/contests/list', permission: 'contest:read', icon: TrophyOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
{ label: '报名管理', path: '/contests/registrations', permission: 'contest:registration:read', icon: UserAddOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
{ label: '作品管理', path: '/contests/works', permission: 'contest:work:read', icon: FileTextOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
{ label: '评委管理', path: '/contests/judges', permission: 'judge:read', icon: SolutionOutlined, color: '#ec4899', bgColor: 'rgba(236,72,153,0.1)' },
{ label: '用户管理', path: '/system/users', permission: 'user:read', icon: TeamOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
]
const visibleActions = computed(() =>
allActions.filter(a => authStore.hasPermission(a.permission))
)
const formatDateRange = (start: string, end: string) => {
if (!start || !end) return '-'
return `${dayjs(start).format('MM/DD')} - ${dayjs(end).format('MM/DD')}`
}
const goTo = (path: string) => {
const tenantCode = authStore.tenantCode
router.push(`/${tenantCode}${path}`)
}
const fetchDashboard = async () => {
loading.value = true
try {
dashboard.value = await request.get('/contests/dashboard')
} catch {
message.error('获取统计数据失败')
} finally {
loading.value = false
}
}
onMounted(fetchDashboard)
</script>
<style scoped lang="scss">
$primary: #6366f1;
// #1
.welcome-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 28px;
background: linear-gradient(135deg, #eef2ff 0%, #fdf2f8 100%);
border-radius: 16px;
margin-bottom: 16px;
.welcome-left {
h1 { font-size: 20px; font-weight: 700; color: #1e1b4b; margin: 0 0 6px; }
p { font-size: 13px; color: #6b7280; margin: 0; display: flex; align-items: center; gap: 6px; }
}
.welcome-right {
.date-text { font-size: 13px; color: #9ca3af; }
}
}
// #6
.todo-section {
display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px;
}
.todo-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px; border-radius: 10px; font-size: 13px; cursor: pointer; transition: all 0.2s;
&.warning { background: #fef3c7; color: #92400e; border: 1px solid #fde68a;
&:hover { background: #fde68a; }
}
&.info { background: #ede9fe; color: #5b21b6; border: 1px solid #ddd6fe;
&:hover { background: #ddd6fe; }
}
.todo-arrow { margin-left: auto; font-size: 11px; opacity: 0.5; }
}
// #2
.empty-guide {
background: #fff; border-radius: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 20px;
:deep(.ant-result) { padding: 24px 0; }
:deep(.ant-result-title) { font-size: 18px; font-weight: 700; color: #1e1b4b; }
:deep(.ant-result-subtitle) { color: #6b7280; }
}
.guide-steps {
display: flex; flex-direction: column; gap: 12px; max-width: 400px; margin: 0 auto; text-align: left;
}
.guide-step {
display: flex; align-items: center; gap: 14px;
padding: 14px 18px; background: #faf9fe; border-radius: 12px;
cursor: pointer; transition: all 0.2s;
&:hover { background: #eef2ff; transform: translateX(4px); }
.step-num {
width: 28px; height: 28px; border-radius: 50%; background: $primary; color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0;
}
.step-content {
flex: 1; display: flex; flex-direction: column;
strong { font-size: 14px; color: #1e1b4b; }
span { font-size: 12px; color: #9ca3af; }
}
:deep(.anticon) { color: #d1d5db; }
}
// #5
.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px;
@media (min-width: 1200px) { grid-template-columns: repeat(6, 1fr); }
}
.stat-card {
display: flex; align-items: center; gap: 12px; padding: 16px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); transition: all 0.2s;
&.clickable { cursor: pointer;
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); transform: translateY(-2px); }
}
.stat-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
.stat-info { display: flex; flex-direction: column;
.stat-count { font-size: 20px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
.stat-label { font-size: 12px; color: #9ca3af; }
}
}
//
.section-card {
border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
:deep(.ant-card-head) { .ant-card-head-title { font-size: 15px; font-weight: 600; } }
}
// #3
.action-grid {
display: flex; gap: 24px; flex-wrap: wrap;
.action-item {
display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; transition: all 0.2s;
&:hover { transform: translateY(-2px); }
.action-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; }
span { font-size: 12px; color: #374151; font-weight: 500; }
}
}
// #4
.contest-list { display: flex; flex-direction: column; gap: 8px; }
.contest-item {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 12px 16px; border-radius: 10px; cursor: pointer; transition: background 0.2s;
&:hover { background: rgba($primary, 0.03); }
.contest-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1;
.contest-name { font-size: 14px; font-weight: 600; color: #1e1b4b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.contest-time { font-size: 12px; color: #9ca3af; }
}
.contest-stats { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
}
</style>

View File

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

41
pnpm-lock.yaml generated
View File

@ -179,6 +179,9 @@ importers:
dayjs:
specifier: ^1.11.10
version: 1.11.19
echarts:
specifier: ^6.0.0
version: 6.0.0
pinia:
specifier: ^2.1.7
version: 2.3.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3))
@ -191,6 +194,9 @@ importers:
vue:
specifier: ^3.4.21
version: 3.5.24(typescript@5.9.3)
vue-echarts:
specifier: ^8.0.1
version: 8.0.1(echarts@6.0.0)(vue@3.5.24(typescript@5.9.3))
vue-router:
specifier: ^4.3.0
version: 4.6.3(vue@3.5.24(typescript@5.9.3))
@ -2248,6 +2254,9 @@ packages:
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
echarts@6.0.0:
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@ -2654,15 +2663,17 @@ packages:
glob@10.4.5:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globals@13.24.0:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
@ -4227,6 +4238,9 @@ packages:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -4394,6 +4408,12 @@ packages:
'@vue/composition-api':
optional: true
vue-echarts@8.0.1:
resolution: {integrity: sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==}
peerDependencies:
echarts: ^6.0.0
vue: ^3.3.0
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
@ -4530,6 +4550,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
snapshots:
'@alloc/quick-lru@5.2.0': {}
@ -6799,6 +6822,11 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
echarts@6.0.0:
dependencies:
tslib: 2.3.0
zrender: 6.0.0
ee-first@1.1.1: {}
effect@3.18.4:
@ -9037,6 +9065,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tslib@2.3.0: {}
tslib@2.8.1: {}
tunnel-agent@0.6.0:
@ -9144,6 +9174,11 @@ snapshots:
dependencies:
vue: 3.5.24(typescript@5.9.3)
vue-echarts@8.0.1(echarts@6.0.0)(vue@3.5.24(typescript@5.9.3)):
dependencies:
echarts: 6.0.0
vue: 3.5.24(typescript@5.9.3)
vue-eslint-parser@9.4.3(eslint@8.57.1):
dependencies:
debug: 4.4.3
@ -9301,3 +9336,7 @@ snapshots:
yocto-queue@0.1.0: {}
zod@3.25.76: {}
zrender@6.0.0:
dependencies:
tslib: 2.3.0