租户端基础设施: - 新增工作台首页(欢迎信息/统计/待办/快捷操作/新手引导) - 新增机构信息管理页(自助查看编辑机构信息) - 修复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>
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|