自动叫号,转移

master
cysamurai 4 weeks ago
parent 25b4217969
commit 603e07977f

@ -1,12 +1,12 @@
{ {
"name": "call-client", "name": "call-client",
"version": "0.1.5", "version": "0.1.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "call-client", "name": "call-client",
"version": "0.1.5", "version": "0.1.6",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"@tauri-apps/api": "^1", "@tauri-apps/api": "^1",

@ -1,7 +1,7 @@
{ {
"name": "call-client", "name": "call-client",
"private": true, "private": true,
"version": "0.1.5", "version": "0.1.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

@ -392,7 +392,7 @@ dependencies = [
[[package]] [[package]]
name = "call-client" name = "call-client"
version = "0.1.5" version = "0.1.6"
dependencies = [ dependencies = [
"chrono", "chrono",
"flate2", "flate2",

@ -1,6 +1,6 @@
[package] [package]
name = "call-client" name = "call-client"
version = "0.1.5" version = "0.1.6"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"

@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main", "login", "ticketList"], "windows": ["main", "login", "ticketList", "transfer"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-close", "core:window:allow-close",

@ -151,6 +151,52 @@ pub fn open_ticket_window(app: AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
#[tauri::command]
pub fn open_transfer_window(app: AppHandle) -> Result<(), String> {
if let Some(window) = app.get_window("transfer") {
let _ = window.eval(
"if (window.location.hash !== '#/transfer') { window.location.hash = '/transfer'; }",
);
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
return Ok(());
}
let window = WindowBuilder::new(
&app,
"transfer",
WindowUrl::App("/#/transfer".into()),
)
.title("票号转移")
.inner_size(640.0, 480.0)
.resizable(false)
.visible(true)
.decorations(false)
.always_on_top(true)
.build()
.map_err(|error| format!("创建票号转移窗口失败: {error}"))?;
let _ = window.eval(
"if (window.location.hash !== '#/transfer') { window.location.hash = '/transfer'; }",
);
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
Ok(())
}
#[tauri::command]
pub fn close_transfer_window(app: AppHandle) -> Result<(), String> {
let Some(window) = app.get_window("transfer") else {
return Ok(());
};
let _ = window.hide();
Ok(())
}
#[tauri::command] #[tauri::command]
pub fn close_ticket_window(app: AppHandle) -> Result<(), String> { pub fn close_ticket_window(app: AppHandle) -> Result<(), String> {
let Some(window) = app.get_window("ticketList") else { let Some(window) = app.get_window("ticketList") else {
@ -198,10 +244,12 @@ pub fn open_main_window(app: AppHandle) -> Result<(), String> {
let _ = main_window.eval("if (window.location.hash !== '#/main') { window.location.hash = '/main'; }"); let _ = main_window.eval("if (window.location.hash !== '#/main') { window.location.hash = '/main'; }");
let _ = main_window.show(); let _ = main_window.show();
let _ = main_window.unminimize(); let _ = main_window.unminimize();
let _ = center_window_on_primary_monitor(&main_window);
let _ = main_window.set_focus(); let _ = main_window.set_focus();
// 隐藏登录窗而非 close避免关闭配置里的主 WebView 导致进程退出
if let Some(login_window) = app.get_window("login") { if let Some(login_window) = app.get_window("login") {
let _ = login_window.close(); let _ = login_window.hide();
} }
Ok(()) Ok(())

@ -17,8 +17,9 @@ use commands::{
spawn_background_pending_call_client_update_sync, upgrade_call_client_via_apt, spawn_background_pending_call_client_update_sync, upgrade_call_client_via_apt,
}, },
window::{ window::{
close_taxer_info_window, close_ticket_window, ensure_main_window, focus_window, close_taxer_info_window, close_ticket_window, close_transfer_window, ensure_main_window,
open_login_window, open_main_window, open_taxer_info_window, open_ticket_window, quit_app, focus_window, open_login_window, open_main_window, open_taxer_info_window,
open_ticket_window, open_transfer_window, quit_app,
toggle_main_call_window_from_tray, toggle_main_call_window_from_tray,
}, },
}; };
@ -128,6 +129,8 @@ pub fn run() {
upgrade_call_client_via_apt, upgrade_call_client_via_apt,
open_ticket_window, open_ticket_window,
close_ticket_window, close_ticket_window,
open_transfer_window,
close_transfer_window,
close_taxer_info_window, close_taxer_info_window,
focus_window, focus_window,
open_main_window, open_main_window,

@ -2,7 +2,7 @@
"$schema": "../node_modules/@tauri-apps/cli/schema.json", "$schema": "../node_modules/@tauri-apps/cli/schema.json",
"package": { "package": {
"productName": "call-client", "productName": "call-client",
"version": "0.1.5" "version": "0.1.6"
}, },
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@ -53,6 +53,18 @@
"resizable": true, "resizable": true,
"decorations": false, "decorations": false,
"alwaysOnTop": true "alwaysOnTop": true
},
{
"label": "transfer",
"title": "票号转移",
"url": "/#/transfer",
"width": 640,
"height": 480,
"visible": false,
"resizable": false,
"maximizable": false,
"decorations": false,
"alwaysOnTop": true
} }
], ],
"systemTray": { "systemTray": {

@ -1,4 +1,10 @@
import type { CallRequest, CallResponse, PauseRequest, ReCallRequest } from "../types/action"; import type {
CallRequest,
CallResponse,
PauseRequest,
ReCallRequest,
TransferRequest,
} from "../types/action";
import type { import type {
IsRankData, IsRankData,
IsRankRequest, IsRankRequest,
@ -69,5 +75,9 @@ export const api = {
http.get<IsRankData>("/call-terminal/is-rank", params), http.get<IsRankData>("/call-terminal/is-rank", params),
getQueueCount: (params: QueueCountRequest) => getQueueCount: (params: QueueCountRequest) =>
http.get<QueueCountData>("/call-terminal/queue-count", params), http.get<QueueCountData>("/call-terminal/queue-count", params),
businessList: () =>
http.get<unknown>("/call-terminal/business-list"),
transfer: (data: TransferRequest) =>
http.post<CallResponse>("/call-terminal/transfer", data),
}, },
}; };

@ -1,8 +1,14 @@
import { emit, listen, type Event } from "@tauri-apps/api/event"; import { emit, listen, type Event } from "@tauri-apps/api/event";
import type { TaxerTicketContextPayload, TicketActionPayload } from "./types"; import type {
TaxerTicketContextPayload,
TicketActionPayload,
TransferDonePayload,
} from "./types";
const MAIN_TICKET_EVENT = "main:ticket-action"; const MAIN_TICKET_EVENT = "main:ticket-action";
const TICKET_MAIN_EVENT = "ticket:main-action"; const TICKET_MAIN_EVENT = "ticket:main-action";
const TRANSFER_MAIN_EVENT = "transfer:main-action";
const TRANSFER_OPEN_EVENT = "transfer:open-context";
const TAXER_TICKET_CONTEXT_EVENT = "taxer:ticket-context"; const TAXER_TICKET_CONTEXT_EVENT = "taxer:ticket-context";
const TAXER_NSR_CLEAR_EVENT = "taxer:nsr-clear"; const TAXER_NSR_CLEAR_EVENT = "taxer:nsr-clear";
const TAXER_TICKET_START_EVENT = "taxer:ticket-start"; const TAXER_TICKET_START_EVENT = "taxer:ticket-start";
@ -73,6 +79,48 @@ export async function listenMainAction(
} }
} }
/** 主窗口打开转移子窗口时广播当前票 UID子窗口每次显示时重新拉取 */
export async function emitTransferOpen(payload: { ticketUid: number }): Promise<void> {
try {
await emit(TRANSFER_OPEN_EVENT, payload);
} catch (error) {
throw new Error(`发送转移打开事件失败: ${String(error)}`);
}
}
export async function listenTransferOpen(
handler: (payload: { ticketUid: number }) => void,
): Promise<() => void> {
try {
return await listen<{ ticketUid: number }>(TRANSFER_OPEN_EVENT, (event) => {
handler(event.payload);
});
} catch (error) {
throw new Error(`订阅转移打开事件失败: ${String(error)}`);
}
}
/** 转移子窗口通知主窗口转移结果 */
export async function emitTransferDone(payload: TransferDonePayload): Promise<void> {
try {
await emit(TRANSFER_MAIN_EVENT, payload);
} catch (error) {
throw new Error(`发送转移结果事件失败: ${String(error)}`);
}
}
export async function listenTransferDone(
handler: (payload: TransferDonePayload) => void,
): Promise<() => void> {
try {
return await listen<TransferDonePayload>(TRANSFER_MAIN_EVENT, (event) => {
handler(event.payload);
});
} catch (error) {
throw new Error(`订阅转移结果事件失败: ${String(error)}`);
}
}
/** /**
* 广 * 广
*/ */

@ -24,6 +24,8 @@ const DEFAULT_SESSION: SessionState = {
ziyunServiceUrl: null, ziyunServiceUrl: null,
bsNsrUrl: null, bsNsrUrl: null,
bsTaxAuthorityNum: null, bsTaxAuthorityNum: null,
activeTicketUid: null,
transferTicketUid: null,
taxer_compare_code: null, taxer_compare_code: null,
taxer_ticket_id: null, taxer_ticket_id: null,
taxer_use_doc_fields: null, taxer_use_doc_fields: null,
@ -93,6 +95,8 @@ function normalizeSession(raw: unknown): SessionState {
const bsNsrUrl = typeof source.bsNsrUrl === "string" ? source.bsNsrUrl : null; const bsNsrUrl = typeof source.bsNsrUrl === "string" ? source.bsNsrUrl : null;
const bsTaxAuthorityNum = const bsTaxAuthorityNum =
typeof source.bsTaxAuthorityNum === "string" ? source.bsTaxAuthorityNum : null; typeof source.bsTaxAuthorityNum === "string" ? source.bsTaxAuthorityNum : null;
const activeTicketUid = normalizeOptionalUid(source.activeTicketUid);
const transferTicketUid = normalizeOptionalUid(source.transferTicketUid);
const taxer_compare_code = const taxer_compare_code =
typeof source.taxer_compare_code === "string" typeof source.taxer_compare_code === "string"
? source.taxer_compare_code ? source.taxer_compare_code
@ -129,6 +133,8 @@ function normalizeSession(raw: unknown): SessionState {
ziyunServiceUrl, ziyunServiceUrl,
bsNsrUrl, bsNsrUrl,
bsTaxAuthorityNum, bsTaxAuthorityNum,
activeTicketUid,
transferTicketUid,
taxer_compare_code, taxer_compare_code,
taxer_ticket_id, taxer_ticket_id,
taxer_use_doc_fields, taxer_use_doc_fields,

@ -25,6 +25,10 @@ export interface SessionState {
bsNsrUrl?: string | null; bsNsrUrl?: string | null;
/** 税务机关代码模板占位(来自 init 的 BS_TAX_AUTHORITY_NUM */ /** 税务机关代码模板占位(来自 init 的 BS_TAX_AUTHORITY_NUM */
bsTaxAuthorityNum?: string | null; bsTaxAuthorityNum?: string | null;
/** 当前办理中的票 UID呼叫/开始后写入,弃号/转移成功等场景清空) */
activeTicketUid?: number | null;
/** 主窗口打开转移子窗口时写入,转移完成或取消后清空 */
transferTicketUid?: number | null;
taxer_compare_code?: string | null; taxer_compare_code?: string | null;
taxer_ticket_id?: string | null; taxer_ticket_id?: string | null;
taxer_use_doc_fields?: boolean | null; taxer_use_doc_fields?: boolean | null;
@ -60,6 +64,14 @@ export interface TicketActionPayload {
tktNum?: string; tktNum?: string;
} }
export interface TransferDonePayload {
success: boolean;
ticketNo?: string;
targetWindowName?: string;
businessName?: string;
message?: string;
}
export interface TaxerTicketContextPayload { export interface TaxerTicketContextPayload {
ticketNo?: string; ticketNo?: string;
sfzhm?: string; sfzhm?: string;
@ -79,4 +91,15 @@ export interface PendingCallClientUpdateStored {
candidateVersion: string; candidateVersion: string;
} }
/** `config.json` 中异常中断票号快照(见 `interrupted_ticket_snapshot` */
export interface InterruptedTicketSnapshotStored {
ticketUid: number;
ticketNo: string;
flowStatus: "calling" | "working" | "paused";
windowUid: number;
empUid: number;
pauseReason?: string;
savedAt: number;
}
export type LogLevel = "debug" | "info" | "warn" | "error"; export type LogLevel = "debug" | "info" | "warn" | "error";

@ -45,6 +45,28 @@ export async function startWindowDragging(): Promise<void> {
} }
} }
/**
*
*/
export async function openTransferWindow(): Promise<void> {
try {
await invoke("open_transfer_window");
} catch (error) {
throw new Error(`打开票号转移窗口失败: ${String(error)}`);
}
}
/**
*
*/
export async function closeTransferWindow(): Promise<void> {
try {
await invoke("close_transfer_window");
} catch (error) {
throw new Error(`关闭票号转移窗口失败: ${String(error)}`);
}
}
/** /**
* *
*/ */

@ -3,6 +3,7 @@ import { getAllConfig } from "../host/config";
import type { AppConfig } from "../host/types"; import type { AppConfig } from "../host/types";
import { applyServerIpToHttp } from "../utils/service"; import { applyServerIpToHttp } from "../utils/service";
import TicketListView from "../views/TicketListView.vue"; import TicketListView from "../views/TicketListView.vue";
import TransferView from "../views/TransferView.vue";
const LoginView = () => import("../views/LoginView.vue"); const LoginView = () => import("../views/LoginView.vue");
const MainView = () => import("../views/MainView.vue"); const MainView = () => import("../views/MainView.vue");
@ -24,6 +25,7 @@ const routes: RouteRecordRaw[] = [
{ path: "/main", name: "main", component: MainView }, { path: "/main", name: "main", component: MainView },
{ path: "/taxerInfo", name: "taxerInfo", component: TaxerInfoView }, { path: "/taxerInfo", name: "taxerInfo", component: TaxerInfoView },
{ path: "/ticketList", name: "ticketList", component: TicketListView }, { path: "/ticketList", name: "ticketList", component: TicketListView },
{ path: "/transfer", name: "transfer", component: TransferView },
// 避免在窗口初始 URL/hash 不匹配时出现空白页面 // 避免在窗口初始 URL/hash 不匹配时出现空白页面
{ path: "/:pathMatch(.*)*", redirect: "/ticketList" }, { path: "/:pathMatch(.*)*", redirect: "/ticketList" },
]; ];
@ -35,7 +37,7 @@ export const router = createRouter({
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
// 票号列表窗口由主窗口显式打开,优先保证路由可渲染,避免守卫异步阻塞导致白屏。 // 票号列表窗口由主窗口显式打开,优先保证路由可渲染,避免守卫异步阻塞导致白屏。
if (to.path === "/ticketList" || to.path === "/taxerInfo") { if (to.path === "/ticketList" || to.path === "/taxerInfo" || to.path === "/transfer") {
next(); next();
return; return;
} }

@ -36,3 +36,39 @@ export interface PauseRequest {
empUid: number; empUid: number;
pauseReason: string; pauseReason: string;
} }
export interface TransferClientItem {
address: string;
driver: string;
com: number;
serial: number;
ip: string;
port: string;
param: string;
}
/** `POST /call-terminal/transfer` 请求体 */
export interface TransferRequest {
windowUid: number;
empUid: number;
ticketUid: number;
/** 转窗口时传目标窗口 UID转业务时传 null */
targetWindowUid: number | null;
/** 转业务时传业务 UID转窗口时传 null */
targetBusinessUid: number | null;
resumeToken: string;
/** 接口保留字段,固定传 0业务含义由 targetBusinessUid 表达 */
targetPosition: number;
rank: number;
rankUserName: string;
rankUserPhone: string;
idCard: string;
phone: string;
serviceUrl: string;
forward: boolean;
read: boolean;
customText: string;
pauseReason: string;
driver: string;
clients: TransferClientItem[];
}

@ -0,0 +1,5 @@
export interface TransferBusiness {
businessUid: number;
businessName: string;
businessCode?: string;
}

@ -0,0 +1,109 @@
import { getAllConfig, mergeConfig } from "../host/config";
import type { CallStatus } from "../types/action";
/** 写入 `config.json`,登录重置 session 后仍可恢复 */
export const INTERRUPTED_TICKET_CONFIG_KEY = "interrupted_ticket_snapshot";
/** 呼叫/开始之后可恢复的流程状态 */
export type InterruptedFlowStatus = Extract<
CallStatus,
"calling" | "working" | "paused"
>;
export interface InterruptedTicketSnapshot {
ticketUid: number;
ticketNo: string;
flowStatus: InterruptedFlowStatus;
windowUid: number;
empUid: number;
pauseReason?: string;
savedAt: number;
}
function parseFlowStatus(value: unknown): InterruptedFlowStatus | null {
if (value === "calling" || value === "working" || value === "paused") {
return value;
}
return null;
}
function parseSnapshot(raw: unknown): InterruptedTicketSnapshot | null {
if (!raw || typeof raw !== "object") {
return null;
}
const record = raw as Record<string, unknown>;
const ticketUid = Number(record.ticketUid);
const windowUid = Number(record.windowUid);
const empUid = Number(record.empUid);
const flowStatus = parseFlowStatus(record.flowStatus);
if (
!Number.isFinite(ticketUid) ||
ticketUid <= 0 ||
!flowStatus ||
!Number.isFinite(windowUid) ||
windowUid <= 0 ||
!Number.isFinite(empUid) ||
empUid <= 0
) {
return null;
}
const ticketNo = String(record.ticketNo ?? "").trim();
const pauseReason =
typeof record.pauseReason === "string" && record.pauseReason.trim() !== ""
? record.pauseReason.trim()
: undefined;
const savedAt = Number(record.savedAt);
return {
ticketUid,
ticketNo,
flowStatus,
windowUid,
empUid,
pauseReason,
savedAt: Number.isFinite(savedAt) && savedAt > 0 ? savedAt : Date.now(),
};
}
/** 读取异常中断票号缓存 */
export async function readInterruptedTicketSnapshot(): Promise<InterruptedTicketSnapshot | null> {
try {
const config = await getAllConfig();
return parseSnapshot(config[INTERRUPTED_TICKET_CONFIG_KEY]);
} catch {
return null;
}
}
/** 写入异常中断票号缓存 */
export async function saveInterruptedTicketSnapshot(
snapshot: InterruptedTicketSnapshot,
): Promise<void> {
await mergeConfig({
[INTERRUPTED_TICKET_CONFIG_KEY]: {
...snapshot,
savedAt: snapshot.savedAt > 0 ? snapshot.savedAt : Date.now(),
},
});
}
/** 清除异常中断票号缓存 */
export async function clearInterruptedTicketSnapshot(): Promise<void> {
await mergeConfig({
[INTERRUPTED_TICKET_CONFIG_KEY]: null,
});
}
export function interruptedFlowStatusLabel(
status: InterruptedFlowStatus,
): string {
switch (status) {
case "calling":
return "呼叫中";
case "working":
return "办理中";
case "paused":
return "已暂停";
default:
return status;
}
}

@ -0,0 +1,125 @@
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";
function extractArray(raw: unknown): unknown[] {
if (Array.isArray(raw)) {
return raw;
}
if (!raw || typeof raw !== "object") {
return [];
}
const record = raw as Record<string, unknown>;
for (const key of [
"businesses",
"businessList",
"list",
"items",
"records",
"data",
]) {
const value = record[key];
if (Array.isArray(value)) {
return value;
}
}
return [];
}
function normalizeBusinessItem(item: unknown): TransferBusiness | null {
if (!item || typeof item !== "object") {
return null;
}
const record = item as Record<string, unknown>;
const businessUid = Number(
record.businessUid ?? record.bizUid ?? record.uid ?? record.id ?? -1,
);
if (!Number.isFinite(businessUid) || businessUid <= 0) {
return null;
}
const businessName = String(
record.businessName ??
record.bizName ??
record.name ??
record.label ??
record.title ??
"",
).trim();
const businessCode = String(
record.businessCode ?? record.bizCode ?? record.code ?? "",
).trim();
return {
businessUid,
businessName: businessName || `业务 ${businessUid}`,
businessCode: businessCode || undefined,
};
}
/** 解析 business-list 接口返回(兼容多种字段命名) */
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,
): TransferRequest {
const base: TransferRequest = {
windowUid: Number(session.winUid ?? 0),
empUid: Number(session.empUid ?? 0),
ticketUid,
targetWindowUid: null,
targetBusinessUid: null,
resumeToken: "",
targetPosition: 0,
rank: 0,
rankUserName: "",
rankUserPhone: "",
idCard: "",
phone: "",
serviceUrl: String(session.ziyunServiceUrl ?? "").trim(),
forward: true,
read: true,
customText: "",
pauseReason: "",
driver: "",
clients: [],
};
if (target.kind === "window") {
return {
...base,
targetWindowUid: target.windowUid,
targetBusinessUid: null,
};
}
return {
...base,
targetWindowUid: null,
targetBusinessUid: target.business.businessUid,
};
}

@ -32,8 +32,18 @@ import {
emitTaxerNsrClear, emitTaxerNsrClear,
emitTaxerTicketContext, emitTaxerTicketContext,
emitTaxerTicketStart, emitTaxerTicketStart,
emitTransferOpen,
listenMainAction, listenMainAction,
listenTransferDone,
} from "../host/events"; } from "../host/events";
import {
clearInterruptedTicketSnapshot,
interruptedFlowStatusLabel,
readInterruptedTicketSnapshot,
saveInterruptedTicketSnapshot,
type InterruptedFlowStatus,
type InterruptedTicketSnapshot,
} from "../utils/interruptedTicket";
import { buildTaxerContextFromActionData } from "../utils/taxerTicketContext"; import { buildTaxerContextFromActionData } from "../utils/taxerTicketContext";
import { log } from "../host/logger"; import { log } from "../host/logger";
import { getSession, setSession } from "../host/session"; import { getSession, setSession } from "../host/session";
@ -43,6 +53,7 @@ import {
minimizeWindow, minimizeWindow,
openTaxerInfoWindow, openTaxerInfoWindow,
openTicketListWindow, openTicketListWindow,
openTransferWindow,
quitApplication, quitApplication,
} from "../host/window"; } from "../host/window";
import type { ActionButton } from "../types/action"; import type { ActionButton } from "../types/action";
@ -110,6 +121,10 @@ let autoStartTickLeft = 0;
const autoStartCountdownLeft = ref(0); const autoStartCountdownLeft = ref(0);
/** 当前呼叫票号展示(自动开始倒计时文案) */ /** 当前呼叫票号展示(自动开始倒计时文案) */
const callingTicketNoLabel = ref(""); const callingTicketNoLabel = ref("");
/** 最近一次暂停原因(异常中断恢复「已暂停」时用于日志展示) */
const lastPauseReason = ref("");
/** 本会话是否已处理过异常中断恢复提示(避免重复弹窗) */
const interruptRecoveryPrompted = ref(false);
const EVALUATING_COUNTDOWN_SEC = 15; const EVALUATING_COUNTDOWN_SEC = 15;
const DEFAULT_AUTO_CALL_WAIT_SECONDS = 5; const DEFAULT_AUTO_CALL_WAIT_SECONDS = 5;
@ -122,7 +137,10 @@ const pauseReasonOptions = ["午休", "休息一下", "整理资料", "其他"];
const isMainWindowActive = ref(true); const isMainWindowActive = ref(true);
/** 主窗口是否已最小化(自动叫号仅在非最小化时运行) */ /** 主窗口是否已最小化(自动叫号仅在非最小化时运行) */
const isMainWindowMinimized = ref(false); const isMainWindowMinimized = ref(false);
/** 主窗口是否已显示(登录前不创建主窗口;隐藏态不轮询等候人数) */
const isMainWindowVisible = ref(false);
const buttonPanel = ref<"main" | "more" | "pause">("main"); const buttonPanel = ref<"main" | "more" | "pause">("main");
let unlistenTransferDone: (() => void) | null = null;
const isSyncingMainScreen = ref(false); const isSyncingMainScreen = ref(false);
const isActionPending = ref(false); const isActionPending = ref(false);
@ -139,6 +157,15 @@ function resolveAutoCallWaitSeconds(): number {
return DEFAULT_AUTO_CALL_WAIT_SECONDS; return DEFAULT_AUTO_CALL_WAIT_SECONDS;
} }
async function refreshMainWindowVisibleState(): Promise<void> {
try {
isMainWindowVisible.value = await appWindow.isVisible();
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
await log("warn", `读取主窗口可见状态失败: ${detail}`);
}
}
async function refreshMainWindowMinimizedState(): Promise<void> { async function refreshMainWindowMinimizedState(): Promise<void> {
try { try {
const minimized = await appWindow.isMinimized(); const minimized = await appWindow.isMinimized();
@ -158,6 +185,11 @@ async function refreshMainWindowMinimizedState(): Promise<void> {
} }
} }
async function refreshMainWindowChromeState(): Promise<void> {
await refreshMainWindowVisibleState();
await refreshMainWindowMinimizedState();
}
/** caller-init 缓存:为 true 且在 calling 时自动倒计时并开始办理 */ /** caller-init 缓存:为 true 且在 calling 时自动倒计时并开始办理 */
const autoStartEnabled = computed( const autoStartEnabled = computed(
() => sessionState.value.autoStartEnabled === true, () => sessionState.value.autoStartEnabled === true,
@ -651,8 +683,180 @@ async function emitTaxerContextFromAction(res: unknown): Promise<void> {
* 提取票据 UID * 提取票据 UID
*/ */
function getActionTicketUid(res: unknown): number { function getActionTicketUid(res: unknown): number {
const value = getActionData(res).ticketUid; const parsed = parseOptionalNumber(getActionData(res).ticketUid);
return typeof value === "number" ? value : -1; return parsed !== null && parsed > 0 ? parsed : -1;
}
async function syncActiveTicketToSession(ticketUid: number): Promise<void> {
if (ticketUid <= 0) {
return;
}
const session = await getSession();
await setSession({
...session,
activeTicketUid: ticketUid,
});
}
async function clearActiveTicketInSession(): Promise<void> {
const session = await getSession();
await setSession({
...session,
activeTicketUid: null,
transferTicketUid: null,
});
try {
await clearInterruptedTicketSnapshot();
} catch (error) {
await logErr("清除异常中断票号缓存失败", error);
}
}
async function persistInterruptedTicketCache(
flowStatus: InterruptedFlowStatus,
ticketUid: number,
ticketNo: string,
pauseReason?: string,
): Promise<void> {
if (ticketUid <= 0) {
return;
}
const windowUid = Number(sessionState.value.winUid ?? 0);
const empUid = Number(sessionState.value.empUid ?? 0);
if (windowUid <= 0 || empUid <= 0) {
return;
}
try {
await saveInterruptedTicketSnapshot({
ticketUid,
ticketNo: ticketNo.trim(),
flowStatus,
windowUid,
empUid,
pauseReason: pauseReason?.trim() || undefined,
savedAt: Date.now(),
});
} catch (error) {
await logErr("写入异常中断票号缓存失败", error);
}
}
function applyInterruptedTicketRecovery(snapshot: InterruptedTicketSnapshot): void {
callingTkt.value = snapshot.ticketUid;
callingTicketNoLabel.value = snapshot.ticketNo;
callStatus.value = snapshot.flowStatus;
if (snapshot.flowStatus === "calling" || snapshot.flowStatus === "working") {
callBtnText.value = "重呼";
pauseBtnText.value = "暂停";
const ticketLabel = snapshot.ticketNo.trim() || String(snapshot.ticketUid);
if (snapshot.flowStatus === "calling") {
updateLog(`已恢复异常中断票号(呼叫中):${ticketLabel}`);
} else {
updateLog(`已恢复异常中断票号(办理中):${ticketLabel}`);
}
return;
}
callBtnText.value = "重呼";
pauseBtnText.value = "恢复";
const reason = snapshot.pauseReason?.trim() ?? lastPauseReason.value.trim();
if (reason) {
lastPauseReason.value = reason;
applyPausedLogMessage(reason);
} else {
updateLog(
`已恢复异常中断票号(已暂停):${snapshot.ticketNo.trim() || snapshot.ticketUid}`,
);
}
}
async function tryRecoverInterruptedTicket(): Promise<void> {
if (!sessionReadyForQueueCount.value || !isMainWindowVisible.value) {
return;
}
if (interruptRecoveryPrompted.value) {
return;
}
let snapshot: InterruptedTicketSnapshot | null = null;
try {
snapshot = await readInterruptedTicketSnapshot();
} catch (error) {
await logErr("读取异常中断票号缓存失败", error);
return;
}
if (!snapshot) {
return;
}
const winUid = Number(sessionState.value.winUid ?? 0);
const empUid = Number(sessionState.value.empUid ?? 0);
if (winUid <= 0 || empUid <= 0) {
return;
}
if (snapshot.windowUid !== winUid || snapshot.empUid !== empUid) {
interruptRecoveryPrompted.value = true;
await log(
"info",
`异常中断缓存与当前窗口不一致,已忽略: cache win=${snapshot.windowUid} emp=${snapshot.empUid}, current win=${winUid} emp=${empUid}`,
);
try {
await clearInterruptedTicketSnapshot();
} catch (error) {
await logErr("清除过期异常中断票号缓存失败", error);
}
return;
}
interruptRecoveryPrompted.value = true;
const ticketLabel = snapshot.ticketNo.trim() || String(snapshot.ticketUid);
const statusLabel = interruptedFlowStatusLabel(snapshot.flowStatus);
const confirmed = await confirmNative({
title: "异常中断恢复",
message: `检测到异常中断前的票号 ${ticketLabel}${statusLabel}),是否继续办理?`,
okLabel: "是",
cancelLabel: "否",
});
if (!confirmed) {
try {
await clearInterruptedTicketSnapshot();
} catch (error) {
await logErr("清除异常中断票号缓存失败", error);
}
await log("info", "用户选择不恢复异常中断票号,进入正常流程");
return;
}
applyInterruptedTicketRecovery(snapshot);
await syncActiveTicketToSession(snapshot.ticketUid);
await log(
"info",
`已恢复异常中断票号: ticketUid=${snapshot.ticketUid}, flow=${snapshot.flowStatus}`,
);
if (snapshot.flowStatus === "calling" && autoStartEnabled.value) {
resetAndStartAutoStartCountdown();
}
}
function resolveTransferTicketUid(
session: SessionState,
memoryTicketUid: number,
): number {
if (memoryTicketUid > 0) {
return memoryTicketUid;
}
const fromTransfer = parseOptionalNumber(session.transferTicketUid);
if (fromTransfer !== null && fromTransfer > 0) {
return fromTransfer;
}
const fromActive = parseOptionalNumber(session.activeTicketUid);
if (fromActive !== null && fromActive > 0) {
return fromActive;
}
return -1;
} }
function parseOptionalNumber(value: unknown): number | null { function parseOptionalNumber(value: unknown): number | null {
@ -793,7 +997,9 @@ function startEvaluatingCountdown(prefixText: string): void {
callStatus.value = "idle"; callStatus.value = "idle";
callBtnText.value = "呼叫"; callBtnText.value = "呼叫";
callingTkt.value = -1; callingTkt.value = -1;
callingTicketNoLabel.value = "";
message.value = "欢迎使用紫云呼叫终端"; message.value = "欢迎使用紫云呼叫终端";
void clearActiveTicketInSession();
void log("info", "评价倒计时结束,已停止 isRank 轮询并进入待机"); void log("info", "评价倒计时结束,已停止 isRank 轮询并进入待机");
} else { } else {
message.value = evaluatingPrefixText.value; message.value = evaluatingPrefixText.value;
@ -828,7 +1034,9 @@ async function pollIsRankOnce(): Promise<void> {
callStatus.value = "idle"; callStatus.value = "idle";
callBtnText.value = "呼叫"; callBtnText.value = "呼叫";
callingTkt.value = -1; callingTkt.value = -1;
callingTicketNoLabel.value = "";
message.value = "欢迎使用紫云呼叫终端"; message.value = "欢迎使用紫云呼叫终端";
await clearActiveTicketInSession();
await log("info", "isRank: 评价完成,进入待机"); await log("info", "isRank: 评价完成,进入待机");
return; return;
} }
@ -960,21 +1168,23 @@ function startQueueCountPolling(): void {
} }
watch( watch(
[callStatus, sessionReadyForQueueCount], [callStatus, sessionReadyForQueueCount, isMainWindowVisible],
([status, ready]) => { ([status, ready, visible]) => {
if (status === "evaluating" && isMainWindowActive.value) { if (status === "evaluating" && isMainWindowActive.value) {
startIsRankPolling(); startIsRankPolling();
} else { } else {
clearIsRankPolling(); clearIsRankPolling();
} }
if (status === "idle" && ready) { if (status === "idle" && ready && visible) {
startQueueCountPolling(); startQueueCountPolling();
} else { } else {
clearQueueCountPolling( clearQueueCountPolling(
status !== "idle" !visible
? "主窗口未显示(登录成功并打开主窗口后再轮询)"
: status !== "idle"
? `callStatus=${status}` ? `callStatus=${status}`
: "会话未就绪(需 queueToken 且 winUid选窗并打开主窗口后再轮询)", : "会话未就绪(需 queueToken 且 winUid选窗并登录后再轮询)",
); );
} }
}, },
@ -996,15 +1206,32 @@ watch(isMainWindowMinimized, () => {
syncAutoStartCountdown(); syncAutoStartCountdown();
}); });
watch(isMainWindowVisible, (visible) => {
if (!visible) {
clearQueueCountPolling("主窗口已隐藏");
return;
}
void (async () => {
await refreshMainSessionStateFromDisk();
if (sessionReadyForQueueCount.value) {
await tryRecoverInterruptedTicket();
}
})();
});
watch(callStatus, (newStatus, oldStatus) => { watch(callStatus, (newStatus, oldStatus) => {
handleAutoCallOnCallStatusChange(newStatus, oldStatus); handleAutoCallOnCallStatusChange(newStatus, oldStatus);
handleAutoStartOnCallStatusChange(newStatus, oldStatus); handleAutoStartOnCallStatusChange(newStatus, oldStatus);
}); });
watch([autoCallEnabled, sessionReadyForQueueCount], () => { watch([autoCallEnabled, sessionReadyForQueueCount, isMainWindowVisible], () => {
if (callStatus.value !== "idle") { if (callStatus.value !== "idle") {
return; return;
} }
if (!isMainWindowVisible.value) {
pauseAutoCallCountdown("主窗口未显示");
return;
}
if (autoCallEnabled.value && sessionReadyForQueueCount.value) { if (autoCallEnabled.value && sessionReadyForQueueCount.value) {
if (autoCallTickLeft <= 0 && autoCallCountdownLeft.value <= 0) { if (autoCallTickLeft <= 0 && autoCallCountdownLeft.value <= 0) {
resetAndStartAutoCallCountdown(); resetAndStartAutoCallCountdown();
@ -1016,10 +1243,14 @@ watch([autoCallEnabled, sessionReadyForQueueCount], () => {
} }
}); });
watch([autoStartEnabled, sessionReadyForQueueCount], () => { watch([autoStartEnabled, sessionReadyForQueueCount, isMainWindowVisible], () => {
if (callStatus.value !== "calling") { if (callStatus.value !== "calling") {
return; return;
} }
if (!isMainWindowVisible.value) {
pauseAutoStartCountdown("主窗口未显示");
return;
}
if (autoStartEnabled.value && sessionReadyForQueueCount.value) { if (autoStartEnabled.value && sessionReadyForQueueCount.value) {
if (autoStartTickLeft <= 0 && autoStartCountdownLeft.value <= 0) { if (autoStartTickLeft <= 0 && autoStartCountdownLeft.value <= 0) {
resetAndStartAutoStartCountdown(); resetAndStartAutoStartCountdown();
@ -1147,7 +1378,17 @@ async function callAction(options?: {
ticketUid: callingTkt.value, ticketUid: callingTkt.value,
}); });
if (isActionSuccess(recallRes)) { if (isActionSuccess(recallRes)) {
const recallUid = getActionTicketUid(recallRes);
if (recallUid > 0) {
callingTkt.value = recallUid;
await syncActiveTicketToSession(recallUid);
}
callingTicketNoLabel.value = getActionTicketNo(recallRes); callingTicketNoLabel.value = getActionTicketNo(recallRes);
await persistInterruptedTicketCache(
"calling",
callingTkt.value,
callingTicketNoLabel.value,
);
updateLog(`已重呼:${getActionTicketNo(recallRes)},请勿重复点击!`); updateLog(`已重呼:${getActionTicketNo(recallRes)},请勿重复点击!`);
await log( await log(
"info", "info",
@ -1165,7 +1406,13 @@ async function callAction(options?: {
callStatus.value = "calling"; callStatus.value = "calling";
callBtnText.value = "重呼"; callBtnText.value = "重呼";
callingTkt.value = getActionTicketUid(res); callingTkt.value = getActionTicketUid(res);
await syncActiveTicketToSession(callingTkt.value);
callingTicketNoLabel.value = getActionTicketNo(res); callingTicketNoLabel.value = getActionTicketNo(res);
await persistInterruptedTicketCache(
"calling",
callingTkt.value,
callingTicketNoLabel.value,
);
updateLog(`正在呼叫:${getActionTicketNo(res)}`); updateLog(`正在呼叫:${getActionTicketNo(res)}`);
await log( await log(
"info", "info",
@ -1202,6 +1449,7 @@ async function abandonAction(): Promise<void> {
callBtnText.value = "呼叫"; callBtnText.value = "呼叫";
callingTkt.value = -1; callingTkt.value = -1;
callingTicketNoLabel.value = ""; callingTicketNoLabel.value = "";
await clearActiveTicketInSession();
updateLog(`弃号成功: ${getActionTicketNo(res)}`); updateLog(`弃号成功: ${getActionTicketNo(res)}`);
await emitTaxerNsrClear(); await emitTaxerNsrClear();
return; return;
@ -1237,6 +1485,18 @@ async function startAction(options?: {
if (isActionSuccess(res)) { if (isActionSuccess(res)) {
callStatus.value = "working"; callStatus.value = "working";
const startedUid = getActionTicketUid(res);
if (startedUid > 0) {
callingTkt.value = startedUid;
}
await syncActiveTicketToSession(
callingTkt.value > 0 ? callingTkt.value : startedUid,
);
await persistInterruptedTicketCache(
"working",
callingTkt.value > 0 ? callingTkt.value : startedUid,
getActionTicketNo(res),
);
updateLog(`正在办理:${getActionTicketNo(res)}`); updateLog(`正在办理:${getActionTicketNo(res)}`);
await emitTaxerTicketStart(buildTaxerContextFromAction(res)); await emitTaxerTicketStart(buildTaxerContextFromAction(res));
} }
@ -1265,6 +1525,11 @@ async function completeAction(): Promise<void> {
return; return;
} }
try {
await clearInterruptedTicketSnapshot();
} catch (error) {
await logErr("办理完成后清除异常中断缓存失败", error);
}
await emitTaxerNsrClear(); await emitTaxerNsrClear();
callStatus.value = "evaluating"; callStatus.value = "evaluating";
callBtnText.value = "呼叫"; callBtnText.value = "呼叫";
@ -1342,6 +1607,8 @@ async function pauseAction(): Promise<void> {
if (isActionSuccess(res)) { if (isActionSuccess(res)) {
callStatus.value = "idle"; callStatus.value = "idle";
pauseBtnText.value = "暂停"; pauseBtnText.value = "暂停";
lastPauseReason.value = "";
await clearActiveTicketInSession();
updateLog("已恢复待机"); updateLog("已恢复待机");
} }
}); });
@ -1374,9 +1641,16 @@ async function confirmPauseReason(reason: string): Promise<void> {
if (isActionSuccess(res)) { if (isActionSuccess(res)) {
callStatus.value = "paused"; callStatus.value = "paused";
pauseBtnText.value = "恢复"; pauseBtnText.value = "恢复";
lastPauseReason.value = pauseReason;
pauseAutoCallCountdown("暂停成功"); pauseAutoCallCountdown("暂停成功");
pauseAutoStartCountdown("暂停成功"); pauseAutoStartCountdown("暂停成功");
applyPausedLogMessage(pauseReason); applyPausedLogMessage(pauseReason);
await persistInterruptedTicketCache(
"paused",
callingTkt.value,
callingTicketNoLabel.value,
pauseReason,
);
buttonPanel.value = "main"; buttonPanel.value = "main";
return; return;
} }
@ -1409,7 +1683,7 @@ function handleButtonClick(button: ActionButton): void {
void pauseAction(); void pauseAction();
break; break;
case "transfer": case "transfer":
updateLog("转移功能暂未对接"); openTransferDialog();
break; break;
case "start": case "start":
void startAction(); void startAction();
@ -1425,6 +1699,57 @@ function handleButtonClick(button: ActionButton): void {
} }
} }
async function openTransferDialog(): Promise<void> {
try {
const session = await getSession();
const ticketUid = resolveTransferTicketUid(session, callingTkt.value);
if (ticketUid <= 0) {
void showWarningNative(
"当前无可转移的票号,请先呼叫并开始办理后再试",
"转移",
);
return;
}
callingTkt.value = ticketUid;
await setSession({
...session,
activeTicketUid: ticketUid,
transferTicketUid: ticketUid,
});
await emitTransferOpen({ ticketUid });
await minimizeWindow();
await refreshMainWindowChromeState();
await openTransferWindow();
updateLog(`票号转移窗口已打开: ticketUid=${ticketUid}`);
} catch (error) {
await logErr("打开票号转移窗口失败", error);
}
}
function applyTransferDoneFromChild(payload: {
success: boolean;
ticketNo?: string;
targetWindowName?: string;
businessName?: string;
message?: string;
}): void {
if (!payload.success) {
return;
}
callStatus.value = "idle";
callBtnText.value = "呼叫";
callingTkt.value = -1;
callingTicketNoLabel.value = "";
void clearActiveTicketInSession();
const ticketLabel = payload.ticketNo?.trim() || "—";
const destLabel =
payload.targetWindowName?.trim() ||
payload.businessName?.trim() ||
"—";
updateLog(`转移成功:${ticketLabel}${destLabel}`);
void emitTaxerNsrClear();
}
async function openMoreContextMenu(): Promise<void> { async function openMoreContextMenu(): Promise<void> {
buttonPanel.value = "more"; buttonPanel.value = "more";
} }
@ -1581,6 +1906,10 @@ onMounted(async () => {
"info", "info",
`MainView 会话: autoCallEnabled=${sessionState.value.autoCallEnabled === true}, autoCallWaitSeconds=${resolveAutoCallWaitSeconds()}, autoStartEnabled=${sessionState.value.autoStartEnabled === true}, autoStartWaitSeconds=${resolveAutoStartWaitSeconds()}`, `MainView 会话: autoCallEnabled=${sessionState.value.autoCallEnabled === true}, autoCallWaitSeconds=${resolveAutoCallWaitSeconds()}, autoStartEnabled=${sessionState.value.autoStartEnabled === true}, autoStartWaitSeconds=${resolveAutoStartWaitSeconds()}`,
); );
await refreshMainWindowVisibleState();
if (sessionReadyForQueueCount.value && isMainWindowVisible.value) {
await tryRecoverInterruptedTicket();
}
if ( if (
autoCallEnabled.value && autoCallEnabled.value &&
callStatus.value === "idle" && callStatus.value === "idle" &&
@ -1599,16 +1928,18 @@ onMounted(async () => {
await logErr("读取 session 失败", error); await logErr("读取 session 失败", error);
} }
// queue-count watch([callStatus, sessionReadyForQueueCount]) idle + token winUid start // queue-count watch idle + + start
try { try {
const visible = await appWindow.isVisible(); if (!isMainWindowVisible.value) {
await refreshMainWindowVisibleState();
}
// show isVisible false false // show isVisible false false
if (visible) { if (isMainWindowVisible.value) {
isMainWindowActive.value = true; isMainWindowActive.value = true;
} }
isMainWindowActive.value = await appWindow.isFocused(); isMainWindowActive.value = await appWindow.isFocused();
await refreshMainWindowMinimizedState(); await refreshMainWindowChromeState();
const unlistenFns: Array<() => void> = []; const unlistenFns: Array<() => void> = [];
unlistenFns.push( unlistenFns.push(
@ -1617,12 +1948,12 @@ onMounted(async () => {
if (focused) { if (focused) {
void refreshMainSessionStateFromDisk(); void refreshMainSessionStateFromDisk();
} }
void refreshMainWindowMinimizedState(); void refreshMainWindowChromeState();
}), }),
); );
unlistenFns.push( unlistenFns.push(
await appWindow.onResized(() => { await appWindow.onResized(() => {
void refreshMainWindowMinimizedState(); void refreshMainWindowChromeState();
}), }),
); );
unlistenWindowFocusChanged = () => { unlistenWindowFocusChanged = () => {
@ -1634,6 +1965,10 @@ onMounted(async () => {
await logErr("订阅主窗口焦点/尺寸事件失败", error); await logErr("订阅主窗口焦点/尺寸事件失败", error);
} }
unlistenTransferDone = await listenTransferDone((payload) => {
applyTransferDoneFromChild(payload);
});
unlistenMainAction = await listenMainAction((action, payload) => { unlistenMainAction = await listenMainAction((action, payload) => {
if (typeof payload?.ticketUid === "number" && payload.ticketUid > 0) { if (typeof payload?.ticketUid === "number" && payload.ticketUid > 0) {
callingTkt.value = payload.ticketUid; callingTkt.value = payload.ticketUid;
@ -1666,6 +2001,9 @@ onUnmounted(() => {
if (unlistenMainAction) { if (unlistenMainAction) {
unlistenMainAction(); unlistenMainAction();
} }
if (unlistenTransferDone) {
unlistenTransferDone();
}
}); });
</script> </script>
@ -1814,6 +2152,7 @@ onUnmounted(() => {
<div class="log-div" data-tauri-drag-region @dblclick.prevent.stop> <div class="log-div" data-tauri-drag-region @dblclick.prevent.stop>
<span class="log-span" data-tauri-drag-region>{{ message }}</span> <span class="log-span" data-tauri-drag-region>{{ message }}</span>
</div> </div>
</div> </div>
</template> </template>

@ -0,0 +1,504 @@
<template>
<div class="transfer-container">
<div class="transfer-header">
<div class="transfer-header-title" data-tauri-drag-region @dblclick.prevent.stop>
票号转移
</div>
<div class="transfer-header-actions">
<button class="control-button" type="button" @mousedown.stop @click="handleMinimizeClick">
<el-icon class="control-icon">
<component :is="Minus" />
</el-icon>
</button>
<button
class="control-button"
type="button"
@mousedown.stop
@dblclick.prevent.stop
@click="handleCloseClick"
>
<el-icon class="control-icon">
<component :is="Close" />
</el-icon>
</button>
</div>
</div>
<div v-loading="loading" class="transfer-body">
<p v-if="loadError" class="transfer-error">{{ loadError }}</p>
<p v-else class="transfer-hint">目标窗口与目标业务只能二选一</p>
<div v-if="!loadError" class="transfer-columns">
<section class="transfer-column">
<h3 class="transfer-column-title">目标窗口</h3>
<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"
:key="biz.businessUid"
:value="biz.businessUid"
class="transfer-radio"
>
<span class="transfer-radio-label">{{ biz.businessName }}</span>
<span v-if="biz.businessCode" class="transfer-radio-sub">
{{ biz.businessCode }}
</span>
</el-radio>
</el-radio-group>
<el-empty v-else description="暂无可选业务" :image-size="56" />
</section>
</div>
</div>
<div class="transfer-footer">
<el-button @click="handleCloseClick"></el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="loading || Boolean(loadError) || !hasTransferTarget"
@click="handleConfirm"
>
确认转移
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { Close, Minus } from "@element-plus/icons-vue";
import { appWindow } from "@tauri-apps/api/window";
import { ElButton, ElEmpty, ElRadio, ElRadioGroup } from "element-plus";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { api } from "../api";
import { showErrorNative, showWarningNative } from "../host/dialog";
import { emitTransferDone, listenTransferOpen } from "../host/events";
import { log } from "../host/logger";
import { getSession, setSession } from "../host/session";
import type { SessionState } from "../host/types";
import type { TransferBusiness } from "../types/transfer";
import type { ServiceWindow } from "../types/window";
import { closeTransferWindow, focusNamedWindow, minimizeWindow } from "../host/window";
import {
buildTransferRequest,
normalizeBusinessList,
normalizeTransferWindows,
type TransferTarget,
} from "../utils/transfer";
const loading = ref(false);
const submitting = ref(false);
const loadError = ref("");
const ticketUid = ref(-1);
const currentWindowUid = ref(0);
const windows = ref<ServiceWindow[]>([]);
const businesses = ref<TransferBusiness[]>([]);
const selectedWindowUid = 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 unlistenWindowFocus: (() => void) | null = null;
function parseOptionalNumber(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 resolveTicketUidFromSession(
session: SessionState,
explicit?: number,
): number {
if (typeof explicit === "number" && explicit > 0) {
return explicit;
}
const fromTransfer = parseOptionalNumber(session.transferTicketUid);
if (fromTransfer !== null && fromTransfer > 0) {
return fromTransfer;
}
const fromActive = parseOptionalNumber(session.activeTicketUid);
if (fromActive !== null && fromActive > 0) {
return fromActive;
}
return -1;
}
function getActionData(res: unknown): Record<string, unknown> {
const result = (res ?? {}) as { data?: Record<string, unknown> } & Record<string, unknown>;
return result.data && typeof result.data === "object" ? result.data : result;
}
function isActionSuccess(res: unknown): boolean {
return getActionData(res).success === true;
}
function getActionMessage(res: unknown): string {
return String(getActionData(res).message ?? "");
}
function getActionTicketNo(res: unknown): string {
return String(getActionData(res).ticketNo ?? "");
}
async function loadTransferOptions(explicitTicketUid?: number): Promise<void> {
loading.value = true;
loadError.value = "";
windows.value = [];
businesses.value = [];
selectedWindowUid.value = undefined;
selectedBusinessUid.value = undefined;
try {
const session = await getSession();
currentWindowUid.value = Number(session.winUid ?? 0);
ticketUid.value = resolveTicketUidFromSession(session, explicitTicketUid);
if (ticketUid.value <= 0) {
loadError.value =
"未获取到待转移票号,请先在主窗口完成呼叫并开始办理后再打开转移";
await log(
"warn",
`转移窗口: 无有效 ticketUid transferTicketUid=${String(session.transferTicketUid)} activeTicketUid=${String(session.activeTicketUid)}`,
);
return;
}
const [winRes, bizRes] = await Promise.all([
api.window.list(),
api.action.businessList(),
]);
windows.value = normalizeTransferWindows(winRes, currentWindowUid.value);
businesses.value = normalizeBusinessList(bizRes);
} catch (error) {
loadError.value = error instanceof Error ? error.message : String(error);
await log(
"error",
`转移窗口加载选项失败: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
loading.value = false;
}
}
async function handleMinimizeClick(): Promise<void> {
try {
await minimizeWindow();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await showErrorNative(message || "最小化失败", "票号转移");
}
}
async function handleCloseClick(): Promise<void> {
try {
await closeTransferWindow();
await focusNamedWindow("main");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await showErrorNative(message || "关闭失败", "票号转移");
}
}
async function handleConfirm(): Promise<void> {
const target = resolveTransferTarget();
if (!target) {
await showWarningNative(
"请选择目标窗口或目标业务(只能二选一)",
"票号转移",
);
return;
}
submitting.value = true;
try {
const session = await getSession();
const res = await api.action.transfer(
buildTransferRequest(session, ticketUid.value, target),
);
if (isActionSuccess(res)) {
await setSession({
...session,
activeTicketUid: 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({
success: true,
ticketNo: getActionTicketNo(res),
targetWindowName,
businessName,
message: getActionMessage(res),
});
await closeTransferWindow();
await focusNamedWindow("main");
return;
}
await showWarningNative(
getActionMessage(res) || "转移未成功",
"票号转移",
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await showErrorNative(message || "转移失败", "票号转移");
await log("error", `转移提交失败: ${message}`);
} finally {
submitting.value = false;
}
}
onMounted(async () => {
try {
unlistenTransferOpen = await listenTransferOpen((payload) => {
void loadTransferOptions(payload.ticketUid);
});
unlistenWindowFocus = await appWindow.onFocusChanged(({ payload: focused }) => {
if (focused) {
void loadTransferOptions();
}
});
} catch (error) {
await log(
"error",
`转移窗口订阅事件失败: ${error instanceof Error ? error.message : String(error)}`,
);
}
void loadTransferOptions();
});
onUnmounted(() => {
if (unlistenTransferOpen) {
unlistenTransferOpen();
}
if (unlistenWindowFocus) {
unlistenWindowFocus();
}
});
</script>
<style scoped lang="scss">
.transfer-container {
width: 640px;
height: 480px;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fff;
}
.transfer-header {
width: 100%;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #004d99 0%, #003b7a 100%);
color: #fff;
padding: 0 6px 0 12px;
}
.transfer-header-title {
flex: 1;
height: 100%;
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
}
.transfer-header-actions {
display: flex;
align-items: center;
}
.control-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
border-radius: 4px;
-webkit-app-region: no-drag;
border: none;
background: transparent;
cursor: pointer;
}
.control-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.control-icon {
font-size: 18px;
}
.transfer-body {
flex: 1;
min-height: 0;
padding: 16px;
overflow: hidden;
}
.transfer-error {
margin: 0;
color: #f56c6c;
font-size: 14px;
line-height: 1.5;
}
.transfer-hint {
margin: 0 0 12px;
font-size: 13px;
color: #909399;
line-height: 1.4;
}
.transfer-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: 100%;
}
.transfer-column {
min-height: 0;
padding: 12px;
border-radius: 8px;
background: #f5f7fa;
border: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
}
.transfer-column-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.transfer-radio-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.transfer-radio {
display: flex;
align-items: flex-start;
margin-right: 0;
height: auto;
white-space: normal;
}
.transfer-radio-label {
display: block;
color: #303133;
line-height: 1.4;
}
.transfer-radio-sub {
display: block;
margin-top: 2px;
font-size: 12px;
color: #909399;
line-height: 1.3;
}
.transfer-footer {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 12px 16px;
border-top: 1px solid #ebeef5;
}
</style>

@ -0,0 +1,161 @@
# 紫云广播同步屏broadcast-client产品介绍
## 1. 产品定位
**紫云广播同步屏**(工程名 `broadcast-client`)是面向政务大厅、办税服务厅等场景的**大厅信息展示与叫号播报桌面客户端**。它在办事窗口外的 LED 条屏、同步屏或综合大屏上,实时展示窗口编号、叫号提示、滚动字幕、时钟及综合业务信息,并通过 Socket 接收排队系统推送,与窗口呼叫终端联动。
产品基于 **Tauri 2 + Vue 3 + TypeScript** 构建,支持**同步屏**与**综合屏**两种展示模式提供可视化配置界面、Socket 服务、系统托盘与内网 APT 升级能力,适配 **Linux 桌面(含麒麟 V10** 的 amd64 / arm64 部署。
---
## 2. 典型应用场景
| 场景 | 说明 |
|------|------|
| 窗口条屏 | 在屏幕顶部横条展示多窗口编号与「请 XX 号办理」等动态文案 |
| 叫号联动 | 接收后台 `CALL` 类 Socket 报文,按 `windowId` 更新对应窗口区域文字 |
| 滚动字幕 | 配置横幅区域,持续滚动宣传或提示语 |
| 时钟展示 | 将某窗口区域切换为 `hh:mm` 时钟,用于非叫号时段 |
| 综合大屏 | 全屏展示大厅名称、多行叫号文字、底部字幕及可选视频轮播 |
| 多屏分工 | 通过勾选「同步屏 / 综合屏 / 语音播报」控制各通道是否启用 |
---
## 3. 核心功能
### 3.1 同步屏BroadcastView
同步屏面向**超宽横条**显示设备,核心能力包括:
**标尺与分段显示**
- 逻辑画布宽度可达数千像素(可配置),高度默认 64px 量级。
- 当物理屏幕宽度小于逻辑宽度时,按「横向切片、纵向堆叠」自动分段,保证超宽内容在窄屏上完整可见。
- 标尺刻度10px 小格、50px 中格、100px 大格,便于运维对齐物理 LED 布局。
**窗口区域**
- 可配置多个「窗口区域」,每个区域包含:
- **窗口号区域**:显示窗口编号,支持圆圈边框、字号、颜色、粗细等样式。
- **文本区域**:分为静态文本与动态文本;动态文本由 Socket 报文驱动更新。
- 支持将区域切换为**时钟模式**(仅显示时间,样式沿用动态文本配置)。
- 子元素支持跨分段连续显示,避免在切断处出现错位或缺失。
**滚动字幕**
- 独立配置字幕区域的位置、尺寸、文字内容、字号颜色与滚动速度,文字自左向右循环滚动。
**窗口行为**
- 无边框、黑色背景、置顶显示;支持最小化后从任务栏恢复。
- 右键菜单:打开配置、最小化、退出等。
### 3.2 综合屏CompositeView
综合屏面向**大厅全屏电视或投影**,布局分为:
| 区域 | 占比(约) | 内容 |
|------|------------|------|
| 头部 | 12% | 大厅名称(可配字号、颜色) |
| 中部 | 76% | 左右分栏:左侧文字叫号区;右侧可选文字区或视频轮播区 |
| 底部 | 12% | 滚动字幕 |
**文字叫号逻辑**
- 展示报文中的 `voiceText`(语音文案同步为可见文字)。
- 按配置行数从上往下堆叠:新叫号占据第一行,旧内容下移;超出行数时淘汰最早一条。
- 无视频时,左右文字区可分担溢出内容。
**视频轮播**
- 可配置多个视频 URL分号分隔、音量支持轮播播放。
**交互**
- 启动后全屏显示;**双击**在全屏与窗口化之间切换。
### 3.3 Socket 叫号服务
- 配置界面可启动本地 Socket 服务(默认监听 **9501** 端口)。
- 界面以**红绿灯**指示服务运行状态。
- 典型报文示例(`action: "CALL"`
```json
{
"action": "CALL",
"windowId": 1,
"windowName": "A12窗口",
"ledAddress": "192.168.1.100",
"timestamp": 1712567890000,
"payload": {
"ticketNumber": "A001",
"displayText": "请 A001 号办理",
"voiceText": "请 A001 号到 A12 窗口办理",
"flash": true
}
}
```
- `windowId` 对应配置中的窗口区域编号;`displayText` 更新同步屏动态文本;综合屏使用 `voiceText` 参与文字区展示。
### 3.4 可视化配置ConfigView
- 独立配置窗口:可拖动、白底、折叠面板组织各配置项。
- 支持配置:主画布尺寸、分段列表(手动添加/编辑每段长度与起点、标尺开关、窗口区域、滚动字幕、综合屏参数、Socket 与输出通道勾选等。
- 配置持久化至 `broadcast-config.json`,启动时加载并**实时生效**同步屏布局。
- 保存配置后可启动 Socket 服务;支持「同步屏 / 综合屏 / 语音播报」多选输出(语音通道按产品规划扩展)。
### 3.5 系统托盘与生命周期
- 托盘图标:**双击**激活应用;右键菜单可打开配置窗口、退出应用。
- 退出时从系统托盘移除图标,兼容麒麟 V10 等桌面环境。
- 未勾选「同步屏」时,同步屏窗口不显示且不渲染内容,仅保留综合屏或其他已启用通道。
### 3.6 日志与升级
- Socket 服务启动、停止、收包与处理过程写入本地日志(文件名含时间戳,支持大小与保留天数限制)。
- 支持内网 **APT 检查更新** 与紫云软件源自动配置(与 call-client 同源发布体系)。
---
## 4. 与呼叫终端、后台的关系
```text
排队叫号后台 ──HTTP──► call-client窗口呼叫、办理
└──Socket(9501)──► broadcast-client同步屏 / 综合屏展示)
```
- **call-client**:办事员在窗口侧发起呼叫,后台可向条屏推送叫号报文。
- **broadcast-client**:在条屏或大厅屏上呈现叫号结果,无需办事员手动刷新页面。
- 两者可独立部署:仅展示大厅时可只装广播客户端;完整方案建议组合部署。
---
## 5. 部署与运行环境
| 项目 | 说明 |
|------|------|
| 操作系统 | Linux 桌面(推荐麒麟 V10亦支持 Windows 开发调试) |
| 安装方式 | `.deb` 包或内网 APT 仓库 |
| 架构 | amd64、arm64 |
| 网络 | 需开放 Socket 监听端口(默认 9501并保证叫号推送源可达 |
| 配置目录 | `~/.config/com.ziyun.broadcastclient/broadcast-config.json` |
| 日志目录 | 同配置目录下 `socket-service-*.log` |
| 显示建议 | 同步屏超宽横屏或分段拼接屏综合屏1080p 及以上电视/投影 |
| 当前版本 | 0.1.1(以 `tauri.conf.json` 为准) |
---
## 6. 技术特点(简述)
- **分段渲染引擎**`segmentService`、`childSliceService` 等模块将超宽逻辑坐标映射到物理屏幕,保证窗口区与子元素在切段处视觉连续。
- **数据驱动 UI**:配置变更即驱动重算布局,无需重启进程即可更新同步屏。
- **多视图单入口**:通过 URL hash / query 区分同步屏、综合屏、配置页Tauri 多窗口复用同一前端构建产物。
- **轻量 Tauri 壳**:适合 7×24 小时常驻大厅设备,资源占用低于传统 Electron 方案。
---
## 7. 产品价值总结
紫云广播同步屏将排队叫号结果**自动、醒目、可配置**地呈现给大厅群众:窗口条屏解决「看哪号、去哪窗」的问题,综合屏解决「大厅级信息汇总与宣传」的问题。可视化配置与 Socket 接入降低了现场布线后的调试成本,与紫云呼叫终端共同构成**窗口办理 + 大厅引导**的完整智慧办税展示方案。

@ -0,0 +1,127 @@
# 紫云呼叫终端call-client产品介绍
## 1. 产品定位
**紫云呼叫终端**(工程名 `call-client`)是面向政务大厅、办税服务厅等场景的**窗口叫号与办理桌面客户端**。工作人员通过本终端完成选窗登录、呼叫下一位、开始/完成办理、暂停服务、票号转移与评价等操作,并与紫云排队叫号后台服务实时联动。
产品基于 **Tauri 2 + Vue 3 + TypeScript** 构建,采用轻量原生壳 + Web 前端的混合架构,在 Linux含麒麟 V10 等国产化桌面)上提供 `.deb` 安装包,支持 **amd64 / arm64** 双架构发布与内网 APT 在线升级。
---
## 2. 典型应用场景
| 场景 | 说明 |
|------|------|
| 窗口叫号 | 办事员一键呼叫下一位等候群众,支持重呼、弃号 |
| 业务办理 | 呼叫成功后开始办理,完成后进入评价流程 |
| 服务暂停 | 临时暂停窗口服务并记录暂停原因 |
| 票号调度 | 将当前票号转移至其他窗口或业务队列 |
| 票池选号 | 从票号列表中检索并指定呼叫特定票号 |
| 纳税人辅助 | 打开办税员信息页,配合实名与业务办理 |
| 大屏联动 | 可选开启主屏同步,将窗口画面推送至外显设备 |
| 异常恢复 | 应用异常退出后重新登录,可提示是否继续办理中断前的票号 |
---
## 3. 核心功能
### 3.1 登录与窗口选择
- **账号登录**:对接排队系统 `/auth/login`,获取 `queueToken` 等会话凭据。
- **服务窗口选择**:登录后从 `/windows/list` 拉取可办窗口列表,选择本机对应窗口。
- **呼号端初始化**:选窗后调用 `/call-terminal/caller-init`,加载自动叫号/自动开始、转移开关、纳税人信息 URL 等窗口级策略。
- **服务地址配置**:首次使用可配置后台服务器地址(`config.json`),支持路由守卫自动跳转配置页。
### 3.2 主叫号操作(主窗口)
主窗口为**置顶窄条工具栏**形态,常驻桌面,便于办事员快速操作:
| 操作 | 说明 |
|------|------|
| 呼叫 / 重呼 | 呼叫下一位或对已叫票号重呼 |
| 开始 | 群众到站后开始办理 |
| 完成 | 办理结束并进入评价阶段 |
| 弃号 | 放弃当前票号 |
| 暂停 / 恢复 | 窗口服务暂停与恢复 |
| 转移 | 打开票号转移子窗口,将票转至其他窗口或业务(二选一) |
| 评价 | 触发评价接口,并轮询是否已完成评价 |
**智能辅助:**
- **自动叫号**:待机状态下按配置秒数倒计时,结束后自动发起呼叫。
- **自动开始**:呼叫成功后倒计时,结束后自动开始办理。
- **等候人数**:轮询 `/call-terminal/queue-count`,在主界面日志区展示当前窗口等候人数。
- **手动打断**:用户手动点击「呼叫」「开始」时,自动停止对应倒计时,避免与人工操作冲突。
### 3.3 多窗口协同
| 窗口 | 用途 |
|------|------|
| 登录窗 | 账号登录、选窗、检查更新 |
| 主窗口 | 叫号与办理主流程 |
| 票号列表 | 票池查询、筛选、从列表发起呼叫/评价 |
| 票号转移 | 选择目标窗口或目标业务并提交转移 |
| 办税员信息 | 展示纳税人相关业务页面URL 由后台 init 下发) |
各子窗口通过 Tauri 事件总线(`emit` / `listen`)与主窗口通信;会话状态写入磁盘 `runtime_session.json`,多窗口共享。
### 3.4 异常中断恢复
呼叫或开始办理成功后,系统将**票号、票号显示名、流程状态**(呼叫中 / 办理中 / 已暂停)持久化到本地配置。若应用异常退出,用户重新登录并进入主界面后:
- 弹出**原生确认框**,询问是否继续办理中断前的票号;
- 选择「是」则恢复对应界面状态与票号上下文;
- 选择「否」则清除缓存,进入正常待机流程。
### 3.5 系统托盘与单实例
- 启动时**单实例锁**,防止重复打开多个客户端。
- **系统托盘**:快速显示/最小化主窗口、打开票号列表、退出应用。
- 主窗口支持无边框、置顶、最小化等桌面交互。
### 3.6 升级与运维
- 登录页支持**检查 APT 更新**(内网紫云软件源),可引导配置源、执行 `apt` 升级。
- 本地 **app.log** 日志轮转,错误弹窗可一键打开日志文件。
- 配置与会话路径遵循 XDG 规范(见仓库根目录 `README.md`)。
---
## 4. 与后台服务的关系
客户端通过 HTTP 与紫云排队叫号服务通信,主要接口域包括:
- 认证:`/auth/login`、`/auth/logout`
- 窗口:`/windows/list`
- 呼号端:`/call-terminal/*`(呼叫、开始、完成、暂停、恢复、弃号、评价、票池、排队统计、转移、业务列表等)
广播侧(`broadcast-client`)可通过 Socket 接收叫号推送,与呼叫终端形成**「窗口操作 + 大厅展示」**的完整闭环见《broadcast-client 产品介绍》)。
---
## 5. 部署与运行环境
| 项目 | 说明 |
|------|------|
| 操作系统 | Linux 桌面(推荐麒麟 V10 等;亦支持 Windows 开发与调试) |
| 安装方式 | `.deb` 包或内网 APT 仓库 |
| 架构 | amd64、arm64 |
| 网络 | 需能访问排队叫号后台 HTTP 服务 |
| 配置目录 | `~/.config/com.ziyun.callclient/``config.json`、`app.log` 等) |
| 会话目录 | `~/.local/share/com.ziyun.callclient/runtime_session.json` |
| 当前版本 | 0.1.6(以 `tauri.conf.json` 为准) |
---
## 6. 技术特点(简述)
- **Tauri 2 原生壳**体积小、启动快Rust 侧负责窗口、会话、日志、升级、屏幕同步等能力。
- **Vue 3 + Element Plus**业务界面组件化类型安全TypeScript
- **宿主适配层(`src/host`**:统一封装配置、会话、对话框、窗口、事件,便于维护与测试。
- **从 Electron 平滑迁移**:保留原业务语义,面向 Linux 打包与国产化环境优化。
---
## 7. 产品价值总结
紫云呼叫终端将窗口办事员的**高频叫号操作**收敛在一块常驻工具条中,减少切换成本;通过自动叫号/开始、票池选号、转移与异常恢复,提升高峰时段吞吐与业务连续性;通过与广播同步屏、综合屏联动,实现**窗口内操作、大厅外可见**的一体化办税服务体验。

@ -0,0 +1,42 @@
# 1.call-client转移功能修修改
- 转移功能只能窗口和业务二选一,不能同时选择。
- 点击确认转移后,调用/api/queue/caller/call-terminal/transfer接口请求参数如下
{
"windowUid": 0,
"empUid": 0,
"ticketUid": 0,
"targetWindowUid": 0,
"resumeToken": "string",
"targetPosition": 0,
"rank": 0,
"rankUserName": "string",
"rankUserPhone": "string",
"idCard": "string",
"phone": "string",
"serviceUrl": "string",
"forward": true,
"read": true,
"customText": "string",
"pauseReason": "string",
"driver": "string",
"clients": [
{
"address": "string",
"driver": "string",
"com": 0,
"serial": 0,
"ip": "string",
"port": "string",
"param": "string"
}
]
}
# 2.call-client异常中断处理
- 点击呼叫或开始之后票号是否有缓存为防止异常中断的情况点击呼叫或开始之后需要将票号缓存起来在异常中断重新登录并跳转到mainview之后先查看是否缓存了票号有则弹出原生提示框提示是否继续办理异常中断的号码如果点击是则继续根据缓存的票号状态进行流程如果点击否则开始正常的流程。
# 3.修改1中的接口参数
- targetPosition不用理会新增targetBusinessUid参数按窗口转移使用targetWindowUid传窗口uid同时targetBusinessUid传null反之按业务转移使用targetBusinessUid传业务id同时targetWindowUid传null。
Loading…
Cancel
Save