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

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