From 85225861d5f1cff1bee78506ef075c8528a0aecd Mon Sep 17 00:00:00 2001 From: cysamurai Date: Mon, 6 Jul 2026 15:26:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=91=BC=E5=8F=B7=E7=AB=AF?= =?UTF-8?q?=E9=83=A8=E5=88=86bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- call-client/src/host/dialog.ts | 118 +++++++-- call-client/src/types/rank.ts | 1 - call-client/src/types/transfer.ts | 11 +- call-client/src/utils/transfer.ts | 90 +++---- call-client/src/views/MainView.vue | 81 ++++--- call-client/src/views/TransferView.vue | 321 ++++++++++++------------- 需求修改记录.md | 12 +- 7 files changed, 368 insertions(+), 266 deletions(-) diff --git a/call-client/src/host/dialog.ts b/call-client/src/host/dialog.ts index 1be9b39..38a0b99 100644 --- a/call-client/src/host/dialog.ts +++ b/call-client/src/host/dialog.ts @@ -1,5 +1,6 @@ import { ask, message } from "@tauri-apps/api/dialog"; import { invoke } from "@tauri-apps/api/tauri"; +import { appWindow, WebviewWindow } from "@tauri-apps/api/window"; import { open } from "@tauri-apps/api/shell"; import type { AppLogPaths, @@ -7,16 +8,83 @@ import type { ShowErrorNativeOptions, } from "./types"; +/** + * 原生对话框在 Linux 上常由 zenity 等绘制,层级低于 alwaysOnTop 窗口。 + * 弹窗前临时取消各可见窗口置顶,关闭后再恢复。 + */ +async function suspendAlwaysOnTopForNativeDialog(): Promise<() => Promise> { + const labelsToRestore: string[] = []; + + async function trySuspendWindow(win: WebviewWindow): Promise { + try { + const [onTop, visible] = await Promise.all([ + win.isAlwaysOnTop(), + win.isVisible(), + ]); + if (onTop && visible) { + await win.setAlwaysOnTop(false); + labelsToRestore.push(win.label); + } + } catch { + // 忽略单窗失败 + } + } + + try { + const labels = await invoke("list_windows"); + await Promise.all( + labels.map(async (label) => { + const win = WebviewWindow.getByLabel(label); + if (win) { + await trySuspendWindow(win); + } + }), + ); + } catch { + await trySuspendWindow(appWindow); + } + + if (labelsToRestore.length === 0) { + await trySuspendWindow(appWindow); + } + + return async () => { + for (const label of labelsToRestore) { + try { + const win = WebviewWindow.getByLabel(label); + if (win) { + await win.setAlwaysOnTop(true); + } + } catch { + // 忽略恢复失败 + } + } + }; +} + +async function runWithNativeDialogAccessibility( + fn: () => Promise, +): Promise { + const restore = await suspendAlwaysOnTopForNativeDialog(); + try { + return await fn(); + } finally { + await restore(); + } +} + /** * 统一封装原生确认框。 */ export async function confirmNative(options: NativeConfirmOptions): Promise { try { - return await ask(options.message, { - title: options.title, - okLabel: options.okLabel, - cancelLabel: options.cancelLabel, - }); + return await runWithNativeDialogAccessibility(() => + ask(options.message, { + title: options.title, + okLabel: options.okLabel, + cancelLabel: options.cancelLabel, + }), + ); } catch (error) { throw new Error(`打开确认框失败: ${String(error)}`); } @@ -51,7 +119,9 @@ export async function showErrorNative( const logActions = options?.logActions ?? "none"; if (logActions === "none") { try { - await message(body, { title, type: "error" }); + await runWithNativeDialogAccessibility(() => + message(body, { title, type: "error" }), + ); } catch (error) { throw new Error(`打开错误提示框失败: ${String(error)}`); } @@ -63,7 +133,9 @@ export async function showErrorNative( paths = await fetchLogPaths(); } catch { try { - await message(body, { title, type: "error" }); + await runWithNativeDialogAccessibility(() => + message(body, { title, type: "error" }), + ); } catch (error) { throw new Error(`打开错误提示框失败: ${String(error)}`); } @@ -71,12 +143,14 @@ export async function showErrorNative( } try { - const openFile = await ask(body, { - title, - type: "error", - okLabel: "打开日志文件", - cancelLabel: "确定", - }); + const openFile = await runWithNativeDialogAccessibility(() => + ask(body, { + title, + type: "error", + okLabel: "打开日志文件", + cancelLabel: "确定", + }), + ); if (openFile) { await open(paths.logFile); } @@ -85,10 +159,12 @@ export async function showErrorNative( const fallback = truncateErrorTextForDialog( `${content}\n(无法打开日志文件:${String(error)})`, ); - await message(fallback, { - title, - type: "error", - }); + await runWithNativeDialogAccessibility(() => + message(fallback, { + title, + type: "error", + }), + ); } catch (inner) { throw new Error(`打开错误提示框失败: ${String(inner)}`); } @@ -113,7 +189,9 @@ export async function showInfoNative( title = "提示", ): Promise { try { - await message(content, { title, type: "info" }); + await runWithNativeDialogAccessibility(() => + message(content, { title, type: "info" }), + ); } catch (error) { throw new Error(`打开提示框失败: ${String(error)}`); } @@ -127,7 +205,9 @@ export async function showWarningNative( title = "提示", ): Promise { try { - await message(content, { title, type: "warning" }); + await runWithNativeDialogAccessibility(() => + message(content, { title, type: "warning" }), + ); } catch (error) { throw new Error(`打开警告提示框失败: ${String(error)}`); } diff --git a/call-client/src/types/rank.ts b/call-client/src/types/rank.ts index d15c420..fdcef5a 100644 --- a/call-client/src/types/rank.ts +++ b/call-client/src/types/rank.ts @@ -1,7 +1,6 @@ export interface IsRankData { hasRank: boolean; ticketUid?: number; - isEvaluated?: boolean; } export interface IsRankRequest { diff --git a/call-client/src/types/transfer.ts b/call-client/src/types/transfer.ts index 446d382..f075c51 100644 --- a/call-client/src/types/transfer.ts +++ b/call-client/src/types/transfer.ts @@ -1,5 +1,14 @@ +/** business-list 中业务可办理的窗口 */ +export interface TransferBusinessWin { + windowUid: number; + windowName: string; +} + export interface TransferBusiness { businessUid: number; businessName: string; - businessCode?: string; + /** 该业务可办理的窗口列表 */ + wins: TransferBusinessWin[]; + /** 展示用:`业务名(窗口1,窗口2)` */ + displayLabel: string; } diff --git a/call-client/src/utils/transfer.ts b/call-client/src/utils/transfer.ts index 32c3bb1..e6e0cfd 100644 --- a/call-client/src/utils/transfer.ts +++ b/call-client/src/utils/transfer.ts @@ -1,7 +1,6 @@ import type { TransferRequest } from "../types/action"; import type { SessionState } from "../host/types"; -import type { TransferBusiness } from "../types/transfer"; -import type { ServiceWindow, WindowResponse } from "../types/window"; +import type { TransferBusiness, TransferBusinessWin } from "../types/transfer"; function extractArray(raw: unknown): unknown[] { if (Array.isArray(raw)) { @@ -27,6 +26,39 @@ function extractArray(raw: unknown): unknown[] { return []; } +function normalizeWinItem(item: unknown): TransferBusinessWin | null { + if (!item || typeof item !== "object") { + return null; + } + const record = item as Record; + const windowUid = Number( + record.uid ?? record.windowUid ?? record.winUid ?? record.id ?? -1, + ); + if (!Number.isFinite(windowUid) || windowUid <= 0) { + return null; + } + const windowName = String( + record.name ?? record.windowName ?? record.winName ?? "", + ).trim(); + return { + windowUid, + windowName: windowName || `窗口 ${windowUid}`, + }; +} + +/** 生成业务展示文案:`综合办税(综合办税窗口1,综合办税窗口2)` */ +export function formatTransferBusinessDisplayLabel( + businessName: string, + wins: TransferBusinessWin[], +): string { + const name = businessName.trim() || "—"; + if (wins.length === 0) { + return name; + } + const winNames = wins.map((win) => win.windowName).join(","); + return `${name}(${winNames})`; +} + function normalizeBusinessItem(item: unknown): TransferBusiness | null { if (!item || typeof item !== "object") { return null; @@ -46,55 +78,37 @@ function normalizeBusinessItem(item: unknown): TransferBusiness | null { record.title ?? "", ).trim(); - const businessCode = String( - record.businessCode ?? record.bizCode ?? record.code ?? "", - ).trim(); + const resolvedName = businessName || `业务 ${businessUid}`; + const wins = (Array.isArray(record.wins) ? record.wins : []) + .map((win) => normalizeWinItem(win)) + .filter((win): win is TransferBusinessWin => win !== null); return { businessUid, - businessName: businessName || `业务 ${businessUid}`, - businessCode: businessCode || undefined, + businessName: resolvedName, + wins, + displayLabel: formatTransferBusinessDisplayLabel(resolvedName, wins), }; } -/** 解析 business-list 接口返回(兼容多种字段命名) */ +/** 解析 business-list 接口返回(兼容 `data: [{ uid, name, wins }]` 等格式) */ export function normalizeBusinessList(raw: unknown): TransferBusiness[] { return extractArray(raw) .map((item) => normalizeBusinessItem(item)) .filter((item): item is TransferBusiness => item !== null); } -/** 转移目标窗口列表(排除当前窗口) */ -export function normalizeTransferWindows( - raw: WindowResponse | unknown, - currentWindowUid: number, -): ServiceWindow[] { - const windows = Array.isArray((raw as WindowResponse)?.windows) - ? (raw as WindowResponse).windows - : []; - return windows.filter( - (win) => - Number.isFinite(win.windowUid) && - win.windowUid > 0 && - win.windowUid !== currentWindowUid, - ); -} - -export type TransferTarget = - | { kind: "window"; windowUid: number } - | { kind: "business"; business: TransferBusiness }; - -/** 组装转移接口请求体(窗口与业务二选一) */ +/** 组装转移接口请求体(仅按业务转移) */ export function buildTransferRequest( session: SessionState, ticketUid: number, - target: TransferTarget, + business: TransferBusiness, ): TransferRequest { - const base: TransferRequest = { + return { windowUid: Number(session.winUid ?? 0), empUid: Number(session.empUid ?? 0), ticketUid, targetWindowUid: null, - targetBusinessUid: null, + targetBusinessUid: business.businessUid, resumeToken: "", targetPosition: 0, rank: 0, @@ -110,16 +124,4 @@ export function buildTransferRequest( driver: "", clients: [], }; - if (target.kind === "window") { - return { - ...base, - targetWindowUid: target.windowUid, - targetBusinessUid: null, - }; - } - return { - ...base, - targetWindowUid: null, - targetBusinessUid: target.business.businessUid, - }; } diff --git a/call-client/src/views/MainView.vue b/call-client/src/views/MainView.vue index 5730770..e1c01aa 100644 --- a/call-client/src/views/MainView.vue +++ b/call-client/src/views/MainView.vue @@ -101,6 +101,8 @@ const evaluatingPrefixText = ref("评价中"); let evaluatingCountdownTimer: ReturnType | null = null; let isRankPollingTimer: ReturnType | null = null; let isRankPollingBusy = false; +/** 进入评价态后是否已观测到 hasRank=false(避免完成瞬间 hasRank 已为 true 误结束) */ +let isRankSeenFalseSinceEvaluating = false; let queueCountPollingTimer: ReturnType | null = null; /** 避免 windowUid 未就绪时每 15s 重复打同一条跳过日志 */ let lastQueueCountSkipInvalidWinLogAt = 0; @@ -130,11 +132,6 @@ const EVALUATING_COUNTDOWN_SEC = 15; const DEFAULT_AUTO_CALL_WAIT_SECONDS = 5; const DEFAULT_AUTO_START_WAIT_SECONDS = 50; const pauseReasonOptions = ["午休", "休息一下", "整理资料", "其他"]; -/** - * 主窗口是否处于前台焦点(用于评价 isRank 轮询;失焦后暂停以减轻请求)。 - * 自动叫号不依赖该标志,仅在看得到主窗口未最小化时倒计时。 - */ -const isMainWindowActive = ref(true); /** 主窗口是否已最小化(自动叫号仅在非最小化时运行) */ const isMainWindowMinimized = ref(false); /** 主窗口是否已显示(登录前不创建主窗口;隐藏态不轮询等候人数) */ @@ -945,6 +942,28 @@ function clearIsRankPolling(): void { } } +function resetIsRankEvaluatingGate(): void { + isRankSeenFalseSinceEvaluating = false; +} + +/** 手动发起评价后可直接接受 hasRank=true,无需先看到 false */ +function armIsRankEvaluatingGate(): void { + isRankSeenFalseSinceEvaluating = true; +} + +async function finishEvaluatingToIdle(reason: string): Promise { + clearIsRankPolling(); + clearEvaluatingCountdown(); + resetIsRankEvaluatingGate(); + callStatus.value = "idle"; + callBtnText.value = "呼叫"; + callingTkt.value = -1; + callingTicketNoLabel.value = ""; + message.value = "欢迎使用紫云呼叫终端"; + await clearActiveTicketInSession(); + await log("info", reason); +} + /** * 清理等待人数轮询。 */ @@ -993,6 +1012,7 @@ function startEvaluatingCountdown(prefixText: string): void { clearEvaluatingCountdown(); // 倒计时结束:停止 isRank 轮询并回到待机(不依赖接口再返回已评价)。 clearIsRankPolling(); + resetIsRankEvaluatingGate(); if (callStatus.value === "evaluating") { callStatus.value = "idle"; callBtnText.value = "呼叫"; @@ -1027,19 +1047,18 @@ async function pollIsRankOnce(): Promise { } const res = await api.action.isRank({ ticketUid }); - const ranked = res.hasRank === true || res.isEvaluated === true; - if (ranked) { - clearIsRankPolling(); - clearEvaluatingCountdown(); - callStatus.value = "idle"; - callBtnText.value = "呼叫"; - callingTkt.value = -1; - callingTicketNoLabel.value = ""; - message.value = "欢迎使用紫云呼叫终端"; - await clearActiveTicketInSession(); - await log("info", "isRank: 评价完成,进入待机"); + if (res.hasRank !== true) { + isRankSeenFalseSinceEvaluating = true; + return; + } + if (!isRankSeenFalseSinceEvaluating) { + await log( + "info", + `isRank: hasRank=true 但评价态初始即为已评价,忽略并继续倒计时 ticketUid=${ticketUid}`, + ); return; } + await finishEvaluatingToIdle("isRank: 评价完成,进入待机"); } catch (error) { await logErr("查询 isRank 失败", error); } finally { @@ -1170,7 +1189,7 @@ function startQueueCountPolling(): void { watch( [callStatus, sessionReadyForQueueCount, isMainWindowVisible], ([status, ready, visible]) => { - if (status === "evaluating" && isMainWindowActive.value) { + if (status === "evaluating") { startIsRankPolling(); } else { clearIsRankPolling(); @@ -1191,16 +1210,6 @@ watch( { immediate: true }, ); -watch(isMainWindowActive, (active) => { - if (callStatus.value === "evaluating") { - if (active) { - startIsRankPolling(); - } else { - clearIsRankPolling(); - } - } -}); - watch(isMainWindowMinimized, () => { syncAutoCallCountdown(); syncAutoStartCountdown(); @@ -1220,6 +1229,11 @@ watch(isMainWindowVisible, (visible) => { }); watch(callStatus, (newStatus, oldStatus) => { + if (newStatus === "evaluating" && oldStatus !== "evaluating") { + resetIsRankEvaluatingGate(); + } else if (newStatus !== "evaluating" && oldStatus === "evaluating") { + resetIsRankEvaluatingGate(); + } handleAutoCallOnCallStatusChange(newStatus, oldStatus); handleAutoStartOnCallStatusChange(newStatus, oldStatus); }); @@ -1554,6 +1568,7 @@ async function invokeEvaluateApi(): Promise { if (res.success) { callStatus.value = "evaluating"; callBtnText.value = "呼叫"; + armIsRankEvaluatingGate(); startEvaluatingCountdown("评价中"); } }); @@ -1720,7 +1735,12 @@ async function openTransferDialog(): Promise { await minimizeWindow(); await refreshMainWindowChromeState(); await openTransferWindow(); - updateLog(`票号转移窗口已打开: ticketUid=${ticketUid}`); + const ticketNo = callingTicketNoLabel.value.trim(); + updateLog( + ticketNo + ? `票号转移窗口已打开: ${ticketNo}` + : "票号转移窗口已打开", + ); } catch (error) { await logErr("打开票号转移窗口失败", error); } @@ -1935,16 +1955,11 @@ onMounted(async () => { await refreshMainWindowVisibleState(); } // 部分环境刚 show 时 isVisible 仍为 false,不强行置 false,否则等候人数轮询会被立刻关掉。 - if (isMainWindowVisible.value) { - isMainWindowActive.value = true; - } - isMainWindowActive.value = await appWindow.isFocused(); await refreshMainWindowChromeState(); const unlistenFns: Array<() => void> = []; unlistenFns.push( await appWindow.onFocusChanged(({ payload: focused }) => { - isMainWindowActive.value = focused; if (focused) { void refreshMainSessionStateFromDisk(); } diff --git a/call-client/src/views/TransferView.vue b/call-client/src/views/TransferView.vue index 92a3d63..7aeeb26 100644 --- a/call-client/src/views/TransferView.vue +++ b/call-client/src/views/TransferView.vue @@ -26,54 +26,40 @@

{{ loadError }}

-

目标窗口与目标业务只能二选一

-
-
-

目标窗口

- +

请选择要转移到的目标业务

+
+
- -
-

目标业务

- - - {{ biz.businessName }} - - {{ biz.businessCode }} +
+
+ + {{ win.windowName }} - - - - -
+
+

暂无可办理窗口

+ + + +