dsh-config-manager 0.1.83 → 0.1.85

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 (88) hide show
  1. package/lib/adapters/file-collection.d.ts +9 -2
  2. package/lib/adapters/file-collection.js +22 -3
  3. package/lib/adapters/self.d.ts +7 -3
  4. package/lib/client.d.ts +153 -399
  5. package/lib/client.js +1526 -2229
  6. package/lib/core/messages.d.ts +1 -0
  7. package/lib/core/messages.js +2 -0
  8. package/lib/core/restore.js +7 -1
  9. package/lib/index.d.ts +6 -4
  10. package/lib/index.js +7 -7
  11. package/lib/routes/sync.js +92 -70
  12. package/lib/sync/git/git-transport.d.ts +2 -0
  13. package/lib/sync/git/git-transport.js +75 -22
  14. package/lib/sync/messages.d.ts +2 -0
  15. package/lib/sync/messages.js +4 -0
  16. package/lib/sync/sync-engine.d.ts +4 -4
  17. package/lib/sync/sync-engine.js +3 -4
  18. package/lib/ui/i18n.d.ts +2 -84
  19. package/lib/ui/i18n.js +4 -168
  20. package/package.json +1 -1
  21. package/src/adapters/file-collection.ts +22 -3
  22. package/src/adapters/self.test.ts +43 -0
  23. package/src/adapters/self.ts +7 -3
  24. package/src/client/ConfigManagerSection.tsx +1 -18
  25. package/src/client/about/AboutPanel.tsx +4 -11
  26. package/src/client/about/ReleaseNotesDialog.tsx +1 -2
  27. package/src/client/chat/chat-locales.ts +0 -8
  28. package/src/client/common/CommandPalette.tsx +0 -1
  29. package/src/client/common/ContentPicker.tsx +10 -1
  30. package/src/client/common/info-hint-guard.test.ts +4 -4
  31. package/src/client/config-manager.module.css +2 -32
  32. package/src/client/environment/EnvironmentPanel.tsx +10 -9
  33. package/src/client/environment/maintenance/DiskUsageCard.tsx +10 -10
  34. package/src/client/export/ExportView.tsx +10 -13
  35. package/src/client/history/HistoryPanel.tsx +9 -11
  36. package/src/client/history/history-locales.ts +4 -40
  37. package/src/client/home/BackupScheduleCard.tsx +0 -2
  38. package/src/client/home/HomePanel.tsx +75 -49
  39. package/src/client/import/ForeignImportView.tsx +6 -7
  40. package/src/client/import/ImportWizardView.tsx +8 -9
  41. package/src/client/import/import-wizard-steps.tsx +20 -15
  42. package/src/client/library/BackupInspectView.tsx +3 -11
  43. package/src/client/library/LibraryActions.tsx +2 -1
  44. package/src/client/library/LibraryPanel.tsx +6 -3
  45. package/src/client/locales.ts +122 -278
  46. package/src/client/market/MarketImportReview.tsx +6 -5
  47. package/src/client/market/MarketPanel.tsx +2 -2
  48. package/src/client/market/MyConfigsLoginCard.tsx +0 -3
  49. package/src/client/market/MyConfigsView.tsx +3 -4
  50. package/src/client/market/MyConfigsWizard.tsx +8 -4
  51. package/src/client/market/market-locales.ts +20 -60
  52. package/src/client/recovery/IncompleteCopiesSection.tsx +6 -8
  53. package/src/client/recovery/RecoveryPanel.tsx +48 -73
  54. package/src/client/recovery/RollbackCard.tsx +27 -20
  55. package/src/client/recovery/recovery-locales.test.ts +1 -3
  56. package/src/client/recovery/recovery-locales.ts +71 -133
  57. package/src/client/recovery/recovery-panel-layout.test.ts +4 -2
  58. package/src/client/sync/DecryptPasswordCard.tsx +2 -2
  59. package/src/client/sync/MergeModal.tsx +3 -1
  60. package/src/client/sync/MergePreviewView.tsx +14 -16
  61. package/src/client/sync/PushPreviewCard.tsx +0 -1
  62. package/src/client/sync/SecurityOptionsCard.tsx +5 -1
  63. package/src/client/sync/SyncChannelEntryCard.tsx +3 -10
  64. package/src/client/sync/SyncConfirmView.tsx +23 -20
  65. package/src/client/sync/SyncLogList.tsx +5 -2
  66. package/src/client/sync/SyncPanel.tsx +10 -9
  67. package/src/client/sync/SyncProbeRow.tsx +6 -8
  68. package/src/client/sync/SyncSectionPickerDialog.tsx +3 -3
  69. package/src/client/sync/WorkDirCard.tsx +6 -7
  70. package/src/client/sync/preview-failure.test.ts +58 -0
  71. package/src/client/sync/preview-failure.ts +47 -0
  72. package/src/client/sync/sync-api.ts +19 -3
  73. package/src/client/sync/sync-entry.test.ts +6 -0
  74. package/src/client/sync/sync-entry.ts +4 -1
  75. package/src/client/sync/sync-locales.ts +31 -147
  76. package/src/client/sync/sync-push-preview.test.ts +1 -1
  77. package/src/client/sync/sync-view.ts +1 -4
  78. package/src/core/messages.ts +2 -0
  79. package/src/core/restore.ts +8 -1
  80. package/src/index.ts +8 -8
  81. package/src/routes/sync.ts +91 -69
  82. package/src/sync/git/git-clone-auth.test.ts +61 -0
  83. package/src/sync/git/git-transport.test.ts +75 -1
  84. package/src/sync/git/git-transport.ts +74 -20
  85. package/src/sync/messages.ts +4 -0
  86. package/src/sync/sync-engine.ts +6 -6
  87. package/src/sync/sync-preview-empty.test.ts +58 -0
  88. package/src/ui/i18n.ts +4 -168
@@ -8,8 +8,6 @@ import type { MigrationKind, MigrationResult } from '../../core/migration-histor
8
8
  type KindKey = `history.kind.${MigrationKind}`;
9
9
  type ResultKey = `history.result.${MigrationResult}`;
10
10
  type CommonKey =
11
- | 'view.history'
12
- | 'history.title'
13
11
  | 'history.subtitle'
14
12
  | 'history.empty'
15
13
  | 'history.loading'
@@ -25,22 +23,12 @@ type CommonKey =
25
23
  | 'history.filter.recent.all'
26
24
  | 'history.filter.recent.50'
27
25
  | 'history.filter.recent.200'
28
- | 'history.filter.sections'
29
- | 'history.filter.title'
30
26
  | 'history.search.placeholder'
31
27
  | 'history.export.json'
32
28
  | 'history.export.markdown'
33
29
  | 'history.exported'
34
30
  | 'history.exporting'
35
31
  | 'history.exportError'
36
- | 'history.loadError'
37
- | 'history.reload'
38
- | 'history.table.time'
39
- | 'history.table.kind'
40
- | 'history.table.result'
41
- | 'history.table.sections'
42
- | 'history.table.summary'
43
- | 'history.updatedAt'
44
32
  | 'history.refresh'
45
33
  | 'history.refreshError'
46
34
  /** ⓘ 说明按钮的可访问名(与主字典 / market / sync / recovery 同口径,见 info-hint-guard t6-4) */
@@ -49,14 +37,12 @@ type CommonKey =
49
37
  export type HistoryKey = KindKey | ResultKey | CommonKey;
50
38
 
51
39
  export const zh = {
52
- 'view.history': '迁移历史',
53
40
  'common.infoHint': '查看说明',
54
- 'history.title': '迁移与审计历史',
55
41
  'history.subtitle': '统一记录全部破坏性/迁移操作(导入/恢复/回滚/档案/同步/备份/快照操作),只可追加、不可修改或删除,跨重启持久,可查询可导出。',
56
- 'history.empty': '暂无迁移记录。执行导入、恢复、档案切换、同步应用或备份后,这里会出现审计记录。',
42
+ 'history.empty': '暂无迁移记录。',
57
43
  'history.loading': '加载迁移历史…',
58
44
  'history.corruptedBanner': '检测到无法读取/可能被篡改的历史条目',
59
- 'history.corruptedCount': '{count} 条已被跳过(不计入导出)',
45
+ 'history.corruptedCount': '{count} 条已跳过 · 不参与导出',
60
46
  'history.stats.total': '总数',
61
47
  'history.stats.success': '成功',
62
48
  'history.stats.failed': '失败',
@@ -67,22 +53,12 @@ export const zh = {
67
53
  'history.filter.recent.all': '全部',
68
54
  'history.filter.recent.50': '最近 50 条',
69
55
  'history.filter.recent.200': '最近 200 条',
70
- 'history.filter.sections': '涉及分区',
71
- 'history.filter.title': '筛选与导出',
72
56
  'history.search.placeholder': '搜索摘要 / 错误 / 分区…',
73
57
  'history.export.json': '导出 JSON',
74
58
  'history.export.markdown': '导出 Markdown',
75
59
  'history.exported': '已导出',
76
60
  'history.exporting': '导出中…',
77
61
  'history.exportError': '导出失败',
78
- 'history.loadError': '加载迁移历史失败',
79
- 'history.reload': '重新加载',
80
- 'history.table.time': '时间',
81
- 'history.table.kind': '操作',
82
- 'history.table.result': '结果',
83
- 'history.table.sections': '涉及分区',
84
- 'history.table.summary': '摘要',
85
- 'history.updatedAt': '更新于',
86
62
  'history.refresh': '刷新',
87
63
  'history.refreshError': '刷新失败',
88
64
  'history.kind.import': '导入',
@@ -108,14 +84,12 @@ export const zh = {
108
84
  } as const satisfies Record<HistoryKey, string>;
109
85
 
110
86
  export const en = {
111
- 'view.history': 'Migration History',
112
87
  'common.infoHint': 'Show description',
113
- 'history.title': 'Migration & Audit History',
114
88
  'history.subtitle': 'Unified append-only audit trail of all destructive/migration operations (import/restore/rollback/profile/sync/backup/snapshot), durable across restarts, queryable and exportable.',
115
- 'history.empty': 'No migration records yet. Executing an import, restore, profile switch, sync apply, or scheduled backup will create audit entries here.',
89
+ 'history.empty': 'No migration records yet.',
116
90
  'history.loading': 'Loading migration history…',
117
91
  'history.corruptedBanner': 'Detected unreadable or possibly tampered history entries',
118
- 'history.corruptedCount': '{count} entry(s) were skipped (excluded from export)',
92
+ 'history.corruptedCount': '{count} skipped · not exported',
119
93
  'history.stats.total': 'Total',
120
94
  'history.stats.success': 'Success',
121
95
  'history.stats.failed': 'Failed',
@@ -126,22 +100,12 @@ export const en = {
126
100
  'history.filter.recent.all': 'All',
127
101
  'history.filter.recent.50': 'Latest 50',
128
102
  'history.filter.recent.200': 'Latest 200',
129
- 'history.filter.sections': 'Sections',
130
- 'history.filter.title': 'Filter & export',
131
103
  'history.search.placeholder': 'Search summary / error / section…',
132
104
  'history.export.json': 'Export JSON',
133
105
  'history.export.markdown': 'Export Markdown',
134
106
  'history.exported': 'Exported',
135
107
  'history.exporting': 'Exporting…',
136
108
  'history.exportError': 'Export failed',
137
- 'history.loadError': 'Failed to load migration history',
138
- 'history.reload': 'Reload',
139
- 'history.table.time': 'Time',
140
- 'history.table.kind': 'Operation',
141
- 'history.table.result': 'Result',
142
- 'history.table.sections': 'Sections',
143
- 'history.table.summary': 'Summary',
144
- 'history.updatedAt': 'Updated',
145
109
  'history.refresh': 'Refresh',
146
110
  'history.refreshError': 'Refresh failed',
147
111
  'history.kind.import': 'Import',
@@ -215,7 +215,6 @@ export function BackupScheduleCard({ api, t, syncT, onBackupDone }: BackupSchedu
215
215
  <Card>
216
216
  <div className={css.groupHeader}>
217
217
  <span className={css.groupLabel}>{t('backupSchedule.title')}</span>
218
- {lastRun !== undefined && <Badge kind={backupRunBadgeKind(lastRun)}>{runStatusText(lastRun)}</Badge>}
219
218
  <InfoHint text={t('backupSchedule.hint')} label={t('common.infoHint')} />
220
219
  <span className={css.statusSpacer} />
221
220
  <Button
@@ -377,7 +376,6 @@ export function BackupScheduleCard({ api, t, syncT, onBackupDone }: BackupSchedu
377
376
  value={retentionDraft[field]}
378
377
  disabled={busy}
379
378
  style={{ width: 88 }}
380
- aria-label={t('retention.title')}
381
379
  onChange={(event) => {
382
380
  const raw = event.target.value
383
381
  const parsed = raw === '' ? 0 : Number(raw)
@@ -14,7 +14,8 @@
14
14
  * (Canvas 纪律:不留底部空洞)。
15
15
  * 状态行**不重复**恢复待处理的提示 —— 全局 SAFE MODE 横幅已经承担(§4.4)。
16
16
  *
17
- * 数据流:挂载/刷新时对 5 个**毫秒级**只读 API 做 Promise.allSettled 并行聚合,首屏不等分区预览;
17
+ * 数据流:挂载/刷新时 4 个**毫秒级**只读 API 逐个 settle 逐个渲染(骨架只等它们);/sync/status
18
+ * 独立加载、绝不参与骨架(它碰 DSH 凭据服务与 git 工作副本自检,真机出现过 300 s 不返回);首屏不等分区预览;
18
19
  * 分区构成(export-preview)单独后发 —— 它要遍历全部默认分区(含本地插件打包、会话扫描),
19
20
  * 是最慢的一步,等它会让整页转圈(真机曾达 12~30 s)。全部渲染模型来自 src/ui/overview-view.ts
20
21
  * 纯函数(node 单测覆盖),本组件只做装配。
@@ -47,12 +48,15 @@ import {
47
48
  } from '../../ui/overview-view.ts'
48
49
  import { Badge, Banner, Button, Card, StatusDot, Stepper } from '../common/ui.tsx'
49
50
  import { SkeletonRows } from '../common/Skeleton.tsx'
50
- import { BackupIcon, ExportIcon, ImportIcon, SyncIcon } from '../common/Icon.tsx'
51
+ import { BackupIcon, ExportIcon, ImportIcon } from '../common/Icon.tsx'
51
52
  import { CopyButton } from '../common/CopyButton.tsx'
52
53
  import { Modal } from '../common/Modal.tsx'
53
54
  import { BackupScheduleCard } from './BackupScheduleCard.tsx'
54
55
  import css from '../config-manager.module.css'
55
56
 
57
+ /** 首页同步状态请求超时(ms):它只填一个指标段,超时走默认 30 秒文案档(时长与文案一致)。 */
58
+ const HOME_SYNC_TIMEOUT_MS = 30_000
59
+
56
60
  /** issue #43:立即备份跳过原因 → 文案键(文案统一走 locale 字典;未知 token 走 other)。 */
57
61
  const BACKUP_SKIP_KEY: Record<BackupSkipReason, ConfigManagerKey> = {
58
62
  disabled: 'overview.quick.backupSkippedDisabled',
@@ -142,7 +146,10 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
142
146
  const [scheduleOpen, setScheduleOpen] = useState(false)
143
147
  const store = useSyncExternalStore(runStore.subscribe, runStore.getSnapshot)
144
148
  const [data, setData] = useState<OverviewData>(initialData)
149
+ /** 首屏 4 个毫秒级只读接口是否仍在飞(决定状态条/活动表的骨架)。 */
145
150
  const [loading, setLoading] = useState(true)
151
+ /** 同步状态的独立加载态:'failed' 时指标段显示「读取超时」(绝不冒充「未配置」)。 */
152
+ const [syncState, setSyncState] = useState<'loading' | 'ready' | 'failed'>('loading')
146
153
  /** 首屏 5 个只读请求的失败情况(null = 全部成功 / 还没跑过)。
147
154
  * 为什么必须有它:此前 allSettled 把失败一律吞成 null,而健康判定对 null 不降级 ——
148
155
  * 宿主接口整体不可达时,用户看到的是绿色「配置健康」+「暂无操作记录」,
@@ -152,34 +159,60 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
152
159
  /** 卸载后不再 setState(异步回调竞态防护) */
153
160
  const aliveRef = useRef(true)
154
161
  useEffect(() => () => { aliveRef.current = false }, [])
155
-
156
- /** 首屏:5 个毫秒级只读接口;settle 即渲染(不 await 分区预览) */
157
- const loadFast = useCallback(async (): Promise<void> => {
158
- const results = await Promise.allSettled([
159
- api.listBackupFiles(),
160
- api.snapshots(),
161
- api.backupSchedule(),
162
- syncApi.status(),
163
- historyApi.list({}),
164
- ])
162
+ /** 本轮首屏的失败集合(4 个快请求 + 独立同步状态共享):横幅的计数与首条原因都出自这里。 */
163
+ const failuresRef = useRef<unknown[]>([])
164
+ const publishFailures = useCallback((): void => {
165
165
  if (!aliveRef.current) return
166
- // 失败必须留痕(数量 + 第一条原因);成功时清掉上一轮的错误。
167
- const failures = results.flatMap((r) => (r.status === 'rejected' ? [r.reason] : []))
168
- const first = failures[0]
169
- setLoadError(failures.length === 0 ? null : {
170
- count: failures.length,
166
+ const list = failuresRef.current
167
+ const first = list[0]
168
+ setLoadError(list.length === 0 ? null : {
169
+ count: list.length,
171
170
  message: first instanceof Error ? first.message : String(first),
172
171
  })
173
- setData((prev) => ({
174
- ...prev,
175
- backups: results[0].status === 'fulfilled' ? results[0].value : null,
176
- snapshots: results[1].status === 'fulfilled' ? results[1].value : null,
177
- schedule: results[2].status === 'fulfilled' ? results[2].value : null,
178
- sync: results[3].status === 'fulfilled' ? results[3].value : null,
179
- history: results[4].status === 'fulfilled' ? results[4].value : null,
180
- }))
181
- setLoading(false)
182
- }, [api, syncApi, historyApi])
172
+ }, [])
173
+
174
+ /** 首屏 4 个毫秒级只读接口(backups/snapshots/schedule/history):**逐个 settle 逐个渲染**,
175
+ * 凑齐才撤骨架;「首屏好了没」只看它们 —— 同步状态另走一条独立通道(见下方注释)。 */
176
+ const loadFast = useCallback(async (): Promise<void> => {
177
+ failuresRef.current = []
178
+ let remaining = 4
179
+ /** 单个快请求:成功就写自己那块数据,失败只记账(不牵连其它块,也不延长骨架)。 */
180
+ const runFast = <T,>(promise: Promise<T>, apply: (value: T) => void): void => {
181
+ void promise.then(
182
+ (value) => { if (aliveRef.current) apply(value) },
183
+ (reason) => { failuresRef.current.push(reason) },
184
+ ).finally(() => {
185
+ remaining -= 1
186
+ if (remaining > 0) return
187
+ publishFailures()
188
+ if (aliveRef.current) setLoading(false)
189
+ })
190
+ }
191
+ runFast(api.listBackupFiles(), (value) => { setData((prev) => ({ ...prev, backups: value })) })
192
+ runFast(api.snapshots(), (value) => { setData((prev) => ({ ...prev, snapshots: value })) })
193
+ runFast(api.backupSchedule(), (value) => { setData((prev) => ({ ...prev, schedule: value })) })
194
+ runFast(historyApi.list({}), (value) => { setData((prev) => ({ ...prev, history: value })) })
195
+ // 同步状态**独立加载、绝不参与首屏骨架**:/sync/status 会碰 DSH credentials 服务与 git 工作副本
196
+ // 自检,真机出现过 300 秒不返回。此前它与其余 4 个请求同桶 allSettled,一个挂住就把整页按在骨架上。
197
+ // 超时取 30 秒档(客户端超时文案按时长插值,读作「30 秒」,与实测一致)。
198
+ setSyncState('loading')
199
+ // workDirDetail: false —— 首页只用这一份填指标段,不渲染 WorkDirCard;关掉 ahead/dirty/
200
+ // 远端快照数可省 5 次 git 子进程启动(Windows 每次约 80 ms,实测 730 ms → 170 ms)。
201
+ void syncApi.status({ timeoutMs: HOME_SYNC_TIMEOUT_MS, workDirDetail: false }).then(
202
+ (value) => {
203
+ if (!aliveRef.current) return
204
+ setData((prev) => ({ ...prev, sync: value }))
205
+ setSyncState('ready')
206
+ publishFailures()
207
+ },
208
+ (reason) => {
209
+ failuresRef.current.push(reason)
210
+ if (!aliveRef.current) return
211
+ setSyncState('failed')
212
+ publishFailures()
213
+ },
214
+ )
215
+ }, [api, syncApi, historyApi, publishFailures])
183
216
 
184
217
  /**
185
218
  * 全量刷新(挂载、立即备份后、定时备份弹窗关闭后)。
@@ -223,10 +256,6 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
223
256
  }
224
257
  }
225
258
 
226
- // 纯导航(与 nav 页签同语义;状态入 runStore,切页/刷新不丢)
227
- // v2:本页不再有「备份页」这个去处(定时备份就在本页,产物在产物库)
228
- const navPanel = (panel: 'sync'): void => { runStore.patch({ panel }) }
229
-
230
259
  /**
231
260
  * 导入是**流程**不是页面(与导出同一条容器判据):开侧滑面板,不切页。
232
261
  *
@@ -309,9 +338,17 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
309
338
  } else if (m.key === 'backups' && m.metaParams['time'] !== undefined) {
310
339
  dim = renderRelTime(Number(m.metaParams['time']), t)
311
340
  }
341
+ // 同步状态读取失败(超时/不可用):值留空、附注如实写「读取超时」——
342
+ // 落到 sync=null 的「无值」上会读成「没配置」,与真实原因相反。
343
+ if (m.key === 'sync' && syncState === 'failed') {
344
+ return { key: m.key, label: t(METRIC_LABEL[m.key]), value: '—', dim: t('overview.meta.syncUnavailable') }
345
+ }
312
346
  return { key: m.key, label: t(METRIC_LABEL[m.key]), value, dim }
313
347
  })
314
348
 
349
+ /** 某指标段是否仍在等数据:同步段只看自己的加载态,其余三段/四段看首屏快请求。 */
350
+ const segLoading = (key: OverviewMetricKey): boolean => (key === 'sync' ? syncState === 'loading' : loading)
351
+
315
352
  /** 结果渲染:ok = 绿点(降噪);failed/skipped = 徽章(需要被看见)。 */
316
353
  const resultNode = (badge: 'ok' | 'error' | 'warn'): ReactNode =>
317
354
  badge === 'ok'
@@ -358,10 +395,10 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
358
395
  <span>{seg.label}</span>
359
396
  {/* 首屏数值未到货时用骨架占位(不再在整条右端挂一颗转圈):
360
397
  段本身的位置与宽度保持不变,数据到货只换「值」这一块。 */}
361
- {loading
398
+ {segLoading(seg.key)
362
399
  ? <span className={css.skeletonBar + ' ' + css.statValueSkeleton} aria-hidden="true" />
363
400
  : <b>{seg.value}</b>}
364
- {!loading && seg.dim !== null && <span className={css.statSegDim}>· {seg.dim}</span>}
401
+ {!segLoading(seg.key) && seg.dim !== null && <span className={css.statSegDim}>· {seg.dim}</span>}
365
402
  </button>
366
403
  ))}
367
404
  </div>
@@ -373,35 +410,30 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
373
410
  <Button
374
411
  variant="primary"
375
412
  loading={backupRunning}
376
- title={t('overview.quick.backupTitle')}
377
413
  onClick={() => { void runBackupNow() }}
378
414
  >
379
415
  {backupRunning ? null : <BackupIcon size={14} />} {t('overview.quick.backup')}
380
416
  </Button>
381
- <Button title={t('overview.quick.exportTitle')} onClick={openExportTask}>
417
+ <Button onClick={openExportTask}>
382
418
  <ExportIcon size={14} /> {t('nav.export')} ZIP
383
419
  </Button>
384
- <Button title={t('overview.quick.importTitle')} onClick={openImportTask}>
420
+ <Button onClick={openImportTask}>
385
421
  <ImportIcon size={14} /> {t('nav.import')}
386
422
  </Button>
387
423
  {/* 外部 agent 入口(t17):与「导入」并列但**直达来源选择** ——
388
424
  装了 Claude Code / Cursor 的用户要找的就是这条路,而不是先进向导再找按钮。 */}
389
425
  {openForeignImport !== undefined && (
390
- <Button title={t('foreign.source.hint')} onClick={openForeignImportTask}>
426
+ <Button onClick={openForeignImportTask}>
391
427
  <ImportIcon size={14} /> {t('foreign.source.title')}
392
428
  </Button>
393
429
  )}
394
- <Button title={t('overview.quick.syncTitle')} onClick={() => { navPanel('sync') }}>
395
- <SyncIcon size={14} /> {t('overview.quick.sync')}
396
- </Button>
397
430
  </div>
398
431
 
399
432
  {emptyState ? (
400
- /* 首用空态:引导创建第一个备份(填充剩余高度) */
433
+ /* 首用空态:只交代顺序(备份 → 导出 → 导入),动作全交给上方工具栏 ——
434
+ 此前这里又放了一遍「立即备份 / 远程同步」,等于同一屏两个入口。 */
401
435
  <Card className={`${css.activityCard} ${css.fillCard}`}>
402
436
  <span className={css.groupLabel}>{t('overview.empty.title')}</span>
403
- <span className={css.hint} style={{ display: 'block', marginBottom: 10 }}>{t('overview.empty.body')}</span>
404
- {/* 三步主线(备份 → 导出 → 导入):顺序来自 overviewFirstSteps(),组件不写死 */}
405
437
  <div className={css.emptySteps}>
406
438
  <Stepper
407
439
  steps={overviewFirstSteps().map((s, i) => ({
@@ -411,12 +443,6 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
411
443
  }))}
412
444
  />
413
445
  </div>
414
- <div className={css.toolRow}>
415
- <Button variant="primary" disabled={backupRunning} onClick={() => { void runBackupNow() }}>
416
- {t('overview.quick.backup')}
417
- </Button>
418
- <Button onClick={() => { navPanel('sync') }}>{t('overview.quick.sync')}</Button>
419
- </div>
420
446
  </Card>
421
447
  ) : (
422
448
  <>
@@ -211,13 +211,6 @@ export function ForeignImportView({ api, t, uiT, onReady }: ForeignImportViewPro
211
211
  <Banner kind="info">{t('foreign.source.empty')}</Banner>
212
212
  )}
213
213
 
214
- {/* 冲突策略(用户决策:同 id 不覆盖、跳过并报码)——
215
- 在用户点下去**之前**就把规则说清楚,而不是等他遇到冲突才解释。 */}
216
- {vm !== null && vm.conflictPolicy !== null && (
217
- <div className={css.hint}>{t('foreign.conflict.policy')}</div>
218
- )}
219
-
220
-
221
214
  <div className={css.marketReviewPage}>
222
215
  <div className={css.marketReviewScroll}>
223
216
  <div className={css.sourceList}>
@@ -236,6 +229,12 @@ export function ForeignImportView({ api, t, uiT, onReady }: ForeignImportViewPro
236
229
  </div>
237
230
  </div>
238
231
 
232
+ {/* 冲突规则紧贴来源列表:它约束的是「选中来源之后会发生什么」,属于列表的脚注,
233
+ 不再作为页面开场白横在列表之前(开场白会被读成页面介绍,而不是决策依据)。 */}
234
+ {vm !== null && vm.conflictPolicy !== null && (
235
+ <div className={css.hint}>{t('foreign.conflict.policy')}</div>
236
+ )}
237
+
239
238
  {/* 三类已知有损(F-5):不阻断导入,也不会出现在兼容性评分里(评分对它们零原因)。
240
239
  三条文案与判定都在纯函数 foreignLossyRows 里,这里只摆放;产包后逐条标注本包是否
241
240
  适用(未产包时不给徽章,绝不写「不适用」)。
@@ -80,6 +80,8 @@ export interface ImportWizardViewProps {
80
80
  * - 补录凭据(ref 名清单,非值;无值展示,安全不变量不破);
81
81
  * - 失败/跳过项(count 传达「可重试」,明细仍在 ReportView 内联报告里)。
82
82
  * 三组均无内容 → 显示「全部完成」ok Banner(替代旧版单行 needsRestart 提示)。
83
+ * 每组只有**一行标题句(含计数)+ 明细列表**:原先「组标题 + 复述同一件事的提示句」两层
84
+ * 结构已合并 —— 计数与原因写在同一行,列表紧随其后。
83
85
  */
84
86
  function NextStepsCard({ plan, result, t }: {
85
87
  plan: ImportPlan | null
@@ -100,8 +102,7 @@ function NextStepsCard({ plan, result, t }: {
100
102
  <div className={css.groupLabel}>{t('nextSteps.title')}</div>
101
103
  {steps.restartItems.length > 0 && (
102
104
  <div className={css.nextStepsGroup}>
103
- <div className={css.groupLabel}>{t('nextSteps.restart.title', { count: String(steps.restartItems.length) })}</div>
104
- <div className={css.hint}>{t('nextSteps.restart.hint')}</div>
105
+ <div className={css.hint}>{t('nextSteps.restart.hint', { count: String(steps.restartItems.length) })}</div>
105
106
  <ul className={`${css.reportList} ${css.nextStepsList}`}>
106
107
  {steps.restartItems.map((item) => (
107
108
  // description 由宿主按计划项拼装(可能含 MCP env/headers 等本地配置片段)→ 渲染前过 redact
@@ -112,14 +113,12 @@ function NextStepsCard({ plan, result, t }: {
112
113
  )}
113
114
  {steps.sessionsRestart !== null && (
114
115
  <div className={css.nextStepsGroup}>
115
- <div className={css.groupLabel}>{t('nextSteps.sessionsRestart.title')}</div>
116
116
  <div className={css.hint}>{t('nextSteps.sessionsRestart.hint', { count: String(steps.sessionsRestart.count) })}</div>
117
117
  </div>
118
118
  )}
119
119
  {steps.missingSecrets.length > 0 && (
120
120
  <div className={css.nextStepsGroup}>
121
- <div className={css.groupLabel}>{t('nextSteps.secrets.title', { count: String(steps.missingSecrets.length) })}</div>
122
- <div className={css.hint}>{t('nextSteps.secrets.hint')}</div>
121
+ <div className={css.hint}>{t('nextSteps.secrets.hint', { count: String(steps.missingSecrets.length) })}</div>
123
122
  <ul className={`${css.reportList} ${css.nextStepsList}`}>
124
123
  {steps.missingSecrets.map((ref) => <li key={ref}>{ref}</li>)}
125
124
  </ul>
@@ -127,8 +126,7 @@ function NextStepsCard({ plan, result, t }: {
127
126
  )}
128
127
  {steps.unresolved.length > 0 && (
129
128
  <div className={css.nextStepsGroup}>
130
- <div className={css.groupLabel}>{t('nextSteps.unresolved.title', { count: String(steps.unresolved.length) })}</div>
131
- <div className={css.hint}>{t('nextSteps.unresolved.hint')}</div>
129
+ <div className={css.hint}>{t('nextSteps.unresolved.hint', { count: String(steps.unresolved.length) })}</div>
132
130
  </div>
133
131
  )}
134
132
  </Card>
@@ -312,7 +310,6 @@ function ContentSelectStage(props: {
312
310
  />
313
311
  </Card>
314
312
  )}
315
- {nothingSelected && <Banner kind="warn">{t('import.nothingSelected')}</Banner>}
316
313
  <div className={css.actionRow}>
317
314
  <Button variant="ghost" onClick={onReset}>{t('import.select.reselect')}</Button>
318
315
  {/* 阻断态下「下一步」不再是导航,而是「按新处置重新生成计划」——同一个动作,
@@ -320,6 +317,7 @@ function ContentSelectStage(props: {
320
317
  <Button
321
318
  variant="primary"
322
319
  disabled={nothingSelected}
320
+ title={nothingSelected ? t('import.nothingSelected') : undefined}
323
321
  onClick={sessionFormatBlocked ? onRetryPlan : onNext}
324
322
  >
325
323
  {sessionFormatBlocked ? t('import.sessionFormat.retryPlan') : t('common.next')}
@@ -355,8 +353,9 @@ function ImportingStep(props: {
355
353
  </Button>
356
354
  </div>
357
355
  )}
356
+ {/* 进度与阶段由 ProgressBar + 日志面板表达(原来的「正在导入…(可能需要几分钟,请勿关闭页面)」
357
+ 只是在同屏把进度再说一遍);跳过是**另一个动作的状态**,仍单独给一行。 */}
358
358
  {skipRequested && <div className={css.hint}>{t('import.skipPending')}</div>}
359
- <div className={css.hint}>{t('import.importing')}</div>
360
359
  {/* P0-8:失败后(running=false)必须给出重试入口 —— 与 ExportView 的
361
360
  ErrorBanner(onRetry) 对齐,并保留执行中不暴露重试(避免重复启动 run)。
362
361
  正常路径下向导已把 step 退回确认页(那里也有重试按钮),这里是兜底。 */}
@@ -13,7 +13,7 @@ import type { ConsultReport } from '../../core/migration-consult.ts'
13
13
  import type { TranslateNS } from '../client-types.ts'
14
14
  import type { UiT } from '../../ui/i18n.ts'
15
15
  import {
16
- Banner, Button, Card, Checkbox, Empty, SectionTitle,
16
+ Badge, Banner, Button, Card, Checkbox, Empty, SectionTitle,
17
17
  } from '../common/ui.tsx'
18
18
  import { Skeleton } from '../common/Skeleton.tsx'
19
19
  import { InfoHint } from '../common/InfoHint.tsx'
@@ -121,8 +121,10 @@ export function SelectStep(props: {
121
121
  }}
122
122
  />
123
123
  {selectModel.selectedName !== null && (
124
- <div className={css.hint} data-testid="import-selected-file">
125
- {t('import.select.file', { name: selectModel.selectedName })}
124
+ /* 已选文件是**值**,不是说明:用徽章承载(原先是一句「已选择:xxx」),
125
+ data-testid 保持不变以便既有守卫继续定位。 */
126
+ <div className={css.statRow} data-testid="import-selected-file">
127
+ <Badge kind="info">{t('import.select.file', { name: selectModel.selectedName })}</Badge>
126
128
  </div>
127
129
  )}
128
130
  <div className={css.actionRow} style={{ marginBottom: 0 }}>
@@ -193,7 +195,7 @@ export function ConsultStage(props: {
193
195
  <div className={css.actionRow}>
194
196
  <Button variant="ghost" onClick={onReset}>{t('import.select.reselect')}</Button>
195
197
  <Button variant="primary" onClick={onNext}>
196
- {t('import.consult.next')}
198
+ {t('common.next')}
197
199
  </Button>
198
200
  </div>
199
201
  </div>
@@ -372,8 +374,8 @@ export function SessionFormatDispositionField(props: {
372
374
  ))}
373
375
  </div>
374
376
  <div className={css.hint}>{options.find((o) => o.value === disposition)?.hint ?? ''}</div>
375
- {/* 升级/重导出的指引 + 可复制命令:三种处置下都摆出来(guide 不阻塞,但也需要它) */}
376
- <div className={css.groupLabel}>{t('import.sessionFormat.guide.title')}</div>
377
+ {/* 升级/重导出的指引 + 可复制命令:三种处置下都摆出来(guide 不阻塞,但也需要它)。
378
+ 两条 <li> 自身就是完整指令,不再加「怎么让这些对话可见」这层分组标题。 */}
377
379
  <ul className={css.reportList}>
378
380
  <li>{t('import.sessionFormat.guide.upgrade', { newer: String(newest), target: String(facts.target) })}</li>
379
381
  <li>{t('import.sessionFormat.guide.doctor')}</li>
@@ -427,16 +429,19 @@ export function ConfirmStage(props: {
427
429
  {isEncrypted && decryptRefs.length > 0 && (
428
430
  <Banner kind="ok">{t('import.confirm.encrypted', { count: String(decryptRefs.length) })}</Banner>
429
431
  )}
430
- {/* UI-14:将导入的分区/条目合计(与预览步选择器同源)+ 被用户取消的项数 */}
431
- <div className={css.hint}>
432
- {t('picker.summaryImport', {
433
- sections: String(summary.sections),
434
- units: String(summary.units),
435
- })}
432
+ {/* UI-14:将导入的合计 + 被取消勾选的项数 —— 两个**计数**,做成并列徽章
433
+ (原先两行说明句;计数属结构化信息,不需要句子外壳)。 */}
434
+ <div className={css.statRow}>
435
+ <Badge kind="info">
436
+ {t('picker.summaryImport', {
437
+ sections: String(summary.sections),
438
+ units: String(summary.units),
439
+ })}
440
+ </Badge>
441
+ {excludedCount > 0 && (
442
+ <Badge kind="warn">{t('import.excludedByUser', { count: String(excludedCount) })}</Badge>
443
+ )}
436
444
  </div>
437
- {excludedCount > 0 && (
438
- <div className={css.hint}>{t('import.excludedByUser', { count: String(excludedCount) })}</div>
439
- )}
440
445
  {/* T1:确认页是最后一道闸门 —— 处置选择在这里仍可改(改完点「确认导入」时会用新处置
441
446
  重新生成计划,abort 仍然在计划阶段被拦住)。 */}
442
447
  <SessionFormatDispositionField
@@ -31,15 +31,6 @@ export function BackupInspectView({ result, t }: BackupInspectViewProps) {
31
31
  case 'others': return 'backupFiles.inspectGroup.others'
32
32
  }
33
33
  }
34
- // kindTag 颜色变体(kind → CSS 类;颜色语义见 backup-inspect.ts InspectChangeGroup.kind)
35
- const kindTagClass = (kind: 'error' | 'info' | 'warn' | 'ok'): string => {
36
- switch (kind) {
37
- case 'error': return css.kindTagError ?? ''
38
- case 'warn': return css.kindTagWarn ?? ''
39
- case 'ok': return css.kindTagOk ?? ''
40
- case 'info': return css.kindTagInfo ?? ''
41
- }
42
- }
43
34
  const groups = inspectGroupedChanges(summary)
44
35
  return (
45
36
  <div>
@@ -80,8 +71,9 @@ export function BackupInspectView({ result, t }: BackupInspectViewProps) {
80
71
  <ul className={css.reportList}>
81
72
  {group.items.map((item) => (
82
73
  <li key={item.id}>
83
- <span className={`${css.kindTag} ${kindTagClass(group.kind)}`}>{item.kind}</span>
84
- {/* 差异查看的计划项文本由宿主拼装 → 渲染前过 redact(安全自查) */}
74
+ {/* 逐项不再重复 kind:分组标题已按 kind 命名,裸枚举值(Create/Skip…)没有第二种说法。
75
+ 前导 {' '} 是 plan-text-redaction 逐字锚点的一部分,别删(见该表 snapshot-inspect-description)。
76
+ 差异查看的计划项文本由宿主拼装 → 渲染前过 redact(安全自查) */}
85
77
  {' '}{item.adapter}: {redact(item.description)}
86
78
  </li>
87
79
  ))}
@@ -614,7 +614,8 @@ function pullProgressLabel(mode: PullState['mode'], t: TranslateNS<'config-manag
614
614
  * 地址从 /sync/status 现取,与产物库加载远端列表同源。
615
615
  */
616
616
  async function remotePayload(syncApi: SyncApi): Promise<{ transport: 'git'; repoUrl: string } | { transport: 'webdav'; url: string }> {
617
- const status = await syncApi.status()
617
+ // 只要通道与地址,不要工作副本的 ahead/dirty/远端快照(省 5 次 git 子进程启动)
618
+ const status = await syncApi.status({ workDirDetail: false })
618
619
  const isWebdav = status.lastSyncChannel === 'webdav'
619
620
  const webdavUrl = (status.webdav?.url ?? '').trim()
620
621
  const repoUrl = (status.repoUrl ?? '').trim()
@@ -116,7 +116,7 @@ export function LibraryPanel({ api, syncApi, marketApi, t, uiT, onAction, onOpen
116
116
  // 远端快照:payload 必须带通道与地址 —— 宿主 prepareSync 需要 repoUrl/url,
117
117
  // 只传 {} 会被 400 `repoUrl is required` 挡回(此前该源恒失败,页面显示「1 个来源读取失败」)。
118
118
  // 先取 /sync/status 拿到通道与地址,再据此发请求;两处都不可用时该源如实标失败,绝不空手请求。
119
- put(setRemote, syncApi.status().then((status) => {
119
+ put(setRemote, syncApi.status({ workDirDetail: false }).then((status) => {
120
120
  if (!mounted.current) return [] as SyncSnapshotLite[]
121
121
  setRemoteChannel(status.lastSyncChannel === 'webdav' ? 'webdav' : status.lastSyncChannel === 'git' ? 'git' : null)
122
122
  const configured = status.configured === true
@@ -218,9 +218,12 @@ export function LibraryPanel({ api, syncApi, marketApi, t, uiT, onAction, onOpen
218
218
  onChange={(event) => { runStore.patch({ library: { query: event.target.value } }) }}
219
219
  />
220
220
 
221
- {/* 快照目录读不出来(不是「没有快照」):与来源失败同一层级地如实说明 */}
221
+ {/* 快照目录读不出来(不是「没有快照」):状态用 warn 徽章 + 行内原因,不写成一句话 */}
222
222
  {snapshotsUnreadable !== null && (
223
- <div className={css.hint}>{t('library.snapshotsUnreadable')}{t('common.labelSeparator')}{redact(snapshotsUnreadable)}</div>
223
+ <div className={css.actionRow}>
224
+ <Badge kind="warn">{t('library.snapshotsUnreadable')}</Badge>
225
+ <span className={css.cellMetaNoteText}>{redact(snapshotsUnreadable)}</span>
226
+ </div>
224
227
  )}
225
228
 
226
229
  {/* 某个来源读不到:一条 warn + 重试,既不静默跳过,也不整页报错(§6.7) */}