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 }>;
|
||||
}
|
||||
|
||||
// Baseline as of P2 B2 (2026-07-22, course-plans + elective modules added).
|
||||
// Baseline as of P2 B3 (2026-07-22, error-book + diagnostic + analytics modules added).
|
||||
// Update when adding pages.
|
||||
const BASELINE: Baseline = {
|
||||
total: 53,
|
||||
total: 58,
|
||||
categories: {
|
||||
dashboards: {
|
||||
pattern: "shell/{admin,teacher,student,parent}/page.tsx",
|
||||
|
||||
@@ -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 "./course-plans";
|
||||
export * from "./elective";
|
||||
export * from "./error-book";
|
||||
export * from "./diagnostic";
|
||||
export * from "./analytics";
|
||||
export * from "./parent";
|
||||
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 "./course-plans.graphql";
|
||||
export * from "./elective.graphql";
|
||||
export * from "./error-book.graphql";
|
||||
export * from "./diagnostic.graphql";
|
||||
export * from "./analytics.graphql";
|
||||
export * from "./parent.graphql";
|
||||
export * from "./admin.graphql";
|
||||
|
||||
@@ -704,7 +704,61 @@
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics"
|
||||
"title": "Analytics",
|
||||
"overview": {
|
||||
"title": "Analytics Overview",
|
||||
"description": "View class-wide learning analytics, weak points and trends",
|
||||
"mswNotice": "Analytics overview contract is @contract-pending, currently backed by MSW. Will switch to real data once backend aggregate field is ready.",
|
||||
"totalStudents": "Total Students",
|
||||
"avgScore": "Avg Score",
|
||||
"avgMastery": "Avg Mastery",
|
||||
"atRiskCount": "At-Risk Students",
|
||||
"sectionWeakPoints": "Weak Points",
|
||||
"colKpTitle": "Knowledge Point",
|
||||
"colErrorCount": "Error Count",
|
||||
"colMastery": "Mastery",
|
||||
"sectionTrends": "Recent Trends",
|
||||
"sectionClassBreakdown": "Class Breakdown",
|
||||
"colClassName": "Class",
|
||||
"colStudentCount": "Students",
|
||||
"colAvgScore": "Avg Score",
|
||||
"colAtRiskCount": "At-Risk",
|
||||
"colActions": "Actions",
|
||||
"viewStudentAnalytics": "View Student Analytics"
|
||||
},
|
||||
"student": {
|
||||
"title": "Student Analytics",
|
||||
"notFound": "No analytics data found for this student",
|
||||
"backToOverview": "Back to Overview",
|
||||
"mswNotice": "Student analytics contract is @contract-pending, currently backed by MSW. Will switch to real data once backend query field is ready.",
|
||||
"subtitle": "{className} · No. {studentNo}",
|
||||
"sectionBasic": "Basic Info",
|
||||
"fieldName": "Name",
|
||||
"fieldStudentNo": "Student No.",
|
||||
"fieldClassName": "Class",
|
||||
"fieldAvgScore": "Avg Score",
|
||||
"fieldClassRank": "Class Rank",
|
||||
"fieldTotalStudents": "Total Students",
|
||||
"sectionWeakPoints": "Weak Points",
|
||||
"noWeakPoints": "No weak points data",
|
||||
"colKpTitle": "Knowledge Point",
|
||||
"colMastery": "Mastery",
|
||||
"colErrorCount": "Error Count",
|
||||
"sectionTrends": "Learning Trends",
|
||||
"noTrends": "No trend data",
|
||||
"sectionRecentExams": "Recent Exams",
|
||||
"noRecentExams": "No recent exam records",
|
||||
"colExamTitle": "Exam",
|
||||
"colScore": "Score",
|
||||
"colScoreRate": "Score Rate",
|
||||
"colLevel": "Level",
|
||||
"colExamDate": "Exam Date"
|
||||
},
|
||||
"error": {
|
||||
"title": "Analytics page error",
|
||||
"unknown": "An unknown error occurred in the analytics module",
|
||||
"retry": "Retry"
|
||||
}
|
||||
},
|
||||
"knowledgeGraph": {
|
||||
"title": "Knowledge Graph"
|
||||
@@ -1085,10 +1139,83 @@
|
||||
}
|
||||
},
|
||||
"diagnostic": {
|
||||
"title": "Diagnostic"
|
||||
"title": "Diagnostic",
|
||||
"list": {
|
||||
"title": "Diagnostic Reports",
|
||||
"description": "View student and class diagnostic reports",
|
||||
"searchPlaceholder": "Search report title or summary",
|
||||
"statusFilter": "Filter by status",
|
||||
"statusAll": "All statuses",
|
||||
"statusDraft": "Draft",
|
||||
"statusGenerated": "Generated",
|
||||
"statusPublished": "Published",
|
||||
"statusArchived": "Archived",
|
||||
"total": "{count} total",
|
||||
"publishedCount": "{count} published",
|
||||
"colTitle": "Title",
|
||||
"colType": "Type",
|
||||
"colStudentId": "Student ID",
|
||||
"colStatus": "Status",
|
||||
"colGeneratedAt": "Generated At",
|
||||
"colActions": "Actions",
|
||||
"viewClassDetail": "View Class Detail"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Class Diagnostic Report",
|
||||
"notFound": "No diagnostic report found for this class",
|
||||
"backToList": "Back to List",
|
||||
"mswNotice": "Class diagnostic detail contract is @contract-pending, currently backed by MSW. Will switch to real data once backend single-query field is ready.",
|
||||
"generatedAtPrefix": "Generated at {date}",
|
||||
"sectionBasic": "Basic Info",
|
||||
"fieldTitle": "Title",
|
||||
"fieldReportType": "Report Type",
|
||||
"fieldStatus": "Status",
|
||||
"fieldClassName": "Class",
|
||||
"fieldStudentCount": "Student Count",
|
||||
"fieldAvgScore": "Avg Score",
|
||||
"fieldSummary": "Summary",
|
||||
"noSummary": "No summary",
|
||||
"fieldGeneratedAt": "Generated At",
|
||||
"sectionWeakPoints": "Weak Points",
|
||||
"noWeakPoints": "No weak points",
|
||||
"colKpTitle": "Knowledge Point",
|
||||
"colMastery": "Mastery",
|
||||
"colErrorCount": "Error Count",
|
||||
"sectionRecommendations": "Recommendations",
|
||||
"noRecommendations": "No recommendations"
|
||||
},
|
||||
"error": {
|
||||
"title": "Diagnostic page error",
|
||||
"unknown": "An unknown error occurred in the diagnostic module",
|
||||
"retry": "Retry"
|
||||
}
|
||||
},
|
||||
"errorBook": {
|
||||
"title": "Error Book"
|
||||
"title": "Error Book",
|
||||
"list": {
|
||||
"title": "Error Book",
|
||||
"description": "View student error records and knowledge point stats",
|
||||
"searchPlaceholder": "Search knowledge point or content",
|
||||
"knowledgePointFilter": "Filter by knowledge point",
|
||||
"allKnowledgePoints": "All knowledge points",
|
||||
"total": "{count} total",
|
||||
"colKnowledgePoint": "Knowledge Point",
|
||||
"colErrorCount": "Error Count",
|
||||
"colLastErrorTime": "Last Error Time",
|
||||
"colContent": "Content",
|
||||
"noContent": "No content"
|
||||
},
|
||||
"stats": {
|
||||
"totalErrorQuestions": "Total Error Questions",
|
||||
"totalErrorCount": "Total Error Count",
|
||||
"recent7dErrors": "Recent 7d Errors",
|
||||
"knowledgePointCount": "Knowledge Points"
|
||||
},
|
||||
"error": {
|
||||
"title": "Error Book page error",
|
||||
"unknown": "An unknown error occurred in the error book module",
|
||||
"retry": "Retry"
|
||||
}
|
||||
},
|
||||
"practice": {
|
||||
"title": "Practice"
|
||||
|
||||
@@ -704,7 +704,61 @@
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "学情分析"
|
||||
"title": "学情分析",
|
||||
"overview": {
|
||||
"title": "学情分析总览",
|
||||
"description": "查看班级整体学情、薄弱知识点与趋势",
|
||||
"mswNotice": "学情总览契约为 @contract-pending,当前通过 MSW 兜底。后端补齐聚合根字段后将切换为真实数据。",
|
||||
"totalStudents": "学生总数",
|
||||
"avgScore": "平均分",
|
||||
"avgMastery": "平均掌握度",
|
||||
"atRiskCount": "预警学生数",
|
||||
"sectionWeakPoints": "薄弱知识点",
|
||||
"colKpTitle": "知识点",
|
||||
"colErrorCount": "错误次数",
|
||||
"colMastery": "掌握度",
|
||||
"sectionTrends": "近期趋势",
|
||||
"sectionClassBreakdown": "班级分解",
|
||||
"colClassName": "班级",
|
||||
"colStudentCount": "学生数",
|
||||
"colAvgScore": "平均分",
|
||||
"colAtRiskCount": "预警数",
|
||||
"colActions": "操作",
|
||||
"viewStudentAnalytics": "查看学生分析"
|
||||
},
|
||||
"student": {
|
||||
"title": "学生学情分析",
|
||||
"notFound": "未找到该学生的学情数据",
|
||||
"backToOverview": "返回总览",
|
||||
"mswNotice": "单生学情分析契约为 @contract-pending,当前通过 MSW 兜底。后端补齐查询字段后将切换为真实数据。",
|
||||
"subtitle": "{className} · 学号 {studentNo}",
|
||||
"sectionBasic": "基本信息",
|
||||
"fieldName": "姓名",
|
||||
"fieldStudentNo": "学号",
|
||||
"fieldClassName": "班级",
|
||||
"fieldAvgScore": "平均分",
|
||||
"fieldClassRank": "班级排名",
|
||||
"fieldTotalStudents": "班级总人数",
|
||||
"sectionWeakPoints": "薄弱知识点",
|
||||
"noWeakPoints": "暂无薄弱知识点数据",
|
||||
"colKpTitle": "知识点",
|
||||
"colMastery": "掌握度",
|
||||
"colErrorCount": "错误次数",
|
||||
"sectionTrends": "学习趋势",
|
||||
"noTrends": "暂无趋势数据",
|
||||
"sectionRecentExams": "近期考试",
|
||||
"noRecentExams": "暂无近期考试记录",
|
||||
"colExamTitle": "考试",
|
||||
"colScore": "得分",
|
||||
"colScoreRate": "得分率",
|
||||
"colLevel": "等级",
|
||||
"colExamDate": "考试日期"
|
||||
},
|
||||
"error": {
|
||||
"title": "学情分析页面出错了",
|
||||
"unknown": "学情分析模块发生未知错误",
|
||||
"retry": "重试"
|
||||
}
|
||||
},
|
||||
"knowledgeGraph": {
|
||||
"title": "知识图谱"
|
||||
@@ -1085,10 +1139,83 @@
|
||||
}
|
||||
},
|
||||
"diagnostic": {
|
||||
"title": "诊断报告"
|
||||
"title": "诊断报告",
|
||||
"list": {
|
||||
"title": "诊断报告",
|
||||
"description": "查看学生与班级的诊断报告",
|
||||
"searchPlaceholder": "搜索报告标题或摘要",
|
||||
"statusFilter": "按状态筛选",
|
||||
"statusAll": "全部状态",
|
||||
"statusDraft": "草稿",
|
||||
"statusGenerated": "已生成",
|
||||
"statusPublished": "已发布",
|
||||
"statusArchived": "已归档",
|
||||
"total": "共 {count} 条",
|
||||
"publishedCount": "已发布 {count} 条",
|
||||
"colTitle": "标题",
|
||||
"colType": "类型",
|
||||
"colStudentId": "学生 ID",
|
||||
"colStatus": "状态",
|
||||
"colGeneratedAt": "生成时间",
|
||||
"colActions": "操作",
|
||||
"viewClassDetail": "查看班级详情"
|
||||
},
|
||||
"detail": {
|
||||
"title": "班级诊断报告",
|
||||
"notFound": "未找到该班级的诊断报告",
|
||||
"backToList": "返回列表",
|
||||
"mswNotice": "班级诊断详情契约为 @contract-pending,当前通过 MSW 兜底。后端补齐单查字段后将切换为真实数据。",
|
||||
"generatedAtPrefix": "生成于 {date}",
|
||||
"sectionBasic": "基本信息",
|
||||
"fieldTitle": "标题",
|
||||
"fieldReportType": "报告类型",
|
||||
"fieldStatus": "状态",
|
||||
"fieldClassName": "班级",
|
||||
"fieldStudentCount": "学生数",
|
||||
"fieldAvgScore": "平均分",
|
||||
"fieldSummary": "摘要",
|
||||
"noSummary": "暂无摘要",
|
||||
"fieldGeneratedAt": "生成时间",
|
||||
"sectionWeakPoints": "薄弱知识点",
|
||||
"noWeakPoints": "暂无薄弱知识点",
|
||||
"colKpTitle": "知识点",
|
||||
"colMastery": "掌握度",
|
||||
"colErrorCount": "错误次数",
|
||||
"sectionRecommendations": "教学建议",
|
||||
"noRecommendations": "暂无教学建议"
|
||||
},
|
||||
"error": {
|
||||
"title": "诊断报告页面出错了",
|
||||
"unknown": "诊断报告模块发生未知错误",
|
||||
"retry": "重试"
|
||||
}
|
||||
},
|
||||
"errorBook": {
|
||||
"title": "错题本"
|
||||
"title": "错题本",
|
||||
"list": {
|
||||
"title": "错题本",
|
||||
"description": "查看学生的错题记录与知识点统计",
|
||||
"searchPlaceholder": "搜索知识点或内容",
|
||||
"knowledgePointFilter": "按知识点筛选",
|
||||
"allKnowledgePoints": "全部知识点",
|
||||
"total": "共 {count} 条",
|
||||
"colKnowledgePoint": "知识点",
|
||||
"colErrorCount": "错误次数",
|
||||
"colLastErrorTime": "最近错误时间",
|
||||
"colContent": "内容",
|
||||
"noContent": "无内容"
|
||||
},
|
||||
"stats": {
|
||||
"totalErrorQuestions": "错题总数",
|
||||
"totalErrorCount": "错误次数",
|
||||
"recent7dErrors": "近 7 天错误",
|
||||
"knowledgePointCount": "知识点数"
|
||||
},
|
||||
"error": {
|
||||
"title": "错题本页面出错了",
|
||||
"unknown": "错题本模块发生未知错误",
|
||||
"retry": "重试"
|
||||
}
|
||||
},
|
||||
"practice": {
|
||||
"title": "练习分析"
|
||||
|
||||
@@ -3475,6 +3475,317 @@ export function graphqlResponse(
|
||||
return { data: { updateElective: { id: eleId } } };
|
||||
}
|
||||
|
||||
// ── Error Book 域(教师域 B2 迁移,✅ 真实查询 MSW 兜底)──
|
||||
// schema: errorBookItems: [ErrorBookList!]!(无参数)
|
||||
// GetErrorBookItems:返回 mock 错题本列表(按 student 聚合,items 为数组)
|
||||
case "GetErrorBookItems": {
|
||||
return {
|
||||
data: {
|
||||
errorBookItems: [
|
||||
{
|
||||
student_id: "stu-001",
|
||||
items: [
|
||||
{
|
||||
question_id: "q-001",
|
||||
knowledge_point_id: "kp-001",
|
||||
knowledge_point_title: "二次函数",
|
||||
error_count: 8,
|
||||
last_error_time: "2026-07-20T10:30:00Z",
|
||||
content: "已知 f(x)=2x²+3x-1,求 f(2) 的值。",
|
||||
},
|
||||
{
|
||||
question_id: "q-002",
|
||||
knowledge_point_id: "kp-002",
|
||||
knowledge_point_title: "概率统计",
|
||||
error_count: 5,
|
||||
last_error_time: "2026-07-18T14:00:00Z",
|
||||
content: "从 5 男 3 女中选 3 人,求恰好 2 男 1 女的概率。",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
{
|
||||
student_id: "stu-002",
|
||||
items: [
|
||||
{
|
||||
question_id: "q-003",
|
||||
knowledge_point_id: "kp-001",
|
||||
knowledge_point_title: "二次函数",
|
||||
error_count: 3,
|
||||
last_error_time: "2026-07-19T09:00:00Z",
|
||||
content: "求 y=x²-4x+3 的顶点坐标。",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Diagnostic 域(教师域 B2 迁移)──
|
||||
// GetDiagnosticReports:✅ 真实查询 diagnosticReports(无参数),MSW 兜底
|
||||
case "GetDiagnosticReports": {
|
||||
return {
|
||||
data: {
|
||||
diagnosticReports: [
|
||||
{
|
||||
student_id: "stu-001",
|
||||
reports: [
|
||||
{
|
||||
report_id: "rpt-001",
|
||||
student_id: "stu-001",
|
||||
report_type: "WEEKLY",
|
||||
title: "第一周学情诊断",
|
||||
summary:
|
||||
"本周整体表现良好,但二次函数掌握不足,建议加强练习。",
|
||||
generated_at: "2026-07-22T08:00:00Z",
|
||||
status: "PUBLISHED",
|
||||
},
|
||||
{
|
||||
report_id: "rpt-002",
|
||||
student_id: "stu-001",
|
||||
report_type: "UNIT",
|
||||
title: "函数单元诊断",
|
||||
summary: "函数概念理解到位,但复合函数求值需加强。",
|
||||
generated_at: "2026-07-15T08:00:00Z",
|
||||
status: "PUBLISHED",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
{
|
||||
student_id: "stu-002",
|
||||
reports: [
|
||||
{
|
||||
report_id: "rpt-003",
|
||||
student_id: "stu-002",
|
||||
report_type: "MONTHLY",
|
||||
title: "七月学情月报",
|
||||
summary: "本月进步明显,概率统计仍有薄弱点。",
|
||||
generated_at: "2026-07-20T08:00:00Z",
|
||||
status: "GENERATED",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
// GetDiagnosticReport($classId):@contract-pending 班级诊断详情,MSW 兜底
|
||||
case "GetDiagnosticReport": {
|
||||
const classId = (variables?.classId as string | undefined) ?? "";
|
||||
return {
|
||||
data: {
|
||||
diagnosticReport: {
|
||||
report_id: "rpt-cls-001",
|
||||
student_id: classId,
|
||||
report_type: "UNIT",
|
||||
title: "班级函数单元诊断报告",
|
||||
summary:
|
||||
"本班函数单元整体掌握度 72%,二次函数薄弱,建议增加针对性练习。",
|
||||
generated_at: "2026-07-21T10:00:00Z",
|
||||
status: "PUBLISHED",
|
||||
class_id: classId,
|
||||
class_name: "高三(1)班",
|
||||
student_count: 38,
|
||||
avg_score: 72.5,
|
||||
weak_points: [
|
||||
{
|
||||
knowledge_point_id: "kp-001",
|
||||
title: "二次函数",
|
||||
mastery: 0.45,
|
||||
error_count: 15,
|
||||
},
|
||||
{
|
||||
knowledge_point_id: "kp-002",
|
||||
title: "概率统计",
|
||||
mastery: 0.62,
|
||||
error_count: 8,
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
"针对二次函数安排 2 课时专项复习",
|
||||
"组织小组讨论解决概率统计常见误区",
|
||||
"每周安排一次函数单元小测跟踪进度",
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Analytics 域(教师域 B2 迁移)──
|
||||
// GetLearningTrend:✅ 真实查询 learningTrend(无参数),MSW 兜底
|
||||
case "GetLearningTrend": {
|
||||
return {
|
||||
data: {
|
||||
learningTrend: {
|
||||
student_id: "stu-001",
|
||||
points: [
|
||||
{ date: "2026-07-01", score: 75 },
|
||||
{ date: "2026-07-08", score: 78 },
|
||||
{ date: "2026-07-15", score: 82 },
|
||||
{ date: "2026-07-22", score: 85 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// GetStudentWeakness:✅ 真实查询 studentWeakness(无参数),MSW 兜底
|
||||
case "GetStudentWeakness": {
|
||||
return {
|
||||
data: {
|
||||
studentWeakness: {
|
||||
student_id: "stu-001",
|
||||
weak_points: [
|
||||
{
|
||||
knowledge_point_id: "kp-001",
|
||||
title: "二次函数",
|
||||
mastery: 0.45,
|
||||
error_count: 8,
|
||||
},
|
||||
{
|
||||
knowledge_point_id: "kp-002",
|
||||
title: "概率统计",
|
||||
mastery: 0.62,
|
||||
error_count: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// GetAnalyticsOverview:@contract-pending 总览聚合,MSW 兜底
|
||||
case "GetAnalyticsOverview": {
|
||||
return {
|
||||
data: {
|
||||
analyticsOverview: {
|
||||
total_students: 142,
|
||||
avg_score: 78.5,
|
||||
avg_mastery: 0.72,
|
||||
at_risk_count: 12,
|
||||
weak_points: [
|
||||
{
|
||||
knowledge_point_id: "kp-001",
|
||||
title: "二次函数",
|
||||
error_count: 45,
|
||||
mastery: 0.45,
|
||||
},
|
||||
{
|
||||
knowledge_point_id: "kp-002",
|
||||
title: "概率统计",
|
||||
error_count: 32,
|
||||
mastery: 0.62,
|
||||
},
|
||||
{
|
||||
knowledge_point_id: "kp-003",
|
||||
title: "三角函数",
|
||||
error_count: 28,
|
||||
mastery: 0.68,
|
||||
},
|
||||
],
|
||||
trends: [
|
||||
{ date: "2026-07-01", avg_score: 75.2 },
|
||||
{ date: "2026-07-08", avg_score: 76.8 },
|
||||
{ date: "2026-07-15", avg_score: 77.5 },
|
||||
{ date: "2026-07-22", avg_score: 78.5 },
|
||||
],
|
||||
class_breakdown: [
|
||||
{
|
||||
class_id: "cls-001",
|
||||
class_name: "高三(1)班",
|
||||
avg_score: 82.3,
|
||||
student_count: 38,
|
||||
at_risk_count: 3,
|
||||
},
|
||||
{
|
||||
class_id: "cls-002",
|
||||
class_name: "高三(2)班",
|
||||
avg_score: 75.1,
|
||||
student_count: 40,
|
||||
at_risk_count: 5,
|
||||
},
|
||||
{
|
||||
class_id: "cls-003",
|
||||
class_name: "高三(3)班",
|
||||
avg_score: 78.0,
|
||||
student_count: 36,
|
||||
at_risk_count: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// GetStudentAnalytics($studentId):@contract-pending 单生分析,MSW 兜底
|
||||
case "GetStudentAnalytics": {
|
||||
const studentId = (variables?.studentId as string | undefined) ?? "";
|
||||
return {
|
||||
data: {
|
||||
studentAnalytics: {
|
||||
student_id: studentId,
|
||||
student_name: "张明",
|
||||
student_no: "202601001",
|
||||
class_id: "cls-001",
|
||||
class_name: "高三(1)班",
|
||||
avg_score: 85.2,
|
||||
class_rank: 5,
|
||||
total_students: 38,
|
||||
weak_points: [
|
||||
{
|
||||
knowledge_point_id: "kp-001",
|
||||
title: "二次函数",
|
||||
mastery: 0.45,
|
||||
error_count: 8,
|
||||
},
|
||||
{
|
||||
knowledge_point_id: "kp-002",
|
||||
title: "概率统计",
|
||||
mastery: 0.62,
|
||||
error_count: 5,
|
||||
},
|
||||
{
|
||||
knowledge_point_id: "kp-004",
|
||||
title: "导数应用",
|
||||
mastery: 0.71,
|
||||
error_count: 3,
|
||||
},
|
||||
],
|
||||
trends: [
|
||||
{ date: "2026-07-01", score: 78 },
|
||||
{ date: "2026-07-08", score: 82 },
|
||||
{ date: "2026-07-15", score: 80 },
|
||||
{ date: "2026-07-22", score: 85 },
|
||||
],
|
||||
recent_exams: [
|
||||
{
|
||||
exam_id: "exam-001",
|
||||
exam_title: "函数单元测试",
|
||||
score: 82,
|
||||
total_score: 100,
|
||||
date: "2026-07-10",
|
||||
},
|
||||
{
|
||||
exam_id: "exam-002",
|
||||
exam_title: "概率统计小测",
|
||||
score: 88,
|
||||
total_score: 100,
|
||||
date: "2026-07-17",
|
||||
},
|
||||
{
|
||||
exam_id: "exam-003",
|
||||
exam_title: "期末模拟考",
|
||||
score: 85,
|
||||
total_score: 100,
|
||||
date: "2026-07-20",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 通用 ──
|
||||
case "GetNotificationsList":
|
||||
return {
|
||||
|
||||
@@ -185,6 +185,24 @@ export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_MANAGE"],
|
||||
},
|
||||
// P2 迁移(B3 教师域):错题本列表页根路由(无尾斜杠)
|
||||
// 无 ERROR_BOOK_* 教师权限点(student 有 ERROR_BOOK_READ),复用 CLASS_READ/CLASS_MANAGE
|
||||
"/shell/teacher/error-book": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||
},
|
||||
// P2 迁移(B3 教师域):诊断报告列表页根路由(无尾斜杠)
|
||||
// 复用 DIAGNOSTIC_READ/DIAGNOSTIC_MANAGE(与 diagnostics 路由一致,此为 diagnostic 单数别名)
|
||||
"/shell/teacher/diagnostic": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
|
||||
},
|
||||
// P2 迁移(B3 教师域):学情分析列表页根路由(无尾斜杠)
|
||||
// 无 ANALYTICS_* 权限点,复用 CLASS_READ/CLASS_MANAGE(学情分析隶属班级域)
|
||||
"/shell/teacher/analytics": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||
},
|
||||
|
||||
// ── student 专属 ──────────────────────────────────────────
|
||||
"/shell/student/error-book": {
|
||||
@@ -352,6 +370,30 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{
|
||||
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_MANAGE"],
|
||||
},
|
||||
},
|
||||
// P2 迁移(B3 教师域):错题本子路由(预留,未来 /shell/teacher/error-book/[id])
|
||||
{
|
||||
prefix: "/shell/teacher/error-book/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||
},
|
||||
},
|
||||
// P2 迁移(B3 教师域):诊断报告子路由(含 /shell/teacher/diagnostic/class/[classId])
|
||||
{
|
||||
prefix: "/shell/teacher/diagnostic/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
|
||||
},
|
||||
},
|
||||
// P2 迁移(B3 教师域):学情分析子路由(含 /shell/teacher/analytics/[studentId])
|
||||
{
|
||||
prefix: "/shell/teacher/analytics/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||
},
|
||||
},
|
||||
// 公告管理
|
||||
{
|
||||
prefix: "/shell/admin/announcements/",
|
||||
|
||||
Reference in New Issue
Block a user