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
@@ -80,6 +80,7 @@ export const zh = {
80
80
  'import.importing': '正在导入…(插件安装较多时可能需要几分钟,请勿关闭页面)',
81
81
  'import.log.title': '导入日志',
82
82
  'import.log.empty': '等待命令输出…',
83
+ 'import.log.newOutput': '↓ 新输出',
83
84
  'import.skipCurrent': '跳过当前插件',
84
85
  'import.skipPending': '正在跳过…其余项将继续导入',
85
86
  'import.retrySkipped': '重试失败/跳过的项 ({count})',
@@ -126,6 +127,7 @@ export const zh = {
126
127
  'snapshots.noActions': '该快照无可用恢复动作(或全部跳过)。',
127
128
  'snapshots.execute': '执行恢复',
128
129
  'snapshots.executing': '恢复执行中…',
130
+ 'snapshots.confirmTitle': '确认恢复',
129
131
  'snapshots.confirmRestore': '确认执行恢复?当前文件会先备份到快照的 pre-restore 目录,插件通过官方 dsh plugin remove 卸载。',
130
132
  'snapshots.reportTitle': '恢复报告',
131
133
  'snapshots.restored': '已还原',
@@ -133,6 +135,27 @@ export const zh = {
133
135
  'snapshots.manualHints': '需人工处理',
134
136
  'snapshots.failed': '失败',
135
137
  'snapshots.skipped': '跳过',
138
+ // —— 定时全量备份(快照 tab)——
139
+ 'backupSchedule.title': '定时全量备份',
140
+ 'backupSchedule.hint': '按固定间隔在后台自动导出全量备份 ZIP(恒不含 secret、不加密;要加密请手动导出)。配置存 sync/backup-schedule.json,随 self 分区备份 / 远程同步迁移。',
141
+ 'backupSchedule.enabled': '启用定时备份',
142
+ 'backupSchedule.enabledHint': '开启后 DSH 在后台按间隔自动备份;每次启动时立即执行一次(距上次运行不足 1 小时则跳过启动触发)。',
143
+ 'backupSchedule.interval': '备份间隔',
144
+ 'backupSchedule.interval.6h': '每 6 小时',
145
+ 'backupSchedule.interval.12h': '每 12 小时',
146
+ 'backupSchedule.interval.24h': '每 24 小时',
147
+ 'backupSchedule.interval.7d': '每 7 天',
148
+ 'backupSchedule.save': '保存设置',
149
+ 'backupSchedule.saved': '设置已保存',
150
+ 'backupSchedule.runNow': '立即备份',
151
+ 'backupSchedule.running': '备份中…',
152
+ 'backupSchedule.lastRun': '上次运行',
153
+ 'backupSchedule.never': '从未运行',
154
+ 'backupSchedule.status.success': '成功',
155
+ 'backupSchedule.status.skipped': '已跳过',
156
+ 'backupSchedule.status.failed': '失败',
157
+ 'backupSchedule.loading': '加载定时备份设置…',
158
+ 'backupSchedule.error': '定时备份设置加载失败',
136
159
  // —— 报告 ——
137
160
  'report.export.title': '导出完成',
138
161
  'report.import.title': '导入结果',
@@ -235,6 +258,7 @@ export const en: Record<keyof typeof zh, string> = {
235
258
  'import.importing': 'Importing… (installing many plugins may take a few minutes; keep this page open)',
236
259
  'import.log.title': 'Import Log',
237
260
  'import.log.empty': 'Waiting for command output…',
261
+ 'import.log.newOutput': '↓ New output',
238
262
  'import.skipCurrent': 'Skip current plugin',
239
263
  'import.skipPending': 'Skipping… the rest of the import continues',
240
264
  'import.retrySkipped': 'Retry failed/skipped items ({count})',
@@ -280,6 +304,7 @@ export const en: Record<keyof typeof zh, string> = {
280
304
  'snapshots.noActions': 'No restorable actions for this snapshot (or all skipped).',
281
305
  'snapshots.execute': 'Restore now',
282
306
  'snapshots.executing': 'Restoring…',
307
+ 'snapshots.confirmTitle': 'Confirm restore',
283
308
  'snapshots.confirmRestore': 'Confirm restore? Current files are backed up to the snapshot\'s pre-restore folder first; plugins are removed via the official dsh plugin remove.',
284
309
  'snapshots.reportTitle': 'Restore Report',
285
310
  'snapshots.restored': 'Restored',
@@ -287,6 +312,27 @@ export const en: Record<keyof typeof zh, string> = {
287
312
  'snapshots.manualHints': 'Manual action needed',
288
313
  'snapshots.failed': 'Failed',
289
314
  'snapshots.skipped': 'Skipped',
315
+ // —— Scheduled full backups (snapshots tab) ——
316
+ 'backupSchedule.title': 'Scheduled Full Backups',
317
+ 'backupSchedule.hint': 'Automatically export a full backup ZIP in the background on a fixed cadence (secrets are never included, never encrypted — use manual export for encryption). Config lives in sync/backup-schedule.json and migrates with the self section.',
318
+ 'backupSchedule.enabled': 'Enable scheduled backups',
319
+ 'backupSchedule.enabledHint': 'When on, DSH backs up automatically on the cadence below; one run fires on every startup (skipped if less than 1h since the last run).',
320
+ 'backupSchedule.interval': 'Backup interval',
321
+ 'backupSchedule.interval.6h': 'Every 6 hours',
322
+ 'backupSchedule.interval.12h': 'Every 12 hours',
323
+ 'backupSchedule.interval.24h': 'Every 24 hours',
324
+ 'backupSchedule.interval.7d': 'Every 7 days',
325
+ 'backupSchedule.save': 'Save settings',
326
+ 'backupSchedule.saved': 'Settings saved',
327
+ 'backupSchedule.runNow': 'Back up now',
328
+ 'backupSchedule.running': 'Backing up…',
329
+ 'backupSchedule.lastRun': 'Last run',
330
+ 'backupSchedule.never': 'Never run',
331
+ 'backupSchedule.status.success': 'Success',
332
+ 'backupSchedule.status.skipped': 'Skipped',
333
+ 'backupSchedule.status.failed': 'Failed',
334
+ 'backupSchedule.loading': 'Loading scheduled backup settings…',
335
+ 'backupSchedule.error': 'Failed to load scheduled backup settings',
290
336
  'report.export.title': 'Export Complete',
291
337
  'report.import.title': 'Import Result',
292
338
  'report.rollback.title': 'Rollback Report',
@@ -273,14 +273,18 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
273
273
  }
274
274
  }
275
275
 
276
- /** 下载 + 校验单条目 → dry-run 详情预览(零写入)。自托管条目(带 repo)必须携带来源仓库。 */
276
+ /** 下载 + 校验单条目 → dry-run 详情预览(零写入)。自托管条目(带 repo)必须携带来源仓库。
277
+ * 竞态守卫:下载期间用户可发起另一条目下载(downloadingId 已变),晚到响应一律丢弃,
278
+ * 防止「弹窗标题是 B、详情是 A」的串扰。 */
277
279
  const runDownload = async (item: MarketListItem): Promise<void> => {
278
280
  patch({ downloadingId: item.id, error: null })
279
281
  try {
280
282
  const detail = await api.download(item.id, item.repo)
283
+ if (stateRef.current.downloadingId !== item.id) return
281
284
  // 初始化逐分区批准表:低风险默认勾选,高风险默认不勾选(须逐项显式批准)
282
285
  patch({ downloadingId: null, detail, approvals: defaultApprovals(detail.plan) })
283
286
  } catch (err) {
287
+ if (stateRef.current.downloadingId !== item.id) return
284
288
  patch({ downloadingId: null, error: err instanceof Error ? err.message : String(err) })
285
289
  }
286
290
  }
@@ -378,10 +382,10 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
378
382
  <Card>
379
383
  <span className={css.groupLabel}>{t('config.title')}</span>
380
384
  <div className={css.actionRow}>
381
- <Button variant="primary" disabled={state.refreshing || state.importing} onClick={() => { void runRefresh() }}>
385
+ <Button variant="primary" disabled={state.refreshing || state.importing || state.browsing} onClick={() => { void runRefresh() }}>
382
386
  {state.refreshing ? <Spinner label={t('config.refreshing')} /> : t('config.refresh')}
383
387
  </Button>
384
- <Button disabled={state.browsing} onClick={() => { void runBrowse() }}>
388
+ <Button disabled={state.browsing || state.refreshing || state.importing} onClick={() => { void runBrowse() }}>
385
389
  {state.browsing ? <Spinner label={t('list.loading')} /> : t('list.browse')}
386
390
  </Button>
387
391
  </div>
@@ -598,7 +602,8 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
598
602
  )}
599
603
  </div>
600
604
  </div>
601
- <Button disabled={state.downloadingId === it.id} onClick={() => { openDownload(it) }}>
605
+ {/* 下载按钮:任一下载进行中全部禁用(防止并发下载详情串扰 + 防重复点击) */}
606
+ <Button disabled={state.downloadingId !== null} onClick={() => { openDownload(it) }}>
602
607
  {state.downloadingId === it.id ? <Spinner label={t('common.loading')} /> : t('list.download')}
603
608
  </Button>
604
609
  </div>
@@ -556,18 +556,18 @@ export function MyConfigsView({
556
556
 
557
557
  /* ------------------------------------------------ 装回本地(复用市场下载 + 逐分区批准链路) */
558
558
 
559
- /** 装回本地(复用市场下载 + 逐分区批准链路;条目内容在用户自己的公开仓库,必须带 repo 来源) */
559
+ /** 装回本地(复用市场下载 + 逐分区批准链路;条目内容在用户自己的公开仓库,必须带 repo 来源)
560
+ * 竞态守卫:下载期间用户可切换装回另一条目(install.itemId 已变),晚到响应一律丢弃,
561
+ * 防止「会话标题是 B、详情是 A」的串扰。 */
560
562
  const runDownload = async (entry: MyItemEntry): Promise<void> => {
561
563
  commitInstall({ itemId: entry.id, detail: null, approvals: {}, importing: false, importResult: null, error: null })
562
564
  try {
563
565
  const detail = await api.download(entry.id, entry.repoUrl)
564
- if (installRef.current !== null) {
565
- commitInstall({ ...installRef.current, detail, approvals: defaultApprovals(detail.plan) })
566
- }
566
+ if (installRef.current === null || installRef.current.itemId !== entry.id) return
567
+ commitInstall({ ...installRef.current, detail, approvals: defaultApprovals(detail.plan) })
567
568
  } catch (err) {
568
- if (installRef.current !== null) {
569
- patchInstall({ error: err instanceof Error ? err.message : String(err) })
570
- }
569
+ if (installRef.current === null || installRef.current.itemId !== entry.id) return
570
+ patchInstall({ error: err instanceof Error ? err.message : String(err) })
571
571
  }
572
572
  }
573
573
 
@@ -956,7 +956,7 @@ export function MyConfigsView({
956
956
  <div className={css.rowActions}>
957
957
  <Button onClick={() => { startUpdate(entry); openUpload() }}>{t('myconfigs.item.update')}</Button>
958
958
  <Button
959
- disabled={install !== null && install.itemId === view.id && install.detail === null}
959
+ disabled={install !== null && install.detail === null}
960
960
  onClick={() => { openInstall(entry) }}
961
961
  >
962
962
  {t('myconfigs.item.install')}
@@ -19,7 +19,8 @@ import assert from 'node:assert/strict'
19
19
 
20
20
  import { RunStore, STATE_KEY, type StoreStorage } from './run-store.ts'
21
21
  import type { RunProgress } from './common/progress-view.ts'
22
- import type { RunState } from '../core/run-registry.ts'
22
+ import { MAX_RUN_LOG_LINES, type RunState } from '../core/run-registry.ts'
23
+ import type { RestoreReport } from '../core/restore.ts'
23
24
  import type { ImportDecisions } from '../core/types.ts'
24
25
  import type { ConfigManagerApi } from './api.ts'
25
26
  import {
@@ -503,6 +504,9 @@ test('m3-poll: watchRunning 经 /runs 发现进行中 run 并轮询 /progress
503
504
  // 发现后转入 /progress 轮询且回填过真实进度(内部计数 + 当前项名)
504
505
  assert.ok(progressCalls >= 2, '发现 runId 后轮询 /progress')
505
506
  assert.equal(imp.running, false)
507
+ // 发现活跃 run 时 runId 同步写入 store(fresh run 期间「跳过当前插件」有正确目标,
508
+ // 不会用上一次导入的陈旧 runId —— run-store patchProgress 同步 runId)
509
+ assert.equal(imp.runId, RUN_ID, 'watchRunning 发现 run 后 store.runId 立即填充')
506
510
  })
507
511
 
508
512
  test('m3-poll: watchRunning 轮询回填分区/内部计数进度(ProgressBar 徽章数据源)', async () => {
@@ -565,6 +569,48 @@ test('m3-poll: stopRunWatch 停止发现阶段轮询(视图请求结束后)'
565
569
  store.stopRunWatch('export')
566
570
  })
567
571
 
572
+ test('m3-poll: 导入日志超过 MAX_RUN_LOG_LINES 后 store progress.log 仍持续更新(P1-3 封顶不冻结)', async () => {
573
+ // 模拟导入日志已满 500 行封顶:后续轮询长度恒为 500,但每次 append 都换新数组引用
574
+ // (run-registry appendLog 不可变写入)。store 的 progress.log 必须持续拿到新引用,
575
+ // 否则 ImportLogPanel 的 memo 按引用比较会判定「无变化」→ 日志面板冻结。
576
+ let log: string[] = []
577
+ for (let i = 0; i < MAX_RUN_LOG_LINES; i++) log = [...log, `line-${i}`]
578
+ const progressResponses: RunState[] = []
579
+ for (let i = 0; i < 4; i++) {
580
+ log = [...log.slice(-(MAX_RUN_LOG_LINES - 1)), `cap-${i}`]
581
+ progressResponses.push(runningRun('import', { section: 'plugins', item: i + 1, itemTotal: 8, detail: 'plugin:pkg', log }))
582
+ }
583
+ progressResponses.push({
584
+ runId: RUN_ID, kind: 'import', status: 'done',
585
+ section: null, sectionTotal: null, item: null, itemTotal: null, detail: null, log: [],
586
+ result: makeImportResult(), createdAt: 1, updatedAt: 3,
587
+ })
588
+ let progressCalls = 0
589
+ const api = makeApi({
590
+ runs: async () => [runningRun('import')],
591
+ progress: async () => progressResponses[Math.min(progressCalls++, progressResponses.length - 1)]!,
592
+ })
593
+ const store = new RunStore({ storage: null, pollIntervalMs: 5 })
594
+ await store.resume(api)
595
+ // 订阅捕获轮询期间的 progress.log 引用
596
+ const seenRefs = new Set<string[]>()
597
+ const seenTails = new Set<string>()
598
+ const unsub = store.subscribe(() => {
599
+ const p = store.getSnapshot().import.progress
600
+ const log = p?.log ?? []
601
+ if (log.length === MAX_RUN_LOG_LINES) {
602
+ seenRefs.add(log)
603
+ seenTails.add(log[log.length - 1]!)
604
+ }
605
+ })
606
+ await sleep(150)
607
+ unsub()
608
+ assert.equal(store.getSnapshot().import.step, 'result', '轮询收敛到完成')
609
+ assert.ok(seenRefs.size >= 3, `封顶后轮询仍拿到新数组引用(实际 ${seenRefs.size} 个不同引用)`)
610
+ assert.ok(seenTails.has('cap-3'), '最新行 cap-3 已到达 store(日志没有冻结在旧行)')
611
+ assert.ok(!seenTails.has('line-499'), '截断保留最新行(cap-* 覆盖 line-* 尾部)')
612
+ })
613
+
568
614
  test('m3-poll: 同一 kind 重复 watchRunning 不重复启动', async () => {
569
615
  let runsCalls = 0
570
616
  const api = makeApi({
@@ -873,3 +919,68 @@ test('低频面板: 旧版顶层 syncMode 载荷 → 迁移为 git 通道的 byC
873
919
  assert.equal(st.sync.byChannel.git.selectedSnapshotId, 'snap-old')
874
920
  assert.equal(st.sync.byChannel.webdav.syncMode, 'default', 'webdav 通道保持缺省')
875
921
  })
922
+
923
+ /* -------------------------------------------------- restore(P1-1)权威状态 */
924
+
925
+ test('低频面板: 快照恢复 running 为瞬态——不写入 sessionStorage、刷新后复位(以宿主 /runs 为权威)', () => {
926
+ const { storage, raw } = makeStorage()
927
+ const first = new RunStore({ storage })
928
+ first.patch({ panel: 'snapshots', snapshots: { selectedId: 'snap-1', running: true } })
929
+ const persisted = JSON.parse(raw() ?? '{}') as { snapshots?: { running?: boolean } }
930
+ assert.equal(persisted.snapshots?.running, false, 'running 恒不落盘(白名单剔除)')
931
+
932
+ // 即便旧载荷携带 running=true,applyPersisted 也硬性归零——绝不把浏览器陈旧状态
933
+ // 当成「恢复仍在执行」的依据(宿主 /runs 是唯一权威,resume() 负责重新置位)
934
+ storage.setItem(STATE_KEY, JSON.stringify({
935
+ ...persisted,
936
+ snapshots: { ...persisted.snapshots, running: true },
937
+ }))
938
+ const second = new RunStore({ storage })
939
+ assert.equal(second.getSnapshot().snapshots.running, false, '刷新后 running 复位(不读存储)')
940
+ assert.equal(second.getSnapshot().snapshots.selectedId, 'snap-1', '非瞬态字段仍恢复')
941
+ })
942
+
943
+ test('m2-resume: 活跃 restore run 经 /runs 恢复 running 并轮询到完成回填报告(宿主为权威)', async () => {
944
+ const report: RestoreReport = {
945
+ snapshotId: 'snap-1',
946
+ restored: ['settings.yaml'],
947
+ removedPlugins: ['@scope/pkg'],
948
+ manualHints: [],
949
+ failed: [],
950
+ skipped: [],
951
+ }
952
+ const progressResponses: RunState[] = [
953
+ runningRun('restore', { section: 'restore', item: 1, itemTotal: 3, detail: '整文件还原 settings.yaml' }),
954
+ {
955
+ runId: RUN_ID, kind: 'restore', status: 'done',
956
+ section: null, sectionTotal: null, item: null, itemTotal: null, detail: null, log: [],
957
+ result: report, createdAt: 1, updatedAt: 3,
958
+ },
959
+ ]
960
+ let progressCalls = 0
961
+ const api = makeApi({
962
+ runs: async () => [runningRun('restore')],
963
+ progress: async () => progressResponses[Math.min(progressCalls++, progressResponses.length - 1)]!,
964
+ })
965
+ const store = new RunStore({ storage: null, pollIntervalMs: 5 })
966
+ const resumed = await store.resume(api)
967
+ assert.equal(resumed, true, '发现活跃 restore run')
968
+ assert.equal(store.getSnapshot().snapshots.running, true, 'running 镜像置位(宿主 /runs 为权威)')
969
+
970
+ await sleep(80)
971
+ const snap = store.getSnapshot().snapshots
972
+ assert.equal(snap.running, false, '完成后 running 复位')
973
+ assert.deepEqual(snap.report?.restored, ['settings.yaml'], '恢复报告回填 store')
974
+ assert.equal(snap.actionError, null)
975
+ })
976
+
977
+ test('m2-resume: store 镜像显示恢复中但 host 无活跃 restore run → 复位并提示不可恢复', async () => {
978
+ const api = makeApi({ runs: async () => [] })
979
+ const store = new RunStore({ storage: null })
980
+ store.patch({ snapshots: { running: true } })
981
+ const resumed = await store.resume(api)
982
+ assert.equal(resumed, false)
983
+ const snap = store.getSnapshot().snapshots
984
+ assert.equal(snap.running, false, '无宿主 run 时 running 复位(不残留 UI 假状态)')
985
+ assert.ok(snap.actionError !== null, '如实提示结果不可恢复')
986
+ })
@@ -38,6 +38,7 @@ import { renderExportReport } from '../ui/report.ts'
38
38
  import type { FlowPhase } from '../ui/flow.ts'
39
39
  import type { ImportStep } from '../ui/types.ts'
40
40
  import type { RunProgress } from './common/progress-view.ts'
41
+ import type { BackupScheduleDraft } from '../ui/backup-schedule.ts'
41
42
  import type {
42
43
  GlobalConflictStrategy, ImportAnalysis, ImportDecisions, ImportPlan, ImportResult,
43
44
  ItemResolution, PathMapping,
@@ -189,13 +190,20 @@ export interface MarketStoreSlice {
189
190
  myConfirmDeleteId: string | null
190
191
  }
191
192
 
192
- /** 快照恢复面板的切片(无敏感字段;快照列表本身可随时重载,不持久化)。 */
193
+ /** 快照恢复面板的切片(无敏感字段;快照列表本身可随时重载,不持久化)。
194
+ * running 为「内存切片瞬态」:切 tab 由模块级单例保留、刷新时被 toPersistedState
195
+ * 白名单剔除 —— 恢复是否仍在执行以宿主 RunRegistry(/runs + /progress)为权威,
196
+ * 刷新后经 resume() 重新发现;浏览器持久化绝不作为 destructive operation 的状态源。 */
193
197
  export interface SnapshotsStoreSlice {
194
198
  selectedId: string | null
195
199
  plan: RestorePlan | null
200
+ /** 真实恢复执行中(瞬态;宿主 /runs 是权威来源,本字段只是镜像) */
201
+ running: boolean
196
202
  report: RestoreReport | null
197
203
  actionError: string | null
198
204
  error: string | null
205
+ /** 定时备份设置草稿(未保存修改切 tab / 刷新保留;null = 无草稿,以宿主配置为准) */
206
+ backupDraft: BackupScheduleDraft | null
199
207
  }
200
208
 
201
209
  /* ------------------------------------------------- 持久化(非敏感)状态形状 */
@@ -422,9 +430,11 @@ function defaultSnapshotsState(): SnapshotsStoreSlice {
422
430
  return {
423
431
  selectedId: null,
424
432
  plan: null,
433
+ running: false,
425
434
  report: null,
426
435
  actionError: null,
427
436
  error: null,
437
+ backupDraft: null,
428
438
  }
429
439
  }
430
440
 
@@ -501,9 +511,11 @@ export function toSnapshotsStoreSlice(s: SnapshotsStoreSlice): SnapshotsStoreSli
501
511
  return {
502
512
  selectedId: s.selectedId,
503
513
  plan: s.plan,
514
+ running: s.running,
504
515
  report: s.report,
505
516
  actionError: s.actionError,
506
517
  error: s.error,
518
+ backupDraft: s.backupDraft,
507
519
  }
508
520
  }
509
521
 
@@ -555,7 +567,13 @@ export function toPersistedState(state: StoreState): PersistedState {
555
567
  },
556
568
  },
557
569
  market: state.market,
558
- snapshots: state.snapshots,
570
+ snapshots: {
571
+ ...state.snapshots,
572
+ // 快照恢复 running 为内存切片瞬态:不落盘 —— 恢复是否仍在执行以宿主
573
+ // RunRegistry(/runs + /progress)为权威,刷新后由 resume() 重新发现;
574
+ // 持久化「running=true」会把浏览器陈旧状态误当成宿主真实状态(P1-1 原则)。
575
+ running: false,
576
+ },
559
577
  }
560
578
  }
561
579
 
@@ -896,7 +914,13 @@ export class RunStore {
896
914
  }
897
915
  })(),
898
916
  market: { ...defaultMarketState(), ...parsed.market },
899
- snapshots: { ...defaultSnapshotsState(), ...parsed.snapshots },
917
+ snapshots: {
918
+ ...defaultSnapshotsState(),
919
+ ...parsed.snapshots,
920
+ // 硬性:恢复执行中为瞬态,绝不从存储恢复(即使旧载荷携带 running=true 也清空)——
921
+ // 是否仍有恢复在执行以宿主 /runs 为权威,resume() 会重新发现并置 true
922
+ running: false,
923
+ },
900
924
  }
901
925
  // 安全兜底:整体加密备份容器已解锁标志绝不从存储恢复(archiveUnlocked 必为 false)→
902
926
  // 刷新后只要仍标记为加密容器且已越过 decrypt-archive 阶段,就强制退回重新解锁。
@@ -1032,6 +1056,25 @@ export class RunStore {
1032
1056
  },
1033
1057
  })
1034
1058
  }
1059
+
1060
+ // 快照恢复(P1-1):宿主 /runs 是「恢复是否仍在执行」的权威来源。
1061
+ // 刷新/重开面板后若存在活跃 restore run → 恢复 running 并轮询 /progress
1062
+ // (结果经 applySettled 回填 report);持久化的 running 恒为 false(白名单
1063
+ // 剔除),绝不把浏览器陈旧状态当成宿主真实状态。
1064
+ const restoreRun = active.find((r) => r.kind === 'restore')
1065
+ if (restoreRun !== undefined) {
1066
+ resumed = true
1067
+ this.patch({ snapshots: { running: true, actionError: null } })
1068
+ this.pollRun(restoreRun.runId, 'restore')
1069
+ } else if (this.state.snapshots.running) {
1070
+ // 面板镜像显示恢复中,但 host 无活跃 restore run:请求已结束且响应丢失
1071
+ this.patch({
1072
+ snapshots: {
1073
+ running: false,
1074
+ actionError: '上次恢复任务已结束但结果无法恢复,请重新执行',
1075
+ },
1076
+ })
1077
+ }
1035
1078
  return resumed
1036
1079
  }
1037
1080
 
@@ -1137,7 +1180,13 @@ export class RunStore {
1137
1180
  private patchProgress(kind: RunKind, state: RunState): void {
1138
1181
  const event = mapRunProgress(kind, state)
1139
1182
  if (kind === 'export') this.patch({ export: { progress: event } })
1140
- else this.patch({ import: { progress: event } })
1183
+ else if (kind === 'restore') this.patch({ snapshots: { running: true } })
1184
+ else {
1185
+ // 同步写入 runId:watchRunning 发现活跃 import run 时立即填充——
1186
+ // 否则 fresh run(非刷新恢复)期间 store.runId 仍是上一次导入的陈旧值
1187
+ // (/execute 响应在整段导入完成后才带 runId),「跳过当前插件」会打到旧 run。
1188
+ this.patch({ import: { progress: event, runId: state.runId } })
1189
+ }
1141
1190
  }
1142
1191
 
1143
1192
  /** run 完成/失败:把 RunState.result 回填 store(并镜像回控制器)。 */
@@ -1168,6 +1217,22 @@ export class RunStore {
1168
1217
  }
1169
1218
  return
1170
1219
  }
1220
+ // restore(P1-1):完成/失败回填快照面板切片(running 复位 + report/actionError)。
1221
+ // 刷新恢复路径与面板本地状态收敛:applySettled 写入 store,面板挂载时经
1222
+ // initFromStore 读取(切 tab 后回来也能看到结果)。
1223
+ if (kind === 'restore') {
1224
+ const result = state.result as RestoreReport | undefined
1225
+ if (state.status === 'done' && result !== undefined && typeof result === 'object') {
1226
+ this.patch({
1227
+ snapshots: { running: false, report: result, actionError: null },
1228
+ })
1229
+ } else {
1230
+ this.patch({
1231
+ snapshots: { running: false, actionError: state.error ?? '恢复失败(结果不可用)' },
1232
+ })
1233
+ }
1234
+ return
1235
+ }
1171
1236
  // import
1172
1237
  const result = state.result as ImportResult | undefined
1173
1238
  if (state.status === 'done' && result !== undefined && typeof result === 'object') {
@@ -1221,6 +1286,15 @@ export class RunStore {
1221
1286
  error: '任务已结束或超过保留期,进度不可恢复',
1222
1287
  },
1223
1288
  })
1289
+ } else if (kind === 'restore') {
1290
+ // 恢复 run 被清理/丢失:复位 running(宿主侧恢复动作可能仍在执行——
1291
+ // 以 RunRegistry 为准,本提示只说明进度不可恢复,不臆断恢复已停止)
1292
+ this.patch({
1293
+ snapshots: {
1294
+ running: false,
1295
+ actionError: '恢复任务已结束或超过保留期,进度不可恢复',
1296
+ },
1297
+ })
1224
1298
  } else {
1225
1299
  this.importWizardInst?.reset()
1226
1300
  this.patch({