Day5: 租户端全面优化 + 数据统计看板 + 成果发布完善
租户端基础设施: - 新增工作台首页(欢迎信息/统计/待办/快捷操作/新手引导) - 新增机构信息管理页(自助查看编辑机构信息) - 修复403报错(fetchTenants加超管守卫) - 修复权限(log:read/notice:update/notice:delete/contest:work:read) - 修复评审规则组件映射 活动管理全模块优化(机构端视角): - 活动列表:加统计概览+精简列+筛选自动查询+发布弹窗修复+操作逻辑优化 - 创建/编辑活动:重构布局(去card嵌套+栅格响应式+分区卡片) - 评委管理:统一主色调+冻结确认+导入导出disabled - 报名管理:去Tab+统计+审核状态列+批量审核接口 - 报名记录:统计概览+去机构列+撤销审核+返回按钮+去参与方式列 - 作品管理:去Tab+统计+递交进度彩色+筛选修复(assignStatus/submitTime) - 评审进度:去Tab+统计+实际完成率状态+筛选修复 - 评审规则:表格加评委数/计算方式+描述列修复+删除保护 - 成果发布:去Tab+统计+操作文案优化 - 通知公告:统一主色调+发布确认+操作逻辑+状态筛选+时间范围 成果发布详情功能补全: - 计算得分/排名/设置奖项三步操作流程 - 排名列(金银铜徽章)+奖项列+奖项筛选 - 自定义奖项(动态添加行替代硬编码一二三等奖) - 后端AutoSetAwardsDto改为awards数组格式 数据统计看板(新模块): - 后端analytics module(overview+review两个接口) - 运营概览:6指标卡片+报名转化漏斗+ECharts月度趋势+活动对比表 - 评审分析:4效率卡片+评委工作量表+ECharts奖项分布饼图 - 菜单注册:数据统计→运营概览+评审分析 Bug修复: - 超管重置其他租户用户密码报"用户不存在" - gdlib登录快捷标签密码不一致 - 分配评委去掉评审时间限制 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
83f007d20e
commit
9215465bd5
@ -13,6 +13,7 @@ import { LogsModule } from './logs/logs.module';
|
||||
import { TenantsModule } from './tenants/tenants.module';
|
||||
import { SchoolModule } from './school/school.module';
|
||||
import { ContestsModule } from './contests/contests.module';
|
||||
import { AnalyticsModule } from './contests/analytics/analytics.module';
|
||||
import { JudgesManagementModule } from './judges-management/judges-management.module';
|
||||
import { UploadModule } from './upload/upload.module';
|
||||
import { HomeworkModule } from './homework/homework.module';
|
||||
@ -47,6 +48,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
TenantsModule,
|
||||
SchoolModule,
|
||||
ContestsModule,
|
||||
AnalyticsModule,
|
||||
JudgesManagementModule,
|
||||
UploadModule,
|
||||
HomeworkModule,
|
||||
|
||||
36
backend/src/contests/analytics/analytics.controller.ts
Normal file
36
backend/src/contests/analytics/analytics.controller.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Controller, Get, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Controller('analytics')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||
|
||||
@Get('overview')
|
||||
@RequirePermission('contest:read')
|
||||
getOverview(
|
||||
@Request() req,
|
||||
@Query('timeRange') timeRange?: string,
|
||||
@Query('contestId') contestId?: string,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.analyticsService.getOverview(tenantId, {
|
||||
timeRange,
|
||||
contestId: contestId ? parseInt(contestId) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('review')
|
||||
@RequirePermission('contest:read')
|
||||
getReviewAnalysis(
|
||||
@Request() req,
|
||||
@Query('contestId') contestId?: string,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.analyticsService.getReviewAnalysis(tenantId, {
|
||||
contestId: contestId ? parseInt(contestId) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
11
backend/src/contests/analytics/analytics.module.ts
Normal file
11
backend/src/contests/analytics/analytics.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
296
backend/src/contests/analytics/analytics.service.ts
Normal file
296
backend/src/contests/analytics/analytics.service.ts
Normal file
@ -0,0 +1,296 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 检查活动是否对租户可见
|
||||
*/
|
||||
private isContestVisibleToTenant(contest: any, tenantId: number): boolean {
|
||||
if (contest.contestState !== 'published') return false;
|
||||
if (!contest.contestTenants) return true;
|
||||
try {
|
||||
const ids = Array.isArray(contest.contestTenants)
|
||||
? contest.contestTenants
|
||||
: JSON.parse(contest.contestTenants as string);
|
||||
return ids.includes(tenantId);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营概览
|
||||
*/
|
||||
async getOverview(tenantId: number, params: { timeRange?: string; contestId?: number }) {
|
||||
const { contestId } = params;
|
||||
|
||||
// 获取该租户可见的活动
|
||||
const allContests = await this.prisma.contest.findMany({
|
||||
where: { contestState: 'published' },
|
||||
select: { id: true, contestTenants: true, contestState: true, contestName: true },
|
||||
});
|
||||
let visibleContestIds = allContests
|
||||
.filter(c => this.isContestVisibleToTenant(c, tenantId))
|
||||
.map(c => c.id);
|
||||
|
||||
if (contestId) {
|
||||
visibleContestIds = visibleContestIds.filter(id => id === contestId);
|
||||
}
|
||||
|
||||
const regWhere: any = { tenantId, contestId: { in: visibleContestIds } };
|
||||
const workWhere: any = { tenantId, contestId: { in: visibleContestIds }, validState: 1, isLatest: true };
|
||||
|
||||
// 核心指标
|
||||
const [totalRegistrations, passedRegistrations, totalWorks, reviewedWorks, awardedWorks] = await Promise.all([
|
||||
this.prisma.contestRegistration.count({ where: regWhere }),
|
||||
this.prisma.contestRegistration.count({ where: { ...regWhere, registrationState: 'passed' } }),
|
||||
this.prisma.contestWork.count({ where: workWhere }),
|
||||
this.prisma.contestWork.count({ where: { ...workWhere, status: { in: ['accepted', 'awarded'] } } }),
|
||||
this.prisma.contestWork.count({ where: { ...workWhere, awardName: { not: null } } }),
|
||||
]);
|
||||
|
||||
// 漏斗数据
|
||||
const funnel = {
|
||||
registered: totalRegistrations,
|
||||
passed: passedRegistrations,
|
||||
submitted: totalWorks,
|
||||
reviewed: reviewedWorks,
|
||||
awarded: awardedWorks,
|
||||
};
|
||||
|
||||
// 月度趋势(最近6个月)
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5);
|
||||
sixMonthsAgo.setDate(1);
|
||||
sixMonthsAgo.setHours(0, 0, 0, 0);
|
||||
|
||||
const registrationsByMonth = await this.prisma.$queryRawUnsafe<any[]>(`
|
||||
SELECT DATE_FORMAT(registration_time, '%Y-%m') as month, COUNT(*) as count
|
||||
FROM t_contest_registration
|
||||
WHERE tenant_id = ? AND contest_id IN (${visibleContestIds.join(',') || '0'})
|
||||
AND registration_time >= ?
|
||||
GROUP BY month ORDER BY month
|
||||
`, tenantId, sixMonthsAgo);
|
||||
|
||||
const worksByMonth = await this.prisma.$queryRawUnsafe<any[]>(`
|
||||
SELECT DATE_FORMAT(submit_time, '%Y-%m') as month, COUNT(*) as count
|
||||
FROM t_contest_work
|
||||
WHERE tenant_id = ? AND contest_id IN (${visibleContestIds.join(',') || '0'})
|
||||
AND valid_state = 1 AND is_latest = 1
|
||||
AND submit_time >= ?
|
||||
GROUP BY month ORDER BY month
|
||||
`, tenantId, sixMonthsAgo);
|
||||
|
||||
// 构建连续6个月数据
|
||||
const monthlyTrend: { month: string; registrations: number; works: number }[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() - 5 + i);
|
||||
const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
const regRow = registrationsByMonth.find((r: any) => r.month === m);
|
||||
const workRow = worksByMonth.find((r: any) => r.month === m);
|
||||
monthlyTrend.push({
|
||||
month: m,
|
||||
registrations: Number(regRow?.count || 0),
|
||||
works: Number(workRow?.count || 0),
|
||||
});
|
||||
}
|
||||
|
||||
// 活动对比
|
||||
const contestComparison: any[] = [];
|
||||
for (const cid of visibleContestIds) {
|
||||
const contest = allContests.find(c => c.id === cid);
|
||||
if (!contest) continue;
|
||||
|
||||
const [regTotal, regPassed, worksTotal, worksReviewed, worksAwarded] = await Promise.all([
|
||||
this.prisma.contestRegistration.count({ where: { tenantId, contestId: cid } }),
|
||||
this.prisma.contestRegistration.count({ where: { tenantId, contestId: cid, registrationState: 'passed' } }),
|
||||
this.prisma.contestWork.count({ where: { tenantId, contestId: cid, validState: 1, isLatest: true } }),
|
||||
this.prisma.contestWork.count({ where: { tenantId, contestId: cid, validState: 1, isLatest: true, status: { in: ['accepted', 'awarded'] } } }),
|
||||
this.prisma.contestWork.count({ where: { tenantId, contestId: cid, validState: 1, isLatest: true, awardName: { not: null } } }),
|
||||
]);
|
||||
|
||||
const avgScore = await this.prisma.contestWork.aggregate({
|
||||
where: { tenantId, contestId: cid, validState: 1, isLatest: true, finalScore: { not: null } },
|
||||
_avg: { finalScore: true },
|
||||
});
|
||||
|
||||
contestComparison.push({
|
||||
contestId: cid,
|
||||
contestName: contest.contestName,
|
||||
registrations: regTotal,
|
||||
passRate: regTotal > 0 ? Math.round(regPassed / regTotal * 100) : 0,
|
||||
submitRate: regPassed > 0 ? Math.round(worksTotal / regPassed * 100) : 0,
|
||||
reviewRate: worksTotal > 0 ? Math.round(worksReviewed / worksTotal * 100) : 0,
|
||||
awardRate: worksTotal > 0 ? Math.round(worksAwarded / worksTotal * 100) : 0,
|
||||
avgScore: avgScore._avg.finalScore ? Number(Number(avgScore._avg.finalScore).toFixed(2)) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalContests: visibleContestIds.length,
|
||||
totalRegistrations,
|
||||
passedRegistrations,
|
||||
totalWorks,
|
||||
reviewedWorks,
|
||||
awardedWorks,
|
||||
},
|
||||
funnel,
|
||||
monthlyTrend,
|
||||
contestComparison,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 评审分析
|
||||
*/
|
||||
async getReviewAnalysis(tenantId: number, params: { contestId?: number }) {
|
||||
const { contestId } = params;
|
||||
|
||||
// 获取可见活动
|
||||
const allContests = await this.prisma.contest.findMany({
|
||||
where: { contestState: 'published' },
|
||||
select: { id: true, contestTenants: true, contestState: true },
|
||||
});
|
||||
let visibleContestIds = allContests
|
||||
.filter(c => this.isContestVisibleToTenant(c, tenantId))
|
||||
.map(c => c.id);
|
||||
|
||||
if (contestId) {
|
||||
visibleContestIds = visibleContestIds.filter(id => id === contestId);
|
||||
}
|
||||
|
||||
if (visibleContestIds.length === 0) {
|
||||
return {
|
||||
efficiency: { avgReviewDays: 0, dailyReviewCount: 0, pendingAssignments: 0, avgScoreStddev: 0 },
|
||||
judgeWorkload: [],
|
||||
awardDistribution: [],
|
||||
};
|
||||
}
|
||||
|
||||
const contestIdList = visibleContestIds.join(',');
|
||||
|
||||
// 评审效率
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const [pendingAssignments, recentScoreCount] = await Promise.all([
|
||||
this.prisma.contestWorkJudgeAssignment.count({
|
||||
where: { contestId: { in: visibleContestIds }, status: 'assigned' },
|
||||
}),
|
||||
this.prisma.contestWorkScore.count({
|
||||
where: { contestId: { in: visibleContestIds }, scoreTime: { gte: thirtyDaysAgo } },
|
||||
}),
|
||||
]);
|
||||
|
||||
// 平均评审周期:从作品提交到第一次评分的天数
|
||||
let avgReviewDays = 0;
|
||||
try {
|
||||
const reviewDaysResult = await this.prisma.$queryRawUnsafe<any[]>(`
|
||||
SELECT AVG(DATEDIFF(s.score_time, w.submit_time)) as avg_days
|
||||
FROM t_contest_work_score s
|
||||
JOIN t_contest_work w ON s.work_id = w.id
|
||||
WHERE s.contest_id IN (${contestIdList})
|
||||
AND w.valid_state = 1
|
||||
`);
|
||||
avgReviewDays = reviewDaysResult[0]?.avg_days ? Number(Number(reviewDaysResult[0].avg_days).toFixed(1)) : 0;
|
||||
} catch { /* */ }
|
||||
|
||||
// 评分标准差(评委间一致性)
|
||||
let avgScoreStddev = 0;
|
||||
try {
|
||||
const stddevResult = await this.prisma.$queryRawUnsafe<any[]>(`
|
||||
SELECT AVG(stddev_score) as avg_stddev
|
||||
FROM (
|
||||
SELECT work_id, STDDEV(total_score) as stddev_score
|
||||
FROM t_contest_work_score
|
||||
WHERE contest_id IN (${contestIdList}) AND valid_state = 1
|
||||
GROUP BY work_id
|
||||
HAVING COUNT(*) > 1
|
||||
) sub
|
||||
`);
|
||||
avgScoreStddev = stddevResult[0]?.avg_stddev ? Number(Number(stddevResult[0].avg_stddev).toFixed(1)) : 0;
|
||||
} catch { /* */ }
|
||||
|
||||
// 评委工作量
|
||||
const judges = await this.prisma.contestJudge.findMany({
|
||||
where: { contestId: { in: visibleContestIds }, validState: 1 },
|
||||
include: {
|
||||
judge: { select: { id: true, nickname: true, username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// 按评委去重
|
||||
const judgeMap = new Map<number, any>();
|
||||
for (const j of judges) {
|
||||
if (!judgeMap.has(j.judgeId)) {
|
||||
judgeMap.set(j.judgeId, {
|
||||
judgeId: j.judgeId,
|
||||
judgeName: j.judge?.nickname || j.judge?.username || '-',
|
||||
contestIds: new Set<number>(),
|
||||
});
|
||||
}
|
||||
judgeMap.get(j.judgeId).contestIds.add(j.contestId);
|
||||
}
|
||||
|
||||
const judgeWorkload: any[] = [];
|
||||
for (const [judgeId, info] of judgeMap) {
|
||||
const [assignedCount, scoredCount, scores] = await Promise.all([
|
||||
this.prisma.contestWorkJudgeAssignment.count({
|
||||
where: { judgeId, contestId: { in: visibleContestIds } },
|
||||
}),
|
||||
this.prisma.contestWorkScore.count({
|
||||
where: { judgeId, contestId: { in: visibleContestIds }, validState: 1 },
|
||||
}),
|
||||
this.prisma.contestWorkScore.findMany({
|
||||
where: { judgeId, contestId: { in: visibleContestIds }, validState: 1 },
|
||||
select: { totalScore: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const scoreValues = scores.map(s => Number(s.totalScore));
|
||||
const avg = scoreValues.length > 0 ? scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length : 0;
|
||||
const variance = scoreValues.length > 1
|
||||
? scoreValues.reduce((sum, v) => sum + Math.pow(v - avg, 2), 0) / (scoreValues.length - 1)
|
||||
: 0;
|
||||
|
||||
judgeWorkload.push({
|
||||
judgeId,
|
||||
judgeName: info.judgeName,
|
||||
contestCount: info.contestIds.size,
|
||||
assignedCount,
|
||||
scoredCount,
|
||||
completionRate: assignedCount > 0 ? Math.round(scoredCount / assignedCount * 100) : 0,
|
||||
avgScore: scoreValues.length > 0 ? Number(avg.toFixed(2)) : null,
|
||||
scoreStddev: scoreValues.length > 1 ? Number(Math.sqrt(variance).toFixed(2)) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
// 奖项分布
|
||||
const awardGroups = await this.prisma.contestWork.groupBy({
|
||||
by: ['awardName'],
|
||||
where: { tenantId, contestId: { in: visibleContestIds }, validState: 1, isLatest: true, awardName: { not: null } },
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
const totalAwarded = awardGroups.reduce((sum, g) => sum + g._count.id, 0);
|
||||
const awardDistribution = awardGroups.map(g => ({
|
||||
awardName: g.awardName,
|
||||
count: g._count.id,
|
||||
percentage: totalAwarded > 0 ? Math.round(g._count.id / totalAwarded * 100) : 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
efficiency: {
|
||||
avgReviewDays,
|
||||
dailyReviewCount: Number((recentScoreCount / 30).toFixed(1)),
|
||||
pendingAssignments,
|
||||
avgScoreStddev,
|
||||
},
|
||||
judgeWorkload,
|
||||
awardDistribution,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -34,8 +34,16 @@ export class ContestsController {
|
||||
|
||||
@Get('stats')
|
||||
@RequirePermission('contest:read')
|
||||
getStats() {
|
||||
return this.contestsService.getStats();
|
||||
getStats(@Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.contestsService.getStats(tenantId);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@RequirePermission('contest:read')
|
||||
getDashboard(@Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.contestsService.getTenantDashboard(tenantId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
|
||||
@ -217,12 +217,13 @@ export class ContestsService {
|
||||
/**
|
||||
* 活动统计(仅超管)
|
||||
*/
|
||||
async getStats() {
|
||||
async getStats(tenantId?: number) {
|
||||
const contests = await this.prisma.contest.findMany({
|
||||
where: { validState: 1 },
|
||||
select: {
|
||||
id: true,
|
||||
contestState: true,
|
||||
contestTenants: true,
|
||||
status: true,
|
||||
registerStartTime: true,
|
||||
registerEndTime: true,
|
||||
@ -233,8 +234,13 @@ export class ContestsService {
|
||||
},
|
||||
});
|
||||
|
||||
const result = { total: contests.length, unpublished: 0, registering: 0, submitting: 0, reviewing: 0, finished: 0 };
|
||||
for (const c of contests) {
|
||||
// 如果有 tenantId,只统计该租户可见的活动
|
||||
const filtered = tenantId
|
||||
? contests.filter(c => this.isContestVisibleToTenant(c, tenantId))
|
||||
: contests;
|
||||
|
||||
const result = { total: filtered.length, unpublished: 0, registering: 0, submitting: 0, reviewing: 0, finished: 0 };
|
||||
for (const c of filtered) {
|
||||
const stage = this.getContestStage(c);
|
||||
if (stage === 'unpublished') result.unpublished++;
|
||||
else if (stage === 'registering') result.registering++;
|
||||
@ -1097,4 +1103,96 @@ export class ContestsService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 租户端仪表盘统计 */
|
||||
async getTenantDashboard(tenantId: number) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// 获取该租户可见的活动 ID 列表
|
||||
const allContests = await this.prisma.contest.findMany({
|
||||
where: { contestState: 'published' },
|
||||
select: { id: true, contestTenants: true, contestState: true, status: true, endTime: true, submitEndTime: true, contestName: true },
|
||||
});
|
||||
const visibleContests = allContests.filter(c => this.isContestVisibleToTenant(c, tenantId));
|
||||
const contestIds = visibleContests.map(c => c.id);
|
||||
const ongoingCount = visibleContests.filter(c => c.status === 'ongoing').length;
|
||||
|
||||
const [totalContests, totalRegistrations, pendingRegistrations, totalWorks, todayRegistrations] = await Promise.all([
|
||||
Promise.resolve(contestIds.length),
|
||||
this.prisma.contestRegistration.count({
|
||||
where: { tenantId, contestId: { in: contestIds } },
|
||||
}),
|
||||
this.prisma.contestRegistration.count({
|
||||
where: { tenantId, contestId: { in: contestIds }, registrationState: 'pending' },
|
||||
}),
|
||||
this.prisma.contestWork.count({
|
||||
where: { tenantId, contestId: { in: contestIds }, validState: 1 },
|
||||
}),
|
||||
this.prisma.contestRegistration.count({
|
||||
where: { tenantId, contestId: { in: contestIds }, registrationTime: { gte: today } },
|
||||
}),
|
||||
]);
|
||||
|
||||
// 最近活动列表(最多5个)
|
||||
const recentContestIds = contestIds.slice(0, 5);
|
||||
const recentContests = recentContestIds.length > 0
|
||||
? await this.prisma.contest.findMany({
|
||||
where: { id: { in: recentContestIds } },
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
status: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
submitEndTime: true,
|
||||
_count: { select: { registrations: true, works: true } },
|
||||
},
|
||||
orderBy: { createTime: 'desc' },
|
||||
})
|
||||
: [];
|
||||
|
||||
// 待办提醒
|
||||
const todos: { type: string; message: string; link?: string }[] = [];
|
||||
if (pendingRegistrations > 0) {
|
||||
todos.push({ type: 'warning', message: `有 ${pendingRegistrations} 个待审核报名`, link: '/contests/registrations' });
|
||||
}
|
||||
// 7天内即将结束的活动
|
||||
const sevenDaysLater = new Date();
|
||||
sevenDaysLater.setDate(sevenDaysLater.getDate() + 7);
|
||||
for (const c of recentContests) {
|
||||
if (c.status === 'ongoing' && c.endTime) {
|
||||
const end = new Date(c.endTime);
|
||||
if (end <= sevenDaysLater && end >= today) {
|
||||
const days = Math.ceil((end.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
todos.push({ type: 'info', message: `活动「${c.contestName}」将在 ${days} 天后结束`, link: `/contests/${c.id}` });
|
||||
}
|
||||
}
|
||||
if (c.submitEndTime) {
|
||||
const submitEnd = new Date(c.submitEndTime);
|
||||
if (submitEnd <= sevenDaysLater && submitEnd >= today) {
|
||||
const days = Math.ceil((submitEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
todos.push({ type: 'warning', message: `活动「${c.contestName}」作品提交将在 ${days} 天后截止`, link: `/contests/${c.id}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 机构信息
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { name: true, code: true, tenantType: true },
|
||||
});
|
||||
|
||||
return {
|
||||
tenant,
|
||||
totalContests,
|
||||
ongoingContests: ongoingCount,
|
||||
totalRegistrations,
|
||||
pendingRegistrations,
|
||||
totalWorks,
|
||||
todayRegistrations,
|
||||
recentContests,
|
||||
todos,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,5 +22,13 @@ export class QueryNoticeDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
publishDate?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
publishStartDate?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
publishEndDate?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -74,7 +74,9 @@ export class NoticesService {
|
||||
pageSize = 10,
|
||||
title,
|
||||
publishDate,
|
||||
} = queryDto;
|
||||
publishStartDate,
|
||||
publishEndDate,
|
||||
} = queryDto as any;
|
||||
|
||||
const where: any = {
|
||||
validState: 1,
|
||||
@ -82,21 +84,20 @@ export class NoticesService {
|
||||
|
||||
// 标题搜索
|
||||
if (title) {
|
||||
where.title = {
|
||||
contains: title,
|
||||
};
|
||||
where.title = { contains: title };
|
||||
}
|
||||
|
||||
// 发布日期搜索
|
||||
if (publishDate) {
|
||||
// 发布日期搜索(兼容单日期和范围)
|
||||
if (publishStartDate || publishEndDate) {
|
||||
where.publishTime = {};
|
||||
if (publishStartDate) where.publishTime.gte = new Date(publishStartDate);
|
||||
if (publishEndDate) where.publishTime.lte = new Date(publishEndDate + ' 23:59:59');
|
||||
} else if (publishDate) {
|
||||
const startDate = new Date(publishDate);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
const endDate = new Date(publishDate);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
where.publishTime = {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
};
|
||||
where.publishTime = { gte: startDate, lte: endDate };
|
||||
}
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
@ -105,8 +106,7 @@ export class NoticesService {
|
||||
this.prisma.contestNotice.findMany({
|
||||
where,
|
||||
orderBy: [
|
||||
{ priority: 'desc' },
|
||||
{ publishTime: 'desc' },
|
||||
{ createTime: 'desc' },
|
||||
],
|
||||
include: {
|
||||
contest: {
|
||||
|
||||
@ -41,8 +41,9 @@ export class RegistrationsController {
|
||||
|
||||
@Get('stats')
|
||||
@RequirePermission('contest:read')
|
||||
getStats(@Query('contestId') contestId?: string) {
|
||||
return this.registrationsService.getStats(contestId ? parseInt(contestId) : undefined);
|
||||
getStats(@Query('contestId') contestId?: string, @Request() req?) {
|
||||
const tenantId = req?.tenantId || req?.user?.tenantId;
|
||||
return this.registrationsService.getStats(contestId ? parseInt(contestId) : undefined, tenantId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ -87,6 +88,25 @@ export class RegistrationsController {
|
||||
return this.registrationsService.review(id, reviewDto, operatorId, tenantId);
|
||||
}
|
||||
|
||||
@Patch(':id/revoke')
|
||||
@RequirePermission('contest:update')
|
||||
revokeReview(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
const operatorId = req.user?.userId;
|
||||
return this.registrationsService.revokeReview(id, operatorId, tenantId);
|
||||
}
|
||||
|
||||
@Post('batch-review')
|
||||
@RequirePermission('contest:update')
|
||||
batchReview(
|
||||
@Body() dto: { ids: number[]; registrationState: string; reason?: string },
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
const operatorId = req.user?.userId;
|
||||
return this.registrationsService.batchReview(dto.ids, dto.registrationState, operatorId, tenantId, dto.reason);
|
||||
}
|
||||
|
||||
@Post(':id/teachers')
|
||||
@RequirePermission('contest:update')
|
||||
addTeacher(
|
||||
|
||||
@ -264,12 +264,14 @@ export class RegistrationsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 报名统计(仅超管)
|
||||
* 报名统计
|
||||
*/
|
||||
async getStats(contestId?: number) {
|
||||
async getStats(contestId?: number, tenantId?: number) {
|
||||
const baseWhere: any = {};
|
||||
if (contestId) {
|
||||
baseWhere.contestId = contestId;
|
||||
if (contestId) baseWhere.contestId = contestId;
|
||||
if (tenantId) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { isSuper: true } });
|
||||
if (tenant?.isSuper !== 1) baseWhere.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const [total, pending, passed, rejected] = await Promise.all([
|
||||
@ -282,6 +284,48 @@ export class RegistrationsService {
|
||||
return { total, pending, passed, rejected };
|
||||
}
|
||||
|
||||
/** 撤销审核(恢复为待审核) */
|
||||
async revokeReview(id: number, operatorId?: number, tenantId?: number) {
|
||||
const registration = await this.findOne(id, tenantId);
|
||||
if (!['passed', 'rejected'].includes(registration.registrationState)) {
|
||||
throw new BadRequestException('当前状态不支持撤销');
|
||||
}
|
||||
|
||||
return this.prisma.contestRegistration.update({
|
||||
where: { id },
|
||||
data: {
|
||||
registrationState: 'pending',
|
||||
reason: null,
|
||||
operator: operatorId,
|
||||
operationDate: new Date(),
|
||||
modifier: operatorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量审核 */
|
||||
async batchReview(ids: number[], registrationState: string, operatorId?: number, tenantId?: number, reason?: string) {
|
||||
if (!ids?.length) return { success: true, count: 0 };
|
||||
|
||||
const where: any = { id: { in: ids } };
|
||||
if (tenantId) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { isSuper: true } });
|
||||
if (tenant?.isSuper !== 1) where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const result = await this.prisma.contestRegistration.updateMany({
|
||||
where,
|
||||
data: {
|
||||
registrationState,
|
||||
reason: reason || null,
|
||||
operator: operatorId,
|
||||
operationDate: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, count: result.count };
|
||||
}
|
||||
|
||||
async findAll(queryDto: QueryRegistrationDto, tenantId?: number) {
|
||||
const {
|
||||
page = 1,
|
||||
|
||||
@ -1,23 +1,18 @@
|
||||
import { IsNumber, IsOptional, Min } from 'class-validator';
|
||||
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 {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(0)
|
||||
first?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(0)
|
||||
second?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(0)
|
||||
third?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(0)
|
||||
excellent?: number;
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AwardTierDto)
|
||||
awards: AwardTierDto[];
|
||||
}
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import { IsString, IsOptional, IsIn } from 'class-validator';
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class SetAwardDto {
|
||||
@IsString()
|
||||
@IsIn(['first', 'second', 'third', 'excellent', 'none'])
|
||||
awardLevel: string;
|
||||
awardLevel: string; // 自定义奖项标识,如 "gold", "silver" 或自定义
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
awardName?: string;
|
||||
awardName?: string; // 奖项显示名称,如 "金奖", "最佳创意奖"
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
|
||||
@ -281,15 +281,12 @@ export class ResultsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据排名自动设置奖项
|
||||
* 根据排名自动设置奖项(支持自定义奖项)
|
||||
*/
|
||||
async autoSetAwards(
|
||||
contestId: number,
|
||||
awardConfig: {
|
||||
first?: number;
|
||||
second?: number;
|
||||
third?: number;
|
||||
excellent?: number;
|
||||
awards: Array<{ name: string; count: number }>;
|
||||
},
|
||||
) {
|
||||
const contest = await this.prisma.contest.findUnique({
|
||||
@ -317,34 +314,26 @@ export class ResultsService {
|
||||
throw new BadRequestException('没有已排名的作品,请先计算排名');
|
||||
}
|
||||
|
||||
const firstCount = awardConfig.first || 0;
|
||||
const secondCount = awardConfig.second || 0;
|
||||
const thirdCount = awardConfig.third || 0;
|
||||
const excellentCount = awardConfig.excellent || 0;
|
||||
// 构建奖项分配表:按顺序展开 [{name, count}] 为 [name, name, ...]
|
||||
const awardSlots: string[] = [];
|
||||
for (const tier of (awardConfig.awards || [])) {
|
||||
for (let i = 0; i < (tier.count || 0); i++) {
|
||||
awardSlots.push(tier.name);
|
||||
}
|
||||
}
|
||||
|
||||
let assignedCount = 0;
|
||||
const awards: { workId: number; awardLevel: string; awardName: string }[] = [];
|
||||
|
||||
for (let i = 0; i < works.length; i++) {
|
||||
const work = works[i];
|
||||
let awardLevel: string | null = null;
|
||||
let awardName: string | null = null;
|
||||
const awardName = i < awardSlots.length ? awardSlots[i] : null;
|
||||
|
||||
if (i < firstCount) {
|
||||
awardLevel = 'first';
|
||||
awardName = '一等奖';
|
||||
} else if (i < firstCount + secondCount) {
|
||||
awardLevel = 'second';
|
||||
awardName = '二等奖';
|
||||
} else if (i < firstCount + secondCount + thirdCount) {
|
||||
awardLevel = 'third';
|
||||
awardName = '三等奖';
|
||||
} else if (i < firstCount + secondCount + thirdCount + excellentCount) {
|
||||
awardLevel = 'excellent';
|
||||
awardName = '优秀奖';
|
||||
}
|
||||
if (awardName) {
|
||||
// awardLevel 用序号标识(tier_0, tier_1...),awardName 存自定义名称
|
||||
const tierIndex = awardConfig.awards.findIndex(t => t.name === awardName);
|
||||
const awardLevel = `tier_${tierIndex}`;
|
||||
|
||||
if (awardLevel) {
|
||||
await this.prisma.contestWork.update({
|
||||
where: { id: work.id },
|
||||
data: {
|
||||
|
||||
@ -33,15 +33,6 @@ export class ReviewsService {
|
||||
throw new BadRequestException('作品不属于该活动');
|
||||
}
|
||||
|
||||
// 检查评审时间
|
||||
const now = new Date();
|
||||
if (
|
||||
now < work.contest.reviewStartTime ||
|
||||
now > work.contest.reviewEndTime
|
||||
) {
|
||||
throw new BadRequestException('不在评审时间范围内');
|
||||
}
|
||||
|
||||
// 验证评委是否存在且是该活动的评委
|
||||
const judges = await this.prisma.contestJudge.findMany({
|
||||
where: {
|
||||
|
||||
@ -52,5 +52,26 @@ export class QueryWorkDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
keyword?: string; // 搜索作品编号、提交者姓名
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string; // 选手/队伍名称
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
assignStatus?: string; // assigned / unassigned
|
||||
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
tenantId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
submitStartTime?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
submitEndTime?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -301,6 +301,34 @@ export class WorksService {
|
||||
];
|
||||
}
|
||||
|
||||
// 选手/队伍名称搜索
|
||||
if (queryDto.name) {
|
||||
const nameWhere = [
|
||||
{ registration: { user: { nickname: { contains: queryDto.name } } } },
|
||||
{ registration: { team: { teamName: { contains: queryDto.name } } } },
|
||||
];
|
||||
where.OR = where.OR ? [...where.OR, ...nameWhere] : nameWhere;
|
||||
}
|
||||
|
||||
// 分配状态筛选
|
||||
if (queryDto.assignStatus === 'assigned') {
|
||||
where.assignments = { some: {} };
|
||||
} else if (queryDto.assignStatus === 'unassigned') {
|
||||
where.assignments = { none: {} };
|
||||
}
|
||||
|
||||
// 指定租户筛选(超管用)
|
||||
if (queryDto.tenantId) {
|
||||
where.tenantId = queryDto.tenantId;
|
||||
}
|
||||
|
||||
// 递交时间范围
|
||||
if (queryDto.submitStartTime || queryDto.submitEndTime) {
|
||||
where.submitTime = {};
|
||||
if (queryDto.submitStartTime) where.submitTime.gte = new Date(queryDto.submitStartTime);
|
||||
if (queryDto.submitEndTime) where.submitTime.lte = new Date(queryDto.submitEndTime + ' 23:59:59');
|
||||
}
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.contestWork.findMany({
|
||||
where,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -86,12 +86,17 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
// 超管端可以更新任意租户的用户
|
||||
const tenant = await this.usersService.getTenant(tenantId);
|
||||
if (tenant?.isSuper === 1) {
|
||||
return this.usersService.update(+id, updateUserDto, undefined);
|
||||
}
|
||||
return this.usersService.update(+id, updateUserDto, tenantId);
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,10 @@ import * as bcrypt from 'bcrypt';
|
||||
export class UsersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getTenant(tenantId: number) {
|
||||
return this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, isSuper: true } });
|
||||
}
|
||||
|
||||
async create(createUserDto: CreateUserDto, tenantId: number) {
|
||||
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
|
||||
const { roleIds, ...userData } = createUserDto;
|
||||
|
||||
@ -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 |
|
||||
|
||||
## 用户端(公众端)
|
||||
|
||||
|
||||
445
docs/design/org-admin/analytics-dashboard-mockup.html
Normal file
445
docs/design/org-admin/analytics-dashboard-mockup.html
Normal file
@ -0,0 +1,445 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>数据统计 — 活动管理平台</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,500;0,9..40,700;1,9..40,400&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca' },
|
||||
surface: '#f8f7fc',
|
||||
card: '#ffffff',
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['"DM Sans"', '"Noto Sans SC"', 'system-ui'],
|
||||
body: ['"Noto Sans SC"', '"DM Sans"', 'system-ui'],
|
||||
mono: ['"DM Sans"', 'monospace'],
|
||||
},
|
||||
borderRadius: { 'card': '12px' },
|
||||
boxShadow: {
|
||||
'card': '0 2px 12px rgba(0,0,0,0.06)',
|
||||
'card-hover': '0 8px 24px rgba(99,102,241,0.12)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background: #f8f7fc; }
|
||||
.tab-active { color: #6366f1; border-bottom: 2px solid #6366f1; font-weight: 700; }
|
||||
.tab-inactive { color: #9ca3af; border-bottom: 2px solid transparent; }
|
||||
.tab-inactive:hover { color: #6b7280; }
|
||||
.stat-card { transition: all 0.25s cubic-bezier(0.4,0,0.2,1); }
|
||||
.stat-card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px rgba(99,102,241,0.12); }
|
||||
.funnel-bar { transition: width 0.8s cubic-bezier(0.4,0,0.2,1); }
|
||||
.fade-in { animation: fadeIn 0.5s ease both; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.stagger-1 { animation-delay: 0.05s; }
|
||||
.stagger-2 { animation-delay: 0.1s; }
|
||||
.stagger-3 { animation-delay: 0.15s; }
|
||||
.stagger-4 { animation-delay: 0.2s; }
|
||||
.stagger-5 { animation-delay: 0.25s; }
|
||||
.stagger-6 { animation-delay: 0.3s; }
|
||||
table th { font-weight: 600; font-size: 13px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
table td { font-size: 14px; }
|
||||
.rate-pill { display: inline-flex; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; }
|
||||
select { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-body text-gray-800 min-h-screen">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="max-w-[1280px] mx-auto px-6 pt-6">
|
||||
<!-- Title -->
|
||||
<div class="bg-white rounded-card shadow-card px-6 py-4 mb-5 flex items-center justify-between">
|
||||
<h1 class="text-xl font-display font-bold text-gray-900 tracking-tight">数据统计</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="exportPDF()" class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 text-sm font-medium text-gray-600 hover:bg-gray-50 transition">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M12 16V4m0 12l-4-4m4 4l4-4M4 20h16"/></svg>
|
||||
导出 PDF
|
||||
</button>
|
||||
<button onclick="exportExcel()" class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 text-sm font-medium text-gray-600 hover:bg-gray-50 transition">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M9 17H5a2 2 0 01-2-2V5a2 2 0 012-2h4m6 0h4a2 2 0 012 2v10a2 2 0 01-2 2h-4m-6-8l6 6m0-6l-6 6"/></svg>
|
||||
导出 Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs + Filters -->
|
||||
<div class="bg-white rounded-card shadow-card px-6 py-0 mb-5 flex items-center justify-between">
|
||||
<div class="flex gap-6">
|
||||
<button id="tab-overview" onclick="switchTab('overview')" class="tab-active py-4 text-sm font-display cursor-pointer transition-colors">运营概览</button>
|
||||
<button id="tab-review" onclick="switchTab('review')" class="tab-inactive py-4 text-sm font-display cursor-pointer transition-colors">评审分析</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<select class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-gray-700 font-medium focus:outline-none focus:ring-2 focus:ring-primary-200">
|
||||
<option>本月</option><option>本季度</option><option>本年</option><option>全部</option>
|
||||
</select>
|
||||
<select class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-gray-700 font-medium focus:outline-none focus:ring-2 focus:ring-primary-200">
|
||||
<option>全部活动</option><option>2026年少儿绘本创作大赛</option><option>第三届亲子阅读绘画展</option><option>寒假绘本阅读打卡活动</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content: Overview -->
|
||||
<div id="content-overview">
|
||||
<!-- Stat Cards -->
|
||||
<div class="grid grid-cols-6 gap-4 mb-5">
|
||||
<div class="stat-card fade-in stagger-1 bg-white rounded-card shadow-card p-4 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center text-lg" style="background:rgba(99,102,241,0.1);color:#6366f1">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">6</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">活动总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-2 bg-white rounded-card shadow-card p-4 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(59,130,246,0.1);color:#3b82f6">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">12</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">累计报名</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-3 bg-white rounded-card shadow-card p-4 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(16,185,129,0.1);color:#10b981">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">10</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">报名通过</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-4 bg-white rounded-card shadow-card p-4 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(245,158,11,0.1);color:#f59e0b">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">8</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">作品总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-5 bg-white rounded-card shadow-card p-4 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(20,184,166,0.1);color:#14b8a6">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">5</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">已完成评审</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-6 bg-white rounded-card shadow-card p-4 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(239,68,68,0.1);color:#ef4444">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5 2a2 2 0 00-2 2v14l3.5-2 3.5 2 3.5-2 3.5 2V4a2 2 0 00-2-2H5zm2.5 3a1.5 1.5 0 100 3 1.5 1.5 0 000-3zm6.207.293a1 1 0 00-1.414 0l-6 6a1 1 0 101.414 1.414l6-6a1 1 0 000-1.414zM12.5 10a1.5 1.5 0 100 3 1.5 1.5 0 000-3z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">3</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">获奖作品</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Funnel + Trend -->
|
||||
<div class="grid grid-cols-2 gap-5 mb-5">
|
||||
<!-- Funnel -->
|
||||
<div class="bg-white rounded-card shadow-card p-6 fade-in" style="animation-delay:0.35s">
|
||||
<h3 class="text-sm font-display font-bold text-gray-900 mb-5 tracking-tight">报名转化漏斗</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">报名</span>
|
||||
<span class="text-sm font-display font-bold text-gray-900">12</span>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:100%;background:linear-gradient(90deg,#6366f1,#818cf8)"></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">通过审核</span>
|
||||
<div class="flex items-center gap-2"><span class="rate-pill bg-green-50 text-green-600">83.3%</span><span class="text-sm font-display font-bold text-gray-900">10</span></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:83.3%;background:linear-gradient(90deg,#10b981,#34d399)"></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">提交作品</span>
|
||||
<div class="flex items-center gap-2"><span class="rate-pill bg-blue-50 text-blue-600">80.0%</span><span class="text-sm font-display font-bold text-gray-900">8</span></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:66.7%;background:linear-gradient(90deg,#3b82f6,#60a5fa)"></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">评审完成</span>
|
||||
<div class="flex items-center gap-2"><span class="rate-pill bg-amber-50 text-amber-600">62.5%</span><span class="text-sm font-display font-bold text-gray-900">5</span></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:41.7%;background:linear-gradient(90deg,#f59e0b,#fbbf24)"></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">获奖</span>
|
||||
<div class="flex items-center gap-2"><span class="rate-pill bg-red-50 text-red-500">60.0%</span><span class="text-sm font-display font-bold text-gray-900">3</span></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:25%;background:linear-gradient(90deg,#ef4444,#f87171)"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trend Chart -->
|
||||
<div class="bg-white rounded-card shadow-card p-6 fade-in" style="animation-delay:0.4s">
|
||||
<h3 class="text-sm font-display font-bold text-gray-900 mb-4 tracking-tight">月度趋势</h3>
|
||||
<div id="trendChart" style="height:280px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contest Comparison Table -->
|
||||
<div class="bg-white rounded-card shadow-card p-6 fade-in" style="animation-delay:0.45s">
|
||||
<h3 class="text-sm font-display font-bold text-gray-900 mb-4 tracking-tight">活动对比</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100">
|
||||
<th class="py-3 px-4">活动名称</th>
|
||||
<th class="py-3 px-4 text-center">报名数</th>
|
||||
<th class="py-3 px-4 text-center">通过率</th>
|
||||
<th class="py-3 px-4 text-center">提交率</th>
|
||||
<th class="py-3 px-4 text-center">评审完成率</th>
|
||||
<th class="py-3 px-4 text-center">获奖率</th>
|
||||
<th class="py-3 px-4 text-center">平均分</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-50 hover:bg-primary-50/30 transition-colors">
|
||||
<td class="py-3.5 px-4 font-medium text-gray-900">2026年少儿绘本创作大赛</td>
|
||||
<td class="py-3.5 px-4 text-center font-display font-bold">5</td>
|
||||
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-amber-50 text-amber-600">60%</span></td>
|
||||
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
|
||||
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
|
||||
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
|
||||
<td class="py-3.5 px-4 text-center font-display font-bold text-primary-500">84.89</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-50 hover:bg-primary-50/30 transition-colors">
|
||||
<td class="py-3.5 px-4 font-medium text-gray-900">第三届亲子阅读绘画展</td>
|
||||
<td class="py-3.5 px-4 text-center font-display font-bold">4</td>
|
||||
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
|
||||
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-amber-50 text-amber-600">75%</span></td>
|
||||
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-gray-100 text-gray-400">0%</span></td>
|
||||
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-gray-100 text-gray-400">0%</span></td>
|
||||
<td class="py-3.5 px-4 text-center text-gray-300">-</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-primary-50/30 transition-colors">
|
||||
<td class="py-3.5 px-4 font-medium text-gray-900">寒假绘本阅读打卡活动</td>
|
||||
<td class="py-3.5 px-4 text-center font-display font-bold">3</td>
|
||||
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
|
||||
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-amber-50 text-amber-600">67%</span></td>
|
||||
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
|
||||
<td class="py-3.5 px-4 text-center"><span class="rate-pill bg-gray-100 text-gray-400">0%</span></td>
|
||||
<td class="py-3.5 px-4 text-center font-display font-bold text-primary-500">85.33</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content: Review Analysis -->
|
||||
<div id="content-review" class="hidden">
|
||||
<!-- Review Efficiency Cards -->
|
||||
<div class="grid grid-cols-4 gap-4 mb-5">
|
||||
<div class="stat-card fade-in stagger-1 bg-white rounded-card shadow-card p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-11 h-11 rounded-xl flex items-center justify-center" style="background:rgba(59,130,246,0.1);color:#3b82f6">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">3.2<span class="text-sm font-normal text-gray-400 ml-0.5">天</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">平均评审周期</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-2 bg-white rounded-card shadow-card p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-11 h-11 rounded-xl flex items-center justify-center" style="background:rgba(16,185,129,0.1);color:#10b981">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">1.5<span class="text-sm font-normal text-gray-400 ml-0.5">个/日</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">日均评审量</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-3 bg-white rounded-card shadow-card p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-11 h-11 rounded-xl flex items-center justify-center" style="background:rgba(239,68,68,0.1);color:#ef4444">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M12 9v2m0 4h.01M5.07 19H19a2.18 2.18 0 001.9-3.2L13.9 4a2.18 2.18 0 00-3.8 0L3.17 15.8A2.18 2.18 0 005.07 19z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">2<span class="text-sm font-normal text-gray-400 ml-0.5">个</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">待评审积压</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-4 bg-white rounded-card shadow-card p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-11 h-11 rounded-xl flex items-center justify-center" style="background:rgba(99,102,241,0.1);color:#6366f1">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">2.8<span class="text-sm font-normal text-gray-400 ml-0.5">分</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">评分一致性</div>
|
||||
<div class="text-[10px] text-gray-300 mt-0.5">标准差越小越好</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Judge Workload + Award Distribution -->
|
||||
<div class="grid grid-cols-5 gap-5 mb-5">
|
||||
<!-- Judge Table -->
|
||||
<div class="col-span-3 bg-white rounded-card shadow-card p-6 fade-in" style="animation-delay:0.25s">
|
||||
<h3 class="text-sm font-display font-bold text-gray-900 mb-4 tracking-tight">评委工作量</h3>
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100">
|
||||
<th class="py-3 px-3">评委姓名</th>
|
||||
<th class="py-3 px-3 text-center">关联活动</th>
|
||||
<th class="py-3 px-3 text-center">已分配</th>
|
||||
<th class="py-3 px-3 text-center">已评分</th>
|
||||
<th class="py-3 px-3 text-center">完成率</th>
|
||||
<th class="py-3 px-3 text-center">平均分</th>
|
||||
<th class="py-3 px-3 text-center">标准差</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-50 hover:bg-primary-50/30 transition-colors">
|
||||
<td class="py-3.5 px-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white text-xs font-bold">陈</div>
|
||||
<span class="font-medium text-gray-900">陈评委</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3.5 px-3 text-center font-display">2</td>
|
||||
<td class="py-3.5 px-3 text-center font-display">6</td>
|
||||
<td class="py-3.5 px-3 text-center font-display">6</td>
|
||||
<td class="py-3.5 px-3 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
|
||||
<td class="py-3.5 px-3 text-center font-display font-bold text-primary-500">85.67</td>
|
||||
<td class="py-3.5 px-3 text-center"><span class="text-sm text-amber-500 font-medium">5.89</span></td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-50 hover:bg-primary-50/30 transition-colors">
|
||||
<td class="py-3.5 px-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-xs font-bold">李</div>
|
||||
<span class="font-medium text-gray-900">李评委</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3.5 px-3 text-center font-display">2</td>
|
||||
<td class="py-3.5 px-3 text-center font-display">6</td>
|
||||
<td class="py-3.5 px-3 text-center font-display">6</td>
|
||||
<td class="py-3.5 px-3 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
|
||||
<td class="py-3.5 px-3 text-center font-display font-bold text-primary-500">83.00</td>
|
||||
<td class="py-3.5 px-3 text-center"><span class="text-sm text-green-500 font-medium">5.10</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-primary-50/30 transition-colors">
|
||||
<td class="py-3.5 px-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center text-white text-xs font-bold">王</div>
|
||||
<span class="font-medium text-gray-900">王评委</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3.5 px-3 text-center font-display">2</td>
|
||||
<td class="py-3.5 px-3 text-center font-display">6</td>
|
||||
<td class="py-3.5 px-3 text-center font-display">6</td>
|
||||
<td class="py-3.5 px-3 text-center"><span class="rate-pill bg-green-50 text-green-600">100%</span></td>
|
||||
<td class="py-3.5 px-3 text-center font-display font-bold text-primary-500">86.00</td>
|
||||
<td class="py-3.5 px-3 text-center"><span class="text-sm text-green-500 font-medium">3.27</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Award Distribution -->
|
||||
<div class="col-span-2 bg-white rounded-card shadow-card p-6 fade-in" style="animation-delay:0.3s">
|
||||
<h3 class="text-sm font-display font-bold text-gray-900 mb-4 tracking-tight">奖项分布</h3>
|
||||
<div id="awardChart" style="height:260px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-8"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tab switching
|
||||
function switchTab(tab) {
|
||||
document.getElementById('content-overview').classList.toggle('hidden', tab !== 'overview');
|
||||
document.getElementById('content-review').classList.toggle('hidden', tab !== 'review');
|
||||
document.getElementById('tab-overview').className = tab === 'overview' ? 'tab-active py-4 text-sm font-display cursor-pointer transition-colors' : 'tab-inactive py-4 text-sm font-display cursor-pointer transition-colors';
|
||||
document.getElementById('tab-review').className = tab === 'review' ? 'tab-active py-4 text-sm font-display cursor-pointer transition-colors' : 'tab-inactive py-4 text-sm font-display cursor-pointer transition-colors';
|
||||
if (tab === 'review') { initAwardChart(); }
|
||||
}
|
||||
|
||||
// Trend Chart
|
||||
const trendChart = echarts.init(document.getElementById('trendChart'));
|
||||
trendChart.setOption({
|
||||
tooltip: { trigger: 'axis', backgroundColor: '#fff', borderColor: '#e5e7eb', borderWidth: 1, textStyle: { color: '#374151', fontSize: 13, fontFamily: 'DM Sans, Noto Sans SC' }, boxShadow: '0 4px 12px rgba(0,0,0,0.08)' },
|
||||
legend: { data: ['报名量', '作品量'], bottom: 0, textStyle: { fontSize: 12, color: '#9ca3af', fontFamily: 'Noto Sans SC' }, itemWidth: 16, itemHeight: 3, itemGap: 24 },
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 40 },
|
||||
xAxis: { type: 'category', data: ['10月', '11月', '12月', '1月', '2月', '3月'], axisLine: { lineStyle: { color: '#e5e7eb' } }, axisLabel: { color: '#9ca3af', fontSize: 12 }, axisTick: { show: false } },
|
||||
yAxis: { type: 'value', axisLine: { show: false }, axisTick: { show: false }, splitLine: { lineStyle: { color: '#f3f4f6', type: 'dashed' } }, axisLabel: { color: '#9ca3af', fontSize: 12 } },
|
||||
series: [
|
||||
{ name: '报名量', type: 'line', data: [3, 5, 8, 6, 12, 15], smooth: true, symbol: 'circle', symbolSize: 6, lineStyle: { width: 3, color: '#6366f1' }, itemStyle: { color: '#6366f1', borderWidth: 2, borderColor: '#fff' }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(99,102,241,0.15)' }, { offset: 1, color: 'rgba(99,102,241,0)' }]) } },
|
||||
{ name: '作品量', type: 'line', data: [1, 3, 5, 4, 8, 10], smooth: true, symbol: 'circle', symbolSize: 6, lineStyle: { width: 3, color: '#f59e0b' }, itemStyle: { color: '#f59e0b', borderWidth: 2, borderColor: '#fff' }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(245,158,11,0.12)' }, { offset: 1, color: 'rgba(245,158,11,0)' }]) } }
|
||||
]
|
||||
});
|
||||
|
||||
// Award Chart
|
||||
function initAwardChart() {
|
||||
const el = document.getElementById('awardChart');
|
||||
if (!el) return;
|
||||
const chart = echarts.init(el);
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item', backgroundColor: '#fff', borderColor: '#e5e7eb', borderWidth: 1, textStyle: { color: '#374151', fontSize: 13 } },
|
||||
legend: { bottom: 0, textStyle: { fontSize: 12, color: '#9ca3af' }, itemWidth: 12, itemHeight: 12, itemGap: 16 },
|
||||
series: [{
|
||||
type: 'pie', radius: ['45%', '72%'], center: ['50%', '45%'],
|
||||
label: { show: true, formatter: '{b}\n{d}%', fontSize: 12, color: '#6b7280', lineHeight: 18 },
|
||||
labelLine: { length: 12, length2: 8 },
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 3 },
|
||||
data: [
|
||||
{ value: 1, name: '一等奖', itemStyle: { color: '#ef4444' } },
|
||||
{ value: 1, name: '二等奖', itemStyle: { color: '#f59e0b' } },
|
||||
{ value: 1, name: '三等奖', itemStyle: { color: '#3b82f6' } }
|
||||
],
|
||||
emphasis: { itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,0,0,0.12)' } }
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
// Resize
|
||||
window.addEventListener('resize', () => { trendChart.resize(); });
|
||||
|
||||
// Export placeholders
|
||||
function exportPDF() { alert('PDF 导出功能将在开发时实现'); }
|
||||
function exportExcel() { alert('Excel 导出功能将在开发时实现'); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
238
docs/design/org-admin/data-analytics-dashboard.md
Normal file
238
docs/design/org-admin/data-analytics-dashboard.md
Normal file
@ -0,0 +1,238 @@
|
||||
# 租户端数据统计分析看板 — 设计方案
|
||||
|
||||
> 所属端:租户端(机构管理端)
|
||||
> 状态:已实现
|
||||
> 创建日期:2026-03-31
|
||||
> 最后更新:2026-03-31
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求背景
|
||||
|
||||
机构领导需要一个数据统计看板来了解:
|
||||
- 活动运营情况:活动办得怎么样,报名和参赛情况
|
||||
- 运营效率:审核速度、评审进度、整体运营时效
|
||||
- 评委工作量:每位评委评了多少作品、评分质量
|
||||
|
||||
## 2. 数据来源盘点
|
||||
|
||||
基于现有系统已实现的功能,可用的数据表和字段:
|
||||
|
||||
| 数据表 | 可用维度 | 可用指标 |
|
||||
|--------|----------|----------|
|
||||
| t_contest | 活动名称、类型、状态(ongoing/finished)、发布状态、各时间节点 | 活动数量、阶段分布 |
|
||||
| t_contest_registration | 活动ID、审核状态(pending/passed/rejected)、报名时间 | 报名数、通过率、时间分布 |
|
||||
| t_contest_work | 活动ID、状态(submitted/reviewing/accepted/awarded)、提交时间、最终得分、排名、奖项 | 作品数、评审状态分布、得分分布、获奖分布 |
|
||||
| t_contest_work_score | 作品ID、评委ID、分数、评分时间 | 评委评分量、评分时间分布 |
|
||||
| t_contest_work_judge_assignment | 作品ID、评委ID、状态 | 分配完成率 |
|
||||
| t_contest_judge | 活动ID、评委ID | 评委数量、评委-活动关联 |
|
||||
| t_contest_notice | 活动ID、发布时间 | 公告数量 |
|
||||
|
||||
## 3. 看板设计
|
||||
|
||||
### 3.1 整体结构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 数据统计 时间范围: [本月▾] [活动▾] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ │活动数│ │报名数│ │通过数│ │作品数│ │已评审│ │获奖数│ │
|
||||
│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ ┌───── 报名转化漏斗 ─────┐ ┌────── 月度趋势 ──────────┐ │
|
||||
│ │ 报名 → 通过 → 提交 │ │ 📈 报名量/作品量折线图 │ │
|
||||
│ │ → 评审完成 → 获奖 │ │ │ │
|
||||
│ └────────────────────────┘ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───── 活动对比 ─────────────────────────────────────────┐ │
|
||||
│ │ 表格:各活动 报名/通过率/作品提交率/评审完成率/获奖率 │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──── 评委工作量 ────────┐ ┌────── 奖项分布 ──────────┐ │
|
||||
│ │ 表格:评委 评审量/均分 │ │ 🥧 饼图:各奖项占比 │ │
|
||||
│ └────────────────────────┘ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──── 评审效率 ──────────────────────────────────────────┐ │
|
||||
│ │ 平均评审周期 │ 日均评审量 │ 待评审积压 │ 评分标准差 │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 模块详细设计
|
||||
|
||||
#### 模块A:核心指标卡片(顶部)
|
||||
|
||||
6 个数字卡片,一行排列:
|
||||
|
||||
| 指标 | 数据来源 | 计算方式 |
|
||||
|------|----------|----------|
|
||||
| 活动总数 | t_contest | COUNT WHERE tenant 可见 |
|
||||
| 累计报名 | t_contest_registration | COUNT WHERE tenant_id |
|
||||
| 报名通过 | t_contest_registration | COUNT WHERE registration_state='passed' |
|
||||
| 作品总数 | t_contest_work | COUNT WHERE valid_state=1 |
|
||||
| 已完成评审 | t_contest_work | COUNT WHERE status IN ('accepted','awarded') |
|
||||
| 获奖作品 | t_contest_work | COUNT WHERE award_level IS NOT NULL AND award_level != 'none' |
|
||||
|
||||
#### 模块B:报名转化漏斗
|
||||
|
||||
展示从报名到获奖的转化路径和各环节转化率:
|
||||
|
||||
```
|
||||
报名人数 (12) → 通过审核 (10) → 提交作品 (8) → 评审完成 (5) → 获奖 (3)
|
||||
83.3% 80.0% 62.5% 60.0%
|
||||
```
|
||||
|
||||
数据来源:
|
||||
- 报名人数:registration COUNT
|
||||
- 通过审核:registration COUNT WHERE state='passed'
|
||||
- 提交作品:work COUNT WHERE valid_state=1
|
||||
- 评审完成:work COUNT WHERE status IN ('accepted','awarded')
|
||||
- 获奖:work COUNT WHERE award_name IS NOT NULL
|
||||
|
||||
#### 模块C:月度趋势图
|
||||
|
||||
折线图,X轴为月份,Y轴双轴:
|
||||
- 左轴:报名数量(按 registration_time 月份分组)
|
||||
- 右轴:作品数量(按 submit_time 月份分组)
|
||||
|
||||
时间范围:最近6个月
|
||||
|
||||
数据来源:
|
||||
```sql
|
||||
-- 月度报名
|
||||
SELECT DATE_FORMAT(registration_time, '%Y-%m') as month, COUNT(*)
|
||||
FROM t_contest_registration WHERE tenant_id=?
|
||||
GROUP BY month ORDER BY month
|
||||
|
||||
-- 月度作品
|
||||
SELECT DATE_FORMAT(submit_time, '%Y-%m') as month, COUNT(*)
|
||||
FROM t_contest_work WHERE tenant_id=? AND valid_state=1
|
||||
GROUP BY month ORDER BY month
|
||||
```
|
||||
|
||||
#### 模块D:活动对比表
|
||||
|
||||
表格形式,每行一个活动:
|
||||
|
||||
| 列 | 数据来源 |
|
||||
|----|----------|
|
||||
| 活动名称 | t_contest.contest_name |
|
||||
| 报名数 | registration COUNT |
|
||||
| 通过率 | passed COUNT / total COUNT × 100% |
|
||||
| 作品提交率 | work COUNT / passed registration COUNT × 100% |
|
||||
| 评审完成率 | (accepted+awarded) COUNT / work COUNT × 100% |
|
||||
| 获奖率 | awarded COUNT / work COUNT × 100% |
|
||||
| 平均得分 | AVG(final_score) |
|
||||
|
||||
#### 模块E:评委工作量
|
||||
|
||||
表格形式,每行一个评委:
|
||||
|
||||
| 列 | 数据来源 |
|
||||
|----|----------|
|
||||
| 评委姓名 | users.nickname via t_contest_judge |
|
||||
| 关联活动数 | t_contest_judge COUNT DISTINCT contest_id |
|
||||
| 已分配作品数 | t_contest_work_judge_assignment COUNT |
|
||||
| 已评分作品数 | t_contest_work_score COUNT |
|
||||
| 评分完成率 | scored / assigned × 100% |
|
||||
| 平均打分 | AVG(total_score) |
|
||||
| 评分标准差 | STDDEV(total_score)(衡量评分一致性,越小越一致) |
|
||||
|
||||
#### 模块F:奖项分布
|
||||
|
||||
饼图/环形图,展示获奖作品中各奖项的占比:
|
||||
|
||||
数据来源:
|
||||
```sql
|
||||
SELECT award_name, COUNT(*)
|
||||
FROM t_contest_work
|
||||
WHERE tenant_id=? AND award_name IS NOT NULL AND valid_state=1
|
||||
GROUP BY award_name
|
||||
```
|
||||
|
||||
#### 模块G:评审效率指标
|
||||
|
||||
4 个数字卡片:
|
||||
|
||||
| 指标 | 计算方式 |
|
||||
|------|----------|
|
||||
| 平均评审周期 | AVG(score_time - submit_time),从作品提交到第一次评分的平均天数 |
|
||||
| 日均评审量 | 最近30天 score COUNT / 30 |
|
||||
| 待评审积压 | assignment COUNT WHERE status='assigned'(已分配未评分) |
|
||||
| 评分一致性 | 所有作品的评委间评分标准差的平均值(越小越好) |
|
||||
|
||||
### 3.3 筛选条件
|
||||
|
||||
顶部全局筛选栏:
|
||||
|
||||
| 筛选 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 时间范围 | 下拉 | 本月/本季度/本年/全部/自定义时间段 |
|
||||
| 指定活动 | 下拉 | 全部活动 / 选择特定活动(切换后所有模块联动) |
|
||||
|
||||
### 3.4 交互设计
|
||||
|
||||
- 数字卡片可点击,跳转到对应管理页面(如点击「累计报名」跳到报名管理)
|
||||
- 活动对比表的活动名称可点击,切换筛选到该活动
|
||||
- 评委工作量表的评委名可点击查看评分明细
|
||||
- 所有图表支持 hover 显示详细数据
|
||||
- 支持将看板数据导出为 PDF/Excel
|
||||
|
||||
## 4. 菜单位置
|
||||
|
||||
新增一级菜单「数据统计」,放在「活动管理」之后:
|
||||
|
||||
```
|
||||
工作台
|
||||
活动管理
|
||||
├── ...
|
||||
数据统计(新增)
|
||||
├── 运营概览 — 核心卡片 + 漏斗 + 趋势 + 活动对比
|
||||
└── 评审分析 — 评委工作量 + 评审效率 + 奖项分布
|
||||
系统设置
|
||||
├── ...
|
||||
```
|
||||
|
||||
## 5. 后端 API 设计
|
||||
|
||||
### 5.1 运营概览
|
||||
|
||||
```
|
||||
GET /api/analytics/overview
|
||||
参数: timeRange(month/quarter/year/all), contestId?(可选)
|
||||
返回:
|
||||
{
|
||||
summary: { totalContests, totalRegistrations, passedRegistrations, totalWorks, reviewedWorks, awardedWorks },
|
||||
funnel: { registered, passed, submitted, reviewed, awarded },
|
||||
monthlyTrend: [{ month, registrations, works }],
|
||||
contestComparison: [{
|
||||
contestId, contestName,
|
||||
registrations, passRate, submitRate, reviewRate, awardRate, avgScore
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 评审分析
|
||||
|
||||
```
|
||||
GET /api/analytics/review
|
||||
参数: timeRange, contestId?
|
||||
返回:
|
||||
{
|
||||
efficiency: { avgReviewDays, dailyReviewCount, pendingAssignments, avgScoreStddev },
|
||||
judgeWorkload: [{
|
||||
judgeId, judgeName,
|
||||
contestCount, assignedCount, scoredCount, completionRate, avgScore, scoreStddev
|
||||
}],
|
||||
awardDistribution: [{ awardName, count, percentage }]
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 技术方案
|
||||
|
||||
- 前端图表库:使用 ECharts 或 Ant Design Charts(@ant-design/charts)
|
||||
- 数据缓存:统计数据变化不频繁,后端可加 5 分钟缓存
|
||||
- 大数据量:月度趋势等聚合查询用 GROUP BY + 索引优化
|
||||
- 导出:前端生成 PDF(html2canvas + jsPDF)或 CSV
|
||||
180
docs/design/org-admin/tenant-portal-optimization.md
Normal file
180
docs/design/org-admin/tenant-portal-optimization.md
Normal file
@ -0,0 +1,180 @@
|
||||
# 租户端(机构管理端)全面优化记录
|
||||
|
||||
> 所属端:租户端(机构管理员视角)
|
||||
> 状态:已优化
|
||||
> 创建日期:2026-03-31
|
||||
> 最后更新:2026-03-31
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
以广东省立中山图书馆(gdlib)为典型租户,从机构管理员/运营人员视角全面审查并优化了租户端的所有模块。
|
||||
|
||||
## Day5 (2026-03-31) — 优化内容
|
||||
|
||||
### 基础设施
|
||||
|
||||
- [x] 数据隔离验证:确认活动/报名/作品查询全部带 tenantId 过滤
|
||||
- [x] 日志菜单权限修复:补充 log:read 权限
|
||||
- [x] 公告权限修复:补充 notice:update / notice:delete 权限
|
||||
- [x] 403 报错修复:fetchTenants 调用加 isSuperAdmin 守卫(contests/Index, system/users/Index)
|
||||
- [x] 评审规则组件映射修复:contests/ReviewRules 指向正确的 reviews/Index.vue
|
||||
- [x] 作品详情路由权限修复:work:read 改为 contest:work:read
|
||||
|
||||
### 工作台(新增)
|
||||
- [x] 新增租户端工作台页面(TenantDashboard.vue)
|
||||
- [x] 欢迎信息 + 机构标识(时段问候、管理员姓名、机构名称/类型)
|
||||
- [x] 6个统计卡片(可见活动/进行中/总报名/待审核报名/总作品/今日报名),可点击跳转
|
||||
- [x] 空数据新手引导(三步:创建活动→添加成员→邀请评委)
|
||||
- [x] 快捷操作按权限动态显示
|
||||
- [x] 待办提醒(待审核报名 + 即将截止的活动)
|
||||
- [x] 最近活动列表 + 查看全部入口
|
||||
- [x] 后端 GET /contests/dashboard 接口
|
||||
|
||||
### 机构信息(新增)
|
||||
- [x] 新增机构信息管理页面(tenant-info/Index.vue)
|
||||
- [x] 查看/编辑机构名称和描述
|
||||
- [x] 复制登录地址
|
||||
- [x] 后端 GET/PATCH /tenants/my-tenant 接口
|
||||
|
||||
### 活动列表
|
||||
- [x] 租户端加统计概览(6个阶段卡片,后端 getStats 加 tenantId 过滤)
|
||||
- [x] 精简表格列(去掉主办方/可见范围/公开机构,加活动阶段列)
|
||||
- [x] 筛选自动查询(下拉 @change)
|
||||
- [x] 报名/作品数可点击跳转
|
||||
- [x] 修复发布弹窗机构选择 bug(租户端用 my-tenant 接口获取自己信息)
|
||||
- [x] 操作按钮逻辑优化(未发布:发布/编辑/删除;已发布:查看/评委/编辑/取消发布)
|
||||
|
||||
### 创建/编辑活动
|
||||
- [x] 重构页面布局:去掉 card 嵌套,改为独立分区卡片
|
||||
- [x] 修复 form layout 冲突(vertical + labelCol)
|
||||
- [x] 去掉固定宽度,改用栅格响应式
|
||||
- [x] 4 个分区:主办信息、活动信息、图片附件、时间配置
|
||||
|
||||
### 评委管理
|
||||
- [x] 筛选自动查询
|
||||
- [x] 导入/导出改为 disabled + tooltip
|
||||
- [x] 主色调统一 #6366f1
|
||||
- [x] 冻结/解冻二次确认
|
||||
|
||||
### 报名管理(Index)
|
||||
- [x] 去掉个人/团队 Tab,合并展示加类型列
|
||||
- [x] 统计概览(总报名/待审核/已通过/已拒绝)
|
||||
- [x] 表格加审核状态分类计数列(并行查询每个活动的统计)
|
||||
- [x] 去掉手动启动/关闭报名
|
||||
|
||||
### 报名记录(Records)
|
||||
- [x] 主色调统一
|
||||
- [x] 统计概览 + 可点击筛选
|
||||
- [x] 租户端去掉机构列
|
||||
- [x] 筛选自动查询
|
||||
- [x] 通过加二次确认
|
||||
- [x] 批量审核改用后端批量接口 POST /contests/registrations/batch-review
|
||||
- [x] 返回按钮
|
||||
- [x] 去掉「参与方式」列(子女已改为独立账号)
|
||||
- [x] 撤销审核功能 PATCH /contests/registrations/:id/revoke
|
||||
|
||||
### 作品管理(Index)
|
||||
- [x] 去掉 Tab,加统计概览 + 类型筛选
|
||||
- [x] 递交进度彩色数字(已交/应交)
|
||||
- [x] 活动名可点击
|
||||
|
||||
### 作品详情(WorksDetail)
|
||||
- [x] 返回按钮
|
||||
- [x] 统计概览
|
||||
- [x] 租户端去掉机构筛选
|
||||
- [x] 筛选自动查询(分配状态、递交时间、机构下拉)
|
||||
- [x] 后端支持 assignStatus / name / submitStartTime / submitEndTime 筛选
|
||||
- [x] 分配评委去掉评审时间限制(任何时候都可分配)
|
||||
|
||||
### 评审进度
|
||||
- [x] 去掉 Tab,加统计概览 + 类型筛选
|
||||
- [x] 评审状态改用实际完成率(无作品/未开始/进行中/已完成)
|
||||
- [x] 进度数字颜色区分
|
||||
- [x] 评审进度详情页筛选修复(评审进度前端过滤生效)
|
||||
|
||||
### 评审规则
|
||||
- [x] 组件映射修复
|
||||
- [x] 主色调统一
|
||||
- [x] 表格加评委数/计算方式列
|
||||
- [x] 修复规则描述列数据展示错误
|
||||
- [x] 已关联活动删除保护提示
|
||||
- [x] Drawer 标题区分新建/编辑
|
||||
|
||||
### 成果发布(Index)
|
||||
- [x] 去掉 Tab,加统计概览(全部/已发布/未发布)
|
||||
- [x] 加发布状态筛选 + 类型筛选
|
||||
- [x] 活动名可点击
|
||||
- [x] 操作按钮文案优化(查看成果/发布成果)
|
||||
|
||||
### 成果发布详情(Detail)— 功能补全
|
||||
- [x] 统计摘要(总作品/已评分/已排名/已设奖/平均分)
|
||||
- [x] 三步操作流程(计算得分→计算排名→设置奖项)
|
||||
- [x] 排名列(金银铜色徽章)
|
||||
- [x] 奖项列(彩色标签)
|
||||
- [x] 奖项筛选(动态从数据提取)
|
||||
- [x] 单个设奖(combobox,选项来自自动设奖配置 + 已有数据)
|
||||
- [x] 自动设奖改为自定义奖项(动态添加行:奖项名称+人数)
|
||||
- [x] 后端 AutoSetAwardsDto 改为 awards 数组格式
|
||||
- [x] 发布按钮二次确认
|
||||
|
||||
### 通知公告
|
||||
- [x] 主色调统一
|
||||
- [x] 发布/取消发布二次确认
|
||||
- [x] 操作逻辑优化(未发布:发布/编辑/删除;已发布:查看/取消发布)
|
||||
- [x] 发布状态筛选
|
||||
- [x] 日期改为时间范围选择器
|
||||
- [x] 创建时间列 + 按创建时间倒序
|
||||
- [x] 后端支持 publishStartDate / publishEndDate 范围查询
|
||||
|
||||
### 新增 API
|
||||
```
|
||||
GET /contests/dashboard — 租户端仪表盘
|
||||
GET /contests/stats (加 tenantId) — 活动统计支持租户过滤
|
||||
GET /tenants/my-tenant — 获取当前租户信息
|
||||
PATCH /tenants/my-tenant — 更新当前租户信息
|
||||
POST /contests/registrations/batch-review — 批量审核报名
|
||||
PATCH /contests/registrations/:id/revoke — 撤销报名审核
|
||||
GET /contests/registrations/stats (加 tenantId) — 报名统计支持租户过滤
|
||||
```
|
||||
|
||||
### 成果发布详情(Detail)— 功能补全
|
||||
- [x] 统计摘要(总作品/已评分/已排名/已设奖/平均分)
|
||||
- [x] 三步操作流程(计算得分→计算排名→设置奖项)
|
||||
- [x] 排名列(金银铜色徽章)+ 奖项列(彩色标签)+ 奖项筛选
|
||||
- [x] 自定义奖项支持(动态添加奖项名称+人数,替代硬编码一/二/三等奖)
|
||||
- [x] 单个设奖(combobox,选项来自自动设奖配置 + 已有数据)
|
||||
- [x] 后端 AutoSetAwardsDto 改为 awards 数组格式
|
||||
|
||||
### 数据统计模块(新增)
|
||||
- [x] 后端 analytics.module / controller / service
|
||||
- [x] GET /analytics/overview — 核心指标+漏斗+月度趋势+活动对比
|
||||
- [x] GET /analytics/review — 评审效率+评委工作量+奖项分布
|
||||
- [x] 前端安装 echarts + vue-echarts
|
||||
- [x] analytics/Overview.vue — 6个指标卡片 + 报名转化漏斗 + ECharts月度趋势折线图 + 活动对比表
|
||||
- [x] analytics/Review.vue — 4个效率卡片 + 评委工作量表 + ECharts奖项分布饼图
|
||||
- [x] 菜单注册:数据统计(运营概览 + 评审分析)
|
||||
|
||||
### Bug 修复
|
||||
- [x] 超管端重置其他租户用户密码报「用户不存在」— controller 增加超管判断跳过租户过滤
|
||||
- [x] gdlib 登录快捷标签密码与实际不一致 — 更新为 admin123
|
||||
|
||||
### 新增 API(完整)
|
||||
```
|
||||
GET /contests/dashboard — 租户端仪表盘
|
||||
GET /contests/stats (加 tenantId) — 活动统计支持租户过滤
|
||||
GET /tenants/my-tenant — 获取当前租户信息
|
||||
PATCH /tenants/my-tenant — 更新当前租户信息
|
||||
POST /contests/registrations/batch-review — 批量审核报名
|
||||
PATCH /contests/registrations/:id/revoke — 撤销报名审核
|
||||
GET /contests/registrations/stats (加 tenantId) — 报名统计支持租户过滤
|
||||
GET /analytics/overview — 运营概览统计
|
||||
GET /analytics/review — 评审分析统计
|
||||
```
|
||||
|
||||
### 数据库变更
|
||||
- menus 表新增:工作台(id=50)、机构信息(id=51)、数据统计(id=52)、运营概览(id=53)、评审分析(id=54)
|
||||
- permissions 表新增:log:read、notice:update、notice:delete(gdlib 租户)
|
||||
- work_tags 表新增 color 字段
|
||||
- 前端依赖新增:echarts、vue-echarts
|
||||
@ -20,10 +20,12 @@
|
||||
"ant-design-vue": "^4.1.1",
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^6.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"three": "^0.182.0",
|
||||
"vee-validate": "^4.12.4",
|
||||
"vue": "^3.4.21",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-router": "^4.3.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
62
frontend/src/api/analytics.ts
Normal file
62
frontend/src/api/analytics.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface OverviewData {
|
||||
summary: {
|
||||
totalContests: number
|
||||
totalRegistrations: number
|
||||
passedRegistrations: number
|
||||
totalWorks: number
|
||||
reviewedWorks: number
|
||||
awardedWorks: number
|
||||
}
|
||||
funnel: {
|
||||
registered: number
|
||||
passed: number
|
||||
submitted: number
|
||||
reviewed: number
|
||||
awarded: number
|
||||
}
|
||||
monthlyTrend: Array<{ month: string; registrations: number; works: number }>
|
||||
contestComparison: Array<{
|
||||
contestId: number
|
||||
contestName: string
|
||||
registrations: number
|
||||
passRate: number
|
||||
submitRate: number
|
||||
reviewRate: number
|
||||
awardRate: number
|
||||
avgScore: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ReviewData {
|
||||
efficiency: {
|
||||
avgReviewDays: number
|
||||
dailyReviewCount: number
|
||||
pendingAssignments: number
|
||||
avgScoreStddev: number
|
||||
}
|
||||
judgeWorkload: Array<{
|
||||
judgeId: number
|
||||
judgeName: string
|
||||
contestCount: number
|
||||
assignedCount: number
|
||||
scoredCount: number
|
||||
completionRate: number
|
||||
avgScore: number | null
|
||||
scoreStddev: number
|
||||
}>
|
||||
awardDistribution: Array<{
|
||||
awardName: string
|
||||
count: number
|
||||
percentage: number
|
||||
}>
|
||||
}
|
||||
|
||||
export const analyticsApi = {
|
||||
getOverview: (params?: { timeRange?: string; contestId?: number }): Promise<OverviewData> =>
|
||||
request.get('/analytics/overview', { params }),
|
||||
|
||||
getReview: (params?: { contestId?: number }): Promise<ReviewData> =>
|
||||
request.get('/analytics/review', { params }),
|
||||
}
|
||||
@ -841,6 +841,16 @@ export const registrationsApi = {
|
||||
return response;
|
||||
},
|
||||
|
||||
// 撤销报名审核
|
||||
revokeReview: async (id: number): Promise<ContestRegistration> => {
|
||||
return await request.patch<any, ContestRegistration>(`/contests/registrations/${id}/revoke`);
|
||||
},
|
||||
|
||||
// 批量审核报名
|
||||
batchReview: async (data: { ids: number[]; registrationState: string; reason?: string }): Promise<{ success: boolean; count: number }> => {
|
||||
return await request.post<any, { success: boolean; count: number }>('/contests/registrations/batch-review', data);
|
||||
},
|
||||
|
||||
// 删除报名
|
||||
delete: async (id: number): Promise<void> => {
|
||||
return await request.delete<any, void>(`/contests/registrations/${id}`);
|
||||
@ -1333,7 +1343,7 @@ export interface ResultsSummary {
|
||||
}
|
||||
|
||||
export interface SetAwardForm {
|
||||
awardLevel: 'first' | 'second' | 'third' | 'excellent' | 'none';
|
||||
awardLevel: string;
|
||||
awardName?: string;
|
||||
certificateUrl?: string;
|
||||
}
|
||||
@ -1347,10 +1357,7 @@ export interface BatchSetAwardsForm {
|
||||
}
|
||||
|
||||
export interface AutoSetAwardsForm {
|
||||
first?: number;
|
||||
second?: number;
|
||||
third?: number;
|
||||
excellent?: number;
|
||||
awards: Array<{ name: string; count: number }>;
|
||||
}
|
||||
|
||||
// 成果管理
|
||||
|
||||
@ -254,7 +254,7 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
title: "参赛作品详情",
|
||||
requiresAuth: true,
|
||||
permissions: ["work:read"],
|
||||
permissions: ["contest:work:read"],
|
||||
},
|
||||
},
|
||||
// 作业提交记录路由
|
||||
|
||||
@ -15,6 +15,10 @@ const EmptyLayout = () => import("@/layouts/EmptyLayout.vue")
|
||||
const componentMap: Record<string, () => Promise<any>> = {
|
||||
// 工作台模块
|
||||
"workbench/Index": () => import("@/views/workbench/Index.vue"),
|
||||
"workbench/TenantDashboard": () => import("@/views/workbench/TenantDashboard.vue"),
|
||||
"analytics/Overview": () => import("@/views/analytics/Overview.vue"),
|
||||
"analytics/Review": () => import("@/views/analytics/Review.vue"),
|
||||
"system/tenant-info/Index": () => import("@/views/system/tenant-info/Index.vue"),
|
||||
"workbench/ai-3d/Index": () => import("@/views/workbench/ai-3d/Index.vue"),
|
||||
// 学校管理模块
|
||||
"school/schools/Index": () => import("@/views/school/schools/Index.vue"),
|
||||
@ -43,7 +47,7 @@ const componentMap: Record<string, () => Promise<any>> = {
|
||||
"contests/judges/Index": () => import("@/views/contests/judges/Index.vue"),
|
||||
"contests/results/Index": () => import("@/views/contests/results/Index.vue"),
|
||||
"contests/notices/Index": () => import("@/views/contests/notices/Index.vue"),
|
||||
"contests/ReviewRules": () => import("@/views/contests/Index.vue"), // 评审规则临时使用活动列表
|
||||
"contests/ReviewRules": () => import("@/views/contests/reviews/Index.vue"),
|
||||
// 内容管理模块
|
||||
"content/WorkReview": () => import("@/views/content/WorkReview.vue"),
|
||||
"content/WorkManagement": () => import("@/views/content/WorkManagement.vue"),
|
||||
|
||||
226
frontend/src/views/analytics/Overview.vue
Normal file
226
frontend/src/views/analytics/Overview.vue
Normal file
@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div class="analytics-overview">
|
||||
<a-card class="title-card">
|
||||
<template #title>运营概览</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-select v-model:value="contestFilter" style="width: 200px" placeholder="全部活动" allow-clear @change="fetchData">
|
||||
<a-select-option v-for="c in contestOptions" :key="c.id" :value="c.id">{{ c.name }}</a-select-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<!-- 核心指标卡片 -->
|
||||
<div class="stats-row">
|
||||
<div v-for="item in statsItems" :key="item.key" class="stat-card">
|
||||
<div class="stat-icon" :style="{ background: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-count">{{ item.value }}</span>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 漏斗 + 趋势 -->
|
||||
<div class="grid-2">
|
||||
<!-- 报名转化漏斗 -->
|
||||
<div class="card-section">
|
||||
<h3 class="section-title">报名转化漏斗</h3>
|
||||
<div class="funnel-list">
|
||||
<div v-for="(item, idx) in funnelItems" :key="item.label" class="funnel-item">
|
||||
<div class="funnel-header">
|
||||
<span class="funnel-label">{{ item.label }}</span>
|
||||
<div class="funnel-values">
|
||||
<span v-if="idx > 0" class="funnel-rate" :style="{ background: item.rateBg, color: item.rateColor }">{{ item.rate }}</span>
|
||||
<span class="funnel-count">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="funnel-bar-bg">
|
||||
<div class="funnel-bar" :style="{ width: item.width + '%', background: item.gradient }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 月度趋势 -->
|
||||
<div class="card-section">
|
||||
<h3 class="section-title">月度趋势</h3>
|
||||
<v-chart :option="trendOption" autoresize style="height: 280px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 活动对比 -->
|
||||
<div class="card-section" style="margin-top: 16px">
|
||||
<h3 class="section-title">活动对比</h3>
|
||||
<a-table :columns="comparisonColumns" :data-source="data?.contestComparison || []" :pagination="false" row-key="contestId" size="small">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'passRate' || column.key === 'submitRate' || column.key === 'reviewRate' || column.key === 'awardRate'">
|
||||
<span class="rate-pill" :class="getRateClass(record[column.key])">{{ record[column.key] }}%</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'avgScore'">
|
||||
<span v-if="record.avgScore" class="score-text">{{ record.avgScore }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
TrophyOutlined, TeamOutlined, CheckCircleOutlined,
|
||||
FileTextOutlined, AuditOutlined, StarOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import { analyticsApi, type OverviewData } from '@/api/analytics'
|
||||
import { contestsApi } from '@/api/contests'
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent])
|
||||
|
||||
const loading = ref(true)
|
||||
const data = ref<OverviewData | null>(null)
|
||||
const contestFilter = ref<number | undefined>(undefined)
|
||||
const contestOptions = ref<{ id: number; name: string }[]>([])
|
||||
|
||||
const statsItems = computed(() => {
|
||||
const s = data.value?.summary
|
||||
if (!s) return []
|
||||
return [
|
||||
{ key: 'contests', label: '活动总数', value: s.totalContests, icon: TrophyOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
|
||||
{ key: 'reg', label: '累计报名', value: s.totalRegistrations, icon: TeamOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
|
||||
{ key: 'passed', label: '报名通过', value: s.passedRegistrations, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
|
||||
{ key: 'works', label: '作品总数', value: s.totalWorks, icon: FileTextOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
|
||||
{ key: 'reviewed', label: '已完成评审', value: s.reviewedWorks, icon: AuditOutlined, color: '#14b8a6', bgColor: 'rgba(20,184,166,0.1)' },
|
||||
{ key: 'awarded', label: '获奖作品', value: s.awardedWorks, icon: StarOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)' },
|
||||
]
|
||||
})
|
||||
|
||||
const funnelItems = computed(() => {
|
||||
const f = data.value?.funnel
|
||||
if (!f) return []
|
||||
const max = f.registered || 1
|
||||
const calcRate = (cur: number, prev: number) => prev > 0 ? (cur / prev * 100).toFixed(1) + '%' : '0%'
|
||||
return [
|
||||
{ label: '报名', value: f.registered, width: 100, gradient: 'linear-gradient(90deg,#6366f1,#818cf8)', rate: '', rateBg: '', rateColor: '' },
|
||||
{ label: '通过审核', value: f.passed, width: f.passed / max * 100, gradient: 'linear-gradient(90deg,#10b981,#34d399)', rate: calcRate(f.passed, f.registered), rateBg: '#ecfdf5', rateColor: '#10b981' },
|
||||
{ label: '提交作品', value: f.submitted, width: f.submitted / max * 100, gradient: 'linear-gradient(90deg,#3b82f6,#60a5fa)', rate: calcRate(f.submitted, f.passed), rateBg: '#eff6ff', rateColor: '#3b82f6' },
|
||||
{ label: '评审完成', value: f.reviewed, width: f.reviewed / max * 100, gradient: 'linear-gradient(90deg,#f59e0b,#fbbf24)', rate: calcRate(f.reviewed, f.submitted), rateBg: '#fffbeb', rateColor: '#f59e0b' },
|
||||
{ label: '获奖', value: f.awarded, width: f.awarded / max * 100, gradient: 'linear-gradient(90deg,#ef4444,#f87171)', rate: calcRate(f.awarded, f.reviewed), rateBg: '#fef2f2', rateColor: '#ef4444' },
|
||||
]
|
||||
})
|
||||
|
||||
const trendOption = computed(() => {
|
||||
const trend = data.value?.monthlyTrend || []
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['报名量', '作品量'], bottom: 0, textStyle: { fontSize: 12, color: '#9ca3af' } },
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 40 },
|
||||
xAxis: { type: 'category', data: trend.map(t => t.month), axisLine: { lineStyle: { color: '#e5e7eb' } }, axisLabel: { color: '#9ca3af' }, axisTick: { show: false } },
|
||||
yAxis: { type: 'value', axisLine: { show: false }, axisTick: { show: false }, splitLine: { lineStyle: { color: '#f3f4f6', type: 'dashed' } }, axisLabel: { color: '#9ca3af' } },
|
||||
series: [
|
||||
{ name: '报名量', type: 'line', data: trend.map(t => t.registrations), smooth: true, symbol: 'circle', symbolSize: 6, lineStyle: { width: 3, color: '#6366f1' }, itemStyle: { color: '#6366f1' }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(99,102,241,0.15)' }, { offset: 1, color: 'rgba(99,102,241,0)' }] } } },
|
||||
{ name: '作品量', type: 'line', data: trend.map(t => t.works), smooth: true, symbol: 'circle', symbolSize: 6, lineStyle: { width: 3, color: '#f59e0b' }, itemStyle: { color: '#f59e0b' }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(245,158,11,0.12)' }, { offset: 1, color: 'rgba(245,158,11,0)' }] } } },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const comparisonColumns = [
|
||||
{ title: '活动名称', dataIndex: 'contestName', key: 'contestName', width: 200 },
|
||||
{ title: '报名数', dataIndex: 'registrations', key: 'registrations', width: 80, align: 'center' as const },
|
||||
{ title: '通过率', key: 'passRate', width: 90, align: 'center' as const },
|
||||
{ title: '提交率', key: 'submitRate', width: 90, align: 'center' as const },
|
||||
{ title: '评审完成率', key: 'reviewRate', width: 100, align: 'center' as const },
|
||||
{ title: '获奖率', key: 'awardRate', width: 90, align: 'center' as const },
|
||||
{ title: '平均分', key: 'avgScore', width: 80, align: 'center' as const },
|
||||
]
|
||||
|
||||
const getRateClass = (rate: number) => {
|
||||
if (rate >= 80) return 'rate-high'
|
||||
if (rate >= 50) return 'rate-mid'
|
||||
if (rate > 0) return 'rate-low'
|
||||
return 'rate-zero'
|
||||
}
|
||||
|
||||
const fetchContestOptions = async () => {
|
||||
try {
|
||||
const res = await contestsApi.getList({ page: 1, pageSize: 100 })
|
||||
contestOptions.value = res.list.map(c => ({ id: c.id, name: c.contestName }))
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await analyticsApi.getOverview({ contestId: contestFilter.value })
|
||||
} catch { message.error('获取统计数据失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchContestOptions(); fetchData() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||
:deep(.ant-card-body) { padding: 0; }
|
||||
}
|
||||
|
||||
.stats-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card {
|
||||
display: flex; align-items: center; gap: 12px; padding: 16px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); transition: all 0.2s;
|
||||
&:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba($primary, 0.12); }
|
||||
.stat-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
||||
.stat-info { display: flex; flex-direction: column;
|
||||
.stat-count { font-size: 22px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
|
||||
.stat-label { font-size: 12px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
.card-section {
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 24px;
|
||||
.section-title { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; }
|
||||
}
|
||||
|
||||
// 漏斗
|
||||
.funnel-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.funnel-item {
|
||||
.funnel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.funnel-label { font-size: 13px; font-weight: 500; color: #374151; }
|
||||
.funnel-values { display: flex; align-items: center; gap: 8px; }
|
||||
.funnel-count { font-size: 14px; font-weight: 700; color: #1e1b4b; }
|
||||
.funnel-rate { display: inline-flex; padding: 1px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
|
||||
.funnel-bar-bg { height: 28px; background: #f3f4f6; border-radius: 8px; overflow: hidden; }
|
||||
.funnel-bar { height: 100%; border-radius: 8px; transition: width 0.8s cubic-bezier(0.4,0,0.2,1); }
|
||||
}
|
||||
|
||||
// 转化率标签
|
||||
.rate-pill { display: inline-flex; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||
.rate-high { background: #ecfdf5; color: #10b981; }
|
||||
.rate-mid { background: #fffbeb; color: #f59e0b; }
|
||||
.rate-low { background: #fef2f2; color: #ef4444; }
|
||||
.rate-zero { background: #f3f4f6; color: #d1d5db; }
|
||||
|
||||
.score-text { font-weight: 700; color: $primary; }
|
||||
.text-muted { color: #d1d5db; }
|
||||
|
||||
:deep(.ant-table-wrapper) { background: transparent;
|
||||
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; font-size: 13px; }
|
||||
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
|
||||
}
|
||||
</style>
|
||||
218
frontend/src/views/analytics/Review.vue
Normal file
218
frontend/src/views/analytics/Review.vue
Normal file
@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="analytics-review">
|
||||
<a-card class="title-card">
|
||||
<template #title>评审分析</template>
|
||||
<template #extra>
|
||||
<a-select v-model:value="contestFilter" style="width: 200px" placeholder="全部活动" allow-clear @change="fetchData">
|
||||
<a-select-option v-for="c in contestOptions" :key="c.id" :value="c.id">{{ c.name }}</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<!-- 效率卡片 -->
|
||||
<div class="stats-row">
|
||||
<div v-for="item in efficiencyItems" :key="item.key" class="stat-card">
|
||||
<div class="stat-icon" :style="{ background: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-count">{{ item.value }}<span class="stat-unit">{{ item.unit }}</span></span>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
<span v-if="item.hint" class="stat-hint">{{ item.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-5-2">
|
||||
<!-- 评委工作量 -->
|
||||
<div class="card-section col-span-3">
|
||||
<h3 class="section-title">评委工作量</h3>
|
||||
<a-table :columns="judgeColumns" :data-source="data?.judgeWorkload || []" :pagination="false" row-key="judgeId" size="small">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'judgeName'">
|
||||
<div class="judge-cell">
|
||||
<div class="judge-avatar" :style="{ background: getAvatarColor(record.judgeName) }">{{ record.judgeName?.charAt(0) }}</div>
|
||||
<span class="judge-name">{{ record.judgeName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'completionRate'">
|
||||
<span class="rate-pill" :class="getRateClass(record.completionRate)">{{ record.completionRate }}%</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'avgScore'">
|
||||
<span v-if="record.avgScore" class="score-text">{{ record.avgScore }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'scoreStddev'">
|
||||
<span :class="getStddevClass(record.scoreStddev)">{{ record.scoreStddev }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 奖项分布 -->
|
||||
<div class="card-section col-span-2">
|
||||
<h3 class="section-title">奖项分布</h3>
|
||||
<div v-if="data?.awardDistribution?.length">
|
||||
<v-chart :option="awardOption" autoresize style="height: 260px" />
|
||||
</div>
|
||||
<a-empty v-else description="暂无奖项数据" style="padding: 60px 0" />
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ClockCircleOutlined, ThunderboltOutlined, WarningOutlined, BarChartOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { PieChart } from 'echarts/charts'
|
||||
import { TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import { analyticsApi, type ReviewData } from '@/api/analytics'
|
||||
import { contestsApi } from '@/api/contests'
|
||||
|
||||
use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent])
|
||||
|
||||
const loading = ref(true)
|
||||
const data = ref<ReviewData | null>(null)
|
||||
const contestFilter = ref<number | undefined>(undefined)
|
||||
const contestOptions = ref<{ id: number; name: string }[]>([])
|
||||
|
||||
const avatarColors = ['#6366f1', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']
|
||||
const getAvatarColor = (name: string) => {
|
||||
const idx = name ? name.charCodeAt(0) % avatarColors.length : 0
|
||||
return `linear-gradient(135deg, ${avatarColors[idx]}, ${avatarColors[(idx + 1) % avatarColors.length]})`
|
||||
}
|
||||
|
||||
const efficiencyItems = computed(() => {
|
||||
const e = data.value?.efficiency
|
||||
if (!e) return []
|
||||
return [
|
||||
{ key: 'days', label: '平均评审周期', value: e.avgReviewDays, unit: '天', icon: ClockCircleOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)', hint: '' },
|
||||
{ key: 'daily', label: '日均评审量', value: e.dailyReviewCount, unit: '个/日', icon: ThunderboltOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)', hint: '' },
|
||||
{ key: 'pending', label: '待评审积压', value: e.pendingAssignments, unit: '个', icon: WarningOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)', hint: '' },
|
||||
{ key: 'stddev', label: '评分一致性', value: e.avgScoreStddev, unit: '分', icon: BarChartOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)', hint: '标准差越小越好' },
|
||||
]
|
||||
})
|
||||
|
||||
const awardColors = ['#ef4444', '#f59e0b', '#3b82f6', '#10b981', '#8b5cf6', '#ec4899', '#14b8a6']
|
||||
const awardOption = computed(() => {
|
||||
const dist = data.value?.awardDistribution || []
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, textStyle: { fontSize: 12, color: '#9ca3af' } },
|
||||
series: [{
|
||||
type: 'pie', radius: ['45%', '72%'], center: ['50%', '45%'],
|
||||
label: { show: true, formatter: '{b}\n{d}%', fontSize: 12, color: '#6b7280' },
|
||||
labelLine: { length: 12, length2: 8 },
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 3 },
|
||||
data: dist.map((d, i) => ({
|
||||
value: d.count,
|
||||
name: d.awardName,
|
||||
itemStyle: { color: awardColors[i % awardColors.length] },
|
||||
})),
|
||||
emphasis: { itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,0,0,0.12)' } },
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
const judgeColumns = [
|
||||
{ title: '评委姓名', key: 'judgeName', width: 140 },
|
||||
{ title: '关联活动', dataIndex: 'contestCount', key: 'contestCount', width: 80, align: 'center' as const },
|
||||
{ title: '已分配', dataIndex: 'assignedCount', key: 'assignedCount', width: 70, align: 'center' as const },
|
||||
{ title: '已评分', dataIndex: 'scoredCount', key: 'scoredCount', width: 70, align: 'center' as const },
|
||||
{ title: '完成率', key: 'completionRate', width: 80, align: 'center' as const },
|
||||
{ title: '平均分', key: 'avgScore', width: 80, align: 'center' as const },
|
||||
{ title: '标准差', key: 'scoreStddev', width: 80, align: 'center' as const },
|
||||
]
|
||||
|
||||
const getRateClass = (rate: number) => {
|
||||
if (rate >= 80) return 'rate-high'
|
||||
if (rate >= 50) return 'rate-mid'
|
||||
return 'rate-low'
|
||||
}
|
||||
|
||||
const getStddevClass = (stddev: number) => {
|
||||
if (stddev <= 3) return 'stddev-good'
|
||||
if (stddev <= 6) return 'stddev-ok'
|
||||
return 'stddev-bad'
|
||||
}
|
||||
|
||||
const fetchContestOptions = async () => {
|
||||
try {
|
||||
const res = await contestsApi.getList({ page: 1, pageSize: 100 })
|
||||
contestOptions.value = res.list.map(c => ({ id: c.id, name: c.contestName }))
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await analyticsApi.getReview({ contestId: contestFilter.value })
|
||||
} catch { message.error('获取评审分析数据失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchContestOptions(); fetchData() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||
:deep(.ant-card-body) { padding: 0; }
|
||||
}
|
||||
|
||||
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card {
|
||||
display: flex; align-items: center; gap: 12px; padding: 18px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); transition: all 0.2s;
|
||||
&:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba($primary, 0.12); }
|
||||
.stat-icon { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
|
||||
.stat-info { display: flex; flex-direction: column;
|
||||
.stat-count { font-size: 24px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
|
||||
.stat-unit { font-size: 13px; font-weight: 400; color: #9ca3af; margin-left: 2px; }
|
||||
.stat-label { font-size: 12px; color: #9ca3af; margin-top: 2px; }
|
||||
.stat-hint { font-size: 10px; color: #d1d5db; }
|
||||
}
|
||||
}
|
||||
|
||||
.grid-5-2 { display: grid; grid-template-columns: 3fr 2fr; gap: 16px; }
|
||||
.col-span-3 { grid-column: 1; }
|
||||
.col-span-2 { grid-column: 2; }
|
||||
|
||||
.card-section {
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 24px;
|
||||
.section-title { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; }
|
||||
}
|
||||
|
||||
.judge-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.judge-avatar {
|
||||
width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 12px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.judge-name { font-weight: 500; color: #1e1b4b; }
|
||||
|
||||
.rate-pill { display: inline-flex; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||
.rate-high { background: #ecfdf5; color: #10b981; }
|
||||
.rate-mid { background: #fffbeb; color: #f59e0b; }
|
||||
.rate-low { background: #fef2f2; color: #ef4444; }
|
||||
|
||||
.score-text { font-weight: 700; color: $primary; }
|
||||
.text-muted { color: #d1d5db; }
|
||||
|
||||
.stddev-good { font-weight: 600; color: #10b981; }
|
||||
.stddev-ok { font-weight: 600; color: #f59e0b; }
|
||||
.stddev-bad { font-weight: 600; color: #ef4444; }
|
||||
|
||||
:deep(.ant-table-wrapper) { background: transparent;
|
||||
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; font-size: 13px; }
|
||||
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
|
||||
}
|
||||
</style>
|
||||
@ -119,7 +119,7 @@ const isDev = import.meta.env.DEV
|
||||
// 开发环境快捷切换 — 按新架构设计
|
||||
const tenantTabs = [
|
||||
{ code: "super", name: "平台超管", icon: SafetyOutlined, username: "admin", password: "admin@super" },
|
||||
{ code: "gdlib", name: "广东省图", icon: BankOutlined, username: "admin", password: "admin@gdlib" },
|
||||
{ code: "gdlib", name: "广东省图", icon: BankOutlined, username: "admin", password: "admin123" },
|
||||
{ code: "judge", name: "评委端", icon: TrophyOutlined, username: "admin", password: "admin@judge" },
|
||||
]
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,8 +15,8 @@
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<!-- 超管统计卡片 -->
|
||||
<div v-if="isSuperAdmin" class="stats-row">
|
||||
<!-- 统计卡片(超管+租户端都显示) -->
|
||||
<div class="stats-row">
|
||||
<div
|
||||
v-for="item in statsItems"
|
||||
:key="item.stage"
|
||||
@ -34,38 +34,13 @@
|
||||
</div>
|
||||
|
||||
<!-- 搜索表单 -->
|
||||
<a-form
|
||||
:model="searchParams"
|
||||
layout="inline"
|
||||
class="search-form"
|
||||
@finish="handleSearch"
|
||||
>
|
||||
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
|
||||
<a-form-item label="活动名称">
|
||||
<a-input
|
||||
v-model:value="searchParams.contestName"
|
||||
placeholder="请输入活动名称"
|
||||
allow-clear
|
||||
style="width: 180px"
|
||||
/>
|
||||
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 180px" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="!isSuperAdmin" label="活动状态">
|
||||
<a-select
|
||||
v-model:value="searchParams.contestState"
|
||||
placeholder="请选择状态"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
>
|
||||
<a-select-option value="published">已发布</a-select-option>
|
||||
<a-select-option value="unpublished">未发布</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="isSuperAdmin" label="活动阶段">
|
||||
<a-select
|
||||
v-model:value="searchParams.stage"
|
||||
placeholder="全部阶段"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
>
|
||||
<a-form-item label="活动阶段">
|
||||
<a-select v-model:value="searchParams.stage" placeholder="全部阶段" allow-clear style="width: 120px" @change="handleSearch">
|
||||
<a-select-option value="">全部</a-select-option>
|
||||
<a-select-option value="unpublished">未发布</a-select-option>
|
||||
<a-select-option value="registering">报名中</a-select-option>
|
||||
<a-select-option value="submitting">征稿中</a-select-option>
|
||||
@ -74,70 +49,34 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="活动类型">
|
||||
<a-select
|
||||
v-model:value="searchParams.contestType"
|
||||
placeholder="请选择类型"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
>
|
||||
<a-select v-model:value="searchParams.contestType" placeholder="全部" allow-clear style="width: 120px" @change="handleSearch">
|
||||
<a-select-option value="individual">个人参与</a-select-option>
|
||||
<a-select-option value="team">团队参与</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="可见范围">
|
||||
<a-select
|
||||
v-model:value="searchParams.visibility"
|
||||
placeholder="全部"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
>
|
||||
<a-select-option value="public">公开</a-select-option>
|
||||
<a-select-option value="targeted">定向推送</a-select-option>
|
||||
<a-select-option value="designated">指定机构</a-select-option>
|
||||
<a-select-option value="internal">仅内部</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="isSuperAdmin" label="主办机构">
|
||||
<a-select
|
||||
v-model:value="searchParams.creatorTenantId"
|
||||
placeholder="全部机构"
|
||||
allow-clear
|
||||
show-search
|
||||
:filter-option="filterTenantOption"
|
||||
style="width: 160px"
|
||||
:options="tenantOptions"
|
||||
/>
|
||||
<a-select v-model:value="searchParams.creatorTenantId" placeholder="全部机构" allow-clear show-search
|
||||
:filter-option="filterTenantOption" style="width: 160px" :options="tenantOptions" @change="handleSearch" />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
<template #icon><SearchOutlined /></template> 搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
<template #icon><ReloadOutlined /></template> 重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-table
|
||||
:columns="currentColumns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
class="data-table"
|
||||
>
|
||||
<a-table :columns="currentColumns" :data-source="dataSource" :loading="loading" :pagination="pagination"
|
||||
row-key="id" @change="handleTableChange" class="data-table">
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'index'">
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'organizer'">
|
||||
<span v-if="isSuperAdmin && record.creatorTenant">
|
||||
{{ record.creatorTenant.name }}
|
||||
</span>
|
||||
<span v-if="isSuperAdmin && record.creatorTenant">{{ record.creatorTenant.name }}</span>
|
||||
<span v-else-if="record.organizers">
|
||||
{{ typeof record.organizers === 'string' ? record.organizers : (Array.isArray(record.organizers) ? record.organizers[0] : '-') }}
|
||||
</span>
|
||||
@ -145,7 +84,7 @@
|
||||
</template>
|
||||
<template v-else-if="column.key === 'contestType'">
|
||||
<a-tag :color="record.contestType === 'individual' ? 'blue' : 'green'">
|
||||
{{ record.contestType === "individual" ? "个人参与" : "团队参与" }}
|
||||
{{ record.contestType === 'individual' ? '个人' : '团队' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'stage'">
|
||||
@ -153,80 +92,48 @@
|
||||
{{ stageTagMap[record.stage]?.label || '已发布' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'contestState'">
|
||||
<a-tag :color="record.contestState === 'published' ? 'success' : 'default'">
|
||||
{{ record.contestState === "published" ? "已发布" : "未发布" }}
|
||||
</a-tag>
|
||||
<template v-else-if="column.key === 'regCount'">
|
||||
<a-button v-if="record._count?.registrations > 0" type="link" size="small" class="count-link"
|
||||
@click="goToRegistrations(record.id)">
|
||||
{{ record._count.registrations }}
|
||||
</a-button>
|
||||
<span v-else class="text-muted">0</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'workCount'">
|
||||
<a-button v-if="record._count?.works > 0" type="link" size="small" class="count-link"
|
||||
@click="goToWorks(record.id)">
|
||||
{{ record._count.works }}
|
||||
</a-button>
|
||||
<span v-else class="text-muted">0</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'reviewProgress'">
|
||||
<span v-if="record.totalWorksCount > 0">
|
||||
<span :class="{ 'text-success': record.reviewedCount === record.totalWorksCount }">{{ record.reviewedCount }}</span>
|
||||
<span class="text-muted">/{{ record.totalWorksCount }}</span>
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'judges'">
|
||||
<a-tag v-if="record._count?.judges > 0" color="blue">{{ record._count.judges }}人</a-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'visibility'">
|
||||
<a-tag v-if="record.visibility === 'public'" color="green">公开</a-tag>
|
||||
<a-tag v-else-if="record.visibility === 'targeted'" color="orange">
|
||||
<a-tooltip>
|
||||
<template #title>
|
||||
<div v-if="record.targetCities?.length">城市:{{ record.targetCities.join('、') }}</div>
|
||||
<div v-if="record.ageMin || record.ageMax">年龄:{{ record.ageMin || 0 }}-{{ record.ageMax || '不限' }}岁</div>
|
||||
<div v-if="!record.targetCities?.length && !record.ageMin && !record.ageMax">无附加条件</div>
|
||||
</template>
|
||||
定向推送
|
||||
</a-tooltip>
|
||||
</a-tag>
|
||||
<a-tag v-else-if="record.visibility === 'internal'" color="default">仅内部</a-tag>
|
||||
<a-tag v-else-if="record.visibility === 'targeted'" color="orange">定向</a-tag>
|
||||
<a-tag v-else-if="record.visibility === 'internal'" color="default">内部</a-tag>
|
||||
<a-tag v-else color="blue">指定机构</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'publicScope'">
|
||||
<template v-if="record.contestTenants && record.contestTenants.length > 0">
|
||||
<a-tooltip>
|
||||
<template #title>
|
||||
<div v-for="tenantId in record.contestTenants" :key="tenantId">
|
||||
{{ getTenantName(tenantId) }}
|
||||
</div>
|
||||
<div v-for="tid in record.contestTenants" :key="tid">{{ getTenantName(tid) }}</div>
|
||||
</template>
|
||||
<a-tag>{{ record.contestTenants.length }}个机构</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'regCount'">
|
||||
<a-button
|
||||
v-if="isSuperAdmin && record._count?.registrations > 0"
|
||||
type="link"
|
||||
size="small"
|
||||
class="count-link"
|
||||
@click="goToRegistrations(record.id)"
|
||||
>
|
||||
{{ record._count.registrations }}
|
||||
</a-button>
|
||||
<span v-else-if="record._count?.registrations > 0">{{ record._count.registrations }}</span>
|
||||
<span v-else class="text-muted">0</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'workCount'">
|
||||
<a-button
|
||||
v-if="isSuperAdmin && record._count?.works > 0"
|
||||
type="link"
|
||||
size="small"
|
||||
class="count-link"
|
||||
@click="goToWorks(record.id)"
|
||||
>
|
||||
{{ record._count.works }}
|
||||
</a-button>
|
||||
<span v-else-if="record._count?.works > 0">{{ record._count.works }}</span>
|
||||
<span v-else class="text-muted">0</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'reviewProgress'">
|
||||
<span v-if="record.totalWorksCount > 0">
|
||||
<span :class="{ 'text-success': record.reviewedCount === record.totalWorksCount }">
|
||||
{{ record.reviewedCount }}
|
||||
</span>
|
||||
<span class="text-muted">/{{ record.totalWorksCount }}</span>
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'judges'">
|
||||
<a-tag v-if="record._count?.judges > 0" color="blue">
|
||||
{{ record._count.judges }}人
|
||||
</a-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'contestTime'">
|
||||
<div v-if="record.startTime || record.endTime">
|
||||
<div>{{ formatDate(record.startTime) }}</div>
|
||||
@ -236,90 +143,58 @@
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<!-- 租户端操作 -->
|
||||
<template v-if="!isSuperAdmin">
|
||||
<a-button v-permission="'contest:publish'" type="link" size="small" @click="handlePublishClick(record)">
|
||||
{{ record.contestState === "published" ? "取消发布" : "发布" }}
|
||||
</a-button>
|
||||
<a-button v-permission="'contest:update'" type="link" size="small" @click="handleAddJudge(record.id)">
|
||||
添加评委
|
||||
</a-button>
|
||||
<a-button v-permission="'contest:update'" type="link" size="small" @click.stop="handleEdit(record.id)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button v-permission="'contest:delete'" type="link" danger size="small" @click="handleDeleteClick(record)">
|
||||
删除
|
||||
</a-button>
|
||||
<!-- 未发布:发布、编辑、删除 -->
|
||||
<template v-if="record.contestState !== 'published'">
|
||||
<a-button v-permission="'contest:publish'" type="link" size="small" style="color: #10b981" @click="handlePublishClick(record)">发布</a-button>
|
||||
<a-button v-permission="'contest:update'" type="link" size="small" @click="handleEdit(record.id)">编辑</a-button>
|
||||
<a-button v-permission="'contest:delete'" type="link" danger size="small" @click="handleDeleteClick(record)">删除</a-button>
|
||||
</template>
|
||||
<a-button
|
||||
v-if="isSuperAdmin"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="router.push(`/${tenantCode}/contests/${record.id}/overview`)"
|
||||
>
|
||||
查看详情
|
||||
</a-button>
|
||||
<!-- 已发布:查看、添加评委、取消发布 -->
|
||||
<template v-else>
|
||||
<a-button type="link" size="small" @click="router.push(`/${tenantCode}/contests/${record.id}`)">查看</a-button>
|
||||
<a-button v-permission="'contest:update'" type="link" size="small" @click="handleAddJudge(record.id)">评委</a-button>
|
||||
<a-button v-permission="'contest:update'" type="link" size="small" @click="handleEdit(record.id)">编辑</a-button>
|
||||
<a-button v-permission="'contest:publish'" type="link" size="small" style="color: #f59e0b" @click="handlePublishClick(record)">取消发布</a-button>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 超管端操作 -->
|
||||
<a-button v-if="isSuperAdmin" type="link" size="small"
|
||||
@click="router.push(`/${tenantCode}/contests/${record.id}/overview`)">查看详情</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加评委侧边弹框 -->
|
||||
<a-drawer
|
||||
v-model:open="judgeDrawerVisible"
|
||||
title="添加评委"
|
||||
placement="right"
|
||||
width="800px"
|
||||
@close="handleJudgeDrawerClose"
|
||||
>
|
||||
<AddJudgeDrawer
|
||||
v-if="currentContestId && currentContest"
|
||||
:contest-id="currentContestId"
|
||||
:contest="currentContest"
|
||||
@success="handleJudgeAddSuccess"
|
||||
/>
|
||||
<a-drawer v-model:open="judgeDrawerVisible" title="添加评委" placement="right" width="800px" @close="handleJudgeDrawerClose">
|
||||
<AddJudgeDrawer v-if="currentContestId && currentContest" :contest-id="currentContestId" :contest="currentContest" @success="handleJudgeAddSuccess" />
|
||||
</a-drawer>
|
||||
|
||||
<!-- 发布弹框 -->
|
||||
<a-modal
|
||||
v-model:open="publishModalVisible"
|
||||
title="发布活动"
|
||||
:confirm-loading="publishLoading"
|
||||
@ok="handlePublishConfirm"
|
||||
>
|
||||
<a-modal v-model:open="publishModalVisible" title="发布活动" :confirm-loading="publishLoading" @ok="handlePublishConfirm">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="选择公开范围(可见机构)" required>
|
||||
<a-select
|
||||
v-model:value="selectedTenants"
|
||||
mode="multiple"
|
||||
placeholder="请选择公开范围"
|
||||
style="width: 100%"
|
||||
:options="tenantOptions"
|
||||
:filter-option="filterTenantOption"
|
||||
show-search
|
||||
/>
|
||||
<a-select v-model:value="selectedTenants" mode="multiple" placeholder="请选择公开范围" style="width: 100%"
|
||||
:options="publishTenantOptions" :filter-option="filterTenantOption" show-search />
|
||||
<div style="margin-top: 8px">
|
||||
<a-button type="link" size="small" @click="selectedTenants = publishTenantOptions.map((o: any) => o.value)">全选</a-button>
|
||||
<a-button type="link" size="small" @click="selectedTenants = []">清空</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-alert type="warning" message="发布后,只有选中的机构可以看到此活动" show-icon class="mt-2" />
|
||||
</a-modal>
|
||||
|
||||
<!-- 取消发布确认弹框 -->
|
||||
<a-modal
|
||||
v-model:open="unpublishModalVisible"
|
||||
title="取消发布"
|
||||
:confirm-loading="publishLoading"
|
||||
@ok="handleUnpublishConfirm"
|
||||
>
|
||||
<a-modal v-model:open="unpublishModalVisible" title="取消发布" :confirm-loading="publishLoading" @ok="handleUnpublishConfirm">
|
||||
<p>确定要取消发布活动「{{ currentPublishContest?.contestName }}」吗?</p>
|
||||
<a-alert type="warning" message="取消发布后,所有机构将无法看到此活动" show-icon />
|
||||
</a-modal>
|
||||
|
||||
<!-- 删除确认弹框 -->
|
||||
<a-modal
|
||||
v-model:open="deleteModalVisible"
|
||||
title="删除活动"
|
||||
:confirm-loading="deleteLoading"
|
||||
@ok="handleDeleteConfirm"
|
||||
>
|
||||
<a-modal v-model:open="deleteModalVisible" title="删除活动" :confirm-loading="deleteLoading" @ok="handleDeleteConfirm">
|
||||
<p>确定要删除活动「{{ currentDeleteContest?.contestName }}」吗?</p>
|
||||
<a-alert type="error" message="删除后数据将无法恢复,请谨慎操作!" show-icon />
|
||||
</a-modal>
|
||||
@ -332,22 +207,11 @@ import { ref, computed, reactive, onMounted } from "vue"
|
||||
import { message } from "ant-design-vue"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
AppstoreOutlined,
|
||||
FormOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
PlusOutlined, SearchOutlined, ReloadOutlined,
|
||||
AppstoreOutlined, FormOutlined, EditOutlined, EyeOutlined,
|
||||
CheckCircleOutlined, CloseCircleOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import {
|
||||
contestsApi,
|
||||
type Contest,
|
||||
type QueryContestParams,
|
||||
type ContestStats,
|
||||
} from "@/api/contests"
|
||||
import { contestsApi, type Contest, type QueryContestParams, type ContestStats } from "@/api/contests"
|
||||
import { tenantsApi, type Tenant } from "@/api/tenants"
|
||||
import AddJudgeDrawer from "./components/AddJudgeDrawer.vue"
|
||||
import dayjs from "dayjs"
|
||||
@ -358,7 +222,7 @@ const tenantCode = route.params.tenantCode as string
|
||||
const authStore = useAuthStore()
|
||||
const isSuperAdmin = computed(() => authStore.hasAnyRole(['super_admin']))
|
||||
|
||||
// ========== 统计卡片 ==========
|
||||
// ========== #1 统计卡片(超管+租户端通用) ==========
|
||||
const stats = ref<ContestStats>({ total: 0, unpublished: 0, registering: 0, submitting: 0, reviewing: 0, finished: 0 })
|
||||
const activeStage = ref<string>('')
|
||||
|
||||
@ -372,68 +236,47 @@ const statsItems = computed(() => [
|
||||
])
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
stats.value = await contestsApi.getStats()
|
||||
} catch { /* 静默 */ }
|
||||
try { stats.value = await contestsApi.getStats() } catch { /* */ }
|
||||
}
|
||||
|
||||
const handleStatClick = (stage: string) => {
|
||||
if (activeStage.value === stage) {
|
||||
activeStage.value = ''
|
||||
searchParams.stage = undefined
|
||||
} else {
|
||||
activeStage.value = stage
|
||||
searchParams.stage = stage || undefined
|
||||
}
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// ========== 列表 ==========
|
||||
const loading = ref(false)
|
||||
const dataSource = ref<Contest[]>([])
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
})
|
||||
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` })
|
||||
const searchParams = reactive<QueryContestParams>({})
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await contestsApi.getList({
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
...searchParams,
|
||||
})
|
||||
const res = await contestsApi.getList({ page: pagination.current, pageSize: pagination.pageSize, ...searchParams })
|
||||
dataSource.value = res.list
|
||||
pagination.total = res.total
|
||||
} catch {
|
||||
message.error('获取活动列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchList()
|
||||
} catch { message.error('获取活动列表失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSearch = () => { pagination.current = 1; fetchList() }
|
||||
const handleReset = () => {
|
||||
Object.keys(searchParams).forEach((key) => {
|
||||
;(searchParams as any)[key] = undefined
|
||||
})
|
||||
Object.keys(searchParams).forEach(k => { (searchParams as any)[k] = undefined })
|
||||
activeStage.value = ''
|
||||
pagination.current = 1
|
||||
fetchList()
|
||||
fetchStats()
|
||||
}
|
||||
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
|
||||
|
||||
const handleTableChange = (pag: any) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// ========== 租户 ==========
|
||||
// ========== 租户(超管筛选用) ==========
|
||||
const tenants = ref<Tenant[]>([])
|
||||
const tenantOptions = ref<{ label: string; value: number }[]>([])
|
||||
|
||||
@ -441,51 +284,60 @@ const fetchTenants = async () => {
|
||||
try {
|
||||
const response = await tenantsApi.getList({ page: 1, pageSize: 100 })
|
||||
tenants.value = response.list
|
||||
// 主办机构筛选:排除所有系统租户,只保留真正的机构(如广东省图)
|
||||
const systemCodes = ['super', 'public', 'judge', 'teacher', 'student', 'school']
|
||||
tenantOptions.value = response.list
|
||||
.filter((t) => !t.isSuper && !systemCodes.includes(t.code))
|
||||
.map((t) => ({ label: t.name, value: t.id }))
|
||||
} catch { /* 静默 */ }
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
// #5 发布弹窗的机构选项(租户端用自己的 tenantId 作为默认选中)
|
||||
const publishTenantOptions = ref<{ label: string; value: number }[]>([])
|
||||
|
||||
const fetchPublishTenants = async () => {
|
||||
try {
|
||||
if (isSuperAdmin.value) {
|
||||
publishTenantOptions.value = tenantOptions.value
|
||||
} else {
|
||||
// 租户端:从 my-tenant 获取自己的信息,加上其他可见机构
|
||||
const myTenant = await (await import('@/utils/request')).default.get('/tenants/my-tenant') as any
|
||||
publishTenantOptions.value = [{ label: myTenant.name, value: myTenant.id }]
|
||||
}
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const getTenantName = (tenantId: number) => {
|
||||
const tenant = tenants.value.find((t) => t.id === tenantId)
|
||||
return tenant?.name || `机构${tenantId}`
|
||||
}
|
||||
const filterTenantOption = (input: string, option: any) => option.label?.toLowerCase().includes(input.toLowerCase())
|
||||
|
||||
const filterTenantOption = (input: string, option: any) => {
|
||||
return option.label?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
|
||||
// ========== 表格列定义 ==========
|
||||
// ========== #2 + #6 表格列(精简租户端) ==========
|
||||
const superColumns = [
|
||||
{ title: "序号", key: "index", width: 60 },
|
||||
{ title: "序号", key: "index", width: 50 },
|
||||
{ title: "活动名称", dataIndex: "contestName", key: "contestName", width: 200 },
|
||||
{ title: "主办机构", key: "organizer", width: 120 },
|
||||
{ title: "活动类型", key: "contestType", width: 90 },
|
||||
{ title: "活动阶段", key: "stage", width: 100 },
|
||||
{ title: "可见范围", key: "visibility", width: 90 },
|
||||
{ title: "报名", key: "regCount", width: 70 },
|
||||
{ title: "作品", key: "workCount", width: 70 },
|
||||
{ title: "评审", key: "reviewProgress", width: 80 },
|
||||
{ title: "活动时间", key: "contestTime", width: 160 },
|
||||
{ title: "操作", key: "action", width: 100, fixed: "right" as const },
|
||||
{ title: "类型", key: "contestType", width: 70 },
|
||||
{ title: "阶段", key: "stage", width: 80 },
|
||||
{ title: "可见范围", key: "visibility", width: 80 },
|
||||
{ title: "报名", key: "regCount", width: 60 },
|
||||
{ title: "作品", key: "workCount", width: 60 },
|
||||
{ title: "评审", key: "reviewProgress", width: 70 },
|
||||
{ title: "活动时间", key: "contestTime", width: 150 },
|
||||
{ title: "操作", key: "action", width: 90, fixed: "right" as const },
|
||||
]
|
||||
|
||||
// 租户端精简:去掉主办方/可见范围/公开机构,加上阶段
|
||||
const orgColumns = [
|
||||
{ title: "序号", key: "index", width: 70 },
|
||||
{ title: "活动名称", dataIndex: "contestName", key: "contestName", width: 200 },
|
||||
{ title: "主办方", key: "organizer", width: 140 },
|
||||
{ title: "活动类型", key: "contestType", width: 100 },
|
||||
{ title: "活动状态", key: "contestState", width: 100 },
|
||||
{ title: "可见范围", key: "visibility", width: 100 },
|
||||
{ title: "公开机构", key: "publicScope", width: 120 },
|
||||
{ title: "报名", key: "regCount", width: 70 },
|
||||
{ title: "作品", key: "workCount", width: 70 },
|
||||
{ title: "评委", key: "judges", width: 70 },
|
||||
{ title: "活动时间", key: "contestTime", width: 180 },
|
||||
{ title: "操作", key: "action", width: 260, fixed: "right" as const },
|
||||
{ title: "序号", key: "index", width: 50 },
|
||||
{ title: "活动名称", dataIndex: "contestName", key: "contestName", width: 220 },
|
||||
{ title: "类型", key: "contestType", width: 70 },
|
||||
{ title: "阶段", key: "stage", width: 80 },
|
||||
{ title: "报名", key: "regCount", width: 60 },
|
||||
{ title: "作品", key: "workCount", width: 60 },
|
||||
{ title: "评委", key: "judges", width: 60 },
|
||||
{ title: "活动时间", key: "contestTime", width: 150 },
|
||||
{ title: "操作", key: "action", width: 220, fixed: "right" as const },
|
||||
]
|
||||
|
||||
const currentColumns = computed(() => isSuperAdmin.value ? superColumns : orgColumns)
|
||||
@ -500,7 +352,7 @@ const stageTagMap: Record<string, { label: string; color: string }> = {
|
||||
finished: { label: '已结束', color: 'default' },
|
||||
}
|
||||
|
||||
// ========== 超管跳转 ==========
|
||||
// ========== #4 报名/作品数可点击跳转 ==========
|
||||
const goToRegistrations = (contestId: number) => {
|
||||
router.push(`/${tenantCode}/contests/registrations?contestId=${contestId}`)
|
||||
}
|
||||
@ -508,17 +360,13 @@ const goToWorks = (contestId: number) => {
|
||||
router.push(`/${tenantCode}/contests/works/${contestId}/list`)
|
||||
}
|
||||
|
||||
// ========== 工具函数 ==========
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "-"
|
||||
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
|
||||
}
|
||||
|
||||
// ========== 机构端操作(超管不可见)==========
|
||||
const handleAdd = () => {
|
||||
router.push(`/${tenantCode}/contests/create`)
|
||||
}
|
||||
|
||||
// ========== 机构端操作 ==========
|
||||
const handleAdd = () => { router.push(`/${tenantCode}/contests/create`) }
|
||||
const handleEdit = (id: number) => {
|
||||
if (!id) { message.warning("活动ID不存在"); return }
|
||||
router.push(`/${tenantCode}/contests/${id}/edit`)
|
||||
@ -531,27 +379,13 @@ const currentContest = ref<Contest | null>(null)
|
||||
|
||||
const handleAddJudge = async (id: number) => {
|
||||
currentContestId.value = id
|
||||
try {
|
||||
currentContest.value = await contestsApi.getDetail(id)
|
||||
judgeDrawerVisible.value = true
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "获取活动信息失败")
|
||||
}
|
||||
try { currentContest.value = await contestsApi.getDetail(id); judgeDrawerVisible.value = true }
|
||||
catch (e: any) { message.error(e?.response?.data?.message || "获取活动信息失败") }
|
||||
}
|
||||
const handleJudgeDrawerClose = () => { judgeDrawerVisible.value = false; currentContestId.value = null; currentContest.value = null }
|
||||
const handleJudgeAddSuccess = () => { message.success("添加评委成功"); fetchList(); handleJudgeDrawerClose() }
|
||||
|
||||
const handleJudgeDrawerClose = () => {
|
||||
judgeDrawerVisible.value = false
|
||||
currentContestId.value = null
|
||||
currentContest.value = null
|
||||
}
|
||||
|
||||
const handleJudgeAddSuccess = () => {
|
||||
message.success("添加评委成功")
|
||||
fetchList()
|
||||
handleJudgeDrawerClose()
|
||||
}
|
||||
|
||||
// 发布
|
||||
// #5 发布(修复租户端 tenantOptions 为空的 bug)
|
||||
const publishModalVisible = ref(false)
|
||||
const unpublishModalVisible = ref(false)
|
||||
const publishLoading = ref(false)
|
||||
@ -563,15 +397,15 @@ const handlePublishClick = async (record: Contest) => {
|
||||
if (record.contestState === "published") {
|
||||
unpublishModalVisible.value = true
|
||||
} else {
|
||||
// 先确保机构选项已加载
|
||||
if (publishTenantOptions.value.length === 0) await fetchPublishTenants()
|
||||
try {
|
||||
const contest = await contestsApi.getDetail(record.id)
|
||||
selectedTenants.value = Array.isArray(contest.contestTenants)
|
||||
? contest.contestTenants.map((id) => Number(id)).filter((id) => !isNaN(id))
|
||||
: []
|
||||
publishModalVisible.value = true
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "获取活动信息失败")
|
||||
}
|
||||
} catch (e: any) { message.error(e?.response?.data?.message || "获取活动信息失败") }
|
||||
}
|
||||
}
|
||||
|
||||
@ -583,11 +417,9 @@ const handlePublishConfirm = async () => {
|
||||
await contestsApi.publish(currentPublishContest.value!.id, "published")
|
||||
message.success("发布成功")
|
||||
publishModalVisible.value = false
|
||||
fetchList()
|
||||
if (isSuperAdmin.value) fetchStats()
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "发布失败")
|
||||
} finally { publishLoading.value = false }
|
||||
fetchList(); fetchStats()
|
||||
} catch (e: any) { message.error(e?.response?.data?.message || "发布失败") }
|
||||
finally { publishLoading.value = false }
|
||||
}
|
||||
|
||||
const handleUnpublishConfirm = async () => {
|
||||
@ -596,40 +428,32 @@ const handleUnpublishConfirm = async () => {
|
||||
await contestsApi.publish(currentPublishContest.value!.id, "unpublished")
|
||||
message.success("取消发布成功")
|
||||
unpublishModalVisible.value = false
|
||||
fetchList()
|
||||
if (isSuperAdmin.value) fetchStats()
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "取消发布失败")
|
||||
} finally { publishLoading.value = false }
|
||||
fetchList(); fetchStats()
|
||||
} catch (e: any) { message.error(e?.response?.data?.message || "取消发布失败") }
|
||||
finally { publishLoading.value = false }
|
||||
}
|
||||
|
||||
// 删除
|
||||
// #7 删除(只允许删除未发布的活动)
|
||||
const deleteModalVisible = ref(false)
|
||||
const deleteLoading = ref(false)
|
||||
const currentDeleteContest = ref<Contest | null>(null)
|
||||
|
||||
const handleDeleteClick = (record: Contest) => {
|
||||
currentDeleteContest.value = record
|
||||
deleteModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleDeleteClick = (record: Contest) => { currentDeleteContest.value = record; deleteModalVisible.value = true }
|
||||
const handleDeleteConfirm = async () => {
|
||||
deleteLoading.value = true
|
||||
try {
|
||||
await contestsApi.delete(currentDeleteContest.value!.id)
|
||||
message.success("删除成功")
|
||||
deleteModalVisible.value = false
|
||||
fetchList()
|
||||
if (isSuperAdmin.value) fetchStats()
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "删除失败")
|
||||
} finally { deleteLoading.value = false }
|
||||
fetchList(); fetchStats()
|
||||
} catch (e: any) { message.error(e?.response?.data?.message || "删除失败") }
|
||||
finally { deleteLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
fetchTenants()
|
||||
if (isSuperAdmin.value) fetchStats()
|
||||
fetchStats()
|
||||
if (isSuperAdmin.value) fetchTenants()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -638,88 +462,31 @@ $primary: #6366f1;
|
||||
|
||||
.contests-page {
|
||||
.title-card {
|
||||
margin-bottom: 16px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
border-bottom: none;
|
||||
.ant-card-head-title { font-size: 18px; font-weight: 600; }
|
||||
}
|
||||
margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||
:deep(.ant-card-body) { padding: 0; }
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary) {
|
||||
background: linear-gradient(135deg, $primary 0%, darken($primary, 10%) 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba($primary, 0.35);
|
||||
&:hover {
|
||||
box-shadow: 0 6px 16px rgba($primary, 0.45);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 统计卡片
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
flex: 1; display: flex; align-items: center; gap: 12px; padding: 14px 16px;
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
cursor: pointer; border: 2px solid transparent; transition: all 0.2s;
|
||||
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); }
|
||||
&.active { border-color: $primary; background: rgba($primary, 0.02); }
|
||||
|
||||
.stat-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.stat-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
|
||||
.stat-info { display: flex; flex-direction: column;
|
||||
.stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
|
||||
.stat-label { font-size: 12px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.search-form { margin-bottom: 16px; padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||||
|
||||
// 表格
|
||||
.data-table {
|
||||
:deep(.ant-table-wrapper) {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
|
||||
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
|
||||
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
|
||||
.ant-table-pagination { padding: 16px; margin: 0; }
|
||||
|
||||
@ -12,14 +12,18 @@
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增
|
||||
</a-button>
|
||||
<a-button v-permission="'judge:create'">
|
||||
<a-tooltip title="功能开发中,敬请期待">
|
||||
<a-button v-permission="'judge:create'" disabled>
|
||||
<template #icon><UploadOutlined /></template>
|
||||
导入
|
||||
</a-button>
|
||||
<a-button v-permission="'judge:read'">
|
||||
</a-tooltip>
|
||||
<a-tooltip title="功能开发中,敬请期待">
|
||||
<a-button v-permission="'judge:read'" disabled>
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
导出
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-popconfirm
|
||||
v-permission="'judge:delete'"
|
||||
title="确定要删除选中的评委吗?"
|
||||
@ -72,6 +76,7 @@
|
||||
placeholder="请选择状态"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option value="disabled">停用</a-select-option>
|
||||
<a-select-option value="enabled">启用</a-select-option>
|
||||
@ -236,7 +241,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import { message, Modal } from "ant-design-vue"
|
||||
import type { FormInstance, TableProps } from "ant-design-vue"
|
||||
import {
|
||||
PlusOutlined,
|
||||
@ -438,10 +443,19 @@ const handleEdit = (record: Judge) => {
|
||||
form.password = ""
|
||||
}
|
||||
|
||||
// 切换状态(冻结/解冻)
|
||||
const handleToggleStatus = async (record: Judge) => {
|
||||
// 切换状态(冻结/解冻)+ 二次确认
|
||||
const handleToggleStatus = (record: Judge) => {
|
||||
const isFreeze = record.status === "enabled"
|
||||
Modal.confirm({
|
||||
title: isFreeze ? '确定冻结该评委?' : '确定解冻该评委?',
|
||||
content: isFreeze
|
||||
? `冻结后「${record.nickname}」将无法登录评委端,进行中的评审任务将暂停`
|
||||
: `解冻后「${record.nickname}」将恢复登录和评审功能`,
|
||||
okText: isFreeze ? '确定冻结' : '确定解冻',
|
||||
okType: isFreeze ? 'danger' : 'primary',
|
||||
onOk: async () => {
|
||||
try {
|
||||
if (record.status === "enabled") {
|
||||
if (isFreeze) {
|
||||
await judgesManagementApi.freeze(record.id)
|
||||
message.success("冻结成功")
|
||||
} else {
|
||||
@ -452,6 +466,8 @@ const handleToggleStatus = async (record: Judge) => {
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "操作失败")
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 删除
|
||||
@ -535,85 +551,31 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 主色调
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
$primary: #6366f1;
|
||||
|
||||
.judges-page {
|
||||
// 标题卡片样式
|
||||
:deep(.ant-card) {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-card-head {
|
||||
border-bottom: none;
|
||||
padding: 16px 24px;
|
||||
|
||||
.ant-card-head-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
.ant-card-head-title { font-size: 18px; font-weight: 600; }
|
||||
}
|
||||
.ant-card-body { padding: 0; }
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 渐变主按钮样式
|
||||
:deep(.ant-btn-primary) {
|
||||
background: $gradient-primary;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba($primary, 0.35);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 8%) 100%);
|
||||
box-shadow: 0 6px 16px rgba($primary, 0.45);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 表格样式
|
||||
:deep(.ant-table-wrapper) {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
|
||||
.ant-table {
|
||||
.ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover > td {
|
||||
background: rgba($primary, 0.04);
|
||||
}
|
||||
|
||||
> td {
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-pagination {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
|
||||
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
|
||||
.ant-table-pagination { padding: 16px; margin: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -622,6 +584,6 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -25,12 +25,18 @@
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="发布日期">
|
||||
<a-date-picker
|
||||
v-model:value="searchForm.publishDate"
|
||||
placeholder="请选择发布日期"
|
||||
style="width: 200px"
|
||||
<a-form-item label="发布状态">
|
||||
<a-select v-model:value="searchForm.status" placeholder="全部" allow-clear style="width: 110px" @change="handleSearch">
|
||||
<a-select-option value="published">已发布</a-select-option>
|
||||
<a-select-option value="unpublished">未发布</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="发布时间">
|
||||
<a-range-picker
|
||||
v-model:value="searchForm.publishDateRange"
|
||||
style="width: 240px"
|
||||
allow-clear
|
||||
@change="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
@ -69,34 +75,27 @@
|
||||
{{ record.publishTime ? "已发布" : "未发布" }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'createTime'">
|
||||
{{ formatDateTime(record.createTime) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'publishTime'">
|
||||
{{ formatDateTime(record.publishTime) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button
|
||||
v-permission="'notice:update'"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleTogglePublish(record)"
|
||||
>
|
||||
{{ record.publishTime ? "取消发布" : "发布" }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-permission="'notice:update'"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleEdit(record)"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-permission="'notice:delete'"
|
||||
title="确定要删除这条公告吗?"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<!-- 未发布:发布、编辑、删除 -->
|
||||
<template v-if="!record.publishTime">
|
||||
<a-button v-permission="'notice:update'" type="link" size="small" style="color: #10b981" @click="handleTogglePublish(record)">发布</a-button>
|
||||
<a-button v-permission="'notice:update'" type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-popconfirm v-permission="'notice:delete'" title="确定要删除这条公告吗?" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" danger size="small">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
<!-- 已发布:查看、取消发布 -->
|
||||
<template v-else>
|
||||
<a-button type="link" size="small" @click="handleView(record)">查看</a-button>
|
||||
<a-button v-permission="'notice:update'" type="link" size="small" style="color: #f59e0b" @click="handleTogglePublish(record)">取消发布</a-button>
|
||||
</template>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
@ -233,7 +232,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import { message, Modal } from "ant-design-vue"
|
||||
import { PlusOutlined, UploadOutlined, SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
|
||||
import dayjs, { type Dayjs } from "dayjs"
|
||||
import type { FormInstance, UploadFile } from "ant-design-vue"
|
||||
@ -261,7 +260,8 @@ const pagination = reactive({
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
title: "",
|
||||
publishDate: null as Dayjs | null,
|
||||
status: undefined as string | undefined,
|
||||
publishDateRange: null as [Dayjs, Dayjs] | null,
|
||||
})
|
||||
|
||||
// 详情弹窗
|
||||
@ -319,11 +319,16 @@ const columns = [
|
||||
width: 100,
|
||||
align: "center" as const,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "createTime",
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: "发布时间",
|
||||
key: "publishTime",
|
||||
dataIndex: "publishTime",
|
||||
width: 180,
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
@ -375,13 +380,23 @@ const fetchNotices = async () => {
|
||||
params.title = searchForm.title
|
||||
}
|
||||
|
||||
if (searchForm.publishDate) {
|
||||
params.publishDate = searchForm.publishDate.format("YYYY-MM-DD")
|
||||
if (searchForm.publishDateRange?.[0]) {
|
||||
params.publishStartDate = searchForm.publishDateRange[0].format("YYYY-MM-DD")
|
||||
}
|
||||
if (searchForm.publishDateRange?.[1]) {
|
||||
params.publishEndDate = searchForm.publishDateRange[1].format("YYYY-MM-DD")
|
||||
}
|
||||
|
||||
const response = await noticesApi.getAll(params)
|
||||
dataSource.value = response.list || []
|
||||
pagination.total = response.total || 0
|
||||
let list = response.list || []
|
||||
// 前端过滤发布状态
|
||||
if (searchForm.status === 'published') {
|
||||
list = list.filter((n: any) => !!n.publishTime)
|
||||
} else if (searchForm.status === 'unpublished') {
|
||||
list = list.filter((n: any) => !n.publishTime)
|
||||
}
|
||||
dataSource.value = list
|
||||
pagination.total = searchForm.status ? list.length : (response.total || 0)
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "获取公告列表失败")
|
||||
} finally {
|
||||
@ -422,7 +437,8 @@ const handleSearch = () => {
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
searchForm.title = ""
|
||||
searchForm.publishDate = null
|
||||
searchForm.status = undefined
|
||||
searchForm.publishDateRange = null
|
||||
pagination.current = 1
|
||||
fetchNotices()
|
||||
}
|
||||
@ -472,26 +488,31 @@ const handleDelete = async (id: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 发布/取消发布
|
||||
const handleTogglePublish = async (record: ContestNotice) => {
|
||||
// 发布/取消发布(二次确认)
|
||||
const handleTogglePublish = (record: ContestNotice) => {
|
||||
const isPublished = !!record.publishTime
|
||||
Modal.confirm({
|
||||
title: isPublished ? '确定取消发布?' : '确定发布?',
|
||||
content: isPublished
|
||||
? `取消发布后公告「${record.title}」将不再对外展示`
|
||||
: `发布后公告「${record.title}」将立即对外展示`,
|
||||
okText: isPublished ? '取消发布' : '确定发布',
|
||||
okType: isPublished ? 'danger' : 'primary',
|
||||
onOk: async () => {
|
||||
try {
|
||||
if (record.publishTime) {
|
||||
// 取消发布
|
||||
await noticesApi.update(record.id, {
|
||||
publishTime: null,
|
||||
} as any)
|
||||
if (isPublished) {
|
||||
await noticesApi.update(record.id, { publishTime: null } as any)
|
||||
message.success("取消发布成功")
|
||||
} else {
|
||||
// 发布
|
||||
await noticesApi.update(record.id, {
|
||||
publishTime: new Date().toISOString(),
|
||||
} as any)
|
||||
await noticesApi.update(record.id, { publishTime: new Date().toISOString() } as any)
|
||||
message.success("发布成功")
|
||||
}
|
||||
fetchNotices()
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "操作失败")
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 上传前检查
|
||||
@ -595,106 +616,26 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 主色调
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
$primary: #6366f1;
|
||||
|
||||
.notices-page {
|
||||
// 标题卡片样式
|
||||
:deep(.ant-card) {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
|
||||
.ant-card-head {
|
||||
border-bottom: none;
|
||||
padding: 16px 24px;
|
||||
|
||||
.ant-card-head-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px;
|
||||
.ant-card-head { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||
.ant-card-body { padding: 0; }
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 渐变主按钮样式
|
||||
:deep(.ant-btn-primary) {
|
||||
background: $gradient-primary;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba($primary, 0.35);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 8%) 100%);
|
||||
box-shadow: 0 6px 16px rgba($primary, 0.45);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 表格样式
|
||||
:deep(.ant-table-wrapper) {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
|
||||
.ant-table {
|
||||
.ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover > td {
|
||||
background: rgba($primary, 0.04);
|
||||
}
|
||||
|
||||
> td {
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-pagination {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
|
||||
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
|
||||
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
|
||||
.ant-table-pagination { padding: 16px; margin: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
|
||||
// 自适应换行
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
margin-right: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
margin-bottom: 16px; padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
</style>
|
||||
|
||||
@ -213,35 +213,51 @@
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<!-- ========== 机构端:保持原有两层结构 ========== -->
|
||||
<!-- ========== 机构端:活动维度 + 统计概览 ========== -->
|
||||
<template v-else>
|
||||
<a-card class="title-card">
|
||||
<template #title>报名管理</template>
|
||||
</a-card>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange" class="org-tabs">
|
||||
<a-tab-pane key="individual" tab="个人参与" />
|
||||
<a-tab-pane key="team" tab="团队参与" />
|
||||
</a-tabs>
|
||||
<!-- 统计概览 -->
|
||||
<div class="stats-row">
|
||||
<div v-for="item in orgStatsItems" :key="item.key" class="stat-card">
|
||||
<div class="stat-icon" :style="{ background: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-count">{{ item.value }}</span>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form :model="searchParams" layout="inline" class="filter-bar" @finish="handleSearch">
|
||||
<!-- 筛选 -->
|
||||
<div class="filter-bar">
|
||||
<a-form layout="inline" @finish="handleSearch">
|
||||
<a-form-item label="活动名称">
|
||||
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 200px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="活动类型">
|
||||
<a-select v-model:value="searchParams.contestType" placeholder="全部" allow-clear style="width: 120px" @change="handleSearch">
|
||||
<a-select-option value="individual">个人参与</a-select-option>
|
||||
<a-select-option value="team">团队参与</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
<template #icon><SearchOutlined /></template> 搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
<template #icon><ReloadOutlined /></template> 重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 活动列表 -->
|
||||
<a-table
|
||||
:columns="activeTab === 'individual' ? orgIndividualColumns : orgTeamColumns"
|
||||
:columns="orgColumns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@ -254,30 +270,46 @@
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'contestName'">
|
||||
<a @click="handleViewContest(record.id)">{{ record.contestName }}</a>
|
||||
<a @click="handleViewRecords(record)">{{ record.contestName }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'organizers'">
|
||||
{{ formatOrganizers(record.organizers) }}
|
||||
<template v-else-if="column.key === 'contestType'">
|
||||
<a-tag :color="record.contestType === 'individual' ? 'blue' : 'green'">
|
||||
{{ record.contestType === 'individual' ? '个人' : '团队' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'registrationCount'">
|
||||
<template v-else-if="column.key === 'regStats'">
|
||||
<div class="reg-stats-cell">
|
||||
<a-tooltip title="待审核">
|
||||
<span class="reg-stat pending" v-if="record._regPending > 0">
|
||||
<clock-circle-outlined /> {{ record._regPending }}
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="已通过">
|
||||
<span class="reg-stat passed">
|
||||
<check-circle-outlined /> {{ record._regPassed || 0 }}
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="已拒绝">
|
||||
<span class="reg-stat rejected" v-if="record._regRejected > 0">
|
||||
<close-circle-outlined /> {{ record._regRejected }}
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'totalReg'">
|
||||
{{ record._count?.registrations || 0 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'teamCount'">
|
||||
{{ record._count?.teams || 0 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'registerTime'">
|
||||
<div v-if="record.registerStartTime || record.registerEndTime">
|
||||
<div>开始:{{ formatDate(record.registerStartTime) }}</div>
|
||||
<div>结束:{{ formatDate(record.registerEndTime) }}</div>
|
||||
<div v-if="record.registerStartTime">
|
||||
<div>{{ formatDate(record.registerStartTime) }}</div>
|
||||
<div class="text-muted">至 {{ formatDate(record.registerEndTime) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleViewRecords(record)">报名记录</a-button>
|
||||
<a-button v-permission="'contest:update'" type="link" size="small" :disabled="record.registerState === 'open'" @click="handleStartRegistration(record)">启动报名</a-button>
|
||||
<a-button v-permission="'contest:update'" type="link" danger size="small" :disabled="record.registerState !== 'open'" @click="handleStopRegistration(record)">关闭报名</a-button>
|
||||
</a-space>
|
||||
<a-button type="link" size="small" @click="handleViewRecords(record)">
|
||||
查看报名 <right-outlined />
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@ -297,6 +329,8 @@ import {
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
RightOutlined,
|
||||
FormOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import {
|
||||
contestsApi,
|
||||
@ -442,76 +476,72 @@ const teamMemberColumns = [
|
||||
]
|
||||
|
||||
// =============================================
|
||||
// 机构端逻辑(保持不变)
|
||||
// 机构端逻辑
|
||||
// =============================================
|
||||
const activeTab = ref<'individual' | 'team'>('individual')
|
||||
const orgStats = ref({ total: 0, pending: 0, passed: 0, rejected: 0 })
|
||||
const orgStatsItems = computed(() => [
|
||||
{ key: 'total', label: '总报名', value: orgStats.value.total, icon: TeamOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
|
||||
{ key: 'pending', label: '待审核', value: orgStats.value.pending, icon: ClockCircleOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
|
||||
{ key: 'passed', label: '已通过', value: orgStats.value.passed, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
|
||||
{ key: 'rejected', label: '已拒绝', value: orgStats.value.rejected, icon: CloseCircleOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)' },
|
||||
])
|
||||
|
||||
const loading = ref(false)
|
||||
const dataSource = ref<Contest[]>([])
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` })
|
||||
const searchParams = reactive<QueryContestParams>({ contestName: '' })
|
||||
|
||||
const orgIndividualColumns = [
|
||||
{ title: '序号', key: 'index', width: 70 },
|
||||
{ title: '活动名称', key: 'contestName', width: 250 },
|
||||
{ title: '主办单位', key: 'organizers', width: 200 },
|
||||
{ title: '报名人数', key: 'registrationCount', width: 120 },
|
||||
{ title: '报名时间', key: 'registerTime', width: 200 },
|
||||
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const },
|
||||
const orgColumns = [
|
||||
{ title: '序号', key: 'index', width: 50 },
|
||||
{ title: '活动名称', key: 'contestName', width: 200 },
|
||||
{ title: '类型', key: 'contestType', width: 70 },
|
||||
{ title: '报名审核', key: 'regStats', width: 160 },
|
||||
{ title: '总报名', key: 'totalReg', width: 70 },
|
||||
{ title: '报名时间', key: 'registerTime', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 120, fixed: 'right' as const },
|
||||
]
|
||||
|
||||
const orgTeamColumns = [
|
||||
{ title: '序号', key: 'index', width: 70 },
|
||||
{ title: '活动名称', key: 'contestName', width: 250 },
|
||||
{ title: '主办单位', key: 'organizers', width: 200 },
|
||||
{ title: '报名队伍数', key: 'teamCount', width: 120 },
|
||||
{ title: '报名时间', key: 'registerTime', width: 200 },
|
||||
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const },
|
||||
]
|
||||
const fetchOrgStats = async () => {
|
||||
try { orgStats.value = await registrationsApi.getStats() } catch { /* */ }
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await contestsApi.getList({
|
||||
...searchParams,
|
||||
contestType: activeTab.value,
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
})
|
||||
dataSource.value = res.list
|
||||
// 并行查询每个活动的报名状态计数
|
||||
const list = res.list
|
||||
if (list.length > 0) {
|
||||
const statsPromises = list.map(c =>
|
||||
registrationsApi.getStats(c.id).catch(() => ({ total: 0, pending: 0, passed: 0, rejected: 0 }))
|
||||
)
|
||||
const statsResults = await Promise.all(statsPromises)
|
||||
list.forEach((c: any, i: number) => {
|
||||
c._regPending = statsResults[i].pending
|
||||
c._regPassed = statsResults[i].passed
|
||||
c._regRejected = statsResults[i].rejected
|
||||
})
|
||||
}
|
||||
dataSource.value = list
|
||||
pagination.total = res.total
|
||||
} catch { message.error('获取列表失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleTabChange = () => { pagination.current = 1; fetchList() }
|
||||
const handleSearch = () => { pagination.current = 1; fetchList() }
|
||||
const handleReset = () => { searchParams.contestName = ''; pagination.current = 1; fetchList() }
|
||||
const handleReset = () => { searchParams.contestName = ''; searchParams.contestType = undefined; pagination.current = 1; fetchList(); fetchOrgStats() }
|
||||
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
|
||||
const handleViewContest = (id: number) => router.push({ name: 'ContestsDetail', params: { tenantCode, id } })
|
||||
const handleViewRecords = (record: Contest) => router.push({ name: 'RegistrationRecords', params: { tenantCode, id: record.id } })
|
||||
|
||||
const formatOrganizers = (org: any) => {
|
||||
if (!org) return '-'
|
||||
if (Array.isArray(org)) return org.join('、') || '-'
|
||||
if (typeof org === 'string') { try { const p = JSON.parse(org); return Array.isArray(p) ? p.join('、') : org } catch { return org } }
|
||||
return '-'
|
||||
}
|
||||
|
||||
const handleStartRegistration = async (record: Contest) => {
|
||||
try { await contestsApi.update(record.id, { registerState: 'open' } as any); message.success('已启动报名'); fetchList() }
|
||||
catch (e: any) { message.error(e?.response?.data?.message || '启动报名失败') }
|
||||
}
|
||||
const handleStopRegistration = async (record: Contest) => {
|
||||
try { await contestsApi.update(record.id, { registerState: 'closed' } as any); message.success('已关闭报名'); fetchList() }
|
||||
catch (e: any) { message.error(e?.response?.data?.message || '关闭报名失败') }
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 初始化
|
||||
// =============================================
|
||||
onMounted(() => {
|
||||
if (isSuperAdmin.value) {
|
||||
// 读取 URL 参数
|
||||
const queryContestId = route.query.contestId ? Number(route.query.contestId) : undefined
|
||||
const queryStatus = route.query.status as string | undefined
|
||||
if (queryContestId) superSearch.contestId = queryContestId
|
||||
@ -521,6 +551,7 @@ onMounted(() => {
|
||||
fetchSuperStats()
|
||||
fetchSuperList()
|
||||
} else {
|
||||
fetchOrgStats()
|
||||
fetchList()
|
||||
}
|
||||
})
|
||||
@ -587,6 +618,16 @@ $primary: #6366f1;
|
||||
|
||||
.text-muted { color: #d1d5db; }
|
||||
|
||||
.reg-stats-cell {
|
||||
display: flex; gap: 10px;
|
||||
.reg-stat {
|
||||
display: flex; align-items: center; gap: 3px; font-size: 12px; font-weight: 500;
|
||||
&.pending { color: #f59e0b; }
|
||||
&.passed { color: #10b981; }
|
||||
&.rejected { color: #ef4444; }
|
||||
}
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-top: 24px;
|
||||
h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
|
||||
|
||||
@ -2,12 +2,17 @@
|
||||
<div class="registration-records-page">
|
||||
<a-card class="mb-4">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<a-button type="text" style="padding: 0; margin-right: 4px" @click="router.back()">
|
||||
<template #icon><arrow-left-outlined /></template>
|
||||
</a-button>
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<router-link :to="{ name: 'ContestsRegistrations', params: { tenantCode } }">报名管理</router-link>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ contestName || '报名记录' }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
@ -27,129 +32,43 @@
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<!-- 个人参与搜索表单 -->
|
||||
<a-form
|
||||
v-if="contestType === 'individual'"
|
||||
:model="searchParams"
|
||||
layout="inline"
|
||||
class="search-form"
|
||||
@finish="handleSearch"
|
||||
>
|
||||
<a-form-item label="机构">
|
||||
<a-input
|
||||
v-model:value="searchParams.tenantName"
|
||||
placeholder="请输入机构名称"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="姓名">
|
||||
<a-input
|
||||
v-model:value="searchParams.nickname"
|
||||
placeholder="请输入姓名"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="报名账号">
|
||||
<a-input
|
||||
v-model:value="searchParams.accountNo"
|
||||
placeholder="请输入账号"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="审核状态">
|
||||
<a-select
|
||||
v-model:value="searchParams.registrationState"
|
||||
placeholder="请选择状态"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
>
|
||||
<a-select-option value="pending">待审核</a-select-option>
|
||||
<a-select-option value="passed">已通过</a-select-option>
|
||||
<a-select-option value="rejected">已拒绝</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="报名时间">
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
style="width: 240px"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<!-- #3 统计概览 -->
|
||||
<div class="stats-row">
|
||||
<div v-for="item in recordStatsItems" :key="item.key" :class="['stat-card', { active: activeRecordState === item.key }]" @click="handleRecordStatClick(item.key)">
|
||||
<div class="stat-icon" :style="{ background: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-count">{{ item.value }}</span>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 团队参与搜索表单 -->
|
||||
<a-form
|
||||
v-else
|
||||
:model="searchParams"
|
||||
layout="inline"
|
||||
class="search-form"
|
||||
@finish="handleSearch"
|
||||
>
|
||||
<a-form-item label="机构">
|
||||
<a-input
|
||||
v-model:value="searchParams.tenantName"
|
||||
placeholder="请输入机构名称"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
/>
|
||||
<!-- 搜索表单(合并个人/团队) -->
|
||||
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
|
||||
<a-form-item v-if="contestType === 'individual'" label="姓名">
|
||||
<a-input v-model:value="searchParams.nickname" placeholder="请输入姓名" allow-clear style="width: 120px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="队伍名称">
|
||||
<a-input
|
||||
v-model:value="searchParams.teamName"
|
||||
placeholder="请输入队伍名称"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
/>
|
||||
<a-form-item v-if="contestType === 'team'" label="队伍名称">
|
||||
<a-input v-model:value="searchParams.teamName" placeholder="请输入队伍名称" allow-clear style="width: 130px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="报名账号">
|
||||
<a-input
|
||||
v-model:value="searchParams.accountNo"
|
||||
placeholder="请输入账号"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
/>
|
||||
<a-input v-model:value="searchParams.accountNo" placeholder="请输入账号" allow-clear style="width: 140px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="审核状态">
|
||||
<a-select
|
||||
v-model:value="searchParams.registrationState"
|
||||
placeholder="请选择状态"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
>
|
||||
<a-select v-model:value="searchParams.registrationState" placeholder="全部" allow-clear style="width: 110px" @change="handleSearch">
|
||||
<a-select-option value="pending">待审核</a-select-option>
|
||||
<a-select-option value="passed">已通过</a-select-option>
|
||||
<a-select-option value="rejected">已拒绝</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="报名时间">
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
style="width: 240px"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
<a-range-picker v-model:value="dateRange" style="width: 240px" @change="handleDateChange" />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
<a-button type="primary" html-type="submit"><template #icon><SearchOutlined /></template> 搜索</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset"><template #icon><ReloadOutlined /></template> 重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
@ -180,13 +99,6 @@
|
||||
<template v-else-if="column.key === 'nickname'">
|
||||
{{ record.user?.nickname || record.accountName || "-" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'participantType'">
|
||||
<template v-if="record.participantType === 'child'">
|
||||
<a-tag color="green">代子女报名</a-tag>
|
||||
<div class="child-name" v-if="record.child">{{ record.child.name }}</div>
|
||||
</template>
|
||||
<a-tag v-else color="blue">本人参与</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'accountNo'">
|
||||
{{ record.accountNo || record.user?.username || "-" }}
|
||||
</template>
|
||||
@ -200,38 +112,10 @@
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleViewDetail(record)"
|
||||
>
|
||||
详细信息
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.registrationState !== 'passed'"
|
||||
v-permission="'contest:update'"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handlePass(record)"
|
||||
>
|
||||
通过
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.registrationState !== 'rejected'"
|
||||
v-permission="'contest:update'"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleReject(record)"
|
||||
>
|
||||
拒绝
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-permission="'contest:delete'"
|
||||
title="确定要删除该报名记录吗?"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<a-button type="link" danger size="small">删除</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">详情</a-button>
|
||||
<a-button v-if="record.registrationState === 'pending'" v-permission="'contest:update'" type="link" size="small" style="color: #10b981" @click="handlePass(record)">通过</a-button>
|
||||
<a-button v-if="record.registrationState === 'pending'" v-permission="'contest:update'" type="link" size="small" danger @click="handleReject(record)">拒绝</a-button>
|
||||
<a-button v-if="record.registrationState === 'passed' || record.registrationState === 'rejected'" v-permission="'contest:update'" type="link" size="small" style="color: #f59e0b" @click="handleRevoke(record)">撤销</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
@ -277,38 +161,10 @@
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleViewMembers(record)"
|
||||
>
|
||||
成员信息
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.registrationState !== 'passed'"
|
||||
v-permission="'contest:update'"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handlePass(record)"
|
||||
>
|
||||
通过
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.registrationState !== 'rejected'"
|
||||
v-permission="'contest:update'"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleReject(record)"
|
||||
>
|
||||
拒绝
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-permission="'contest:delete'"
|
||||
title="确定要删除该报名记录吗?"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<a-button type="link" danger size="small">删除</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button type="link" size="small" @click="handleViewMembers(record)">成员</a-button>
|
||||
<a-button v-if="record.registrationState === 'pending'" v-permission="'contest:update'" type="link" size="small" style="color: #10b981" @click="handlePass(record)">通过</a-button>
|
||||
<a-button v-if="record.registrationState === 'pending'" v-permission="'contest:update'" type="link" size="small" danger @click="handleReject(record)">拒绝</a-button>
|
||||
<a-button v-if="record.registrationState === 'passed' || record.registrationState === 'rejected'" v-permission="'contest:update'" type="link" size="small" style="color: #f59e0b" @click="handleRevoke(record)">撤销</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
@ -432,12 +288,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import { message, Modal } from "ant-design-vue"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
import type { TableProps } from "ant-design-vue"
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DownloadOutlined,
|
||||
ArrowLeftOutlined,
|
||||
TeamOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import {
|
||||
registrationsApi,
|
||||
@ -455,6 +317,30 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
const contestId = Number(route.params.id)
|
||||
const authStore = useAuthStore()
|
||||
const isSuperAdmin = computed(() => authStore.hasAnyRole(['super_admin']))
|
||||
|
||||
// #3 统计概览
|
||||
const recordStats = ref({ total: 0, pending: 0, passed: 0, rejected: 0 })
|
||||
const activeRecordState = ref('')
|
||||
|
||||
const recordStatsItems = computed(() => [
|
||||
{ key: '', label: '全部', value: recordStats.value.total, icon: TeamOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
|
||||
{ key: 'pending', label: '待审核', value: recordStats.value.pending, icon: ClockCircleOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
|
||||
{ key: 'passed', label: '已通过', value: recordStats.value.passed, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
|
||||
{ key: 'rejected', label: '已拒绝', value: recordStats.value.rejected, icon: CloseCircleOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)' },
|
||||
])
|
||||
|
||||
const fetchRecordStats = async () => {
|
||||
try { recordStats.value = await registrationsApi.getStats(contestId) } catch { /* */ }
|
||||
}
|
||||
|
||||
const handleRecordStatClick = (key: string) => {
|
||||
if (activeRecordState.value === key) { activeRecordState.value = ''; searchParams.registrationState = undefined }
|
||||
else { activeRecordState.value = key; searchParams.registrationState = key || undefined }
|
||||
pagination.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 活动信息
|
||||
const contestName = ref("")
|
||||
@ -520,95 +406,37 @@ const rejectLoading = ref(false)
|
||||
const rejectReason = ref("")
|
||||
const currentRejectId = ref<number | null>(null)
|
||||
|
||||
// 个人参与表格列
|
||||
const individualColumns = [
|
||||
{
|
||||
title: "序号",
|
||||
key: "index",
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
title: "机构",
|
||||
key: "tenant",
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: "姓名",
|
||||
key: "nickname",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "参与方式",
|
||||
key: "participantType",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "报名账号",
|
||||
key: "accountNo",
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: "审核状态",
|
||||
key: "registrationState",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "报名时间",
|
||||
key: "registrationTime",
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 220,
|
||||
fixed: "right" as const,
|
||||
},
|
||||
]
|
||||
// 个人参与表格列(租户端去掉机构列)
|
||||
const individualColumns = computed(() => {
|
||||
const cols: any[] = [
|
||||
{ title: "序号", key: "index", width: 50 },
|
||||
]
|
||||
if (isSuperAdmin.value) cols.push({ title: "机构", key: "tenant", width: 140 })
|
||||
cols.push(
|
||||
{ title: "姓名", key: "nickname", width: 120 },
|
||||
{ title: "报名账号", key: "accountNo", width: 140 },
|
||||
{ title: "审核状态", key: "registrationState", width: 90 },
|
||||
{ title: "报名时间", key: "registrationTime", width: 150 },
|
||||
{ title: "操作", key: "action", width: 200, fixed: "right" as const },
|
||||
)
|
||||
return cols
|
||||
})
|
||||
|
||||
// 团队参与表格列
|
||||
const teamColumns = [
|
||||
{
|
||||
title: "序号",
|
||||
key: "index",
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
title: "机构",
|
||||
key: "tenant",
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: "队伍名称",
|
||||
key: "teamName",
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: "参与方式",
|
||||
key: "participantType",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "报名账号",
|
||||
key: "accountNo",
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: "审核状态",
|
||||
key: "registrationState",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "报名时间",
|
||||
key: "registrationTime",
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 220,
|
||||
fixed: "right" as const,
|
||||
},
|
||||
]
|
||||
const teamColumns = computed(() => {
|
||||
const cols: any[] = [
|
||||
{ title: "序号", key: "index", width: 50 },
|
||||
]
|
||||
if (isSuperAdmin.value) cols.push({ title: "机构", key: "tenant", width: 140 })
|
||||
cols.push(
|
||||
{ title: "队伍名称", key: "teamName", width: 140 },
|
||||
{ title: "报名账号", key: "accountNo", width: 130 },
|
||||
{ title: "审核状态", key: "registrationState", width: 90 },
|
||||
{ title: "报名时间", key: "registrationTime", width: 140 },
|
||||
{ title: "操作", key: "action", width: 200, fixed: "right" as const },
|
||||
)
|
||||
return cols
|
||||
})
|
||||
|
||||
// 团队成员表格列
|
||||
const memberColumns = [
|
||||
@ -814,17 +642,40 @@ const handleViewMembers = async (record: ContestRegistration) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 通过
|
||||
const handlePass = async (record: ContestRegistration) => {
|
||||
// #5 通过加二次确认
|
||||
const handlePass = (record: ContestRegistration) => {
|
||||
Modal.confirm({
|
||||
title: '确定通过?',
|
||||
content: `通过后「${record.user?.nickname || record.accountName || '该用户'}」将可以提交作品`,
|
||||
okText: '确定通过',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await registrationsApi.review(record.id, {
|
||||
registrationState: "passed",
|
||||
})
|
||||
await registrationsApi.review(record.id, { registrationState: "passed" })
|
||||
message.success("已通过")
|
||||
fetchList()
|
||||
fetchRecordStats()
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "操作失败")
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 撤销审核
|
||||
const handleRevoke = (record: ContestRegistration) => {
|
||||
Modal.confirm({
|
||||
title: '确定撤销审核?',
|
||||
content: '撤销后将恢复为待审核状态',
|
||||
okText: '确定撤销',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await registrationsApi.revokeReview(record.id)
|
||||
message.success('已撤销')
|
||||
fetchList()
|
||||
fetchRecordStats()
|
||||
} catch (error: any) { message.error(error?.response?.data?.message || '撤销失败') }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 拒绝
|
||||
@ -846,6 +697,7 @@ const handleRejectSubmit = async () => {
|
||||
message.success("已拒绝")
|
||||
rejectModalVisible.value = false
|
||||
fetchList()
|
||||
fetchRecordStats()
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "操作失败")
|
||||
} finally {
|
||||
@ -859,6 +711,7 @@ const handleDelete = async (id: number) => {
|
||||
await registrationsApi.delete(id)
|
||||
message.success("删除成功")
|
||||
fetchList()
|
||||
fetchRecordStats()
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "删除失败")
|
||||
}
|
||||
@ -871,26 +724,21 @@ const handleBatchReview = () => {
|
||||
batchReviewModalVisible.value = true
|
||||
}
|
||||
|
||||
// 提交批量审核
|
||||
// #6 提交批量审核(使用后端批量接口)
|
||||
const handleBatchReviewSubmit = async () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
message.warning("请先选择要审核的记录")
|
||||
return
|
||||
}
|
||||
if (selectedRowKeys.value.length === 0) { message.warning("请先选择要审核的记录"); return }
|
||||
try {
|
||||
batchReviewLoading.value = true
|
||||
await Promise.all(
|
||||
selectedRowKeys.value.map((id) =>
|
||||
registrationsApi.review(id, {
|
||||
const res = await registrationsApi.batchReview({
|
||||
ids: selectedRowKeys.value,
|
||||
registrationState: batchReviewForm.registrationState,
|
||||
reason: batchReviewForm.reason,
|
||||
reason: batchReviewForm.reason || undefined,
|
||||
})
|
||||
)
|
||||
)
|
||||
message.success("批量审核成功")
|
||||
message.success(`批量审核成功,${res.count} 条记录已更新`)
|
||||
batchReviewModalVisible.value = false
|
||||
selectedRowKeys.value = []
|
||||
fetchList()
|
||||
fetchRecordStats()
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "批量审核失败")
|
||||
} finally {
|
||||
@ -963,115 +811,47 @@ const handleExport = async () => {
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchContestInfo()
|
||||
fetchRecordStats()
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 主色调
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
$primary: #6366f1;
|
||||
|
||||
.registration-records-page {
|
||||
// 标题卡片样式
|
||||
:deep(.ant-card) {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-card-head {
|
||||
border-bottom: none;
|
||||
padding: 16px 24px;
|
||||
|
||||
.ant-card-head-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px;
|
||||
.ant-card-head { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||
.ant-card-body { padding: 0; }
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 渐变主按钮样式
|
||||
:deep(.ant-btn-primary) {
|
||||
background: $gradient-primary;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba($primary, 0.35);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 8%) 100%);
|
||||
box-shadow: 0 6px 16px rgba($primary, 0.45);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 表格样式
|
||||
:deep(.ant-table-wrapper) {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
|
||||
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
|
||||
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
|
||||
.ant-table-pagination { padding: 16px; margin: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
.ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover > td {
|
||||
background: rgba($primary, 0.04);
|
||||
}
|
||||
|
||||
> td {
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-pagination {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card {
|
||||
flex: 1; display: flex; align-items: center; gap: 12px; padding: 14px 16px;
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
cursor: pointer; border: 2px solid transparent; transition: all 0.2s;
|
||||
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); }
|
||||
&.active { border-color: $primary; background: rgba($primary, 0.02); }
|
||||
.stat-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
|
||||
.stat-info { display: flex; flex-direction: column;
|
||||
.stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
|
||||
.stat-label { font-size: 12px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
|
||||
// 自适应换行 - 使用 flex wrap
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 16px 24px;
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
margin-bottom: 16px; padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.org-detail {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.org-detail { font-size: 12px; color: #666; margin-top: 2px; }
|
||||
.child-name { font-size: 11px; color: #10b981; margin-top: 2px; }
|
||||
</style>
|
||||
|
||||
@ -4,123 +4,173 @@
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<a-button type="text" @click="handleBack">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
返回
|
||||
<template #icon><arrow-left-outlined /></template>
|
||||
</a-button>
|
||||
<span class="page-title">{{ contestInfo?.contestName || "成果发布" }}</span>
|
||||
<span class="page-title">{{ contestInfo?.contestName || '成果发布' }}</span>
|
||||
<a-tag v-if="contestInfo?.resultState === 'published'" color="green">已发布</a-tag>
|
||||
<a-tag v-else color="default">未发布</a-tag>
|
||||
</div>
|
||||
<div v-if="!isSuperAdmin" class="header-right">
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="publishLoading"
|
||||
@click="handlePublish"
|
||||
>
|
||||
{{ contestInfo?.resultState === "published" ? "撤回发布" : "发布成果" }}
|
||||
<a-button type="primary" :loading="publishLoading" @click="handlePublish" :disabled="!canPublish">
|
||||
{{ contestInfo?.resultState === 'published' ? '撤回发布' : '发布成果' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索表单 -->
|
||||
<a-form
|
||||
:model="searchParams"
|
||||
layout="inline"
|
||||
class="search-form"
|
||||
@finish="handleSearch"
|
||||
>
|
||||
<!-- 统计摘要 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(99,102,241,0.1)"><file-text-outlined style="color: #6366f1" /></div>
|
||||
<div class="stat-info"><span class="stat-count">{{ summary.totalWorks }}</span><span class="stat-label">总作品</span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(59,130,246,0.1)"><check-circle-outlined style="color: #3b82f6" /></div>
|
||||
<div class="stat-info"><span class="stat-count">{{ summary.scoredWorks }}</span><span class="stat-label">已评分</span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(16,185,129,0.1)"><ordered-list-outlined style="color: #10b981" /></div>
|
||||
<div class="stat-info"><span class="stat-count">{{ summary.rankedWorks }}</span><span class="stat-label">已排名</span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(245,158,11,0.1)"><trophy-outlined style="color: #f59e0b" /></div>
|
||||
<div class="stat-info"><span class="stat-count">{{ summary.awardedWorks }}</span><span class="stat-label">已设奖</span></div>
|
||||
</div>
|
||||
<div class="stat-card" v-if="summary.avgScore">
|
||||
<div class="stat-icon" style="background: rgba(139,92,246,0.1)"><fund-outlined style="color: #8b5cf6" /></div>
|
||||
<div class="stat-info"><span class="stat-count">{{ summary.avgScore }}</span><span class="stat-label">平均分</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作步骤(未发布时显示) -->
|
||||
<div v-if="!isSuperAdmin && contestInfo?.resultState !== 'published'" class="action-bar">
|
||||
<a-space>
|
||||
<a-button @click="handleCalculateScores" :loading="calcScoreLoading">
|
||||
<template #icon><calculator-outlined /></template>
|
||||
第一步:计算得分
|
||||
</a-button>
|
||||
<a-button @click="handleCalculateRankings" :loading="calcRankLoading" :disabled="summary.scoredWorks === 0">
|
||||
<template #icon><ordered-list-outlined /></template>
|
||||
第二步:计算排名
|
||||
</a-button>
|
||||
<a-button @click="autoAwardVisible = true" :disabled="summary.rankedWorks === 0">
|
||||
<template #icon><trophy-outlined /></template>
|
||||
第三步:设置奖项
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<div class="filter-bar">
|
||||
<a-form layout="inline" @finish="handleSearch">
|
||||
<a-form-item label="作品编号">
|
||||
<a-input
|
||||
v-model:value="searchParams.workNo"
|
||||
placeholder="请输入作品编号"
|
||||
allow-clear
|
||||
style="width: 180px"
|
||||
/>
|
||||
<a-input v-model:value="searchParams.workNo" placeholder="请输入作品编号" allow-clear style="width: 150px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="报名账号">
|
||||
<a-input
|
||||
v-model:value="searchParams.accountNo"
|
||||
placeholder="请输入报名账号"
|
||||
allow-clear
|
||||
style="width: 180px"
|
||||
/>
|
||||
<a-input v-model:value="searchParams.accountNo" placeholder="请输入报名账号" allow-clear style="width: 150px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="奖项">
|
||||
<a-select v-model:value="searchParams.awardLevel" placeholder="全部" allow-clear style="width: 120px" @change="handleSearch">
|
||||
<a-select-option v-for="opt in awardFilterOptions" :key="opt" :value="opt">{{ opt }}</a-select-option>
|
||||
<a-select-option value="_none">无奖项</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
<a-button type="primary" html-type="submit"><template #icon><search-outlined /></template> 搜索</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset"><template #icon><reload-outlined /></template> 重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id" @change="handleTableChange" class="data-table">
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'index'">
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'rank'">
|
||||
<span v-if="record.rank" class="rank-badge" :class="getRankClass(record.rank)">{{ record.rank }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'workNo'">
|
||||
<a @click="handleViewWorkDetail(record)">{{ record.workNo || "-" }}</a>
|
||||
<a @click="handleViewWorkDetail(record)">{{ record.workNo || '-' }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'finalScore'">
|
||||
<span v-if="record.judgeScore !== null && record.judgeScore !== undefined" class="score">
|
||||
{{ Number(record.judgeScore).toFixed(2) }}
|
||||
</span>
|
||||
<span v-else-if="record.finalScore !== null" class="score">
|
||||
{{ Number(record.finalScore).toFixed(2) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
<span v-if="record.finalScore != null" class="score">{{ Number(record.finalScore).toFixed(2) }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'awardLevel'">
|
||||
<a-tag v-if="record.awardName" :color="getAwardColor(record.awardName)">
|
||||
{{ record.awardName }}
|
||||
</a-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'nickname'">
|
||||
{{ record.registration?.user?.nickname || "-" }}
|
||||
{{ record.registration?.user?.nickname || record.registration?.team?.teamName || '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'username'">
|
||||
{{ record.registration?.user?.username || "-" }}
|
||||
{{ record.registration?.user?.username || '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'org'">
|
||||
<div>
|
||||
<div>{{ record.registration?.user?.tenant?.name || "-" }}</div>
|
||||
<div v-if="record.registration?.user?.student?.class" class="org-detail">
|
||||
{{ record.registration.user.student.class.grade?.name || "" }}
|
||||
{{ record.registration.user.student.class.name || "" }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'teachers'">
|
||||
{{ formatTeachers(record.registration?.teachers) }}
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button v-if="!isSuperAdmin && contestInfo?.resultState !== 'published'" type="link" size="small" @click="openSetAward(record)">设奖</a-button>
|
||||
<a-button type="link" size="small" @click="handleViewWorkDetail(record)">查看</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 作品详情弹框 -->
|
||||
<WorkDetailModal
|
||||
v-model:open="workDetailModalVisible"
|
||||
:work-id="currentWorkId"
|
||||
/>
|
||||
<WorkDetailModal v-model:open="workDetailModalVisible" :work-id="currentWorkId" />
|
||||
|
||||
<!-- 单个设置奖项弹窗 -->
|
||||
<a-modal v-model:open="setAwardVisible" title="设置奖项" @ok="handleSetAward" :confirm-loading="setAwardLoading">
|
||||
<a-form layout="vertical" style="margin-top: 16px">
|
||||
<a-form-item label="作品">
|
||||
<span>{{ currentAwardWork?.workNo }} — {{ currentAwardWork?.registration?.user?.nickname || currentAwardWork?.registration?.team?.teamName }}</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="奖项名称" required>
|
||||
<a-select v-model:value="awardForm.awardName" placeholder="选择或输入奖项名称" mode="combobox" :options="existingAwardOptions" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 自动设置奖项弹窗 -->
|
||||
<a-modal v-model:open="autoAwardVisible" title="按排名自动设置奖项" @ok="handleAutoSetAwards" :confirm-loading="autoAwardLoading" width="520px">
|
||||
<p style="color: #6b7280; font-size: 13px; margin-bottom: 16px">
|
||||
自定义奖项名称和获奖人数,系统将按排名从高到低依次分配。
|
||||
</p>
|
||||
<div class="award-tiers">
|
||||
<div v-for="(tier, idx) in autoAwardTiers" :key="idx" class="award-tier-row">
|
||||
<a-input v-model:value="tier.name" placeholder="奖项名称,如:金奖" style="flex: 1" />
|
||||
<a-input-number v-model:value="tier.count" :min="1" placeholder="人数" style="width: 100px" />
|
||||
<a-button type="text" danger @click="autoAwardTiers.splice(idx, 1)" :disabled="autoAwardTiers.length <= 1">
|
||||
<template #icon><delete-outlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
<a-button type="dashed" block @click="autoAwardTiers.push({ name: '', count: 1 })" style="margin-top: 8px">
|
||||
<template #icon><plus-outlined /></template>
|
||||
添加奖项
|
||||
</a-button>
|
||||
</div>
|
||||
<div style="margin-top: 16px; display: flex; justify-content: space-between; color: #6b7280; font-size: 13px">
|
||||
<span>已排名作品:{{ summary.rankedWorks }} 个</span>
|
||||
<span>将分配:{{ autoAwardTotal }} 个</span>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { message, Modal } from "ant-design-vue"
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
import { resultsApi } from "@/api/contests"
|
||||
import WorkDetailModal from "../components/WorkDetailModal.vue"
|
||||
ArrowLeftOutlined, SearchOutlined, ReloadOutlined,
|
||||
FileTextOutlined, CheckCircleOutlined, OrderedListOutlined,
|
||||
TrophyOutlined, FundOutlined, CalculatorOutlined,
|
||||
DeleteOutlined, PlusOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { resultsApi, type ContestResult, type ResultsSummary } from '@/api/contests'
|
||||
import WorkDetailModal from '../components/WorkDetailModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@ -129,82 +179,95 @@ const isSuperAdmin = computed(() => authStore.hasAnyRole(['super_admin']))
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
const contestId = Number(route.params.id)
|
||||
|
||||
// 活动信息
|
||||
const contestInfo = ref<{
|
||||
id: number
|
||||
contestName: string
|
||||
resultState: string
|
||||
} | null>(null)
|
||||
// 奖项颜色:按在列表中出现的顺序循环分配
|
||||
const awardColors = ['red', 'orange', 'blue', 'green', 'purple', 'cyan', 'magenta']
|
||||
const getAwardColor = (awardName: string) => {
|
||||
const names = [...new Set(dataSource.value.filter(w => w.awardName).map(w => w.awardName!))]
|
||||
const idx = names.indexOf(awardName)
|
||||
return idx >= 0 ? awardColors[idx % awardColors.length] : 'default'
|
||||
}
|
||||
|
||||
// 作品详情弹框
|
||||
const getRankClass = (rank: number) => {
|
||||
if (rank === 1) return 'gold'
|
||||
if (rank === 2) return 'silver'
|
||||
if (rank === 3) return 'bronze'
|
||||
return ''
|
||||
}
|
||||
|
||||
// 活动信息
|
||||
const contestInfo = ref<any>(null)
|
||||
const summary = ref({ totalWorks: 0, scoredWorks: 0, rankedWorks: 0, awardedWorks: 0, unscoredWorks: 0, avgScore: null as string | null })
|
||||
const canPublish = computed(() => summary.value.rankedWorks > 0)
|
||||
|
||||
// 奖项筛选选项(从已有数据中动态提取)
|
||||
const awardFilterOptions = computed(() => {
|
||||
const names = new Set<string>()
|
||||
dataSource.value.forEach(w => { if (w.awardName) names.add(w.awardName) })
|
||||
return Array.from(names)
|
||||
})
|
||||
|
||||
// 列表
|
||||
const loading = ref(false)
|
||||
const publishLoading = ref(false)
|
||||
const dataSource = ref<ContestResult[]>([])
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` })
|
||||
const searchParams = reactive({ workNo: '', accountNo: '', awardLevel: undefined as string | undefined })
|
||||
|
||||
// 作品详情
|
||||
const workDetailModalVisible = ref(false)
|
||||
const currentWorkId = ref<number | null>(null)
|
||||
|
||||
// 列表状态
|
||||
const loading = ref(false)
|
||||
const publishLoading = ref(false)
|
||||
const dataSource = ref<any[]>([])
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
// 计算loading
|
||||
const calcScoreLoading = ref(false)
|
||||
const calcRankLoading = ref(false)
|
||||
|
||||
// 单个设奖
|
||||
const setAwardVisible = ref(false)
|
||||
const setAwardLoading = ref(false)
|
||||
const currentAwardWork = ref<ContestResult | null>(null)
|
||||
const awardForm = reactive({ awardLevel: '' as string, awardName: '' })
|
||||
|
||||
// 奖项选项:来自自动设奖配置 + 数据中已有的奖项名称
|
||||
const existingAwardOptions = computed(() => {
|
||||
const names = new Set<string>()
|
||||
// 从自动设奖配置中获取
|
||||
autoAwardTiers.value.forEach(t => { if (t.name) names.add(t.name) })
|
||||
// 从已有数据中获取
|
||||
dataSource.value.forEach(w => { if (w.awardName) names.add(w.awardName) })
|
||||
return Array.from(names).map(n => ({ value: n, label: n }))
|
||||
})
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = reactive({
|
||||
workNo: "",
|
||||
accountNo: "",
|
||||
// 自动设奖
|
||||
const autoAwardVisible = ref(false)
|
||||
const autoAwardLoading = ref(false)
|
||||
const autoAwardTiers = ref<Array<{ name: string; count: number }>>([
|
||||
{ name: '一等奖', count: 1 },
|
||||
{ name: '二等奖', count: 2 },
|
||||
{ name: '三等奖', count: 3 },
|
||||
])
|
||||
const autoAwardTotal = computed(() => autoAwardTiers.value.reduce((sum, t) => sum + (t.count || 0), 0))
|
||||
|
||||
const columns = computed(() => {
|
||||
const cols: any[] = [
|
||||
{ title: '排名', key: 'rank', width: 70 },
|
||||
{ title: '作品编号', key: 'workNo', width: 120 },
|
||||
{ title: '最终得分', key: 'finalScore', width: 90 },
|
||||
{ title: '奖项', key: 'awardLevel', width: 100 },
|
||||
{ title: '姓名/队伍', key: 'nickname', width: 120 },
|
||||
{ title: '账号', key: 'username', width: 120 },
|
||||
]
|
||||
cols.push({ title: '操作', key: 'action', width: 120, fixed: 'right' as const })
|
||||
return cols
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: "序号",
|
||||
key: "index",
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
title: "作品编号",
|
||||
key: "workNo",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "评委评分",
|
||||
key: "finalScore",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "姓名",
|
||||
key: "nickname",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "账号",
|
||||
key: "username",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "机构信息",
|
||||
key: "org",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "指导老师",
|
||||
key: "teachers",
|
||||
width: 150,
|
||||
},
|
||||
]
|
||||
|
||||
// 格式化指导老师
|
||||
const formatTeachers = (teachers: any[] | undefined) => {
|
||||
if (!teachers || teachers.length === 0) return "-"
|
||||
return teachers
|
||||
.map((t) => t.user?.nickname || t.user?.username)
|
||||
.filter(Boolean)
|
||||
.join("、") || "-"
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
const res: ResultsSummary = await resultsApi.getSummary(contestId)
|
||||
contestInfo.value = res.contest
|
||||
summary.value = { ...res.summary, avgScore: res.scoreStats?.avgScore || null }
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@ -215,183 +278,167 @@ const fetchList = async () => {
|
||||
accountNo: searchParams.accountNo || undefined,
|
||||
})
|
||||
contestInfo.value = response.contest
|
||||
dataSource.value = response.list
|
||||
pagination.total = response.total
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "获取列表失败")
|
||||
} finally {
|
||||
loading.value = false
|
||||
let list = response.list
|
||||
// 前端过滤奖项
|
||||
if (searchParams.awardLevel) {
|
||||
if (searchParams.awardLevel === '_none') {
|
||||
list = list.filter(w => !w.awardName)
|
||||
} else {
|
||||
list = list.filter(w => w.awardName === searchParams.awardLevel)
|
||||
}
|
||||
}
|
||||
dataSource.value = list
|
||||
pagination.total = searchParams.awardLevel ? list.length : response.total
|
||||
} catch (e: any) { message.error(e?.response?.data?.message || '获取列表失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
const handleSearch = () => { pagination.current = 1; fetchList() }
|
||||
const handleReset = () => { searchParams.workNo = ''; searchParams.accountNo = ''; searchParams.awardLevel = undefined; pagination.current = 1; fetchList() }
|
||||
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
|
||||
const handleBack = () => { router.push(`/${tenantCode}/contests/results`) }
|
||||
const handleViewWorkDetail = (record: any) => { currentWorkId.value = record.id; workDetailModalVisible.value = true }
|
||||
|
||||
// 计算得分
|
||||
const handleCalculateScores = async () => {
|
||||
calcScoreLoading.value = true
|
||||
try {
|
||||
const res = await resultsApi.calculateScores(contestId)
|
||||
message.success(res.message)
|
||||
fetchList()
|
||||
fetchSummary()
|
||||
} catch (e: any) { message.error(e?.response?.data?.message || '计算失败') }
|
||||
finally { calcScoreLoading.value = false }
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
searchParams.workNo = ""
|
||||
searchParams.accountNo = ""
|
||||
pagination.current = 1
|
||||
// 计算排名
|
||||
const handleCalculateRankings = async () => {
|
||||
calcRankLoading.value = true
|
||||
try {
|
||||
const res = await resultsApi.calculateRankings(contestId)
|
||||
message.success(res.message)
|
||||
fetchList()
|
||||
fetchSummary()
|
||||
} catch (e: any) { message.error(e?.response?.data?.message || '计算失败') }
|
||||
finally { calcRankLoading.value = false }
|
||||
}
|
||||
|
||||
// 表格变化
|
||||
const handleTableChange = (pag: any) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
// 单个设奖
|
||||
const openSetAward = (record: ContestResult) => {
|
||||
currentAwardWork.value = record
|
||||
awardForm.awardName = record.awardName || ''
|
||||
awardForm.awardLevel = record.awardLevel || ''
|
||||
setAwardVisible.value = true
|
||||
}
|
||||
const handleSetAward = async () => {
|
||||
if (!awardForm.awardName) { message.warning('请输入奖项名称'); return }
|
||||
setAwardLoading.value = true
|
||||
try {
|
||||
await resultsApi.setAward(currentAwardWork.value!.id, {
|
||||
awardLevel: awardForm.awardName, // 用名称作为 level
|
||||
awardName: awardForm.awardName,
|
||||
})
|
||||
message.success('设置成功')
|
||||
setAwardVisible.value = false
|
||||
fetchList()
|
||||
fetchSummary()
|
||||
} catch (e: any) { message.error(e?.response?.data?.message || '设置失败') }
|
||||
finally { setAwardLoading.value = false }
|
||||
}
|
||||
|
||||
// 返回
|
||||
const handleBack = () => {
|
||||
router.push(`/${tenantCode}/contests/results`)
|
||||
// 自动设奖
|
||||
const handleAutoSetAwards = async () => {
|
||||
const validTiers = autoAwardTiers.value.filter(t => t.name && t.count > 0)
|
||||
if (validTiers.length === 0) { message.warning('请至少设置一个奖项'); return }
|
||||
autoAwardLoading.value = true
|
||||
try {
|
||||
await resultsApi.autoSetAwards(contestId, { awards: validTiers })
|
||||
message.success('自动设奖完成')
|
||||
autoAwardVisible.value = false
|
||||
fetchList()
|
||||
fetchSummary()
|
||||
} catch (e: any) { message.error(e?.response?.data?.message || '设奖失败') }
|
||||
finally { autoAwardLoading.value = false }
|
||||
}
|
||||
|
||||
// 查看作品详情
|
||||
const handleViewWorkDetail = (record: any) => {
|
||||
currentWorkId.value = record.id
|
||||
workDetailModalVisible.value = true
|
||||
}
|
||||
|
||||
// 发布/撤回成果
|
||||
// 发布/撤回
|
||||
const handlePublish = () => {
|
||||
const isPublished = contestInfo.value?.resultState === "published"
|
||||
|
||||
const isPublished = contestInfo.value?.resultState === 'published'
|
||||
Modal.confirm({
|
||||
title: isPublished ? "确定撤回成果发布吗?" : "确定发布成果吗?",
|
||||
content: isPublished
|
||||
? "撤回后,成果将不再对外公开显示"
|
||||
: "发布后,活动结果将公开显示",
|
||||
title: isPublished ? '确定撤回成果发布?' : '确定发布成果?',
|
||||
content: isPublished ? '撤回后成果将不再对外公开' : `将发布 ${summary.value.rankedWorks} 个作品的排名和奖项信息`,
|
||||
okText: isPublished ? '撤回' : '确定发布',
|
||||
okType: isPublished ? 'danger' : 'primary',
|
||||
onOk: async () => {
|
||||
publishLoading.value = true
|
||||
try {
|
||||
if (isPublished) {
|
||||
await resultsApi.unpublish(contestId)
|
||||
message.success("已撤回发布")
|
||||
message.success('已撤回发布')
|
||||
} else {
|
||||
await resultsApi.publish(contestId)
|
||||
message.success("发布成功")
|
||||
message.success('发布成功')
|
||||
}
|
||||
fetchList()
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "操作失败")
|
||||
} finally {
|
||||
publishLoading.value = false
|
||||
}
|
||||
fetchSummary()
|
||||
} catch (e: any) { message.error(e?.response?.data?.message || '操作失败') }
|
||||
finally { publishLoading.value = false }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
onMounted(() => { fetchSummary(); fetchList() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.results-detail-page {
|
||||
:deep(.ant-btn-primary) {
|
||||
background: linear-gradient(135deg, $primary 0%, darken($primary, 10%) 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba($primary, 0.35);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 16px rgba($primary, 0.45);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper) {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
|
||||
.ant-table {
|
||||
.ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover > td {
|
||||
background: rgba($primary, 0.04);
|
||||
}
|
||||
|
||||
> td {
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-pagination {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 16px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px;
|
||||
.header-left { display: flex; align-items: center; gap: 8px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; color: #1e1b4b; }
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 16px 24px;
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 0;
|
||||
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card {
|
||||
flex: 1; display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
.stat-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
|
||||
.stat-info { display: flex; flex-direction: column;
|
||||
.stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
|
||||
.stat-label { font-size: 12px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
|
||||
.org-detail {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
.action-bar {
|
||||
padding: 16px 20px; background: rgba($primary, 0.03); border: 1px dashed rgba($primary, 0.15);
|
||||
border-radius: 12px; margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-weight: bold;
|
||||
color: #52c41a;
|
||||
.filter-bar { padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px; }
|
||||
|
||||
.data-table {
|
||||
:deep(.ant-table-wrapper) { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
|
||||
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
|
||||
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
|
||||
.ant-table-pagination { padding: 16px; margin: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: 50%; font-size: 13px; font-weight: 700; background: #f3f4f6; color: #374151;
|
||||
&.gold { background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #fff; }
|
||||
&.silver { background: linear-gradient(135deg, #d1d5db, #9ca3af); color: #fff; }
|
||||
&.bronze { background: linear-gradient(135deg, #f59e0b, #d97706); color: #fff; }
|
||||
}
|
||||
|
||||
.score { font-weight: 700; color: #10b981; }
|
||||
.text-muted { color: #d1d5db; }
|
||||
|
||||
.award-tiers {
|
||||
.award-tier-row {
|
||||
display: flex; gap: 8px; align-items: center; margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -126,48 +126,60 @@
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<!-- ========== 机构端:保持不变 ========== -->
|
||||
<!-- ========== 机构端 ========== -->
|
||||
<template v-else>
|
||||
<a-card class="title-card">
|
||||
<template #title>成果发布</template>
|
||||
</a-card>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" class="org-tabs" @change="handleTabChange">
|
||||
<a-tab-pane key="individual" tab="个人参与" />
|
||||
<a-tab-pane key="team" tab="团队参与" />
|
||||
</a-tabs>
|
||||
<!-- 统计概览 -->
|
||||
<div class="stats-row">
|
||||
<div v-for="item in orgStatsItems" :key="item.key" :class="['stat-card', { active: orgActiveFilter === item.key }]" @click="handleOrgStatClick(item.key)">
|
||||
<div class="stat-icon" :style="{ background: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-count">{{ item.count }}</span>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form :model="searchParams" layout="inline" class="filter-bar" @finish="handleSearch">
|
||||
<!-- 筛选 -->
|
||||
<div class="filter-bar">
|
||||
<a-form layout="inline" @finish="handleSearch">
|
||||
<a-form-item label="活动名称">
|
||||
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 200px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="活动类型">
|
||||
<a-select v-model:value="searchParams.contestType" placeholder="全部" allow-clear style="width: 120px" @change="handleSearch">
|
||||
<a-select-option value="individual">个人参与</a-select-option>
|
||||
<a-select-option value="team">团队参与</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="发布状态">
|
||||
<a-select v-model:value="orgResultState" placeholder="全部" allow-clear style="width: 110px" @change="handleSearch">
|
||||
<a-select-option value="published">已发布</a-select-option>
|
||||
<a-select-option value="unpublished">未发布</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
<a-button type="primary" html-type="submit"><template #icon><SearchOutlined /></template> 搜索</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset"><template #icon><ReloadOutlined /></template> 重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="orgColumns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
class="data-table"
|
||||
>
|
||||
<a-table :columns="orgColumns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id" @change="handleTableChange" class="data-table">
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'index'">
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'organizers'">
|
||||
{{ record.organizers || '-' }}
|
||||
<template v-else-if="column.key === 'contestName'">
|
||||
<a @click="handleViewDetail(record)">{{ record.contestName }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'contestType'">
|
||||
<a-tag :color="record.contestType === 'individual' ? 'blue' : 'green'">{{ record.contestType === 'individual' ? '个人' : '团队' }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'registrationCount'">
|
||||
{{ record._count?.registrations || 0 }}
|
||||
@ -180,7 +192,8 @@
|
||||
<a-tag v-else color="default">未发布</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">详情</a-button>
|
||||
<a-button v-if="record.resultState === 'published'" type="link" size="small" @click="handleViewDetail(record)">查看成果</a-button>
|
||||
<a-button v-else type="link" size="small" style="color: #10b981" @click="handleViewDetail(record)">发布成果</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@ -342,42 +355,73 @@ const superColumns = [
|
||||
]
|
||||
|
||||
// =============================================
|
||||
// 机构端逻辑(保持不变)
|
||||
// 机构端逻辑
|
||||
// =============================================
|
||||
const activeTab = ref<'individual' | 'team'>('individual')
|
||||
const orgActiveFilter = ref('')
|
||||
const orgPublishedCount = ref(0)
|
||||
const orgUnpublishedCount = ref(0)
|
||||
const orgResultState = ref<string | undefined>(undefined)
|
||||
|
||||
const orgStatsItems = computed(() => [
|
||||
{ key: '', label: '全部', count: orgPublishedCount.value + orgUnpublishedCount.value, icon: TrophyOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
|
||||
{ key: 'published', label: '已发布', count: orgPublishedCount.value, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
|
||||
{ key: 'unpublished', label: '未发布', count: orgUnpublishedCount.value, icon: CloseCircleOutlined, color: '#9ca3af', bgColor: 'rgba(156,163,175,0.1)' },
|
||||
])
|
||||
|
||||
const handleOrgStatClick = (key: string) => {
|
||||
if (orgActiveFilter.value === key) { orgActiveFilter.value = ''; orgResultState.value = undefined }
|
||||
else { orgActiveFilter.value = key; orgResultState.value = key || undefined }
|
||||
pagination.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const dataSource = ref<Contest[]>([])
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` })
|
||||
const searchParams = reactive<QueryContestParams>({ contestName: '' })
|
||||
|
||||
const orgColumns = [
|
||||
{ title: '序号', key: 'index', width: 70 },
|
||||
{ title: '活动名称', key: 'contestName', dataIndex: 'contestName', width: 250 },
|
||||
{ title: '主办机构', key: 'organizers', width: 140 },
|
||||
{ title: '报名人数', key: 'registrationCount', width: 100 },
|
||||
{ title: '提交作品数', key: 'worksCount', width: 100 },
|
||||
{ title: '发布状态', key: 'resultState', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
|
||||
{ title: '序号', key: 'index', width: 50 },
|
||||
{ title: '活动名称', key: 'contestName', width: 200 },
|
||||
{ title: '类型', key: 'contestType', width: 70 },
|
||||
{ title: '报名', key: 'registrationCount', width: 70 },
|
||||
{ title: '作品', key: 'worksCount', width: 70 },
|
||||
{ title: '发布状态', key: 'resultState', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 110, fixed: 'right' as const },
|
||||
]
|
||||
|
||||
const fetchOrgStats = async () => {
|
||||
try {
|
||||
const res = await contestsApi.getList({ page: 1, pageSize: 100 })
|
||||
orgPublishedCount.value = res.list.filter(c => c.resultState === 'published').length
|
||||
orgUnpublishedCount.value = res.list.filter(c => c.resultState !== 'published').length
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await contestsApi.getList({
|
||||
...searchParams,
|
||||
contestType: activeTab.value,
|
||||
contestName: searchParams.contestName || undefined,
|
||||
contestType: searchParams.contestType || undefined,
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
})
|
||||
dataSource.value = res.list
|
||||
pagination.total = res.total
|
||||
// 前端过滤发布状态
|
||||
let list = res.list
|
||||
if (orgResultState.value) {
|
||||
list = list.filter(c =>
|
||||
orgResultState.value === 'published' ? c.resultState === 'published' : c.resultState !== 'published'
|
||||
)
|
||||
}
|
||||
dataSource.value = list
|
||||
pagination.total = orgResultState.value ? list.length : res.total
|
||||
} catch { message.error('获取列表失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleTabChange = () => { pagination.current = 1; fetchList() }
|
||||
const handleSearch = () => { pagination.current = 1; fetchList() }
|
||||
const handleReset = () => { searchParams.contestName = ''; pagination.current = 1; fetchList() }
|
||||
const handleSearch = () => { orgActiveFilter.value = ''; pagination.current = 1; fetchList() }
|
||||
const handleReset = () => { searchParams.contestName = ''; searchParams.contestType = undefined; orgResultState.value = undefined; orgActiveFilter.value = ''; pagination.current = 1; fetchList(); fetchOrgStats() }
|
||||
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
|
||||
const handleViewDetail = (record: Contest) => { router.push(`/${tenantCode}/contests/results/${record.id}`) }
|
||||
|
||||
@ -390,6 +434,7 @@ onMounted(() => {
|
||||
fetchSuperStats()
|
||||
fetchSuperList()
|
||||
} else {
|
||||
fetchOrgStats()
|
||||
fetchList()
|
||||
}
|
||||
})
|
||||
@ -450,11 +495,4 @@ $primary: #6366f1;
|
||||
.contest-link { padding: 0; text-align: left; }
|
||||
.text-muted { color: #d1d5db; }
|
||||
|
||||
.org-tabs {
|
||||
background: #fff;
|
||||
padding: 0 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -53,7 +53,19 @@
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'ruleDescription'">
|
||||
<span>{{ formatDimensions(record.dimensions) }}</span>
|
||||
<span v-if="record.ruleDescription" class="text-desc">{{ record.ruleDescription }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'dimensions'">
|
||||
<span class="text-desc">{{ formatDimensions(record.dimensions) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'judgeCount'">
|
||||
<a-tooltip title="每个作品需要几位评委评分">
|
||||
<a-tag color="blue">{{ record.judgeCount || '-' }}人/作品</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'calculationRule'">
|
||||
{{ calculationRuleText[record.calculationRule] || record.calculationRule || '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'contests'">
|
||||
<span v-if="record.contests && record.contests.length > 0">
|
||||
@ -73,7 +85,7 @@
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-permission="'contest:update'"
|
||||
title="确定要删除这个评审规则吗?"
|
||||
:title="record.contests?.length > 0 ? `该规则已关联${record.contests.length}个活动,确定删除吗?` : '确定要删除这个评审规则吗?'"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<a-button type="link" danger size="small">删除</a-button>
|
||||
@ -86,7 +98,7 @@
|
||||
<!-- 新增/编辑评审规则抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="modalVisible"
|
||||
title="编辑规则"
|
||||
:title="isEditing ? '编辑规则' : '新建规则'"
|
||||
placement="right"
|
||||
width="600px"
|
||||
:footer-style="{ textAlign: 'right' }"
|
||||
@ -355,35 +367,24 @@ const rules = {
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: "序号",
|
||||
key: "index",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "规则名称",
|
||||
dataIndex: "ruleName",
|
||||
key: "ruleName",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: "规则描述",
|
||||
key: "ruleDescription",
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
title: "关联活动",
|
||||
key: "contests",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 150,
|
||||
fixed: "right" as const,
|
||||
},
|
||||
{ title: "序号", key: "index", width: 50 },
|
||||
{ title: "规则名称", dataIndex: "ruleName", key: "ruleName", width: 160 },
|
||||
{ title: "规则说明", key: "ruleDescription", width: 200 },
|
||||
{ title: "评分维度", key: "dimensions", width: 200 },
|
||||
{ title: "每作品评委", key: "judgeCount", width: 90 },
|
||||
{ title: "计算方式", key: "calculationRule", width: 100 },
|
||||
{ title: "关联活动", key: "contests", width: 150 },
|
||||
{ title: "操作", key: "action", width: 130, fixed: "right" as const },
|
||||
]
|
||||
|
||||
const calculationRuleText: Record<string, string> = {
|
||||
average: '全部均值',
|
||||
remove_max_min: '去最高最低',
|
||||
removeMaxMin: '去最高最低',
|
||||
remove_min: '去最低分',
|
||||
removeMin: '去最低分',
|
||||
}
|
||||
|
||||
// 格式化维度描述
|
||||
const formatDimensions = (dimensions: any) => {
|
||||
if (!dimensions) return "-"
|
||||
@ -573,99 +574,28 @@ const handleCancel = () => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
$primary: #6366f1;
|
||||
|
||||
.review-rules-page {
|
||||
:deep(.ant-card) {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-card-head {
|
||||
border-bottom: none;
|
||||
padding: 16px 24px;
|
||||
|
||||
.ant-card-head-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary) {
|
||||
background: $gradient-primary;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba($primary, 0.35);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 8%) 100%);
|
||||
box-shadow: 0 6px 16px rgba($primary, 0.45);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px;
|
||||
.ant-card-head { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||
.ant-card-body { padding: 0; }
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper) {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
|
||||
.ant-table {
|
||||
.ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover > td {
|
||||
background: rgba($primary, 0.04);
|
||||
}
|
||||
|
||||
> td {
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-pagination {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
|
||||
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
|
||||
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
|
||||
.ant-table-pagination { padding: 16px; margin: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
.text-desc { font-size: 12px; color: #6b7280; }
|
||||
.text-muted { color: #d1d5db; }
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
margin-right: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
margin-bottom: 16px; padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.scoring-standards {
|
||||
|
||||
@ -191,64 +191,74 @@
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<!-- ========== 机构端:保持原有两层结构 ========== -->
|
||||
<!-- ========== 机构端 ========== -->
|
||||
<template v-else>
|
||||
<a-card class="title-card">
|
||||
<template #title>评审进度</template>
|
||||
</a-card>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" class="org-tabs" @change="handleTabChange">
|
||||
<a-tab-pane key="individual" tab="个人参与" />
|
||||
<a-tab-pane key="team" tab="团队参与" />
|
||||
</a-tabs>
|
||||
<!-- 统计概览 -->
|
||||
<div class="stats-row">
|
||||
<div v-for="item in orgStatsItems" :key="item.key" class="stat-card">
|
||||
<div class="stat-icon" :style="{ background: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-count">{{ item.value }}</span>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form :model="searchParams" layout="inline" class="filter-bar" @finish="handleSearch">
|
||||
<!-- 筛选 -->
|
||||
<div class="filter-bar">
|
||||
<a-form layout="inline" @finish="handleSearch">
|
||||
<a-form-item label="活动名称">
|
||||
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 200px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="活动类型">
|
||||
<a-select v-model:value="searchParams.contestType" placeholder="全部" allow-clear style="width: 120px" @change="handleSearch">
|
||||
<a-select-option value="individual">个人参与</a-select-option>
|
||||
<a-select-option value="team">团队参与</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
<a-button type="primary" html-type="submit"><template #icon><SearchOutlined /></template> 搜索</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset"><template #icon><ReloadOutlined /></template> 重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="orgColumns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
class="data-table"
|
||||
>
|
||||
<a-table :columns="orgColumns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id" @change="handleTableChange" class="data-table">
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'index'">
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'organizers'">
|
||||
{{ record.organizers || '-' }}
|
||||
<template v-else-if="column.key === 'contestName'">
|
||||
<a @click="handleViewDetail(record)">{{ record.contestName }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'contestType'">
|
||||
<a-tag :color="record.contestType === 'individual' ? 'blue' : 'green'">{{ record.contestType === 'individual' ? '个人' : '团队' }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'reviewStatus'">
|
||||
<a-tag :color="getOrgReviewStatusColor(record)">{{ getOrgReviewStatusText(record) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'reviewedCount'">
|
||||
{{ record.reviewedCount || 0 }} / {{ record.totalWorksCount || 0 }}
|
||||
<template v-else-if="column.key === 'reviewProgress'">
|
||||
<div class="progress-cell">
|
||||
<span class="progress-num" :class="getProgressClass(record)">{{ record.reviewedCount || 0 }}</span>
|
||||
<span class="progress-sep">/</span>
|
||||
<span class="progress-total">{{ record.totalWorksCount || 0 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'reviewTime'">
|
||||
<div v-if="record.reviewStartTime || record.reviewEndTime">
|
||||
<div v-if="record.reviewStartTime">
|
||||
<div>{{ formatDate(record.reviewStartTime) }}</div>
|
||||
<div>至 {{ formatDate(record.reviewEndTime) }}</div>
|
||||
<div class="text-muted">至 {{ formatDate(record.reviewEndTime) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">查看详情</a-button>
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">查看详情 <right-outlined /></a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@ -268,6 +278,7 @@ import {
|
||||
MinusCircleOutlined,
|
||||
SyncOutlined,
|
||||
CheckCircleOutlined,
|
||||
RightOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import {
|
||||
contestsApi,
|
||||
@ -444,39 +455,60 @@ const scoreColumns = [
|
||||
]
|
||||
|
||||
// =============================================
|
||||
// 机构端逻辑(保持不变)
|
||||
// 机构端逻辑
|
||||
// =============================================
|
||||
const activeTab = ref<'individual' | 'team'>('individual')
|
||||
const orgStats = ref<WorksStats>({ total: 0, submitted: 0, reviewing: 0, reviewed: 0 })
|
||||
const orgStatsItems = computed(() => [
|
||||
{ key: 'total', label: '总作品', value: orgStats.value.total, icon: FileTextOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
|
||||
{ key: 'not_reviewed', label: '未评审', value: orgStats.value.submitted, icon: MinusCircleOutlined, color: '#9ca3af', bgColor: 'rgba(156,163,175,0.1)' },
|
||||
{ key: 'reviewing', label: '评审中', value: orgStats.value.reviewing, icon: SyncOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
|
||||
{ key: 'reviewed', label: '已完成', value: orgStats.value.reviewed, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
|
||||
])
|
||||
|
||||
const loading = ref(false)
|
||||
const dataSource = ref<Contest[]>([])
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
|
||||
const searchParams = reactive({ contestName: '' })
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` })
|
||||
const searchParams = reactive<{ contestName?: string; contestType?: string }>({})
|
||||
|
||||
const orgColumns = [
|
||||
{ title: '序号', key: 'index', width: 70 },
|
||||
{ title: '活动名称', dataIndex: 'contestName', key: 'contestName', width: 200 },
|
||||
{ title: '主办机构', key: 'organizers', width: 140 },
|
||||
{ title: '评审状态', key: 'reviewStatus', width: 100 },
|
||||
{ title: '已评审/作品数', key: 'reviewedCount', width: 130 },
|
||||
{ title: '评审时间', key: 'reviewTime', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
|
||||
{ title: '序号', key: 'index', width: 50 },
|
||||
{ title: '活动名称', key: 'contestName', width: 200 },
|
||||
{ title: '类型', key: 'contestType', width: 70 },
|
||||
{ title: '评审状态', key: 'reviewStatus', width: 80 },
|
||||
{ title: '评审进度', key: 'reviewProgress', width: 100 },
|
||||
{ title: '评审时间', key: 'reviewTime', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 120, fixed: 'right' as const },
|
||||
]
|
||||
|
||||
const getOrgReviewStatusColor = (record: Contest) => {
|
||||
const now = new Date()
|
||||
const start = record.reviewStartTime ? new Date(record.reviewStartTime) : null
|
||||
const end = record.reviewEndTime ? new Date(record.reviewEndTime) : null
|
||||
if (!start || now < start) return 'default'
|
||||
if (end && now > end) return 'success'
|
||||
return 'processing'
|
||||
// #3 评审状态改用实际完成率
|
||||
const getOrgReviewStatusColor = (record: any) => {
|
||||
const reviewed = record.reviewedCount || 0
|
||||
const total = record.totalWorksCount || 0
|
||||
if (total === 0) return 'default'
|
||||
if (reviewed >= total) return 'success'
|
||||
if (reviewed > 0) return 'processing'
|
||||
return 'warning'
|
||||
}
|
||||
const getOrgReviewStatusText = (record: Contest) => {
|
||||
const now = new Date()
|
||||
const start = record.reviewStartTime ? new Date(record.reviewStartTime) : null
|
||||
const end = record.reviewEndTime ? new Date(record.reviewEndTime) : null
|
||||
if (!start || now < start) return '未开始'
|
||||
if (end && now > end) return '已完成'
|
||||
return '进行中'
|
||||
const getOrgReviewStatusText = (record: any) => {
|
||||
const reviewed = record.reviewedCount || 0
|
||||
const total = record.totalWorksCount || 0
|
||||
if (total === 0) return '无作品'
|
||||
if (reviewed >= total) return '已完成'
|
||||
if (reviewed > 0) return '进行中'
|
||||
return '未开始'
|
||||
}
|
||||
|
||||
// #4 进度颜色
|
||||
const getProgressClass = (record: any) => {
|
||||
const reviewed = record.reviewedCount || 0
|
||||
const total = record.totalWorksCount || 0
|
||||
if (total > 0 && reviewed >= total) return 'complete'
|
||||
if (reviewed > 0) return 'partial'
|
||||
return 'empty'
|
||||
}
|
||||
|
||||
const fetchOrgStats = async () => {
|
||||
try { orgStats.value = await worksApi.getStats() } catch { /* */ }
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
@ -486,7 +518,7 @@ const fetchList = async () => {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
contestName: searchParams.contestName || undefined,
|
||||
contestType: activeTab.value,
|
||||
contestType: searchParams.contestType || undefined,
|
||||
})
|
||||
dataSource.value = res.list
|
||||
pagination.total = res.total
|
||||
@ -494,12 +526,11 @@ const fetchList = async () => {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleTabChange = () => { pagination.current = 1; fetchList() }
|
||||
const handleSearch = () => { pagination.current = 1; fetchList() }
|
||||
const handleReset = () => { searchParams.contestName = ''; pagination.current = 1; fetchList() }
|
||||
const handleReset = () => { searchParams.contestName = ''; searchParams.contestType = undefined; pagination.current = 1; fetchList(); fetchOrgStats() }
|
||||
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
|
||||
const handleViewDetail = (record: Contest) => {
|
||||
router.push(`/${tenantCode}/contests/reviews/${record.id}/progress?type=${activeTab.value}`)
|
||||
router.push(`/${tenantCode}/contests/reviews/${record.id}/progress`)
|
||||
}
|
||||
|
||||
// =============================================
|
||||
@ -514,6 +545,7 @@ onMounted(() => {
|
||||
fetchSuperStats()
|
||||
fetchSuperList()
|
||||
} else {
|
||||
fetchOrgStats()
|
||||
fetchList()
|
||||
}
|
||||
})
|
||||
@ -571,6 +603,16 @@ $primary: #6366f1;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-cell {
|
||||
.progress-num { font-weight: 700;
|
||||
&.complete { color: #10b981; }
|
||||
&.partial { color: #f59e0b; }
|
||||
&.empty { color: #d1d5db; }
|
||||
}
|
||||
.progress-sep { color: #d1d5db; margin: 0 2px; }
|
||||
.progress-total { color: #9ca3af; }
|
||||
}
|
||||
|
||||
.contest-link { padding: 0; text-align: left; }
|
||||
.work-link { color: $primary; cursor: pointer; &:hover { text-decoration: underline; } }
|
||||
.text-muted { color: #d1d5db; }
|
||||
@ -581,11 +623,4 @@ $primary: #6366f1;
|
||||
h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
|
||||
}
|
||||
|
||||
.org-tabs {
|
||||
background: #fff;
|
||||
padding: 0 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -52,6 +52,7 @@
|
||||
placeholder="请选择评审进度"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option value="not_reviewed">未评审</a-select-option>
|
||||
<a-select-option value="in_progress">评审中</a-select-option>
|
||||
@ -412,6 +413,14 @@ const fetchContestInfo = async () => {
|
||||
}
|
||||
|
||||
// 获取作品列表
|
||||
const getWorkReviewState = (record: any): string => {
|
||||
const reviewed = record.reviewedCount || 0
|
||||
const total = record.totalJudgesCount || 0
|
||||
if (reviewed === 0) return 'not_reviewed'
|
||||
if (total > 0 && reviewed >= total) return 'completed'
|
||||
return 'in_progress'
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@ -422,8 +431,13 @@ const fetchList = async () => {
|
||||
workNo: searchParams.workNo || undefined,
|
||||
username: searchParams.username || undefined,
|
||||
})
|
||||
dataSource.value = response.list
|
||||
pagination.total = response.total
|
||||
// 评审进度前端过滤
|
||||
let list = response.list
|
||||
if (searchParams.reviewProgress) {
|
||||
list = list.filter((w: any) => getWorkReviewState(w) === searchParams.reviewProgress)
|
||||
}
|
||||
dataSource.value = list
|
||||
pagination.total = searchParams.reviewProgress ? list.length : response.total
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "获取作品列表失败")
|
||||
} finally {
|
||||
|
||||
@ -126,64 +126,73 @@
|
||||
<WorkDetailModal v-model:open="workModalVisible" :work-id="currentWorkId" />
|
||||
</template>
|
||||
|
||||
<!-- ========== 机构端:保持原有两层结构 ========== -->
|
||||
<!-- ========== 机构端:活动维度 + 统计 ========== -->
|
||||
<template v-else>
|
||||
<a-card class="title-card">
|
||||
<template #title>参赛作品</template>
|
||||
<template #title>作品管理</template>
|
||||
</a-card>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" class="org-tabs" @change="handleTabChange">
|
||||
<a-tab-pane key="individual" tab="个人参与" />
|
||||
<a-tab-pane key="team" tab="团队参与" />
|
||||
</a-tabs>
|
||||
<!-- 统计概览 -->
|
||||
<div class="stats-row">
|
||||
<div v-for="item in orgStatsItems" :key="item.key" class="stat-card">
|
||||
<div class="stat-icon" :style="{ background: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-count">{{ item.value }}</span>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form :model="searchParams" layout="inline" class="filter-bar" @finish="handleSearch">
|
||||
<!-- 筛选 -->
|
||||
<div class="filter-bar">
|
||||
<a-form layout="inline" @finish="handleSearch">
|
||||
<a-form-item label="活动名称">
|
||||
<a-input v-model:value="searchParams.contestName" placeholder="请输入活动名称" allow-clear style="width: 200px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="活动类型">
|
||||
<a-select v-model:value="searchParams.contestType" placeholder="全部" allow-clear style="width: 120px" @change="handleSearch">
|
||||
<a-select-option value="individual">个人参与</a-select-option>
|
||||
<a-select-option value="team">团队参与</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
<a-button type="primary" html-type="submit"><template #icon><SearchOutlined /></template> 搜索</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset"><template #icon><ReloadOutlined /></template> 重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="orgColumns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
class="data-table"
|
||||
>
|
||||
<a-table :columns="orgColumns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id" @change="handleTableChange" class="data-table">
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'index'">
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'organizers'">
|
||||
{{ record.organizers || '-' }}
|
||||
<template v-else-if="column.key === 'contestName'">
|
||||
<a @click="handleViewDetail(record)">{{ record.contestName }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'registrationCount'">
|
||||
{{ activeTab === 'team' ? (record._count?.teams || 0) : (record._count?.registrations || 0) }}
|
||||
<template v-else-if="column.key === 'contestType'">
|
||||
<a-tag :color="record.contestType === 'individual' ? 'blue' : 'green'">{{ record.contestType === 'individual' ? '个人' : '团队' }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'worksCount'">
|
||||
{{ record._count?.works || 0 }} / {{ activeTab === 'team' ? (record._count?.teams || 0) : (record._count?.registrations || 0) }}
|
||||
<template v-else-if="column.key === 'worksProgress'">
|
||||
<div class="works-progress">
|
||||
<span class="progress-num" :class="{ complete: (record._count?.works || 0) >= (record._count?.registrations || 1) }">
|
||||
{{ record._count?.works || 0 }}
|
||||
</span>
|
||||
<span class="progress-sep">/</span>
|
||||
<span class="progress-total">{{ record._count?.registrations || 0 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'contestTime'">
|
||||
<div v-if="record.startTime || record.endTime">
|
||||
<div>{{ formatDate(record.startTime) }}</div>
|
||||
<div>至 {{ formatDate(record.endTime) }}</div>
|
||||
<template v-else-if="column.key === 'submitTime'">
|
||||
<div v-if="record.submitStartTime">
|
||||
<div>{{ formatDate(record.submitStartTime) }}</div>
|
||||
<div class="text-muted">至 {{ formatDate(record.submitEndTime) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">查看详情</a-button>
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">查看作品 <right-outlined /></a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@ -203,6 +212,8 @@ import {
|
||||
SendOutlined,
|
||||
EyeOutlined,
|
||||
CheckCircleOutlined,
|
||||
RightOutlined,
|
||||
InboxOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import {
|
||||
contestsApi,
|
||||
@ -344,23 +355,33 @@ const superColumns = [
|
||||
]
|
||||
|
||||
// =============================================
|
||||
// 机构端逻辑(保持不变)
|
||||
// 机构端逻辑
|
||||
// =============================================
|
||||
const activeTab = ref<'individual' | 'team'>('individual')
|
||||
const orgStats = ref({ total: 0, submitted: 0, reviewing: 0, reviewed: 0 })
|
||||
const orgStatsItems = computed(() => [
|
||||
{ key: 'total', label: '总作品', value: orgStats.value.total, icon: FileTextOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
|
||||
{ key: 'submitted', label: '已提交', value: orgStats.value.submitted, icon: SendOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
|
||||
{ key: 'reviewing', label: '评审中', value: orgStats.value.reviewing, icon: EyeOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
|
||||
{ key: 'reviewed', label: '已评完', value: orgStats.value.reviewed, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
|
||||
])
|
||||
|
||||
const loading = ref(false)
|
||||
const dataSource = ref<Contest[]>([])
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
|
||||
const searchParams = reactive({ contestName: '' })
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` })
|
||||
const searchParams = reactive<{ contestName?: string; contestType?: string }>({})
|
||||
|
||||
const orgColumns = computed(() => [
|
||||
{ title: '序号', key: 'index', width: 70 },
|
||||
{ title: '活动名称', dataIndex: 'contestName', key: 'contestName', width: 200 },
|
||||
{ title: '主办机构', key: 'organizers', width: 140 },
|
||||
{ title: activeTab.value === 'team' ? '报名队伍数' : '报名人数', key: 'registrationCount', width: 120 },
|
||||
{ title: '已递交/应递交作品数', key: 'worksCount', width: 160 },
|
||||
{ title: '活动时间', key: 'contestTime', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
|
||||
])
|
||||
const orgColumns = [
|
||||
{ title: '序号', key: 'index', width: 50 },
|
||||
{ title: '活动名称', key: 'contestName', width: 200 },
|
||||
{ title: '类型', key: 'contestType', width: 70 },
|
||||
{ title: '已交/应交', key: 'worksProgress', width: 100 },
|
||||
{ title: '提交时间', key: 'submitTime', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 120, fixed: 'right' as const },
|
||||
]
|
||||
|
||||
const fetchOrgStats = async () => {
|
||||
try { orgStats.value = await worksApi.getStats() } catch { /* */ }
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
@ -369,7 +390,7 @@ const fetchList = async () => {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
contestName: searchParams.contestName || undefined,
|
||||
contestType: activeTab.value,
|
||||
contestType: searchParams.contestType || undefined,
|
||||
})
|
||||
dataSource.value = res.list
|
||||
pagination.total = res.total
|
||||
@ -377,12 +398,11 @@ const fetchList = async () => {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleTabChange = () => { pagination.current = 1; fetchList() }
|
||||
const handleSearch = () => { pagination.current = 1; fetchList() }
|
||||
const handleReset = () => { searchParams.contestName = ''; pagination.current = 1; fetchList() }
|
||||
const handleReset = () => { searchParams.contestName = ''; searchParams.contestType = undefined; pagination.current = 1; fetchList(); fetchOrgStats() }
|
||||
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
|
||||
const handleViewDetail = (record: Contest) => {
|
||||
router.push(`/${tenantCode}/contests/works/${record.id}/list?type=${activeTab.value}`)
|
||||
router.push(`/${tenantCode}/contests/works/${record.id}/list`)
|
||||
}
|
||||
|
||||
// =============================================
|
||||
@ -397,6 +417,7 @@ onMounted(() => {
|
||||
fetchSuperStats()
|
||||
fetchSuperList()
|
||||
} else {
|
||||
fetchOrgStats()
|
||||
fetchList()
|
||||
}
|
||||
})
|
||||
@ -454,6 +475,12 @@ $primary: #6366f1;
|
||||
}
|
||||
}
|
||||
|
||||
.works-progress {
|
||||
.progress-num { font-weight: 700; color: #6366f1; &.complete { color: #10b981; } }
|
||||
.progress-sep { color: #d1d5db; margin: 0 2px; }
|
||||
.progress-total { color: #9ca3af; }
|
||||
}
|
||||
|
||||
.contest-link { padding: 0; text-align: left; }
|
||||
.work-link { color: $primary; cursor: pointer; &:hover { text-decoration: underline; } }
|
||||
.text-muted { color: #d1d5db; }
|
||||
|
||||
@ -2,12 +2,17 @@
|
||||
<div class="works-detail-page">
|
||||
<a-card class="mb-4">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<a-button type="text" style="padding: 0; margin-right: 4px" @click="router.back()">
|
||||
<template #icon><arrow-left-outlined /></template>
|
||||
</a-button>
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<router-link :to="`/${tenantCode}/contests/works`">作品管理</router-link>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ contestName || '参赛作品' }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button
|
||||
@ -20,6 +25,19 @@
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<!-- 统计概览 -->
|
||||
<div class="stats-row">
|
||||
<div v-for="item in detailStatsItems" :key="item.key" class="stat-card">
|
||||
<div class="stat-icon" :style="{ background: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-count">{{ item.value }}</span>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索表单 -->
|
||||
<a-form
|
||||
:model="searchParams"
|
||||
@ -59,6 +77,7 @@
|
||||
placeholder="请选择"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option value="assigned">已分配</a-select-option>
|
||||
<a-select-option value="unassigned">未分配</a-select-option>
|
||||
@ -68,9 +87,10 @@
|
||||
<a-range-picker
|
||||
v-model:value="searchParams.submitTimeRange"
|
||||
style="width: 240px"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="机构">
|
||||
<a-form-item v-if="isSuperAdmin" label="机构">
|
||||
<a-select
|
||||
v-model:value="searchParams.tenantId"
|
||||
placeholder="请选择机构"
|
||||
@ -78,6 +98,7 @@
|
||||
style="width: 150px"
|
||||
show-search
|
||||
:filter-option="filterOption"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="tenant in tenants"
|
||||
@ -315,7 +336,12 @@ import {
|
||||
ArrowLeftOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
FileTextOutlined,
|
||||
SendOutlined,
|
||||
EyeOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
import dayjs from "dayjs"
|
||||
import {
|
||||
contestsApi,
|
||||
@ -337,6 +363,20 @@ const router = useRouter()
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
const contestId = Number(route.params.id)
|
||||
const contestType = (route.query.type as string) || "individual"
|
||||
const authStore = useAuthStore()
|
||||
const isSuperAdmin = computed(() => authStore.hasAnyRole(['super_admin']))
|
||||
|
||||
// #5 统计概览
|
||||
const detailStats = ref({ total: 0, submitted: 0, reviewing: 0, reviewed: 0 })
|
||||
const detailStatsItems = computed(() => [
|
||||
{ key: 'total', label: '总作品', value: detailStats.value.total, icon: FileTextOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
|
||||
{ key: 'submitted', label: '已提交', value: detailStats.value.submitted, icon: SendOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
|
||||
{ key: 'reviewing', label: '评审中', value: detailStats.value.reviewing, icon: EyeOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
|
||||
{ key: 'reviewed', label: '已评完', value: detailStats.value.reviewed, icon: CheckCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
|
||||
])
|
||||
const fetchDetailStats = async () => {
|
||||
try { detailStats.value = await worksApi.getStats(contestId) } catch { /* */ }
|
||||
}
|
||||
|
||||
// 活动名称
|
||||
const contestName = ref("")
|
||||
@ -501,6 +541,11 @@ const fetchList = async () => {
|
||||
contestId,
|
||||
workNo: searchParams.workNo || undefined,
|
||||
username: searchParams.username || undefined,
|
||||
name: searchParams.name || undefined,
|
||||
assignStatus: searchParams.assignStatus || undefined,
|
||||
tenantId: searchParams.tenantId || undefined,
|
||||
submitStartTime: searchParams.submitTimeRange?.[0]?.format('YYYY-MM-DD') || undefined,
|
||||
submitEndTime: searchParams.submitTimeRange?.[1]?.format('YYYY-MM-DD') || undefined,
|
||||
})
|
||||
dataSource.value = response.list
|
||||
pagination.total = response.total
|
||||
@ -653,15 +698,25 @@ const handleConfirmAssign = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
fetchContestInfo()
|
||||
fetchTenants()
|
||||
fetchDetailStats()
|
||||
if (isSuperAdmin.value) fetchTenants()
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
$primary: #6366f1;
|
||||
|
||||
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card {
|
||||
flex: 1; display: flex; align-items: center; gap: 12px; padding: 14px 16px;
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
.stat-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
|
||||
.stat-info { display: flex; flex-direction: column;
|
||||
.stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
|
||||
.stat-label { font-size: 12px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
|
||||
.works-detail-page {
|
||||
:deep(.ant-card) {
|
||||
@ -686,23 +741,6 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary) {
|
||||
background: $gradient-primary;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba($primary, 0.35);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 8%) 100%);
|
||||
box-shadow: 0 6px 16px rgba($primary, 0.45);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper) {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
|
||||
171
frontend/src/views/system/tenant-info/Index.vue
Normal file
171
frontend/src/views/system/tenant-info/Index.vue
Normal file
@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="tenant-info-page">
|
||||
<a-card class="title-card">
|
||||
<template #title>机构信息</template>
|
||||
</a-card>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="tenant" class="info-content">
|
||||
<!-- 基本信息卡片 -->
|
||||
<a-card title="基本信息" :bordered="false" class="section-card">
|
||||
<a-descriptions :column="2" bordered size="small">
|
||||
<a-descriptions-item label="机构名称" :span="2">
|
||||
<div class="editable-field">
|
||||
<span v-if="!editing">{{ tenant.name }}</span>
|
||||
<a-input v-else v-model:value="editForm.name" style="max-width: 300px" />
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="机构编码">
|
||||
<a-tag color="blue">{{ tenant.code }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="机构类型">
|
||||
<a-tag :color="tenantTypeColor(tenant.tenantType)">{{ tenantTypeLabel(tenant.tenantType) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="登录地址" :span="2">
|
||||
<span class="url-text">/{{ tenant.code }}/login</span>
|
||||
<a-button type="link" size="small" @click="copyLoginUrl">
|
||||
<copy-outlined /> 复制
|
||||
</a-button>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-badge :status="tenant.validState === 1 ? 'success' : 'error'" :text="tenant.validState === 1 ? '正常' : '停用'" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ formatDate(tenant.createTime) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="机构描述" :span="2">
|
||||
<div class="editable-field">
|
||||
<span v-if="!editing">{{ tenant.description || '暂无描述' }}</span>
|
||||
<a-textarea v-else v-model:value="editForm.description" :rows="3" style="max-width: 400px" placeholder="机构描述" />
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="edit-actions" style="margin-top: 16px">
|
||||
<template v-if="!editing">
|
||||
<a-button type="primary" @click="startEdit">
|
||||
<template #icon><edit-outlined /></template>
|
||||
编辑信息
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="saving" @click="handleSave">保存</a-button>
|
||||
<a-button @click="cancelEdit">取消</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<a-card title="数据概况" :bordered="false" class="section-card" style="margin-top: 16px">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ tenant._count?.users || 0 }}</span>
|
||||
<span class="stat-label">用户数</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ tenant._count?.roles || 0 }}</span>
|
||||
<span class="stat-label">角色数</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { EditOutlined, CopyOutlined } from '@ant-design/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const editing = ref(false)
|
||||
const tenant = ref<any>(null)
|
||||
const editForm = reactive({ name: '', description: '' })
|
||||
|
||||
const tenantTypeLabel = (type: string) => {
|
||||
const map: Record<string, string> = { library: '图书馆', kindergarten: '幼儿园', school: '学校', institution: '社会机构', other: '其他' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
const tenantTypeColor = (type: string) => {
|
||||
const map: Record<string, string> = { library: 'purple', kindergarten: 'green', school: 'blue', institution: 'orange', other: 'default' }
|
||||
return map[type] || 'default'
|
||||
}
|
||||
|
||||
const formatDate = (d: string) => d ? dayjs(d).format('YYYY-MM-DD') : '-'
|
||||
|
||||
const copyLoginUrl = () => {
|
||||
const url = `${window.location.origin}/${tenant.value.code}/login`
|
||||
navigator.clipboard.writeText(url).then(() => message.success('已复制')).catch(() => message.info(url))
|
||||
}
|
||||
|
||||
const fetchTenant = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
tenant.value = await request.get('/tenants/my-tenant')
|
||||
} catch {
|
||||
message.error('获取机构信息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = () => {
|
||||
editForm.name = tenant.value.name
|
||||
editForm.description = tenant.value.description || ''
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
const cancelEdit = () => { editing.value = false }
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editForm.name.trim()) { message.warning('机构名称不能为空'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
await request.patch('/tenants/my-tenant', {
|
||||
name: editForm.name,
|
||||
description: editForm.description || undefined,
|
||||
})
|
||||
message.success('保存成功')
|
||||
editing.value = false
|
||||
fetchTenant()
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTenant)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||
:deep(.ant-card-body) { padding: 0; }
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
:deep(.ant-card-head) { .ant-card-head-title { font-size: 15px; font-weight: 600; } }
|
||||
}
|
||||
|
||||
.url-text { font-family: monospace; font-size: 13px; color: #6b7280; }
|
||||
|
||||
.editable-field { min-height: 22px; }
|
||||
|
||||
.stats-grid {
|
||||
display: flex; gap: 32px;
|
||||
.stat-item {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #1e1b4b; }
|
||||
.stat-label { font-size: 13px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -294,6 +294,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import { message, Modal, Empty } from 'ant-design-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
@ -317,6 +318,8 @@ const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
|
||||
|
||||
// ========== 统计 ==========
|
||||
const stats = ref<UserStats>({ total: 0, platform: 0, org: 0, judge: 0, public: 0 })
|
||||
const authStore = useAuthStore()
|
||||
const isSuperAdmin = computed(() => authStore.hasAnyRole(['super_admin']))
|
||||
const activeType = ref<string>('')
|
||||
|
||||
const statsItems = computed(() => [
|
||||
@ -552,7 +555,7 @@ const regStateColor = (s: string) =>
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
fetchList()
|
||||
fetchTenants()
|
||||
if (isSuperAdmin.value) fetchTenants()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
333
frontend/src/views/workbench/TenantDashboard.vue
Normal file
333
frontend/src/views/workbench/TenantDashboard.vue
Normal file
@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<div class="tenant-dashboard">
|
||||
<!-- #1 欢迎信息 + 机构标识 -->
|
||||
<div class="welcome-banner">
|
||||
<div class="welcome-left">
|
||||
<h1>{{ greetingText }},{{ authStore.user?.nickname || '管理员' }}</h1>
|
||||
<p v-if="dashboard.tenant">
|
||||
<bank-outlined /> {{ dashboard.tenant.name }}
|
||||
<a-tag :color="tenantTypeColor(dashboard.tenant.tenantType)" style="margin-left: 8px">{{ tenantTypeLabel(dashboard.tenant.tenantType) }}</a-tag>
|
||||
</p>
|
||||
</div>
|
||||
<div class="welcome-right">
|
||||
<span class="date-text">{{ todayText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #6 待办提醒 -->
|
||||
<div v-if="dashboard.todos?.length > 0" class="todo-section">
|
||||
<div v-for="(todo, idx) in dashboard.todos" :key="idx" :class="['todo-item', todo.type]" @click="todo.link && goTo(todo.link)">
|
||||
<alert-outlined v-if="todo.type === 'warning'" />
|
||||
<info-circle-outlined v-else />
|
||||
<span>{{ todo.message }}</span>
|
||||
<right-outlined v-if="todo.link" class="todo-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #2 空数据引导 -->
|
||||
<div v-if="!loading && isEmpty" class="empty-guide">
|
||||
<a-result title="欢迎使用活动管理平台" sub-title="开始配置你的第一个活动吧">
|
||||
<template #icon>
|
||||
<trophy-outlined style="font-size: 48px; color: #6366f1" />
|
||||
</template>
|
||||
<template #extra>
|
||||
<div class="guide-steps">
|
||||
<div class="guide-step" @click="goTo('/contests/list')">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-content">
|
||||
<strong>创建活动</strong>
|
||||
<span>配置活动信息、报名规则和提交要求</span>
|
||||
</div>
|
||||
<right-outlined />
|
||||
</div>
|
||||
<div class="guide-step" @click="goTo('/system/users')">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-content">
|
||||
<strong>添加团队成员</strong>
|
||||
<span>创建管理员和工作人员账号</span>
|
||||
</div>
|
||||
<right-outlined />
|
||||
</div>
|
||||
<div class="guide-step" @click="goTo('/contests/judges')">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-content">
|
||||
<strong>邀请评委</strong>
|
||||
<span>添加评委并分配评审任务</span>
|
||||
</div>
|
||||
<right-outlined />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
|
||||
<!-- 有数据时的正常视图 -->
|
||||
<template v-if="!loading && !isEmpty">
|
||||
<!-- #5 统计卡片可点击 -->
|
||||
<div class="stats-row">
|
||||
<div
|
||||
v-for="item in statsItems"
|
||||
:key="item.key"
|
||||
class="stat-card"
|
||||
:class="{ clickable: !!item.link }"
|
||||
@click="item.link && goTo(item.link)"
|
||||
>
|
||||
<div class="stat-icon" :style="{ background: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-count">{{ item.value }}</span>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #3 快捷操作(按权限动态显示) -->
|
||||
<a-card title="快捷操作" :bordered="false" class="section-card">
|
||||
<div class="action-grid">
|
||||
<div v-for="act in visibleActions" :key="act.label" class="action-item" @click="goTo(act.path)">
|
||||
<div class="action-icon" :style="{ background: act.bgColor }">
|
||||
<component :is="act.icon" :style="{ color: act.color }" />
|
||||
</div>
|
||||
<span>{{ act.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- #4 最近活动 + 查看全部 -->
|
||||
<a-card :bordered="false" class="section-card" style="margin-top: 16px">
|
||||
<template #title>最近活动</template>
|
||||
<template #extra>
|
||||
<a-button type="link" size="small" @click="goTo('/contests/list')">查看全部 <right-outlined /></a-button>
|
||||
</template>
|
||||
<div v-if="dashboard.recentContests?.length === 0" style="text-align: center; padding: 30px; color: #9ca3af">
|
||||
暂无活动数据
|
||||
</div>
|
||||
<div v-else class="contest-list">
|
||||
<div v-for="contest in dashboard.recentContests" :key="contest.id" class="contest-item" @click="goTo(`/contests/${contest.id}`)">
|
||||
<div class="contest-info">
|
||||
<span class="contest-name">{{ contest.contestName }}</span>
|
||||
<span class="contest-time">{{ formatDateRange(contest.startTime, contest.endTime) }}</span>
|
||||
</div>
|
||||
<div class="contest-stats">
|
||||
<a-tag>{{ contest._count?.registrations || 0 }} 报名</a-tag>
|
||||
<a-tag>{{ contest._count?.works || 0 }} 作品</a-tag>
|
||||
<a-badge :status="contest.status === 'ongoing' ? 'processing' : 'default'" :text="contest.status === 'ongoing' ? '进行中' : '已结束'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<!-- loading -->
|
||||
<div v-if="loading" style="text-align: center; padding: 80px"><a-spin size="large" /></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
TrophyOutlined, UserAddOutlined, FileTextOutlined,
|
||||
SolutionOutlined, TeamOutlined, BankOutlined,
|
||||
FundViewOutlined, FormOutlined, AuditOutlined, ClockCircleOutlined,
|
||||
RightOutlined, AlertOutlined, InfoCircleOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const dashboard = ref<any>({})
|
||||
|
||||
// #1 问候语
|
||||
const greetingText = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '夜深了'
|
||||
if (h < 12) return '上午好'
|
||||
if (h < 14) return '中午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
const todayText = computed(() => dayjs().format('YYYY年MM月DD日 dddd'))
|
||||
|
||||
const tenantTypeLabel = (type: string) => {
|
||||
const map: Record<string, string> = { library: '图书馆', kindergarten: '幼儿园', school: '学校', institution: '社会机构', other: '其他' }
|
||||
return map[type] || type
|
||||
}
|
||||
const tenantTypeColor = (type: string) => {
|
||||
const map: Record<string, string> = { library: 'purple', kindergarten: 'green', school: 'blue', institution: 'orange', other: 'default' }
|
||||
return map[type] || 'default'
|
||||
}
|
||||
|
||||
// #2 空数据判断
|
||||
const isEmpty = computed(() =>
|
||||
dashboard.value.totalContests === 0 &&
|
||||
dashboard.value.totalRegistrations === 0 &&
|
||||
dashboard.value.totalWorks === 0
|
||||
)
|
||||
|
||||
// #5 统计卡片可点击
|
||||
const statsItems = computed(() => [
|
||||
{ key: 'contests', label: '可见活动', value: dashboard.value.totalContests || 0, icon: TrophyOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)', link: '/contests/list' },
|
||||
{ key: 'ongoing', label: '进行中', value: dashboard.value.ongoingContests || 0, icon: ClockCircleOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)', link: '/contests/list' },
|
||||
{ key: 'registrations', label: '总报名数', value: dashboard.value.totalRegistrations || 0, icon: FormOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)', link: '/contests/registrations' },
|
||||
{ key: 'pending', label: '待审核报名', value: dashboard.value.pendingRegistrations || 0, icon: AuditOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)', link: '/contests/registrations' },
|
||||
{ key: 'works', label: '总作品数', value: dashboard.value.totalWorks || 0, icon: FileTextOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)', link: '/contests/works' },
|
||||
{ key: 'today', label: '今日报名', value: dashboard.value.todayRegistrations || 0, icon: FundViewOutlined, color: '#8b5cf6', bgColor: 'rgba(139,92,246,0.1)', link: '/contests/registrations' },
|
||||
])
|
||||
|
||||
// #3 快捷操作(按权限过滤)
|
||||
const allActions = [
|
||||
{ label: '活动列表', path: '/contests/list', permission: 'contest:read', icon: TrophyOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
|
||||
{ label: '报名管理', path: '/contests/registrations', permission: 'contest:registration:read', icon: UserAddOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
|
||||
{ label: '作品管理', path: '/contests/works', permission: 'contest:work:read', icon: FileTextOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
|
||||
{ label: '评委管理', path: '/contests/judges', permission: 'judge:read', icon: SolutionOutlined, color: '#ec4899', bgColor: 'rgba(236,72,153,0.1)' },
|
||||
{ label: '用户管理', path: '/system/users', permission: 'user:read', icon: TeamOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
|
||||
]
|
||||
|
||||
const visibleActions = computed(() =>
|
||||
allActions.filter(a => authStore.hasPermission(a.permission))
|
||||
)
|
||||
|
||||
const formatDateRange = (start: string, end: string) => {
|
||||
if (!start || !end) return '-'
|
||||
return `${dayjs(start).format('MM/DD')} - ${dayjs(end).format('MM/DD')}`
|
||||
}
|
||||
|
||||
const goTo = (path: string) => {
|
||||
const tenantCode = authStore.tenantCode
|
||||
router.push(`/${tenantCode}${path}`)
|
||||
}
|
||||
|
||||
const fetchDashboard = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
dashboard.value = await request.get('/contests/dashboard')
|
||||
} catch {
|
||||
message.error('获取统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
// #1 欢迎横幅
|
||||
.welcome-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 28px;
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #fdf2f8 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.welcome-left {
|
||||
h1 { font-size: 20px; font-weight: 700; color: #1e1b4b; margin: 0 0 6px; }
|
||||
p { font-size: 13px; color: #6b7280; margin: 0; display: flex; align-items: center; gap: 6px; }
|
||||
}
|
||||
.welcome-right {
|
||||
.date-text { font-size: 13px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
|
||||
// #6 待办提醒
|
||||
.todo-section {
|
||||
display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px;
|
||||
}
|
||||
.todo-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px; border-radius: 10px; font-size: 13px; cursor: pointer; transition: all 0.2s;
|
||||
|
||||
&.warning { background: #fef3c7; color: #92400e; border: 1px solid #fde68a;
|
||||
&:hover { background: #fde68a; }
|
||||
}
|
||||
&.info { background: #ede9fe; color: #5b21b6; border: 1px solid #ddd6fe;
|
||||
&:hover { background: #ddd6fe; }
|
||||
}
|
||||
.todo-arrow { margin-left: auto; font-size: 11px; opacity: 0.5; }
|
||||
}
|
||||
|
||||
// #2 空数据引导
|
||||
.empty-guide {
|
||||
background: #fff; border-radius: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 20px;
|
||||
:deep(.ant-result) { padding: 24px 0; }
|
||||
:deep(.ant-result-title) { font-size: 18px; font-weight: 700; color: #1e1b4b; }
|
||||
:deep(.ant-result-subtitle) { color: #6b7280; }
|
||||
}
|
||||
.guide-steps {
|
||||
display: flex; flex-direction: column; gap: 12px; max-width: 400px; margin: 0 auto; text-align: left;
|
||||
}
|
||||
.guide-step {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 18px; background: #faf9fe; border-radius: 12px;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
&:hover { background: #eef2ff; transform: translateX(4px); }
|
||||
|
||||
.step-num {
|
||||
width: 28px; height: 28px; border-radius: 50%; background: $primary; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.step-content {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
strong { font-size: 14px; color: #1e1b4b; }
|
||||
span { font-size: 12px; color: #9ca3af; }
|
||||
}
|
||||
:deep(.anticon) { color: #d1d5db; }
|
||||
}
|
||||
|
||||
// #5 统计卡片
|
||||
.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px;
|
||||
@media (min-width: 1200px) { grid-template-columns: repeat(6, 1fr); }
|
||||
}
|
||||
.stat-card {
|
||||
display: flex; align-items: center; gap: 12px; padding: 16px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); transition: all 0.2s;
|
||||
&.clickable { cursor: pointer;
|
||||
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); transform: translateY(-2px); }
|
||||
}
|
||||
.stat-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
||||
.stat-info { display: flex; flex-direction: column;
|
||||
.stat-count { font-size: 20px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
|
||||
.stat-label { font-size: 12px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
|
||||
// 区块卡片
|
||||
.section-card {
|
||||
border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
:deep(.ant-card-head) { .ant-card-head-title { font-size: 15px; font-weight: 600; } }
|
||||
}
|
||||
|
||||
// #3 快捷操作
|
||||
.action-grid {
|
||||
display: flex; gap: 24px; flex-wrap: wrap;
|
||||
.action-item {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; transition: all 0.2s;
|
||||
&:hover { transform: translateY(-2px); }
|
||||
.action-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; }
|
||||
span { font-size: 12px; color: #374151; font-weight: 500; }
|
||||
}
|
||||
}
|
||||
|
||||
// #4 最近活动
|
||||
.contest-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.contest-item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
padding: 12px 16px; border-radius: 10px; cursor: pointer; transition: background 0.2s;
|
||||
&:hover { background: rgba($primary, 0.03); }
|
||||
.contest-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1;
|
||||
.contest-name { font-size: 14px; font-weight: 600; color: #1e1b4b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.contest-time { font-size: 12px; color: #9ca3af; }
|
||||
}
|
||||
.contest-stats { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
}
|
||||
</style>
|
||||
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
@ -179,6 +179,9 @@ importers:
|
||||
dayjs:
|
||||
specifier: ^1.11.10
|
||||
version: 1.11.19
|
||||
echarts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
pinia:
|
||||
specifier: ^2.1.7
|
||||
version: 2.3.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3))
|
||||
@ -191,6 +194,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.4.21
|
||||
version: 3.5.24(typescript@5.9.3)
|
||||
vue-echarts:
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1(echarts@6.0.0)(vue@3.5.24(typescript@5.9.3))
|
||||
vue-router:
|
||||
specifier: ^4.3.0
|
||||
version: 4.6.3(vue@3.5.24(typescript@5.9.3))
|
||||
@ -2248,6 +2254,9 @@ packages:
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
echarts@6.0.0:
|
||||
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
@ -2654,15 +2663,17 @@ packages:
|
||||
|
||||
glob@10.4.5:
|
||||
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@10.5.0:
|
||||
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
globals@13.24.0:
|
||||
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
|
||||
@ -4227,6 +4238,9 @@ packages:
|
||||
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@ -4394,6 +4408,12 @@ packages:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-echarts@8.0.1:
|
||||
resolution: {integrity: sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==}
|
||||
peerDependencies:
|
||||
echarts: ^6.0.0
|
||||
vue: ^3.3.0
|
||||
|
||||
vue-eslint-parser@9.4.3:
|
||||
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
|
||||
engines: {node: ^14.17.0 || >=16.0.0}
|
||||
@ -4530,6 +4550,9 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zrender@6.0.0:
|
||||
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
@ -6799,6 +6822,11 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
echarts@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.0.0
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
effect@3.18.4:
|
||||
@ -9037,6 +9065,8 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-bom: 3.0.0
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
@ -9144,6 +9174,11 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.24(typescript@5.9.3)
|
||||
|
||||
vue-echarts@8.0.1(echarts@6.0.0)(vue@3.5.24(typescript@5.9.3)):
|
||||
dependencies:
|
||||
echarts: 6.0.0
|
||||
vue: 3.5.24(typescript@5.9.3)
|
||||
|
||||
vue-eslint-parser@9.4.3(eslint@8.57.1):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@ -9301,3 +9336,7 @@ snapshots:
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zrender@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user