110 lines
3.1 KiB
TypeScript
110 lines
3.1 KiB
TypeScript
/**
|
||
* 多标签页检测 Hook(ai14)
|
||
*
|
||
* 使用 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 };
|
||
}
|