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,119 @@
// ChildSummaryCard子女概览卡片
// 依据02-architecture-design.md §15.4 ChildSummaryCard 设计
// - 平均分 / 班级排名 / 出勤率 / 待完成作业
// - 成绩趋势指示
// - 即将到来的事件
"use client";
import type { ChildSummary } from "@/types";
import { formatPercent, formatNumber, formatDate } from "@/lib/utils";
import { cn } from "@/lib/utils";
interface ChildSummaryCardProps {
summary: ChildSummary;
}
const TREND_LABELS = {
up: { text: "上升", color: "text-success" },
down: { text: "下降", color: "text-danger" },
stable: { text: "稳定", color: "text-ink-muted" },
} as const;
const DEFAULT_TREND = { text: "稳定", color: "text-ink-muted" };
export function ChildSummaryCard({ summary }: ChildSummaryCardProps) {
const trend = TREND_LABELS[summary.recentGradeTrend] ?? DEFAULT_TREND;
return (
<div className="space-y-4">
{/* 指标网格 */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Metric label="平均分" value={formatNumber(summary.avgScore)} />
<Metric
label="班级排名"
value={`${summary.classRank} / ${summary.classSize}`}
/>
<Metric label="出勤率" value={formatPercent(summary.attendanceRate)} />
<Metric
label="待完成作业"
value={formatNumber(summary.pendingHomeworkCount)}
highlight={summary.pendingHomeworkCount > 0}
/>
</div>
{/* 成绩趋势 */}
<div className="rounded border border-rule bg-paper-elevated p-4">
<div className="flex items-center justify-between">
<h3 className="font-serif text-base"></h3>
<span className={cn("text-sm", trend.color)}>{trend.text}</span>
</div>
<ul className="mt-3 space-y-2">
{summary.recentScores.slice(0, 5).map((score) => (
<li
key={score.examId}
className="flex items-center justify-between border-b border-rule pb-2 text-sm last:border-b-0 last:pb-0"
>
<div>
<span className="font-medium">{score.subject}</span>
<span className="ml-2 text-ink-muted">{score.examName}</span>
</div>
<div className="flex items-center gap-3">
<span className="font-mono">{score.studentScore}</span>
{score.classAverage !== undefined && (
<span className="text-xs text-ink-muted">
{score.classAverage}
</span>
)}
</div>
</li>
))}
</ul>
</div>
{/* 即将到来事件 */}
{summary.upcomingEvents && summary.upcomingEvents.length > 0 && (
<div className="rounded border border-rule bg-paper-elevated p-4">
<h3 className="font-serif text-base"></h3>
<ul className="mt-3 space-y-2">
{summary.upcomingEvents.map((event) => (
<li
key={event.id}
className="flex items-center justify-between text-sm"
>
<span className="mark-left">{event.title}</span>
<span className="text-ink-muted">
{formatDate(event.dueDate)}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
function Metric({
label,
value,
highlight,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div
className={cn(
"rounded border border-rule bg-paper-elevated p-4",
highlight && "border-warning",
)}
>
<p className="text-xs text-ink-muted">{label}</p>
<p className="mt-1 font-serif text-2xl">{value}</p>
</div>
);
}
export default ChildSummaryCard;