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,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>
);
}