dsh-config-manager 0.1.19 → 0.1.21
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/client.d.ts +319 -2
- package/lib/client.js +1271 -109
- package/lib/client.js.map +1 -1
- package/lib/core/run-registry.d.ts +2 -2
- package/lib/core/run-registry.js +5 -1
- package/lib/core/run-registry.js.map +1 -1
- package/lib/index.js +408 -2
- package/lib/index.js.map +1 -1
- package/lib/sync/ancestor.d.ts +17 -0
- package/lib/sync/ancestor.js +64 -0
- package/lib/sync/ancestor.js.map +1 -0
- package/lib/sync/autosync-config.d.ts +34 -0
- package/lib/sync/autosync-config.js +116 -0
- package/lib/sync/autosync-config.js.map +1 -0
- package/lib/sync/autosync-scheduler.d.ts +95 -0
- package/lib/sync/autosync-scheduler.js +379 -0
- package/lib/sync/autosync-scheduler.js.map +1 -0
- package/lib/sync/merge.d.ts +42 -0
- package/lib/sync/merge.js +325 -0
- package/lib/sync/merge.js.map +1 -0
- package/lib/sync/review-queue.d.ts +51 -0
- package/lib/sync/review-queue.js +119 -0
- package/lib/sync/review-queue.js.map +1 -0
- package/lib/sync/risk.d.ts +59 -0
- package/lib/sync/risk.js +76 -0
- package/lib/sync/risk.js.map +1 -0
- package/lib/sync/sync-config.d.ts +7 -2
- package/lib/sync/sync-config.js +24 -4
- package/lib/sync/sync-config.js.map +1 -1
- package/lib/sync/sync-engine.d.ts +88 -2
- package/lib/sync/sync-engine.js +333 -8
- package/lib/sync/sync-engine.js.map +1 -1
- package/lib/sync/sync-history.d.ts +39 -0
- package/lib/sync/sync-history.js +88 -0
- package/lib/sync/sync-history.js.map +1 -0
- package/lib/sync/sync-session.d.ts +39 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-session.js.map +1 -0
- package/lib/sync/sync-state.d.ts +7 -2
- package/lib/sync/sync-state.js +9 -5
- package/lib/sync/sync-state.js.map +1 -1
- package/lib/ui/i18n.d.ts +63 -0
- package/lib/ui/i18n.js +130 -1
- package/lib/ui/i18n.js.map +1 -1
- package/package.json +1 -1
- package/src/client/config-manager.module.css +18 -0
- package/src/client/sync/SyncConfirmView.tsx +301 -0
- package/src/client/sync/SyncHistoryView.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +155 -0
- package/src/client/sync/SyncSettingsView.tsx +241 -12
- package/src/client/sync/history-model.test.ts +82 -0
- package/src/client/sync/history-model.ts +112 -0
- package/src/client/sync/sync-api.test.ts +155 -0
- package/src/client/sync/sync-api.ts +219 -0
- package/src/client/sync/sync-locales.ts +162 -1
- package/src/client/sync/sync-view-v2.test.ts +131 -0
- package/src/client/sync/sync-view.ts +151 -4
- package/src/core/run-registry.ts +7 -3
- package/src/index.ts +420 -3
- package/src/sync/ancestor.test.ts +152 -0
- package/src/sync/ancestor.ts +81 -0
- package/src/sync/autosync-config.test.ts +93 -0
- package/src/sync/autosync-config.ts +137 -0
- package/src/sync/autosync-scheduler.test.ts +191 -0
- package/src/sync/autosync-scheduler.ts +443 -0
- package/src/sync/merge.test.ts +204 -0
- package/src/sync/merge.ts +360 -0
- package/src/sync/review-queue.test.ts +120 -0
- package/src/sync/review-queue.ts +167 -0
- package/src/sync/risk.test.ts +131 -0
- package/src/sync/risk.ts +122 -0
- package/src/sync/sync-config.test.ts +106 -0
- package/src/sync/sync-config.ts +23 -4
- package/src/sync/sync-engine.test.ts +392 -3
- package/src/sync/sync-engine.ts +383 -10
- package/src/sync/sync-history.test.ts +85 -0
- package/src/sync/sync-history.ts +126 -0
- package/src/sync/sync-session.test.ts +137 -0
- package/src/sync/sync-session.ts +76 -0
- package/src/sync/sync-state.test.ts +48 -2
- package/src/sync/sync-state.ts +11 -5
- package/src/ui/i18n.ts +132 -3
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* + gitBin(可选);
|
|
8
8
|
* - 私有仓库强制提示横幅(常驻);
|
|
9
9
|
* - 推送按钮 → SyncPushReport(快照 id / 分区 / 告警);
|
|
10
|
-
* - 拉取按钮 → SyncPullReport.changes 差异摘要(description/kind/severity
|
|
11
|
-
*
|
|
10
|
+
* - 拉取按钮 → SyncPullReport.changes 差异摘要(description/kind/severity);
|
|
11
|
+
* - 【方案 A】一键同步主按钮:拉取 → 差异确认会话(SyncConfirmView 逐项确认)→
|
|
12
|
+
* 确认导入(apply-items)→ 执行结果 + 一键回滚(restoreId);「选择历史快照」下拉;
|
|
13
|
+
* - 【方案 A】自动同步设置区块:总开关 + 间隔下拉 + 状态(上次运行 / 下次倒计时);
|
|
12
14
|
* - 状态行:凭据配置 + 上次同步时间 + 通道(来自 GET /sync/status,组件挂载时加载)。
|
|
13
15
|
*
|
|
14
16
|
* 全部渲染模型来自 ./sync-view.ts 纯函数(node 单测覆盖),组件只做装配;
|
|
@@ -22,12 +24,18 @@ import type { SyncPullReport, SyncPushReport } from '../../sync/sync-engine.ts'
|
|
|
22
24
|
import { Badge, Banner, Button, Card, SectionTitle, Spinner } from '../common/ui.tsx'
|
|
23
25
|
import { ErrorBanner } from '../common/ErrorBanner.tsx'
|
|
24
26
|
import { SYNC_CREDENTIAL_REF } from './sync-api.ts'
|
|
25
|
-
import type {
|
|
27
|
+
import type {
|
|
28
|
+
AutosyncInterval, AutosyncStatusResponse, SyncApi, SyncSnapshotLite, SyncStartResponse,
|
|
29
|
+
SyncStatusResponse,
|
|
30
|
+
} from './sync-api.ts'
|
|
26
31
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
autosyncIntervalMs, autosyncStatusText, computeAutosyncCountdown, computeGithubLoginView,
|
|
33
|
+
computeSyncButtons, computeSyncStatus, formatIntervalDuration, githubPollMessage, kindLabel,
|
|
34
|
+
privateRepoHint, pullReportView, pushReportView, severityLabel,
|
|
29
35
|
} from './sync-view.ts'
|
|
30
36
|
import type { GithubLoginPhase } from './sync-view.ts'
|
|
37
|
+
import { SyncHistoryView } from './SyncHistoryView.tsx'
|
|
38
|
+
import { SyncConfirmView } from './SyncConfirmView.tsx'
|
|
31
39
|
import css from '../config-manager.module.css'
|
|
32
40
|
|
|
33
41
|
export interface SyncSettingsViewProps {
|
|
@@ -43,9 +51,23 @@ interface SyncUiState {
|
|
|
43
51
|
gitBin: string
|
|
44
52
|
/** 仅内存:成功后清空(已写入 DSH credentials),绝不持久化 */
|
|
45
53
|
token: string
|
|
46
|
-
busy: 'push' | 'pull' | null
|
|
54
|
+
busy: 'sync' | 'push' | 'pull' | 'rollback' | null
|
|
47
55
|
pushReport: SyncPushReport | null
|
|
48
56
|
pullReport: SyncPullReport | null
|
|
57
|
+
/** 一键同步差异确认会话(POST /sync/sync 结果;非空时渲染 SyncConfirmView) */
|
|
58
|
+
confirmSession: SyncStartResponse | null
|
|
59
|
+
/** 远端历史快照列表(「选择历史快照」下拉数据源) */
|
|
60
|
+
snapshots: SyncSnapshotLite[]
|
|
61
|
+
/** 当前选中的历史快照 id('' = 最新) */
|
|
62
|
+
selectedSnapshotId: string
|
|
63
|
+
/** 自动同步状态 */
|
|
64
|
+
autosync: AutosyncStatusResponse | null
|
|
65
|
+
/** 自动同步开关(回填自 autosync) */
|
|
66
|
+
autosyncEnabled: boolean
|
|
67
|
+
/** 自动同步间隔(回填自 autosync) */
|
|
68
|
+
autosyncInterval: AutosyncInterval
|
|
69
|
+
/** 最近一次一键同步执行结果(回滚入口) */
|
|
70
|
+
lastRestoreId: string | null
|
|
49
71
|
error: string | null
|
|
50
72
|
/** GitHub OAuth device flow 状态(flowId/userCode 仅内存,token 只存宿主) */
|
|
51
73
|
github: GithubUiState
|
|
@@ -65,6 +87,8 @@ const initialGithub: GithubUiState = {
|
|
|
65
87
|
phase: 'idle', flowId: '', userCode: '', verificationUri: '', interval: 5, error: null,
|
|
66
88
|
}
|
|
67
89
|
|
|
90
|
+
const AUTOSYNC_INTERVAL_OPTIONS: AutosyncInterval[] = ['5m', '15m', '30m', '60m', '6h', '12h', '24h'];
|
|
91
|
+
|
|
68
92
|
const initial: SyncUiState = {
|
|
69
93
|
loading: true,
|
|
70
94
|
loadError: null,
|
|
@@ -75,6 +99,13 @@ const initial: SyncUiState = {
|
|
|
75
99
|
busy: null,
|
|
76
100
|
pushReport: null,
|
|
77
101
|
pullReport: null,
|
|
102
|
+
confirmSession: null,
|
|
103
|
+
snapshots: [],
|
|
104
|
+
selectedSnapshotId: '',
|
|
105
|
+
autosync: null,
|
|
106
|
+
autosyncEnabled: false,
|
|
107
|
+
autosyncInterval: '30m',
|
|
108
|
+
lastRestoreId: null,
|
|
78
109
|
error: null,
|
|
79
110
|
github: initialGithub,
|
|
80
111
|
}
|
|
@@ -87,17 +118,53 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
|
|
|
87
118
|
/** GitHub 轮询定时器(卸载/取消时清理,防止泄漏与跨流程串扰) */
|
|
88
119
|
const githubPollTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
89
120
|
|
|
90
|
-
/** 挂载时读取同步状态(配置回填 + 上次同步时间 +
|
|
121
|
+
/** 挂载时读取同步状态(配置回填 + 上次同步时间 + 凭据状态 + autosync) */
|
|
91
122
|
const loadStatus = async (): Promise<void> => {
|
|
92
123
|
patch({ loading: true, loadError: null })
|
|
93
124
|
try {
|
|
94
125
|
const info = await api.status()
|
|
95
|
-
patch({
|
|
126
|
+
patch({
|
|
127
|
+
loading: false,
|
|
128
|
+
statusInfo: info,
|
|
129
|
+
repoUrl: info.repoUrl ?? '',
|
|
130
|
+
gitBin: info.gitBin ?? '',
|
|
131
|
+
...(info.autosync !== undefined
|
|
132
|
+
? {
|
|
133
|
+
autosync: info.autosync,
|
|
134
|
+
autosyncEnabled: info.autosync.enabled,
|
|
135
|
+
autosyncInterval: info.autosync.interval,
|
|
136
|
+
}
|
|
137
|
+
: {}),
|
|
138
|
+
})
|
|
139
|
+
// 独立拉取 autosync(若 status 未带则补一次)
|
|
140
|
+
if (info.autosync === undefined) {
|
|
141
|
+
void loadAutosync()
|
|
142
|
+
}
|
|
96
143
|
} catch (err) {
|
|
97
144
|
patch({ loading: false, loadError: err instanceof Error ? err.message : String(err) })
|
|
98
145
|
}
|
|
99
146
|
}
|
|
100
147
|
|
|
148
|
+
/** 读取自动同步状态(GET /sync/autosync)。 */
|
|
149
|
+
const loadAutosync = async (): Promise<void> => {
|
|
150
|
+
try {
|
|
151
|
+
const autosync = await api.autosyncStatus()
|
|
152
|
+
patch({ autosync, autosyncEnabled: autosync.enabled, autosyncInterval: autosync.interval })
|
|
153
|
+
} catch (err) {
|
|
154
|
+
patch({ error: err instanceof Error ? err.message : String(err) })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 读取远端历史快照列表(「选择历史快照」下拉数据源)。 */
|
|
159
|
+
const loadSnapshots = async (): Promise<void> => {
|
|
160
|
+
try {
|
|
161
|
+
const res = await api.snapshotsList(payload())
|
|
162
|
+
patch({ snapshots: res.snapshots })
|
|
163
|
+
} catch {
|
|
164
|
+
// 拉取失败不阻断主流程(下拉留空,用户可重试)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
101
168
|
useEffect(() => {
|
|
102
169
|
void loadStatus()
|
|
103
170
|
// api 为注入单例(注册时创建),生命周期内稳定;仅挂载时加载一次
|
|
@@ -199,6 +266,60 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
|
|
|
199
266
|
}
|
|
200
267
|
}
|
|
201
268
|
|
|
269
|
+
/* ------------------------------------------------ 一键同步(方案 A) */
|
|
270
|
+
|
|
271
|
+
/** 一键同步:拉取 → 差异确认会话(先取消旧会话,再发起新会话)。 */
|
|
272
|
+
const runSync = async (snapshotId?: string): Promise<void> => {
|
|
273
|
+
// 清理旧的差异确认会话(避免残留临时 ZIP / 同 key 冲突)
|
|
274
|
+
if (state.confirmSession !== null) {
|
|
275
|
+
try { await api.cancel(state.confirmSession.syncSessionId) } catch { /* 尽力清理 */ }
|
|
276
|
+
}
|
|
277
|
+
patch({ busy: 'sync', error: null, confirmSession: null, lastRestoreId: null })
|
|
278
|
+
try {
|
|
279
|
+
const session = await api.sync({ ...payload(), ...(snapshotId !== undefined && snapshotId !== '' ? { snapshotId } : {}) })
|
|
280
|
+
if (!session.ok) {
|
|
281
|
+
patch({ busy: null, error: session.message ?? t('syncflow.syncFailed') })
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
patch({ busy: null, confirmSession: session, token: '' })
|
|
285
|
+
void loadSnapshots()
|
|
286
|
+
} catch (err) {
|
|
287
|
+
patch({ busy: null, error: err instanceof Error ? err.message : String(err) })
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 用户取消差异确认:清除会话,复位到空闲。 */
|
|
292
|
+
const cancelConfirm = (): void => {
|
|
293
|
+
patch({ confirmSession: null })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 从 SyncConfirmView 透传的一键回滚完成信号。 */
|
|
297
|
+
const onRollbackApplied = (): void => {
|
|
298
|
+
patch({ lastRestoreId: null })
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* ------------------------------------------------ 自动同步(方案 A) */
|
|
302
|
+
|
|
303
|
+
const toggleAutosync = async (enabled: boolean): Promise<void> => {
|
|
304
|
+
patch({ autosyncEnabled: enabled, error: null })
|
|
305
|
+
try {
|
|
306
|
+
const updated = await api.autosyncUpdate({ enabled, interval: state.autosyncInterval })
|
|
307
|
+
patch({ autosync: updated, autosyncEnabled: updated.enabled, autosyncInterval: updated.interval })
|
|
308
|
+
} catch (err) {
|
|
309
|
+
patch({ error: err instanceof Error ? err.message : String(err) })
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const updateAutosyncInterval = async (interval: AutosyncInterval): Promise<void> => {
|
|
314
|
+
patch({ autosyncInterval: interval, error: null })
|
|
315
|
+
try {
|
|
316
|
+
const updated = await api.autosyncUpdate({ enabled: state.autosyncEnabled, interval })
|
|
317
|
+
patch({ autosync: updated, autosyncEnabled: updated.enabled, autosyncInterval: updated.interval })
|
|
318
|
+
} catch (err) {
|
|
319
|
+
patch({ error: err instanceof Error ? err.message : String(err) })
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
202
323
|
const status = computeSyncStatus(state.statusInfo, state.loading, state.loadError, uiT)
|
|
203
324
|
const buttons = computeSyncButtons(state.busy, state.repoUrl, uiT)
|
|
204
325
|
const pushView = pushReportView(state.pushReport, uiT)
|
|
@@ -210,6 +331,11 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
|
|
|
210
331
|
const githubBusy =
|
|
211
332
|
state.github.phase === 'starting' || state.github.phase === 'waiting' || state.github.phase === 'polling'
|
|
212
333
|
|
|
334
|
+
const autosyncText = state.autosync !== null ? autosyncStatusText(state.autosync, uiT) : t('autosync.statusNever')
|
|
335
|
+
const autosyncCountdown = state.autosync !== null && state.autosync.elapsedMs >= 0
|
|
336
|
+
? formatIntervalDuration(computeAutosyncCountdown(state.autosync.elapsedMs, autosyncIntervalMs(state.autosync.interval)), uiT)
|
|
337
|
+
: null
|
|
338
|
+
|
|
213
339
|
return (
|
|
214
340
|
<div className={css.viewBody}>
|
|
215
341
|
<SectionTitle title={t('section.label')} subtitle={t('section.description')} />
|
|
@@ -243,7 +369,7 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
|
|
|
243
369
|
className={css.input}
|
|
244
370
|
value={state.token}
|
|
245
371
|
autoComplete="off"
|
|
246
|
-
placeholder=
|
|
372
|
+
placeholder={t('config.tokenPlaceholder')}
|
|
247
373
|
disabled={state.busy !== null}
|
|
248
374
|
onChange={(e: ChangeEvent<HTMLInputElement>) => { patch({ token: e.target.value }) }}
|
|
249
375
|
/>
|
|
@@ -312,9 +438,16 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
|
|
|
312
438
|
</div>
|
|
313
439
|
</Card>
|
|
314
440
|
|
|
315
|
-
{/*
|
|
441
|
+
{/* 一键同步 + 手动推送/拉取 */}
|
|
316
442
|
<div className={css.actionRow}>
|
|
317
|
-
<Button
|
|
443
|
+
<Button
|
|
444
|
+
variant="primary"
|
|
445
|
+
disabled={state.busy !== null || state.repoUrl.trim() === ''}
|
|
446
|
+
onClick={() => { void runSync() }}
|
|
447
|
+
>
|
|
448
|
+
{state.busy === 'sync' ? <Spinner label={t('syncflow.syncing')} /> : t('syncflow.button')}
|
|
449
|
+
</Button>
|
|
450
|
+
<Button disabled={!buttons.canPush || githubBusy} onClick={() => { void runPush() }}>
|
|
318
451
|
{state.busy === 'push' ? <Spinner label={buttons.pushLabel} /> : buttons.pushLabel}
|
|
319
452
|
</Button>
|
|
320
453
|
<Button disabled={!buttons.canPull || githubBusy} onClick={() => { void runPull() }}>
|
|
@@ -322,8 +455,48 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
|
|
|
322
455
|
</Button>
|
|
323
456
|
</div>
|
|
324
457
|
|
|
458
|
+
{/* 选择历史快照下拉 */}
|
|
459
|
+
<div className={css.statRow}>
|
|
460
|
+
<label className={css.field}>
|
|
461
|
+
<span className={css.fieldLabel}>{t('syncflow.selectSnapshot')}</span>
|
|
462
|
+
<select
|
|
463
|
+
className={css.input}
|
|
464
|
+
value={state.selectedSnapshotId}
|
|
465
|
+
disabled={state.busy !== null}
|
|
466
|
+
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
|
|
467
|
+
const id = e.target.value
|
|
468
|
+
patch({ selectedSnapshotId: id })
|
|
469
|
+
void runSync(id === '' ? undefined : id)
|
|
470
|
+
}}
|
|
471
|
+
>
|
|
472
|
+
<option value="">{t('syncflow.latestSnapshot')}</option>
|
|
473
|
+
{state.snapshots.map((s) => (
|
|
474
|
+
<option key={s.id} value={s.id}>
|
|
475
|
+
{s.id}{t('syncflow.snapshotOption', { date: s.createdAt.slice(0, 10), count: String(s.sectionCount) })}
|
|
476
|
+
</option>
|
|
477
|
+
))}
|
|
478
|
+
</select>
|
|
479
|
+
{state.snapshots.length === 0 && <span className={css.hint}>{t('syncflow.noSnapshots')}</span>}
|
|
480
|
+
</label>
|
|
481
|
+
</div>
|
|
482
|
+
|
|
325
483
|
{state.error !== null && <ErrorBanner error={state.error} />}
|
|
326
484
|
|
|
485
|
+
{/* 一键同步差异确认(拉取 → 逐项确认 → 导入) */}
|
|
486
|
+
{state.confirmSession !== null && (
|
|
487
|
+
<SyncConfirmView
|
|
488
|
+
api={api}
|
|
489
|
+
syncSessionId={state.confirmSession.syncSessionId}
|
|
490
|
+
snapshotId={state.confirmSession.snapshotId}
|
|
491
|
+
items={state.confirmSession.items}
|
|
492
|
+
needsReview={state.confirmSession.needsReview}
|
|
493
|
+
compatibility={state.confirmSession.compatibility}
|
|
494
|
+
t={t}
|
|
495
|
+
onCancel={cancelConfirm}
|
|
496
|
+
onRollbackDone={onRollbackApplied}
|
|
497
|
+
/>
|
|
498
|
+
)}
|
|
499
|
+
|
|
327
500
|
{/* 推送结果 */}
|
|
328
501
|
{pushView !== null && (
|
|
329
502
|
<Card>
|
|
@@ -378,6 +551,62 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
|
|
|
378
551
|
{pullView.previewHint !== '' && <Banner kind="info">{pullView.previewHint}</Banner>}
|
|
379
552
|
</Card>
|
|
380
553
|
)}
|
|
554
|
+
|
|
555
|
+
{/* 自动同步设置(方案 A) */}
|
|
556
|
+
<Card>
|
|
557
|
+
<span className={css.groupLabel}>{t('autosync.title')}</span>
|
|
558
|
+
<span className={css.hint}>{t('autosync.description')}</span>
|
|
559
|
+
<label className={css.checkboxRow}>
|
|
560
|
+
<input
|
|
561
|
+
type="checkbox"
|
|
562
|
+
checked={state.autosyncEnabled}
|
|
563
|
+
disabled={state.busy !== null}
|
|
564
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => { void toggleAutosync(e.target.checked) }}
|
|
565
|
+
/>
|
|
566
|
+
<span>{t('autosync.enable')}</span>
|
|
567
|
+
</label>
|
|
568
|
+
<label className={css.field}>
|
|
569
|
+
<span className={css.fieldLabel}>{t('autosync.interval')}</span>
|
|
570
|
+
<select
|
|
571
|
+
className={css.input}
|
|
572
|
+
value={state.autosyncInterval}
|
|
573
|
+
disabled={state.busy !== null}
|
|
574
|
+
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
|
|
575
|
+
void updateAutosyncInterval(e.target.value as AutosyncInterval)
|
|
576
|
+
}}
|
|
577
|
+
>
|
|
578
|
+
{AUTOSYNC_INTERVAL_OPTIONS.map((iv) => (
|
|
579
|
+
<option key={iv} value={iv}>{intervalLabel(iv, t)}</option>
|
|
580
|
+
))}
|
|
581
|
+
</select>
|
|
582
|
+
<span className={css.hint}>{t('autosync.intervalHint')}</span>
|
|
583
|
+
</label>
|
|
584
|
+
<div className={css.statRow}>
|
|
585
|
+
<Badge kind={state.autosync?.lastRunStatus === 'failed' ? 'error' : state.autosync?.lastRunStatus === 'skipped' ? 'warn' : 'info'}>
|
|
586
|
+
{autosyncText}
|
|
587
|
+
</Badge>
|
|
588
|
+
{autosyncCountdown !== null && state.autosyncEnabled && (
|
|
589
|
+
<Badge kind="info">{t('autosync.nextRun', { time: autosyncCountdown })}</Badge>
|
|
590
|
+
)}
|
|
591
|
+
</div>
|
|
592
|
+
</Card>
|
|
593
|
+
|
|
594
|
+
{/* P2:同步历史视图(Host /sync/history 端点) */}
|
|
595
|
+
<SyncHistoryView api={api} t={t} />
|
|
381
596
|
</div>
|
|
382
597
|
)
|
|
383
|
-
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** AutosyncInterval → 可读标签(复用 i18n interval 键)。 */
|
|
601
|
+
function intervalLabel(iv: AutosyncInterval, t: TranslateNS<'config-manager-sync'>): string {
|
|
602
|
+
switch (iv) {
|
|
603
|
+
case '5m': return t('autosync.interval5m');
|
|
604
|
+
case '15m': return t('autosync.interval15m');
|
|
605
|
+
case '30m': return t('autosync.interval30m');
|
|
606
|
+
case '60m': return t('autosync.interval60m');
|
|
607
|
+
case '6h': return t('autosync.interval6h');
|
|
608
|
+
case '12h': return t('autosync.interval12h');
|
|
609
|
+
case '24h': return t('autosync.interval24h');
|
|
610
|
+
default: return iv;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-ui (方案 A):同步历史投影(含自动同步记录)纯函数测试。
|
|
3
|
+
* TDD:先写失败测试,再实现 history-model.ts 对应函数。
|
|
4
|
+
*/
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
|
|
8
|
+
import type { SyncHistoryEntry } from './sync-api.ts';
|
|
9
|
+
import {
|
|
10
|
+
autosyncStatusLabel, describeSkipReason, directionLabel, formatDateTime,
|
|
11
|
+
projectAutosyncEntry, projectSyncHistoryEntries,
|
|
12
|
+
} from './history-model.ts';
|
|
13
|
+
import type { AutosyncHistoryEntry } from './sync-api.ts';
|
|
14
|
+
|
|
15
|
+
const autosyncEntry = (overrides: Partial<AutosyncHistoryEntry>): AutosyncHistoryEntry => ({
|
|
16
|
+
direction: 'both',
|
|
17
|
+
status: 'skipped',
|
|
18
|
+
skipReason: 'conflict',
|
|
19
|
+
conflictedSections: ['settings', 'plugins'],
|
|
20
|
+
appliedSections: [],
|
|
21
|
+
failureCountAtRun: 0,
|
|
22
|
+
createdAt: '2026-08-17T10:00:00.000Z',
|
|
23
|
+
...overrides,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('projectSyncHistoryEntries:快照 + 自动同步 按 createdAt 倒序合并', () => {
|
|
27
|
+
const entries: SyncHistoryEntry[] = [
|
|
28
|
+
{ id: 'a', createdAt: '2026-08-17T10:00:00.000Z', kind: 'apply', sectionCount: 3, reviewCount: 0 },
|
|
29
|
+
{
|
|
30
|
+
id: 'b', createdAt: '2026-08-17T12:00:00.000Z', kind: 'autosync',
|
|
31
|
+
autosync: autosyncEntry({ createdAt: '2026-08-17T12:00:00.000Z' }),
|
|
32
|
+
},
|
|
33
|
+
{ id: 'c', createdAt: '2026-08-17T11:00:00.000Z', kind: 'apply', sectionCount: 2, reviewCount: 0 },
|
|
34
|
+
];
|
|
35
|
+
const sorted = projectSyncHistoryEntries(entries);
|
|
36
|
+
assert.equal(sorted[0]!.id, 'b');
|
|
37
|
+
assert.equal(sorted[1]!.id, 'c');
|
|
38
|
+
assert.equal(sorted[2]!.id, 'a');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('directionLabel / autosyncStatusLabel:方向与状态映射', () => {
|
|
42
|
+
assert.equal(directionLabel('pull'), '下载');
|
|
43
|
+
assert.equal(directionLabel('push'), '上传');
|
|
44
|
+
assert.equal(directionLabel('both'), '双向');
|
|
45
|
+
assert.equal(autosyncStatusLabel('success'), '成功');
|
|
46
|
+
assert.equal(autosyncStatusLabel('skipped'), '已跳过');
|
|
47
|
+
assert.equal(autosyncStatusLabel('failed'), '失败');
|
|
48
|
+
assert.equal(autosyncStatusLabel('partial'), '部分成功');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('describeSkipReason:已知原因映射,未知回退原串', () => {
|
|
52
|
+
assert.equal(describeSkipReason('conflict'), '冲突项被跳过');
|
|
53
|
+
assert.equal(describeSkipReason('no-remote'), '远端无快照');
|
|
54
|
+
assert.equal(describeSkipReason('not-configured'), '未配置仓库');
|
|
55
|
+
assert.equal(describeSkipReason('network'), '网络问题');
|
|
56
|
+
assert.equal(describeSkipReason('weird'), 'weird');
|
|
57
|
+
assert.equal(describeSkipReason(undefined), '未知');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('projectAutosyncEntry:摘要行 + 可展开明细(冲突分区 / 应用分区 / 错误)', () => {
|
|
61
|
+
const row = projectAutosyncEntry(autosyncEntry({}));
|
|
62
|
+
assert.equal(row.direction, '双向');
|
|
63
|
+
assert.equal(row.status, '已跳过');
|
|
64
|
+
assert.match(row.summary, /双向/);
|
|
65
|
+
assert.match(row.summary, /已跳过/);
|
|
66
|
+
assert.match(row.summary, /冲突项被跳过/);
|
|
67
|
+
assert.deepEqual(row.conflictedSections, ['settings', 'plugins']);
|
|
68
|
+
assert.equal(row.hasDetail, true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('projectAutosyncEntry:无冲突/无应用/无错误 → hasDetail=false', () => {
|
|
72
|
+
const row = projectAutosyncEntry(autosyncEntry({
|
|
73
|
+
conflictedSections: undefined, appliedSections: undefined, error: undefined,
|
|
74
|
+
}));
|
|
75
|
+
assert.equal(row.hasDetail, false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('formatDateTime:合法 ISO → 本地格式;空/非法回退', () => {
|
|
79
|
+
assert.match(formatDateTime('2026-08-17T10:30:00.000Z'), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
|
|
80
|
+
assert.equal(formatDateTime(''), '—');
|
|
81
|
+
assert.equal(formatDateTime('not-a-date'), 'not-a-date');
|
|
82
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-ui (P2b):SyncHistoryView 的纯函数投影。
|
|
3
|
+
*
|
|
4
|
+
* 现状:本地祖先快照目录(manifest.json)→ SnapshotHistoryEntry;倒序排序 + ISO 格式化。
|
|
5
|
+
* 方案 A 扩展:/sync/history 现返回 { entries: SyncHistoryEntry[] }(快照 kind=apply
|
|
6
|
+
* + 自动同步 kind=autosync),投影需统一处理两源,并生成自动同步跳过冲突的可读明细。
|
|
7
|
+
*/
|
|
8
|
+
import type { AutosyncHistoryEntry, SyncHistoryEntry } from './sync-api.ts';
|
|
9
|
+
|
|
10
|
+
/** 兼容旧快照条目(manifest.json 投影)。 */
|
|
11
|
+
export interface SnapshotHistoryEntry {
|
|
12
|
+
id: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
/** 分区数(manifest.sectionHashes 的 key 数) */
|
|
15
|
+
sectionCount: number;
|
|
16
|
+
/** 关联到该快照的待审项数(来自 sync-review-queue.json 中的 items) */
|
|
17
|
+
reviewCount: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 把快照 entries 排序(createdAt 倒序)并组装展示字段。 */
|
|
21
|
+
export function projectHistoryRows(entries: readonly SnapshotHistoryEntry[]): SnapshotHistoryEntry[] {
|
|
22
|
+
return [...entries].sort(byCreatedAtDesc);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 统一历史条目(快照 + 自动同步)按 createdAt 倒序排序。 */
|
|
26
|
+
export function projectSyncHistoryEntries(entries: readonly SyncHistoryEntry[]): SyncHistoryEntry[] {
|
|
27
|
+
return [...entries].sort(byCreatedAtDesc);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function byCreatedAtDesc(a: { createdAt: string }, b: { createdAt: string }): number {
|
|
31
|
+
return a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** ISO 时间 → 本地可读字符串(短格式) */
|
|
35
|
+
export function formatDateTime(iso: string): string {
|
|
36
|
+
if (!iso) return '—';
|
|
37
|
+
const d = new Date(iso);
|
|
38
|
+
if (isNaN(d.getTime())) return iso;
|
|
39
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
40
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ---------------------------------------------------------------- 自动同步记录投影 */
|
|
44
|
+
|
|
45
|
+
/** 自动同步执行记录的方向可读标签。 */
|
|
46
|
+
export function directionLabel(direction: AutosyncHistoryEntry['direction']): string {
|
|
47
|
+
switch (direction) {
|
|
48
|
+
case 'pull': return '下载';
|
|
49
|
+
case 'push': return '上传';
|
|
50
|
+
default: return '双向';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 自动同步执行状态可读标签。 */
|
|
55
|
+
export function autosyncStatusLabel(status: AutosyncHistoryEntry['status']): string {
|
|
56
|
+
switch (status) {
|
|
57
|
+
case 'success': return '成功';
|
|
58
|
+
case 'skipped': return '已跳过';
|
|
59
|
+
case 'partial': return '部分成功';
|
|
60
|
+
default: return '失败';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 跳过原因 → 可读描述(host 透传语义;未知原因回退原串)。 */
|
|
65
|
+
export function describeSkipReason(reason: string | undefined): string {
|
|
66
|
+
switch (reason) {
|
|
67
|
+
case 'conflict': return '冲突项被跳过';
|
|
68
|
+
case 'no-remote': return '远端无快照';
|
|
69
|
+
case 'not-configured': return '未配置仓库';
|
|
70
|
+
case 'network': return '网络问题';
|
|
71
|
+
default: return reason ?? '未知';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 把一条自动同步记录投影为展示行(摘要文本 + 可展开的被跳过冲突分区明细)。 */
|
|
76
|
+
export interface AutosyncHistoryRow {
|
|
77
|
+
id: string;
|
|
78
|
+
createdAt: string;
|
|
79
|
+
direction: string;
|
|
80
|
+
status: string;
|
|
81
|
+
/** 摘要行文本(如「下载 · 已跳过 · 冲突项被跳过」)。 */
|
|
82
|
+
summary: string;
|
|
83
|
+
/** 被跳过的冲突分区 id(展开明细用);无则 undefined。 */
|
|
84
|
+
conflictedSections?: string[];
|
|
85
|
+
/** 实际应用的分区 id。 */
|
|
86
|
+
appliedSections?: string[];
|
|
87
|
+
error?: string;
|
|
88
|
+
notifiedAt?: string;
|
|
89
|
+
/** 是否有关联的跳过分区明细可展开。 */
|
|
90
|
+
hasDetail: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 自动同步记录 → 展示行投影。 */
|
|
94
|
+
export function projectAutosyncEntry(entry: AutosyncHistoryEntry): AutosyncHistoryRow {
|
|
95
|
+
const parts: string[] = [directionLabel(entry.direction), autosyncStatusLabel(entry.status)];
|
|
96
|
+
if (entry.skipReason !== undefined) parts.push(describeSkipReason(entry.skipReason));
|
|
97
|
+
return {
|
|
98
|
+
id: entry.createdAt,
|
|
99
|
+
createdAt: entry.createdAt,
|
|
100
|
+
direction: directionLabel(entry.direction),
|
|
101
|
+
status: autosyncStatusLabel(entry.status),
|
|
102
|
+
summary: parts.join(' · '),
|
|
103
|
+
conflictedSections: entry.conflictedSections,
|
|
104
|
+
appliedSections: entry.appliedSections,
|
|
105
|
+
error: entry.error,
|
|
106
|
+
notifiedAt: entry.notifiedAt,
|
|
107
|
+
hasDetail:
|
|
108
|
+
(entry.conflictedSections !== undefined && entry.conflictedSections.length > 0) ||
|
|
109
|
+
(entry.appliedSections !== undefined && entry.appliedSections.length > 0) ||
|
|
110
|
+
entry.error !== undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|