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,79 @@
1
+ /**
2
+ * m3 progress-view 单测:computeProgressView 渲染模型(纯函数,node 可测)。
3
+ *
4
+ * 覆盖(验收 m3-render):
5
+ * - 兼容路径:普通 ProgressEvent(无 section/item)→ 阶段文案 + step/total 百分比;
6
+ * - 分区徽章(导出:settings · 3/12,current=item 序号、total=sectionTotal);
7
+ * - 内部计数徽章(导入:plugins · 6/18);
8
+ * - 导出冗余:itemTotal === sectionTotal 时内部计数徽章去重;
9
+ * - detail 与分区 id 相同时去冗余(当前项名);
10
+ * - null / 不定态(无 step/total → percent null)。
11
+ */
12
+ import test from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+
15
+ import { computeProgressView } from './progress-view.ts'
16
+ import type { RunProgress } from './progress-view.ts'
17
+
18
+ test('m3-render: null 事件 → 空渲染模型', () => {
19
+ const view = computeProgressView(null)
20
+ assert.equal(view.label, '')
21
+ assert.equal(view.percent, null)
22
+ assert.equal(view.sectionBadge, null)
23
+ assert.equal(view.countBadge, null)
24
+ assert.equal(view.detail, null)
25
+ })
26
+
27
+ test('m3-render: 兼容路径 —— 普通 ProgressEvent 走原逻辑(阶段 + 百分比)', () => {
28
+ const event: RunProgress = { stage: 'validating', step: 2, total: 4 }
29
+ const view = computeProgressView(event)
30
+ assert.equal(view.label, 'Validating backup...')
31
+ assert.equal(view.percent, 50)
32
+ assert.equal(view.sectionBadge, null)
33
+ assert.equal(view.countBadge, null)
34
+ assert.equal(view.detail, null)
35
+ })
36
+
37
+ test('m3-render: 无 step/total → 不定态(percent null)', () => {
38
+ const view = computeProgressView({ stage: 'executing', detail: 'running' })
39
+ assert.equal(view.percent, null)
40
+ })
41
+
42
+ test('m3-render: 导出进度 → 分区徽章 settings · 3/12(内部计数去冗余)', () => {
43
+ // m1 导出埋点:section=分区 id、sectionTotal=分区总数、item=1-based 分区序号、itemTotal=总数
44
+ const event: RunProgress = {
45
+ stage: 'exporting',
46
+ section: 'settings',
47
+ sectionTotal: 12,
48
+ item: 3,
49
+ itemTotal: 12,
50
+ detail: 'settings',
51
+ }
52
+ const view = computeProgressView(event)
53
+ assert.equal(view.percent, 25, '百分比 = item/itemTotal = 3/12')
54
+ assert.deepEqual(view.sectionBadge, { label: 'settings', current: 3, total: 12 })
55
+ assert.equal(view.countBadge, null, '导出内部计数与分区计数同源 → 去冗余')
56
+ assert.equal(view.detail, null, 'detail 与分区 id 相同 → 去冗余')
57
+ })
58
+
59
+ test('m3-render: 导入进度 → 内部计数徽章 plugins · 6/18 + 当前项名', () => {
60
+ // m1 导入埋点:section=当前 adapter、item/itemTotal=计划项进度、detail=计划项 id
61
+ const event: RunProgress = {
62
+ stage: 'executing',
63
+ section: 'plugins',
64
+ sectionTotal: null,
65
+ item: 6,
66
+ itemTotal: 18,
67
+ detail: 'plugin:pkg-a',
68
+ }
69
+ const view = computeProgressView(event)
70
+ assert.equal(view.percent, 33)
71
+ assert.equal(view.sectionBadge, null, '导入无分区总数语义')
72
+ assert.deepEqual(view.countBadge, { label: 'plugins', current: 6, total: 18 })
73
+ assert.equal(view.detail, 'plugin:pkg-a', '当前项名保留')
74
+ })
75
+
76
+ test('m3-render: 未知阶段回退显示 stage id', () => {
77
+ const view = computeProgressView({ stage: 'weird-stage' })
78
+ assert.equal(view.label, 'weird-stage')
79
+ })
@@ -0,0 +1,94 @@
1
+ /**
2
+ * m3: 进度展示的纯逻辑层(框架无关,node 可测)。
3
+ *
4
+ * 背景:m1 的 RunState 携带真实进度(section/sectionTotal = 当前分区 3/12、
5
+ * item/itemTotal = 内部计数如插件 6/18、detail = 当前项名);m2 的 store 把
6
+ * RunState 映射为 ProgressEvent 并存入 progress 字段。本模块定义客户端的
7
+ * RunProgress(ProgressEvent 超集,额外携带分区/内部计数,JSON 可序列化),
8
+ * 并提供 computeProgressView() 把进度事件转成渲染模型 —— ProgressBar 只做
9
+ * 纯渲染,所有判定逻辑集中在此、可单测。
10
+ *
11
+ * 兼容路径:无 section/item 的普通 ProgressEvent(控制器 onProgress 发出)
12
+ * 走原逻辑 —— 阶段文案 + step/total 百分比,无徽章。
13
+ */
14
+ import { stageText } from '../../ui/progress.ts'
15
+ import type { ProgressEvent } from '../../ui/types.ts'
16
+
17
+ /** m3: 客户端进度事件 = ProgressEvent 超集(RunState 轮询映射产物;可 JSON 序列化)。 */
18
+ export interface RunProgress extends ProgressEvent {
19
+ /** 当前分区(adapter id / 阶段名)与分区总数(如 settings · 3/12);导入无此语义为 null */
20
+ section?: string | null
21
+ sectionTotal?: number | null
22
+ /** 内部计数(如插件 6/18;导出 = 分区序号,导入 = 已处理计划项数) */
23
+ item?: number | null
24
+ itemTotal?: number | null
25
+ }
26
+
27
+ /** 徽章渲染数据(label · current/total)。 */
28
+ export interface ProgressBadge {
29
+ label: string
30
+ current: number
31
+ total: number
32
+ }
33
+
34
+ /** ProgressBar 的渲染模型(纯数据,测试断言用)。 */
35
+ export interface ProgressView {
36
+ /** 阶段文案(stageText) */
37
+ label: string
38
+ /** 百分比(step/total;无则 null = 不定态动画) */
39
+ percent: number | null
40
+ /** 当前分区徽章(settings · 3/12) */
41
+ sectionBadge: ProgressBadge | null
42
+ /** 内部计数徽章(plugins · 6/18;导出时与分区计数同源则去冗余) */
43
+ countBadge: ProgressBadge | null
44
+ /** 当前项名(detail;与分区名相同时去冗余) */
45
+ detail: string | null
46
+ }
47
+
48
+ /**
49
+ * 把进度事件换算成渲染模型。
50
+ * - percent:优先 ProgressEvent.step/total(控制器阶段进度 / 轮询的内部计数);
51
+ * - sectionBadge(当前分区,如 settings · 3/12):label=分区 id、current=item
52
+ * (1-based 分区序号,见 core exporter onSection)、total=sectionTotal;
53
+ * - countBadge(内部计数,如 plugins · 6/18):label=分区 id、current=item、
54
+ * total=itemTotal;导出时 itemTotal 与 sectionTotal 同值(分区序号即内部计数)
55
+ * → 置 null,避免「3/12 3/12」重复;
56
+ * - detail(当前项名):非空且不等于分区 id(导出时 detail 恒为分区 id,去冗余)。
57
+ */
58
+ export function computeProgressView(event: RunProgress | null): ProgressView {
59
+ if (event === null) {
60
+ return { label: '', percent: null, sectionBadge: null, countBadge: null, detail: null }
61
+ }
62
+ const section = event.section ?? null
63
+ const sectionTotal = event.sectionTotal ?? null
64
+ const item = event.item ?? null
65
+ const itemTotal = event.itemTotal ?? null
66
+
67
+ const percent =
68
+ event.step !== undefined && event.total !== undefined && event.total > 0
69
+ ? Math.round((event.step / event.total) * 100)
70
+ : item !== null && itemTotal !== null && itemTotal > 0
71
+ ? Math.round((item / itemTotal) * 100)
72
+ : null
73
+
74
+ const sectionBadge: ProgressBadge | null =
75
+ section !== null && item !== null && sectionTotal !== null && sectionTotal > 0
76
+ ? { label: section, current: item, total: sectionTotal }
77
+ : null
78
+
79
+ let countBadge: ProgressBadge | null =
80
+ section !== null && item !== null && itemTotal !== null && itemTotal > 0
81
+ ? { label: section, current: item, total: itemTotal }
82
+ : null
83
+ if (countBadge !== null && sectionBadge !== null && itemTotal === sectionTotal) {
84
+ // 导出:item/itemTotal 与 section/sectionTotal 同源(当前分区序号),去冗余
85
+ countBadge = null
86
+ }
87
+
88
+ const detail =
89
+ typeof event.detail === 'string' && event.detail !== '' && event.detail !== section
90
+ ? event.detail
91
+ : null
92
+
93
+ return { label: stageText(event.stage), percent, sectionBadge, countBadge, detail }
94
+ }
@@ -69,26 +69,6 @@
69
69
  padding: 4px 2px 16px;
70
70
  }
71
71
 
72
- .iconButton {
73
- display: inline-flex;
74
- align-items: center;
75
- justify-content: center;
76
- width: 26px;
77
- height: 26px;
78
- padding: 0;
79
- background: transparent;
80
- border: none;
81
- border-radius: 6px;
82
- color: var(--dsw-alias-label-secondary);
83
- cursor: pointer;
84
- font-size: 15px;
85
- }
86
-
87
- .iconButton:hover {
88
- background: var(--dsw-alias-interactive-bg-hover);
89
- color: var(--dsw-alias-label-primary);
90
- }
91
-
92
72
  /* ---------------- 区块标题 ---------------- */
93
73
 
94
74
  .sectionTitleBlock {
@@ -464,6 +444,33 @@
464
444
 
465
445
  .progressDetail {
466
446
  color: var(--dsw-alias-label-tertiary);
447
+ overflow: hidden;
448
+ text-overflow: ellipsis;
449
+ white-space: nowrap;
450
+ max-width: 320px;
451
+ }
452
+
453
+ /* m3:分区徽章 / 内部计数徽章 */
454
+ .progressBadge {
455
+ display: inline-flex;
456
+ align-items: center;
457
+ gap: 2px;
458
+ padding: 1px 8px;
459
+ border-radius: 999px;
460
+ font-size: 11px;
461
+ font-variant-numeric: tabular-nums;
462
+ line-height: 18px;
463
+ white-space: nowrap;
464
+ }
465
+
466
+ .progressBadgeSection {
467
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 14%, transparent);
468
+ color: var(--dsw-alias-state-business-primary);
469
+ }
470
+
471
+ .progressBadgeCount {
472
+ background: color-mix(in srgb, var(--dsw-alias-state-info-primary) 14%, transparent);
473
+ color: var(--dsw-alias-state-info-primary);
467
474
  }
468
475
 
469
476
  .progressPercent {
@@ -757,3 +764,75 @@
757
764
  @keyframes dshCmSpin {
758
765
  to { transform: rotate(360deg); }
759
766
  }
767
+
768
+ /* ---------------- 快照恢复面板(M4) ---------------- */
769
+
770
+ .snapshotList {
771
+ display: flex;
772
+ flex-direction: column;
773
+ gap: 2px;
774
+ }
775
+
776
+ .snapshotRowHeader,
777
+ .snapshotRow {
778
+ display: grid;
779
+ grid-template-columns: minmax(120px, 1.2fr) minmax(140px, 1.6fr) 90px 56px 56px;
780
+ gap: 8px;
781
+ align-items: center;
782
+ padding: 6px 10px;
783
+ font-size: 12px;
784
+ }
785
+
786
+ .snapshotRowHeader {
787
+ color: var(--dsw-alias-label-tertiary);
788
+ font-size: 11px;
789
+ border-bottom: 1px solid var(--dsw-alias-border-l1);
790
+ }
791
+
792
+ .snapshotRow {
793
+ width: 100%;
794
+ text-align: left;
795
+ color: var(--dsw-alias-label-primary);
796
+ background: transparent;
797
+ border: 1px solid transparent;
798
+ border-radius: 6px;
799
+ cursor: pointer;
800
+ }
801
+
802
+ .snapshotRow:hover {
803
+ background: var(--dsw-alias-interactive-bg-hover);
804
+ }
805
+
806
+ .snapshotRow[data-active] {
807
+ border-color: var(--dsw-alias-state-business-primary);
808
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent);
809
+ }
810
+
811
+ .warnText {
812
+ color: var(--dsw-alias-state-warn-primary);
813
+ }
814
+
815
+ .reportList {
816
+ margin: 8px 0 4px;
817
+ padding-left: 18px;
818
+ display: flex;
819
+ flex-direction: column;
820
+ gap: 3px;
821
+ font-size: 12px;
822
+ color: var(--dsw-alias-label-primary);
823
+ }
824
+
825
+ .kindTag {
826
+ display: inline-block;
827
+ padding: 1px 6px;
828
+ border-radius: 4px;
829
+ font-size: 10.5px;
830
+ background: var(--dsw-alias-interactive-bg-hover);
831
+ color: var(--dsw-alias-label-secondary);
832
+ }
833
+
834
+ .rowActions {
835
+ display: flex;
836
+ gap: 8px;
837
+ margin-top: 10px;
838
+ }
@@ -5,19 +5,21 @@
5
5
  * - Custom:按 §1 分组目录(EXPORT_GROUPS × DEFAULT_CATEGORIES)逐项勾选,
6
6
  * ExportFlow.validateSelection() 给出设备相关分区警告;
7
7
  * - Include secrets:可选加密备份(需设置加密密码,密码仅本次内存使用,
8
- * 经 api.exportPassword 随请求体传给 Host 半,绝不落盘/入 manifest);
8
+ * 经 api.exportPassword 随请求体传给 Host 半,绝不落盘/入 manifest,
9
+ * **也绝不进入 sessionStorage** —— m2 白名单剔除,刷新后要求重输);
9
10
  * - 进度:ExportFlow.run 发出 ProgressEvent → ProgressBar;
10
11
  * - 结果:ReportView(export) + 下载按钮(File System Access API 流式落盘)。
12
+ *
13
+ * m2:全部 UI 状态由模块级 runStore 持有(切 tab/关面板不重建、刷新恢复),
14
+ * 控制器实例(ExportFlow)由 store 缓存复用。
11
15
  */
12
- import { useCallback, useState } from 'react'
16
+ import { useCallback, useSyncExternalStore } from 'react'
13
17
  import type { ChangeEvent } from 'react'
14
- import { ExportFlow, DEFAULT_CATEGORIES } from '../../ui/export-flow.ts'
15
18
  import { EXPORT_GROUPS } from '../../ui/types.ts'
16
- import type { ExportRunResult } from '../../ui/export-flow.ts'
17
- import type { ProgressEvent } from '../../ui/types.ts'
18
19
  import type { SectionId } from '../../schema/types.ts'
19
20
  import type { TranslateNS } from '../client-types.ts'
20
21
  import type { ConfigManagerApi } from '../api.ts'
22
+ import { runStore, type ExportMode } from '../run-store.ts'
21
23
  import { Badge, Banner, Button, Card, Checkbox, SectionTitle, Spinner } from '../common/ui.tsx'
22
24
  import { ErrorBanner } from '../common/ErrorBanner.tsx'
23
25
  import { ProgressBar } from '../common/ProgressBar.tsx'
@@ -29,42 +31,46 @@ export interface ExportViewProps {
29
31
  t: TranslateNS<'config-manager'>
30
32
  }
31
33
 
32
- type ExportMode = 'quick' | 'custom'
33
-
34
- /** Custom 模式的初始勾选 = 推荐分区(可再调整) */
35
- function defaultCustomSelection(): SectionId[] {
36
- return DEFAULT_CATEGORIES.filter((c) => c.defaultIncluded).map((c) => c.id)
37
- }
38
-
39
34
  /**
40
35
  * 导出主视图:Quick/Custom 切换 → 勾选/密码 → 执行 → 进度 → 报告 → 下载。
41
36
  */
42
37
  export function ExportView({ api, t }: ExportViewProps) {
43
- const [mode, setMode] = useState<ExportMode>('quick')
44
- const [selection, setSelection] = useState<SectionId[]>(defaultCustomSelection)
45
- const [includeSecrets, setIncludeSecrets] = useState(false)
46
- const [password, setPassword] = useState('')
47
- const [passwordConfirm, setPasswordConfirm] = useState('')
48
- const [running, setRunning] = useState(false)
49
- const [progress, setProgress] = useState<ProgressEvent | null>(null)
50
- const [result, setResult] = useState<ExportRunResult | null>(null)
51
- const [error, setError] = useState<unknown>(null)
52
- const [downloaded, setDownloaded] = useState(false)
53
-
54
- // ExportFlow 控制器实例(port=api,onProgress 桥到 React state)
55
- const [flow] = useState(() => new ExportFlow({
56
- port: api,
57
- onProgress: (event) => { setProgress(event) },
58
- }))
59
-
60
- const toggleSection = useCallback((id: SectionId, checked: boolean) => {
61
- setSelection((prev) => {
62
- const has = prev.includes(id)
63
- if (checked && !has) return [...prev, id]
64
- if (!checked && has) return prev.filter((s) => s !== id)
65
- return prev
66
- })
67
- }, [])
38
+ // m2:状态统一来自模块级 store(sessionStorage 持久化;切 tab 不重建)
39
+ const state = useSyncExternalStore(runStore.subscribe, runStore.getSnapshot)
40
+ const exp = state.export
41
+ // 控制器实例由 store 缓存复用(切 tab / 关面板不重建)
42
+ const flow = runStore.exportFlow(api)
43
+
44
+ const mode = exp.mode
45
+ const selection = exp.selection
46
+ const includeSecrets = exp.includeSecrets
47
+ // 密码字段仅内存(store 的敏感字段,绝不序列化进 sessionStorage)
48
+ const password = exp.password
49
+ const passwordConfirm = exp.passwordConfirm
50
+ const running = exp.running
51
+ const progress = exp.progress
52
+ const result = exp.result
53
+ const error = exp.error
54
+ const downloaded = exp.downloaded
55
+
56
+ const setMode = (next: ExportMode): void => {
57
+ runStore.patch({ export: { mode: next } })
58
+ }
59
+ const setIncludeSecrets = (next: boolean): void => {
60
+ runStore.patch({ export: { includeSecrets: next } })
61
+ }
62
+ const setPassword = (value: string): void => {
63
+ runStore.patch({ export: { password: value } })
64
+ }
65
+ const setPasswordConfirm = (value: string): void => {
66
+ runStore.patch({ export: { passwordConfirm: value } })
67
+ }
68
+
69
+ const toggleSection = useCallback((id: SectionId, checked: boolean): void => {
70
+ const has = selection.includes(id)
71
+ if (checked && !has) runStore.patch({ export: { selection: [...selection, id] } })
72
+ if (!checked && has) runStore.patch({ export: { selection: selection.filter((s) => s !== id) } })
73
+ }, [selection])
68
74
 
69
75
  const passwordInvalid =
70
76
  includeSecrets && (password === '' || password !== passwordConfirm)
@@ -72,21 +78,29 @@ export function ExportView({ api, t }: ExportViewProps) {
72
78
  /** 执行导出(Quick 或 Custom) */
73
79
  const runExport = async (): Promise<void> => {
74
80
  if (passwordInvalid) return
75
- setRunning(true)
76
- setError(null)
77
- setResult(null)
78
- setDownloaded(false)
79
- setProgress(null)
81
+ runStore.patch({
82
+ export: { running: true, error: null, result: null, downloaded: false, progress: null, runId: null },
83
+ })
84
+ // m3:请求进行期间经 /runs 发现 runId 并轮询 /progress(500ms)显示真实进度
85
+ runStore.watchRunning('export', 500)
80
86
  try {
81
87
  // 加密密码随本次导出请求体传给 Host 半(仅内存)
82
88
  api.exportPassword = includeSecrets ? password : null
83
89
  const run = await flow.run(mode, selection, { includeSecrets })
84
- setResult(run)
90
+ // ExportResponse 携带 runId(/progress 查询与刷新恢复用);控制器类型不含,运行时对象有
91
+ const runId = (run as { runId?: unknown }).runId
92
+ runStore.patch({
93
+ export: {
94
+ result: run,
95
+ runId: typeof runId === 'string' ? runId : null,
96
+ progress: { stage: 'done', step: 1, total: 1 },
97
+ },
98
+ })
85
99
  } catch (err) {
86
- setError(err)
100
+ runStore.patch({ export: { error: err instanceof Error ? err.message : String(err) } })
87
101
  } finally {
88
- setRunning(false)
89
- setProgress(null)
102
+ runStore.stopRunWatch('export')
103
+ runStore.patch({ export: { running: false } })
90
104
  }
91
105
  }
92
106
 
@@ -94,12 +108,12 @@ export function ExportView({ api, t }: ExportViewProps) {
94
108
  const download = async (): Promise<void> => {
95
109
  if (result === null) return
96
110
  try {
97
- setError(null)
111
+ runStore.patch({ export: { error: null } })
98
112
  const outcome = await api.download(result.zipPath)
99
- setDownloaded(true)
113
+ runStore.patch({ export: { downloaded: true } })
100
114
  void outcome // blob/streamed 由 api 处理;此处仅确认成功
101
115
  } catch (err) {
102
- setError(err)
116
+ runStore.patch({ export: { error: err instanceof Error ? err.message : String(err) } })
103
117
  }
104
118
  }
105
119