修复呼号端部分bug

master
cysamurai 1 month ago
parent 603e07977f
commit 85225861d5

@ -1,5 +1,6 @@
import { ask, message } from "@tauri-apps/api/dialog"; import { ask, message } from "@tauri-apps/api/dialog";
import { invoke } from "@tauri-apps/api/tauri"; import { invoke } from "@tauri-apps/api/tauri";
import { appWindow, WebviewWindow } from "@tauri-apps/api/window";
import { open } from "@tauri-apps/api/shell"; import { open } from "@tauri-apps/api/shell";
import type { import type {
AppLogPaths, AppLogPaths,
@ -7,16 +8,83 @@ import type {
ShowErrorNativeOptions, ShowErrorNativeOptions,
} from "./types"; } from "./types";
/**
* Linux zenity alwaysOnTop
*
*/
async function suspendAlwaysOnTopForNativeDialog(): Promise<() => Promise<void>> {
const labelsToRestore: string[] = [];
async function trySuspendWindow(win: WebviewWindow): Promise<void> {
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<string[]>("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<T>(
fn: () => Promise<T>,
): Promise<T> {
const restore = await suspendAlwaysOnTopForNativeDialog();
try {
return await fn();
} finally {
await restore();
}
}
/** /**
* *
*/ */
export async function confirmNative(options: NativeConfirmOptions): Promise<boolean> { export async function confirmNative(options: NativeConfirmOptions): Promise<boolean> {
try { try {
return await ask(options.message, { return await runWithNativeDialogAccessibility(() =>
ask(options.message, {
title: options.title, title: options.title,
okLabel: options.okLabel, okLabel: options.okLabel,
cancelLabel: options.cancelLabel, cancelLabel: options.cancelLabel,
}); }),
);
} catch (error) { } catch (error) {
throw new Error(`打开确认框失败: ${String(error)}`); throw new Error(`打开确认框失败: ${String(error)}`);
} }
@ -51,7 +119,9 @@ export async function showErrorNative(
const logActions = options?.logActions ?? "none"; const logActions = options?.logActions ?? "none";
if (logActions === "none") { if (logActions === "none") {
try { try {
await message(body, { title, type: "error" }); await runWithNativeDialogAccessibility(() =>
message(body, { title, type: "error" }),
);
} catch (error) { } catch (error) {
throw new Error(`打开错误提示框失败: ${String(error)}`); throw new Error(`打开错误提示框失败: ${String(error)}`);
} }
@ -63,7 +133,9 @@ export async function showErrorNative(
paths = await fetchLogPaths(); paths = await fetchLogPaths();
} catch { } catch {
try { try {
await message(body, { title, type: "error" }); await runWithNativeDialogAccessibility(() =>
message(body, { title, type: "error" }),
);
} catch (error) { } catch (error) {
throw new Error(`打开错误提示框失败: ${String(error)}`); throw new Error(`打开错误提示框失败: ${String(error)}`);
} }
@ -71,12 +143,14 @@ export async function showErrorNative(
} }
try { try {
const openFile = await ask(body, { const openFile = await runWithNativeDialogAccessibility(() =>
ask(body, {
title, title,
type: "error", type: "error",
okLabel: "打开日志文件", okLabel: "打开日志文件",
cancelLabel: "确定", cancelLabel: "确定",
}); }),
);
if (openFile) { if (openFile) {
await open(paths.logFile); await open(paths.logFile);
} }
@ -85,10 +159,12 @@ export async function showErrorNative(
const fallback = truncateErrorTextForDialog( const fallback = truncateErrorTextForDialog(
`${content}\n无法打开日志文件${String(error)}`, `${content}\n无法打开日志文件${String(error)}`,
); );
await message(fallback, { await runWithNativeDialogAccessibility(() =>
message(fallback, {
title, title,
type: "error", type: "error",
}); }),
);
} catch (inner) { } catch (inner) {
throw new Error(`打开错误提示框失败: ${String(inner)}`); throw new Error(`打开错误提示框失败: ${String(inner)}`);
} }
@ -113,7 +189,9 @@ export async function showInfoNative(
title = "提示", title = "提示",
): Promise<void> { ): Promise<void> {
try { try {
await message(content, { title, type: "info" }); await runWithNativeDialogAccessibility(() =>
message(content, { title, type: "info" }),
);
} catch (error) { } catch (error) {
throw new Error(`打开提示框失败: ${String(error)}`); throw new Error(`打开提示框失败: ${String(error)}`);
} }
@ -127,7 +205,9 @@ export async function showWarningNative(
title = "提示", title = "提示",
): Promise<void> { ): Promise<void> {
try { try {
await message(content, { title, type: "warning" }); await runWithNativeDialogAccessibility(() =>
message(content, { title, type: "warning" }),
);
} catch (error) { } catch (error) {
throw new Error(`打开警告提示框失败: ${String(error)}`); throw new Error(`打开警告提示框失败: ${String(error)}`);
} }

@ -1,7 +1,6 @@
export interface IsRankData { export interface IsRankData {
hasRank: boolean; hasRank: boolean;
ticketUid?: number; ticketUid?: number;
isEvaluated?: boolean;
} }
export interface IsRankRequest { export interface IsRankRequest {

@ -1,5 +1,14 @@
/** business-list 中业务可办理的窗口 */
export interface TransferBusinessWin {
windowUid: number;
windowName: string;
}
export interface TransferBusiness { export interface TransferBusiness {
businessUid: number; businessUid: number;
businessName: string; businessName: string;
businessCode?: string; /** 该业务可办理的窗口列表 */
wins: TransferBusinessWin[];
/** 展示用:`业务名(窗口1,窗口2)` */
displayLabel: string;
} }

@ -1,7 +1,6 @@
import type { TransferRequest } from "../types/action"; import type { TransferRequest } from "../types/action";
import type { SessionState } from "../host/types"; import type { SessionState } from "../host/types";
import type { TransferBusiness } from "../types/transfer"; import type { TransferBusiness, TransferBusinessWin } from "../types/transfer";
import type { ServiceWindow, WindowResponse } from "../types/window";
function extractArray(raw: unknown): unknown[] { function extractArray(raw: unknown): unknown[] {
if (Array.isArray(raw)) { if (Array.isArray(raw)) {
@ -27,6 +26,39 @@ function extractArray(raw: unknown): unknown[] {
return []; return [];
} }
function normalizeWinItem(item: unknown): TransferBusinessWin | null {
if (!item || typeof item !== "object") {
return null;
}
const record = item as Record<string, unknown>;
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 { function normalizeBusinessItem(item: unknown): TransferBusiness | null {
if (!item || typeof item !== "object") { if (!item || typeof item !== "object") {
return null; return null;
@ -46,55 +78,37 @@ function normalizeBusinessItem(item: unknown): TransferBusiness | null {
record.title ?? record.title ??
"", "",
).trim(); ).trim();
const businessCode = String( const resolvedName = businessName || `业务 ${businessUid}`;
record.businessCode ?? record.bizCode ?? record.code ?? "", const wins = (Array.isArray(record.wins) ? record.wins : [])
).trim(); .map((win) => normalizeWinItem(win))
.filter((win): win is TransferBusinessWin => win !== null);
return { return {
businessUid, businessUid,
businessName: businessName || `业务 ${businessUid}`, businessName: resolvedName,
businessCode: businessCode || undefined, wins,
displayLabel: formatTransferBusinessDisplayLabel(resolvedName, wins),
}; };
} }
/** 解析 business-list 接口返回(兼容多种字段命名 */ /** 解析 business-list 接口返回(兼容 `data: [{ uid, name, wins }]` 等格式 */
export function normalizeBusinessList(raw: unknown): TransferBusiness[] { export function normalizeBusinessList(raw: unknown): TransferBusiness[] {
return extractArray(raw) return extractArray(raw)
.map((item) => normalizeBusinessItem(item)) .map((item) => normalizeBusinessItem(item))
.filter((item): item is TransferBusiness => item !== null); .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( export function buildTransferRequest(
session: SessionState, session: SessionState,
ticketUid: number, ticketUid: number,
target: TransferTarget, business: TransferBusiness,
): TransferRequest { ): TransferRequest {
const base: TransferRequest = { return {
windowUid: Number(session.winUid ?? 0), windowUid: Number(session.winUid ?? 0),
empUid: Number(session.empUid ?? 0), empUid: Number(session.empUid ?? 0),
ticketUid, ticketUid,
targetWindowUid: null, targetWindowUid: null,
targetBusinessUid: null, targetBusinessUid: business.businessUid,
resumeToken: "", resumeToken: "",
targetPosition: 0, targetPosition: 0,
rank: 0, rank: 0,
@ -110,16 +124,4 @@ export function buildTransferRequest(
driver: "", driver: "",
clients: [], clients: [],
}; };
if (target.kind === "window") {
return {
...base,
targetWindowUid: target.windowUid,
targetBusinessUid: null,
};
}
return {
...base,
targetWindowUid: null,
targetBusinessUid: target.business.businessUid,
};
} }

@ -101,6 +101,8 @@ const evaluatingPrefixText = ref("评价中");
let evaluatingCountdownTimer: ReturnType<typeof setInterval> | null = null; let evaluatingCountdownTimer: ReturnType<typeof setInterval> | null = null;
let isRankPollingTimer: ReturnType<typeof setInterval> | null = null; let isRankPollingTimer: ReturnType<typeof setInterval> | null = null;
let isRankPollingBusy = false; let isRankPollingBusy = false;
/** 进入评价态后是否已观测到 hasRank=false避免完成瞬间 hasRank 已为 true 误结束) */
let isRankSeenFalseSinceEvaluating = false;
let queueCountPollingTimer: ReturnType<typeof setInterval> | null = null; let queueCountPollingTimer: ReturnType<typeof setInterval> | null = null;
/** 避免 windowUid 未就绪时每 15s 重复打同一条跳过日志 */ /** 避免 windowUid 未就绪时每 15s 重复打同一条跳过日志 */
let lastQueueCountSkipInvalidWinLogAt = 0; let lastQueueCountSkipInvalidWinLogAt = 0;
@ -130,11 +132,6 @@ const EVALUATING_COUNTDOWN_SEC = 15;
const DEFAULT_AUTO_CALL_WAIT_SECONDS = 5; const DEFAULT_AUTO_CALL_WAIT_SECONDS = 5;
const DEFAULT_AUTO_START_WAIT_SECONDS = 50; const DEFAULT_AUTO_START_WAIT_SECONDS = 50;
const pauseReasonOptions = ["午休", "休息一下", "整理资料", "其他"]; const pauseReasonOptions = ["午休", "休息一下", "整理资料", "其他"];
/**
* 主窗口是否处于前台焦点用于评价 isRank 轮询失焦后暂停以减轻请求
* 自动叫号不依赖该标志仅在看得到主窗口未最小化时倒计时
*/
const isMainWindowActive = ref(true);
/** 主窗口是否已最小化(自动叫号仅在非最小化时运行) */ /** 主窗口是否已最小化(自动叫号仅在非最小化时运行) */
const isMainWindowMinimized = ref(false); 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<void> {
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(); clearEvaluatingCountdown();
// isRank // isRank
clearIsRankPolling(); clearIsRankPolling();
resetIsRankEvaluatingGate();
if (callStatus.value === "evaluating") { if (callStatus.value === "evaluating") {
callStatus.value = "idle"; callStatus.value = "idle";
callBtnText.value = "呼叫"; callBtnText.value = "呼叫";
@ -1027,19 +1047,18 @@ async function pollIsRankOnce(): Promise<void> {
} }
const res = await api.action.isRank({ ticketUid }); const res = await api.action.isRank({ ticketUid });
const ranked = res.hasRank === true || res.isEvaluated === true; if (res.hasRank !== true) {
if (ranked) { isRankSeenFalseSinceEvaluating = true;
clearIsRankPolling(); return;
clearEvaluatingCountdown(); }
callStatus.value = "idle"; if (!isRankSeenFalseSinceEvaluating) {
callBtnText.value = "呼叫"; await log(
callingTkt.value = -1; "info",
callingTicketNoLabel.value = ""; `isRank: hasRank=true 但评价态初始即为已评价,忽略并继续倒计时 ticketUid=${ticketUid}`,
message.value = "欢迎使用紫云呼叫终端"; );
await clearActiveTicketInSession();
await log("info", "isRank: 评价完成,进入待机");
return; return;
} }
await finishEvaluatingToIdle("isRank: 评价完成,进入待机");
} catch (error) { } catch (error) {
await logErr("查询 isRank 失败", error); await logErr("查询 isRank 失败", error);
} finally { } finally {
@ -1170,7 +1189,7 @@ function startQueueCountPolling(): void {
watch( watch(
[callStatus, sessionReadyForQueueCount, isMainWindowVisible], [callStatus, sessionReadyForQueueCount, isMainWindowVisible],
([status, ready, visible]) => { ([status, ready, visible]) => {
if (status === "evaluating" && isMainWindowActive.value) { if (status === "evaluating") {
startIsRankPolling(); startIsRankPolling();
} else { } else {
clearIsRankPolling(); clearIsRankPolling();
@ -1191,16 +1210,6 @@ watch(
{ immediate: true }, { immediate: true },
); );
watch(isMainWindowActive, (active) => {
if (callStatus.value === "evaluating") {
if (active) {
startIsRankPolling();
} else {
clearIsRankPolling();
}
}
});
watch(isMainWindowMinimized, () => { watch(isMainWindowMinimized, () => {
syncAutoCallCountdown(); syncAutoCallCountdown();
syncAutoStartCountdown(); syncAutoStartCountdown();
@ -1220,6 +1229,11 @@ watch(isMainWindowVisible, (visible) => {
}); });
watch(callStatus, (newStatus, oldStatus) => { watch(callStatus, (newStatus, oldStatus) => {
if (newStatus === "evaluating" && oldStatus !== "evaluating") {
resetIsRankEvaluatingGate();
} else if (newStatus !== "evaluating" && oldStatus === "evaluating") {
resetIsRankEvaluatingGate();
}
handleAutoCallOnCallStatusChange(newStatus, oldStatus); handleAutoCallOnCallStatusChange(newStatus, oldStatus);
handleAutoStartOnCallStatusChange(newStatus, oldStatus); handleAutoStartOnCallStatusChange(newStatus, oldStatus);
}); });
@ -1554,6 +1568,7 @@ async function invokeEvaluateApi(): Promise<void> {
if (res.success) { if (res.success) {
callStatus.value = "evaluating"; callStatus.value = "evaluating";
callBtnText.value = "呼叫"; callBtnText.value = "呼叫";
armIsRankEvaluatingGate();
startEvaluatingCountdown("评价中"); startEvaluatingCountdown("评价中");
} }
}); });
@ -1720,7 +1735,12 @@ async function openTransferDialog(): Promise<void> {
await minimizeWindow(); await minimizeWindow();
await refreshMainWindowChromeState(); await refreshMainWindowChromeState();
await openTransferWindow(); await openTransferWindow();
updateLog(`票号转移窗口已打开: ticketUid=${ticketUid}`); const ticketNo = callingTicketNoLabel.value.trim();
updateLog(
ticketNo
? `票号转移窗口已打开: ${ticketNo}`
: "票号转移窗口已打开",
);
} catch (error) { } catch (error) {
await logErr("打开票号转移窗口失败", error); await logErr("打开票号转移窗口失败", error);
} }
@ -1935,16 +1955,11 @@ onMounted(async () => {
await refreshMainWindowVisibleState(); await refreshMainWindowVisibleState();
} }
// show isVisible false false // show isVisible false false
if (isMainWindowVisible.value) {
isMainWindowActive.value = true;
}
isMainWindowActive.value = await appWindow.isFocused();
await refreshMainWindowChromeState(); await refreshMainWindowChromeState();
const unlistenFns: Array<() => void> = []; const unlistenFns: Array<() => void> = [];
unlistenFns.push( unlistenFns.push(
await appWindow.onFocusChanged(({ payload: focused }) => { await appWindow.onFocusChanged(({ payload: focused }) => {
isMainWindowActive.value = focused;
if (focused) { if (focused) {
void refreshMainSessionStateFromDisk(); void refreshMainSessionStateFromDisk();
} }

@ -26,54 +26,40 @@
<div v-loading="loading" class="transfer-body"> <div v-loading="loading" class="transfer-body">
<p v-if="loadError" class="transfer-error">{{ loadError }}</p> <p v-if="loadError" class="transfer-error">{{ loadError }}</p>
<p v-else class="transfer-hint">目标窗口与目标业务只能二选一</p> <template v-else>
<div v-if="!loadError" class="transfer-columns"> <p class="transfer-hint">请选择要转移到的目标业务</p>
<section class="transfer-column"> <div v-if="businesses.length > 0" class="transfer-list" role="listbox">
<h3 class="transfer-column-title">目标窗口</h3> <button
<el-radio-group
v-if="windows.length > 0"
v-model="selectedWindowUid"
class="transfer-radio-group"
@change="onWindowSelectionChange"
>
<el-radio
v-for="win in windows"
:key="win.windowUid"
:value="win.windowUid"
class="transfer-radio"
>
<span class="transfer-radio-label">{{ win.windowName }}</span>
<span v-if="win.windowCode" class="transfer-radio-sub">
{{ win.windowCode }}
</span>
</el-radio>
</el-radio-group>
<el-empty v-else description="暂无可选窗口" :image-size="56" />
</section>
<section class="transfer-column">
<h3 class="transfer-column-title">目标业务</h3>
<el-radio-group
v-if="businesses.length > 0"
v-model="selectedBusinessUid"
class="transfer-radio-group"
@change="onBusinessSelectionChange"
>
<el-radio
v-for="biz in businesses" v-for="biz in businesses"
:key="biz.businessUid" :key="biz.businessUid"
:value="biz.businessUid" type="button"
class="transfer-radio" class="transfer-card"
:class="{ 'is-selected': selectedBusinessUid === biz.businessUid }"
role="option"
:aria-selected="selectedBusinessUid === biz.businessUid"
@click="selectedBusinessUid = biz.businessUid"
> >
<span class="transfer-radio-label">{{ biz.businessName }}</span> <div class="transfer-card-head">
<span v-if="biz.businessCode" class="transfer-radio-sub"> <span class="transfer-card-indicator" aria-hidden="true" />
{{ biz.businessCode }} <span class="transfer-card-name">{{ biz.businessName }}</span>
<span v-if="biz.wins.length > 0" class="transfer-card-badge">
{{ biz.wins.length }} 个窗口
</span>
</div>
<div v-if="biz.wins.length > 0" class="transfer-win-tags">
<span
v-for="win in biz.wins"
:key="win.windowUid"
class="transfer-win-tag"
>
{{ win.windowName }}
</span> </span>
</el-radio>
</el-radio-group>
<el-empty v-else description="暂无可选业务" :image-size="56" />
</section>
</div> </div>
<p v-else class="transfer-no-wins">暂无可办理窗口</p>
</button>
</div>
<el-empty v-else description="暂无可选业务" :image-size="56" />
</template>
</div> </div>
<div class="transfer-footer"> <div class="transfer-footer">
@ -81,7 +67,7 @@
<el-button <el-button
type="primary" type="primary"
:loading="submitting" :loading="submitting"
:disabled="loading || Boolean(loadError) || !hasTransferTarget" :disabled="loading || Boolean(loadError) || selectedBusinessUid === undefined"
@click="handleConfirm" @click="handleConfirm"
> >
确认转移 确认转移
@ -93,8 +79,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Close, Minus } from "@element-plus/icons-vue"; import { Close, Minus } from "@element-plus/icons-vue";
import { appWindow } from "@tauri-apps/api/window"; import { appWindow } from "@tauri-apps/api/window";
import { ElButton, ElEmpty, ElRadio, ElRadioGroup } from "element-plus"; import { ElButton, ElEmpty } from "element-plus";
import { computed, onMounted, onUnmounted, ref } from "vue"; import { onMounted, onUnmounted, ref } from "vue";
import { api } from "../api"; import { api } from "../api";
import { showErrorNative, showWarningNative } from "../host/dialog"; import { showErrorNative, showWarningNative } from "../host/dialog";
import { emitTransferDone, listenTransferOpen } from "../host/events"; import { emitTransferDone, listenTransferOpen } from "../host/events";
@ -102,69 +88,16 @@ import { log } from "../host/logger";
import { getSession, setSession } from "../host/session"; import { getSession, setSession } from "../host/session";
import type { SessionState } from "../host/types"; import type { SessionState } from "../host/types";
import type { TransferBusiness } from "../types/transfer"; import type { TransferBusiness } from "../types/transfer";
import type { ServiceWindow } from "../types/window";
import { closeTransferWindow, focusNamedWindow, minimizeWindow } from "../host/window"; import { closeTransferWindow, focusNamedWindow, minimizeWindow } from "../host/window";
import { import { buildTransferRequest, normalizeBusinessList } from "../utils/transfer";
buildTransferRequest,
normalizeBusinessList,
normalizeTransferWindows,
type TransferTarget,
} from "../utils/transfer";
const loading = ref(false); const loading = ref(false);
const submitting = ref(false); const submitting = ref(false);
const loadError = ref(""); const loadError = ref("");
const ticketUid = ref(-1); const ticketUid = ref(-1);
const currentWindowUid = ref(0);
const windows = ref<ServiceWindow[]>([]);
const businesses = ref<TransferBusiness[]>([]); const businesses = ref<TransferBusiness[]>([]);
const selectedWindowUid = ref<number | undefined>(undefined);
const selectedBusinessUid = ref<number | undefined>(undefined); const selectedBusinessUid = ref<number | undefined>(undefined);
const hasTransferTarget = computed(() => {
const hasWindow = selectedWindowUid.value !== undefined;
const hasBusiness = selectedBusinessUid.value !== undefined;
return hasWindow !== hasBusiness;
});
function onWindowSelectionChange(): void {
if (selectedWindowUid.value !== undefined) {
selectedBusinessUid.value = undefined;
}
}
function onBusinessSelectionChange(): void {
if (selectedBusinessUid.value !== undefined) {
selectedWindowUid.value = undefined;
}
}
function resolveTransferTarget(): TransferTarget | null {
if (
selectedWindowUid.value !== undefined &&
selectedBusinessUid.value === undefined
) {
const windowUid = selectedWindowUid.value;
if (windows.value.some((item) => item.windowUid === windowUid)) {
return { kind: "window", windowUid };
}
return null;
}
if (
selectedBusinessUid.value !== undefined &&
selectedWindowUid.value === undefined
) {
const business = businesses.value.find(
(item) => item.businessUid === selectedBusinessUid.value,
);
if (business) {
return { kind: "business", business };
}
return null;
}
return null;
}
let unlistenTransferOpen: (() => void) | null = null; let unlistenTransferOpen: (() => void) | null = null;
let unlistenWindowFocus: (() => void) | null = null; let unlistenWindowFocus: (() => void) | null = null;
@ -214,17 +147,25 @@ function getActionTicketNo(res: unknown): string {
return String(getActionData(res).ticketNo ?? ""); return String(getActionData(res).ticketNo ?? "");
} }
function resolveSelectedBusiness(): TransferBusiness | null {
if (selectedBusinessUid.value === undefined) {
return null;
}
return (
businesses.value.find(
(item) => item.businessUid === selectedBusinessUid.value,
) ?? null
);
}
async function loadTransferOptions(explicitTicketUid?: number): Promise<void> { async function loadTransferOptions(explicitTicketUid?: number): Promise<void> {
loading.value = true; loading.value = true;
loadError.value = ""; loadError.value = "";
windows.value = [];
businesses.value = []; businesses.value = [];
selectedWindowUid.value = undefined;
selectedBusinessUid.value = undefined; selectedBusinessUid.value = undefined;
try { try {
const session = await getSession(); const session = await getSession();
currentWindowUid.value = Number(session.winUid ?? 0);
ticketUid.value = resolveTicketUidFromSession(session, explicitTicketUid); ticketUid.value = resolveTicketUidFromSession(session, explicitTicketUid);
if (ticketUid.value <= 0) { if (ticketUid.value <= 0) {
loadError.value = loadError.value =
@ -236,11 +177,7 @@ async function loadTransferOptions(explicitTicketUid?: number): Promise<void> {
return; return;
} }
const [winRes, bizRes] = await Promise.all([ const bizRes = await api.action.businessList();
api.window.list(),
api.action.businessList(),
]);
windows.value = normalizeTransferWindows(winRes, currentWindowUid.value);
businesses.value = normalizeBusinessList(bizRes); businesses.value = normalizeBusinessList(bizRes);
} catch (error) { } catch (error) {
loadError.value = error instanceof Error ? error.message : String(error); loadError.value = error instanceof Error ? error.message : String(error);
@ -273,12 +210,9 @@ async function handleCloseClick(): Promise<void> {
} }
async function handleConfirm(): Promise<void> { async function handleConfirm(): Promise<void> {
const target = resolveTransferTarget(); const business = resolveSelectedBusiness();
if (!target) { if (!business) {
await showWarningNative( await showWarningNative("请选择目标业务", "票号转移");
"请选择目标窗口或目标业务(只能二选一)",
"票号转移",
);
return; return;
} }
@ -286,7 +220,7 @@ async function handleConfirm(): Promise<void> {
try { try {
const session = await getSession(); const session = await getSession();
const res = await api.action.transfer( const res = await api.action.transfer(
buildTransferRequest(session, ticketUid.value, target), buildTransferRequest(session, ticketUid.value, business),
); );
if (isActionSuccess(res)) { if (isActionSuccess(res)) {
@ -295,18 +229,10 @@ async function handleConfirm(): Promise<void> {
activeTicketUid: null, activeTicketUid: null,
transferTicketUid: null, transferTicketUid: null,
}); });
const targetWindowName =
target.kind === "window"
? windows.value.find((item) => item.windowUid === target.windowUid)
?.windowName
: undefined;
const businessName =
target.kind === "business" ? target.business.businessName : undefined;
await emitTransferDone({ await emitTransferDone({
success: true, success: true,
ticketNo: getActionTicketNo(res), ticketNo: getActionTicketNo(res),
targetWindowName, businessName: business.displayLabel,
businessName,
message: getActionMessage(res), message: getActionMessage(res),
}); });
await closeTransferWindow(); await closeTransferWindow();
@ -418,8 +344,10 @@ onUnmounted(() => {
.transfer-body { .transfer-body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
padding: 16px; padding: 14px 16px 12px;
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
} }
.transfer-error { .transfer-error {
@ -430,67 +358,130 @@ onUnmounted(() => {
} }
.transfer-hint { .transfer-hint {
margin: 0 0 12px; margin: 0 0 10px;
flex-shrink: 0;
font-size: 13px; font-size: 13px;
color: #909399; color: #909399;
line-height: 1.4; line-height: 1.4;
} }
.transfer-columns { .transfer-list {
display: grid; flex: 1;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: 100%;
}
.transfer-column {
min-height: 0; min-height: 0;
padding: 12px; overflow-y: auto;
border-radius: 8px;
background: #f5f7fa;
border: 1px solid #e4e7ed;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px;
padding-right: 2px;
} }
.transfer-column-title { .transfer-card {
margin: 0 0 12px; width: 100%;
font-size: 14px; margin: 0;
font-weight: 600; padding: 12px 14px;
color: #303133; text-align: left;
border: 1px solid #dcdfe6;
border-radius: 8px;
background: #fff;
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.15s ease;
} }
.transfer-radio-group { .transfer-card:hover {
border-color: #b3d4fc;
background: #fafcff;
}
.transfer-card.is-selected {
border-color: #409eff;
background: #ecf5ff;
box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.15);
}
.transfer-card-head {
display: flex; display: flex;
flex-direction: column; align-items: center;
align-items: flex-start;
gap: 8px; gap: 8px;
width: 100%; min-width: 0;
flex: 1;
min-height: 0;
overflow-y: auto;
} }
.transfer-radio { .transfer-card-indicator {
display: flex; flex-shrink: 0;
align-items: flex-start; width: 14px;
margin-right: 0; height: 14px;
height: auto; border: 2px solid #c0c4cc;
white-space: normal; border-radius: 50%;
background: #fff;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.transfer-card.is-selected .transfer-card-indicator {
border-color: #409eff;
background: #409eff;
box-shadow: inset 0 0 0 3px #ecf5ff;
} }
.transfer-radio-label { .transfer-card-name {
display: block; flex: 1;
min-width: 0;
font-size: 15px;
font-weight: 600;
color: #303133; color: #303133;
line-height: 1.35;
}
.transfer-card-badge {
flex-shrink: 0;
padding: 2px 8px;
font-size: 12px;
line-height: 1.4; line-height: 1.4;
color: #606266;
background: #f4f4f5;
border-radius: 10px;
}
.transfer-card.is-selected .transfer-card-badge {
color: #409eff;
background: rgba(64, 158, 255, 0.12);
}
.transfer-win-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
padding-left: 22px;
} }
.transfer-radio-sub { .transfer-win-tag {
display: block; display: inline-block;
margin-top: 2px; max-width: 100%;
padding: 3px 8px;
font-size: 12px; font-size: 12px;
color: #909399; line-height: 1.35;
line-height: 1.3; color: #606266;
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
word-break: break-all;
}
.transfer-card.is-selected .transfer-win-tag {
color: #409eff;
background: #fff;
border-color: #c6e2ff;
}
.transfer-no-wins {
margin: 8px 0 0 22px;
font-size: 12px;
color: #c0c4cc;
line-height: 1.4;
} }
.transfer-footer { .transfer-footer {

@ -1,4 +1,4 @@
# 1.call-client转移功能修修改 ### 1.call-client转移功能修修改
- 转移功能只能窗口和业务二选一,不能同时选择。 - 转移功能只能窗口和业务二选一,不能同时选择。
- 点击确认转移后,调用/api/queue/caller/call-terminal/transfer接口请求参数如下 - 点击确认转移后,调用/api/queue/caller/call-terminal/transfer接口请求参数如下
@ -33,10 +33,16 @@
] ]
} }
# 2.call-client异常中断处理 ### 2.call-client异常中断处理
- 点击呼叫或开始之后票号是否有缓存为防止异常中断的情况点击呼叫或开始之后需要将票号缓存起来在异常中断重新登录并跳转到mainview之后先查看是否缓存了票号有则弹出原生提示框提示是否继续办理异常中断的号码如果点击是则继续根据缓存的票号状态进行流程如果点击否则开始正常的流程。 - 点击呼叫或开始之后票号是否有缓存为防止异常中断的情况点击呼叫或开始之后需要将票号缓存起来在异常中断重新登录并跳转到mainview之后先查看是否缓存了票号有则弹出原生提示框提示是否继续办理异常中断的号码如果点击是则继续根据缓存的票号状态进行流程如果点击否则开始正常的流程。
# 3.修改1中的接口参数 ### 3.修改1中的接口参数
- targetPosition不用理会新增targetBusinessUid参数按窗口转移使用targetWindowUid传窗口uid同时targetBusinessUid传null反之按业务转移使用targetBusinessUid传业务id同时targetWindowUid传null。 - targetPosition不用理会新增targetBusinessUid参数按窗口转移使用targetWindowUid传窗口uid同时targetBusinessUid传null反之按业务转移使用targetBusinessUid传业务id同时targetWindowUid传null。
### 4.call-client转移功能现在仅按照业务转移/call-terminal/business-list现在的数据格式为"data": [
{ "uid": 1, "name": "综合办税", "wins": [{ "uid": 1, "name": "综合办税窗口1" }, { "uid": 2, "name": "综合办税窗口2" }] },
{ "uid": 2, "name": "发票办理", "wins": [{ "uid": 3, "name": "发票办理窗口3" }] }
]界面上需要在业务名称后面展示对应可办理的窗口名称即wins中的数据综合办税(综合办税窗口1,综合办税窗口2)

Loading…
Cancel
Save