diff --git a/call-client/src/api/index.ts b/call-client/src/api/index.ts index 6cbc939..ce7e620 100644 --- a/call-client/src/api/index.ts +++ b/call-client/src/api/index.ts @@ -32,6 +32,8 @@ export interface CallerInitData { export const api = { user: { login: (data: UserRequest) => http.post("/auth/login", data), + logout: () => + http.post<{ success?: boolean; message?: string }>("/auth/logout", {}), }, window: { diff --git a/call-client/src/host/window.ts b/call-client/src/host/window.ts index 2030116..711728f 100644 --- a/call-client/src/host/window.ts +++ b/call-client/src/host/window.ts @@ -1,5 +1,7 @@ import { invoke } from "@tauri-apps/api/tauri"; import { appWindow } from "@tauri-apps/api/window"; +import { postLogoutBeforeQuit } from "../utils/service"; +import { clearSession } from "./session"; /** * 最小化当前窗口。 @@ -17,9 +19,18 @@ export async function minimizeWindow(): Promise { */ export async function closeWindow(): Promise { try { - await invoke("quit_app"); - } catch (error) { - throw new Error(`关闭窗口失败: ${String(error)}`); + await postLogoutBeforeQuit(); + } finally { + try { + await clearSession(); + } catch { + // 忽略 + } + try { + await invoke("quit_app"); + } catch (error) { + throw new Error(`关闭窗口失败: ${String(error)}`); + } } } @@ -116,8 +127,17 @@ export async function openTaxerInfoWindow(): Promise { */ export async function quitApplication(): Promise { try { - await invoke("quit_app"); - } catch (error) { - throw new Error(`退出应用失败: ${String(error)}`); + await postLogoutBeforeQuit(); + } finally { + try { + await clearSession(); + } catch { + // 忽略 + } + try { + await invoke("quit_app"); + } catch (error) { + throw new Error(`退出应用失败: ${String(error)}`); + } } } diff --git a/call-client/src/utils/service.ts b/call-client/src/utils/service.ts index 9249952..46aea4d 100644 --- a/call-client/src/utils/service.ts +++ b/call-client/src/utils/service.ts @@ -6,6 +6,7 @@ import axios, { } from "axios"; import { Body, fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http"; import { showErrorNative } from "../host/dialog"; +import { log } from "../host/logger"; import { getSession, setSession } from "../host/session"; import type { SessionState } from "../host/types"; @@ -13,6 +14,7 @@ export const API_QUEUE_CALLER_PATH = "/api/queue/caller"; const DEFAULT_API_PORT = 8845; const AUTH_LOGIN_PATH = "/auth/login"; const AUTH_REFRESH_PATH = "/auth/refresh"; +const AUTH_LOGOUT_PATH = "/auth/logout"; type ApiEnvelope = { code: number; @@ -29,6 +31,8 @@ type RefreshTokenResponse = { type RetryableAxiosRequestConfig = AxiosRequestConfig & { _retry?: boolean; _skipAuthRefresh?: boolean; + /** 不弹原生错误框(用于退出前 logout 等) */ + _suppressErrorUi?: boolean; }; let refreshTokenPromise: Promise | null = null; @@ -294,23 +298,29 @@ instance.interceptors.response.use( async (response) => { const { data } = response; const payload = data as ApiEnvelope; + const requestConfig = response.config as RetryableAxiosRequestConfig; if (!isApiSuccessCode(payload?.code)) { - const requestConfig = response.config as RetryableAxiosRequestConfig; if (payload?.code === 401 && canRefreshAndRetry(requestConfig)) { return retryWithRefreshedToken(requestConfig); } const message = getApiMessage(payload); - await showErrorNative(message); + if (!requestConfig._suppressErrorUi) { + await showErrorNative(message); + } throw new Error(message); } return payload.data; }, async (error) => { + const requestConfig = (error.config ?? {}) as RetryableAxiosRequestConfig; + if (requestConfig._suppressErrorUi) { + throw error; + } + if (error.response) { - const requestConfig = error.config as RetryableAxiosRequestConfig; if (error.response.status === 401 && canRefreshAndRetry(requestConfig)) { try { return await retryWithRefreshedToken(requestConfig); @@ -355,6 +365,45 @@ instance.interceptors.response.use( }, ); +async function logQuitEvent(level: "info" | "warn" | "error", message: string): Promise { + try { + await log(level, message); + } catch { + // 退出路径不因写日志失败而阻塞 + } +} + +/** + * 应用退出前调用 `POST .../auth/logout`;失败静默忽略,不弹错误框。 + */ +export async function postLogoutBeforeQuit(): Promise { + const baseURL = instance.defaults.baseURL; + if (!baseURL || typeof baseURL !== "string" || baseURL.trim() === "") { + await logQuitEvent("warn", "退出前 logout 跳过: 未配置 HTTP baseURL"); + return; + } + + const logoutUrl = `${String(baseURL).replace(/\/$/, "")}${AUTH_LOGOUT_PATH}`; + await logQuitEvent("info", `退出前调用 logout: url=${logoutUrl}`); + + try { + await instance.post( + AUTH_LOGOUT_PATH, + {}, + { + timeout: 5000, + _skipAuthRefresh: true, + _suppressErrorUi: true, + headers: { "Content-Type": "application/json" }, + } as RetryableAxiosRequestConfig, + ); + await logQuitEvent("info", "退出前 logout 调用成功"); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + await logQuitEvent("warn", `退出前 logout 调用失败(仍继续退出): ${detail}`); + } +} + export const http = { /** * 发送原生 axios 请求。 diff --git a/call-client/src/views/LoginView.vue b/call-client/src/views/LoginView.vue index 95187b7..94776cd 100644 --- a/call-client/src/views/LoginView.vue +++ b/call-client/src/views/LoginView.vue @@ -4,7 +4,7 @@ import { ElForm, ElMessage, ElMessageBox } from "element-plus"; import { getVersion } from "@tauri-apps/api/app"; import { writeText } from "@tauri-apps/api/clipboard"; import { invoke } from "@tauri-apps/api/tauri"; -import { computed, onMounted, onUnmounted, ref } from "vue"; +import { computed, onMounted, onUnmounted, reactive, ref } from "vue"; import { api } from "../api"; import { getAllConfig, mergeConfig } from "../host/config"; import { log } from "../host/logger"; @@ -17,8 +17,11 @@ import { startWindowDragging, } from "../host/window"; -const username = ref("admin"); -const password = ref("admin"); +/** el-form 需要稳定引用,勿使用每次渲染新建的 `{ username, password }` 对象 */ +const loginForm = reactive({ + username: "admin", + password: "admin", +}); const loginType = ref("NSFW"); const isLoading = ref(false); const formRef = ref>(); @@ -58,7 +61,7 @@ const rules = { }; const isFormValid = computed( - () => username.value.trim() !== "" && password.value !== "", + () => loginForm.username.trim() !== "" && loginForm.password.trim() !== "", ); const isWindowSelected = computed(() => selectedWin.value !== ""); @@ -78,6 +81,13 @@ function showMessage( }); } +/** + * 从窗口选择返回账号登录步骤。 + */ +function backToUserForm(): void { + isLoginSuccessed.value = false; +} + /** * 处理账号登录。 */ @@ -87,100 +97,113 @@ async function handleLogin(): Promise { return; } - await formRef.value?.validate(async (valid) => { - if (!valid) { - return; - } - - isLoading.value = true; + const form = formRef.value; + if (!form) { + return; + } - try { - const loginRes = await api.user.login({ - loginMode: loginType.value, - clientType: "CALLER", - username: username.value, - password: password.value, - hallRegNum: "", - }); - - if (!loginRes?.queueToken) { - await log( - "warn", - `登录失败: 接口未返回有效 queueToken, user=${username.value}`, - ); - return; - } + isLoading.value = true; + try { + await form.validate(); + } catch { + isLoading.value = false; + return; + } - sessionState = { - empUid: loginRes.operatorProfile.empUid, - winUid: 0, - queueToken: loginRes.queueToken, - refreshToken: loginRes.refreshToken, - }; + try { + const loginRes = await api.user.login({ + loginMode: loginType.value, + clientType: "CALLER", + username: loginForm.username, + password: loginForm.password, + hallRegNum: "", + }); - await setSession(sessionState); + if (!loginRes?.queueToken) { + await log( + "warn", + `登录失败: 接口未返回有效 queueToken, user=${loginForm.username}`, + ); + return; + } - const winList = await api.window.list(); - options.value = winList.windows.map((win) => ({ - label: win.windowName, - value: win.windowUid.toString(), - })); - - if ( - cachedWinKey.value && - options.value.some((item) => item.value === cachedWinKey.value) - ) { - selectedWin.value = cachedWinKey.value; - } + sessionState = { + empUid: loginRes.operatorProfile.empUid, + winUid: 0, + queueToken: loginRes.queueToken, + refreshToken: loginRes.refreshToken, + }; - const initWindowUid = - selectedWin.value.trim() !== "" - ? Number.parseInt(selectedWin.value, 10) - : Number(sessionState.winUid ?? 0); - - const initRes = await api.action.init({ - empUid: Number(sessionState.empUid ?? -1), - windowUid: Number.isFinite(initWindowUid) ? initWindowUid : 0, - }); - - const initSuccess = - ((initRes as { data?: { success?: boolean } }).data?.success ?? - (initRes as { success?: boolean }).success) === true; - - if (!initSuccess) { - await log("warn", "初始化接口调用完成,但返回 success=false"); - } else { - sessionState.empUid = Number(sessionState.empUid ?? loginRes.operatorProfile.empUid); - sessionState.winUid = Number.isFinite(initWindowUid) ? initWindowUid : Number(sessionState.winUid ?? 0); - await setSession(sessionState); - } + await setSession(sessionState); - isLoginSuccessed.value = true; - await log( - "info", - `登录成功: user=${username.value}, empUid=${sessionState.empUid}`, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - await log("error", `登录失败: user=${username.value}, ${message}`); - } finally { - isLoading.value = false; + const winList = await api.window.list(); + options.value = winList.windows.map((win) => ({ + label: win.windowName, + value: win.windowUid.toString(), + })); + + if ( + cachedWinKey.value && + options.value.some((item) => item.value === cachedWinKey.value) + ) { + selectedWin.value = cachedWinKey.value; } - }); + + isLoginSuccessed.value = true; + await log( + "info", + `登录成功: user=${loginForm.username}, empUid=${sessionState.empUid}`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await log("error", `登录失败: user=${loginForm.username}, ${message}`); + } finally { + isLoading.value = false; + } } /** * 处理窗口登录确认。 */ async function handleWindowLogin(): Promise { + isLoading.value = true; try { const winUid = Number.parseInt(selectedWin.value, 10); + if (!Number.isFinite(winUid) || winUid <= 0) { + showMessage("warning", "请选择有效窗口"); + return; + } + const selected = options.value.find( (item) => item.value === selectedWin.value, ); + const initRes = await api.action.init({ + empUid: Number(sessionState.empUid ?? -1), + windowUid: winUid, + }); + + const initSuccess = + ((initRes as { data?: { success?: boolean } }).data?.success ?? + (initRes as { success?: boolean }).success) === true; + + if (!initSuccess) { + const initData = (initRes as { data?: { message?: string } }).data; + const msg = + (typeof initData?.message === "string" && initData.message.trim() !== "" + ? initData.message + : null) ?? + (typeof (initRes as { message?: string }).message === "string" + ? (initRes as { message?: string }).message + : null) ?? + "窗口初始化失败,请重试或更换窗口"; + showMessage("error", String(msg)); + await log("warn", `call-terminal/init 未成功: windowUid=${winUid}`); + return; + } + await mergeConfig({ - last_username: username.value.trim(), + last_username: loginForm.username.trim(), selected_win_key: selectedWin.value, selected_win_value: selected?.label ?? "", selected_win_uid: winUid, @@ -226,6 +249,8 @@ async function handleWindowLogin(): Promise { const message = error instanceof Error ? error.message : String(error); showMessage("error", message || "打开主窗口失败"); await log("error", `打开主窗口失败: ${message}`); + } finally { + isLoading.value = false; } } @@ -380,7 +405,7 @@ onMounted(async () => { const config = await getAllConfig(); if (typeof config.last_username === "string" && config.last_username.trim()) { - username.value = config.last_username; + loginForm.username = config.last_username; } if ( typeof config.selected_win_key === "string" && @@ -465,7 +490,7 @@ onUnmounted(() => {