feat(parent-portal): 完成参考项目(CICD)家长端功能全量覆盖 - 9 新页面 + 3 页面增强 + 763 测试通过
## 变更内容 ### P0 高优先级新页面 - /parent/leave: 请假表单(react-hook-form + Zod) + 历史列表(状态筛选) - /parent/grades/report-card: 报告卡(学年/学期筛选 + 打印 + 教师评语) - /parent/children/[studentId]: 子女详情聚合页(5 Tab: overview/homework/grades/exams/schedule) ### P1 中优先级新页面 - /parent/error-book: 错题本(5 项统计 + Top 错题 + 薄弱知识点) - /parent/diagnostic: 诊断报告(掌握度摘要 + 已发布诊断报告) - /parent/practice: 练习统计(4 项统计 + 练习历史) ### P2 低优先级新页面 - /parent/course-plans + [id]: 课程计划列表 + 详情(章节列表) - /parent/lesson-plans + [planId]/view: 备课列表(学科筛选) + 只读详情 - /parent/elective: 选修课(分类色点 + 状态徽标) ### P3 现有页面增强 - /parent/dashboard: ParentAttentionBanner + 多子女卡片网格 + 趋势图标 + 逾期高亮 - /parent/attendance: AttendanceRateCard + AttendanceWarningBanner + 月份导航 - /parent/grades: GrowthArchiveChart + ExportGradesButton + 班级均对比线 ### 基础设施 - types/index.ts: +20 新类型(LeaveRequest/ReportCard/ErrorBookStats/DiagnosticReport/PracticeStats/CoursePlan/LessonPlan/ElectiveSelection/ChildDetail/ScheduleItem 等) - operations.ts: +19 GraphQL operations(17 query + 2 mutation) - fixtures.ts: +18 mock 数据集 - handlers.ts: +19 MSW GraphQL handler ### 质量校验 - typecheck: 0 错误 - lint: 0 错误 - test: 71 文件 / 763 测试全部通过(从 413 增至 763, +350 测试) ### 文档 - workline.md: 新增 §7 参考项目(CICD)差距分析与实现安排 + §7.3-7.5 实现进度与覆盖完成度 ### 设计决策 - 保留当前"单子女切换"范式(MultiChildTabBar + useChildSwitcher),不迁移到"多子女同屏对比" - 仪表盘除外:已增强为多子女卡片网格并列展示 - 参考项目所有 11 个家长端页面功能已 100% 覆盖 Refs: ARB-020 §22, ARB-022 §24.4
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
// 依据:02-architecture-design.md §15.5 AttendanceCalendar 设计
|
||||
// - 月历视图,每天用颜色点标记考勤状态
|
||||
// - present=success / late=warning / absent=danger / leave=ink-subtle
|
||||
// - 支持月份导航(可选 month + onMonthChange props)
|
||||
|
||||
"use client";
|
||||
|
||||
@@ -11,6 +12,10 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface AttendanceCalendarProps {
|
||||
records: AttendanceRecord[];
|
||||
/** YYYY-MM 格式,未传则使用当前月 */
|
||||
month?: string;
|
||||
/** 月份切换回调,传入则显示导航按钮 */
|
||||
onMonthChange?: (month: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<AttendanceRecord["status"], string> = {
|
||||
@@ -29,11 +34,34 @@ const STATUS_LABELS: Record<AttendanceRecord["status"], string> = {
|
||||
|
||||
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
|
||||
export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
|
||||
const { year, month, days, recordMap } = useMemo(() => {
|
||||
function parseMonth(month: string): { year: number; month: number } {
|
||||
const [yearStr, monthStr] = month.split("-");
|
||||
return {
|
||||
year: parseInt(yearStr ?? "0", 10),
|
||||
month: parseInt(monthStr ?? "1", 10) - 1,
|
||||
};
|
||||
}
|
||||
|
||||
function formatMonth(year: number, month: number): string {
|
||||
return `${year}-${String(month + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function shiftMonth(month: string, delta: number): string {
|
||||
const { year, month: m } = parseMonth(month);
|
||||
const date = new Date(year, m + delta, 1);
|
||||
return formatMonth(date.getFullYear(), date.getMonth());
|
||||
}
|
||||
|
||||
export function AttendanceCalendar({
|
||||
records,
|
||||
month,
|
||||
onMonthChange,
|
||||
}: AttendanceCalendarProps) {
|
||||
const { year, month: monthLabel, days, recordMap } = useMemo(() => {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth();
|
||||
const target = month ? parseMonth(month) : { year: now.getFullYear(), month: now.getMonth() };
|
||||
const y = target.year;
|
||||
const m = target.month;
|
||||
const firstDay = new Date(y, m, 1);
|
||||
const lastDay = new Date(y, m + 1, 0);
|
||||
const startWeekday = firstDay.getDay();
|
||||
@@ -50,7 +78,7 @@ export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
|
||||
}
|
||||
|
||||
return { year: y, month: m, days: dayCells, recordMap: map };
|
||||
}, [records]);
|
||||
}, [records, month]);
|
||||
|
||||
// 统计
|
||||
const stats = useMemo(() => {
|
||||
@@ -64,9 +92,31 @@ export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
|
||||
return (
|
||||
<div className="rounded border border-rule bg-paper-elevated p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-serif text-base">
|
||||
{year}年{month + 1}月考勤
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{onMonthChange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMonthChange(shiftMonth(formatMonth(year, monthLabel), -1))}
|
||||
className="rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
|
||||
aria-label="上一月"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
<h3 className="font-serif text-base">
|
||||
{year}年{monthLabel + 1}月考勤
|
||||
</h3>
|
||||
{onMonthChange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMonthChange(shiftMonth(formatMonth(year, monthLabel), 1))}
|
||||
className="rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
|
||||
aria-label="下一月"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs">
|
||||
{(Object.keys(STATUS_LABELS) as AttendanceRecord["status"][]).map(
|
||||
(s) => (
|
||||
@@ -98,7 +148,7 @@ export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
|
||||
if (day === null) {
|
||||
return <div key={idx} className="aspect-square" />;
|
||||
}
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
const dateStr = `${year}-${String(monthLabel + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
const record = recordMap.get(dateStr);
|
||||
return (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user