@wanghaopeng1148/deskpet 2.0.1 → 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.
Files changed (53) hide show
  1. package/README.md +54 -15
  2. package/bin/deskpet.mjs +70 -2
  3. package/dist/node/server/db/database.js +113 -0
  4. package/dist/node/server/db/migrate-legacy.js +88 -0
  5. package/dist/node/server/db/task-repository.js +374 -0
  6. package/dist/node/server/http/http-server.js +493 -0
  7. package/dist/node/server/http/ws-hub.js +59 -0
  8. package/dist/node/server/main.js +291 -0
  9. package/dist/node/server/plugins/actions/builtin.js +67 -0
  10. package/dist/node/server/plugins/actions/clipboard-watch.js +27 -0
  11. package/dist/node/server/plugins/actions/http-request.js +41 -0
  12. package/dist/node/server/plugins/actions/jenkins-build.js +183 -0
  13. package/dist/node/server/plugins/actions/open-app.js +41 -0
  14. package/dist/node/server/plugins/actions/python-script.js +180 -0
  15. package/dist/node/server/plugins/actions/screenshot.js +38 -0
  16. package/dist/node/server/plugins/actions/send-keystroke.js +100 -0
  17. package/dist/node/server/plugins/actions/show-reminder.js +7 -0
  18. package/dist/node/server/plugins/actions/ssh-command.js +123 -0
  19. package/dist/node/server/plugins/actions/task-chain.js +24 -0
  20. package/dist/node/server/plugins/actions/volume-control.js +31 -0
  21. package/dist/node/server/plugins/index.js +35 -0
  22. package/dist/node/server/plugins/registry.js +23 -0
  23. package/dist/node/server/services/clipboard-watcher.js +112 -0
  24. package/dist/node/server/services/config-store.js +141 -0
  25. package/dist/node/server/services/idle-monitor.js +131 -0
  26. package/dist/node/server/services/notifier.js +36 -0
  27. package/dist/node/server/services/quick-actions-store.js +52 -0
  28. package/dist/node/server/services/remote-connector.js +67 -0
  29. package/dist/node/server/services/scanner-reader.js +217 -0
  30. package/dist/node/server/services/script-runner.js +228 -0
  31. package/dist/node/server/services/snapshot-service.js +135 -0
  32. package/dist/node/server/services/task-scheduler.js +813 -0
  33. package/dist/node/server/services/wechat-bot.js +635 -0
  34. package/dist/node/server/services/wechat-command-types.js +1 -0
  35. package/dist/node/server/services/wechat-commands.js +330 -0
  36. package/dist/node/server/suppress-warnings.js +12 -0
  37. package/dist/node/server/utils/asset-url.js +26 -0
  38. package/dist/node/server/utils/auto-start.js +186 -0
  39. package/dist/node/server/utils/clipboard.js +50 -0
  40. package/dist/node/server/utils/dashboard-url.js +8 -0
  41. package/dist/node/server/utils/instance-guard.js +165 -0
  42. package/dist/node/server/utils/native-notify.js +53 -0
  43. package/dist/node/server/utils/open.js +37 -0
  44. package/dist/node/server/utils/paths.js +95 -0
  45. package/dist/node/server/utils/python-interpreter.js +129 -0
  46. package/dist/node/shared/animation-engine.js +349 -0
  47. package/dist/node/shared/chain-condition.js +39 -0
  48. package/dist/node/shared/cron-weekly.js +124 -0
  49. package/dist/node/shared/py-task-params.js +335 -0
  50. package/dist/node/shared/types.js +69 -0
  51. package/package.json +6 -2
  52. package/server/http/http-server.ts +4 -1
  53. package/server/utils/paths.ts +28 -1
@@ -0,0 +1,493 @@
1
+ import express from 'express';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { defaultTaskConfig } from "../db/task-repository.js";
5
+ import { ScannerReader } from "../services/scanner-reader.js";
6
+ import { testJenkinsConnection, testSshConnection } from "../services/remote-connector.js";
7
+ import { resolveAssetRequest } from "../utils/asset-url.js";
8
+ import { detectPythonInterpreters } from "../utils/python-interpreter.js";
9
+ import { getPackageRoot, moduleDir } from "../utils/paths.js";
10
+ /** 创建 Express 应用(不监听端口,便于测试) */
11
+ export function createApp(deps) {
12
+ const app = express();
13
+ app.use(express.json({ limit: '5mb' }));
14
+ // CORS — 允许局域网/开发跨域访问(个人工具,无鉴权)
15
+ app.use((req, res, next) => {
16
+ res.setHeader('Access-Control-Allow-Origin', '*');
17
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
18
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
19
+ if (req.method === 'OPTIONS') {
20
+ res.sendStatus(204);
21
+ return;
22
+ }
23
+ next();
24
+ });
25
+ const wrap = (fn) => (req, res) => {
26
+ Promise.resolve(fn(req, res)).catch((err) => {
27
+ console.error('[http] 接口异常:', err);
28
+ res.status(500).json({ error: String(err) });
29
+ });
30
+ };
31
+ /** Express5 路由参数归一化(可能为 string[]) */
32
+ const p = (v) => Array.isArray(v) ? (v[0] ?? '') : (v ?? '');
33
+ // ── 仪表盘 ──────────────────────────────────────────────
34
+ app.get('/api/dashboard', wrap((_req, res) => {
35
+ const tasks = deps.scheduler.listTasks();
36
+ const recent = deps.repo.getRecentExecutions(10);
37
+ const queueState = deps.scheduler.getQueueState();
38
+ res.json({
39
+ tasksTotal: tasks.length,
40
+ tasksEnabled: tasks.filter((t) => t.config.enabled).length,
41
+ runningTaskId: queueState.running?.id ?? null,
42
+ runningTaskName: queueState.running?.name ?? null,
43
+ queuedCount: queueState.queue.length,
44
+ recentExecutions: recent,
45
+ stats: computeStats(deps.repo)
46
+ });
47
+ }));
48
+ // ── 任务 CRUD ───────────────────────────────────────────
49
+ app.get('/api/tasks', wrap((_req, res) => res.json(deps.scheduler.listTasks())));
50
+ app.post('/api/tasks', wrap((req, res) => {
51
+ const body = req.body;
52
+ const config = normalizeConfig(body);
53
+ const dto = deps.scheduler.createTask(config);
54
+ broadcastChanged(deps);
55
+ res.status(201).json(dto);
56
+ }));
57
+ app.put('/api/tasks/:id', wrap((req, res) => {
58
+ const body = req.body;
59
+ const existing = deps.scheduler.getTask(p(req.params.id));
60
+ if (!existing) {
61
+ res.status(404).json({ error: '任务不存在' });
62
+ return;
63
+ }
64
+ const merged = mergeConfig(existing.config, body);
65
+ const ok = deps.scheduler.updateTask(p(req.params.id), merged);
66
+ broadcastChanged(deps);
67
+ res.json({ ok });
68
+ }));
69
+ app.delete('/api/tasks/:id', wrap((req, res) => {
70
+ const ok = deps.scheduler.deleteTask(p(req.params.id));
71
+ broadcastChanged(deps);
72
+ res.json({ ok });
73
+ }));
74
+ app.post('/api/tasks/:id/run', wrap((req, res) => {
75
+ const id = p(req.params.id);
76
+ // 运行时可选传入参数(临时覆盖脚本默认参数,执行后自动恢复任务配置)
77
+ const raw = req.body?.args;
78
+ const args = Array.isArray(raw) ? raw.map(String).filter((s) => s !== '') : [];
79
+ const ok = args.length
80
+ ? deps.scheduler.runTaskWithArgs(id, args)
81
+ : deps.scheduler.requestRun(id, 'manual');
82
+ res.json({ ok });
83
+ }));
84
+ app.post('/api/tasks/:id/stop', wrap((req, res) => {
85
+ const ok = deps.scheduler.stopTask(p(req.params.id));
86
+ res.json({ ok });
87
+ }));
88
+ app.post('/api/tasks/:id/toggle', wrap((req, res) => {
89
+ const t = deps.scheduler.getTask(p(req.params.id));
90
+ if (!t) {
91
+ res.status(404).json({ ok: false });
92
+ return;
93
+ }
94
+ const ok = deps.scheduler.setEnabled(p(req.params.id), !t.config.enabled);
95
+ broadcastChanged(deps);
96
+ res.json({ ok, enabled: !t.config.enabled });
97
+ }));
98
+ // ── 执行队列 ─────────────────────────────────────────────
99
+ /** 当前执行队列(运行中 + 排队中,按先后顺序) */
100
+ app.get('/api/queue', wrap((_req, res) => {
101
+ res.json(deps.scheduler.getQueueState());
102
+ }));
103
+ /** 清空排队中的任务(不终止正在运行的任务) */
104
+ app.post('/api/queue/clear', wrap((_req, res) => {
105
+ const cleared = deps.scheduler.clearQueue();
106
+ broadcastChanged(deps);
107
+ res.json({ ok: true, cleared });
108
+ }));
109
+ /** 全部停止:终止运行中的任务并清空队列 */
110
+ app.post('/api/queue/stop-all', wrap((_req, res) => {
111
+ const r = deps.scheduler.stopAll();
112
+ broadcastChanged(deps);
113
+ res.json({ ok: true, ...r });
114
+ }));
115
+ // ── 日志与历史 ───────────────────────────────────────────
116
+ app.get('/api/executions', wrap((req, res) => {
117
+ const limit = clampInt(String(req.query.limit ?? 50), 1, 500);
118
+ res.json(deps.repo.getRecentExecutions(limit));
119
+ }));
120
+ app.get('/api/tasks/:id/logs', wrap((req, res) => {
121
+ const limit = clampInt(String(req.query.limit ?? 50), 1, 500);
122
+ const offset = clampInt(String(req.query.offset ?? 0), 0, 100000);
123
+ res.json(deps.repo.listExecutions(p(req.params.id), limit, offset));
124
+ }));
125
+ /** 任务本次执行的实时输出(运行中也能看;配合 WS 事件 task-output 增量追加) */
126
+ app.get('/api/tasks/:id/output', wrap((req, res) => {
127
+ res.json(deps.scheduler.getLiveOutput(p(req.params.id)));
128
+ }));
129
+ app.get('/api/tasks/:id/history', wrap((req, res) => {
130
+ res.json(deps.repo.listExecutions(p(req.params.id), 200));
131
+ }));
132
+ // ── 统计 ────────────────────────────────────────────────
133
+ app.get('/api/stats', wrap((_req, res) => {
134
+ res.json(computeStats(deps.repo));
135
+ }));
136
+ // ── 动作清单(供任务表单选择) ───────────────────────────
137
+ app.get('/api/actions', wrap((_req, res) => {
138
+ res.json(deps.registry.list());
139
+ }));
140
+ // ── 设置 ────────────────────────────────────────────────
141
+ app.get('/api/settings', wrap((_req, res) => {
142
+ res.json(deps.config.get());
143
+ }));
144
+ app.put('/api/settings', wrap((req, res) => {
145
+ deps.config.update(req.body);
146
+ res.json({ ok: true, settings: deps.config.get() });
147
+ }));
148
+ // ── Linux 服务器 / Jenkins(远程执行) ───────────────────
149
+ /** 服务器列表(脱敏:不下发密码) */
150
+ app.get('/api/servers', wrap((_req, res) => {
151
+ const servers = (deps.config.get().servers ?? []).map((s) => ({
152
+ id: s.id,
153
+ name: s.name,
154
+ host: s.host,
155
+ port: s.port,
156
+ username: s.username
157
+ }));
158
+ res.json({ servers });
159
+ }));
160
+ /** 测试 SSH 连通性(不落库,直接测传入的凭据) */
161
+ app.post('/api/servers/test', wrap(async (req, res) => {
162
+ const body = (req.body ?? {});
163
+ res.json(await testSshConnection(body));
164
+ }));
165
+ /** 测试 Jenkins 地址与账号 */
166
+ app.post('/api/jenkins/test', wrap(async (req, res) => {
167
+ const body = (req.body ?? {});
168
+ res.json(await testJenkinsConnection(body));
169
+ }));
170
+ // ── Python 解释器 ───────────────────────────────────────
171
+ /** 探测本机可用的 Python 解释器(任务表单「解释器」下拉用;已跳过 Store 别名存根) */
172
+ app.get('/api/python/interpreters', wrap((_req, res) => {
173
+ res.json({ interpreters: detectPythonInterpreters() });
174
+ }));
175
+ // ── 微信 Bot(M4) ──────────────────────────────────────
176
+ app.post('/api/wechat/login', wrap((_req, res) => {
177
+ if (!deps.wechat) {
178
+ res.json({ ok: false, message: '微信模块未接入' });
179
+ return;
180
+ }
181
+ const ok = deps.wechat.startLogin();
182
+ res.json({ ok, message: ok ? '已发起扫码登录' : '已有登录流程在进行中' });
183
+ }));
184
+ app.get('/api/wechat/status', wrap((_req, res) => {
185
+ if (!deps.wechat) {
186
+ res.json({
187
+ status: 'idle',
188
+ statusText: '模块未接入',
189
+ connected: false,
190
+ sessionAlive: false,
191
+ listening: false,
192
+ botId: '',
193
+ pushTarget: '',
194
+ linkedAt: '',
195
+ qrContent: null
196
+ });
197
+ return;
198
+ }
199
+ res.json(deps.wechat.getStatus());
200
+ }));
201
+ app.post('/api/wechat/login/cancel', wrap((_req, res) => {
202
+ deps.wechat?.cancelLogin();
203
+ res.json({ ok: true });
204
+ }));
205
+ app.post('/api/wechat/test', wrap(async (_req, res) => {
206
+ if (!deps.wechat) {
207
+ res.json({ ok: false, message: '微信模块未接入' });
208
+ return;
209
+ }
210
+ const r = await deps.wechat.sendTestMessage();
211
+ if (r.ok)
212
+ deps.repo.insertWechatMessage('out', '[测试] 连通性测试消息');
213
+ res.json(r);
214
+ }));
215
+ app.post('/api/wechat/disconnect', wrap((_req, res) => {
216
+ if (!deps.wechat) {
217
+ res.json({ ok: false, message: '微信模块未接入' });
218
+ return;
219
+ }
220
+ deps.wechat.disconnect();
221
+ broadcastChanged(deps);
222
+ res.json({ ok: true });
223
+ }));
224
+ app.get('/api/wechat/messages', wrap((req, res) => {
225
+ const limit = clampInt(String(req.query.limit ?? 100), 1, 500);
226
+ res.json(deps.repo.listWechatMessages(limit));
227
+ }));
228
+ // ── 扫码枪(M4) ────────────────────────────────────────
229
+ app.get('/api/scanner/ports', wrap(async (_req, res) => {
230
+ const ports = await ScannerReader.listPorts();
231
+ res.json({ ports });
232
+ }));
233
+ app.get('/api/scanner/status', wrap((_req, res) => {
234
+ const diag = deps.scanner?.getDiagnostics() ?? null;
235
+ res.json({
236
+ available: !!deps.scanner,
237
+ running: deps.scanner?.isRunning ?? false,
238
+ connected: deps.scanner?.isConnected ?? false,
239
+ error: diag?.lastError ?? null,
240
+ lastStatus: diag?.lastStatus ?? null,
241
+ settings: deps.config.get().scanner
242
+ });
243
+ }));
244
+ app.post('/api/scanner/start', wrap(async (_req, res) => {
245
+ if (!deps.scanner) {
246
+ res.json({ ok: false, error: '扫码枪未初始化' });
247
+ return;
248
+ }
249
+ const err = await deps.scanner.start();
250
+ res.json({ ok: err === null, error: err });
251
+ }));
252
+ app.post('/api/scanner/stop', wrap((_req, res) => {
253
+ deps.scanner?.stop();
254
+ res.json({ ok: true });
255
+ }));
256
+ // ── 快捷指令(花瓣菜单,M5) ────────────────────────────
257
+ app.get('/api/quick-actions', wrap((_req, res) => {
258
+ res.json(deps.quickActions ? deps.quickActions.load() : []);
259
+ }));
260
+ app.put('/api/quick-actions', wrap((req, res) => {
261
+ if (!deps.quickActions) {
262
+ res.status(404).json({ error: '快捷指令未接入' });
263
+ return;
264
+ }
265
+ const actions = req.body.actions;
266
+ if (!Array.isArray(actions)) {
267
+ res.status(400).json({ error: 'body 需为 {actions: [...]}' });
268
+ return;
269
+ }
270
+ deps.quickActions.save(actions);
271
+ deps.wsHub?.broadcast('quick-actions-changed', {});
272
+ res.json({ ok: true });
273
+ }));
274
+ // ── 备份与快照(M5) ────────────────────────────────────
275
+ app.get('/api/backup/snapshots', wrap((_req, res) => {
276
+ res.json(deps.snapshots ? deps.snapshots.list() : []);
277
+ }));
278
+ app.post('/api/backup/snapshot', wrap((_req, res) => {
279
+ if (!deps.snapshots) {
280
+ res.status(404).json({ error: '快照服务未接入' });
281
+ return;
282
+ }
283
+ const id = deps.snapshots.snapshot('manual');
284
+ res.json({ ok: true, id });
285
+ }));
286
+ app.delete('/api/backup/snapshots/:id', wrap((req, res) => {
287
+ const ok = deps.snapshots?.remove(parseInt(p(req.params.id), 10)) ?? false;
288
+ res.json({ ok });
289
+ }));
290
+ app.post('/api/backup/restore/:id', wrap((req, res) => {
291
+ if (!deps.snapshots) {
292
+ res.status(404).json({ error: '快照服务未接入' });
293
+ return;
294
+ }
295
+ const ok = deps.snapshots.restore(parseInt(p(req.params.id), 10));
296
+ if (ok) {
297
+ broadcastChanged(deps);
298
+ deps.wsHub?.broadcast('config-restored', {});
299
+ }
300
+ res.json({ ok });
301
+ }));
302
+ app.get('/api/backup/export', wrap((_req, res) => {
303
+ if (!deps.snapshots) {
304
+ res.status(404).json({ error: '备份服务未接入' });
305
+ return;
306
+ }
307
+ const pack = deps.snapshots.exportPackData();
308
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
309
+ res.setHeader('Content-Disposition', `attachment; filename="deskpet-backup-${new Date().toISOString().slice(0, 10)}.deskpet"`);
310
+ res.send(JSON.stringify(pack, null, 2));
311
+ }));
312
+ app.post('/api/backup/import', wrap((req, res) => {
313
+ if (!deps.snapshots) {
314
+ res.status(404).json({ error: '备份服务未接入' });
315
+ return;
316
+ }
317
+ const body = req.body;
318
+ const jsonText = typeof body?.pack === 'string'
319
+ ? body.pack
320
+ : typeof req.body === 'string'
321
+ ? String(req.body)
322
+ : JSON.stringify(req.body);
323
+ const r = deps.snapshots.importPack(jsonText);
324
+ if (r.ok) {
325
+ broadcastChanged(deps);
326
+ deps.wsHub?.broadcast('config-restored', {});
327
+ }
328
+ res.json(r);
329
+ }));
330
+ // ── 剪贴板监听规则(M5) ────────────────────────────────
331
+ app.get('/api/clipboard-watch', wrap((_req, res) => {
332
+ res.json(deps.clipboardWatch ? deps.clipboardWatch.list() : []);
333
+ }));
334
+ // ── Webhook 入站触发(外部系统 → 任务) ─────────────────
335
+ app.post('/api/hooks/:taskId', wrap((req, res) => {
336
+ const taskId = p(req.params.taskId);
337
+ const rec = deps.scheduler.getTask(taskId);
338
+ if (!rec) {
339
+ res.status(404).json({ ok: false, error: `任务不存在: ${taskId}` });
340
+ return;
341
+ }
342
+ if (!rec.config.enabled) {
343
+ res.json({ ok: false, error: '任务已禁用' });
344
+ return;
345
+ }
346
+ // body.args: string(按空白拆分)或 string[];仅脚本任务支持参数
347
+ const raw = req.body?.args;
348
+ let args = [];
349
+ if (typeof raw === 'string')
350
+ args = raw.trim().split(/\s+/).filter(Boolean);
351
+ else if (Array.isArray(raw))
352
+ args = raw.map(String);
353
+ let ok;
354
+ if (args.length && rec.config.action === 'python_script') {
355
+ ok = deps.scheduler.runTaskWithArgs(taskId, args);
356
+ }
357
+ else {
358
+ ok = deps.scheduler.requestRun(taskId, 'trigger');
359
+ }
360
+ deps.wsHub?.broadcast('webhook-received', { taskId, taskName: rec.config.name });
361
+ res.json({ ok, taskId, args: args.length ? args : undefined });
362
+ }));
363
+ // ── 本地资源分发(皮肤精灵图等,取代 deskpet:// 协议) ────
364
+ app.get('/api/assets', wrap((req, res) => {
365
+ const abs = resolveAssetRequest(req.query.path);
366
+ if (!abs) {
367
+ res.status(404).json({ error: '资源不存在或无访问权限' });
368
+ return;
369
+ }
370
+ res.sendFile(abs);
371
+ }));
372
+ // ── 静态托管管理台 SPA ───────────────────────────────────
373
+ const staticRoot = resolveStaticRoot();
374
+ if (staticRoot && existsSync(staticRoot)) {
375
+ app.use(express.static(staticRoot));
376
+ // '/' 与 '/index.html' → dashboard
377
+ app.get('/', (_req, res) => {
378
+ res.sendFile(join(staticRoot, 'dashboard/index.html'));
379
+ });
380
+ app.get('/dashboard', (_req, res) => {
381
+ res.sendFile(join(staticRoot, 'dashboard/index.html'));
382
+ });
383
+ }
384
+ else {
385
+ // 未构建前端时给出提示,避免直接 404 无从排查
386
+ app.get('/', (_req, res) => {
387
+ res
388
+ .status(200)
389
+ .type('text/plain; charset=utf-8')
390
+ .send('DeskPet 服务已启动,但未找到前端构建产物。请先执行 npm run build:web。');
391
+ });
392
+ }
393
+ // API 404 兜底(在静态之后)
394
+ app.use('/api', (_req, res) => {
395
+ res.status(404).json({ error: '接口不存在' });
396
+ });
397
+ return app;
398
+ }
399
+ function broadcastChanged(deps) {
400
+ deps.wsHub?.broadcast('tasks-changed', { ts: Date.now() });
401
+ }
402
+ /** 统计聚合:今日执行、7 天成功率、平均耗时、每日趋势、失败 Top */
403
+ function computeStats(repo) {
404
+ const rows = repo.listExecutions(undefined, 1000);
405
+ const dayStart = new Date();
406
+ dayStart.setHours(0, 0, 0, 0);
407
+ const weekAgo = Date.now() - 7 * 24 * 3600 * 1000;
408
+ let todayTotal = 0;
409
+ let todaySuccess = 0;
410
+ let weekTotal = 0;
411
+ let weekSuccess = 0;
412
+ let weekDuration = 0;
413
+ const trendMap = new Map();
414
+ const failMap = new Map();
415
+ for (const r of rows) {
416
+ const t = new Date(r.startedAt).getTime();
417
+ const dateKey = r.startedAt.slice(0, 10);
418
+ const trend = trendMap.get(dateKey) ?? { total: 0, success: 0 };
419
+ trend.total += 1;
420
+ if (r.status === 'completed')
421
+ trend.success += 1;
422
+ trendMap.set(dateKey, trend);
423
+ if (r.status === 'completed') {
424
+ weekSuccess += 1;
425
+ }
426
+ weekTotal += 1;
427
+ weekDuration += r.durationMs ?? 0;
428
+ if (t >= dayStart.getTime()) {
429
+ todayTotal += 1;
430
+ if (r.status === 'completed')
431
+ todaySuccess += 1;
432
+ }
433
+ if (r.status === 'failed' || r.status === 'timeout') {
434
+ const f = failMap.get(r.taskId) ?? { name: r.taskName, fails: 0, lastError: '' };
435
+ f.fails += 1;
436
+ f.lastError = r.error || f.lastError;
437
+ failMap.set(r.taskId, f);
438
+ }
439
+ }
440
+ void weekAgo;
441
+ return {
442
+ today: { total: todayTotal, success: todaySuccess },
443
+ successRate7d: weekTotal > 0 ? Math.round((weekSuccess / weekTotal) * 1000) / 10 : null,
444
+ avgDurationMs7d: weekTotal > 0 ? Math.round(weekDuration / weekTotal) : null,
445
+ dailyTrend: [...trendMap.entries()]
446
+ .sort(([a], [b]) => (a < b ? -1 : 1))
447
+ .slice(-14)
448
+ .map(([date, v]) => ({ date, ...v })),
449
+ topFailures: [...failMap.entries()]
450
+ .sort(([, a], [, b]) => b.fails - a.fails)
451
+ .slice(0, 5)
452
+ .map(([taskId, v]) => ({ taskId, ...v }))
453
+ };
454
+ }
455
+ function normalizeConfig(body) {
456
+ const base = defaultTaskConfig();
457
+ const merged = mergeConfig(base, body);
458
+ if (!merged.name)
459
+ merged.name = `任务 ${new Date().toLocaleString('zh-CN')}`;
460
+ return merged;
461
+ }
462
+ function mergeConfig(base, patch) {
463
+ return {
464
+ ...base,
465
+ ...patch,
466
+ trigger: { ...base.trigger, ...(patch.trigger ?? {}) },
467
+ actionParams: { ...(base.actionParams ?? {}), ...(patch.actionParams ?? {}) },
468
+ scriptArgs: patch.scriptArgs ?? base.scriptArgs,
469
+ notifyChannels: patch.notifyChannels ?? base.notifyChannels
470
+ };
471
+ }
472
+ function clampInt(v, min, max) {
473
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
474
+ if (!Number.isFinite(n))
475
+ return min;
476
+ return Math.max(min, Math.min(max, Math.floor(n)));
477
+ }
478
+ /** 静态资源根目录:优先 vite 构建产物 dist/web,兼容旧路径 out/renderer */
479
+ function resolveStaticRoot() {
480
+ const candidates = [
481
+ // 用包根定位,源码运行与编译产物(dist/node/...)都适用;
482
+ // moduleDir() 的相对层级只在源码形态下成立,不能单独依赖
483
+ join(getPackageRoot(), 'dist/web'),
484
+ join(moduleDir(), '../../dist/web'),
485
+ join(moduleDir(), '../renderer'),
486
+ join(process.cwd(), 'dist/web')
487
+ ];
488
+ for (const root of candidates) {
489
+ if (existsSync(join(root, 'dashboard/index.html')))
490
+ return root;
491
+ }
492
+ return null;
493
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * WebSocket 推送中心 — 实时事件(任务状态/日志/皮肤变更)广播给所有 WebUI 客户端
3
+ */
4
+ import { WebSocketServer } from 'ws';
5
+ export class WsHub {
6
+ wss = null;
7
+ clients = new Set();
8
+ /** 挂载到 HTTP server(路径 /ws) */
9
+ attach(server, path = '/ws') {
10
+ this.wss = new WebSocketServer({ server, path });
11
+ this.wss.on('connection', (ws) => {
12
+ this.clients.add(ws);
13
+ ws.send(JSON.stringify({ type: 'hello', payload: { ok: true }, ts: Date.now() }));
14
+ ws.on('close', () => this.clients.delete(ws));
15
+ ws.on('error', () => this.clients.delete(ws));
16
+ // 心跳保活
17
+ const ping = setInterval(() => {
18
+ if (ws.readyState === ws.OPEN)
19
+ ws.ping();
20
+ else
21
+ clearInterval(ping);
22
+ }, 30000);
23
+ ws.on('close', () => clearInterval(ping));
24
+ });
25
+ }
26
+ /** 广播事件给全部客户端 */
27
+ broadcast(type, payload) {
28
+ if (!this.wss || this.clients.size === 0)
29
+ return;
30
+ const msg = { type, payload, ts: Date.now() };
31
+ const data = JSON.stringify(msg);
32
+ for (const ws of this.clients) {
33
+ if (ws.readyState === ws.OPEN) {
34
+ try {
35
+ ws.send(data);
36
+ }
37
+ catch {
38
+ /* 忽略单个客户端失败 */
39
+ }
40
+ }
41
+ }
42
+ }
43
+ get clientCount() {
44
+ return this.clients.size;
45
+ }
46
+ close() {
47
+ for (const ws of this.clients) {
48
+ try {
49
+ ws.close();
50
+ }
51
+ catch {
52
+ /* ignore */
53
+ }
54
+ }
55
+ this.clients.clear();
56
+ this.wss?.close();
57
+ this.wss = null;
58
+ }
59
+ }