feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013
## P1 功能(20 项) - 站内消息系统、家长仪表盘、学生考勤管理 - Excel 导入导出、用户批量导入、成绩导出 - 排课规则+自动排课+课表调整 - 成绩趋势+对比分析、密码安全策略、速率限制 - 数据变更日志、文件预览+存储策略、全文检索 - 依赖审计集成 CI、数据库定时备份、E2E 测试完善 - 通知偏好管理 ## 基础设施修复 - src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求) - .env: MySQL 端口从 13002 切换至 14013 - scripts/create-db.ts: 新增数据库初始化脚本 ## 架构文档同步 - 004_architecture_impact_map.md 和 005_architecture_data.json 完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
293
src/modules/grades/data-access-analytics.ts
Normal file
293
src/modules/grades/data-access-analytics.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, asc, eq, inArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
gradeRecords,
|
||||
subjects,
|
||||
} from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import type {
|
||||
ClassComparisonItem,
|
||||
GradeDistributionBucket,
|
||||
GradeDistributionResult,
|
||||
GradeTrendPoint,
|
||||
GradeTrendResult,
|
||||
SubjectComparisonItem,
|
||||
} from "./types"
|
||||
|
||||
const toNumber = (v: unknown): number => {
|
||||
const n = typeof v === "number" ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const normalize = (score: number, fullScore: number): number => {
|
||||
if (fullScore <= 0) return 0
|
||||
return Math.round((score / fullScore) * 10000) / 100
|
||||
}
|
||||
|
||||
const buildScopeClassFilter = (scope: DataScope) => {
|
||||
if (scope.type === "all") return null
|
||||
if (scope.type === "class_taught") {
|
||||
return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0`
|
||||
}
|
||||
if (scope.type === "grade_managed") return sql`1=0`
|
||||
if (scope.type === "class_members") return null
|
||||
if (scope.type === "children") {
|
||||
return scope.childrenIds.length > 0
|
||||
? inArray(gradeRecords.studentId, scope.childrenIds)
|
||||
: sql`1=0`
|
||||
}
|
||||
if (scope.type === "owned") return eq(gradeRecords.studentId, scope.userId)
|
||||
return sql`1=0`
|
||||
}
|
||||
|
||||
export interface GradeTrendParams {
|
||||
classId: string
|
||||
subjectId?: string
|
||||
studentId?: string
|
||||
semester?: "1" | "2"
|
||||
scope: DataScope
|
||||
currentUserId?: string
|
||||
}
|
||||
|
||||
export async function getGradeTrend(
|
||||
params: GradeTrendParams
|
||||
): Promise<GradeTrendResult | null> {
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
|
||||
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
}
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
record: gradeRecords,
|
||||
className: classes.name,
|
||||
subjectName: subjects.name,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
|
||||
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(gradeRecords.createdAt))
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const points: GradeTrendPoint[] = rows.map((r) => {
|
||||
const score = toNumber(r.record.score)
|
||||
const fullScore = toNumber(r.record.fullScore)
|
||||
return {
|
||||
date: r.record.createdAt.toISOString(),
|
||||
title: r.record.title,
|
||||
score,
|
||||
fullScore,
|
||||
normalizedScore: normalize(score, fullScore),
|
||||
type: r.record.type,
|
||||
}
|
||||
})
|
||||
|
||||
const avg = points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
|
||||
const className = rows[0].className ?? "Class"
|
||||
const subjectName = rows[0].subjectName ?? "All Subjects"
|
||||
const studentLabel = params.studentId
|
||||
? `Student ${params.studentId.slice(-4)}`
|
||||
: "Class Average"
|
||||
|
||||
return {
|
||||
label: params.subjectId
|
||||
? `${className} · ${subjectName} · ${studentLabel}`
|
||||
: `${className} · ${studentLabel}`,
|
||||
points,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClassComparisonParams {
|
||||
gradeId: string
|
||||
subjectId: string
|
||||
examId?: string
|
||||
scope: DataScope
|
||||
}
|
||||
|
||||
export async function getClassComparison(
|
||||
params: ClassComparisonParams
|
||||
): Promise<ClassComparisonItem[]> {
|
||||
const classRows = await db
|
||||
.select({ id: classes.id, name: classes.name })
|
||||
.from(classes)
|
||||
.where(eq(classes.gradeId, params.gradeId))
|
||||
|
||||
if (classRows.length === 0) return []
|
||||
|
||||
const scope = params.scope
|
||||
const allowedClassIds =
|
||||
scope.type === "class_taught"
|
||||
? classRows.filter((c) => scope.classIds.includes(c.id)).map((c) => c.id)
|
||||
: classRows.map((c) => c.id)
|
||||
|
||||
if (allowedClassIds.length === 0) return []
|
||||
|
||||
const result: ClassComparisonItem[] = []
|
||||
|
||||
for (const cls of classRows) {
|
||||
if (!allowedClassIds.includes(cls.id)) continue
|
||||
|
||||
const conditions = [
|
||||
eq(gradeRecords.classId, cls.id),
|
||||
eq(gradeRecords.subjectId, params.subjectId),
|
||||
]
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
studentId: gradeRecords.studentId,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
if (rows.length === 0) {
|
||||
result.push({
|
||||
classId: cls.id, className: cls.name, averageScore: 0, medianScore: 0,
|
||||
passRate: 0, excellentRate: 0, count: 0, studentCount: 0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = rows.map((r) => normalize(toNumber(r.score), toNumber(r.fullScore)))
|
||||
const sorted = [...normalized].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
|
||||
const avg = normalized.reduce((a, b) => a + b, 0) / normalized.length
|
||||
const uniqueStudents = new Set(rows.map((r) => r.studentId)).size
|
||||
|
||||
result.push({
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
medianScore: Math.round(median * 100) / 100,
|
||||
passRate: Math.round((normalized.filter((s) => s >= 60).length / normalized.length) * 10000) / 100,
|
||||
excellentRate: Math.round((normalized.filter((s) => s >= 85).length / normalized.length) * 10000) / 100,
|
||||
count: normalized.length,
|
||||
studentCount: uniqueStudents,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export interface SubjectComparisonParams {
|
||||
classId: string
|
||||
examId?: string
|
||||
scope: DataScope
|
||||
}
|
||||
|
||||
export async function getSubjectComparison(
|
||||
params: SubjectComparisonParams
|
||||
): Promise<SubjectComparisonItem[]> {
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
subjectId: gradeRecords.subjectId,
|
||||
subjectName: subjects.name,
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
|
||||
.where(and(...conditions))
|
||||
|
||||
const bySubject = new Map<string, { name: string; scores: number[] }>()
|
||||
|
||||
for (const r of rows) {
|
||||
const sid = r.subjectId
|
||||
if (!sid) continue
|
||||
const entry = bySubject.get(sid) ?? { name: r.subjectName ?? "Unknown", scores: [] }
|
||||
entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore)))
|
||||
bySubject.set(sid, entry)
|
||||
}
|
||||
|
||||
const result: SubjectComparisonItem[] = []
|
||||
for (const [subjectId, entry] of bySubject.entries()) {
|
||||
if (entry.scores.length === 0) continue
|
||||
const sorted = [...entry.scores].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
|
||||
const avg = entry.scores.reduce((a, b) => a + b, 0) / entry.scores.length
|
||||
|
||||
result.push({
|
||||
subjectId,
|
||||
subjectName: entry.name,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
medianScore: Math.round(median * 100) / 100,
|
||||
passRate: Math.round((entry.scores.filter((s) => s >= 60).length / entry.scores.length) * 10000) / 100,
|
||||
excellentRate: Math.round((entry.scores.filter((s) => s >= 85).length / entry.scores.length) * 10000) / 100,
|
||||
count: entry.scores.length,
|
||||
})
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.averageScore - a.averageScore)
|
||||
}
|
||||
|
||||
export interface GradeDistributionParams {
|
||||
classId: string
|
||||
subjectId?: string
|
||||
examId?: string
|
||||
scope: DataScope
|
||||
currentUserId?: string
|
||||
}
|
||||
|
||||
export async function getGradeDistribution(
|
||||
params: GradeDistributionParams
|
||||
): Promise<GradeDistributionResult> {
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
}
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({ score: gradeRecords.score, fullScore: gradeRecords.fullScore })
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
const buckets: GradeDistributionBucket[] = [
|
||||
{ label: "90-100", min: 90, max: 100, count: 0 },
|
||||
{ label: "80-89", min: 80, max: 89, count: 0 },
|
||||
{ label: "70-79", min: 70, max: 79, count: 0 },
|
||||
{ label: "60-69", min: 60, max: 69, count: 0 },
|
||||
{ label: "<60", min: 0, max: 59, count: 0 },
|
||||
]
|
||||
|
||||
for (const r of rows) {
|
||||
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
|
||||
const rounded = Math.round(normalized)
|
||||
if (rounded >= 90) buckets[0].count++
|
||||
else if (rounded >= 80) buckets[1].count++
|
||||
else if (rounded >= 70) buckets[2].count++
|
||||
else if (rounded >= 60) buckets[3].count++
|
||||
else buckets[4].count++
|
||||
}
|
||||
|
||||
return { buckets, totalCount: rows.length }
|
||||
}
|
||||
Reference in New Issue
Block a user