@wanghaopeng1148/deskpet 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +394 -0
  2. package/bin/deskpet.mjs +142 -0
  3. package/dist/web/assets/DashboardView-BLXa7VrZ.css +1 -0
  4. package/dist/web/assets/DashboardView-CTyWcLtQ.js +60 -0
  5. package/dist/web/assets/SettingsView-7c3RiRrt.js +1 -0
  6. package/dist/web/assets/SettingsView-BkL0OpZC.css +1 -0
  7. package/dist/web/assets/TasksView-BTK1OTwU.css +1 -0
  8. package/dist/web/assets/TasksView-s1MaR5sZ.js +5 -0
  9. package/dist/web/assets/ToolsView-B8V-A1j_.js +178 -0
  10. package/dist/web/assets/ToolsView-DGLJATQ9.css +1 -0
  11. package/dist/web/assets/WeChatView-ChLBhPso.js +1 -0
  12. package/dist/web/assets/WeChatView-CvdhJ05E.css +1 -0
  13. package/dist/web/assets/browser-CjSdxGTc.js +8 -0
  14. package/dist/web/assets/dashboard-DJ_Miuzx.js +93 -0
  15. package/dist/web/assets/dashboard-Fbcagzwx.css +1 -0
  16. package/dist/web/dashboard/index.html +13 -0
  17. package/package.json +71 -0
  18. package/resources/icons/tray.png +0 -0
  19. package/resources/icons/tray@2x.png +0 -0
  20. package/resources/previews/busy.png +0 -0
  21. package/resources/previews/click.png +0 -0
  22. package/resources/previews/hover.png +0 -0
  23. package/resources/previews/idle.png +0 -0
  24. package/resources/previews/preview.png +0 -0
  25. package/resources/previews/sleep.png +0 -0
  26. package/resources/skins/default_cute/pet.json +7 -0
  27. package/resources/skins/default_cute/spritesheet.webp +0 -0
  28. package/resources/skins/default_cute/submission.json +29 -0
  29. package/server/db/database.ts +118 -0
  30. package/server/db/migrate-legacy.ts +121 -0
  31. package/server/db/task-repository.ts +585 -0
  32. package/server/http/http-server.ts +725 -0
  33. package/server/http/ws-hub.ts +66 -0
  34. package/server/main.ts +322 -0
  35. package/server/plugins/actions/builtin.ts +73 -0
  36. package/server/plugins/actions/clipboard-watch.ts +38 -0
  37. package/server/plugins/actions/http-request.ts +53 -0
  38. package/server/plugins/actions/jenkins-build.ts +209 -0
  39. package/server/plugins/actions/open-app.ts +40 -0
  40. package/server/plugins/actions/python-script.ts +187 -0
  41. package/server/plugins/actions/screenshot.ts +41 -0
  42. package/server/plugins/actions/send-keystroke.ts +103 -0
  43. package/server/plugins/actions/show-reminder.ts +16 -0
  44. package/server/plugins/actions/ssh-command.ts +148 -0
  45. package/server/plugins/actions/task-chain.ts +30 -0
  46. package/server/plugins/actions/volume-control.ts +35 -0
  47. package/server/plugins/index.ts +37 -0
  48. package/server/plugins/registry.ts +72 -0
  49. package/server/services/clipboard-watcher.ts +126 -0
  50. package/server/services/config-store.ts +152 -0
  51. package/server/services/idle-monitor.ts +146 -0
  52. package/server/services/notifier.ts +54 -0
  53. package/server/services/quick-actions-store.ts +54 -0
  54. package/server/services/remote-connector.ts +75 -0
  55. package/server/services/scanner-reader.ts +257 -0
  56. package/server/services/script-runner.ts +263 -0
  57. package/server/services/snapshot-service.ts +196 -0
  58. package/server/services/task-scheduler.ts +900 -0
  59. package/server/services/wechat-bot.ts +744 -0
  60. package/server/services/wechat-command-types.ts +15 -0
  61. package/server/services/wechat-commands.ts +367 -0
  62. package/server/suppress-warnings.ts +10 -0
  63. package/server/utils/asset-url.ts +27 -0
  64. package/server/utils/auto-start.ts +90 -0
  65. package/server/utils/clipboard.ts +50 -0
  66. package/server/utils/dashboard-url.ts +9 -0
  67. package/server/utils/instance-guard.ts +170 -0
  68. package/server/utils/native-notify.ts +68 -0
  69. package/server/utils/open.ts +38 -0
  70. package/server/utils/paths.ts +72 -0
  71. package/server/utils/python-interpreter.ts +154 -0
  72. package/shared/animation-engine.ts +422 -0
  73. package/shared/chain-condition.ts +60 -0
  74. package/shared/cron-weekly.ts +131 -0
  75. package/shared/py-task-params.ts +356 -0
  76. package/shared/types.ts +309 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * 场景触发监听 — 取代 Electron powerMonitor
3
+ * IdleMonitor : 系统闲置时长(Windows GetLastInputInfo / macOS HIDIdleTime / Linux xprintidle)
4
+ * NetworkMonitor : 网络恢复(DNS 探测,取代 powerMonitor 'online' 事件)
5
+ *
6
+ * 均为尽力而为:平台不支持或命令失败时静默,不触发场景任务。
7
+ */
8
+ import { execFile } from 'node:child_process'
9
+ import { lookup } from 'node:dns'
10
+ import { promisify } from 'node:util'
11
+
12
+ const dnsLookup = promisify(lookup)
13
+
14
+ function execText(cmd: string, args: string[], timeout = 5000): Promise<string> {
15
+ return new Promise((resolvePromise) => {
16
+ execFile(
17
+ cmd,
18
+ args,
19
+ { timeout, windowsHide: true, maxBuffer: 1024 * 1024 },
20
+ (err, stdout) => resolvePromise(err ? '' : String(stdout ?? ''))
21
+ )
22
+ })
23
+ }
24
+
25
+ /** 获取系统闲置秒数(失败返回 0,表示"未闲置") */
26
+ export async function getSystemIdleSeconds(): Promise<number> {
27
+ try {
28
+ if (process.platform === 'darwin') {
29
+ const out = await execText('ioreg', ['-c', 'IOHIDSystem'])
30
+ const m = /"HIDIdleTime"\s*=\s*(\d+)/.exec(out)
31
+ if (m?.[1]) return Math.floor(Number(m[1]) / 1_000_000_000)
32
+ return 0
33
+ }
34
+
35
+ if (process.platform === 'win32') {
36
+ const script = [
37
+ 'Add-Type -Namespace Win32 -Name IdleTime -MemberDefinition \'',
38
+ '[DllImport("user32.dll")] public static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);',
39
+ '[StructLayout(LayoutKind.Sequential)] public struct LASTINPUTINFO { public uint cbSize; public uint dwTime; }',
40
+ 'public static uint GetIdleMs() { LASTINPUTINFO lii = new LASTINPUTINFO(); lii.cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf(lii); GetLastInputInfo(ref lii); return (uint)System.Environment.TickCount - lii.dwTime; }\'',
41
+ '[Win32.IdleTime]::GetIdleMs()'
42
+ ].join(' ')
43
+ const out = await execText(
44
+ 'powershell',
45
+ ['-NoProfile', '-NonInteractive', '-Command', script],
46
+ 15000
47
+ )
48
+ const ms = Number(out.trim().split(/\s+/).pop())
49
+ return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0
50
+ }
51
+
52
+ if (process.platform === 'linux') {
53
+ const out = await execText('xprintidle', [])
54
+ const ms = Number(out.trim())
55
+ return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0
56
+ }
57
+ } catch {
58
+ /* ignore */
59
+ }
60
+ return 0
61
+ }
62
+
63
+ export class IdleMonitor {
64
+ private timer: ReturnType<typeof setInterval> | null = null
65
+ private lastMinutes = -1
66
+ private busy = false
67
+
68
+ private onIdleChange: (idleMinutes: number) => void
69
+ private intervalMs: number
70
+
71
+ constructor(onIdleChange: (idleMinutes: number) => void, intervalMs = 30000) {
72
+ this.onIdleChange = onIdleChange
73
+ this.intervalMs = intervalMs
74
+ }
75
+
76
+ start(): void {
77
+ if (this.timer) return
78
+ this.timer = setInterval(() => {
79
+ void this.tick()
80
+ }, this.intervalMs)
81
+ this.timer.unref?.()
82
+ }
83
+
84
+ private async tick(): Promise<void> {
85
+ if (this.busy) return
86
+ this.busy = true
87
+ try {
88
+ const seconds = await getSystemIdleSeconds()
89
+ const minutes = Math.floor(seconds / 60)
90
+ if (minutes !== this.lastMinutes) {
91
+ this.lastMinutes = minutes
92
+ this.onIdleChange(minutes)
93
+ }
94
+ } catch {
95
+ /* 平台不可用时静默 */
96
+ } finally {
97
+ this.busy = false
98
+ }
99
+ }
100
+
101
+ stop(): void {
102
+ if (this.timer) clearInterval(this.timer)
103
+ this.timer = null
104
+ }
105
+ }
106
+
107
+ /** 网络恢复检测 — 周期性 DNS 探测,从失败恢复为成功时回调一次 */
108
+ export class NetworkMonitor {
109
+ private timer: ReturnType<typeof setInterval> | null = null
110
+ private lastOnline = true
111
+
112
+ private onOnline: () => void
113
+ private intervalMs: number
114
+ private host: string
115
+
116
+ constructor(onOnline: () => void, intervalMs = 60000, host = 'www.baidu.com') {
117
+ this.onOnline = onOnline
118
+ this.intervalMs = intervalMs
119
+ this.host = host
120
+ }
121
+
122
+ start(): void {
123
+ if (this.timer) return
124
+ this.timer = setInterval(() => {
125
+ void this.tick()
126
+ }, this.intervalMs)
127
+ this.timer.unref?.()
128
+ }
129
+
130
+ private async tick(): Promise<void> {
131
+ let online = false
132
+ try {
133
+ await dnsLookup(this.host)
134
+ online = true
135
+ } catch {
136
+ online = false
137
+ }
138
+ if (online && !this.lastOnline) this.onOnline()
139
+ this.lastOnline = online
140
+ }
141
+
142
+ stop(): void {
143
+ if (this.timer) clearInterval(this.timer)
144
+ this.timer = null
145
+ }
146
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 通知中心 — 系统原生通知 + WebSocket 推送 + 微信 三通道统一收口
3
+ *
4
+ * 无 Electron 依赖:桌面通道由系统命令发出(utils/native-notify),
5
+ * WebUI 通道经 WebSocket 广播,前端可用 Notification API 或页面内提示呈现。
6
+ */
7
+ import type { Reminder } from '../../shared/types.ts'
8
+ import { notifyNative } from '../utils/native-notify.ts'
9
+
10
+ /** 广播出口(WsHub 满足该接口) */
11
+ export interface NotificationSink {
12
+ broadcast(type: string, payload: unknown): void
13
+ }
14
+
15
+ export class Notifier {
16
+ private sink: NotificationSink | null = null
17
+ private wechatSender: ((text: string) => void) | null = null
18
+
19
+ constructor(sink?: NotificationSink) {
20
+ this.sink = sink ?? null
21
+ }
22
+
23
+ /** 绑定广播出口(WsHub 就绪后注入) */
24
+ setSink(sink: NotificationSink): void {
25
+ this.sink = sink
26
+ }
27
+
28
+ /** 注入微信发送通道 */
29
+ setWechatSender(sender: (text: string) => void): void {
30
+ this.wechatSender = sender
31
+ }
32
+
33
+ /** 系统原生通知 + 广播到 WebUI */
34
+ notify(title: string, body: string): void {
35
+ notifyNative(title, body)
36
+ this.sink?.broadcast('notification', { title, body, ts: Date.now() })
37
+ }
38
+
39
+ /** 任务提醒:广播到 WebUI(原气泡/全屏窗口已移除),并尝试系统通知 */
40
+ showReminder(reminder: Reminder): void {
41
+ const payload = { ...reminder, ts: Date.now() }
42
+ this.sink?.broadcast('reminder', payload)
43
+ notifyNative('任务提醒', reminder.text)
44
+ }
45
+
46
+ /** 微信通道(未连接时静默丢弃) */
47
+ sendWechat(text: string): void {
48
+ try {
49
+ this.wechatSender?.(text)
50
+ } catch (err) {
51
+ console.error('[notify] 微信推送失败:', err)
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 快捷指令存储 — 花瓣菜单按钮配置(userData/config/quick-actions.json)
3
+ */
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
5
+ import type { QuickAction } from '../../shared/types.ts'
6
+
7
+ const DEFAULT_ACTIONS: QuickAction[] = [
8
+ { id: 'qa-dashboard', label: '管理台', icon: '🖥' },
9
+ { id: 'qa-hide', label: '隐藏', icon: '🙈' }
10
+ ]
11
+
12
+ export class QuickActionsStore {
13
+ private cache: QuickAction[] | null = null
14
+ private filePath: string
15
+
16
+ constructor(filePath: string) {
17
+ this.filePath = filePath
18
+ }
19
+
20
+ load(): QuickAction[] {
21
+ if (this.cache) return this.cache
22
+ try {
23
+ if (!existsSync(this.filePath)) {
24
+ this.cache = [...DEFAULT_ACTIONS]
25
+ this.save(this.cache)
26
+ return this.cache
27
+ }
28
+ const raw = JSON.parse(readFileSync(this.filePath, 'utf-8')) as { actions?: QuickAction[] }
29
+ const actions = Array.isArray(raw.actions) ? raw.actions : [...DEFAULT_ACTIONS]
30
+ // 迁移:补齐缺失的内置动作(追加到末尾,用户可在设置中调整顺序)
31
+ let dirty = false
32
+ for (const def of DEFAULT_ACTIONS) {
33
+ if (!actions.some((a) => a.id === def.id)) {
34
+ actions.push({ ...def })
35
+ dirty = true
36
+ }
37
+ }
38
+ this.cache = actions
39
+ if (dirty) this.save(this.cache)
40
+ } catch {
41
+ this.cache = [...DEFAULT_ACTIONS]
42
+ }
43
+ return this.cache
44
+ }
45
+
46
+ save(actions: QuickAction[]): void {
47
+ this.cache = actions
48
+ try {
49
+ writeFileSync(this.filePath, JSON.stringify({ actions }, null, 2), 'utf-8')
50
+ } catch (err) {
51
+ console.error('[quick-actions] 保存失败:', err)
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 远程连接自检 — 供「系统设置」验证 Linux 服务器 / Jenkins 凭据是否可用
3
+ * 只做连通性探测,不执行任何命令
4
+ */
5
+ import { Client } from 'ssh2'
6
+ import type { JenkinsConfig, LinuxServer } from '../../shared/types.ts'
7
+
8
+ export interface TestResult {
9
+ ok: boolean
10
+ message: string
11
+ }
12
+
13
+ const CONNECT_TIMEOUT_MS = 10_000
14
+
15
+ /** 测试 SSH 能否连通并认证通过 */
16
+ export function testSshConnection(server: Partial<LinuxServer>): Promise<TestResult> {
17
+ const host = String(server.host ?? '').trim()
18
+ const username = String(server.username ?? '').trim()
19
+ if (!host || !username) {
20
+ return Promise.resolve({ ok: false, message: '请先填写 IP 与用户名' })
21
+ }
22
+
23
+ return new Promise((resolve) => {
24
+ const conn = new Client()
25
+ let done = false
26
+ const finish = (r: TestResult): void => {
27
+ if (done) return
28
+ done = true
29
+ clearTimeout(timer)
30
+ try {
31
+ conn.end()
32
+ } catch {
33
+ /* 已断开时忽略 */
34
+ }
35
+ resolve(r)
36
+ }
37
+ const timer = setTimeout(() => finish({ ok: false, message: '连接超时(10 秒)' }), CONNECT_TIMEOUT_MS)
38
+
39
+ conn
40
+ .on('ready', () => {
41
+ finish({ ok: true, message: `连接成功:${username}@${host}:${server.port || 22}` })
42
+ })
43
+ .on('error', (err: Error) => {
44
+ finish({ ok: false, message: `连接失败:${err.message}` })
45
+ })
46
+ .connect({
47
+ host,
48
+ port: Number(server.port) || 22,
49
+ username,
50
+ password: String(server.password ?? ''),
51
+ readyTimeout: CONNECT_TIMEOUT_MS
52
+ })
53
+ })
54
+ }
55
+
56
+ /** 测试 Jenkins 地址与账号(读取 /api/json) */
57
+ export async function testJenkinsConnection(cfg: Partial<JenkinsConfig>): Promise<TestResult> {
58
+ const base = String(cfg.url ?? '').trim().replace(/\/+$/, '')
59
+ if (!base) return { ok: false, message: '请先填写 Jenkins URL' }
60
+
61
+ const auth = 'Basic ' + Buffer.from(`${cfg.username ?? ''}:${cfg.password ?? ''}`, 'utf-8').toString('base64')
62
+ try {
63
+ const res = await fetch(`${base}/api/json`, { headers: { Authorization: auth } })
64
+ if (res.status === 401 || res.status === 403) {
65
+ return { ok: false, message: `认证失败(HTTP ${res.status}):请检查用户名与密码/API Token` }
66
+ }
67
+ if (!res.ok) {
68
+ return { ok: false, message: `连接失败:HTTP ${res.status}` }
69
+ }
70
+ const j = (await res.json()) as { nodeName?: string }
71
+ return { ok: true, message: j.nodeName ? `连接成功:${j.nodeName}` : '连接成功' }
72
+ } catch (err) {
73
+ return { ok: false, message: `连接失败:${(err as Error).message}` }
74
+ }
75
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * 扫码枪读取 — 串口模式条码扫描器(对齐旧版 scanner_reader.py)
3
+ *
4
+ * 通信特性(参考 Honeywell 1450g):
5
+ * - 串口 9600 baud
6
+ * - 数据帧一次性吐出,通常 STX(\x02) 开头 ETX(\x03) 结尾
7
+ * - 首块到达后短暂等待拼帧,再剥离控制字符取纯值
8
+ *
9
+ * serialport 为原生模块:不可用时服务降级,不影响主程序。
10
+ * 注意:本项目为 ESM("type": "module"),必须用动态 import() 加载,
11
+ * 不能用 require()(ESM 上下文 require 未定义)。
12
+ */
13
+ import { EventEmitter } from 'node:events'
14
+ import type { ScannerSettings } from '../../shared/types.ts'
15
+
16
+ /** 剥离条码帧控制字符(STX/ETX/CR/LF),提取纯条码值 */
17
+ export function stripBarcode(raw: Buffer | string): string {
18
+ const text = (typeof raw === 'string' ? raw : raw.toString('ascii')).replace(
19
+ /[\x02\x03\r\n]/g,
20
+ ''
21
+ )
22
+ return text.trim()
23
+ }
24
+
25
+ export interface SerialPortLike {
26
+ on(event: 'data', cb: (chunk: Buffer) => void): void
27
+ on(event: 'close', cb: () => void): void
28
+ on(event: 'error', cb: (err: Error) => void): void
29
+ write(data: Buffer): void
30
+ close(): void
31
+ }
32
+
33
+ interface SerialPortCtor {
34
+ new (options: { path: string; baudRate: number; autoOpen?: boolean }): SerialPortLike & {
35
+ open(cb?: (err?: Error) => void): void
36
+ set(options: { rts?: boolean; dtr?: boolean }): void
37
+ }
38
+ }
39
+
40
+ type SerialModule = {
41
+ SerialPort: SerialPortCtor
42
+ list: () => Promise<Array<{ path: string }>>
43
+ }
44
+
45
+ /** 动态加载 serialport(ESM 下用 import(),原生模块缺失时返回 null 并降级) */
46
+ async function loadSerialModule(): Promise<SerialModule | null> {
47
+ try {
48
+ const mod = (await import('serialport')) as unknown as {
49
+ SerialPort?: SerialPortCtor
50
+ default?: { SerialPort?: SerialPortCtor }
51
+ }
52
+ const SerialPort = mod.SerialPort ?? mod.default?.SerialPort
53
+ if (!SerialPort) return null
54
+ // serialport v13: list 是 SerialPort 的静态方法,而非顶层导出
55
+ const list = (SerialPort as unknown as { list?: () => Promise<Array<{ path: string }>> }).list
56
+ if (!list) return null
57
+ return { SerialPort, list }
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ const FRAME_SETTLE_MS = 50 // 首块到达后等待拼帧
64
+ const RECONNECT_INTERVAL_MS = 3000
65
+ const OPEN_TIMEOUT_MS = 4000 // 开串口最长等待,避免前端一直转圈
66
+
67
+ export interface ScannerDiagnostics {
68
+ running: boolean
69
+ connected: boolean
70
+ lastError: string | null
71
+ lastStatus: { level: string; message: string; at: number } | null
72
+ }
73
+
74
+ export class ScannerReader extends EventEmitter {
75
+ private port: SerialPortLike | null = null
76
+ private running = false
77
+ private connecting = false
78
+ private connected = false
79
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
80
+ private lastError: string | null = null
81
+ private lastStatus: { level: string; message: string; at: number } | null = null
82
+ private getConfig: () => ScannerSettings
83
+
84
+ constructor(getConfig: () => ScannerSettings) {
85
+ super()
86
+ this.getConfig = getConfig
87
+ }
88
+
89
+ get isRunning(): boolean {
90
+ return this.running
91
+ }
92
+
93
+ get isConnected(): boolean {
94
+ return this.connected
95
+ }
96
+
97
+ /** 返回最近一次状态/错误,供前端诊断展示 */
98
+ getDiagnostics(): ScannerDiagnostics {
99
+ return {
100
+ running: this.running,
101
+ connected: this.connected,
102
+ lastError: this.lastError,
103
+ lastStatus: this.lastStatus
104
+ }
105
+ }
106
+
107
+ /** 枚举系统可用串口 */
108
+ static async listPorts(): Promise<string[]> {
109
+ const mod = await loadSerialModule()
110
+ if (!mod) return []
111
+ try {
112
+ const ports = await mod.list()
113
+ return ports.map((p) => p.path)
114
+ } catch {
115
+ return []
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 启动并连接扫码枪。
121
+ * @returns null 表示连接成功;否则返回具体错误文案(供前端直接展示)。
122
+ */
123
+ start(): Promise<string | null> {
124
+ if (this.running) {
125
+ return Promise.resolve(this.connected ? null : this.lastError ?? '扫码枪正在连接…')
126
+ }
127
+ const cfg = this.getConfig()
128
+ if (!cfg.port) {
129
+ this.lastError = '未配置扫码枪串口(请在设置中填写串口号,如 COM9)'
130
+ this.report('warning', this.lastError)
131
+ return Promise.resolve(this.lastError)
132
+ }
133
+ this.running = true
134
+ return this.connect()
135
+ }
136
+
137
+ private async connect(): Promise<string | null> {
138
+ if (this.connecting || !this.running) return this.lastError
139
+ const cfg = this.getConfig()
140
+ this.connecting = true
141
+ const mod = await loadSerialModule()
142
+ if (!mod) {
143
+ this.connecting = false
144
+ this.lastError = 'serialport 模块不可用(原生依赖未安装或加载失败)'
145
+ this.report('error', this.lastError)
146
+ return this.lastError
147
+ }
148
+ return new Promise<string | null>((resolve) => {
149
+ let settled = false
150
+ const finish = (err: string | null) => {
151
+ if (settled) return
152
+ settled = true
153
+ this.connecting = false
154
+ resolve(err)
155
+ }
156
+ const timeout = setTimeout(
157
+ () => finish(this.lastError ?? '串口打开超时(设备未响应、未插好或波特率不匹配)'),
158
+ OPEN_TIMEOUT_MS
159
+ )
160
+ try {
161
+ const port = new mod.SerialPort({ path: cfg.port, baudRate: cfg.baud, autoOpen: false })
162
+ port.open((err) => {
163
+ clearTimeout(timeout)
164
+ if (err) {
165
+ this.lastError = `打开串口 ${cfg.port} 失败: ${err.message}`
166
+ this.report('error', this.lastError)
167
+ this.connected = false
168
+ finish(this.lastError)
169
+ this.scheduleReconnect()
170
+ return
171
+ }
172
+ this.connected = true
173
+ this.lastError = null
174
+ try {
175
+ ;(port as unknown as { set(o: { rts: boolean; dtr: boolean }): void }).set({
176
+ rts: true,
177
+ dtr: true
178
+ })
179
+ } catch {
180
+ /* 部分 USB 转串口不支持 */
181
+ }
182
+ this.port = port
183
+ this.report('info', `扫码枪已连接: ${cfg.port} @ ${cfg.baud}`)
184
+
185
+ let frame = Buffer.alloc(0)
186
+ let settleTimer: ReturnType<typeof setTimeout> | null = null
187
+ port.on('data', (chunk: Buffer) => {
188
+ frame = Buffer.concat([frame, chunk])
189
+ // 拼帧:首块到达后再等一小段时间收尾
190
+ if (settleTimer) clearTimeout(settleTimer)
191
+ settleTimer = setTimeout(() => {
192
+ const value = stripBarcode(frame)
193
+ frame = Buffer.alloc(0)
194
+ if (value) this.emit('scan', value)
195
+ }, FRAME_SETTLE_MS)
196
+ })
197
+ port.on('close', () => {
198
+ this.port = null
199
+ this.connected = false
200
+ if (this.running) {
201
+ this.report('warning', '扫码枪串口断开,自动重连中…')
202
+ this.scheduleReconnect()
203
+ }
204
+ })
205
+ port.on('error', (e: Error) => {
206
+ this.report('error', `串口异常: ${e.message}`)
207
+ })
208
+
209
+ finish(null)
210
+ })
211
+ } catch (err) {
212
+ clearTimeout(timeout)
213
+ this.lastError = `串口初始化失败: ${String(err)}`
214
+ this.report('error', this.lastError)
215
+ finish(this.lastError)
216
+ this.scheduleReconnect()
217
+ }
218
+ })
219
+ }
220
+
221
+ private scheduleReconnect(): void {
222
+ if (!this.running || this.reconnectTimer) return
223
+ this.reconnectTimer = setTimeout(() => {
224
+ this.reconnectTimer = null
225
+ if (this.running && !this.connected) {
226
+ void this.connect()
227
+ }
228
+ }, RECONNECT_INTERVAL_MS)
229
+ this.reconnectTimer.unref?.()
230
+ }
231
+
232
+ stop(): void {
233
+ this.running = false
234
+ this.connected = false
235
+ if (this.reconnectTimer) {
236
+ clearTimeout(this.reconnectTimer)
237
+ this.reconnectTimer = null
238
+ }
239
+ try {
240
+ this.port?.close()
241
+ } catch {
242
+ /* ignore */
243
+ }
244
+ this.port = null
245
+ this.report('info', '扫码枪已停止')
246
+ }
247
+
248
+ shutdown(): void {
249
+ this.stop()
250
+ }
251
+
252
+ /** 记录最近状态并向外广播 */
253
+ private report(level: string, message: string): void {
254
+ this.lastStatus = { level, message, at: Date.now() }
255
+ this.emit('status', level, message)
256
+ }
257
+ }