feat(portal-shell): attendance + classes + students 模块 8 页迁移(教师域 §9.1 B2)
§9.1 line 632-634 教师域: - /shell/teacher/attendance (列表) / /sheet (表单) / /report (报表) / /stats (统计) — 4 页 - /shell/teacher/classes (列表) / /[id] (详情) / /schedule (课表) — 3 页 - /shell/teacher/students (列表) — 1 页 契约: - attendance 全 ❌ → MSW 兜底 - classes 🟡 classInfo(id) ✅ 真实单查 + 列表 ❌ MSW - students 全 ❌ → MSW 兜底 新增文件: - src/lib/api/{attendance,classes,students}.ts (14 hooks 合计) - src/lib/api/operations/{attendance,classes,students}.graphql.ts (14 documents) - src/features/teacher/{attendance,classes,students}/ (clients + transformations + tests) - src/app/shell/teacher/{attendance,classes,students}/ (8 page.tsx + 3 loading + 3 error) 修改文件: - src/mocks/graphql-data.ts (12 mock 数据 + handler cases) - src/messages/{zh-CN,en}.json (attendance/classes/students i18n 命名空间) - src/lib/api/{index,operations/index}.ts (导出 attendance/classes/students) - src/shared/lib/route-permissions.ts (attendance/classes/students 路由权限) - scripts/check-page-count.ts (baseline 40 → 48) DoD 验收(§11.3 11 项): - typecheck 0 errors - lint 0 errors - vitest 566 tests passed - lint:tokens 0 errors - check:pages 48 PASS - route-permissions 已声明 - 三态齐备 - @contract-pending + MSW 兜底 - i18n zh-CN + en 同步 设计决策: - classes/[id] 走真实 classInfo(id) 查询 - 无 STUDENT_* 权限点,students 路由复用 CLASS_READ/CLASS_MANAGE 关联:ARCHITECTURE.md §5.3 / §5.4 / §5.5 / §9.1 / §10 P2 / §11.3 / §11.4 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Attendance 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ATTENDANCE_STATUS_LABEL,
|
||||
attendanceRateToBarClass,
|
||||
attendanceRateToColorClass,
|
||||
attendanceStatusToBadgeClass,
|
||||
computeAttendanceRate,
|
||||
countAttendanceStatus,
|
||||
formatAttendanceDate,
|
||||
formatAttendanceDay,
|
||||
formatAttendanceRate,
|
||||
formatAttendanceStatus,
|
||||
formatSchedulePeriod,
|
||||
formatWeekday,
|
||||
isAttendanceAbnormal,
|
||||
isAttendancePresent,
|
||||
} from "../transformations";
|
||||
|
||||
describe("formatAttendanceStatus", () => {
|
||||
it("maps known statuses to Chinese labels", () => {
|
||||
expect(formatAttendanceStatus("present")).toBe("出勤");
|
||||
expect(formatAttendanceStatus("absent")).toBe("缺勤");
|
||||
expect(formatAttendanceStatus("late")).toBe("迟到");
|
||||
expect(formatAttendanceStatus("leave")).toBe("请假");
|
||||
});
|
||||
|
||||
it("returns original value for unknown status", () => {
|
||||
expect(formatAttendanceStatus("unknown")).toBe("unknown");
|
||||
expect(formatAttendanceStatus("")).toBe("");
|
||||
});
|
||||
|
||||
it("ATTENDANCE_STATUS_LABEL covers 4 standard statuses", () => {
|
||||
expect(Object.keys(ATTENDANCE_STATUS_LABEL)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attendanceStatusToBadgeClass", () => {
|
||||
it("returns emerald class for present", () => {
|
||||
expect(attendanceStatusToBadgeClass("present")).toContain("emerald");
|
||||
});
|
||||
|
||||
it("returns destructive class for absent", () => {
|
||||
expect(attendanceStatusToBadgeClass("absent")).toContain("destructive");
|
||||
});
|
||||
|
||||
it("returns amber class for late", () => {
|
||||
expect(attendanceStatusToBadgeClass("late")).toContain("amber");
|
||||
});
|
||||
|
||||
it("returns blue class for leave", () => {
|
||||
expect(attendanceStatusToBadgeClass("leave")).toContain("blue");
|
||||
});
|
||||
|
||||
it("returns muted for unknown status", () => {
|
||||
expect(attendanceStatusToBadgeClass("unknown")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAttendanceDate", () => {
|
||||
it("formats valid ISO date string with time", () => {
|
||||
const result = formatAttendanceDate("2026-07-22T10:30:00Z");
|
||||
expect(result).toContain("2026");
|
||||
expect(result).toContain("07");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/empty", () => {
|
||||
expect(formatAttendanceDate(null)).toBe("--");
|
||||
expect(formatAttendanceDate(undefined)).toBe("--");
|
||||
expect(formatAttendanceDate("")).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid date", () => {
|
||||
expect(formatAttendanceDate("not-a-date")).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAttendanceDay", () => {
|
||||
it("formats valid ISO date string as date only", () => {
|
||||
const result = formatAttendanceDay("2026-07-22T10:30:00Z");
|
||||
expect(result).toContain("2026");
|
||||
expect(result).toContain("07");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/empty", () => {
|
||||
expect(formatAttendanceDay(null)).toBe("--");
|
||||
expect(formatAttendanceDay(undefined)).toBe("--");
|
||||
expect(formatAttendanceDay("")).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAttendanceRate", () => {
|
||||
it("formats 0~1 float as percentage", () => {
|
||||
expect(formatAttendanceRate(0)).toBe("0.0%");
|
||||
expect(formatAttendanceRate(0.85)).toBe("85.0%");
|
||||
expect(formatAttendanceRate(1)).toBe("100.0%");
|
||||
});
|
||||
|
||||
it("treats values > 1 as already percentage", () => {
|
||||
expect(formatAttendanceRate(85)).toBe("85.0%");
|
||||
expect(formatAttendanceRate(100)).toBe("100.0%");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/non-finite", () => {
|
||||
expect(formatAttendanceRate(null)).toBe("--");
|
||||
expect(formatAttendanceRate(undefined)).toBe("--");
|
||||
expect(formatAttendanceRate(Number.NaN)).toBe("--");
|
||||
expect(formatAttendanceRate(Number.POSITIVE_INFINITY)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("attendanceRateToColorClass", () => {
|
||||
it("returns emerald for >= 0.95", () => {
|
||||
expect(attendanceRateToColorClass(0.95)).toBe("text-emerald-600");
|
||||
expect(attendanceRateToColorClass(1)).toBe("text-emerald-600");
|
||||
});
|
||||
|
||||
it("returns primary for >= 0.9 and < 0.95", () => {
|
||||
expect(attendanceRateToColorClass(0.9)).toBe("text-primary");
|
||||
expect(attendanceRateToColorClass(0.94)).toBe("text-primary");
|
||||
});
|
||||
|
||||
it("returns amber for >= 0.8 and < 0.9", () => {
|
||||
expect(attendanceRateToColorClass(0.8)).toBe("text-amber-600");
|
||||
expect(attendanceRateToColorClass(0.89)).toBe("text-amber-600");
|
||||
});
|
||||
|
||||
it("returns destructive for < 0.8", () => {
|
||||
expect(attendanceRateToColorClass(0.79)).toBe("text-destructive");
|
||||
expect(attendanceRateToColorClass(0)).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("treats values > 1 as percentage and normalizes", () => {
|
||||
expect(attendanceRateToColorClass(95)).toBe("text-emerald-600");
|
||||
expect(attendanceRateToColorClass(85)).toBe("text-amber-600");
|
||||
});
|
||||
|
||||
it("returns muted for null/undefined/non-finite", () => {
|
||||
expect(attendanceRateToColorClass(null)).toBe("text-muted-foreground");
|
||||
expect(attendanceRateToColorClass(undefined)).toBe("text-muted-foreground");
|
||||
expect(attendanceRateToColorClass(Number.NaN)).toBe(
|
||||
"text-muted-foreground",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attendanceRateToBarClass", () => {
|
||||
it("returns emerald bar for >= 0.95", () => {
|
||||
expect(attendanceRateToBarClass(0.95)).toBe("bg-emerald-500/70");
|
||||
expect(attendanceRateToBarClass(1)).toBe("bg-emerald-500/70");
|
||||
});
|
||||
|
||||
it("returns primary bar for >= 0.9 and < 0.95", () => {
|
||||
expect(attendanceRateToBarClass(0.9)).toBe("bg-primary/70");
|
||||
expect(attendanceRateToBarClass(0.94)).toBe("bg-primary/70");
|
||||
});
|
||||
|
||||
it("returns amber bar for >= 0.8 and < 0.9", () => {
|
||||
expect(attendanceRateToBarClass(0.8)).toBe("bg-amber-500/70");
|
||||
expect(attendanceRateToBarClass(0.89)).toBe("bg-amber-500/70");
|
||||
});
|
||||
|
||||
it("returns destructive bar for < 0.8", () => {
|
||||
expect(attendanceRateToBarClass(0.79)).toBe("bg-destructive/70");
|
||||
expect(attendanceRateToBarClass(0)).toBe("bg-destructive/70");
|
||||
});
|
||||
|
||||
it("treats values > 1 as percentage and normalizes", () => {
|
||||
expect(attendanceRateToBarClass(95)).toBe("bg-emerald-500/70");
|
||||
expect(attendanceRateToBarClass(85)).toBe("bg-amber-500/70");
|
||||
});
|
||||
|
||||
it("returns muted bar for null/undefined/non-finite", () => {
|
||||
expect(attendanceRateToBarClass(null)).toBe("bg-muted");
|
||||
expect(attendanceRateToBarClass(undefined)).toBe("bg-muted");
|
||||
expect(attendanceRateToBarClass(Number.NaN)).toBe("bg-muted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatWeekday", () => {
|
||||
it("maps 0-6 to Chinese weekday labels", () => {
|
||||
expect(formatWeekday(0)).toBe("周日");
|
||||
expect(formatWeekday(1)).toBe("周一");
|
||||
expect(formatWeekday(2)).toBe("周二");
|
||||
expect(formatWeekday(3)).toBe("周三");
|
||||
expect(formatWeekday(4)).toBe("周四");
|
||||
expect(formatWeekday(5)).toBe("周五");
|
||||
expect(formatWeekday(6)).toBe("周六");
|
||||
});
|
||||
|
||||
it("returns placeholder for out-of-range", () => {
|
||||
expect(formatWeekday(-1)).toBe("--");
|
||||
expect(formatWeekday(7)).toBe("--");
|
||||
expect(formatWeekday(3.7)).toBe("周三");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSchedulePeriod", () => {
|
||||
it("formats integer period as section label", () => {
|
||||
expect(formatSchedulePeriod(1)).toBe("第 1 节");
|
||||
expect(formatSchedulePeriod(8)).toBe("第 8 节");
|
||||
});
|
||||
|
||||
it("returns placeholder for non-finite", () => {
|
||||
expect(formatSchedulePeriod(Number.NaN)).toBe("--");
|
||||
expect(formatSchedulePeriod(Number.POSITIVE_INFINITY)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAttendancePresent / isAttendanceAbnormal", () => {
|
||||
it("present is present, not abnormal", () => {
|
||||
expect(isAttendancePresent("present")).toBe(true);
|
||||
expect(isAttendanceAbnormal("present")).toBe(false);
|
||||
});
|
||||
|
||||
it("absent is abnormal, not present", () => {
|
||||
expect(isAttendancePresent("absent")).toBe(false);
|
||||
expect(isAttendanceAbnormal("absent")).toBe(true);
|
||||
});
|
||||
|
||||
it("late is abnormal", () => {
|
||||
expect(isAttendanceAbnormal("late")).toBe(true);
|
||||
});
|
||||
|
||||
it("leave is not abnormal, not present", () => {
|
||||
expect(isAttendancePresent("leave")).toBe(false);
|
||||
expect(isAttendanceAbnormal("leave")).toBe(false);
|
||||
});
|
||||
|
||||
it("unknown is neither present nor abnormal", () => {
|
||||
expect(isAttendancePresent("unknown")).toBe(false);
|
||||
expect(isAttendanceAbnormal("unknown")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countAttendanceStatus", () => {
|
||||
it("counts each status correctly", () => {
|
||||
const counts = countAttendanceStatus([
|
||||
"present",
|
||||
"present",
|
||||
"absent",
|
||||
"late",
|
||||
"leave",
|
||||
"present",
|
||||
"unknown",
|
||||
]);
|
||||
expect(counts.present).toBe(3);
|
||||
expect(counts.absent).toBe(1);
|
||||
expect(counts.late).toBe(1);
|
||||
expect(counts.leave).toBe(1);
|
||||
});
|
||||
|
||||
it("returns zeros for empty array", () => {
|
||||
const counts = countAttendanceStatus([]);
|
||||
expect(counts.present).toBe(0);
|
||||
expect(counts.absent).toBe(0);
|
||||
expect(counts.late).toBe(0);
|
||||
expect(counts.leave).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores unknown statuses", () => {
|
||||
const counts = countAttendanceStatus(["foo", "bar"]);
|
||||
expect(counts.present).toBe(0);
|
||||
expect(counts.absent).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAttendanceRate", () => {
|
||||
it("computes rate = present / total", () => {
|
||||
expect(computeAttendanceRate(8, 10)).toBe(0.8);
|
||||
expect(computeAttendanceRate(0, 10)).toBe(0);
|
||||
expect(computeAttendanceRate(10, 10)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 when total is 0", () => {
|
||||
expect(computeAttendanceRate(0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when total is negative or non-finite", () => {
|
||||
expect(computeAttendanceRate(1, -1)).toBe(0);
|
||||
expect(computeAttendanceRate(1, Number.NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps to 1 when present > total", () => {
|
||||
expect(computeAttendanceRate(12, 10)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考勤记录列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 attendanceRecords(...):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/classes_contract.md#attendance-records-list
|
||||
*
|
||||
* URL 状态:?classId=&date=&status=&q=
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardList } 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 { useAttendanceRecords, type AttendanceRecord } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
attendanceStatusToBadgeClass,
|
||||
formatAttendanceDate,
|
||||
formatAttendanceDay,
|
||||
formatAttendanceStatus,
|
||||
} from "@/features/teacher/attendance/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
*/
|
||||
export function AttendanceListClient(): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "";
|
||||
const date = searchParams.get("date") ?? "";
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useAttendanceRecords({
|
||||
classId: classId || undefined,
|
||||
date: date || undefined,
|
||||
status: statusFilter || undefined,
|
||||
q: q || undefined,
|
||||
});
|
||||
|
||||
const filteredItems = useMemo<AttendanceRecord[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items;
|
||||
}, [data]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/attendance?${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;
|
||||
|
||||
const emptyNode = (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title={t("list.emptyTitle")}
|
||||
description={t("list.emptyDescription")}
|
||||
action={{
|
||||
label: t("list.emptyAction"),
|
||||
href: "/shell/teacher/attendance/sheet",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<ClipboardList className="size-6" />}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href="/shell/teacher/attendance/sheet">
|
||||
{t("list.newSheet")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => updateQuery("classId", e.target.value)}
|
||||
placeholder={t("list.classPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.classPlaceholder")}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => updateQuery("date", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.dateFilter")}
|
||||
/>
|
||||
<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="present">{t("list.statusPresent")}</option>
|
||||
<option value="absent">{t("list.statusAbsent")}</option>
|
||||
<option value="late">{t("list.statusLate")}</option>
|
||||
<option value="leave">{t("list.statusLeave")}</option>
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
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>
|
||||
}
|
||||
>
|
||||
<AttendanceTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 考勤记录列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function AttendanceTable({
|
||||
items,
|
||||
}: {
|
||||
items: AttendanceRecord[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
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.colStudentName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colClassName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colDate")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colRemark")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colRecordedBy")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colUpdatedAt")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{r.studentName}</td>
|
||||
<td className="p-3 text-muted-foreground">{r.className}</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatAttendanceDay(r.date)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<AttendanceStatusBadge status={r.status} />
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{r.remark ?? "-"}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{r.recordedBy}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatAttendanceDate(r.updatedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 考勤状态徽章(按状态色阶展示)。
|
||||
*/
|
||||
function AttendanceStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: string;
|
||||
}): React.ReactElement {
|
||||
const label = formatAttendanceStatus(status);
|
||||
const cls = attendanceStatusToBadgeClass(status);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考勤报表页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 报表查询 attendanceReport(classId, range):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/classes_contract.md#attendance-report
|
||||
*
|
||||
* 三态规范(§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 { useAttendanceReport, type AttendanceReportItem } from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
attendanceRateToColorClass,
|
||||
formatAttendanceRate,
|
||||
} from "@/features/teacher/attendance/transformations";
|
||||
|
||||
/**
|
||||
* 报表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function AttendanceReportClient(): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
const tCommon = useTranslations("common");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "cls-001";
|
||||
const startDate = searchParams.get("startDate") ?? undefined;
|
||||
const endDate = searchParams.get("endDate") ?? undefined;
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useAttendanceReport(
|
||||
classId,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
|
||||
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("report.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title={t("report.notFound")}
|
||||
action={{
|
||||
label: t("report.backToList"),
|
||||
href: "/shell/teacher/attendance",
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={t("report.title")}
|
||||
description={
|
||||
data
|
||||
? t("report.subtitle", {
|
||||
className: data.className,
|
||||
range: data.range,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<FileText className="size-6" />}
|
||||
backHref="/shell/teacher/attendance"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <ReportSummarySection data={data} /> : null}
|
||||
{data ? <ReportItemsSection items={data.items} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 报表汇总区。
|
||||
*/
|
||||
function ReportSummarySection({
|
||||
data,
|
||||
}: {
|
||||
data: NonNullable<ReturnType<typeof useAttendanceReport>["data"]>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
const summary = data.summary;
|
||||
return (
|
||||
<DetailSection title={t("report.sectionSummary")}>
|
||||
<DetailField
|
||||
label={t("report.fieldTotal")}
|
||||
value={String(summary.total)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("report.fieldPresent")}
|
||||
value={String(summary.present)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("report.fieldAbsent")}
|
||||
value={String(summary.absent)}
|
||||
/>
|
||||
<DetailField label={t("report.fieldLate")} value={String(summary.late)} />
|
||||
<DetailField
|
||||
label={t("report.fieldLeave")}
|
||||
value={String(summary.leave)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("report.fieldAttendanceRate")}
|
||||
value={
|
||||
<span
|
||||
className={`font-medium ${attendanceRateToColorClass(summary.attendanceRate)}`}
|
||||
>
|
||||
{formatAttendanceRate(summary.attendanceRate)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 报表明细区(按学生聚合)。
|
||||
*/
|
||||
function ReportItemsSection({
|
||||
items,
|
||||
}: {
|
||||
items: AttendanceReportItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
return (
|
||||
<DetailSection title={t("report.sectionItems")}>
|
||||
<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("report.colStudentName")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("report.colPresent")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("report.colAbsent")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("report.colLate")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("report.colLeave")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("report.colAttendanceRate")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((item) => (
|
||||
<tr key={item.studentId} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{item.studentName}</td>
|
||||
<td className="p-3 text-center text-muted-foreground">
|
||||
{item.present}
|
||||
</td>
|
||||
<td className="p-3 text-center text-muted-foreground">
|
||||
{item.absent}
|
||||
</td>
|
||||
<td className="p-3 text-center text-muted-foreground">
|
||||
{item.late}
|
||||
</td>
|
||||
<td className="p-3 text-center text-muted-foreground">
|
||||
{item.leave}
|
||||
</td>
|
||||
<td
|
||||
className={`p-3 text-right font-medium ${attendanceRateToColorClass(item.attendanceRate)}`}
|
||||
>
|
||||
{formatAttendanceRate(item.attendanceRate)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考勤点名表页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 查询 attendanceSheet(classId, date):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - mutation saveAttendanceSheet(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/classes_contract.md#attendance-sheet
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:FormPageSkeleton(由 server page Suspense 兜底)
|
||||
* - error:errorSummary 表单级错误
|
||||
* - success:notify.success + 刷新列表
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useTransition, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
ATTENDANCE_STATUS,
|
||||
useAttendanceSheet,
|
||||
useSaveAttendanceSheet,
|
||||
type AttendanceEntryInput,
|
||||
} from "@/lib/api";
|
||||
import { FormPageShell } from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import {
|
||||
attendanceStatusToBadgeClass,
|
||||
formatAttendanceStatus,
|
||||
} from "@/features/teacher/attendance/transformations";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
ATTENDANCE_STATUS.PRESENT,
|
||||
ATTENDANCE_STATUS.ABSENT,
|
||||
ATTENDANCE_STATUS.LATE,
|
||||
ATTENDANCE_STATUS.LEAVE,
|
||||
];
|
||||
|
||||
/**
|
||||
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function AttendanceSheetClient(): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const presetClassId = searchParams.get("classId") ?? "";
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const presetDate = searchParams.get("date") ?? today;
|
||||
|
||||
const [classId, setClassId] = useState(presetClassId);
|
||||
const [date, setDate] = useState(presetDate);
|
||||
const [entries, setEntries] = useState<Record<string, AttendanceEntryInput>>(
|
||||
{},
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useAttendanceSheet(classId, date, {
|
||||
enabled: classId.length > 0 && date.length > 0,
|
||||
});
|
||||
|
||||
// 初始化 entries(首次加载数据后)
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setEntries((prev) => {
|
||||
if (Object.keys(prev).length > 0) return prev;
|
||||
const next: Record<string, AttendanceEntryInput> = {};
|
||||
for (const e of data.entries) {
|
||||
next[e.studentId] = {
|
||||
studentId: e.studentId,
|
||||
status: e.status,
|
||||
remark: e.remark ?? undefined,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const handleStatusChange = (studentId: string, status: string): void => {
|
||||
setEntries((prev) => ({
|
||||
...prev,
|
||||
[studentId]: {
|
||||
studentId,
|
||||
status,
|
||||
remark: prev[studentId]?.remark,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const { run: saveSheet } = useSaveAttendanceSheet();
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (!classId.trim()) {
|
||||
notify.error(t("sheet.errorClassRequired"));
|
||||
return;
|
||||
}
|
||||
if (!date) {
|
||||
notify.error(t("sheet.errorDateRequired"));
|
||||
return;
|
||||
}
|
||||
const entryList = Object.values(entries);
|
||||
if (entryList.length === 0) {
|
||||
notify.warning(t("sheet.errorNoEntries"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await saveSheet({ classId, date, entries: entryList });
|
||||
notify.success(t("sheet.success"));
|
||||
startTransition(() => {
|
||||
router.push(
|
||||
`/shell/teacher/attendance?classId=${classId}&date=${date}`,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
notify.error(`${t("sheet.error")}: ${String(err)}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const errorSummary = error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{t("sheet.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<FormPageShell
|
||||
title={t("sheet.title")}
|
||||
description={t("sheet.description")}
|
||||
icon={<ClipboardCheck className="size-6" />}
|
||||
backHref="/shell/teacher/attendance"
|
||||
loading={loading && !data}
|
||||
submitting={submitting}
|
||||
submitLabel={t("sheet.submit")}
|
||||
cancelLabel={t("sheet.cancel")}
|
||||
errorSummary={errorSummary}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium">{t("sheet.classId")}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
placeholder={t("sheet.classIdPlaceholder")}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium">{t("sheet.date")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{data ? (
|
||||
<div className="space-y-3">
|
||||
<div className="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("sheet.colStudentName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("sheet.colStatus")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("sheet.colRemark")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.entries.map((entry) => {
|
||||
const current = entries[entry.studentId];
|
||||
const status = current?.status ?? entry.status;
|
||||
return (
|
||||
<tr key={entry.studentId}>
|
||||
<td className="p-3 font-medium">{entry.studentName}</td>
|
||||
<td className="p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleStatusChange(entry.studentId, opt)
|
||||
}
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium transition-opacity ${
|
||||
status === opt
|
||||
? attendanceStatusToBadgeClass(opt)
|
||||
: "bg-muted text-muted-foreground opacity-50"
|
||||
}`}
|
||||
aria-pressed={status === opt}
|
||||
>
|
||||
{formatAttendanceStatus(opt)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{current?.remark ?? entry.remark ?? "-"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("sheet.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FormPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考勤统计页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 统计查询 attendanceStats(...):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/classes_contract.md#attendance-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 {
|
||||
useAttendanceStats,
|
||||
type AttendanceClassRankingItem,
|
||||
type AttendanceStatusDistribution,
|
||||
type AttendanceTrendItem,
|
||||
} from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
attendanceRateToBarClass,
|
||||
attendanceRateToColorClass,
|
||||
attendanceStatusToBadgeClass,
|
||||
formatAttendanceDate,
|
||||
formatAttendanceRate,
|
||||
formatAttendanceStatus,
|
||||
} from "@/features/teacher/attendance/transformations";
|
||||
|
||||
/**
|
||||
* 统计客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function AttendanceStatsClient(): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
const tCommon = useTranslations("common");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const classId = searchParams.get("classId") ?? undefined;
|
||||
const startDate = searchParams.get("startDate") ?? undefined;
|
||||
const endDate = searchParams.get("endDate") ?? undefined;
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useAttendanceStats({
|
||||
classId,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
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("stats.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("stats.notFound")}
|
||||
action={{
|
||||
label: t("stats.backToList"),
|
||||
href: "/shell/teacher/attendance",
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={t("stats.title")}
|
||||
description={
|
||||
data
|
||||
? t("stats.subtitle", {
|
||||
rate: formatAttendanceRate(data.overallAttendanceRate),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<BarChart3 className="size-6" />}
|
||||
backHref="/shell/teacher/attendance"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <StatsOverviewSection data={data} /> : null}
|
||||
{data ? (
|
||||
<StatsDistributionSection items={data.statusDistribution} />
|
||||
) : null}
|
||||
{data ? <StatsTrendSection items={data.trend} /> : null}
|
||||
{data ? <StatsRankingSection items={data.classRanking} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
type StatsData = NonNullable<ReturnType<typeof useAttendanceStats>["data"]>;
|
||||
|
||||
/**
|
||||
* 概览区。
|
||||
*/
|
||||
function StatsOverviewSection({
|
||||
data,
|
||||
}: {
|
||||
data: StatsData;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
return (
|
||||
<DetailSection title={t("stats.sectionOverview")}>
|
||||
<DetailField
|
||||
label={t("stats.fieldOverallRate")}
|
||||
value={
|
||||
<span
|
||||
className={`font-medium ${attendanceRateToColorClass(data.overallAttendanceRate)}`}
|
||||
>
|
||||
{formatAttendanceRate(data.overallAttendanceRate)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("stats.fieldTotalRecords")}
|
||||
value={String(data.totalRecords)}
|
||||
/>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态分布区。
|
||||
*/
|
||||
function StatsDistributionSection({
|
||||
items,
|
||||
}: {
|
||||
items: AttendanceStatusDistribution[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
return (
|
||||
<DetailSection title={t("stats.sectionDistribution")}>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("stats.noDistribution")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.status}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${attendanceStatusToBadgeClass(item.status)}`}
|
||||
>
|
||||
{formatAttendanceStatus(item.status)}
|
||||
</span>
|
||||
<div className="flex flex-1 items-center gap-3">
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.round(item.ratio * 100)}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span className="w-20 text-right font-mono text-xs text-muted-foreground">
|
||||
{item.count} · {formatAttendanceRate(item.ratio)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 趋势区。
|
||||
*/
|
||||
function StatsTrendSection({
|
||||
items,
|
||||
}: {
|
||||
items: AttendanceTrendItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<DetailSection title={t("stats.sectionTrend")}>
|
||||
<p className="text-sm text-muted-foreground">{t("stats.noTrend")}</p>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
const maxRate = Math.max(
|
||||
...items.map((i) => Math.min(1, i.attendanceRate)),
|
||||
0.01,
|
||||
);
|
||||
return (
|
||||
<DetailSection title={t("stats.sectionTrend")}>
|
||||
<div className="flex h-40 items-end gap-1">
|
||||
{items.map((item) => {
|
||||
const heightPct = Math.round(
|
||||
(Math.min(1, item.attendanceRate) / maxRate) * 100,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={item.date}
|
||||
className="flex flex-1 flex-col items-center gap-1"
|
||||
title={`${formatAttendanceDate(item.date)}: ${formatAttendanceRate(item.attendanceRate)}`}
|
||||
>
|
||||
<div
|
||||
className={`w-full rounded-t ${attendanceRateToBarClass(item.attendanceRate)}`}
|
||||
style={{ height: `${Math.max(2, heightPct)}%` }}
|
||||
aria-label={`${formatAttendanceDate(item.date)} ${formatAttendanceRate(item.attendanceRate)}`}
|
||||
/>
|
||||
<span className="w-full truncate text-center text-xs text-muted-foreground">
|
||||
{formatAttendanceDate(item.date).slice(5, 10)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级排名区。
|
||||
*/
|
||||
function StatsRankingSection({
|
||||
items,
|
||||
}: {
|
||||
items: AttendanceClassRankingItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<DetailSection title={t("stats.sectionRanking")}>
|
||||
<p className="text-sm text-muted-foreground">{t("stats.noRanking")}</p>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DetailSection title={t("stats.sectionRanking")}>
|
||||
<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("stats.colRank")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("stats.colClassName")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("stats.colTotalStudents")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("stats.colAttendanceRate")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((item, idx) => (
|
||||
<tr key={item.classId} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="p-3 font-medium">{item.className}</td>
|
||||
<td className="p-3 text-center text-muted-foreground">
|
||||
{item.totalStudents}
|
||||
</td>
|
||||
<td
|
||||
className={`p-3 text-right font-medium ${attendanceRateToColorClass(item.attendanceRate)}`}
|
||||
>
|
||||
{formatAttendanceRate(item.attendanceRate)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Attendance 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
/** 考勤状态中文标签映射(对齐 ATTENDANCE_STATUS 枚举) */
|
||||
export const ATTENDANCE_STATUS_LABEL: Record<string, string> = {
|
||||
present: "出勤",
|
||||
absent: "缺勤",
|
||||
late: "迟到",
|
||||
leave: "请假",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将考勤状态映射为中文标签。未知状态回退为原始值。
|
||||
*/
|
||||
export function formatAttendanceStatus(status: string): string {
|
||||
return ATTENDANCE_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据考勤状态返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function attendanceStatusToBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "present":
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||
case "absent":
|
||||
return "bg-destructive/10 text-destructive";
|
||||
case "late":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||
case "leave":
|
||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatAttendanceDate(
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为纯日期展示(zh-CN,仅年月日)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatAttendanceDay(
|
||||
isoDate: string | null | undefined,
|
||||
): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化出勤率为百分比字符串。
|
||||
* 输入 0~1 的浮点数,返回 "85.0%" 形式;输入无效返回 "--"。
|
||||
*/
|
||||
export function formatAttendanceRate(rate: number | null | undefined): string {
|
||||
if (rate == null || !Number.isFinite(rate)) return "--";
|
||||
const pct = rate <= 1 ? rate * 100 : rate;
|
||||
return `${pct.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据出勤率返回 Tailwind 文本语义类名。
|
||||
* - >= 0.95:emerald(优秀)
|
||||
* - >= 0.9:primary(达标)
|
||||
* - >= 0.8:amber(待改善)
|
||||
* - < 0.8:destructive(需关注)
|
||||
*/
|
||||
export function attendanceRateToColorClass(
|
||||
rate: number | null | undefined,
|
||||
): string {
|
||||
if (rate == null || !Number.isFinite(rate)) return "text-muted-foreground";
|
||||
const r = rate <= 1 ? rate : rate / 100;
|
||||
if (r >= 0.95) return "text-emerald-600";
|
||||
if (r >= 0.9) return "text-primary";
|
||||
if (r >= 0.8) return "text-amber-600";
|
||||
return "text-destructive";
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据出勤率返回 Tailwind 柱状图背景语义类名(静态映射,禁止动态拼接)。
|
||||
* - >= 0.95:emerald(优秀)
|
||||
* - >= 0.9:primary(达标)
|
||||
* - >= 0.8:amber(待改善)
|
||||
* - < 0.8:destructive(需关注)
|
||||
*
|
||||
* 关联:project_rules §3.9 / §3.10 禁止字符串拼接动态类名
|
||||
*/
|
||||
export function attendanceRateToBarClass(
|
||||
rate: number | null | undefined,
|
||||
): string {
|
||||
if (rate == null || !Number.isFinite(rate)) return "bg-muted";
|
||||
const r = rate <= 1 ? rate : rate / 100;
|
||||
if (r >= 0.95) return "bg-emerald-500/70";
|
||||
if (r >= 0.9) return "bg-primary/70";
|
||||
if (r >= 0.8) return "bg-amber-500/70";
|
||||
return "bg-destructive/70";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将星期几(0=周日 … 6=周六)映射为中文标签。
|
||||
*/
|
||||
export function formatWeekday(weekday: number): string {
|
||||
const labels = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
const idx = Math.trunc(weekday);
|
||||
if (idx < 0 || idx > 6) return "--";
|
||||
return labels[idx] ?? "--";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化课表节次为展示字符串("第 1 节")。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatSchedulePeriod(period: number): string {
|
||||
if (!Number.isFinite(period)) return "--";
|
||||
return `第 ${period} 节`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断考勤状态是否为出勤(present)。
|
||||
*/
|
||||
export function isAttendancePresent(status: string): boolean {
|
||||
return status === "present";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断考勤状态是否为异常(缺勤/迟到)。
|
||||
*/
|
||||
export function isAttendanceAbnormal(status: string): boolean {
|
||||
return status === "absent" || status === "late";
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计给定状态列表中各状态的数量。
|
||||
* 返回 { present, absent, late, leave } 计数对象。
|
||||
*/
|
||||
export function countAttendanceStatus(statuses: readonly string[]): {
|
||||
present: number;
|
||||
absent: number;
|
||||
late: number;
|
||||
leave: number;
|
||||
} {
|
||||
const counts = { present: 0, absent: 0, late: 0, leave: 0 };
|
||||
for (const s of statuses) {
|
||||
if (s in counts) {
|
||||
counts[s as keyof typeof counts] += 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算出勤率(present / total)。
|
||||
* total 为 0 时返回 0。
|
||||
*/
|
||||
export function computeAttendanceRate(present: number, total: number): number {
|
||||
if (!Number.isFinite(total) || total <= 0) return 0;
|
||||
return Math.min(1, present / total);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Classes 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClassInfo, ClassScheduleItem } from "@/lib/api";
|
||||
|
||||
import {
|
||||
formatClassDate,
|
||||
formatSchedulePeriod,
|
||||
formatScheduleTime,
|
||||
formatStudentCount,
|
||||
formatSubjectCount,
|
||||
formatWeekday,
|
||||
groupScheduleByWeekday,
|
||||
hasDescription,
|
||||
hasHeadTeacher,
|
||||
sortScheduleByDay,
|
||||
toClassListItem,
|
||||
truncateClassName,
|
||||
} from "../transformations";
|
||||
|
||||
const sampleClassInfo: ClassInfo = {
|
||||
id: "cls-001",
|
||||
name: "高一(1)班",
|
||||
gradeId: "g-10",
|
||||
headTeacherId: "usr-teacher-001",
|
||||
description: "理科实验班",
|
||||
createdAt: "2026-07-01T00:00:00Z",
|
||||
updatedAt: "2026-07-10T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("toClassListItem", () => {
|
||||
it("extracts list fields from ClassInfo", () => {
|
||||
const item = toClassListItem(sampleClassInfo);
|
||||
expect(item.id).toBe("cls-001");
|
||||
expect(item.name).toBe("高一(1)班");
|
||||
expect(item.gradeId).toBe("g-10");
|
||||
expect(item.headTeacherId).toBe("usr-teacher-001");
|
||||
expect(item.headTeacherName).toBeNull();
|
||||
expect(item.studentCount).toBe(0);
|
||||
expect(item.subjectCount).toBe(0);
|
||||
expect(item).not.toHaveProperty("description", undefined);
|
||||
});
|
||||
|
||||
it("preserves null headTeacherId", () => {
|
||||
const cls: ClassInfo = { ...sampleClassInfo, headTeacherId: null };
|
||||
const item = toClassListItem(cls);
|
||||
expect(item.headTeacherId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatClassDate", () => {
|
||||
it("formats valid ISO date string", () => {
|
||||
const result = formatClassDate("2026-07-22T10:30:00Z");
|
||||
expect(result).toContain("2026");
|
||||
expect(result).toContain("07");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/empty", () => {
|
||||
expect(formatClassDate(null)).toBe("--");
|
||||
expect(formatClassDate(undefined)).toBe("--");
|
||||
expect(formatClassDate("")).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid date", () => {
|
||||
expect(formatClassDate("not-a-date")).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateClassName", () => {
|
||||
it("returns name unchanged when within limit", () => {
|
||||
expect(truncateClassName("高一(1)班", 10)).toBe("高一(1)班");
|
||||
});
|
||||
|
||||
it("truncates and appends ellipsis when over limit", () => {
|
||||
const long = "a".repeat(50);
|
||||
const result = truncateClassName(long, 40);
|
||||
expect(result.endsWith("...")).toBe(true);
|
||||
expect(result.length).toBe(43);
|
||||
});
|
||||
|
||||
it("collapses whitespace", () => {
|
||||
expect(truncateClassName("高一\n(1) 班", 40)).toBe("高一 (1) 班");
|
||||
});
|
||||
|
||||
it("uses default maxLen of 40", () => {
|
||||
const long = "b".repeat(50);
|
||||
const result = truncateClassName(long);
|
||||
expect(result.endsWith("...")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStudentCount", () => {
|
||||
it("formats valid count", () => {
|
||||
expect(formatStudentCount(0)).toBe("0 人");
|
||||
expect(formatStudentCount(38)).toBe("38 人");
|
||||
});
|
||||
|
||||
it("returns 0 人 for invalid input", () => {
|
||||
expect(formatStudentCount(-1)).toBe("0 人");
|
||||
expect(formatStudentCount(Number.NaN)).toBe("0 人");
|
||||
expect(formatStudentCount(Number.POSITIVE_INFINITY)).toBe("0 人");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSubjectCount", () => {
|
||||
it("formats valid count", () => {
|
||||
expect(formatSubjectCount(0)).toBe("0 科");
|
||||
expect(formatSubjectCount(9)).toBe("9 科");
|
||||
});
|
||||
|
||||
it("returns 0 科 for invalid input", () => {
|
||||
expect(formatSubjectCount(-1)).toBe("0 科");
|
||||
expect(formatSubjectCount(Number.NaN)).toBe("0 科");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatWeekday", () => {
|
||||
it("maps 0-6 to Chinese weekday labels", () => {
|
||||
expect(formatWeekday(0)).toBe("周日");
|
||||
expect(formatWeekday(1)).toBe("周一");
|
||||
expect(formatWeekday(6)).toBe("周六");
|
||||
});
|
||||
|
||||
it("returns placeholder for out-of-range", () => {
|
||||
expect(formatWeekday(-1)).toBe("--");
|
||||
expect(formatWeekday(7)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSchedulePeriod", () => {
|
||||
it("formats integer period", () => {
|
||||
expect(formatSchedulePeriod(1)).toBe("第 1 节");
|
||||
expect(formatSchedulePeriod(8)).toBe("第 8 节");
|
||||
});
|
||||
|
||||
it("returns placeholder for non-finite", () => {
|
||||
expect(formatSchedulePeriod(Number.NaN)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatScheduleTime", () => {
|
||||
it("formats time range", () => {
|
||||
expect(formatScheduleTime("08:00", "08:45")).toBe("08:00-08:45");
|
||||
});
|
||||
|
||||
it("returns placeholder for missing values", () => {
|
||||
expect(formatScheduleTime(null, "08:45")).toBe("--");
|
||||
expect(formatScheduleTime("08:00", null)).toBe("--");
|
||||
expect(formatScheduleTime(undefined, undefined)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortScheduleByDay", () => {
|
||||
const items: ClassScheduleItem[] = [
|
||||
{
|
||||
id: "s2",
|
||||
weekday: 2,
|
||||
period: 2,
|
||||
subjectId: "sub-math",
|
||||
subjectName: "数学",
|
||||
teacherId: "t1",
|
||||
teacherName: "张老师",
|
||||
classroom: "101",
|
||||
startTime: "09:00",
|
||||
endTime: "09:45",
|
||||
},
|
||||
{
|
||||
id: "s1",
|
||||
weekday: 1,
|
||||
period: 1,
|
||||
subjectId: "sub-chinese",
|
||||
subjectName: "语文",
|
||||
teacherId: "t2",
|
||||
teacherName: "李老师",
|
||||
classroom: "102",
|
||||
startTime: "08:00",
|
||||
endTime: "08:45",
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
weekday: 1,
|
||||
period: 2,
|
||||
subjectId: "sub-english",
|
||||
subjectName: "英语",
|
||||
teacherId: "t3",
|
||||
teacherName: "王老师",
|
||||
classroom: "103",
|
||||
startTime: "09:00",
|
||||
endTime: "09:45",
|
||||
},
|
||||
];
|
||||
|
||||
it("sorts by weekday then period", () => {
|
||||
const sorted = sortScheduleByDay(items);
|
||||
expect(sorted[0]?.id).toBe("s1");
|
||||
expect(sorted[1]?.id).toBe("s3");
|
||||
expect(sorted[2]?.id).toBe("s2");
|
||||
});
|
||||
|
||||
it("returns empty array for null/undefined", () => {
|
||||
expect(sortScheduleByDay(null)).toEqual([]);
|
||||
expect(sortScheduleByDay(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not mutate input", () => {
|
||||
const copy = [...items];
|
||||
sortScheduleByDay(items);
|
||||
expect(items.map((i) => i.id)).toEqual(copy.map((i) => i.id));
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupScheduleByWeekday", () => {
|
||||
const items: ClassScheduleItem[] = [
|
||||
{
|
||||
id: "s1",
|
||||
weekday: 1,
|
||||
period: 1,
|
||||
subjectId: "sub-chinese",
|
||||
subjectName: "语文",
|
||||
teacherId: "t2",
|
||||
teacherName: "李老师",
|
||||
classroom: "102",
|
||||
startTime: "08:00",
|
||||
endTime: "08:45",
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
weekday: 3,
|
||||
period: 1,
|
||||
subjectId: "sub-math",
|
||||
subjectName: "数学",
|
||||
teacherId: "t1",
|
||||
teacherName: "张老师",
|
||||
classroom: "101",
|
||||
startTime: "08:00",
|
||||
endTime: "08:45",
|
||||
},
|
||||
];
|
||||
|
||||
it("returns 7 groups", () => {
|
||||
const groups = groupScheduleByWeekday(items);
|
||||
expect(groups).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("groups items by weekday", () => {
|
||||
const groups = groupScheduleByWeekday(items);
|
||||
expect(groups[1]).toHaveLength(1);
|
||||
expect(groups[1]?.[0]?.id).toBe("s1");
|
||||
expect(groups[3]).toHaveLength(1);
|
||||
expect(groups[3]?.[0]?.id).toBe("s2");
|
||||
expect(groups[0]).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 7 empty groups for null/undefined", () => {
|
||||
const groups = groupScheduleByWeekday(null);
|
||||
expect(groups).toHaveLength(7);
|
||||
expect(groups.every((g) => g.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasHeadTeacher", () => {
|
||||
it("returns true when headTeacherId is set", () => {
|
||||
expect(hasHeadTeacher(sampleClassInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when headTeacherId is null", () => {
|
||||
expect(hasHeadTeacher({ ...sampleClassInfo, headTeacherId: null })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDescription", () => {
|
||||
it("returns true when description is set", () => {
|
||||
expect(hasDescription(sampleClassInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when description is null", () => {
|
||||
expect(hasDescription({ ...sampleClassInfo, description: null })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when description is empty string", () => {
|
||||
expect(hasDescription({ ...sampleClassInfo, description: "" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约(混合):
|
||||
* - 单查 classInfo(id: ID!):✅ schema 真实字段(classes 子图)
|
||||
* - 学生名单 classStudents(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 任课老师 classTeachers(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/classes_contract.md#class-students
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Users, ChevronLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
useClassInfo,
|
||||
useClassStudents,
|
||||
useClassTeachers,
|
||||
type ClassStudent,
|
||||
type ClassTeacher,
|
||||
} from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatClassDate,
|
||||
hasDescription,
|
||||
hasHeadTeacher,
|
||||
} from "@/features/teacher/classes/transformations";
|
||||
import { formatStudentDate } from "@/features/teacher/students/transformations";
|
||||
|
||||
/**
|
||||
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ClassDetailClient(): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const classId = params?.id ?? "";
|
||||
|
||||
// ✅ 真实查询:classInfo(id: ID!),schema 已就绪
|
||||
const { data, loading, error } = useClassInfo(classId);
|
||||
|
||||
// @contract-pending:学生名单,schema 无 classStudents(classId) → MSW 兜底
|
||||
const {
|
||||
data: studentsData,
|
||||
loading: studentsLoading,
|
||||
error: studentsError,
|
||||
} = useClassStudents(classId, { enabled: Boolean(data) });
|
||||
|
||||
// @contract-pending:任课老师,schema 无 classTeachers(classId) → MSW 兜底
|
||||
const {
|
||||
data: teachersData,
|
||||
loading: teachersLoading,
|
||||
error: teachersError,
|
||||
} = useClassTeachers(classId, { enabled: Boolean(data) });
|
||||
|
||||
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;
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t("detail.notFound")}
|
||||
action={{
|
||||
label: t("detail.backToList"),
|
||||
href: "/shell/teacher/classes",
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.name ?? t("detail.title")}
|
||||
description={
|
||||
data
|
||||
? t("detail.createdAtPrefix", {
|
||||
date: formatClassDate(data.createdAt),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<Users className="size-6" />}
|
||||
backHref="/shell/teacher/classes"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <ClassDetailBody classInfo={data} /> : null}
|
||||
{data ? (
|
||||
<StudentsSection
|
||||
students={studentsData?.items}
|
||||
loading={studentsLoading}
|
||||
error={studentsError}
|
||||
mswNotice={t("detail.studentsMswNotice")}
|
||||
/>
|
||||
) : null}
|
||||
{data ? (
|
||||
<TeachersSection
|
||||
teachers={teachersData?.items}
|
||||
loading={teachersLoading}
|
||||
error={teachersError}
|
||||
mswNotice={t("detail.teachersMswNotice")}
|
||||
/>
|
||||
) : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
type ClassInfoData = NonNullable<ReturnType<typeof useClassInfo>["data"]>;
|
||||
|
||||
/**
|
||||
* 详情基本信息区(对齐 §7.3 详情页模板)。
|
||||
*/
|
||||
function ClassDetailBody({
|
||||
classInfo,
|
||||
}: {
|
||||
classInfo: ClassInfoData;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
return (
|
||||
<DetailSection title={t("detail.sectionBasic")}>
|
||||
<DetailField label={t("detail.fieldName")} value={classInfo.name} />
|
||||
<DetailField label={t("detail.fieldGradeId")} value={classInfo.gradeId} />
|
||||
<DetailField
|
||||
label={t("detail.fieldHeadTeacherId")}
|
||||
value={
|
||||
hasHeadTeacher(classInfo)
|
||||
? (classInfo.headTeacherId ?? "--")
|
||||
: t("detail.noHeadTeacher")
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldDescription")}
|
||||
value={
|
||||
hasDescription(classInfo)
|
||||
? (classInfo.description ?? "--")
|
||||
: t("detail.noDescription")
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldCreatedAt")}
|
||||
value={formatClassDate(classInfo.createdAt)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldUpdatedAt")}
|
||||
value={formatClassDate(classInfo.updatedAt)}
|
||||
/>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生名单区(@contract-pending,MSW 兜底)。
|
||||
*/
|
||||
function StudentsSection({
|
||||
students,
|
||||
loading,
|
||||
error,
|
||||
mswNotice,
|
||||
}: {
|
||||
students: ClassStudent[] | undefined;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
mswNotice: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{mswNotice}</p>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<DetailSection title={t("detail.sectionStudents")}>
|
||||
{errorNode}
|
||||
{!error && loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-8 animate-pulse rounded bg-muted/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && !loading && (!students || students.length === 0) ? (
|
||||
<Link
|
||||
href="/shell/teacher/classes"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
{t("detail.noStudents")}
|
||||
</Link>
|
||||
) : null}
|
||||
{!error && !loading && students && students.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colStudentNo")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colStudentName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colEnrolledAt")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{students.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{s.studentNo}
|
||||
</td>
|
||||
<td className="p-3 font-medium">{s.name}</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatStudentDate(s.enrolledAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任课老师区(@contract-pending,MSW 兜底)。
|
||||
*/
|
||||
function TeachersSection({
|
||||
teachers,
|
||||
loading,
|
||||
error,
|
||||
mswNotice,
|
||||
}: {
|
||||
teachers: ClassTeacher[] | undefined;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
mswNotice: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{mswNotice}</p>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<DetailSection title={t("detail.sectionTeachers")}>
|
||||
{errorNode}
|
||||
{!error && loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-8 animate-pulse rounded bg-muted/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && !loading && (!teachers || teachers.length === 0) ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("detail.noTeachers")}
|
||||
</p>
|
||||
) : null}
|
||||
{!error && !loading && teachers && teachers.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colTeacherName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colSubjectName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colTeacherRole")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{teachers.map((tc) => (
|
||||
<tr key={tc.id} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{tc.name}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{tc.subjectName}
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{tc.role}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级课表页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 课表查询 classSchedule(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/classes_contract.md#class-schedule
|
||||
*
|
||||
* URL 状态:?classId=(可选,指定班级课表;未指定时返回默认课表)
|
||||
*
|
||||
* 三态规范(§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 { Calendar } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useClassSchedule, type ClassScheduleItem } from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatSchedulePeriod,
|
||||
formatScheduleTime,
|
||||
formatWeekday,
|
||||
groupScheduleByWeekday,
|
||||
sortScheduleByDay,
|
||||
} from "@/features/teacher/classes/transformations";
|
||||
|
||||
/**
|
||||
* 课表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ClassScheduleClient(): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
const tCommon = useTranslations("common");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "cls-001";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useClassSchedule(classId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("schedule.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title={t("schedule.notFound")}
|
||||
action={{
|
||||
label: t("schedule.backToList"),
|
||||
href: "/shell/teacher/classes",
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={t("schedule.title")}
|
||||
description={
|
||||
data
|
||||
? t("schedule.subtitle", {
|
||||
className: data.className,
|
||||
weekRange: data.weekRange,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<Calendar className="size-6" />}
|
||||
backHref="/shell/teacher/classes"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <ScheduleOverviewSection data={data} /> : null}
|
||||
{data ? <ScheduleByDaySection items={data.items} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
type ScheduleData = NonNullable<ReturnType<typeof useClassSchedule>["data"]>;
|
||||
|
||||
/**
|
||||
* 课表概览区。
|
||||
*/
|
||||
function ScheduleOverviewSection({
|
||||
data,
|
||||
}: {
|
||||
data: ScheduleData;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
return (
|
||||
<DetailSection title={t("schedule.sectionOverview")}>
|
||||
<DetailField
|
||||
label={t("schedule.fieldClassName")}
|
||||
value={data.className}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("schedule.fieldWeekRange")}
|
||||
value={data.weekRange}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("schedule.fieldTotalLessons")}
|
||||
value={String(data.items.length)}
|
||||
/>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按星期分组的课表区。
|
||||
*/
|
||||
function ScheduleByDaySection({
|
||||
items,
|
||||
}: {
|
||||
items: ClassScheduleItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
const sorted = sortScheduleByDay(items);
|
||||
const groups = groupScheduleByWeekday(sorted);
|
||||
|
||||
return (
|
||||
<DetailSection title={t("schedule.sectionByDay")}>
|
||||
<div className="space-y-4">
|
||||
{groups.map((group, idx) => {
|
||||
if (group.length === 0) return null;
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{formatWeekday(idx)}
|
||||
</h4>
|
||||
<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("schedule.colPeriod")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("schedule.colSubject")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("schedule.colTeacher")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("schedule.colClassroom")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("schedule.colTime")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{group.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatSchedulePeriod(item.period)}
|
||||
</td>
|
||||
<td className="p-3 font-medium">{item.subjectName}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{item.teacherName}
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{item.classroom ?? "--"}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatScheduleTime(item.startTime, item.endTime)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 classes(...):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 单查 classInfo(id):✅ 真实字段(本页未使用,详情页使用)
|
||||
* - 契约工单:docs/architecture/issues/contracts/classes_contract.md#classes-list
|
||||
*
|
||||
* URL 状态:?gradeId=&subjectId=&q=
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Users } 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 { useClasses, type ClassListItem } from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatClassDate,
|
||||
formatStudentCount,
|
||||
formatSubjectCount,
|
||||
truncateClassName,
|
||||
} from "@/features/teacher/classes/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
*/
|
||||
export function ClassesListClient(): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const gradeId = searchParams.get("gradeId") ?? "";
|
||||
const subjectId = searchParams.get("subjectId") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useClasses({
|
||||
gradeId: gradeId || undefined,
|
||||
subjectId: subjectId || undefined,
|
||||
q: q || undefined,
|
||||
});
|
||||
|
||||
const filteredItems = useMemo<ClassListItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items;
|
||||
}, [data]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/classes?${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;
|
||||
|
||||
const emptyNode = (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t("list.emptyTitle")}
|
||||
description={t("list.emptyDescription")}
|
||||
action={{
|
||||
label: t("list.emptyAction"),
|
||||
href: "/shell/teacher/classes",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<Users className="size-6" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={gradeId}
|
||||
onChange={(e) => updateQuery("gradeId", e.target.value)}
|
||||
placeholder={t("list.gradePlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.gradePlaceholder")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectId}
|
||||
onChange={(e) => updateQuery("subjectId", e.target.value)}
|
||||
placeholder={t("list.subjectPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.subjectPlaceholder")}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
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>
|
||||
}
|
||||
>
|
||||
<ClassesTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function ClassesTable({
|
||||
items,
|
||||
}: {
|
||||
items: ClassListItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
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.colName")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colGrade")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colHeadTeacher")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("list.colStudentCount")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("list.colSubjectCount")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colDescription")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colUpdatedAt")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("list.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((cls) => (
|
||||
<tr key={cls.id} className="hover:bg-muted/30">
|
||||
<td className="max-w-xs p-3">
|
||||
<Link
|
||||
href={`/shell/teacher/classes/${cls.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{truncateClassName(cls.name)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{cls.gradeId}
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{cls.headTeacherName ?? "--"}
|
||||
</td>
|
||||
<td className="p-3 text-center text-xs text-muted-foreground">
|
||||
{formatStudentCount(cls.studentCount)}
|
||||
</td>
|
||||
<td className="p-3 text-center text-xs text-muted-foreground">
|
||||
{formatSubjectCount(cls.subjectCount)}
|
||||
</td>
|
||||
<td className="max-w-xs p-3 text-xs text-muted-foreground">
|
||||
{cls.description ?? "--"}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatClassDate(cls.updatedAt)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/classes/${cls.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("list.viewDetail")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Classes 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
import type { ClassInfo, ClassListItem, ClassScheduleItem } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* 将班级详情(schema ClassInfo)映射为列表项视图模型。
|
||||
*
|
||||
* schema ClassInfo 无 studentCount/subjectCount/headTeacherName 字段,
|
||||
* 列表项中的这些字段在列表查询(MSW)中提供,详情→列表裁剪时置 0/null。
|
||||
*/
|
||||
export function toClassListItem(cls: ClassInfo): ClassListItem {
|
||||
return {
|
||||
id: cls.id,
|
||||
name: cls.name,
|
||||
gradeId: cls.gradeId,
|
||||
headTeacherId: cls.headTeacherId,
|
||||
headTeacherName: null,
|
||||
description: cls.description,
|
||||
studentCount: 0,
|
||||
subjectCount: 0,
|
||||
createdAt: cls.createdAt,
|
||||
updatedAt: cls.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatClassDate(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",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断班级名称用于列表展示。
|
||||
* - 超过 maxLen 字符时截断并加省略号
|
||||
* - maxLen 默认 40
|
||||
*/
|
||||
export function truncateClassName(name: string, maxLen = 40): string {
|
||||
const text = name.replace(/\s+/g, " ").trim();
|
||||
if (text.length <= maxLen) return text;
|
||||
return `${text.slice(0, maxLen)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化学生数为展示字符串。
|
||||
* 输入无效返回 "0 人"。
|
||||
*/
|
||||
export function formatStudentCount(count: number): string {
|
||||
if (!Number.isFinite(count) || count < 0) return "0 人";
|
||||
return `${count} 人`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化科目数为展示字符串。
|
||||
* 输入无效返回 "0 科"。
|
||||
*/
|
||||
export function formatSubjectCount(count: number): string {
|
||||
if (!Number.isFinite(count) || count < 0) return "0 科";
|
||||
return `${count} 科`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将星期几(0=周日 … 6=周六)映射为中文标签。
|
||||
*/
|
||||
export function formatWeekday(weekday: number): string {
|
||||
const labels = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
const idx = Math.trunc(weekday);
|
||||
if (idx < 0 || idx > 6) return "--";
|
||||
return labels[idx] ?? "--";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化节次为展示字符串("第 1 节")。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatSchedulePeriod(period: number): string {
|
||||
if (!Number.isFinite(period)) return "--";
|
||||
return `第 ${period} 节`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间段为展示字符串("08:00-08:45")。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatScheduleTime(
|
||||
startTime: string | null | undefined,
|
||||
endTime: string | null | undefined,
|
||||
): string {
|
||||
if (!startTime || !endTime) return "--";
|
||||
return `${startTime}-${endTime}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按星期 + 节次升序排序课表条目(稳定排序)。
|
||||
* 输入为 null/undefined 时返回空数组。
|
||||
*/
|
||||
export function sortScheduleByDay(
|
||||
items: ClassScheduleItem[] | null | undefined,
|
||||
): ClassScheduleItem[] {
|
||||
if (!items) return [];
|
||||
return [...items].sort((a, b) => {
|
||||
if (a.weekday !== b.weekday) return a.weekday - b.weekday;
|
||||
return a.period - b.period;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按星期分组课表条目,返回 7 组(周一至周日)。
|
||||
* 输入为 null/undefined 时返回 7 个空数组。
|
||||
*/
|
||||
export function groupScheduleByWeekday(
|
||||
items: ClassScheduleItem[] | null | undefined,
|
||||
): ClassScheduleItem[][] {
|
||||
const groups: ClassScheduleItem[][] = [[], [], [], [], [], [], []];
|
||||
if (!items) return groups;
|
||||
for (const item of items) {
|
||||
const idx = Math.trunc(item.weekday);
|
||||
if (idx >= 0 && idx <= 6) {
|
||||
const group = groups[idx];
|
||||
if (group) group.push(item);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断班级是否有班主任(headTeacherId 非空)。
|
||||
*/
|
||||
export function hasHeadTeacher(cls: ClassInfo): boolean {
|
||||
return Boolean(cls.headTeacherId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断班级是否有描述。
|
||||
*/
|
||||
export function hasDescription(cls: ClassInfo): boolean {
|
||||
return Boolean(cls.description);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Students 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Student } from "@/lib/api";
|
||||
|
||||
import {
|
||||
GENDER_LABEL,
|
||||
formatGender,
|
||||
formatStudentCount,
|
||||
formatStudentDate,
|
||||
formatStudentDay,
|
||||
genderToBadgeClass,
|
||||
hasStudentNo,
|
||||
toStudentListItem,
|
||||
truncateStudentName,
|
||||
} from "../transformations";
|
||||
|
||||
const sampleStudent: Student = {
|
||||
id: "stu-001",
|
||||
studentNo: "2026001",
|
||||
name: "张明",
|
||||
gender: "male",
|
||||
classId: "cls-001",
|
||||
className: "高一(1)班",
|
||||
gradeId: "g-10",
|
||||
enrolledAt: "2026-09-01T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("formatGender", () => {
|
||||
it("maps known genders to Chinese labels", () => {
|
||||
expect(formatGender("male")).toBe("男");
|
||||
expect(formatGender("female")).toBe("女");
|
||||
expect(formatGender("unknown")).toBe("未知");
|
||||
});
|
||||
|
||||
it("returns original value for unknown gender", () => {
|
||||
expect(formatGender("other")).toBe("other");
|
||||
expect(formatGender("")).toBe("");
|
||||
});
|
||||
|
||||
it("GENDER_LABEL covers 3 standard genders", () => {
|
||||
expect(Object.keys(GENDER_LABEL)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStudentDate", () => {
|
||||
it("formats valid ISO date string with time", () => {
|
||||
const result = formatStudentDate("2026-07-22T10:30:00Z");
|
||||
expect(result).toContain("2026");
|
||||
expect(result).toContain("07");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/empty", () => {
|
||||
expect(formatStudentDate(null)).toBe("--");
|
||||
expect(formatStudentDate(undefined)).toBe("--");
|
||||
expect(formatStudentDate("")).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid date", () => {
|
||||
expect(formatStudentDate("not-a-date")).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStudentDay", () => {
|
||||
it("formats valid ISO date string as date only", () => {
|
||||
const result = formatStudentDay("2026-09-01T00:00:00Z");
|
||||
expect(result).toContain("2026");
|
||||
expect(result).toContain("09");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/empty", () => {
|
||||
expect(formatStudentDay(null)).toBe("--");
|
||||
expect(formatStudentDay(undefined)).toBe("--");
|
||||
expect(formatStudentDay("")).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateStudentName", () => {
|
||||
it("returns name unchanged when within limit", () => {
|
||||
expect(truncateStudentName("张明", 30)).toBe("张明");
|
||||
});
|
||||
|
||||
it("truncates and appends ellipsis when over limit", () => {
|
||||
const long = "a".repeat(40);
|
||||
const result = truncateStudentName(long, 30);
|
||||
expect(result.endsWith("...")).toBe(true);
|
||||
expect(result.length).toBe(33);
|
||||
});
|
||||
|
||||
it("collapses whitespace", () => {
|
||||
expect(truncateStudentName("张\n明 三", 30)).toBe("张 明 三");
|
||||
});
|
||||
|
||||
it("uses default maxLen of 30", () => {
|
||||
const long = "b".repeat(40);
|
||||
const result = truncateStudentName(long);
|
||||
expect(result.endsWith("...")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toStudentListItem", () => {
|
||||
it("extracts all fields from student", () => {
|
||||
const item = toStudentListItem(sampleStudent);
|
||||
expect(item.id).toBe("stu-001");
|
||||
expect(item.studentNo).toBe("2026001");
|
||||
expect(item.name).toBe("张明");
|
||||
expect(item.gender).toBe("male");
|
||||
expect(item.classId).toBe("cls-001");
|
||||
expect(item.className).toBe("高一(1)班");
|
||||
expect(item.gradeId).toBe("g-10");
|
||||
expect(item.enrolledAt).toBe("2026-09-01T00:00:00Z");
|
||||
});
|
||||
|
||||
it("returns isomorphic shape", () => {
|
||||
const item = toStudentListItem(sampleStudent);
|
||||
expect(Object.keys(item).sort()).toEqual(Object.keys(sampleStudent).sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("genderToBadgeClass", () => {
|
||||
it("returns blue class for male", () => {
|
||||
expect(genderToBadgeClass("male")).toContain("blue");
|
||||
});
|
||||
|
||||
it("returns pink class for female", () => {
|
||||
expect(genderToBadgeClass("female")).toContain("pink");
|
||||
});
|
||||
|
||||
it("returns muted for unknown", () => {
|
||||
expect(genderToBadgeClass("unknown")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns muted for unrecognized gender", () => {
|
||||
expect(genderToBadgeClass("other")).toBe("bg-muted text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasStudentNo", () => {
|
||||
it("returns true for non-empty string", () => {
|
||||
expect(hasStudentNo("2026001")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for null/undefined", () => {
|
||||
expect(hasStudentNo(null)).toBe(false);
|
||||
expect(hasStudentNo(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty/whitespace string", () => {
|
||||
expect(hasStudentNo("")).toBe(false);
|
||||
expect(hasStudentNo(" ")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStudentCount", () => {
|
||||
it("formats valid count", () => {
|
||||
expect(formatStudentCount(0)).toBe("0 人");
|
||||
expect(formatStudentCount(38)).toBe("38 人");
|
||||
});
|
||||
|
||||
it("returns 0 人 for invalid input", () => {
|
||||
expect(formatStudentCount(-1)).toBe("0 人");
|
||||
expect(formatStudentCount(Number.NaN)).toBe("0 人");
|
||||
expect(formatStudentCount(Number.POSITIVE_INFINITY)).toBe("0 人");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 学生管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 students(...):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/classes_contract.md#students
|
||||
*
|
||||
* URL 状态:?classId=&gradeId=&q=
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { GraduationCap } from "lucide-react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useStudents, type StudentListItem } from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatGender,
|
||||
formatStudentDay,
|
||||
genderToBadgeClass,
|
||||
truncateStudentName,
|
||||
} from "@/features/teacher/students/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
*/
|
||||
export function StudentsListClient(): React.ReactElement {
|
||||
const t = useTranslations("students");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "";
|
||||
const gradeId = searchParams.get("gradeId") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudents({
|
||||
classId: classId || undefined,
|
||||
gradeId: gradeId || undefined,
|
||||
q: q || undefined,
|
||||
});
|
||||
|
||||
const filteredItems = useMemo<StudentListItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items;
|
||||
}, [data]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/students?${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;
|
||||
|
||||
const emptyNode = (
|
||||
<EmptyState
|
||||
icon={GraduationCap}
|
||||
title={t("list.emptyTitle")}
|
||||
description={t("list.emptyDescription")}
|
||||
action={{
|
||||
label: t("list.emptyAction"),
|
||||
href: "/shell/teacher/students",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<GraduationCap className="size-6" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => updateQuery("classId", e.target.value)}
|
||||
placeholder={t("list.classPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.classPlaceholder")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={gradeId}
|
||||
onChange={(e) => updateQuery("gradeId", e.target.value)}
|
||||
placeholder={t("list.gradePlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.gradePlaceholder")}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
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>
|
||||
}
|
||||
>
|
||||
<StudentsTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function StudentsTable({
|
||||
items,
|
||||
}: {
|
||||
items: StudentListItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("students");
|
||||
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.colStudentNo")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colGender")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colClassName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colGradeId")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colEnrolledAt")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{s.studentNo}
|
||||
</td>
|
||||
<td className="p-3 font-medium">{truncateStudentName(s.name)}</td>
|
||||
<td className="p-3">
|
||||
<GenderBadge gender={s.gender} />
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{s.className}</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{s.gradeId}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatStudentDay(s.enrolledAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 性别徽章(按性别色阶展示)。
|
||||
*/
|
||||
function GenderBadge({ gender }: { gender: string }): React.ReactElement {
|
||||
const label = formatGender(gender);
|
||||
const cls = genderToBadgeClass(gender);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Students 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
import type { Student, StudentListItem } from "@/lib/api";
|
||||
|
||||
/** 性别中文标签映射 */
|
||||
export const GENDER_LABEL: Record<string, string> = {
|
||||
male: "男",
|
||||
female: "女",
|
||||
unknown: "未知",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将性别代码映射为中文标签。未知值回退为原始值。
|
||||
*/
|
||||
export function formatGender(gender: string): string {
|
||||
return GENDER_LABEL[gender] ?? gender;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatStudentDate(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",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为纯日期展示(zh-CN,仅年月日)。用于入学日期等。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatStudentDay(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断学生姓名用于列表展示(通常无需截断,保留以对齐模块模式)。
|
||||
* - 超过 maxLen 字符时截断并加省略号
|
||||
* - maxLen 默认 30
|
||||
*/
|
||||
export function truncateStudentName(name: string, maxLen = 30): string {
|
||||
const text = name.replace(/\s+/g, " ").trim();
|
||||
if (text.length <= maxLen) return text;
|
||||
return `${text.slice(0, maxLen)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从学生实体中提取列表项视图模型(StudentListItem 与 Student 同构)。
|
||||
* 保留为显式函数以对齐 questions/textbooks 模式,便于未来字段裁剪。
|
||||
*/
|
||||
export function toStudentListItem(student: Student): StudentListItem {
|
||||
return {
|
||||
id: student.id,
|
||||
studentNo: student.studentNo,
|
||||
name: student.name,
|
||||
gender: student.gender,
|
||||
classId: student.classId,
|
||||
className: student.className,
|
||||
gradeId: student.gradeId,
|
||||
enrolledAt: student.enrolledAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据性别返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function genderToBadgeClass(gender: string): string {
|
||||
switch (gender) {
|
||||
case "male":
|
||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||
case "female":
|
||||
return "bg-pink-500/10 text-pink-600 dark:text-pink-400";
|
||||
case "unknown":
|
||||
return "bg-muted text-muted-foreground";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断学号是否有效(非空且非空白)。
|
||||
*/
|
||||
export function hasStudentNo(studentNo: string | null | undefined): boolean {
|
||||
return Boolean(studentNo && studentNo.trim().length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化学生数为展示字符串。
|
||||
* 输入无效返回 "0 人"。
|
||||
*/
|
||||
export function formatStudentCount(count: number): string {
|
||||
if (!Number.isFinite(count) || count < 0) return "0 人";
|
||||
return `${count} 人`;
|
||||
}
|
||||
Reference in New Issue
Block a user