From 57b32c54aaa52d3897bfba5f62e3f23a1daa650a Mon Sep 17 00:00:00 2001 From: cysamurai Date: Wed, 13 May 2026 15:37:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- call-client/src-tauri/src/commands/session.rs | 56 ++++--- call-client/src-tauri/src/state.rs | 13 +- call-client/src/host/session.ts | 82 ++++++--- call-client/src/utils/service.ts | 49 +++++- call-client/src/views/LoginView.vue | 13 +- call-client/src/views/MainView.vue | 157 ++++++++++++++---- call-client/src/views/TicketListView.vue | 98 +++++++---- 7 files changed, 329 insertions(+), 139 deletions(-) diff --git a/call-client/src-tauri/src/commands/session.rs b/call-client/src-tauri/src/commands/session.rs index b8e9a41..4db8d01 100644 --- a/call-client/src-tauri/src/commands/session.rs +++ b/call-client/src-tauri/src/commands/session.rs @@ -1,38 +1,40 @@ -use tauri::State; +use std::fs; +use std::path::PathBuf; -use crate::state::{AppState, SessionState}; +use tauri::AppHandle; -#[tauri::command] -pub fn session_get(state: State<'_, AppState>) -> Result { - let session = state - .session - .lock() - .map_err(|error| format!("读取会话锁失败: {error}"))?; +const RUNTIME_SESSION_FILE: &str = "runtime_session.json"; - Ok(session.clone()) +fn runtime_session_path(app: &AppHandle) -> Result { + let dir = app + .path_resolver() + .app_data_dir() + .ok_or_else(|| "无法获取应用数据目录".to_string())?; + fs::create_dir_all(&dir).map_err(|e| format!("创建应用数据目录失败: {e}"))?; + Ok(dir.join(RUNTIME_SESSION_FILE)) } +/// 读取完整运行时会话 JSON(与前端 `SessionState` 一致,camelCase)。 +/// 多窗口下各 WebView 的 localStorage 互不共享,因此会话必须落盘,由所有窗口共用。 #[tauri::command] -pub fn session_set( - payload: SessionState, - state: State<'_, AppState>, -) -> Result { - let mut session = state - .session - .lock() - .map_err(|error| format!("写入会话锁失败: {error}"))?; - - *session = payload.clone(); - Ok(payload) +pub fn session_get(app: AppHandle) -> Result { + let path = runtime_session_path(&app)?; + if !path.exists() { + return Ok("{}".to_string()); + } + fs::read_to_string(&path).map_err(|e| format!("读取会话文件失败: {e}")) } #[tauri::command] -pub fn session_clear(state: State<'_, AppState>) -> Result { - let mut session = state - .session - .lock() - .map_err(|error| format!("清空会话锁失败: {error}"))?; +pub fn session_set(app: AppHandle, json: String) -> Result<(), String> { + let _: serde_json::Value = + serde_json::from_str(&json).map_err(|e| format!("会话 JSON 无效: {e}"))?; + let path = runtime_session_path(&app)?; + fs::write(&path, json).map_err(|e| format!("写入会话文件失败: {e}"))?; + Ok(()) +} - *session = SessionState::default(); - Ok(session.clone()) +#[tauri::command] +pub fn session_clear(app: AppHandle) -> Result<(), String> { + session_set(app, "{}".to_string()) } diff --git a/call-client/src-tauri/src/state.rs b/call-client/src-tauri/src/state.rs index 93f02c6..7e08290 100644 --- a/call-client/src-tauri/src/state.rs +++ b/call-client/src-tauri/src/state.rs @@ -1,17 +1,7 @@ use std::sync::Mutex; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionState { - pub emp_uid: Option, - pub win_uid: Option, - pub queue_token: Option, -} - +/// 应用级状态(屏幕同步等)。运行时会话已改为磁盘文件,见 `commands/session.rs`。 pub struct AppState { - pub session: Mutex, pub screen_sync: Mutex, } @@ -25,7 +15,6 @@ pub struct ScreenSyncState { impl Default for AppState { fn default() -> Self { Self { - session: Mutex::new(SessionState::default()), screen_sync: Mutex::new(ScreenSyncState::default()), } } diff --git a/call-client/src/host/session.ts b/call-client/src/host/session.ts index da65fe8..7687679 100644 --- a/call-client/src/host/session.ts +++ b/call-client/src/host/session.ts @@ -1,3 +1,5 @@ +import { invoke } from "@tauri-apps/api/tauri"; + import type { SessionState } from "./types"; const SESSION_KEY = "runtime_session"; @@ -29,17 +31,23 @@ const DEFAULT_SESSION: SessionState = { taxer_kz: null, }; +/** 与登录页 / MainView 一致:支持 number 或可解析为整数的 string,避免 localStorage JSON 把数字写成字符串后读成 null */ +function normalizeOptionalUid(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + function normalizeSession(raw: unknown): SessionState { const source = (raw ?? {}) as Partial; - const empUid = - typeof source.empUid === "number" && Number.isFinite(source.empUid) - ? source.empUid - : null; - const winUid = - typeof source.winUid === "number" && Number.isFinite(source.winUid) - ? source.winUid - : null; + const empUid = normalizeOptionalUid(source.empUid); + const winUid = normalizeOptionalUid(source.winUid); const queueToken = typeof source.queueToken === "string" ? source.queueToken : null; const refreshToken = @@ -125,41 +133,67 @@ function normalizeSession(raw: unknown): SessionState { } /** - * 读取当前应用会话(localStorage 持久化)。 + * 读取当前应用会话:Tauri 下为应用数据目录中的 `runtime_session.json`(多窗口共享); + * 非 Tauri 环境回退为 localStorage。 + * + * 注意:不再从 localStorage「迁回」磁盘。退出后磁盘已清空时,主窗口 WebView 里可能仍残留 + * 已登出/已拉黑的旧 token;若再写回磁盘会导致重新登录后仍带旧 token 请求。 */ export async function getSession(): Promise { try { - const value = window.localStorage.getItem(SESSION_KEY); - if (!value) { - return { ...DEFAULT_SESSION }; - } - return normalizeSession(JSON.parse(value)); + const raw = await invoke("session_get"); + return normalizeSession(JSON.parse(raw || "{}")); } catch (error) { - throw new Error(`读取会话失败: ${String(error)}`); + try { + const value = window.localStorage.getItem(SESSION_KEY); + if (!value) { + return { ...DEFAULT_SESSION }; + } + return normalizeSession(JSON.parse(value)); + } catch (inner) { + throw new Error(`读取会话失败: ${String(error)} / ${String(inner)}`); + } } } /** - * 写入当前应用会话(localStorage 持久化)。 + * 写入当前应用会话:Tauri 下写入磁盘;成功后删除当前 WebView 的 localStorage 副本,避免与磁盘不一致。 + * 非 Tauri 环境仅写 localStorage。 */ export async function setSession(payload: SessionState): Promise { + const normalized = normalizeSession(payload); + const json = JSON.stringify(normalized); try { - const normalized = normalizeSession(payload); - window.localStorage.setItem(SESSION_KEY, JSON.stringify(normalized)); + await invoke("session_set", { json }); + try { + window.localStorage.removeItem(SESSION_KEY); + } catch { + // 忽略 + } return normalized; } catch (error) { - throw new Error(`写入会话失败: ${String(error)}`); + try { + window.localStorage.setItem(SESSION_KEY, json); + return normalized; + } catch (inner) { + throw new Error(`写入会话失败: ${String(error)} / ${String(inner)}`); + } } } /** - * 清空当前应用会话(localStorage 持久化)。 + * 清空当前应用会话(磁盘 + 当前窗口 localStorage)。 */ export async function clearSession(): Promise { try { - window.localStorage.setItem(SESSION_KEY, JSON.stringify(DEFAULT_SESSION)); - return { ...DEFAULT_SESSION }; - } catch (error) { - throw new Error(`清空会话失败: ${String(error)}`); + await invoke("session_clear"); + } catch { + // 非 Tauri 或未就绪时忽略 + } + try { + window.localStorage.removeItem(SESSION_KEY); + } catch { + // 忽略 } + return { ...DEFAULT_SESSION }; } diff --git a/call-client/src/utils/service.ts b/call-client/src/utils/service.ts index 46aea4d..d354818 100644 --- a/call-client/src/utils/service.ts +++ b/call-client/src/utils/service.ts @@ -176,19 +176,51 @@ function getApiMessage(payload: unknown): string { return "请求失败"; } -function isAuthEndpoint(url?: string): boolean { +function isLoginOrRefreshUrl(url?: string): boolean { if (!url) { return false; } return url.includes(AUTH_LOGIN_PATH) || url.includes(AUTH_REFRESH_PATH); } +/** 登录 / 刷新 / 登出 不应在 401 时走 refresh 重试(登出后 token 已作废,刷新无意义) */ +function shouldAvoidAuthRefreshRetry(url?: string): boolean { + if (!url) { + return false; + } + return ( + url.includes(AUTH_LOGIN_PATH) || + url.includes(AUTH_REFRESH_PATH) || + url.includes(AUTH_LOGOUT_PATH) + ); +} + function setAuthHeader(config: AxiosRequestConfig, queueToken: string): void { const headers = (config.headers ?? {}) as Record; headers.Authorization = `Bearer ${queueToken}`; config.headers = headers; } +/** 本次请求是否已携带 Bearer(未携带时 401 不应走 refresh,否则会误报「会话已过期」) */ +function requestHadBearerAuth(config?: AxiosRequestConfig): boolean { + const headers = config?.headers; + if (!headers || typeof headers !== "object") { + return false; + } + const h = headers as Record & { get?: (name: string) => unknown }; + const auth = + (typeof h.get === "function" + ? (h.get("Authorization") ?? h.get("authorization")) + : null) ?? + h.Authorization ?? + h.authorization; + return ( + typeof auth === "string" && + auth.startsWith("Bearer ") && + auth.length > "Bearer ".length + 4 + ); +} + async function refreshQueueToken(): Promise { if (refreshTokenPromise) { return refreshTokenPromise; @@ -239,12 +271,23 @@ function canRefreshAndRetry(config?: RetryableAxiosRequestConfig): boolean { if (config._retry || config._skipAuthRefresh) { return false; } - return !isAuthEndpoint(config.url); + if (!requestHadBearerAuth(config)) { + return false; + } + return !shouldAvoidAuthRefreshRetry(config.url); } async function retryWithRefreshedToken(config: RetryableAxiosRequestConfig): Promise { const nextToken = await refreshQueueToken(); if (!nextToken) { + let detail = "refresh 未返回 queueToken"; + try { + const s = await getSession(); + detail = `refresh 失败: refreshToken存在=${Boolean(s.refreshToken?.trim())}, url=${config.url ?? ""}`; + } catch { + // ignore + } + await log("warn", `auth: 401 后 ${detail}`); throw new Error("会话已过期,请重新登录"); } @@ -283,7 +326,7 @@ instance.interceptors.request.use( token = null; } - if (token && !isAuthEndpoint(config.url)) { + if (token && !isLoginOrRefreshUrl(config.url)) { setAuthHeader(config, token); } diff --git a/call-client/src/views/LoginView.vue b/call-client/src/views/LoginView.vue index 760052a..251070c 100644 --- a/call-client/src/views/LoginView.vue +++ b/call-client/src/views/LoginView.vue @@ -150,7 +150,8 @@ async function handleLogin(): Promise { sessionState = { empUid: loginRes.operatorProfile.empUid, - winUid: 0, + /** 未选窗口前不写占位数字 0,避免与「无效窗口」混淆且与 MainView `<=0` 判定一致 */ + winUid: null, queueToken: loginRes.queueToken, refreshToken: loginRes.refreshToken, }; @@ -236,10 +237,16 @@ async function handleWindowLogin(): Promise { // 登录完成后拉取 caller-init,并将返回 data 作为运行时缓存写入 session。 try { const callerInit = await api.action.callerInit(); + const callerWin = Number(callerInit.windowUid); + const resolvedWinUid = + Number.isFinite(callerWin) && callerWin > 0 ? callerWin : winUid; + const callerEmp = Number(callerInit.empUid); + const resolvedEmpUid = Number.isFinite(callerEmp) ? callerEmp : Number(sessionState.empUid ?? -1); + sessionState = { ...sessionState, - winUid: Number(callerInit.windowUid ?? winUid), - empUid: Number(callerInit.empUid ?? sessionState.empUid ?? -1), + winUid: resolvedWinUid, + empUid: resolvedEmpUid, windowName: String(callerInit.windowName ?? ""), empName: String(callerInit.empName ?? ""), autoCallEnabled: Boolean(callerInit.autoCallEnabled), diff --git a/call-client/src/views/MainView.vue b/call-client/src/views/MainView.vue index eb53939..3ff7cf6 100644 --- a/call-client/src/views/MainView.vue +++ b/call-client/src/views/MainView.vue @@ -30,7 +30,7 @@ import { } from "../host/dialog"; import { emitTaxerTicketContext, listenMainAction } from "../host/events"; import { log } from "../host/logger"; -import { getSession } from "../host/session"; +import { getSession, setSession } from "../host/session"; import { startScreenSync, stopScreenSync } from "../host/sync"; import type { SessionState } from "../host/types"; import { @@ -60,6 +60,15 @@ const sessionState = ref({ queueToken: "", refreshToken: "", }); + +/** 与产品流程一致:账号登录拿到 token + 选窗写入 winUid 之后,才允许等候人数轮询(避免主窗口提前挂载时误启动) */ +const sessionReadyForQueueCount = computed(() => { + const t = sessionState.value.queueToken; + const tokenOk = typeof t === "string" && t.trim() !== ""; + const w = sessionState.value.winUid; + const winOk = typeof w === "number" && Number.isFinite(w) && w > 0; + return tokenOk && winOk; +}); const textColor = ref("#99ccff"); const iconColor = ref("#dcdfe6"); const message = ref("欢迎使用紫云呼叫终端"); @@ -72,12 +81,19 @@ let evaluatingCountdownTimer: ReturnType | null = null; let isRankPollingTimer: ReturnType | null = null; let isRankPollingBusy = false; let queueCountPollingTimer: ReturnType | null = null; +/** 避免 windowUid 未就绪时每 15s 重复打同一条跳过日志 */ +let lastQueueCountSkipInvalidWinLogAt = 0; +/** 避免未登录(无 queueToken)时每 15s 重复打同一条跳过日志 */ +let lastQueueCountSkipNoTokenLogAt = 0; let unlistenWindowFocusChanged: (() => void) | null = null; let unlistenMainAction: (() => void) | null = null; const EVALUATING_COUNTDOWN_SEC = 15; const pauseReasonOptions = ["午休", "休息一下", "整理资料", "其他"]; -/** 主界面挂载时即视为可轮询;失焦后由 window blur 置为 false 暂停 */ +/** + * 主窗口是否处于前台焦点(用于高频 isRank 轮询;失焦后暂停以减轻请求)。 + * 等候人数 queue-count 不与该标志绑定,待机时后台仍定时拉取。 + */ const isMainWindowActive = ref(true); const buttonPanel = ref<"main" | "more" | "pause">("main"); const isSyncingMainScreen = ref(false); @@ -294,10 +310,11 @@ function clearIsRankPolling(): void { /** * 清理等待人数轮询。 */ -function clearQueueCountPolling(): void { +function clearQueueCountPolling(reason: string): void { if (queueCountPollingTimer !== null) { clearInterval(queueCountPollingTimer); queueCountPollingTimer = null; + void log("info", `queue-count 轮询已停止: ${reason}`); } } @@ -399,14 +416,59 @@ function startIsRankPolling(): void { }, 1000); } +/** + * 从磁盘会话刷新主界面缓存的 emp/win/token(登录在其它窗口完成后,焦点回到主窗口时对齐)。 + */ +async function refreshMainSessionStateFromDisk(): Promise { + try { + const session = await getSession(); + sessionState.value = { + empUid: parseOptionalNumber(session.empUid), + winUid: parseOptionalNumber(session.winUid), + queueToken: + typeof session.queueToken === "string" && session.queueToken.trim() !== "" + ? session.queueToken + : null, + refreshToken: + typeof session.refreshToken === "string" && session.refreshToken.trim() !== "" + ? session.refreshToken + : null, + }; + } catch (error) { + await logErr("主窗口从磁盘同步 session 失败", error); + } +} + /** * 轮询当前窗口等候人数。 */ async function pollQueueCountOnce(): Promise { try { - const winUidFromSession = Number(sessionState.value.winUid ?? -1); - const windowUid = winUidFromSession; - if (windowUid <= 0) { + const session = await getSession(); + const token = + typeof session.queueToken === "string" ? session.queueToken.trim() : ""; + if (!token) { + const now = Date.now(); + if (now - lastQueueCountSkipNoTokenLogAt > 60_000) { + lastQueueCountSkipNoTokenLogAt = now; + await log( + "info", + "GET /call-terminal/queue-count 跳过: 无有效 queueToken(可能已登出或未登录),不发起请求以免触发 401→refresh→「会话已过期」", + ); + } + return; + } + + const windowUid = Number(session.winUid ?? -1); + if (!Number.isFinite(windowUid) || windowUid <= 0) { + const now = Date.now(); + if (now - lastQueueCountSkipInvalidWinLogAt > 60_000) { + lastQueueCountSkipInvalidWinLogAt = now; + await log( + "info", + `GET /call-terminal/queue-count 跳过: windowUid 无效 (${String(session.winUid ?? "null")}),待 session 就绪后再请求`, + ); + } return; } @@ -415,6 +477,10 @@ async function pollQueueCountOnce(): Promise { typeof res.queueCount === "number" ? res.queueCount : Number(res.count ?? 0); + await log( + "info", + `GET /call-terminal/queue-count: windowUid=${windowUid}, queueCount=${count}`, + ); message.value = `欢迎使用紫云呼叫终端,当前窗口等候人数:${count}`; } catch (error) { await logErr("查询 getQueueCount 失败", error); @@ -425,7 +491,11 @@ async function pollQueueCountOnce(): Promise { * 开启等待人数轮询。 */ function startQueueCountPolling(): void { - clearQueueCountPolling(); + clearQueueCountPolling("重启前清理"); + void log( + "info", + `queue-count 轮询已启动: interval=15000ms, callStatus=${callStatus.value}, winUid=${sessionState.value.winUid ?? "null"}, hasQueueToken=${Boolean(sessionState.value.queueToken && String(sessionState.value.queueToken).trim() !== "")}`, + ); void pollQueueCountOnce(); queueCountPollingTimer = setInterval(() => { void pollQueueCountOnce(); @@ -433,32 +503,28 @@ function startQueueCountPolling(): void { } watch( - callStatus, - (status) => { + [callStatus, sessionReadyForQueueCount], + ([status, ready]) => { if (status === "evaluating" && isMainWindowActive.value) { startIsRankPolling(); } else { clearIsRankPolling(); } - if (status === "idle" && isMainWindowActive.value) { + if (status === "idle" && ready) { startQueueCountPolling(); } else { - clearQueueCountPolling(); + clearQueueCountPolling( + status !== "idle" + ? `callStatus=${status}` + : "会话未就绪(需 queueToken 且 winUid,选窗并打开主窗口后再轮询)", + ); } }, { immediate: true }, ); watch(isMainWindowActive, (active) => { - if (callStatus.value === "idle") { - if (active) { - startQueueCountPolling(); - } else { - clearQueueCountPolling(); - } - } - if (callStatus.value === "evaluating") { if (active) { startIsRankPolling(); @@ -953,28 +1019,46 @@ async function handleMoreCommand( onMounted(async () => { try { const session = await getSession(); + let winUid = parseOptionalNumber(session.winUid); + const empUid = parseOptionalNumber(session.empUid); + const queueToken = + typeof session.queueToken === "string" && session.queueToken.trim() !== "" + ? session.queueToken + : null; + const refreshToken = + typeof session.refreshToken === "string" && session.refreshToken.trim() !== "" + ? session.refreshToken + : null; + + /** 登录页会把上次选择的窗口写入 config.json;主窗口此前只读 session,未做兜底。 */ + if (winUid === null || winUid <= 0) { + try { + const config = await getAllConfig(); + const fromConfig = parseOptionalNumber(config.selected_win_uid); + if (fromConfig !== null && fromConfig > 0) { + winUid = fromConfig; + await setSession({ ...session, winUid: fromConfig }); + await log( + "info", + `MainView 挂载: session 无有效 winUid,已从 config.selected_win_uid 补全并写回会话: ${fromConfig}`, + ); + } + } catch (error) { + await logErr("读取 config 以补全 winUid 失败", error); + } + } + sessionState.value = { - empUid: parseOptionalNumber(session.empUid), - winUid: parseOptionalNumber(session.winUid), - queueToken: - typeof session.queueToken === "string" && - session.queueToken.trim() !== "" - ? session.queueToken - : null, - refreshToken: - typeof session.refreshToken === "string" && - session.refreshToken.trim() !== "" - ? session.refreshToken - : null, + empUid, + winUid, + queueToken, + refreshToken, }; } catch (error) { await logErr("读取 session 失败", error); } - // session 在 setup 的 watch 之后才就绪,此处再拉一次等候人数轮询,避免 winUid 仍为占位值时从未请求。 - if (callStatus.value === "idle" && isMainWindowActive.value) { - startQueueCountPolling(); - } + // queue-count 由 watch([callStatus, sessionReadyForQueueCount]) 在「idle + 已有 token 且 winUid」后启动,勿在此处强行 start。 try { const visible = await appWindow.isVisible(); @@ -984,6 +1068,7 @@ onMounted(async () => { } const onFocus = () => { isMainWindowActive.value = true; + void refreshMainSessionStateFromDisk(); }; const onBlur = () => { isMainWindowActive.value = false; @@ -1021,7 +1106,7 @@ onUnmounted(() => { } clearEvaluatingCountdown(); clearIsRankPolling(); - clearQueueCountPolling(); + clearQueueCountPolling("组件卸载"); if (unlistenWindowFocusChanged) { unlistenWindowFocusChanged(); } diff --git a/call-client/src/views/TicketListView.vue b/call-client/src/views/TicketListView.vue index 6afeab2..32e3599 100644 --- a/call-client/src/views/TicketListView.vue +++ b/call-client/src/views/TicketListView.vue @@ -340,6 +340,17 @@ async function handleReEvaluate(row: Tkt): Promise { await log("info", `票池评价按钮点击: ticketUid=${row.ticketUid}, tktNum=${row.tktNum}`); } +/** + * 与主界面一致:账号登录有 token 且已选窗后,才自动拉取票池 / 开轮询。 + */ +async function ticketPoolSessionReady(): Promise { + const session = await getSession(); + const token = + typeof session.queueToken === "string" ? session.queueToken.trim() : ""; + const winUid = Number(session.winUid ?? -1); + return Boolean(token && Number.isFinite(winUid) && winUid > 0); +} + /** * 刷新票池数据。 */ @@ -357,6 +368,18 @@ async function refreshData(): Promise { try { await safeLog("info", "票池刷新: start"); const session = await getSession(); + const queueToken = + typeof session.queueToken === "string" ? session.queueToken.trim() : ""; + if (!queueToken) { + await safeLog( + "warn", + "票池刷新: 无有效 queueToken(已登出或未登录),跳过请求以避免 401", + ); + allData.value = []; + totalCount.value = 0; + return; + } + const winUid = Number(session.winUid ?? -1); if (winUid <= 0) { @@ -399,22 +422,31 @@ function scheduleImmediateRefresh(): void { if (!isTicketWindowActive.value) { return; } - if (focusRefreshTimer !== null) { - clearTimeout(focusRefreshTimer); - } - // show/focus 切换瞬间等待一个微小延迟,确保窗口已恢复可见后再刷新。 - focusRefreshTimer = setTimeout(() => { - void refreshData(); - }, 80); + void (async () => { + if (!(await ticketPoolSessionReady())) { + return; + } + if (focusRefreshTimer !== null) { + clearTimeout(focusRefreshTimer); + } + focusRefreshTimer = setTimeout(() => { + void refreshData(); + }, 80); + })(); } function startRefreshPolling(): void { - if (refreshTimer !== null) { - return; - } - refreshTimer = setInterval(() => { - void refreshData(); - }, 20000); + void (async () => { + if (refreshTimer !== null) { + return; + } + if (!(await ticketPoolSessionReady())) { + return; + } + refreshTimer = setInterval(() => { + void refreshData(); + }, 20000); + })(); } function stopRefreshPolling(): void { @@ -424,15 +456,11 @@ function stopRefreshPolling(): void { } } -function handleWindowFocus(): void { - isTicketWindowActive.value = true; - scheduleImmediateRefresh(); -} - function handleVisibilityChange(): void { if (!document.hidden) { isTicketWindowActive.value = true; scheduleImmediateRefresh(); + void startRefreshPolling(); return; } isTicketWindowActive.value = false; @@ -444,36 +472,39 @@ onMounted(async () => { await safeLog("info", "TicketListView mounted"); try { isTicketWindowActive.value = await appWindow.isVisible(); - const onFocus = () => { + + const onWindowFocus = (): void => { isTicketWindowActive.value = true; scheduleImmediateRefresh(); - startRefreshPolling(); + void startRefreshPolling(); }; - const onBlur = () => { + const onWindowBlur = (): void => { isTicketWindowActive.value = false; stopRefreshPolling(); }; - window.addEventListener("focus", onFocus); - window.addEventListener("blur", onBlur); + window.addEventListener("focus", onWindowFocus); + window.addEventListener("blur", onWindowBlur); unlistenWindowFocusChanged = () => { - window.removeEventListener("focus", onFocus); - window.removeEventListener("blur", onBlur); + window.removeEventListener("focus", onWindowFocus); + window.removeEventListener("blur", onWindowBlur); }; - if (isTicketWindowActive.value) { - scheduleImmediateRefresh(); - startRefreshPolling(); - } const session = await getSession(); tableHeight.value = 720 - 220; - if (!session.winUid) { + if (!(await ticketPoolSessionReady())) { + await safeLog( + "info", + "票池: 未登录或未选窗,不自动刷新(登录、选窗并打开本窗口后再拉取)", + ); + } else if (!session.winUid) { await safeLog("warn", "票池页面启动时未获取到窗口 UID"); } - window.addEventListener("focus", handleWindowFocus); + document.addEventListener("visibilitychange", handleVisibilityChange); - if (isTicketWindowActive.value) { + + if (isTicketWindowActive.value && (await ticketPoolSessionReady())) { scheduleImmediateRefresh(); - startRefreshPolling(); + void startRefreshPolling(); } } catch (error) { await safeLog( @@ -494,7 +525,6 @@ onUnmounted(() => { if (unlistenWindowFocusChanged) { unlistenWindowFocusChanged(); } - window.removeEventListener("focus", handleWindowFocus); document.removeEventListener("visibilitychange", handleVisibilityChange); });