From 603e07977fc2a305c859b78e739893c97a14a65d Mon Sep 17 00:00:00 2001 From: cysamurai Date: Wed, 27 May 2026 17:34:39 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8F=AB=E5=8F=B7=EF=BC=8C?= =?UTF-8?q?=E8=BD=AC=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- call-client/package-lock.json | 4 +- call-client/package.json | 2 +- call-client/src-tauri/Cargo.lock | 2 +- call-client/src-tauri/Cargo.toml | 2 +- .../src-tauri/capabilities/default.json | 2 +- call-client/src-tauri/src/commands/window.rs | 50 +- call-client/src-tauri/src/lib.rs | 7 +- call-client/src-tauri/tauri.conf.json | 14 +- call-client/src/api/index.ts | 12 +- call-client/src/host/events.ts | 50 +- call-client/src/host/session.ts | 6 + call-client/src/host/types.ts | 23 + call-client/src/host/window.ts | 22 + call-client/src/router/index.ts | 4 +- call-client/src/types/action.ts | 36 ++ call-client/src/types/transfer.ts | 5 + call-client/src/utils/interruptedTicket.ts | 109 ++++ call-client/src/utils/transfer.ts | 125 +++++ call-client/src/views/MainView.vue | 373 ++++++++++++- call-client/src/views/TransferView.vue | 504 ++++++++++++++++++ docs/broadcast-client-产品介绍.md | 161 ++++++ docs/call-client-产品介绍.md | 127 +++++ 需求修改记录.md | 42 ++ 23 files changed, 1652 insertions(+), 30 deletions(-) create mode 100644 call-client/src/types/transfer.ts create mode 100644 call-client/src/utils/interruptedTicket.ts create mode 100644 call-client/src/utils/transfer.ts create mode 100644 call-client/src/views/TransferView.vue create mode 100644 docs/broadcast-client-产品介绍.md create mode 100644 docs/call-client-产品介绍.md create mode 100644 需求修改记录.md diff --git a/call-client/package-lock.json b/call-client/package-lock.json index b9b3d10..7285af4 100644 --- a/call-client/package-lock.json +++ b/call-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "call-client", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "call-client", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "@element-plus/icons-vue": "^2.3.2", "@tauri-apps/api": "^1", diff --git a/call-client/package.json b/call-client/package.json index ba5246e..da4cdbd 100644 --- a/call-client/package.json +++ b/call-client/package.json @@ -1,7 +1,7 @@ { "name": "call-client", "private": true, - "version": "0.1.5", + "version": "0.1.6", "type": "module", "scripts": { "dev": "vite", diff --git a/call-client/src-tauri/Cargo.lock b/call-client/src-tauri/Cargo.lock index 8c590b5..f44d9b4 100644 --- a/call-client/src-tauri/Cargo.lock +++ b/call-client/src-tauri/Cargo.lock @@ -392,7 +392,7 @@ dependencies = [ [[package]] name = "call-client" -version = "0.1.5" +version = "0.1.6" dependencies = [ "chrono", "flate2", diff --git a/call-client/src-tauri/Cargo.toml b/call-client/src-tauri/Cargo.toml index 5ee2d09..772d5b7 100644 --- a/call-client/src-tauri/Cargo.toml +++ b/call-client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "call-client" -version = "0.1.5" +version = "0.1.6" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/call-client/src-tauri/capabilities/default.json b/call-client/src-tauri/capabilities/default.json index d3add96..47613ba 100644 --- a/call-client/src-tauri/capabilities/default.json +++ b/call-client/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main", "login", "ticketList"], + "windows": ["main", "login", "ticketList", "transfer"], "permissions": [ "core:default", "core:window:allow-close", diff --git a/call-client/src-tauri/src/commands/window.rs b/call-client/src-tauri/src/commands/window.rs index 418adbf..8824f17 100644 --- a/call-client/src-tauri/src/commands/window.rs +++ b/call-client/src-tauri/src/commands/window.rs @@ -151,6 +151,52 @@ pub fn open_ticket_window(app: AppHandle) -> Result<(), String> { 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] pub fn close_ticket_window(app: AppHandle) -> Result<(), String> { 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.show(); let _ = main_window.unminimize(); + let _ = center_window_on_primary_monitor(&main_window); let _ = main_window.set_focus(); + // 隐藏登录窗而非 close,避免关闭配置里的主 WebView 导致进程退出 if let Some(login_window) = app.get_window("login") { - let _ = login_window.close(); + let _ = login_window.hide(); } Ok(()) diff --git a/call-client/src-tauri/src/lib.rs b/call-client/src-tauri/src/lib.rs index 3ddcf4d..1210c64 100644 --- a/call-client/src-tauri/src/lib.rs +++ b/call-client/src-tauri/src/lib.rs @@ -17,8 +17,9 @@ use commands::{ spawn_background_pending_call_client_update_sync, upgrade_call_client_via_apt, }, window::{ - close_taxer_info_window, close_ticket_window, ensure_main_window, focus_window, - open_login_window, open_main_window, open_taxer_info_window, open_ticket_window, quit_app, + close_taxer_info_window, close_ticket_window, close_transfer_window, ensure_main_window, + 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, }, }; @@ -128,6 +129,8 @@ pub fn run() { upgrade_call_client_via_apt, open_ticket_window, close_ticket_window, + open_transfer_window, + close_transfer_window, close_taxer_info_window, focus_window, open_main_window, diff --git a/call-client/src-tauri/tauri.conf.json b/call-client/src-tauri/tauri.conf.json index b559094..ced0e5e 100644 --- a/call-client/src-tauri/tauri.conf.json +++ b/call-client/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "../node_modules/@tauri-apps/cli/schema.json", "package": { "productName": "call-client", - "version": "0.1.5" + "version": "0.1.6" }, "build": { "beforeDevCommand": "npm run dev", @@ -53,6 +53,18 @@ "resizable": true, "decorations": false, "alwaysOnTop": true + }, + { + "label": "transfer", + "title": "票号转移", + "url": "/#/transfer", + "width": 640, + "height": 480, + "visible": false, + "resizable": false, + "maximizable": false, + "decorations": false, + "alwaysOnTop": true } ], "systemTray": { diff --git a/call-client/src/api/index.ts b/call-client/src/api/index.ts index 5cb91cb..848c8d3 100644 --- a/call-client/src/api/index.ts +++ b/call-client/src/api/index.ts @@ -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 { IsRankData, IsRankRequest, @@ -69,5 +75,9 @@ export const api = { http.get("/call-terminal/is-rank", params), getQueueCount: (params: QueueCountRequest) => http.get("/call-terminal/queue-count", params), + businessList: () => + http.get("/call-terminal/business-list"), + transfer: (data: TransferRequest) => + http.post("/call-terminal/transfer", data), }, }; diff --git a/call-client/src/host/events.ts b/call-client/src/host/events.ts index 8d06c85..7ce8447 100644 --- a/call-client/src/host/events.ts +++ b/call-client/src/host/events.ts @@ -1,8 +1,14 @@ 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 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_NSR_CLEAR_EVENT = "taxer:nsr-clear"; 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 { + 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 { + 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(TRANSFER_MAIN_EVENT, (event) => { + handler(event.payload); + }); + } catch (error) { + throw new Error(`订阅转移结果事件失败: ${String(error)}`); + } +} + /** * 向办税员窗口广播当前呼叫票号上下文。 */ diff --git a/call-client/src/host/session.ts b/call-client/src/host/session.ts index 02d86d8..8d8c34f 100644 --- a/call-client/src/host/session.ts +++ b/call-client/src/host/session.ts @@ -24,6 +24,8 @@ const DEFAULT_SESSION: SessionState = { ziyunServiceUrl: null, bsNsrUrl: null, bsTaxAuthorityNum: null, + activeTicketUid: null, + transferTicketUid: null, taxer_compare_code: null, taxer_ticket_id: 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 bsTaxAuthorityNum = typeof source.bsTaxAuthorityNum === "string" ? source.bsTaxAuthorityNum : null; + const activeTicketUid = normalizeOptionalUid(source.activeTicketUid); + const transferTicketUid = normalizeOptionalUid(source.transferTicketUid); const taxer_compare_code = typeof source.taxer_compare_code === "string" ? source.taxer_compare_code @@ -129,6 +133,8 @@ function normalizeSession(raw: unknown): SessionState { ziyunServiceUrl, bsNsrUrl, bsTaxAuthorityNum, + activeTicketUid, + transferTicketUid, taxer_compare_code, taxer_ticket_id, taxer_use_doc_fields, diff --git a/call-client/src/host/types.ts b/call-client/src/host/types.ts index f3c493f..7b6e3e9 100644 --- a/call-client/src/host/types.ts +++ b/call-client/src/host/types.ts @@ -25,6 +25,10 @@ export interface SessionState { bsNsrUrl?: string | null; /** 税务机关代码模板占位(来自 init 的 BS_TAX_AUTHORITY_NUM) */ bsTaxAuthorityNum?: string | null; + /** 当前办理中的票 UID(呼叫/开始后写入,弃号/转移成功等场景清空) */ + activeTicketUid?: number | null; + /** 主窗口打开转移子窗口时写入,转移完成或取消后清空 */ + transferTicketUid?: number | null; taxer_compare_code?: string | null; taxer_ticket_id?: string | null; taxer_use_doc_fields?: boolean | null; @@ -60,6 +64,14 @@ export interface TicketActionPayload { tktNum?: string; } +export interface TransferDonePayload { + success: boolean; + ticketNo?: string; + targetWindowName?: string; + businessName?: string; + message?: string; +} + export interface TaxerTicketContextPayload { ticketNo?: string; sfzhm?: string; @@ -79,4 +91,15 @@ export interface PendingCallClientUpdateStored { 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"; diff --git a/call-client/src/host/window.ts b/call-client/src/host/window.ts index 711728f..3a86518 100644 --- a/call-client/src/host/window.ts +++ b/call-client/src/host/window.ts @@ -45,6 +45,28 @@ export async function startWindowDragging(): Promise { } } +/** + * 打开或聚焦票号转移窗口。 + */ +export async function openTransferWindow(): Promise { + try { + await invoke("open_transfer_window"); + } catch (error) { + throw new Error(`打开票号转移窗口失败: ${String(error)}`); + } +} + +/** + * 关闭(隐藏)票号转移窗口。 + */ +export async function closeTransferWindow(): Promise { + try { + await invoke("close_transfer_window"); + } catch (error) { + throw new Error(`关闭票号转移窗口失败: ${String(error)}`); + } +} + /** * 打开或聚焦票号列表窗口。 */ diff --git a/call-client/src/router/index.ts b/call-client/src/router/index.ts index f95b84c..305313c 100644 --- a/call-client/src/router/index.ts +++ b/call-client/src/router/index.ts @@ -3,6 +3,7 @@ import { getAllConfig } from "../host/config"; import type { AppConfig } from "../host/types"; import { applyServerIpToHttp } from "../utils/service"; import TicketListView from "../views/TicketListView.vue"; +import TransferView from "../views/TransferView.vue"; const LoginView = () => import("../views/LoginView.vue"); const MainView = () => import("../views/MainView.vue"); @@ -24,6 +25,7 @@ const routes: RouteRecordRaw[] = [ { path: "/main", name: "main", component: MainView }, { path: "/taxerInfo", name: "taxerInfo", component: TaxerInfoView }, { path: "/ticketList", name: "ticketList", component: TicketListView }, + { path: "/transfer", name: "transfer", component: TransferView }, // 避免在窗口初始 URL/hash 不匹配时出现空白页面 { path: "/:pathMatch(.*)*", redirect: "/ticketList" }, ]; @@ -35,7 +37,7 @@ export const router = createRouter({ router.beforeEach(async (to, _from, next) => { // 票号列表窗口由主窗口显式打开,优先保证路由可渲染,避免守卫异步阻塞导致白屏。 - if (to.path === "/ticketList" || to.path === "/taxerInfo") { + if (to.path === "/ticketList" || to.path === "/taxerInfo" || to.path === "/transfer") { next(); return; } diff --git a/call-client/src/types/action.ts b/call-client/src/types/action.ts index fbe6067..8127bb3 100644 --- a/call-client/src/types/action.ts +++ b/call-client/src/types/action.ts @@ -36,3 +36,39 @@ export interface PauseRequest { empUid: number; 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[]; +} diff --git a/call-client/src/types/transfer.ts b/call-client/src/types/transfer.ts new file mode 100644 index 0000000..446d382 --- /dev/null +++ b/call-client/src/types/transfer.ts @@ -0,0 +1,5 @@ +export interface TransferBusiness { + businessUid: number; + businessName: string; + businessCode?: string; +} diff --git a/call-client/src/utils/interruptedTicket.ts b/call-client/src/utils/interruptedTicket.ts new file mode 100644 index 0000000..8ae8222 --- /dev/null +++ b/call-client/src/utils/interruptedTicket.ts @@ -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; + 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 { + try { + const config = await getAllConfig(); + return parseSnapshot(config[INTERRUPTED_TICKET_CONFIG_KEY]); + } catch { + return null; + } +} + +/** 写入异常中断票号缓存 */ +export async function saveInterruptedTicketSnapshot( + snapshot: InterruptedTicketSnapshot, +): Promise { + await mergeConfig({ + [INTERRUPTED_TICKET_CONFIG_KEY]: { + ...snapshot, + savedAt: snapshot.savedAt > 0 ? snapshot.savedAt : Date.now(), + }, + }); +} + +/** 清除异常中断票号缓存 */ +export async function clearInterruptedTicketSnapshot(): Promise { + 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; + } +} diff --git a/call-client/src/utils/transfer.ts b/call-client/src/utils/transfer.ts new file mode 100644 index 0000000..32c3bb1 --- /dev/null +++ b/call-client/src/utils/transfer.ts @@ -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; + 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; + 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, + }; +} diff --git a/call-client/src/views/MainView.vue b/call-client/src/views/MainView.vue index a7f6c89..5730770 100644 --- a/call-client/src/views/MainView.vue +++ b/call-client/src/views/MainView.vue @@ -32,8 +32,18 @@ import { emitTaxerNsrClear, emitTaxerTicketContext, emitTaxerTicketStart, + emitTransferOpen, listenMainAction, + listenTransferDone, } from "../host/events"; +import { + clearInterruptedTicketSnapshot, + interruptedFlowStatusLabel, + readInterruptedTicketSnapshot, + saveInterruptedTicketSnapshot, + type InterruptedFlowStatus, + type InterruptedTicketSnapshot, +} from "../utils/interruptedTicket"; import { buildTaxerContextFromActionData } from "../utils/taxerTicketContext"; import { log } from "../host/logger"; import { getSession, setSession } from "../host/session"; @@ -43,6 +53,7 @@ import { minimizeWindow, openTaxerInfoWindow, openTicketListWindow, + openTransferWindow, quitApplication, } from "../host/window"; import type { ActionButton } from "../types/action"; @@ -110,6 +121,10 @@ let autoStartTickLeft = 0; const autoStartCountdownLeft = ref(0); /** 当前呼叫票号展示(自动开始倒计时文案) */ const callingTicketNoLabel = ref(""); +/** 最近一次暂停原因(异常中断恢复「已暂停」时用于日志展示) */ +const lastPauseReason = ref(""); +/** 本会话是否已处理过异常中断恢复提示(避免重复弹窗) */ +const interruptRecoveryPrompted = ref(false); const EVALUATING_COUNTDOWN_SEC = 15; const DEFAULT_AUTO_CALL_WAIT_SECONDS = 5; @@ -122,7 +137,10 @@ const pauseReasonOptions = ["午休", "休息一下", "整理资料", "其他"]; const isMainWindowActive = ref(true); /** 主窗口是否已最小化(自动叫号仅在非最小化时运行) */ const isMainWindowMinimized = ref(false); +/** 主窗口是否已显示(登录前不创建主窗口;隐藏态不轮询等候人数) */ +const isMainWindowVisible = ref(false); const buttonPanel = ref<"main" | "more" | "pause">("main"); +let unlistenTransferDone: (() => void) | null = null; const isSyncingMainScreen = ref(false); const isActionPending = ref(false); @@ -139,6 +157,15 @@ function resolveAutoCallWaitSeconds(): number { return DEFAULT_AUTO_CALL_WAIT_SECONDS; } +async function refreshMainWindowVisibleState(): Promise { + 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 { try { const minimized = await appWindow.isMinimized(); @@ -158,6 +185,11 @@ async function refreshMainWindowMinimizedState(): Promise { } } +async function refreshMainWindowChromeState(): Promise { + await refreshMainWindowVisibleState(); + await refreshMainWindowMinimizedState(); +} + /** caller-init 缓存:为 true 且在 calling 时自动倒计时并开始办理 */ const autoStartEnabled = computed( () => sessionState.value.autoStartEnabled === true, @@ -651,8 +683,180 @@ async function emitTaxerContextFromAction(res: unknown): Promise { * 提取票据 UID。 */ function getActionTicketUid(res: unknown): number { - const value = getActionData(res).ticketUid; - return typeof value === "number" ? value : -1; + const parsed = parseOptionalNumber(getActionData(res).ticketUid); + return parsed !== null && parsed > 0 ? parsed : -1; +} + +async function syncActiveTicketToSession(ticketUid: number): Promise { + if (ticketUid <= 0) { + return; + } + const session = await getSession(); + await setSession({ + ...session, + activeTicketUid: ticketUid, + }); +} + +async function clearActiveTicketInSession(): Promise { + 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 { + 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 { + 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 { @@ -793,7 +997,9 @@ function startEvaluatingCountdown(prefixText: string): void { callStatus.value = "idle"; callBtnText.value = "呼叫"; callingTkt.value = -1; + callingTicketNoLabel.value = ""; message.value = "欢迎使用紫云呼叫终端"; + void clearActiveTicketInSession(); void log("info", "评价倒计时结束,已停止 isRank 轮询并进入待机"); } else { message.value = evaluatingPrefixText.value; @@ -828,7 +1034,9 @@ async function pollIsRankOnce(): Promise { callStatus.value = "idle"; callBtnText.value = "呼叫"; callingTkt.value = -1; + callingTicketNoLabel.value = ""; message.value = "欢迎使用紫云呼叫终端"; + await clearActiveTicketInSession(); await log("info", "isRank: 评价完成,进入待机"); return; } @@ -960,21 +1168,23 @@ function startQueueCountPolling(): void { } watch( - [callStatus, sessionReadyForQueueCount], - ([status, ready]) => { + [callStatus, sessionReadyForQueueCount, isMainWindowVisible], + ([status, ready, visible]) => { if (status === "evaluating" && isMainWindowActive.value) { startIsRankPolling(); } else { clearIsRankPolling(); } - if (status === "idle" && ready) { + if (status === "idle" && ready && visible) { startQueueCountPolling(); } else { clearQueueCountPolling( - status !== "idle" - ? `callStatus=${status}` - : "会话未就绪(需 queueToken 且 winUid,选窗并打开主窗口后再轮询)", + !visible + ? "主窗口未显示(登录成功并打开主窗口后再轮询)" + : status !== "idle" + ? `callStatus=${status}` + : "会话未就绪(需 queueToken 且 winUid,选窗并登录后再轮询)", ); } }, @@ -996,15 +1206,32 @@ watch(isMainWindowMinimized, () => { syncAutoStartCountdown(); }); +watch(isMainWindowVisible, (visible) => { + if (!visible) { + clearQueueCountPolling("主窗口已隐藏"); + return; + } + void (async () => { + await refreshMainSessionStateFromDisk(); + if (sessionReadyForQueueCount.value) { + await tryRecoverInterruptedTicket(); + } + })(); +}); + watch(callStatus, (newStatus, oldStatus) => { handleAutoCallOnCallStatusChange(newStatus, oldStatus); handleAutoStartOnCallStatusChange(newStatus, oldStatus); }); -watch([autoCallEnabled, sessionReadyForQueueCount], () => { +watch([autoCallEnabled, sessionReadyForQueueCount, isMainWindowVisible], () => { if (callStatus.value !== "idle") { return; } + if (!isMainWindowVisible.value) { + pauseAutoCallCountdown("主窗口未显示"); + return; + } if (autoCallEnabled.value && sessionReadyForQueueCount.value) { if (autoCallTickLeft <= 0 && autoCallCountdownLeft.value <= 0) { resetAndStartAutoCallCountdown(); @@ -1016,10 +1243,14 @@ watch([autoCallEnabled, sessionReadyForQueueCount], () => { } }); -watch([autoStartEnabled, sessionReadyForQueueCount], () => { +watch([autoStartEnabled, sessionReadyForQueueCount, isMainWindowVisible], () => { if (callStatus.value !== "calling") { return; } + if (!isMainWindowVisible.value) { + pauseAutoStartCountdown("主窗口未显示"); + return; + } if (autoStartEnabled.value && sessionReadyForQueueCount.value) { if (autoStartTickLeft <= 0 && autoStartCountdownLeft.value <= 0) { resetAndStartAutoStartCountdown(); @@ -1147,7 +1378,17 @@ async function callAction(options?: { ticketUid: callingTkt.value, }); if (isActionSuccess(recallRes)) { + const recallUid = getActionTicketUid(recallRes); + if (recallUid > 0) { + callingTkt.value = recallUid; + await syncActiveTicketToSession(recallUid); + } callingTicketNoLabel.value = getActionTicketNo(recallRes); + await persistInterruptedTicketCache( + "calling", + callingTkt.value, + callingTicketNoLabel.value, + ); updateLog(`已重呼:${getActionTicketNo(recallRes)},请勿重复点击!`); await log( "info", @@ -1165,7 +1406,13 @@ async function callAction(options?: { callStatus.value = "calling"; callBtnText.value = "重呼"; callingTkt.value = getActionTicketUid(res); + await syncActiveTicketToSession(callingTkt.value); callingTicketNoLabel.value = getActionTicketNo(res); + await persistInterruptedTicketCache( + "calling", + callingTkt.value, + callingTicketNoLabel.value, + ); updateLog(`正在呼叫:${getActionTicketNo(res)}`); await log( "info", @@ -1202,6 +1449,7 @@ async function abandonAction(): Promise { callBtnText.value = "呼叫"; callingTkt.value = -1; callingTicketNoLabel.value = ""; + await clearActiveTicketInSession(); updateLog(`弃号成功: ${getActionTicketNo(res)}`); await emitTaxerNsrClear(); return; @@ -1237,6 +1485,18 @@ async function startAction(options?: { if (isActionSuccess(res)) { 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)}`); await emitTaxerTicketStart(buildTaxerContextFromAction(res)); } @@ -1265,6 +1525,11 @@ async function completeAction(): Promise { return; } + try { + await clearInterruptedTicketSnapshot(); + } catch (error) { + await logErr("办理完成后清除异常中断缓存失败", error); + } await emitTaxerNsrClear(); callStatus.value = "evaluating"; callBtnText.value = "呼叫"; @@ -1342,6 +1607,8 @@ async function pauseAction(): Promise { if (isActionSuccess(res)) { callStatus.value = "idle"; pauseBtnText.value = "暂停"; + lastPauseReason.value = ""; + await clearActiveTicketInSession(); updateLog("已恢复待机"); } }); @@ -1374,9 +1641,16 @@ async function confirmPauseReason(reason: string): Promise { if (isActionSuccess(res)) { callStatus.value = "paused"; pauseBtnText.value = "恢复"; + lastPauseReason.value = pauseReason; pauseAutoCallCountdown("暂停成功"); pauseAutoStartCountdown("暂停成功"); applyPausedLogMessage(pauseReason); + await persistInterruptedTicketCache( + "paused", + callingTkt.value, + callingTicketNoLabel.value, + pauseReason, + ); buttonPanel.value = "main"; return; } @@ -1409,7 +1683,7 @@ function handleButtonClick(button: ActionButton): void { void pauseAction(); break; case "transfer": - updateLog("转移功能暂未对接"); + openTransferDialog(); break; case "start": void startAction(); @@ -1425,6 +1699,57 @@ function handleButtonClick(button: ActionButton): void { } } +async function openTransferDialog(): Promise { + 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 { buttonPanel.value = "more"; } @@ -1581,6 +1906,10 @@ onMounted(async () => { "info", `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 ( autoCallEnabled.value && callStatus.value === "idle" && @@ -1599,16 +1928,18 @@ onMounted(async () => { await logErr("读取 session 失败", error); } - // queue-count 由 watch([callStatus, sessionReadyForQueueCount]) 在「idle + 已有 token 且 winUid」后启动,勿在此处强行 start。 + // queue-count 由 watch 在「idle + 会话就绪 + 主窗口已显示」后启动,勿在此处强行 start。 try { - const visible = await appWindow.isVisible(); + if (!isMainWindowVisible.value) { + await refreshMainWindowVisibleState(); + } // 部分环境刚 show 时 isVisible 仍为 false,不强行置 false,否则等候人数轮询会被立刻关掉。 - if (visible) { + if (isMainWindowVisible.value) { isMainWindowActive.value = true; } isMainWindowActive.value = await appWindow.isFocused(); - await refreshMainWindowMinimizedState(); + await refreshMainWindowChromeState(); const unlistenFns: Array<() => void> = []; unlistenFns.push( @@ -1617,12 +1948,12 @@ onMounted(async () => { if (focused) { void refreshMainSessionStateFromDisk(); } - void refreshMainWindowMinimizedState(); + void refreshMainWindowChromeState(); }), ); unlistenFns.push( await appWindow.onResized(() => { - void refreshMainWindowMinimizedState(); + void refreshMainWindowChromeState(); }), ); unlistenWindowFocusChanged = () => { @@ -1634,6 +1965,10 @@ onMounted(async () => { await logErr("订阅主窗口焦点/尺寸事件失败", error); } + unlistenTransferDone = await listenTransferDone((payload) => { + applyTransferDoneFromChild(payload); + }); + unlistenMainAction = await listenMainAction((action, payload) => { if (typeof payload?.ticketUid === "number" && payload.ticketUid > 0) { callingTkt.value = payload.ticketUid; @@ -1666,6 +2001,9 @@ onUnmounted(() => { if (unlistenMainAction) { unlistenMainAction(); } + if (unlistenTransferDone) { + unlistenTransferDone(); + } }); @@ -1814,6 +2152,7 @@ onUnmounted(() => {
{{ message }}
+ diff --git a/call-client/src/views/TransferView.vue b/call-client/src/views/TransferView.vue new file mode 100644 index 0000000..92a3d63 --- /dev/null +++ b/call-client/src/views/TransferView.vue @@ -0,0 +1,504 @@ + + + + + diff --git a/docs/broadcast-client-产品介绍.md b/docs/broadcast-client-产品介绍.md new file mode 100644 index 0000000..253091f --- /dev/null +++ b/docs/broadcast-client-产品介绍.md @@ -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 接入降低了现场布线后的调试成本,与紫云呼叫终端共同构成**窗口办理 + 大厅引导**的完整智慧办税展示方案。 diff --git a/docs/call-client-产品介绍.md b/docs/call-client-产品介绍.md new file mode 100644 index 0000000..dae374e --- /dev/null +++ b/docs/call-client-产品介绍.md @@ -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. 产品价值总结 + +紫云呼叫终端将窗口办事员的**高频叫号操作**收敛在一块常驻工具条中,减少切换成本;通过自动叫号/开始、票池选号、转移与异常恢复,提升高峰时段吞吐与业务连续性;通过与广播同步屏、综合屏联动,实现**窗口内操作、大厅外可见**的一体化办税服务体验。 diff --git a/需求修改记录.md b/需求修改记录.md new file mode 100644 index 0000000..47a423e --- /dev/null +++ b/需求修改记录.md @@ -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。