refactor(grades,diagnostic): 成绩和学情诊断模块审计修复

P0-1: 10 个页面补充 requirePermission 权限校验
P0-2: diagnostic/data-access-reports.ts 移除直查 users 表,改用 getUserNamesByIds
P0-3: 新增 grade/grades/diagnostic 三组 i18n 翻译文件(zh-CN/en)
P0-4: 新增 /management/grade 重定向页面

P1-2: 抽取 toNumber/normalize/buildScopeClassFilter 到 lib/grade-utils.ts
P1-3: 为 12 个 Action 新增 Zod safeParse 校验(schema.ts +12 查询 schema)
P1-4: 修复 as 断言违规,改用类型守卫函数

P2-2: 移除 diagnostic 组件中 Tailwind 任意值

同步更新架构图文档 004 和 005
This commit is contained in:
SpecialX
2026-06-22 16:23:34 +08:00
parent 20691f53ce
commit 45ee1ae43c
29 changed files with 2276 additions and 186 deletions

View File

@@ -131,7 +131,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`}
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (mastered ${kp.masteredCount}/${kp.totalStudents})`}
>
<span className="max-w-[120px] truncate text-xs font-medium">
<span className="max-w-32 truncate text-xs font-medium">
{kp.knowledgePointName}
</span>
<span className="text-sm font-bold">{kp.averageMastery.toFixed(0)}%</span>
@@ -252,7 +252,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
type="month"
value={period}
onChange={(e) => setPeriod(e.target.value)}
className="w-[180px]"
className="w-44"
/>
</div>
<Button onClick={handleGenerate} disabled={isGenerating}>

View File

@@ -42,7 +42,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
domain={[0, 100]}
tickCount={5}
showLegend={hasClassAverage}
heightClassName="mx-auto h-[360px] w-full max-w-[520px]"
heightClassName="mx-auto h-96 w-full max-w-lg"
gridStrokeDasharray="4 4"
series={[
{

View File

@@ -195,7 +195,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
<span className="text-sm font-medium">
{r.period ?? "Untitled period"}
</span>
<Badge variant="outline" className="text-[10px]">
<Badge variant="outline" className="text-xs">
{r.reportType}
</Badge>
</div>

View File

@@ -1,11 +1,12 @@
import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { and, desc, eq, inArray, type SQL } from "drizzle-orm"
import { and, desc, eq, type SQL } from "drizzle-orm"
import { cache } from "react"
import { db } from "@/shared/db"
import { learningDiagnosticReports, users } from "@/shared/db/schema"
import { learningDiagnosticReports } from "@/shared/db/schema"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access"
import type {
@@ -132,31 +133,25 @@ export const getDiagnosticReports = cache(
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
const rows = await db
.select({
report: learningDiagnosticReports,
studentName: users.name,
})
.select({ report: learningDiagnosticReports })
.from(learningDiagnosticReports)
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(learningDiagnosticReports.createdAt))
const generatorIds = Array.from(
new Set(rows.map((r) => r.report.generatedBy).filter((id): id is string => id !== null))
)
const generatorMap = new Map<string, string>()
if (generatorIds.length > 0) {
const generators = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, generatorIds))
for (const g of generators) generatorMap.set(g.id, g.name ?? "Unknown")
// 收集所有需要查询姓名的用户 ID学生 + 生成者),通过 users data-access 统一获取
const userIds = new Set<string>()
for (const r of rows) {
userIds.add(r.report.studentId)
if (r.report.generatedBy) userIds.add(r.report.generatedBy)
}
const userMap = await getUserNamesByIds(Array.from(userIds))
return rows.map((r) => ({
...serializeReport(r.report),
studentName: r.studentName ?? "Unknown",
generatedByName: r.report.generatedBy ? generatorMap.get(r.report.generatedBy) ?? "Unknown" : null,
studentName: userMap.get(r.report.studentId)?.name ?? "Unknown",
generatedByName: r.report.generatedBy
? userMap.get(r.report.generatedBy)?.name ?? "Unknown"
: null,
}))
},
)
@@ -165,26 +160,23 @@ export const getDiagnosticReports = cache(
export const getDiagnosticReportById = cache(
async (id: string): Promise<DiagnosticReportWithDetails | null> => {
const [row] = await db
.select({ report: learningDiagnosticReports, studentName: users.name })
.select({ report: learningDiagnosticReports })
.from(learningDiagnosticReports)
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
.where(eq(learningDiagnosticReports.id, id))
.limit(1)
if (!row) return null
let generatedByName: string | null = null
if (row.report.generatedBy) {
const [gen] = await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, row.report.generatedBy))
.limit(1)
generatedByName = gen?.name ?? null
}
// 通过 users data-access 获取学生姓名和生成者姓名
const userIds = [row.report.studentId]
if (row.report.generatedBy) userIds.push(row.report.generatedBy)
const userMap = await getUserNamesByIds(userIds)
return {
...serializeReport(row.report),
studentName: row.studentName ?? "Unknown",
generatedByName,
studentName: userMap.get(row.report.studentId)?.name ?? "Unknown",
generatedByName: row.report.generatedBy
? userMap.get(row.report.generatedBy)?.name ?? null
: null,
}
},
)