diff --git a/backend/data/menus.json b/backend/data/menus.json index 675fa64..babbc45 100644 --- a/backend/data/menus.json +++ b/backend/data/menus.json @@ -1,36 +1,133 @@ [ + { + "name": "工作台", + "path": "/workbench", + "icon": "DashboardOutlined", + "component": "workbench/Index", + "sort": 1 + }, { "name": "我的评审", "path": "/activities", - "icon": "FlagOutlined", - "component": null, - "parentId": null, - "sort": 1, - "permission": "activity:read", + "icon": "AuditOutlined", + "sort": 2, "children": [ { - "name": "活动列表", - "path": "/activities", - "icon": "UnorderedListOutlined", - "component": "contests/Activities", + "name": "评审任务", + "path": "/activities/review", + "icon": "FileSearchOutlined", + "component": "activities/Review", "sort": 1, - "permission": "activity:read" + "permission": "review:score" }, { - "name": "我的报名", - "path": "/activities/registrations", + "name": "预设评语", + "path": "/activities/preset-comments", + "icon": "MessageOutlined", + "component": "activities/PresetComments", + "sort": 2, + "permission": "review:score" + } + ] + }, + { + "name": "活动监管", + "path": "/contests", + "icon": "FundViewOutlined", + "sort": 3, + "children": [ + { + "name": "全部活动", + "path": "/contests/list", + "icon": "UnorderedListOutlined", + "component": "contests/Index", + "sort": 1, + "permission": "contest:read" + }, + { + "name": "报名数据", + "path": "/contests/registrations", "icon": "UserAddOutlined", "component": "contests/registrations/Index", "sort": 2, - "permission": "registration:create" + "permission": "contest:registration:read" }, { - "name": "我的作品", - "path": "/activities/works", + "name": "作品数据", + "path": "/contests/works", "icon": "FileTextOutlined", "component": "contests/works/Index", "sort": 3, - "permission": "work:create" + "permission": "contest:work:read" + }, + { + "name": "评审进度", + "path": "/contests/review-progress", + "icon": "DashboardOutlined", + "component": "contests/reviews/Progress", + "sort": 4, + "permission": "review:progress:read" + }, + { + "name": "评委管理", + "path": "/contests/judges", + "icon": "SolutionOutlined", + "component": "contests/judges/Index", + "sort": 5, + "permission": "judge:read" + }, + { + "name": "评审规则", + "path": "/contests/review-rules", + "icon": "CheckCircleOutlined", + "component": "contests/reviews/Index", + "sort": 6, + "permission": "review:rule:read" + }, + { + "name": "活动成果", + "path": "/contests/results", + "icon": "TrophyOutlined", + "component": "contests/results/Index", + "sort": 7, + "permission": "result:read" + }, + { + "name": "通知管理", + "path": "/contests/notices", + "icon": "BellOutlined", + "component": "contests/notices/Index", + "sort": 8, + "permission": "contest:notice:read" + } + ] + }, + { + "name": "内容管理", + "path": "/content", + "icon": "PictureOutlined", + "sort": 4, + "children": [ + { + "name": "作品审核", + "path": "/content/review", + "component": "content/WorkReview", + "sort": 1, + "permission": "content:review" + }, + { + "name": "作品管理", + "path": "/content/management", + "component": "content/WorkManagement", + "sort": 2, + "permission": "content:manage" + }, + { + "name": "标签管理", + "path": "/content/tags", + "component": "content/TagManagement", + "sort": 3, + "permission": "content:tags" } ] }, @@ -38,10 +135,7 @@ "name": "学校管理", "path": "/school", "icon": "BankOutlined", - "component": null, - "parentId": null, - "sort": 2, - "permission": "school:read", + "sort": 5, "children": [ { "name": "学校信息", @@ -97,23 +191,20 @@ "name": "活动管理", "path": "/contests", "icon": "TrophyOutlined", - "component": null, - "parentId": null, - "sort": 3, - "permission": "contest:create", + "sort": 6, "children": [ { "name": "活动列表", - "path": "/contests", + "path": "/contests/list", "icon": "UnorderedListOutlined", "component": "contests/Index", "sort": 1, - "permission": "contest:create" + "permission": "contest:read" }, { "name": "评委管理", "path": "/contests/judges", - "icon": "SolutionOutlined", + "icon": "UserSwitchOutlined", "component": "contests/judges/Index", "sort": 2, "permission": "judge:read" @@ -121,10 +212,10 @@ { "name": "报名管理", "path": "/contests/registrations", - "icon": "UserAddOutlined", + "icon": "FormOutlined", "component": "contests/registrations/Index", "sort": 3, - "permission": "registration:approve" + "permission": "contest:registration:read" }, { "name": "作品管理", @@ -132,23 +223,23 @@ "icon": "FileTextOutlined", "component": "contests/works/Index", "sort": 4, - "permission": "contest:read" + "permission": "contest:work:read" }, { "name": "评审进度", "path": "/contests/review-progress", - "icon": "AuditOutlined", + "icon": "DashboardOutlined", "component": "contests/reviews/Progress", "sort": 5, - "permission": "review-rule:read" + "permission": "review:progress:read" }, { "name": "评审规则", - "path": "/contests/reviews", - "icon": "CheckCircleOutlined", - "component": "contests/reviews/Index", + "path": "/contests/review-rules", + "icon": "SettingOutlined", + "component": "contests/ReviewRules", "sort": 6, - "permission": "review-rule:read" + "permission": "review:rule:read" }, { "name": "成果发布", @@ -156,61 +247,63 @@ "icon": "TrophyOutlined", "component": "contests/results/Index", "sort": 7, - "permission": "contest:create" + "permission": "result:read" }, { - "name": "通知管理", + "name": "活动公告", "path": "/contests/notices", - "icon": "BellOutlined", + "icon": "NotificationOutlined", "component": "contests/notices/Index", "sort": 8, - "permission": "notice:create" + "permission": "contest:notice:read" } ] }, { - "name": "作业管理", - "path": "/homework", - "icon": "FormOutlined", - "component": null, - "parentId": null, - "sort": 4, - "permission": "homework:read", + "name": "机构管理", + "path": "/organization", + "icon": "BankOutlined", + "sort": 7, "children": [ { - "name": "作业列表", - "path": "/homework", - "icon": "FileTextOutlined", - "component": "homework/Index", + "name": "机构管理", + "path": "/system/tenants", + "icon": "UnorderedListOutlined", + "component": "system/tenants/Index", "sort": 1, - "permission": "homework:create" - }, - { - "name": "评审规则", - "path": "/homework/review-rules", - "icon": "CheckCircleOutlined", - "component": "homework/ReviewRules", - "sort": 2, - "permission": "homework-review-rule:read" - }, - { - "name": "我的作业", - "path": "/homework/my", - "icon": "BookOutlined", - "component": "homework/StudentList", - "sort": 3, - "permission": "homework-submission:create" + "permission": "tenant:read" } ] }, { - "name": "系统管理", + "name": "用户中心", + "path": "/users-center", + "icon": "TeamOutlined", + "sort": 8, + "children": [ + { + "name": "平台用户", + "path": "/system/users", + "icon": "UserSwitchOutlined", + "component": "system/users/Index", + "sort": 2, + "permission": "user:read" + }, + { + "name": "角色管理", + "path": "/system/roles", + "icon": "SafetyOutlined", + "component": "system/roles/Index", + "sort": 3, + "permission": "role:read" + } + ] + }, + { + "name": "系统设置", "path": "/system", "icon": "SettingOutlined", - "component": null, - "parentId": null, "sort": 9, - "permission": "user:read", "children": [ { "name": "用户管理", @@ -265,35 +358,14 @@ "path": "/system/permissions", "icon": "SafetyOutlined", "component": "system/permissions/Index", - "sort": 7, - "permission": "permission:read" + "sort": 7 }, { "name": "租户管理", "path": "/system/tenants", - "icon": "TeamOutlined", + "icon": "BankOutlined", "component": "system/tenants/Index", - "sort": 8, - "permission": "tenant:read" - } - ] - }, - { - "name": "工作台", - "path": "/workbench", - "icon": "DashboardOutlined", - "component": null, - "parentId": null, - "sort": 10, - "permission": "ai-3d:read", - "children": [ - { - "name": "3D建模实验室", - "path": "/workbench/3d-lab", - "icon": "ExperimentOutlined", - "component": "workbench/ai-3d/Index", - "sort": 1, - "permission": "ai-3d:read" + "sort": 8 } ] } diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d208491..af288d3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") /// 使用次数(冗余) diff --git a/backend/scripts/init-menus.ts b/backend/scripts/init-menus.ts index 39c5d77..a472c92 100644 --- a/backend/scripts/init-menus.ts +++ b/backend/scripts/init-menus.ts @@ -44,17 +44,17 @@ if (!fs.existsSync(menusFilePath)) { const menus = JSON.parse(fs.readFileSync(menusFilePath, 'utf-8')); -// 超级租户可见的菜单名称(工作台只对普通租户可见) -const SUPER_TENANT_MENUS = ['我的评审', '活动管理', '系统管理']; +// 超级租户可见的菜单名称 +const SUPER_TENANT_MENUS = ['我的评审', '活动监管', '内容管理', '活动管理', '机构管理', '用户中心', '系统设置']; // 普通租户可见的菜单名称 -const NORMAL_TENANT_MENUS = ['工作台', '学校管理', '我的评审', '作业管理', '系统管理']; +const NORMAL_TENANT_MENUS = ['工作台', '学校管理', '我的评审', '活动管理', '系统设置']; -// 普通租户在系统管理下排除的子菜单(只保留用户管理和角色管理) +// 普通租户在系统设置下排除的子菜单(只保留用户管理和角色管理) const NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS = ['租户管理', '数据字典', '系统配置', '日志记录', '菜单管理', '权限管理']; -// 普通租户在我的评审下排除的子菜单(只保留活动列表) -const NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS = ['我的报名', '我的作品']; +// 普通租户在我的评审下排除的子菜单(只保留评审任务) +const NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS = ['预设评语']; async function initMenus() { try { @@ -215,13 +215,13 @@ async function initMenus() { if (menu.parentId) { const parentMenu = allMenus.find(m => m.id === menu.parentId); if (parentMenu && NORMAL_TENANT_MENUS.includes(parentMenu.name)) { - // 系统管理下排除部分子菜单 - if (parentMenu.name === '系统管理' && NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS.includes(menu.name)) { - continue; // 跳过排除的菜单 + // 系统设置下排除部分子菜单 + if (parentMenu.name === '系统设置' && NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS.includes(menu.name)) { + continue; } - // 我的评审下排除部分子菜单(只保留活动列表) + // 我的评审下排除部分子菜单 if (parentMenu.name === '我的评审' && NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS.includes(menu.name)) { - continue; // 跳过排除的菜单 + continue; } normalTenantMenuIds.add(menu.id); } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index b8d71b9..72b2d5d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/contests/analytics/analytics.controller.ts b/backend/src/contests/analytics/analytics.controller.ts new file mode 100644 index 0000000..5b72ca2 --- /dev/null +++ b/backend/src/contests/analytics/analytics.controller.ts @@ -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, + }); + } +} diff --git a/backend/src/contests/analytics/analytics.module.ts b/backend/src/contests/analytics/analytics.module.ts new file mode 100644 index 0000000..ff54b5f --- /dev/null +++ b/backend/src/contests/analytics/analytics.module.ts @@ -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 {} diff --git a/backend/src/contests/analytics/analytics.service.ts b/backend/src/contests/analytics/analytics.service.ts new file mode 100644 index 0000000..4f54d6f --- /dev/null +++ b/backend/src/contests/analytics/analytics.service.ts @@ -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(` + 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(` + 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(` + 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(` + 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(); + 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(), + }); + } + 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, + }; + } +} diff --git a/backend/src/contests/contests/contests.controller.ts b/backend/src/contests/contests/contests.controller.ts index d140933..13b6fff 100644 --- a/backend/src/contests/contests/contests.controller.ts +++ b/backend/src/contests/contests/contests.controller.ts @@ -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() diff --git a/backend/src/contests/contests/contests.service.ts b/backend/src/contests/contests/contests.service.ts index 57d5fb1..bb50543 100644 --- a/backend/src/contests/contests/contests.service.ts +++ b/backend/src/contests/contests/contests.service.ts @@ -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, + }; + } } diff --git a/backend/src/contests/notices/dto/query-notice.dto.ts b/backend/src/contests/notices/dto/query-notice.dto.ts index 6fde8db..bf10f40 100644 --- a/backend/src/contests/notices/dto/query-notice.dto.ts +++ b/backend/src/contests/notices/dto/query-notice.dto.ts @@ -22,5 +22,13 @@ export class QueryNoticeDto { @IsString() @IsOptional() publishDate?: string; + + @IsString() + @IsOptional() + publishStartDate?: string; + + @IsString() + @IsOptional() + publishEndDate?: string; } diff --git a/backend/src/contests/notices/notices.service.ts b/backend/src/contests/notices/notices.service.ts index ae41259..96d2ea4 100644 --- a/backend/src/contests/notices/notices.service.ts +++ b/backend/src/contests/notices/notices.service.ts @@ -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: { diff --git a/backend/src/contests/registrations/registrations.controller.ts b/backend/src/contests/registrations/registrations.controller.ts index 6604dac..5643f5b 100644 --- a/backend/src/contests/registrations/registrations.controller.ts +++ b/backend/src/contests/registrations/registrations.controller.ts @@ -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( diff --git a/backend/src/contests/registrations/registrations.service.ts b/backend/src/contests/registrations/registrations.service.ts index ac804e5..7801b69 100644 --- a/backend/src/contests/registrations/registrations.service.ts +++ b/backend/src/contests/registrations/registrations.service.ts @@ -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, diff --git a/backend/src/contests/results/dto/auto-set-awards.dto.ts b/backend/src/contests/results/dto/auto-set-awards.dto.ts index 497e295..56e7bd0 100644 --- a/backend/src/contests/results/dto/auto-set-awards.dto.ts +++ b/backend/src/contests/results/dto/auto-set-awards.dto.ts @@ -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[]; +} diff --git a/backend/src/contests/results/dto/set-award.dto.ts b/backend/src/contests/results/dto/set-award.dto.ts index 49141f4..ad52984 100644 --- a/backend/src/contests/results/dto/set-award.dto.ts +++ b/backend/src/contests/results/dto/set-award.dto.ts @@ -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; +} diff --git a/backend/src/contests/results/results.service.ts b/backend/src/contests/results/results.service.ts index 3016db9..514de8e 100644 --- a/backend/src/contests/results/results.service.ts +++ b/backend/src/contests/results/results.service.ts @@ -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: { diff --git a/backend/src/contests/reviews/reviews.service.ts b/backend/src/contests/reviews/reviews.service.ts index 50845e9..3e87c6b 100644 --- a/backend/src/contests/reviews/reviews.service.ts +++ b/backend/src/contests/reviews/reviews.service.ts @@ -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: { diff --git a/backend/src/contests/works/dto/query-work.dto.ts b/backend/src/contests/works/dto/query-work.dto.ts index 8743192..f643a88 100644 --- a/backend/src/contests/works/dto/query-work.dto.ts +++ b/backend/src/contests/works/dto/query-work.dto.ts @@ -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; } diff --git a/backend/src/contests/works/works.service.ts b/backend/src/contests/works/works.service.ts index 8e6870e..162becd 100644 --- a/backend/src/contests/works/works.service.ts +++ b/backend/src/contests/works/works.service.ts @@ -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, diff --git a/backend/src/public/content-review.controller.ts b/backend/src/public/content-review.controller.ts index 0cfdf11..492592b 100644 --- a/backend/src/public/content-review.controller.ts +++ b/backend/src/public/content-review.controller.ts @@ -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, diff --git a/backend/src/public/content-review.service.ts b/backend/src/public/content-review.service.ts index a41f7c6..4de6682 100644 --- a/backend/src/public/content-review.service.ts +++ b/backend/src/public/content-review.service.ts @@ -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(); diff --git a/backend/src/public/gallery.service.ts b/backend/src/public/gallery.service.ts index 172b7bd..ce62b1d 100644 --- a/backend/src/public/gallery.service.ts +++ b/backend/src/public/gallery.service.ts @@ -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; diff --git a/backend/src/public/interaction.service.ts b/backend/src/public/interaction.service.ts new file mode 100644 index 0000000..7d60ef4 --- /dev/null +++ b/backend/src/public/interaction.service.ts @@ -0,0 +1,173 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class InteractionService { + constructor(private prisma: PrismaService) {} + + /** 点赞/取消点赞(toggle) */ + async toggleLike(userId: number, workId: number) { + // 校验作品存在且已发布 + await this.ensureWorkExists(workId); + + const existing = await this.prisma.userWorkLike.findUnique({ + where: { userId_workId: { userId, workId } }, + }); + + if (existing) { + await this.prisma.$transaction([ + this.prisma.userWorkLike.delete({ + where: { id: existing.id }, + }), + this.prisma.userWork.update({ + where: { id: workId }, + data: { likeCount: { decrement: 1 } }, + }), + ]); + const work = await this.prisma.userWork.findUnique({ + where: { id: workId }, + select: { likeCount: true }, + }); + return { liked: false, likeCount: work.likeCount }; + } else { + await this.prisma.$transaction([ + this.prisma.userWorkLike.create({ + data: { userId, workId }, + }), + this.prisma.userWork.update({ + where: { id: workId }, + data: { likeCount: { increment: 1 } }, + }), + ]); + const work = await this.prisma.userWork.findUnique({ + where: { id: workId }, + select: { likeCount: true }, + }); + return { liked: true, likeCount: work.likeCount }; + } + } + + /** 收藏/取消收藏(toggle) */ + async toggleFavorite(userId: number, workId: number) { + await this.ensureWorkExists(workId); + + const existing = await this.prisma.userWorkFavorite.findUnique({ + where: { userId_workId: { userId, workId } }, + }); + + if (existing) { + await this.prisma.$transaction([ + this.prisma.userWorkFavorite.delete({ + where: { id: existing.id }, + }), + this.prisma.userWork.update({ + where: { id: workId }, + data: { favoriteCount: { decrement: 1 } }, + }), + ]); + const work = await this.prisma.userWork.findUnique({ + where: { id: workId }, + select: { favoriteCount: true }, + }); + return { favorited: false, favoriteCount: work.favoriteCount }; + } else { + await this.prisma.$transaction([ + this.prisma.userWorkFavorite.create({ + data: { userId, workId }, + }), + this.prisma.userWork.update({ + where: { id: workId }, + data: { favoriteCount: { increment: 1 } }, + }), + ]); + const work = await this.prisma.userWork.findUnique({ + where: { id: workId }, + select: { favoriteCount: true }, + }); + return { favorited: true, favoriteCount: work.favoriteCount }; + } + } + + /** 查询当前用户对某作品的交互状态 */ + async getInteractionStatus(userId: number, workId: number) { + const [like, favorite] = await Promise.all([ + this.prisma.userWorkLike.findUnique({ + where: { userId_workId: { userId, workId } }, + }), + this.prisma.userWorkFavorite.findUnique({ + where: { userId_workId: { userId, workId } }, + }), + ]); + return { liked: !!like, favorited: !!favorite }; + } + + /** 我的收藏列表 */ + async getMyFavorites(userId: number, query: { page?: number; pageSize?: number }) { + const page = query.page || 1; + const pageSize = query.pageSize || 12; + const skip = (page - 1) * pageSize; + + const where = { + userId, + work: { status: 'published', isDeleted: 0 }, + }; + + const [list, total] = await Promise.all([ + this.prisma.userWorkFavorite.findMany({ + where, + skip, + take: pageSize, + orderBy: { createTime: 'desc' }, + include: { + work: { + select: { + id: true, + title: true, + coverUrl: true, + likeCount: true, + viewCount: true, + favoriteCount: true, + creator: { select: { id: true, nickname: true, avatar: true } }, + }, + }, + }, + }), + this.prisma.userWorkFavorite.count({ where }), + ]); + + return { list, total, page, pageSize }; + } + + /** 批量查询交互状态(用于列表页) */ + async batchGetInteractionStatus(userId: number, workIds: number[]) { + if (!workIds.length) return {}; + + const [likes, favorites] = await Promise.all([ + this.prisma.userWorkLike.findMany({ + where: { userId, workId: { in: workIds } }, + select: { workId: true }, + }), + this.prisma.userWorkFavorite.findMany({ + where: { userId, workId: { in: workIds } }, + select: { workId: true }, + }), + ]); + + const likedSet = new Set(likes.map(l => l.workId)); + const favoritedSet = new Set(favorites.map(f => f.workId)); + + const result: Record = {}; + for (const id of workIds) { + result[id] = { liked: likedSet.has(id), favorited: favoritedSet.has(id) }; + } + return result; + } + + private async ensureWorkExists(workId: number) { + const work = await this.prisma.userWork.findFirst({ + where: { id: workId, status: 'published', isDeleted: 0 }, + select: { id: true }, + }); + if (!work) throw new NotFoundException('作品不存在或未发布'); + } +} diff --git a/backend/src/public/public.controller.ts b/backend/src/public/public.controller.ts index c673a9b..9367855 100644 --- a/backend/src/public/public.controller.ts +++ b/backend/src/public/public.controller.ts @@ -17,6 +17,7 @@ import { UserWorksService } from './user-works.service'; import { CreationService } from './creation.service'; import { TagsService } from './tags.service'; import { GalleryService } from './gallery.service'; +import { InteractionService } from './interaction.service'; import { PublicRegisterDto, PublicLoginDto } from './dto/register.dto'; import { CreateChildDto, UpdateChildDto } from './dto/child.dto'; import { PublicRegisterActivityDto } from './dto/registration.dto'; @@ -30,6 +31,7 @@ export class PublicController { private readonly creationService: CreationService, private readonly tagsService: TagsService, private readonly galleryService: GalleryService, + private readonly interactionService: InteractionService, ) {} // ==================== 注册 & 登录(公开接口) ==================== @@ -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, + }); + } } diff --git a/backend/src/public/public.module.ts b/backend/src/public/public.module.ts index d107060..d962b3e 100644 --- a/backend/src/public/public.module.ts +++ b/backend/src/public/public.module.ts @@ -10,6 +10,7 @@ import { CreationService } from './creation.service'; import { TagsService } from './tags.service'; import { GalleryService } from './gallery.service'; import { ContentReviewService } from './content-review.service'; +import { InteractionService } from './interaction.service'; import { PrismaModule } from '../prisma/prisma.module'; @Module({ @@ -24,7 +25,7 @@ import { PrismaModule } from '../prisma/prisma.module'; }), ], controllers: [PublicController, TagsController, ContentReviewController], - providers: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService], - exports: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService], + providers: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService, InteractionService], + exports: [PublicService, UserWorksService, CreationService, TagsService, GalleryService, ContentReviewService, InteractionService], }) export class PublicModule {} diff --git a/backend/src/public/public.service.ts b/backend/src/public/public.service.ts index f81f0eb..b9ac873 100644 --- a/backend/src/public/public.service.ts +++ b/backend/src/public/public.service.ts @@ -629,6 +629,16 @@ export class PublicService { child: { select: { id: true, name: true }, }, + works: { + where: { validState: 1 }, + select: { + id: true, + title: true, + previewUrl: true, + createTime: true, + }, + orderBy: { createTime: 'desc' }, + }, }, }); return registration; @@ -902,6 +912,25 @@ export class PublicService { grade: true, }, }, + works: { + where: { validState: 1 }, + select: { + id: true, + title: true, + previewUrl: true, + createTime: true, + attachments: { + select: { + id: true, + fileName: true, + fileUrl: true, + fileType: true, + }, + take: 1, + }, + }, + orderBy: { createTime: 'desc' }, + }, }, }), this.prisma.contestRegistration.count({ where }), diff --git a/backend/src/public/tags.controller.ts b/backend/src/public/tags.controller.ts index b8eba0b..921d6cb 100644 --- a/backend/src/public/tags.controller.ts +++ b/backend/src/public/tags.controller.ts @@ -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); diff --git a/backend/src/public/tags.service.ts b/backend/src/public/tags.service.ts index 53f8a3b..9ecba9f 100644 --- a/backend/src/public/tags.service.ts +++ b/backend/src/public/tags.service.ts @@ -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 } }); diff --git a/backend/src/tenants/tenants.controller.ts b/backend/src/tenants/tenants.controller.ts index d1a254d..40ae333 100644 --- a/backend/src/tenants/tenants.controller.ts +++ b/backend/src/tenants/tenants.controller.ts @@ -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') diff --git a/backend/src/tenants/tenants.service.ts b/backend/src/tenants/tenants.service.ts index 5532ea7..c13adb1 100644 --- a/backend/src/tenants/tenants.service.ts +++ b/backend/src/tenants/tenants.service.ts @@ -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) { diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index e781dc9..6b5be89 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -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); } diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index a5a890e..443a3f4 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -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 ? { diff --git a/docs/design/README.md b/docs/design/README.md index ffb2973..339b5d2 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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 | ## 评委端 diff --git a/docs/design/org-admin/analytics-dashboard-mockup.html b/docs/design/org-admin/analytics-dashboard-mockup.html new file mode 100644 index 0000000..f54cc61 --- /dev/null +++ b/docs/design/org-admin/analytics-dashboard-mockup.html @@ -0,0 +1,445 @@ + + + + + +数据统计 — 活动管理平台 + + + + + + + + + + +
+ +
+

数据统计

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ +
+
+
6
+
活动总数
+
+
+
+
+
+
+ +
+
+
12
+
累计报名
+
+
+
+
+
+
+ +
+
+
10
+
报名通过
+
+
+
+
+
+
+ +
+
+
8
+
作品总数
+
+
+
+
+
+
+ +
+
+
5
+
已完成评审
+
+
+
+
+
+
+ +
+
+
3
+
获奖作品
+
+
+
+
+ + +
+ +
+

报名转化漏斗

+
+
+
+ 报名 + 12 +
+
+
+
+
+ 通过审核 +
83.3%10
+
+
+
+
+
+ 提交作品 +
80.0%8
+
+
+
+
+
+ 评审完成 +
62.5%5
+
+
+
+
+
+ 获奖 +
60.0%3
+
+
+
+
+
+ + +
+

月度趋势

+
+
+
+ + +
+

活动对比

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
活动名称报名数通过率提交率评审完成率获奖率平均分
2026年少儿绘本创作大赛560%100%100%100%84.89
第三届亲子阅读绘画展4100%75%0%0%-
寒假绘本阅读打卡活动3100%67%100%0%85.33
+
+
+
+ + + + +
+
+ + + + diff --git a/docs/design/org-admin/data-analytics-dashboard.md b/docs/design/org-admin/data-analytics-dashboard.md new file mode 100644 index 0000000..25eeded --- /dev/null +++ b/docs/design/org-admin/data-analytics-dashboard.md @@ -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 diff --git a/docs/design/org-admin/tenant-portal-optimization.md b/docs/design/org-admin/tenant-portal-optimization.md new file mode 100644 index 0000000..1f0eedf --- /dev/null +++ b/docs/design/org-admin/tenant-portal-optimization.md @@ -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 diff --git a/docs/design/public/like-favorite.md b/docs/design/public/like-favorite.md new file mode 100644 index 0000000..ff77d38 --- /dev/null +++ b/docs/design/public/like-favorite.md @@ -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 失败时回滚 +- 点赞按钮动效:心形图标缩放弹跳 +- 自己的作品也可以点赞/收藏(不做限制) diff --git a/docs/design/public/ugc-development-plan.md b/docs/design/public/ugc-development-plan.md index 8dbe340..ba33d69 100644 --- a/docs/design/public/ugc-development-plan.md +++ b/docs/design/public/ugc-development-plan.md @@ -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) --- diff --git a/docs/design/super-admin/content-management.md b/docs/design/super-admin/content-management.md index a3b7cf7..ffa3fc7 100644 --- a/docs/design/super-admin/content-management.md +++ b/docs/design/super-admin/content-management.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),标签颜色) diff --git a/docs/design/super-admin/org-management.md b/docs/design/super-admin/org-management.md new file mode 100644 index 0000000..2ac7bcf --- /dev/null +++ b/docs/design/super-admin/org-management.md @@ -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 类型筛选 +``` diff --git a/docs/design/super-admin/unified-user-management.md b/docs/design/super-admin/unified-user-management.md index a648d6a..5bacfd7 100644 --- a/docs/design/super-admin/unified-user-management.md +++ b/docs/design/super-admin/unified-user-management.md @@ -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 错误 diff --git a/frontend/package.json b/frontend/package.json index 6bb3db2..59bd7d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/src/api/analytics.ts b/frontend/src/api/analytics.ts new file mode 100644 index 0000000..4560a5b --- /dev/null +++ b/frontend/src/api/analytics.ts @@ -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 => + request.get('/analytics/overview', { params }), + + getReview: (params?: { contestId?: number }): Promise => + request.get('/analytics/review', { params }), +} diff --git a/frontend/src/api/contests.ts b/frontend/src/api/contests.ts index 904efff..f258329 100644 --- a/frontend/src/api/contests.ts +++ b/frontend/src/api/contests.ts @@ -841,6 +841,16 @@ export const registrationsApi = { return response; }, + // 撤销报名审核 + revokeReview: async (id: number): Promise => { + return await request.patch(`/contests/registrations/${id}/revoke`); + }, + + // 批量审核报名 + batchReview: async (data: { ids: number[]; registrationState: string; reason?: string }): Promise<{ success: boolean; count: number }> => { + return await request.post('/contests/registrations/batch-review', data); + }, + // 删除报名 delete: async (id: number): Promise => { return await request.delete(`/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 }>; } // 成果管理 diff --git a/frontend/src/api/public.ts b/frontend/src/api/public.ts index eddb33d..826f91a 100644 --- a/frontend/src/api/public.ts +++ b/frontend/src/api/public.ts @@ -243,14 +243,26 @@ export const publicActivitiesApi = { ) => publicApi.post(`/public/activities/${id}/submit-work`, data), } -// ==================== 我的报名 & 作品 ==================== +// ==================== 我的报名 ==================== export const publicMineApi = { registrations: (params?: { page?: number; pageSize?: number }) => publicApi.get("/public/mine/registrations", { params }), +} - works: (params?: { page?: number; pageSize?: number }) => - publicApi.get("/public/mine/works", { params }), +// ==================== 点赞 & 收藏 ==================== + +export const publicInteractionApi = { + like: (workId: number) => + publicApi.post(`/public/works/${workId}/like`), + favorite: (workId: number) => + publicApi.post(`/public/works/${workId}/favorite`), + getInteraction: (workId: number) => + publicApi.get(`/public/works/${workId}/interaction`), + batchStatus: (workIds: number[]) => + publicApi.post("/public/works/batch-interaction", { workIds }), + myFavorites: (params?: { page?: number; pageSize?: number }) => + publicApi.get("/public/mine/favorites", { params }), } // ==================== 用户作品库 ==================== @@ -384,6 +396,9 @@ export const publicTagsApi = { // ==================== 作品广场 ==================== export const publicGalleryApi = { + recommended: (): Promise => + publicApi.get("/public/gallery/recommended"), + list: (params?: { page?: number pageSize?: number diff --git a/frontend/src/api/tenants.ts b/frontend/src/api/tenants.ts index dd63a86..5ab4887 100644 --- a/frontend/src/api/tenants.ts +++ b/frontend/src/api/tenants.ts @@ -87,6 +87,11 @@ export async function getTenantMenus(id: number): Promise { return response; } +// 切换租户启用/停用 +export async function toggleTenantStatus(id: number): Promise { + return await request.patch(`/tenants/${id}/status`); +} + // 兼容性导出:保留 tenantsApi 对象 export const tenantsApi = { getList: getTenantsList, @@ -95,4 +100,5 @@ export const tenantsApi = { update: updateTenant, delete: deleteTenant, getTenantMenus: getTenantMenus, + toggleStatus: toggleTenantStatus, }; diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index f0481c0..a74dcdf 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -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; diff --git a/frontend/src/layouts/PublicLayout.vue b/frontend/src/layouts/PublicLayout.vue index 956721b..58f651b 100644 --- a/frontend/src/layouts/PublicLayout.vue +++ b/frontend/src/layouts/PublicLayout.vue @@ -7,6 +7,42 @@ 乐绘世界 + + +
- - - + +
- + + + + - - + + + + + + +
+ + +
- + - 保存 + + +
+ 用户端预览效果: + {{ form.name || '标签名称' }} +
+ + 保存
diff --git a/frontend/src/views/content/WorkManagement.vue b/frontend/src/views/content/WorkManagement.vue index b789a48..1dbe71b 100644 --- a/frontend/src/views/content/WorkManagement.vue +++ b/frontend/src/views/content/WorkManagement.vue @@ -4,11 +4,21 @@ - +
-
-
{{ item.value }}
-
{{ item.label }}
+
+
+ +
+
+ {{ item.value }} + {{ item.label }} +
@@ -18,8 +28,16 @@ + + + 全部 + 正常 + 已下架 + 推荐中 + + - + 最新发布 最多点赞 最多浏览 @@ -40,6 +58,13 @@ + + + + +

+ 下架后作品「{{ takedownTarget?.title }}」将不再公开展示,请填写下架原因: +

+ + 含不适宜内容 + 涉嫌抄袭/侵权 + 用户投诉/举报 + 违反平台规范 + 其他 + + +
+ + + + +
diff --git a/frontend/src/views/content/WorkReview.vue b/frontend/src/views/content/WorkReview.vue index 0b6a479..89bdecb 100644 --- a/frontend/src/views/content/WorkReview.vue +++ b/frontend/src/views/content/WorkReview.vue @@ -21,7 +21,8 @@
- + + 全部 待审核 已通过 已拒绝 @@ -39,26 +40,61 @@
+ +
+ + 勾选表格中的待审核作品可进行批量操作 +
+ - + - -
+ +
- + - + - - - 已发布 - 未发布 - - - - + + + 全部 未发布 报名中 征稿中 @@ -74,70 +49,34 @@ - + 个人参与 团队参与 - - - 公开 - 定向推送 - 指定机构 - 仅内部 - - - + - - 搜索 + 搜索 - - 重置 + 重置 - + + @@ -233,7 +232,7 @@ diff --git a/frontend/src/views/contests/registrations/Index.vue b/frontend/src/views/contests/registrations/Index.vue index 1f4404c..543985c 100644 --- a/frontend/src/views/contests/registrations/Index.vue +++ b/frontend/src/views/contests/registrations/Index.vue @@ -213,35 +213,51 @@ - + @@ -277,38 +161,10 @@ @@ -432,12 +288,18 @@ diff --git a/frontend/src/views/contests/results/Detail.vue b/frontend/src/views/contests/results/Detail.vue index 3da294b..9c5a3d6 100644 --- a/frontend/src/views/contests/results/Detail.vue +++ b/frontend/src/views/contests/results/Detail.vue @@ -1,397 +1,444 @@ - - - - - + + + + + diff --git a/frontend/src/views/contests/results/Index.vue b/frontend/src/views/contests/results/Index.vue index 3b76ab7..07611a8 100644 --- a/frontend/src/views/contests/results/Index.vue +++ b/frontend/src/views/contests/results/Index.vue @@ -126,48 +126,60 @@ - +