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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user