dsh-config-manager 0.1.12 → 0.1.13

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 (107) hide show
  1. package/README.md +30 -0
  2. package/README.zh-CN.md +30 -0
  3. package/lib/adapters/test-helpers.d.ts +4 -2
  4. package/lib/adapters/test-helpers.js +11 -3
  5. package/lib/adapters/test-helpers.js.map +1 -1
  6. package/lib/cli/index.d.ts +44 -0
  7. package/lib/cli/index.js +230 -0
  8. package/lib/cli/index.js.map +1 -0
  9. package/lib/client.d.ts +260 -5
  10. package/lib/client.js +2519 -820
  11. package/lib/client.js.map +1 -1
  12. package/lib/core/analyzer.d.ts +18 -1
  13. package/lib/core/analyzer.js +29 -0
  14. package/lib/core/analyzer.js.map +1 -1
  15. package/lib/core/backup.d.ts +3 -1
  16. package/lib/core/backup.js +70 -0
  17. package/lib/core/backup.js.map +1 -1
  18. package/lib/core/exporter.d.ts +11 -0
  19. package/lib/core/exporter.js +11 -0
  20. package/lib/core/exporter.js.map +1 -1
  21. package/lib/core/importer.d.ts +3 -0
  22. package/lib/core/importer.js +1 -0
  23. package/lib/core/importer.js.map +1 -1
  24. package/lib/core/index.d.ts +1 -0
  25. package/lib/core/index.js +1 -0
  26. package/lib/core/index.js.map +1 -1
  27. package/lib/core/restore.d.ts +92 -0
  28. package/lib/core/restore.js +505 -0
  29. package/lib/core/restore.js.map +1 -0
  30. package/lib/core/run-registry.d.ts +65 -0
  31. package/lib/core/run-registry.js +120 -0
  32. package/lib/core/run-registry.js.map +1 -0
  33. package/lib/core/types.d.ts +20 -0
  34. package/lib/core/types.js.map +1 -1
  35. package/lib/index.d.ts +45 -0
  36. package/lib/index.js +442 -8
  37. package/lib/index.js.map +1 -1
  38. package/lib/sync/fs.d.ts +18 -0
  39. package/lib/sync/fs.js +47 -0
  40. package/lib/sync/fs.js.map +1 -0
  41. package/lib/sync/git/git-transport.d.ts +87 -0
  42. package/lib/sync/git/git-transport.js +333 -0
  43. package/lib/sync/git/git-transport.js.map +1 -0
  44. package/lib/sync/layout.d.ts +29 -0
  45. package/lib/sync/layout.js +166 -0
  46. package/lib/sync/layout.js.map +1 -0
  47. package/lib/sync/sync-config.d.ts +16 -0
  48. package/lib/sync/sync-config.js +67 -0
  49. package/lib/sync/sync-config.js.map +1 -0
  50. package/lib/sync/sync-engine.d.ts +96 -0
  51. package/lib/sync/sync-engine.js +236 -0
  52. package/lib/sync/sync-engine.js.map +1 -0
  53. package/lib/sync/sync-state.d.ts +31 -0
  54. package/lib/sync/sync-state.js +103 -0
  55. package/lib/sync/sync-state.js.map +1 -0
  56. package/lib/sync/transport.d.ts +50 -0
  57. package/lib/sync/transport.js +22 -0
  58. package/lib/sync/transport.js.map +1 -0
  59. package/package.json +4 -1
  60. package/src/adapters/test-helpers.ts +10 -3
  61. package/src/cli/index.ts +278 -0
  62. package/src/client/ConfigManagerSection.tsx +49 -14
  63. package/src/client/api.ts +53 -5
  64. package/src/client/client-types.ts +0 -6
  65. package/src/client/common/ProgressBar.tsx +31 -19
  66. package/src/client/common/progress-view-edge.test.ts +60 -0
  67. package/src/client/common/progress-view.test.ts +79 -0
  68. package/src/client/common/progress-view.ts +94 -0
  69. package/src/client/config-manager.module.css +99 -20
  70. package/src/client/export/ExportView.tsx +63 -49
  71. package/src/client/import/ImportWizardView.tsx +107 -66
  72. package/src/client/index.ts +22 -0
  73. package/src/client/locales.ts +53 -0
  74. package/src/client/run-store.test.ts +500 -0
  75. package/src/client/run-store.ts +772 -0
  76. package/src/client/snapshots/SnapshotsPanel.tsx +240 -0
  77. package/src/client/sync/SyncSettingsView.tsx +261 -0
  78. package/src/client/sync/sync-api.test.ts +117 -0
  79. package/src/client/sync/sync-api.ts +132 -0
  80. package/src/client/sync/sync-locales.ts +77 -0
  81. package/src/client/sync/sync-view.test.ts +211 -0
  82. package/src/client/sync/sync-view.ts +202 -0
  83. package/src/core/analyzer.ts +50 -1
  84. package/src/core/backup.ts +78 -2
  85. package/src/core/exporter.ts +21 -0
  86. package/src/core/importer.ts +4 -0
  87. package/src/core/index.ts +5 -0
  88. package/src/core/restore.ts +587 -0
  89. package/src/core/run-progress.test.ts +237 -0
  90. package/src/core/run-registry.test.ts +139 -0
  91. package/src/core/run-registry.ts +166 -0
  92. package/src/core/smoke.test.ts +5 -0
  93. package/src/core/types.ts +22 -0
  94. package/src/index.ts +476 -8
  95. package/src/security/security.test.ts +5 -0
  96. package/src/sync/fs.ts +46 -0
  97. package/src/sync/git/git-transport.test.ts +456 -0
  98. package/src/sync/git/git-transport.ts +390 -0
  99. package/src/sync/layout.test.ts +226 -0
  100. package/src/sync/layout.ts +183 -0
  101. package/src/sync/sync-config.ts +72 -0
  102. package/src/sync/sync-engine.test.ts +299 -0
  103. package/src/sync/sync-engine.ts +318 -0
  104. package/src/sync/sync-state.test.ts +118 -0
  105. package/src/sync/sync-state.ts +123 -0
  106. package/src/sync/transport.test.ts +110 -0
  107. package/src/sync/transport.ts +73 -0
@@ -0,0 +1,772 @@
1
+ /**
2
+ * m2: 模块级 run/UI store —— dsh-config-manager 浏览器半的状态中枢。
3
+ *
4
+ * 解决的问题(验收 m2-state / m2-refresh / m2-resume):
5
+ * - 切 tab / 关面板重开:模块级单例存活于当前 JS 上下文,ExportFlow /
6
+ * ImportWizard 控制器实例与 UI 状态**不重建**(m2-state);
7
+ * - 页面刷新:非敏感状态经 sessionStorage(键 dsh.cfgMgr.state.v1)
8
+ * 序列化/反序列化恢复(m2-refresh);敏感字段
9
+ * password / passwordConfirm / secretInputs **只存在内存**,序列化白名单
10
+ * 显式剔除 —— 刷新后自动清空,secrets 阶段要求重输;
11
+ * - 进行中 run:视图挂载时经 GET /runs 找回活跃 runId,轮询 GET /progress
12
+ * 直到完成/失败,把 RunState.result 回填到 store(m2-resume)。服务端在
13
+ * 刷新/关面板期间继续执行,本 store 只负责重新订阅进度。
14
+ *
15
+ * 控制器 rehydrate 说明:ImportWizard 没有公开的 hydrate 入口(m2 约束为只改
16
+ * src/client/、不动 src/ui/),刷新恢复需要把持久化的非敏感快照写回控制器
17
+ * 私有字段。writeWizardSnapshot() 是唯一的受控访问点:类型层 private 只是
18
+ * 编译期约束(运行时是普通属性),所有写入值都来自序列化白名单(已剔除
19
+ * 密码/密钥/secretInputs),且只在 load / hydrate / resume-settle 时调用。
20
+ *
21
+ * 安全约束:
22
+ * - toPersistedState() 用解构白名单剔除敏感字段,即使未来往 LiveState 加字段,
23
+ * 未显式放行也不会落入 sessionStorage;
24
+ * - runId 为 32-hex 不可猜标识,可安全持久化(/runs + /progress 的查询键)。
25
+ */
26
+ import { ConflictCollector } from '../ui/conflict-view.ts'
27
+ import { DEFAULT_CATEGORIES, ExportFlow } from '../ui/export-flow.ts'
28
+ import type { ExportRunResult } from '../ui/export-flow.ts'
29
+ import { ImportWizard } from '../ui/import-wizard.ts'
30
+ import { renderExportReport } from '../ui/report.ts'
31
+ import type { ImportStep } from '../ui/types.ts'
32
+ import type { RunProgress } from './common/progress-view.ts'
33
+ import type {
34
+ GlobalConflictStrategy, ImportAnalysis, ImportDecisions, ImportPlan, ImportResult,
35
+ ItemResolution, PathMapping,
36
+ } from '../core/types.ts'
37
+ import type { RunKind, RunState } from '../core/run-registry.ts'
38
+ import type { Manifest, SectionId } from '../schema/types.ts'
39
+ import type { ConfigManagerApi } from './api.ts'
40
+
41
+ /* ---------------------------------------------------------------- 基础类型 */
42
+
43
+ /** 主视图(ConfigManagerSection 的 tab 状态)。 */
44
+ export type MainView = 'export' | 'import'
45
+
46
+ /** 导出模式。 */
47
+ export type ExportMode = 'quick' | 'custom'
48
+
49
+ /** 存储抽象(浏览器 sessionStorage / 测试 mock / 无存储)。 */
50
+ export interface StoreStorage {
51
+ getItem(key: string): string | null
52
+ setItem(key: string, value: string): void
53
+ removeItem(key: string): void
54
+ }
55
+
56
+ /** sessionStorage 键。 */
57
+ export const STATE_KEY = 'dsh.cfgMgr.state.v1'
58
+
59
+ /** Custom 模式的初始勾选 = 推荐分区(可再调整) */
60
+ export function defaultCustomSelection(): SectionId[] {
61
+ return DEFAULT_CATEGORIES
62
+ .filter((c) => c.defaultIncluded)
63
+ .map((c) => c.id)
64
+ }
65
+
66
+ /* ------------------------------------------------- 持久化(非敏感)状态形状 */
67
+
68
+ /** 导出视图的持久化切片(不含 password/passwordConfirm)。 */
69
+ export interface PersistedExportState {
70
+ mode: ExportMode
71
+ selection: SectionId[]
72
+ includeSecrets: boolean
73
+ running: boolean
74
+ progress: RunProgress | null
75
+ result: ExportRunResult | null
76
+ /** 错误消息(已 redact 的文本;Error 对象不可 JSON 序列化,统一转字符串) */
77
+ error: string | null
78
+ downloaded: boolean
79
+ /** 最近一次导出 run id(/runs + /progress 重新订阅用;32-hex 不可猜) */
80
+ runId: string | null
81
+ }
82
+
83
+ /** 导入向导的持久化切片(不含 secretInputs)。 */
84
+ export interface PersistedImportState {
85
+ step: ImportStep
86
+ zipPath: string | null
87
+ analysis: ImportAnalysis | null
88
+ plan: ImportPlan | null
89
+ result: ImportResult | null
90
+ rollbackOnError: boolean
91
+ errors: string[]
92
+ /** UI 层流程阶段(wizard.step 之外的页面;见 src/ui/flow.ts) */
93
+ phase: FlowPhase
94
+ conflictStrategy: GlobalConflictStrategy
95
+ conflictResolutions: Record<string, ItemResolution>
96
+ pathMappings: PathMapping[]
97
+ uploading: boolean
98
+ running: boolean
99
+ progress: RunProgress | null
100
+ error: string | null
101
+ runId: string | null
102
+ }
103
+
104
+ /** 刷新恢复的顶层持久化状态(v1 结构版本)。 */
105
+ export interface PersistedState {
106
+ v: 1
107
+ view: MainView
108
+ export: PersistedExportState
109
+ import: PersistedImportState
110
+ }
111
+
112
+ /* --------------------------------------- 运行时状态(含仅内存的敏感字段) */
113
+
114
+ /** 导出运行时状态 = 持久化字段 + 仅内存的密码字段。 */
115
+ export interface ExportLiveState extends PersistedExportState {
116
+ /** 加密密码(仅内存,绝不写入 sessionStorage;刷新后清空) */
117
+ password: string
118
+ passwordConfirm: string
119
+ }
120
+
121
+ /** 导入运行时状态 = 持久化字段 + 仅内存的敏感/实例字段。 */
122
+ export interface ImportLiveState extends PersistedImportState {
123
+ /** 秘密补录值(仅内存,绝不写入 sessionStorage;刷新后清空、secrets 阶段重输) */
124
+ secretInputs: Record<string, string>
125
+ /** 冲突决策收集器实例(仅内存;刷新后由 plan + conflictResolutions 重建) */
126
+ conflictCollector: ConflictCollector | null
127
+ }
128
+
129
+ /** store 的完整运行时快照(getSnapshot 返回;含敏感字段,仅供组件读取)。 */
130
+ export interface StoreState {
131
+ v: 1
132
+ view: MainView
133
+ export: ExportLiveState
134
+ import: ImportLiveState
135
+ }
136
+
137
+ /** patch 的输入形状(浅合并对应切片)。 */
138
+ export interface StorePatch {
139
+ view?: MainView
140
+ export?: Partial<ExportLiveState>
141
+ import?: Partial<ImportLiveState>
142
+ }
143
+
144
+ /** 向导 step 的类型别名(与 src/ui/types.ts 的 ImportStep 保持一致)。 */
145
+ type ImportWizardStep = ImportStep
146
+
147
+ /** UI 层流程阶段(与 src/ui/flow.ts 的 FlowPhase 保持一致)。 */
148
+ type FlowPhase = 'preview' | 'conflicts' | 'path-mapping' | 'secrets' | 'confirm'
149
+
150
+ /* ------------------------------------------------------------- 默认值 */
151
+
152
+ function defaultExportState(): ExportLiveState {
153
+ return {
154
+ mode: 'quick',
155
+ selection: defaultCustomSelection(),
156
+ includeSecrets: false,
157
+ password: '',
158
+ passwordConfirm: '',
159
+ running: false,
160
+ progress: null,
161
+ result: null,
162
+ error: null,
163
+ downloaded: false,
164
+ runId: null,
165
+ }
166
+ }
167
+
168
+ function defaultImportState(): ImportLiveState {
169
+ return {
170
+ step: 'select',
171
+ zipPath: null,
172
+ analysis: null,
173
+ plan: null,
174
+ result: null,
175
+ rollbackOnError: true,
176
+ errors: [],
177
+ phase: 'preview',
178
+ conflictStrategy: 'merge',
179
+ conflictResolutions: {},
180
+ pathMappings: [],
181
+ secretInputs: {},
182
+ uploading: false,
183
+ running: false,
184
+ progress: null,
185
+ error: null,
186
+ runId: null,
187
+ conflictCollector: null,
188
+ }
189
+ }
190
+
191
+ function defaultState(): StoreState {
192
+ return {
193
+ v: 1,
194
+ view: 'export',
195
+ export: defaultExportState(),
196
+ import: defaultImportState(),
197
+ }
198
+ }
199
+
200
+ /* ------------------------------------------------- 序列化白名单(安全关键) */
201
+
202
+ /**
203
+ * 持久化白名单:解构剔除敏感字段(password/passwordConfirm/secretInputs)
204
+ * 与不可序列化的实例字段(conflictCollector),其余原样落入 sessionStorage。
205
+ * 这是 sessionStorage 的唯一写入路径 —— 敏感值在此被硬性隔离。
206
+ */
207
+ export function toPersistedState(state: StoreState): PersistedState {
208
+ const { password: _password, passwordConfirm: _passwordConfirm, ...exportRest } = state.export
209
+ const { secretInputs: _secretInputs, conflictCollector: _conflictCollector, ...importRest } = state.import
210
+ return { v: 1, view: state.view, export: exportRest, import: importRest }
211
+ }
212
+
213
+ /** 解析 + 轻量校验持久化状态;损坏/版本不符返回 null(调用方回退默认并清键)。 */
214
+ export function parsePersistedState(raw: string): PersistedState | null {
215
+ let parsed: unknown
216
+ try {
217
+ parsed = JSON.parse(raw)
218
+ } catch {
219
+ return null
220
+ }
221
+ if (typeof parsed !== 'object' || parsed === null) return null
222
+ const p = parsed as Record<string, unknown>
223
+ if (p['v'] !== 1) return null
224
+ const view = p['view']
225
+ if (view !== 'export' && view !== 'import') return null
226
+ const exp = p['export']
227
+ const imp = p['import']
228
+ if (typeof exp !== 'object' || exp === null || typeof imp !== 'object' || imp === null) return null
229
+ return { v: 1, view, export: exp as PersistedExportState, import: imp as PersistedImportState }
230
+ }
231
+
232
+ /* ----------------------------------------------------- 控制器 rehydrate */
233
+
234
+ /** ImportWizard 私有字段的镜像形状(仅供 writeWizardSnapshot 受控写入)。 */
235
+ interface WizardInternals {
236
+ step: ImportWizardStep
237
+ zipPath: string | null
238
+ analysis: ImportAnalysis | null
239
+ plan: ImportPlan | null
240
+ result: ImportResult | null
241
+ rollbackOnError: boolean
242
+ errors: string[]
243
+ decisions: ImportDecisions
244
+ secretInputs: Record<string, string>
245
+ }
246
+
247
+ /**
248
+ * 把 store 的导入快照写回 ImportWizard 控制器私有字段(受控 rehydrate)。
249
+ * - 只写非敏感字段;secretInputs 恒置空 —— 刷新后 secrets 阶段要求重输;
250
+ * - 仅在 load / hydrate / resume-settle 时调用,不参与正常交互路径。
251
+ */
252
+ function writeWizardSnapshot(wizard: ImportWizard, imp: ImportLiveState): void {
253
+ const internals = wizard as unknown as WizardInternals
254
+ internals.step = imp.step
255
+ internals.zipPath = imp.zipPath
256
+ internals.analysis = imp.analysis
257
+ internals.plan = imp.plan
258
+ internals.result = imp.result
259
+ internals.rollbackOnError = imp.rollbackOnError
260
+ internals.errors = [...imp.errors]
261
+ internals.decisions = {
262
+ strategy: imp.conflictStrategy,
263
+ resolutions: { ...imp.conflictResolutions },
264
+ pathMappings: imp.pathMappings.map((m) => ({ ...m, appliesTo: [...m.appliesTo] })),
265
+ }
266
+ internals.secretInputs = {}
267
+ }
268
+
269
+ /** 由 plan + 已持久化的决策重建 ConflictCollector(刷新恢复用)。 */
270
+ export function rebuildConflictCollector(
271
+ plan: ImportPlan,
272
+ resolutions: Record<string, ItemResolution>,
273
+ ): ConflictCollector {
274
+ const collector = new ConflictCollector(plan)
275
+ for (const [id, resolution] of Object.entries(resolutions)) {
276
+ collector.resolve(id, resolution)
277
+ }
278
+ return collector
279
+ }
280
+
281
+ /**
282
+ * RunState → RunProgress(m3 轮询进度回填)。
283
+ * - step/total = 内部计数(百分比条按 item/itemTotal 推进);
284
+ * - section/sectionTotal 与 item/itemTotal 单独保留给分区徽章/内部计数徽章;
285
+ * - detail = 当前项名(导出时恒为分区名,ProgressBar 侧会去冗余)。
286
+ */
287
+ function mapRunProgress(kind: RunKind, state: RunState): RunProgress {
288
+ return {
289
+ stage: kind === 'export' ? 'exporting' : 'executing',
290
+ detail: state.detail ?? undefined,
291
+ step: state.item ?? undefined,
292
+ total: state.itemTotal ?? undefined,
293
+ section: state.section,
294
+ sectionTotal: state.sectionTotal,
295
+ item: state.item,
296
+ itemTotal: state.itemTotal,
297
+ }
298
+ }
299
+
300
+ /* ------------------------------------------------------------------- store */
301
+
302
+ export interface RunStoreOptions {
303
+ /** 存储提供者;缺省 = window.sessionStorage(Node/无 window 时为 null) */
304
+ storage?: StoreStorage | null
305
+ /** 进度轮询间隔(测试注入小值;缺省 1000ms) */
306
+ pollIntervalMs?: number
307
+ }
308
+
309
+ function defaultStorage(): StoreStorage | null {
310
+ if (typeof window === 'undefined') return null
311
+ try {
312
+ return window.sessionStorage ?? null
313
+ } catch {
314
+ return null
315
+ }
316
+ }
317
+
318
+ /**
319
+ * 模块级单例 store:控制器实例 + React UI 状态 + sessionStorage 恢复。
320
+ * 最小接口:subscribe / getSnapshot / load / save / patch / syncWizard /
321
+ * exportFlow / importWizard / resume / stopResume。
322
+ */
323
+ export class RunStore {
324
+ private readonly storage: StoreStorage | null
325
+ private readonly pollIntervalMs: number
326
+ private readonly listeners = new Set<() => void>()
327
+ private state: StoreState
328
+ private exportFlowInst: ExportFlow | null = null
329
+ private importWizardInst: ImportWizard | null = null
330
+ private apiRef: ConfigManagerApi | null = null
331
+ /** 正在轮询的 runId 集合(stopResume 清空;防重复订阅) */
332
+ private readonly polling = new Set<string>()
333
+ /** 每个 runId 至多一个挂起的轮询定时器(fire 后由下一次调度覆盖) */
334
+ private readonly timers = new Map<string, ReturnType<typeof setTimeout>>()
335
+ /** 正在「发现进行中 run」的 kind 集合(watchRunning 活动标记;stopRunWatch/stopResume 清空) */
336
+ private readonly watchActive = new Set<RunKind>()
337
+
338
+ constructor(opts: RunStoreOptions = {}) {
339
+ this.storage = opts.storage !== undefined ? opts.storage : defaultStorage()
340
+ this.pollIntervalMs = opts.pollIntervalMs ?? 1000
341
+ this.state = defaultState()
342
+ this.load()
343
+ }
344
+
345
+ /* ------------------------------------------------------------- 订阅 */
346
+
347
+ /** 订阅 store 变化(useSyncExternalStore 的 subscribe)。 */
348
+ subscribe(listener: () => void): () => void {
349
+ this.listeners.add(listener)
350
+ return () => {
351
+ this.listeners.delete(listener)
352
+ }
353
+ }
354
+
355
+ /** 当前完整快照(引用稳定:仅在 patch 后替换,适合 useSyncExternalStore)。 */
356
+ getSnapshot(): StoreState {
357
+ return this.state
358
+ }
359
+
360
+ private notify(): void {
361
+ for (const listener of this.listeners) listener()
362
+ }
363
+
364
+ /* ------------------------------------------------------- 状态更新 */
365
+
366
+ /** 浅合并补丁并立即持久化(保存走白名单,敏感字段不落盘)。 */
367
+ patch(patchObj: StorePatch): void {
368
+ this.state = {
369
+ ...this.state,
370
+ view: patchObj.view ?? this.state.view,
371
+ export: { ...this.state.export, ...patchObj.export },
372
+ import: { ...this.state.import, ...patchObj.import },
373
+ }
374
+ this.notify()
375
+ this.save()
376
+ }
377
+
378
+ /** 把 ImportWizard 控制器的 snapshot() 镜像进 store(wizard 动作后调用)。 */
379
+ syncWizard(): void {
380
+ const wizard = this.importWizardInst
381
+ if (wizard === null) return
382
+ const snap = wizard.snapshot()
383
+ this.patch({
384
+ import: {
385
+ step: snap.step,
386
+ zipPath: snap.zipPath,
387
+ analysis: snap.analysis,
388
+ plan: snap.plan,
389
+ result: snap.result,
390
+ rollbackOnError: snap.rollbackOnError,
391
+ errors: snap.errors,
392
+ },
393
+ })
394
+ }
395
+
396
+ /* ------------------------------------------------------ 持久化 load/save */
397
+
398
+ /** 从存储恢复非敏感状态(构造时调用一次;损坏数据回退默认并清键)。 */
399
+ load(): void {
400
+ if (this.storage === null) return
401
+ let raw: string | null = null
402
+ try {
403
+ raw = this.storage.getItem(STATE_KEY)
404
+ } catch {
405
+ return
406
+ }
407
+ if (raw === null || raw === '') {
408
+ this.state = defaultState()
409
+ return
410
+ }
411
+ const parsed = parsePersistedState(raw)
412
+ if (parsed === null) {
413
+ // 损坏/版本不符:清掉脏键,回退默认
414
+ try {
415
+ this.storage.removeItem(STATE_KEY)
416
+ } catch {
417
+ // 忽略存储错误
418
+ }
419
+ this.state = defaultState()
420
+ return
421
+ }
422
+ this.applyPersisted(parsed)
423
+ }
424
+
425
+ /** 把解析后的持久化状态合并进运行时状态;敏感字段强制回到默认(清空)。 */
426
+ private applyPersisted(parsed: PersistedState): void {
427
+ this.state = {
428
+ v: 1,
429
+ view: parsed.view,
430
+ export: {
431
+ ...defaultExportState(),
432
+ ...parsed.export,
433
+ // 硬性:密码/确认密码绝不从存储恢复
434
+ password: '',
435
+ passwordConfirm: '',
436
+ },
437
+ import: {
438
+ ...defaultImportState(),
439
+ ...parsed.import,
440
+ // 硬性:秘密补录值绝不从存储恢复;collector 由 plan+决策重建
441
+ secretInputs: {},
442
+ conflictCollector: null,
443
+ },
444
+ }
445
+ // 安全兜底:confirm 阶段若仍缺必填 secret(刷新后 secretInputs 必为空),
446
+ // 强制退回 secrets 阶段要求重输(验收 m2-refresh 的「secrets 阶段要求重输」)。
447
+ const missing = this.state.import.plan?.missingSecrets ?? []
448
+ if (this.state.import.phase === 'confirm' && missing.length > 0) {
449
+ this.state.import.phase = 'secrets'
450
+ }
451
+ // 冲突阶段:由恢复后的 plan + 决策重建 collector(刷新后实例必然丢失)
452
+ const imp = this.state.import
453
+ if (imp.phase === 'conflicts' && imp.plan !== null) {
454
+ imp.conflictCollector = rebuildConflictCollector(imp.plan, imp.conflictResolutions)
455
+ }
456
+ }
457
+
458
+ /** 把非敏感状态写入 sessionStorage(白名单序列化;无存储时为空操作)。 */
459
+ save(): void {
460
+ if (this.storage === null) return
461
+ try {
462
+ this.storage.setItem(STATE_KEY, JSON.stringify(toPersistedState(this.state)))
463
+ } catch {
464
+ // 存储满/不可用:静默降级,状态仍完整存在于内存
465
+ }
466
+ }
467
+
468
+ /* ---------------------------------------------------- 控制器实例(缓存) */
469
+
470
+ /** ExportFlow 控制器实例:懒创建 + 缓存(切 tab/关面板不重建)。 */
471
+ exportFlow(api: ConfigManagerApi): ExportFlow {
472
+ if (this.exportFlowInst === null) {
473
+ if (this.apiRef === null) this.apiRef = api
474
+ this.exportFlowInst = new ExportFlow({
475
+ port: api,
476
+ onProgress: (event) => { this.patch({ export: { progress: event } }) },
477
+ })
478
+ }
479
+ return this.exportFlowInst
480
+ }
481
+
482
+ /** ImportWizard 控制器实例:懒创建 + 缓存;首次创建时从持久化快照 rehydrate。 */
483
+ importWizard(api: ConfigManagerApi): ImportWizard {
484
+ if (this.importWizardInst === null) {
485
+ if (this.apiRef === null) this.apiRef = api
486
+ const wizard = new ImportWizard({
487
+ port: api,
488
+ onProgress: (event) => { this.patch({ import: { progress: event } }) },
489
+ defaultRollbackOnError: true,
490
+ })
491
+ // 刷新恢复:把持久化快照写回控制器(仅非敏感字段;secretInputs 置空)
492
+ writeWizardSnapshot(wizard, this.state.import)
493
+ this.importWizardInst = wizard
494
+ }
495
+ return this.importWizardInst
496
+ }
497
+
498
+ /* ------------------------------------------------- m2-resume:run 重订阅 */
499
+
500
+ /**
501
+ * 重新订阅进行中的 run:GET /runs 找回活跃 runId → 轮询 /progress 直到
502
+ * 完成/失败,把 RunState.result 回填 store(导出结果 / 导入结果均可恢复)。
503
+ * 返回是否有 run 被恢复。幂等:同一 runId 不会重复轮询。
504
+ */
505
+ async resume(api: ConfigManagerApi): Promise<boolean> {
506
+ if (this.apiRef === null) this.apiRef = api
507
+ let active: RunState[]
508
+ try {
509
+ active = await api.runs()
510
+ } catch {
511
+ // host 不可达:保持现状,不做破坏性重置
512
+ return false
513
+ }
514
+ let resumed = false
515
+ const exportRun = active.find((r) => r.kind === 'export')
516
+ if (exportRun !== undefined) {
517
+ resumed = true
518
+ this.patch({
519
+ export: {
520
+ running: true,
521
+ runId: exportRun.runId,
522
+ progress: mapRunProgress('export', exportRun),
523
+ },
524
+ })
525
+ this.pollRun(exportRun.runId, 'export')
526
+ } else if (this.state.export.running) {
527
+ // 持久化显示导出中,但 host 无活跃导出 run:请求已结束且响应丢失,不可恢复
528
+ this.patch({
529
+ export: {
530
+ running: false,
531
+ progress: null,
532
+ runId: null,
533
+ error: '上次导出任务已结束但结果无法恢复,请重新导出',
534
+ },
535
+ })
536
+ }
537
+
538
+ const importRun = active.find((r) => r.kind === 'import')
539
+ if (importRun !== undefined) {
540
+ resumed = true
541
+ this.patch({
542
+ import: {
543
+ running: true,
544
+ runId: importRun.runId,
545
+ step: 'importing',
546
+ progress: mapRunProgress('import', importRun),
547
+ },
548
+ })
549
+ this.pollRun(importRun.runId, 'import')
550
+ } else if (this.state.import.step === 'importing') {
551
+ // 持久化显示导入中,但 host 无活跃导入 run:任务已结束/被清理,结果不可恢复
552
+ this.importWizardInst?.reset()
553
+ this.patch({
554
+ import: {
555
+ step: 'select',
556
+ phase: 'preview',
557
+ runId: null,
558
+ running: false,
559
+ progress: null,
560
+ conflictCollector: null,
561
+ errors: [],
562
+ error: '上次导入任务已结束或超过保留期,结果无法恢复,请重新导入',
563
+ },
564
+ })
565
+ }
566
+ return resumed
567
+ }
568
+
569
+ /** 停止全部轮询(视图卸载时调用;服务端执行不受影响,重开面板再 resume)。 */
570
+ stopResume(): void {
571
+ this.polling.clear()
572
+ this.watchActive.clear()
573
+ for (const timer of this.timers.values()) clearTimeout(timer)
574
+ this.timers.clear()
575
+ }
576
+
577
+ /* ------------------------------------- m3:本次会话内启动 run 的实时轮询 */
578
+
579
+ /**
580
+ * 本次会话内启动的 run(POST /export 或 /execute 请求进行期间)的实时进度订阅。
581
+ * 请求是同步的、响应到达才知 runId,所以先经 GET /runs 发现进行中的 run(500ms),
582
+ * 发现后转入 /progress 轮询(同一间隔),done/failed 自动停止。
583
+ *
584
+ * 与 m2 resume 的收敛:两者共用 pollRun / polling / timers 同一套轮询器,
585
+ * 只是间隔不同(resume 恢复 1s、本方法实时 500ms)且发现阶段不同
586
+ * (resume 用 /runs 一次性找回 runId;本方法持续 /runs 直到出现活跃 run)。
587
+ * 同一 runId 不会重复轮询(polling 集合防重)。
588
+ */
589
+ watchRunning(kind: RunKind, intervalMs?: number): void {
590
+ if (this.watchActive.has(kind)) return
591
+ this.watchActive.add(kind)
592
+ const api = this.apiRef
593
+ if (api === null) {
594
+ this.watchActive.delete(kind)
595
+ return
596
+ }
597
+ const interval = intervalMs ?? this.pollIntervalMs
598
+ const tick = (): void => {
599
+ if (!this.watchActive.has(kind)) return
600
+ void api.runs().then(
601
+ (active) => {
602
+ if (!this.watchActive.has(kind)) return
603
+ const run = active.find((r) => r.kind === kind && r.status === 'running')
604
+ if (run === undefined) {
605
+ // 请求尚未注册 run / 已瞬间结束:继续发现,直到视图 stop 或转入 /progress
606
+ this.scheduleWatch(kind, tick, interval)
607
+ return
608
+ }
609
+ // 发现进行中 run:停止发现,交给 /progress 轮询器(同一间隔)
610
+ this.watchActive.delete(kind)
611
+ this.patchProgress(kind, run)
612
+ this.pollRun(run.runId, kind, interval)
613
+ },
614
+ () => {
615
+ if (this.watchActive.has(kind)) this.scheduleWatch(kind, tick, interval)
616
+ },
617
+ )
618
+ }
619
+ this.scheduleWatch(kind, tick, interval)
620
+ }
621
+
622
+ /** 停止某 kind 的「发现进行中 run」轮询(视图请求结束后调用;/progress 轮询自行结束)。 */
623
+ stopRunWatch(kind: RunKind): void {
624
+ this.watchActive.delete(kind)
625
+ }
626
+
627
+ /** 调度下一轮发现;每 kind 只保留一个挂起定时器。 */
628
+ private scheduleWatch(kind: RunKind, tick: () => void, intervalMs: number): void {
629
+ const timer = setTimeout(tick, intervalMs)
630
+ this.timers.set(`watch:${kind}`, timer)
631
+ }
632
+
633
+ private pollRun(runId: string, kind: RunKind, intervalMs?: number): void {
634
+ if (this.polling.has(runId)) return
635
+ this.polling.add(runId)
636
+ const api = this.apiRef
637
+ if (api === null) return
638
+ const interval = intervalMs ?? this.pollIntervalMs
639
+ const tick = (): void => {
640
+ if (!this.polling.has(runId)) return
641
+ void api.progress(runId).then(
642
+ (state) => {
643
+ if (!this.polling.has(runId)) return
644
+ if (state.status === 'running') {
645
+ this.patchProgress(kind, state)
646
+ this.scheduleTick(runId, tick, interval)
647
+ } else {
648
+ this.polling.delete(runId)
649
+ this.applySettled(kind, state)
650
+ }
651
+ },
652
+ () => {
653
+ // 404(过保留期)/网络错误:run 不可恢复,停止轮询并如实提示
654
+ this.polling.delete(runId)
655
+ this.applyGone(kind)
656
+ },
657
+ )
658
+ }
659
+ this.scheduleTick(runId, tick, interval)
660
+ }
661
+
662
+ /** 调度下一轮询;每 runId 只保留一个挂起定时器(fire 后由下一次调度覆盖)。 */
663
+ private scheduleTick(runId: string, tick: () => void, intervalMs: number): void {
664
+ const timer = setTimeout(tick, intervalMs)
665
+ this.timers.set(runId, timer)
666
+ }
667
+
668
+ private patchProgress(kind: RunKind, state: RunState): void {
669
+ const event = mapRunProgress(kind, state)
670
+ if (kind === 'export') this.patch({ export: { progress: event } })
671
+ else this.patch({ import: { progress: event } })
672
+ }
673
+
674
+ /** run 完成/失败:把 RunState.result 回填 store(并镜像回控制器)。 */
675
+ private applySettled(kind: RunKind, state: RunState): void {
676
+ if (kind === 'export') {
677
+ const result = state.result as
678
+ | { zipPath: string; manifest: Manifest; report: ExportRunResult['report'] }
679
+ | undefined
680
+ if (state.status === 'done' && result !== undefined && typeof result.zipPath === 'string') {
681
+ this.patch({
682
+ export: {
683
+ running: false,
684
+ progress: { stage: 'done', step: 1, total: 1 },
685
+ result: { ...result, text: renderExportReport(result.report) },
686
+ downloaded: false,
687
+ },
688
+ })
689
+ } else {
690
+ this.patch({
691
+ export: {
692
+ running: false,
693
+ progress: null,
694
+ result: null,
695
+ downloaded: false,
696
+ error: state.error ?? '导出失败(结果不可用)',
697
+ },
698
+ })
699
+ }
700
+ return
701
+ }
702
+ // import
703
+ const result = state.result as ImportResult | undefined
704
+ if (state.status === 'done' && result !== undefined && typeof result === 'object') {
705
+ const wizard = this.importWizardInst
706
+ if (wizard !== null) {
707
+ const imp: ImportLiveState = {
708
+ ...this.state.import,
709
+ step: 'result',
710
+ result,
711
+ secretInputs: {},
712
+ conflictCollector: null,
713
+ }
714
+ writeWizardSnapshot(wizard, imp)
715
+ }
716
+ this.patch({
717
+ import: {
718
+ step: 'result',
719
+ result,
720
+ running: false,
721
+ progress: { stage: 'done', step: 1, total: 1 },
722
+ error: null,
723
+ },
724
+ })
725
+ } else {
726
+ const wizard = this.importWizardInst
727
+ if (wizard !== null) {
728
+ const internals = wizard as unknown as WizardInternals
729
+ internals.errors = [...internals.errors, state.error ?? '导入失败']
730
+ }
731
+ this.patch({
732
+ import: {
733
+ running: false,
734
+ progress: null,
735
+ error: state.error ?? '导入失败',
736
+ errors: [...this.state.import.errors, state.error ?? '导入失败'],
737
+ },
738
+ })
739
+ }
740
+ }
741
+
742
+ /** run 消失(404/网络错误):停止轮询并提示不可恢复。 */
743
+ private applyGone(kind: RunKind): void {
744
+ if (kind === 'export') {
745
+ this.patch({
746
+ export: {
747
+ running: false,
748
+ progress: null,
749
+ runId: null,
750
+ error: '任务已结束或超过保留期,进度不可恢复',
751
+ },
752
+ })
753
+ } else {
754
+ this.importWizardInst?.reset()
755
+ this.patch({
756
+ import: {
757
+ running: false,
758
+ progress: null,
759
+ runId: null,
760
+ step: 'select',
761
+ phase: 'preview',
762
+ conflictCollector: null,
763
+ errors: [],
764
+ error: '任务已结束或超过保留期,进度不可恢复',
765
+ },
766
+ })
767
+ }
768
+ }
769
+ }
770
+
771
+ /** 模块级单例(浏览器半所有视图共享;构造时自动 load())。 */
772
+ export const runStore = new RunStore()