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:
SpecialX
2026-07-13 14:27:04 +08:00
parent f13ca612e6
commit d49d211425
117 changed files with 30867 additions and 108 deletions

View 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
*
* 维护者ai13teacher-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();
};
}, []);
}

View File

@@ -0,0 +1,175 @@
"use client";
/**
* 通知 WebSocket HookP5
*
* 维护者ai13teacher-portal
* 关联02-architecture-design.md §5 实时推送push-gateway
*
* - 连接 push-gatewayws://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: "角色",
};