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:
41
apps/parent-portal/src/hooks/useChildAttendance.ts
Normal file
41
apps/parent-portal/src/hooks/useChildAttendance.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
29
apps/parent-portal/src/hooks/useChildGrades.ts
Normal file
29
apps/parent-portal/src/hooks/useChildGrades.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
29
apps/parent-portal/src/hooks/useChildHomework.ts
Normal file
29
apps/parent-portal/src/hooks/useChildHomework.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
31
apps/parent-portal/src/hooks/useChildSummary.ts
Normal file
31
apps/parent-portal/src/hooks/useChildSummary.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
30
apps/parent-portal/src/hooks/useChildSwitcher.ts
Normal file
30
apps/parent-portal/src/hooks/useChildSwitcher.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
29
apps/parent-portal/src/hooks/useChildWeakness.ts
Normal file
29
apps/parent-portal/src/hooks/useChildWeakness.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
97
apps/parent-portal/src/hooks/useCrossTabSync.ts
Normal file
97
apps/parent-portal/src/hooks/useCrossTabSync.ts
Normal 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(() => {
|
||||
// 方案 1:BroadcastChannel(首选)
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 方案 2:storage 事件降级(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 {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
39
apps/parent-portal/src/hooks/useMyChildren.ts
Normal file
39
apps/parent-portal/src/hooks/useMyChildren.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// useMyChildren:获取家长绑定子女列表
|
||||
// 依据:02-architecture-design.md §4.2 GraphQL 接入
|
||||
// - ISSUE-010:iam GetChildrenByParent P0 阻塞,MSW mock 返回 student-001 + student-002
|
||||
// - 加载后写入 Zustand store(setChildren),触发默认选中
|
||||
// - 单次加载,不轮询(子女列表变更由 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,
|
||||
};
|
||||
}
|
||||
51
apps/parent-portal/src/hooks/useMyNotifications.ts
Normal file
51
apps/parent-portal/src/hooks/useMyNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
108
apps/parent-portal/src/hooks/useNotificationPreferences.ts
Normal file
108
apps/parent-portal/src/hooks/useNotificationPreferences.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
35
apps/parent-portal/src/hooks/usePermission.ts
Normal file
35
apps/parent-portal/src/hooks/usePermission.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
70
apps/parent-portal/src/hooks/useRealtimeNotifications.ts
Normal file
70
apps/parent-portal/src/hooks/useRealtimeNotifications.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// useRealtimeNotifications:实时通知 Hook(P5)
|
||||
// 依据: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 };
|
||||
}
|
||||
168
apps/parent-portal/src/hooks/useWebSocket.ts
Normal file
168
apps/parent-portal/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// useWebSocket:push-gateway WebSocket 连接
|
||||
// 依据:02-architecture-design.md §5 实时推送(P5)
|
||||
// - 连接 push-gateway WebSocket(NEXT_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 {
|
||||
// 轮询失败静默忽略
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user