Files
Edu/apps/student-portal/src/hooks/use-multi-tab-guard.ts
SpecialX 74474a2d04 feat(student-portal): 完整实现 student-portal 微前端
包含 src 全部实现、Dockerfile、配置文件、contracts 包等
2026-07-10 19:10:36 +08:00

110 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 多标签页检测 Hookai14
*
* 使用 BroadcastChannel 检测同一考试是否已在其他标签页打开
* 降级方案Safari < 15.4 不支持时使用 localStorage 事件
*/
import { useState, useEffect, useCallback } from "react";
const CHANNEL_NAME = "student-exam-guard";
type GuardMessage =
| { type: "tab-opened"; examId: string; tabId: string; ts: number }
| { type: "tab-exists"; examId: string; tabId: string; ts: number }
| { type: "tab-closed"; examId: string; tabId: string; ts: number };
export interface UseMultiTabGuardReturn {
/** 是否检测到重复标签页 */
isDuplicate: boolean;
/** 关闭警告(用户确认后) */
dismissWarning: () => void;
}
function generateTabId(): string {
return `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function useMultiTabGuard(examId: string): UseMultiTabGuardReturn {
const [isDuplicate, setIsDuplicate] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const tabId = generateTabId();
// 降级方案:不支持 BroadcastChannel 时使用 localStorage
if (typeof BroadcastChannel === "undefined") {
const storageKey = `exam-active-${examId}`;
window.localStorage.setItem(
storageKey,
JSON.stringify({ tabId, ts: Date.now() }),
);
const onStorage = (e: StorageEvent): void => {
if (e.key === storageKey && e.newValue) {
try {
const other = JSON.parse(e.newValue) as { tabId: string };
if (other.tabId !== tabId && !dismissed) {
setIsDuplicate(true);
}
} catch {
// 解析失败忽略
}
}
};
window.addEventListener("storage", onStorage);
return () => {
window.localStorage.removeItem(storageKey);
window.removeEventListener("storage", onStorage);
};
}
// 主方案BroadcastChannel
const channel = new BroadcastChannel(CHANNEL_NAME);
const msg: GuardMessage = {
type: "tab-opened",
examId,
tabId,
ts: Date.now(),
};
channel.postMessage(msg);
const onMessage = (event: MessageEvent): void => {
const data = event.data as GuardMessage;
if (!data || data.tabId === tabId || data.examId !== examId) return;
if (
(data.type === "tab-opened" || data.type === "tab-exists") &&
!dismissed
) {
setIsDuplicate(true);
// 回应:我也在作答
channel.postMessage({
type: "tab-exists",
examId,
tabId,
ts: Date.now(),
} satisfies GuardMessage);
}
};
channel.addEventListener("message", onMessage);
return () => {
channel.postMessage({
type: "tab-closed",
examId,
tabId,
ts: Date.now(),
} satisfies GuardMessage);
channel.removeEventListener("message", onMessage);
channel.close();
};
}, [examId, dismissed]);
const dismissWarning = useCallback(() => {
setDismissed(true);
setIsDuplicate(false);
}, []);
return { isDuplicate, dismissWarning };
}