@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.
- package/README.md +394 -0
- package/bin/deskpet.mjs +142 -0
- package/dist/web/assets/DashboardView-BLXa7VrZ.css +1 -0
- package/dist/web/assets/DashboardView-CTyWcLtQ.js +60 -0
- package/dist/web/assets/SettingsView-7c3RiRrt.js +1 -0
- package/dist/web/assets/SettingsView-BkL0OpZC.css +1 -0
- package/dist/web/assets/TasksView-BTK1OTwU.css +1 -0
- package/dist/web/assets/TasksView-s1MaR5sZ.js +5 -0
- package/dist/web/assets/ToolsView-B8V-A1j_.js +178 -0
- package/dist/web/assets/ToolsView-DGLJATQ9.css +1 -0
- package/dist/web/assets/WeChatView-ChLBhPso.js +1 -0
- package/dist/web/assets/WeChatView-CvdhJ05E.css +1 -0
- package/dist/web/assets/browser-CjSdxGTc.js +8 -0
- package/dist/web/assets/dashboard-DJ_Miuzx.js +93 -0
- package/dist/web/assets/dashboard-Fbcagzwx.css +1 -0
- package/dist/web/dashboard/index.html +13 -0
- package/package.json +71 -0
- package/resources/icons/tray.png +0 -0
- package/resources/icons/tray@2x.png +0 -0
- package/resources/previews/busy.png +0 -0
- package/resources/previews/click.png +0 -0
- package/resources/previews/hover.png +0 -0
- package/resources/previews/idle.png +0 -0
- package/resources/previews/preview.png +0 -0
- package/resources/previews/sleep.png +0 -0
- package/resources/skins/default_cute/pet.json +7 -0
- package/resources/skins/default_cute/spritesheet.webp +0 -0
- package/resources/skins/default_cute/submission.json +29 -0
- package/server/db/database.ts +118 -0
- package/server/db/migrate-legacy.ts +121 -0
- package/server/db/task-repository.ts +585 -0
- package/server/http/http-server.ts +725 -0
- package/server/http/ws-hub.ts +66 -0
- package/server/main.ts +322 -0
- package/server/plugins/actions/builtin.ts +73 -0
- package/server/plugins/actions/clipboard-watch.ts +38 -0
- package/server/plugins/actions/http-request.ts +53 -0
- package/server/plugins/actions/jenkins-build.ts +209 -0
- package/server/plugins/actions/open-app.ts +40 -0
- package/server/plugins/actions/python-script.ts +187 -0
- package/server/plugins/actions/screenshot.ts +41 -0
- package/server/plugins/actions/send-keystroke.ts +103 -0
- package/server/plugins/actions/show-reminder.ts +16 -0
- package/server/plugins/actions/ssh-command.ts +148 -0
- package/server/plugins/actions/task-chain.ts +30 -0
- package/server/plugins/actions/volume-control.ts +35 -0
- package/server/plugins/index.ts +37 -0
- package/server/plugins/registry.ts +72 -0
- package/server/services/clipboard-watcher.ts +126 -0
- package/server/services/config-store.ts +152 -0
- package/server/services/idle-monitor.ts +146 -0
- package/server/services/notifier.ts +54 -0
- package/server/services/quick-actions-store.ts +54 -0
- package/server/services/remote-connector.ts +75 -0
- package/server/services/scanner-reader.ts +257 -0
- package/server/services/script-runner.ts +263 -0
- package/server/services/snapshot-service.ts +196 -0
- package/server/services/task-scheduler.ts +900 -0
- package/server/services/wechat-bot.ts +744 -0
- package/server/services/wechat-command-types.ts +15 -0
- package/server/services/wechat-commands.ts +367 -0
- package/server/suppress-warnings.ts +10 -0
- package/server/utils/asset-url.ts +27 -0
- package/server/utils/auto-start.ts +90 -0
- package/server/utils/clipboard.ts +50 -0
- package/server/utils/dashboard-url.ts +9 -0
- package/server/utils/instance-guard.ts +170 -0
- package/server/utils/native-notify.ts +68 -0
- package/server/utils/open.ts +38 -0
- package/server/utils/paths.ts +72 -0
- package/server/utils/python-interpreter.ts +154 -0
- package/shared/animation-engine.ts +422 -0
- package/shared/chain-condition.ts +60 -0
- package/shared/cron-weekly.ts +131 -0
- package/shared/py-task-params.ts +356 -0
- 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
|
+
}
|