@wanghaopeng1148/deskpet 2.0.0 → 2.0.2
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 +70 -16
- package/bin/deskpet.mjs +70 -2
- package/dist/node/server/db/database.js +113 -0
- package/dist/node/server/db/migrate-legacy.js +88 -0
- package/dist/node/server/db/task-repository.js +374 -0
- package/dist/node/server/http/http-server.js +493 -0
- package/dist/node/server/http/ws-hub.js +59 -0
- package/dist/node/server/main.js +291 -0
- package/dist/node/server/plugins/actions/builtin.js +67 -0
- package/dist/node/server/plugins/actions/clipboard-watch.js +27 -0
- package/dist/node/server/plugins/actions/http-request.js +41 -0
- package/dist/node/server/plugins/actions/jenkins-build.js +183 -0
- package/dist/node/server/plugins/actions/open-app.js +41 -0
- package/dist/node/server/plugins/actions/python-script.js +180 -0
- package/dist/node/server/plugins/actions/screenshot.js +38 -0
- package/dist/node/server/plugins/actions/send-keystroke.js +100 -0
- package/dist/node/server/plugins/actions/show-reminder.js +7 -0
- package/dist/node/server/plugins/actions/ssh-command.js +123 -0
- package/dist/node/server/plugins/actions/task-chain.js +24 -0
- package/dist/node/server/plugins/actions/volume-control.js +31 -0
- package/dist/node/server/plugins/index.js +35 -0
- package/dist/node/server/plugins/registry.js +23 -0
- package/dist/node/server/services/clipboard-watcher.js +112 -0
- package/dist/node/server/services/config-store.js +141 -0
- package/dist/node/server/services/idle-monitor.js +131 -0
- package/dist/node/server/services/notifier.js +36 -0
- package/dist/node/server/services/quick-actions-store.js +52 -0
- package/dist/node/server/services/remote-connector.js +67 -0
- package/dist/node/server/services/scanner-reader.js +217 -0
- package/dist/node/server/services/script-runner.js +228 -0
- package/dist/node/server/services/snapshot-service.js +135 -0
- package/dist/node/server/services/task-scheduler.js +813 -0
- package/dist/node/server/services/wechat-bot.js +635 -0
- package/dist/node/server/services/wechat-command-types.js +1 -0
- package/dist/node/server/services/wechat-commands.js +330 -0
- package/dist/node/server/suppress-warnings.js +12 -0
- package/dist/node/server/utils/asset-url.js +26 -0
- package/dist/node/server/utils/auto-start.js +186 -0
- package/dist/node/server/utils/clipboard.js +50 -0
- package/dist/node/server/utils/dashboard-url.js +8 -0
- package/dist/node/server/utils/instance-guard.js +165 -0
- package/dist/node/server/utils/native-notify.js +53 -0
- package/dist/node/server/utils/open.js +37 -0
- package/dist/node/server/utils/paths.js +95 -0
- package/dist/node/server/utils/python-interpreter.js +129 -0
- package/dist/node/shared/animation-engine.js +349 -0
- package/dist/node/shared/chain-condition.js +39 -0
- package/dist/node/shared/cron-weekly.js +124 -0
- package/dist/node/shared/py-task-params.js +335 -0
- package/dist/node/shared/types.js +69 -0
- package/package.json +6 -2
- package/server/http/http-server.ts +4 -1
- package/server/utils/auto-start.ts +158 -49
- package/server/utils/paths.ts +28 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置存储 — 轻量 JSON 持久化(对应旧版 ConfigStore + electron-store 职责)
|
|
3
|
+
* 文件: userData/config/settings.json
|
|
4
|
+
*/
|
|
5
|
+
import { EventEmitter } from 'events';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { ensureDir, getConfigDir } from "../utils/paths.js";
|
|
9
|
+
export const DEFAULT_SETTINGS = {
|
|
10
|
+
system: {
|
|
11
|
+
autoStart: false,
|
|
12
|
+
powerMode: 'balanced',
|
|
13
|
+
idleSleepMinutes: 3
|
|
14
|
+
},
|
|
15
|
+
audio: {
|
|
16
|
+
enabled: true,
|
|
17
|
+
volume: 70
|
|
18
|
+
},
|
|
19
|
+
server: {
|
|
20
|
+
host: '127.0.0.1',
|
|
21
|
+
port: 3210
|
|
22
|
+
},
|
|
23
|
+
wechat: {
|
|
24
|
+
notifyOnTaskComplete: true,
|
|
25
|
+
notifyOnTaskFailed: true,
|
|
26
|
+
notifyOnStartup: false,
|
|
27
|
+
autoConnect: true,
|
|
28
|
+
allowRemoteCommand: true
|
|
29
|
+
},
|
|
30
|
+
tasks: {
|
|
31
|
+
breakerThreshold: 3
|
|
32
|
+
},
|
|
33
|
+
scanner: {
|
|
34
|
+
enabled: false,
|
|
35
|
+
port: '',
|
|
36
|
+
baud: 9600,
|
|
37
|
+
notifyWechat: true,
|
|
38
|
+
notifyDesktop: true
|
|
39
|
+
},
|
|
40
|
+
servers: [],
|
|
41
|
+
jenkins: {
|
|
42
|
+
url: '',
|
|
43
|
+
username: '',
|
|
44
|
+
password: ''
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
function deepMerge(base, patch) {
|
|
48
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
49
|
+
return (patch === undefined ? base : patch);
|
|
50
|
+
}
|
|
51
|
+
const out = { ...base };
|
|
52
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
53
|
+
const baseV = base[k];
|
|
54
|
+
out[k] = typeof v === 'object' && v !== null && !Array.isArray(v) && baseV
|
|
55
|
+
? deepMerge(baseV, v)
|
|
56
|
+
: v;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
export class ConfigStore extends EventEmitter {
|
|
61
|
+
settings;
|
|
62
|
+
filePath;
|
|
63
|
+
constructor(filePath) {
|
|
64
|
+
super();
|
|
65
|
+
this.filePath = filePath ?? join(getConfigDir(), 'settings.json');
|
|
66
|
+
this.settings = this.load();
|
|
67
|
+
}
|
|
68
|
+
load() {
|
|
69
|
+
try {
|
|
70
|
+
if (existsSync(this.filePath)) {
|
|
71
|
+
const raw = JSON.parse(readFileSync(this.filePath, 'utf-8'));
|
|
72
|
+
return deepMerge(structuredClone(DEFAULT_SETTINGS), raw);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
console.error('[config] 读取配置失败,使用默认值:', err);
|
|
77
|
+
}
|
|
78
|
+
// 首次初始化:写入默认配置
|
|
79
|
+
const defaults = structuredClone(DEFAULT_SETTINGS);
|
|
80
|
+
try {
|
|
81
|
+
ensureDir(getConfigDir());
|
|
82
|
+
writeFileSync(this.filePath, JSON.stringify(defaults, null, 2), 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
console.error('[config] 初始化配置写入失败:', err);
|
|
86
|
+
}
|
|
87
|
+
return defaults;
|
|
88
|
+
}
|
|
89
|
+
get(key) {
|
|
90
|
+
if (key) {
|
|
91
|
+
const keys = key.split('.');
|
|
92
|
+
let cur = this.settings;
|
|
93
|
+
for (const k of keys) {
|
|
94
|
+
if (cur && typeof cur === 'object' && k in cur) {
|
|
95
|
+
cur = cur[k];
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return structuredClone(cur);
|
|
102
|
+
}
|
|
103
|
+
return structuredClone(this.settings);
|
|
104
|
+
}
|
|
105
|
+
set(key, value) {
|
|
106
|
+
const keys = key.split('.');
|
|
107
|
+
let cur = this.settings;
|
|
108
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
109
|
+
const k = keys[i];
|
|
110
|
+
if (!cur[k] || typeof cur[k] !== 'object')
|
|
111
|
+
cur[k] = {};
|
|
112
|
+
cur = cur[k];
|
|
113
|
+
}
|
|
114
|
+
cur[keys[keys.length - 1]] = value;
|
|
115
|
+
this.save();
|
|
116
|
+
this.emit('changed');
|
|
117
|
+
}
|
|
118
|
+
update(patch) {
|
|
119
|
+
this.settings = deepMerge(this.settings, patch);
|
|
120
|
+
this.save();
|
|
121
|
+
this.emit('changed');
|
|
122
|
+
}
|
|
123
|
+
/** 整体替换设置(快照恢复 / 配置包导入用) */
|
|
124
|
+
replaceAll(settings) {
|
|
125
|
+
this.settings = deepMerge(structuredClone(DEFAULT_SETTINGS), settings);
|
|
126
|
+
this.save();
|
|
127
|
+
this.emit('changed');
|
|
128
|
+
}
|
|
129
|
+
save() {
|
|
130
|
+
try {
|
|
131
|
+
ensureDir(getConfigDir());
|
|
132
|
+
writeFileSync(this.filePath, JSON.stringify(this.settings, null, 2), 'utf-8');
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.error('[config] 保存配置失败:', err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function deepMergeConfig(base, patch) {
|
|
140
|
+
return deepMerge(base, patch);
|
|
141
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
const dnsLookup = promisify(lookup);
|
|
12
|
+
function execText(cmd, args, timeout = 5000) {
|
|
13
|
+
return new Promise((resolvePromise) => {
|
|
14
|
+
execFile(cmd, args, { timeout, windowsHide: true, maxBuffer: 1024 * 1024 }, (err, stdout) => resolvePromise(err ? '' : String(stdout ?? '')));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** 获取系统闲置秒数(失败返回 0,表示"未闲置") */
|
|
18
|
+
export async function getSystemIdleSeconds() {
|
|
19
|
+
try {
|
|
20
|
+
if (process.platform === 'darwin') {
|
|
21
|
+
const out = await execText('ioreg', ['-c', 'IOHIDSystem']);
|
|
22
|
+
const m = /"HIDIdleTime"\s*=\s*(\d+)/.exec(out);
|
|
23
|
+
if (m?.[1])
|
|
24
|
+
return Math.floor(Number(m[1]) / 1_000_000_000);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
if (process.platform === 'win32') {
|
|
28
|
+
const script = [
|
|
29
|
+
'Add-Type -Namespace Win32 -Name IdleTime -MemberDefinition \'',
|
|
30
|
+
'[DllImport("user32.dll")] public static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);',
|
|
31
|
+
'[StructLayout(LayoutKind.Sequential)] public struct LASTINPUTINFO { public uint cbSize; public uint dwTime; }',
|
|
32
|
+
'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; }\'',
|
|
33
|
+
'[Win32.IdleTime]::GetIdleMs()'
|
|
34
|
+
].join(' ');
|
|
35
|
+
const out = await execText('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], 15000);
|
|
36
|
+
const ms = Number(out.trim().split(/\s+/).pop());
|
|
37
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
38
|
+
}
|
|
39
|
+
if (process.platform === 'linux') {
|
|
40
|
+
const out = await execText('xprintidle', []);
|
|
41
|
+
const ms = Number(out.trim());
|
|
42
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* ignore */
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
export class IdleMonitor {
|
|
51
|
+
timer = null;
|
|
52
|
+
lastMinutes = -1;
|
|
53
|
+
busy = false;
|
|
54
|
+
onIdleChange;
|
|
55
|
+
intervalMs;
|
|
56
|
+
constructor(onIdleChange, intervalMs = 30000) {
|
|
57
|
+
this.onIdleChange = onIdleChange;
|
|
58
|
+
this.intervalMs = intervalMs;
|
|
59
|
+
}
|
|
60
|
+
start() {
|
|
61
|
+
if (this.timer)
|
|
62
|
+
return;
|
|
63
|
+
this.timer = setInterval(() => {
|
|
64
|
+
void this.tick();
|
|
65
|
+
}, this.intervalMs);
|
|
66
|
+
this.timer.unref?.();
|
|
67
|
+
}
|
|
68
|
+
async tick() {
|
|
69
|
+
if (this.busy)
|
|
70
|
+
return;
|
|
71
|
+
this.busy = true;
|
|
72
|
+
try {
|
|
73
|
+
const seconds = await getSystemIdleSeconds();
|
|
74
|
+
const minutes = Math.floor(seconds / 60);
|
|
75
|
+
if (minutes !== this.lastMinutes) {
|
|
76
|
+
this.lastMinutes = minutes;
|
|
77
|
+
this.onIdleChange(minutes);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* 平台不可用时静默 */
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
this.busy = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
stop() {
|
|
88
|
+
if (this.timer)
|
|
89
|
+
clearInterval(this.timer);
|
|
90
|
+
this.timer = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** 网络恢复检测 — 周期性 DNS 探测,从失败恢复为成功时回调一次 */
|
|
94
|
+
export class NetworkMonitor {
|
|
95
|
+
timer = null;
|
|
96
|
+
lastOnline = true;
|
|
97
|
+
onOnline;
|
|
98
|
+
intervalMs;
|
|
99
|
+
host;
|
|
100
|
+
constructor(onOnline, intervalMs = 60000, host = 'www.baidu.com') {
|
|
101
|
+
this.onOnline = onOnline;
|
|
102
|
+
this.intervalMs = intervalMs;
|
|
103
|
+
this.host = host;
|
|
104
|
+
}
|
|
105
|
+
start() {
|
|
106
|
+
if (this.timer)
|
|
107
|
+
return;
|
|
108
|
+
this.timer = setInterval(() => {
|
|
109
|
+
void this.tick();
|
|
110
|
+
}, this.intervalMs);
|
|
111
|
+
this.timer.unref?.();
|
|
112
|
+
}
|
|
113
|
+
async tick() {
|
|
114
|
+
let online = false;
|
|
115
|
+
try {
|
|
116
|
+
await dnsLookup(this.host);
|
|
117
|
+
online = true;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
online = false;
|
|
121
|
+
}
|
|
122
|
+
if (online && !this.lastOnline)
|
|
123
|
+
this.onOnline();
|
|
124
|
+
this.lastOnline = online;
|
|
125
|
+
}
|
|
126
|
+
stop() {
|
|
127
|
+
if (this.timer)
|
|
128
|
+
clearInterval(this.timer);
|
|
129
|
+
this.timer = null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { notifyNative } from "../utils/native-notify.js";
|
|
2
|
+
export class Notifier {
|
|
3
|
+
sink = null;
|
|
4
|
+
wechatSender = null;
|
|
5
|
+
constructor(sink) {
|
|
6
|
+
this.sink = sink ?? null;
|
|
7
|
+
}
|
|
8
|
+
/** 绑定广播出口(WsHub 就绪后注入) */
|
|
9
|
+
setSink(sink) {
|
|
10
|
+
this.sink = sink;
|
|
11
|
+
}
|
|
12
|
+
/** 注入微信发送通道 */
|
|
13
|
+
setWechatSender(sender) {
|
|
14
|
+
this.wechatSender = sender;
|
|
15
|
+
}
|
|
16
|
+
/** 系统原生通知 + 广播到 WebUI */
|
|
17
|
+
notify(title, body) {
|
|
18
|
+
notifyNative(title, body);
|
|
19
|
+
this.sink?.broadcast('notification', { title, body, ts: Date.now() });
|
|
20
|
+
}
|
|
21
|
+
/** 任务提醒:广播到 WebUI(原气泡/全屏窗口已移除),并尝试系统通知 */
|
|
22
|
+
showReminder(reminder) {
|
|
23
|
+
const payload = { ...reminder, ts: Date.now() };
|
|
24
|
+
this.sink?.broadcast('reminder', payload);
|
|
25
|
+
notifyNative('任务提醒', reminder.text);
|
|
26
|
+
}
|
|
27
|
+
/** 微信通道(未连接时静默丢弃) */
|
|
28
|
+
sendWechat(text) {
|
|
29
|
+
try {
|
|
30
|
+
this.wechatSender?.(text);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
console.error('[notify] 微信推送失败:', err);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 快捷指令存储 — 花瓣菜单按钮配置(userData/config/quick-actions.json)
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
const DEFAULT_ACTIONS = [
|
|
6
|
+
{ id: 'qa-dashboard', label: '管理台', icon: '🖥' },
|
|
7
|
+
{ id: 'qa-hide', label: '隐藏', icon: '🙈' }
|
|
8
|
+
];
|
|
9
|
+
export class QuickActionsStore {
|
|
10
|
+
cache = null;
|
|
11
|
+
filePath;
|
|
12
|
+
constructor(filePath) {
|
|
13
|
+
this.filePath = filePath;
|
|
14
|
+
}
|
|
15
|
+
load() {
|
|
16
|
+
if (this.cache)
|
|
17
|
+
return this.cache;
|
|
18
|
+
try {
|
|
19
|
+
if (!existsSync(this.filePath)) {
|
|
20
|
+
this.cache = [...DEFAULT_ACTIONS];
|
|
21
|
+
this.save(this.cache);
|
|
22
|
+
return this.cache;
|
|
23
|
+
}
|
|
24
|
+
const raw = JSON.parse(readFileSync(this.filePath, 'utf-8'));
|
|
25
|
+
const actions = Array.isArray(raw.actions) ? raw.actions : [...DEFAULT_ACTIONS];
|
|
26
|
+
// 迁移:补齐缺失的内置动作(追加到末尾,用户可在设置中调整顺序)
|
|
27
|
+
let dirty = false;
|
|
28
|
+
for (const def of DEFAULT_ACTIONS) {
|
|
29
|
+
if (!actions.some((a) => a.id === def.id)) {
|
|
30
|
+
actions.push({ ...def });
|
|
31
|
+
dirty = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
this.cache = actions;
|
|
35
|
+
if (dirty)
|
|
36
|
+
this.save(this.cache);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
this.cache = [...DEFAULT_ACTIONS];
|
|
40
|
+
}
|
|
41
|
+
return this.cache;
|
|
42
|
+
}
|
|
43
|
+
save(actions) {
|
|
44
|
+
this.cache = actions;
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(this.filePath, JSON.stringify({ actions }, null, 2), 'utf-8');
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
console.error('[quick-actions] 保存失败:', err);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程连接自检 — 供「系统设置」验证 Linux 服务器 / Jenkins 凭据是否可用
|
|
3
|
+
* 只做连通性探测,不执行任何命令
|
|
4
|
+
*/
|
|
5
|
+
import { Client } from 'ssh2';
|
|
6
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
7
|
+
/** 测试 SSH 能否连通并认证通过 */
|
|
8
|
+
export function testSshConnection(server) {
|
|
9
|
+
const host = String(server.host ?? '').trim();
|
|
10
|
+
const username = String(server.username ?? '').trim();
|
|
11
|
+
if (!host || !username) {
|
|
12
|
+
return Promise.resolve({ ok: false, message: '请先填写 IP 与用户名' });
|
|
13
|
+
}
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const conn = new Client();
|
|
16
|
+
let done = false;
|
|
17
|
+
const finish = (r) => {
|
|
18
|
+
if (done)
|
|
19
|
+
return;
|
|
20
|
+
done = true;
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
try {
|
|
23
|
+
conn.end();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* 已断开时忽略 */
|
|
27
|
+
}
|
|
28
|
+
resolve(r);
|
|
29
|
+
};
|
|
30
|
+
const timer = setTimeout(() => finish({ ok: false, message: '连接超时(10 秒)' }), CONNECT_TIMEOUT_MS);
|
|
31
|
+
conn
|
|
32
|
+
.on('ready', () => {
|
|
33
|
+
finish({ ok: true, message: `连接成功:${username}@${host}:${server.port || 22}` });
|
|
34
|
+
})
|
|
35
|
+
.on('error', (err) => {
|
|
36
|
+
finish({ ok: false, message: `连接失败:${err.message}` });
|
|
37
|
+
})
|
|
38
|
+
.connect({
|
|
39
|
+
host,
|
|
40
|
+
port: Number(server.port) || 22,
|
|
41
|
+
username,
|
|
42
|
+
password: String(server.password ?? ''),
|
|
43
|
+
readyTimeout: CONNECT_TIMEOUT_MS
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** 测试 Jenkins 地址与账号(读取 /api/json) */
|
|
48
|
+
export async function testJenkinsConnection(cfg) {
|
|
49
|
+
const base = String(cfg.url ?? '').trim().replace(/\/+$/, '');
|
|
50
|
+
if (!base)
|
|
51
|
+
return { ok: false, message: '请先填写 Jenkins URL' };
|
|
52
|
+
const auth = 'Basic ' + Buffer.from(`${cfg.username ?? ''}:${cfg.password ?? ''}`, 'utf-8').toString('base64');
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(`${base}/api/json`, { headers: { Authorization: auth } });
|
|
55
|
+
if (res.status === 401 || res.status === 403) {
|
|
56
|
+
return { ok: false, message: `认证失败(HTTP ${res.status}):请检查用户名与密码/API Token` };
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
return { ok: false, message: `连接失败:HTTP ${res.status}` };
|
|
60
|
+
}
|
|
61
|
+
const j = (await res.json());
|
|
62
|
+
return { ok: true, message: j.nodeName ? `连接成功:${j.nodeName}` : '连接成功' };
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return { ok: false, message: `连接失败:${err.message}` };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
/** 剥离条码帧控制字符(STX/ETX/CR/LF),提取纯条码值 */
|
|
15
|
+
export function stripBarcode(raw) {
|
|
16
|
+
const text = (typeof raw === 'string' ? raw : raw.toString('ascii')).replace(/[\x02\x03\r\n]/g, '');
|
|
17
|
+
return text.trim();
|
|
18
|
+
}
|
|
19
|
+
/** 动态加载 serialport(ESM 下用 import(),原生模块缺失时返回 null 并降级) */
|
|
20
|
+
async function loadSerialModule() {
|
|
21
|
+
try {
|
|
22
|
+
const mod = (await import('serialport'));
|
|
23
|
+
const SerialPort = mod.SerialPort ?? mod.default?.SerialPort;
|
|
24
|
+
if (!SerialPort)
|
|
25
|
+
return null;
|
|
26
|
+
// serialport v13: list 是 SerialPort 的静态方法,而非顶层导出
|
|
27
|
+
const list = SerialPort.list;
|
|
28
|
+
if (!list)
|
|
29
|
+
return null;
|
|
30
|
+
return { SerialPort, list };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const FRAME_SETTLE_MS = 50; // 首块到达后等待拼帧
|
|
37
|
+
const RECONNECT_INTERVAL_MS = 3000;
|
|
38
|
+
const OPEN_TIMEOUT_MS = 4000; // 开串口最长等待,避免前端一直转圈
|
|
39
|
+
export class ScannerReader extends EventEmitter {
|
|
40
|
+
port = null;
|
|
41
|
+
running = false;
|
|
42
|
+
connecting = false;
|
|
43
|
+
connected = false;
|
|
44
|
+
reconnectTimer = null;
|
|
45
|
+
lastError = null;
|
|
46
|
+
lastStatus = null;
|
|
47
|
+
getConfig;
|
|
48
|
+
constructor(getConfig) {
|
|
49
|
+
super();
|
|
50
|
+
this.getConfig = getConfig;
|
|
51
|
+
}
|
|
52
|
+
get isRunning() {
|
|
53
|
+
return this.running;
|
|
54
|
+
}
|
|
55
|
+
get isConnected() {
|
|
56
|
+
return this.connected;
|
|
57
|
+
}
|
|
58
|
+
/** 返回最近一次状态/错误,供前端诊断展示 */
|
|
59
|
+
getDiagnostics() {
|
|
60
|
+
return {
|
|
61
|
+
running: this.running,
|
|
62
|
+
connected: this.connected,
|
|
63
|
+
lastError: this.lastError,
|
|
64
|
+
lastStatus: this.lastStatus
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** 枚举系统可用串口 */
|
|
68
|
+
static async listPorts() {
|
|
69
|
+
const mod = await loadSerialModule();
|
|
70
|
+
if (!mod)
|
|
71
|
+
return [];
|
|
72
|
+
try {
|
|
73
|
+
const ports = await mod.list();
|
|
74
|
+
return ports.map((p) => p.path);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 启动并连接扫码枪。
|
|
82
|
+
* @returns null 表示连接成功;否则返回具体错误文案(供前端直接展示)。
|
|
83
|
+
*/
|
|
84
|
+
start() {
|
|
85
|
+
if (this.running) {
|
|
86
|
+
return Promise.resolve(this.connected ? null : this.lastError ?? '扫码枪正在连接…');
|
|
87
|
+
}
|
|
88
|
+
const cfg = this.getConfig();
|
|
89
|
+
if (!cfg.port) {
|
|
90
|
+
this.lastError = '未配置扫码枪串口(请在设置中填写串口号,如 COM9)';
|
|
91
|
+
this.report('warning', this.lastError);
|
|
92
|
+
return Promise.resolve(this.lastError);
|
|
93
|
+
}
|
|
94
|
+
this.running = true;
|
|
95
|
+
return this.connect();
|
|
96
|
+
}
|
|
97
|
+
async connect() {
|
|
98
|
+
if (this.connecting || !this.running)
|
|
99
|
+
return this.lastError;
|
|
100
|
+
const cfg = this.getConfig();
|
|
101
|
+
this.connecting = true;
|
|
102
|
+
const mod = await loadSerialModule();
|
|
103
|
+
if (!mod) {
|
|
104
|
+
this.connecting = false;
|
|
105
|
+
this.lastError = 'serialport 模块不可用(原生依赖未安装或加载失败)';
|
|
106
|
+
this.report('error', this.lastError);
|
|
107
|
+
return this.lastError;
|
|
108
|
+
}
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
let settled = false;
|
|
111
|
+
const finish = (err) => {
|
|
112
|
+
if (settled)
|
|
113
|
+
return;
|
|
114
|
+
settled = true;
|
|
115
|
+
this.connecting = false;
|
|
116
|
+
resolve(err);
|
|
117
|
+
};
|
|
118
|
+
const timeout = setTimeout(() => finish(this.lastError ?? '串口打开超时(设备未响应、未插好或波特率不匹配)'), OPEN_TIMEOUT_MS);
|
|
119
|
+
try {
|
|
120
|
+
const port = new mod.SerialPort({ path: cfg.port, baudRate: cfg.baud, autoOpen: false });
|
|
121
|
+
port.open((err) => {
|
|
122
|
+
clearTimeout(timeout);
|
|
123
|
+
if (err) {
|
|
124
|
+
this.lastError = `打开串口 ${cfg.port} 失败: ${err.message}`;
|
|
125
|
+
this.report('error', this.lastError);
|
|
126
|
+
this.connected = false;
|
|
127
|
+
finish(this.lastError);
|
|
128
|
+
this.scheduleReconnect();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
this.connected = true;
|
|
132
|
+
this.lastError = null;
|
|
133
|
+
try {
|
|
134
|
+
;
|
|
135
|
+
port.set({
|
|
136
|
+
rts: true,
|
|
137
|
+
dtr: true
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
/* 部分 USB 转串口不支持 */
|
|
142
|
+
}
|
|
143
|
+
this.port = port;
|
|
144
|
+
this.report('info', `扫码枪已连接: ${cfg.port} @ ${cfg.baud}`);
|
|
145
|
+
let frame = Buffer.alloc(0);
|
|
146
|
+
let settleTimer = null;
|
|
147
|
+
port.on('data', (chunk) => {
|
|
148
|
+
frame = Buffer.concat([frame, chunk]);
|
|
149
|
+
// 拼帧:首块到达后再等一小段时间收尾
|
|
150
|
+
if (settleTimer)
|
|
151
|
+
clearTimeout(settleTimer);
|
|
152
|
+
settleTimer = setTimeout(() => {
|
|
153
|
+
const value = stripBarcode(frame);
|
|
154
|
+
frame = Buffer.alloc(0);
|
|
155
|
+
if (value)
|
|
156
|
+
this.emit('scan', value);
|
|
157
|
+
}, FRAME_SETTLE_MS);
|
|
158
|
+
});
|
|
159
|
+
port.on('close', () => {
|
|
160
|
+
this.port = null;
|
|
161
|
+
this.connected = false;
|
|
162
|
+
if (this.running) {
|
|
163
|
+
this.report('warning', '扫码枪串口断开,自动重连中…');
|
|
164
|
+
this.scheduleReconnect();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
port.on('error', (e) => {
|
|
168
|
+
this.report('error', `串口异常: ${e.message}`);
|
|
169
|
+
});
|
|
170
|
+
finish(null);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
clearTimeout(timeout);
|
|
175
|
+
this.lastError = `串口初始化失败: ${String(err)}`;
|
|
176
|
+
this.report('error', this.lastError);
|
|
177
|
+
finish(this.lastError);
|
|
178
|
+
this.scheduleReconnect();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
scheduleReconnect() {
|
|
183
|
+
if (!this.running || this.reconnectTimer)
|
|
184
|
+
return;
|
|
185
|
+
this.reconnectTimer = setTimeout(() => {
|
|
186
|
+
this.reconnectTimer = null;
|
|
187
|
+
if (this.running && !this.connected) {
|
|
188
|
+
void this.connect();
|
|
189
|
+
}
|
|
190
|
+
}, RECONNECT_INTERVAL_MS);
|
|
191
|
+
this.reconnectTimer.unref?.();
|
|
192
|
+
}
|
|
193
|
+
stop() {
|
|
194
|
+
this.running = false;
|
|
195
|
+
this.connected = false;
|
|
196
|
+
if (this.reconnectTimer) {
|
|
197
|
+
clearTimeout(this.reconnectTimer);
|
|
198
|
+
this.reconnectTimer = null;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
this.port?.close();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
/* ignore */
|
|
205
|
+
}
|
|
206
|
+
this.port = null;
|
|
207
|
+
this.report('info', '扫码枪已停止');
|
|
208
|
+
}
|
|
209
|
+
shutdown() {
|
|
210
|
+
this.stop();
|
|
211
|
+
}
|
|
212
|
+
/** 记录最近状态并向外广播 */
|
|
213
|
+
report(level, message) {
|
|
214
|
+
this.lastStatus = { level, message, at: Date.now() };
|
|
215
|
+
this.emit('status', level, message);
|
|
216
|
+
}
|
|
217
|
+
}
|