Files
Edu/apps/teacher-portal/src/app/(app)/attendance/sheet/page.tsx
SpecialX d49d211425 feat(teacher-portal): 完成参考项目差距闭环 P3-P7 全量实现
- P3 考试/作业/成绩 mutation + 详情页 + 批改界面 + 乐观更新 + 多 Tab 同步
- P4 知识图谱 SVG 可视化 + 学情分析仪表盘 + parent-portal Remote
- P5 WebSocket 通知中心 + AI 出题(SSE) + AI 教案 + AI 学情报告
- P6 可观测性硬化:Sentry + WebVitals + OTel + A11y + 性能配置 + Cookie 迁移
- P7 参考项目差距闭环:新增 35 个页面覆盖 13 个缺失模块
  - attendance(考勤 4 页)/questions(题库)/textbooks(教材 2 页)
  - classes/[id] 详情 + classes/schedule 课表
  - course-plans(2 页)/diagnostic(2 页)/error-book/practice
  - exams/[id]/build 组卷 + exams/[id]/analytics 考后分析
  - exams/[id]/edit-rich 富文本编辑 + exams/[id]/proctoring 监考
  - grades/entry 批量录入 + grades/stats 统计 + grades/analytics 分析 + grades/report-card 报告卡
  - homework/submissions 列表 + assignments/[id]/submissions 批量批改
  - homework/submissions/[submissionId] 单份批改 + scan-grading 扫描批改
  - lesson-plans 编辑器 + library + calendar + heatmap 5 页
  - elective 选修课 3 页 /leave 请假 /schedule-changes 调课
- P7 基础设施:61 GraphQL operations + 5 handlers + 13 fixtures + 11 viewports
- 集成 browser.ts/server.ts 注册所有 p7 handlers(fallthrough 顺序)
- viewports.ts 扩展 11 个新导航项
- 验证:tsc --noEmit 零错误 + eslint 零错误
- 文档:workline.md 新增 §5 P7 参考项目差距闭环(含完整文件清单)
2026-07-13 14:27:04 +08:00

345 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* Attendance 录入页 - 批量录入考勤
*
* 数据来源P7 扩展MSW mock
* - GraphQL AttendanceSheetQuery按 classId + date 拉取学生列表 + 当天状态
* - GraphQL SaveAttendanceSheetMutation批量保存考勤
* - GraphQL ClassesQuery班级下拉选项
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo, useEffect } from "react";
import { useQuery, useMutation } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
import {
AttendanceSheetQuery,
SaveAttendanceSheetMutation,
} from "@/lib/graphql-p7-admin";
import type {
AttendanceStatus,
AttendanceSheetItem,
SaveAttendanceRecordInput,
} from "@/lib/graphql-p7-admin";
const STATUS_OPTIONS: { value: AttendanceStatus; label: string }[] = [
{ value: "PRESENT", label: "出勤" },
{ value: "LATE", label: "迟到" },
{ value: "LEAVE", label: "请假" },
{ value: "ABSENT", label: "缺勤" },
{ value: "EARLY_LEAVE", label: "早退" },
];
function statusColor(status: AttendanceStatus): string {
switch (status) {
case "PRESENT":
return "var(--color-success)";
case "LATE":
return "var(--color-warning)";
case "EARLY_LEAVE":
return "var(--color-warning)";
case "LEAVE":
return "var(--color-accent)";
case "ABSENT":
return "var(--color-danger)";
default:
return "var(--color-ink-muted)";
}
}
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
interface RowState {
status: AttendanceStatus;
note: string;
}
export default function AttendanceSheetPage(): React.ReactNode {
const [classId, setClassId] = useState("");
const [date, setDate] = useState(todayStr());
const [rows, setRows] = useState<Record<string, RowState>>({});
const [submitting, setSubmitting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedMsg, setSavedMsg] = useState<string | null>(null);
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const firstClassId = classes[0]?.id ?? "";
const targetClassId = classId || firstClassId;
const [sheetResult, reexecuteSheet] = useQuery({
query: AttendanceSheetQuery,
variables: { classId: targetClassId, date },
pause: !targetClassId || !date,
});
const sheet: AttendanceSheetItem[] = sheetResult.data?.attendanceSheet ?? [];
const [, saveSheet] = useMutation(SaveAttendanceSheetMutation);
// 当 sheet 数据变化时同步本地 rows
useEffect(() => {
const next: Record<string, RowState> = {};
for (const item of sheet) {
next[item.studentId] = {
status: item.status ?? "PRESENT",
note: item.note ?? "",
};
}
setRows(next);
setSavedMsg(null);
setSaveError(null);
}, [sheet]);
const dirtyCount = useMemo(() => {
let count = 0;
for (const item of sheet) {
const row = rows[item.studentId];
if (!row) continue;
const originalStatus = item.status ?? "PRESENT";
const originalNote = item.note ?? "";
if (row.status !== originalStatus || row.note !== originalNote) {
count++;
}
}
return count;
}, [rows, sheet]);
const handleSaveAll = async (): Promise<void> => {
if (!targetClassId || !date) {
setSaveError("请选择班级和日期");
return;
}
const records: SaveAttendanceRecordInput[] = [];
for (const item of sheet) {
const row = rows[item.studentId];
if (!row) continue;
records.push({
studentId: item.studentId,
status: row.status,
note: row.note.trim() || null,
});
}
if (records.length === 0) {
setSaveError("当前无可保存的记录");
return;
}
setSubmitting(true);
setSaveError(null);
setSavedMsg(null);
const res = await saveSheet({
input: { classId: targetClassId, date, records },
});
setSubmitting(false);
if (res.error) {
setSaveError(res.error.message);
return;
}
setSavedMsg(
`已保存 ${res.data?.saveAttendanceSheet?.savedCount ?? records.length} 条记录`,
);
reexecuteSheet({ requestPolicy: "network-only" });
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href="/attendance"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL AttendanceSheetQuery + SaveAttendanceSheetMutation · P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 班级 + 日期选择 */}
<section className="mb-8">
<div className="grid grid-cols-2 gap-6 mb-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={targetClassId}
onChange={(e) => setClassId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
/>
</div>
</div>
</section>
{!targetClassId || !date ? (
<Empty
title="请选择班级和日期"
description="选择后即可拉取学生名单进行批量录入"
/>
) : sheetResult.fetching ? (
<Loading lines={8} />
) : sheetResult.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{sheetResult.error.message}
</p>
</div>
) : sheet.length === 0 ? (
<Empty title="暂无学生" description="该班级尚未导入学生名单" />
) : (
<section>
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl font-serif text-ink">
<span className="ml-2 text-sm font-sans text-ink-muted">
{sheet.length} · {dirtyCount}
</span>
</h2>
<div className="flex items-center gap-3">
{saveError && (
<span className="text-tiny text-danger">{saveError}</span>
)}
{savedMsg && !submitting && (
<span className="text-tiny text-success">{savedMsg}</span>
)}
<button
type="button"
onClick={handleSaveAll}
disabled={submitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? "保存中..." : "保存全部"}
</button>
</div>
</div>
<div className="rule-thin mb-4" />
{/* 录入表格 */}
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{sheet.map((item) => {
const row = rows[item.studentId];
const currentStatus = row?.status ?? "PRESENT";
return (
<tr
key={item.studentId}
className="border-t border-rule"
>
<td className="px-4 py-3 font-serif text-ink">
{item.studentName}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{item.studentNo}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 flex-wrap">
{STATUS_OPTIONS.map((opt) => {
const active = currentStatus === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() =>
setRows((prev) => ({
...prev,
[item.studentId]: {
status: opt.value,
note: row?.note ?? "",
},
}))
}
className="px-2 py-0.5 text-tiny rounded-button border transition-colors"
style={{
color: active
? "var(--bg-paper)"
: statusColor(opt.value),
borderColor: statusColor(opt.value),
background: active
? statusColor(opt.value)
: "transparent",
}}
>
{opt.label}
</button>
);
})}
</div>
</td>
<td className="px-4 py-3">
<input
type="text"
value={row?.note ?? ""}
onChange={(ev) =>
setRows((prev) => ({
...prev,
[item.studentId]: {
status: currentStatus,
note: ev.target.value,
},
}))
}
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
placeholder="可选:备注"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
</div>
);
}