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 参考项目差距闭环(含完整文件清单)
This commit is contained in:
SpecialX
2026-07-13 14:27:04 +08:00
parent f13ca612e6
commit d49d211425
117 changed files with 30867 additions and 108 deletions

View File

@@ -0,0 +1,341 @@
"use client";
/**
* Attendance 列表页 - 考勤记录
*
* 数据来源P7 扩展MSW mock
* - GraphQL AttendanceListQuery按 classId/日期范围/状态筛选 + 分页
* - GraphQL ClassesQuery班级下拉选项
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo } from "react";
import { useQuery } 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 {
AttendanceListQuery,
} from "@/lib/graphql-p7-admin";
import type {
AttendanceStatus,
AttendanceRecord,
AttendanceListFilter,
} from "@/lib/graphql-p7-admin";
const ALL_STATUSES: { value: AttendanceStatus; label: string }[] = [
{ value: "PRESENT", label: "出勤" },
{ value: "LATE", label: "迟到" },
{ value: "EARLY_LEAVE", label: "早退" },
{ value: "LEAVE", label: "请假" },
{ value: "ABSENT", label: "缺勤" },
];
const STATUS_LABEL: Record<AttendanceStatus, string> = {
PRESENT: "出勤",
LATE: "迟到",
EARLY_LEAVE: "早退",
LEAVE: "请假",
ABSENT: "缺勤",
};
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)";
}
}
const PAGE_SIZE = 30;
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function dateMinusDays(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export default function AttendanceListPage(): React.ReactNode {
const [classId, setClassId] = useState("");
const [startDate, setStartDate] = useState(dateMinusDays(6));
const [endDate, setEndDate] = useState(todayStr());
const [selectedStatuses, setSelectedStatuses] = useState<AttendanceStatus[]>(
[],
);
const [page, setPage] = useState(1);
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const firstClassId = classes[0]?.id ?? "";
const filter: AttendanceListFilter = useMemo(
() => ({
classId: classId || firstClassId || null,
startDate: startDate || null,
endDate: endDate || null,
statuses: selectedStatuses.length > 0 ? selectedStatuses : null,
page,
pageSize: PAGE_SIZE,
}),
[classId, firstClassId, startDate, endDate, selectedStatuses, page],
);
const [result] = useQuery({
query: AttendanceListQuery,
variables: { filter },
});
const data = result.data?.attendanceList;
const items: AttendanceRecord[] = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = data?.totalPages ?? 1;
const toggleStatus = (s: AttendanceStatus): void => {
setSelectedStatuses((prev) =>
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s],
);
setPage(1);
};
return (
<div className="px-10 py-10">
<header className="mb-8 flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL AttendanceListQuery · // + P7 MSW mock
</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/attendance/sheet"
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</Link>
<Link
href="/attendance/stats"
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</Link>
<Link
href="/attendance/report"
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</Link>
</div>
</header>
<div className="rule-thin mb-8" />
{/* 筛选栏 */}
<section className="mb-8">
<div className="grid grid-cols-3 gap-6 mb-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={classId || firstClassId}
onChange={(e) => {
setClassId(e.target.value);
setPage(1);
}}
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={startDate}
onChange={(e) => {
setStartDate(e.target.value);
setPage(1);
}}
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>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
setPage(1);
}}
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>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-2">
</label>
<div className="flex items-center gap-2 flex-wrap">
{ALL_STATUSES.map((s) => {
const active = selectedStatuses.includes(s.value);
return (
<button
key={s.value}
type="button"
onClick={() => toggleStatus(s.value)}
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
active
? "bg-accent text-ink-on-accent border-accent"
: "bg-transparent text-ink-muted border-rule hover:text-ink"
}`}
>
{s.label}
</button>
);
})}
</div>
</div>
</section>
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : items.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">
{total}
</span>
</h2>
<p className="text-tiny text-ink-muted">
{page} / {totalPages}
</p>
</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>
<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>
{items.map((r) => (
<tr key={r.id} className="border-t border-rule">
<td className="px-4 py-3 font-serif text-ink">
{r.className}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{r.date}
</td>
<td className="px-4 py-3 font-serif text-ink">
{r.studentName}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{r.studentNo}
</td>
<td className="px-4 py-3">
<span
className="inline-block px-2 py-0.5 rounded-button text-tiny font-mono"
style={{
color: statusColor(r.status),
border: `1px solid ${statusColor(r.status)}`,
}}
>
{STATUS_LABEL[r.status]}
</span>
</td>
<td className="px-4 py-3 text-tiny text-ink-muted">
{r.note ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
<div className="mt-6 flex items-center justify-between">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
<span className="text-tiny text-ink-muted">
{page} / {totalPages} {PAGE_SIZE}
</span>
<button
type="button"
onClick={() =>
setPage((p) => Math.min(totalPages, p + 1))
}
disabled={page >= totalPages}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
</div>
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,337 @@
"use client";
/**
* Attendance 报告页 - 周报/月报 + 打印
*
* 数据来源P7 扩展MSW mock
* - GraphQL AttendanceReportQuery按 classId + reportType + 日期范围生成报告数据
* - GraphQL ClassesQuery班级下拉选项
*
* A4 纸质风格 + window.print()
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo } from "react";
import { useQuery } 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 { AttendanceReportQuery } from "@/lib/graphql-p7-admin";
import type {
AttendanceReport as AttendanceReportData,
AttendanceReportType,
} from "@/lib/graphql-p7-admin";
function dateMinusDays(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function todayStr(): string {
return dateMinusDays(0);
}
const REPORT_TYPES: { value: AttendanceReportType; label: string; days: number }[] = [
{ value: "WEEKLY", label: "周报", days: 7 },
{ value: "MONTHLY", label: "月报", days: 30 },
];
export default function AttendanceReportPage(): React.ReactNode {
const [classId, setClassId] = useState("");
const [reportType, setReportType] = useState<AttendanceReportType>("WEEKLY");
const [endDate, setEndDate] = useState(todayStr());
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const firstClassId = classes[0]?.id ?? "";
const targetClassId = classId || firstClassId;
const startDate = useMemo(() => {
const preset = REPORT_TYPES.find((r) => r.value === reportType);
const days = preset?.days ?? 7;
return dateMinusDays(days - 1);
}, [reportType]);
const [reportResult] = useQuery({
query: AttendanceReportQuery,
variables: {
classId: targetClassId,
reportType,
startDate,
endDate,
},
pause: !targetClassId,
});
const report: AttendanceReportData | undefined =
reportResult.data?.attendanceReport;
const handlePrint = (): void => {
if (typeof window !== "undefined") {
window.print();
}
};
return (
<div className="px-10 py-10">
<header className="mb-8 print:hidden">
<p className="mb-2">
<Link
href="/attendance"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<div className="flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL AttendanceReportQuery · / · A4 P7 MSW mock
</p>
</div>
{report && (
<button
type="button"
onClick={handlePrint}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
/ PDF
</button>
)}
</div>
</header>
<div className="rule-thin mb-8 print:hidden" />
{/* 筛选栏 */}
<section className="mb-8 print:hidden">
<div className="grid grid-cols-3 gap-6">
<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>
<select
value={reportType}
onChange={(e) =>
setReportType(e.target.value as AttendanceReportType)
}
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"
>
{REPORT_TYPES.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(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>
<p className="mt-3 text-tiny text-ink-muted">
{startDate} ~ {endDate}{reportType === "WEEKLY" ? "周报" : "月报"}
</p>
</section>
{reportResult.fetching ? (
<Loading lines={6} />
) : reportResult.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{reportResult.error.message}
</p>
</div>
) : !report ? (
<Empty title="暂无报告" description="请选择班级和报告类型" />
) : (
<article className="bg-surface border border-rule rounded-card p-10 max-w-[820px] mx-auto">
{/* 报告标题 */}
<header className="text-center mb-8 pb-6 border-b border-rule">
<h1 className="text-2xl font-serif text-ink">
{report.reportType === "WEEKLY" ? "周" : "月"}
</h1>
<p className="mt-2 text-sm text-ink-muted">
{report.className} · {report.startDate} ~ {report.endDate}
</p>
<p className="mt-1 text-tiny font-mono text-ink-muted">
{new Date(report.generatedAt).toLocaleString("zh-CN")}
</p>
</header>
{/* 班级信息 */}
<section className="mb-8">
<h2 className="text-lg font-serif text-ink mb-3"></h2>
<dl className="grid grid-cols-2 gap-y-2 text-sm">
<dt className="text-ink-muted"></dt>
<dd className="text-ink">{report.className}</dd>
<dt className="text-ink-muted"></dt>
<dd className="text-ink">
{report.reportType === "WEEKLY" ? "周报" : "月报"}
</dd>
<dt className="text-ink-muted"></dt>
<dd className="font-mono text-ink">
{report.startDate} ~ {report.endDate}
</dd>
<dt className="text-ink-muted"></dt>
<dd className="font-mono text-ink">
{report.summary.totalRecords}
</dd>
</dl>
</section>
{/* 统计摘要 */}
<section className="mb-8">
<h2 className="text-lg font-serif text-ink mb-3"></h2>
<div className="grid grid-cols-4 gap-3">
<div className="p-3 border border-rule rounded-card text-center">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-1 text-2xl font-serif text-accent">
{report.summary.presentRate.toFixed(1)}%
</p>
</div>
<div className="p-3 border border-rule rounded-card text-center">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-1 text-2xl font-serif text-warning">
{report.summary.lateRate.toFixed(1)}%
</p>
</div>
<div className="p-3 border border-rule rounded-card text-center">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
退
</p>
<p className="mt-1 text-2xl font-serif text-warning">
{report.summary.earlyLeaveRate.toFixed(1)}%
</p>
</div>
<div className="p-3 border border-rule rounded-card text-center">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-1 text-2xl font-serif text-ink">
{report.summary.leaveRate.toFixed(1)}%
</p>
</div>
</div>
<p className="mt-3 text-tiny text-ink-muted">
{report.summary.warningCount}
</p>
</section>
{/* 明细表 */}
<section className="mb-8">
<h2 className="text-lg font-serif text-ink mb-3"></h2>
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr>
<th className="px-3 py-2 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-3 py-2 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
退
</th>
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{report.details.map((d) => (
<tr key={d.studentId} className="border-t border-rule">
<td className="px-3 py-2 font-mono text-tiny text-ink-muted">
{d.studentNo}
</td>
<td className="px-3 py-2 font-serif text-ink">
{d.studentName}
</td>
<td className="px-3 py-2 text-right font-mono text-tiny text-ink">
{d.presentCount}
</td>
<td className="px-3 py-2 text-right font-mono text-tiny text-ink-muted">
{d.lateCount}
</td>
<td className="px-3 py-2 text-right font-mono text-tiny text-ink-muted">
{d.earlyLeaveCount}
</td>
<td className="px-3 py-2 text-right font-mono text-tiny text-ink-muted">
{d.leaveCount}
</td>
<td className="px-3 py-2 text-right font-mono text-tiny text-danger">
{d.absentCount}
</td>
<td className="px-3 py-2 text-right font-serif text-ink">
{d.presentRate.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* 教师签字 */}
<section className="mt-12 pt-6 border-t border-rule">
<div className="flex items-end justify-between">
<div className="text-sm text-ink-muted">
<p>________________________</p>
<p className="mt-2">________________________</p>
</div>
<div className="text-sm text-ink-muted text-right">
<p>__________________</p>
<p className="mt-2">__________________</p>
</div>
</div>
</section>
</article>
)}
</div>
);
}

View File

@@ -0,0 +1,344 @@
"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>
);
}

View File

@@ -0,0 +1,551 @@
"use client";
/**
* Attendance 统计页 - 班级考勤统计 + 趋势图 + 班级对比 + 学生预警
*
* 数据来源P7 扩展MSW mock
* - GraphQL AttendanceStatsQuery班级考勤统计含 30 天趋势)
* - GraphQL AttendanceClassComparisonQuery5 班级出勤率对比
* - GraphQL AttendanceWarningsQuery学生考勤预警出勤率 < 90%
* - GraphQL ClassesQuery班级下拉选项
*
* SVG 图表参考 grades/analytics/charts.tsx 模式var(--color-*) 语义令牌)
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo } from "react";
import { useQuery } 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 {
AttendanceStatsQuery,
AttendanceClassComparisonQuery,
AttendanceWarningsQuery,
} from "@/lib/graphql-p7-admin";
import type {
AttendanceStats,
AttendanceTrendPoint,
AttendanceClassComparison,
AttendanceWarning,
} from "@/lib/graphql-p7-admin";
function dateMinusDays(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function todayStr(): string {
return dateMinusDays(0);
}
const RANGE_PRESETS: { label: string; days: number }[] = [
{ label: "近 7 天", days: 7 },
{ label: "近 14 天", days: 14 },
{ label: "近 30 天", days: 30 },
];
// ============ 趋势折线图30 天每日出勤率) ============
const TREND_PADDING = { top: 24, right: 24, bottom: 36, left: 36 };
const TREND_W = 720;
const TREND_H = 220;
function TrendLineChart({ data }: { data: AttendanceTrendPoint[] }): React.ReactNode {
if (data.length === 0) {
return <p className="text-sm text-ink-muted"></p>;
}
const innerW = TREND_W - TREND_PADDING.left - TREND_PADDING.right;
const innerH = TREND_H - TREND_PADDING.top - TREND_PADDING.bottom;
const yMax = 100;
const yMin = 0;
const yRange = yMax - yMin;
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
const toPoint = (value: number, i: number) => ({
x: TREND_PADDING.left + i * xStep,
y: TREND_PADDING.top + innerH - ((value - yMin) / yRange) * innerH,
});
const buildPath = (key: "presentRate" | "lateRate" | "absentRate") =>
data
.map((d, i) => {
const p = toPoint(d[key], i);
return `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`;
})
.join(" ");
const gridLines = [0, 50, 100].map((v) => {
const ratio = 1 - (v - yMin) / yRange;
return { y: TREND_PADDING.top + innerH * ratio, value: v };
});
const lineColor = (key: "presentRate" | "lateRate" | "absentRate") =>
key === "presentRate"
? "var(--color-accent)"
: key === "lateRate"
? "var(--color-warning)"
: "var(--color-danger)";
// X 轴标签最多显示 6 个
const labelStep = Math.max(1, Math.ceil(data.length / 6));
return (
<svg
viewBox={`0 0 ${TREND_W} ${TREND_H}`}
className="w-full border border-rule rounded-card bg-surface"
role="img"
aria-label="考勤趋势折线图"
>
{gridLines.map((g, i) => (
<g key={`grid-${i}`}>
<line
x1={TREND_PADDING.left}
y1={g.y}
x2={TREND_W - TREND_PADDING.right}
y2={g.y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={TREND_PADDING.left - 8}
y={g.y + 4}
textAnchor="end"
fontSize="11"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{g.value}
</text>
</g>
))}
{(["absentRate", "lateRate", "presentRate"] as const).map((key) => (
<g key={key}>
<path
d={buildPath(key)}
fill="none"
stroke={lineColor(key)}
strokeWidth={key === "presentRate" ? 2.5 : 1.5}
strokeDasharray={key === "presentRate" ? undefined : "4 3"}
strokeLinecap="round"
strokeLinejoin="round"
/>
{data.map((d, i) => {
const p = toPoint(d[key], i);
return (
<circle
key={`${key}-${i}`}
cx={p.x}
cy={p.y}
r={key === "presentRate" ? 3 : 2}
fill={lineColor(key)}
stroke="var(--bg-paper)"
strokeWidth={1}
/>
);
})}
</g>
))}
{data.map((d, i) =>
i % labelStep === 0 ? (
<text
key={`x-${i}`}
x={TREND_PADDING.left + i * xStep}
y={TREND_H - TREND_PADDING.bottom + 18}
textAnchor="middle"
fontSize="10"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{d.date.slice(5)}
</text>
) : null,
)}
</svg>
);
}
// ============ 班级对比柱图 ============
const CMP_W = 480;
const CMP_H = 200;
const CMP_PAD = { top: 24, right: 16, bottom: 48, left: 36 };
function ClassComparisonChart({
data,
}: {
data: AttendanceClassComparison[];
}): React.ReactNode {
if (data.length === 0) {
return <p className="text-sm text-ink-muted"></p>;
}
const maxRate = 100;
const innerW = CMP_W - CMP_PAD.left - CMP_PAD.right;
const innerH = CMP_H - CMP_PAD.top - CMP_PAD.bottom;
const barW = innerW / data.length;
return (
<svg
viewBox={`0 0 ${CMP_W} ${CMP_H}`}
className="w-full border border-rule rounded-card bg-surface"
role="img"
aria-label="班级考勤对比柱图"
>
{[0, 0.5, 1].map((ratio, i) => {
const y = CMP_PAD.top + innerH * ratio;
const value = Math.round(maxRate * (1 - ratio));
return (
<g key={`grid-${i}`}>
<line
x1={CMP_PAD.left}
y1={y}
x2={CMP_W - CMP_PAD.right}
y2={y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={CMP_PAD.left - 8}
y={y + 4}
textAnchor="end"
fontSize="11"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{value}%
</text>
</g>
);
})}
{data.map((item, i) => {
const barH = (item.presentRate / maxRate) * innerH;
const x = CMP_PAD.left + i * barW + barW * 0.2;
const y = CMP_PAD.top + innerH - barH;
const w = barW * 0.6;
return (
<g key={item.classId}>
<rect
x={x}
y={y}
width={w}
height={Math.max(0, barH)}
fill="var(--color-accent)"
rx={3}
/>
<text
x={x + w / 2}
y={y - 6}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink)"
>
{item.presentRate}%
</text>
<text
x={x + w / 2}
y={CMP_H - CMP_PAD.bottom + 14}
textAnchor="middle"
fontSize="10"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{item.className}
</text>
</g>
);
})}
</svg>
);
}
export default function AttendanceStatsPage(): React.ReactNode {
const [classId, setClassId] = useState("");
const [rangeDays, setRangeDays] = useState(7);
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const firstClassId = classes[0]?.id ?? "";
const targetClassId = classId || firstClassId;
const startDate = useMemo(() => dateMinusDays(rangeDays - 1), [rangeDays]);
const endDate = useMemo(() => todayStr(), []);
const [statsResult] = useQuery({
query: AttendanceStatsQuery,
variables: { classId: targetClassId, startDate, endDate },
pause: !targetClassId,
});
const [cmpResult] = useQuery({
query: AttendanceClassComparisonQuery,
variables: { startDate, endDate },
});
const [warningsResult] = useQuery({
query: AttendanceWarningsQuery,
variables: { classId: targetClassId, startDate, endDate },
pause: !targetClassId,
});
const stats: AttendanceStats | undefined = statsResult.data?.attendanceStats;
const comparisons: AttendanceClassComparison[] =
cmpResult.data?.attendanceClassComparison ?? [];
const warnings: AttendanceWarning[] =
warningsResult.data?.attendanceWarnings ?? [];
return (
<div className="px-10 py-10">
<header className="mb-8 flex items-baseline justify-between">
<div>
<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 AttendanceStatsQuery · + + + P7 MSW mock
</p>
</div>
</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>
<div className="flex items-center gap-2 flex-wrap">
{RANGE_PRESETS.map((p) => {
const active = rangeDays === p.days;
return (
<button
key={p.days}
type="button"
onClick={() => setRangeDays(p.days)}
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
active
? "bg-accent text-ink-on-accent border-accent"
: "bg-transparent text-ink-muted border-rule hover:text-ink"
}`}
>
{p.label}
</button>
);
})}
</div>
</div>
</div>
<p className="text-tiny text-ink-muted">
{startDate} ~ {endDate}
</p>
</section>
{classesResult.fetching || statsResult.fetching ? (
<Loading lines={6} />
) : statsResult.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{statsResult.error.message}
</p>
</div>
) : !stats ? (
<Empty title="暂无统计数据" description="请选择班级和时间范围" />
) : (
<>
{/* 5 个统计卡片 */}
<section className="mb-10">
<h2 className="text-xl font-serif text-ink mb-4">
{stats.className} ·
</h2>
<div className="rule-thin mb-6" />
<dl className="grid grid-cols-5 gap-4">
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-accent">
{stats.presentRate.toFixed(1)}%
</dd>
</div>
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-warning">
{stats.lateRate.toFixed(1)}%
</dd>
</div>
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
退
</dt>
<dd className="mt-2 text-3xl font-serif text-warning">
{stats.earlyLeaveRate.toFixed(1)}%
</dd>
</div>
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-ink">
{stats.leaveRate.toFixed(1)}%
</dd>
</div>
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-danger">
{stats.warningCount}
</dd>
</div>
</dl>
<p className="mt-4 text-tiny text-ink-muted">
{stats.totalRecords} · {stats.presentCount} ·
{stats.lateCount} · 退 {stats.earlyLeaveCount} ·
{stats.leaveCount} · {stats.absentCount}
</p>
</section>
{/* 趋势折线图 */}
<section className="mb-10">
<h2 className="text-xl font-serif text-ink mb-4">
{stats.trend.length}
</h2>
<div className="rule-thin mb-4" />
<TrendLineChart data={stats.trend} />
<div className="mt-3 flex items-center gap-4 text-tiny text-ink-muted">
<span className="inline-flex items-center gap-1">
<span
className="inline-block w-3 h-0.5"
style={{ background: "var(--color-accent)" }}
/>
</span>
<span className="inline-flex items-center gap-1">
<span
className="inline-block w-3 h-0.5"
style={{
background: "var(--color-warning)",
borderTop: "1px dashed var(--color-warning)",
}}
/>
</span>
<span className="inline-flex items-center gap-1">
<span
className="inline-block w-3 h-0.5"
style={{ background: "var(--color-danger)" }}
/>
</span>
</div>
</section>
{/* 班级对比柱图 */}
<section className="mb-10">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-4" />
<ClassComparisonChart data={comparisons} />
</section>
{/* 学生预警表 */}
<section>
<h2 className="text-xl font-serif text-ink mb-4">
<span className="ml-2 text-sm font-sans text-ink-muted">
&lt; 90% · {warnings.length}
</span>
</h2>
<div className="rule-thin mb-4" />
{warnings.length === 0 ? (
<Empty title="暂无预警" description="所有学生出勤率均达标" />
) : (
<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 w-32">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
</th>
</tr>
</thead>
<tbody>
{warnings.map((w) => (
<tr key={w.studentId} className="border-t border-rule">
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{w.studentNo}
</td>
<td className="px-4 py-3 font-serif text-ink">
{w.studentName}
</td>
<td className="px-4 py-3 text-ink-muted">
{w.className}
</td>
<td className="px-4 py-3 font-serif text-danger">
{w.presentRate.toFixed(1)}%
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{w.absentCount}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{w.lateCount}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</>
)}
</div>
);
}