feat(portal-shell): grades 模块 5 页迁移(教师域 §9.1 B2)
§9.1 B2 grades 行:/grades、/entry、/analytics、/stats、/report-card
契约:grade(id) ✅ 真实查询;列表/分析/统计/成绩单 → MSW 兜底
新增文件:
- src/lib/api/grades.ts (6 hooks)
- src/lib/api/operations/grades.graphql.ts (6 documents)
- src/features/teacher/grades/ (7 files: transformations + tests + 5 clients)
- src/app/shell/teacher/grades/ (5 page.tsx + loading.tsx + error.tsx)
修改文件:
- src/mocks/graphql-data.ts (5 mock 常量 + 6 handler cases)
- src/messages/{zh-CN,en}.json (grades i18n)
- src/lib/api/{index,operations/index}.ts (导出 grades)
- scripts/check-page-count.ts (baseline 26 → 31)
DoD 验收(§11.3 11 项):
- typecheck 0 errors
- lint 0 errors
- vitest 357 tests passed
- lint:tokens 0 errors
- check:pages 31 PASS
关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §10 P2 / §11.3 / §11.4
This commit is contained in:
@@ -19,9 +19,9 @@ interface Baseline {
|
||||
categories: Record<string, { pattern: string; min: number; label: string }>;
|
||||
}
|
||||
|
||||
// Baseline as of P2 (2026-07-22, homework module added). Update when adding pages.
|
||||
// Baseline as of P2 (2026-07-22, grades module added). Update when adding pages.
|
||||
const BASELINE: Baseline = {
|
||||
total: 26,
|
||||
total: 31,
|
||||
categories: {
|
||||
dashboards: {
|
||||
pattern: "shell/{admin,teacher,student,parent}/page.tsx",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { GradeAnalyticsClient } from "@/features/teacher/grades/grade-analytics-client";
|
||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 成绩分析页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 GradeAnalyticsClient(client component)中。
|
||||
*
|
||||
* 数据契约:混合契约
|
||||
* - 基础统计 ✅ assignmentAnalysis(data-ana 子图,schema 已就绪)
|
||||
* - 扩展字段(排名/分布)❌ → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function GradeAnalyticsPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<GradeAnalyticsClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { GradeEntryClient } from "@/features/teacher/grades/grade-entry-client";
|
||||
import { FormPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 成绩录入表单页(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 GradeEntryClient(client component)中。
|
||||
*
|
||||
* 数据契约:mutation createGrade(input) ❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-grade-mutation
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function GradeEntryPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<FormPageSkeleton />}>
|
||||
<GradeEntryClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
38
apps/portal-shell/src/app/shell/teacher/grades/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/grades/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 { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
export default function GradesError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
|
||||
useEffect(() => {
|
||||
console.error("[portal-shell] grades 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>
|
||||
);
|
||||
}
|
||||
12
apps/portal-shell/src/app/shell/teacher/grades/loading.tsx
Normal file
12
apps/portal-shell/src/app/shell/teacher/grades/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 成绩路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||
*
|
||||
* 子页面(录入/分析/统计/成绩单)的 Skeleton 由各自 server page 的 <Suspense> 兜底,
|
||||
* 本文件仅在 /shell/teacher/grades 列表/重定向期间显示。
|
||||
*/
|
||||
export default function GradesLoading(): React.ReactElement {
|
||||
return <ListPageSkeleton rows={5} />;
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/grades/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/grades/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { GradesListClient } from "@/features/teacher/grades/grades-list-client";
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 成绩管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 GradesListClient(client component)中。
|
||||
*
|
||||
* 数据契约:列表查询 grades(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#grades-list
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function GradesListPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||
<GradesListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ReportCardClient } from "@/features/teacher/grades/report-card-client";
|
||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 学生成绩单页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 ReportCardClient(client component)中。
|
||||
*
|
||||
* 数据契约:reportCard(studentId, period) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#report-card
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function ReportCardPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<ReportCardClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { GradeStatsClient } from "@/features/teacher/grades/grade-stats-client";
|
||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 成绩统计页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 GradeStatsClient(client component)中。
|
||||
*
|
||||
* 数据契约:gradeStats(classId, period) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#grade-stats
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function GradeStatsPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<GradeStatsClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Grades 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Grade } from "@/lib/api";
|
||||
|
||||
import {
|
||||
GRADE_LEVEL_LABEL,
|
||||
GRADE_STATUS_LABEL,
|
||||
calcGradeDistribution,
|
||||
formatGradeDate,
|
||||
formatGradeLevel,
|
||||
formatGradeStatus,
|
||||
formatPassRate,
|
||||
formatPercent,
|
||||
formatScore,
|
||||
isPassing,
|
||||
levelToColorClass,
|
||||
parseScore,
|
||||
scoreRateToColorClass,
|
||||
scoreToLevel,
|
||||
toGradeListItem,
|
||||
} from "../transformations";
|
||||
|
||||
describe("formatGradeStatus", () => {
|
||||
it("maps known statuses to Chinese labels", () => {
|
||||
expect(formatGradeStatus("GRADED")).toBe("已评分");
|
||||
expect(formatGradeStatus("PENDING")).toBe("待评分");
|
||||
expect(formatGradeStatus("EXEMPT")).toBe("免考");
|
||||
expect(formatGradeStatus("ABSENT")).toBe("缺考");
|
||||
});
|
||||
|
||||
it("returns original value for unknown status", () => {
|
||||
expect(formatGradeStatus("UNKNOWN")).toBe("UNKNOWN");
|
||||
expect(formatGradeStatus("")).toBe("");
|
||||
});
|
||||
|
||||
it("GRADE_STATUS_LABEL covers all standard statuses", () => {
|
||||
expect(Object.keys(GRADE_STATUS_LABEL)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGradeLevel", () => {
|
||||
it("maps known levels to Chinese labels", () => {
|
||||
expect(formatGradeLevel("A")).toBe("优秀");
|
||||
expect(formatGradeLevel("B")).toBe("良好");
|
||||
expect(formatGradeLevel("C")).toBe("合格");
|
||||
expect(formatGradeLevel("D")).toBe("不合格");
|
||||
});
|
||||
|
||||
it("returns original value for unknown level", () => {
|
||||
expect(formatGradeLevel("X")).toBe("X");
|
||||
expect(formatGradeLevel("")).toBe("");
|
||||
});
|
||||
|
||||
it("GRADE_LEVEL_LABEL covers A/B/C/D", () => {
|
||||
expect(Object.keys(GRADE_LEVEL_LABEL)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGradeDate", () => {
|
||||
it("formats valid ISO date string", () => {
|
||||
const result = formatGradeDate("2026-07-22T10:00:00Z");
|
||||
expect(result).toContain("2026");
|
||||
expect(result).toContain("07");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/empty", () => {
|
||||
expect(formatGradeDate(null)).toBe("--");
|
||||
expect(formatGradeDate(undefined)).toBe("--");
|
||||
expect(formatGradeDate("")).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid date", () => {
|
||||
expect(formatGradeDate("not-a-date")).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseScore", () => {
|
||||
it("parses numeric string", () => {
|
||||
expect(parseScore("95")).toBe(95);
|
||||
expect(parseScore("0")).toBe(0);
|
||||
});
|
||||
|
||||
it("passes through number input", () => {
|
||||
expect(parseScore(88)).toBe(88);
|
||||
});
|
||||
|
||||
it("returns 0 for non-numeric string", () => {
|
||||
expect(parseScore("abc")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for NaN", () => {
|
||||
expect(parseScore(Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
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 non-finite input", () => {
|
||||
expect(formatScore(Number.NaN)).toBe("--");
|
||||
expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--");
|
||||
expect(formatScore(Number.NEGATIVE_INFINITY)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPassRate / formatPercent", () => {
|
||||
it("formats rate in [0,1] as percentage", () => {
|
||||
expect(formatPassRate(0.86)).toBe("86%");
|
||||
expect(formatPassRate(0)).toBe("0%");
|
||||
expect(formatPassRate(1)).toBe("100%");
|
||||
expect(formatPercent(0.92)).toBe("92%");
|
||||
});
|
||||
|
||||
it("returns placeholder for out-of-range or non-finite input", () => {
|
||||
expect(formatPassRate(-0.1)).toBe("--");
|
||||
expect(formatPassRate(1.1)).toBe("--");
|
||||
expect(formatPassRate(Number.NaN)).toBe("--");
|
||||
expect(formatPercent(Number.NaN)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreToLevel", () => {
|
||||
it("maps score ratio to A/B/C/D levels", () => {
|
||||
expect(scoreToLevel(90, 100)).toBe("A");
|
||||
expect(scoreToLevel(85, 100)).toBe("A");
|
||||
expect(scoreToLevel(75, 100)).toBe("B");
|
||||
expect(scoreToLevel(70, 100)).toBe("B");
|
||||
expect(scoreToLevel(65, 100)).toBe("C");
|
||||
expect(scoreToLevel(60, 100)).toBe("C");
|
||||
expect(scoreToLevel(59, 100)).toBe("D");
|
||||
expect(scoreToLevel(0, 100)).toBe("D");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid input", () => {
|
||||
expect(scoreToLevel(Number.NaN, 100)).toBe("--");
|
||||
expect(scoreToLevel(80, 0)).toBe("--");
|
||||
expect(scoreToLevel(80, -1)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPassing", () => {
|
||||
it("returns true for score >= 60% of totalScore by default", () => {
|
||||
expect(isPassing(60, 100)).toBe(true);
|
||||
expect(isPassing(59, 100)).toBe(false);
|
||||
expect(isPassing(80, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it("respects custom passScore", () => {
|
||||
expect(isPassing(50, 100, 50)).toBe(true);
|
||||
expect(isPassing(49, 100, 50)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for invalid input", () => {
|
||||
expect(isPassing(Number.NaN, 100)).toBe(false);
|
||||
expect(isPassing(80, 0)).toBe(false);
|
||||
expect(isPassing(80, -1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreRateToColorClass", () => {
|
||||
it("returns emerald for high rates (>= 0.8)", () => {
|
||||
expect(scoreRateToColorClass(80, 100)).toBe("text-emerald-600");
|
||||
expect(scoreRateToColorClass(95, 100)).toBe("text-emerald-600");
|
||||
});
|
||||
|
||||
it("returns amber for medium rates (>= 0.6)", () => {
|
||||
expect(scoreRateToColorClass(60, 100)).toBe("text-amber-600");
|
||||
expect(scoreRateToColorClass(79, 100)).toBe("text-amber-600");
|
||||
});
|
||||
|
||||
it("returns destructive for low rates (< 0.6)", () => {
|
||||
expect(scoreRateToColorClass(59, 100)).toBe("text-destructive");
|
||||
expect(scoreRateToColorClass(0, 100)).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for invalid input", () => {
|
||||
expect(scoreRateToColorClass(Number.NaN, 100)).toBe(
|
||||
"text-muted-foreground",
|
||||
);
|
||||
expect(scoreRateToColorClass(80, 0)).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("levelToColorClass", () => {
|
||||
it("maps each level to correct color class", () => {
|
||||
expect(levelToColorClass("A")).toBe("text-emerald-600");
|
||||
expect(levelToColorClass("B")).toBe("text-blue-600");
|
||||
expect(levelToColorClass("C")).toBe("text-amber-600");
|
||||
expect(levelToColorClass("D")).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for unknown level", () => {
|
||||
expect(levelToColorClass("X")).toBe("text-muted-foreground");
|
||||
expect(levelToColorClass("")).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("calcGradeDistribution", () => {
|
||||
it("distributes scores into 5 buckets", () => {
|
||||
const scores = [
|
||||
{ score: 95, totalScore: 100 },
|
||||
{ score: 85, totalScore: 100 },
|
||||
{ score: 75, totalScore: 100 },
|
||||
{ score: 65, totalScore: 100 },
|
||||
{ score: 45, totalScore: 100 },
|
||||
];
|
||||
const result = calcGradeDistribution(scores);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0]!.count).toBe(1); // 0-59
|
||||
expect(result[1]!.count).toBe(1); // 60-69
|
||||
expect(result[2]!.count).toBe(1); // 70-79
|
||||
expect(result[3]!.count).toBe(1); // 80-89
|
||||
expect(result[4]!.count).toBe(1); // 90-100
|
||||
});
|
||||
|
||||
it("handles empty array", () => {
|
||||
const result = calcGradeDistribution([]);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.every((b) => b.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("skips invalid scores", () => {
|
||||
const scores = [
|
||||
{ score: 90, totalScore: 100 },
|
||||
{ score: Number.NaN, totalScore: 100 },
|
||||
{ score: 80, totalScore: 0 },
|
||||
];
|
||||
const result = calcGradeDistribution(scores);
|
||||
expect(result[4]!.count).toBe(1); // only 90/100 counted
|
||||
expect(result[3]!.count).toBe(0);
|
||||
});
|
||||
|
||||
it("handles boundary values correctly", () => {
|
||||
const scores = [
|
||||
{ score: 60, totalScore: 100 }, // 60-69
|
||||
{ score: 70, totalScore: 100 }, // 70-79
|
||||
{ score: 80, totalScore: 100 }, // 80-89
|
||||
{ score: 90, totalScore: 100 }, // 90-100
|
||||
{ score: 59, totalScore: 100 }, // 0-59
|
||||
];
|
||||
const result = calcGradeDistribution(scores);
|
||||
expect(result[0]!.count).toBe(1); // 59 → 0-59
|
||||
expect(result[1]!.count).toBe(1); // 60 → 60-69
|
||||
expect(result[2]!.count).toBe(1); // 70 → 70-79
|
||||
expect(result[3]!.count).toBe(1); // 80 → 80-89
|
||||
expect(result[4]!.count).toBe(1); // 90 → 90-100
|
||||
});
|
||||
});
|
||||
|
||||
describe("toGradeListItem", () => {
|
||||
it("extracts list fields from full grade with extra info", () => {
|
||||
const grade: Grade = {
|
||||
id: "grade-001",
|
||||
studentId: "stu-001",
|
||||
examId: "exam-001",
|
||||
homeworkId: null,
|
||||
score: "95",
|
||||
totalScore: "100",
|
||||
feedback: "基础扎实",
|
||||
gradedBy: "usr-teacher-001",
|
||||
schoolId: "sch-001",
|
||||
idempotencyKey: null,
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
};
|
||||
|
||||
const item = toGradeListItem(grade, {
|
||||
studentName: "张明",
|
||||
studentNo: "2026001",
|
||||
classId: "cls-001",
|
||||
className: "高三(1)班",
|
||||
examName: "期中考试",
|
||||
status: "GRADED",
|
||||
});
|
||||
|
||||
expect(item.id).toBe("grade-001");
|
||||
expect(item.studentName).toBe("张明");
|
||||
expect(item.score).toBe(95); // string → number
|
||||
expect(item.totalScore).toBe(100);
|
||||
expect(item.examName).toBe("期中考试");
|
||||
expect(item.status).toBe("GRADED");
|
||||
});
|
||||
|
||||
it("handles missing extra info with defaults", () => {
|
||||
const grade: Grade = {
|
||||
id: "grade-002",
|
||||
studentId: "stu-002",
|
||||
examId: null,
|
||||
homeworkId: "hw-001",
|
||||
score: "88",
|
||||
totalScore: "100",
|
||||
feedback: null,
|
||||
gradedBy: "usr-teacher-001",
|
||||
schoolId: "sch-001",
|
||||
idempotencyKey: null,
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
};
|
||||
|
||||
const item = toGradeListItem(grade);
|
||||
expect(item.studentName).toBe("--");
|
||||
expect(item.studentNo).toBe("--");
|
||||
expect(item.className).toBe("--");
|
||||
expect(item.examName).toBeNull();
|
||||
expect(item.homeworkTitle).toBeNull();
|
||||
expect(item.status).toBe("GRADED");
|
||||
expect(item.score).toBe(88);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩分析页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约(混合契约):
|
||||
* - 基础统计 ✅ assignmentAnalysis(data-ana 子图,schema 已就绪)
|
||||
* - 扩展字段(排名/分布)❌ → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useGradeAnalytics } from "@/lib/api";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatPassRate,
|
||||
formatScore,
|
||||
levelToColorClass,
|
||||
} from "@/features/teacher/grades/transformations";
|
||||
|
||||
/**
|
||||
* 分析客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function GradeAnalyticsClient(): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
const tCommon = useTranslations("common");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const examId = searchParams.get("examId") ?? "exam-001";
|
||||
const classId = searchParams.get("classId") ?? undefined;
|
||||
|
||||
// 混合契约:基础统计 + 扩展字段 MSW
|
||||
const { data, loading, error } = useGradeAnalytics({ examId, 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>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.scopeName ?? t("analytics.title")}
|
||||
description={
|
||||
data ? t("analytics.subtitle", { scope: data.scope }) : undefined
|
||||
}
|
||||
icon={<BarChart3 className="size-6" />}
|
||||
backHref="/shell/teacher/grades"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("analytics.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <GradeAnalyticsBody data={data} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析内容区(汇总卡片 + 分布图 + 学生排名)。
|
||||
*/
|
||||
function GradeAnalyticsBody({
|
||||
data,
|
||||
}: {
|
||||
data: NonNullable<ReturnType<typeof useGradeAnalytics>["data"]>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("analytics.sectionSummary")}>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryTotal")}
|
||||
value={String(data.summary.totalStudents)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryGraded")}
|
||||
value={String(data.summary.gradedCount)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryAvg")}
|
||||
value={formatScore(data.summary.avgScore)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryMax")}
|
||||
value={formatScore(data.summary.maxScore)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryMin")}
|
||||
value={formatScore(data.summary.minScore)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryPassRate")}
|
||||
value={formatPassRate(data.summary.passRate)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryPassScore")}
|
||||
value={formatScore(data.summary.passScore)}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("analytics.sectionDistribution")}>
|
||||
<DistributionChart items={data.distribution} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("analytics.sectionRankings")}>
|
||||
<RankingsTable items={data.rankings} />
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总卡片。
|
||||
*/
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg 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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分数段分布柱状图(纯 SVG)。
|
||||
*/
|
||||
function DistributionChart({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{ label: string; count: number }>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("analytics.emptyDistribution")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const maxCount = Math.max(...items.map((i) => i.count), 1);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-3">
|
||||
<span className="w-16 shrink-0 text-sm text-muted-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="h-6 flex-1 overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 shrink-0 text-right text-sm">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生排名表格。
|
||||
*/
|
||||
function RankingsTable({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{
|
||||
studentId: string;
|
||||
studentNo: string;
|
||||
studentName: string;
|
||||
score: number;
|
||||
rank: number;
|
||||
level: string;
|
||||
}>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("analytics.emptyRankings")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">{t("analytics.colRank")}</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colStudentNo")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colStudentName")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("analytics.colScore")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("analytics.colLevel")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.studentId} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4 font-medium">{item.rank}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{item.studentNo}</td>
|
||||
<td className="py-2 pr-4">{item.studentName}</td>
|
||||
<td className="py-2 pr-4">{formatScore(item.score)}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={levelToColorClass(item.level)}>
|
||||
{item.level}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩录入表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - mutation createGrade(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-grade-mutation
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:FormPageSkeleton(由 server page Suspense 兜底)
|
||||
* - error:errorSummary 表单级错误
|
||||
* - success:notify.success + router.push 回列表
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Award } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useCreateGrade, type CreateGradeInput } from "@/lib/api";
|
||||
import { FormPageShell } from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/**
|
||||
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function GradeEntryClient(): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const presetClassId = searchParams.get("classId") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { run: createGrade, loading: submitting } = useCreateGrade();
|
||||
|
||||
const handleSubmit = async (input: CreateGradeInput): Promise<void> => {
|
||||
try {
|
||||
const result = await createGrade(input);
|
||||
notify.success(t("entry.success"));
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/grades?classId=${input.classId}`);
|
||||
});
|
||||
void result;
|
||||
} catch (err) {
|
||||
notify.error(`${t("entry.error")}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GradeEntryFormInner
|
||||
presetClassId={presetClassId}
|
||||
submitting={submitting}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单主体(受控表单 + 内联校验)。
|
||||
*/
|
||||
function GradeEntryFormInner({
|
||||
presetClassId,
|
||||
submitting,
|
||||
onSubmit,
|
||||
}: {
|
||||
presetClassId: string;
|
||||
submitting: boolean;
|
||||
onSubmit: (input: CreateGradeInput) => Promise<void>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const [classId, setClassId] = useState(presetClassId);
|
||||
const [studentId, setStudentId] = useState("");
|
||||
const [examId, setExamId] = useState("");
|
||||
const [homeworkId, setHomeworkId] = useState("");
|
||||
const [score, setScore] = useState("");
|
||||
const [totalScore, setTotalScore] = useState("100");
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFormSubmit = (): void => {
|
||||
setError(null);
|
||||
|
||||
if (!classId.trim()) {
|
||||
setError(t("entry.errorClassRequired"));
|
||||
return;
|
||||
}
|
||||
if (!studentId.trim()) {
|
||||
setError(t("entry.errorStudentRequired"));
|
||||
return;
|
||||
}
|
||||
const scoreNum = Number(score);
|
||||
if (!Number.isFinite(scoreNum) || scoreNum < 0) {
|
||||
setError(t("entry.errorScoreInvalid"));
|
||||
return;
|
||||
}
|
||||
const totalNum = Number(totalScore);
|
||||
if (!Number.isFinite(totalNum) || totalNum <= 0) {
|
||||
setError(t("entry.errorTotalInvalid"));
|
||||
return;
|
||||
}
|
||||
if (!examId.trim() && !homeworkId.trim()) {
|
||||
setError(t("entry.errorSourceRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
const input: CreateGradeInput = {
|
||||
classId: classId.trim(),
|
||||
studentId: studentId.trim(),
|
||||
examId: examId.trim() || undefined,
|
||||
homeworkId: homeworkId.trim() || undefined,
|
||||
score: scoreNum,
|
||||
totalScore: totalNum,
|
||||
feedback: feedback.trim() || undefined,
|
||||
};
|
||||
|
||||
void onSubmit(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormPageShell
|
||||
title={t("entry.title")}
|
||||
description={t("entry.description")}
|
||||
icon={<Award className="size-6" />}
|
||||
backHref={`/shell/teacher/grades?classId=${classId}`}
|
||||
onSubmit={handleFormSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel={t("entry.submit")}
|
||||
cancelLabel={tCommon("button.cancel")}
|
||||
errorSummary={
|
||||
error ? <p className="text-sm text-destructive">{error}</p> : undefined
|
||||
}
|
||||
>
|
||||
{/* 班级 ID */}
|
||||
<FormField label={t("entry.classId")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder="cls-001"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 学生 ID */}
|
||||
<FormField label={t("entry.studentId")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={studentId}
|
||||
onChange={(e) => setStudentId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder="stu-001"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 考试 ID(可选) */}
|
||||
<FormField label={t("entry.examId")}>
|
||||
<input
|
||||
type="text"
|
||||
value={examId}
|
||||
onChange={(e) => setExamId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("entry.examIdPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 作业 ID(可选) */}
|
||||
<FormField label={t("entry.homeworkId")}>
|
||||
<input
|
||||
type="text"
|
||||
value={homeworkId}
|
||||
onChange={(e) => setHomeworkId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("entry.homeworkIdPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 分数 + 满分 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label={t("entry.score")} required>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={score}
|
||||
onChange={(e) => setScore(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("entry.totalScore")} required>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={totalScore}
|
||||
onChange={(e) => setTotalScore(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* 反馈 */}
|
||||
<FormField label={t("entry.feedbackLabel")}>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("entry.feedbackPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* @contract-pending 提示 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("entry.contractPending")}
|
||||
</p>
|
||||
</FormPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单字段容器(label + children)。
|
||||
*/
|
||||
function FormField({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-destructive">*</span> : null}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩统计页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - gradeStats(classId, period):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#grade-stats
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useGradeStats, type GradeStatsSummary } from "@/lib/api";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatPassRate,
|
||||
formatScore,
|
||||
scoreRateToColorClass,
|
||||
} from "@/features/teacher/grades/transformations";
|
||||
|
||||
/**
|
||||
* 统计客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function GradeStatsClient(): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
const tCommon = useTranslations("common");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "cls-001";
|
||||
const period = searchParams.get("period") ?? undefined;
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useGradeStats(classId, period);
|
||||
|
||||
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 (
|
||||
<DetailPageShell
|
||||
title={t("stats.title")}
|
||||
description={
|
||||
data
|
||||
? t("stats.subtitle", { classId: data.classId, period: data.period })
|
||||
: undefined
|
||||
}
|
||||
icon={<BarChart3 className="size-6" />}
|
||||
backHref="/shell/teacher/grades"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("stats.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <GradeStatsBody summaries={data.summaries} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计内容区(学科维度汇总卡片 + 表格)。
|
||||
*/
|
||||
function GradeStatsBody({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: GradeStatsSummary[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
if (summaries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("stats.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("stats.sectionSummary")}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{summaries.map((s) => (
|
||||
<SubjectSummaryCard
|
||||
key={`${s.classId}-${s.subjectId}`}
|
||||
summary={s}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("stats.sectionTable")}>
|
||||
<StatsTable summaries={summaries} />
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学科维度统计卡片。
|
||||
*/
|
||||
function SubjectSummaryCard({
|
||||
summary,
|
||||
}: {
|
||||
summary: GradeStatsSummary;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold">{summary.subjectName}</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("stats.studentCount", { count: summary.studentCount })}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">{t("stats.avgScore")}</dt>
|
||||
<dd className="text-right font-medium">
|
||||
{formatScore(summary.avgScore)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("stats.maxScore")}</dt>
|
||||
<dd className="text-right font-medium">
|
||||
{formatScore(summary.maxScore)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("stats.minScore")}</dt>
|
||||
<dd className="text-right font-medium">
|
||||
{formatScore(summary.minScore)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("stats.passRate")}</dt>
|
||||
<dd
|
||||
className={`text-right font-medium ${scoreRateToColorClass(summary.passRate, 1)}`}
|
||||
>
|
||||
{formatPassRate(summary.passRate)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("stats.passCount")}</dt>
|
||||
<dd className="text-right font-medium">{summary.passCount}</dd>
|
||||
<dt className="text-muted-foreground">{t("stats.failCount")}</dt>
|
||||
<dd className="text-right font-medium text-destructive">
|
||||
{summary.failCount}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学科维度统计表格。
|
||||
*/
|
||||
function StatsTable({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: GradeStatsSummary[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colSubject")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colClass")}</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("stats.colStudentCount")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colAvgScore")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colMaxScore")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colMinScore")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colPassRate")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colPassCount")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colFailCount")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summaries.map((s) => (
|
||||
<tr
|
||||
key={`${s.classId}-${s.subjectId}`}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-2 pr-4 font-medium">{s.subjectName}</td>
|
||||
<td className="py-2 pr-4 text-xs">{s.className}</td>
|
||||
<td className="py-2 pr-4">{s.studentCount}</td>
|
||||
<td className="py-2 pr-4 font-medium">
|
||||
{formatScore(s.avgScore)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">{formatScore(s.maxScore)}</td>
|
||||
<td className="py-2 pr-4">{formatScore(s.minScore)}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={scoreRateToColorClass(s.passRate, 1)}>
|
||||
{formatPassRate(s.passRate)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4">{s.passCount}</td>
|
||||
<td className="py-2 pr-4 text-destructive">{s.failCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 grades(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#grades-list
|
||||
*
|
||||
* URL 状态:?classId=xxx &examId=xxx &studentId=xxx &status=xxx &q=xxx
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Award } 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 { useGradesList, type GradeListItem } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatGradeDate,
|
||||
formatGradeStatus,
|
||||
formatScore,
|
||||
scoreRateToColorClass,
|
||||
} from "@/features/teacher/grades/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function GradesListClient(): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "cls-001";
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useGradesList(classId);
|
||||
|
||||
// 客户端二次筛选(status + q)
|
||||
const filteredItems = useMemo<GradeListItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items.filter((item) => {
|
||||
if (statusFilter && item.status !== statusFilter) return false;
|
||||
if (
|
||||
q &&
|
||||
!item.studentName.toLowerCase().includes(q.toLowerCase()) &&
|
||||
!item.studentNo.toLowerCase().includes(q.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [data, statusFilter, q]);
|
||||
|
||||
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/grades?${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>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("list.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<Award className="size-6" />}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href={`/shell/teacher/grades/entry?classId=${classId}`}>
|
||||
{t("list.entry")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
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="GRADED">{t("list.statusGraded")}</option>
|
||||
<option value="PENDING">{t("list.statusPending")}</option>
|
||||
<option value="EXEMPT">{t("list.statusExempt")}</option>
|
||||
<option value="ABSENT">{t("list.statusAbsent")}</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>
|
||||
}
|
||||
>
|
||||
<GradesTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩列表表格。
|
||||
*/
|
||||
function GradesTable({
|
||||
items,
|
||||
}: {
|
||||
items: GradeListItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
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.colStudent")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colClass")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colSource")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colScore")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colGradedAt")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((grade) => (
|
||||
<tr key={grade.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<p className="font-medium">{grade.studentName}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{grade.studentNo}
|
||||
</p>
|
||||
</td>
|
||||
<td className="p-3 text-xs">{grade.className}</td>
|
||||
<td className="p-3 text-xs">
|
||||
{grade.examName ?? grade.homeworkTitle ?? "--"}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span
|
||||
className={`font-semibold ${scoreRateToColorClass(grade.score, grade.totalScore)}`}
|
||||
>
|
||||
{formatScore(grade.score)}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
/ {formatScore(grade.totalScore)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<GradeStatusBadge status={grade.status} />
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{formatGradeDate(grade.gradedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩状态徽章。
|
||||
*/
|
||||
function GradeStatusBadge({ status }: { status: string }): React.ReactElement {
|
||||
const label = formatGradeStatus(status);
|
||||
const cls =
|
||||
status === "GRADED"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: status === "PENDING"
|
||||
? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
: status === "EXEMPT"
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
: status === "ABSENT"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted text-muted-foreground";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 学生成绩单页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - reportCard(studentId, period):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#report-card
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useReportCard, type ReportCardEntry } from "@/lib/api";
|
||||
import {
|
||||
DetailField,
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatGradeDate,
|
||||
formatScore,
|
||||
levelToColorClass,
|
||||
} from "@/features/teacher/grades/transformations";
|
||||
|
||||
/**
|
||||
* 成绩单客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ReportCardClient(): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
const tCommon = useTranslations("common");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const studentId = searchParams.get("studentId") ?? "stu-001";
|
||||
const period = searchParams.get("period") ?? undefined;
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useReportCard(studentId, period);
|
||||
|
||||
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 (
|
||||
<DetailPageShell
|
||||
title={
|
||||
data
|
||||
? t("reportCard.titleWithName", { name: data.studentName })
|
||||
: t("reportCard.title")
|
||||
}
|
||||
description={
|
||||
data
|
||||
? t("reportCard.subtitle", {
|
||||
no: data.studentNo,
|
||||
className: data.className,
|
||||
period: data.period,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<FileText className="size-6" />}
|
||||
backHref="/shell/teacher/grades"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("reportCard.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <ReportCardBody data={data} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩单内容区(学生信息 + 科目成绩 + 总评)。
|
||||
*/
|
||||
function ReportCardBody({
|
||||
data,
|
||||
}: {
|
||||
data: NonNullable<ReturnType<typeof useReportCard>["data"]>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("reportCard.sectionBasic")}>
|
||||
<DetailField
|
||||
label={t("reportCard.fieldStudentName")}
|
||||
value={data.studentName}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("reportCard.fieldStudentNo")}
|
||||
value={data.studentNo}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("reportCard.fieldClass")}
|
||||
value={data.className}
|
||||
/>
|
||||
<DetailField label={t("reportCard.fieldPeriod")} value={data.period} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("reportCard.sectionEntries")}>
|
||||
<ReportCardTable entries={data.entries} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("reportCard.sectionSummary")}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<SummaryStat
|
||||
label={t("reportCard.totalAvgScore")}
|
||||
value={formatScore(data.totalAvgScore)}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={t("reportCard.totalRank")}
|
||||
value={t("reportCard.rankValue", {
|
||||
rank: data.totalRank,
|
||||
total: data.totalStudents,
|
||||
})}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={t("reportCard.totalStudents")}
|
||||
value={String(data.totalStudents)}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩单科目表格。
|
||||
*/
|
||||
function ReportCardTable({
|
||||
entries,
|
||||
}: {
|
||||
entries: ReportCardEntry[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("reportCard.emptyEntries")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("reportCard.colSubject")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("reportCard.colSource")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("reportCard.colScore")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("reportCard.colLevel")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("reportCard.colFeedback")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("reportCard.colGradedAt")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.subjectId} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4 font-medium">{entry.subjectName}</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{entry.examName ?? entry.homeworkTitle ?? "--"}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="font-semibold">
|
||||
{formatScore(entry.score)}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
/ {formatScore(entry.totalScore)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={levelToColorClass(entry.gradeLevel)}>
|
||||
{entry.gradeLevel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs text-muted-foreground">
|
||||
{entry.feedback ?? "--"}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">
|
||||
{formatGradeDate(entry.gradedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 总评统计卡片。
|
||||
*/
|
||||
function SummaryStat({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg 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>
|
||||
);
|
||||
}
|
||||
248
apps/portal-shell/src/features/teacher/grades/transformations.ts
Normal file
248
apps/portal-shell/src/features/teacher/grades/transformations.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Grades 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
import type { Grade, GradeListItem } from "@/lib/api";
|
||||
|
||||
/** 成绩状态中文标签映射 */
|
||||
export const GRADE_STATUS_LABEL: Record<string, string> = {
|
||||
GRADED: "已评分",
|
||||
PENDING: "待评分",
|
||||
EXEMPT: "免考",
|
||||
ABSENT: "缺考",
|
||||
};
|
||||
|
||||
/** 等级中文标签映射 */
|
||||
export const GRADE_LEVEL_LABEL: Record<string, string> = {
|
||||
A: "优秀",
|
||||
B: "良好",
|
||||
C: "合格",
|
||||
D: "不合格",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将成绩状态枚举值映射为中文标签。
|
||||
* 未知状态回退为原始值。
|
||||
*/
|
||||
export function formatGradeStatus(status: string): string {
|
||||
return GRADE_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将等级(A/B/C/D)映射为中文标签。
|
||||
* 未知等级回退为原始值。
|
||||
*/
|
||||
export function formatGradeLevel(level: string): string {
|
||||
return GRADE_LEVEL_LABEL[level] ?? level;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatGradeDate(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",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 schema 的 score/totalScore(String 或 Number)转为数值用于展示。
|
||||
* 转换失败返回 0。
|
||||
*/
|
||||
export function parseScore(score: string | number): number {
|
||||
const n = typeof score === "number" ? score : Number(score);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化分数(数值)为展示字符串,保留 1 位小数。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatScore(score: number): string {
|
||||
if (!Number.isFinite(score)) return "--";
|
||||
return score.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 0-1 的小数(如 0.86)格式化为百分比字符串 "86%"。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatPassRate(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||
return `${(rate * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 0-1 的小数格式化为百分比字符串。
|
||||
* 与 formatPassRate 同义,语义区分用于不同场景。
|
||||
*/
|
||||
export function formatPercent(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||
return `${(rate * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据得分比例(0-1)返回等级标签(A/B/C/D)。
|
||||
* - rate >= 0.85 → A
|
||||
* - rate >= 0.7 → B
|
||||
* - rate >= 0.6 → C
|
||||
* - 其他 → D
|
||||
*/
|
||||
export function scoreToLevel(score: number, totalScore: number): string {
|
||||
if (
|
||||
!Number.isFinite(score) ||
|
||||
!Number.isFinite(totalScore) ||
|
||||
totalScore <= 0
|
||||
) {
|
||||
return "--";
|
||||
}
|
||||
const rate = score / totalScore;
|
||||
if (rate >= 0.85) return "A";
|
||||
if (rate >= 0.7) return "B";
|
||||
if (rate >= 0.6) return "C";
|
||||
return "D";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断成绩是否及格(score >= passScore,默认 passScore 为 totalScore * 0.6)。
|
||||
*/
|
||||
export function isPassing(
|
||||
score: number,
|
||||
totalScore: number,
|
||||
passScore?: number,
|
||||
): boolean {
|
||||
if (
|
||||
!Number.isFinite(score) ||
|
||||
!Number.isFinite(totalScore) ||
|
||||
totalScore <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const threshold = passScore ?? totalScore * 0.6;
|
||||
return score >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据得分比例返回 Tailwind 文本语义类名。
|
||||
* - >= 0.8 → text-emerald-600(高)
|
||||
* - >= 0.6 → text-amber-600(中)
|
||||
* - 其他 → text-destructive(低)
|
||||
*/
|
||||
export function scoreRateToColorClass(
|
||||
score: number,
|
||||
totalScore: number,
|
||||
): string {
|
||||
if (
|
||||
!Number.isFinite(score) ||
|
||||
!Number.isFinite(totalScore) ||
|
||||
totalScore <= 0
|
||||
) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
const rate = score / totalScore;
|
||||
if (rate >= 0.8) return "text-emerald-600";
|
||||
if (rate >= 0.6) return "text-amber-600";
|
||||
return "text-destructive";
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据等级(A/B/C/D)返回 Tailwind 文本语义类名。
|
||||
*/
|
||||
export function levelToColorClass(level: string): string {
|
||||
switch (level) {
|
||||
case "A":
|
||||
return "text-emerald-600";
|
||||
case "B":
|
||||
return "text-blue-600";
|
||||
case "C":
|
||||
return "text-amber-600";
|
||||
case "D":
|
||||
return "text-destructive";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算分数段分布(0-59, 60-69, 70-79, 80-89, 90-100)。
|
||||
* 返回按分数段排序的数组。
|
||||
*/
|
||||
export function calcGradeDistribution(
|
||||
scores: ReadonlyArray<{ score: number; totalScore: number }>,
|
||||
): Array<{ label: string; count: number }> {
|
||||
const buckets = [
|
||||
{ label: "0-59", count: 0 },
|
||||
{ label: "60-69", count: 0 },
|
||||
{ label: "70-79", count: 0 },
|
||||
{ label: "80-89", count: 0 },
|
||||
{ label: "90-100", count: 0 },
|
||||
];
|
||||
for (const item of scores) {
|
||||
if (
|
||||
!Number.isFinite(item.score) ||
|
||||
!Number.isFinite(item.totalScore) ||
|
||||
item.totalScore <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const rate = item.score / item.totalScore;
|
||||
if (rate < 0.6) {
|
||||
buckets[0]!.count++;
|
||||
} else if (rate < 0.7) {
|
||||
buckets[1]!.count++;
|
||||
} else if (rate < 0.8) {
|
||||
buckets[2]!.count++;
|
||||
} else if (rate < 0.9) {
|
||||
buckets[3]!.count++;
|
||||
} else {
|
||||
buckets[4]!.count++;
|
||||
}
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从成绩详情中提取列表项视图模型(裁剪字段)。
|
||||
* 注意:Grade 的 score/totalScore 是 string,GradeListItem 的是 number。
|
||||
*/
|
||||
export function toGradeListItem(
|
||||
grade: Grade,
|
||||
extra?: {
|
||||
studentName?: string;
|
||||
studentNo?: string;
|
||||
classId?: string;
|
||||
className?: string;
|
||||
examName?: string | null;
|
||||
homeworkTitle?: string | null;
|
||||
status?: string;
|
||||
},
|
||||
): GradeListItem {
|
||||
return {
|
||||
id: grade.id,
|
||||
studentId: grade.studentId,
|
||||
studentName: extra?.studentName ?? "--",
|
||||
studentNo: extra?.studentNo ?? "--",
|
||||
classId: extra?.classId ?? "",
|
||||
className: extra?.className ?? "--",
|
||||
examId: grade.examId,
|
||||
examName: extra?.examName ?? null,
|
||||
homeworkId: grade.homeworkId,
|
||||
homeworkTitle: extra?.homeworkTitle ?? null,
|
||||
score: parseScore(grade.score),
|
||||
totalScore: parseScore(grade.totalScore),
|
||||
status: extra?.status ?? "GRADED",
|
||||
gradedAt: grade.updatedAt,
|
||||
gradedBy: grade.gradedBy,
|
||||
};
|
||||
}
|
||||
426
apps/portal-shell/src/lib/api/grades.ts
Normal file
426
apps/portal-shell/src/lib/api/grades.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Grades domain API(ARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域成绩模块)
|
||||
*
|
||||
* 三类操作:
|
||||
* 1. useGrade(按 id 单查):✅ 真实查询 grade(id: ID!),schema 已就绪
|
||||
* 2. 列表/分析/统计/成绩单查询:❌ schema 无对应字段 → MSW 兜底(@contract-pending)
|
||||
* 3. useCreateGrade(mutation):❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 注:homework.ts 已有 useRecordGrade(用于作业详情内联批改),
|
||||
* 故本域成绩录入 mutation 命名为 useCreateGrade 以避免命名冲突。
|
||||
*
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
* 后端补齐后:重跑 normalize + codegen → 关闭 skipDocumentsValidation → 切换 fetcher → 删 mock
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 契约纪律 / §5.4 MSW 兜底 / §9.1 / §11.4 契约工单
|
||||
*/
|
||||
import type { FetchPolicy } from "@apollo/client";
|
||||
|
||||
import { useWidgetMutation } from "../useWidgetMutation";
|
||||
import { useWidgetQuery } from "../useWidgetQuery";
|
||||
import { ApiError } from "./errors";
|
||||
import {
|
||||
CREATE_GRADE_DOC,
|
||||
GET_GRADE_ANALYTICS_DOC,
|
||||
GET_GRADE_DOC,
|
||||
GET_GRADES_LIST_DOC,
|
||||
GET_GRADE_STATS_DOC,
|
||||
GET_REPORT_CARD_DOC,
|
||||
} from "./operations/grades.graphql";
|
||||
import type { UseQueryResult } from "./types";
|
||||
|
||||
// ===== 数据类型(对齐 schema Grade 类型)=====
|
||||
|
||||
/**
|
||||
* 成绩实体(对齐 combined-schema.graphql Grade 类型,core-edu 子图)
|
||||
*
|
||||
* 字段命名 camelCase(与 schema 一致);score / totalScore 在 schema 中是 String,
|
||||
* 但业务层语义为数值,使用时由调用方做 Number() 转换。
|
||||
*/
|
||||
export interface Grade {
|
||||
id: string;
|
||||
studentId: string;
|
||||
examId: string | null;
|
||||
homeworkId: string | null;
|
||||
score: string;
|
||||
totalScore: string;
|
||||
feedback: string | null;
|
||||
gradedBy: string;
|
||||
schoolId: string;
|
||||
idempotencyKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 成绩列表项(含关联展示字段,用于列表渲染) */
|
||||
export interface GradeListItem {
|
||||
id: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
examId: string | null;
|
||||
examName: string | null;
|
||||
homeworkId: string | null;
|
||||
homeworkTitle: string | null;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
status: string;
|
||||
gradedAt: string;
|
||||
gradedBy: string;
|
||||
}
|
||||
|
||||
/** 列表查询响应(@contract-pending 假契约形状,MSW 返回此结构) */
|
||||
interface GradesListResponse {
|
||||
grades: {
|
||||
items: GradeListItem[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** 单查响应(真实 schema) */
|
||||
interface GradeResponse {
|
||||
grade: Grade | null;
|
||||
}
|
||||
|
||||
/** 成绩录入输入 */
|
||||
export interface CreateGradeInput {
|
||||
studentId: string;
|
||||
classId: string;
|
||||
examId?: string;
|
||||
homeworkId?: string;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
/** 成绩录入 mutation 响应(@contract-pending) */
|
||||
interface CreateGradeResponse {
|
||||
createGrade: { gradeId: string } | null;
|
||||
}
|
||||
|
||||
// ===== Analytics 类型(混合契约:基础统计 + 扩展 MSW)=====
|
||||
|
||||
/** 成绩分析汇总 */
|
||||
export interface GradeAnalyticsSummary {
|
||||
totalStudents: number;
|
||||
gradedCount: number;
|
||||
avgScore: number;
|
||||
maxScore: number;
|
||||
minScore: number;
|
||||
passRate: number;
|
||||
passScore: number;
|
||||
}
|
||||
|
||||
/** 分数段分布 */
|
||||
export interface GradeDistribution {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 学生排名 */
|
||||
export interface GradeRanking {
|
||||
studentId: string;
|
||||
studentNo: string;
|
||||
studentName: string;
|
||||
score: number;
|
||||
rank: number;
|
||||
level: string;
|
||||
}
|
||||
|
||||
/** 成绩分析完整数据(@contract-pending 扩展字段) */
|
||||
export interface GradeAnalytics {
|
||||
scope: string;
|
||||
scopeName: string;
|
||||
summary: GradeAnalyticsSummary;
|
||||
distribution: GradeDistribution[];
|
||||
rankings: GradeRanking[];
|
||||
}
|
||||
|
||||
/** Analytics 查询响应 */
|
||||
interface GradeAnalyticsResponse {
|
||||
gradeAnalytics: GradeAnalytics | null;
|
||||
}
|
||||
|
||||
// ===== Stats 类型(@contract-pending 全 MSW)=====
|
||||
|
||||
/** 班级/学科统计摘要 */
|
||||
export interface GradeStatsSummary {
|
||||
classId: string;
|
||||
className: string;
|
||||
subjectId: string;
|
||||
subjectName: string;
|
||||
studentCount: number;
|
||||
avgScore: number;
|
||||
maxScore: number;
|
||||
minScore: number;
|
||||
passRate: number;
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
}
|
||||
|
||||
/** 成绩统计数据 */
|
||||
export interface GradeStats {
|
||||
classId: string;
|
||||
period: string;
|
||||
summaries: GradeStatsSummary[];
|
||||
}
|
||||
|
||||
/** Stats 查询响应 */
|
||||
interface GradeStatsResponse {
|
||||
gradeStats: GradeStats | null;
|
||||
}
|
||||
|
||||
// ===== Report Card 类型(@contract-pending 全 MSW)=====
|
||||
|
||||
/** 成绩单条目 */
|
||||
export interface ReportCardEntry {
|
||||
subjectId: string;
|
||||
subjectName: string;
|
||||
examName: string | null;
|
||||
homeworkTitle: string | null;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
gradeLevel: string;
|
||||
feedback: string | null;
|
||||
gradedAt: string;
|
||||
}
|
||||
|
||||
/** 成绩单完整数据 */
|
||||
export interface ReportCard {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
className: string;
|
||||
period: string;
|
||||
entries: ReportCardEntry[];
|
||||
totalAvgScore: number;
|
||||
totalRank: number;
|
||||
totalStudents: number;
|
||||
}
|
||||
|
||||
/** ReportCard 查询响应 */
|
||||
interface ReportCardResponse {
|
||||
reportCard: ReportCard | null;
|
||||
}
|
||||
|
||||
// ===== 查询选项 =====
|
||||
|
||||
export interface GradeQueryOptions {
|
||||
enabled?: boolean;
|
||||
pollInterval?: number;
|
||||
fetchPolicy?: FetchPolicy;
|
||||
}
|
||||
|
||||
// ===== Hooks =====
|
||||
|
||||
/**
|
||||
* 按 id 查询成绩详情(真实 schema,✅ 契约已就绪)。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1
|
||||
*/
|
||||
export function useGrade(
|
||||
id: string,
|
||||
options?: GradeQueryOptions,
|
||||
): UseQueryResult<Grade | null> {
|
||||
const result = useWidgetQuery<GradeResponse, { id: string }>(
|
||||
GET_GRADE_DOC,
|
||||
{ id },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? id.length > 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.grade ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询班级下的成绩列表(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* schema 无 grades(classId) 根字段,由 MSW handlers 返回 mock 数据。
|
||||
* 后端补齐列表查询后切换到真实 fetcher,页面无需改动。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
|
||||
*/
|
||||
export function useGradesList(
|
||||
classId: string,
|
||||
options?: GradeQueryOptions & {
|
||||
examId?: string;
|
||||
studentId?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
): UseQueryResult<{ items: GradeListItem[]; total: number }> {
|
||||
const result = useWidgetQuery<
|
||||
GradesListResponse,
|
||||
{
|
||||
classId: string;
|
||||
examId?: string;
|
||||
studentId?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
>(
|
||||
GET_GRADES_LIST_DOC,
|
||||
{
|
||||
classId,
|
||||
examId: options?.examId,
|
||||
studentId: options?.studentId,
|
||||
status: options?.status,
|
||||
limit: options?.limit,
|
||||
offset: options?.offset,
|
||||
},
|
||||
{
|
||||
enabled: options?.enabled ?? classId.length > 0,
|
||||
fetchPolicy: options?.fetchPolicy,
|
||||
pollInterval: options?.pollInterval,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.grades,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩录入 mutation(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
|
||||
* 后端补齐 mutation 后切换到真实 fetcher。
|
||||
*
|
||||
* 注:homework.ts 的 useRecordGrade 用于作业详情内联批改(input.homeworkId 必填),
|
||||
* 本 hook 用于成绩模块的独立录入页(支持 examId 或 homeworkId 二选一)。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §9.1 录入页 / §11.4 契约工单
|
||||
*/
|
||||
export function useCreateGrade(): {
|
||||
run: (input: CreateGradeInput) => Promise<{ gradeId: string }>;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
} {
|
||||
const {
|
||||
run: rawRun,
|
||||
loading,
|
||||
error,
|
||||
} = useWidgetMutation<CreateGradeResponse, { input: CreateGradeInput }>(
|
||||
CREATE_GRADE_DOC,
|
||||
);
|
||||
|
||||
const run = async (input: CreateGradeInput): Promise<{ gradeId: string }> => {
|
||||
const data = await rawRun({ input });
|
||||
if (!data?.createGrade) {
|
||||
throw new ApiError("Failed to create grade", "INTERNAL_ERROR");
|
||||
}
|
||||
return data.createGrade;
|
||||
};
|
||||
|
||||
return { run, loading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询成绩分析数据(混合契约:基础统计 + 扩展 MSW)。
|
||||
*
|
||||
* schema 有 assignmentAnalysis 基础字段,但排名/分布等扩展字段走 MSW。
|
||||
* MSW 开启时返回完整 mock;后端补齐扩展字段后切换真实 fetcher。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §9.1 analytics 页 / §11.4 契约工单
|
||||
*/
|
||||
export function useGradeAnalytics(
|
||||
params: { examId?: string; classId?: string },
|
||||
options?: GradeQueryOptions,
|
||||
): UseQueryResult<GradeAnalytics | null> {
|
||||
const result = useWidgetQuery<
|
||||
GradeAnalyticsResponse,
|
||||
{ examId?: string; classId?: string }
|
||||
>(
|
||||
GET_GRADE_ANALYTICS_DOC,
|
||||
{ examId: params.examId, classId: params.classId },
|
||||
{
|
||||
...options,
|
||||
enabled:
|
||||
options?.enabled ?? (Boolean(params.examId) || Boolean(params.classId)),
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.gradeAnalytics ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询班级成绩统计(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* schema 无 gradeStats 根字段,由 MSW handlers 返回 mock 数据。
|
||||
* 用于 /shell/teacher/grades/stats 统计页。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §9.1 统计页 / §11.4 契约工单
|
||||
*/
|
||||
export function useGradeStats(
|
||||
classId: string,
|
||||
period?: string,
|
||||
options?: GradeQueryOptions,
|
||||
): UseQueryResult<GradeStats | null> {
|
||||
const result = useWidgetQuery<
|
||||
GradeStatsResponse,
|
||||
{ classId: string; period?: string }
|
||||
>(
|
||||
GET_GRADE_STATS_DOC,
|
||||
{ classId, period },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? classId.length > 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.gradeStats ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询学生成绩单(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* schema 无 reportCard 根字段,由 MSW handlers 返回 mock 数据。
|
||||
* 用于 /shell/teacher/grades/report-card 成绩单页。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §9.1 成绩单页 / §11.4 契约工单
|
||||
*/
|
||||
export function useReportCard(
|
||||
studentId: string,
|
||||
period?: string,
|
||||
options?: GradeQueryOptions,
|
||||
): UseQueryResult<ReportCard | null> {
|
||||
const result = useWidgetQuery<
|
||||
ReportCardResponse,
|
||||
{ studentId: string; period?: string }
|
||||
>(
|
||||
GET_REPORT_CARD_DOC,
|
||||
{ studentId, period },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? studentId.length > 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.reportCard ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export * from "./topbar";
|
||||
export * from "./teacher";
|
||||
export * from "./exams";
|
||||
export * from "./homework";
|
||||
export * from "./grades";
|
||||
export * from "./student";
|
||||
export * from "./parent";
|
||||
export * from "./admin";
|
||||
|
||||
179
apps/portal-shell/src/lib/api/operations/grades.graphql.ts
Normal file
179
apps/portal-shell/src/lib/api/operations/grades.graphql.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// Grades domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
|
||||
//
|
||||
// 拆分原则:
|
||||
// - GetGrade(按 id 单查):✅ combined-schema 中真实存在(grade(id: ID!): Grade)
|
||||
// - 其余 5 个查询/mutation:❌ schema 无对应字段/Mutation 类型
|
||||
// → 走 MSW 兜底(@contract-pending),等待后端补齐契约
|
||||
//
|
||||
// 注:homework.graphql.ts 已有同名的 RecordGrade mutation(用于作业详情内联批改),
|
||||
// 故本域成绩录入 mutation 命名为 CreateGrade 以避免命名冲突。
|
||||
//
|
||||
// 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
// 关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
// ── 真实查询:grade(id) 单查 ─────────────────────────────────────
|
||||
// 字段全部对齐 combined-schema.graphql 中 Grade 类型(core-edu 子图)
|
||||
// 注意:score / totalScore 在 schema 中为 String 类型(业务语义为数值)
|
||||
export const GET_GRADE_DOC = gql`
|
||||
query GetGrade($id: ID!) {
|
||||
grade(id: $id) {
|
||||
id
|
||||
studentId
|
||||
examId
|
||||
homeworkId
|
||||
score
|
||||
totalScore
|
||||
feedback
|
||||
gradedBy
|
||||
schoolId
|
||||
idempotencyKey
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 假契约查询(@contract-pending)─────────────────────────────
|
||||
// 列表查询:schema 无 grades(classId) 根字段
|
||||
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
|
||||
// 契约工单:core-edu_contract.md#grades-list
|
||||
export const GET_GRADES_LIST_DOC = gql`
|
||||
query GetGradesList(
|
||||
$classId: ID!
|
||||
$examId: ID
|
||||
$studentId: ID
|
||||
$status: String
|
||||
$limit: Int
|
||||
$offset: Int
|
||||
) {
|
||||
grades(
|
||||
classId: $classId
|
||||
examId: $examId
|
||||
studentId: $studentId
|
||||
status: $status
|
||||
limit: $limit
|
||||
offset: $offset
|
||||
) {
|
||||
items {
|
||||
id
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
classId
|
||||
className
|
||||
examId
|
||||
examName
|
||||
homeworkId
|
||||
homeworkTitle
|
||||
score
|
||||
totalScore
|
||||
status
|
||||
gradedAt
|
||||
gradedBy
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 假契约变更(@contract-pending)─────────────────────────────
|
||||
// 成绩录入:schema 无 Mutation 类型
|
||||
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
|
||||
// 契约工单:core-edu_contract.md#create-grade-mutation
|
||||
export const CREATE_GRADE_DOC = gql`
|
||||
mutation CreateGrade($input: CreateGradeInput!) {
|
||||
createGrade(input: $input) {
|
||||
gradeId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 成绩分析(混合契约:基础统计部分可用 + 扩展 MSW)─────────────
|
||||
// schema 有 assignmentAnalysis 基础字段,但排名/分布等扩展字段走 MSW
|
||||
// 契约工单:core-edu_contract.md#grade-analytics
|
||||
export const GET_GRADE_ANALYTICS_DOC = gql`
|
||||
query GetGradeAnalytics($examId: ID, $classId: ID) {
|
||||
gradeAnalytics(examId: $examId, classId: $classId) {
|
||||
scope
|
||||
scopeName
|
||||
summary {
|
||||
totalStudents
|
||||
gradedCount
|
||||
avgScore
|
||||
maxScore
|
||||
minScore
|
||||
passRate
|
||||
passScore
|
||||
}
|
||||
distribution {
|
||||
label
|
||||
count
|
||||
}
|
||||
rankings {
|
||||
studentId
|
||||
studentNo
|
||||
studentName
|
||||
score
|
||||
rank
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 成绩统计(@contract-pending 全 MSW)────────────────────────
|
||||
// schema 无 gradeStats 根字段 → MSW 兜底
|
||||
// 用于 /shell/teacher/grades/stats 统计页
|
||||
// 契约工单:core-edu_contract.md#grade-stats
|
||||
export const GET_GRADE_STATS_DOC = gql`
|
||||
query GetGradeStats($classId: ID!, $period: String) {
|
||||
gradeStats(classId: $classId, period: $period) {
|
||||
classId
|
||||
period
|
||||
summaries {
|
||||
classId
|
||||
className
|
||||
subjectId
|
||||
subjectName
|
||||
studentCount
|
||||
avgScore
|
||||
maxScore
|
||||
minScore
|
||||
passRate
|
||||
passCount
|
||||
failCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 成绩单(@contract-pending 全 MSW)──────────────────────────
|
||||
// schema 无 reportCard 根字段 → MSW 兜底
|
||||
// 用于 /shell/teacher/grades/report-card 成绩单页
|
||||
// 契约工单:core-edu_contract.md#report-card
|
||||
export const GET_REPORT_CARD_DOC = gql`
|
||||
query GetReportCard($studentId: ID!, $period: String) {
|
||||
reportCard(studentId: $studentId, period: $period) {
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
className
|
||||
period
|
||||
entries {
|
||||
subjectId
|
||||
subjectName
|
||||
examName
|
||||
homeworkTitle
|
||||
score
|
||||
totalScore
|
||||
gradeLevel
|
||||
feedback
|
||||
gradedAt
|
||||
}
|
||||
totalAvgScore
|
||||
totalRank
|
||||
totalStudents
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -6,6 +6,7 @@ export * from "./topbar.graphql";
|
||||
export * from "./teacher.graphql";
|
||||
export * from "./exams.graphql";
|
||||
export * from "./homework.graphql";
|
||||
export * from "./grades.graphql";
|
||||
export * from "./student.graphql";
|
||||
export * from "./parent.graphql";
|
||||
export * from "./admin.graphql";
|
||||
|
||||
@@ -513,7 +513,125 @@
|
||||
}
|
||||
},
|
||||
"grades": {
|
||||
"title": "Grades"
|
||||
"title": "Grades",
|
||||
"list": {
|
||||
"title": "Grades",
|
||||
"description": "View and manage all student grades",
|
||||
"entry": "Record Grade",
|
||||
"searchPlaceholder": "Search by student name/no...",
|
||||
"statusFilter": "Filter by status",
|
||||
"statusAll": "All statuses",
|
||||
"statusGraded": "Graded",
|
||||
"statusPending": "Pending",
|
||||
"statusExempt": "Exempt",
|
||||
"statusAbsent": "Absent",
|
||||
"total": "{count} total",
|
||||
"colStudent": "Student",
|
||||
"colClass": "Class",
|
||||
"colSource": "Source",
|
||||
"colScore": "Score",
|
||||
"colStatus": "Status",
|
||||
"colGradedAt": "Graded At",
|
||||
"mswNotice": "List query contract is pending, please ensure NEXT_PUBLIC_MSW=1 is enabled."
|
||||
},
|
||||
"entry": {
|
||||
"title": "Record Grade",
|
||||
"description": "Fill in student grade information",
|
||||
"submit": "Submit",
|
||||
"success": "Grade recorded successfully",
|
||||
"error": "Submission failed",
|
||||
"classId": "Class ID",
|
||||
"studentId": "Student ID",
|
||||
"examId": "Exam ID (optional)",
|
||||
"examIdPlaceholder": "e.g. exam-001",
|
||||
"homeworkId": "Homework ID (optional)",
|
||||
"homeworkIdPlaceholder": "e.g. hw-001",
|
||||
"score": "Score",
|
||||
"totalScore": "Total Score",
|
||||
"feedbackLabel": "Feedback",
|
||||
"feedbackPlaceholder": "Write feedback to the student...",
|
||||
"errorClassRequired": "Please fill in Class ID",
|
||||
"errorStudentRequired": "Please fill in Student ID",
|
||||
"errorScoreInvalid": "Score is invalid, please enter a non-negative number",
|
||||
"errorTotalInvalid": "Total score is invalid, please enter a positive number",
|
||||
"errorSourceRequired": "At least one of Exam ID or Homework ID is required",
|
||||
"contractPending": "Grade submission contract is @contract-pending, currently backed by MSW. Will switch to real submission once backend mutation is ready."
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Grade Analytics",
|
||||
"subtitle": "Scope: {scope}",
|
||||
"notFound": "No analytics data found, grading may not be complete",
|
||||
"sectionSummary": "Summary",
|
||||
"sectionDistribution": "Score Distribution",
|
||||
"sectionRankings": "Student Rankings",
|
||||
"summaryTotal": "Expected",
|
||||
"summaryGraded": "Graded",
|
||||
"summaryAvg": "Average",
|
||||
"summaryMax": "Max",
|
||||
"summaryMin": "Min",
|
||||
"summaryPassRate": "Pass Rate",
|
||||
"summaryPassScore": "Pass Score",
|
||||
"emptyDistribution": "No score distribution data",
|
||||
"emptyRankings": "No ranking data",
|
||||
"colRank": "Rank",
|
||||
"colStudentNo": "Student No.",
|
||||
"colStudentName": "Name",
|
||||
"colScore": "Score",
|
||||
"colLevel": "Level"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Grade Statistics",
|
||||
"subtitle": "Class: {classId} · Period: {period}",
|
||||
"notFound": "No statistics data found",
|
||||
"empty": "No subject statistics data",
|
||||
"sectionSummary": "Subject Summary Cards",
|
||||
"sectionTable": "Subject Statistics Table",
|
||||
"studentCount": "{count} students",
|
||||
"avgScore": "Average",
|
||||
"maxScore": "Max",
|
||||
"minScore": "Min",
|
||||
"passRate": "Pass Rate",
|
||||
"passCount": "Pass Count",
|
||||
"failCount": "Fail Count",
|
||||
"colSubject": "Subject",
|
||||
"colClass": "Class",
|
||||
"colStudentCount": "Students",
|
||||
"colAvgScore": "Average",
|
||||
"colMaxScore": "Max",
|
||||
"colMinScore": "Min",
|
||||
"colPassRate": "Pass Rate",
|
||||
"colPassCount": "Pass Count",
|
||||
"colFailCount": "Fail Count"
|
||||
},
|
||||
"reportCard": {
|
||||
"title": "Report Card",
|
||||
"titleWithName": "{name}'s Report Card",
|
||||
"subtitle": "No.: {no} · Class: {className} · Period: {period}",
|
||||
"notFound": "No report card data found",
|
||||
"sectionBasic": "Student Info",
|
||||
"sectionEntries": "Subject Grades",
|
||||
"sectionSummary": "Summary",
|
||||
"fieldStudentName": "Name",
|
||||
"fieldStudentNo": "Student No.",
|
||||
"fieldClass": "Class",
|
||||
"fieldPeriod": "Period",
|
||||
"colSubject": "Subject",
|
||||
"colSource": "Source",
|
||||
"colScore": "Score",
|
||||
"colLevel": "Level",
|
||||
"colFeedback": "Feedback",
|
||||
"colGradedAt": "Graded At",
|
||||
"emptyEntries": "No subject grades",
|
||||
"totalAvgScore": "Total Average",
|
||||
"totalRank": "Total Rank",
|
||||
"totalStudents": "Total Students",
|
||||
"rankValue": "Rank {rank} / {total}"
|
||||
},
|
||||
"error": {
|
||||
"title": "Grades page error",
|
||||
"unknown": "Unknown error",
|
||||
"retry": "Retry"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics"
|
||||
|
||||
@@ -513,7 +513,125 @@
|
||||
}
|
||||
},
|
||||
"grades": {
|
||||
"title": "成绩查询"
|
||||
"title": "成绩查询",
|
||||
"list": {
|
||||
"title": "成绩管理",
|
||||
"description": "查看和管理所有学生成绩",
|
||||
"entry": "录入成绩",
|
||||
"searchPlaceholder": "搜索学生姓名/学号...",
|
||||
"statusFilter": "按状态筛选",
|
||||
"statusAll": "全部状态",
|
||||
"statusGraded": "已评分",
|
||||
"statusPending": "待评分",
|
||||
"statusExempt": "免考",
|
||||
"statusAbsent": "缺考",
|
||||
"total": "共 {count} 条",
|
||||
"colStudent": "学生",
|
||||
"colClass": "班级",
|
||||
"colSource": "来源",
|
||||
"colScore": "分数",
|
||||
"colStatus": "状态",
|
||||
"colGradedAt": "评分时间",
|
||||
"mswNotice": "列表查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。"
|
||||
},
|
||||
"entry": {
|
||||
"title": "录入成绩",
|
||||
"description": "填写学生成绩信息",
|
||||
"submit": "提交",
|
||||
"success": "成绩录入成功",
|
||||
"error": "录入失败",
|
||||
"classId": "班级 ID",
|
||||
"studentId": "学生 ID",
|
||||
"examId": "考试 ID(可选)",
|
||||
"examIdPlaceholder": "如 exam-001",
|
||||
"homeworkId": "作业 ID(可选)",
|
||||
"homeworkIdPlaceholder": "如 hw-001",
|
||||
"score": "分数",
|
||||
"totalScore": "满分",
|
||||
"feedbackLabel": "反馈",
|
||||
"feedbackPlaceholder": "给学生写下评语...",
|
||||
"errorClassRequired": "请填写班级 ID",
|
||||
"errorStudentRequired": "请填写学生 ID",
|
||||
"errorScoreInvalid": "分数无效,请输入非负数字",
|
||||
"errorTotalInvalid": "满分无效,请输入正数",
|
||||
"errorSourceRequired": "考试 ID 和作业 ID 至少填写一项",
|
||||
"contractPending": "成绩录入契约为 @contract-pending,当前通过 MSW 兜底。后端补齐 mutation 后将切换为真实提交。"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "成绩分析",
|
||||
"subtitle": "统计范围:{scope}",
|
||||
"notFound": "未找到分析数据,可能尚未完成评分",
|
||||
"sectionSummary": "汇总信息",
|
||||
"sectionDistribution": "分数段分布",
|
||||
"sectionRankings": "学生排名",
|
||||
"summaryTotal": "应参加人数",
|
||||
"summaryGraded": "已评分人数",
|
||||
"summaryAvg": "平均分",
|
||||
"summaryMax": "最高分",
|
||||
"summaryMin": "最低分",
|
||||
"summaryPassRate": "及格率",
|
||||
"summaryPassScore": "及格线",
|
||||
"emptyDistribution": "暂无分数段分布数据",
|
||||
"emptyRankings": "暂无排名数据",
|
||||
"colRank": "名次",
|
||||
"colStudentNo": "学号",
|
||||
"colStudentName": "姓名",
|
||||
"colScore": "分数",
|
||||
"colLevel": "等级"
|
||||
},
|
||||
"stats": {
|
||||
"title": "成绩统计",
|
||||
"subtitle": "班级:{classId} · 周期:{period}",
|
||||
"notFound": "未找到统计数据",
|
||||
"empty": "暂无学科统计数据",
|
||||
"sectionSummary": "学科维度统计卡片",
|
||||
"sectionTable": "学科维度统计表",
|
||||
"studentCount": "应参加 {count} 人",
|
||||
"avgScore": "平均分",
|
||||
"maxScore": "最高分",
|
||||
"minScore": "最低分",
|
||||
"passRate": "及格率",
|
||||
"passCount": "及格人数",
|
||||
"failCount": "不及格人数",
|
||||
"colSubject": "学科",
|
||||
"colClass": "班级",
|
||||
"colStudentCount": "学生数",
|
||||
"colAvgScore": "平均分",
|
||||
"colMaxScore": "最高分",
|
||||
"colMinScore": "最低分",
|
||||
"colPassRate": "及格率",
|
||||
"colPassCount": "及格人数",
|
||||
"colFailCount": "不及格人数"
|
||||
},
|
||||
"reportCard": {
|
||||
"title": "学生成绩单",
|
||||
"titleWithName": "{name} 的成绩单",
|
||||
"subtitle": "学号:{no} · 班级:{className} · 周期:{period}",
|
||||
"notFound": "未找到成绩单数据",
|
||||
"sectionBasic": "学生信息",
|
||||
"sectionEntries": "科目成绩",
|
||||
"sectionSummary": "总评",
|
||||
"fieldStudentName": "姓名",
|
||||
"fieldStudentNo": "学号",
|
||||
"fieldClass": "班级",
|
||||
"fieldPeriod": "周期",
|
||||
"colSubject": "学科",
|
||||
"colSource": "来源",
|
||||
"colScore": "分数",
|
||||
"colLevel": "等级",
|
||||
"colFeedback": "反馈",
|
||||
"colGradedAt": "评分时间",
|
||||
"emptyEntries": "暂无科目成绩",
|
||||
"totalAvgScore": "总平均分",
|
||||
"totalRank": "总名次",
|
||||
"totalStudents": "总人数",
|
||||
"rankValue": "第 {rank} / {total} 名"
|
||||
},
|
||||
"error": {
|
||||
"title": "成绩页面出错了",
|
||||
"unknown": "未知错误",
|
||||
"retry": "重试"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "学情分析"
|
||||
|
||||
@@ -306,33 +306,6 @@ const mockExamDetail = {
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
};
|
||||
|
||||
const mockGrades = [
|
||||
{
|
||||
student_id: "stu-001",
|
||||
student_name: "张明",
|
||||
exam_id: "exam-001",
|
||||
exam_name: "期中考试",
|
||||
score: 95,
|
||||
rank: 1,
|
||||
},
|
||||
{
|
||||
student_id: "stu-002",
|
||||
student_name: "李华",
|
||||
exam_id: "exam-001",
|
||||
exam_name: "期中考试",
|
||||
score: 88,
|
||||
rank: 2,
|
||||
},
|
||||
{
|
||||
student_id: "stu-003",
|
||||
student_name: "王芳",
|
||||
exam_id: "exam-001",
|
||||
exam_name: "期中考试",
|
||||
score: 76,
|
||||
rank: 3,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Exam Analytics mock(混合契约:基础统计真实 + 扩展字段 MSW)──
|
||||
// 用于 /shell/teacher/exams/[id]/analytics 详情页 MSW 兜底
|
||||
// 关联:ARCHITECTURE.md §9.1 analytics 页 / §11.4 契约工单
|
||||
@@ -856,6 +829,310 @@ const mockAiBatchGrading = {
|
||||
},
|
||||
};
|
||||
|
||||
// ── Grades 域(@contract-pending,schema 无 grades 列表/createGrade mutation)
|
||||
// 用于 /shell/teacher/grades 列表页 + /entry 录入页 MSW 兜底
|
||||
// 关联:ARCHITECTURE.md §9.1 / §11.4 契约工单
|
||||
interface MockGradeListItem {
|
||||
id: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
examId: string | null;
|
||||
examName: string | null;
|
||||
homeworkId: string | null;
|
||||
homeworkTitle: string | null;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
status: string;
|
||||
gradedAt: string;
|
||||
gradedBy: string;
|
||||
}
|
||||
const mockGradesList: MockGradeListItem[] = [
|
||||
{
|
||||
id: "grade-001",
|
||||
studentId: "stu-001",
|
||||
studentName: "张明",
|
||||
studentNo: "2026001",
|
||||
classId: "cls-001",
|
||||
className: "高三(1)班",
|
||||
examId: "exam-001",
|
||||
examName: "2026 春季期中考试",
|
||||
homeworkId: null,
|
||||
homeworkTitle: null,
|
||||
score: 95,
|
||||
totalScore: 100,
|
||||
status: "GRADED",
|
||||
gradedAt: "2026-04-16T10:00:00Z",
|
||||
gradedBy: "usr-teacher-001",
|
||||
},
|
||||
{
|
||||
id: "grade-002",
|
||||
studentId: "stu-002",
|
||||
studentName: "李华",
|
||||
studentNo: "2026002",
|
||||
classId: "cls-001",
|
||||
className: "高三(1)班",
|
||||
examId: "exam-001",
|
||||
examName: "2026 春季期中考试",
|
||||
homeworkId: null,
|
||||
homeworkTitle: null,
|
||||
score: 88,
|
||||
totalScore: 100,
|
||||
status: "GRADED",
|
||||
gradedAt: "2026-04-16T10:00:00Z",
|
||||
gradedBy: "usr-teacher-001",
|
||||
},
|
||||
{
|
||||
id: "grade-003",
|
||||
studentId: "stu-003",
|
||||
studentName: "王芳",
|
||||
studentNo: "2026003",
|
||||
classId: "cls-001",
|
||||
className: "高三(1)班",
|
||||
examId: "exam-001",
|
||||
examName: "2026 春季期中考试",
|
||||
homeworkId: null,
|
||||
homeworkTitle: null,
|
||||
score: 76,
|
||||
totalScore: 100,
|
||||
status: "GRADED",
|
||||
gradedAt: "2026-04-16T10:00:00Z",
|
||||
gradedBy: "usr-teacher-001",
|
||||
},
|
||||
{
|
||||
id: "grade-004",
|
||||
studentId: "stu-004",
|
||||
studentName: "赵六",
|
||||
studentNo: "2026004",
|
||||
classId: "cls-001",
|
||||
className: "高三(1)班",
|
||||
examId: "exam-001",
|
||||
examName: "2026 春季期中考试",
|
||||
homeworkId: null,
|
||||
homeworkTitle: null,
|
||||
score: 45,
|
||||
totalScore: 100,
|
||||
status: "GRADED",
|
||||
gradedAt: "2026-04-16T10:00:00Z",
|
||||
gradedBy: "usr-teacher-001",
|
||||
},
|
||||
{
|
||||
id: "grade-005",
|
||||
studentId: "stu-005",
|
||||
studentName: "钱七",
|
||||
studentNo: "2026005",
|
||||
classId: "cls-001",
|
||||
className: "高三(1)班",
|
||||
examId: null,
|
||||
examName: null,
|
||||
homeworkId: "hw-001",
|
||||
homeworkTitle: "集合与函数练习",
|
||||
score: 92,
|
||||
totalScore: 100,
|
||||
status: "GRADED",
|
||||
gradedAt: "2026-07-25T09:00:00Z",
|
||||
gradedBy: "usr-teacher-001",
|
||||
},
|
||||
{
|
||||
id: "grade-006",
|
||||
studentId: "stu-001",
|
||||
studentName: "张明",
|
||||
studentNo: "2026001",
|
||||
classId: "cls-001",
|
||||
className: "高三(1)班",
|
||||
examId: null,
|
||||
examName: null,
|
||||
homeworkId: "hw-001",
|
||||
homeworkTitle: "集合与函数练习",
|
||||
score: 85,
|
||||
totalScore: 100,
|
||||
status: "PENDING",
|
||||
gradedAt: "2026-07-26T09:00:00Z",
|
||||
gradedBy: "usr-teacher-001",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Grade 单查 mock(与 combined-schema Grade 类型字段对齐)
|
||||
// 用于 grade(id) 单查兜底(真实 schema 可用,MSW 也兜底)
|
||||
const mockGradeDetail = {
|
||||
id: "grade-001",
|
||||
studentId: "stu-001",
|
||||
examId: "exam-001",
|
||||
homeworkId: null,
|
||||
score: "95",
|
||||
totalScore: "100",
|
||||
feedback: "基础扎实,继续保持",
|
||||
gradedBy: "usr-teacher-001",
|
||||
schoolId: "sch-001",
|
||||
idempotencyKey: null,
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
};
|
||||
|
||||
// ── Grade Analytics mock(混合契约:基础统计 + 扩展字段 MSW)──
|
||||
// 用于 /shell/teacher/grades/analytics 分析页 MSW 兜底
|
||||
// 关联:ARCHITECTURE.md §9.1 analytics 页 / §11.4 契约工单
|
||||
const mockGradeAnalytics = {
|
||||
scope: "exam:exam-001",
|
||||
scopeName: "2026 春季期中考试",
|
||||
summary: {
|
||||
totalStudents: 38,
|
||||
gradedCount: 36,
|
||||
avgScore: 78.5,
|
||||
maxScore: 98,
|
||||
minScore: 45,
|
||||
passRate: 0.86,
|
||||
passScore: 60,
|
||||
},
|
||||
distribution: [
|
||||
{ label: "0-59", count: 5 },
|
||||
{ label: "60-69", count: 6 },
|
||||
{ label: "70-79", count: 8 },
|
||||
{ label: "80-89", count: 10 },
|
||||
{ label: "90-100", count: 7 },
|
||||
],
|
||||
rankings: [
|
||||
{
|
||||
studentId: "stu-001",
|
||||
studentNo: "2026001",
|
||||
studentName: "张明",
|
||||
score: 98,
|
||||
rank: 1,
|
||||
level: "A",
|
||||
},
|
||||
{
|
||||
studentId: "stu-002",
|
||||
studentNo: "2026002",
|
||||
studentName: "李华",
|
||||
score: 95,
|
||||
rank: 2,
|
||||
level: "A",
|
||||
},
|
||||
{
|
||||
studentId: "stu-003",
|
||||
studentNo: "2026003",
|
||||
studentName: "王芳",
|
||||
score: 91,
|
||||
rank: 3,
|
||||
level: "A",
|
||||
},
|
||||
{
|
||||
studentId: "stu-005",
|
||||
studentNo: "2026005",
|
||||
studentName: "钱七",
|
||||
score: 88,
|
||||
rank: 4,
|
||||
level: "B",
|
||||
},
|
||||
{
|
||||
studentId: "stu-004",
|
||||
studentNo: "2026004",
|
||||
studentName: "赵六",
|
||||
score: 45,
|
||||
rank: 36,
|
||||
level: "D",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── Grade Stats mock(@contract-pending 全 MSW)──
|
||||
// 用于 /shell/teacher/grades/stats 统计页 MSW 兜底
|
||||
const mockGradeStats = {
|
||||
classId: "cls-001",
|
||||
period: "2026-spring",
|
||||
summaries: [
|
||||
{
|
||||
classId: "cls-001",
|
||||
className: "高三(1)班",
|
||||
subjectId: "sub-math",
|
||||
subjectName: "数学",
|
||||
studentCount: 38,
|
||||
avgScore: 78.5,
|
||||
maxScore: 98,
|
||||
minScore: 45,
|
||||
passRate: 0.86,
|
||||
passCount: 31,
|
||||
failCount: 5,
|
||||
},
|
||||
{
|
||||
classId: "cls-001",
|
||||
className: "高三(1)班",
|
||||
subjectId: "sub-physics",
|
||||
subjectName: "物理",
|
||||
studentCount: 38,
|
||||
avgScore: 72.3,
|
||||
maxScore: 95,
|
||||
minScore: 38,
|
||||
passRate: 0.79,
|
||||
passCount: 30,
|
||||
failCount: 8,
|
||||
},
|
||||
{
|
||||
classId: "cls-001",
|
||||
className: "高三(1)班",
|
||||
subjectId: "sub-chemistry",
|
||||
subjectName: "化学",
|
||||
studentCount: 38,
|
||||
avgScore: 81.2,
|
||||
maxScore: 97,
|
||||
minScore: 52,
|
||||
passRate: 0.92,
|
||||
passCount: 35,
|
||||
failCount: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── Report Card mock(@contract-pending 全 MSW)──
|
||||
// 用于 /shell/teacher/grades/report-card 成绩单页 MSW 兜底
|
||||
const mockReportCard = {
|
||||
studentId: "stu-001",
|
||||
studentName: "张明",
|
||||
studentNo: "2026001",
|
||||
className: "高三(1)班",
|
||||
period: "2026-spring",
|
||||
entries: [
|
||||
{
|
||||
subjectId: "sub-math",
|
||||
subjectName: "数学",
|
||||
examName: "2026 春季期中考试",
|
||||
homeworkTitle: null,
|
||||
score: 98,
|
||||
totalScore: 100,
|
||||
gradeLevel: "A",
|
||||
feedback: "基础扎实,继续保持",
|
||||
gradedAt: "2026-04-16T10:00:00Z",
|
||||
},
|
||||
{
|
||||
subjectId: "sub-physics",
|
||||
subjectName: "物理",
|
||||
examName: "2026 春季期中考试",
|
||||
homeworkTitle: null,
|
||||
score: 92,
|
||||
totalScore: 100,
|
||||
gradeLevel: "A",
|
||||
feedback: "物理思维清晰",
|
||||
gradedAt: "2026-04-16T11:00:00Z",
|
||||
},
|
||||
{
|
||||
subjectId: "sub-chemistry",
|
||||
subjectName: "化学",
|
||||
examName: null,
|
||||
homeworkTitle: "化学方程式练习",
|
||||
score: 88,
|
||||
totalScore: 100,
|
||||
gradeLevel: "B",
|
||||
feedback: "方程式记忆需加强",
|
||||
gradedAt: "2026-07-20T09:00:00Z",
|
||||
},
|
||||
],
|
||||
totalAvgScore: 92.7,
|
||||
totalRank: 3,
|
||||
totalStudents: 38,
|
||||
};
|
||||
|
||||
// ── GraphQL Response ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1248,9 +1525,94 @@ export function graphqlResponse(
|
||||
return { data: { recordGrade: { gradeId } } };
|
||||
}
|
||||
|
||||
// ── Grades 域(预留) ──
|
||||
case "GetGrades":
|
||||
return { data: { grades: mockGrades } };
|
||||
// ── Grades 域(教师域 P2 迁移,@contract-pending)──
|
||||
// GetGrade($id):按 id 单查(真实 schema 可用,MSW 也兜底)
|
||||
case "GetGrade": {
|
||||
const gradeId = (variables?.id as string | undefined) ?? "";
|
||||
const listMatch = mockGradesList.find((g) => g.id === gradeId);
|
||||
// mockGradesList 字段是 number,mockGradeDetail 字段是 string
|
||||
// 统一返回 string 类型(对齐 schema Grade.score/totalScore: String)
|
||||
return {
|
||||
data: {
|
||||
grade: listMatch
|
||||
? {
|
||||
id: listMatch.id,
|
||||
studentId: listMatch.studentId,
|
||||
examId: listMatch.examId,
|
||||
homeworkId: listMatch.homeworkId,
|
||||
score: String(listMatch.score),
|
||||
totalScore: String(listMatch.totalScore),
|
||||
feedback: "基础扎实,继续保持",
|
||||
gradedBy: listMatch.gradedBy,
|
||||
schoolId: "sch-001",
|
||||
idempotencyKey: null,
|
||||
createdAt: listMatch.gradedAt,
|
||||
updatedAt: listMatch.gradedAt,
|
||||
}
|
||||
: mockGradeDetail,
|
||||
},
|
||||
};
|
||||
}
|
||||
// GetGradesList($classId):列表查询,按 classId/examId/studentId/status 过滤
|
||||
case "GetGradesList": {
|
||||
const classId = variables?.classId as string | undefined;
|
||||
const examId = variables?.examId as string | undefined;
|
||||
const studentId = variables?.studentId as string | undefined;
|
||||
const status = variables?.status as string | undefined;
|
||||
const filtered = mockGradesList.filter(
|
||||
(g) =>
|
||||
(!classId || g.classId === classId) &&
|
||||
(!examId || g.examId === examId) &&
|
||||
(!studentId || g.studentId === studentId) &&
|
||||
(!status || g.status === status),
|
||||
);
|
||||
return {
|
||||
data: {
|
||||
grades: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// CreateGrade($input):成绩录入 mutation 兜底
|
||||
case "CreateGrade": {
|
||||
const input = (variables?.input ?? {}) as Record<string, unknown>;
|
||||
const newId = `grade-${Date.now()}`;
|
||||
mockGradesList.push({
|
||||
id: newId,
|
||||
studentId: (input.studentId as string) ?? "stu-001",
|
||||
studentName: "新录入学生",
|
||||
studentNo: "2026999",
|
||||
classId: (input.classId as string) ?? "cls-001",
|
||||
className: "高三(1)班",
|
||||
examId: (input.examId as string | null) ?? null,
|
||||
examName: (input.examId as string) ? "考试成绩" : null,
|
||||
homeworkId: (input.homeworkId as string | null) ?? null,
|
||||
homeworkTitle: (input.homeworkId as string) ? "作业成绩" : null,
|
||||
score: (input.score as number) ?? 0,
|
||||
totalScore: (input.totalScore as number) ?? 100,
|
||||
status: "GRADED",
|
||||
gradedAt: new Date().toISOString(),
|
||||
gradedBy: "usr-teacher-001",
|
||||
});
|
||||
return { data: { createGrade: { gradeId: newId } } };
|
||||
}
|
||||
|
||||
// ── Grade Analytics(混合契约:基础统计 + 扩展字段 MSW)──
|
||||
// 用于 /shell/teacher/grades/analytics 分析页
|
||||
case "GetGradeAnalytics":
|
||||
return { data: { gradeAnalytics: mockGradeAnalytics } };
|
||||
|
||||
// ── Grade Stats(@contract-pending 全 MSW)──
|
||||
// 用于 /shell/teacher/grades/stats 统计页
|
||||
case "GetGradeStats":
|
||||
return { data: { gradeStats: mockGradeStats } };
|
||||
|
||||
// ── Report Card(@contract-pending 全 MSW)──
|
||||
// 用于 /shell/teacher/grades/report-card 成绩单页
|
||||
case "GetReportCard":
|
||||
return { data: { reportCard: mockReportCard } };
|
||||
|
||||
// ── 通用 ──
|
||||
case "GetNotificationsList":
|
||||
|
||||
Reference in New Issue
Block a user