- 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 参考项目差距闭环(含完整文件清单)
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Attendance 录入页 - 批量录入考勤
|
||
*
|
||
* 数据来源(P7 扩展,MSW mock):
|
||
* - GraphQL AttendanceSheetQuery:按 classId + date 拉取学生列表 + 当天状态
|
||
* - GraphQL SaveAttendanceSheetMutation:批量保存考勤
|
||
* - GraphQL ClassesQuery:班级下拉选项
|
||
*
|
||
* 维护者:ai13(teacher-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>
|
||
);
|
||
}
|