Compare commits
5 Commits
418aa57ea8
...
9215465bd5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9215465bd5 | ||
|
|
83f007d20e | ||
|
|
f246b38fc1 | ||
|
|
66827c0199 | ||
|
|
4466e28b3b |
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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") /// 使用次数(冗余)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
36
backend/src/contests/analytics/analytics.controller.ts
Normal file
36
backend/src/contests/analytics/analytics.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
11
backend/src/contests/analytics/analytics.module.ts
Normal file
11
backend/src/contests/analytics/analytics.module.ts
Normal 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 {}
|
||||
296
backend/src/contests/analytics/analytics.service.ts
Normal file
296
backend/src/contests/analytics/analytics.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,5 +22,13 @@ export class QueryNoticeDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
publishDate?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
publishStartDate?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
publishEndDate?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
173
backend/src/public/interaction.service.ts
Normal file
173
backend/src/public/interaction.service.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class InteractionService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/** 点赞/取消点赞(toggle) */
|
||||
async toggleLike(userId: number, workId: number) {
|
||||
// 校验作品存在且已发布
|
||||
await this.ensureWorkExists(workId);
|
||||
|
||||
const existing = await this.prisma.userWorkLike.findUnique({
|
||||
where: { userId_workId: { userId, workId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.userWorkLike.delete({
|
||||
where: { id: existing.id },
|
||||
}),
|
||||
this.prisma.userWork.update({
|
||||
where: { id: workId },
|
||||
data: { likeCount: { decrement: 1 } },
|
||||
}),
|
||||
]);
|
||||
const work = await this.prisma.userWork.findUnique({
|
||||
where: { id: workId },
|
||||
select: { likeCount: true },
|
||||
});
|
||||
return { liked: false, likeCount: work.likeCount };
|
||||
} else {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.userWorkLike.create({
|
||||
data: { userId, workId },
|
||||
}),
|
||||
this.prisma.userWork.update({
|
||||
where: { id: workId },
|
||||
data: { likeCount: { increment: 1 } },
|
||||
}),
|
||||
]);
|
||||
const work = await this.prisma.userWork.findUnique({
|
||||
where: { id: workId },
|
||||
select: { likeCount: true },
|
||||
});
|
||||
return { liked: true, likeCount: work.likeCount };
|
||||
}
|
||||
}
|
||||
|
||||
/** 收藏/取消收藏(toggle) */
|
||||
async toggleFavorite(userId: number, workId: number) {
|
||||
await this.ensureWorkExists(workId);
|
||||
|
||||
const existing = await this.prisma.userWorkFavorite.findUnique({
|
||||
where: { userId_workId: { userId, workId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.userWorkFavorite.delete({
|
||||
where: { id: existing.id },
|
||||
}),
|
||||
this.prisma.userWork.update({
|
||||
where: { id: workId },
|
||||
data: { favoriteCount: { decrement: 1 } },
|
||||
}),
|
||||
]);
|
||||
const work = await this.prisma.userWork.findUnique({
|
||||
where: { id: workId },
|
||||
select: { favoriteCount: true },
|
||||
});
|
||||
return { favorited: false, favoriteCount: work.favoriteCount };
|
||||
} else {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.userWorkFavorite.create({
|
||||
data: { userId, workId },
|
||||
}),
|
||||
this.prisma.userWork.update({
|
||||
where: { id: workId },
|
||||
data: { favoriteCount: { increment: 1 } },
|
||||
}),
|
||||
]);
|
||||
const work = await this.prisma.userWork.findUnique({
|
||||
where: { id: workId },
|
||||
select: { favoriteCount: true },
|
||||
});
|
||||
return { favorited: true, favoriteCount: work.favoriteCount };
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询当前用户对某作品的交互状态 */
|
||||
async getInteractionStatus(userId: number, workId: number) {
|
||||
const [like, favorite] = await Promise.all([
|
||||
this.prisma.userWorkLike.findUnique({
|
||||
where: { userId_workId: { userId, workId } },
|
||||
}),
|
||||
this.prisma.userWorkFavorite.findUnique({
|
||||
where: { userId_workId: { userId, workId } },
|
||||
}),
|
||||
]);
|
||||
return { liked: !!like, favorited: !!favorite };
|
||||
}
|
||||
|
||||
/** 我的收藏列表 */
|
||||
async getMyFavorites(userId: number, query: { page?: number; pageSize?: number }) {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 12;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where = {
|
||||
userId,
|
||||
work: { status: 'published', isDeleted: 0 },
|
||||
};
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.userWorkFavorite.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createTime: 'desc' },
|
||||
include: {
|
||||
work: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
coverUrl: true,
|
||||
likeCount: true,
|
||||
viewCount: true,
|
||||
favoriteCount: true,
|
||||
creator: { select: { id: true, nickname: true, avatar: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.userWorkFavorite.count({ where }),
|
||||
]);
|
||||
|
||||
return { list, total, page, pageSize };
|
||||
}
|
||||
|
||||
/** 批量查询交互状态(用于列表页) */
|
||||
async batchGetInteractionStatus(userId: number, workIds: number[]) {
|
||||
if (!workIds.length) return {};
|
||||
|
||||
const [likes, favorites] = await Promise.all([
|
||||
this.prisma.userWorkLike.findMany({
|
||||
where: { userId, workId: { in: workIds } },
|
||||
select: { workId: true },
|
||||
}),
|
||||
this.prisma.userWorkFavorite.findMany({
|
||||
where: { userId, workId: { in: workIds } },
|
||||
select: { workId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const likedSet = new Set(likes.map(l => l.workId));
|
||||
const favoritedSet = new Set(favorites.map(f => f.workId));
|
||||
|
||||
const result: Record<number, { liked: boolean; favorited: boolean }> = {};
|
||||
for (const id of workIds) {
|
||||
result[id] = { liked: likedSet.has(id), favorited: favoritedSet.has(id) };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async ensureWorkExists(workId: number) {
|
||||
const work = await this.prisma.userWork.findFirst({
|
||||
where: { id: workId, status: 'published', isDeleted: 0 },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!work) throw new NotFoundException('作品不存在或未发布');
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ import { UserWorksService } from './user-works.service';
|
||||
import { CreationService } from './creation.service';
|
||||
import { TagsService } from './tags.service';
|
||||
import { GalleryService } from './gallery.service';
|
||||
import { InteractionService } from './interaction.service';
|
||||
import { PublicRegisterDto, PublicLoginDto } from './dto/register.dto';
|
||||
import { CreateChildDto, UpdateChildDto } from './dto/child.dto';
|
||||
import { PublicRegisterActivityDto } from './dto/registration.dto';
|
||||
@ -30,6 +31,7 @@ export class PublicController {
|
||||
private readonly creationService: CreationService,
|
||||
private readonly tagsService: TagsService,
|
||||
private readonly galleryService: GalleryService,
|
||||
private readonly interactionService: InteractionService,
|
||||
) {}
|
||||
|
||||
// ==================== 注册 & 登录(公开接口) ====================
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import { CreationService } from './creation.service';
|
||||
import { TagsService } from './tags.service';
|
||||
import { GalleryService } from './gallery.service';
|
||||
import { ContentReviewService } from './content-review.service';
|
||||
import { InteractionService } from './interaction.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
@ -24,7 +25,7 @@ import { PrismaModule } from '../prisma/prisma.module';
|
||||
}),
|
||||
],
|
||||
controllers: [PublicController, TagsController, ContentReviewController],
|
||||
providers: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService],
|
||||
exports: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService],
|
||||
providers: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService, InteractionService],
|
||||
exports: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService, InteractionService],
|
||||
})
|
||||
export class PublicModule {}
|
||||
|
||||
@ -629,6 +629,16 @@ export class PublicService {
|
||||
child: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
works: {
|
||||
where: { validState: 1 },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
previewUrl: true,
|
||||
createTime: true,
|
||||
},
|
||||
orderBy: { createTime: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
return registration;
|
||||
@ -902,6 +912,25 @@ export class PublicService {
|
||||
grade: true,
|
||||
},
|
||||
},
|
||||
works: {
|
||||
where: { validState: 1 },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
previewUrl: true,
|
||||
createTime: true,
|
||||
attachments: {
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileUrl: true,
|
||||
fileType: true,
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: { createTime: 'desc' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.contestRegistration.count({ where }),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 } });
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
? {
|
||||
|
||||
@ -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 |
|
||||
|
||||
## 评委端
|
||||
|
||||
|
||||
445
docs/design/org-admin/analytics-dashboard-mockup.html
Normal file
445
docs/design/org-admin/analytics-dashboard-mockup.html
Normal 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>
|
||||
238
docs/design/org-admin/data-analytics-dashboard.md
Normal file
238
docs/design/org-admin/data-analytics-dashboard.md
Normal 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 + 索引优化
|
||||
- 导出:前端生成 PDF(html2canvas + jsPDF)或 CSV
|
||||
180
docs/design/org-admin/tenant-portal-optimization.md
Normal file
180
docs/design/org-admin/tenant-portal-optimization.md
Normal 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:delete(gdlib 租户)
|
||||
- work_tags 表新增 color 字段
|
||||
- 前端依赖新增:echarts、vue-echarts
|
||||
122
docs/design/public/like-favorite.md
Normal file
122
docs/design/public/like-favorite.md
Normal 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 }),
|
||||
}
|
||||
```
|
||||
|
||||
## 交互细节
|
||||
|
||||
- 未登录用户点击点赞/收藏 → 跳转登录页
|
||||
- 乐观更新:点击后立即更新 UI,API 失败时回滚
|
||||
- 点赞按钮动效:心形图标缩放弹跳
|
||||
- 自己的作品也可以点赞/收藏(不做限制)
|
||||
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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),标签颜色)
|
||||
|
||||
42
docs/design/super-admin/org-management.md
Normal file
42
docs/design/super-admin/org-management.md
Normal 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 类型筛选
|
||||
```
|
||||
@ -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 错误
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
62
frontend/src/api/analytics.ts
Normal file
62
frontend/src/api/analytics.ts
Normal 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 }),
|
||||
}
|
||||
@ -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 }>;
|
||||
}
|
||||
|
||||
// 成果管理
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -7,6 +7,42 @@
|
||||
<img src="@/assets/images/logo-icon.png" alt="乐绘世界" class="header-logo" />
|
||||
<span class="header-title">乐绘世界</span>
|
||||
</div>
|
||||
<!-- 桌面端导航菜单 -->
|
||||
<nav class="header-nav">
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{ active: currentTab === 'home' }"
|
||||
@click="goHome"
|
||||
>
|
||||
<home-outlined />
|
||||
<span>发现</span>
|
||||
</div>
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{ active: currentTab === 'activity' }"
|
||||
@click="goActivity"
|
||||
>
|
||||
<trophy-outlined />
|
||||
<span>活动</span>
|
||||
</div>
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{ active: currentTab === 'create' }"
|
||||
@click="goCreate"
|
||||
>
|
||||
<plus-circle-outlined />
|
||||
<span>创作</span>
|
||||
</div>
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{ active: currentTab === 'works' }"
|
||||
@click="goWorks"
|
||||
>
|
||||
<appstore-outlined />
|
||||
<span>作品库</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<template v-if="isLoggedIn">
|
||||
<div class="user-menu" @click="goMine">
|
||||
@ -170,6 +206,45 @@ $primary: #6366f1;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 桌面端顶部导航菜单 ==========
|
||||
.header-nav {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $primary;
|
||||
background: rgba($primary, 0.06);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $primary;
|
||||
background: rgba($primary, 0.08);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -245,6 +320,10 @@ $primary: #6366f1;
|
||||
|
||||
// ========== 响应式 ==========
|
||||
@media (min-width: 768px) {
|
||||
.header-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.public-tabbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -67,10 +67,10 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
meta: { title: "我的报名" },
|
||||
},
|
||||
{
|
||||
path: "mine/works",
|
||||
name: "PublicMyWorks",
|
||||
component: () => import("@/views/public/mine/Works.vue"),
|
||||
meta: { title: "我的作品" },
|
||||
path: "mine/favorites",
|
||||
name: "PublicMyFavorites",
|
||||
component: () => import("@/views/public/mine/Favorites.vue"),
|
||||
meta: { title: "我的收藏" },
|
||||
},
|
||||
{
|
||||
path: "mine/children",
|
||||
@ -254,7 +254,7 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
title: "参赛作品详情",
|
||||
requiresAuth: true,
|
||||
permissions: ["work:read"],
|
||||
permissions: ["contest:work:read"],
|
||||
},
|
||||
},
|
||||
// 作业提交记录路由
|
||||
|
||||
@ -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"),
|
||||
|
||||
226
frontend/src/views/analytics/Overview.vue
Normal file
226
frontend/src/views/analytics/Overview.vue
Normal 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>
|
||||
218
frontend/src/views/analytics/Review.vue
Normal file
218
frontend/src/views/analytics/Review.vue
Normal 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>
|
||||
@ -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" },
|
||||
]
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
@ -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; }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 */ }
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
138
frontend/src/views/public/mine/Favorites.vue
Normal file
138
frontend/src/views/public/mine/Favorites.vue
Normal file
@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="favorites-page">
|
||||
<div class="page-header">
|
||||
<h2>我的收藏</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-wrap"><a-spin /></div>
|
||||
|
||||
<div v-else-if="list.length === 0" class="empty-wrap">
|
||||
<a-empty description="还没有收藏任何作品">
|
||||
<a-button type="primary" shape="round" @click="$router.push('/p/gallery')">
|
||||
去发现作品
|
||||
</a-button>
|
||||
</a-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="works-grid">
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="work-card"
|
||||
@click="$router.push(`/p/works/${item.work.id}`)"
|
||||
>
|
||||
<div class="card-cover">
|
||||
<img v-if="item.work.coverUrl" :src="item.work.coverUrl" :alt="item.work.title" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<picture-outlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>{{ item.work.title }}</h3>
|
||||
<div class="card-author">
|
||||
<a-avatar :size="20" :src="item.work.creator?.avatar">
|
||||
{{ item.work.creator?.nickname?.charAt(0) }}
|
||||
</a-avatar>
|
||||
<span>{{ item.work.creator?.nickname }}</span>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<span><heart-outlined /> {{ item.work.likeCount || 0 }}</span>
|
||||
<span><eye-outlined /> {{ item.work.viewCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > pageSize" class="pagination-wrap">
|
||||
<a-pagination
|
||||
v-model:current="page"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
simple
|
||||
@change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PictureOutlined, HeartOutlined, EyeOutlined } from '@ant-design/icons-vue'
|
||||
import { publicInteractionApi } from '@/api/public'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const pageSize = 12
|
||||
const total = ref(0)
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await publicInteractionApi.myFavorites({ page: page.value, pageSize })
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
message.error('获取收藏列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.favorites-page { max-width: 700px; margin: 0 auto; }
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 16px;
|
||||
h2 { font-size: 20px; font-weight: 700; color: #1e1b4b; margin: 0; }
|
||||
}
|
||||
|
||||
.loading-wrap, .empty-wrap { padding: 60px 0; display: flex; justify-content: center; }
|
||||
|
||||
.works-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
@media (min-width: 640px) { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.work-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid rgba($primary, 0.04);
|
||||
|
||||
&:hover { box-shadow: 0 4px 20px rgba($primary, 0.1); transform: translateY(-2px); }
|
||||
|
||||
.card-cover {
|
||||
aspect-ratio: 3/4;
|
||||
background: #f5f3ff;
|
||||
img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.cover-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 28px; color: #d1d5db; }
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 10px 12px;
|
||||
h3 { font-size: 13px; font-weight: 600; color: #1e1b4b; margin: 0 0 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.card-author {
|
||||
display: flex; align-items: center; gap: 6px; margin-bottom: 6px;
|
||||
span { font-size: 11px; color: #6b7280; }
|
||||
}
|
||||
.card-stats {
|
||||
display: flex; gap: 12px;
|
||||
span { font-size: 11px; color: #9ca3af; display: flex; align-items: center; gap: 3px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrap { display: flex; justify-content: center; padding: 24px 0; }
|
||||
</style>
|
||||
@ -33,13 +33,13 @@
|
||||
<right-outlined class="menu-arrow" />
|
||||
</div>
|
||||
|
||||
<div class="menu-item" @click="$router.push('/p/mine/works')">
|
||||
<div class="menu-icon" style="background: #fdf2f8; color: #ec4899">
|
||||
<picture-outlined />
|
||||
<div class="menu-item" @click="$router.push('/p/mine/favorites')">
|
||||
<div class="menu-icon" style="background: #fef3c7; color: #f59e0b">
|
||||
<star-outlined />
|
||||
</div>
|
||||
<div class="menu-content">
|
||||
<span class="menu-label">我的作品</span>
|
||||
<span class="menu-desc">{{ workCount > 0 ? `${workCount} 个作品` : '管理提交的作品' }}</span>
|
||||
<span class="menu-label">我的收藏</span>
|
||||
<span class="menu-desc">收藏的绘本作品</span>
|
||||
</div>
|
||||
<right-outlined class="menu-arrow" />
|
||||
</div>
|
||||
@ -98,7 +98,7 @@ import { useRouter } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import {
|
||||
FileTextOutlined,
|
||||
PictureOutlined,
|
||||
StarOutlined,
|
||||
TeamOutlined,
|
||||
RightOutlined,
|
||||
LogoutOutlined,
|
||||
@ -111,7 +111,6 @@ const user = ref<any>(null)
|
||||
const showEditModal = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const regCount = ref(0)
|
||||
const workCount = ref(0)
|
||||
|
||||
// 判断当前是否子女身份
|
||||
const isChildMode = computed(() => {
|
||||
@ -161,12 +160,8 @@ const fetchProfile = async () => {
|
||||
|
||||
const fetchCounts = async () => {
|
||||
try {
|
||||
const [regs, works] = await Promise.all([
|
||||
publicMineApi.registrations({ page: 1, pageSize: 1 }),
|
||||
publicMineApi.works({ page: 1, pageSize: 1 }),
|
||||
])
|
||||
const regs = await publicMineApi.registrations({ page: 1, pageSize: 1 })
|
||||
regCount.value = regs?.total || 0
|
||||
workCount.value = works?.total || 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
|
||||
@ -15,34 +15,65 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="reg-list">
|
||||
<div v-for="item in list" :key="item.id" class="reg-card" @click="goDetail(item.contest?.id)">
|
||||
<div class="reg-cover">
|
||||
<img v-if="item.contest?.coverUrl" :src="item.contest.coverUrl" />
|
||||
<div v-else class="cover-placeholder">
|
||||
{{ item.contest?.contestName?.charAt(0) }}
|
||||
<div v-for="item in list" :key="item.id" class="reg-card">
|
||||
<!-- 报名信息区 -->
|
||||
<div class="reg-main" @click="goDetail(item.contest?.id)">
|
||||
<div class="reg-cover">
|
||||
<img v-if="item.contest?.coverUrl" :src="item.contest.coverUrl" />
|
||||
<div v-else class="cover-placeholder">
|
||||
{{ item.contest?.contestName?.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="reg-info">
|
||||
<h3>{{ item.contest?.contestName }}</h3>
|
||||
<div class="reg-meta">
|
||||
<a-tag :color="statusColor(item.registrationState)">
|
||||
{{ statusLabel(item.registrationState) }}
|
||||
</a-tag>
|
||||
<span class="participant-label">
|
||||
{{ item.participantType === 'child' ? `子女:${item.child?.name}` : '本人参与' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="reg-bottom">
|
||||
<span class="reg-time">{{ formatDate(item.registrationTime) }}</span>
|
||||
<a-button
|
||||
v-if="item.registrationState === 'passed' && isInSubmitPhase(item.contest) && (!item.works || item.works.length === 0)"
|
||||
type="primary"
|
||||
size="small"
|
||||
shape="round"
|
||||
@click.stop="goSubmit(item.contest?.id)"
|
||||
>
|
||||
提交作品
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reg-info">
|
||||
<h3>{{ item.contest?.contestName }}</h3>
|
||||
<div class="reg-meta">
|
||||
<a-tag :color="statusColor(item.registrationState)">
|
||||
{{ statusLabel(item.registrationState) }}
|
||||
</a-tag>
|
||||
<span class="participant-label">
|
||||
{{ item.participantType === 'child' ? `子女:${item.child?.name}` : '本人参与' }}
|
||||
</span>
|
||||
|
||||
<!-- 参赛作品区 -->
|
||||
<div v-if="item.works && item.works.length > 0" class="work-section">
|
||||
<div class="work-section-title">
|
||||
<picture-outlined />
|
||||
<span>参赛作品</span>
|
||||
</div>
|
||||
<div class="reg-bottom">
|
||||
<span class="reg-time">{{ formatDate(item.registrationTime) }}</span>
|
||||
<a-button
|
||||
v-if="item.registrationState === 'passed' && isInSubmitPhase(item.contest)"
|
||||
type="primary"
|
||||
size="small"
|
||||
shape="round"
|
||||
@click.stop="goSubmit(item.contest?.id)"
|
||||
>
|
||||
提交作品
|
||||
</a-button>
|
||||
<div
|
||||
v-for="work in item.works"
|
||||
:key="work.id"
|
||||
class="work-item"
|
||||
@click.stop="goDetail(item.contest?.id)"
|
||||
>
|
||||
<div class="work-thumb">
|
||||
<img v-if="work.previewUrl" :src="work.previewUrl" />
|
||||
<div v-else-if="work.attachments?.[0]?.fileUrl" class="thumb-file">
|
||||
<file-image-outlined />
|
||||
</div>
|
||||
<div v-else class="thumb-empty">
|
||||
<picture-outlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="work-detail">
|
||||
<span class="work-title">{{ work.title || '未命名作品' }}</span>
|
||||
<span class="work-time">提交于 {{ formatDate(work.createTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -63,6 +94,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { PictureOutlined, FileImageOutlined } from '@ant-design/icons-vue'
|
||||
import { publicMineApi } from '@/api/public'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
@ -128,16 +160,20 @@ $primary: #6366f1;
|
||||
.reg-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.reg-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba($primary, 0.06);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.08); transform: translateY(-1px); }
|
||||
}
|
||||
|
||||
.reg-main {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
|
||||
.reg-cover {
|
||||
width: 80px; height: 60px; border-radius: 10px; overflow: hidden; flex-shrink: 0;
|
||||
@ -160,5 +196,66 @@ $primary: #6366f1;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 参赛作品区 ==========
|
||||
.work-section {
|
||||
border-top: 1px dashed rgba($primary, 0.08);
|
||||
padding: 12px 16px;
|
||||
background: rgba($primary, 0.015);
|
||||
|
||||
.work-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
margin-bottom: 10px;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 13px;
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.work-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: rgba($primary, 0.04); }
|
||||
|
||||
& + .work-item { margin-top: 8px; }
|
||||
|
||||
.work-thumb {
|
||||
width: 44px; height: 44px; border-radius: 8px; overflow: hidden; flex-shrink: 0;
|
||||
background: #f5f3ff;
|
||||
|
||||
img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.thumb-file, .thumb-empty {
|
||||
width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px; color: #c7d2fe;
|
||||
}
|
||||
}
|
||||
|
||||
.work-detail {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
|
||||
.work-title {
|
||||
font-size: 13px; font-weight: 500; color: #1e1b4b;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.work-time { font-size: 11px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrap { display: flex; justify-content: center; margin-top: 24px; }
|
||||
</style>
|
||||
|
||||
@ -1,123 +0,0 @@
|
||||
<template>
|
||||
<div class="works-page">
|
||||
<div class="page-header">
|
||||
<h2>我的作品</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-wrap"><a-spin /></div>
|
||||
|
||||
<div v-else-if="list.length === 0" class="empty-wrap">
|
||||
<a-empty description="还没有提交过作品" />
|
||||
</div>
|
||||
|
||||
<div v-else class="works-grid">
|
||||
<div v-for="item in list" :key="item.id" class="work-card">
|
||||
<div class="work-cover">
|
||||
<img v-if="item.coverUrl" :src="item.coverUrl" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<picture-outlined style="font-size: 32px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="work-body">
|
||||
<h3>{{ item.title || '未命名作品' }}</h3>
|
||||
<p class="work-contest">{{ item.contest?.contestName }}</p>
|
||||
<div class="work-meta">
|
||||
<span v-if="item.registration?.participantType === 'child'">
|
||||
创作者:{{ item.registration?.child?.name }}
|
||||
</span>
|
||||
<span class="work-time">{{ formatDate(item.createTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > pageSize" class="pagination-wrap">
|
||||
<a-pagination v-model:current="page" :total="total" :page-size="pageSize" size="small" @change="fetchList" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { PictureOutlined } from '@ant-design/icons-vue'
|
||||
import { publicMineApi } from '@/api/public'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(12)
|
||||
const total = ref(0)
|
||||
|
||||
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await publicMineApi.works({ page: page.value, pageSize: pageSize.value })
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch { /* */ } finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.works-page { max-width: 800px; margin: 0 auto; }
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
h2 { font-size: 20px; font-weight: 700; color: #1e1b4b; margin: 0; }
|
||||
}
|
||||
|
||||
.loading-wrap, .empty-wrap { padding: 60px 0; display: flex; justify-content: center; }
|
||||
|
||||
.works-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.work-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba($primary, 0.06);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.08); transform: translateY(-2px); }
|
||||
|
||||
.work-cover {
|
||||
height: 140px; overflow: hidden;
|
||||
img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.cover-placeholder {
|
||||
width: 100%; height: 100%;
|
||||
background: #f5f3ff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #c7d2fe;
|
||||
}
|
||||
}
|
||||
|
||||
.work-body {
|
||||
padding: 14px 16px;
|
||||
h3 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.work-contest { font-size: 12px; color: #9ca3af; margin: 0 0 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.work-meta {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
span { font-size: 11px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrap { display: flex; justify-content: center; margin-top: 24px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.works-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.work-card .work-cover { height: 110px; }
|
||||
}
|
||||
</style>
|
||||
@ -54,10 +54,29 @@
|
||||
<div v-if="work.tags?.length" class="tags-row">
|
||||
<a-tag v-for="t in work.tags" :key="t.tag.id" color="purple">{{ t.tag.name }}</a-tag>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<span>{{ work.viewCount || 0 }} 浏览</span>
|
||||
<span>{{ work.likeCount || 0 }} 点赞</span>
|
||||
<span>{{ work.favoriteCount || 0 }} 收藏</span>
|
||||
</div>
|
||||
|
||||
<!-- 互动栏 -->
|
||||
<div class="interaction-bar">
|
||||
<div
|
||||
:class="['action-btn', { active: interaction.liked }]"
|
||||
@click="handleLike"
|
||||
>
|
||||
<heart-filled v-if="interaction.liked" />
|
||||
<heart-outlined v-else />
|
||||
<span>{{ displayLikeCount }}</span>
|
||||
</div>
|
||||
<div
|
||||
:class="['action-btn', { active: interaction.favorited }]"
|
||||
@click="handleFavorite"
|
||||
>
|
||||
<star-filled v-if="interaction.favorited" />
|
||||
<star-outlined v-else />
|
||||
<span>{{ displayFavoriteCount }}</span>
|
||||
</div>
|
||||
<div class="action-btn">
|
||||
<eye-outlined />
|
||||
<span>{{ work.viewCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -69,27 +88,38 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowLeftOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||||
import { publicUserWorksApi, type UserWork } from '@/api/public'
|
||||
import {
|
||||
ArrowLeftOutlined, LeftOutlined, RightOutlined,
|
||||
HeartOutlined, HeartFilled, StarOutlined, StarFilled, EyeOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { publicUserWorksApi, publicGalleryApi, publicInteractionApi, type UserWork } from '@/api/public'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const workId = Number(route.params.id)
|
||||
|
||||
const work = ref<UserWork | null>(null)
|
||||
const loading = ref(true)
|
||||
const currentPageIndex = ref(0)
|
||||
const interaction = ref({ liked: false, favorited: false })
|
||||
const actionLoading = ref(false)
|
||||
|
||||
const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.value] || null)
|
||||
|
||||
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
||||
|
||||
const isOwner = computed(() => {
|
||||
const u = localStorage.getItem('public_user')
|
||||
if (!u || !work.value) return false
|
||||
try { return JSON.parse(u).id === work.value.userId } catch { return false }
|
||||
})
|
||||
|
||||
const displayLikeCount = computed(() => work.value?.likeCount || 0)
|
||||
const displayFavoriteCount = computed(() => work.value?.favoriteCount || 0)
|
||||
|
||||
const statusTextMap: Record<string, string> = {
|
||||
draft: '草稿', pending_review: '审核中', published: '已发布', rejected: '被拒绝', taken_down: '已下架',
|
||||
}
|
||||
@ -102,10 +132,63 @@ const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
|
||||
const prevPage = () => { if (currentPageIndex.value > 0) currentPageIndex.value-- }
|
||||
const nextPage = () => { if (work.value?.pages && currentPageIndex.value < work.value.pages.length - 1) currentPageIndex.value++ }
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!isLoggedIn.value) { router.push('/p/login'); return }
|
||||
if (actionLoading.value) return
|
||||
actionLoading.value = true
|
||||
// 乐观更新
|
||||
const wasLiked = interaction.value.liked
|
||||
interaction.value.liked = !wasLiked
|
||||
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? -1 : 1)
|
||||
try {
|
||||
const res = await publicInteractionApi.like(workId)
|
||||
interaction.value.liked = res.liked
|
||||
if (work.value) work.value.likeCount = res.likeCount
|
||||
} catch {
|
||||
// 回滚
|
||||
interaction.value.liked = wasLiked
|
||||
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? 1 : -1)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (!isLoggedIn.value) { router.push('/p/login'); return }
|
||||
if (actionLoading.value) return
|
||||
actionLoading.value = true
|
||||
const wasFavorited = interaction.value.favorited
|
||||
interaction.value.favorited = !wasFavorited
|
||||
if (work.value) work.value.favoriteCount = (work.value.favoriteCount || 0) + (wasFavorited ? -1 : 1)
|
||||
try {
|
||||
const res = await publicInteractionApi.favorite(workId)
|
||||
interaction.value.favorited = res.favorited
|
||||
if (work.value) work.value.favoriteCount = res.favoriteCount
|
||||
} catch {
|
||||
interaction.value.favorited = wasFavorited
|
||||
if (work.value) work.value.favoriteCount = (work.value.favoriteCount || 0) + (wasFavorited ? 1 : -1)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWork = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
work.value = await publicUserWorksApi.detail(workId)
|
||||
// 优先尝试广场接口(公开作品),失败再尝试自己作品库
|
||||
try {
|
||||
work.value = await publicGalleryApi.detail(workId)
|
||||
} catch {
|
||||
work.value = await publicUserWorksApi.detail(workId)
|
||||
}
|
||||
// 已登录时获取交互状态
|
||||
if (isLoggedIn.value) {
|
||||
try {
|
||||
interaction.value = await publicInteractionApi.getInteraction(workId)
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
} catch {
|
||||
message.error('获取作品详情失败')
|
||||
} finally {
|
||||
@ -187,11 +270,59 @@ $primary: #6366f1;
|
||||
}
|
||||
|
||||
.description { font-size: 13px; color: #4b5563; line-height: 1.6; margin-bottom: 10px; }
|
||||
.tags-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.stats-row {
|
||||
.tags-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
// ========== 互动栏 ==========
|
||||
.interaction-bar {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-top: 16px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 14px 0;
|
||||
border: 1px solid rgba($primary, 0.06);
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
span { font-size: 12px; color: #9ca3af; }
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 20px;
|
||||
border-radius: 24px;
|
||||
font-size: 18px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba($primary, 0.04);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #ec4899;
|
||||
|
||||
&:hover {
|
||||
background: rgba(236, 72, 153, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
// 点赞动效
|
||||
&.active :deep(.anticon) {
|
||||
animation: pop 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.3); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
|
||||
171
frontend/src/views/system/tenant-info/Index.vue
Normal file
171
frontend/src/views/system/tenant-info/Index.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
333
frontend/src/views/workbench/TenantDashboard.vue
Normal file
333
frontend/src/views/workbench/TenantDashboard.vue
Normal 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>
|
||||
@ -25,6 +25,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
"/api": {
|
||||
|
||||
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user