feat(parent-portal): 完整实现 P4+P5+P6 家长端微前端

实现内容(仲裁裁决驱动,首次即最终方案):

P4 核心功能
- 认证:localStorage token 存储(F12)+ REST 登录(ISSUE-004)+ refreshAccessToken 竞态防护
- 子女切换:ChildSwitcher(Tab ≤3 / 下拉 ≥4)+ Zustand store(ISSUE-009 纯前端切换)
- 数据查询:urql GraphQL 消费 parent-bff(F9)+ TanStack Query 缓存
- 通知中心:NotificationFeed + 已读/全部已读 mutations
- 通知偏好:三维矩阵 + ISSUE-033 localStorage 降级
- 5 层状态管理:URL/Server/Client/Global UI/Form
- 跨标签同步:BroadcastChannel + storage 事件

P5 实时推送
- WebSocket 连接 push-gateway + 指数退避重连
- HTTP 轮询降级(60s)+ 实时通知 Hook

P6 硬化
- Web Vitals 上报 + OTel trace
- i18n 5 语言(zh-CN/en-US/zh-TW/ja-JP/ar-SA 含 RTL)
- PWA manifest + Service Worker
- CSP 安全头 + 权限点 F7 命名 + 设计令牌三层

测试与构建
- Vitest 92 测试全通过(utils/auth/child-store/ChildSwitcher/NotificationFeed/login)
- MSW mock 未就绪上游(parent-bff GraphQL + iam REST + iam GetChildrenByParent P0 阻塞用 fixtures)
- Dockerfile 多阶段构建(G1,端口 4002,HEALTHCHECK /api/health)
- typecheck + lint 零错误

经验沉淀
- known-issues.md §2.13 追加 12 条实现期经验(无 AI 身份标注)
- arch.db 已更新(15 TS 模块 / 482 符号 / 138 proto)

依据:02-architecture-design.md(回写总裁裁决)、coord-final-decisions.md、
president-final-rulings.md、parent-portal_workline.md、parent-portal_contract.md
This commit is contained in:
SpecialX
2026-07-10 17:40:27 +08:00
parent b54bfd101b
commit 5661938cc0
76 changed files with 9525 additions and 70 deletions

View File

@@ -0,0 +1,143 @@
// AttendanceCalendar月度考勤日历
// 依据02-architecture-design.md §15.5 AttendanceCalendar 设计
// - 月历视图,每天用颜色点标记考勤状态
// - present=success / late=warning / absent=danger / leave=ink-subtle
"use client";
import { useMemo } from "react";
import type { AttendanceRecord } from "@/types";
import { cn } from "@/lib/utils";
interface AttendanceCalendarProps {
records: AttendanceRecord[];
}
const STATUS_STYLES: Record<AttendanceRecord["status"], string> = {
present: "bg-success",
late: "bg-warning",
absent: "bg-danger",
leave: "bg-ink-subtle",
};
const STATUS_LABELS: Record<AttendanceRecord["status"], string> = {
present: "出勤",
late: "迟到",
absent: "缺勤",
leave: "请假",
};
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
const { year, month, days, recordMap } = useMemo(() => {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const firstDay = new Date(y, m, 1);
const lastDay = new Date(y, m + 1, 0);
const startWeekday = firstDay.getDay();
const totalDays = lastDay.getDate();
const dayCells: (number | null)[] = [];
for (let i = 0; i < startWeekday; i++) dayCells.push(null);
for (let d = 1; d <= totalDays; d++) dayCells.push(d);
while (dayCells.length % 7 !== 0) dayCells.push(null);
const map = new Map<string, AttendanceRecord>();
for (const r of records) {
map.set(r.date, r);
}
return { year: y, month: m, days: dayCells, recordMap: map };
}, [records]);
// 统计
const stats = useMemo(() => {
const counts = { present: 0, late: 0, absent: 0, leave: 0 };
for (const r of records) {
counts[r.status]++;
}
return counts;
}, [records]);
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 gap-3 text-xs">
{(Object.keys(STATUS_LABELS) as AttendanceRecord["status"][]).map(
(s) => (
<span key={s} className="flex items-center gap-1">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
STATUS_STYLES[s],
)}
/>
{STATUS_LABELS[s]}
</span>
),
)}
</div>
</div>
{/* 日历网格 */}
<div className="mt-4">
<div className="grid grid-cols-7 gap-1 text-center text-xs text-ink-muted">
{WEEKDAYS.map((w) => (
<div key={w} className="py-1">
{w}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1">
{days.map((day, idx) => {
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 record = recordMap.get(dateStr);
return (
<div
key={idx}
className="flex aspect-square flex-col items-center justify-center rounded text-sm"
title={record ? STATUS_LABELS[record.status] : undefined}
>
<span className="text-xs">{day}</span>
{record && (
<span
className={cn(
"mt-0.5 inline-block h-1.5 w-1.5 rounded-full",
STATUS_STYLES[record.status],
)}
/>
)}
</div>
);
})}
</div>
</div>
{/* 统计 */}
<div className="mt-4 flex gap-4 border-t border-rule pt-3 text-sm">
<span className="text-ink-muted">
<span className="font-mono text-success">{stats.present}</span>
</span>
<span className="text-ink-muted">
<span className="font-mono text-warning">{stats.late}</span>
</span>
<span className="text-ink-muted">
<span className="font-mono text-danger">{stats.absent}</span>
</span>
<span className="text-ink-muted">
<span className="font-mono">{stats.leave}</span>
</span>
</div>
</div>
);
}
export default AttendanceCalendar;