import {ref, computed} from 'vue'; import type { PrinterInfoDTO, PrintHtmlData, PrintResult, ClientMessage, ServerMessage, } from './printerProtocol'; /** localStorage 存「上次选中打印机」的 key(与 showPrinterSelectionDialog 共享) */ const SELECTED_PRINTER_KEY = 'lastSelectedPrinter'; /** 默认打印服务地址 */ export const DEFAULT_PRINTER_URL = 'ws://127.0.0.1:17891'; /** 打印机列表轮询间隔(ms) */ const POLL_INTERVAL = 10_000; /** 单次请求超时(ms) */ const REQUEST_TIMEOUT = 60_000; /** 重连最大间隔(ms) */ const MAX_RECONNECT_DELAY = 30_000; /** 最大重试次数 */ const MAX_RETRY_COUNT = 5; type Status = 'disconnected' | 'connecting' | 'connected' | 'retry_stopped'; type StatusListener = (status: Status) => void; type PrintersListener = (printers: PrinterInfoDTO[]) => void; type SelectListener = (printerName: string | null) => void; /** 读取持久化的「上次选中打印机」 */ function getStoredSelectedPrinter(): string { try { return localStorage.getItem(SELECTED_PRINTER_KEY) || ''; } catch { return ''; } } /** 持久化「上次选中打印机」 */ function setStoredSelectedPrinter(name: string): void { try { localStorage.setItem(SELECTED_PRINTER_KEY, name); } catch { /* 忽略隐私模式等异常 */ } } /** * 打印服务客户端(模块级单例)。 * * 管理唯一一条 WebSocket 连接,提供请求-响应配对、自动重连、打印机列表轮询。 * 悬浮球组件、showPrinterSelectionDialog、serverPrint 共享同一实例 * (同一连接、同一份打印机列表、同一选中打印机)。 * * 服务地址由 install 时的 printerHost 硬编码传入,运行期不可改。 */ export class PrinterClient { // ===== 响应式状态(供 Composable 直接暴露) ===== /** 连接状态 */ readonly status = ref('disconnected'); /** 打印机列表 */ readonly printers = ref([]); /** 当前选中的打印机 name(初始从 localStorage 读,变化时写回) */ readonly selectedPrinter = ref(null); /** 是否正在打印中 */ readonly printing = ref(false); // ===== 内部状态 ===== private ws: WebSocket | null = null; private url = ''; private manualClosed = false; private reconnectTimer: any = null; private reconnectDelay = 1000; private retryCount = 0; private pollTimer: any = null; private seq = 0; private pending = new Map void; timer: any}>(); private statusListeners = new Set(); private printersListeners = new Set(); private selectListeners = new Set(); constructor() { // 初始选中打印机从 localStorage 读(可能为空字符串,转 null) const stored = getStoredSelectedPrinter(); this.selectedPrinter.value = stored || null; } /** 当前服务地址 */ getUrl(): string { return this.url; } /** 订阅连接状态变化 */ onStatus(fn: StatusListener): () => void { this.statusListeners.add(fn); return () => this.statusListeners.delete(fn); } /** 订阅打印机列表变化 */ onPrintersChange(fn: PrintersListener): () => void { this.printersListeners.add(fn); return () => this.printersListeners.delete(fn); } /** 订阅选中打印机变化 */ onSelectChange(fn: SelectListener): () => void { this.selectListeners.add(fn); return () => this.selectListeners.delete(fn); } /** 设置选中打印机(悬浮球/选择弹框选时调),同步持久化 */ setSelectedPrinter(name: string | null): void { this.selectedPrinter.value = name; if (name) { setStoredSelectedPrinter(name); } this.selectListeners.forEach((fn) => fn(name)); } /** 连接到打印服务(地址由 install 时硬编码传入,运行期不可改)。 * 幂等:若 url 未变且当前已连接/连接中,则跳过,避免重复建连(install 与 usePrinterClient 各调一次) */ connect(url?: string): void { const targetUrl = url || this.url || DEFAULT_PRINTER_URL; const sameUrl = targetUrl === this.url; const alive = this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING); if (sameUrl && alive && !this.manualClosed) { // 同一地址且仍在连接/已连接,不重复建连 return; } this.url = targetUrl; this.manualClosed = false; this.clearReconnect(); this.retryCount = 0; this.openSocket(); } /** 断开连接(手动,不再自动重连) */ disconnect(): void { this.manualClosed = true; this.clearReconnect(); this.stopPolling(); if (this.ws) { this.ws.onclose = null; this.ws.onerror = null; this.ws.onmessage = null; this.ws.close(); this.ws = null; } this.failAllPending('连接已关闭'); this.setStatus('retry_stopped'); } /** 手动重新连接(重置重试计数器,重连失败时悬浮球「重新连接」按钮调) */ manualReconnect(): void { this.retryCount = 0; this.clearReconnect(); this.openSocket(); } /** 获取打印机列表 */ async getPrinters(): Promise { const res = await this.request<{printers: PrinterInfoDTO[]}>({type: 'getPrinters'}); const list = res?.printers || []; this.updatePrinters(list); return list; } /** 直接打印 HTML(hiprint 渲染好的 HTML) */ async printHtml(data: PrintHtmlData): Promise { const res = await this.request({type: 'printHtml', data}); return res; } // ===== 内部实现 ===== private openSocket(): void { this.setStatus('connecting'); let ws: WebSocket; try { ws = new WebSocket(this.url); } catch { this.scheduleReconnect(); return; } this.ws = ws; ws.onopen = () => { this.reconnectDelay = 1000; this.retryCount = 0; this.setStatus('connected'); // 连接成功立即拉一次打印机列表 + 开启轮询 this.getPrinters().catch(() => {}); this.startPolling(); }; ws.onmessage = (ev) => this.onMessage(ev); ws.onclose = () => { this.stopPolling(); this.failAllPending('连接已关闭'); if (!this.manualClosed) { this.scheduleReconnect(); } else { this.setStatus('disconnected'); } }; ws.onerror = () => { // 错误后通常会触发 onclose,重连由 close 处理 }; } private onMessage(ev: MessageEvent): void { let msg: ServerMessage; try { msg = JSON.parse(ev.data); } catch { return; } if (!msg || !msg.id) return; const entry = this.pending.get(msg.id); if (!entry) return; this.pending.delete(msg.id); clearTimeout(entry.timer); if (msg.type === 'printers') { entry.resolve(msg.data); } else if (msg.type === 'printResult') { entry.resolve(msg.data); } } /** 发送请求并等待响应(按 id 配对,带超时) */ private request(payload: Omit): Promise { return new Promise((resolve) => { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { // 未连接时返回失败结果(不 reject,避免业务 try/catch 噪音) resolve({success: false, error: 'INTERNAL', message: '打印服务未连接'} as any); return; } const id = String(++this.seq); const timer = setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); resolve({success: false, error: 'INTERNAL', message: '请求超时'} as any); } }, REQUEST_TIMEOUT); this.pending.set(id, {resolve: resolve as (v: any) => void, timer}); this.ws.send(JSON.stringify({...payload, id})); }); } /** 指数退避重连 */ private scheduleReconnect(): void { this.clearReconnect(); this.retryCount++; if (this.retryCount > MAX_RETRY_COUNT) { this.setStatus('retry_stopped'); return; } this.setStatus('disconnected'); this.reconnectTimer = setTimeout(() => { this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY); this.openSocket(); }, this.reconnectDelay); } private clearReconnect(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } this.reconnectDelay = 1000; } /** 定时轮询打印机列表,变化才回调 */ private startPolling(): void { this.stopPolling(); this.pollTimer = setInterval(() => { this.getPrinters().catch(() => {}); }, POLL_INTERVAL); } private stopPolling(): void { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } } private setStatus(s: Status): void { this.status.value = s; this.statusListeners.forEach((fn) => fn(s)); } /** * 更新打印机列表。 * name 集合变化时重算选中打印机: * - 上次选中的还在列表 → 保持 * - 上次选中的不在了 → fallback 默认/第一个 * - 首次拉到列表 → 选默认打印机 */ private updatePrinters(list: PrinterInfoDTO[]): void { const oldList = this.printers.value; const oldKeys = oldList.map((p) => p.name).join(','); const newKeys = list.map((p) => p.name).join(','); const nameChanged = oldKeys !== newKeys || oldList.length !== list.length; this.printers.value = list; if (nameChanged) { const current = this.selectedPrinter.value; if (current && list.some((p) => p.name === current)) { // 选中仍在列表,保持 } else if (list.length > 0) { // 选中已被移除或首次:fallback 默认/第一个 const def = list.find((p) => p.isDefault) || list[0]; this.setSelectedPrinter(def.name); } else { this.setSelectedPrinter(null); } } this.printersListeners.forEach((fn) => fn(list)); } /** 连接断开时让所有在途请求失败 */ private failAllPending(reason: string): void { this.pending.forEach((entry) => { clearTimeout(entry.timer); entry.resolve({success: false, error: 'INTERNAL', message: reason}); }); this.pending.clear(); } } /** 模块级单例:悬浮球、选择弹框、serverPrint 共享同一连接 */ export const printerClient = new PrinterClient(); /** 下拉选项计算属性(供 Composable 复用) */ export const printerOptions = computed(() => printerClient.printers.value.map((p) => ({ label: p.displayName || p.name, value: p.name, })) );