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:
143
apps/parent-portal/src/components/AttendanceCalendar.tsx
Normal file
143
apps/parent-portal/src/components/AttendanceCalendar.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user