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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user