修正初始化错误

master
cysamurai 1 month ago
parent 41fc488225
commit 57b32c54aa

@ -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<SessionState, String> {
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<PathBuf, String> {
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<SessionState, String> {
let mut session = state
.session
.lock()
.map_err(|error| format!("写入会话锁失败: {error}"))?;
*session = payload.clone();
Ok(payload)
pub fn session_get(app: AppHandle) -> Result<String, String> {
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<SessionState, String> {
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())
}

@ -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<i64>,
pub win_uid: Option<i64>,
pub queue_token: Option<String>,
}
/// 应用级状态(屏幕同步等)。运行时会话已改为磁盘文件,见 `commands/session.rs`。
pub struct AppState {
pub session: Mutex<SessionState>,
pub screen_sync: Mutex<ScreenSyncState>,
}
@ -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()),
}
}

@ -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<SessionState>;
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<SessionState> {
try {
const value = window.localStorage.getItem(SESSION_KEY);
if (!value) {
return { ...DEFAULT_SESSION };
}
return normalizeSession(JSON.parse(value));
const raw = await invoke<string>("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<SessionState> {
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<SessionState> {
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 };
}

@ -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<string, string>;
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<string, unknown> & { 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<string | null> {
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<any> {
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);
}

@ -150,7 +150,8 @@ async function handleLogin(): Promise<void> {
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<void> {
// 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),

@ -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<SessionState>({
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<typeof setInterval> | null = null;
let isRankPollingTimer: ReturnType<typeof setInterval> | null = null;
let isRankPollingBusy = false;
let queueCountPollingTimer: ReturnType<typeof setInterval> | 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<void> {
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<void> {
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<void> {
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<void> {
* 开启等待人数轮询
*/
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();
}

@ -340,6 +340,17 @@ async function handleReEvaluate(row: Tkt): Promise<void> {
await log("info", `票池评价按钮点击: ticketUid=${row.ticketUid}, tktNum=${row.tktNum}`);
}
/**
* 与主界面一致账号登录有 token 且已选窗后才自动拉取票池 / 开轮询
*/
async function ticketPoolSessionReady(): Promise<boolean> {
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<void> {
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);
});
</script>

Loading…
Cancel
Save