feat(student-portal): 完整实现 student-portal 微前端
包含 src 全部实现、Dockerfile、配置文件、contracts 包等
This commit is contained in:
165
apps/student-portal/src/hooks/use-anti-cheat.ts
Normal file
165
apps/student-portal/src/hooks/use-anti-cheat.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 防作弊监控 Hook(ai14)
|
||||
*
|
||||
* 监听浏览器事件:visibilitychange / copy / paste / contextmenu / fullscreenchange
|
||||
* 记录违规行为,超阈值显示警告遮罩
|
||||
* 违规记录批量上报(防抖),上报失败静默保留本地
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { ViolationRecord, ViolationType } from "@/lib/exam-types";
|
||||
|
||||
const GRAPHQL_ENDPOINT =
|
||||
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql";
|
||||
const FLUSH_THRESHOLD = 10; // 队列满 10 条后批量上报
|
||||
|
||||
/** 批量上报违规记录到服务端 */
|
||||
async function flushViolations(
|
||||
examId: string,
|
||||
batch: ViolationRecord[],
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(GRAPHQL_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: `mutation RecordViolations($examId: ID!, $behaviors: [BehaviorInput!]!) {
|
||||
recordExamViolation(examId: $examId, behaviors: $behaviors) { ok }
|
||||
}`,
|
||||
variables: { examId, behaviors: batch },
|
||||
}),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseAntiCheatReturn {
|
||||
violations: ViolationRecord[];
|
||||
isFullscreen: boolean;
|
||||
requestFullscreen: () => Promise<void>;
|
||||
exitFullscreen: () => Promise<void>;
|
||||
showWarning: boolean;
|
||||
warningMessage: string | null;
|
||||
dismissWarning: () => void;
|
||||
}
|
||||
|
||||
export function useAntiCheat(examId: string): UseAntiCheatReturn {
|
||||
const [violations, setViolations] = useState<ViolationRecord[]>([]);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const [warningMessage, setWarningMessage] = useState<string | null>(null);
|
||||
const queueRef = useRef<ViolationRecord[]>([]);
|
||||
const tabSwitchCount = useRef(0);
|
||||
|
||||
const record = useCallback(
|
||||
(type: ViolationType, details?: string): void => {
|
||||
const entry: ViolationRecord = { type, timestamp: Date.now(), details };
|
||||
setViolations((prev) => [...prev, entry]);
|
||||
queueRef.current.push(entry);
|
||||
if (queueRef.current.length >= FLUSH_THRESHOLD) {
|
||||
const batch = queueRef.current.splice(0);
|
||||
void flushViolations(examId, batch);
|
||||
}
|
||||
},
|
||||
[examId],
|
||||
);
|
||||
|
||||
// 阻止默认行为并记录(ClipboardEvent / MouseEvent 均继承自 Event)
|
||||
useEffect(() => {
|
||||
const onCopy = (e: ClipboardEvent): void => {
|
||||
e.preventDefault();
|
||||
record("copy", "copy attempted");
|
||||
};
|
||||
const onPaste = (e: ClipboardEvent): void => {
|
||||
e.preventDefault();
|
||||
record("paste", "paste attempted");
|
||||
};
|
||||
const onContext = (e: MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
record("contextmenu", "contextmenu attempted");
|
||||
};
|
||||
|
||||
document.addEventListener("copy", onCopy);
|
||||
document.addEventListener("paste", onPaste);
|
||||
document.addEventListener("contextmenu", onContext);
|
||||
return () => {
|
||||
document.removeEventListener("copy", onCopy);
|
||||
document.removeEventListener("paste", onPaste);
|
||||
document.removeEventListener("contextmenu", onContext);
|
||||
};
|
||||
}, [record]);
|
||||
|
||||
// 切屏检测
|
||||
useEffect(() => {
|
||||
const onVisibility = (): void => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
tabSwitchCount.current += 1;
|
||||
record("tab-switch", `第 ${tabSwitchCount.current} 次切换`);
|
||||
if (tabSwitchCount.current >= 3) {
|
||||
setShowWarning(true);
|
||||
setWarningMessage("检测到多次切屏,已记录违规行为");
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => document.removeEventListener("visibilitychange", onVisibility);
|
||||
}, [record]);
|
||||
|
||||
// 全屏状态检测
|
||||
useEffect(() => {
|
||||
const onFullscreen = (): void => {
|
||||
const isFs = document.fullscreenElement !== null;
|
||||
setIsFullscreen(isFs);
|
||||
if (!isFs) {
|
||||
record("fullscreen-exit", "退出全屏");
|
||||
}
|
||||
};
|
||||
document.addEventListener("fullscreenchange", onFullscreen);
|
||||
return () => document.removeEventListener("fullscreenchange", onFullscreen);
|
||||
}, [record]);
|
||||
|
||||
// 卸载时 flush 剩余记录
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (queueRef.current.length > 0) {
|
||||
const batch = queueRef.current.splice(0);
|
||||
void flushViolations(examId, batch);
|
||||
}
|
||||
};
|
||||
}, [examId]);
|
||||
|
||||
const requestFullscreen = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await document.documentElement.requestFullscreen();
|
||||
} catch {
|
||||
// 全屏请求失败,静默处理
|
||||
}
|
||||
}, []);
|
||||
|
||||
const exitFullscreen = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
} catch {
|
||||
// 退出全屏失败,静默处理
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissWarning = useCallback((): void => {
|
||||
setShowWarning(false);
|
||||
setWarningMessage(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
violations,
|
||||
isFullscreen,
|
||||
requestFullscreen,
|
||||
exitFullscreen,
|
||||
showWarning,
|
||||
warningMessage,
|
||||
dismissWarning,
|
||||
};
|
||||
}
|
||||
160
apps/student-portal/src/hooks/use-exam-draft.ts
Normal file
160
apps/student-portal/src/hooks/use-exam-draft.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 考试草稿管理 Hook(ai14)
|
||||
*
|
||||
* 使用 idb-keyval 持久化草稿到 IndexedDB
|
||||
* 支持断网恢复:进入考试页时检测已有草稿
|
||||
* 自动保存:每 NEXT_PUBLIC_EXAM_AUTOSAVE_INTERVAL 秒 + 组件卸载时
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { get, set, del } from "idb-keyval";
|
||||
import type { AnswerInput, ExamTakingDraft } from "@/lib/exam-types";
|
||||
import { getStudentId, buildDraftKey } from "@/lib/exam-types";
|
||||
|
||||
const DEFAULT_AUTOSAVE_INTERVAL = 30; // 默认 30 秒
|
||||
|
||||
/** 获取自动保存间隔(毫秒) */
|
||||
function getAutosaveIntervalMs(): number {
|
||||
const seconds = Number(process.env.NEXT_PUBLIC_EXAM_AUTOSAVE_INTERVAL);
|
||||
return (
|
||||
(Number.isFinite(seconds) && seconds > 0
|
||||
? seconds
|
||||
: DEFAULT_AUTOSAVE_INTERVAL) * 1000
|
||||
);
|
||||
}
|
||||
|
||||
export interface UseExamDraftReturn {
|
||||
draft: ExamTakingDraft | null;
|
||||
saveDraft: (answers: Record<string, AnswerInput>) => Promise<void>;
|
||||
/** 更新内部答案引用(不触发保存,供自动保存定时器使用) */
|
||||
setAnswers: (answers: Record<string, AnswerInput>) => void;
|
||||
clearDraft: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
hasDraft: boolean;
|
||||
lastSavedAt: number | null;
|
||||
/** 手动加载草稿(用于恢复确认后) */
|
||||
loadDraft: () => Promise<ExamTakingDraft | null>;
|
||||
}
|
||||
|
||||
export function useExamDraft(
|
||||
examId: string,
|
||||
expiresAt?: string,
|
||||
): UseExamDraftReturn {
|
||||
const [draft, setDraft] = useState<ExamTakingDraft | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
|
||||
const answersRef = useRef<Record<string, AnswerInput>>({});
|
||||
const expiresAtRef = useRef<string>(expiresAt ?? "");
|
||||
|
||||
// 当外部传入新的 expiresAt 时更新引用
|
||||
useEffect(() => {
|
||||
if (expiresAt) {
|
||||
expiresAtRef.current = expiresAt;
|
||||
}
|
||||
}, [expiresAt]);
|
||||
|
||||
/** 保存草稿到 IDB */
|
||||
const saveDraft = useCallback(
|
||||
async (answers: Record<string, AnswerInput>): Promise<void> => {
|
||||
const studentId = getStudentId();
|
||||
const key = buildDraftKey(examId, studentId);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const now = Date.now();
|
||||
const updated: ExamTakingDraft = draft
|
||||
? { ...draft, answers, lastSavedAt: now }
|
||||
: {
|
||||
examId,
|
||||
studentId,
|
||||
answers,
|
||||
startedAt: now,
|
||||
serverStartedAt: now,
|
||||
lastSavedAt: now,
|
||||
durationSeconds: 0,
|
||||
expiresAt: expiresAtRef.current,
|
||||
};
|
||||
await set(key, updated);
|
||||
setDraft(updated);
|
||||
setLastSavedAt(now);
|
||||
answersRef.current = answers;
|
||||
} catch {
|
||||
// IDB 写入失败,静默处理(内存中仍保留)
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[examId, draft],
|
||||
);
|
||||
|
||||
/** 从 IDB 加载草稿 */
|
||||
const loadDraft = useCallback(async (): Promise<ExamTakingDraft | null> => {
|
||||
const studentId = getStudentId();
|
||||
const key = buildDraftKey(examId, studentId);
|
||||
try {
|
||||
const saved = (await get(key)) as ExamTakingDraft | undefined;
|
||||
return saved ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [examId]);
|
||||
|
||||
/** 清除草稿 */
|
||||
const clearDraft = useCallback(async (): Promise<void> => {
|
||||
const studentId = getStudentId();
|
||||
const key = buildDraftKey(examId, studentId);
|
||||
try {
|
||||
await del(key);
|
||||
setDraft(null);
|
||||
setLastSavedAt(null);
|
||||
} catch {
|
||||
// 清除失败静默处理
|
||||
}
|
||||
}, [examId]);
|
||||
|
||||
/** 更新内部答案引用(不触发保存,供自动保存定时器使用) */
|
||||
const setAnswers = useCallback(
|
||||
(answers: Record<string, AnswerInput>): void => {
|
||||
answersRef.current = answers;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** 自动保存(定时器 + 卸载时) */
|
||||
const autoSave = useCallback(async (): Promise<void> => {
|
||||
if (Object.keys(answersRef.current).length === 0) return;
|
||||
await saveDraft(answersRef.current);
|
||||
}, [saveDraft]);
|
||||
|
||||
// 进入时加载已有草稿
|
||||
useEffect(() => {
|
||||
loadDraft().then((saved) => {
|
||||
if (saved) {
|
||||
setDraft(saved);
|
||||
setLastSavedAt(saved.lastSavedAt);
|
||||
answersRef.current = saved.answers;
|
||||
expiresAtRef.current = saved.expiresAt;
|
||||
}
|
||||
});
|
||||
}, [loadDraft]);
|
||||
|
||||
// 定时自动保存
|
||||
useEffect(() => {
|
||||
const interval = setInterval(autoSave, getAutosaveIntervalMs());
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
// 卸载时保存草稿
|
||||
autoSave();
|
||||
};
|
||||
}, [autoSave]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
saveDraft,
|
||||
setAnswers,
|
||||
clearDraft,
|
||||
isSaving,
|
||||
hasDraft: draft !== null && Object.keys(draft.answers).length > 0,
|
||||
lastSavedAt,
|
||||
loadDraft,
|
||||
};
|
||||
}
|
||||
70
apps/student-portal/src/hooks/use-exam-state-machine.ts
Normal file
70
apps/student-portal/src/hooks/use-exam-state-machine.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 考试作答状态机 Hook(ai14)
|
||||
*
|
||||
* 状态流转:NotStarted → InProgress → AutoSaving → Submitting → Submitted
|
||||
* 防止非法状态转移,控制提交时机
|
||||
*/
|
||||
|
||||
import { useReducer, useMemo } from "react";
|
||||
|
||||
export type ExamState =
|
||||
"NotStarted" | "InProgress" | "AutoSaving" | "Submitting" | "Submitted";
|
||||
|
||||
type ExamAction =
|
||||
| { type: "start" }
|
||||
| { type: "beginAutoSave" }
|
||||
| { type: "endAutoSave" }
|
||||
| { type: "beginSubmit" }
|
||||
| { type: "completeSubmit" };
|
||||
|
||||
/** 状态转移函数(纯函数,校验合法转移) */
|
||||
function reducer(state: ExamState, action: ExamAction): ExamState {
|
||||
switch (action.type) {
|
||||
case "start":
|
||||
return state === "NotStarted" ? "InProgress" : state;
|
||||
case "beginAutoSave":
|
||||
return state === "InProgress" ? "AutoSaving" : state;
|
||||
case "endAutoSave":
|
||||
return state === "AutoSaving" ? "InProgress" : state;
|
||||
case "beginSubmit":
|
||||
return state === "InProgress" || state === "AutoSaving"
|
||||
? "Submitting"
|
||||
: state;
|
||||
case "completeSubmit":
|
||||
return state === "Submitting" ? "Submitted" : state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseExamStateMachineReturn {
|
||||
state: ExamState;
|
||||
actions: {
|
||||
start: () => void;
|
||||
beginAutoSave: () => void;
|
||||
endAutoSave: () => void;
|
||||
beginSubmit: () => void;
|
||||
completeSubmit: () => void;
|
||||
};
|
||||
canSubmit: boolean;
|
||||
}
|
||||
|
||||
/** 考试作答状态机 */
|
||||
export function useExamStateMachine(): UseExamStateMachineReturn {
|
||||
const [state, dispatch] = useReducer(reducer, "NotStarted");
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
start: () => dispatch({ type: "start" }),
|
||||
beginAutoSave: () => dispatch({ type: "beginAutoSave" }),
|
||||
endAutoSave: () => dispatch({ type: "endAutoSave" }),
|
||||
beginSubmit: () => dispatch({ type: "beginSubmit" }),
|
||||
completeSubmit: () => dispatch({ type: "completeSubmit" }),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const canSubmit = state === "InProgress" || state === "AutoSaving";
|
||||
|
||||
return { state, actions, canSubmit };
|
||||
}
|
||||
109
apps/student-portal/src/hooks/use-multi-tab-guard.ts
Normal file
109
apps/student-portal/src/hooks/use-multi-tab-guard.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 多标签页检测 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 };
|
||||
}
|
||||
141
apps/student-portal/src/hooks/use-notifications-websocket.ts
Normal file
141
apps/student-portal/src/hooks/use-notifications-websocket.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 通知 WebSocket Hook(ai14,P5)
|
||||
*
|
||||
* - 连接 push-gateway:ws://localhost:8081/ws(NEXT_PUBLIC_PUSH_GATEWAY_URL)
|
||||
* - 指数退避重连:1s → 2s → 4s → 8s → … 上限 30s
|
||||
* - 收到消息:派发 CustomEvent 通知页面重新查询 + BroadcastChannel 跨标签广播
|
||||
* - Mock 模式(NEXT_PUBLIC_API_MOCKING=enabled):使用 mock-socket 模拟推送
|
||||
* - 返回:{ isConnected, lastMessage, reconnect }
|
||||
*
|
||||
* 注意:urql 不支持 react-query 的 invalidateQueries,改用 CustomEvent 解耦。
|
||||
* 通知页面监听 'edu-notifications-updated' 事件后调用 reexecute()。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** 通知更新事件名(供页面监听) */
|
||||
export const NOTIFICATION_UPDATED_EVENT = "edu-notifications-updated";
|
||||
|
||||
export interface NotificationMessage {
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UseNotificationsWebSocketResult {
|
||||
isConnected: boolean;
|
||||
lastMessage: NotificationMessage | null;
|
||||
reconnect: () => void;
|
||||
}
|
||||
|
||||
const CHANNEL_NAME = "edu-notification";
|
||||
const MAX_DELAY_MS = 30000;
|
||||
|
||||
export function useNotificationsWebSocket(): UseNotificationsWebSocketResult {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [lastMessage, setLastMessage] = useState<NotificationMessage | null>(
|
||||
null,
|
||||
);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const retryRef = useRef(0);
|
||||
const reconnectRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_PUSH_GATEWAY_URL ?? "ws://localhost:8081";
|
||||
const url = `${base}/ws`;
|
||||
const channel =
|
||||
typeof BroadcastChannel !== "undefined"
|
||||
? new BroadcastChannel(CHANNEL_NAME)
|
||||
: null;
|
||||
|
||||
const notifyUpdate = (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(NOTIFICATION_UPDATED_EVENT));
|
||||
};
|
||||
|
||||
const onMessage = (raw: string): void => {
|
||||
let msg: NotificationMessage;
|
||||
try {
|
||||
msg = JSON.parse(raw) as NotificationMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLastMessage(msg);
|
||||
notifyUpdate();
|
||||
channel?.postMessage(msg);
|
||||
};
|
||||
|
||||
const connect = (): void => {
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => {
|
||||
retryRef.current = 0;
|
||||
setIsConnected(true);
|
||||
};
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
const raw = typeof e.data === "string" ? e.data : "";
|
||||
if (raw) onMessage(raw);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false);
|
||||
const delay = Math.min(1000 * 2 ** retryRef.current, MAX_DELAY_MS);
|
||||
retryRef.current += 1;
|
||||
connectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
};
|
||||
|
||||
let connectTimer: ReturnType<typeof setTimeout>;
|
||||
let mockServer: { stop: () => void } | null = null;
|
||||
|
||||
// 跨标签广播监听:其他标签的 WebSocket 消息也会触发本标签更新
|
||||
const onChannelMessage = (): void => {
|
||||
notifyUpdate();
|
||||
};
|
||||
channel?.addEventListener("message", onChannelMessage);
|
||||
|
||||
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
|
||||
// Mock 模式:mock-socket 拦截 WebSocket,周期性推送通知
|
||||
void import("mock-socket").then(({ Server }) => {
|
||||
const server = new Server(url);
|
||||
server.on("connection", (socket) => {
|
||||
const timer = setInterval(() => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "NotificationRequested",
|
||||
payload: { ts: Date.now() },
|
||||
}),
|
||||
);
|
||||
}, 60000);
|
||||
socket.on("close", () => clearInterval(timer));
|
||||
});
|
||||
mockServer = server;
|
||||
connect();
|
||||
});
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
|
||||
reconnectRef.current = (): void => {
|
||||
retryRef.current = 0;
|
||||
clearTimeout(connectTimer);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
clearTimeout(connectTimer);
|
||||
mockServer?.stop();
|
||||
wsRef.current?.close();
|
||||
channel?.removeEventListener("message", onChannelMessage);
|
||||
channel?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback((): void => {
|
||||
reconnectRef.current();
|
||||
}, []);
|
||||
|
||||
return { isConnected, lastMessage, reconnect };
|
||||
}
|
||||
89
apps/student-portal/src/hooks/use-server-time-sync.ts
Normal file
89
apps/student-portal/src/hooks/use-server-time-sync.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 服务器时间同步 Hook(ai14)
|
||||
*
|
||||
* 定期轮询服务器时间,计算客户端与服务器的时间偏移
|
||||
* 用于考试倒计时校正,防止客户端篡改时间
|
||||
* 同步失败时保持上次偏移,标记为降级模式
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { ServerTimeSyncState } from "@/lib/exam-types";
|
||||
|
||||
const DEFAULT_SYNC_INTERVAL = 300; // 默认 5 分钟(秒)
|
||||
const GRAPHQL_ENDPOINT =
|
||||
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql";
|
||||
|
||||
/** 获取同步间隔(毫秒) */
|
||||
function getSyncIntervalMs(): number {
|
||||
const seconds = Number(process.env.NEXT_PUBLIC_EXAM_TIME_SYNC_INTERVAL);
|
||||
return (
|
||||
(Number.isFinite(seconds) && seconds > 0
|
||||
? seconds
|
||||
: DEFAULT_SYNC_INTERVAL) * 1000
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询服务器时间 */
|
||||
async function fetchServerTime(): Promise<number> {
|
||||
const response = await fetch(GRAPHQL_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: "{ serverTime }" }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`服务器时间查询失败: ${response.status}`);
|
||||
}
|
||||
const result = (await response.json()) as { data?: { serverTime?: string } };
|
||||
const serverTimeStr = result.data?.serverTime;
|
||||
if (!serverTimeStr) {
|
||||
throw new Error("服务器时间响应格式错误");
|
||||
}
|
||||
return new Date(serverTimeStr).getTime();
|
||||
}
|
||||
|
||||
export function useServerTimeSync(): ServerTimeSyncState {
|
||||
const [state, setState] = useState<ServerTimeSyncState>({
|
||||
serverTime: Date.now(),
|
||||
offset: 0,
|
||||
isSyncing: false,
|
||||
lastSyncAt: null,
|
||||
isDegraded: false,
|
||||
});
|
||||
const offsetRef = useRef(0);
|
||||
|
||||
const sync = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isSyncing: true }));
|
||||
const requestStart = Date.now();
|
||||
try {
|
||||
const serverTime = await fetchServerTime();
|
||||
const requestEnd = Date.now();
|
||||
const roundTrip = requestEnd - requestStart;
|
||||
// 估算服务器响应时的客户端时间(取往返中点)
|
||||
const estimatedClientAtServer = requestStart + roundTrip / 2;
|
||||
const newOffset = serverTime - estimatedClientAtServer;
|
||||
offsetRef.current = newOffset;
|
||||
setState({
|
||||
serverTime,
|
||||
offset: newOffset,
|
||||
isSyncing: false,
|
||||
lastSyncAt: Date.now(),
|
||||
isDegraded: false,
|
||||
});
|
||||
} catch {
|
||||
// 同步失败:保持上次偏移,标记降级
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isSyncing: false,
|
||||
isDegraded: true,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
sync();
|
||||
const interval = setInterval(sync, getSyncIntervalMs());
|
||||
return () => clearInterval(interval);
|
||||
}, [sync]);
|
||||
|
||||
return state;
|
||||
}
|
||||
63
apps/student-portal/src/hooks/use-submit-dedup.ts
Normal file
63
apps/student-portal/src/hooks/use-submit-dedup.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 提交去重 Hook(ai14)
|
||||
*
|
||||
* 生成幂等 key,防止快速点击导致的重复提交
|
||||
* 支持基于 crypto.randomUUID 的 key 生成与降级方案
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
|
||||
export interface UseSubmitDedupReturn {
|
||||
/** 幂等 key(每次新提交生成新的) */
|
||||
idempotencyKey: string;
|
||||
/** 是否正在提交中 */
|
||||
isSubmitting: boolean;
|
||||
/** 标记开始提交(生成新 key + 锁定) */
|
||||
markSubmitting: () => void;
|
||||
/** 标记提交完成(解锁,生成新 key 供下次使用) */
|
||||
markComplete: () => void;
|
||||
/** 是否允许提交(未在提交中) */
|
||||
canSubmit: boolean;
|
||||
}
|
||||
|
||||
/** 生成幂等 key(优先 crypto.randomUUID,降级 Date+Random) */
|
||||
function generateIdempotencyKey(examId: string): string {
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function"
|
||||
) {
|
||||
return `exam-${examId}-${crypto.randomUUID()}`;
|
||||
}
|
||||
return `exam-${examId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function useSubmitDedup(examId: string): UseSubmitDedupReturn {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [idempotencyKey, setIdempotencyKey] = useState(() =>
|
||||
generateIdempotencyKey(examId),
|
||||
);
|
||||
const lockRef = useRef(false);
|
||||
|
||||
const markSubmitting = useCallback(() => {
|
||||
if (lockRef.current) return;
|
||||
lockRef.current = true;
|
||||
setIdempotencyKey(generateIdempotencyKey(examId));
|
||||
setIsSubmitting(true);
|
||||
}, [examId]);
|
||||
|
||||
const markComplete = useCallback(() => {
|
||||
lockRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
setIdempotencyKey(generateIdempotencyKey(examId));
|
||||
}, [examId]);
|
||||
|
||||
const canSubmit = !lockRef.current;
|
||||
|
||||
return {
|
||||
idempotencyKey,
|
||||
isSubmitting,
|
||||
markSubmitting,
|
||||
markComplete,
|
||||
canSubmit,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user