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:
SpecialX
2026-07-22 18:41:55 +08:00
parent 081cb5fbc3
commit 2f8f3f3855
22 changed files with 3144 additions and 34 deletions

View File

@@ -19,9 +19,9 @@ interface Baseline {
categories: Record<string, { pattern: string; min: number; label: string }>; 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 = { const BASELINE: Baseline = {
total: 26, total: 31,
categories: { categories: {
dashboards: { dashboards: {
pattern: "shell/{admin,teacher,student,parent}/page.tsx", pattern: "shell/{admin,teacher,student,parent}/page.tsx",

View File

@@ -0,0 +1,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 边界包裹。
* 业务逻辑在 GradeAnalyticsClientclient component中。
*
* 数据契约:混合契约
* - 基础统计 ✅ assignmentAnalysisdata-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>
);
}

View File

@@ -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 要求)。
* 业务逻辑在 GradeEntryClientclient 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>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 成绩路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { 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>
);
}

View File

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

View 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 要求)。
* 业务逻辑在 GradesListClientclient 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>
);
}

View File

@@ -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 边界包裹。
* 业务逻辑在 ReportCardClientclient 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>
);
}

View File

@@ -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 边界包裹。
* 业务逻辑在 GradeStatsClientclient 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>
);
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,246 @@
"use client";
/**
* 成绩分析页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约(混合契约):
* - 基础统计 ✅ assignmentAnalysisdata-ana 子图schema 已就绪)
* - 扩展字段(排名/分布)❌ → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 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>
);
}

View File

@@ -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
* - loadingFormPageSkeleton由 server page Suspense 兜底)
* - errorerrorSummary 表单级错误
* - successnotify.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-pendingMSW 兜底
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>
);
}

View File

@@ -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
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 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-pendingMSW 兜底
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>
);
}

View File

@@ -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 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联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-pendingMSW 兜底
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>
);
}

View File

@@ -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
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 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-pendingMSW 兜底
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>
);
}

View 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/totalScoreString 或 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 是 stringGradeListItem 的是 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,
};
}

View File

@@ -0,0 +1,426 @@
"use client";
/**
* Grades domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域成绩模块)
*
* 三类操作:
* 1. useGrade按 id 单查):✅ 真实查询 grade(id: ID!)schema 已就绪
* 2. 列表/分析/统计/成绩单查询:❌ schema 无对应字段 → MSW 兜底(@contract-pending
* 3. useCreateGrademutation❌ 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-pendingMSW 兜底)。
*
* 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-pendingMSW 兜底)。
*
* 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-pendingMSW 兜底)。
*
* 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-pendingMSW 兜底)。
*
* 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,
};
}

View File

@@ -16,6 +16,7 @@ export * from "./topbar";
export * from "./teacher"; export * from "./teacher";
export * from "./exams"; export * from "./exams";
export * from "./homework"; export * from "./homework";
export * from "./grades";
export * from "./student"; export * from "./student";
export * from "./parent"; export * from "./parent";
export * from "./admin"; export * from "./admin";

View 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
}
}
`;

View File

@@ -6,6 +6,7 @@ export * from "./topbar.graphql";
export * from "./teacher.graphql"; export * from "./teacher.graphql";
export * from "./exams.graphql"; export * from "./exams.graphql";
export * from "./homework.graphql"; export * from "./homework.graphql";
export * from "./grades.graphql";
export * from "./student.graphql"; export * from "./student.graphql";
export * from "./parent.graphql"; export * from "./parent.graphql";
export * from "./admin.graphql"; export * from "./admin.graphql";

View File

@@ -513,7 +513,125 @@
} }
}, },
"grades": { "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": { "analytics": {
"title": "Analytics" "title": "Analytics"

View File

@@ -513,7 +513,125 @@
} }
}, },
"grades": { "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": { "analytics": {
"title": "学情分析" "title": "学情分析"

View File

@@ -306,33 +306,6 @@ const mockExamDetail = {
updatedAt: "2026-04-16T10:00:00Z", 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── // ── Exam Analytics mock混合契约基础统计真实 + 扩展字段 MSW──
// 用于 /shell/teacher/exams/[id]/analytics 详情页 MSW 兜底 // 用于 /shell/teacher/exams/[id]/analytics 详情页 MSW 兜底
// 关联ARCHITECTURE.md §9.1 analytics 页 / §11.4 契约工单 // 关联ARCHITECTURE.md §9.1 analytics 页 / §11.4 契约工单
@@ -856,6 +829,310 @@ const mockAiBatchGrading = {
}, },
}; };
// ── Grades 域(@contract-pendingschema 无 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 ─────────────────────────────────────────── // ── GraphQL Response ───────────────────────────────────────────
/** /**
@@ -1248,9 +1525,94 @@ export function graphqlResponse(
return { data: { recordGrade: { gradeId } } }; return { data: { recordGrade: { gradeId } } };
} }
// ── Grades 域(预留) ── // ── Grades 域(教师域 P2 迁移,@contract-pending──
case "GetGrades": // GetGrade($id):按 id 单查(真实 schema 可用MSW 也兜底)
return { data: { grades: mockGrades } }; case "GetGrade": {
const gradeId = (variables?.id as string | undefined) ?? "";
const listMatch = mockGradesList.find((g) => g.id === gradeId);
// mockGradesList 字段是 numbermockGradeDetail 字段是 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": case "GetNotificationsList":