@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,725 @@
1
+ /**
2
+ * 管理台 HTTP 服务 — Express :3210(方案 §5.2 HTTP API,兼容浏览器直连)
3
+ *
4
+ * 职责:
5
+ * 1. /api/* REST 接口(任务/服务器/统计/设置)
6
+ * 2. 静态托管管理台 WebUI(out/renderer/dashboard 构建产物)
7
+ * 3. WebSocket 实时事件(配合 WsHub)
8
+ *
9
+ * 安全: 个人本机/局域网使用,不做权限体系;默认监听 127.0.0.1
10
+ */
11
+ import type { Express, Request, Response } from 'express'
12
+ import express from 'express'
13
+ import { existsSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import type { TaskConfig } from '../../shared/types.ts'
16
+ import { defaultTaskConfig } from '../db/task-repository.ts'
17
+ import type { TaskRepository } from '../db/task-repository.ts'
18
+ import type { ActionRegistry, SchedulerNotifier } from '../plugins/registry.ts'
19
+ import type { ConfigStore } from '../services/config-store.ts'
20
+ import type { TaskScheduler } from '../services/task-scheduler.ts'
21
+ import type { WechatBot } from '../services/wechat-bot.ts'
22
+ import { ScannerReader } from '../services/scanner-reader.ts'
23
+ import type { SnapshotService } from '../services/snapshot-service.ts'
24
+ import type { QuickActionsStore } from '../services/quick-actions-store.ts'
25
+ import { testJenkinsConnection, testSshConnection } from '../services/remote-connector.ts'
26
+ import type { ClipboardWatchRegistry } from '../services/clipboard-watcher.ts'
27
+ import { resolveAssetRequest } from '../utils/asset-url.ts'
28
+ import { detectPythonInterpreters } from '../utils/python-interpreter.ts'
29
+ import { moduleDir } from '../utils/paths.ts'
30
+ import type { WsHub } from './ws-hub.ts'
31
+
32
+ export interface HttpServerDeps {
33
+ scheduler: TaskScheduler
34
+ repo: TaskRepository
35
+ config: ConfigStore
36
+ notifier: SchedulerNotifier
37
+ registry: ActionRegistry
38
+ /** 实时事件广播(可选,测试时可省略) */
39
+ wsHub?: WsHub
40
+ /** 微信 Bot(可选,未接线时接口返回未接入) */
41
+ wechat?: WechatBot
42
+ /** 扫码枪(可选) */
43
+ scanner?: ScannerReader
44
+ /** 配置快照服务(可选) */
45
+ snapshots?: SnapshotService
46
+ /** 快捷指令存储(花瓣菜单,M5) */
47
+ quickActions?: QuickActionsStore
48
+ /** 剪贴板监听注册表(M5) */
49
+ clipboardWatch?: ClipboardWatchRegistry
50
+ }
51
+
52
+ /** 创建 Express 应用(不监听端口,便于测试) */
53
+ export function createApp(deps: HttpServerDeps): Express {
54
+ const app = express()
55
+ app.use(express.json({ limit: '5mb' }))
56
+
57
+ // CORS — 允许局域网/开发跨域访问(个人工具,无鉴权)
58
+ app.use((req, res, next) => {
59
+ res.setHeader('Access-Control-Allow-Origin', '*')
60
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS')
61
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
62
+ if (req.method === 'OPTIONS') {
63
+ res.sendStatus(204)
64
+ return
65
+ }
66
+ next()
67
+ })
68
+
69
+ const wrap = (fn: (req: Request, res: Response) => unknown) =>
70
+ (req: Request, res: Response): void => {
71
+ Promise.resolve(fn(req, res)).catch((err) => {
72
+ console.error('[http] 接口异常:', err)
73
+ res.status(500).json({ error: String(err) })
74
+ })
75
+ }
76
+
77
+ /** Express5 路由参数归一化(可能为 string[]) */
78
+ const p = (v: string | string[] | undefined): string =>
79
+ Array.isArray(v) ? (v[0] ?? '') : (v ?? '')
80
+
81
+ // ── 仪表盘 ──────────────────────────────────────────────
82
+ app.get(
83
+ '/api/dashboard',
84
+ wrap((_req, res) => {
85
+ const tasks = deps.scheduler.listTasks()
86
+ const recent = deps.repo.getRecentExecutions(10)
87
+ const queueState = deps.scheduler.getQueueState()
88
+ res.json({
89
+ tasksTotal: tasks.length,
90
+ tasksEnabled: tasks.filter((t) => t.config.enabled).length,
91
+ runningTaskId: queueState.running?.id ?? null,
92
+ runningTaskName: queueState.running?.name ?? null,
93
+ queuedCount: queueState.queue.length,
94
+ recentExecutions: recent,
95
+ stats: computeStats(deps.repo)
96
+ })
97
+ })
98
+ )
99
+
100
+ // ── 任务 CRUD ───────────────────────────────────────────
101
+ app.get('/api/tasks', wrap((_req, res) => res.json(deps.scheduler.listTasks())))
102
+
103
+ app.post(
104
+ '/api/tasks',
105
+ wrap((req, res) => {
106
+ const body = req.body as Partial<TaskConfig>
107
+ const config = normalizeConfig(body)
108
+ const dto = deps.scheduler.createTask(config)
109
+ broadcastChanged(deps)
110
+ res.status(201).json(dto)
111
+ })
112
+ )
113
+
114
+ app.put(
115
+ '/api/tasks/:id',
116
+ wrap((req, res) => {
117
+ const body = req.body as Partial<TaskConfig>
118
+ const existing = deps.scheduler.getTask(p(req.params.id))
119
+ if (!existing) {
120
+ res.status(404).json({ error: '任务不存在' })
121
+ return
122
+ }
123
+ const merged = mergeConfig(existing.config, body)
124
+ const ok = deps.scheduler.updateTask(p(req.params.id), merged)
125
+ broadcastChanged(deps)
126
+ res.json({ ok })
127
+ })
128
+ )
129
+
130
+ app.delete(
131
+ '/api/tasks/:id',
132
+ wrap((req, res) => {
133
+ const ok = deps.scheduler.deleteTask(p(req.params.id))
134
+ broadcastChanged(deps)
135
+ res.json({ ok })
136
+ })
137
+ )
138
+
139
+ app.post(
140
+ '/api/tasks/:id/run',
141
+ wrap((req, res) => {
142
+ const id = p(req.params.id)
143
+ // 运行时可选传入参数(临时覆盖脚本默认参数,执行后自动恢复任务配置)
144
+ const raw = (req.body as { args?: unknown } | null)?.args
145
+ const args = Array.isArray(raw) ? raw.map(String).filter((s) => s !== '') : []
146
+ const ok = args.length
147
+ ? deps.scheduler.runTaskWithArgs(id, args)
148
+ : deps.scheduler.requestRun(id, 'manual')
149
+ res.json({ ok })
150
+ })
151
+ )
152
+
153
+ app.post(
154
+ '/api/tasks/:id/stop',
155
+ wrap((req, res) => {
156
+ const ok = deps.scheduler.stopTask(p(req.params.id))
157
+ res.json({ ok })
158
+ })
159
+ )
160
+
161
+ app.post(
162
+ '/api/tasks/:id/toggle',
163
+ wrap((req, res) => {
164
+ const t = deps.scheduler.getTask(p(req.params.id))
165
+ if (!t) {
166
+ res.status(404).json({ ok: false })
167
+ return
168
+ }
169
+ const ok = deps.scheduler.setEnabled(p(req.params.id), !t.config.enabled)
170
+ broadcastChanged(deps)
171
+ res.json({ ok, enabled: !t.config.enabled })
172
+ })
173
+ )
174
+
175
+ // ── 执行队列 ─────────────────────────────────────────────
176
+ /** 当前执行队列(运行中 + 排队中,按先后顺序) */
177
+ app.get(
178
+ '/api/queue',
179
+ wrap((_req, res) => {
180
+ res.json(deps.scheduler.getQueueState())
181
+ })
182
+ )
183
+
184
+ /** 清空排队中的任务(不终止正在运行的任务) */
185
+ app.post(
186
+ '/api/queue/clear',
187
+ wrap((_req, res) => {
188
+ const cleared = deps.scheduler.clearQueue()
189
+ broadcastChanged(deps)
190
+ res.json({ ok: true, cleared })
191
+ })
192
+ )
193
+
194
+ /** 全部停止:终止运行中的任务并清空队列 */
195
+ app.post(
196
+ '/api/queue/stop-all',
197
+ wrap((_req, res) => {
198
+ const r = deps.scheduler.stopAll()
199
+ broadcastChanged(deps)
200
+ res.json({ ok: true, ...r })
201
+ })
202
+ )
203
+
204
+ // ── 日志与历史 ───────────────────────────────────────────
205
+ app.get(
206
+ '/api/executions',
207
+ wrap((req, res) => {
208
+ const limit = clampInt(String(req.query.limit ?? 50), 1, 500)
209
+ res.json(deps.repo.getRecentExecutions(limit))
210
+ })
211
+ )
212
+
213
+ app.get(
214
+ '/api/tasks/:id/logs',
215
+ wrap((req, res) => {
216
+ const limit = clampInt(String(req.query.limit ?? 50), 1, 500)
217
+ const offset = clampInt(String(req.query.offset ?? 0), 0, 100000)
218
+ res.json(deps.repo.listExecutions(p(req.params.id), limit, offset))
219
+ })
220
+ )
221
+
222
+ /** 任务本次执行的实时输出(运行中也能看;配合 WS 事件 task-output 增量追加) */
223
+ app.get(
224
+ '/api/tasks/:id/output',
225
+ wrap((req, res) => {
226
+ res.json(deps.scheduler.getLiveOutput(p(req.params.id)))
227
+ })
228
+ )
229
+
230
+ app.get(
231
+ '/api/tasks/:id/history',
232
+ wrap((req, res) => {
233
+ res.json(deps.repo.listExecutions(p(req.params.id), 200))
234
+ })
235
+ )
236
+
237
+ // ── 统计 ────────────────────────────────────────────────
238
+ app.get(
239
+ '/api/stats',
240
+ wrap((_req, res) => {
241
+ res.json(computeStats(deps.repo))
242
+ })
243
+ )
244
+
245
+ // ── 动作清单(供任务表单选择) ───────────────────────────
246
+ app.get(
247
+ '/api/actions',
248
+ wrap((_req, res) => {
249
+ res.json(deps.registry.list())
250
+ })
251
+ )
252
+
253
+ // ── 设置 ────────────────────────────────────────────────
254
+ app.get(
255
+ '/api/settings',
256
+ wrap((_req, res) => {
257
+ res.json(deps.config.get())
258
+ })
259
+ )
260
+
261
+ app.put(
262
+ '/api/settings',
263
+ wrap((req, res) => {
264
+ deps.config.update(req.body as never)
265
+ res.json({ ok: true, settings: deps.config.get() })
266
+ })
267
+ )
268
+
269
+ // ── Linux 服务器 / Jenkins(远程执行) ───────────────────
270
+ /** 服务器列表(脱敏:不下发密码) */
271
+ app.get(
272
+ '/api/servers',
273
+ wrap((_req, res) => {
274
+ const servers = (deps.config.get().servers ?? []).map((s) => ({
275
+ id: s.id,
276
+ name: s.name,
277
+ host: s.host,
278
+ port: s.port,
279
+ username: s.username
280
+ }))
281
+ res.json({ servers })
282
+ })
283
+ )
284
+
285
+ /** 测试 SSH 连通性(不落库,直接测传入的凭据) */
286
+ app.post(
287
+ '/api/servers/test',
288
+ wrap(async (req, res) => {
289
+ const body = (req.body ?? {}) as {
290
+ host?: string
291
+ port?: number
292
+ username?: string
293
+ password?: string
294
+ }
295
+ res.json(await testSshConnection(body))
296
+ })
297
+ )
298
+
299
+ /** 测试 Jenkins 地址与账号 */
300
+ app.post(
301
+ '/api/jenkins/test',
302
+ wrap(async (req, res) => {
303
+ const body = (req.body ?? {}) as { url?: string; username?: string; password?: string }
304
+ res.json(await testJenkinsConnection(body))
305
+ })
306
+ )
307
+
308
+ // ── Python 解释器 ───────────────────────────────────────
309
+ /** 探测本机可用的 Python 解释器(任务表单「解释器」下拉用;已跳过 Store 别名存根) */
310
+ app.get(
311
+ '/api/python/interpreters',
312
+ wrap((_req, res) => {
313
+ res.json({ interpreters: detectPythonInterpreters() })
314
+ })
315
+ )
316
+
317
+ // ── 微信 Bot(M4) ──────────────────────────────────────
318
+ app.post(
319
+ '/api/wechat/login',
320
+ wrap((_req, res) => {
321
+ if (!deps.wechat) {
322
+ res.json({ ok: false, message: '微信模块未接入' })
323
+ return
324
+ }
325
+ const ok = deps.wechat.startLogin()
326
+ res.json({ ok, message: ok ? '已发起扫码登录' : '已有登录流程在进行中' })
327
+ })
328
+ )
329
+
330
+ app.get(
331
+ '/api/wechat/status',
332
+ wrap((_req, res) => {
333
+ if (!deps.wechat) {
334
+ res.json({
335
+ status: 'idle',
336
+ statusText: '模块未接入',
337
+ connected: false,
338
+ sessionAlive: false,
339
+ listening: false,
340
+ botId: '',
341
+ pushTarget: '',
342
+ linkedAt: '',
343
+ qrContent: null
344
+ })
345
+ return
346
+ }
347
+ res.json(deps.wechat.getStatus())
348
+ })
349
+ )
350
+
351
+ app.post(
352
+ '/api/wechat/login/cancel',
353
+ wrap((_req, res) => {
354
+ deps.wechat?.cancelLogin()
355
+ res.json({ ok: true })
356
+ })
357
+ )
358
+
359
+ app.post(
360
+ '/api/wechat/test',
361
+ wrap(async (_req, res) => {
362
+ if (!deps.wechat) {
363
+ res.json({ ok: false, message: '微信模块未接入' })
364
+ return
365
+ }
366
+ const r = await deps.wechat.sendTestMessage()
367
+ if (r.ok) deps.repo.insertWechatMessage('out', '[测试] 连通性测试消息')
368
+ res.json(r)
369
+ })
370
+ )
371
+
372
+ app.post(
373
+ '/api/wechat/disconnect',
374
+ wrap((_req, res) => {
375
+ if (!deps.wechat) {
376
+ res.json({ ok: false, message: '微信模块未接入' })
377
+ return
378
+ }
379
+ deps.wechat.disconnect()
380
+ broadcastChanged(deps)
381
+ res.json({ ok: true })
382
+ })
383
+ )
384
+
385
+ app.get(
386
+ '/api/wechat/messages',
387
+ wrap((req, res) => {
388
+ const limit = clampInt(String(req.query.limit ?? 100), 1, 500)
389
+ res.json(deps.repo.listWechatMessages(limit))
390
+ })
391
+ )
392
+
393
+ // ── 扫码枪(M4) ────────────────────────────────────────
394
+ app.get(
395
+ '/api/scanner/ports',
396
+ wrap(async (_req, res) => {
397
+ const ports = await ScannerReader.listPorts()
398
+ res.json({ ports })
399
+ })
400
+ )
401
+
402
+ app.get(
403
+ '/api/scanner/status',
404
+ wrap((_req, res) => {
405
+ const diag = deps.scanner?.getDiagnostics() ?? null
406
+ res.json({
407
+ available: !!deps.scanner,
408
+ running: deps.scanner?.isRunning ?? false,
409
+ connected: deps.scanner?.isConnected ?? false,
410
+ error: diag?.lastError ?? null,
411
+ lastStatus: diag?.lastStatus ?? null,
412
+ settings: deps.config.get().scanner
413
+ })
414
+ })
415
+ )
416
+
417
+ app.post(
418
+ '/api/scanner/start',
419
+ wrap(async (_req, res) => {
420
+ if (!deps.scanner) {
421
+ res.json({ ok: false, error: '扫码枪未初始化' })
422
+ return
423
+ }
424
+ const err = await deps.scanner.start()
425
+ res.json({ ok: err === null, error: err })
426
+ })
427
+ )
428
+
429
+ app.post(
430
+ '/api/scanner/stop',
431
+ wrap((_req, res) => {
432
+ deps.scanner?.stop()
433
+ res.json({ ok: true })
434
+ })
435
+ )
436
+
437
+ // ── 快捷指令(花瓣菜单,M5) ────────────────────────────
438
+ app.get(
439
+ '/api/quick-actions',
440
+ wrap((_req, res) => {
441
+ res.json(deps.quickActions ? deps.quickActions.load() : [])
442
+ })
443
+ )
444
+
445
+ app.put(
446
+ '/api/quick-actions',
447
+ wrap((req, res) => {
448
+ if (!deps.quickActions) {
449
+ res.status(404).json({ error: '快捷指令未接入' })
450
+ return
451
+ }
452
+ const actions = (req.body as { actions?: unknown }).actions
453
+ if (!Array.isArray(actions)) {
454
+ res.status(400).json({ error: 'body 需为 {actions: [...]}' })
455
+ return
456
+ }
457
+ deps.quickActions.save(actions as never[])
458
+ deps.wsHub?.broadcast('quick-actions-changed', {})
459
+ res.json({ ok: true })
460
+ })
461
+ )
462
+
463
+ // ── 备份与快照(M5) ────────────────────────────────────
464
+ app.get(
465
+ '/api/backup/snapshots',
466
+ wrap((_req, res) => {
467
+ res.json(deps.snapshots ? deps.snapshots.list() : [])
468
+ })
469
+ )
470
+
471
+ app.post(
472
+ '/api/backup/snapshot',
473
+ wrap((_req, res) => {
474
+ if (!deps.snapshots) {
475
+ res.status(404).json({ error: '快照服务未接入' })
476
+ return
477
+ }
478
+ const id = deps.snapshots.snapshot('manual')
479
+ res.json({ ok: true, id })
480
+ })
481
+ )
482
+
483
+ app.delete(
484
+ '/api/backup/snapshots/:id',
485
+ wrap((req, res) => {
486
+ const ok = deps.snapshots?.remove(parseInt(p(req.params.id), 10)) ?? false
487
+ res.json({ ok })
488
+ })
489
+ )
490
+
491
+ app.post(
492
+ '/api/backup/restore/:id',
493
+ wrap((req, res) => {
494
+ if (!deps.snapshots) {
495
+ res.status(404).json({ error: '快照服务未接入' })
496
+ return
497
+ }
498
+ const ok = deps.snapshots.restore(parseInt(p(req.params.id), 10))
499
+ if (ok) {
500
+ broadcastChanged(deps)
501
+ deps.wsHub?.broadcast('config-restored', {})
502
+ }
503
+ res.json({ ok })
504
+ })
505
+ )
506
+
507
+ app.get(
508
+ '/api/backup/export',
509
+ wrap((_req, res) => {
510
+ if (!deps.snapshots) {
511
+ res.status(404).json({ error: '备份服务未接入' })
512
+ return
513
+ }
514
+ const pack = deps.snapshots.exportPackData()
515
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
516
+ res.setHeader(
517
+ 'Content-Disposition',
518
+ `attachment; filename="deskpet-backup-${new Date().toISOString().slice(0, 10)}.deskpet"`
519
+ )
520
+ res.send(JSON.stringify(pack, null, 2))
521
+ })
522
+ )
523
+
524
+ app.post(
525
+ '/api/backup/import',
526
+ wrap((req, res) => {
527
+ if (!deps.snapshots) {
528
+ res.status(404).json({ error: '备份服务未接入' })
529
+ return
530
+ }
531
+ const body = req.body as { pack?: string } | null
532
+ const jsonText =
533
+ typeof body?.pack === 'string'
534
+ ? body.pack
535
+ : typeof req.body === 'string'
536
+ ? String(req.body)
537
+ : JSON.stringify(req.body)
538
+ const r = deps.snapshots.importPack(jsonText)
539
+ if (r.ok) {
540
+ broadcastChanged(deps)
541
+ deps.wsHub?.broadcast('config-restored', {})
542
+ }
543
+ res.json(r)
544
+ })
545
+ )
546
+
547
+ // ── 剪贴板监听规则(M5) ────────────────────────────────
548
+ app.get(
549
+ '/api/clipboard-watch',
550
+ wrap((_req, res) => {
551
+ res.json(deps.clipboardWatch ? deps.clipboardWatch.list() : [])
552
+ })
553
+ )
554
+
555
+ // ── Webhook 入站触发(外部系统 → 任务) ─────────────────
556
+ app.post(
557
+ '/api/hooks/:taskId',
558
+ wrap((req, res) => {
559
+ const taskId = p(req.params.taskId)
560
+ const rec = deps.scheduler.getTask(taskId)
561
+ if (!rec) {
562
+ res.status(404).json({ ok: false, error: `任务不存在: ${taskId}` })
563
+ return
564
+ }
565
+ if (!rec.config.enabled) {
566
+ res.json({ ok: false, error: '任务已禁用' })
567
+ return
568
+ }
569
+ // body.args: string(按空白拆分)或 string[];仅脚本任务支持参数
570
+ const raw = (req.body as { args?: unknown } | null)?.args
571
+ let args: string[] = []
572
+ if (typeof raw === 'string') args = raw.trim().split(/\s+/).filter(Boolean)
573
+ else if (Array.isArray(raw)) args = raw.map(String)
574
+
575
+ let ok: boolean
576
+ if (args.length && rec.config.action === 'python_script') {
577
+ ok = deps.scheduler.runTaskWithArgs(taskId, args)
578
+ } else {
579
+ ok = deps.scheduler.requestRun(taskId, 'trigger')
580
+ }
581
+ deps.wsHub?.broadcast('webhook-received', { taskId, taskName: rec.config.name })
582
+ res.json({ ok, taskId, args: args.length ? args : undefined })
583
+ })
584
+ )
585
+
586
+ // ── 本地资源分发(皮肤精灵图等,取代 deskpet:// 协议) ────
587
+ app.get(
588
+ '/api/assets',
589
+ wrap((req, res) => {
590
+ const abs = resolveAssetRequest(req.query.path)
591
+ if (!abs) {
592
+ res.status(404).json({ error: '资源不存在或无访问权限' })
593
+ return
594
+ }
595
+ res.sendFile(abs)
596
+ })
597
+ )
598
+
599
+ // ── 静态托管管理台 SPA ───────────────────────────────────
600
+ const staticRoot = resolveStaticRoot()
601
+ if (staticRoot && existsSync(staticRoot)) {
602
+ app.use(express.static(staticRoot))
603
+ // '/' 与 '/index.html' → dashboard
604
+ app.get('/', (_req, res) => {
605
+ res.sendFile(join(staticRoot, 'dashboard/index.html'))
606
+ })
607
+ app.get('/dashboard', (_req, res) => {
608
+ res.sendFile(join(staticRoot, 'dashboard/index.html'))
609
+ })
610
+ } else {
611
+ // 未构建前端时给出提示,避免直接 404 无从排查
612
+ app.get('/', (_req, res) => {
613
+ res
614
+ .status(200)
615
+ .type('text/plain; charset=utf-8')
616
+ .send('DeskPet 服务已启动,但未找到前端构建产物。请先执行 npm run build:web。')
617
+ })
618
+ }
619
+
620
+ // API 404 兜底(在静态之后)
621
+ app.use('/api', (_req, res) => {
622
+ res.status(404).json({ error: '接口不存在' })
623
+ })
624
+
625
+ return app
626
+ }
627
+
628
+ function broadcastChanged(deps: HttpServerDeps): void {
629
+ deps.wsHub?.broadcast('tasks-changed', { ts: Date.now() })
630
+ }
631
+
632
+ /** 统计聚合:今日执行、7 天成功率、平均耗时、每日趋势、失败 Top */
633
+ function computeStats(repo: TaskRepository) {
634
+ const rows = repo.listExecutions(undefined, 1000)
635
+ const dayStart = new Date()
636
+ dayStart.setHours(0, 0, 0, 0)
637
+ const weekAgo = Date.now() - 7 * 24 * 3600 * 1000
638
+
639
+ let todayTotal = 0
640
+ let todaySuccess = 0
641
+ let weekTotal = 0
642
+ let weekSuccess = 0
643
+ let weekDuration = 0
644
+ const trendMap = new Map<string, { total: number; success: number }>()
645
+ const failMap = new Map<string, { name: string; fails: number; lastError: string }>()
646
+
647
+ for (const r of rows) {
648
+ const t = new Date(r.startedAt).getTime()
649
+ const dateKey = r.startedAt.slice(0, 10)
650
+ const trend = trendMap.get(dateKey) ?? { total: 0, success: 0 }
651
+ trend.total += 1
652
+ if (r.status === 'completed') trend.success += 1
653
+ trendMap.set(dateKey, trend)
654
+
655
+ if (r.status === 'completed') {
656
+ weekSuccess += 1
657
+ }
658
+ weekTotal += 1
659
+ weekDuration += r.durationMs ?? 0
660
+
661
+ if (t >= dayStart.getTime()) {
662
+ todayTotal += 1
663
+ if (r.status === 'completed') todaySuccess += 1
664
+ }
665
+
666
+ if (r.status === 'failed' || r.status === 'timeout') {
667
+ const f = failMap.get(r.taskId) ?? { name: r.taskName, fails: 0, lastError: '' }
668
+ f.fails += 1
669
+ f.lastError = r.error || f.lastError
670
+ failMap.set(r.taskId, f)
671
+ }
672
+ }
673
+ void weekAgo
674
+
675
+ return {
676
+ today: { total: todayTotal, success: todaySuccess },
677
+ successRate7d: weekTotal > 0 ? Math.round((weekSuccess / weekTotal) * 1000) / 10 : null,
678
+ avgDurationMs7d: weekTotal > 0 ? Math.round(weekDuration / weekTotal) : null,
679
+ dailyTrend: [...trendMap.entries()]
680
+ .sort(([a], [b]) => (a < b ? -1 : 1))
681
+ .slice(-14)
682
+ .map(([date, v]) => ({ date, ...v })),
683
+ topFailures: [...failMap.entries()]
684
+ .sort(([, a], [, b]) => b.fails - a.fails)
685
+ .slice(0, 5)
686
+ .map(([taskId, v]) => ({ taskId, ...v }))
687
+ }
688
+ }
689
+
690
+ function normalizeConfig(body: Partial<TaskConfig>): TaskConfig {
691
+ const base = defaultTaskConfig()
692
+ const merged = mergeConfig(base, body)
693
+ if (!merged.name) merged.name = `任务 ${new Date().toLocaleString('zh-CN')}`
694
+ return merged
695
+ }
696
+
697
+ function mergeConfig(base: TaskConfig, patch: Partial<TaskConfig>): TaskConfig {
698
+ return {
699
+ ...base,
700
+ ...patch,
701
+ trigger: { ...base.trigger, ...(patch.trigger ?? {}) },
702
+ actionParams: { ...(base.actionParams ?? {}), ...(patch.actionParams ?? {}) },
703
+ scriptArgs: patch.scriptArgs ?? base.scriptArgs,
704
+ notifyChannels: patch.notifyChannels ?? base.notifyChannels
705
+ }
706
+ }
707
+
708
+ function clampInt(v: unknown, min: number, max: number): number {
709
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10)
710
+ if (!Number.isFinite(n)) return min
711
+ return Math.max(min, Math.min(max, Math.floor(n)))
712
+ }
713
+
714
+ /** 静态资源根目录:优先 vite 构建产物 dist/web,兼容旧路径 out/renderer */
715
+ function resolveStaticRoot(): string | null {
716
+ const candidates = [
717
+ join(moduleDir(), '../../dist/web'),
718
+ join(moduleDir(), '../renderer'),
719
+ join(process.cwd(), 'dist/web')
720
+ ]
721
+ for (const root of candidates) {
722
+ if (existsSync(join(root, 'dashboard/index.html'))) return root
723
+ }
724
+ return null
725
+ }