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:
341
apps/teacher-portal/src/app/(app)/attendance/page.tsx
Normal file
341
apps/teacher-portal/src/app/(app)/attendance/page.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attendance 列表页 - 考勤记录
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL AttendanceListQuery:按 classId/日期范围/状态筛选 + 分页
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
337
apps/teacher-portal/src/app/(app)/attendance/report/page.tsx
Normal file
337
apps/teacher-portal/src/app/(app)/attendance/report/page.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attendance 报告页 - 周报/月报 + 打印
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL AttendanceReportQuery:按 classId + reportType + 日期范围生成报告数据
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* A4 纸质风格 + window.print()
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
344
apps/teacher-portal/src/app/(app)/attendance/sheet/page.tsx
Normal file
344
apps/teacher-portal/src/app/(app)/attendance/sheet/page.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
551
apps/teacher-portal/src/app/(app)/attendance/stats/page.tsx
Normal file
551
apps/teacher-portal/src/app/(app)/attendance/stats/page.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attendance 统计页 - 班级考勤统计 + 趋势图 + 班级对比 + 学生预警
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL AttendanceStatsQuery:班级考勤统计(含 30 天趋势)
|
||||
* - GraphQL AttendanceClassComparisonQuery:5 班级出勤率对比
|
||||
* - GraphQL AttendanceWarningsQuery:学生考勤预警(出勤率 < 90%)
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* SVG 图表参考 grades/analytics/charts.tsx 模式(var(--color-*) 语义令牌)
|
||||
*
|
||||
* 维护者:ai13(teacher-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">
|
||||
出勤率 < 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user