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.
- package/lib/adapters/file-collection.d.ts +9 -2
- package/lib/adapters/file-collection.js +22 -3
- package/lib/adapters/self.d.ts +7 -3
- package/lib/client.d.ts +153 -399
- package/lib/client.js +1526 -2229
- package/lib/core/messages.d.ts +1 -0
- package/lib/core/messages.js +2 -0
- package/lib/core/restore.js +7 -1
- package/lib/index.d.ts +6 -4
- package/lib/index.js +7 -7
- package/lib/routes/sync.js +92 -70
- package/lib/sync/git/git-transport.d.ts +2 -0
- package/lib/sync/git/git-transport.js +75 -22
- package/lib/sync/messages.d.ts +2 -0
- package/lib/sync/messages.js +4 -0
- package/lib/sync/sync-engine.d.ts +4 -4
- package/lib/sync/sync-engine.js +3 -4
- package/lib/ui/i18n.d.ts +2 -84
- package/lib/ui/i18n.js +4 -168
- package/package.json +1 -1
- package/src/adapters/file-collection.ts +22 -3
- package/src/adapters/self.test.ts +43 -0
- package/src/adapters/self.ts +7 -3
- package/src/client/ConfigManagerSection.tsx +1 -18
- package/src/client/about/AboutPanel.tsx +4 -11
- package/src/client/about/ReleaseNotesDialog.tsx +1 -2
- package/src/client/chat/chat-locales.ts +0 -8
- package/src/client/common/CommandPalette.tsx +0 -1
- package/src/client/common/ContentPicker.tsx +10 -1
- package/src/client/common/info-hint-guard.test.ts +4 -4
- package/src/client/config-manager.module.css +2 -32
- package/src/client/environment/EnvironmentPanel.tsx +10 -9
- package/src/client/environment/maintenance/DiskUsageCard.tsx +10 -10
- package/src/client/export/ExportView.tsx +10 -13
- package/src/client/history/HistoryPanel.tsx +9 -11
- package/src/client/history/history-locales.ts +4 -40
- package/src/client/home/BackupScheduleCard.tsx +0 -2
- package/src/client/home/HomePanel.tsx +75 -49
- package/src/client/import/ForeignImportView.tsx +6 -7
- package/src/client/import/ImportWizardView.tsx +8 -9
- package/src/client/import/import-wizard-steps.tsx +20 -15
- package/src/client/library/BackupInspectView.tsx +3 -11
- package/src/client/library/LibraryActions.tsx +2 -1
- package/src/client/library/LibraryPanel.tsx +6 -3
- package/src/client/locales.ts +122 -278
- package/src/client/market/MarketImportReview.tsx +6 -5
- package/src/client/market/MarketPanel.tsx +2 -2
- package/src/client/market/MyConfigsLoginCard.tsx +0 -3
- package/src/client/market/MyConfigsView.tsx +3 -4
- package/src/client/market/MyConfigsWizard.tsx +8 -4
- package/src/client/market/market-locales.ts +20 -60
- package/src/client/recovery/IncompleteCopiesSection.tsx +6 -8
- package/src/client/recovery/RecoveryPanel.tsx +48 -73
- package/src/client/recovery/RollbackCard.tsx +27 -20
- package/src/client/recovery/recovery-locales.test.ts +1 -3
- package/src/client/recovery/recovery-locales.ts +71 -133
- package/src/client/recovery/recovery-panel-layout.test.ts +4 -2
- package/src/client/sync/DecryptPasswordCard.tsx +2 -2
- package/src/client/sync/MergeModal.tsx +3 -1
- package/src/client/sync/MergePreviewView.tsx +14 -16
- package/src/client/sync/PushPreviewCard.tsx +0 -1
- package/src/client/sync/SecurityOptionsCard.tsx +5 -1
- package/src/client/sync/SyncChannelEntryCard.tsx +3 -10
- package/src/client/sync/SyncConfirmView.tsx +23 -20
- package/src/client/sync/SyncLogList.tsx +5 -2
- package/src/client/sync/SyncPanel.tsx +10 -9
- package/src/client/sync/SyncProbeRow.tsx +6 -8
- package/src/client/sync/SyncSectionPickerDialog.tsx +3 -3
- package/src/client/sync/WorkDirCard.tsx +6 -7
- package/src/client/sync/preview-failure.test.ts +58 -0
- package/src/client/sync/preview-failure.ts +47 -0
- package/src/client/sync/sync-api.ts +19 -3
- package/src/client/sync/sync-entry.test.ts +6 -0
- package/src/client/sync/sync-entry.ts +4 -1
- package/src/client/sync/sync-locales.ts +31 -147
- package/src/client/sync/sync-push-preview.test.ts +1 -1
- package/src/client/sync/sync-view.ts +1 -4
- package/src/core/messages.ts +2 -0
- package/src/core/restore.ts +8 -1
- package/src/index.ts +8 -8
- package/src/routes/sync.ts +91 -69
- package/src/sync/git/git-clone-auth.test.ts +61 -0
- package/src/sync/git/git-transport.test.ts +75 -1
- package/src/sync/git/git-transport.ts +74 -20
- package/src/sync/messages.ts +4 -0
- package/src/sync/sync-engine.ts +6 -6
- package/src/sync/sync-preview-empty.test.ts +58 -0
- 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.
|
|
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}
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
157
|
-
const
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
{
|
|
398
|
+
{segLoading(seg.key)
|
|
362
399
|
? <span className={css.skeletonBar + ' ' + css.statValueSkeleton} aria-hidden="true" />
|
|
363
400
|
: <b>{seg.value}</b>}
|
|
364
|
-
{!
|
|
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
|
|
417
|
+
<Button onClick={openExportTask}>
|
|
382
418
|
<ExportIcon size={14} /> {t('nav.export')} ZIP
|
|
383
419
|
</Button>
|
|
384
|
-
<Button
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
125
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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.
|
|
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) */}
|