feat(teacher-portal): v2 下游核查 + push-gateway 接入 + 测试扩展至 87 + ui-components 12/12
v2 核查结论: - iam/push-gateway/api-gateway 已就绪 - teacher-bff teacher 域仍是 P2 占位(56/61 operations 不可用) - 5 个 gRPC target 留空走降级模式 B - 保留 MSW mock,记录 12 项 teacher-bff 待补工作到 nextstep-v2.md v2 完成工作: - push-gateway WebSocket 接入(环境变量 + URL query token + Reconnect 协议) - 单元测试扩展(usePermission 15 + useAuth 9,总计 87/87 passed) - ui-components 剩余 3 组件(Chart/Calendar/RichTextEditor,12/12) - 设计令牌完整迁移(无 hsl/hex 字面量) - i18n 5/55 页面 + 26 模块 key - 性能优化(size-limit 9 项 + 5 页面懒加载) - useAuth 迁移阻塞核查(iam 未实现 Set-Cookie,修正 v1 假设) 文档:新增 nextstep-v2.md;workline.md §5.7 添加 v2 工作记录。 验证:typecheck + lint + 87/87 tests + arch:scan 全部通过。
This commit is contained in:
246
apps/teacher-portal/src/hooks/__tests__/use-auth.test.ts
Normal file
246
apps/teacher-portal/src/hooks/__tests__/use-auth.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* useAuth Hook 单元测试
|
||||
*
|
||||
* 测试点:
|
||||
* - 初始状态:isLoading=true → useEffect 后 isLoading=false
|
||||
* - 从 localStorage 恢复 token + user
|
||||
* - login:存 localStorage + 更新 state
|
||||
* - logout:清 localStorage + 更新 state
|
||||
* - refreshUser:更新 user 信息
|
||||
* - localStorage 损坏时降级为未认证
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useAuth } from "@edu/hooks";
|
||||
|
||||
// ============ 类型定义(对齐 packages/hooks/src/types.ts) ============
|
||||
|
||||
interface UserSession {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
roles: string[];
|
||||
dataScope?: "SELF" | "CLASS" | "GRADE" | "SCHOOL" | "DISTRICT" | "ALL";
|
||||
}
|
||||
|
||||
// ============ localStorage mock ============
|
||||
|
||||
const TOKEN_KEY = "edu_auth_token";
|
||||
const USER_KEY = "edu_auth_user";
|
||||
|
||||
const mockUser: UserSession = {
|
||||
userId: "u-001",
|
||||
username: "teacher2",
|
||||
displayName: "张老师",
|
||||
roles: ["teacher"],
|
||||
dataScope: "SCHOOL",
|
||||
};
|
||||
|
||||
describe("useAuth", () => {
|
||||
beforeEach(() => {
|
||||
// 每个 test 独立的 localStorage
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
key: (index: number) => Array.from(store.keys())[index] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
describe("初始状态", () => {
|
||||
it("首次渲染后从空 localStorage 读取,isLoading=false", async () => {
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
// 等待 useEffect 执行完成
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("从 localStorage 恢复会话", () => {
|
||||
it("localStorage 有 token + user 时恢复为已认证状态", async () => {
|
||||
localStorage.setItem(TOKEN_KEY, "fake-jwt-token");
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(mockUser));
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
expect(result.current.token).toBe("fake-jwt-token");
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it("localStorage 仅有 token 无 user 时 isAuthenticated=false", async () => {
|
||||
localStorage.setItem(TOKEN_KEY, "fake-jwt-token");
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("localStorage 仅有 user 无 token 时 isAuthenticated=false", async () => {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(mockUser));
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("localStorage user JSON 损坏时降级为未认证", async () => {
|
||||
localStorage.setItem(TOKEN_KEY, "fake-jwt-token");
|
||||
localStorage.setItem(USER_KEY, "{invalid-json}");
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(result.current.user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("login 后存 localStorage + 更新 state 为已认证", async () => {
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.login("new-jwt-token", mockUser);
|
||||
});
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
expect(result.current.token).toBe("new-jwt-token");
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
|
||||
// 验证 localStorage 已写入
|
||||
expect(localStorage.getItem(TOKEN_KEY)).toBe("new-jwt-token");
|
||||
expect(localStorage.getItem(USER_KEY)).toBe(JSON.stringify(mockUser));
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
it("logout 后清 localStorage + 更新 state 为未认证", async () => {
|
||||
localStorage.setItem(TOKEN_KEY, "fake-jwt-token");
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(mockUser));
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.logout();
|
||||
});
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
|
||||
// 验证 localStorage 已清除
|
||||
expect(localStorage.getItem(TOKEN_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(USER_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshUser", () => {
|
||||
it("refreshUser 更新 user 信息并同步 localStorage", async () => {
|
||||
localStorage.setItem(TOKEN_KEY, "fake-jwt-token");
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(mockUser));
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const updatedUser: UserSession = {
|
||||
...mockUser,
|
||||
displayName: "李老师",
|
||||
roles: ["teacher", "head_teacher"],
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.refreshUser(updatedUser);
|
||||
});
|
||||
|
||||
expect(result.current.user).toEqual(updatedUser);
|
||||
expect(result.current.user?.displayName).toBe("李老师");
|
||||
expect(result.current.user?.roles).toEqual(["teacher", "head_teacher"]);
|
||||
|
||||
// token 不变
|
||||
expect(result.current.token).toBe("fake-jwt-token");
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
|
||||
// localStorage 已同步
|
||||
expect(localStorage.getItem(USER_KEY)).toBe(JSON.stringify(updatedUser));
|
||||
});
|
||||
});
|
||||
|
||||
describe("跨 hook 实例共享会话", () => {
|
||||
it("一个实例 login 后另一个实例能读取到相同会话", async () => {
|
||||
const { result: first } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
first.current.login("shared-token", mockUser);
|
||||
});
|
||||
|
||||
// 新实例初始化时应读取到第一个实例写入的 localStorage
|
||||
const { result: second } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(second.current.isAuthenticated).toBe(true);
|
||||
expect(second.current.token).toBe("shared-token");
|
||||
expect(second.current.user).toEqual(mockUser);
|
||||
});
|
||||
});
|
||||
});
|
||||
258
apps/teacher-portal/src/hooks/__tests__/use-permission.test.ts
Normal file
258
apps/teacher-portal/src/hooks/__tests__/use-permission.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* usePermission Hook 单元测试
|
||||
*
|
||||
* 测试点:
|
||||
* - hasPermission:权限点命中 / 未命中
|
||||
* - hasAny:任一权限命中
|
||||
* - hasAll:全部权限命中
|
||||
* - hasRole:角色命中 / 未命中
|
||||
* - dataScope:返回上下文数据范围
|
||||
* - context=null 时返回安全默认值
|
||||
* - 权限/角色集合 useMemo 缓存稳定性
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { usePermission } from "@edu/hooks";
|
||||
|
||||
describe("usePermission", () => {
|
||||
describe("hasPermission", () => {
|
||||
it("命中已拥有的权限点返回 true", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: ["EXAM_READ", "GRADE_READ", "CLASS_READ"],
|
||||
dataScope: "SCHOOL",
|
||||
roles: ["teacher"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.hasPermission("EXAM_READ")).toBe(true);
|
||||
expect(result.current.hasPermission("GRADE_READ")).toBe(true);
|
||||
});
|
||||
|
||||
it("未拥有的权限点返回 false", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: ["EXAM_READ"],
|
||||
dataScope: "SELF",
|
||||
roles: ["teacher"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.hasPermission("EXAM_DELETE")).toBe(false);
|
||||
expect(result.current.hasPermission("GRADE_WRITE")).toBe(false);
|
||||
});
|
||||
|
||||
it("支持数据范围后缀(_OWN / _CHILD)", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: ["GRADE_READ_CHILD", "GRADE_READ_OWN"],
|
||||
dataScope: "CLASS",
|
||||
roles: ["teacher"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.hasPermission("GRADE_READ_CHILD")).toBe(true);
|
||||
expect(result.current.hasPermission("GRADE_READ_OWN")).toBe(true);
|
||||
expect(result.current.hasPermission("GRADE_READ_ALL")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAny", () => {
|
||||
it("任一权限命中返回 true", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: ["EXAM_READ"],
|
||||
dataScope: "SELF",
|
||||
roles: ["teacher"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.hasAny("EXAM_READ", "EXAM_WRITE")).toBe(true);
|
||||
});
|
||||
|
||||
it("全部未命中返回 false", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: ["EXAM_READ"],
|
||||
dataScope: "SELF",
|
||||
roles: ["teacher"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.current.hasAny("EXAM_DELETE", "EXAM_WRITE", "ADMIN_ACCESS"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("空参数返回 false", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: ["EXAM_READ"],
|
||||
dataScope: "SELF",
|
||||
roles: ["teacher"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.hasAny()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAll", () => {
|
||||
it("全部权限命中返回 true", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: ["EXAM_READ", "GRADE_READ", "CLASS_READ"],
|
||||
dataScope: "SCHOOL",
|
||||
roles: ["teacher"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.hasAll("EXAM_READ", "GRADE_READ")).toBe(true);
|
||||
});
|
||||
|
||||
it("部分未命中返回 false", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: ["EXAM_READ", "GRADE_READ"],
|
||||
dataScope: "SCHOOL",
|
||||
roles: ["teacher"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.current.hasAll("EXAM_READ", "GRADE_READ", "EXAM_DELETE"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("空参数返回 true(空真值)", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: ["EXAM_READ"],
|
||||
dataScope: "SELF",
|
||||
roles: ["teacher"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.hasAll()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasRole", () => {
|
||||
it("命中已拥有角色返回 true", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: [],
|
||||
dataScope: "SCHOOL",
|
||||
roles: ["teacher", "head_teacher"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.hasRole("teacher")).toBe(true);
|
||||
expect(result.current.hasRole("head_teacher")).toBe(true);
|
||||
});
|
||||
|
||||
it("未拥有角色返回 false", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: [],
|
||||
dataScope: "SELF",
|
||||
roles: ["teacher"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.hasRole("admin")).toBe(false);
|
||||
expect(result.current.hasRole("principal")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dataScope", () => {
|
||||
it("返回上下文中的 dataScope", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: [],
|
||||
dataScope: "GRADE",
|
||||
roles: ["teacher"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.dataScope).toBe("GRADE");
|
||||
});
|
||||
|
||||
it("支持全部 6 级 DataScope", () => {
|
||||
const scopes = [
|
||||
"SELF",
|
||||
"CLASS",
|
||||
"GRADE",
|
||||
"SCHOOL",
|
||||
"DISTRICT",
|
||||
"ALL",
|
||||
] as const;
|
||||
|
||||
for (const scope of scopes) {
|
||||
const { result } = renderHook(() =>
|
||||
usePermission({
|
||||
context: {
|
||||
permissions: [],
|
||||
dataScope: scope,
|
||||
roles: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result.current.dataScope).toBe(scope);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("context=null", () => {
|
||||
it("返回安全默认值(所有检查返回 false,dataScope=null)", () => {
|
||||
const { result } = renderHook(() => usePermission({ context: null }));
|
||||
|
||||
expect(result.current.hasPermission("EXAM_READ")).toBe(false);
|
||||
expect(result.current.hasAny("EXAM_READ", "EXAM_WRITE")).toBe(false);
|
||||
expect(result.current.hasAll("EXAM_READ")).toBe(false);
|
||||
expect(result.current.hasRole("teacher")).toBe(false);
|
||||
expect(result.current.dataScope).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMemo 缓存稳定性", () => {
|
||||
it("相同 context 引用时 hasPermission 函数引用稳定", () => {
|
||||
const context = {
|
||||
permissions: ["EXAM_READ"],
|
||||
dataScope: "SCHOOL" as const,
|
||||
roles: ["teacher"],
|
||||
};
|
||||
|
||||
const { result, rerender } = renderHook(() => usePermission({ context }));
|
||||
|
||||
const first = result.current.hasPermission;
|
||||
rerender();
|
||||
expect(result.current.hasPermission).toBe(first);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 通知 WebSocket Hook(P5)
|
||||
* 通知 WebSocket Hook(P5 + v2 接入真实 push-gateway)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:02-architecture-design.md §5 实时推送(push-gateway)
|
||||
* 关联:services/push-gateway/docs/nextstep.md(WebSocket /ws + Reconnect 协议)
|
||||
*
|
||||
* - 连接 push-gateway:ws://localhost:8081/ws
|
||||
* - 连接 push-gateway:NEXT_PUBLIC_PUSH_GATEWAY_WS_URL(默认 ws://localhost:8081/ws)
|
||||
* - Token 传递:URL query param `?token=<jwt>`(从 localStorage 读取 edu_access_token)
|
||||
* - Reconnect 协议(P6):session_id + last_seq,断线重连时补发 ring buffer 消息
|
||||
* - useEffect 建立连接,useRef 持有 WebSocket 实例
|
||||
* - 监听消息事件,解析 JSON 后通过 state 通知
|
||||
* - 自动重连(指数退避,最多 5 次)
|
||||
@@ -21,10 +24,7 @@ import type { NotificationItem, NotificationType } from "@/lib/graphql-p5";
|
||||
import { mockNotifications } from "@/mocks/fixtures/notifications";
|
||||
|
||||
export type ConnectionState =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error";
|
||||
"connecting" | "connected" | "disconnected" | "error";
|
||||
|
||||
export interface UseNotificationsWebSocketResult {
|
||||
notifications: NotificationItem[];
|
||||
@@ -32,11 +32,26 @@ export interface UseNotificationsWebSocketResult {
|
||||
reconnect: () => void;
|
||||
}
|
||||
|
||||
const WS_URL = "ws://localhost:8081/ws";
|
||||
const WS_URL =
|
||||
process.env.NEXT_PUBLIC_PUSH_GATEWAY_WS_URL || "ws://localhost:8081/ws";
|
||||
const MAX_RETRIES = 5;
|
||||
const MAX_DELAY_MS = 30000;
|
||||
const MOCK_INTERVAL_MS = 30000;
|
||||
|
||||
/** 从 localStorage 读取 access token(兼容 DevMode dev-token) */
|
||||
function getAccessToken(): string {
|
||||
if (typeof window === "undefined") return "dev-token";
|
||||
return window.localStorage.getItem("edu_access_token") || "dev-token";
|
||||
}
|
||||
|
||||
/** 生成唯一 session_id(用于 Reconnect 协议) */
|
||||
function generateSessionId(): string {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `sess-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function useNotificationsWebSocket(): UseNotificationsWebSocketResult {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [connectionState, setConnectionState] =
|
||||
@@ -46,6 +61,10 @@ export function useNotificationsWebSocket(): UseNotificationsWebSocketResult {
|
||||
const retryRef = useRef(0);
|
||||
const reconnectFnRef = useRef<() => void>(() => {});
|
||||
|
||||
// Reconnect 协议状态(P6:session_id + last_seq)
|
||||
const sessionIdRef = useRef<string>(generateSessionId());
|
||||
const lastSeqRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let mockTimer: ReturnType<typeof setInterval> | undefined;
|
||||
@@ -63,9 +82,15 @@ export function useNotificationsWebSocket(): UseNotificationsWebSocketResult {
|
||||
if (disposed) return;
|
||||
setConnectionState("connecting");
|
||||
|
||||
// 构建 URL:token + session_id + last_seq(Reconnect 协议)
|
||||
const token = getAccessToken();
|
||||
const sessionId = sessionIdRef.current;
|
||||
const lastSeq = lastSeqRef.current;
|
||||
const url = `${WS_URL}?token=${encodeURIComponent(token)}&session_id=${encodeURIComponent(sessionId)}&last_seq=${lastSeq}`;
|
||||
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(WS_URL);
|
||||
ws = new WebSocket(url);
|
||||
} catch {
|
||||
setConnectionState("error");
|
||||
scheduleReconnect();
|
||||
@@ -85,10 +110,34 @@ export function useNotificationsWebSocket(): UseNotificationsWebSocketResult {
|
||||
const msg = JSON.parse(raw) as {
|
||||
type?: string;
|
||||
payload?: NotificationItem;
|
||||
seq?: number;
|
||||
// Reconnect 协议:服务端可能返回 seq 字段
|
||||
};
|
||||
// 更新 last_seq(用于断线重连补发)
|
||||
if (typeof msg.seq === "number" && msg.seq > lastSeqRef.current) {
|
||||
lastSeqRef.current = msg.seq;
|
||||
}
|
||||
const item = msg.payload;
|
||||
if (item && item.id) {
|
||||
pushNotification(item);
|
||||
} else if (msg.type && typeof msg.type === "string") {
|
||||
// 兼容 push-gateway 直接推送的事件格式
|
||||
// push-gateway 消息格式:{ event_type, payload: { id, type, title, message, ... } }
|
||||
const fakeItem: NotificationItem = {
|
||||
id:
|
||||
(msg.payload as { id?: string } | undefined)?.id ||
|
||||
`evt-${Date.now()}`,
|
||||
type: "BROADCAST",
|
||||
title:
|
||||
(msg.payload as { title?: string } | undefined)?.title ||
|
||||
msg.type,
|
||||
message:
|
||||
(msg.payload as { message?: string } | undefined)?.message ||
|
||||
"",
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
pushNotification(fakeItem);
|
||||
}
|
||||
} catch {
|
||||
// 忽略非 JSON 消息
|
||||
@@ -118,8 +167,7 @@ export function useNotificationsWebSocket(): UseNotificationsWebSocketResult {
|
||||
setConnectionState("connected");
|
||||
mockTimer = setInterval(() => {
|
||||
if (disposed) return;
|
||||
const base =
|
||||
mockNotifications[mockIndex % mockNotifications.length];
|
||||
const base = mockNotifications[mockIndex % mockNotifications.length];
|
||||
mockIndex += 1;
|
||||
if (!base) return;
|
||||
pushNotification({
|
||||
@@ -143,6 +191,8 @@ export function useNotificationsWebSocket(): UseNotificationsWebSocketResult {
|
||||
|
||||
reconnectFnRef.current = (): void => {
|
||||
retryRef.current = 0;
|
||||
// 手动重连生成新 session_id(触发服务端 ring buffer 补发)
|
||||
sessionIdRef.current = generateSessionId();
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (mockTimer) clearInterval(mockTimer);
|
||||
wsRef.current?.close();
|
||||
|
||||
Reference in New Issue
Block a user