refactor(grades,diagnostic): 完成成绩和学情诊断模块审计 P1+P2 改进项

P1-1: 抽取 stats-service.ts,将 8 个统计计算纯函数从 data-access 层分离
P1-5: 创建 WidgetBoundary 组件 + 补齐 teacher 路由 loading.tsx/error.tsx (14 文件)
P1-6: 同步架构图文档 004/005,新增 stats-service 与 widget-boundary 节点
P2-1: 补充 a11y ARIA 属性(5 图表 role=img + aria-label,2 表格 caption,3 列表 role=list,3 按钮 aria-label)
P2-3: 修复班级报告 studentId 字段语义错误(schema 改为可空 + 迁移 + 代码适配)
P2-4: 修复 grade_managed scope 返回空数据(改为子查询 classes 表按 gradeId 过滤)
P2-5: 新增 /parent/diagnostic/ 页面(多子女学情诊断聚合 + loading + error)
P2-6: 统一 SearchParams 工具(student/grades 和 management/grade/insights 改用 @/shared/lib/search-params)
This commit is contained in:
SpecialX
2026-06-22 17:07:32 +08:00
parent e997abaf5e
commit 5f3a1a4662
41 changed files with 9043 additions and 381 deletions

View File

@@ -35,35 +35,37 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
emptyDescription="No knowledge point mastery records found for this student."
emptyClassName="h-60"
>
<ComparisonRadarChart
data={chartData}
angleKey="shortName"
angleTickFontSize={11}
domain={[0, 100]}
tickCount={5}
showLegend={hasClassAverage}
heightClassName="mx-auto h-96 w-full max-w-lg"
gridStrokeDasharray="4 4"
series={[
{
dataKey: "student",
name: "Student",
color: "hsl(var(--primary))",
fillOpacity: 0.35,
strokeWidth: 2,
show: true,
},
{
dataKey: "classAverage",
name: "Class Avg",
color: "hsl(var(--chart-2))",
fillOpacity: 0.15,
strokeWidth: 2,
strokeDasharray: "4 4",
show: hasClassAverage,
},
]}
/>
<div role="img" aria-label={`知识点掌握度雷达图:${isEmpty ? "暂无数据" : `${data.length} 个知识点的掌握度${hasClassAverage ? "(含班级平均对比)" : ""}`}`}>
<ComparisonRadarChart
data={chartData}
angleKey="shortName"
angleTickFontSize={11}
domain={[0, 100]}
tickCount={5}
showLegend={hasClassAverage}
heightClassName="mx-auto h-96 w-full max-w-lg"
gridStrokeDasharray="4 4"
series={[
{
dataKey: "student",
name: "Student",
color: "hsl(var(--primary))",
fillOpacity: 0.35,
strokeWidth: 2,
show: true,
},
{
dataKey: "classAverage",
name: "Class Avg",
color: "hsl(var(--chart-2))",
fillOpacity: 0.15,
strokeWidth: 2,
strokeDasharray: "4 4",
show: hasClassAverage,
},
]}
/>
</div>
</ChartCardShell>
)
}

View File

@@ -157,6 +157,7 @@ export function ReportList({ reports }: ReportListProps) {
) : (
<div className="rounded-md border bg-card">
<Table>
<caption className="sr-only"></caption>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
@@ -175,7 +176,7 @@ export function ReportList({ reports }: ReportListProps) {
<TableCell>
<Badge variant="outline">{typeLabels[r.reportType] ?? r.reportType}</Badge>
</TableCell>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell className="font-medium">{r.studentName ?? (r.reportType === "class" ? "(班级报告)" : r.reportType === "grade" ? "(年级报告)" : "-")}</TableCell>
<TableCell>{r.period ?? "-"}</TableCell>
<TableCell className="text-right font-mono">
{r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"}
@@ -195,6 +196,7 @@ export function ReportList({ reports }: ReportListProps) {
className="h-8 w-8 text-green-600"
onClick={() => setPublishId(r.id)}
title="Publish"
aria-label={`发布报告 ${r.studentName}`}
>
<Send className="h-4 w-4" />
</Button>
@@ -205,6 +207,7 @@ export function ReportList({ reports }: ReportListProps) {
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)}
title="Delete"
aria-label={`删除报告 ${r.studentName}`}
>
<Trash2 className="h-4 w-4" />
</Button>

View File

@@ -96,7 +96,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
{summary.strengths.length === 0 ? (
<p className="text-sm text-muted-foreground">No strengths identified yet.</p>
) : (
<ul className="space-y-2">
<ul className="space-y-2" role="list" aria-label="优势知识点列表">
{summary.strengths.map((m) => (
<li key={m.knowledgePointId} className="flex items-center justify-between">
<span className="text-sm">{m.knowledgePointName}</span>
@@ -119,7 +119,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
{summary.weaknesses.length === 0 ? (
<p className="text-sm text-muted-foreground">No weaknesses identified.</p>
) : (
<ul className="space-y-2">
<ul className="space-y-2" role="list" aria-label="薄弱知识点列表">
{summary.weaknesses.map((m) => (
<li key={m.knowledgePointId} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
@@ -162,7 +162,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
{latestReport.recommendations && latestReport.recommendations.length > 0 ? (
<div>
<h4 className="mb-2 text-sm font-semibold">Recommendations</h4>
<ul className="space-y-1.5">
<ul className="space-y-1.5" role="list" aria-label="学习建议列表">
{latestReport.recommendations.map((rec, i) => (
<li key={i} className="text-sm text-muted-foreground"> {rec}</li>
))}

View File

@@ -109,7 +109,7 @@ export async function generateClassDiagnosticReport(
const id = createId()
await db.insert(learningDiagnosticReports).values({
id,
studentId: generatedBy, // 班级报告 studentId 存生成者 IDschema 要求 NOT NULL
studentId: null, // 班级报告无单个学生,studentId 置空P2-3 修复:不再存生成者 ID
generatedBy,
reportType: "class",
period,
@@ -141,14 +141,16 @@ export const getDiagnosticReports = cache(
// 收集所有需要查询姓名的用户 ID学生 + 生成者),通过 users data-access 统一获取
const userIds = new Set<string>()
for (const r of rows) {
userIds.add(r.report.studentId)
if (r.report.studentId) 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: userMap.get(r.report.studentId)?.name ?? "Unknown",
studentName: r.report.studentId
? userMap.get(r.report.studentId)?.name ?? "Unknown"
: null,
generatedByName: r.report.generatedBy
? userMap.get(r.report.generatedBy)?.name ?? "Unknown"
: null,
@@ -167,13 +169,16 @@ export const getDiagnosticReportById = cache(
if (!row) return null
// 通过 users data-access 获取学生姓名和生成者姓名
const userIds = [row.report.studentId]
const userIds: string[] = []
if (row.report.studentId) userIds.push(row.report.studentId)
if (row.report.generatedBy) userIds.push(row.report.generatedBy)
const userMap = await getUserNamesByIds(userIds)
return {
...serializeReport(row.report),
studentName: userMap.get(row.report.studentId)?.name ?? "Unknown",
studentName: row.report.studentId
? userMap.get(row.report.studentId)?.name ?? "Unknown"
: null,
generatedByName: row.report.generatedBy
? userMap.get(row.report.generatedBy)?.name ?? null
: null,

View File

@@ -36,7 +36,7 @@ export interface StudentMasterySummary {
/** 诊断报告 */
export interface DiagnosticReport {
id: string
studentId: string
studentId: string | null
generatedBy: string | null
reportType: DiagnosticReportType
period: string | null
@@ -52,7 +52,7 @@ export interface DiagnosticReport {
/** 含学生名的诊断报告join users 后) */
export interface DiagnosticReportWithDetails extends DiagnosticReport {
studentName: string
studentName: string | null
generatedByName: string | null
}