feat(portal-shell): error-book + diagnostic + analytics 模块 5 页迁移(教师域 §9.1 B2)

§9.1 line 637-639 教师域:
- /shell/teacher/error-book (列表,1 页)
- /shell/teacher/diagnostic (列表) + /class/[classId] (详情) — 2 页
- /shell/teacher/analytics (概览) + /[studentId] (详情) — 2 页
契约:
- error-book  真实 errorBookItems/errorBookStats
- diagnostic  真实 diagnosticReports 列表 + 详情  MSW
- analytics 🟡 真实 learningTrend/studentWeakness + 概览  MSW

新增文件(24 个):
- src/lib/api/{error-book,diagnostic,analytics}.ts (hooks)
- src/lib/api/operations/{error-book,diagnostic,analytics}.graphql.ts (documents)
- src/features/teacher/{error-book,diagnostic,analytics}/ (clients + transformations + tests)
- src/app/shell/teacher/{error-book,diagnostic,analytics}/ (5 page.tsx + 3 loading + 3 error)

修改文件(7 个):
- src/mocks/graphql-data.ts (7 handler cases)
- src/messages/{zh-CN,en}.json (errorBook/diagnostic/analytics i18n)
- src/lib/api/{index,operations/index}.ts (导出)
- src/shared/lib/route-permissions.ts (3 EXACT + 3 PREFIX 路由权限)
- scripts/check-page-count.ts (baseline 53 → 58)

DoD 验收(§11.3 11 项):
- typecheck 0 errors
- lint 0 errors
- vitest 734 tests passed
- lint:tokens 0 errors
- check:pages 58 PASS
- route-permissions 已声明
- 三态齐备
- @contract-pending + MSW 兜底(仅对 schema 不存在的字段)
- i18n zh-CN + en 同步

设计决策:
- 类型命名冲突解决:ErrorBookItem → ErrorBookEntry;ErrorBookStats → TeacherErrorBookStats;
  KnowledgePointErrorStats → ErrorBookKpStats;useErrorBookStats → useTeacherErrorBookStats
  (避免与 dashboard.ts/student.ts 同名类型冲突)
- analytics TrendPoint/WeakPoint 形状相同,从 dashboard.ts import 复用
- error-book/analytics 复用 CLASS_READ/CLASS_MANAGE(无专用教师权限点);
  diagnostic 复用 DIAGNOSTIC_READ/DIAGNOSTIC_MANAGE

关联:ARCHITECTURE.md §5.3 / §5.4 / §5.5 / §9.1 / §10 P2 / §11.3 / §11.4
契约工单:docs/architecture/issues/contracts/core-edu_contract.md
This commit is contained in:
SpecialX
2026-07-22 23:50:30 +08:00
parent 80cc1d2461
commit 5a9f652943
35 changed files with 4207 additions and 8 deletions

View File

@@ -19,10 +19,10 @@ interface Baseline {
categories: Record<string, { pattern: string; min: number; label: string }>; categories: Record<string, { pattern: string; min: number; label: string }>;
} }
// Baseline as of P2 B2 (2026-07-22, course-plans + elective modules added). // Baseline as of P2 B3 (2026-07-22, error-book + diagnostic + analytics modules added).
// Update when adding pages. // Update when adding pages.
const BASELINE: Baseline = { const BASELINE: Baseline = {
total: 53, total: 58,
categories: { categories: {
dashboards: { dashboards: {
pattern: "shell/{admin,teacher,student,parent}/page.tsx", pattern: "shell/{admin,teacher,student,parent}/page.tsx",

View File

@@ -0,0 +1,22 @@
import { Suspense } from "react";
import { StudentAnalyticsClient } from "@/features/teacher/analytics/student-analytics-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 单生学情分析详情页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
*
* 数据契约:单查 studentAnalytics(studentId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/data-ana_contract.md#student-analytics
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function StudentAnalyticsPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<StudentAnalyticsClient />
</Suspense>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 学情分析路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { useTranslations } from "next-intl";
export default function AnalyticsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("analytics");
useEffect(() => {
console.error("[portal-shell] analytics route error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive">
{t("error.title")}
</h2>
<p className="text-sm text-muted-foreground">
{error.message || t("error.unknown")}
</p>
<Button onClick={reset} variant="outline">
{t("error.retry")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 学情分析路由段加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*
* 子页面(学生详情)的 Skeleton 由各自 server page 的 <Suspense> 兜底,
* 本文件仅在 /shell/teacher/analytics 总览期间显示。
*/
export default function AnalyticsLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,22 @@
import { Suspense } from "react";
import { AnalyticsOverviewClient } from "@/features/teacher/analytics/analytics-overview-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 学情分析总览页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
*
* 数据契约analyticsOverview ❌ schema 无此聚合根字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/data-ana_contract.md#analytics-overview
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function AnalyticsOverviewPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<AnalyticsOverviewClient />
</Suspense>
);
}

View File

@@ -0,0 +1,22 @@
import { Suspense } from "react";
import { DiagnosticClassDetailClient } from "@/features/teacher/diagnostic/diagnostic-class-detail-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 班级诊断报告详情页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
*
* 数据契约:单查 diagnosticReport(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/data-ana_contract.md#diagnostic-class-detail
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function DiagnosticClassDetailPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<DiagnosticClassDetailClient />
</Suspense>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 诊断报告路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { useTranslations } from "next-intl";
export default function DiagnosticError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("diagnostic");
useEffect(() => {
console.error("[portal-shell] diagnostic route error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive">
{t("error.title")}
</h2>
<p className="text-sm text-muted-foreground">
{error.message || t("error.unknown")}
</p>
<Button onClick={reset} variant="outline">
{t("error.retry")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 诊断报告路由段加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*
* 子页面(班级详情)的 Skeleton 由各自 server page 的 <Suspense> 兜底,
* 本文件仅在 /shell/teacher/diagnostic 列表期间显示。
*/
export default function DiagnosticLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,22 @@
import { Suspense } from "react";
import { DiagnosticListClient } from "@/features/teacher/diagnostic/diagnostic-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 诊断报告列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 DiagnosticListClientclient component中。
*
* 数据契约diagnosticReports ✅ 真实 schema 字段
*
* 关联ARCHITECTURE.md §5.3 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3
*/
export default function DiagnosticListPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<DiagnosticListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 错题本路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { useTranslations } from "next-intl";
export default function ErrorBookError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("errorBook");
useEffect(() => {
console.error("[portal-shell] error-book route error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive">
{t("error.title")}
</h2>
<p className="text-sm text-muted-foreground">
{error.message || t("error.unknown")}
</p>
<Button onClick={reset} variant="outline">
{t("error.retry")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 错题本路由段加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*/
export default function ErrorBookLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,22 @@
import { Suspense } from "react";
import { ErrorBookListClient } from "@/features/teacher/error-book/error-book-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 错题本列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 ErrorBookListClientclient component中。
*
* 数据契约errorBookItems / errorBookStats ✅ 真实 schema 字段
*
* 关联ARCHITECTURE.md §5.3 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3
*/
export default function ErrorBookListPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<ErrorBookListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,242 @@
/**
* Analytics 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { WeakPoint } from "@/lib/api";
import {
calcScoreRate,
formatMastery,
formatRank,
formatScore,
formatScoreRate,
formatTrendDate,
isAtRisk,
masteryToColorClass,
rankToColorClass,
scoreRateToLevel,
scoreToColorClass,
sortWeakPointsByErrorCountDesc,
sortWeakPointsByMasteryAsc,
} from "../transformations";
describe("formatScore", () => {
it("formats finite numbers with 1 decimal place", () => {
expect(formatScore(82.5)).toBe("82.5");
expect(formatScore(98)).toBe("98.0");
expect(formatScore(0)).toBe("0.0");
});
it("returns placeholder for null/undefined", () => {
expect(formatScore(null)).toBe("--");
expect(formatScore(undefined)).toBe("--");
});
it("returns placeholder for non-finite input", () => {
expect(formatScore(Number.NaN)).toBe("--");
});
});
describe("formatMastery", () => {
it("formats mastery in [0,1] as percentage", () => {
expect(formatMastery(0.45)).toBe("45%");
expect(formatMastery(0)).toBe("0%");
expect(formatMastery(1)).toBe("100%");
});
it("returns placeholder for out-of-range", () => {
expect(formatMastery(-0.1)).toBe("--");
expect(formatMastery(1.1)).toBe("--");
});
});
describe("formatRank", () => {
it("formats rank with total", () => {
expect(formatRank(5, 42)).toBe("5 / 42");
expect(formatRank(1, 10)).toBe("1 / 10");
});
it("formats rank only when total invalid", () => {
expect(formatRank(5, null)).toBe("5");
expect(formatRank(5, undefined)).toBe("5");
});
it("returns placeholder for invalid rank", () => {
expect(formatRank(null, 42)).toBe("--");
expect(formatRank(-1, 42)).toBe("--");
});
});
describe("formatTrendDate", () => {
it("formats valid ISO date string", () => {
const result = formatTrendDate("2026-07-15T00:00:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatTrendDate(null)).toBe("--");
expect(formatTrendDate(undefined)).toBe("--");
expect(formatTrendDate("")).toBe("--");
});
});
describe("scoreToColorClass", () => {
it("returns destructive for score < 60", () => {
expect(scoreToColorClass(50)).toBe("text-destructive");
});
it("returns amber for score in [60, 80)", () => {
expect(scoreToColorClass(70)).toContain("amber");
});
it("returns emerald for score >= 80", () => {
expect(scoreToColorClass(90)).toContain("emerald");
});
it("returns muted for non-finite input", () => {
expect(scoreToColorClass(Number.NaN)).toBe("text-muted-foreground");
});
});
describe("masteryToColorClass", () => {
it("returns destructive for low mastery", () => {
expect(masteryToColorClass(0.3)).toBe("text-destructive");
});
it("returns emerald for high mastery", () => {
expect(masteryToColorClass(0.8)).toContain("emerald");
});
});
describe("rankToColorClass", () => {
it("returns emerald for top 10%", () => {
expect(rankToColorClass(1, 20)).toContain("emerald");
expect(rankToColorClass(5, 50)).toContain("emerald");
});
it("returns primary for top 30%", () => {
expect(rankToColorClass(5, 20)).toBe("text-primary");
});
it("returns destructive for bottom 30%", () => {
expect(rankToColorClass(18, 20)).toBe("text-destructive");
});
it("returns amber for middle range", () => {
expect(rankToColorClass(10, 20)).toContain("amber");
});
it("returns muted for invalid input", () => {
expect(rankToColorClass(0, 20)).toBe("text-muted-foreground");
expect(rankToColorClass(5, 0)).toBe("text-muted-foreground");
});
});
describe("isAtRisk", () => {
it("returns true for avgScore < 60", () => {
expect(isAtRisk(50)).toBe(true);
expect(isAtRisk(59)).toBe(true);
});
it("returns false for avgScore >= 60", () => {
expect(isAtRisk(60)).toBe(false);
expect(isAtRisk(100)).toBe(false);
});
it("returns false for non-finite input", () => {
expect(isAtRisk(Number.NaN)).toBe(false);
});
});
describe("calcScoreRate", () => {
it("calculates rate correctly", () => {
expect(calcScoreRate(80, 100)).toBe(0.8);
expect(calcScoreRate(0, 100)).toBe(0);
expect(calcScoreRate(100, 100)).toBe(1);
});
it("caps at 1 when score > totalScore", () => {
expect(calcScoreRate(120, 100)).toBe(1);
});
it("returns 0 for zero or invalid totalScore", () => {
expect(calcScoreRate(80, 0)).toBe(0);
expect(calcScoreRate(80, -1)).toBe(0);
});
it("returns 0 for negative score", () => {
expect(calcScoreRate(-5, 100)).toBe(0);
});
});
describe("formatScoreRate", () => {
it("formats rate in [0,1] as percentage", () => {
expect(formatScoreRate(0.8)).toBe("80%");
expect(formatScoreRate(0)).toBe("0%");
expect(formatScoreRate(1)).toBe("100%");
});
it("returns placeholder for out-of-range", () => {
expect(formatScoreRate(-0.1)).toBe("--");
expect(formatScoreRate(1.1)).toBe("--");
});
});
describe("scoreRateToLevel", () => {
it("maps rate to A/B/C/D levels", () => {
expect(scoreRateToLevel(0.9)).toBe("A");
expect(scoreRateToLevel(0.85)).toBe("A");
expect(scoreRateToLevel(0.8)).toBe("B");
expect(scoreRateToLevel(0.75)).toBe("B");
expect(scoreRateToLevel(0.7)).toBe("C");
expect(scoreRateToLevel(0.6)).toBe("C");
expect(scoreRateToLevel(0.5)).toBe("D");
expect(scoreRateToLevel(0)).toBe("D");
});
it("returns placeholder for out-of-range", () => {
expect(scoreRateToLevel(-0.1)).toBe("--");
expect(scoreRateToLevel(1.1)).toBe("--");
});
});
describe("sortWeakPointsByMasteryAsc", () => {
it("sorts by mastery ascending", () => {
const wps: WeakPoint[] = [
{ knowledge_point_id: "kp-1", title: "A", mastery: 0.5, error_count: 3 },
{ knowledge_point_id: "kp-2", title: "B", mastery: 0.2, error_count: 5 },
{ knowledge_point_id: "kp-3", title: "C", mastery: 0.8, error_count: 1 },
];
const sorted = sortWeakPointsByMasteryAsc(wps);
expect(sorted[0]?.knowledge_point_id).toBe("kp-2");
expect(sorted[1]?.knowledge_point_id).toBe("kp-1");
expect(sorted[2]?.knowledge_point_id).toBe("kp-3");
});
it("does not mutate original array", () => {
const wps: WeakPoint[] = [
{ knowledge_point_id: "kp-1", title: "A", mastery: 0.5, error_count: 3 },
{ knowledge_point_id: "kp-2", title: "B", mastery: 0.2, error_count: 5 },
];
sortWeakPointsByMasteryAsc(wps);
expect(wps[0]?.knowledge_point_id).toBe("kp-1");
});
});
describe("sortWeakPointsByErrorCountDesc", () => {
it("sorts by error_count descending", () => {
const wps: WeakPoint[] = [
{ knowledge_point_id: "kp-1", title: "A", mastery: 0.5, error_count: 3 },
{ knowledge_point_id: "kp-2", title: "B", mastery: 0.2, error_count: 5 },
{ knowledge_point_id: "kp-3", title: "C", mastery: 0.8, error_count: 1 },
];
const sorted = sortWeakPointsByErrorCountDesc(wps);
expect(sorted[0]?.knowledge_point_id).toBe("kp-2");
expect(sorted[1]?.knowledge_point_id).toBe("kp-1");
expect(sorted[2]?.knowledge_point_id).toBe("kp-3");
});
});

View File

@@ -0,0 +1,281 @@
"use client";
/**
* 学情分析总览页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约(@contract-pending MSW 兜底):
* - analyticsOverview❌ schema 无此聚合根字段 → MSW 兜底
* - 契约工单docs/architecture/issues/contracts/data-ana_contract.md#analytics-overview
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BarChart3 } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useAnalyticsOverview } from "@/lib/api";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatMastery,
formatScore,
formatTrendDate,
masteryToColorClass,
scoreToColorClass,
sortWeakPointsByErrorCountDesc,
} from "@/features/teacher/analytics/transformations";
/**
* 总览客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function AnalyticsOverviewClient(): React.ReactElement {
const t = useTranslations("analytics");
const tCommon = useTranslations("common");
// @contract-pending MSW 兜底
const { data, loading, error } = useAnalyticsOverview();
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t("overview.mswNotice")}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("overview.title")}
description={t("overview.description")}
icon={<BarChart3 className="size-6" />}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={!loading && !error && !data}
errorNode={errorNode}
>
{data ? <OverviewStatsSection data={data} /> : null}
{data ? <WeakPointsSection data={data} /> : null}
{data ? <TrendsSection data={data} /> : null}
{data ? <ClassBreakdownSection data={data} /> : null}
</ListPageShell>
);
}
/**
* 总览统计卡片区。
*/
function OverviewStatsSection({
data,
}: {
data: NonNullable<ReturnType<typeof useAnalyticsOverview>["data"]>;
}): React.ReactElement {
const t = useTranslations("analytics");
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard
label={t("overview.totalStudents")}
value={String(data.total_students)}
/>
<StatCard
label={t("overview.avgScore")}
value={formatScore(data.avg_score)}
valueClass={scoreToColorClass(data.avg_score)}
/>
<StatCard
label={t("overview.avgMastery")}
value={formatMastery(data.avg_mastery)}
valueClass={masteryToColorClass(data.avg_mastery)}
/>
<StatCard
label={t("overview.atRiskCount")}
value={String(data.at_risk_count)}
valueClass={data.at_risk_count > 0 ? "text-destructive" : undefined}
/>
</div>
);
}
/**
* 单个统计卡片。
*/
function StatCard({
label,
value,
valueClass,
}: {
label: string;
value: string;
valueClass?: string;
}): React.ReactElement {
return (
<div className="rounded-xl border bg-card p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={`mt-1 text-2xl font-semibold ${valueClass ?? ""}`}>
{value}
</p>
</div>
);
}
/**
* 薄弱知识点区。
*/
function WeakPointsSection({
data,
}: {
data: NonNullable<ReturnType<typeof useAnalyticsOverview>["data"]>;
}): React.ReactElement {
const t = useTranslations("analytics");
const sorted = sortWeakPointsByErrorCountDesc(data.weak_points);
if (sorted.length === 0) return <></>;
return (
<section className="rounded-xl border bg-card p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("overview.sectionWeakPoints")}
</h2>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("overview.colKpTitle")}
</th>
<th className="p-3 text-left font-medium">
{t("overview.colErrorCount")}
</th>
<th className="p-3 text-left font-medium">
{t("overview.colMastery")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{sorted.map((wp) => (
<tr key={wp.knowledge_point_id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{wp.title}</td>
<td className="p-3">{wp.error_count}</td>
<td className="p-3">
<span className={masteryToColorClass(wp.mastery)}>
{formatMastery(wp.mastery)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
/**
* 趋势区(简单文本列表展示)。
*/
function TrendsSection({
data,
}: {
data: NonNullable<ReturnType<typeof useAnalyticsOverview>["data"]>;
}): React.ReactElement {
const t = useTranslations("analytics");
if (data.trends.length === 0) return <></>;
return (
<section className="rounded-xl border bg-card p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("overview.sectionTrends")}
</h2>
<div className="flex flex-wrap gap-3">
{data.trends.map((trend) => (
<div
key={trend.date}
className="flex items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2"
>
<span className="text-xs text-muted-foreground">
{formatTrendDate(trend.date)}
</span>
<span
className={`text-sm font-medium ${scoreToColorClass(trend.avg_score)}`}
>
{formatScore(trend.avg_score)}
</span>
</div>
))}
</div>
</section>
);
}
/**
* 班级分解区(每行有"查看学生"链接到 /shell/teacher/analytics/[studentId])。
*/
function ClassBreakdownSection({
data,
}: {
data: NonNullable<ReturnType<typeof useAnalyticsOverview>["data"]>;
}): React.ReactElement {
const t = useTranslations("analytics");
if (data.class_breakdown.length === 0) return <></>;
return (
<section className="rounded-xl border bg-card p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("overview.sectionClassBreakdown")}
</h2>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("overview.colClassName")}
</th>
<th className="p-3 text-left font-medium">
{t("overview.colStudentCount")}
</th>
<th className="p-3 text-left font-medium">
{t("overview.colAvgScore")}
</th>
<th className="p-3 text-left font-medium">
{t("overview.colAtRiskCount")}
</th>
<th className="p-3 text-right font-medium">
{t("overview.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{data.class_breakdown.map((cls) => (
<tr key={cls.class_id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{cls.class_name}</td>
<td className="p-3">{cls.student_count}</td>
<td className="p-3">
<span className={scoreToColorClass(cls.avg_score)}>
{formatScore(cls.avg_score)}
</span>
</td>
<td className="p-3">
<span
className={cls.at_risk_count > 0 ? "text-destructive" : ""}
>
{cls.at_risk_count}
</span>
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/analytics/stu-001`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("overview.viewStudentAnalytics")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}

View File

@@ -0,0 +1,313 @@
"use client";
/**
* 单生学情分析详情页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约(@contract-pending MSW 兜底):
* - 单查 studentAnalytics(studentId):❌ schema 无此字段 → MSW 兜底
* - 契约工单docs/architecture/issues/contracts/data-ana_contract.md#student-analytics
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BarChart3 } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useStudentAnalytics } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
calcScoreRate,
formatMastery,
formatRank,
formatScore,
formatScoreRate,
formatTrendDate,
masteryToColorClass,
rankToColorClass,
scoreRateToLevel,
scoreToColorClass,
sortWeakPointsByMasteryAsc,
} from "@/features/teacher/analytics/transformations";
/**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function StudentAnalyticsClient(): React.ReactElement {
const t = useTranslations("analytics");
const tCommon = useTranslations("common");
const params = useParams<{ studentId: string }>();
const studentId = params?.studentId ?? "";
// @contract-pending MSW 兜底
const { data, loading, error } = useStudentAnalytics(studentId);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t("student.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode =
!loading && !error && !data ? (
<EmptyState
icon={BarChart3}
title={t("student.notFound")}
action={{
label: t("student.backToOverview"),
href: "/shell/teacher/analytics",
}}
/>
) : undefined;
return (
<DetailPageShell
title={data?.student_name ?? t("student.title")}
description={
data
? t("student.subtitle", {
className: data.class_name,
studentNo: data.student_no,
})
: undefined
}
icon={<BarChart3 className="size-6" />}
backHref="/shell/teacher/analytics"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <StudentBasicSection data={data} /> : null}
{data ? <StudentWeakPointsSection data={data} /> : null}
{data ? <StudentTrendsSection data={data} /> : null}
{data ? <StudentRecentExamsSection data={data} /> : null}
</DetailPageShell>
);
}
/**
* 学生基本信息区。
*/
function StudentBasicSection({
data,
}: {
data: NonNullable<ReturnType<typeof useStudentAnalytics>["data"]>;
}): React.ReactElement {
const t = useTranslations("analytics");
return (
<DetailSection title={t("student.sectionBasic")}>
<DetailField label={t("student.fieldName")} value={data.student_name} />
<DetailField
label={t("student.fieldStudentNo")}
value={data.student_no}
/>
<DetailField
label={t("student.fieldClassName")}
value={data.class_name}
/>
<DetailField
label={t("student.fieldAvgScore")}
value={
<span className={scoreToColorClass(data.avg_score)}>
{formatScore(data.avg_score)}
</span>
}
/>
<DetailField
label={t("student.fieldClassRank")}
value={
<span
className={rankToColorClass(data.class_rank, data.total_students)}
>
{formatRank(data.class_rank, data.total_students)}
</span>
}
/>
<DetailField
label={t("student.fieldTotalStudents")}
value={String(data.total_students)}
/>
</DetailSection>
);
}
/**
* 薄弱知识点区(按掌握度升序,最薄弱在前)。
*/
function StudentWeakPointsSection({
data,
}: {
data: NonNullable<ReturnType<typeof useStudentAnalytics>["data"]>;
}): React.ReactElement {
const t = useTranslations("analytics");
const sorted = sortWeakPointsByMasteryAsc(data.weak_points);
if (sorted.length === 0) {
return (
<DetailSection title={t("student.sectionWeakPoints")}>
<p className="text-sm text-muted-foreground">
{t("student.noWeakPoints")}
</p>
</DetailSection>
);
}
return (
<DetailSection title={t("student.sectionWeakPoints")}>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("student.colKpTitle")}
</th>
<th className="p-3 text-left font-medium">
{t("student.colMastery")}
</th>
<th className="p-3 text-left font-medium">
{t("student.colErrorCount")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{sorted.map((wp) => (
<tr key={wp.knowledge_point_id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{wp.title}</td>
<td className="p-3">
<span className={masteryToColorClass(wp.mastery)}>
{formatMastery(wp.mastery)}
</span>
</td>
<td className="p-3">{wp.error_count}</td>
</tr>
))}
</tbody>
</table>
</div>
</DetailSection>
);
}
/**
* 学习趋势区。
*/
function StudentTrendsSection({
data,
}: {
data: NonNullable<ReturnType<typeof useStudentAnalytics>["data"]>;
}): React.ReactElement {
const t = useTranslations("analytics");
if (data.trends.length === 0) {
return (
<DetailSection title={t("student.sectionTrends")}>
<p className="text-sm text-muted-foreground">{t("student.noTrends")}</p>
</DetailSection>
);
}
return (
<DetailSection title={t("student.sectionTrends")}>
<div className="flex flex-wrap gap-3">
{data.trends.map((trend) => (
<div
key={trend.date}
className="flex items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2"
>
<span className="text-xs text-muted-foreground">
{formatTrendDate(trend.date)}
</span>
<span
className={`text-sm font-medium ${scoreToColorClass(trend.score)}`}
>
{formatScore(trend.score)}
</span>
</div>
))}
</div>
</DetailSection>
);
}
/**
* 近期考试区。
*/
function StudentRecentExamsSection({
data,
}: {
data: NonNullable<ReturnType<typeof useStudentAnalytics>["data"]>;
}): React.ReactElement {
const t = useTranslations("analytics");
if (data.recent_exams.length === 0) {
return (
<DetailSection title={t("student.sectionRecentExams")}>
<p className="text-sm text-muted-foreground">
{t("student.noRecentExams")}
</p>
</DetailSection>
);
}
return (
<DetailSection title={t("student.sectionRecentExams")}>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("student.colExamTitle")}
</th>
<th className="p-3 text-left font-medium">
{t("student.colScore")}
</th>
<th className="p-3 text-left font-medium">
{t("student.colScoreRate")}
</th>
<th className="p-3 text-left font-medium">
{t("student.colLevel")}
</th>
<th className="p-3 text-left font-medium">
{t("student.colExamDate")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{data.recent_exams.map((exam) => {
const rate = calcScoreRate(exam.score, exam.total_score);
const level = scoreRateToLevel(rate);
return (
<tr key={exam.exam_id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{exam.exam_title}</td>
<td className="p-3">
<span className={scoreToColorClass(exam.score)}>
{formatScore(exam.score)}
</span>
<span className="text-xs text-muted-foreground">
/ {exam.total_score}
</span>
</td>
<td className="p-3">{formatScoreRate(rate)}</td>
<td className="p-3">{level}</td>
<td className="p-3 font-mono text-xs">
{formatTrendDate(exam.date)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</DetailSection>
);
}

View File

@@ -0,0 +1,194 @@
/**
* Analytics 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type { WeakPoint } from "@/lib/api";
/**
* 格式化分数(保留 1 位小数)。
* 输入无效返回 "--"。
*/
export function formatScore(score: number | null | undefined): string {
if (score == null || !Number.isFinite(score)) return "--";
return score.toFixed(1);
}
/**
* 格式化掌握度0-1 浮点)为百分比字符串。
* 输入无效或越界返回 "--"。
*/
export function formatMastery(mastery: number | null | undefined): string {
if (
mastery == null ||
!Number.isFinite(mastery) ||
mastery < 0 ||
mastery > 1
)
return "--";
return `${Math.round(mastery * 100)}%`;
}
/**
* 格式化排名({rank}/{total})。
* 输入无效返回 "--"。
*/
export function formatRank(
rank: number | null | undefined,
total: number | null | undefined,
): string {
if (rank == null || !Number.isFinite(rank) || rank < 0) return "--";
if (total == null || !Number.isFinite(total) || total < 0) return `${rank}`;
return `${rank} / ${total}`;
}
/**
* 格式化趋势日期ISO → yyyy-MM-dd
* 输入无效返回占位符。
*/
export function formatTrendDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
/**
* 根据分数返回 Tailwind 文本语义类名。
* - < 60 → destructive
* - < 80 → amber
* - 其他 → emerald
*/
export function scoreToColorClass(score: number | null | undefined): string {
if (score == null || !Number.isFinite(score) || score < 0) {
return "text-muted-foreground";
}
if (score < 60) return "text-destructive";
if (score < 80) return "text-amber-600 dark:text-amber-400";
return "text-emerald-600 dark:text-emerald-400";
}
/**
* 根据掌握度0-1返回 Tailwind 文本语义类名。
* - < 0.4 → destructive低掌握
* - < 0.7 → amber中掌握
* - 其他 → emerald高掌握
*/
export function masteryToColorClass(
mastery: number | null | undefined,
): string {
if (
mastery == null ||
!Number.isFinite(mastery) ||
mastery < 0 ||
mastery > 1
) {
return "text-muted-foreground";
}
if (mastery < 0.4) return "text-destructive";
if (mastery < 0.7) return "text-amber-600 dark:text-amber-400";
return "text-emerald-600 dark:text-emerald-400";
}
/**
* 根据排名比例rank/total返回 Tailwind 文本语义类名。
* - 前 10% → emerald
* - 前 30% → primary
* - 后 30% → destructive
* - 其他 → amber
*/
export function rankToColorClass(
rank: number | null | undefined,
total: number | null | undefined,
): string {
if (
rank == null ||
total == null ||
!Number.isFinite(rank) ||
!Number.isFinite(total) ||
rank <= 0 ||
total <= 0 ||
rank > total
) {
return "text-muted-foreground";
}
const ratio = rank / total;
if (ratio <= 0.1) return "text-emerald-600 dark:text-emerald-400";
if (ratio <= 0.3) return "text-primary";
if (ratio >= 0.7) return "text-destructive";
return "text-amber-600 dark:text-amber-400";
}
/**
* 判断学生是否为风险学生(平均分 < 60
*/
export function isAtRisk(avgScore: number): boolean {
return Number.isFinite(avgScore) && avgScore < 60;
}
/**
* 计算得分率score / totalScore
* 输入无效或 totalScore 为 0 返回 0。
*/
export function calcScoreRate(score: number, totalScore: number): number {
if (
!Number.isFinite(score) ||
!Number.isFinite(totalScore) ||
totalScore <= 0 ||
score < 0
) {
return 0;
}
const ratio = score / totalScore;
if (ratio > 1) return 1;
return ratio;
}
/**
* 格式化得分率0-1 → 百分比)。
*/
export function formatScoreRate(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return `${Math.round(rate * 100)}%`;
}
/**
* 根据得分率返回等级A/B/C/D
* - >= 0.85 → A
* - >= 0.75 → B
* - >= 0.6 → C
* - 其他 → D
*/
export function scoreRateToLevel(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
if (rate >= 0.85) return "A";
if (rate >= 0.75) return "B";
if (rate >= 0.6) return "C";
return "D";
}
/**
* 按掌握度升序排序薄弱知识点(最薄弱在前)。
*/
export function sortWeakPointsByMasteryAsc(
weakPoints: WeakPoint[],
): WeakPoint[] {
return [...weakPoints].sort((a, b) => (a.mastery ?? 0) - (b.mastery ?? 0));
}
/**
* 按错误次数降序排序薄弱知识点(错误最多在前)。
*/
export function sortWeakPointsByErrorCountDesc(
weakPoints: WeakPoint[],
): WeakPoint[] {
return [...weakPoints].sort(
(a, b) => (b.error_count ?? 0) - (a.error_count ?? 0),
);
}

View File

@@ -0,0 +1,216 @@
/**
* Diagnostic 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { DiagnosticReport } from "@/lib/api";
import {
DIAGNOSTIC_STATUS_LABEL,
avgScoreToColorClass,
diagnosticStatusToBadgeClass,
filterPublishedReports,
formatAvgScore,
formatDiagnosticDate,
formatDiagnosticStatus,
formatMastery,
formatReportType,
formatStudentCount,
isDiagnosticDraft,
isDiagnosticPublished,
isValidDiagnosticStatus,
masteryToColorClass,
} from "../transformations";
describe("formatDiagnosticStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatDiagnosticStatus("DRAFT")).toBe("草稿");
expect(formatDiagnosticStatus("GENERATED")).toBe("已生成");
expect(formatDiagnosticStatus("PUBLISHED")).toBe("已发布");
expect(formatDiagnosticStatus("ARCHIVED")).toBe("已归档");
});
it("returns original value for unknown status", () => {
expect(formatDiagnosticStatus("UNKNOWN")).toBe("UNKNOWN");
expect(formatDiagnosticStatus("")).toBe("");
});
it("DIAGNOSTIC_STATUS_LABEL covers all standard statuses", () => {
expect(Object.keys(DIAGNOSTIC_STATUS_LABEL)).toHaveLength(4);
});
});
describe("diagnosticStatusToBadgeClass", () => {
it("returns correct badge class for each status", () => {
expect(diagnosticStatusToBadgeClass("DRAFT")).toBe(
"bg-muted text-muted-foreground",
);
expect(diagnosticStatusToBadgeClass("GENERATED")).toContain("primary");
expect(diagnosticStatusToBadgeClass("PUBLISHED")).toContain("emerald");
expect(diagnosticStatusToBadgeClass("ARCHIVED")).toBe(
"bg-muted text-muted-foreground",
);
});
it("returns muted for unknown status", () => {
expect(diagnosticStatusToBadgeClass("UNKNOWN")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("formatDiagnosticDate", () => {
it("formats valid ISO date string", () => {
const result = formatDiagnosticDate("2026-07-25T23:59:59Z");
expect(result).toContain("2026");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatDiagnosticDate(null)).toBe("--");
expect(formatDiagnosticDate(undefined)).toBe("--");
expect(formatDiagnosticDate("")).toBe("--");
});
});
describe("formatMastery", () => {
it("formats mastery in [0,1] as percentage", () => {
expect(formatMastery(0.45)).toBe("45%");
expect(formatMastery(0)).toBe("0%");
expect(formatMastery(1)).toBe("100%");
});
it("returns placeholder for out-of-range", () => {
expect(formatMastery(-0.1)).toBe("--");
expect(formatMastery(1.1)).toBe("--");
});
});
describe("formatAvgScore", () => {
it("formats finite numbers with 1 decimal place", () => {
expect(formatAvgScore(82.5)).toBe("82.5");
expect(formatAvgScore(98)).toBe("98.0");
expect(formatAvgScore(0)).toBe("0.0");
});
it("returns placeholder for null/undefined", () => {
expect(formatAvgScore(null)).toBe("--");
expect(formatAvgScore(undefined)).toBe("--");
});
});
describe("formatStudentCount", () => {
it("formats count with 人 suffix", () => {
expect(formatStudentCount(38)).toBe("38 人");
expect(formatStudentCount(0)).toBe("0 人");
});
it("returns placeholder for invalid input", () => {
expect(formatStudentCount(null)).toBe("--");
expect(formatStudentCount(-1)).toBe("--");
});
});
describe("masteryToColorClass", () => {
it("returns destructive for low mastery", () => {
expect(masteryToColorClass(0.3)).toBe("text-destructive");
});
it("returns amber for medium mastery", () => {
expect(masteryToColorClass(0.5)).toContain("amber");
});
it("returns emerald for high mastery", () => {
expect(masteryToColorClass(0.8)).toContain("emerald");
});
});
describe("avgScoreToColorClass", () => {
it("returns destructive for score < 60", () => {
expect(avgScoreToColorClass(50)).toBe("text-destructive");
expect(avgScoreToColorClass(59)).toBe("text-destructive");
});
it("returns amber for score in [60, 80)", () => {
expect(avgScoreToColorClass(60)).toContain("amber");
expect(avgScoreToColorClass(79)).toContain("amber");
});
it("returns emerald for score >= 80", () => {
expect(avgScoreToColorClass(80)).toContain("emerald");
expect(avgScoreToColorClass(100)).toContain("emerald");
});
});
describe("isDiagnosticPublished / isDiagnosticDraft", () => {
it("PUBLISHED is published but not draft", () => {
expect(isDiagnosticPublished("PUBLISHED")).toBe(true);
expect(isDiagnosticDraft("PUBLISHED")).toBe(false);
});
it("DRAFT is draft but not published", () => {
expect(isDiagnosticDraft("DRAFT")).toBe(true);
expect(isDiagnosticPublished("DRAFT")).toBe(false);
});
it("unknown status is neither", () => {
expect(isDiagnosticPublished("UNKNOWN")).toBe(false);
expect(isDiagnosticDraft("UNKNOWN")).toBe(false);
});
});
describe("isValidDiagnosticStatus", () => {
it("returns true for valid statuses", () => {
expect(isValidDiagnosticStatus("DRAFT")).toBe(true);
expect(isValidDiagnosticStatus("GENERATED")).toBe(true);
expect(isValidDiagnosticStatus("PUBLISHED")).toBe(true);
expect(isValidDiagnosticStatus("ARCHIVED")).toBe(true);
});
it("returns false for invalid statuses", () => {
expect(isValidDiagnosticStatus("UNKNOWN")).toBe(false);
expect(isValidDiagnosticStatus("")).toBe(false);
});
});
describe("formatReportType", () => {
it("maps known types to Chinese labels", () => {
expect(formatReportType("WEEKLY")).toBe("周报");
expect(formatReportType("MONTHLY")).toBe("月报");
expect(formatReportType("EXAM")).toBe("考试诊断");
expect(formatReportType("UNIT")).toBe("单元诊断");
});
it("returns original value for unknown type", () => {
expect(formatReportType("UNKNOWN")).toBe("UNKNOWN");
});
});
describe("filterPublishedReports", () => {
it("filters only published reports", () => {
const reports: DiagnosticReport[] = [
{
report_id: "r-1",
student_id: "s-1",
report_type: "WEEKLY",
title: "T1",
summary: "",
generated_at: "2026-07-01T00:00:00Z",
status: "PUBLISHED",
},
{
report_id: "r-2",
student_id: "s-1",
report_type: "WEEKLY",
title: "T2",
summary: "",
generated_at: "2026-07-02T00:00:00Z",
status: "DRAFT",
},
];
const published = filterPublishedReports(reports);
expect(published).toHaveLength(1);
expect(published[0]?.report_id).toBe("r-1");
});
});

View File

@@ -0,0 +1,252 @@
"use client";
/**
* 班级诊断报告详情页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约(@contract-pending 全 MSW
* - 单查 diagnosticReport(classId):❌ schema 无此字段 → MSW 兜底
* - 契约工单docs/architecture/issues/contracts/data-ana_contract.md#diagnostic-class-detail
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { Stethoscope } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useDiagnosticReport } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
avgScoreToColorClass,
diagnosticStatusToBadgeClass,
formatAvgScore,
formatDiagnosticDate,
formatDiagnosticStatus,
formatMastery,
formatReportType,
formatStudentCount,
masteryToColorClass,
} from "@/features/teacher/diagnostic/transformations";
/**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function DiagnosticClassDetailClient(): React.ReactElement {
const t = useTranslations("diagnostic");
const tCommon = useTranslations("common");
const params = useParams<{ classId: string }>();
const classId = params?.classId ?? "";
// @contract-pending MSW 兜底
const { data, loading, error } = useDiagnosticReport(classId);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t("detail.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode =
!loading && !error && !data ? (
<EmptyState
icon={Stethoscope}
title={t("detail.notFound")}
action={{
label: t("detail.backToList"),
href: "/shell/teacher/diagnostic",
}}
/>
) : undefined;
return (
<DetailPageShell
title={data?.title ?? t("detail.title")}
description={
data
? t("detail.generatedAtPrefix", {
date: formatDiagnosticDate(data.generated_at),
})
: undefined
}
icon={<Stethoscope className="size-6" />}
backHref="/shell/teacher/diagnostic"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <DiagnosticDetailBody detail={data} /> : null}
{data ? <WeakPointsSection weakPoints={data.weak_points} /> : null}
{data ? (
<RecommendationsSection recommendations={data.recommendations} />
) : null}
</DetailPageShell>
);
}
/**
* 详情基本信息区。
*/
function DiagnosticDetailBody({
detail,
}: {
detail: NonNullable<ReturnType<typeof useDiagnosticReport>["data"]>;
}): React.ReactElement {
const t = useTranslations("diagnostic");
return (
<DetailSection title={t("detail.sectionBasic")}>
<DetailField label={t("detail.fieldTitle")} value={detail.title} />
<DetailField
label={t("detail.fieldReportType")}
value={formatReportType(detail.report_type)}
/>
<DetailField
label={t("detail.fieldStatus")}
value={<DiagnosticStatusBadge status={detail.status} />}
/>
<DetailField
label={t("detail.fieldClassName")}
value={detail.class_name}
/>
<DetailField
label={t("detail.fieldStudentCount")}
value={formatStudentCount(detail.student_count)}
/>
<DetailField
label={t("detail.fieldAvgScore")}
value={
<span className={avgScoreToColorClass(detail.avg_score)}>
{formatAvgScore(detail.avg_score)}
</span>
}
/>
<DetailField
label={t("detail.fieldSummary")}
value={detail.summary || t("detail.noSummary")}
/>
<DetailField
label={t("detail.fieldGeneratedAt")}
value={formatDiagnosticDate(detail.generated_at)}
/>
</DetailSection>
);
}
/**
* 薄弱知识点区。
*/
function WeakPointsSection({
weakPoints,
}: {
weakPoints: NonNullable<
ReturnType<typeof useDiagnosticReport>["data"]
>["weak_points"];
}): React.ReactElement {
const t = useTranslations("diagnostic");
if (weakPoints.length === 0) {
return (
<DetailSection title={t("detail.sectionWeakPoints")}>
<p className="text-sm text-muted-foreground">
{t("detail.noWeakPoints")}
</p>
</DetailSection>
);
}
return (
<DetailSection title={t("detail.sectionWeakPoints")}>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("detail.colKpTitle")}
</th>
<th className="p-3 text-left font-medium">
{t("detail.colMastery")}
</th>
<th className="p-3 text-left font-medium">
{t("detail.colErrorCount")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{weakPoints.map((wp) => (
<tr key={wp.knowledge_point_id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{wp.title}</td>
<td className="p-3">
<span className={masteryToColorClass(wp.mastery)}>
{formatMastery(wp.mastery)}
</span>
</td>
<td className="p-3">{wp.error_count}</td>
</tr>
))}
</tbody>
</table>
</div>
</DetailSection>
);
}
/**
* 教学建议区。
*/
function RecommendationsSection({
recommendations,
}: {
recommendations: string[];
}): React.ReactElement {
const t = useTranslations("diagnostic");
if (recommendations.length === 0) {
return (
<DetailSection title={t("detail.sectionRecommendations")}>
<p className="text-sm text-muted-foreground">
{t("detail.noRecommendations")}
</p>
</DetailSection>
);
}
return (
<DetailSection title={t("detail.sectionRecommendations")}>
<ul className="list-inside list-decimal space-y-2 text-sm">
{recommendations.map((rec, idx) => (
<li key={idx}>{rec}</li>
))}
</ul>
</DetailSection>
);
}
/**
* 诊断报告状态徽章。
*/
function DiagnosticStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatDiagnosticStatus(status);
const cls = diagnosticStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
/**
* 诊断报告列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - diagnosticReports✅ 真实 schema 字段
*
* URL 状态:?q=xxx &status=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3
*/
import { Stethoscope } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useDiagnosticReports } from "@/lib/api";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
diagnosticStatusToBadgeClass,
filterPublishedReports,
formatDiagnosticDate,
formatDiagnosticStatus,
formatReportType,
} from "@/features/teacher/diagnostic/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function DiagnosticListClient(): React.ReactElement {
const t = useTranslations("diagnostic");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const q = searchParams.get("q") ?? "";
const statusFilter = searchParams.get("status") ?? "";
// ✅ 真实 schema 查询
const { data, loading, error } = useDiagnosticReports();
// 客户端二次筛选q + status
const filteredItems = useMemo(() => {
const items = data?.items ?? [];
return items.filter((item) => {
if (
q &&
!item.title.toLowerCase().includes(q.toLowerCase()) &&
!item.summary.toLowerCase().includes(q.toLowerCase())
) {
return false;
}
if (statusFilter && item.status !== statusFilter) {
return false;
}
return true;
});
}, [data, q, statusFilter]);
const publishedCount = useMemo(
() => filterPublishedReports(data?.items ?? []).length,
[data],
);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/teacher/diagnostic?${params.toString()}`);
});
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<Stethoscope className="size-6" />}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<select
value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.statusFilter")}
>
<option value="">{t("list.statusAll")}</option>
<option value="DRAFT">{t("list.statusDraft")}</option>
<option value="GENERATED">{t("list.statusGenerated")}</option>
<option value="PUBLISHED">{t("list.statusPublished")}</option>
<option value="ARCHIVED">{t("list.statusArchived")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
<span>·</span>
<span>{t("list.publishedCount", { count: publishedCount })}</span>
</div>
}
>
<DiagnosticTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 诊断报告列表表格。
*/
function DiagnosticTable({
items,
}: {
items: NonNullable<ReturnType<typeof useDiagnosticReports>["data"]>["items"];
}): React.ReactElement {
const t = useTranslations("diagnostic");
if (items.length === 0) return <></>;
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("list.colTitle")}</th>
<th className="p-3 text-left font-medium">{t("list.colType")}</th>
<th className="p-3 text-left font-medium">
{t("list.colStudentId")}
</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colGeneratedAt")}
</th>
<th className="p-3 text-right font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<tr
key={`${item.student_id}-${item.report_id}`}
className="hover:bg-muted/30"
>
<td className="p-3">
<span className="font-medium">{item.title}</span>
{item.summary ? (
<p className="mt-1 text-xs text-muted-foreground">
{item.summary.slice(0, 60) +
(item.summary.length > 60 ? "..." : "")}
</p>
) : null}
</td>
<td className="p-3 text-xs">
{formatReportType(item.report_type)}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{item.student_id}
</td>
<td className="p-3">
<DiagnosticStatusBadge status={item.status} />
</td>
<td className="p-3 font-mono text-xs">
{formatDiagnosticDate(item.generated_at)}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/diagnostic/class/${item.student_id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.viewClassDetail")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 诊断报告状态徽章。
*/
function DiagnosticStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatDiagnosticStatus(status);
const cls = diagnosticStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,180 @@
/**
* Diagnostic 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type { DiagnosticReport, DiagnosticReportStatus } from "@/lib/api";
/** 诊断报告状态中文标签映射 */
export const DIAGNOSTIC_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
GENERATED: "已生成",
PUBLISHED: "已发布",
ARCHIVED: "已归档",
};
/**
* 将诊断报告状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatDiagnosticStatus(status: string): string {
return DIAGNOSTIC_STATUS_LABEL[status] ?? status;
}
/**
* 根据诊断报告状态返回 Tailwind 徽章语义类名。
*/
export function diagnosticStatusToBadgeClass(status: string): string {
switch (status) {
case "DRAFT":
return "bg-muted text-muted-foreground";
case "GENERATED":
return "bg-primary/10 text-primary";
case "PUBLISHED":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "ARCHIVED":
return "bg-muted text-muted-foreground";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatDiagnosticDate(
isoDate: string | null | undefined,
): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 格式化掌握度0-1 浮点)为百分比字符串。
* 输入无效或越界返回 "--"。
*/
export function formatMastery(mastery: number | null | undefined): string {
if (
mastery == null ||
!Number.isFinite(mastery) ||
mastery < 0 ||
mastery > 1
)
return "--";
return `${Math.round(mastery * 100)}%`;
}
/**
* 格式化平均分(保留 1 位小数)。
* 输入无效返回 "--"。
*/
export function formatAvgScore(score: number | null | undefined): string {
if (score == null || !Number.isFinite(score)) return "--";
return score.toFixed(1);
}
/**
* 格式化学生人数。
*/
export function formatStudentCount(count: number | null | undefined): string {
if (count == null || !Number.isFinite(count) || count < 0) return "--";
return `${count}`;
}
/**
* 根据掌握度0-1返回 Tailwind 文本语义类名。
* - < 0.4 → destructive低掌握
* - < 0.7 → amber中掌握
* - 其他 → emerald高掌握
*/
export function masteryToColorClass(mastery: number): string {
if (!Number.isFinite(mastery) || mastery < 0 || mastery > 1) {
return "text-muted-foreground";
}
if (mastery < 0.4) return "text-destructive";
if (mastery < 0.7) return "text-amber-600 dark:text-amber-400";
return "text-emerald-600 dark:text-emerald-400";
}
/**
* 根据平均分返回 Tailwind 文本语义类名。
* - < 60 → destructive
* - < 80 → amber
* - 其他 → emerald
*/
export function avgScoreToColorClass(score: number): string {
if (!Number.isFinite(score) || score < 0) {
return "text-muted-foreground";
}
if (score < 60) return "text-destructive";
if (score < 80) return "text-amber-600 dark:text-amber-400";
return "text-emerald-600 dark:text-emerald-400";
}
/**
* 判断诊断报告是否已发布。
*/
export function isDiagnosticPublished(status: string): boolean {
return status === "PUBLISHED";
}
/**
* 判断诊断报告是否为草稿。
*/
export function isDiagnosticDraft(status: string): boolean {
return status === "DRAFT";
}
/**
* 将 DiagnosticReportStatus 类型守卫:判断字符串是否为合法状态。
*/
export function isValidDiagnosticStatus(
status: string,
): status is DiagnosticReportStatus {
return (
status === "DRAFT" ||
status === "GENERATED" ||
status === "PUBLISHED" ||
status === "ARCHIVED"
);
}
/**
* 判断诊断报告是否有摘要。
*/
export function hasSummary(summary: string | null | undefined): boolean {
return Boolean(summary && summary.trim().length > 0);
}
/**
* 从 DiagnosticReport 提取报告类型中文标签。
*/
export function formatReportType(reportType: string): string {
const map: Record<string, string> = {
WEEKLY: "周报",
MONTHLY: "月报",
EXAM: "考试诊断",
UNIT: "单元诊断",
};
return map[reportType] ?? reportType;
}
/**
* 从诊断报告列表中提取已发布报告。
*/
export function filterPublishedReports(
reports: DiagnosticReport[],
): DiagnosticReport[] {
return reports.filter((r) => isDiagnosticPublished(r.status));
}

View File

@@ -0,0 +1,227 @@
/**
* Error Book 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { TeacherErrorBookStats } from "@/lib/api";
import {
errorRateToColorClass,
formatErrorBookDate,
formatErrorCount,
formatErrorRate,
formatMastery,
hasContent,
isHighErrorRate,
isLowMastery,
masteryToColorClass,
sortKnowledgePointsByErrorRate,
toErrorBookListItem,
} from "../transformations";
describe("formatErrorBookDate", () => {
it("formats valid ISO date string", () => {
const result = formatErrorBookDate("2026-07-25T23:59:59Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatErrorBookDate(null)).toBe("--");
expect(formatErrorBookDate(undefined)).toBe("--");
expect(formatErrorBookDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatErrorBookDate("not-a-date")).toBe("--");
});
});
describe("formatErrorCount", () => {
it("formats positive counts", () => {
expect(formatErrorCount(0)).toBe("0 次");
expect(formatErrorCount(5)).toBe("5 次");
expect(formatErrorCount(100)).toBe("100 次");
});
it("returns placeholder for null/undefined", () => {
expect(formatErrorCount(null)).toBe("--");
expect(formatErrorCount(undefined)).toBe("--");
});
it("returns placeholder for negative or non-finite", () => {
expect(formatErrorCount(-1)).toBe("--");
expect(formatErrorCount(Number.NaN)).toBe("--");
});
});
describe("formatErrorRate", () => {
it("formats rate in [0,1] as percentage", () => {
expect(formatErrorRate(0)).toBe("0%");
expect(formatErrorRate(0.5)).toBe("50%");
expect(formatErrorRate(1)).toBe("100%");
});
it("returns placeholder for out-of-range or non-finite", () => {
expect(formatErrorRate(-0.1)).toBe("--");
expect(formatErrorRate(1.1)).toBe("--");
expect(formatErrorRate(Number.NaN)).toBe("--");
});
});
describe("formatMastery", () => {
it("formats mastery in [0,1] as percentage", () => {
expect(formatMastery(0)).toBe("0%");
expect(formatMastery(0.45)).toBe("45%");
expect(formatMastery(1)).toBe("100%");
});
it("returns placeholder for out-of-range or non-finite", () => {
expect(formatMastery(-0.1)).toBe("--");
expect(formatMastery(1.1)).toBe("--");
expect(formatMastery(Number.NaN)).toBe("--");
});
});
describe("errorRateToColorClass", () => {
it("returns destructive for high error rate", () => {
expect(errorRateToColorClass(0.6)).toBe("text-destructive");
expect(errorRateToColorClass(0.9)).toBe("text-destructive");
});
it("returns amber for medium error rate", () => {
expect(errorRateToColorClass(0.3)).toContain("amber");
expect(errorRateToColorClass(0.59)).toContain("amber");
});
it("returns emerald for low error rate", () => {
expect(errorRateToColorClass(0.1)).toContain("emerald");
expect(errorRateToColorClass(0.29)).toContain("emerald");
});
it("returns muted for non-finite input", () => {
expect(errorRateToColorClass(Number.NaN)).toBe("text-muted-foreground");
});
});
describe("masteryToColorClass", () => {
it("returns destructive for low mastery", () => {
expect(masteryToColorClass(0.3)).toBe("text-destructive");
expect(masteryToColorClass(0.1)).toBe("text-destructive");
});
it("returns amber for medium mastery", () => {
expect(masteryToColorClass(0.4)).toContain("amber");
expect(masteryToColorClass(0.69)).toContain("amber");
});
it("returns emerald for high mastery", () => {
expect(masteryToColorClass(0.7)).toContain("emerald");
expect(masteryToColorClass(1)).toContain("emerald");
});
});
describe("isHighErrorRate", () => {
it("returns true for rate >= 0.6", () => {
expect(isHighErrorRate(0.6)).toBe(true);
expect(isHighErrorRate(1)).toBe(true);
});
it("returns false for rate < 0.6", () => {
expect(isHighErrorRate(0.59)).toBe(false);
expect(isHighErrorRate(0)).toBe(false);
});
});
describe("isLowMastery", () => {
it("returns true for mastery < 0.4", () => {
expect(isLowMastery(0.3)).toBe(true);
expect(isLowMastery(0)).toBe(true);
});
it("returns false for mastery >= 0.4", () => {
expect(isLowMastery(0.4)).toBe(false);
expect(isLowMastery(1)).toBe(false);
});
it("returns false for negative or non-finite", () => {
expect(isLowMastery(-0.1)).toBe(false);
expect(isLowMastery(Number.NaN)).toBe(false);
});
});
describe("sortKnowledgePointsByErrorRate", () => {
it("sorts by error_rate descending", () => {
const stats: TeacherErrorBookStats = {
student_id: "stu-001",
total_error_questions: 10,
total_error_count: 30,
by_knowledge_point: [
{
knowledge_point_id: "kp-1",
title: "A",
error_count: 5,
question_count: 10,
error_rate: 0.5,
},
{
knowledge_point_id: "kp-2",
title: "B",
error_count: 8,
question_count: 10,
error_rate: 0.8,
},
{
knowledge_point_id: "kp-3",
title: "C",
error_count: 2,
question_count: 10,
error_rate: 0.2,
},
],
recent_7d_errors: 5,
};
const sorted = sortKnowledgePointsByErrorRate(stats);
expect(sorted[0]?.knowledge_point_id).toBe("kp-2");
expect(sorted[1]?.knowledge_point_id).toBe("kp-1");
expect(sorted[2]?.knowledge_point_id).toBe("kp-3");
});
it("returns empty array for null/undefined stats", () => {
expect(sortKnowledgePointsByErrorRate(null)).toEqual([]);
expect(sortKnowledgePointsByErrorRate(undefined)).toEqual([]);
});
});
describe("toErrorBookListItem", () => {
it("extracts list fields and injects student_id", () => {
const item = {
question_id: "q-001",
knowledge_point_id: "kp-001",
knowledge_point_title: "二次函数",
error_count: 5,
last_error_time: "2026-07-20T10:00:00Z",
content: "题目内容",
};
const listItem = toErrorBookListItem(item, "stu-001");
expect(listItem.question_id).toBe("q-001");
expect(listItem.student_id).toBe("stu-001");
expect(listItem.knowledge_point_title).toBe("二次函数");
});
});
describe("hasContent", () => {
it("returns true for non-empty content", () => {
expect(hasContent("题目内容")).toBe(true);
expect(hasContent(" x ")).toBe(true);
});
it("returns false for empty/whitespace/null/undefined", () => {
expect(hasContent("")).toBe(false);
expect(hasContent(" ")).toBe(false);
expect(hasContent(null)).toBe(false);
expect(hasContent(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,278 @@
"use client";
/**
* 错题本列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - errorBookItems✅ 真实 schema 字段
* - errorBookStats✅ 真实 schema 字段(复用 dashboard.graphql
*
* URL 状态:?q=xxx &knowledgePoint=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3
*/
import { BookX } from "lucide-react";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useErrorBookItems, useTeacherErrorBookStats } from "@/lib/api";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
errorRateToColorClass,
formatErrorBookDate,
formatErrorCount,
formatErrorRate,
hasContent,
isHighErrorRate,
sortKnowledgePointsByErrorRate,
} from "@/features/teacher/error-book/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function ErrorBookListClient(): React.ReactElement {
const t = useTranslations("errorBook");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const q = searchParams.get("q") ?? "";
const knowledgePointFilter = searchParams.get("knowledgePoint") ?? "";
// ✅ 真实 schema 查询
const { data: itemsData, loading, error } = useErrorBookItems();
const { data: stats } = useTeacherErrorBookStats();
// 客户端二次筛选q + knowledgePoint
const filteredItems = useMemo(() => {
const items = itemsData?.items ?? [];
return items.filter((item) => {
if (
q &&
!item.knowledge_point_title.toLowerCase().includes(q.toLowerCase()) &&
!item.content.toLowerCase().includes(q.toLowerCase())
) {
return false;
}
if (
knowledgePointFilter &&
item.knowledge_point_id !== knowledgePointFilter
) {
return false;
}
return true;
});
}, [itemsData, q, knowledgePointFilter]);
// 知识点选项(从 stats 派生)
const knowledgePointOptions = useMemo(
() => sortKnowledgePointsByErrorRate(stats),
[stats],
);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/teacher/error-book?${params.toString()}`);
});
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<BookX className="size-6" />}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<select
value={knowledgePointFilter}
onChange={(e) => updateQuery("knowledgePoint", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.knowledgePointFilter")}
>
<option value="">{t("list.allKnowledgePoints")}</option>
{knowledgePointOptions.map((kp) => (
<option key={kp.knowledge_point_id} value={kp.knowledge_point_id}>
{kp.title}
</option>
))}
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
{stats ? <StatsSection stats={stats} /> : null}
<ErrorBookTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 统计卡片区(顶部 4 个卡片)。
*/
function StatsSection({
stats,
}: {
stats: NonNullable<ReturnType<typeof useTeacherErrorBookStats>["data"]>;
}): React.ReactElement {
const t = useTranslations("errorBook");
const kpList = sortKnowledgePointsByErrorRate(stats);
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard
label={t("stats.totalErrorQuestions")}
value={String(stats.total_error_questions)}
/>
<StatCard
label={t("stats.totalErrorCount")}
value={formatErrorCount(stats.total_error_count)}
/>
<StatCard
label={t("stats.recent7dErrors")}
value={formatErrorCount(stats.recent_7d_errors)}
/>
<StatCard
label={t("stats.knowledgePointCount")}
value={String(kpList.length)}
/>
</div>
);
}
/**
* 单个统计卡片。
*/
function StatCard({
label,
value,
}: {
label: string;
value: string;
}): React.ReactElement {
return (
<div className="rounded-xl border bg-card p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold">{value}</p>
</div>
);
}
/**
* 错题列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function ErrorBookTable({
items,
}: {
items: NonNullable<ReturnType<typeof useErrorBookItems>["data"]>["items"];
}): React.ReactElement {
const t = useTranslations("errorBook");
if (items.length === 0) return <></>;
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("list.colKnowledgePoint")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colErrorCount")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colLastErrorTime")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colContent")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<tr
key={`${item.student_id}-${item.question_id}`}
className="hover:bg-muted/30"
>
<td className="p-3">
<span className="font-medium">
{item.knowledge_point_title}
</span>
<p className="mt-1 text-xs text-muted-foreground">
{item.knowledge_point_id}
</p>
</td>
<td className="p-3">
<span
className={
isHighErrorRate(
item.error_count > 0 ? item.error_count / 10 : 0,
)
? "text-destructive"
: ""
}
>
{formatErrorCount(item.error_count)}
</span>
</td>
<td className="p-3 font-mono text-xs">
{formatErrorBookDate(item.last_error_time)}
</td>
<td className="p-3 text-xs text-muted-foreground">
{hasContent(item.content)
? item.content.slice(0, 80) +
(item.content.length > 80 ? "..." : "")
: t("list.noContent")}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 知识点错误率徽章(用于详情或扩展展示)。
*/
function ErrorRateBadge({ rate }: { rate: number }): React.ReactElement {
const label = formatErrorRate(rate);
const cls = errorRateToColorClass(rate);
return <span className={`text-xs font-medium ${cls}`}>{label}</span>;
}
// 防止未使用导入警告ErrorRateBadge 在未来扩展中使用)
void ErrorRateBadge;

View File

@@ -0,0 +1,149 @@
/**
* Error Book 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type {
ErrorBookEntry,
ErrorBookListItem,
TeacherErrorBookStats,
ErrorBookKpStats,
} from "@/lib/api";
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatErrorBookDate(
isoDate: string | null | undefined,
): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 格式化错误次数为展示字符串。
* 输入无效返回 "--"。
*/
export function formatErrorCount(count: number | null | undefined): string {
if (count == null || !Number.isFinite(count) || count < 0) return "--";
return `${count}`;
}
/**
* 格式化错误率0-1 浮点)为百分比字符串。
* 输入无效或越界返回 "--"。
*/
export function formatErrorRate(rate: number | null | undefined): string {
if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1)
return "--";
return `${Math.round(rate * 100)}%`;
}
/**
* 格式化掌握度0-1 浮点)为百分比字符串。
* 输入无效或越界返回 "--"。
*/
export function formatMastery(mastery: number | null | undefined): string {
if (
mastery == null ||
!Number.isFinite(mastery) ||
mastery < 0 ||
mastery > 1
)
return "--";
return `${Math.round(mastery * 100)}%`;
}
/**
* 根据错误率0-1返回 Tailwind 文本语义类名。
* - >= 0.6 → destructive高错误率
* - >= 0.3 → amber中错误率
* - 其他 → emerald低错误率
*/
export function errorRateToColorClass(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) {
return "text-muted-foreground";
}
if (rate >= 0.6) return "text-destructive";
if (rate >= 0.3) return "text-amber-600 dark:text-amber-400";
return "text-emerald-600 dark:text-emerald-400";
}
/**
* 根据掌握度0-1返回 Tailwind 文本语义类名。
* - < 0.4 → destructive低掌握
* - < 0.7 → amber中掌握
* - 其他 → emerald高掌握
*/
export function masteryToColorClass(mastery: number): string {
if (!Number.isFinite(mastery) || mastery < 0 || mastery > 1) {
return "text-muted-foreground";
}
if (mastery < 0.4) return "text-destructive";
if (mastery < 0.7) return "text-amber-600 dark:text-amber-400";
return "text-emerald-600 dark:text-emerald-400";
}
/**
* 判断知识点是否为高错误率(>= 0.6)。
*/
export function isHighErrorRate(rate: number): boolean {
return Number.isFinite(rate) && rate >= 0.6;
}
/**
* 判断知识点是否为低掌握度(< 0.4)。
*/
export function isLowMastery(mastery: number): boolean {
return Number.isFinite(mastery) && mastery >= 0 && mastery < 0.4;
}
/**
* 从 ErrorBookStats 中提取知识点统计列表(按错误率降序)。
* by_knowledge_point 运行时为数组schema 缺陷,详见 lib/api/error-book.ts
*/
export function sortKnowledgePointsByErrorRate(
stats: TeacherErrorBookStats | null | undefined,
): ErrorBookKpStats[] {
if (!stats?.by_knowledge_point) return [];
return [...stats.by_knowledge_point].sort(
(a, b) => b.error_rate - a.error_rate,
);
}
/**
* 从 ErrorBookEntry 提取列表项视图模型。
* 保留核心展示字段student_id 由调用方注入。
*/
export function toErrorBookListItem(
item: ErrorBookEntry,
studentId: string,
): ErrorBookListItem {
return {
question_id: item.question_id,
knowledge_point_id: item.knowledge_point_id,
knowledge_point_title: item.knowledge_point_title,
error_count: item.error_count,
last_error_time: item.last_error_time,
content: item.content,
student_id: studentId,
};
}
/**
* 判断错题条目是否有内容。
*/
export function hasContent(content: string | null | undefined): boolean {
return Boolean(content && content.trim().length > 0);
}

View File

@@ -0,0 +1,245 @@
"use client";
/**
* Analytics domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域学情分析模块)
*
* 契约状态:混合(🟡 部分真实 + 部分契约待补)
* - learningTrend: ✅ 真实schema 已就绪LearningTrend无参数
* - studentWeakness: ✅ 真实schema 已就绪StudentWeakness无参数
* - analyticsOverview: ❌ schema 无此聚合根字段 → MSW 兜底(@contract-pending
* - studentAnalytics(studentId): ❌ schema 无此根字段 → MSW 兜底(@contract-pending
*
* Schema 缺陷处理data-ana 子图):
* - LearningTrend.points 在 schema 中是单数 TrendPoint 类型(应为列表)
* - StudentWeakness.weak_points 在 schema 中是单数 WeakPoint 类型(应为列表)
* - lib/api 层 TypeScript 类型按业务语义定义为数组MSW 返回数组形状
*
* 契约工单docs/architecture/issues/contracts/data-ana_contract.md#analytics
* 关联ARCHITECTURE.md §5.3 契约纪律 / §5.4 MSW 兜底 / §5.5 后端已就绪查询 / §9.1 / §11.4
*/
import type { FetchPolicy } from "@apollo/client";
import { useWidgetQuery } from "../useWidgetQuery";
import {
GET_ANALYTICS_OVERVIEW_DOC,
GET_LEARNING_TREND_DOC,
GET_STUDENT_ANALYTICS_DOC,
GET_STUDENT_WEAKNESS_DOC,
} from "./operations/analytics.graphql";
import type { TrendPoint, WeakPoint } from "./dashboard";
import type { UseQueryResult } from "./types";
// ===== 数据类型(对齐 data-ana 子图snake_case=====
// TrendPoint / WeakPoint 复用 dashboard.ts 的定义(同 schema 字段,避免 barrel 导出冲突)
/** 学习趋势(对齐 schema LearningTrendpoints 运行时按列表处理) */
export interface LearningTrend {
student_id: string;
points: TrendPoint[];
}
/** 学生薄弱点(对齐 schema StudentWeaknessweak_points 运行时按列表处理) */
export interface StudentWeakness {
student_id: string;
weak_points: WeakPoint[];
}
/** 总览趋势点(@contract-pending 扩展,含 avg_score */
export interface OverviewTrendPoint {
date: string;
avg_score: number;
}
/** 总览薄弱知识点(@contract-pending 扩展) */
export interface OverviewWeakPoint {
knowledge_point_id: string;
title: string;
error_count: number;
mastery: number;
}
/** 班级学情分解(@contract-pending 扩展) */
export interface AnalyticsClassBreakdown {
class_id: string;
class_name: string;
avg_score: number;
student_count: number;
at_risk_count: number;
}
/** 学情分析总览(@contract-pending 聚合数据) */
export interface AnalyticsOverview {
total_students: number;
avg_score: number;
avg_mastery: number;
at_risk_count: number;
weak_points: OverviewWeakPoint[];
trends: OverviewTrendPoint[];
class_breakdown: AnalyticsClassBreakdown[];
}
/** 学生近期考试(@contract-pending 扩展) */
export interface StudentRecentExam {
exam_id: string;
exam_title: string;
score: number;
total_score: number;
date: string;
}
/** 单生学情分析详情(@contract-pending 聚合数据) */
export interface StudentAnalytics {
student_id: string;
student_name: string;
student_no: string;
class_id: string;
class_name: string;
avg_score: number;
class_rank: number;
total_students: number;
weak_points: WeakPoint[];
trends: TrendPoint[];
recent_exams: StudentRecentExam[];
}
// ===== 响应类型 =====
/** learningTrend 查询响应 */
interface LearningTrendResponse {
learningTrend: LearningTrend | null;
}
/** studentWeakness 查询响应 */
interface StudentWeaknessResponse {
studentWeakness: StudentWeakness | null;
}
/** analyticsOverview 查询响应(@contract-pending */
interface AnalyticsOverviewResponse {
analyticsOverview: AnalyticsOverview | null;
}
/** studentAnalytics 查询响应(@contract-pending */
interface StudentAnalyticsResponse {
studentAnalytics: StudentAnalytics | null;
}
// ===== 查询选项 =====
export interface AnalyticsQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 查询学习趋势(✅ 真实 schemalearningTrend
*
* 关联ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1
*/
export function useLearningTrend(
options?: AnalyticsQueryOptions,
): UseQueryResult<LearningTrend | null> {
const result = useWidgetQuery<LearningTrendResponse>(
GET_LEARNING_TREND_DOC,
{},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.learningTrend ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询学生薄弱知识点(✅ 真实 schemastudentWeakness
*
* 关联ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1
*/
export function useStudentWeakness(
options?: AnalyticsQueryOptions,
): UseQueryResult<StudentWeakness | null> {
const result = useWidgetQuery<StudentWeaknessResponse>(
GET_STUDENT_WEAKNESS_DOC,
{},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.studentWeakness ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询学情分析总览(@contract-pendingMSW 兜底)。
*
* schema 无 analyticsOverview 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/analytics 总览页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 总览页 / §11.4 契约工单
*/
export function useAnalyticsOverview(
options?: AnalyticsQueryOptions,
): UseQueryResult<AnalyticsOverview | null> {
const result = useWidgetQuery<AnalyticsOverviewResponse>(
GET_ANALYTICS_OVERVIEW_DOC,
{},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.analyticsOverview ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 按 studentId 查询单生学情分析(@contract-pendingMSW 兜底)。
*
* schema 无 studentAnalytics(studentId) 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/analytics/[studentId] 学生详情页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 详情页 / §11.4 契约工单
*/
export function useStudentAnalytics(
studentId: string,
options?: AnalyticsQueryOptions,
): UseQueryResult<StudentAnalytics | null> {
const result = useWidgetQuery<
StudentAnalyticsResponse,
{ studentId: string }
>(
GET_STUDENT_ANALYTICS_DOC,
{ studentId },
{
...options,
enabled: options?.enabled ?? studentId.length > 0,
},
);
return {
data: result.data?.studentAnalytics ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -0,0 +1,155 @@
"use client";
/**
* Diagnostic domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域诊断报告模块)
*
* 契约状态:混合
* - diagnosticReports: ✅ 真实schema 已就绪,[DiagnosticReportList!]!,无参数)
* - diagnosticReport(classId): ❌ schema 无此字段 → MSW 兜底(@contract-pending
*
* Schema 缺陷处理data-ana 子图):
* - DiagnosticReportList.reports 在 schema 中是单数 DiagnosticReport 类型(应为列表)
* - lib/api 层 TypeScript 类型按业务语义定义为数组MSW 返回数组形状
*
* 契约工单docs/architecture/issues/contracts/data-ana_contract.md#diagnostic
* 关联ARCHITECTURE.md §5.3 契约纪律 / §5.4 MSW 兜底 / §9.1 / §11.4
*/
import type { FetchPolicy } from "@apollo/client";
import { useWidgetQuery } from "../useWidgetQuery";
import {
GET_DIAGNOSTIC_REPORT_DOC,
GET_DIAGNOSTIC_REPORTS_DOC,
} from "./operations/diagnostic.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(对齐 data-ana 子图snake_case=====
/** 诊断报告状态枚举 */
export type DiagnosticReportStatus =
"DRAFT" | "GENERATED" | "PUBLISHED" | "ARCHIVED";
/** 诊断报告基础信息(对齐 schema DiagnosticReport */
export interface DiagnosticReport {
report_id: string;
student_id: string;
report_type: string;
title: string;
summary: string;
generated_at: string;
status: string;
}
/** 班级诊断报告详情(@contract-pending 扩展字段MSW 返回) */
export interface DiagnosticReportDetail extends DiagnosticReport {
class_id: string;
class_name: string;
student_count: number;
avg_score: number;
weak_points: DiagnosticWeakPoint[];
recommendations: string[];
}
/** 诊断薄弱知识点 */
export interface DiagnosticWeakPoint {
knowledge_point_id: string;
title: string;
mastery: number;
error_count: number;
}
/** 诊断报告列表项(展平后供页面使用,带上聚合来源 student_id */
export interface DiagnosticReportListItem extends DiagnosticReport {
student_id: string;
}
// ===== 响应类型 =====
/**
* diagnosticReports 查询响应。
* schema: diagnosticReports: [DiagnosticReportList!]!
* DiagnosticReportList.reports 在 schema 中是单数运行时按列表处理MSW 返回数组)
*/
interface DiagnosticReportsResponse {
diagnosticReports: Array<{
student_id: string;
reports: DiagnosticReport[];
total: number;
}>;
}
/** diagnosticReport(classId) 查询响应(@contract-pending */
interface DiagnosticReportResponse {
diagnosticReport: DiagnosticReportDetail | null;
}
// ===== 查询选项 =====
export interface DiagnosticQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 查询诊断报告列表(✅ 真实 schemadiagnosticReports
*
* diagnosticReports 返回 [DiagnosticReportList!]!(按 student 聚合),
* 本 hook 展平为 DiagnosticReportListItem[] 供列表页渲染。
*
* 关联ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 列表页
*/
export function useDiagnosticReports(
options?: DiagnosticQueryOptions,
): UseQueryResult<{ items: DiagnosticReportListItem[]; total: number }> {
const result = useWidgetQuery<DiagnosticReportsResponse>(
GET_DIAGNOSTIC_REPORTS_DOC,
{},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
const raw = result.data?.diagnosticReports ?? [];
const items: DiagnosticReportListItem[] = raw.flatMap((group) =>
group.reports.map((r) => ({ ...r, student_id: group.student_id })),
);
const total = raw.reduce((sum, g) => sum + g.total, 0);
return {
data: { items, total },
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 按 classId 查询班级诊断报告详情(@contract-pendingMSW 兜底)。
*
* schema 无 diagnosticReport(classId) 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/diagnostic/class/[classId] 班级诊断详情页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 详情页 / §11.4 契约工单
*/
export function useDiagnosticReport(
classId: string,
options?: DiagnosticQueryOptions,
): UseQueryResult<DiagnosticReportDetail | null> {
const result = useWidgetQuery<DiagnosticReportResponse, { classId: string }>(
GET_DIAGNOSTIC_REPORT_DOC,
{ classId },
{
...options,
enabled: options?.enabled ?? classId.length > 0,
},
);
return {
data: result.data?.diagnosticReport ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -0,0 +1,151 @@
"use client";
/**
* Error Book domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域错题本模块)
*
* 契约状态:✅ 全真实schema 已就绪)
* - errorBookItems: [ErrorBookList!]!(无参数)
* - errorBookStats: ErrorBookStats无参数复用 dashboard.graphql 的 GET_ERROR_BOOK_STATS_DOC
*
* Schema 缺陷处理data-ana 子图):
* - ErrorBookList.items 在 schema 中是单数 ErrorBookItem 类型(应为列表)
* - ErrorBookStats.by_knowledge_point 在 schema 中是单数 KnowledgePointErrorStats应为列表
* - lib/api 层 TypeScript 类型按业务语义定义为数组MSW 返回数组形状
* - 后端补齐 schema 后类型自然对齐
*
* 契约工单docs/architecture/issues/contracts/data-ana_contract.md#error-book
* 关联ARCHITECTURE.md §5.3 契约纪律 / §5.5 后端已就绪查询 / §9.1 / §11.4
*/
import type { FetchPolicy } from "@apollo/client";
import { useWidgetQuery } from "../useWidgetQuery";
import { GET_ERROR_BOOK_STATS_DOC } from "./operations/dashboard.graphql";
import { GET_ERROR_BOOK_ITEMS_DOC } from "./operations/error-book.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(对齐 data-ana 子图snake_case=====
// 注:类型名加 Teacher 前缀避免与 dashboard.ts / student.ts 的同名类型 barrel 导出冲突
/** 错题条目(对齐 schema ErrorBookItem教师域视图 */
export interface ErrorBookEntry {
question_id: string;
knowledge_point_id: string;
knowledge_point_title: string;
error_count: number;
last_error_time: string;
content: string;
}
/** 知识点错误统计(对齐 schema KnowledgePointErrorStats教师域视图 */
export interface ErrorBookKpStats {
knowledge_point_id: string;
title: string;
error_count: number;
question_count: number;
error_rate: number;
}
/** 错题本统计(对齐 schema ErrorBookStats教师域视图by_knowledge_point 运行时按列表处理) */
export interface TeacherErrorBookStats {
student_id: string;
total_error_questions: number;
total_error_count: number;
by_knowledge_point: ErrorBookKpStats[];
recent_7d_errors: number;
}
/** 错题本列表项(展平后供页面使用,每条带上 student_id */
export interface ErrorBookListItem extends ErrorBookEntry {
student_id: string;
}
// ===== 响应类型 =====
/**
* errorBookItems 查询响应。
* schema: errorBookItems: [ErrorBookList!]!
* ErrorBookList.items 在 schema 中是单数运行时按列表处理MSW 返回数组)
*/
interface ErrorBookItemsResponse {
errorBookItems: Array<{
student_id: string;
items: ErrorBookEntry[];
total: number;
}>;
}
/** errorBookStats 查询响应 */
interface ErrorBookStatsResponse {
errorBookStats: TeacherErrorBookStats | null;
}
// ===== 查询选项 =====
export interface ErrorBookQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 查询错题本条目列表(✅ 真实 schemaerrorBookItems
*
* errorBookItems 返回 [ErrorBookList!]!(按 student 聚合),
* 本 hook 展平为 ErrorBookListItem[],每条带上 student_id 便于列表渲染。
*
* 关联ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 列表页
*/
export function useErrorBookItems(
options?: ErrorBookQueryOptions,
): UseQueryResult<{ items: ErrorBookListItem[]; total: number }> {
const result = useWidgetQuery<ErrorBookItemsResponse>(
GET_ERROR_BOOK_ITEMS_DOC,
{},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
const raw = result.data?.errorBookItems ?? [];
const items: ErrorBookListItem[] = raw.flatMap((group) =>
group.items.map((item) => ({ ...item, student_id: group.student_id })),
);
const total = raw.reduce((sum, g) => sum + g.total, 0);
return {
data: { items, total },
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询错题本统计(✅ 真实 schemaerrorBookStats
*
* 复用 dashboard.graphql.ts 的 GET_ERROR_BOOK_STATS_DOC同一 schema 字段)。
* 用于错题本列表页顶部统计卡片区。
*
* 关联ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 列表页
*/
export function useTeacherErrorBookStats(
options?: ErrorBookQueryOptions,
): UseQueryResult<TeacherErrorBookStats | null> {
const result = useWidgetQuery<ErrorBookStatsResponse>(
GET_ERROR_BOOK_STATS_DOC,
{},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.errorBookStats ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -26,5 +26,8 @@ export * from "./students";
export * from "./student"; export * from "./student";
export * from "./course-plans"; export * from "./course-plans";
export * from "./elective"; export * from "./elective";
export * from "./error-book";
export * from "./diagnostic";
export * from "./analytics";
export * from "./parent"; export * from "./parent";
export * from "./admin"; export * from "./admin";

View File

@@ -0,0 +1,118 @@
// Analytics domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - learningTrend✅ combined-schema 中真实存在learningTrend: LearningTrend
// - studentWeakness✅ combined-schema 中真实存在studentWeakness: StudentWeakness
// - analyticsOverview❌ schema 无此聚合根字段 → MSW 兜底(@contract-pending
// - studentAnalytics(studentId):❌ schema 无此根字段 → MSW 兜底(@contract-pending
//
// Schema 缺陷说明data-ana 子图):
// - LearningTrend.points 在 schema 中是单数 TrendPoint 类型(应为列表)
// - StudentWeakness.weak_points 在 schema 中是单数 WeakPoint 类型(应为列表)
// - operations 按单数对象语法查询lib/api 层运行时按列表处理MSW 返回数组)
//
// 契约工单docs/architecture/issues/contracts/data-ana_contract.md#analytics
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 真实查询learningTrend无参数──────────────────────────
// schema: learningTrend: LearningTrend
// LearningTrend { student_id, points: TrendPoint }
// 字段全部 snake_case 对齐 data-ana 子图
export const GET_LEARNING_TREND_DOC = gql`
query GetLearningTrend {
learningTrend {
student_id
points {
date
score
}
}
}
`;
// ── 真实查询studentWeakness无参数────────────────────────
// schema: studentWeakness: StudentWeakness
// StudentWeakness { student_id, weak_points: WeakPoint }
// 字段全部 snake_case 对齐 data-ana 子图
export const GET_STUDENT_WEAKNESS_DOC = gql`
query GetStudentWeakness {
studentWeakness {
student_id
weak_points {
knowledge_point_id
title
mastery
error_count
}
}
}
`;
// ── 假契约查询(@contract-pending─────────────────────────────
// 学情分析总览schema 无 analyticsOverview 根字段 → MSW 兜底
// 用于 /shell/teacher/analytics 总览页(聚合班级学情数据)
// 契约工单data-ana_contract.md#analytics-overview
export const GET_ANALYTICS_OVERVIEW_DOC = gql`
query GetAnalyticsOverview {
analyticsOverview {
total_students
avg_score
avg_mastery
at_risk_count
weak_points {
knowledge_point_id
title
error_count
mastery
}
trends {
date
avg_score
}
class_breakdown {
class_id
class_name
avg_score
student_count
at_risk_count
}
}
}
`;
// ── 假契约查询(@contract-pending─────────────────────────────
// 单生学情分析schema 无 studentAnalytics(studentId) 根字段 → MSW 兜底
// 用于 /shell/teacher/analytics/[studentId] 学生详情页
// 契约工单data-ana_contract.md#student-analytics
export const GET_STUDENT_ANALYTICS_DOC = gql`
query GetStudentAnalytics($studentId: ID!) {
studentAnalytics(studentId: $studentId) {
student_id
student_name
student_no
class_id
class_name
avg_score
class_rank
total_students
weak_points {
knowledge_point_id
title
mastery
error_count
}
trends {
date
score
}
recent_exams {
exam_id
exam_title
score
total_score
date
}
}
}
`;

View File

@@ -0,0 +1,64 @@
// Diagnostic domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - diagnosticReports✅ combined-schema 中真实存在diagnosticReports: [DiagnosticReportList!]!
// - diagnosticReport(classId):❌ schema 无此根字段 → MSW 兜底(@contract-pending
//
// Schema 缺陷说明data-ana 子图):
// - DiagnosticReportList.reports 在 schema 中是单数 DiagnosticReport 类型(应为列表)
// - operations 按单数对象语法查询lib/api 层运行时按列表处理MSW 返回数组)
//
// 契约工单docs/architecture/issues/contracts/data-ana_contract.md#diagnostic
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 真实查询diagnosticReports无参数──────────────────────
// schema: diagnosticReports: [DiagnosticReportList!]!
// 每个 DiagnosticReportList { student_id, reports: DiagnosticReport, total }
// 字段全部 snake_case 对齐 data-ana 子图
export const GET_DIAGNOSTIC_REPORTS_DOC = gql`
query GetDiagnosticReports {
diagnosticReports {
student_id
reports {
report_id
student_id
report_type
title
summary
generated_at
status
}
total
}
}
`;
// ── 假契约查询(@contract-pending─────────────────────────────
// 按 classId 单查班级诊断报告schema 无 diagnosticReport(classId) 根字段
// 页面通过 MSW 兜底获取,后端补齐后切换 fetcher 指向真实查询
// 契约工单data-ana_contract.md#diagnostic-class-detail
export const GET_DIAGNOSTIC_REPORT_DOC = gql`
query GetDiagnosticReport($classId: ID!) {
diagnosticReport(classId: $classId) {
report_id
student_id
report_type
title
summary
generated_at
status
class_id
class_name
student_count
avg_score
weak_points {
knowledge_point_id
title
mastery
error_count
}
recommendations
}
}
`;

View File

@@ -0,0 +1,38 @@
// Error Book domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - errorBookItems✅ combined-schema 中真实存在errorBookItems: [ErrorBookList!]!
// - errorBookStats✅ 真实存在,但已由 dashboard.graphql.ts 定义GET_ERROR_BOOK_STATS_DOC
// → 此处不重复定义error-book.ts 直接从 dashboard.graphql 导入复用
//
// Schema 缺陷说明data-ana 子图):
// - ErrorBookList.items 在 schema 中是单数 ErrorBookItem 类型(应为列表)
// - operations 按单数对象语法查询lib/api 层运行时按列表处理MSW 返回数组)
//
// 契约工单docs/architecture/issues/contracts/data-ana_contract.md#error-book
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 真实查询errorBookItems无参数─────────────────────────
// schema: errorBookItems: [ErrorBookList!]!
// 每个 ErrorBookList { student_id, items: ErrorBookItem, total }
// 字段全部 snake_case 对齐 data-ana 子图
export const GET_ERROR_BOOK_ITEMS_DOC = gql`
query GetErrorBookItems {
errorBookItems {
student_id
items {
question_id
knowledge_point_id
knowledge_point_title
error_count
last_error_time
content
}
total
}
}
`;
// 注GET_ERROR_BOOK_STATS_DOC 已在 dashboard.graphql.ts 定义并 export
// 通过 operations/index.ts 的 `export *` 暴露error-book.ts 直接 import 复用。

View File

@@ -16,5 +16,8 @@ export * from "./students.graphql";
export * from "./student.graphql"; export * from "./student.graphql";
export * from "./course-plans.graphql"; export * from "./course-plans.graphql";
export * from "./elective.graphql"; export * from "./elective.graphql";
export * from "./error-book.graphql";
export * from "./diagnostic.graphql";
export * from "./analytics.graphql";
export * from "./parent.graphql"; export * from "./parent.graphql";
export * from "./admin.graphql"; export * from "./admin.graphql";

View File

@@ -704,7 +704,61 @@
} }
}, },
"analytics": { "analytics": {
"title": "Analytics" "title": "Analytics",
"overview": {
"title": "Analytics Overview",
"description": "View class-wide learning analytics, weak points and trends",
"mswNotice": "Analytics overview contract is @contract-pending, currently backed by MSW. Will switch to real data once backend aggregate field is ready.",
"totalStudents": "Total Students",
"avgScore": "Avg Score",
"avgMastery": "Avg Mastery",
"atRiskCount": "At-Risk Students",
"sectionWeakPoints": "Weak Points",
"colKpTitle": "Knowledge Point",
"colErrorCount": "Error Count",
"colMastery": "Mastery",
"sectionTrends": "Recent Trends",
"sectionClassBreakdown": "Class Breakdown",
"colClassName": "Class",
"colStudentCount": "Students",
"colAvgScore": "Avg Score",
"colAtRiskCount": "At-Risk",
"colActions": "Actions",
"viewStudentAnalytics": "View Student Analytics"
},
"student": {
"title": "Student Analytics",
"notFound": "No analytics data found for this student",
"backToOverview": "Back to Overview",
"mswNotice": "Student analytics contract is @contract-pending, currently backed by MSW. Will switch to real data once backend query field is ready.",
"subtitle": "{className} · No. {studentNo}",
"sectionBasic": "Basic Info",
"fieldName": "Name",
"fieldStudentNo": "Student No.",
"fieldClassName": "Class",
"fieldAvgScore": "Avg Score",
"fieldClassRank": "Class Rank",
"fieldTotalStudents": "Total Students",
"sectionWeakPoints": "Weak Points",
"noWeakPoints": "No weak points data",
"colKpTitle": "Knowledge Point",
"colMastery": "Mastery",
"colErrorCount": "Error Count",
"sectionTrends": "Learning Trends",
"noTrends": "No trend data",
"sectionRecentExams": "Recent Exams",
"noRecentExams": "No recent exam records",
"colExamTitle": "Exam",
"colScore": "Score",
"colScoreRate": "Score Rate",
"colLevel": "Level",
"colExamDate": "Exam Date"
},
"error": {
"title": "Analytics page error",
"unknown": "An unknown error occurred in the analytics module",
"retry": "Retry"
}
}, },
"knowledgeGraph": { "knowledgeGraph": {
"title": "Knowledge Graph" "title": "Knowledge Graph"
@@ -1085,10 +1139,83 @@
} }
}, },
"diagnostic": { "diagnostic": {
"title": "Diagnostic" "title": "Diagnostic",
"list": {
"title": "Diagnostic Reports",
"description": "View student and class diagnostic reports",
"searchPlaceholder": "Search report title or summary",
"statusFilter": "Filter by status",
"statusAll": "All statuses",
"statusDraft": "Draft",
"statusGenerated": "Generated",
"statusPublished": "Published",
"statusArchived": "Archived",
"total": "{count} total",
"publishedCount": "{count} published",
"colTitle": "Title",
"colType": "Type",
"colStudentId": "Student ID",
"colStatus": "Status",
"colGeneratedAt": "Generated At",
"colActions": "Actions",
"viewClassDetail": "View Class Detail"
},
"detail": {
"title": "Class Diagnostic Report",
"notFound": "No diagnostic report found for this class",
"backToList": "Back to List",
"mswNotice": "Class diagnostic detail contract is @contract-pending, currently backed by MSW. Will switch to real data once backend single-query field is ready.",
"generatedAtPrefix": "Generated at {date}",
"sectionBasic": "Basic Info",
"fieldTitle": "Title",
"fieldReportType": "Report Type",
"fieldStatus": "Status",
"fieldClassName": "Class",
"fieldStudentCount": "Student Count",
"fieldAvgScore": "Avg Score",
"fieldSummary": "Summary",
"noSummary": "No summary",
"fieldGeneratedAt": "Generated At",
"sectionWeakPoints": "Weak Points",
"noWeakPoints": "No weak points",
"colKpTitle": "Knowledge Point",
"colMastery": "Mastery",
"colErrorCount": "Error Count",
"sectionRecommendations": "Recommendations",
"noRecommendations": "No recommendations"
},
"error": {
"title": "Diagnostic page error",
"unknown": "An unknown error occurred in the diagnostic module",
"retry": "Retry"
}
}, },
"errorBook": { "errorBook": {
"title": "Error Book" "title": "Error Book",
"list": {
"title": "Error Book",
"description": "View student error records and knowledge point stats",
"searchPlaceholder": "Search knowledge point or content",
"knowledgePointFilter": "Filter by knowledge point",
"allKnowledgePoints": "All knowledge points",
"total": "{count} total",
"colKnowledgePoint": "Knowledge Point",
"colErrorCount": "Error Count",
"colLastErrorTime": "Last Error Time",
"colContent": "Content",
"noContent": "No content"
},
"stats": {
"totalErrorQuestions": "Total Error Questions",
"totalErrorCount": "Total Error Count",
"recent7dErrors": "Recent 7d Errors",
"knowledgePointCount": "Knowledge Points"
},
"error": {
"title": "Error Book page error",
"unknown": "An unknown error occurred in the error book module",
"retry": "Retry"
}
}, },
"practice": { "practice": {
"title": "Practice" "title": "Practice"

View File

@@ -704,7 +704,61 @@
} }
}, },
"analytics": { "analytics": {
"title": "学情分析" "title": "学情分析",
"overview": {
"title": "学情分析总览",
"description": "查看班级整体学情、薄弱知识点与趋势",
"mswNotice": "学情总览契约为 @contract-pending当前通过 MSW 兜底。后端补齐聚合根字段后将切换为真实数据。",
"totalStudents": "学生总数",
"avgScore": "平均分",
"avgMastery": "平均掌握度",
"atRiskCount": "预警学生数",
"sectionWeakPoints": "薄弱知识点",
"colKpTitle": "知识点",
"colErrorCount": "错误次数",
"colMastery": "掌握度",
"sectionTrends": "近期趋势",
"sectionClassBreakdown": "班级分解",
"colClassName": "班级",
"colStudentCount": "学生数",
"colAvgScore": "平均分",
"colAtRiskCount": "预警数",
"colActions": "操作",
"viewStudentAnalytics": "查看学生分析"
},
"student": {
"title": "学生学情分析",
"notFound": "未找到该学生的学情数据",
"backToOverview": "返回总览",
"mswNotice": "单生学情分析契约为 @contract-pending当前通过 MSW 兜底。后端补齐查询字段后将切换为真实数据。",
"subtitle": "{className} · 学号 {studentNo}",
"sectionBasic": "基本信息",
"fieldName": "姓名",
"fieldStudentNo": "学号",
"fieldClassName": "班级",
"fieldAvgScore": "平均分",
"fieldClassRank": "班级排名",
"fieldTotalStudents": "班级总人数",
"sectionWeakPoints": "薄弱知识点",
"noWeakPoints": "暂无薄弱知识点数据",
"colKpTitle": "知识点",
"colMastery": "掌握度",
"colErrorCount": "错误次数",
"sectionTrends": "学习趋势",
"noTrends": "暂无趋势数据",
"sectionRecentExams": "近期考试",
"noRecentExams": "暂无近期考试记录",
"colExamTitle": "考试",
"colScore": "得分",
"colScoreRate": "得分率",
"colLevel": "等级",
"colExamDate": "考试日期"
},
"error": {
"title": "学情分析页面出错了",
"unknown": "学情分析模块发生未知错误",
"retry": "重试"
}
}, },
"knowledgeGraph": { "knowledgeGraph": {
"title": "知识图谱" "title": "知识图谱"
@@ -1085,10 +1139,83 @@
} }
}, },
"diagnostic": { "diagnostic": {
"title": "诊断报告" "title": "诊断报告",
"list": {
"title": "诊断报告",
"description": "查看学生与班级的诊断报告",
"searchPlaceholder": "搜索报告标题或摘要",
"statusFilter": "按状态筛选",
"statusAll": "全部状态",
"statusDraft": "草稿",
"statusGenerated": "已生成",
"statusPublished": "已发布",
"statusArchived": "已归档",
"total": "共 {count} 条",
"publishedCount": "已发布 {count} 条",
"colTitle": "标题",
"colType": "类型",
"colStudentId": "学生 ID",
"colStatus": "状态",
"colGeneratedAt": "生成时间",
"colActions": "操作",
"viewClassDetail": "查看班级详情"
},
"detail": {
"title": "班级诊断报告",
"notFound": "未找到该班级的诊断报告",
"backToList": "返回列表",
"mswNotice": "班级诊断详情契约为 @contract-pending当前通过 MSW 兜底。后端补齐单查字段后将切换为真实数据。",
"generatedAtPrefix": "生成于 {date}",
"sectionBasic": "基本信息",
"fieldTitle": "标题",
"fieldReportType": "报告类型",
"fieldStatus": "状态",
"fieldClassName": "班级",
"fieldStudentCount": "学生数",
"fieldAvgScore": "平均分",
"fieldSummary": "摘要",
"noSummary": "暂无摘要",
"fieldGeneratedAt": "生成时间",
"sectionWeakPoints": "薄弱知识点",
"noWeakPoints": "暂无薄弱知识点",
"colKpTitle": "知识点",
"colMastery": "掌握度",
"colErrorCount": "错误次数",
"sectionRecommendations": "教学建议",
"noRecommendations": "暂无教学建议"
},
"error": {
"title": "诊断报告页面出错了",
"unknown": "诊断报告模块发生未知错误",
"retry": "重试"
}
}, },
"errorBook": { "errorBook": {
"title": "错题本" "title": "错题本",
"list": {
"title": "错题本",
"description": "查看学生的错题记录与知识点统计",
"searchPlaceholder": "搜索知识点或内容",
"knowledgePointFilter": "按知识点筛选",
"allKnowledgePoints": "全部知识点",
"total": "共 {count} 条",
"colKnowledgePoint": "知识点",
"colErrorCount": "错误次数",
"colLastErrorTime": "最近错误时间",
"colContent": "内容",
"noContent": "无内容"
},
"stats": {
"totalErrorQuestions": "错题总数",
"totalErrorCount": "错误次数",
"recent7dErrors": "近 7 天错误",
"knowledgePointCount": "知识点数"
},
"error": {
"title": "错题本页面出错了",
"unknown": "错题本模块发生未知错误",
"retry": "重试"
}
}, },
"practice": { "practice": {
"title": "练习分析" "title": "练习分析"

View File

@@ -3475,6 +3475,317 @@ export function graphqlResponse(
return { data: { updateElective: { id: eleId } } }; return { data: { updateElective: { id: eleId } } };
} }
// ── Error Book 域(教师域 B2 迁移,✅ 真实查询 MSW 兜底)──
// schema: errorBookItems: [ErrorBookList!]!(无参数)
// GetErrorBookItems返回 mock 错题本列表(按 student 聚合items 为数组)
case "GetErrorBookItems": {
return {
data: {
errorBookItems: [
{
student_id: "stu-001",
items: [
{
question_id: "q-001",
knowledge_point_id: "kp-001",
knowledge_point_title: "二次函数",
error_count: 8,
last_error_time: "2026-07-20T10:30:00Z",
content: "已知 f(x)=2x²+3x-1求 f(2) 的值。",
},
{
question_id: "q-002",
knowledge_point_id: "kp-002",
knowledge_point_title: "概率统计",
error_count: 5,
last_error_time: "2026-07-18T14:00:00Z",
content: "从 5 男 3 女中选 3 人,求恰好 2 男 1 女的概率。",
},
],
total: 2,
},
{
student_id: "stu-002",
items: [
{
question_id: "q-003",
knowledge_point_id: "kp-001",
knowledge_point_title: "二次函数",
error_count: 3,
last_error_time: "2026-07-19T09:00:00Z",
content: "求 y=x²-4x+3 的顶点坐标。",
},
],
total: 1,
},
],
},
};
}
// ── Diagnostic 域(教师域 B2 迁移)──
// GetDiagnosticReports✅ 真实查询 diagnosticReports无参数MSW 兜底
case "GetDiagnosticReports": {
return {
data: {
diagnosticReports: [
{
student_id: "stu-001",
reports: [
{
report_id: "rpt-001",
student_id: "stu-001",
report_type: "WEEKLY",
title: "第一周学情诊断",
summary:
"本周整体表现良好,但二次函数掌握不足,建议加强练习。",
generated_at: "2026-07-22T08:00:00Z",
status: "PUBLISHED",
},
{
report_id: "rpt-002",
student_id: "stu-001",
report_type: "UNIT",
title: "函数单元诊断",
summary: "函数概念理解到位,但复合函数求值需加强。",
generated_at: "2026-07-15T08:00:00Z",
status: "PUBLISHED",
},
],
total: 2,
},
{
student_id: "stu-002",
reports: [
{
report_id: "rpt-003",
student_id: "stu-002",
report_type: "MONTHLY",
title: "七月学情月报",
summary: "本月进步明显,概率统计仍有薄弱点。",
generated_at: "2026-07-20T08:00:00Z",
status: "GENERATED",
},
],
total: 1,
},
],
},
};
}
// GetDiagnosticReport($classId)@contract-pending 班级诊断详情MSW 兜底
case "GetDiagnosticReport": {
const classId = (variables?.classId as string | undefined) ?? "";
return {
data: {
diagnosticReport: {
report_id: "rpt-cls-001",
student_id: classId,
report_type: "UNIT",
title: "班级函数单元诊断报告",
summary:
"本班函数单元整体掌握度 72%,二次函数薄弱,建议增加针对性练习。",
generated_at: "2026-07-21T10:00:00Z",
status: "PUBLISHED",
class_id: classId,
class_name: "高三(1)班",
student_count: 38,
avg_score: 72.5,
weak_points: [
{
knowledge_point_id: "kp-001",
title: "二次函数",
mastery: 0.45,
error_count: 15,
},
{
knowledge_point_id: "kp-002",
title: "概率统计",
mastery: 0.62,
error_count: 8,
},
],
recommendations: [
"针对二次函数安排 2 课时专项复习",
"组织小组讨论解决概率统计常见误区",
"每周安排一次函数单元小测跟踪进度",
],
},
},
};
}
// ── Analytics 域(教师域 B2 迁移)──
// GetLearningTrend✅ 真实查询 learningTrend无参数MSW 兜底
case "GetLearningTrend": {
return {
data: {
learningTrend: {
student_id: "stu-001",
points: [
{ date: "2026-07-01", score: 75 },
{ date: "2026-07-08", score: 78 },
{ date: "2026-07-15", score: 82 },
{ date: "2026-07-22", score: 85 },
],
},
},
};
}
// GetStudentWeakness✅ 真实查询 studentWeakness无参数MSW 兜底
case "GetStudentWeakness": {
return {
data: {
studentWeakness: {
student_id: "stu-001",
weak_points: [
{
knowledge_point_id: "kp-001",
title: "二次函数",
mastery: 0.45,
error_count: 8,
},
{
knowledge_point_id: "kp-002",
title: "概率统计",
mastery: 0.62,
error_count: 5,
},
],
},
},
};
}
// GetAnalyticsOverview@contract-pending 总览聚合MSW 兜底
case "GetAnalyticsOverview": {
return {
data: {
analyticsOverview: {
total_students: 142,
avg_score: 78.5,
avg_mastery: 0.72,
at_risk_count: 12,
weak_points: [
{
knowledge_point_id: "kp-001",
title: "二次函数",
error_count: 45,
mastery: 0.45,
},
{
knowledge_point_id: "kp-002",
title: "概率统计",
error_count: 32,
mastery: 0.62,
},
{
knowledge_point_id: "kp-003",
title: "三角函数",
error_count: 28,
mastery: 0.68,
},
],
trends: [
{ date: "2026-07-01", avg_score: 75.2 },
{ date: "2026-07-08", avg_score: 76.8 },
{ date: "2026-07-15", avg_score: 77.5 },
{ date: "2026-07-22", avg_score: 78.5 },
],
class_breakdown: [
{
class_id: "cls-001",
class_name: "高三(1)班",
avg_score: 82.3,
student_count: 38,
at_risk_count: 3,
},
{
class_id: "cls-002",
class_name: "高三(2)班",
avg_score: 75.1,
student_count: 40,
at_risk_count: 5,
},
{
class_id: "cls-003",
class_name: "高三(3)班",
avg_score: 78.0,
student_count: 36,
at_risk_count: 4,
},
],
},
},
};
}
// GetStudentAnalytics($studentId)@contract-pending 单生分析MSW 兜底
case "GetStudentAnalytics": {
const studentId = (variables?.studentId as string | undefined) ?? "";
return {
data: {
studentAnalytics: {
student_id: studentId,
student_name: "张明",
student_no: "202601001",
class_id: "cls-001",
class_name: "高三(1)班",
avg_score: 85.2,
class_rank: 5,
total_students: 38,
weak_points: [
{
knowledge_point_id: "kp-001",
title: "二次函数",
mastery: 0.45,
error_count: 8,
},
{
knowledge_point_id: "kp-002",
title: "概率统计",
mastery: 0.62,
error_count: 5,
},
{
knowledge_point_id: "kp-004",
title: "导数应用",
mastery: 0.71,
error_count: 3,
},
],
trends: [
{ date: "2026-07-01", score: 78 },
{ date: "2026-07-08", score: 82 },
{ date: "2026-07-15", score: 80 },
{ date: "2026-07-22", score: 85 },
],
recent_exams: [
{
exam_id: "exam-001",
exam_title: "函数单元测试",
score: 82,
total_score: 100,
date: "2026-07-10",
},
{
exam_id: "exam-002",
exam_title: "概率统计小测",
score: 88,
total_score: 100,
date: "2026-07-17",
},
{
exam_id: "exam-003",
exam_title: "期末模拟考",
score: 85,
total_score: 100,
date: "2026-07-20",
},
],
},
},
};
}
// ── 通用 ── // ── 通用 ──
case "GetNotificationsList": case "GetNotificationsList":
return { return {

View File

@@ -185,6 +185,24 @@ export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
requiredRoles: ["teacher", "admin"], requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_MANAGE"], anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_MANAGE"],
}, },
// P2 迁移B3 教师域):错题本列表页根路由(无尾斜杠)
// 无 ERROR_BOOK_* 教师权限点student 有 ERROR_BOOK_READ复用 CLASS_READ/CLASS_MANAGE
"/shell/teacher/error-book": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
},
// P2 迁移B3 教师域):诊断报告列表页根路由(无尾斜杠)
// 复用 DIAGNOSTIC_READ/DIAGNOSTIC_MANAGE与 diagnostics 路由一致,此为 diagnostic 单数别名)
"/shell/teacher/diagnostic": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
},
// P2 迁移B3 教师域):学情分析列表页根路由(无尾斜杠)
// 无 ANALYTICS_* 权限点,复用 CLASS_READ/CLASS_MANAGE学情分析隶属班级域
"/shell/teacher/analytics": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
},
// ── student 专属 ────────────────────────────────────────── // ── student 专属 ──────────────────────────────────────────
"/shell/student/error-book": { "/shell/student/error-book": {
@@ -352,6 +370,30 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_MANAGE"], anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_MANAGE"],
}, },
}, },
// P2 迁移B3 教师域):错题本子路由(预留,未来 /shell/teacher/error-book/[id]
{
prefix: "/shell/teacher/error-book/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
},
},
// P2 迁移B3 教师域):诊断报告子路由(含 /shell/teacher/diagnostic/class/[classId]
{
prefix: "/shell/teacher/diagnostic/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
},
},
// P2 迁移B3 教师域):学情分析子路由(含 /shell/teacher/analytics/[studentId]
{
prefix: "/shell/teacher/analytics/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
},
},
// 公告管理 // 公告管理
{ {
prefix: "/shell/admin/announcements/", prefix: "/shell/admin/announcements/",