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,135 @@
// Zustand child-store 单测
// 依据02-architecture-design.md §4.3 状态管理分层、ISSUE-009 纯前端切换
// 覆盖setChildren 默认选中 / switchChild 不调后端 / localStorage 持久化 / reset
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useChildStore } from "./child-store";
import type { ChildInfo } from "@/types";
const mockChildren: ChildInfo[] = [
{
id: "student-001",
name: "张小明",
grade: "grade.7",
schoolName: "实验中学",
classId: "class-7-1",
className: "初一(1)班",
},
{
id: "student-002",
name: "张小红",
grade: "grade.5",
schoolName: "实验小学",
classId: "class-5-2",
className: "五年级(2)班",
},
];
beforeEach(() => {
localStorage.clear();
// 重置 store 到初始状态
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("setChildren", () => {
it("设置子女列表", () => {
useChildStore.getState().setChildren(mockChildren);
expect(useChildStore.getState().children).toHaveLength(2);
});
it("无 currentChildId 时默认选第一个活跃子女", () => {
useChildStore.getState().setChildren(mockChildren);
expect(useChildStore.getState().currentChildId).toBe("student-001");
});
it("已存在的 currentChildId 保持不变", () => {
localStorage.setItem("parent_current_child_id", "student-002");
useChildStore.setState({ currentChildId: "student-002" });
useChildStore.getState().setChildren(mockChildren);
expect(useChildStore.getState().currentChildId).toBe("student-002");
});
it("currentChildId 不在列表中时重选第一个", () => {
useChildStore.setState({ currentChildId: "student-999" });
useChildStore.getState().setChildren(mockChildren);
expect(useChildStore.getState().currentChildId).toBe("student-001");
});
it("跳过已归档子女作为默认选择", () => {
const withArchived: ChildInfo[] = [
{ ...mockChildren[0]!, isArchived: true },
mockChildren[1]!,
];
useChildStore.getState().setChildren(withArchived);
expect(useChildStore.getState().currentChildId).toBe("student-002");
});
it("默认选中后写入 localStorage", () => {
useChildStore.getState().setChildren(mockChildren);
expect(localStorage.getItem("parent_current_child_id")).toBe("student-001");
});
it("空列表时 currentChildId 为 null", () => {
useChildStore.getState().setChildren([]);
expect(useChildStore.getState().currentChildId).toBeNull();
});
});
describe("switchChildISSUE-009纯前端切换", () => {
beforeEach(() => {
useChildStore.getState().setChildren(mockChildren);
});
it("切换到列表中存在的子女", () => {
useChildStore.getState().switchChild("student-002");
expect(useChildStore.getState().currentChildId).toBe("student-002");
});
it("切换不存在的子女被忽略", () => {
useChildStore.getState().switchChild("student-999");
expect(useChildStore.getState().currentChildId).toBe("student-001");
});
it("切换后写入 localStorage", () => {
useChildStore.getState().switchChild("student-002");
expect(localStorage.getItem("parent_current_child_id")).toBe("student-002");
});
it("切换不触发后端请求(无 fetch 调用)", () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
useChildStore.getState().switchChild("student-002");
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
});
describe("setLoading", () => {
it("设置 loading 状态", () => {
useChildStore.getState().setLoading(true);
expect(useChildStore.getState().isLoading).toBe(true);
useChildStore.getState().setLoading(false);
expect(useChildStore.getState().isLoading).toBe(false);
});
});
describe("reset", () => {
it("清空所有状态", () => {
useChildStore.getState().setChildren(mockChildren);
useChildStore.getState().switchChild("student-002");
useChildStore.getState().setLoading(true);
useChildStore.getState().reset();
expect(useChildStore.getState().children).toEqual([]);
expect(useChildStore.getState().currentChildId).toBeNull();
expect(useChildStore.getState().isLoading).toBe(false);
});
it("清空 localStorage 中的 current_child_id", () => {
useChildStore.getState().setChildren(mockChildren);
useChildStore.getState().reset();
expect(localStorage.getItem("parent_current_child_id")).toBeNull();
});
});

View File

@@ -0,0 +1,90 @@
// Zustand store子女切换状态
// 依据02-architecture-design.md §4.3 状态管理分层Client Business 层)
// - ISSUE-009 裁决switchChild 纯前端状态,不调后端
// - 多子女切换currentChildId 持久化到 localStorage
// - 跨标签同步:通过 BroadcastChannel 通知P4-8 实现 hook此处仅广播事件
import { create } from "zustand";
import type { ChildInfo } from "@/types";
const CURRENT_CHILD_KEY = "parent_current_child_id";
// BroadcastChannel跨标签同步ISSUE-009 + P4-8
let bc: BroadcastChannel | null = null;
if (typeof window !== "undefined" && "BroadcastChannel" in window) {
bc = new BroadcastChannel("parent-sync");
}
interface ChildStoreState {
children: ChildInfo[];
currentChildId: string | null;
isLoading: boolean;
setChildren: (children: ChildInfo[]) => void;
switchChild: (childId: string) => void;
setLoading: (loading: boolean) => void;
reset: () => void;
}
function loadCurrentChildId(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(CURRENT_CHILD_KEY);
}
function persistCurrentChildId(childId: string): void {
if (typeof window === "undefined") return;
localStorage.setItem(CURRENT_CHILD_KEY, childId);
}
export const useChildStore = create<ChildStoreState>((set, get) => ({
children: [],
currentChildId: loadCurrentChildId(),
isLoading: false,
setChildren: (children) => {
const currentId = get().currentChildId;
// 若当前未选中或已选中的不在列表中,默认选第一个(非归档)
const activeChildren = children.filter((c) => !c.isArchived);
let nextCurrentId = currentId;
if (!nextCurrentId || !children.some((c) => c.id === nextCurrentId)) {
nextCurrentId = activeChildren[0]?.id ?? children[0]?.id ?? null;
if (nextCurrentId) {
persistCurrentChildId(nextCurrentId);
}
}
set({ children, currentChildId: nextCurrentId });
},
// ISSUE-009纯前端切换不调后端
switchChild: (childId) => {
const exists = get().children.some((c) => c.id === childId);
if (!exists) return;
persistCurrentChildId(childId);
set({ currentChildId: childId });
// 广播跨标签同步事件P4-8
bc?.postMessage({
type: "child-switched",
childId,
ts: Date.now(),
source: "unknown",
});
},
setLoading: (isLoading) => set({ isLoading }),
reset: () => {
if (typeof window !== "undefined") {
localStorage.removeItem(CURRENT_CHILD_KEY);
}
set({ children: [], currentChildId: null, isLoading: false });
},
}));
// 监听跨标签同步P4-8接收其他标签的切换事件
if (bc && typeof window !== "undefined") {
bc.onmessage = (event) => {
if (event.data?.type === "child-switched") {
useChildStore.setState({ currentChildId: event.data.childId });
}
};
}