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,41 @@
// useChildAttendance获取子女考勤记录
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useMemo } from "react";
import { useQuery } from "urql";
import { CHILD_ATTENDANCE } from "@/lib/graphql/operations";
import type { AttendanceRecord } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildAttendanceResponse {
childAttendance: AttendanceRecord[];
}
function getCurrentMonthRange(): { startDate: string; endDate: string } {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return {
startDate: start.toISOString().slice(0, 10),
endDate: end.toISOString().slice(0, 10),
};
}
export function useChildAttendance() {
const currentChildId = useChildStore((s) => s.currentChildId);
const { startDate, endDate } = useMemo(getCurrentMonthRange, []);
const [result] = useQuery<ChildAttendanceResponse>({
query: CHILD_ATTENDANCE,
variables: { childId: currentChildId, startDate, endDate },
pause: !currentChildId,
});
return {
attendance: result.data?.childAttendance ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,29 @@
// useChildGrades获取子女成绩列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery } from "urql";
import { CHILD_GRADES } from "@/lib/graphql/operations";
import type { GradeDataPoint } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildGradesResponse {
childGrades: GradeDataPoint[];
}
export function useChildGrades(subject?: string) {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildGradesResponse>({
query: CHILD_GRADES,
variables: { childId: currentChildId, subject: subject ?? null },
pause: !currentChildId,
});
return {
grades: result.data?.childGrades ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,29 @@
// useChildHomework获取子女作业列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery } from "urql";
import { CHILD_HOMEWORK } from "@/lib/graphql/operations";
import type { HomeworkItem } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildHomeworkResponse {
childHomework: HomeworkItem[];
}
export function useChildHomework(status?: string) {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildHomeworkResponse>({
query: CHILD_HOMEWORK,
variables: { childId: currentChildId, status: status ?? null },
pause: !currentChildId,
});
return {
homework: result.data?.childHomework ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,31 @@
// useChildSummary获取子女仪表盘概览
// 依据02-architecture-design.md §4.2 GraphQL 接入
// - 依赖 currentChildId从 Zustand store 读取)
// - 切换子女时自动重新请求
"use client";
import { useQuery } from "urql";
import { CHILD_SUMMARY } from "@/lib/graphql/operations";
import type { ChildSummary } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildSummaryResponse {
childSummary: ChildSummary;
}
export function useChildSummary() {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildSummaryResponse>({
query: CHILD_SUMMARY,
variables: { childId: currentChildId },
pause: !currentChildId,
});
return {
summary: result.data?.childSummary ?? null,
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,30 @@
// useChildSwitcher子女切换 Hook
// 依据02-architecture-design.md §4.3 状态管理分层、ISSUE-009 纯前端切换
// - 封装 Zustand store提供派生数据currentChild / hasMultipleChildren
// - 切换是纯前端操作不调后端ISSUE-009
// - 自动加载子女列表useMyChildren
"use client";
import { useChildStore } from "@/store/child-store";
import { useMyChildren } from "./useMyChildren";
export function useChildSwitcher() {
const { children, loading, error } = useMyChildren();
const currentChildId = useChildStore((s) => s.currentChildId);
const switchChild = useChildStore((s) => s.switchChild);
const currentChild = children.find((c) => c.id === currentChildId) ?? null;
const hasMultipleChildren = children.filter((c) => !c.isArchived).length > 1;
return {
children,
currentChild,
currentChildId,
switchChild,
loading,
error,
hasMultipleChildren,
};
}

View File

@@ -0,0 +1,29 @@
// useChildWeakness获取子女学情薄弱点
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery } from "urql";
import { CHILD_WEAKNESS } from "@/lib/graphql/operations";
import type { WeaknessPoint } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildWeaknessResponse {
childWeakness: WeaknessPoint[];
}
export function useChildWeakness() {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildWeaknessResponse>({
query: CHILD_WEAKNESS,
variables: { childId: currentChildId },
pause: !currentChildId,
});
return {
weaknesses: result.data?.childWeakness ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,97 @@
// useCrossTabSync跨标签同步 Hook
// 依据02-architecture-design.md §4.4 跨标签同步
// - BroadcastChannel同源标签间实时同步首选
// - storage 事件降级方案BroadcastChannel 不可用时)
// - 同步内容:子女切换 / 子女解绑 / 通知偏好更新
// - 防回环:消息携带 source接收方忽略自己的消息
"use client";
import { useEffect } from "react";
import { useChildStore } from "@/store/child-store";
import type { SyncMessage } from "@/types";
const CHANNEL_NAME = "parent-sync";
const STORAGE_EVENT_KEY = "parent_sync_event";
const SOURCE_ID = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
export function useCrossTabSync() {
useEffect(() => {
// 方案 1BroadcastChannel首选
let bc: BroadcastChannel | null = null;
if ("BroadcastChannel" in window) {
bc = new BroadcastChannel(CHANNEL_NAME);
bc.onmessage = (event: MessageEvent<SyncMessage>) => {
const msg = event.data;
if (!msg || msg.source === SOURCE_ID) return; // 防回环
switch (msg.type) {
case "child-switched":
// 同步子女切换(不广播,避免循环)
useChildStore.setState({ currentChildId: msg.childId });
break;
case "child-unbound":
// 子女解绑:刷新子女列表
// 由各页面自行处理useMyChildren 会重新请求)
break;
case "preferences-updated":
// 通知偏好更新:清除 localStorage 缓存,触发重新加载
localStorage.removeItem("parent_notification_preferences");
break;
}
};
}
// 方案 2storage 事件降级BroadcastChannel 不可用时)
function handleStorageEvent(e: StorageEvent) {
if (e.key !== STORAGE_EVENT_KEY || !e.newValue) return;
try {
const msg = JSON.parse(e.newValue) as SyncMessage;
if (msg.source === SOURCE_ID) return;
switch (msg.type) {
case "child-switched":
useChildStore.setState({ currentChildId: msg.childId });
break;
case "child-unbound":
break;
case "preferences-updated":
localStorage.removeItem("parent_notification_preferences");
break;
}
} catch {
// 忽略解析错误
}
}
window.addEventListener("storage", handleStorageEvent);
return () => {
bc?.close();
window.removeEventListener("storage", handleStorageEvent);
};
}, []);
}
// 导出 source ID 供其他模块广播时使用
export { SOURCE_ID as SYNC_SOURCE_ID };
// 广播跨标签消息(供 child-store / preference hook 使用)
export function broadcastSync(msg: Omit<SyncMessage, "source">) {
const fullMsg: SyncMessage = { ...msg, source: SOURCE_ID } as SyncMessage;
// BroadcastChannel
if ("BroadcastChannel" in window) {
const bc = new BroadcastChannel(CHANNEL_NAME);
bc.postMessage(fullMsg);
bc.close();
}
// storage 事件降级
try {
localStorage.setItem(STORAGE_EVENT_KEY, JSON.stringify(fullMsg));
// 立即清除storage 事件只在值变化时触发)
setTimeout(() => localStorage.removeItem(STORAGE_EVENT_KEY), 0);
} catch {
// 忽略
}
}

View File

@@ -0,0 +1,39 @@
// useMyChildren获取家长绑定子女列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
// - ISSUE-010iam GetChildrenByParent P0 阻塞MSW mock 返回 student-001 + student-002
// - 加载后写入 Zustand storesetChildren触发默认选中
// - 单次加载,不轮询(子女列表变更由 WebSocket ChildBound/ChildUnbound 事件通知 P5
"use client";
import { useEffect } from "react";
import { useQuery } from "urql";
import { MY_CHILDREN } from "@/lib/graphql/operations";
import { useChildStore } from "@/store/child-store";
import type { ChildInfo } from "@/types";
interface MyChildrenResponse {
myChildren: ChildInfo[];
}
export function useMyChildren() {
const setChildren = useChildStore((s) => s.setChildren);
const setLoading = useChildStore((s) => s.setLoading);
const [result] = useQuery<MyChildrenResponse>({
query: MY_CHILDREN,
});
useEffect(() => {
setLoading(result.fetching);
if (result.data?.myChildren) {
setChildren(result.data.myChildren);
}
}, [result.data, result.fetching, setChildren, setLoading]);
return {
children: result.data?.myChildren ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,51 @@
// useMyNotifications获取通知列表 + 标记已读
// 依据02-architecture-design.md §4.2 GraphQL 接入、§14 通知偏好
"use client";
import { useQuery, useMutation } from "urql";
import {
MY_NOTIFICATIONS,
MARK_AS_READ,
MARK_ALL_AS_READ,
} from "@/lib/graphql/operations";
import type { NotificationItem } from "@/types";
interface MyNotificationsResponse {
myNotifications: NotificationItem[];
}
interface MarkAsReadResponse {
markAsRead: { id: string; read: boolean };
}
interface MarkAllAsReadResponse {
markAllAsRead: { count: number };
}
export function useMyNotifications(unreadOnly = false) {
const [result] = useQuery<MyNotificationsResponse>({
query: MY_NOTIFICATIONS,
variables: { unreadOnly, limit: 50 },
});
const [, markAsReadMutation] = useMutation<MarkAsReadResponse>(MARK_AS_READ);
const [, markAllAsReadMutation] =
useMutation<MarkAllAsReadResponse>(MARK_ALL_AS_READ);
async function markAsRead(notificationId: string) {
return markAsReadMutation({ notificationId });
}
async function markAllAsRead() {
return markAllAsReadMutation({});
}
return {
notifications: result.data?.myNotifications ?? [],
loading: result.fetching,
error: result.error,
markAsRead,
markAllAsRead,
};
}

View File

@@ -0,0 +1,108 @@
// useNotificationPreferences通知偏好P4 localStorage 降级)
// 依据02-architecture-design.md §14 通知偏好数据模型、ISSUE-033 裁决
// - P4 阶段localStorage 为主存储GraphQL 查询提供初始默认值
// - 更新时:先写 localStorage再尝试 GraphQL mutation失败不阻塞
// - P5+ 阶段:切换到后端为主存储
"use client";
import { useEffect, useState, useCallback } from "react";
import { useQuery, useMutation } from "urql";
import {
MY_NOTIFICATION_PREFERENCES,
UPDATE_NOTIFICATION_PREFERENCES,
} from "@/lib/graphql/operations";
import type {
NotificationPreferences,
NotificationEventType,
NotificationChannel,
} from "@/types";
import { getLocalStorage, setLocalStorage } from "@/lib/utils";
const PREFS_KEY = "parent_notification_preferences";
interface MyNotificationPreferencesResponse {
myNotificationPreferences: NotificationPreferences;
}
interface UpdatePreferencesResponse {
updateNotificationPreferences: { parentId: string; updatedAt: string };
}
export function useNotificationPreferences(parentId: string) {
const [localPrefs, setLocalPrefs] = useState<NotificationPreferences | null>(
null,
);
// GraphQL 查询(提供初始默认值)
const [queryResult] = useQuery<MyNotificationPreferencesResponse>({
query: MY_NOTIFICATION_PREFERENCES,
});
const [, updateMutation] = useMutation<UpdatePreferencesResponse>(
UPDATE_NOTIFICATION_PREFERENCES,
);
// 加载:优先 localStorage无则用 GraphQL 返回值
useEffect(() => {
const stored = getLocalStorage(PREFS_KEY);
if (stored) {
try {
setLocalPrefs(JSON.parse(stored) as NotificationPreferences);
return;
} catch {
// fallthrough
}
}
if (queryResult.data?.myNotificationPreferences) {
setLocalPrefs(queryResult.data.myNotificationPreferences);
}
}, [queryResult.data]);
// 更新单个偏好ISSUE-033先写 localStorage
const updatePreference = useCallback(
(
childId: string,
eventType: NotificationEventType,
channel: NotificationChannel,
enabled: boolean,
) => {
setLocalPrefs((prev) => {
if (!prev) return prev;
const childPrefs = prev.preferences[childId] ?? {};
const next: NotificationPreferences = {
...prev,
preferences: {
...prev.preferences,
[childId]: {
...childPrefs,
[eventType]: {
...childPrefs[eventType],
[channel]: enabled,
},
},
},
updatedAt: new Date().toISOString(),
};
setLocalStorage(PREFS_KEY, JSON.stringify(next));
// 异步同步到后端(失败不阻塞)
updateMutation({
parentId,
preferences: next.preferences,
defaults: next.defaults,
}).catch(() => {
// P4 localStorage 降级:后端失败不影响本地
});
return next;
});
},
[parentId, updateMutation],
);
return {
preferences: localPrefs,
loading: !localPrefs && queryResult.fetching,
error: queryResult.error,
updatePreference,
};
}

View File

@@ -0,0 +1,35 @@
// 权限 Hook
// 依据project_rules §3.1(前端禁止 role === "xxx" 硬编码,统一用 hasPermission
// 从 localStorage 读取用户 permissions提供 hasPermission 检查
import { useMemo } from "react";
import { getUser } from "@/lib/auth";
import type { Permission } from "@/lib/permissions";
export function usePermission() {
const user = getUser();
const permissions = useMemo(() => {
return new Set(user?.permissions ?? []);
}, [user]);
function hasPermission(permission: Permission): boolean {
return permissions.has(permission);
}
function hasAnyPermission(...required: Permission[]): boolean {
return required.some((p) => permissions.has(p));
}
function hasAllPermissions(...required: Permission[]): boolean {
return required.every((p) => permissions.has(p));
}
return {
permissions: user?.permissions ?? [],
hasPermission,
hasAnyPermission,
hasAllPermissions,
isAuthenticated: user !== null,
};
}

View File

@@ -0,0 +1,70 @@
// useRealtimeNotifications实时通知 HookP5
// 依据02-architecture-design.md §5 实时推送
// - WebSocket 接收 NotificationRequested 事件
// - 收到新通知时更新 urql 缓存(触发 NotificationFeed 重新渲染)
// - 收到 GradeRecorded / SchoolAnnouncement 事件时触发相应页面刷新
"use client";
import { useCallback } from "react";
import { useWebSocket } from "./useWebSocket";
import type { WebSocketEvent } from "@/types";
export function useRealtimeNotifications() {
const handleEvent = useCallback((event: WebSocketEvent) => {
switch (event.type) {
case "NotificationRequested":
// 新通知:触发 NotificationFeed 刷新
// urql 会自动重新请求staleTime 到期或手动 invalidate
// 这里通过 dispatchEvent 通知 NotificationFeed 组件
window.dispatchEvent(
new CustomEvent("realtime-notification", {
detail: event.notification,
}),
);
break;
case "GradeRecorded":
// 成绩发布:触发成绩页面刷新
window.dispatchEvent(
new CustomEvent("realtime-grade", {
detail: {
childId: event.childId,
examId: event.examId,
},
}),
);
break;
case "SchoolAnnouncement":
// 学校公告:显示全局通知
window.dispatchEvent(
new CustomEvent("realtime-announcement", {
detail: { title: event.title, body: event.body },
}),
);
break;
case "ChildBound":
// 子女绑定:刷新子女列表
window.dispatchEvent(new CustomEvent("realtime-child-bound"));
break;
case "ChildUnbound":
// 子女解绑:刷新子女列表
window.dispatchEvent(
new CustomEvent("realtime-child-unbound", {
detail: { childId: event.childId },
}),
);
break;
}
}, []);
const { status } = useWebSocket({
onEvent: handleEvent,
enabled: true,
});
return { status };
}

View File

@@ -0,0 +1,168 @@
// useWebSocketpush-gateway WebSocket 连接
// 依据02-architecture-design.md §5 实时推送P5
// - 连接 push-gateway WebSocketNEXT_PUBLIC_PUSH_GATEWAY_WS_URL
// - 自动重连(指数退避,最大 30s
// - WebSocket 不可用时降级为 HTTP 轮询60s
// - 事件处理NotificationRequested / GradeRecorded / SchoolAnnouncement / ChildBound / ChildUnbound
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { getToken } from "@/lib/auth";
import { getBackoffDelay } from "@/lib/utils";
import type { WebSocketEvent } from "@/types";
const WS_URL = process.env.NEXT_PUBLIC_PUSH_GATEWAY_WS_URL || "";
const POLL_INTERVAL = 60_000; // 60s 轮询降级
const MAX_RECONNECT_ATTEMPTS = 10;
type ConnectionStatus = "connecting" | "connected" | "disconnected" | "polling";
interface UseWebSocketOptions {
onEvent?: (event: WebSocketEvent) => void;
enabled?: boolean;
}
export function useWebSocket({
onEvent,
enabled = true,
}: UseWebSocketOptions = {}) {
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttempts = useRef(0);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const onEventRef = useRef(onEvent);
// 保持 onEvent 引用最新
useEffect(() => {
onEventRef.current = onEvent;
}, [onEvent]);
const handleEvent = useCallback((event: WebSocketEvent) => {
onEventRef.current?.(event);
}, []);
// HTTP 轮询降级
const startPolling = useCallback(() => {
if (pollTimer.current) return;
setStatus("polling");
// 立即拉取一次
void pollNotifications(handleEvent);
pollTimer.current = setInterval(() => {
void pollNotifications(handleEvent);
}, POLL_INTERVAL);
}, [handleEvent]);
const stopPolling = useCallback(() => {
if (pollTimer.current) {
clearInterval(pollTimer.current);
pollTimer.current = null;
}
}, []);
// WebSocket 连接
const connect = useCallback(() => {
if (!enabled || !WS_URL || typeof window === "undefined") {
startPolling();
return;
}
const token = getToken();
if (!token) {
setStatus("disconnected");
return;
}
setStatus("connecting");
try {
const wsUrl = `${WS_URL}?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
reconnectAttempts.current = 0;
setStatus("connected");
stopPolling();
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as WebSocketEvent;
handleEvent(data);
} catch {
// 忽略无效消息
}
};
ws.onerror = () => {
// 错误不立即断开,等 onclose 处理
};
ws.onclose = () => {
setStatus("disconnected");
wsRef.current = null;
// 重连或降级
if (reconnectAttempts.current < MAX_RECONNECT_ATTEMPTS) {
const delay = getBackoffDelay(reconnectAttempts.current);
reconnectAttempts.current++;
reconnectTimer.current = setTimeout(() => {
connect();
}, delay);
} else {
// 超过重连次数,降级为轮询
startPolling();
}
};
} catch {
// WebSocket 创建失败,降级为轮询
startPolling();
}
}, [enabled, stopPolling, startPolling, handleEvent]);
useEffect(() => {
connect();
return () => {
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current);
}
stopPolling();
wsRef.current?.close();
wsRef.current = null;
};
}, [connect, stopPolling]);
return {
status,
reconnect: connect,
};
}
// HTTP 轮询:拉取最新通知
async function pollNotifications(
onEvent: (event: WebSocketEvent) => void,
): Promise<void> {
try {
const res = await fetch("/api/v1/parent/notifications?since=true", {
headers: {
Authorization: `Bearer ${localStorage.getItem("parent_access_token") ?? ""}`,
},
});
if (!res.ok) return;
const json = await res.json();
// 将新通知转为 WebSocketEvent 格式
if (json.data?.notifications) {
for (const notif of json.data.notifications) {
onEvent({
type: "NotificationRequested",
notification: notif,
});
}
}
} catch {
// 轮询失败静默忽略
}
}