@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,422 @@
1
+ /**
2
+ * 动画引擎 — 5 状态动画调度、优先级管理、算法微动
3
+ *
4
+ * 从旧版 Python animation_engine.py 1:1 移植。
5
+ *
6
+ * 状态优先级: 点击(4) > 忙碌(3) > 悬浮(2) > 待机(1) > 休眠(0)
7
+ * 过渡: 0.15s 线性插值
8
+ * 点击动画: 跳跃 / 压扁回弹 / 左右抖动 轮流触发
9
+ */
10
+ import { ClickAnimationType, PetState } from './types.ts'
11
+
12
+ export interface StateParams {
13
+ fps: number
14
+ /** 上下浮动幅度(px) */
15
+ floatAmplitudePx: number
16
+ /** 浮动周期(s) */
17
+ floatPeriodS: number
18
+ /** 基础缩放 */
19
+ scaleBase: number
20
+ /** 缩放变化幅度 */
21
+ scaleAmplitude: number
22
+ /** 旋转微动幅度(度) */
23
+ rotationAmplitude: number
24
+ /** 循环周期(s) */
25
+ cycleDurationS: number
26
+ /** 淡入淡出时长(s) */
27
+ fadeDurationS: number
28
+ }
29
+
30
+ const STATE_PARAMS: Record<PetState, StateParams> = {
31
+ [PetState.SLEEP]: {
32
+ fps: 10,
33
+ floatAmplitudePx: 1.0,
34
+ floatPeriodS: 5.0,
35
+ scaleBase: 1.0,
36
+ scaleAmplitude: 0.01,
37
+ rotationAmplitude: 0.0,
38
+ cycleDurationS: 5.0,
39
+ fadeDurationS: 0.15
40
+ },
41
+ [PetState.IDLE]: {
42
+ fps: 20,
43
+ floatAmplitudePx: 0.0,
44
+ floatPeriodS: 2.5,
45
+ scaleBase: 1.0,
46
+ scaleAmplitude: 0.0,
47
+ rotationAmplitude: 0.0,
48
+ cycleDurationS: 2.5,
49
+ fadeDurationS: 0.15
50
+ },
51
+ [PetState.HOVER]: {
52
+ fps: 30,
53
+ floatAmplitudePx: 2.0,
54
+ floatPeriodS: 1.5,
55
+ scaleBase: 1.05,
56
+ scaleAmplitude: 0.01,
57
+ rotationAmplitude: 2.0,
58
+ cycleDurationS: 1.5,
59
+ fadeDurationS: 0.15
60
+ },
61
+ [PetState.BUSY]: {
62
+ fps: 30,
63
+ floatAmplitudePx: 4.0,
64
+ floatPeriodS: 1.2,
65
+ scaleBase: 1.0,
66
+ scaleAmplitude: 0.03,
67
+ rotationAmplitude: 3.0,
68
+ cycleDurationS: 1.2,
69
+ fadeDurationS: 0.15
70
+ },
71
+ [PetState.CLICK]: {
72
+ fps: 30,
73
+ floatAmplitudePx: 0.0,
74
+ floatPeriodS: 0.4,
75
+ scaleBase: 1.0,
76
+ scaleAmplitude: 0.0,
77
+ rotationAmplitude: 0.0,
78
+ cycleDurationS: 0.4,
79
+ fadeDurationS: 0.15
80
+ }
81
+ }
82
+
83
+ /** 点击动画时长(按类型) */
84
+ const CLICK_DURATIONS: Record<ClickAnimationType, number> = {
85
+ [ClickAnimationType.JUMP]: 0.6,
86
+ [ClickAnimationType.SQUASH]: 0.5,
87
+ [ClickAnimationType.SHAKE]: 0.5
88
+ }
89
+
90
+ /** 单帧变换参数(渲染层直接消费) */
91
+ export interface AnimationFrame {
92
+ offsetX: number
93
+ offsetY: number
94
+ scale: number
95
+ rotation: number
96
+ opacity: number
97
+ state: PetState
98
+ }
99
+
100
+ export type StateChangeCallback = (oldState: PetState, newState: PetState) => void
101
+
102
+ export class AnimationEngine {
103
+ private currentState: PetState = PetState.IDLE
104
+ private targetState: PetState = PetState.IDLE
105
+ private previousState: PetState = PetState.IDLE
106
+
107
+ private stateStartTime: number
108
+ private lastInteractionTime: number
109
+ private idleTimeoutS: number
110
+
111
+ private clickAnimating = false
112
+ private clickStartTime = 0
113
+ private clickDuration = STATE_PARAMS[PetState.CLICK].cycleDurationS
114
+ private clickAnimType: ClickAnimationType = ClickAnimationType.SQUASH
115
+ private clickAnimCycle: ClickAnimationType[] = [
116
+ ClickAnimationType.JUMP,
117
+ ClickAnimationType.SQUASH,
118
+ ClickAnimationType.SHAKE
119
+ ]
120
+ private clickAnimIndex = 0
121
+
122
+ private inTransition = false
123
+ private transitionStart = 0
124
+ private transitionDuration = 0.15
125
+
126
+ private enabled = true
127
+ private speedMultiplier = 1.0
128
+ private amplitudeMultiplier = 1.0
129
+
130
+ private stateChangeCallbacks: StateChangeCallback[] = []
131
+
132
+ constructor(idleTimeoutS = 180.0) {
133
+ this.stateStartTime = now()
134
+ this.lastInteractionTime = now()
135
+ this.idleTimeoutS = idleTimeoutS
136
+ }
137
+
138
+ /** 当前对外状态(过渡期间返回目标状态) */
139
+ get state(): PetState {
140
+ return this.inTransition ? this.targetState : this.currentState
141
+ }
142
+
143
+ get isSleeping(): boolean {
144
+ return this.currentState === PetState.SLEEP
145
+ }
146
+
147
+ get isEnabled(): boolean {
148
+ return this.enabled
149
+ }
150
+
151
+ setEnabled(value: boolean): void {
152
+ this.enabled = value
153
+ }
154
+
155
+ get speed(): number {
156
+ return this.speedMultiplier
157
+ }
158
+
159
+ setSpeed(value: number): void {
160
+ this.speedMultiplier = clamp(value, 0.1, 5.0)
161
+ }
162
+
163
+ get amplitude(): number {
164
+ return this.amplitudeMultiplier
165
+ }
166
+
167
+ setAmplitude(value: number): void {
168
+ this.amplitudeMultiplier = clamp(value, 0.1, 5.0)
169
+ }
170
+
171
+ /** 当前状态已持续毫秒数(渲染层用于推进精灵帧) */
172
+ get stateElapsedMs(): number {
173
+ return (now() - this.stateStartTime) * 1000
174
+ }
175
+
176
+ /** 请求切换到指定状态(遵循优先级规则) */
177
+ requestState(state: PetState, force = false): boolean {
178
+ if (!this.enabled && state !== PetState.SLEEP) return false
179
+
180
+ // 点击动画播放中,仅更高优先级可打断
181
+ if (this.clickAnimating) {
182
+ if (state > PetState.CLICK) {
183
+ this.clickAnimating = false
184
+ } else {
185
+ return false
186
+ }
187
+ }
188
+
189
+ const effectiveState = this.inTransition ? this.targetState : this.currentState
190
+
191
+ // 优先级检查(SLEEP 总是允许)
192
+ if (!force && state < effectiveState && state !== PetState.SLEEP) return false
193
+
194
+ // 同状态不重复切换(CLICK 总是响应)
195
+ if (state === effectiveState && state !== PetState.CLICK) {
196
+ this.lastInteractionTime = now()
197
+ return true
198
+ }
199
+
200
+ // ── CLICK 单次动画 ──────────────────────────────
201
+ if (state === PetState.CLICK) {
202
+ this.clickAnimating = true
203
+ this.clickStartTime = now()
204
+ this.clickAnimType = this.clickAnimCycle[this.clickAnimIndex]
205
+ this.clickAnimIndex = (this.clickAnimIndex + 1) % this.clickAnimCycle.length
206
+ this.clickDuration = CLICK_DURATIONS[this.clickAnimType] ?? 0.4
207
+ // 点击动画即时响应,跳过过渡
208
+ this.previousState = this.currentState
209
+ this.currentState = state
210
+ this.inTransition = false
211
+ this.stateStartTime = now()
212
+ this.lastInteractionTime = now()
213
+ this.emitStateChange(this.previousState, state)
214
+ return true
215
+ }
216
+
217
+ this.previousState = this.currentState
218
+ this.targetState = state
219
+ this.startTransition()
220
+ this.lastInteractionTime = now()
221
+ this.emitStateChange(this.previousState, this.targetState)
222
+ return true
223
+ }
224
+
225
+ /** 通知有用户交互(重置闲置计时器) */
226
+ notifyInteraction(): void {
227
+ this.lastInteractionTime = now()
228
+ }
229
+
230
+ /**
231
+ * 计算当前帧变换参数(每帧调用一次,渲染层 rAF 驱动)
232
+ */
233
+ computeFrame(): AnimationFrame {
234
+ const t = now()
235
+
236
+ // ── 状态过渡 ────────────────────────────────────
237
+ if (this.inTransition) {
238
+ const progress = (t - this.transitionStart) / this.transitionDuration
239
+ if (progress >= 1.0) {
240
+ this.finishTransition()
241
+ } else {
242
+ const prev = this.computeStateFrame(this.previousState, t)
243
+ const target = this.computeStateFrame(this.targetState, t)
244
+ const p = clamp(progress, 0, 1)
245
+ return {
246
+ offsetX: lerp(prev.offsetX, target.offsetX, p),
247
+ offsetY: lerp(prev.offsetY, target.offsetY, p),
248
+ scale: lerp(prev.scale, target.scale, p),
249
+ rotation: lerp(prev.rotation, target.rotation, p),
250
+ opacity: lerp(prev.opacity, target.opacity, p),
251
+ state: this.targetState
252
+ }
253
+ }
254
+ }
255
+
256
+ // ── 点击动画单次处理 ────────────────────────────
257
+ if (this.clickAnimating) {
258
+ const elapsed = t - this.clickStartTime
259
+ if (elapsed >= this.clickDuration) {
260
+ this.clickAnimating = false
261
+ this.previousState = this.currentState
262
+ this.targetState = PetState.IDLE
263
+ this.startTransition()
264
+ return this.computeFrame() // 递归一次获取过渡帧
265
+ }
266
+ return this.computeClickFrame(elapsed)
267
+ }
268
+
269
+ // ── 闲置检测(超时进入休眠) ────────────────────
270
+ if (
271
+ this.idleTimeoutS > 0 &&
272
+ this.currentState !== PetState.SLEEP &&
273
+ this.currentState !== PetState.CLICK
274
+ ) {
275
+ const idle = t - this.lastInteractionTime
276
+ if (idle >= this.idleTimeoutS) {
277
+ this.requestState(PetState.SLEEP)
278
+ }
279
+ }
280
+
281
+ return this.computeStateFrame(this.currentState, t)
282
+ }
283
+
284
+ private computeStateFrame(state: PetState, t: number): AnimationFrame {
285
+ const params = STATE_PARAMS[state]
286
+ const elapsed = t - this.stateStartTime
287
+
288
+ const period = params.cycleDurationS / Math.max(this.speedMultiplier, 0.01)
289
+ const phase = (elapsed % period) / period
290
+ const wave = easeInOutSine(phase)
291
+
292
+ const frame: AnimationFrame = {
293
+ offsetX: 0,
294
+ offsetY: wave * params.floatAmplitudePx * this.amplitudeMultiplier,
295
+ scale: params.scaleBase + wave * params.scaleAmplitude * this.amplitudeMultiplier,
296
+ rotation: wave * params.rotationAmplitude * this.amplitudeMultiplier,
297
+ opacity: 1.0,
298
+ state
299
+ }
300
+
301
+ if (state === PetState.SLEEP) {
302
+ frame.opacity = 0.95 + wave * 0.05
303
+ }
304
+
305
+ return frame
306
+ }
307
+
308
+ private computeClickFrame(elapsed: number): AnimationFrame {
309
+ const total = this.clickDuration
310
+ const progress = Math.min(elapsed / total, 1.0)
311
+ switch (this.clickAnimType) {
312
+ case ClickAnimationType.JUMP:
313
+ return this.computeJumpFrame(progress)
314
+ case ClickAnimationType.SQUASH:
315
+ return this.computeSquashFrame(progress)
316
+ default:
317
+ return this.computeShakeFrame(progress)
318
+ }
319
+ }
320
+
321
+ /** 跳跃: 上升 → 下落 → 落地压扁 → 回弹恢复 (0.6s) */
322
+ private computeJumpFrame(progress: number): AnimationFrame {
323
+ const jumpHeight = 35.0
324
+ let offsetY = 0
325
+ let scale = 1.0
326
+
327
+ if (progress < 0.35) {
328
+ const t = progress / 0.35
329
+ offsetY = -jumpHeight * (2 * t - t * t)
330
+ } else if (progress < 0.65) {
331
+ const t = (progress - 0.35) / 0.3
332
+ offsetY = -jumpHeight * (1 - t * t)
333
+ } else if (progress < 0.8) {
334
+ const t = (progress - 0.65) / 0.15
335
+ scale = 1.0 - 0.1 * t
336
+ } else {
337
+ const t = (progress - 0.8) / 0.2
338
+ scale = 0.9 + 0.1 * t
339
+ }
340
+
341
+ return { offsetX: 0, offsetY, scale, rotation: 0, opacity: 1, state: PetState.CLICK }
342
+ }
343
+
344
+ /** 压扁回弹: 压扁 100→82% → 拉伸 82→112% → 回弹 (0.5s) */
345
+ private computeSquashFrame(progress: number): AnimationFrame {
346
+ let scale: number
347
+ if (progress < 0.35) {
348
+ const t = progress / 0.35
349
+ scale = 1.0 - 0.18 * t
350
+ } else if (progress < 0.65) {
351
+ const t = (progress - 0.35) / 0.3
352
+ scale = 0.82 + 0.3 * t
353
+ } else {
354
+ const t = (progress - 0.65) / 0.35
355
+ scale = 1.12 - 0.12 * t
356
+ }
357
+ return { offsetX: 0, offsetY: 0, scale, rotation: 0, opacity: 1, state: PetState.CLICK }
358
+ }
359
+
360
+ /** 左右抖动: 高频正弦摆动,幅度衰减 (0.5s) */
361
+ private computeShakeFrame(progress: number): AnimationFrame {
362
+ const maxOffset = 10.0
363
+ const decay = 1.0 - progress * 0.7
364
+ const shake = Math.sin(progress * Math.PI * 6) * maxOffset * decay
365
+ return { offsetX: shake, offsetY: 0, scale: 1, rotation: 0, opacity: 1, state: PetState.CLICK }
366
+ }
367
+
368
+ private startTransition(): void {
369
+ this.inTransition = true
370
+ this.transitionStart = now()
371
+ }
372
+
373
+ private finishTransition(): void {
374
+ this.inTransition = false
375
+ this.currentState = this.targetState
376
+ this.stateStartTime = now()
377
+ }
378
+
379
+ /** 注册状态变更回调 */
380
+ onStateChange(cb: StateChangeCallback): () => void {
381
+ this.stateChangeCallbacks.push(cb)
382
+ return () => {
383
+ const i = this.stateChangeCallbacks.indexOf(cb)
384
+ if (i >= 0) this.stateChangeCallbacks.splice(i, 1)
385
+ }
386
+ }
387
+
388
+ private emitStateChange(oldState: PetState, newState: PetState): void {
389
+ for (const cb of this.stateChangeCallbacks) {
390
+ try {
391
+ cb(oldState, newState)
392
+ } catch {
393
+ // 回调异常不阻断引擎
394
+ }
395
+ }
396
+ }
397
+
398
+ /** 批量配置动画参数 */
399
+ configure(enabled: boolean, speed: number, amplitude: number): void {
400
+ this.enabled = enabled
401
+ this.speedMultiplier = speed
402
+ this.amplitudeMultiplier = amplitude
403
+ }
404
+ }
405
+
406
+ // ── 工具函数 ────────────────────────────────────────────────
407
+
408
+ function now(): number {
409
+ return performance.now() / 1000
410
+ }
411
+
412
+ function clamp(v: number, min: number, max: number): number {
413
+ return Math.max(min, Math.min(v, max))
414
+ }
415
+
416
+ function lerp(a: number, b: number, t: number): number {
417
+ return a + (b - a) * clamp(t, 0, 1)
418
+ }
419
+
420
+ function easeInOutSine(t: number): number {
421
+ return Math.sin(t * Math.PI * 2)
422
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 任务链条件判定(方案 §3.3.B:条件分支——退出码/输出匹配)
3
+ *
4
+ * 语法:
5
+ * (空)/ always / 成功 → 仅成功时继续
6
+ * failed / 失败 → 仅失败时继续
7
+ * exit==0 / exit!=0 / exit>=N ... → 按脚本退出码
8
+ * output contains xxx → stdout 包含子串
9
+ */
10
+ import type { ActionResult } from './types.ts'
11
+
12
+ export interface ChainContext {
13
+ success: boolean
14
+ exitCode: number | null
15
+ stdout: string
16
+ }
17
+
18
+ export function evalChainCondition(
19
+ cond: string | null | undefined,
20
+ ctx: ChainContext
21
+ ): boolean {
22
+ const c = (cond ?? '').trim().toLowerCase()
23
+ if (!c || c === 'always' || c === 'success' || c === '成功') return ctx.success
24
+ if (c === 'failed' || c === '失败') return !ctx.success
25
+
26
+ const m = /^exit\s*(==|!=|>=|<=|>|<)\s*(-?\d+)$/.exec(c)
27
+ if (m) {
28
+ const code = ctx.exitCode ?? (ctx.success ? 0 : -1)
29
+ const target = parseInt(m[2], 10)
30
+ switch (m[1]) {
31
+ case '==':
32
+ return code === target
33
+ case '!=':
34
+ return code !== target
35
+ case '>=':
36
+ return code >= target
37
+ case '<=':
38
+ return code <= target
39
+ case '>':
40
+ return code > target
41
+ case '<':
42
+ return code < target
43
+ }
44
+ }
45
+
46
+ const om = /^output\s+contains\s+(.+)$/.exec(c)
47
+ if (om) return ctx.stdout.toLowerCase().includes(om[1].trim().toLowerCase())
48
+
49
+ // 未知条件按成功处理
50
+ return ctx.success
51
+ }
52
+
53
+ /** 便捷:从 ActionResult 构造判定上下文 */
54
+ export function chainContextOf(result: ActionResult): ChainContext {
55
+ return {
56
+ success: result.success,
57
+ exitCode: result.exitCode ?? null,
58
+ stdout: result.stdout ?? ''
59
+ }
60
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * 每周定时(cron)的友好表达 —— 对齐旧版「指定时间(每天/每周)」的交互
3
+ *
4
+ * 旧版 pet(src/ui/task_panel.py)是「HH:MM 输入框 + 周一~周日复选框」,
5
+ * 保存为 `分 时 * * 周几`。这里把这套搬到 Web 端,并加上 cron 描述文本。
6
+ *
7
+ * ⚠️ 周几编号采用**标准 cron 语义**:0=周日, 1=周一, ... 6=周六
8
+ * (与 node-schedule / crontab 一致,调度器直接用 schedule.scheduleJob(cron))。
9
+ * 旧版把「周一」映射成 0,与标准相反(属旧版 bug),此处**不沿用**——
10
+ * 否则周一的任务会在周日跑。
11
+ */
12
+
13
+ /** 显示顺序(中文习惯从周一开始) */
14
+ export const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] as const
15
+
16
+ /** cron 值 → 中文名 */
17
+ export const WEEKDAY_NAMES: Record<number, string> = {
18
+ 1: '周一',
19
+ 2: '周二',
20
+ 3: '周三',
21
+ 4: '周四',
22
+ 5: '周五',
23
+ 6: '周六',
24
+ 0: '周日'
25
+ }
26
+
27
+ /** 全部 7 天(cron 值) */
28
+ export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]
29
+
30
+ export interface WeeklyCron {
31
+ hour: number
32
+ minute: number
33
+ /** cron 周几值(0=周日) */
34
+ weekdays: number[]
35
+ }
36
+
37
+ function norm(v: number, max: number): number {
38
+ if (!Number.isFinite(v)) return 0
39
+ const n = Math.round(v)
40
+ return Math.max(0, Math.min(max, n))
41
+ }
42
+
43
+ function mod7(v: number): number {
44
+ return ((v % 7) + 7) % 7
45
+ }
46
+
47
+ /**
48
+ * 解析「分 时 * * 周几」形式的 cron
49
+ * @returns 解析成功返回 {hour, minute, weekdays};
50
+ * 无法表示为「每周定时」(如带步进写法、指定了日/月、跨周区间)时返回 null,
51
+ * 调用方应回退到「手动编辑 cron 表达式」,避免把用户的表达式改坏。
52
+ */
53
+ export function parseWeeklyCron(cron: string | null | undefined): WeeklyCron | null {
54
+ const s = (cron ?? '').trim()
55
+ if (!s) return null
56
+ const parts = s.split(/\s+/)
57
+ if (parts.length !== 5) return null
58
+ const [mi, ho, dom, mon, dow] = parts
59
+
60
+ // 只支持「每周」形态:日、月字段必须为 *
61
+ if (dom !== '*' || mon !== '*') return null
62
+ if (!/^\d{1,2}$/.test(mi) || !/^\d{1,2}$/.test(ho)) return null
63
+
64
+ const minute = Number(mi)
65
+ const hour = Number(ho)
66
+ if (minute > 59 || hour > 23) return null
67
+
68
+ const weekdays = parseDowField(dow)
69
+ if (!weekdays) return null
70
+ return { hour, minute, weekdays }
71
+ }
72
+
73
+ function parseDowField(field: string): number[] | null {
74
+ const f = field.trim()
75
+ if (f === '*' || f === '?') return [...ALL_WEEKDAYS]
76
+ const set = new Set<number>()
77
+ for (const raw of f.split(',')) {
78
+ const token = raw.trim()
79
+ if (!token) return null
80
+ // 步进(*/2、1-5/2)不在友好 UI 的表达范围内 → 交给手动编辑
81
+ if (token.includes('/')) return null
82
+ const m = /^(\d)(?:-(\d))?$/.exec(token)
83
+ if (!m) return null
84
+ const a = Number(m[1])
85
+ const b = m[2] === undefined ? a : Number(m[2])
86
+ if (a > 7 || b > 7) return null
87
+ if (a > b) return null // 跨周环绕(如 6-1)不支持
88
+ for (let i = a; i <= b; i++) set.add(mod7(i))
89
+ }
90
+ return set.size > 0 ? [...set].sort((x, y) => x - y) : null
91
+ }
92
+
93
+ /**
94
+ * 由「时间 + 周几」生成 cron
95
+ * - 7 天全选(或全不选)→ 周几字段用 `*`(每天)
96
+ */
97
+ export function buildWeeklyCron(minute: number, hour: number, weekdays: number[]): string {
98
+ const m = norm(minute, 59)
99
+ const h = norm(hour, 23)
100
+ const set = [...new Set((weekdays ?? []).map(mod7))].sort((a, b) => a - b)
101
+ const dow = set.length === 0 || set.length === 7 ? '*' : set.join(',')
102
+ return `${m} ${h} * * ${dow}`
103
+ }
104
+
105
+ /** 生成中文说明(任务列表展示用),如「每周一、三、五 17:55」「每天 09:00」 */
106
+ export function formatWeeklyCron(cron: string | null | undefined): string | null {
107
+ const p = parseWeeklyCron(cron)
108
+ if (!p) return null
109
+ const time = `${String(p.hour).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}`
110
+ if (p.weekdays.length === 7) return `每天 ${time}`
111
+ // 按中文习惯顺序(周一 → 周日)展示;后续项省略「周」字 → 「每周一、三、五」
112
+ const labels = WEEKDAY_ORDER.filter((w) => p.weekdays.includes(w)).map(
113
+ (w) => WEEKDAY_NAMES[w] ?? String(w)
114
+ )
115
+ const joined = labels.map((n, i) => (i === 0 ? n : n.replace(/^周/, ''))).join('、')
116
+ return `每${joined} ${time}`
117
+ }
118
+
119
+ /** 快速选择预设 */
120
+ export const WEEKDAY_PRESETS: Array<{ label: string; weekdays: number[] }> = [
121
+ { label: '每天', weekdays: [...ALL_WEEKDAYS] },
122
+ { label: '工作日', weekdays: [1, 2, 3, 4, 5] },
123
+ { label: '周末', weekdays: [6, 0] }
124
+ ]
125
+
126
+ /** 判断两个周几数组是否等价(忽略顺序与重复) */
127
+ export function sameWeekdays(a: number[], b: number[]): boolean {
128
+ const sa = [...new Set(a.map(mod7))].sort((x, y) => x - y).join(',')
129
+ const sb = [...new Set(b.map(mod7))].sort((x, y) => x - y).join(',')
130
+ return sa === sb
131
+ }