dsh-config-manager 0.1.47 → 0.1.49

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 (39) hide show
  1. package/README.md +15 -0
  2. package/README.zh-CN.md +15 -0
  3. package/lib/client.d.ts +70 -3
  4. package/lib/client.js +792 -268
  5. package/lib/client.js.map +1 -1
  6. package/lib/core/restore.d.ts +7 -0
  7. package/lib/core/restore.js +4 -0
  8. package/lib/core/restore.js.map +1 -1
  9. package/lib/core/run-registry.d.ts +11 -4
  10. package/lib/core/run-registry.js +14 -7
  11. package/lib/core/run-registry.js.map +1 -1
  12. package/lib/index.d.ts +7 -1
  13. package/lib/index.js +131 -19
  14. package/lib/index.js.map +1 -1
  15. package/lib/ui/backup-schedule.d.ts +49 -0
  16. package/lib/ui/backup-schedule.js +42 -0
  17. package/lib/ui/backup-schedule.js.map +1 -0
  18. package/package.json +1 -1
  19. package/src/client/api.ts +35 -1
  20. package/src/client/common/ConfirmDialog.tsx +4 -2
  21. package/src/client/common/ErrorBanner.tsx +8 -4
  22. package/src/client/common/ReportView.tsx +10 -3
  23. package/src/client/common/ui.tsx +9 -2
  24. package/src/client/config-manager.module.css +22 -0
  25. package/src/client/export/ExportView.tsx +15 -5
  26. package/src/client/import/ImportWizardView.tsx +75 -14
  27. package/src/client/locales.ts +46 -0
  28. package/src/client/market/MarketPanel.tsx +9 -4
  29. package/src/client/market/MyConfigsView.tsx +8 -8
  30. package/src/client/run-store.test.ts +112 -1
  31. package/src/client/run-store.ts +78 -4
  32. package/src/client/snapshots/SnapshotsPanel.tsx +261 -7
  33. package/src/client/sync/SyncHistoryView.tsx +16 -2
  34. package/src/core/restore.ts +7 -0
  35. package/src/core/run-registry.test.ts +40 -0
  36. package/src/core/run-registry.ts +16 -9
  37. package/src/index.ts +129 -18
  38. package/src/ui/backup-schedule.test.ts +53 -0
  39. package/src/ui/backup-schedule.ts +83 -0
@@ -7,14 +7,31 @@
7
7
  * 状态组件内自持(useState),同时经 toSnapshotsStoreSlice() 镜像进模块级 runStore:
8
8
  * 模块级单例保证「切 tab 不丢」,sessionStorage 白名单保证「刷新恢复」
9
9
  * (选中快照 / dry-run 计划 / 执行报告;快照列表本身可随时重载,不持久化)。
10
+ *
11
+ * 视图底部附「定时全量备份」设置卡(BackupScheduleCard,m-backup-schedule):
12
+ * 开关 + 间隔档位(6h/12h/24h/7d)保存经 PUT /backup-schedule(host 校验 + 重排
13
+ * 调度器);「立即备份」经 POST /backup-schedule/run 复用 BackupScheduler.runOnce
14
+ * (同一时刻防重)。草稿镜像 runStore.snapshots.backupDraft(未保存修改切 tab /
15
+ * 刷新保留)。安全:定时备份恒不含 secret、不加密;本卡无敏感字段。
10
16
  */
11
17
  import { useEffect, useRef, useState } from 'react'
12
18
  import type { ReactNode } from 'react'
13
19
  import type { RestorePlan, RestoreReport, SnapshotMeta } from '../../core/restore.ts'
14
20
  import type { ConfigManagerApi } from '../api.ts'
15
21
  import type { TranslateNS } from '../client-types.ts'
16
- import { Badge, Banner, Button, Card, Empty, SectionTitle, Spinner } from '../common/ui.tsx'
22
+ import { Badge, Banner, Button, Card, Checkbox, Empty, Field, SectionTitle, Spinner } from '../common/ui.tsx'
23
+ import { ConfirmDialog } from '../common/ConfirmDialog.tsx'
17
24
  import { runStore, toSnapshotsStoreSlice, type SnapshotsStoreSlice } from '../run-store.ts'
25
+ import {
26
+ BACKUP_INTERVAL_OPTIONS,
27
+ backupDraftDirty,
28
+ backupRunBadgeKind,
29
+ validateBackupScheduleDraft,
30
+ type BackupInterval,
31
+ type BackupRunStatus,
32
+ type BackupScheduleDraft,
33
+ type BackupScheduleStatus,
34
+ } from '../../ui/backup-schedule.ts'
18
35
  import css from '../config-manager.module.css'
19
36
 
20
37
  export interface SnapshotsPanelProps {
@@ -81,12 +98,15 @@ function actionKindLabel(kind: string): string {
81
98
  /**
82
99
  * 从 runStore 恢复上次的快照面板状态(切 tab 回 / 刷新后挂载)。
83
100
  * 无敏感字段;plan/report 为纯数据,可安全序列化恢复。
101
+ * running 来自 store 镜像(刷新后经 runStore.resume() 以宿主 /runs 为权威重新置位——
102
+ * 持久化白名单恒为 false,绝不把浏览器陈旧状态当成恢复执行中的依据)。
84
103
  */
85
104
  function initFromStore(): PanelState {
86
105
  const s: SnapshotsStoreSlice = runStore.getSnapshot().snapshots
87
106
  return {
88
107
  ...initial,
89
108
  selectedId: s.selectedId,
109
+ running: s.running,
90
110
  plan: s.plan,
91
111
  report: s.report,
92
112
  actionError: s.actionError,
@@ -100,23 +120,30 @@ export function SnapshotsPanel({ api, t }: SnapshotsPanelProps) {
100
120
  const stateRef = useRef<PanelState>(state)
101
121
  /** 挂载守卫:卸载后不再 setState(store 镜像仍执行,异步结果照常落库) */
102
122
  const mountedRef = useRef(true)
123
+ /** dry-run 计划请求代数:快速切换快照时作废在途旧请求(防晚到响应覆盖新选择) */
124
+ const planGeneration = useRef(0)
125
+ /** 执行恢复的二次确认弹窗开关(危险操作,DESIGN.md §8.11 场景) */
126
+ const [confirmOpen, setConfirmOpen] = useState(false)
103
127
 
104
128
  /**
105
129
  * 统一提交入口:更新 stateRef → 挂载时 setState → **总是**镜像进 runStore。
106
130
  * 关键:镜像不依赖 effect flush —— 异步操作(dry-run 计划/执行恢复)完成回调
107
131
  * 在组件已卸载(切走 tab)时也能把结果(plan/report)写进 store,切回恢复。
132
+ * backupDraft 由 BackupScheduleCard 独立管理,此处从 store 读回原值避免覆盖。
108
133
  */
109
134
  const commit = (next: PanelState): void => {
110
135
  stateRef.current = next
111
136
  if (mountedRef.current) setState(next)
112
- runStore.patch({ snapshots: toSnapshotsStoreSlice(next) })
137
+ const backupDraft = runStore.getSnapshot().snapshots.backupDraft
138
+ runStore.patch({ snapshots: { ...toSnapshotsStoreSlice({ ...next, backupDraft }), backupDraft } })
113
139
  }
114
140
  const patch = (p: Partial<PanelState>): void => commit({ ...stateRef.current, ...p })
115
141
 
116
142
  /** 卸载时置挂载守卫 + 最后镜像一次(防止「最后一次改动后立即切 tab」时丢状态)。 */
117
143
  useEffect(() => () => {
118
144
  mountedRef.current = false
119
- runStore.patch({ snapshots: toSnapshotsStoreSlice(stateRef.current) })
145
+ const backupDraft = runStore.getSnapshot().snapshots.backupDraft
146
+ runStore.patch({ snapshots: { ...toSnapshotsStoreSlice({ ...stateRef.current, backupDraft }), backupDraft } })
120
147
  }, [])
121
148
 
122
149
  const load = (): void => {
@@ -135,10 +162,18 @@ export function SnapshotsPanel({ api, t }: SnapshotsPanelProps) {
135
162
  useEffect(load, [api])
136
163
 
137
164
  const select = (id: string): void => {
165
+ // 每次选择递增代数:用户快速切换快照时,旧 dry-run 请求晚到直接丢弃
166
+ // (防止「选了 B 却显示 A 的计划」的状态串扰)。
167
+ const generation = planGeneration.current + 1
168
+ planGeneration.current = generation
138
169
  patch({ selectedId: id, plan: null, report: null, actionError: null, planning: true })
139
170
  api.restoreSnapshot(id, true).then(
140
- (res) => { patch({ planning: false, plan: res.plan ?? null }) },
171
+ (res) => {
172
+ if (generation !== planGeneration.current) return
173
+ patch({ planning: false, plan: res.plan ?? null })
174
+ },
141
175
  (err) => {
176
+ if (generation !== planGeneration.current) return
142
177
  patch({
143
178
  planning: false,
144
179
  actionError: err instanceof Error ? err.message : String(err),
@@ -150,6 +185,11 @@ export function SnapshotsPanel({ api, t }: SnapshotsPanelProps) {
150
185
  const execute = (): void => {
151
186
  if (state.selectedId === null || state.running) return
152
187
  patch({ running: true, report: null, actionError: null })
188
+ setConfirmOpen(false)
189
+ // m3/P1-1:宿主侧权威防重(/restore 经 RunRegistry 登记,同 kind running → 409);
190
+ // 前端 running 只是 UX 镜像。watchRunning 轮询宿主 /runs + /progress——
191
+ // 切 tab(卸载)后轮询继续,完成/失败经 applySettled 写入 store,切回 initFromStore 恢复。
192
+ runStore.watchRunning('restore', 500)
153
193
  api.restoreSnapshot(state.selectedId, false).then(
154
194
  (res) => { patch({ running: false, report: res.report ?? null }) },
155
195
  (err) => {
@@ -158,7 +198,7 @@ export function SnapshotsPanel({ api, t }: SnapshotsPanelProps) {
158
198
  actionError: err instanceof Error ? err.message : String(err),
159
199
  })
160
200
  },
161
- )
201
+ ).finally(() => { runStore.stopRunWatch('restore') })
162
202
  }
163
203
 
164
204
  const summary = (): string => {
@@ -219,6 +259,7 @@ export function SnapshotsPanel({ api, t }: SnapshotsPanelProps) {
219
259
  aria-selected={selected}
220
260
  data-active={selected ? '' : undefined}
221
261
  className={css.snapshotRow}
262
+ disabled={state.planning || state.running}
222
263
  onClick={() => { select(meta.id) }}
223
264
  >
224
265
  <span title={meta.id}>{new Date(meta.createdAt).toLocaleString()}</span>
@@ -255,8 +296,7 @@ export function SnapshotsPanel({ api, t }: SnapshotsPanelProps) {
255
296
  <Button
256
297
  variant="danger"
257
298
  disabled={state.running || state.plan.actions.every((a) => a.kind === 'skip')}
258
- onClick={execute}
259
- title={t('snapshots.confirmRestore')}
299
+ onClick={() => { setConfirmOpen(true) }}
260
300
  >
261
301
  {state.running ? t('snapshots.executing') : t('snapshots.execute')}
262
302
  </Button>
@@ -279,6 +319,220 @@ export function SnapshotsPanel({ api, t }: SnapshotsPanelProps) {
279
319
  )}
280
320
  </>
281
321
  )}
322
+
323
+ {/* 定时全量备份设置(独立于快照列表状态,始终展示) */}
324
+ <BackupScheduleCard api={api} t={t} />
325
+
326
+ {/* 执行恢复二次确认(破坏性操作:整文件还原/删除 + 卸载插件;busy 防重复提交) */}
327
+ <ConfirmDialog
328
+ open={confirmOpen}
329
+ title={t('snapshots.confirmTitle')}
330
+ message={t('snapshots.confirmRestore')}
331
+ confirmLabel={t('snapshots.execute')}
332
+ cancelLabel={t('common.cancel')}
333
+ danger
334
+ busy={state.running}
335
+ onConfirm={execute}
336
+ onCancel={() => { setConfirmOpen(false) }}
337
+ />
282
338
  </div>
283
339
  )
284
340
  }
341
+
342
+ /* ------------------------------------------------- 定时全量备份设置卡 */
343
+
344
+ /** 间隔档位 → 字典键(t 的类型是字面量联合,switch 保持类型安全)。 */
345
+ function intervalLabel(t: TranslateNS<'config-manager'>, interval: BackupInterval): string {
346
+ switch (interval) {
347
+ case '6h': return t('backupSchedule.interval.6h')
348
+ case '12h': return t('backupSchedule.interval.12h')
349
+ case '24h': return t('backupSchedule.interval.24h')
350
+ case '7d': return t('backupSchedule.interval.7d')
351
+ }
352
+ }
353
+
354
+ /** 上次运行状态 → 字典键。 */
355
+ function runStatusLabel(t: TranslateNS<'config-manager'>, status: BackupRunStatus | undefined): string {
356
+ switch (status) {
357
+ case 'success': return t('backupSchedule.status.success')
358
+ case 'skipped': return t('backupSchedule.status.skipped')
359
+ case 'failed': return t('backupSchedule.status.failed')
360
+ default: return '—'
361
+ }
362
+ }
363
+
364
+ function formatRunTime(iso: string | undefined): string {
365
+ if (iso === undefined || iso === '') return ''
366
+ const d = new Date(iso)
367
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleString()
368
+ }
369
+
370
+ /**
371
+ * 定时全量备份设置卡:总开关 + 间隔档位 + 上次运行状态 + 保存 / 立即备份。
372
+ * 状态自持;草稿镜像 runStore.snapshots.backupDraft(未保存修改切 tab/刷新保留),
373
+ * 保存成功清草稿(宿主配置为权威)。
374
+ */
375
+ function BackupScheduleCard({ api, t }: { api: ConfigManagerApi; t: TranslateNS<'config-manager'> }) {
376
+ const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
377
+ const [error, setError] = useState<string | null>(null)
378
+ const [draft, setDraft] = useState<BackupScheduleDraft>({ enabled: false, interval: '24h' })
379
+ const [saved, setSaved] = useState<BackupScheduleStatus | null>(null)
380
+ const [saving, setSaving] = useState(false)
381
+ const [running, setRunning] = useState(false)
382
+ const [lastRun, setLastRun] = useState<BackupRunStatus | undefined>(undefined)
383
+ const [lastRunDetail, setLastRunDetail] = useState<string | null>(null)
384
+ const [flash, setFlash] = useState<string | null>(null)
385
+ const [actionError, setActionError] = useState<string | null>(null)
386
+ /** 挂载守卫:切 tab 卸载后异步回调只更新 store(草稿),不再 setState(React 18 无告警但浪费) */
387
+ const mountedRef = useRef(true)
388
+
389
+ useEffect(() => () => { mountedRef.current = false }, [])
390
+
391
+ const load = (): void => {
392
+ setStatus('loading')
393
+ setError(null)
394
+ api.backupSchedule().then(
395
+ (schedule) => {
396
+ if (!mountedRef.current) return
397
+ setSaved(schedule)
398
+ // 有未保存草稿(切 tab 回来)则保留,否则以宿主配置为权威
399
+ setDraft(runStore.getSnapshot().snapshots.backupDraft ?? { enabled: schedule.enabled, interval: schedule.interval })
400
+ setLastRun(schedule.lastRunStatus)
401
+ setLastRunDetail(formatRunTime(schedule.lastRunAt))
402
+ setStatus('ready')
403
+ },
404
+ (err) => {
405
+ if (!mountedRef.current) return
406
+ setStatus('error')
407
+ setError(err instanceof Error ? err.message : String(err))
408
+ },
409
+ )
410
+ }
411
+
412
+ useEffect(load, [api])
413
+
414
+ const updateDraft = (next: BackupScheduleDraft): void => {
415
+ setDraft(next)
416
+ runStore.patch({ snapshots: { backupDraft: next } })
417
+ }
418
+
419
+ const save = (): void => {
420
+ if (saving || running) return
421
+ const parsed = validateBackupScheduleDraft(draft)
422
+ if (!parsed.ok) {
423
+ setActionError(parsed.error)
424
+ return
425
+ }
426
+ setSaving(true)
427
+ setFlash(null)
428
+ setActionError(null)
429
+ api.saveBackupSchedule(parsed.value).then(
430
+ (schedule) => {
431
+ // 宿主已保存:无论面板是否仍挂载都清 store 草稿(否则切回会显示陈旧未保存态)
432
+ runStore.patch({ snapshots: { backupDraft: null } })
433
+ if (!mountedRef.current) return
434
+ setSaved(schedule)
435
+ setDraft({ enabled: schedule.enabled, interval: schedule.interval })
436
+ setLastRun(schedule.lastRunStatus)
437
+ setLastRunDetail(formatRunTime(schedule.lastRunAt))
438
+ setSaving(false)
439
+ setFlash(t('backupSchedule.saved'))
440
+ },
441
+ (err) => {
442
+ if (!mountedRef.current) return
443
+ setSaving(false)
444
+ setActionError(err instanceof Error ? err.message : String(err))
445
+ },
446
+ )
447
+ }
448
+
449
+ const runNow = (): void => {
450
+ if (running || saving) return
451
+ setRunning(true)
452
+ setFlash(null)
453
+ setActionError(null)
454
+ api.runBackupNow().then(
455
+ (res) => {
456
+ if (!mountedRef.current) return
457
+ setSaved(res.schedule)
458
+ setLastRun(res.run.status)
459
+ setLastRunDetail(res.run.zip !== undefined && res.run.zip !== ''
460
+ ? res.run.zip
461
+ : (res.run.skipReason !== undefined ? res.run.skipReason : formatRunTime(res.schedule.lastRunAt)))
462
+ setRunning(false)
463
+ },
464
+ (err) => {
465
+ if (!mountedRef.current) return
466
+ setRunning(false)
467
+ setActionError(err instanceof Error ? err.message : String(err))
468
+ },
469
+ )
470
+ }
471
+
472
+ const dirty = backupDraftDirty(draft, saved)
473
+ const busy = saving || running
474
+
475
+ return (
476
+ <Card className={css.card}>
477
+ <div className={css.groupLabel}>{t('backupSchedule.title')}</div>
478
+ <div className={css.hint}>{t('backupSchedule.hint')}</div>
479
+
480
+ {status === 'loading' && <Spinner label={t('backupSchedule.loading')} />}
481
+
482
+ {status === 'error' && (
483
+ <Banner kind="error">
484
+ {t('backupSchedule.error')}
485
+ <Button variant="primary" onClick={load}>{t('common.retry')}</Button>
486
+ </Banner>
487
+ )}
488
+
489
+ {status === 'ready' && (
490
+ <>
491
+ <Field label={t('backupSchedule.enabled')}>
492
+ <Checkbox
493
+ checked={draft.enabled}
494
+ onChange={(checked) => { updateDraft({ ...draft, enabled: checked }) }}
495
+ label={t('backupSchedule.enabledHint')}
496
+ disabled={busy}
497
+ />
498
+ </Field>
499
+ <Field label={t('backupSchedule.interval')}>
500
+ <select
501
+ className={css.select}
502
+ value={draft.interval}
503
+ disabled={busy}
504
+ onChange={(event) => { updateDraft({ ...draft, interval: event.target.value as BackupInterval }) }}
505
+ >
506
+ {BACKUP_INTERVAL_OPTIONS.map((interval) => (
507
+ <option key={interval} value={interval}>{intervalLabel(t, interval)}</option>
508
+ ))}
509
+ </select>
510
+ </Field>
511
+ {lastRun !== undefined && (
512
+ <div className={css.statRow}>
513
+ <span className={css.hint}>{t('backupSchedule.lastRun')}</span>
514
+ <Badge kind={backupRunBadgeKind(lastRun)}>{runStatusLabel(t, lastRun)}</Badge>
515
+ {lastRunDetail !== null && lastRunDetail !== '' && <span className={css.hint}>{lastRunDetail}</span>}
516
+ </div>
517
+ )}
518
+ <div className={css.actionRow}>
519
+ <Button
520
+ variant="primary"
521
+ disabled={busy || !dirty}
522
+ onClick={save}
523
+ title={dirty ? undefined : t('backupSchedule.saved')}
524
+ >
525
+ {saving ? <Spinner label={t('backupSchedule.save')} /> : t('backupSchedule.save')}
526
+ </Button>
527
+ <Button disabled={busy || !(saved?.enabled ?? false)} onClick={runNow}>
528
+ {running ? <Spinner label={t('backupSchedule.running')} /> : t('backupSchedule.runNow')}
529
+ </Button>
530
+ </div>
531
+ </>
532
+ )}
533
+
534
+ {flash !== null && <Banner kind="ok">{flash}</Banner>}
535
+ {actionError !== null && <Banner kind="error">{actionError}</Banner>}
536
+ </Card>
537
+ )
538
+ }
@@ -10,6 +10,7 @@ import { useEffect, useMemo, useState } from 'react'
10
10
  import type { ReactNode } from 'react'
11
11
 
12
12
  import { Badge, Card, SectionTitle, Spinner } from '../common/ui.tsx'
13
+ import { ErrorBanner } from '../common/ErrorBanner.tsx'
13
14
  import type { SyncApi, SyncHistoryEntry, AutosyncHistoryEntry } from './sync-api.ts'
14
15
  import {
15
16
  describeSkipReason, directionLabel, formatDateTime, projectAutosyncEntry,
@@ -36,14 +37,18 @@ export function SyncHistoryView(props: SyncHistoryViewProps): ReactNode {
36
37
  const [loading, setLoading] = useState(true);
37
38
  const [error, setError] = useState<string | null>(null);
38
39
  const [entries, setEntries] = useState<SyncHistoryEntry[]>([]);
40
+ /** 重试计数(错误态点「重试」递增 → 重新加载) */
41
+ const [reloadKey, setReloadKey] = useState(0);
39
42
 
40
43
  useEffect(() => {
41
44
  let cancelled = false;
45
+ setLoading(true);
42
46
  (async () => {
43
47
  try {
44
48
  const data = await api.history();
45
49
  if (!cancelled) {
46
50
  setEntries(data.entries);
51
+ setError(null);
47
52
  setLoading(false);
48
53
  }
49
54
  } catch (err) {
@@ -54,7 +59,7 @@ export function SyncHistoryView(props: SyncHistoryViewProps): ReactNode {
54
59
  }
55
60
  })();
56
61
  return () => { cancelled = true; };
57
- }, [api]);
62
+ }, [api, reloadKey]);
58
63
 
59
64
  const rows = useMemo(() => projectSyncHistoryEntries(entries), [entries]);
60
65
  // 快照类条目(兼容旧投影;仅统计展示)
@@ -71,7 +76,16 @@ export function SyncHistoryView(props: SyncHistoryViewProps): ReactNode {
71
76
  );
72
77
 
73
78
  if (loading) return <Spinner label={t('common.loading')} />;
74
- if (error) return <Card><span className="error">{error}</span></Card>;
79
+ if (error) {
80
+ return (
81
+ <ErrorBanner
82
+ error={error}
83
+ onRetry={() => { setReloadKey((k) => k + 1) }}
84
+ retrying={loading}
85
+ t={api.t}
86
+ />
87
+ );
88
+ }
75
89
  if (rows.length === 0) {
76
90
  return (
77
91
  <Card>
@@ -94,6 +94,9 @@ export interface RestoreOptions {
94
94
  pluginUninstaller?: (
95
95
  name: string, profileDir: string, profile: string,
96
96
  ) => Promise<{ ok: boolean; message?: string }>;
97
+ /** 每项动作执行回调(宿主路由埋点:更新 RunRegistry 进度;index/1-based、total=计划动作数、
98
+ * detail=动作描述。dry-run/planRestore 不触发) */
99
+ onAction?: (info: { index: number; total: number; detail: string }) => void;
97
100
  /** 消息翻译器(缺省 zh;宿主按 DSH 应用语言注入) */
98
101
  msg?: MsgFunc;
99
102
  }
@@ -494,7 +497,11 @@ export async function restore(opts: RestoreOptions): Promise<RestoreReport> {
494
497
  };
495
498
 
496
499
  let seq = 0;
500
+ const total = plan.actions.length;
501
+ let actionIndex = 0;
497
502
  for (const action of plan.actions) {
503
+ actionIndex += 1;
504
+ opts.onAction?.({ index: actionIndex, total, detail: action.description });
498
505
  try {
499
506
  switch (action.kind) {
500
507
  case 'hostFileRestore': {
@@ -118,6 +118,46 @@ test('appendLog: 日志行数封顶 MAX_RUN_LOG_LINES(超限截断保留最新
118
118
  assert.equal(state?.log[state!.log.length - 1], `line-${MAX_RUN_LOG_LINES + 9}`)
119
119
  })
120
120
 
121
+ test('appendLog: 不可变追加——每次 append 换新数组引用(React memo 感知新行;封顶后长度恒定但引用仍变)', () => {
122
+ const reg = new RunRegistry()
123
+ const run = reg.register('import')
124
+ const initial = reg.get(run.runId)!.log
125
+ reg.appendLog(run.runId, 'line-1')
126
+ const second = reg.get(run.runId)!.log
127
+ assert.notEqual(second, initial, 'append 必须生成新数组引用(不得原地 push)')
128
+ assert.deepEqual(second, ['line-1'])
129
+
130
+ // 500 行封顶后:长度恒定(memo 按长度比较会漏),但每次 append 引用必变
131
+ const capRun = reg.register('export')
132
+ const refs = new Set<unknown[]>()
133
+ for (let i = 0; i < MAX_RUN_LOG_LINES + 5; i++) {
134
+ reg.appendLog(capRun.runId, `line-${i}`)
135
+ refs.add(reg.get(capRun.runId)!.log)
136
+ }
137
+ assert.equal(reg.get(capRun.runId)!.log.length, MAX_RUN_LOG_LINES)
138
+ assert.equal(refs.size, MAX_RUN_LOG_LINES + 5, '每次 append 都是新引用(含封顶后)——前端以引用比较不会漏渲染')
139
+ })
140
+
141
+ test('register: 快照恢复(restore)同 kind 进行中拒绝(P1-1 并发恢复防护)', () => {
142
+ const reg = new RunRegistry()
143
+ const first = reg.register('restore')
144
+ assert.throws(
145
+ () => reg.register('restore'),
146
+ (err: unknown) => {
147
+ assert.ok(err instanceof RunConflictError)
148
+ assert.equal((err as RunConflictError).runId, first.runId)
149
+ return true
150
+ },
151
+ )
152
+ // 恢复中允许导出(不同 kind 互不阻塞)
153
+ const exp = reg.register('export')
154
+ assert.equal(exp.kind, 'export')
155
+ // 完成后的 restore 不阻塞新 restore
156
+ reg.finish(first.runId, {})
157
+ const second = reg.register('restore')
158
+ assert.notEqual(second.runId, first.runId)
159
+ })
160
+
121
161
  test('finish/fail: 状态与 result/error 落账', () => {
122
162
  const reg = new RunRegistry()
123
163
  const done = reg.register('export')
@@ -21,8 +21,8 @@ import { randomBytes } from 'node:crypto'
21
21
  import { zhMsg } from './messages.ts'
22
22
  import type { MsgFunc } from './messages.ts'
23
23
 
24
- /** run 类型:导出 / 导入 / 自动同步 / 一键同步逐项应用 / 定时备份。 */
25
- export type RunKind = 'export' | 'import' | 'autosync' | 'sync-apply' | 'backup-schedule'
24
+ /** run 类型:导出 / 导入 / 自动同步 / 一键同步逐项应用 / 定时备份 / 快照恢复。 */
25
+ export type RunKind = 'export' | 'import' | 'autosync' | 'sync-apply' | 'backup-schedule' | 'restore'
26
26
 
27
27
  /** run 状态:进行中 / 完成 / 失败。 */
28
28
  export type RunStatus = 'running' | 'done' | 'failed'
@@ -71,7 +71,9 @@ export class RunConflictError extends Error {
71
71
  ? '一键同步已在进行中'
72
72
  : kind === 'backup-schedule'
73
73
  ? '定时备份已在进行中'
74
- : msg('run.conflict.import', { runId }),
74
+ : kind === 'restore'
75
+ ? '快照恢复已在进行中(请等待当前恢复完成)'
76
+ : msg('run.conflict.import', { runId }),
75
77
  )
76
78
  this.name = 'RunConflictError'
77
79
  this.runId = runId
@@ -142,17 +144,22 @@ export class RunRegistry {
142
144
  return { ...run }
143
145
  }
144
146
 
145
- /** 追加一行执行日志(导入逐计划项操作 / 子进程命令);run 不存在返回 undefined,
147
+ /**
148
+ * 追加一行执行日志(导入逐计划项操作 / 子进程命令);run 不存在返回 undefined,
146
149
  * 已完成/失败后的晚到追加被忽略(防御异步回调竞态)。行数封顶 MAX_RUN_LOG_LINES,
147
- * 超限时截断保留最新行。 */
150
+ * 超限时截断保留最新行。
151
+ *
152
+ * **不可变写入(性能正确性关键)**:每次 append 生成新数组(push + splice 会原地
153
+ * mutation —— /progress 轮询返回的 RunState.log 与浏览器侧 store 共享同一引用,
154
+ * 行数封顶后长度恒定,React memo 按引用/长度比较将无法感知新行,日志面板冻结)。
155
+ * 调用方(mapRunProgress → ImportLogPanel)据此以「数组引用变化」判断有新输出。
156
+ */
148
157
  appendLog(runId: string, line: string): RunState | undefined {
149
158
  const run = this.runs.get(runId)
150
159
  if (run === undefined) return undefined
151
160
  if (run.status !== 'running') return { ...run }
152
- run.log.push(line)
153
- if (run.log.length > MAX_RUN_LOG_LINES) {
154
- run.log.splice(0, run.log.length - MAX_RUN_LOG_LINES)
155
- }
161
+ // 不可变追加:保留最新 MAX_RUN_LOG_LINES 行(slice(-(MAX-1)) 在长度不足时取全量)
162
+ run.log = [...run.log.slice(-(MAX_RUN_LOG_LINES - 1)), line]
156
163
  run.updatedAt = this.now()
157
164
  return { ...run }
158
165
  }