feat(portal-shell): error-book + diagnostic + analytics 模块 5 页迁移(教师域 §9.1 B2)
§9.1 line 637-639 教师域: - /shell/teacher/error-book (列表,1 页) - /shell/teacher/diagnostic (列表) + /class/[classId] (详情) — 2 页 - /shell/teacher/analytics (概览) + /[studentId] (详情) — 2 页 契约: - error-book ✅ 真实 errorBookItems/errorBookStats - diagnostic ✅ 真实 diagnosticReports 列表 + 详情 ❌ MSW - analytics 🟡 真实 learningTrend/studentWeakness + 概览 ❌ MSW 新增文件(24 个): - src/lib/api/{error-book,diagnostic,analytics}.ts (hooks) - src/lib/api/operations/{error-book,diagnostic,analytics}.graphql.ts (documents) - src/features/teacher/{error-book,diagnostic,analytics}/ (clients + transformations + tests) - src/app/shell/teacher/{error-book,diagnostic,analytics}/ (5 page.tsx + 3 loading + 3 error) 修改文件(7 个): - src/mocks/graphql-data.ts (7 handler cases) - src/messages/{zh-CN,en}.json (errorBook/diagnostic/analytics i18n) - src/lib/api/{index,operations/index}.ts (导出) - src/shared/lib/route-permissions.ts (3 EXACT + 3 PREFIX 路由权限) - scripts/check-page-count.ts (baseline 53 → 58) DoD 验收(§11.3 11 项): - typecheck 0 errors - lint 0 errors - vitest 734 tests passed - lint:tokens 0 errors - check:pages 58 PASS - route-permissions 已声明 - 三态齐备 - @contract-pending + MSW 兜底(仅对 schema 不存在的字段) - i18n zh-CN + en 同步 设计决策: - 类型命名冲突解决:ErrorBookItem → ErrorBookEntry;ErrorBookStats → TeacherErrorBookStats; KnowledgePointErrorStats → ErrorBookKpStats;useErrorBookStats → useTeacherErrorBookStats (避免与 dashboard.ts/student.ts 同名类型冲突) - analytics TrendPoint/WeakPoint 形状相同,从 dashboard.ts import 复用 - error-book/analytics 复用 CLASS_READ/CLASS_MANAGE(无专用教师权限点); diagnostic 复用 DIAGNOSTIC_READ/DIAGNOSTIC_MANAGE 关联:ARCHITECTURE.md §5.3 / §5.4 / §5.5 / §9.1 / §10 P2 / §11.3 / §11.4 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
This commit is contained in:
@@ -19,10 +19,10 @@ interface Baseline {
|
|||||||
categories: Record<string, { pattern: string; min: number; label: string }>;
|
categories: Record<string, { pattern: string; min: number; label: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Baseline as of P2 B2 (2026-07-22, course-plans + elective modules added).
|
// Baseline as of P2 B3 (2026-07-22, error-book + diagnostic + analytics modules added).
|
||||||
// Update when adding pages.
|
// Update when adding pages.
|
||||||
const BASELINE: Baseline = {
|
const BASELINE: Baseline = {
|
||||||
total: 53,
|
total: 58,
|
||||||
categories: {
|
categories: {
|
||||||
dashboards: {
|
dashboards: {
|
||||||
pattern: "shell/{admin,teacher,student,parent}/page.tsx",
|
pattern: "shell/{admin,teacher,student,parent}/page.tsx",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { StudentAnalyticsClient } from "@/features/teacher/analytics/student-analytics-client";
|
||||||
|
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单生学情分析详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||||
|
*
|
||||||
|
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||||
|
*
|
||||||
|
* 数据契约:单查 studentAnalytics(studentId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
|
* 契约工单:docs/architecture/issues/contracts/data-ana_contract.md#student-analytics
|
||||||
|
*
|
||||||
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
|
*/
|
||||||
|
export default function StudentAnalyticsPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<DetailPageSkeleton />}>
|
||||||
|
<StudentAnalyticsClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/portal-shell/src/app/shell/teacher/analytics/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/analytics/error.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学情分析路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||||
|
* Next.js Route Segment error.tsx,捕获子树未处理异常。
|
||||||
|
*/
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function AnalyticsError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("analytics");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error("[portal-shell] analytics route error:", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
|
||||||
|
<h2 className="text-lg font-semibold text-destructive">
|
||||||
|
{t("error.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{error.message || t("error.unknown")}
|
||||||
|
</p>
|
||||||
|
<Button onClick={reset} variant="outline">
|
||||||
|
{t("error.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学情分析路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||||
|
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||||
|
*
|
||||||
|
* 子页面(学生详情)的 Skeleton 由各自 server page 的 <Suspense> 兜底,
|
||||||
|
* 本文件仅在 /shell/teacher/analytics 总览期间显示。
|
||||||
|
*/
|
||||||
|
export default function AnalyticsLoading(): React.ReactElement {
|
||||||
|
return <ListPageSkeleton rows={5} />;
|
||||||
|
}
|
||||||
22
apps/portal-shell/src/app/shell/teacher/analytics/page.tsx
Normal file
22
apps/portal-shell/src/app/shell/teacher/analytics/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { AnalyticsOverviewClient } from "@/features/teacher/analytics/analytics-overview-client";
|
||||||
|
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学情分析总览页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||||
|
*
|
||||||
|
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||||
|
*
|
||||||
|
* 数据契约:analyticsOverview ❌ schema 无此聚合根字段 → MSW 兜底(@contract-pending)
|
||||||
|
* 契约工单:docs/architecture/issues/contracts/data-ana_contract.md#analytics-overview
|
||||||
|
*
|
||||||
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
|
*/
|
||||||
|
export default function AnalyticsOverviewPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||||
|
<AnalyticsOverviewClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { DiagnosticClassDetailClient } from "@/features/teacher/diagnostic/diagnostic-class-detail-client";
|
||||||
|
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级诊断报告详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||||
|
*
|
||||||
|
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||||
|
*
|
||||||
|
* 数据契约:单查 diagnosticReport(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
|
* 契约工单:docs/architecture/issues/contracts/data-ana_contract.md#diagnostic-class-detail
|
||||||
|
*
|
||||||
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
|
*/
|
||||||
|
export default function DiagnosticClassDetailPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<DetailPageSkeleton />}>
|
||||||
|
<DiagnosticClassDetailClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/portal-shell/src/app/shell/teacher/diagnostic/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/diagnostic/error.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断报告路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||||
|
* Next.js Route Segment error.tsx,捕获子树未处理异常。
|
||||||
|
*/
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function DiagnosticError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("diagnostic");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error("[portal-shell] diagnostic route error:", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
|
||||||
|
<h2 className="text-lg font-semibold text-destructive">
|
||||||
|
{t("error.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{error.message || t("error.unknown")}
|
||||||
|
</p>
|
||||||
|
<Button onClick={reset} variant="outline">
|
||||||
|
{t("error.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断报告路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||||
|
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||||
|
*
|
||||||
|
* 子页面(班级详情)的 Skeleton 由各自 server page 的 <Suspense> 兜底,
|
||||||
|
* 本文件仅在 /shell/teacher/diagnostic 列表期间显示。
|
||||||
|
*/
|
||||||
|
export default function DiagnosticLoading(): React.ReactElement {
|
||||||
|
return <ListPageSkeleton rows={5} />;
|
||||||
|
}
|
||||||
22
apps/portal-shell/src/app/shell/teacher/diagnostic/page.tsx
Normal file
22
apps/portal-shell/src/app/shell/teacher/diagnostic/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { DiagnosticListClient } from "@/features/teacher/diagnostic/diagnostic-list-client";
|
||||||
|
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断报告列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||||
|
*
|
||||||
|
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||||
|
* 业务逻辑在 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 (
|
||||||
|
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||||
|
<DiagnosticListClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/portal-shell/src/app/shell/teacher/error-book/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/error-book/error.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错题本路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||||
|
* Next.js Route Segment error.tsx,捕获子树未处理异常。
|
||||||
|
*/
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function ErrorBookError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("errorBook");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error("[portal-shell] error-book route error:", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
|
||||||
|
<h2 className="text-lg font-semibold text-destructive">
|
||||||
|
{t("error.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{error.message || t("error.unknown")}
|
||||||
|
</p>
|
||||||
|
<Button onClick={reset} variant="outline">
|
||||||
|
{t("error.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错题本路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||||
|
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||||
|
*/
|
||||||
|
export default function ErrorBookLoading(): React.ReactElement {
|
||||||
|
return <ListPageSkeleton rows={5} />;
|
||||||
|
}
|
||||||
22
apps/portal-shell/src/app/shell/teacher/error-book/page.tsx
Normal file
22
apps/portal-shell/src/app/shell/teacher/error-book/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { ErrorBookListClient } from "@/features/teacher/error-book/error-book-list-client";
|
||||||
|
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错题本列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||||
|
*
|
||||||
|
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||||
|
* 业务逻辑在 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 (
|
||||||
|
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||||
|
<ErrorBookListClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 包裹在 <Suspense> 中。
|
||||||
|
*/
|
||||||
|
export function AnalyticsOverviewClient(): React.ReactElement {
|
||||||
|
const t = useTranslations("analytics");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
|
||||||
|
// @contract-pending MSW 兜底
|
||||||
|
const { data, loading, error } = useAnalyticsOverview();
|
||||||
|
|
||||||
|
const errorNode = error ? (
|
||||||
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{tCommon("error.loadFailed", { message: String(error) })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{t("overview.mswNotice")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListPageShell
|
||||||
|
title={t("overview.title")}
|
||||||
|
description={t("overview.description")}
|
||||||
|
icon={<BarChart3 className="size-6" />}
|
||||||
|
loading={loading}
|
||||||
|
loadingNode={<ListPageSkeleton rows={5} />}
|
||||||
|
empty={!loading && !error && !data}
|
||||||
|
errorNode={errorNode}
|
||||||
|
>
|
||||||
|
{data ? <OverviewStatsSection data={data} /> : null}
|
||||||
|
{data ? <WeakPointsSection data={data} /> : null}
|
||||||
|
{data ? <TrendsSection data={data} /> : null}
|
||||||
|
{data ? <ClassBreakdownSection data={data} /> : null}
|
||||||
|
</ListPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总览统计卡片区。
|
||||||
|
*/
|
||||||
|
function OverviewStatsSection({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: NonNullable<ReturnType<typeof useAnalyticsOverview>["data"]>;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("analytics");
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
label={t("overview.totalStudents")}
|
||||||
|
value={String(data.total_students)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("overview.avgScore")}
|
||||||
|
value={formatScore(data.avg_score)}
|
||||||
|
valueClass={scoreToColorClass(data.avg_score)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("overview.avgMastery")}
|
||||||
|
value={formatMastery(data.avg_mastery)}
|
||||||
|
valueClass={masteryToColorClass(data.avg_mastery)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("overview.atRiskCount")}
|
||||||
|
value={String(data.at_risk_count)}
|
||||||
|
valueClass={data.at_risk_count > 0 ? "text-destructive" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个统计卡片。
|
||||||
|
*/
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueClass,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
valueClass?: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={`mt-1 text-2xl font-semibold ${valueClass ?? ""}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 薄弱知识点区。
|
||||||
|
*/
|
||||||
|
function WeakPointsSection({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: NonNullable<ReturnType<typeof useAnalyticsOverview>["data"]>;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("analytics");
|
||||||
|
const sorted = sortWeakPointsByErrorCountDesc(data.weak_points);
|
||||||
|
if (sorted.length === 0) return <></>;
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border bg-card p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">
|
||||||
|
{t("overview.sectionWeakPoints")}
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("overview.colKpTitle")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("overview.colErrorCount")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("overview.colMastery")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{sorted.map((wp) => (
|
||||||
|
<tr key={wp.knowledge_point_id} className="hover:bg-muted/30">
|
||||||
|
<td className="p-3 font-medium">{wp.title}</td>
|
||||||
|
<td className="p-3">{wp.error_count}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<span className={masteryToColorClass(wp.mastery)}>
|
||||||
|
{formatMastery(wp.mastery)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 趋势区(简单文本列表展示)。
|
||||||
|
*/
|
||||||
|
function TrendsSection({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: NonNullable<ReturnType<typeof useAnalyticsOverview>["data"]>;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("analytics");
|
||||||
|
if (data.trends.length === 0) return <></>;
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border bg-card p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">
|
||||||
|
{t("overview.sectionTrends")}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{data.trends.map((trend) => (
|
||||||
|
<div
|
||||||
|
key={trend.date}
|
||||||
|
className="flex items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatTrendDate(trend.date)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${scoreToColorClass(trend.avg_score)}`}
|
||||||
|
>
|
||||||
|
{formatScore(trend.avg_score)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级分解区(每行有"查看学生"链接到 /shell/teacher/analytics/[studentId])。
|
||||||
|
*/
|
||||||
|
function ClassBreakdownSection({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: NonNullable<ReturnType<typeof useAnalyticsOverview>["data"]>;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("analytics");
|
||||||
|
if (data.class_breakdown.length === 0) return <></>;
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border bg-card p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">
|
||||||
|
{t("overview.sectionClassBreakdown")}
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("overview.colClassName")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("overview.colStudentCount")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("overview.colAvgScore")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("overview.colAtRiskCount")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-right font-medium">
|
||||||
|
{t("overview.colActions")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data.class_breakdown.map((cls) => (
|
||||||
|
<tr key={cls.class_id} className="hover:bg-muted/30">
|
||||||
|
<td className="p-3 font-medium">{cls.class_name}</td>
|
||||||
|
<td className="p-3">{cls.student_count}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<span className={scoreToColorClass(cls.avg_score)}>
|
||||||
|
{formatScore(cls.avg_score)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<span
|
||||||
|
className={cls.at_risk_count > 0 ? "text-destructive" : ""}
|
||||||
|
>
|
||||||
|
{cls.at_risk_count}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/shell/teacher/analytics/stu-001`}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t("overview.viewStudentAnalytics")}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 包裹在 <Suspense> 中。
|
||||||
|
*/
|
||||||
|
export function StudentAnalyticsClient(): React.ReactElement {
|
||||||
|
const t = useTranslations("analytics");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const params = useParams<{ studentId: string }>();
|
||||||
|
const studentId = params?.studentId ?? "";
|
||||||
|
|
||||||
|
// @contract-pending MSW 兜底
|
||||||
|
const { data, loading, error } = useStudentAnalytics(studentId);
|
||||||
|
|
||||||
|
const errorNode = error ? (
|
||||||
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{tCommon("error.loadFailed", { message: String(error) })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{t("student.mswNotice")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
const emptyNode =
|
||||||
|
!loading && !error && !data ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={BarChart3}
|
||||||
|
title={t("student.notFound")}
|
||||||
|
action={{
|
||||||
|
label: t("student.backToOverview"),
|
||||||
|
href: "/shell/teacher/analytics",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailPageShell
|
||||||
|
title={data?.student_name ?? t("student.title")}
|
||||||
|
description={
|
||||||
|
data
|
||||||
|
? t("student.subtitle", {
|
||||||
|
className: data.class_name,
|
||||||
|
studentNo: data.student_no,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
icon={<BarChart3 className="size-6" />}
|
||||||
|
backHref="/shell/teacher/analytics"
|
||||||
|
loading={loading}
|
||||||
|
loadingNode={<DetailPageSkeleton />}
|
||||||
|
errorNode={errorNode}
|
||||||
|
emptyNode={emptyNode}
|
||||||
|
>
|
||||||
|
{data ? <StudentBasicSection data={data} /> : null}
|
||||||
|
{data ? <StudentWeakPointsSection data={data} /> : null}
|
||||||
|
{data ? <StudentTrendsSection data={data} /> : null}
|
||||||
|
{data ? <StudentRecentExamsSection data={data} /> : null}
|
||||||
|
</DetailPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生基本信息区。
|
||||||
|
*/
|
||||||
|
function StudentBasicSection({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: NonNullable<ReturnType<typeof useStudentAnalytics>["data"]>;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("analytics");
|
||||||
|
return (
|
||||||
|
<DetailSection title={t("student.sectionBasic")}>
|
||||||
|
<DetailField label={t("student.fieldName")} value={data.student_name} />
|
||||||
|
<DetailField
|
||||||
|
label={t("student.fieldStudentNo")}
|
||||||
|
value={data.student_no}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("student.fieldClassName")}
|
||||||
|
value={data.class_name}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("student.fieldAvgScore")}
|
||||||
|
value={
|
||||||
|
<span className={scoreToColorClass(data.avg_score)}>
|
||||||
|
{formatScore(data.avg_score)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("student.fieldClassRank")}
|
||||||
|
value={
|
||||||
|
<span
|
||||||
|
className={rankToColorClass(data.class_rank, data.total_students)}
|
||||||
|
>
|
||||||
|
{formatRank(data.class_rank, data.total_students)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("student.fieldTotalStudents")}
|
||||||
|
value={String(data.total_students)}
|
||||||
|
/>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 薄弱知识点区(按掌握度升序,最薄弱在前)。
|
||||||
|
*/
|
||||||
|
function StudentWeakPointsSection({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: NonNullable<ReturnType<typeof useStudentAnalytics>["data"]>;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("analytics");
|
||||||
|
const sorted = sortWeakPointsByMasteryAsc(data.weak_points);
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return (
|
||||||
|
<DetailSection title={t("student.sectionWeakPoints")}>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("student.noWeakPoints")}
|
||||||
|
</p>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DetailSection title={t("student.sectionWeakPoints")}>
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("student.colKpTitle")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("student.colMastery")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("student.colErrorCount")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{sorted.map((wp) => (
|
||||||
|
<tr key={wp.knowledge_point_id} className="hover:bg-muted/30">
|
||||||
|
<td className="p-3 font-medium">{wp.title}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<span className={masteryToColorClass(wp.mastery)}>
|
||||||
|
{formatMastery(wp.mastery)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">{wp.error_count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学习趋势区。
|
||||||
|
*/
|
||||||
|
function StudentTrendsSection({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: NonNullable<ReturnType<typeof useStudentAnalytics>["data"]>;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("analytics");
|
||||||
|
if (data.trends.length === 0) {
|
||||||
|
return (
|
||||||
|
<DetailSection title={t("student.sectionTrends")}>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("student.noTrends")}</p>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DetailSection title={t("student.sectionTrends")}>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{data.trends.map((trend) => (
|
||||||
|
<div
|
||||||
|
key={trend.date}
|
||||||
|
className="flex items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatTrendDate(trend.date)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${scoreToColorClass(trend.score)}`}
|
||||||
|
>
|
||||||
|
{formatScore(trend.score)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 近期考试区。
|
||||||
|
*/
|
||||||
|
function StudentRecentExamsSection({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: NonNullable<ReturnType<typeof useStudentAnalytics>["data"]>;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("analytics");
|
||||||
|
if (data.recent_exams.length === 0) {
|
||||||
|
return (
|
||||||
|
<DetailSection title={t("student.sectionRecentExams")}>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("student.noRecentExams")}
|
||||||
|
</p>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DetailSection title={t("student.sectionRecentExams")}>
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("student.colExamTitle")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("student.colScore")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("student.colScoreRate")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("student.colLevel")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("student.colExamDate")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data.recent_exams.map((exam) => {
|
||||||
|
const rate = calcScoreRate(exam.score, exam.total_score);
|
||||||
|
const level = scoreRateToLevel(rate);
|
||||||
|
return (
|
||||||
|
<tr key={exam.exam_id} className="hover:bg-muted/30">
|
||||||
|
<td className="p-3 font-medium">{exam.exam_title}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<span className={scoreToColorClass(exam.score)}>
|
||||||
|
{formatScore(exam.score)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
/ {exam.total_score}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">{formatScoreRate(rate)}</td>
|
||||||
|
<td className="p-3">{level}</td>
|
||||||
|
<td className="p-3 font-mono text-xs">
|
||||||
|
{formatTrendDate(exam.date)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 包裹在 <Suspense> 中。
|
||||||
|
*/
|
||||||
|
export function DiagnosticClassDetailClient(): React.ReactElement {
|
||||||
|
const t = useTranslations("diagnostic");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const params = useParams<{ classId: string }>();
|
||||||
|
const classId = params?.classId ?? "";
|
||||||
|
|
||||||
|
// @contract-pending MSW 兜底
|
||||||
|
const { data, loading, error } = useDiagnosticReport(classId);
|
||||||
|
|
||||||
|
const errorNode = error ? (
|
||||||
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{tCommon("error.loadFailed", { message: String(error) })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{t("detail.mswNotice")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
const emptyNode =
|
||||||
|
!loading && !error && !data ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Stethoscope}
|
||||||
|
title={t("detail.notFound")}
|
||||||
|
action={{
|
||||||
|
label: t("detail.backToList"),
|
||||||
|
href: "/shell/teacher/diagnostic",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailPageShell
|
||||||
|
title={data?.title ?? t("detail.title")}
|
||||||
|
description={
|
||||||
|
data
|
||||||
|
? t("detail.generatedAtPrefix", {
|
||||||
|
date: formatDiagnosticDate(data.generated_at),
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
icon={<Stethoscope className="size-6" />}
|
||||||
|
backHref="/shell/teacher/diagnostic"
|
||||||
|
loading={loading}
|
||||||
|
loadingNode={<DetailPageSkeleton />}
|
||||||
|
errorNode={errorNode}
|
||||||
|
emptyNode={emptyNode}
|
||||||
|
>
|
||||||
|
{data ? <DiagnosticDetailBody detail={data} /> : null}
|
||||||
|
{data ? <WeakPointsSection weakPoints={data.weak_points} /> : null}
|
||||||
|
{data ? (
|
||||||
|
<RecommendationsSection recommendations={data.recommendations} />
|
||||||
|
) : null}
|
||||||
|
</DetailPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情基本信息区。
|
||||||
|
*/
|
||||||
|
function DiagnosticDetailBody({
|
||||||
|
detail,
|
||||||
|
}: {
|
||||||
|
detail: NonNullable<ReturnType<typeof useDiagnosticReport>["data"]>;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("diagnostic");
|
||||||
|
return (
|
||||||
|
<DetailSection title={t("detail.sectionBasic")}>
|
||||||
|
<DetailField label={t("detail.fieldTitle")} value={detail.title} />
|
||||||
|
<DetailField
|
||||||
|
label={t("detail.fieldReportType")}
|
||||||
|
value={formatReportType(detail.report_type)}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("detail.fieldStatus")}
|
||||||
|
value={<DiagnosticStatusBadge status={detail.status} />}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("detail.fieldClassName")}
|
||||||
|
value={detail.class_name}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("detail.fieldStudentCount")}
|
||||||
|
value={formatStudentCount(detail.student_count)}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("detail.fieldAvgScore")}
|
||||||
|
value={
|
||||||
|
<span className={avgScoreToColorClass(detail.avg_score)}>
|
||||||
|
{formatAvgScore(detail.avg_score)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("detail.fieldSummary")}
|
||||||
|
value={detail.summary || t("detail.noSummary")}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("detail.fieldGeneratedAt")}
|
||||||
|
value={formatDiagnosticDate(detail.generated_at)}
|
||||||
|
/>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 薄弱知识点区。
|
||||||
|
*/
|
||||||
|
function WeakPointsSection({
|
||||||
|
weakPoints,
|
||||||
|
}: {
|
||||||
|
weakPoints: NonNullable<
|
||||||
|
ReturnType<typeof useDiagnosticReport>["data"]
|
||||||
|
>["weak_points"];
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("diagnostic");
|
||||||
|
if (weakPoints.length === 0) {
|
||||||
|
return (
|
||||||
|
<DetailSection title={t("detail.sectionWeakPoints")}>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("detail.noWeakPoints")}
|
||||||
|
</p>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DetailSection title={t("detail.sectionWeakPoints")}>
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("detail.colKpTitle")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("detail.colMastery")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("detail.colErrorCount")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{weakPoints.map((wp) => (
|
||||||
|
<tr key={wp.knowledge_point_id} className="hover:bg-muted/30">
|
||||||
|
<td className="p-3 font-medium">{wp.title}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<span className={masteryToColorClass(wp.mastery)}>
|
||||||
|
{formatMastery(wp.mastery)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">{wp.error_count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教学建议区。
|
||||||
|
*/
|
||||||
|
function RecommendationsSection({
|
||||||
|
recommendations,
|
||||||
|
}: {
|
||||||
|
recommendations: string[];
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("diagnostic");
|
||||||
|
if (recommendations.length === 0) {
|
||||||
|
return (
|
||||||
|
<DetailSection title={t("detail.sectionRecommendations")}>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("detail.noRecommendations")}
|
||||||
|
</p>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DetailSection title={t("detail.sectionRecommendations")}>
|
||||||
|
<ul className="list-inside list-decimal space-y-2 text-sm">
|
||||||
|
{recommendations.map((rec, idx) => (
|
||||||
|
<li key={idx}>{rec}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断报告状态徽章。
|
||||||
|
*/
|
||||||
|
function DiagnosticStatusBadge({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const label = formatDiagnosticStatus(status);
|
||||||
|
const cls = diagnosticStatusToBadgeClass(status);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 包裹在 <Suspense> 中。
|
||||||
|
*/
|
||||||
|
export function DiagnosticListClient(): React.ReactElement {
|
||||||
|
const t = useTranslations("diagnostic");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const q = searchParams.get("q") ?? "";
|
||||||
|
const statusFilter = searchParams.get("status") ?? "";
|
||||||
|
|
||||||
|
// ✅ 真实 schema 查询
|
||||||
|
const { data, loading, error } = useDiagnosticReports();
|
||||||
|
|
||||||
|
// 客户端二次筛选(q + status)
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (
|
||||||
|
q &&
|
||||||
|
!item.title.toLowerCase().includes(q.toLowerCase()) &&
|
||||||
|
!item.summary.toLowerCase().includes(q.toLowerCase())
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (statusFilter && item.status !== statusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [data, q, statusFilter]);
|
||||||
|
|
||||||
|
const publishedCount = useMemo(
|
||||||
|
() => filterPublishedReports(data?.items ?? []).length,
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateQuery = (key: string, value: string): void => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`/shell/teacher/diagnostic?${params.toString()}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorNode = error ? (
|
||||||
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{tCommon("error.loadFailed", { message: String(error) })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListPageShell
|
||||||
|
title={t("list.title")}
|
||||||
|
description={t("list.description")}
|
||||||
|
icon={<Stethoscope className="size-6" />}
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<FilterSearchInput
|
||||||
|
placeholder={t("list.searchPlaceholder")}
|
||||||
|
value={q}
|
||||||
|
onChange={(v) => updateQuery("q", v)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => updateQuery("status", e.target.value)}
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
aria-label={t("list.statusFilter")}
|
||||||
|
>
|
||||||
|
<option value="">{t("list.statusAll")}</option>
|
||||||
|
<option value="DRAFT">{t("list.statusDraft")}</option>
|
||||||
|
<option value="GENERATED">{t("list.statusGenerated")}</option>
|
||||||
|
<option value="PUBLISHED">{t("list.statusPublished")}</option>
|
||||||
|
<option value="ARCHIVED">{t("list.statusArchived")}</option>
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
loading={loading}
|
||||||
|
loadingNode={<ListPageSkeleton rows={5} />}
|
||||||
|
empty={filteredItems.length === 0 && !loading}
|
||||||
|
errorNode={errorNode}
|
||||||
|
pagination={
|
||||||
|
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{t("list.publishedCount", { count: publishedCount })}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DiagnosticTable items={filteredItems} />
|
||||||
|
</ListPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断报告列表表格。
|
||||||
|
*/
|
||||||
|
function DiagnosticTable({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: NonNullable<ReturnType<typeof useDiagnosticReports>["data"]>["items"];
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("diagnostic");
|
||||||
|
if (items.length === 0) return <></>;
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3 text-left font-medium">{t("list.colTitle")}</th>
|
||||||
|
<th className="p-3 text-left font-medium">{t("list.colType")}</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("list.colStudentId")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("list.colGeneratedAt")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-right font-medium">
|
||||||
|
{t("list.colActions")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={`${item.student_id}-${item.report_id}`}
|
||||||
|
className="hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<td className="p-3">
|
||||||
|
<span className="font-medium">{item.title}</span>
|
||||||
|
{item.summary ? (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{item.summary.slice(0, 60) +
|
||||||
|
(item.summary.length > 60 ? "..." : "")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-xs">
|
||||||
|
{formatReportType(item.report_type)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||||
|
{item.student_id}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<DiagnosticStatusBadge status={item.status} />
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-mono text-xs">
|
||||||
|
{formatDiagnosticDate(item.generated_at)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/shell/teacher/diagnostic/class/${item.student_id}`}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t("list.viewClassDetail")}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断报告状态徽章。
|
||||||
|
*/
|
||||||
|
function DiagnosticStatusBadge({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const label = formatDiagnosticStatus(status);
|
||||||
|
const cls = diagnosticStatusToBadgeClass(status);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Diagnostic 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||||
|
*
|
||||||
|
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||||
|
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DiagnosticReport, DiagnosticReportStatus } from "@/lib/api";
|
||||||
|
|
||||||
|
/** 诊断报告状态中文标签映射 */
|
||||||
|
export const DIAGNOSTIC_STATUS_LABEL: Record<string, string> = {
|
||||||
|
DRAFT: "草稿",
|
||||||
|
GENERATED: "已生成",
|
||||||
|
PUBLISHED: "已发布",
|
||||||
|
ARCHIVED: "已归档",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将诊断报告状态枚举值映射为中文标签。
|
||||||
|
* 未知状态回退为原始值。
|
||||||
|
*/
|
||||||
|
export function formatDiagnosticStatus(status: string): string {
|
||||||
|
return DIAGNOSTIC_STATUS_LABEL[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据诊断报告状态返回 Tailwind 徽章语义类名。
|
||||||
|
*/
|
||||||
|
export function diagnosticStatusToBadgeClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "DRAFT":
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
case "GENERATED":
|
||||||
|
return "bg-primary/10 text-primary";
|
||||||
|
case "PUBLISHED":
|
||||||
|
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||||
|
case "ARCHIVED":
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
|
||||||
|
* 输入无效时返回占位符。
|
||||||
|
*/
|
||||||
|
export function formatDiagnosticDate(
|
||||||
|
isoDate: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!isoDate) return "--";
|
||||||
|
const d = new Date(isoDate);
|
||||||
|
if (Number.isNaN(d.getTime())) return "--";
|
||||||
|
return d.toLocaleString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化掌握度(0-1 浮点)为百分比字符串。
|
||||||
|
* 输入无效或越界返回 "--"。
|
||||||
|
*/
|
||||||
|
export function formatMastery(mastery: number | null | undefined): string {
|
||||||
|
if (
|
||||||
|
mastery == null ||
|
||||||
|
!Number.isFinite(mastery) ||
|
||||||
|
mastery < 0 ||
|
||||||
|
mastery > 1
|
||||||
|
)
|
||||||
|
return "--";
|
||||||
|
return `${Math.round(mastery * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化平均分(保留 1 位小数)。
|
||||||
|
* 输入无效返回 "--"。
|
||||||
|
*/
|
||||||
|
export function formatAvgScore(score: number | null | undefined): string {
|
||||||
|
if (score == null || !Number.isFinite(score)) return "--";
|
||||||
|
return score.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化学生人数。
|
||||||
|
*/
|
||||||
|
export function formatStudentCount(count: number | null | undefined): string {
|
||||||
|
if (count == null || !Number.isFinite(count) || count < 0) return "--";
|
||||||
|
return `${count} 人`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据掌握度(0-1)返回 Tailwind 文本语义类名。
|
||||||
|
* - < 0.4 → destructive(低掌握)
|
||||||
|
* - < 0.7 → amber(中掌握)
|
||||||
|
* - 其他 → emerald(高掌握)
|
||||||
|
*/
|
||||||
|
export function masteryToColorClass(mastery: number): string {
|
||||||
|
if (!Number.isFinite(mastery) || mastery < 0 || mastery > 1) {
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
if (mastery < 0.4) return "text-destructive";
|
||||||
|
if (mastery < 0.7) return "text-amber-600 dark:text-amber-400";
|
||||||
|
return "text-emerald-600 dark:text-emerald-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据平均分返回 Tailwind 文本语义类名。
|
||||||
|
* - < 60 → destructive
|
||||||
|
* - < 80 → amber
|
||||||
|
* - 其他 → emerald
|
||||||
|
*/
|
||||||
|
export function avgScoreToColorClass(score: number): string {
|
||||||
|
if (!Number.isFinite(score) || score < 0) {
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
if (score < 60) return "text-destructive";
|
||||||
|
if (score < 80) return "text-amber-600 dark:text-amber-400";
|
||||||
|
return "text-emerald-600 dark:text-emerald-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断诊断报告是否已发布。
|
||||||
|
*/
|
||||||
|
export function isDiagnosticPublished(status: string): boolean {
|
||||||
|
return status === "PUBLISHED";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断诊断报告是否为草稿。
|
||||||
|
*/
|
||||||
|
export function isDiagnosticDraft(status: string): boolean {
|
||||||
|
return status === "DRAFT";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 DiagnosticReportStatus 类型守卫:判断字符串是否为合法状态。
|
||||||
|
*/
|
||||||
|
export function isValidDiagnosticStatus(
|
||||||
|
status: string,
|
||||||
|
): status is DiagnosticReportStatus {
|
||||||
|
return (
|
||||||
|
status === "DRAFT" ||
|
||||||
|
status === "GENERATED" ||
|
||||||
|
status === "PUBLISHED" ||
|
||||||
|
status === "ARCHIVED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断诊断报告是否有摘要。
|
||||||
|
*/
|
||||||
|
export function hasSummary(summary: string | null | undefined): boolean {
|
||||||
|
return Boolean(summary && summary.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 DiagnosticReport 提取报告类型中文标签。
|
||||||
|
*/
|
||||||
|
export function formatReportType(reportType: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
WEEKLY: "周报",
|
||||||
|
MONTHLY: "月报",
|
||||||
|
EXAM: "考试诊断",
|
||||||
|
UNIT: "单元诊断",
|
||||||
|
};
|
||||||
|
return map[reportType] ?? reportType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从诊断报告列表中提取已发布报告。
|
||||||
|
*/
|
||||||
|
export function filterPublishedReports(
|
||||||
|
reports: DiagnosticReport[],
|
||||||
|
): DiagnosticReport[] {
|
||||||
|
return reports.filter((r) => isDiagnosticPublished(r.status));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 包裹在 <Suspense> 中
|
||||||
|
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||||
|
*/
|
||||||
|
export function ErrorBookListClient(): React.ReactElement {
|
||||||
|
const t = useTranslations("errorBook");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const q = searchParams.get("q") ?? "";
|
||||||
|
const knowledgePointFilter = searchParams.get("knowledgePoint") ?? "";
|
||||||
|
|
||||||
|
// ✅ 真实 schema 查询
|
||||||
|
const { data: itemsData, loading, error } = useErrorBookItems();
|
||||||
|
const { data: stats } = useTeacherErrorBookStats();
|
||||||
|
|
||||||
|
// 客户端二次筛选(q + knowledgePoint)
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const items = itemsData?.items ?? [];
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (
|
||||||
|
q &&
|
||||||
|
!item.knowledge_point_title.toLowerCase().includes(q.toLowerCase()) &&
|
||||||
|
!item.content.toLowerCase().includes(q.toLowerCase())
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
knowledgePointFilter &&
|
||||||
|
item.knowledge_point_id !== knowledgePointFilter
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [itemsData, q, knowledgePointFilter]);
|
||||||
|
|
||||||
|
// 知识点选项(从 stats 派生)
|
||||||
|
const knowledgePointOptions = useMemo(
|
||||||
|
() => sortKnowledgePointsByErrorRate(stats),
|
||||||
|
[stats],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateQuery = (key: string, value: string): void => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`/shell/teacher/error-book?${params.toString()}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorNode = error ? (
|
||||||
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{tCommon("error.loadFailed", { message: String(error) })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListPageShell
|
||||||
|
title={t("list.title")}
|
||||||
|
description={t("list.description")}
|
||||||
|
icon={<BookX className="size-6" />}
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<FilterSearchInput
|
||||||
|
placeholder={t("list.searchPlaceholder")}
|
||||||
|
value={q}
|
||||||
|
onChange={(v) => updateQuery("q", v)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={knowledgePointFilter}
|
||||||
|
onChange={(e) => updateQuery("knowledgePoint", e.target.value)}
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
aria-label={t("list.knowledgePointFilter")}
|
||||||
|
>
|
||||||
|
<option value="">{t("list.allKnowledgePoints")}</option>
|
||||||
|
{knowledgePointOptions.map((kp) => (
|
||||||
|
<option key={kp.knowledge_point_id} value={kp.knowledge_point_id}>
|
||||||
|
{kp.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
loading={loading}
|
||||||
|
loadingNode={<ListPageSkeleton rows={5} />}
|
||||||
|
empty={filteredItems.length === 0 && !loading}
|
||||||
|
errorNode={errorNode}
|
||||||
|
pagination={
|
||||||
|
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{stats ? <StatsSection stats={stats} /> : null}
|
||||||
|
<ErrorBookTable items={filteredItems} />
|
||||||
|
</ListPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计卡片区(顶部 4 个卡片)。
|
||||||
|
*/
|
||||||
|
function StatsSection({
|
||||||
|
stats,
|
||||||
|
}: {
|
||||||
|
stats: NonNullable<ReturnType<typeof useTeacherErrorBookStats>["data"]>;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("errorBook");
|
||||||
|
const kpList = sortKnowledgePointsByErrorRate(stats);
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
label={t("stats.totalErrorQuestions")}
|
||||||
|
value={String(stats.total_error_questions)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("stats.totalErrorCount")}
|
||||||
|
value={formatErrorCount(stats.total_error_count)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("stats.recent7dErrors")}
|
||||||
|
value={formatErrorCount(stats.recent_7d_errors)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("stats.knowledgePointCount")}
|
||||||
|
value={String(kpList.length)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个统计卡片。
|
||||||
|
*/
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错题列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||||
|
*/
|
||||||
|
function ErrorBookTable({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: NonNullable<ReturnType<typeof useErrorBookItems>["data"]>["items"];
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("errorBook");
|
||||||
|
if (items.length === 0) return <></>;
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("list.colKnowledgePoint")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("list.colErrorCount")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("list.colLastErrorTime")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">
|
||||||
|
{t("list.colContent")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={`${item.student_id}-${item.question_id}`}
|
||||||
|
className="hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<td className="p-3">
|
||||||
|
<span className="font-medium">
|
||||||
|
{item.knowledge_point_title}
|
||||||
|
</span>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{item.knowledge_point_id}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isHighErrorRate(
|
||||||
|
item.error_count > 0 ? item.error_count / 10 : 0,
|
||||||
|
)
|
||||||
|
? "text-destructive"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatErrorCount(item.error_count)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-mono text-xs">
|
||||||
|
{formatErrorBookDate(item.last_error_time)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-xs text-muted-foreground">
|
||||||
|
{hasContent(item.content)
|
||||||
|
? item.content.slice(0, 80) +
|
||||||
|
(item.content.length > 80 ? "..." : "")
|
||||||
|
: t("list.noContent")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识点错误率徽章(用于详情或扩展展示)。
|
||||||
|
*/
|
||||||
|
function ErrorRateBadge({ rate }: { rate: number }): React.ReactElement {
|
||||||
|
const label = formatErrorRate(rate);
|
||||||
|
const cls = errorRateToColorClass(rate);
|
||||||
|
return <span className={`text-xs font-medium ${cls}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止未使用导入警告(ErrorRateBadge 在未来扩展中使用)
|
||||||
|
void ErrorRateBadge;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
245
apps/portal-shell/src/lib/api/analytics.ts
Normal file
245
apps/portal-shell/src/lib/api/analytics.ts
Normal file
@@ -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<LearningTrend | null> {
|
||||||
|
const result = useWidgetQuery<LearningTrendResponse>(
|
||||||
|
GET_LEARNING_TREND_DOC,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
fetchPolicy: options?.fetchPolicy,
|
||||||
|
pollInterval: options?.pollInterval,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: result.data?.learningTrend ?? null,
|
||||||
|
loading: result.loading,
|
||||||
|
error: result.error,
|
||||||
|
refetch: result.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询学生薄弱知识点(✅ 真实 schema,studentWeakness)。
|
||||||
|
*
|
||||||
|
* 关联:ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1
|
||||||
|
*/
|
||||||
|
export function useStudentWeakness(
|
||||||
|
options?: AnalyticsQueryOptions,
|
||||||
|
): UseQueryResult<StudentWeakness | null> {
|
||||||
|
const result = useWidgetQuery<StudentWeaknessResponse>(
|
||||||
|
GET_STUDENT_WEAKNESS_DOC,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
fetchPolicy: options?.fetchPolicy,
|
||||||
|
pollInterval: options?.pollInterval,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: result.data?.studentWeakness ?? null,
|
||||||
|
loading: result.loading,
|
||||||
|
error: result.error,
|
||||||
|
refetch: result.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询学情分析总览(@contract-pending,MSW 兜底)。
|
||||||
|
*
|
||||||
|
* schema 无 analyticsOverview 根字段,由 MSW handlers 返回 mock 数据。
|
||||||
|
* 用于 /shell/teacher/analytics 总览页。
|
||||||
|
*
|
||||||
|
* 关联:ARCHITECTURE.md §5.4 / §9.1 总览页 / §11.4 契约工单
|
||||||
|
*/
|
||||||
|
export function useAnalyticsOverview(
|
||||||
|
options?: AnalyticsQueryOptions,
|
||||||
|
): UseQueryResult<AnalyticsOverview | null> {
|
||||||
|
const result = useWidgetQuery<AnalyticsOverviewResponse>(
|
||||||
|
GET_ANALYTICS_OVERVIEW_DOC,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
fetchPolicy: options?.fetchPolicy,
|
||||||
|
pollInterval: options?.pollInterval,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: result.data?.analyticsOverview ?? null,
|
||||||
|
loading: result.loading,
|
||||||
|
error: result.error,
|
||||||
|
refetch: result.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 studentId 查询单生学情分析(@contract-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<StudentAnalytics | null> {
|
||||||
|
const result = useWidgetQuery<
|
||||||
|
StudentAnalyticsResponse,
|
||||||
|
{ studentId: string }
|
||||||
|
>(
|
||||||
|
GET_STUDENT_ANALYTICS_DOC,
|
||||||
|
{ studentId },
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
enabled: options?.enabled ?? studentId.length > 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: result.data?.studentAnalytics ?? null,
|
||||||
|
loading: result.loading,
|
||||||
|
error: result.error,
|
||||||
|
refetch: result.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
155
apps/portal-shell/src/lib/api/diagnostic.ts
Normal file
155
apps/portal-shell/src/lib/api/diagnostic.ts
Normal file
@@ -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<DiagnosticReportsResponse>(
|
||||||
|
GET_DIAGNOSTIC_REPORTS_DOC,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
fetchPolicy: options?.fetchPolicy,
|
||||||
|
pollInterval: options?.pollInterval,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const raw = result.data?.diagnosticReports ?? [];
|
||||||
|
const items: DiagnosticReportListItem[] = raw.flatMap((group) =>
|
||||||
|
group.reports.map((r) => ({ ...r, student_id: group.student_id })),
|
||||||
|
);
|
||||||
|
const total = raw.reduce((sum, g) => sum + g.total, 0);
|
||||||
|
return {
|
||||||
|
data: { items, total },
|
||||||
|
loading: result.loading,
|
||||||
|
error: result.error,
|
||||||
|
refetch: result.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 classId 查询班级诊断报告详情(@contract-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<DiagnosticReportDetail | null> {
|
||||||
|
const result = useWidgetQuery<DiagnosticReportResponse, { classId: string }>(
|
||||||
|
GET_DIAGNOSTIC_REPORT_DOC,
|
||||||
|
{ classId },
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
enabled: options?.enabled ?? classId.length > 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: result.data?.diagnosticReport ?? null,
|
||||||
|
loading: result.loading,
|
||||||
|
error: result.error,
|
||||||
|
refetch: result.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
151
apps/portal-shell/src/lib/api/error-book.ts
Normal file
151
apps/portal-shell/src/lib/api/error-book.ts
Normal file
@@ -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<ErrorBookItemsResponse>(
|
||||||
|
GET_ERROR_BOOK_ITEMS_DOC,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
fetchPolicy: options?.fetchPolicy,
|
||||||
|
pollInterval: options?.pollInterval,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const raw = result.data?.errorBookItems ?? [];
|
||||||
|
const items: ErrorBookListItem[] = raw.flatMap((group) =>
|
||||||
|
group.items.map((item) => ({ ...item, student_id: group.student_id })),
|
||||||
|
);
|
||||||
|
const total = raw.reduce((sum, g) => sum + g.total, 0);
|
||||||
|
return {
|
||||||
|
data: { items, total },
|
||||||
|
loading: result.loading,
|
||||||
|
error: result.error,
|
||||||
|
refetch: result.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询错题本统计(✅ 真实 schema,errorBookStats)。
|
||||||
|
*
|
||||||
|
* 复用 dashboard.graphql.ts 的 GET_ERROR_BOOK_STATS_DOC(同一 schema 字段)。
|
||||||
|
* 用于错题本列表页顶部统计卡片区。
|
||||||
|
*
|
||||||
|
* 关联:ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 列表页
|
||||||
|
*/
|
||||||
|
export function useTeacherErrorBookStats(
|
||||||
|
options?: ErrorBookQueryOptions,
|
||||||
|
): UseQueryResult<TeacherErrorBookStats | null> {
|
||||||
|
const result = useWidgetQuery<ErrorBookStatsResponse>(
|
||||||
|
GET_ERROR_BOOK_STATS_DOC,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
fetchPolicy: options?.fetchPolicy,
|
||||||
|
pollInterval: options?.pollInterval,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: result.data?.errorBookStats ?? null,
|
||||||
|
loading: result.loading,
|
||||||
|
error: result.error,
|
||||||
|
refetch: result.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,5 +26,8 @@ export * from "./students";
|
|||||||
export * from "./student";
|
export * from "./student";
|
||||||
export * from "./course-plans";
|
export * from "./course-plans";
|
||||||
export * from "./elective";
|
export * from "./elective";
|
||||||
|
export * from "./error-book";
|
||||||
|
export * from "./diagnostic";
|
||||||
|
export * from "./analytics";
|
||||||
export * from "./parent";
|
export * from "./parent";
|
||||||
export * from "./admin";
|
export * from "./admin";
|
||||||
|
|||||||
118
apps/portal-shell/src/lib/api/operations/analytics.graphql.ts
Normal file
118
apps/portal-shell/src/lib/api/operations/analytics.graphql.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// Analytics domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
|
||||||
|
//
|
||||||
|
// 拆分原则:
|
||||||
|
// - learningTrend:✅ combined-schema 中真实存在(learningTrend: LearningTrend)
|
||||||
|
// - studentWeakness:✅ combined-schema 中真实存在(studentWeakness: StudentWeakness)
|
||||||
|
// - analyticsOverview:❌ schema 无此聚合根字段 → MSW 兜底(@contract-pending)
|
||||||
|
// - studentAnalytics(studentId):❌ schema 无此根字段 → MSW 兜底(@contract-pending)
|
||||||
|
//
|
||||||
|
// Schema 缺陷说明(data-ana 子图):
|
||||||
|
// - LearningTrend.points 在 schema 中是单数 TrendPoint 类型(应为列表)
|
||||||
|
// - StudentWeakness.weak_points 在 schema 中是单数 WeakPoint 类型(应为列表)
|
||||||
|
// - operations 按单数对象语法查询,lib/api 层运行时按列表处理(MSW 返回数组)
|
||||||
|
//
|
||||||
|
// 契约工单:docs/architecture/issues/contracts/data-ana_contract.md#analytics
|
||||||
|
// 关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
|
||||||
|
import { gql } from "@apollo/client";
|
||||||
|
|
||||||
|
// ── 真实查询:learningTrend(无参数)──────────────────────────
|
||||||
|
// schema: learningTrend: LearningTrend
|
||||||
|
// LearningTrend { student_id, points: TrendPoint }
|
||||||
|
// 字段全部 snake_case 对齐 data-ana 子图
|
||||||
|
export const GET_LEARNING_TREND_DOC = gql`
|
||||||
|
query GetLearningTrend {
|
||||||
|
learningTrend {
|
||||||
|
student_id
|
||||||
|
points {
|
||||||
|
date
|
||||||
|
score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── 真实查询:studentWeakness(无参数)────────────────────────
|
||||||
|
// schema: studentWeakness: StudentWeakness
|
||||||
|
// StudentWeakness { student_id, weak_points: WeakPoint }
|
||||||
|
// 字段全部 snake_case 对齐 data-ana 子图
|
||||||
|
export const GET_STUDENT_WEAKNESS_DOC = gql`
|
||||||
|
query GetStudentWeakness {
|
||||||
|
studentWeakness {
|
||||||
|
student_id
|
||||||
|
weak_points {
|
||||||
|
knowledge_point_id
|
||||||
|
title
|
||||||
|
mastery
|
||||||
|
error_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── 假契约查询(@contract-pending)─────────────────────────────
|
||||||
|
// 学情分析总览:schema 无 analyticsOverview 根字段 → MSW 兜底
|
||||||
|
// 用于 /shell/teacher/analytics 总览页(聚合班级学情数据)
|
||||||
|
// 契约工单:data-ana_contract.md#analytics-overview
|
||||||
|
export const GET_ANALYTICS_OVERVIEW_DOC = gql`
|
||||||
|
query GetAnalyticsOverview {
|
||||||
|
analyticsOverview {
|
||||||
|
total_students
|
||||||
|
avg_score
|
||||||
|
avg_mastery
|
||||||
|
at_risk_count
|
||||||
|
weak_points {
|
||||||
|
knowledge_point_id
|
||||||
|
title
|
||||||
|
error_count
|
||||||
|
mastery
|
||||||
|
}
|
||||||
|
trends {
|
||||||
|
date
|
||||||
|
avg_score
|
||||||
|
}
|
||||||
|
class_breakdown {
|
||||||
|
class_id
|
||||||
|
class_name
|
||||||
|
avg_score
|
||||||
|
student_count
|
||||||
|
at_risk_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── 假契约查询(@contract-pending)─────────────────────────────
|
||||||
|
// 单生学情分析:schema 无 studentAnalytics(studentId) 根字段 → MSW 兜底
|
||||||
|
// 用于 /shell/teacher/analytics/[studentId] 学生详情页
|
||||||
|
// 契约工单:data-ana_contract.md#student-analytics
|
||||||
|
export const GET_STUDENT_ANALYTICS_DOC = gql`
|
||||||
|
query GetStudentAnalytics($studentId: ID!) {
|
||||||
|
studentAnalytics(studentId: $studentId) {
|
||||||
|
student_id
|
||||||
|
student_name
|
||||||
|
student_no
|
||||||
|
class_id
|
||||||
|
class_name
|
||||||
|
avg_score
|
||||||
|
class_rank
|
||||||
|
total_students
|
||||||
|
weak_points {
|
||||||
|
knowledge_point_id
|
||||||
|
title
|
||||||
|
mastery
|
||||||
|
error_count
|
||||||
|
}
|
||||||
|
trends {
|
||||||
|
date
|
||||||
|
score
|
||||||
|
}
|
||||||
|
recent_exams {
|
||||||
|
exam_id
|
||||||
|
exam_title
|
||||||
|
score
|
||||||
|
total_score
|
||||||
|
date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -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 复用。
|
||||||
@@ -16,5 +16,8 @@ export * from "./students.graphql";
|
|||||||
export * from "./student.graphql";
|
export * from "./student.graphql";
|
||||||
export * from "./course-plans.graphql";
|
export * from "./course-plans.graphql";
|
||||||
export * from "./elective.graphql";
|
export * from "./elective.graphql";
|
||||||
|
export * from "./error-book.graphql";
|
||||||
|
export * from "./diagnostic.graphql";
|
||||||
|
export * from "./analytics.graphql";
|
||||||
export * from "./parent.graphql";
|
export * from "./parent.graphql";
|
||||||
export * from "./admin.graphql";
|
export * from "./admin.graphql";
|
||||||
|
|||||||
@@ -704,7 +704,61 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Analytics"
|
"title": "Analytics",
|
||||||
|
"overview": {
|
||||||
|
"title": "Analytics Overview",
|
||||||
|
"description": "View class-wide learning analytics, weak points and trends",
|
||||||
|
"mswNotice": "Analytics overview contract is @contract-pending, currently backed by MSW. Will switch to real data once backend aggregate field is ready.",
|
||||||
|
"totalStudents": "Total Students",
|
||||||
|
"avgScore": "Avg Score",
|
||||||
|
"avgMastery": "Avg Mastery",
|
||||||
|
"atRiskCount": "At-Risk Students",
|
||||||
|
"sectionWeakPoints": "Weak Points",
|
||||||
|
"colKpTitle": "Knowledge Point",
|
||||||
|
"colErrorCount": "Error Count",
|
||||||
|
"colMastery": "Mastery",
|
||||||
|
"sectionTrends": "Recent Trends",
|
||||||
|
"sectionClassBreakdown": "Class Breakdown",
|
||||||
|
"colClassName": "Class",
|
||||||
|
"colStudentCount": "Students",
|
||||||
|
"colAvgScore": "Avg Score",
|
||||||
|
"colAtRiskCount": "At-Risk",
|
||||||
|
"colActions": "Actions",
|
||||||
|
"viewStudentAnalytics": "View Student Analytics"
|
||||||
|
},
|
||||||
|
"student": {
|
||||||
|
"title": "Student Analytics",
|
||||||
|
"notFound": "No analytics data found for this student",
|
||||||
|
"backToOverview": "Back to Overview",
|
||||||
|
"mswNotice": "Student analytics contract is @contract-pending, currently backed by MSW. Will switch to real data once backend query field is ready.",
|
||||||
|
"subtitle": "{className} · No. {studentNo}",
|
||||||
|
"sectionBasic": "Basic Info",
|
||||||
|
"fieldName": "Name",
|
||||||
|
"fieldStudentNo": "Student No.",
|
||||||
|
"fieldClassName": "Class",
|
||||||
|
"fieldAvgScore": "Avg Score",
|
||||||
|
"fieldClassRank": "Class Rank",
|
||||||
|
"fieldTotalStudents": "Total Students",
|
||||||
|
"sectionWeakPoints": "Weak Points",
|
||||||
|
"noWeakPoints": "No weak points data",
|
||||||
|
"colKpTitle": "Knowledge Point",
|
||||||
|
"colMastery": "Mastery",
|
||||||
|
"colErrorCount": "Error Count",
|
||||||
|
"sectionTrends": "Learning Trends",
|
||||||
|
"noTrends": "No trend data",
|
||||||
|
"sectionRecentExams": "Recent Exams",
|
||||||
|
"noRecentExams": "No recent exam records",
|
||||||
|
"colExamTitle": "Exam",
|
||||||
|
"colScore": "Score",
|
||||||
|
"colScoreRate": "Score Rate",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colExamDate": "Exam Date"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Analytics page error",
|
||||||
|
"unknown": "An unknown error occurred in the analytics module",
|
||||||
|
"retry": "Retry"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"knowledgeGraph": {
|
"knowledgeGraph": {
|
||||||
"title": "Knowledge Graph"
|
"title": "Knowledge Graph"
|
||||||
@@ -1085,10 +1139,83 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"diagnostic": {
|
"diagnostic": {
|
||||||
"title": "Diagnostic"
|
"title": "Diagnostic",
|
||||||
|
"list": {
|
||||||
|
"title": "Diagnostic Reports",
|
||||||
|
"description": "View student and class diagnostic reports",
|
||||||
|
"searchPlaceholder": "Search report title or summary",
|
||||||
|
"statusFilter": "Filter by status",
|
||||||
|
"statusAll": "All statuses",
|
||||||
|
"statusDraft": "Draft",
|
||||||
|
"statusGenerated": "Generated",
|
||||||
|
"statusPublished": "Published",
|
||||||
|
"statusArchived": "Archived",
|
||||||
|
"total": "{count} total",
|
||||||
|
"publishedCount": "{count} published",
|
||||||
|
"colTitle": "Title",
|
||||||
|
"colType": "Type",
|
||||||
|
"colStudentId": "Student ID",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colGeneratedAt": "Generated At",
|
||||||
|
"colActions": "Actions",
|
||||||
|
"viewClassDetail": "View Class Detail"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"title": "Class Diagnostic Report",
|
||||||
|
"notFound": "No diagnostic report found for this class",
|
||||||
|
"backToList": "Back to List",
|
||||||
|
"mswNotice": "Class diagnostic detail contract is @contract-pending, currently backed by MSW. Will switch to real data once backend single-query field is ready.",
|
||||||
|
"generatedAtPrefix": "Generated at {date}",
|
||||||
|
"sectionBasic": "Basic Info",
|
||||||
|
"fieldTitle": "Title",
|
||||||
|
"fieldReportType": "Report Type",
|
||||||
|
"fieldStatus": "Status",
|
||||||
|
"fieldClassName": "Class",
|
||||||
|
"fieldStudentCount": "Student Count",
|
||||||
|
"fieldAvgScore": "Avg Score",
|
||||||
|
"fieldSummary": "Summary",
|
||||||
|
"noSummary": "No summary",
|
||||||
|
"fieldGeneratedAt": "Generated At",
|
||||||
|
"sectionWeakPoints": "Weak Points",
|
||||||
|
"noWeakPoints": "No weak points",
|
||||||
|
"colKpTitle": "Knowledge Point",
|
||||||
|
"colMastery": "Mastery",
|
||||||
|
"colErrorCount": "Error Count",
|
||||||
|
"sectionRecommendations": "Recommendations",
|
||||||
|
"noRecommendations": "No recommendations"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Diagnostic page error",
|
||||||
|
"unknown": "An unknown error occurred in the diagnostic module",
|
||||||
|
"retry": "Retry"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"errorBook": {
|
"errorBook": {
|
||||||
"title": "Error Book"
|
"title": "Error Book",
|
||||||
|
"list": {
|
||||||
|
"title": "Error Book",
|
||||||
|
"description": "View student error records and knowledge point stats",
|
||||||
|
"searchPlaceholder": "Search knowledge point or content",
|
||||||
|
"knowledgePointFilter": "Filter by knowledge point",
|
||||||
|
"allKnowledgePoints": "All knowledge points",
|
||||||
|
"total": "{count} total",
|
||||||
|
"colKnowledgePoint": "Knowledge Point",
|
||||||
|
"colErrorCount": "Error Count",
|
||||||
|
"colLastErrorTime": "Last Error Time",
|
||||||
|
"colContent": "Content",
|
||||||
|
"noContent": "No content"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalErrorQuestions": "Total Error Questions",
|
||||||
|
"totalErrorCount": "Total Error Count",
|
||||||
|
"recent7dErrors": "Recent 7d Errors",
|
||||||
|
"knowledgePointCount": "Knowledge Points"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Error Book page error",
|
||||||
|
"unknown": "An unknown error occurred in the error book module",
|
||||||
|
"retry": "Retry"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"practice": {
|
"practice": {
|
||||||
"title": "Practice"
|
"title": "Practice"
|
||||||
|
|||||||
@@ -704,7 +704,61 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "学情分析"
|
"title": "学情分析",
|
||||||
|
"overview": {
|
||||||
|
"title": "学情分析总览",
|
||||||
|
"description": "查看班级整体学情、薄弱知识点与趋势",
|
||||||
|
"mswNotice": "学情总览契约为 @contract-pending,当前通过 MSW 兜底。后端补齐聚合根字段后将切换为真实数据。",
|
||||||
|
"totalStudents": "学生总数",
|
||||||
|
"avgScore": "平均分",
|
||||||
|
"avgMastery": "平均掌握度",
|
||||||
|
"atRiskCount": "预警学生数",
|
||||||
|
"sectionWeakPoints": "薄弱知识点",
|
||||||
|
"colKpTitle": "知识点",
|
||||||
|
"colErrorCount": "错误次数",
|
||||||
|
"colMastery": "掌握度",
|
||||||
|
"sectionTrends": "近期趋势",
|
||||||
|
"sectionClassBreakdown": "班级分解",
|
||||||
|
"colClassName": "班级",
|
||||||
|
"colStudentCount": "学生数",
|
||||||
|
"colAvgScore": "平均分",
|
||||||
|
"colAtRiskCount": "预警数",
|
||||||
|
"colActions": "操作",
|
||||||
|
"viewStudentAnalytics": "查看学生分析"
|
||||||
|
},
|
||||||
|
"student": {
|
||||||
|
"title": "学生学情分析",
|
||||||
|
"notFound": "未找到该学生的学情数据",
|
||||||
|
"backToOverview": "返回总览",
|
||||||
|
"mswNotice": "单生学情分析契约为 @contract-pending,当前通过 MSW 兜底。后端补齐查询字段后将切换为真实数据。",
|
||||||
|
"subtitle": "{className} · 学号 {studentNo}",
|
||||||
|
"sectionBasic": "基本信息",
|
||||||
|
"fieldName": "姓名",
|
||||||
|
"fieldStudentNo": "学号",
|
||||||
|
"fieldClassName": "班级",
|
||||||
|
"fieldAvgScore": "平均分",
|
||||||
|
"fieldClassRank": "班级排名",
|
||||||
|
"fieldTotalStudents": "班级总人数",
|
||||||
|
"sectionWeakPoints": "薄弱知识点",
|
||||||
|
"noWeakPoints": "暂无薄弱知识点数据",
|
||||||
|
"colKpTitle": "知识点",
|
||||||
|
"colMastery": "掌握度",
|
||||||
|
"colErrorCount": "错误次数",
|
||||||
|
"sectionTrends": "学习趋势",
|
||||||
|
"noTrends": "暂无趋势数据",
|
||||||
|
"sectionRecentExams": "近期考试",
|
||||||
|
"noRecentExams": "暂无近期考试记录",
|
||||||
|
"colExamTitle": "考试",
|
||||||
|
"colScore": "得分",
|
||||||
|
"colScoreRate": "得分率",
|
||||||
|
"colLevel": "等级",
|
||||||
|
"colExamDate": "考试日期"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "学情分析页面出错了",
|
||||||
|
"unknown": "学情分析模块发生未知错误",
|
||||||
|
"retry": "重试"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"knowledgeGraph": {
|
"knowledgeGraph": {
|
||||||
"title": "知识图谱"
|
"title": "知识图谱"
|
||||||
@@ -1085,10 +1139,83 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"diagnostic": {
|
"diagnostic": {
|
||||||
"title": "诊断报告"
|
"title": "诊断报告",
|
||||||
|
"list": {
|
||||||
|
"title": "诊断报告",
|
||||||
|
"description": "查看学生与班级的诊断报告",
|
||||||
|
"searchPlaceholder": "搜索报告标题或摘要",
|
||||||
|
"statusFilter": "按状态筛选",
|
||||||
|
"statusAll": "全部状态",
|
||||||
|
"statusDraft": "草稿",
|
||||||
|
"statusGenerated": "已生成",
|
||||||
|
"statusPublished": "已发布",
|
||||||
|
"statusArchived": "已归档",
|
||||||
|
"total": "共 {count} 条",
|
||||||
|
"publishedCount": "已发布 {count} 条",
|
||||||
|
"colTitle": "标题",
|
||||||
|
"colType": "类型",
|
||||||
|
"colStudentId": "学生 ID",
|
||||||
|
"colStatus": "状态",
|
||||||
|
"colGeneratedAt": "生成时间",
|
||||||
|
"colActions": "操作",
|
||||||
|
"viewClassDetail": "查看班级详情"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"title": "班级诊断报告",
|
||||||
|
"notFound": "未找到该班级的诊断报告",
|
||||||
|
"backToList": "返回列表",
|
||||||
|
"mswNotice": "班级诊断详情契约为 @contract-pending,当前通过 MSW 兜底。后端补齐单查字段后将切换为真实数据。",
|
||||||
|
"generatedAtPrefix": "生成于 {date}",
|
||||||
|
"sectionBasic": "基本信息",
|
||||||
|
"fieldTitle": "标题",
|
||||||
|
"fieldReportType": "报告类型",
|
||||||
|
"fieldStatus": "状态",
|
||||||
|
"fieldClassName": "班级",
|
||||||
|
"fieldStudentCount": "学生数",
|
||||||
|
"fieldAvgScore": "平均分",
|
||||||
|
"fieldSummary": "摘要",
|
||||||
|
"noSummary": "暂无摘要",
|
||||||
|
"fieldGeneratedAt": "生成时间",
|
||||||
|
"sectionWeakPoints": "薄弱知识点",
|
||||||
|
"noWeakPoints": "暂无薄弱知识点",
|
||||||
|
"colKpTitle": "知识点",
|
||||||
|
"colMastery": "掌握度",
|
||||||
|
"colErrorCount": "错误次数",
|
||||||
|
"sectionRecommendations": "教学建议",
|
||||||
|
"noRecommendations": "暂无教学建议"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "诊断报告页面出错了",
|
||||||
|
"unknown": "诊断报告模块发生未知错误",
|
||||||
|
"retry": "重试"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"errorBook": {
|
"errorBook": {
|
||||||
"title": "错题本"
|
"title": "错题本",
|
||||||
|
"list": {
|
||||||
|
"title": "错题本",
|
||||||
|
"description": "查看学生的错题记录与知识点统计",
|
||||||
|
"searchPlaceholder": "搜索知识点或内容",
|
||||||
|
"knowledgePointFilter": "按知识点筛选",
|
||||||
|
"allKnowledgePoints": "全部知识点",
|
||||||
|
"total": "共 {count} 条",
|
||||||
|
"colKnowledgePoint": "知识点",
|
||||||
|
"colErrorCount": "错误次数",
|
||||||
|
"colLastErrorTime": "最近错误时间",
|
||||||
|
"colContent": "内容",
|
||||||
|
"noContent": "无内容"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalErrorQuestions": "错题总数",
|
||||||
|
"totalErrorCount": "错误次数",
|
||||||
|
"recent7dErrors": "近 7 天错误",
|
||||||
|
"knowledgePointCount": "知识点数"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "错题本页面出错了",
|
||||||
|
"unknown": "错题本模块发生未知错误",
|
||||||
|
"retry": "重试"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"practice": {
|
"practice": {
|
||||||
"title": "练习分析"
|
"title": "练习分析"
|
||||||
|
|||||||
@@ -3475,6 +3475,317 @@ export function graphqlResponse(
|
|||||||
return { data: { updateElective: { id: eleId } } };
|
return { data: { updateElective: { id: eleId } } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Error Book 域(教师域 B2 迁移,✅ 真实查询 MSW 兜底)──
|
||||||
|
// schema: errorBookItems: [ErrorBookList!]!(无参数)
|
||||||
|
// GetErrorBookItems:返回 mock 错题本列表(按 student 聚合,items 为数组)
|
||||||
|
case "GetErrorBookItems": {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
errorBookItems: [
|
||||||
|
{
|
||||||
|
student_id: "stu-001",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question_id: "q-001",
|
||||||
|
knowledge_point_id: "kp-001",
|
||||||
|
knowledge_point_title: "二次函数",
|
||||||
|
error_count: 8,
|
||||||
|
last_error_time: "2026-07-20T10:30:00Z",
|
||||||
|
content: "已知 f(x)=2x²+3x-1,求 f(2) 的值。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question_id: "q-002",
|
||||||
|
knowledge_point_id: "kp-002",
|
||||||
|
knowledge_point_title: "概率统计",
|
||||||
|
error_count: 5,
|
||||||
|
last_error_time: "2026-07-18T14:00:00Z",
|
||||||
|
content: "从 5 男 3 女中选 3 人,求恰好 2 男 1 女的概率。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
student_id: "stu-002",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question_id: "q-003",
|
||||||
|
knowledge_point_id: "kp-001",
|
||||||
|
knowledge_point_title: "二次函数",
|
||||||
|
error_count: 3,
|
||||||
|
last_error_time: "2026-07-19T09:00:00Z",
|
||||||
|
content: "求 y=x²-4x+3 的顶点坐标。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Diagnostic 域(教师域 B2 迁移)──
|
||||||
|
// GetDiagnosticReports:✅ 真实查询 diagnosticReports(无参数),MSW 兜底
|
||||||
|
case "GetDiagnosticReports": {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
diagnosticReports: [
|
||||||
|
{
|
||||||
|
student_id: "stu-001",
|
||||||
|
reports: [
|
||||||
|
{
|
||||||
|
report_id: "rpt-001",
|
||||||
|
student_id: "stu-001",
|
||||||
|
report_type: "WEEKLY",
|
||||||
|
title: "第一周学情诊断",
|
||||||
|
summary:
|
||||||
|
"本周整体表现良好,但二次函数掌握不足,建议加强练习。",
|
||||||
|
generated_at: "2026-07-22T08:00:00Z",
|
||||||
|
status: "PUBLISHED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
report_id: "rpt-002",
|
||||||
|
student_id: "stu-001",
|
||||||
|
report_type: "UNIT",
|
||||||
|
title: "函数单元诊断",
|
||||||
|
summary: "函数概念理解到位,但复合函数求值需加强。",
|
||||||
|
generated_at: "2026-07-15T08:00:00Z",
|
||||||
|
status: "PUBLISHED",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
student_id: "stu-002",
|
||||||
|
reports: [
|
||||||
|
{
|
||||||
|
report_id: "rpt-003",
|
||||||
|
student_id: "stu-002",
|
||||||
|
report_type: "MONTHLY",
|
||||||
|
title: "七月学情月报",
|
||||||
|
summary: "本月进步明显,概率统计仍有薄弱点。",
|
||||||
|
generated_at: "2026-07-20T08:00:00Z",
|
||||||
|
status: "GENERATED",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// GetDiagnosticReport($classId):@contract-pending 班级诊断详情,MSW 兜底
|
||||||
|
case "GetDiagnosticReport": {
|
||||||
|
const classId = (variables?.classId as string | undefined) ?? "";
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
diagnosticReport: {
|
||||||
|
report_id: "rpt-cls-001",
|
||||||
|
student_id: classId,
|
||||||
|
report_type: "UNIT",
|
||||||
|
title: "班级函数单元诊断报告",
|
||||||
|
summary:
|
||||||
|
"本班函数单元整体掌握度 72%,二次函数薄弱,建议增加针对性练习。",
|
||||||
|
generated_at: "2026-07-21T10:00:00Z",
|
||||||
|
status: "PUBLISHED",
|
||||||
|
class_id: classId,
|
||||||
|
class_name: "高三(1)班",
|
||||||
|
student_count: 38,
|
||||||
|
avg_score: 72.5,
|
||||||
|
weak_points: [
|
||||||
|
{
|
||||||
|
knowledge_point_id: "kp-001",
|
||||||
|
title: "二次函数",
|
||||||
|
mastery: 0.45,
|
||||||
|
error_count: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
knowledge_point_id: "kp-002",
|
||||||
|
title: "概率统计",
|
||||||
|
mastery: 0.62,
|
||||||
|
error_count: 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
recommendations: [
|
||||||
|
"针对二次函数安排 2 课时专项复习",
|
||||||
|
"组织小组讨论解决概率统计常见误区",
|
||||||
|
"每周安排一次函数单元小测跟踪进度",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Analytics 域(教师域 B2 迁移)──
|
||||||
|
// GetLearningTrend:✅ 真实查询 learningTrend(无参数),MSW 兜底
|
||||||
|
case "GetLearningTrend": {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
learningTrend: {
|
||||||
|
student_id: "stu-001",
|
||||||
|
points: [
|
||||||
|
{ date: "2026-07-01", score: 75 },
|
||||||
|
{ date: "2026-07-08", score: 78 },
|
||||||
|
{ date: "2026-07-15", score: 82 },
|
||||||
|
{ date: "2026-07-22", score: 85 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// GetStudentWeakness:✅ 真实查询 studentWeakness(无参数),MSW 兜底
|
||||||
|
case "GetStudentWeakness": {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
studentWeakness: {
|
||||||
|
student_id: "stu-001",
|
||||||
|
weak_points: [
|
||||||
|
{
|
||||||
|
knowledge_point_id: "kp-001",
|
||||||
|
title: "二次函数",
|
||||||
|
mastery: 0.45,
|
||||||
|
error_count: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
knowledge_point_id: "kp-002",
|
||||||
|
title: "概率统计",
|
||||||
|
mastery: 0.62,
|
||||||
|
error_count: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// GetAnalyticsOverview:@contract-pending 总览聚合,MSW 兜底
|
||||||
|
case "GetAnalyticsOverview": {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
analyticsOverview: {
|
||||||
|
total_students: 142,
|
||||||
|
avg_score: 78.5,
|
||||||
|
avg_mastery: 0.72,
|
||||||
|
at_risk_count: 12,
|
||||||
|
weak_points: [
|
||||||
|
{
|
||||||
|
knowledge_point_id: "kp-001",
|
||||||
|
title: "二次函数",
|
||||||
|
error_count: 45,
|
||||||
|
mastery: 0.45,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
knowledge_point_id: "kp-002",
|
||||||
|
title: "概率统计",
|
||||||
|
error_count: 32,
|
||||||
|
mastery: 0.62,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
knowledge_point_id: "kp-003",
|
||||||
|
title: "三角函数",
|
||||||
|
error_count: 28,
|
||||||
|
mastery: 0.68,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
trends: [
|
||||||
|
{ date: "2026-07-01", avg_score: 75.2 },
|
||||||
|
{ date: "2026-07-08", avg_score: 76.8 },
|
||||||
|
{ date: "2026-07-15", avg_score: 77.5 },
|
||||||
|
{ date: "2026-07-22", avg_score: 78.5 },
|
||||||
|
],
|
||||||
|
class_breakdown: [
|
||||||
|
{
|
||||||
|
class_id: "cls-001",
|
||||||
|
class_name: "高三(1)班",
|
||||||
|
avg_score: 82.3,
|
||||||
|
student_count: 38,
|
||||||
|
at_risk_count: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
class_id: "cls-002",
|
||||||
|
class_name: "高三(2)班",
|
||||||
|
avg_score: 75.1,
|
||||||
|
student_count: 40,
|
||||||
|
at_risk_count: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
class_id: "cls-003",
|
||||||
|
class_name: "高三(3)班",
|
||||||
|
avg_score: 78.0,
|
||||||
|
student_count: 36,
|
||||||
|
at_risk_count: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// GetStudentAnalytics($studentId):@contract-pending 单生分析,MSW 兜底
|
||||||
|
case "GetStudentAnalytics": {
|
||||||
|
const studentId = (variables?.studentId as string | undefined) ?? "";
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
studentAnalytics: {
|
||||||
|
student_id: studentId,
|
||||||
|
student_name: "张明",
|
||||||
|
student_no: "202601001",
|
||||||
|
class_id: "cls-001",
|
||||||
|
class_name: "高三(1)班",
|
||||||
|
avg_score: 85.2,
|
||||||
|
class_rank: 5,
|
||||||
|
total_students: 38,
|
||||||
|
weak_points: [
|
||||||
|
{
|
||||||
|
knowledge_point_id: "kp-001",
|
||||||
|
title: "二次函数",
|
||||||
|
mastery: 0.45,
|
||||||
|
error_count: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
knowledge_point_id: "kp-002",
|
||||||
|
title: "概率统计",
|
||||||
|
mastery: 0.62,
|
||||||
|
error_count: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
knowledge_point_id: "kp-004",
|
||||||
|
title: "导数应用",
|
||||||
|
mastery: 0.71,
|
||||||
|
error_count: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
trends: [
|
||||||
|
{ date: "2026-07-01", score: 78 },
|
||||||
|
{ date: "2026-07-08", score: 82 },
|
||||||
|
{ date: "2026-07-15", score: 80 },
|
||||||
|
{ date: "2026-07-22", score: 85 },
|
||||||
|
],
|
||||||
|
recent_exams: [
|
||||||
|
{
|
||||||
|
exam_id: "exam-001",
|
||||||
|
exam_title: "函数单元测试",
|
||||||
|
score: 82,
|
||||||
|
total_score: 100,
|
||||||
|
date: "2026-07-10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exam_id: "exam-002",
|
||||||
|
exam_title: "概率统计小测",
|
||||||
|
score: 88,
|
||||||
|
total_score: 100,
|
||||||
|
date: "2026-07-17",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exam_id: "exam-003",
|
||||||
|
exam_title: "期末模拟考",
|
||||||
|
score: 85,
|
||||||
|
total_score: 100,
|
||||||
|
date: "2026-07-20",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── 通用 ──
|
// ── 通用 ──
|
||||||
case "GetNotificationsList":
|
case "GetNotificationsList":
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -185,6 +185,24 @@ export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
|||||||
requiredRoles: ["teacher", "admin"],
|
requiredRoles: ["teacher", "admin"],
|
||||||
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_MANAGE"],
|
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_MANAGE"],
|
||||||
},
|
},
|
||||||
|
// P2 迁移(B3 教师域):错题本列表页根路由(无尾斜杠)
|
||||||
|
// 无 ERROR_BOOK_* 教师权限点(student 有 ERROR_BOOK_READ),复用 CLASS_READ/CLASS_MANAGE
|
||||||
|
"/shell/teacher/error-book": {
|
||||||
|
requiredRoles: ["teacher", "admin"],
|
||||||
|
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||||
|
},
|
||||||
|
// P2 迁移(B3 教师域):诊断报告列表页根路由(无尾斜杠)
|
||||||
|
// 复用 DIAGNOSTIC_READ/DIAGNOSTIC_MANAGE(与 diagnostics 路由一致,此为 diagnostic 单数别名)
|
||||||
|
"/shell/teacher/diagnostic": {
|
||||||
|
requiredRoles: ["teacher", "admin"],
|
||||||
|
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
|
||||||
|
},
|
||||||
|
// P2 迁移(B3 教师域):学情分析列表页根路由(无尾斜杠)
|
||||||
|
// 无 ANALYTICS_* 权限点,复用 CLASS_READ/CLASS_MANAGE(学情分析隶属班级域)
|
||||||
|
"/shell/teacher/analytics": {
|
||||||
|
requiredRoles: ["teacher", "admin"],
|
||||||
|
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||||
|
},
|
||||||
|
|
||||||
// ── student 专属 ──────────────────────────────────────────
|
// ── student 专属 ──────────────────────────────────────────
|
||||||
"/shell/student/error-book": {
|
"/shell/student/error-book": {
|
||||||
@@ -352,6 +370,30 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{
|
|||||||
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_MANAGE"],
|
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_MANAGE"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// P2 迁移(B3 教师域):错题本子路由(预留,未来 /shell/teacher/error-book/[id])
|
||||||
|
{
|
||||||
|
prefix: "/shell/teacher/error-book/",
|
||||||
|
config: {
|
||||||
|
requiredRoles: ["teacher", "admin"],
|
||||||
|
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// P2 迁移(B3 教师域):诊断报告子路由(含 /shell/teacher/diagnostic/class/[classId])
|
||||||
|
{
|
||||||
|
prefix: "/shell/teacher/diagnostic/",
|
||||||
|
config: {
|
||||||
|
requiredRoles: ["teacher", "admin"],
|
||||||
|
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// P2 迁移(B3 教师域):学情分析子路由(含 /shell/teacher/analytics/[studentId])
|
||||||
|
{
|
||||||
|
prefix: "/shell/teacher/analytics/",
|
||||||
|
config: {
|
||||||
|
requiredRoles: ["teacher", "admin"],
|
||||||
|
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||||
|
},
|
||||||
|
},
|
||||||
// 公告管理
|
// 公告管理
|
||||||
{
|
{
|
||||||
prefix: "/shell/admin/announcements/",
|
prefix: "/shell/admin/announcements/",
|
||||||
|
|||||||
Reference in New Issue
Block a user