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