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/tenants/tenants.controller.ts b/backend/src/tenants/tenants.controller.ts index d02d08f..40ae333 100644 --- a/backend/src/tenants/tenants.controller.ts +++ b/backend/src/tenants/tenants.controller.ts @@ -48,6 +48,18 @@ export class TenantsController { 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') @RequirePermission('tenant:read') findOne(@Param('id', ParseIntPipe) id: number) { diff --git a/backend/src/tenants/tenants.service.ts b/backend/src/tenants/tenants.service.ts index f2344ce..c13adb1 100644 --- a/backend/src/tenants/tenants.service.ts +++ b/backend/src/tenants/tenants.service.ts @@ -128,6 +128,21 @@ export class TenantsService { 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); 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 f923258..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; diff --git a/docs/design/README.md b/docs/design/README.md index 373b14d..339b5d2 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -13,9 +13,12 @@ | [内容管理模块](./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 | ## 用户端(公众端) 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/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/router/index.ts b/frontend/src/router/index.ts index 189e4df..ed7aa3f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -254,7 +254,7 @@ const baseRoutes: RouteRecordRaw[] = [ meta: { title: "参赛作品详情", requiresAuth: true, - permissions: ["work:read"], + permissions: ["contest:work:read"], }, }, // 作业提交记录路由 diff --git a/frontend/src/utils/menu.ts b/frontend/src/utils/menu.ts index 3f0aaef..9937da4 100644 --- a/frontend/src/utils/menu.ts +++ b/frontend/src/utils/menu.ts @@ -15,6 +15,10 @@ const EmptyLayout = () => import("@/layouts/EmptyLayout.vue") const componentMap: Record Promise> = { // 工作台模块 "workbench/Index": () => import("@/views/workbench/Index.vue"), + "workbench/TenantDashboard": () => import("@/views/workbench/TenantDashboard.vue"), + "analytics/Overview": () => import("@/views/analytics/Overview.vue"), + "analytics/Review": () => import("@/views/analytics/Review.vue"), + "system/tenant-info/Index": () => import("@/views/system/tenant-info/Index.vue"), "workbench/ai-3d/Index": () => import("@/views/workbench/ai-3d/Index.vue"), // 学校管理模块 "school/schools/Index": () => import("@/views/school/schools/Index.vue"), @@ -43,7 +47,7 @@ const componentMap: Record Promise> = { "contests/judges/Index": () => import("@/views/contests/judges/Index.vue"), "contests/results/Index": () => import("@/views/contests/results/Index.vue"), "contests/notices/Index": () => import("@/views/contests/notices/Index.vue"), - "contests/ReviewRules": () => import("@/views/contests/Index.vue"), // 评审规则临时使用活动列表 + "contests/ReviewRules": () => import("@/views/contests/reviews/Index.vue"), // 内容管理模块 "content/WorkReview": () => import("@/views/content/WorkReview.vue"), "content/WorkManagement": () => import("@/views/content/WorkManagement.vue"), diff --git a/frontend/src/views/analytics/Overview.vue b/frontend/src/views/analytics/Overview.vue new file mode 100644 index 0000000..e049a01 --- /dev/null +++ b/frontend/src/views/analytics/Overview.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/frontend/src/views/analytics/Review.vue b/frontend/src/views/analytics/Review.vue new file mode 100644 index 0000000..9287797 --- /dev/null +++ b/frontend/src/views/analytics/Review.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/frontend/src/views/auth/Login.vue b/frontend/src/views/auth/Login.vue index 3343d00..cb7fac0 100644 --- a/frontend/src/views/auth/Login.vue +++ b/frontend/src/views/auth/Login.vue @@ -119,7 +119,7 @@ const isDev = import.meta.env.DEV // 开发环境快捷切换 — 按新架构设计 const tenantTabs = [ { code: "super", name: "平台超管", icon: SafetyOutlined, username: "admin", password: "admin@super" }, - { code: "gdlib", name: "广东省图", icon: BankOutlined, username: "admin", password: "admin@gdlib" }, + { code: "gdlib", name: "广东省图", icon: BankOutlined, username: "admin", password: "admin123" }, { code: "judge", name: "评委端", icon: TrophyOutlined, username: "admin", password: "admin@judge" }, ] diff --git a/frontend/src/views/contests/Create.vue b/frontend/src/views/contests/Create.vue index f765089..d212b79 100644 --- a/frontend/src/views/contests/Create.vue +++ b/frontend/src/views/contests/Create.vue @@ -1,281 +1,186 @@ @@ -287,194 +192,75 @@ import { message } from "ant-design-vue" import type { FormInstance, UploadFile } from "ant-design-vue" import type { Dayjs } from "dayjs" import dayjs from "dayjs" -import { - PlusOutlined, - UploadOutlined, - ArrowLeftOutlined, -} from "@ant-design/icons-vue" +import { PlusOutlined, UploadOutlined, ArrowLeftOutlined } from "@ant-design/icons-vue" import RichTextEditor from "@/components/RichTextEditor.vue" -import { - contestsApi, - attachmentsApi, - reviewRulesApi, - type CreateContestForm, - type Contest, -} from "@/api/contests" +import { contestsApi, attachmentsApi, reviewRulesApi, type CreateContestForm, type Contest } from "@/api/contests" import { uploadFile } from "@/api/upload" const router = useRouter() const route = useRoute() const tenantCode = route.params.tenantCode as string -// 编辑模式 -const contestId = computed(() => - route.params.id ? Number(route.params.id) : null -) +const contestId = computed(() => route.params.id ? Number(route.params.id) : null) const isEdit = computed(() => !!contestId.value) const pageLoading = ref(false) - const formRef = ref() const submitLoading = ref(false) -// 表单数据 -const form = reactive< - CreateContestForm & { - reviewRuleId?: number - } ->({ - contestName: "", - contestType: "individual", - visibility: "designated", - targetCities: [] as string[], - ageMin: undefined as number | undefined, - ageMax: undefined as number | undefined, - startTime: "", - endTime: "", - content: "", - coverUrl: "", - posterUrl: "", - organizers: "", - coOrganizers: "", - sponsors: "", - registerStartTime: "", - registerEndTime: "", - submitRule: "once", - submitStartTime: "", - submitEndTime: "", - reviewStartTime: "", - reviewEndTime: "", - resultPublishTime: "", +const form = reactive({ + contestName: "", contestType: "individual", visibility: "designated", + targetCities: [] as string[], ageMin: undefined as number | undefined, ageMax: undefined as number | undefined, + startTime: "", endTime: "", content: "", coverUrl: "", posterUrl: "", + organizers: "", coOrganizers: "", sponsors: "", + registerStartTime: "", registerEndTime: "", + submitRule: "once", submitStartTime: "", submitEndTime: "", + reviewStartTime: "", reviewEndTime: "", resultPublishTime: "", }) -// 时间范围 const timeRange = ref<[Dayjs, Dayjs] | null>(null) const registerTimeRange = ref<[Dayjs, Dayjs] | null>(null) const submitTimeRange = ref<[Dayjs, Dayjs] | null>(null) const reviewTimeRange = ref<[Dayjs, Dayjs] | null>(null) const resultPublishTime = ref(null) -// 文件列表 const coverFileList = ref([]) const posterFileList = ref([]) const attachmentFileList = ref([]) - -// 评审规则选项 const reviewRuleOptions = ref<{ value: number; label: string }[]>([]) -// 获取评审规则列表 const fetchReviewRules = async () => { try { const rules = await reviewRulesApi.getForSelect() - reviewRuleOptions.value = rules.map((rule) => ({ - value: rule.id, - label: rule.ruleName, - })) - } catch (error) { - console.error("获取评审规则列表失败:", error) - } + reviewRuleOptions.value = rules.map(r => ({ value: r.id, label: r.ruleName })) + } catch { /* */ } } -// 表单验证规则 const rules = { contestName: [{ required: true, message: "请输入活动名称", trigger: "blur" }], - contestType: [ - { required: true, message: "请选择活动类型", trigger: "change" }, - ], - organizers: [ - { required: true, message: "请输入主办单位", trigger: "change" }, - ], + contestType: [{ required: true, message: "请选择活动类型", trigger: "change" }], + organizers: [{ required: true, message: "请输入主办单位", trigger: "change" }], content: [{ required: true, message: "请输入活动详情", trigger: "blur" }], coverUrl: [{ required: true, message: "请上传活动封面", trigger: "change" }], posterUrl: [{ required: true, message: "请上传活动海报", trigger: "change" }], - timeRange: [ - { - required: true, - validator: () => { - if (!timeRange.value || !form.startTime || !form.endTime) { - return Promise.reject(new Error("请选择活动时间")) - } - return Promise.resolve() - }, - trigger: "change", - }, - ], - registerTimeRange: [ - { - required: true, - validator: () => { - if ( - !registerTimeRange.value || - !form.registerStartTime || - !form.registerEndTime - ) { - return Promise.reject(new Error("请选择报名时间")) - } - return Promise.resolve() - }, - trigger: "change", - }, - ], - submitRule: [ - { required: true, message: "请选择提交规则", trigger: "change" }, - ], - submitTimeRange: [ - { - required: true, - validator: () => { - if ( - !submitTimeRange.value || - !form.submitStartTime || - !form.submitEndTime - ) { - return Promise.reject(new Error("请选择提交时间")) - } - return Promise.resolve() - }, - trigger: "change", - }, - ], - reviewTimeRange: [ - { - required: true, - validator: () => { - if ( - !reviewTimeRange.value || - !form.reviewStartTime || - !form.reviewEndTime - ) { - return Promise.reject(new Error("请选择评审时间")) - } - return Promise.resolve() - }, - trigger: "change", - }, - ], + timeRange: [{ required: true, validator: () => (!timeRange.value || !form.startTime || !form.endTime) ? Promise.reject("请选择活动时间") : Promise.resolve(), trigger: "change" }], + registerTimeRange: [{ required: true, validator: () => (!registerTimeRange.value || !form.registerStartTime || !form.registerEndTime) ? Promise.reject("请选择报名时间") : Promise.resolve(), trigger: "change" }], + submitRule: [{ required: true, message: "请选择提交规则", trigger: "change" }], + submitTimeRange: [{ required: true, validator: () => (!submitTimeRange.value || !form.submitStartTime || !form.submitEndTime) ? Promise.reject("请选择提交时间") : Promise.resolve(), trigger: "change" }], + reviewTimeRange: [{ required: true, validator: () => (!reviewTimeRange.value || !form.reviewStartTime || !form.reviewEndTime) ? Promise.reject("请选择评审时间") : Promise.resolve(), trigger: "change" }], } -// 图片上传前验证 const beforeUpload = (file: File) => { const isImage = file.type.startsWith("image/") - if (!isImage) { - message.error("只能上传图片文件!") - return false - } + if (!isImage) { message.error("只能上传图片文件!"); return false } const isLt30M = file.size / 1024 / 1024 < 30 - if (!isLt30M) { - message.error("图片大小不能超过30M!") - return false - } - // 验证图片尺寸(16:9) + if (!isLt30M) { message.error("图片大小不能超过30M!"); return false } return new Promise((resolve) => { const reader = new FileReader() reader.onload = (e) => { const img = new Image() img.onload = () => { - const aspectRatio = img.width / img.height - const targetRatio = 16 / 9 - const tolerance = 0.1 // 允许10%的误差 - if (Math.abs(aspectRatio - targetRatio) > tolerance) { - message.warning("图片尺寸应为16:9,当前尺寸可能不符合要求") - } + const ratio = img.width / img.height + if (Math.abs(ratio - 16 / 9) > 0.1) message.warning("图片尺寸建议为16:9") resolve(true) } img.src = e.target?.result as string @@ -483,454 +269,238 @@ const beforeUpload = (file: File) => { }) } -// 文件上传前验证 const beforeFileUpload = (file: File) => { - const isLt30M = file.size / 1024 / 1024 < 30 - if (!isLt30M) { - message.error("文件大小不能超过30M!") - return false - } + if (file.size / 1024 / 1024 >= 30) { message.error("文件大小不能超过30M!"); return false } return true } -// 封面图片上传 const handleCoverUpload = async (options: any) => { const { file, onSuccess, onError } = options try { const result: any = await uploadFile(file) - // 兼容不同的响应格式 const url = result.data?.url || result.url - if (url) { - form.coverUrl = url - onSuccess() - message.success("封面上传成功") - } else { - throw new Error("无法获取图片地址") - } - } catch (error: any) { - console.error("封面上传失败:", error) - onError(error) - message.error(error?.response?.data?.message || "封面上传失败") - } + if (url) { form.coverUrl = url; onSuccess(); message.success("封面上传成功") } + else throw new Error("无法获取图片地址") + } catch (e: any) { onError(e); message.error(e?.response?.data?.message || "封面上传失败") } } -// 海报图片上传 const handlePosterUpload = async (options: any) => { const { file, onSuccess, onError } = options try { const result: any = await uploadFile(file) - // 兼容不同的响应格式 const url = result.data?.url || result.url - if (url) { - form.posterUrl = url - onSuccess() - message.success("海报上传成功") - } else { - throw new Error("无法获取图片地址") - } - } catch (error: any) { - console.error("海报上传失败:", error) - onError(error) - message.error(error?.response?.data?.message || "海报上传失败") - } + if (url) { form.posterUrl = url; onSuccess(); message.success("海报上传成功") } + else throw new Error("无法获取图片地址") + } catch (e: any) { onError(e); message.error(e?.response?.data?.message || "海报上传失败") } } -// 附件上传 const handleAttachmentUpload = async (options: any) => { const { file, onSuccess, onError } = options try { const result: any = await uploadFile(file) - // 兼容不同的响应格式 const url = result.data?.url || result.url if (url) { - // 更新文件列表中的文件信息,保存上传后的URL - // 使用 nextTick 确保文件已添加到列表中 await nextTick() - const fileIndex = attachmentFileList.value.findIndex( - (f) => f.uid === file.uid || f.name === file.name - ) - if (fileIndex !== -1) { - attachmentFileList.value[fileIndex] = { - ...attachmentFileList.value[fileIndex], - url, - response: result, - status: "done", - } + const idx = attachmentFileList.value.findIndex(f => f.uid === file.uid || f.name === file.name) + if (idx !== -1) { + attachmentFileList.value[idx] = { ...attachmentFileList.value[idx], url, response: result, status: "done" } } else { - // 如果找不到,手动添加到列表 - attachmentFileList.value.push({ - uid: file.uid, - name: file.name, - status: "done", - url, - response: result, - }) + attachmentFileList.value.push({ uid: file.uid, name: file.name, status: "done", url, response: result }) } - onSuccess() - message.success("附件上传成功") - } else { - throw new Error("无法获取文件地址") - } - } catch (error: any) { - console.error("附件上传失败:", error) - // 标记文件为错误状态 - const fileIndex = attachmentFileList.value.findIndex( - (f) => f.uid === file.uid || f.name === file.name - ) - if (fileIndex !== -1) { - attachmentFileList.value[fileIndex].status = "error" - } - onError(error) - message.error(error?.response?.data?.message || "附件上传失败") + onSuccess(); message.success("附件上传成功") + } else throw new Error("无法获取文件地址") + } catch (e: any) { + const idx = attachmentFileList.value.findIndex(f => f.uid === file.uid || f.name === file.name) + if (idx !== -1) attachmentFileList.value[idx].status = "error" + onError(e); message.error(e?.response?.data?.message || "附件上传失败") } } -// 时间范围变化处理 const handleTimeRangeChange = (dates: [Dayjs, Dayjs] | null) => { - if (dates && dates.length === 2) { - form.startTime = dates[0].format("YYYY-MM-DD HH:mm:ss") - form.endTime = dates[1].format("YYYY-MM-DD HH:mm:ss") - } else { - form.startTime = "" - form.endTime = "" - } - // 触发验证 + if (dates?.length === 2) { form.startTime = dates[0].format("YYYY-MM-DD HH:mm:ss"); form.endTime = dates[1].format("YYYY-MM-DD HH:mm:ss") } + else { form.startTime = ""; form.endTime = "" } formRef.value?.validateFields(["timeRange"]) } - const handleRegisterTimeRangeChange = (dates: [Dayjs, Dayjs] | null) => { - if (dates && dates.length === 2) { - form.registerStartTime = dates[0].format("YYYY-MM-DD HH:mm:ss") - form.registerEndTime = dates[1].format("YYYY-MM-DD HH:mm:ss") - } else { - form.registerStartTime = "" - form.registerEndTime = "" - } - // 触发验证 + if (dates?.length === 2) { form.registerStartTime = dates[0].format("YYYY-MM-DD HH:mm:ss"); form.registerEndTime = dates[1].format("YYYY-MM-DD HH:mm:ss") } + else { form.registerStartTime = ""; form.registerEndTime = "" } formRef.value?.validateFields(["registerTimeRange"]) } - const handleSubmitTimeRangeChange = (dates: [Dayjs, Dayjs] | null) => { - if (dates && dates.length === 2) { - form.submitStartTime = dates[0].format("YYYY-MM-DD HH:mm:ss") - form.submitEndTime = dates[1].format("YYYY-MM-DD HH:mm:ss") - } else { - form.submitStartTime = "" - form.submitEndTime = "" - } - // 触发验证 + if (dates?.length === 2) { form.submitStartTime = dates[0].format("YYYY-MM-DD HH:mm:ss"); form.submitEndTime = dates[1].format("YYYY-MM-DD HH:mm:ss") } + else { form.submitStartTime = ""; form.submitEndTime = "" } formRef.value?.validateFields(["submitTimeRange"]) } - const handleReviewTimeRangeChange = (dates: [Dayjs, Dayjs] | null) => { - if (dates && dates.length === 2) { - form.reviewStartTime = dates[0].format("YYYY-MM-DD HH:mm:ss") - form.reviewEndTime = dates[1].format("YYYY-MM-DD HH:mm:ss") - } else { - form.reviewStartTime = "" - form.reviewEndTime = "" - } - // 触发验证 + if (dates?.length === 2) { form.reviewStartTime = dates[0].format("YYYY-MM-DD HH:mm:ss"); form.reviewEndTime = dates[1].format("YYYY-MM-DD HH:mm:ss") } + else { form.reviewStartTime = ""; form.reviewEndTime = "" } formRef.value?.validateFields(["reviewTimeRange"]) } - const handleResultPublishTimeChange = (date: Dayjs | null) => { - if (date) { - form.resultPublishTime = date.format("YYYY-MM-DD HH:mm:ss") - } else { - form.resultPublishTime = "" - } + form.resultPublishTime = date ? date.format("YYYY-MM-DD HH:mm:ss") : "" } -// 禁用报名日期(不早于活动开始时间,不晚于活动结束时间) const disabledRegisterDate = (current: Dayjs | null) => { if (!timeRange.value || !current) return false - const [startTime, endTime] = timeRange.value - return current < startTime.startOf("day") || current > endTime.endOf("day") + return current < timeRange.value[0].startOf("day") || current > timeRange.value[1].endOf("day") } - -// 禁用提交日期(不早于报名结束时间,不晚于活动结束时间) const disabledSubmitDate = (current: Dayjs | null) => { if (!registerTimeRange.value || !timeRange.value || !current) return false - const registerEndTime = registerTimeRange.value[1] - const [, endTime] = timeRange.value - return ( - current < registerEndTime.startOf("day") || current > endTime.endOf("day") - ) + return current < registerTimeRange.value[1].startOf("day") || current > timeRange.value[1].endOf("day") } - -// 禁用评审日期(不早于报名结束时间,不晚于活动结束时间) const disabledReviewDate = (current: Dayjs | null) => { if (!registerTimeRange.value || !timeRange.value || !current) return false - const registerEndTime = registerTimeRange.value[1] - const [, endTime] = timeRange.value - return ( - current < registerEndTime.startOf("day") || current > endTime.endOf("day") - ) + return current < registerTimeRange.value[1].startOf("day") || current > timeRange.value[1].endOf("day") } - -// 禁用公布日期(不早于评审结束时间,不晚于活动结束时间) const disabledPublishDate = (current: Dayjs | null) => { if (!reviewTimeRange.value || !timeRange.value || !current) return false - const reviewEndTime = reviewTimeRange.value[1] - const [, endTime] = timeRange.value - return ( - current < reviewEndTime.startOf("day") || current > endTime.endOf("day") - ) + return current < reviewTimeRange.value[1].startOf("day") || current > timeRange.value[1].endOf("day") } -// 加载活动数据(编辑模式) const loadContestData = async () => { if (!contestId.value) return - pageLoading.value = true try { - const contest = await contestsApi.getDetail(contestId.value) + const c = await contestsApi.getDetail(contestId.value) + form.contestName = c.contestName || "" + form.contestType = c.contestType || "individual" + form.startTime = c.startTime || "" + form.endTime = c.endTime || "" + form.content = c.content || "" + form.coverUrl = c.coverUrl || "" + form.posterUrl = c.posterUrl || "" + form.organizers = Array.isArray(c.organizers) ? c.organizers.join("、") : c.organizers || "" + form.coOrganizers = Array.isArray(c.coOrganizers) ? c.coOrganizers.join("、") : c.coOrganizers || "" + form.sponsors = Array.isArray(c.sponsors) ? c.sponsors.join("、") : c.sponsors || "" + form.registerStartTime = c.registerStartTime || "" + form.registerEndTime = c.registerEndTime || "" + form.submitRule = c.submitRule || "once" + form.submitStartTime = c.submitStartTime || "" + form.submitEndTime = c.submitEndTime || "" + form.reviewRuleId = c.reviewRuleId || undefined + form.reviewStartTime = c.reviewStartTime || "" + form.reviewEndTime = c.reviewEndTime || "" + form.resultPublishTime = c.resultPublishTime || "" - // 填充表单数据 - form.contestName = contest.contestName || "" - form.contestType = contest.contestType || "individual" - form.startTime = contest.startTime || "" - form.endTime = contest.endTime || "" - form.content = contest.content || "" - form.coverUrl = contest.coverUrl || "" - form.posterUrl = contest.posterUrl || "" - // 处理主办/协办/赞助单位(后端返回数组,表单需要字符串) - form.organizers = Array.isArray(contest.organizers) - ? contest.organizers.join("、") - : contest.organizers || "" - form.coOrganizers = Array.isArray(contest.coOrganizers) - ? contest.coOrganizers.join("、") - : contest.coOrganizers || "" - form.sponsors = Array.isArray(contest.sponsors) - ? contest.sponsors.join("、") - : contest.sponsors || "" - form.registerStartTime = contest.registerStartTime || "" - form.registerEndTime = contest.registerEndTime || "" - form.submitRule = contest.submitRule || "once" - form.submitStartTime = contest.submitStartTime || "" - form.submitEndTime = contest.submitEndTime || "" - form.reviewRuleId = contest.reviewRuleId || undefined - form.reviewStartTime = contest.reviewStartTime || "" - form.reviewEndTime = contest.reviewEndTime || "" - form.resultPublishTime = contest.resultPublishTime || "" + if (c.startTime && c.endTime) timeRange.value = [dayjs(c.startTime), dayjs(c.endTime)] + if (c.registerStartTime && c.registerEndTime) registerTimeRange.value = [dayjs(c.registerStartTime), dayjs(c.registerEndTime)] + if (c.submitStartTime && c.submitEndTime) submitTimeRange.value = [dayjs(c.submitStartTime), dayjs(c.submitEndTime)] + if (c.reviewStartTime && c.reviewEndTime) reviewTimeRange.value = [dayjs(c.reviewStartTime), dayjs(c.reviewEndTime)] + if (c.resultPublishTime) resultPublishTime.value = dayjs(c.resultPublishTime) - // 设置时间范围 - if (contest.startTime && contest.endTime) { - timeRange.value = [dayjs(contest.startTime), dayjs(contest.endTime)] + if (c.coverUrl) coverFileList.value = [{ uid: "-1", name: "cover", status: "done", url: c.coverUrl }] + if (c.posterUrl) posterFileList.value = [{ uid: "-2", name: "poster", status: "done", url: c.posterUrl }] + if (c.attachments?.length) { + attachmentFileList.value = c.attachments.map((att: any, i: number) => ({ uid: `-${i + 3}`, name: att.fileName, status: "done", url: att.fileUrl })) } - if (contest.registerStartTime && contest.registerEndTime) { - registerTimeRange.value = [ - dayjs(contest.registerStartTime), - dayjs(contest.registerEndTime), - ] - } - if (contest.submitStartTime && contest.submitEndTime) { - submitTimeRange.value = [ - dayjs(contest.submitStartTime), - dayjs(contest.submitEndTime), - ] - } - if (contest.reviewStartTime && contest.reviewEndTime) { - reviewTimeRange.value = [ - dayjs(contest.reviewStartTime), - dayjs(contest.reviewEndTime), - ] - } - if (contest.resultPublishTime) { - resultPublishTime.value = dayjs(contest.resultPublishTime) - } - - // 设置封面图片 - if (contest.coverUrl) { - coverFileList.value = [ - { - uid: "-1", - name: "cover", - status: "done", - url: contest.coverUrl, - }, - ] - } - - // 设置海报图片 - if (contest.posterUrl) { - posterFileList.value = [ - { - uid: "-2", - name: "poster", - status: "done", - url: contest.posterUrl, - }, - ] - } - - // 加载附件 - if (contest.attachments && contest.attachments.length > 0) { - attachmentFileList.value = contest.attachments.map( - (att: any, index: number) => ({ - uid: `-${index + 3}`, - name: att.fileName, - status: "done", - url: att.fileUrl, - }) - ) - } - } catch (error: any) { - message.error(error?.response?.data?.message || "加载活动数据失败") - router.back() - } finally { - pageLoading.value = false - } + } catch (e: any) { message.error(e?.response?.data?.message || "加载活动数据失败"); router.back() } + finally { pageLoading.value = false } } -// 提交表单 const handleSubmit = async () => { try { await formRef.value?.validate() submitLoading.value = true - // 构建提交数据,确保所有字符串字段不为 null/undefined const submitData: CreateContestForm = { - contestName: form.contestName || "", - contestType: form.contestType || "individual", - startTime: form.startTime || "", - endTime: form.endTime || "", - content: form.content || "", - coverUrl: form.coverUrl || "", - posterUrl: form.posterUrl || "", - organizers: form.organizers || "", - coOrganizers: form.coOrganizers || "", - sponsors: form.sponsors || "", - registerStartTime: form.registerStartTime || "", - registerEndTime: form.registerEndTime || "", - submitRule: form.submitRule || "once", - submitStartTime: form.submitStartTime || "", - submitEndTime: form.submitEndTime || "", + contestName: form.contestName, contestType: form.contestType, startTime: form.startTime, endTime: form.endTime, + content: form.content, coverUrl: form.coverUrl, posterUrl: form.posterUrl, + organizers: form.organizers, coOrganizers: form.coOrganizers, sponsors: form.sponsors, + registerStartTime: form.registerStartTime, registerEndTime: form.registerEndTime, + submitRule: form.submitRule, submitStartTime: form.submitStartTime, submitEndTime: form.submitEndTime, reviewRuleId: form.reviewRuleId || undefined, - reviewStartTime: form.reviewStartTime || "", - reviewEndTime: form.reviewEndTime || "", + reviewStartTime: form.reviewStartTime, reviewEndTime: form.reviewEndTime, resultPublishTime: form.resultPublishTime || undefined, } if (isEdit.value && contestId.value) { - // 编辑模式 - 更新活动 await contestsApi.update(contestId.value, submitData) message.success("保存成功") } else { - // 创建模式 const contest = await contestsApi.create(submitData) - const newContestId = contest.id - - // 如果有附件,创建附件记录 if (attachmentFileList.value.length > 0) { try { - const attachmentPromises = attachmentFileList.value.map((file) => { - const fileUrl = - file.url || file.response?.url || file.response?.data?.url + await Promise.all(attachmentFileList.value.map(file => { + const fileUrl = file.url || file.response?.url || file.response?.data?.url if (fileUrl && file.name) { - return attachmentsApi.create({ - contestId: newContestId, - fileName: file.name, - fileUrl, - format: file.name.split(".").pop()?.toLowerCase(), - size: file.size?.toString(), - }) + return attachmentsApi.create({ contestId: contest.id, fileName: file.name, fileUrl, format: file.name.split(".").pop()?.toLowerCase(), size: file.size?.toString() }) } return Promise.resolve() - }) - await Promise.all(attachmentPromises) - } catch (attachmentError) { - console.error("创建附件记录失败:", attachmentError) - message.warning("活动创建成功,但部分附件记录创建失败") - } + })) + } catch { message.warning("活动创建成功,但部分附件记录创建失败") } } message.success("创建成功") } - - // 跳转到活动列表 router.push(`/${tenantCode}/contests`) - } catch (error: any) { - if (error?.errorFields) { - // 表单验证错误 - return - } - message.error( - error?.response?.data?.message || (isEdit.value ? "保存失败" : "创建失败") - ) - } finally { - submitLoading.value = false - } + } catch (e: any) { + if (e?.errorFields) return + message.error(e?.response?.data?.message || (isEdit.value ? "保存失败" : "创建失败")) + } finally { submitLoading.value = false } } -// 取消 -const handleCancel = () => { - router.back() -} +const handleCancel = () => { router.back() } -// 页面加载 onMounted(() => { - // 获取评审规则列表 fetchReviewRules() - - if (isEdit.value) { - loadContestData() - } + if (isEdit.value) loadContestData() }) - diff --git a/frontend/src/views/contests/Index.vue b/frontend/src/views/contests/Index.vue index a6e5c51..d69f390 100644 --- a/frontend/src/views/contests/Index.vue +++ b/frontend/src/views/contests/Index.vue @@ -15,8 +15,8 @@ - -
+ +
- + - + - - - 已发布 - 未发布 - - - - + + + 全部 未发布 报名中 征稿中 @@ -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 @@ - +