feat(teacher-portal): 完成参考项目差距闭环 P3-P7 全量实现
- P3 考试/作业/成绩 mutation + 详情页 + 批改界面 + 乐观更新 + 多 Tab 同步 - P4 知识图谱 SVG 可视化 + 学情分析仪表盘 + parent-portal Remote - P5 WebSocket 通知中心 + AI 出题(SSE) + AI 教案 + AI 学情报告 - P6 可观测性硬化:Sentry + WebVitals + OTel + A11y + 性能配置 + Cookie 迁移 - P7 参考项目差距闭环:新增 35 个页面覆盖 13 个缺失模块 - attendance(考勤 4 页)/questions(题库)/textbooks(教材 2 页) - classes/[id] 详情 + classes/schedule 课表 - course-plans(2 页)/diagnostic(2 页)/error-book/practice - exams/[id]/build 组卷 + exams/[id]/analytics 考后分析 - exams/[id]/edit-rich 富文本编辑 + exams/[id]/proctoring 监考 - grades/entry 批量录入 + grades/stats 统计 + grades/analytics 分析 + grades/report-card 报告卡 - homework/submissions 列表 + assignments/[id]/submissions 批量批改 - homework/submissions/[submissionId] 单份批改 + scan-grading 扫描批改 - lesson-plans 编辑器 + library + calendar + heatmap 5 页 - elective 选修课 3 页 /leave 请假 /schedule-changes 调课 - P7 基础设施:61 GraphQL operations + 5 handlers + 13 fixtures + 11 viewports - 集成 browser.ts/server.ts 注册所有 p7 handlers(fallthrough 顺序) - viewports.ts 扩展 11 个新导航项 - 验证:tsc --noEmit 零错误 + eslint 零错误 - 文档:workline.md 新增 §5 P7 参考项目差距闭环(含完整文件清单)
This commit is contained in:
105
apps/teacher-portal/src/hooks/use-cross-tab-sync.ts
Normal file
105
apps/teacher-portal/src/hooks/use-cross-tab-sync.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useCrossTabSync - 多 Tab 会话同步 Hook
|
||||
*
|
||||
* 职责:
|
||||
* - 通过 BroadcastChannel('edu-session') 监听跨 Tab 事件
|
||||
* - 事件类型:logout / role-change / token-refresh
|
||||
* - logout:调用 logout() 跳转登录页
|
||||
* - role-change:刷新页面重新获取权限
|
||||
* - token-refresh:更新 localStorage 中的 token
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:02-architecture-design.md §2.1 会话状态
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { setToken } from "@/lib/auth";
|
||||
|
||||
/** 跨 Tab 同步事件类型 */
|
||||
export type CrossTabEventType = "logout" | "role-change" | "token-refresh";
|
||||
|
||||
/** 跨 Tab 事件消息体 */
|
||||
export interface CrossTabMessage {
|
||||
type: CrossTabEventType;
|
||||
payload?: {
|
||||
token?: string;
|
||||
};
|
||||
/** 触发时间戳(用于去重/排序) */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** BroadcastChannel 名称 */
|
||||
const CHANNEL_NAME = "edu-session";
|
||||
|
||||
/**
|
||||
* 广播跨 Tab 事件给其他 Tab
|
||||
*
|
||||
* 当前 Tab 主动触发(如点击登出按钮),其他 Tab 通过此 hook 接收并响应。
|
||||
*/
|
||||
export function broadcastCrossTabEvent(
|
||||
type: CrossTabEventType,
|
||||
payload?: CrossTabMessage["payload"],
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const channel = new BroadcastChannel(CHANNEL_NAME);
|
||||
const message: CrossTabMessage = {
|
||||
type,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
channel.postMessage(message);
|
||||
channel.close();
|
||||
} catch (err) {
|
||||
// BroadcastChannel 不可用时静默降级(单 Tab 模式不受影响)
|
||||
console.warn("[useCrossTabSync] broadcast failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅跨 Tab 事件并响应
|
||||
*
|
||||
* 必须在客户端组件中使用(依赖 window / BroadcastChannel)。
|
||||
*/
|
||||
export function useCrossTabSync(): void {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (typeof BroadcastChannel === "undefined") return;
|
||||
|
||||
const channel = new BroadcastChannel(CHANNEL_NAME);
|
||||
|
||||
const handleMessage = (event: MessageEvent<CrossTabMessage>) => {
|
||||
const message = event.data;
|
||||
if (!message || typeof message !== "object") return;
|
||||
|
||||
switch (message.type) {
|
||||
case "logout":
|
||||
// 收到登出事件:清空本地状态并跳转登录页
|
||||
// 注:不调用 broadcastCrossTabEvent 避免循环
|
||||
window.location.href = "/login";
|
||||
break;
|
||||
|
||||
case "role-change":
|
||||
// 收到角色变更事件:刷新页面以重新获取权限
|
||||
window.location.reload();
|
||||
break;
|
||||
|
||||
case "token-refresh":
|
||||
// 收到 token 刷新事件:更新本地 token
|
||||
if (message.payload?.token) {
|
||||
setToken(message.payload.token);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
channel.addEventListener("message", handleMessage);
|
||||
|
||||
return () => {
|
||||
channel.removeEventListener("message", handleMessage);
|
||||
channel.close();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
175
apps/teacher-portal/src/hooks/use-notifications-websocket.ts
Normal file
175
apps/teacher-portal/src/hooks/use-notifications-websocket.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 通知 WebSocket Hook(P5)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:02-architecture-design.md §5 实时推送(push-gateway)
|
||||
*
|
||||
* - 连接 push-gateway:ws://localhost:8081/ws
|
||||
* - useEffect 建立连接,useRef 持有 WebSocket 实例
|
||||
* - 监听消息事件,解析 JSON 后通过 state 通知
|
||||
* - 自动重连(指数退避,最多 5 次)
|
||||
* - 连接状态枚举:connecting / connected / disconnected / error
|
||||
* - Mock 模式(NEXT_PUBLIC_API_MOCKING=enabled):用 setInterval 每 30s 推送 1 条 mock 通知
|
||||
* (不依赖 mock-socket,避免新增 npm 依赖)
|
||||
* - 返回:{ notifications, connectionState, reconnect }
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { NotificationItem, NotificationType } from "@/lib/graphql-p5";
|
||||
import { mockNotifications } from "@/mocks/fixtures/notifications";
|
||||
|
||||
export type ConnectionState =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error";
|
||||
|
||||
export interface UseNotificationsWebSocketResult {
|
||||
notifications: NotificationItem[];
|
||||
connectionState: ConnectionState;
|
||||
reconnect: () => void;
|
||||
}
|
||||
|
||||
const WS_URL = "ws://localhost:8081/ws";
|
||||
const MAX_RETRIES = 5;
|
||||
const MAX_DELAY_MS = 30000;
|
||||
const MOCK_INTERVAL_MS = 30000;
|
||||
|
||||
export function useNotificationsWebSocket(): UseNotificationsWebSocketResult {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [connectionState, setConnectionState] =
|
||||
useState<ConnectionState>("disconnected");
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const retryRef = useRef(0);
|
||||
const reconnectFnRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let mockTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let mockIndex = 0;
|
||||
let disposed = false;
|
||||
|
||||
const pushNotification = (item: NotificationItem): void => {
|
||||
setNotifications((prev) => {
|
||||
if (prev.some((n) => n.id === item.id)) return prev;
|
||||
return [item, ...prev];
|
||||
});
|
||||
};
|
||||
|
||||
const connect = (): void => {
|
||||
if (disposed) return;
|
||||
setConnectionState("connecting");
|
||||
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(WS_URL);
|
||||
} catch {
|
||||
setConnectionState("error");
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
retryRef.current = 0;
|
||||
setConnectionState("connected");
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
const raw = typeof event.data === "string" ? event.data : "";
|
||||
if (!raw) return;
|
||||
try {
|
||||
const msg = JSON.parse(raw) as {
|
||||
type?: string;
|
||||
payload?: NotificationItem;
|
||||
};
|
||||
const item = msg.payload;
|
||||
if (item && item.id) {
|
||||
pushNotification(item);
|
||||
}
|
||||
} catch {
|
||||
// 忽略非 JSON 消息
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionState("error");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (disposed) return;
|
||||
setConnectionState("disconnected");
|
||||
scheduleReconnect();
|
||||
};
|
||||
};
|
||||
|
||||
const scheduleReconnect = (): void => {
|
||||
if (disposed) return;
|
||||
if (retryRef.current >= MAX_RETRIES) return;
|
||||
const delay = Math.min(1000 * 2 ** retryRef.current, MAX_DELAY_MS);
|
||||
retryRef.current += 1;
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
const startMock = (): void => {
|
||||
setConnectionState("connected");
|
||||
mockTimer = setInterval(() => {
|
||||
if (disposed) return;
|
||||
const base =
|
||||
mockNotifications[mockIndex % mockNotifications.length];
|
||||
mockIndex += 1;
|
||||
if (!base) return;
|
||||
pushNotification({
|
||||
...base,
|
||||
id: `${base.id}-ws-${Date.now()}`,
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}, MOCK_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const start = (): void => {
|
||||
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
|
||||
startMock();
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
|
||||
reconnectFnRef.current = (): void => {
|
||||
retryRef.current = 0;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (mockTimer) clearInterval(mockTimer);
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
start();
|
||||
};
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (mockTimer) clearInterval(mockTimer);
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback((): void => {
|
||||
reconnectFnRef.current();
|
||||
}, []);
|
||||
|
||||
return { notifications, connectionState, reconnect };
|
||||
}
|
||||
|
||||
/** 通知类型标签映射(供页面渲染图标/文案时复用) */
|
||||
export const NOTIFICATION_TYPE_LABEL: Record<NotificationType, string> = {
|
||||
HOMEWORK_SUBMITTED: "作业",
|
||||
EXAM_GRADED: "考试",
|
||||
BROADCAST: "广播",
|
||||
ROLE_CHANGED: "角色",
|
||||
};
|
||||
Reference in New Issue
Block a user