diff --git a/apps/portal-shell/scripts/check-page-count.ts b/apps/portal-shell/scripts/check-page-count.ts index 134c187..5c26ada 100644 --- a/apps/portal-shell/scripts/check-page-count.ts +++ b/apps/portal-shell/scripts/check-page-count.ts @@ -19,10 +19,10 @@ interface Baseline { categories: Record; } -// 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. const BASELINE: Baseline = { - total: 53, + total: 58, categories: { dashboards: { pattern: "shell/{admin,teacher,student,parent}/page.tsx", diff --git a/apps/portal-shell/src/app/shell/teacher/analytics/[studentId]/page.tsx b/apps/portal-shell/src/app/shell/teacher/analytics/[studentId]/page.tsx new file mode 100644 index 0000000..4b88b29 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/analytics/[studentId]/page.tsx @@ -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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/analytics/error.tsx b/apps/portal-shell/src/app/shell/teacher/analytics/error.tsx new file mode 100644 index 0000000..a5e40fa --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/analytics/error.tsx @@ -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 ( +
+

+ {t("error.title")} +

+

+ {error.message || t("error.unknown")} +

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/analytics/loading.tsx b/apps/portal-shell/src/app/shell/teacher/analytics/loading.tsx new file mode 100644 index 0000000..a95c852 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/analytics/loading.tsx @@ -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 的 兜底, + * 本文件仅在 /shell/teacher/analytics 总览期间显示。 + */ +export default function AnalyticsLoading(): React.ReactElement { + return ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/analytics/page.tsx b/apps/portal-shell/src/app/shell/teacher/analytics/page.tsx new file mode 100644 index 0000000..65982a1 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/analytics/page.tsx @@ -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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/diagnostic/class/[classId]/page.tsx b/apps/portal-shell/src/app/shell/teacher/diagnostic/class/[classId]/page.tsx new file mode 100644 index 0000000..2fc7126 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/diagnostic/class/[classId]/page.tsx @@ -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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/diagnostic/error.tsx b/apps/portal-shell/src/app/shell/teacher/diagnostic/error.tsx new file mode 100644 index 0000000..2f6e056 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/diagnostic/error.tsx @@ -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 ( +
+

+ {t("error.title")} +

+

+ {error.message || t("error.unknown")} +

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/diagnostic/loading.tsx b/apps/portal-shell/src/app/shell/teacher/diagnostic/loading.tsx new file mode 100644 index 0000000..59b67fb --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/diagnostic/loading.tsx @@ -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 的 兜底, + * 本文件仅在 /shell/teacher/diagnostic 列表期间显示。 + */ +export default function DiagnosticLoading(): React.ReactElement { + return ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/diagnostic/page.tsx b/apps/portal-shell/src/app/shell/teacher/diagnostic/page.tsx new file mode 100644 index 0000000..09a3edb --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/diagnostic/page.tsx @@ -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 要求)。 + * 业务逻辑在 DiagnosticListClient(client component)中。 + * + * 数据契约:diagnosticReports ✅ 真实 schema 字段 + * + * 关联:ARCHITECTURE.md §5.3 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 + */ +export default function DiagnosticListPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/error-book/error.tsx b/apps/portal-shell/src/app/shell/teacher/error-book/error.tsx new file mode 100644 index 0000000..c3863f0 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/error-book/error.tsx @@ -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 ( +
+

+ {t("error.title")} +

+

+ {error.message || t("error.unknown")} +

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/error-book/loading.tsx b/apps/portal-shell/src/app/shell/teacher/error-book/loading.tsx new file mode 100644 index 0000000..98ada35 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/error-book/loading.tsx @@ -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 ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/error-book/page.tsx b/apps/portal-shell/src/app/shell/teacher/error-book/page.tsx new file mode 100644 index 0000000..518a8af --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/error-book/page.tsx @@ -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 要求)。 + * 业务逻辑在 ErrorBookListClient(client 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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/features/teacher/analytics/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/analytics/__tests__/transformations.test.ts new file mode 100644 index 0000000..e7008cc --- /dev/null +++ b/apps/portal-shell/src/features/teacher/analytics/__tests__/transformations.test.ts @@ -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"); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/analytics/analytics-overview-client.tsx b/apps/portal-shell/src/features/teacher/analytics/analytics-overview-client.tsx new file mode 100644 index 0000000..1cef8dc --- /dev/null +++ b/apps/portal-shell/src/features/teacher/analytics/analytics-overview-client.tsx @@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState) + * + * 关联: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 包裹在 中。 + */ +export function AnalyticsOverviewClient(): React.ReactElement { + const t = useTranslations("analytics"); + const tCommon = useTranslations("common"); + + // @contract-pending MSW 兜底 + const { data, loading, error } = useAnalyticsOverview(); + + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("overview.mswNotice")} +

+
+ ) : undefined; + + return ( + } + loading={loading} + loadingNode={} + empty={!loading && !error && !data} + errorNode={errorNode} + > + {data ? : null} + {data ? : null} + {data ? : null} + {data ? : null} + + ); +} + +/** + * 总览统计卡片区。 + */ +function OverviewStatsSection({ + data, +}: { + data: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("analytics"); + return ( +
+ + + + 0 ? "text-destructive" : undefined} + /> +
+ ); +} + +/** + * 单个统计卡片。 + */ +function StatCard({ + label, + value, + valueClass, +}: { + label: string; + value: string; + valueClass?: string; +}): React.ReactElement { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} + +/** + * 薄弱知识点区。 + */ +function WeakPointsSection({ + data, +}: { + data: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("analytics"); + const sorted = sortWeakPointsByErrorCountDesc(data.weak_points); + if (sorted.length === 0) return <>; + return ( +
+

+ {t("overview.sectionWeakPoints")} +

+
+ + + + + + + + + + {sorted.map((wp) => ( + + + + + + ))} + +
+ {t("overview.colKpTitle")} + + {t("overview.colErrorCount")} + + {t("overview.colMastery")} +
{wp.title}{wp.error_count} + + {formatMastery(wp.mastery)} + +
+
+
+ ); +} + +/** + * 趋势区(简单文本列表展示)。 + */ +function TrendsSection({ + data, +}: { + data: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("analytics"); + if (data.trends.length === 0) return <>; + return ( +
+

+ {t("overview.sectionTrends")} +

+
+ {data.trends.map((trend) => ( +
+ + {formatTrendDate(trend.date)} + + + {formatScore(trend.avg_score)} + +
+ ))} +
+
+ ); +} + +/** + * 班级分解区(每行有"查看学生"链接到 /shell/teacher/analytics/[studentId])。 + */ +function ClassBreakdownSection({ + data, +}: { + data: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("analytics"); + if (data.class_breakdown.length === 0) return <>; + return ( +
+

+ {t("overview.sectionClassBreakdown")} +

+
+ + + + + + + + + + + + {data.class_breakdown.map((cls) => ( + + + + + + + + ))} + +
+ {t("overview.colClassName")} + + {t("overview.colStudentCount")} + + {t("overview.colAvgScore")} + + {t("overview.colAtRiskCount")} + + {t("overview.colActions")} +
{cls.class_name}{cls.student_count} + + {formatScore(cls.avg_score)} + + + 0 ? "text-destructive" : ""} + > + {cls.at_risk_count} + + + + {t("overview.viewStudentAnalytics")} + +
+
+
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/analytics/student-analytics-client.tsx b/apps/portal-shell/src/features/teacher/analytics/student-analytics-client.tsx new file mode 100644 index 0000000..40074a8 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/analytics/student-analytics-client.tsx @@ -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): + * - loading:DetailPageSkeleton + * - error:errorNode 局部降级 + * - notFound:data 为 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 包裹在 中。 + */ +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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("student.mswNotice")} +

+
+ ) : undefined; + + const emptyNode = + !loading && !error && !data ? ( + + ) : undefined; + + return ( + } + backHref="/shell/teacher/analytics" + loading={loading} + loadingNode={} + errorNode={errorNode} + emptyNode={emptyNode} + > + {data ? : null} + {data ? : null} + {data ? : null} + {data ? : null} + + ); +} + +/** + * 学生基本信息区。 + */ +function StudentBasicSection({ + data, +}: { + data: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("analytics"); + return ( + + + + + + {formatScore(data.avg_score)} + + } + /> + + {formatRank(data.class_rank, data.total_students)} + + } + /> + + + ); +} + +/** + * 薄弱知识点区(按掌握度升序,最薄弱在前)。 + */ +function StudentWeakPointsSection({ + data, +}: { + data: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("analytics"); + const sorted = sortWeakPointsByMasteryAsc(data.weak_points); + if (sorted.length === 0) { + return ( + +

+ {t("student.noWeakPoints")} +

+
+ ); + } + return ( + +
+ + + + + + + + + + {sorted.map((wp) => ( + + + + + + ))} + +
+ {t("student.colKpTitle")} + + {t("student.colMastery")} + + {t("student.colErrorCount")} +
{wp.title} + + {formatMastery(wp.mastery)} + + {wp.error_count}
+
+
+ ); +} + +/** + * 学习趋势区。 + */ +function StudentTrendsSection({ + data, +}: { + data: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("analytics"); + if (data.trends.length === 0) { + return ( + +

{t("student.noTrends")}

+
+ ); + } + return ( + +
+ {data.trends.map((trend) => ( +
+ + {formatTrendDate(trend.date)} + + + {formatScore(trend.score)} + +
+ ))} +
+
+ ); +} + +/** + * 近期考试区。 + */ +function StudentRecentExamsSection({ + data, +}: { + data: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("analytics"); + if (data.recent_exams.length === 0) { + return ( + +

+ {t("student.noRecentExams")} +

+
+ ); + } + return ( + +
+ + + + + + + + + + + + {data.recent_exams.map((exam) => { + const rate = calcScoreRate(exam.score, exam.total_score); + const level = scoreRateToLevel(rate); + return ( + + + + + + + + ); + })} + +
+ {t("student.colExamTitle")} + + {t("student.colScore")} + + {t("student.colScoreRate")} + + {t("student.colLevel")} + + {t("student.colExamDate")} +
{exam.exam_title} + + {formatScore(exam.score)} + + + / {exam.total_score} + + {formatScoreRate(rate)}{level} + {formatTrendDate(exam.date)} +
+
+
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/analytics/transformations.ts b/apps/portal-shell/src/features/teacher/analytics/transformations.ts new file mode 100644 index 0000000..5e837ea --- /dev/null +++ b/apps/portal-shell/src/features/teacher/analytics/transformations.ts @@ -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), + ); +} diff --git a/apps/portal-shell/src/features/teacher/diagnostic/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/diagnostic/__tests__/transformations.test.ts new file mode 100644 index 0000000..8bf19c3 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/diagnostic/__tests__/transformations.test.ts @@ -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"); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/diagnostic/diagnostic-class-detail-client.tsx b/apps/portal-shell/src/features/teacher/diagnostic/diagnostic-class-detail-client.tsx new file mode 100644 index 0000000..ab5b67a --- /dev/null +++ b/apps/portal-shell/src/features/teacher/diagnostic/diagnostic-class-detail-client.tsx @@ -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): + * - loading:DetailPageSkeleton + * - error:errorNode 局部降级 + * - notFound:data 为 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 包裹在 中。 + */ +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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("detail.mswNotice")} +

+
+ ) : undefined; + + const emptyNode = + !loading && !error && !data ? ( + + ) : undefined; + + return ( + } + backHref="/shell/teacher/diagnostic" + loading={loading} + loadingNode={} + errorNode={errorNode} + emptyNode={emptyNode} + > + {data ? : null} + {data ? : null} + {data ? ( + + ) : null} + + ); +} + +/** + * 详情基本信息区。 + */ +function DiagnosticDetailBody({ + detail, +}: { + detail: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("diagnostic"); + return ( + + + + } + /> + + + + {formatAvgScore(detail.avg_score)} + + } + /> + + + + ); +} + +/** + * 薄弱知识点区。 + */ +function WeakPointsSection({ + weakPoints, +}: { + weakPoints: NonNullable< + ReturnType["data"] + >["weak_points"]; +}): React.ReactElement { + const t = useTranslations("diagnostic"); + if (weakPoints.length === 0) { + return ( + +

+ {t("detail.noWeakPoints")} +

+
+ ); + } + return ( + +
+ + + + + + + + + + {weakPoints.map((wp) => ( + + + + + + ))} + +
+ {t("detail.colKpTitle")} + + {t("detail.colMastery")} + + {t("detail.colErrorCount")} +
{wp.title} + + {formatMastery(wp.mastery)} + + {wp.error_count}
+
+
+ ); +} + +/** + * 教学建议区。 + */ +function RecommendationsSection({ + recommendations, +}: { + recommendations: string[]; +}): React.ReactElement { + const t = useTranslations("diagnostic"); + if (recommendations.length === 0) { + return ( + +

+ {t("detail.noRecommendations")} +

+
+ ); + } + return ( + +
    + {recommendations.map((rec, idx) => ( +
  • {rec}
  • + ))} +
+
+ ); +} + +/** + * 诊断报告状态徽章。 + */ +function DiagnosticStatusBadge({ + status, +}: { + status: string; +}): React.ReactElement { + const label = formatDiagnosticStatus(status); + const cls = diagnosticStatusToBadgeClass(status); + return ( + + {label} + + ); +} diff --git a/apps/portal-shell/src/features/teacher/diagnostic/diagnostic-list-client.tsx b/apps/portal-shell/src/features/teacher/diagnostic/diagnostic-list-client.tsx new file mode 100644 index 0000000..9904181 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/diagnostic/diagnostic-list-client.tsx @@ -0,0 +1,226 @@ +"use client"; + +/** + * 诊断报告列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * 数据契约: + * - diagnosticReports:✅ 真实 schema 字段 + * + * URL 状态:?q=xxx &status=xxx + * + * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState) + * + * 关联: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 包裹在 中。 + */ +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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+
+ ) : undefined; + + return ( + } + filters={ + <> + updateQuery("q", v)} + /> + + + } + loading={loading} + loadingNode={} + empty={filteredItems.length === 0 && !loading} + errorNode={errorNode} + pagination={ +
+ {t("list.total", { count: filteredItems.length })} + · + {t("list.publishedCount", { count: publishedCount })} +
+ } + > + +
+ ); +} + +/** + * 诊断报告列表表格。 + */ +function DiagnosticTable({ + items, +}: { + items: NonNullable["data"]>["items"]; +}): React.ReactElement { + const t = useTranslations("diagnostic"); + if (items.length === 0) return <>; + return ( +
+ + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + ))} + +
{t("list.colTitle")}{t("list.colType")} + {t("list.colStudentId")} + {t("list.colStatus")} + {t("list.colGeneratedAt")} + + {t("list.colActions")} +
+ {item.title} + {item.summary ? ( +

+ {item.summary.slice(0, 60) + + (item.summary.length > 60 ? "..." : "")} +

+ ) : null} +
+ {formatReportType(item.report_type)} + + {item.student_id} + + + + {formatDiagnosticDate(item.generated_at)} + + + {t("list.viewClassDetail")} + +
+
+ ); +} + +/** + * 诊断报告状态徽章。 + */ +function DiagnosticStatusBadge({ + status, +}: { + status: string; +}): React.ReactElement { + const label = formatDiagnosticStatus(status); + const cls = diagnosticStatusToBadgeClass(status); + return ( + + {label} + + ); +} diff --git a/apps/portal-shell/src/features/teacher/diagnostic/transformations.ts b/apps/portal-shell/src/features/teacher/diagnostic/transformations.ts new file mode 100644 index 0000000..d9f0f44 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/diagnostic/transformations.ts @@ -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 = { + 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 = { + WEEKLY: "周报", + MONTHLY: "月报", + EXAM: "考试诊断", + UNIT: "单元诊断", + }; + return map[reportType] ?? reportType; +} + +/** + * 从诊断报告列表中提取已发布报告。 + */ +export function filterPublishedReports( + reports: DiagnosticReport[], +): DiagnosticReport[] { + return reports.filter((r) => isDiagnosticPublished(r.status)); +} diff --git a/apps/portal-shell/src/features/teacher/error-book/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/error-book/__tests__/transformations.test.ts new file mode 100644 index 0000000..d100508 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/error-book/__tests__/transformations.test.ts @@ -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); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/error-book/error-book-list-client.tsx b/apps/portal-shell/src/features/teacher/error-book/error-book-list-client.tsx new file mode 100644 index 0000000..0bab932 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/error-book/error-book-list-client.tsx @@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState) + * + * 关联: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 包裹在 中 + * (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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+
+ ) : undefined; + + return ( + } + filters={ + <> + updateQuery("q", v)} + /> + + + } + loading={loading} + loadingNode={} + empty={filteredItems.length === 0 && !loading} + errorNode={errorNode} + pagination={ +
+ {t("list.total", { count: filteredItems.length })} +
+ } + > + {stats ? : null} + +
+ ); +} + +/** + * 统计卡片区(顶部 4 个卡片)。 + */ +function StatsSection({ + stats, +}: { + stats: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("errorBook"); + const kpList = sortKnowledgePointsByErrorRate(stats); + return ( +
+ + + + +
+ ); +} + +/** + * 单个统计卡片。 + */ +function StatCard({ + label, + value, +}: { + label: string; + value: string; +}): React.ReactElement { + return ( +
+

{label}

+

{value}

+
+ ); +} + +/** + * 错题列表表格(纯展示组件,对齐 §8.2 排版规范)。 + */ +function ErrorBookTable({ + items, +}: { + items: NonNullable["data"]>["items"]; +}): React.ReactElement { + const t = useTranslations("errorBook"); + if (items.length === 0) return <>; + return ( +
+ + + + + + + + + + + {items.map((item) => ( + + + + + + + ))} + +
+ {t("list.colKnowledgePoint")} + + {t("list.colErrorCount")} + + {t("list.colLastErrorTime")} + + {t("list.colContent")} +
+ + {item.knowledge_point_title} + +

+ {item.knowledge_point_id} +

+
+ 0 ? item.error_count / 10 : 0, + ) + ? "text-destructive" + : "" + } + > + {formatErrorCount(item.error_count)} + + + {formatErrorBookDate(item.last_error_time)} + + {hasContent(item.content) + ? item.content.slice(0, 80) + + (item.content.length > 80 ? "..." : "") + : t("list.noContent")} +
+
+ ); +} + +/** + * 知识点错误率徽章(用于详情或扩展展示)。 + */ +function ErrorRateBadge({ rate }: { rate: number }): React.ReactElement { + const label = formatErrorRate(rate); + const cls = errorRateToColorClass(rate); + return {label}; +} + +// 防止未使用导入警告(ErrorRateBadge 在未来扩展中使用) +void ErrorRateBadge; diff --git a/apps/portal-shell/src/features/teacher/error-book/transformations.ts b/apps/portal-shell/src/features/teacher/error-book/transformations.ts new file mode 100644 index 0000000..57570cd --- /dev/null +++ b/apps/portal-shell/src/features/teacher/error-book/transformations.ts @@ -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); +} diff --git a/apps/portal-shell/src/lib/api/analytics.ts b/apps/portal-shell/src/lib/api/analytics.ts new file mode 100644 index 0000000..13a84d0 --- /dev/null +++ b/apps/portal-shell/src/lib/api/analytics.ts @@ -0,0 +1,245 @@ +"use client"; + +/** + * Analytics domain API(ARCHITECTURE.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 LearningTrend,points 运行时按列表处理) */ +export interface LearningTrend { + student_id: string; + points: TrendPoint[]; +} + +/** 学生薄弱点(对齐 schema StudentWeakness,weak_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 ===== + +/** + * 查询学习趋势(✅ 真实 schema,learningTrend)。 + * + * 关联:ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 + */ +export function useLearningTrend( + options?: AnalyticsQueryOptions, +): UseQueryResult { + const result = useWidgetQuery( + 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, + }; +} + +/** + * 查询学生薄弱知识点(✅ 真实 schema,studentWeakness)。 + * + * 关联:ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 + */ +export function useStudentWeakness( + options?: AnalyticsQueryOptions, +): UseQueryResult { + const result = useWidgetQuery( + 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-pending,MSW 兜底)。 + * + * schema 无 analyticsOverview 根字段,由 MSW handlers 返回 mock 数据。 + * 用于 /shell/teacher/analytics 总览页。 + * + * 关联:ARCHITECTURE.md §5.4 / §9.1 总览页 / §11.4 契约工单 + */ +export function useAnalyticsOverview( + options?: AnalyticsQueryOptions, +): UseQueryResult { + const result = useWidgetQuery( + 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-pending,MSW 兜底)。 + * + * 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 { + 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, + }; +} diff --git a/apps/portal-shell/src/lib/api/diagnostic.ts b/apps/portal-shell/src/lib/api/diagnostic.ts new file mode 100644 index 0000000..161ea8e --- /dev/null +++ b/apps/portal-shell/src/lib/api/diagnostic.ts @@ -0,0 +1,155 @@ +"use client"; + +/** + * Diagnostic domain API(ARCHITECTURE.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 ===== + +/** + * 查询诊断报告列表(✅ 真实 schema,diagnosticReports)。 + * + * diagnosticReports 返回 [DiagnosticReportList!]!(按 student 聚合), + * 本 hook 展平为 DiagnosticReportListItem[] 供列表页渲染。 + * + * 关联:ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 列表页 + */ +export function useDiagnosticReports( + options?: DiagnosticQueryOptions, +): UseQueryResult<{ items: DiagnosticReportListItem[]; total: number }> { + const result = useWidgetQuery( + 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-pending,MSW 兜底)。 + * + * 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 { + const result = useWidgetQuery( + 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, + }; +} diff --git a/apps/portal-shell/src/lib/api/error-book.ts b/apps/portal-shell/src/lib/api/error-book.ts new file mode 100644 index 0000000..a123caa --- /dev/null +++ b/apps/portal-shell/src/lib/api/error-book.ts @@ -0,0 +1,151 @@ +"use client"; + +/** + * Error Book domain API(ARCHITECTURE.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 ===== + +/** + * 查询错题本条目列表(✅ 真实 schema,errorBookItems)。 + * + * 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( + 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, + }; +} + +/** + * 查询错题本统计(✅ 真实 schema,errorBookStats)。 + * + * 复用 dashboard.graphql.ts 的 GET_ERROR_BOOK_STATS_DOC(同一 schema 字段)。 + * 用于错题本列表页顶部统计卡片区。 + * + * 关联:ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 列表页 + */ +export function useTeacherErrorBookStats( + options?: ErrorBookQueryOptions, +): UseQueryResult { + const result = useWidgetQuery( + 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, + }; +} diff --git a/apps/portal-shell/src/lib/api/index.ts b/apps/portal-shell/src/lib/api/index.ts index 8c9ed57..a3f696c 100644 --- a/apps/portal-shell/src/lib/api/index.ts +++ b/apps/portal-shell/src/lib/api/index.ts @@ -26,5 +26,8 @@ export * from "./students"; export * from "./student"; export * from "./course-plans"; export * from "./elective"; +export * from "./error-book"; +export * from "./diagnostic"; +export * from "./analytics"; export * from "./parent"; export * from "./admin"; diff --git a/apps/portal-shell/src/lib/api/operations/analytics.graphql.ts b/apps/portal-shell/src/lib/api/operations/analytics.graphql.ts new file mode 100644 index 0000000..9c53f4e --- /dev/null +++ b/apps/portal-shell/src/lib/api/operations/analytics.graphql.ts @@ -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 + } + } + } +`; diff --git a/apps/portal-shell/src/lib/api/operations/diagnostic.graphql.ts b/apps/portal-shell/src/lib/api/operations/diagnostic.graphql.ts new file mode 100644 index 0000000..24f59e8 --- /dev/null +++ b/apps/portal-shell/src/lib/api/operations/diagnostic.graphql.ts @@ -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 + } + } +`; diff --git a/apps/portal-shell/src/lib/api/operations/error-book.graphql.ts b/apps/portal-shell/src/lib/api/operations/error-book.graphql.ts new file mode 100644 index 0000000..213be71 --- /dev/null +++ b/apps/portal-shell/src/lib/api/operations/error-book.graphql.ts @@ -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 复用。 diff --git a/apps/portal-shell/src/lib/api/operations/index.ts b/apps/portal-shell/src/lib/api/operations/index.ts index f35533c..cacf7eb 100644 --- a/apps/portal-shell/src/lib/api/operations/index.ts +++ b/apps/portal-shell/src/lib/api/operations/index.ts @@ -16,5 +16,8 @@ export * from "./students.graphql"; export * from "./student.graphql"; export * from "./course-plans.graphql"; export * from "./elective.graphql"; +export * from "./error-book.graphql"; +export * from "./diagnostic.graphql"; +export * from "./analytics.graphql"; export * from "./parent.graphql"; export * from "./admin.graphql"; diff --git a/apps/portal-shell/src/messages/en.json b/apps/portal-shell/src/messages/en.json index 64de015..32a00e3 100644 --- a/apps/portal-shell/src/messages/en.json +++ b/apps/portal-shell/src/messages/en.json @@ -704,7 +704,61 @@ } }, "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": { "title": "Knowledge Graph" @@ -1085,10 +1139,83 @@ } }, "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": { - "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": { "title": "Practice" diff --git a/apps/portal-shell/src/messages/zh-CN.json b/apps/portal-shell/src/messages/zh-CN.json index ade193f..e02118e 100644 --- a/apps/portal-shell/src/messages/zh-CN.json +++ b/apps/portal-shell/src/messages/zh-CN.json @@ -704,7 +704,61 @@ } }, "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": { "title": "知识图谱" @@ -1085,10 +1139,83 @@ } }, "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": { - "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": { "title": "练习分析" diff --git a/apps/portal-shell/src/mocks/graphql-data.ts b/apps/portal-shell/src/mocks/graphql-data.ts index 1b15ab3..c9a610d 100644 --- a/apps/portal-shell/src/mocks/graphql-data.ts +++ b/apps/portal-shell/src/mocks/graphql-data.ts @@ -3475,6 +3475,317 @@ export function graphqlResponse( 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": return { diff --git a/apps/portal-shell/src/shared/lib/route-permissions.ts b/apps/portal-shell/src/shared/lib/route-permissions.ts index f6619ca..4fd94ac 100644 --- a/apps/portal-shell/src/shared/lib/route-permissions.ts +++ b/apps/portal-shell/src/shared/lib/route-permissions.ts @@ -185,6 +185,24 @@ export const EXACT_ROUTE_PERMISSIONS: Record = { requiredRoles: ["teacher", "admin"], 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 专属 ────────────────────────────────────────── "/shell/student/error-book": { @@ -352,6 +370,30 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{ 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/",