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:
135
apps/parent-portal/src/store/child-store.test.ts
Normal file
135
apps/parent-portal/src/store/child-store.test.ts
Normal 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("switchChild(ISSUE-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();
|
||||
});
|
||||
});
|
||||
90
apps/parent-portal/src/store/child-store.ts
Normal file
90
apps/parent-portal/src/store/child-store.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user