فهرست منبع

Merge branch 'feature/print' into develop

cc12458 2 هفته پیش
والد
کامیت
54d24a945b

+ 17 - 1
@types/bridge.d.ts

@@ -6,7 +6,15 @@ interface ScanData {
 }
 
 export interface BridgeEventMap {
-  scan: CustomEvent<{code: number, data?: ScanData, message?: string}>;
+  scan: CustomEvent<{ code: number; data?: ScanData; message?: string }>;
+  ['print:connect']: CustomEvent<{ code: number; data?: BridgePrinterDevice | true; message?: string }>;
+  ['print:disconnect']: CustomEvent<{ code: number; data?: BridgePrinterDevice | true; message?: string }>;
+}
+
+export interface BridgePrinterDevice {
+  type: 'wifi';
+  ip?: string;
+  port?: number;
 }
 
 export class Bridge extends EventTarget {
@@ -14,6 +22,7 @@ export class Bridge extends EventTarget {
 
   public static print(): Promise<void>;
   public static print(params: { url?: string }): Promise<void>;
+  public static print(params: { tspl: String; device?: BridgePrinterDevice }): Promise<void>;
 
   public static scan(params?: { timeout?: number; signal?: AbortSignal }): Promise<ScanData>;
 
@@ -25,4 +34,11 @@ export class Bridge extends EventTarget {
    */
   addEventListener<T extends keyof BridgeEventMap>(type: T, listener: (event: BridgeEventMap[T]) => void, options?: boolean | AddEventListenerOptions): () => void;
   removeEventListener<T extends keyof BridgeEventMap>(type: T, listener: (event: BridgeEventMap[T]) => void, options?: boolean | AddEventListenerOptions): () => void;
+
+  postMessage(...args: any[]): Promise<void>;
+
+  public printer: {
+    connect(device: BridgePrinterDevice): Promise<[device: BridgePrinterDevice, onCleanup: () => Promise<BridgePrinterDevice>]>;
+    disconnect(device: BridgePrinterDevice): Promise<BridgePrinterDevice>;
+  };
 }

+ 1 - 0
@types/global.d.ts

@@ -14,6 +14,7 @@ declare global {
   }
 
   type BridgeEventMap = import('./bridge').BridgeEventMap;
+  type BridgePrinterDevice = import('./bridge').BridgePrinterDevice;
 
   /**
    * webview 设备注入的 全局对象(历史遗留)

+ 4 - 0
package.json

@@ -24,11 +24,14 @@
     "@vueuse/components": "^13.0.0",
     "@vueuse/core": "^13.0.0",
     "@vueuse/router": "^13.4.0",
+    "@zumer/snapdom": "^1.8.0",
     "alova": "^3.2.10",
     "big.js": "^6.2.2",
     "eruda": "^3.4.1",
+    "jsbarcode": "^3.12.1",
     "pinia": "^3.0.1",
     "pinia-plugin-persistedstate": "^4.2.0",
+    "qrcode": "^1.5.4",
     "tailwindcss": "^4.0.15",
     "vant": "^4.9.18",
     "vconsole": "^3.15.1",
@@ -39,6 +42,7 @@
     "@tsconfig/node22": "^22.0.0",
     "@types/jsdom": "^21.1.7",
     "@types/node": "^22.13.9",
+    "@types/qrcode": "^1.5.5",
     "@vant/auto-import-resolver": "^1.3.0",
     "@vitejs/plugin-legacy": "^6.0.2",
     "@vitejs/plugin-vue": "^5.2.1",

+ 250 - 0
pnpm-lock.yaml

@@ -17,9 +17,15 @@ importers:
       '@vueuse/core':
         specifier: ^13.0.0
         version: 13.0.0(vue@3.5.13(typescript@5.8.2))
+      '@vueuse/integrations':
+        specifier: ^13.5.0
+        version: 13.5.0(axios@1.8.4)(qrcode@1.5.4)(vue@3.5.13(typescript@5.8.2))
       '@vueuse/router':
         specifier: ^13.4.0
         version: 13.4.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
+      '@zumer/snapdom':
+        specifier: ^1.8.0
+        version: 1.8.0
       alova:
         specifier: ^3.2.10
         version: 3.2.10
@@ -29,12 +35,18 @@ importers:
       eruda:
         specifier: ^3.4.1
         version: 3.4.1
+      jsbarcode:
+        specifier: ^3.12.1
+        version: 3.12.1
       pinia:
         specifier: ^3.0.1
         version: 3.0.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
       pinia-plugin-persistedstate:
         specifier: ^4.2.0
         version: 4.2.0(pinia@3.0.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)))
+      qrcode:
+        specifier: ^1.5.4
+        version: 1.5.4
       tailwindcss:
         specifier: ^4.0.15
         version: 4.0.15
@@ -60,6 +72,9 @@ importers:
       '@types/node':
         specifier: ^22.13.9
         version: 22.13.12
+      '@types/qrcode':
+        specifier: ^1.5.5
+        version: 1.5.5
       '@vant/auto-import-resolver':
         specifier: ^1.3.0
         version: 1.3.0
@@ -1259,6 +1274,9 @@ packages:
   '@types/node@22.13.12':
     resolution: {integrity: sha512-ixiWrCSRi33uqBMRuICcKECW7rtgY43TbsHDpM2XK7lXispd48opW+0IXrBVxv9NMhaz/Ue9kyj6r3NTVyXm8A==}
 
+  '@types/qrcode@1.5.5':
+    resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==}
+
   '@types/sinonjs__fake-timers@8.1.1':
     resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==}
 
@@ -1518,9 +1536,59 @@ packages:
     peerDependencies:
       vue: ^3.5.0
 
+  '@vueuse/core@13.5.0':
+    resolution: {integrity: sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g==}
+    peerDependencies:
+      vue: ^3.5.0
+
+  '@vueuse/integrations@13.5.0':
+    resolution: {integrity: sha512-7RACJySnlpl0MkSzxbtadioNGSX4TL5/Wl2cUy4nDq/XkeHwPYvVM880HJUSiap/FXhVEup9VKTM9y/n5UspAw==}
+    peerDependencies:
+      async-validator: ^4
+      axios: ^1
+      change-case: ^5
+      drauu: ^0.4
+      focus-trap: ^7
+      fuse.js: ^7
+      idb-keyval: ^6
+      jwt-decode: ^4
+      nprogress: ^0.2
+      qrcode: ^1.5
+      sortablejs: ^1
+      universal-cookie: ^7 || ^8
+      vue: ^3.5.0
+    peerDependenciesMeta:
+      async-validator:
+        optional: true
+      axios:
+        optional: true
+      change-case:
+        optional: true
+      drauu:
+        optional: true
+      focus-trap:
+        optional: true
+      fuse.js:
+        optional: true
+      idb-keyval:
+        optional: true
+      jwt-decode:
+        optional: true
+      nprogress:
+        optional: true
+      qrcode:
+        optional: true
+      sortablejs:
+        optional: true
+      universal-cookie:
+        optional: true
+
   '@vueuse/metadata@13.0.0':
     resolution: {integrity: sha512-TRNksqmvtvqsuHf7bbgH9OSXEV2b6+M3BSN4LR5oxWKykOFT9gV78+C2/0++Pq9KCp9KQ1OQDPvGlWNQpOb2Mw==}
 
+  '@vueuse/metadata@13.5.0':
+    resolution: {integrity: sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw==}
+
   '@vueuse/router@13.4.0':
     resolution: {integrity: sha512-3NL9RFgTiWN4SVp6CUFK/9db10BnoLU3iX+TRgcG4HEuR7GC1g+yMqxe33L6kjUTv8Dc9WeaER714vGBc9Xyjg==}
     peerDependencies:
@@ -1537,6 +1605,14 @@ packages:
     peerDependencies:
       vue: ^3.5.0
 
+  '@vueuse/shared@13.5.0':
+    resolution: {integrity: sha512-K7GrQIxJ/ANtucxIXbQlUHdB0TPA8c+q5i+zbrjxuhJCnJ9GtBg75sBSnvmLSxHKPg2Yo8w62PWksl9kwH0Q8g==}
+    peerDependencies:
+      vue: ^3.5.0
+
+  '@zumer/snapdom@1.8.0':
+    resolution: {integrity: sha512-+xjVImca5k/nUxh0yUcdwcRzLFwSzNwzEfzerlUIC8IOnW+ikengvZuSODUXdOi+T7xuLgpj0HTXf+cRjWW/0w==}
+
   abbrev@2.0.0:
     resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -1749,6 +1825,10 @@ packages:
     resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
     engines: {node: '>=6'}
 
+  camelcase@5.3.1:
+    resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+    engines: {node: '>=6'}
+
   caniuse-lite@1.0.30001707:
     resolution: {integrity: sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==}
 
@@ -1802,6 +1882,9 @@ packages:
     resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
     engines: {node: '>=8'}
 
+  cliui@6.0.0:
+    resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
   color-convert@2.0.1:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
     engines: {node: '>=7.0.0'}
@@ -1922,6 +2005,10 @@ packages:
       supports-color:
         optional: true
 
+  decamelize@1.2.0:
+    resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+    engines: {node: '>=0.10.0'}
+
   decimal.js@10.5.0:
     resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==}
 
@@ -1961,6 +2048,9 @@ packages:
     resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
     engines: {node: '>=8'}
 
+  dijkstrajs@1.0.3:
+    resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
   dotenv@16.4.7:
     resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
     engines: {node: '>=12'}
@@ -2226,6 +2316,10 @@ packages:
     resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
     engines: {node: '>=8'}
 
+  find-up@4.1.0:
+    resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+    engines: {node: '>=8'}
+
   find-up@5.0.0:
     resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
     engines: {node: '>=10'}
@@ -2280,6 +2374,10 @@ packages:
     resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
     engines: {node: '>=6.9.0'}
 
+  get-caller-file@2.0.5:
+    resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+    engines: {node: 6.* || 8.* || >= 10.*}
+
   get-intrinsic@1.3.0:
     resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
     engines: {node: '>= 0.4'}
@@ -2555,6 +2653,9 @@ packages:
     resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
     hasBin: true
 
+  jsbarcode@3.12.1:
+    resolution: {integrity: sha512-QZQSqIknC2Rr/YOUyOkCBqsoiBAOTYK+7yNN3JsqfoUtJtkazxNw1dmPpxuv7VVvqW13kA3/mKiLq+s/e3o9hQ==}
+
   jsbn@0.1.1:
     resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==}
 
@@ -2709,6 +2810,10 @@ packages:
     resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==}
     engines: {node: '>=14'}
 
+  locate-path@5.0.0:
+    resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+    engines: {node: '>=8'}
+
   locate-path@6.0.0:
     resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
     engines: {node: '>=10'}
@@ -2906,10 +3011,18 @@ packages:
     engines: {node: '>=8.*'}
     hasBin: true
 
+  p-limit@2.3.0:
+    resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+    engines: {node: '>=6'}
+
   p-limit@3.1.0:
     resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
     engines: {node: '>=10'}
 
+  p-locate@4.1.0:
+    resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+    engines: {node: '>=8'}
+
   p-locate@5.0.0:
     resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
     engines: {node: '>=10'}
@@ -2918,6 +3031,10 @@ packages:
     resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
     engines: {node: '>=10'}
 
+  p-try@2.2.0:
+    resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+    engines: {node: '>=6'}
+
   package-json-from-dist@1.0.1:
     resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
 
@@ -3023,6 +3140,10 @@ packages:
   pkg-types@2.1.0:
     resolution: {integrity: sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==}
 
+  pngjs@5.0.0:
+    resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+    engines: {node: '>=10.13.0'}
+
   postcss-selector-parser@6.1.2:
     resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
     engines: {node: '>=4'}
@@ -3077,6 +3198,11 @@ packages:
     resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
     engines: {node: '>=6'}
 
+  qrcode@1.5.4:
+    resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+    engines: {node: '>=10.13.0'}
+    hasBin: true
+
   qs@6.14.0:
     resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
     engines: {node: '>=0.6'}
@@ -3132,6 +3258,13 @@ packages:
   request-progress@3.0.0:
     resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
 
+  require-directory@2.1.1:
+    resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+    engines: {node: '>=0.10.0'}
+
+  require-main-filename@2.0.0:
+    resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
   resolve-from@4.0.0:
     resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
     engines: {node: '>=4'}
@@ -3317,6 +3450,9 @@ packages:
     engines: {node: '>=10'}
     hasBin: true
 
+  set-blocking@2.0.0:
+    resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
   shebang-command@2.0.0:
     resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
     engines: {node: '>=8'}
@@ -3846,6 +3982,9 @@ packages:
     resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
     engines: {node: '>=18'}
 
+  which-module@2.0.1:
+    resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
   which@2.0.2:
     resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
     engines: {node: '>= 8'}
@@ -3903,9 +4042,20 @@ packages:
   xmlchars@2.2.0:
     resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
 
+  y18n@4.0.3:
+    resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
   yallist@3.1.1:
     resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
 
+  yargs-parser@18.1.3:
+    resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+    engines: {node: '>=6'}
+
+  yargs@15.4.1:
+    resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+    engines: {node: '>=8'}
+
   yauzl@2.10.0:
     resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
 
@@ -5070,6 +5220,10 @@ snapshots:
     dependencies:
       undici-types: 6.20.0
 
+  '@types/qrcode@1.5.5':
+    dependencies:
+      '@types/node': 22.13.12
+
   '@types/sinonjs__fake-timers@8.1.1': {}
 
   '@types/sizzle@2.3.9': {}
@@ -5436,8 +5590,26 @@ snapshots:
       '@vueuse/shared': 13.0.0(vue@3.5.13(typescript@5.8.2))
       vue: 3.5.13(typescript@5.8.2)
 
+  '@vueuse/core@13.5.0(vue@3.5.13(typescript@5.8.2))':
+    dependencies:
+      '@types/web-bluetooth': 0.0.21
+      '@vueuse/metadata': 13.5.0
+      '@vueuse/shared': 13.5.0(vue@3.5.13(typescript@5.8.2))
+      vue: 3.5.13(typescript@5.8.2)
+
+  '@vueuse/integrations@13.5.0(axios@1.8.4)(qrcode@1.5.4)(vue@3.5.13(typescript@5.8.2))':
+    dependencies:
+      '@vueuse/core': 13.5.0(vue@3.5.13(typescript@5.8.2))
+      '@vueuse/shared': 13.5.0(vue@3.5.13(typescript@5.8.2))
+      vue: 3.5.13(typescript@5.8.2)
+    optionalDependencies:
+      axios: 1.8.4(debug@4.4.0)
+      qrcode: 1.5.4
+
   '@vueuse/metadata@13.0.0': {}
 
+  '@vueuse/metadata@13.5.0': {}
+
   '@vueuse/router@13.4.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))':
     dependencies:
       '@vueuse/shared': 13.4.0(vue@3.5.13(typescript@5.8.2))
@@ -5452,6 +5624,12 @@ snapshots:
     dependencies:
       vue: 3.5.13(typescript@5.8.2)
 
+  '@vueuse/shared@13.5.0(vue@3.5.13(typescript@5.8.2))':
+    dependencies:
+      vue: 3.5.13(typescript@5.8.2)
+
+  '@zumer/snapdom@1.8.0': {}
+
   abbrev@2.0.0: {}
 
   acorn-jsx@5.3.2(acorn@8.14.1):
@@ -5651,6 +5829,8 @@ snapshots:
 
   callsites@3.1.0: {}
 
+  camelcase@5.3.1: {}
+
   caniuse-lite@1.0.30001707: {}
 
   caseless@0.12.0: {}
@@ -5711,6 +5891,12 @@ snapshots:
       slice-ansi: 3.0.0
       string-width: 4.2.3
 
+  cliui@6.0.0:
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 6.2.0
+
   color-convert@2.0.1:
     dependencies:
       color-name: 1.1.4
@@ -5848,6 +6034,8 @@ snapshots:
     optionalDependencies:
       supports-color: 8.1.1
 
+  decamelize@1.2.0: {}
+
   decimal.js@10.5.0: {}
 
   deep-eql@5.0.2: {}
@@ -5873,6 +6061,8 @@ snapshots:
 
   detect-libc@2.0.3: {}
 
+  dijkstrajs@1.0.3: {}
+
   dotenv@16.4.7: {}
 
   dunder-proto@1.0.1:
@@ -6203,6 +6393,11 @@ snapshots:
     dependencies:
       to-regex-range: 5.0.1
 
+  find-up@4.1.0:
+    dependencies:
+      locate-path: 5.0.0
+      path-exists: 4.0.0
+
   find-up@5.0.0:
     dependencies:
       locate-path: 6.0.0
@@ -6255,6 +6450,8 @@ snapshots:
 
   gensync@1.0.0-beta.2: {}
 
+  get-caller-file@2.0.5: {}
+
   get-intrinsic@1.3.0:
     dependencies:
       call-bind-apply-helpers: 1.0.2
@@ -6505,6 +6702,8 @@ snapshots:
     dependencies:
       argparse: 2.0.1
 
+  jsbarcode@3.12.1: {}
+
   jsbn@0.1.1: {}
 
   jsdom@26.0.0:
@@ -6649,6 +6848,10 @@ snapshots:
       pkg-types: 2.1.0
       quansync: 0.2.10
 
+  locate-path@5.0.0:
+    dependencies:
+      p-locate: 4.1.0
+
   locate-path@6.0.0:
     dependencies:
       p-locate: 5.0.0
@@ -6836,10 +7039,18 @@ snapshots:
       '@oxlint/win32-arm64': 0.15.15
       '@oxlint/win32-x64': 0.15.15
 
+  p-limit@2.3.0:
+    dependencies:
+      p-try: 2.2.0
+
   p-limit@3.1.0:
     dependencies:
       yocto-queue: 0.1.0
 
+  p-locate@4.1.0:
+    dependencies:
+      p-limit: 2.3.0
+
   p-locate@5.0.0:
     dependencies:
       p-limit: 3.1.0
@@ -6848,6 +7059,8 @@ snapshots:
     dependencies:
       aggregate-error: 3.1.0
 
+  p-try@2.2.0: {}
+
   package-json-from-dist@1.0.1: {}
 
   parent-module@1.0.1:
@@ -6931,6 +7144,8 @@ snapshots:
       exsolve: 1.0.4
       pathe: 2.0.3
 
+  pngjs@5.0.0: {}
+
   postcss-selector-parser@6.1.2:
     dependencies:
       cssesc: 3.0.0
@@ -6975,6 +7190,12 @@ snapshots:
 
   punycode@2.3.1: {}
 
+  qrcode@1.5.4:
+    dependencies:
+      dijkstrajs: 1.0.3
+      pngjs: 5.0.0
+      yargs: 15.4.1
+
   qs@6.14.0:
     dependencies:
       side-channel: 1.1.0
@@ -7032,6 +7253,10 @@ snapshots:
     dependencies:
       throttleit: 1.0.1
 
+  require-directory@2.1.1: {}
+
+  require-main-filename@2.0.0: {}
+
   resolve-from@4.0.0: {}
 
   resolve@1.22.10:
@@ -7193,6 +7418,8 @@ snapshots:
 
   semver@7.7.1: {}
 
+  set-blocking@2.0.0: {}
+
   shebang-command@2.0.0:
     dependencies:
       shebang-regex: 3.0.0
@@ -7767,6 +7994,8 @@ snapshots:
       tr46: 5.1.0
       webidl-conversions: 7.0.0
 
+  which-module@2.0.1: {}
+
   which@2.0.2:
     dependencies:
       isexe: 2.0.0
@@ -7810,8 +8039,29 @@ snapshots:
 
   xmlchars@2.2.0: {}
 
+  y18n@4.0.3: {}
+
   yallist@3.1.1: {}
 
+  yargs-parser@18.1.3:
+    dependencies:
+      camelcase: 5.3.1
+      decamelize: 1.2.0
+
+  yargs@15.4.1:
+    dependencies:
+      cliui: 6.0.0
+      decamelize: 1.2.0
+      find-up: 4.1.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      require-main-filename: 2.0.0
+      set-blocking: 2.0.0
+      string-width: 4.2.3
+      which-module: 2.0.1
+      y18n: 4.0.3
+      yargs-parser: 18.1.3
+
   yauzl@2.10.0:
     dependencies:
       buffer-crc32: 0.2.13

+ 2 - 0
src/api/pda.api.ts

@@ -58,6 +58,8 @@ export function getDataMethod(no: string, mode?: string) {
             remark1: data?.extendedTxt,
             remark2: data?.pharmacistsremarks,
             entrust: data?.entrust,
+
+            date: data?.prescriptionTime,
           },
           medicines:
             data?.oralPreItemList?.map((item: ResponseData) => {

+ 39 - 0
src/core/hook/useQRCode.ts

@@ -0,0 +1,39 @@
+import { type QRCodeToDataURLOptions, toDataURL } from 'qrcode';
+import { type MaybeComputedElementRef, tryOnMounted, unrefElement, useElementSize } from '@vueuse/core';
+
+export function useQRCode(target: MaybeComputedElementRef, text: MaybeRefOrGetter<string | void>, options?: QRCodeToDataURLOptions & { append?: boolean }) {
+  const src = toRef(text);
+  const result = shallowRef('');
+
+  const { width, height } = useElementSize(target);
+
+  const create = () => {
+    if (src.value && width.value > 0) {
+      toDataURL(src.value, Object.assign({ width: width.value }, options), (_, url) => {
+        result.value = url;
+        if (options?.append) {
+          const el = unrefElement(target) as HTMLElement;
+          appendResult(el, url);
+        }
+      });
+    }
+  };
+
+  tryOnMounted(create);
+  watch([src, width], create, { immediate: false });
+
+  return result;
+}
+
+function appendResult(target: HTMLElement, src: string) {
+  if (target == null || !src) return;
+
+  // 清空 el 的所有子元素
+  while (target.firstChild) {
+    target.removeChild(target.firstChild);
+  }
+  // 创建 img 元素并设置 src
+  const img = document.createElement('img');
+  img.src = src;
+  target.appendChild(img);
+}

+ 12 - 0
src/core/launch/platform.launch.ts

@@ -23,6 +23,18 @@ export default function bridgeLoader(): Launcher {
   return async function () {
     if (platformIsPDA()) {
       await waitFor(() => window.bridge != null);
+      window.bridge.printer = {
+        connect(device: BridgePrinterDevice) {
+          const { promise, ...resolvers } = Promise.withResolvers<BridgePrinterDevice>();
+          window.bridge.postMessage('print:connect', device, resolvers);
+          return promise.then((device) => [device, () => this.disconnect(device)]);
+        },
+        disconnect(device: BridgePrinterDevice) {
+          const { promise, ...resolvers } = Promise.withResolvers<BridgePrinterDevice>();
+          window.bridge.postMessage('print:disconnect', device, resolvers);
+          return promise;
+        },
+      };
     }
   };
 }

+ 2 - 0
src/model/step.model.ts

@@ -30,6 +30,8 @@ export interface StepModel {
       phone: string;
       address: string;
     };
+
+    date?: string;
   };
   medicines: {
     id: string;

+ 191 - 0
src/module/print/Printer.vue

@@ -0,0 +1,191 @@
+<script setup lang="ts">
+import { type FormInstance, showToast } from 'vant';
+import { tryOnMounted, tryOnUnmounted } from '@vueuse/core';
+import { platformIsPDA } from '@/platform';
+import { usePrintStore } from '@/stores';
+import { useSnapshot } from '@/module/print/useSnapshot.ts';
+import { regex } from '@/tools/validator.ts';
+import { browserPrint } from '@/stores/print.store.ts';
+
+const props = defineProps<{
+  disabled?: boolean;
+  loader: () => Promise<{ default: Component }>;
+  loaderParams: Record<string, any>;
+  onPrintBefore?: () => boolean | Promise<boolean>;
+  onPrintAfter?: () => void | Promise<void>;
+}>();
+const emits = defineEmits<{ start: [] }>();
+
+const Printer = usePrintStore();
+
+watch(
+  () => props.loaderParams,
+  (value, oldValue) => {
+    if (JSON.stringify(value) !== JSON.stringify(oldValue)) snapshot.value = '';
+  },
+  { immediate: false },
+);
+
+const printing = defineModel<boolean>('loading', { default: false });
+const total = defineModel('total', { default: 1 });
+const host = defineModel('host', { default: '' });
+
+const show = ref(false);
+const connecting = ref(false);
+
+const rules = reactive({
+  host: [
+    { required: true, message: '请输入打印机 IP 地址' },
+    {
+      validator: (value: string) => regex.host.test(value),
+      message: `IP 地址格式不正确`,
+    },
+  ],
+  total: [{ required: true, message: '请输入打印份数' }],
+});
+
+const { snapshot, tspl, render } = useSnapshot(props.loader);
+
+const open = async () => {
+  if ((await props.onPrintBefore?.()) ?? true) {
+    printing.value = true;
+    try {
+      const { snapshot: src } = await render(props.loaderParams);
+      if (platformIsPDA()) show.value = true;
+      else browserPrint(src);
+    } catch (error: any) {
+      showNotify({ message: `创建打印数据错误(${error?.message})`, type: 'danger' });
+    }
+    printing.value = false;
+  }
+};
+
+const form = useTemplateRef<FormInstance>('printer-form');
+const validate = (name?: string) =>
+  form.value?.validate(name).then(
+    () => true,
+    () => false,
+  );
+
+const start = (action: 'cancel' | 'confirm') => {
+  if (action === 'confirm') return validate()?.then((valid) => valid && print()) ?? false;
+  if (action === 'cancel') {
+    printing.value = false;
+    form.value?.resetValidation?.()
+  }
+  return true;
+};
+
+async function print() {
+  if (platformIsPDA()) {
+    if (connecting.value || !host.value) return false;
+
+    try {
+      const data = tspl.value?.(total.value);
+      await Printer.print(host.value, data!!);
+      showToast({ message: `开始打印`, type: 'success' });
+    } catch (error: any) {
+      showNotify({ message: `创建打印任务错误(${error?.message})`, type: 'danger' });
+    }
+  }
+  return true;
+}
+
+const connected = ref(false);
+
+async function toggle() {
+  const valid = await validate('host');
+  if (!valid) return;
+
+  connecting.value = true;
+  try {
+    const device = Printer.getDevice(host.value);
+    if (connected.value) {
+      await Printer.disconnect(device);
+      showToast({ message: '断开成功', type: 'success' });
+      connected.value = false;
+    } else {
+      await Printer.connect(device);
+      connected.value = true;
+      showToast({ message: '连接成功', type: 'success' });
+    }
+  } catch (error: any) {
+    showNotify({ message: `操作失败(${error?.message})`, type: 'warning' });
+    connected.value = false;
+  }
+
+  connecting.value = false;
+}
+
+let connectListener: Array<() => void> = [];
+tryOnMounted(() => {
+  if (!host.value) host.value = Printer.host || '';
+  if (!connected.value) toggle();
+  if (platformIsPDA()) {
+    connectListener.push(
+      window.bridge.addEventListener('print:connect', () => {
+        connected.value = true;
+      }),
+      window.bridge.addEventListener('print:disconnect', () => {
+        connected.value = false;
+      }),
+    );
+  }
+});
+tryOnUnmounted(() => {
+  connectListener.forEach((fn) => fn?.());
+  connectListener = [];
+  snapshot.value = '';
+});
+</script>
+
+<template>
+  <van-dialog v-model:show="show" title="连接打印机" :confirm-button-disabled="connecting" confirm-button-text="打印" show-cancel-button :before-close="start">
+    <van-form ref="printer-form" @submit="print">
+      <van-cell-group>
+        <van-field
+          name="host"
+          label="打印设置"
+          placeholder="打印机 IP 地址"
+          v-model="host"
+          :rules="rules['host']"
+          :readonly="connecting"
+          :disabled="connected"
+          enterkeyhint="go"
+          @keydown.enter="toggle()"
+        >
+          <template #button>
+            <van-button size="small" :type="connected ? 'danger' : 'primary'" :loading="connecting" @click="toggle">
+              {{ connected ? '断开' : '连接' }}
+            </van-button>
+          </template>
+        </van-field>
+        <van-field class="suffix" v-model="total" name="total" :rules="rules['total']" type="digit" label="打印包数" placeholder="请输入" :min="1">
+          <template #extra>
+            <div v-if="total">包</div>
+          </template>
+        </van-field>
+        <van-cell title="温馨提示" label="输入几包就打印几张标签" />
+      </van-cell-group>
+    </van-form>
+  </van-dialog>
+  <van-button block :loading="printing" :disabled="props.disabled" @click="open()">打印标签</van-button>
+</template>
+
+<style scoped lang="scss">
+.van-field.suffix {
+  :deep(.van-field__value) {
+    flex: none;
+    //width: min-content;
+    //min-width: 120px;
+    max-width: 170px;
+
+    input {
+      text-align: center;
+    }
+  }
+}
+:deep(.van-field__label) {
+  align-self: center;
+}
+</style>

+ 141 - 0
src/module/print/Tag_60_40.vue

@@ -0,0 +1,141 @@
+<script setup lang="ts">
+import { useElementSize } from '@vueuse/core';
+import { useStepStore } from '@/stores';
+import { useQRCode } from '@/core/hook/useQRCode.ts';
+
+export interface Props {
+  count: number;
+}
+
+const page = reactive({
+  width: 60,
+  height: 40,
+  dpi: 8,
+});
+
+const props = defineProps<Props>();
+const emits = defineEmits<{ rendered: [((base64: string) => (total?: number) => string)?] }>();
+
+const stepStore = useStepStore();
+const { dataset } = storeToRefs(stepStore);
+
+const decoction = computed(() => {
+  return { 是: '代煎', 否: '自煎' }[dataset.value?.prescription.decoction as string] ?? '';
+});
+const date = computed(() => {
+  const value = dataset.value?.prescription.date;
+  return typeof value === 'string' ? value.split(' ')[0] : '';
+});
+
+const yard = useTemplateRef('yard-wrapper');
+const { width: size } = useElementSize(yard);
+
+const qrcode = computed(() => dataset.value?.no);
+const result = useQRCode(useTemplateRef('yard-container'), qrcode, {
+  margin: 0,
+  append: true,
+});
+
+const tspl = (base64: string) => (total = 1) => `
+SIZE ${page.width} mm,${page.height} mm
+SPEED 2
+DENSITY 8
+GAP 2 mm,0 mm
+CLS
+BEEP
+BITMAP 0,0,${page.width},${page.height * page.dpi},0,${base64}
+PRINT ${total}
+`;
+
+watch(
+  result,
+  () => {
+    if (result.value) emits('rendered', tspl);
+  },
+  { immediate: true },
+);
+</script>
+
+<template>
+  <div class="print-container relative flex flex-col">
+    <div class="text-22 text-center font-black">{{ dataset?.patient?.hospital }}</div>
+    <div class="text-16 text-center">{{ dataset?.no }}</div>
+    <div class="text-22 flex items-center text-center">
+      <div class="text-24 snippet-wrapper" style="flex: 1 1 60%">
+        <span v-if="dataset?.patient?.name" class="font-semibold" data-snippet=",">{{ dataset?.patient?.name }}</span>
+        <span v-if="dataset?.patient?.gender" class="font-semibold" data-snippet=",">{{ dataset?.patient?.gender }}</span>
+        <span v-if="dataset?.patient?.age" class="font-semibold" data-snippet=",">{{ dataset?.patient?.age }}</span>
+      </div>
+      <div style="flex: 1 1 40%">
+        <span class="font-semibold px-2">{{ dataset?.prescription?.count }}</span>
+        <span> 帖 </span>
+        <span class="font-semibold px-2">{{ props.count }}</span>
+        <span> 包 </span>
+      </div>
+    </div>
+    <div class="text-20 flex">
+      <div class="flex-auto">
+        <span>备注:</span>
+        <span class="font-medium">{{ dataset?.prescription?.entrust }}</span>
+      </div>
+      <div class="font-semibold flex-none">{{ decoction }}</div>
+    </div>
+    <div class="pt-2 flex-auto flex text-14">
+      <div class="flex-auto pr-2">{{ dataset?.prescription?.remark2 ?? '' }}</div>
+      <div class="yard-shade"></div>
+    </div>
+    <div class="text-20 flex justify-evenly">
+      <div class="flex-auto font-semibold">{{ dataset?.prescription?.dispatch?.method }}</div>
+      <div class="flex-auto text-center">{{ date }}</div>
+      <div class="flex-auto" ref="yard-wrapper"></div>
+    </div>
+
+    <div class="yard-container absolute" ref="yard-container"></div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+$p: 12px;
+
+.print-container {
+  --width: v-bind(page.width);
+  --height: v-bind(page.height);
+  --dpi: v-bind(page.dpi);
+  width: calc(var(--width) * var(--dpi) * 1px);
+  height: calc(var(--height) * var(--dpi) * 1px);
+  padding: $p;
+
+  border: 1px solid #d0d0d0;
+
+  $font-map: (
+    16: 1.2,
+    18: 1.5,
+    20: 1.5,
+    22: 1.5,
+    24: 1.5,
+  );
+
+  @each $size, $lh in $font-map {
+    .text-#{$size} {
+      font-size: calc(#{$size}px * 1.2);
+      line-height: $lh;
+    }
+  }
+}
+
+.yard-container,
+.yard-shade {
+  --size: v-bind(size);
+  width: calc(var(--size) * 1px);
+  height: calc(var(--size) * 1px);
+}
+
+.yard-container {
+  bottom: $p;
+  right: $p;
+}
+
+.snippet-wrapper > span + span::before {
+  content: attr(data-snippet);
+}
+</style>

+ 76 - 0
src/module/print/useSnapshot.ts

@@ -0,0 +1,76 @@
+import { tryOnMounted, tryOnUnmounted } from '@vueuse/core';
+import { snapdom } from '@zumer/snapdom';
+
+export interface SnapshotOption {
+  immediate?: boolean;
+}
+
+type SnapshotTspl = (total?: number) => string;
+type SnapshotTemplate = (base64: string) => SnapshotTspl;
+
+export function useSnapshot<P extends Record<string, any>>(loader: () => Promise<{ default: Component }>, props?: P, option?: SnapshotOption) {
+  const snapshot = shallowRef<string>();
+  const tspl = shallowRef<SnapshotTspl | void>();
+
+  let app: ReturnType<typeof createApp> | null = null;
+  let container: HTMLDivElement | null = null;
+
+  const mounted = (component: Component, _props?: Partial<P>) => {
+    if (container == null) return;
+    const { promise, resolve } = Promise.withResolvers<SnapshotTemplate>();
+    app = createApp(component, { ...props, ..._props, onRendered: resolve });
+    app.mount(container);
+
+    return promise;
+  };
+
+  const unmounted = () => {
+    app?.unmount();
+    app = null;
+  };
+
+  const capture = async (template?: SnapshotTemplate) => {
+    if (container == null) return;
+
+    const dom = await snapdom(container, { scale: 1 });
+    const img = await dom.toPng({ scale: 2 });
+    snapshot.value = img.src;
+    tspl.value = template?.(img.src);
+  };
+
+  async function render(props?: Partial<P>, forced = false): Promise<{ snapshot: string; tspl: SnapshotTspl }> {
+    if (forced) unmounted();
+
+    if (forced || !snapshot.value) {
+      const { default: component } = await loader();
+
+      const tspl = await mounted(component, props);
+      await capture(tspl);
+      unmounted();
+    }
+    return {
+      snapshot: snapshot.value!!,
+      tspl: tspl.value!!,
+    };
+  }
+
+  tryOnMounted(() => {
+    container = document.createElement('div');
+    container.style.position = 'fixed';
+    container.style.left = '-99999px';
+    container.style.top = '-99999px';
+    container.style.zIndex = '-1';
+    document.body.appendChild(container);
+    if (option?.immediate) render().then();
+  });
+  tryOnUnmounted(() => {
+    if (container) document.body.removeChild(container);
+    container = null;
+
+    unmounted();
+    snapshot.value = '';
+    tspl.value = void 0;
+  });
+
+  return { snapshot, tspl, render };
+}

+ 12 - 1
src/module/step/StepPack.vue

@@ -7,11 +7,12 @@ import { fromPackModel, type PackModel } from '@/model/step.model.ts';
 import { useStepStore } from '@/stores';
 
 import type { ScanData } from '@/core/hook/useScan.ts';
+import Printer from '@/module/print/Printer.vue';
 
 const emits = defineEmits<{ back: [delta?: number] }>();
 
 const storeStore = useStepStore();
-const { dataset } = storeToRefs(storeStore);
+const { dataset, id } = storeToRefs(storeStore);
 
 const {
   loading: submitting,
@@ -34,6 +35,8 @@ const { data, loading } = useWatcher(() => getPackDataMethod(dataset.value?.id!)
     showDialog({ title: '温馨提示', message, closeOnClickOverlay: true }).then(() => emits('back'));
   });
 
+const loader = () => import('@/module/print/Tag_60_40.vue');
+
 defineExpose({
   scan(data: ScanData) {
     model.value.deviceCode = data?.code ?? '';
@@ -54,6 +57,14 @@ defineExpose({
   <van-field v-model="model.packingNote" :readonly="submitting" rows="2" autosize label="打包备注" type="textarea" placeholder="请输入备注内容" />
 
   <slot name="submit" title="打包节点上传" :submitting="submitting" :submit="send"></slot>
+  <teleport v-if="id" to="#bottom-handle">
+    <printer
+      :loader="loader"
+      :loader-params="{ count: data.packageNumber }"
+      :total="+data?.packageNumber"
+      :disabled="submitting"
+    ></printer>
+  </teleport>
 </template>
 
 <style scoped lang="scss">

+ 22 - 4
src/module/step/StepPackRecheck.vue

@@ -5,11 +5,12 @@ import { useForm, useWatcher } from 'alova/client';
 import { getPackDataMethod, setPackRecheckDataMethod } from '@/api/pda.api.ts';
 import { fromPackModel, type PackModel } from '@/model/step.model.ts';
 import { useStepStore } from '@/stores';
+import Printer from '@/module/print/Printer.vue';
 
 const emits = defineEmits<{ back: [delta?: number] }>();
 
 const storeStore = useStepStore();
-const { dataset } = storeToRefs(storeStore);
+const { dataset, id } = storeToRefs(storeStore);
 
 const {
   loading: submitting,
@@ -33,6 +34,8 @@ const { loading } = useWatcher(() => getPackDataMethod(dataset.value?.id!), [dat
   });
 
 function submit(picture: string[]) {
+  if (printing.value) return;
+
   let message = '';
   if (!+model.value.packageDose) {
     message = '请输入包装量';
@@ -46,6 +49,14 @@ function submit(picture: string[]) {
   }
 }
 
+const printing = ref(false);
+const loader = () => import('@/module/print/Tag_60_40.vue');
+function printBefore() {
+  if (+model.value.packageDose) return true;
+  showNotify({ message: '请输入包数', type: 'warning' });
+  return false;
+}
+
 defineExpose({
   reset,
 });
@@ -70,9 +81,16 @@ defineExpose({
   <van-field v-model="model.packingNote" :readonly="submitting" rows="2" autosize label="打包复核备注" type="textarea" placeholder="请输入备注内容" />
 
   <slot name="submit" title="打包复核节点上传" :submitting="submitting" :submit="submit"></slot>
-  <!--<teleport to="#bottom-handle">
-    <van-button block :loading="submitting">打印标签</van-button>
-  </teleport>-->
+  <teleport v-if="id" to="#bottom-handle">
+    <printer
+      :loader="loader"
+      :loader-params="{ count: model.packageNumber }"
+      :total="+model?.packageNumber"
+      :disabled="submitting"
+      v-model:loading="printing"
+      :on-print-before="printBefore"
+    ></printer>
+  </teleport>
 </template>
 
 <style scoped lang="scss">

+ 1 - 1
src/router/index.ts

@@ -17,7 +17,7 @@ const router = createRouter({
           path: 'home',
           name: 'home',
           component: () => import('@/pages/HomePage.vue'),
-          meta: { title: '调配间' },
+          meta: { title: '' },
         },
         {
           path: `step/:mode`,

+ 1 - 0
src/stores/index.ts

@@ -13,3 +13,4 @@ pinia.use(persistedState);
 export default pinia;
 export { useAccountStore } from './account.store.ts';
 export { useStepStore } from './step.store.ts';
+export { usePrintStore } from './print.store.ts';

+ 112 - 0
src/stores/print.store.ts

@@ -0,0 +1,112 @@
+import { defineStore } from 'pinia';
+import { regex } from '@/tools/validator.ts';
+
+const DEFAULT_PORT = 9100;
+
+export const usePrintStore = defineStore(
+  'print',
+  () => {
+    const ip = ref('');
+    const port = ref(DEFAULT_PORT);
+
+    const host = computed(() => {
+      const _ip = ip.value;
+      const _port = port.value || DEFAULT_PORT;
+      return _ip ? `${_ip}:${_port}` : void 0;
+    });
+
+    const _device = computed(() => {
+      const _ip = ip.value;
+      const _port = port.value || DEFAULT_PORT;
+      return _ip ? ({ type: 'wifi', ip: _ip, port: _port } as BridgePrinterDevice) : void 0;
+    });
+
+    function $reset() {
+      ip.value = '';
+      port.value = DEFAULT_PORT;
+    }
+
+    function getDevice(host: string): BridgePrinterDevice | undefined;
+    function getDevice(ip: string, port: number = DEFAULT_PORT): BridgePrinterDevice | undefined {
+      const exec = regex.host.exec(ip);
+      if (exec == null) return void 0;
+
+      const [_, _ip, _port = port] = exec;
+      return { type: 'wifi', ip: _ip, port: +_port } as BridgePrinterDevice;
+    }
+
+    async function connect(device?: BridgePrinterDevice) {
+      if (device == null) device = _device.value;
+      if (device == null) throw { message: `[无法连接] 打印机配置错误` };
+      try {
+        await window.bridge.printer.connect(device);
+        ip.value = device.ip!!;
+        port.value = device.port!!;
+      } catch (e) {
+        $reset();
+        throw e;
+      }
+    }
+
+    async function disconnect(device?: BridgePrinterDevice) {
+      if (device == null) device = _device.value;
+      if (device == null) throw { message: `[无法断开] 打印机配置错误` };
+      await window.bridge.printer.disconnect(device);
+    }
+
+    function print(tspl: string): Promise<void>;
+    function print(host: string, tspl: string): Promise<void>;
+    function print(hostOrTspl: string, tspl?: string): Promise<void> {
+      if (tspl == null) [tspl, hostOrTspl] = [hostOrTspl, ''];
+
+      const device = hostOrTspl ? getDevice(hostOrTspl) : _device.value;
+      if (device == null) throw { message: `[无法打印] 打印机配置错误` };
+
+      return Bridge.print({ device, tspl }).then((result) => {
+        ip.value = device.ip!!;
+        port.value = device.port!!;
+        return result;
+      });
+    }
+
+    return { ip, port, host, getDevice, connect, disconnect, print, $reset };
+  },
+  {
+    persist: {
+      pick: ['ip', 'port'],
+    },
+  },
+);
+
+export function browserPrint(img?: string) {
+  if (img == null) throw { message: `数据为空` };
+  // 1. 创建打印区域
+  const printArea =
+    document.querySelector<HTMLDivElement>('#print-area') ??
+    ((el: HTMLElement) => {
+      el.id = 'print-area';
+      document.body.appendChild(el);
+      return el;
+    })(document.createElement('div'));
+  printArea.innerHTML = `<img src="${img}" style="width:100%" alt=""/>`;
+
+  // 2. 添加打印专用样式
+  if (!document.querySelector('#print-style')) {
+    const style = document.createElement('style');
+    style.id = 'print-style';
+    style.innerHTML = `
+      @media print {
+        body > *:not(#print-area) { display: none !important; }
+        #print-area { display: block; }
+      }
+      #print-area { display: none; }
+    `;
+    document.head.appendChild(style);
+  }
+
+
+  // 3. 显示打印区域并打印
+  printArea.style.display = 'block';
+  window.print();
+  printArea.style.display = 'none';
+}

+ 11 - 0
src/tools/validator.ts

@@ -0,0 +1,11 @@
+const host = () => {
+  // IP 每段 0-255
+  const ip = '(?:25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)';
+  // 端口 1-65535
+  const port = '(?:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))';
+  // 拼接整体正则
+  return new RegExp(`^((?:${ip}\\.){3}${ip})(?::${port})?$`);
+};
+export const regex = {
+  host: host(),
+};