dsh-config-manager 0.1.20 → 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.
Files changed (54) hide show
  1. package/lib/client.d.ts +312 -46
  2. package/lib/client.js +1172 -205
  3. package/lib/client.js.map +1 -1
  4. package/lib/core/run-registry.d.ts +2 -2
  5. package/lib/core/run-registry.js +5 -1
  6. package/lib/core/run-registry.js.map +1 -1
  7. package/lib/index.js +292 -19
  8. package/lib/index.js.map +1 -1
  9. package/lib/sync/autosync-config.d.ts +34 -0
  10. package/lib/sync/autosync-config.js +116 -0
  11. package/lib/sync/autosync-config.js.map +1 -0
  12. package/lib/sync/autosync-scheduler.d.ts +95 -0
  13. package/lib/sync/autosync-scheduler.js +379 -0
  14. package/lib/sync/autosync-scheduler.js.map +1 -0
  15. package/lib/sync/sync-engine.d.ts +59 -4
  16. package/lib/sync/sync-engine.js +138 -12
  17. package/lib/sync/sync-engine.js.map +1 -1
  18. package/lib/sync/sync-history.d.ts +39 -0
  19. package/lib/sync/sync-history.js +88 -0
  20. package/lib/sync/sync-history.js.map +1 -0
  21. package/lib/sync/sync-session.d.ts +39 -0
  22. package/lib/sync/sync-session.js +54 -0
  23. package/lib/sync/sync-session.js.map +1 -0
  24. package/lib/ui/i18n.d.ts +63 -0
  25. package/lib/ui/i18n.js +130 -1
  26. package/lib/ui/i18n.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/client/config-manager.module.css +18 -0
  29. package/src/client/sync/SyncConfirmView.tsx +301 -0
  30. package/src/client/sync/SyncHistoryView.tsx +107 -25
  31. package/src/client/sync/SyncSettingsView.tsx +226 -83
  32. package/src/client/sync/history-model.test.ts +82 -0
  33. package/src/client/sync/history-model.ts +89 -2
  34. package/src/client/sync/sync-api.test.ts +155 -0
  35. package/src/client/sync/sync-api.ts +208 -16
  36. package/src/client/sync/sync-locales.ts +162 -1
  37. package/src/client/sync/sync-view-v2.test.ts +131 -0
  38. package/src/client/sync/sync-view.ts +151 -4
  39. package/src/core/run-registry.ts +7 -3
  40. package/src/index.ts +314 -22
  41. package/src/sync/autosync-config.test.ts +93 -0
  42. package/src/sync/autosync-config.ts +137 -0
  43. package/src/sync/autosync-scheduler.test.ts +191 -0
  44. package/src/sync/autosync-scheduler.ts +443 -0
  45. package/src/sync/sync-engine.test.ts +107 -7
  46. package/src/sync/sync-engine.ts +176 -14
  47. package/src/sync/sync-history.test.ts +85 -0
  48. package/src/sync/sync-history.ts +126 -0
  49. package/src/sync/sync-session.test.ts +137 -0
  50. package/src/sync/sync-session.ts +76 -0
  51. package/src/ui/i18n.ts +132 -3
  52. package/src/client/sync/SyncPullPreviewView.test.ts +0 -54
  53. package/src/client/sync/SyncPullPreviewView.tsx +0 -165
  54. package/src/client/sync/pull-preview-model.ts +0 -44
@@ -1,29 +1,34 @@
1
1
  /**
2
- * 同步历史视图(M6,P2b):列出 localSnapshotsDir 各快照目录的 manifest.json
3
- * (id + createdAt + sectionHashes);每行显示:快照 ID、时间、分区数、关联待审数(来自 sync-review-queue.json)。
2
+ * 同步历史视图(方案 A):列出本地祖先快照目录(kind=apply)+ 自动同步执行记录
3
+ * (kind=autosync)。自动同步行显示时间/方向/状态/跳过冲突/应用分区,点开可看被跳过
4
+ * 冲突分区明细。
4
5
  *
5
- * 数据获取:通过 Host IPC 端点(与 SyncSettingsView 同模式)。本组件只负责渲染;
6
- * 数据加载由宿主 API 提供(不在浏览器侧直接读 fs)。
6
+ * 数据获取:GET /sync/history { entries: SyncHistoryEntry[] }(按 createdAt 倒序合并)。
7
+ * 纯函数投影在 ./history-model.ts(node --test 可测),本组件只做装配。
7
8
  */
8
9
  import { useEffect, useMemo, useState } from 'react'
9
10
  import type { ReactNode } from 'react'
10
11
 
11
- import { Card, SectionTitle, Spinner } from '../common/ui.tsx'
12
- import type { SyncApi } from './sync-api.ts'
13
- import { projectHistoryRows, formatDateTime } from './history-model.ts'
12
+ import { Badge, Card, SectionTitle, Spinner } from '../common/ui.tsx'
13
+ import type { SyncApi, SyncHistoryEntry, AutosyncHistoryEntry } from './sync-api.ts'
14
+ import {
15
+ describeSkipReason, directionLabel, formatDateTime, projectAutosyncEntry,
16
+ projectSyncHistoryEntries,
17
+ } from './history-model.ts'
14
18
  import type { SnapshotHistoryEntry } from './history-model.ts'
15
-
16
- // 纯函数(项目排序、ISO 格式化)放在 ./history-model.ts(node --test 可测)。
19
+ import type { TranslateNS } from '../client-types.ts'
20
+ import css from '../config-manager.module.css'
17
21
 
18
22
  export interface SyncHistoryViewProps {
19
23
  api: SyncApi
24
+ t: TranslateNS<'config-manager-sync'>
20
25
  }
21
26
 
22
27
  export function SyncHistoryView(props: SyncHistoryViewProps): ReactNode {
23
- const { api } = props;
28
+ const { api, t } = props;
24
29
  const [loading, setLoading] = useState(true);
25
30
  const [error, setError] = useState<string | null>(null);
26
- const [entries, setEntries] = useState<SnapshotHistoryEntry[]>([]);
31
+ const [entries, setEntries] = useState<SyncHistoryEntry[]>([]);
27
32
 
28
33
  useEffect(() => {
29
34
  let cancelled = false;
@@ -31,7 +36,7 @@ export function SyncHistoryView(props: SyncHistoryViewProps): ReactNode {
31
36
  try {
32
37
  const data = await api.history();
33
38
  if (!cancelled) {
34
- setEntries(data);
39
+ setEntries(data.entries);
35
40
  setLoading(false);
36
41
  }
37
42
  } catch (err) {
@@ -44,30 +49,107 @@ export function SyncHistoryView(props: SyncHistoryViewProps): ReactNode {
44
49
  return () => { cancelled = true; };
45
50
  }, [api]);
46
51
 
47
- const rows = useMemo(() => projectHistoryRows(entries), [entries]);
52
+ const rows = useMemo(() => projectSyncHistoryEntries(entries), [entries]);
53
+ // 快照类条目(兼容旧投影;仅统计展示)
54
+ const snapshotRows = useMemo<SnapshotHistoryEntry[]>(
55
+ () => rows
56
+ .filter((r) => r.kind === 'apply' || r.kind === 'push' || r.kind === 'pull' || r.kind === 'rollback')
57
+ .map((r) => ({
58
+ id: r.id,
59
+ createdAt: r.createdAt,
60
+ sectionCount: r.sectionCount ?? 0,
61
+ reviewCount: r.reviewCount ?? 0,
62
+ })),
63
+ [rows],
64
+ );
48
65
 
49
- if (loading) return <Spinner label="加载同步历史…" />;
66
+ if (loading) return <Spinner label={t('common.loading')} />;
50
67
  if (error) return <Card><span className="error">{error}</span></Card>;
51
- if (rows.length === 0) return <Card><strong>尚无同步历史</strong><p>完成首次 push/pull 后这里会显示快照记录。</p></Card>;
68
+ if (rows.length === 0) {
69
+ return (
70
+ <Card>
71
+ <strong>{t('history.empty')}</strong>
72
+ <p>{t('history.emptyHint')}</p>
73
+ </Card>
74
+ );
75
+ }
52
76
 
53
77
  return (
54
78
  <Card>
55
- <SectionTitle title={`同步历史(${rows.length})`} />
79
+ <SectionTitle title={`${t('history.title')}(${rows.length})`} />
56
80
  <table className="sync-history-table">
57
81
  <thead>
58
- <tr><th>快照 ID</th><th>时间</th><th>分区</th><th>待审</th></tr>
82
+ <tr><th>{t('history.colTime')}</th><th>{t('history.colKind')}</th><th>{t('history.colDetail')}</th></tr>
59
83
  </thead>
60
84
  <tbody>
61
- {rows.map((r) => (
62
- <tr key={r.id}>
63
- <td><code>{r.id}</code></td>
64
- <td>{formatDateTime(r.createdAt)}</td>
65
- <td>{r.sectionCount}</td>
66
- <td>{r.reviewCount > 0 ? <strong>{r.reviewCount}</strong> : 0}</td>
67
- </tr>
68
- ))}
85
+ {rows.map((r) => {
86
+ if (r.kind === 'autosync' && r.autosync !== undefined) {
87
+ return <AutosyncRow key={r.id} entry={r.autosync} t={t} />;
88
+ }
89
+ const snap = snapshotRows.find((s) => s.id === r.id);
90
+ return (
91
+ <tr key={r.id}>
92
+ <td>{formatDateTime(r.createdAt)}</td>
93
+ <td><Badge kind="info">{t('history.kindSnapshot')}</Badge></td>
94
+ <td>
95
+ <code>{r.id}</code>
96
+ {snap !== undefined && <> · {snap.sectionCount} {t('history.sectionCount')}</>}
97
+ </td>
98
+ </tr>
99
+ );
100
+ })}
69
101
  </tbody>
70
102
  </table>
71
103
  </Card>
72
104
  );
73
105
  }
106
+
107
+ /* ---------------------------------------------------------------- 自动同步行 */
108
+
109
+ interface AutosyncRowProps {
110
+ entry: AutosyncHistoryEntry
111
+ t: TranslateNS<'config-manager-sync'>
112
+ }
113
+
114
+ function AutosyncRow({ entry, t }: AutosyncRowProps): ReactNode {
115
+ const row = projectAutosyncEntry(entry);
116
+ return (
117
+ <tr>
118
+ <td>{formatDateTime(row.createdAt)}</td>
119
+ <td><Badge kind="info">{t('history.kindAutosync')}</Badge></td>
120
+ <td>
121
+ <div>
122
+ {row.summary}
123
+ {entry.pushedSnapshotId !== undefined && <> · {t('history.autosyncPush')} {entry.pushedSnapshotId}</>}
124
+ {entry.pulledSnapshotId !== undefined && <> · {t('history.autosyncPull')} {entry.pulledSnapshotId}</>}
125
+ </div>
126
+ {row.hasDetail && (
127
+ <details>
128
+ <summary>{t('history.detail')}</summary>
129
+ <div className={css.reportList}>
130
+ {row.conflictedSections !== undefined && row.conflictedSections.length > 0 && (
131
+ <div>
132
+ <span className={css.fieldLabel}>{t('history.autosyncConflicted', { sections: '' })}</span>
133
+ <div className={css.statRow}>
134
+ {row.conflictedSections.map((sid) => <Badge key={sid} kind="warn">{sid}</Badge>)}
135
+ </div>
136
+ </div>
137
+ )}
138
+ {row.appliedSections !== undefined && row.appliedSections.length > 0 && (
139
+ <div>
140
+ <span className={css.fieldLabel}>{t('history.autosyncApplied', { sections: '' })}</span>
141
+ <div className={css.statRow}>
142
+ {row.appliedSections.map((sid) => <Badge key={sid} kind="ok">{sid}</Badge>)}
143
+ </div>
144
+ </div>
145
+ )}
146
+ {row.error !== undefined && (
147
+ <div><span className={css.fieldLabel}>{t('history.autosyncError', { error: '' })}</span>{row.error}</div>
148
+ )}
149
+ </div>
150
+ </details>
151
+ )}
152
+ </td>
153
+ </tr>
154
+ );
155
+ }
@@ -7,8 +7,10 @@
7
7
  * + gitBin(可选);
8
8
  * - 私有仓库强制提示横幅(常驻);
9
9
  * - 推送按钮 → SyncPushReport(快照 id / 分区 / 告警);
10
- * - 拉取按钮 → SyncPullReport.changes 差异摘要(description/kind/severity)+
11
- * 「预览不执行导入」提示;needsReview 高亮(v1 不做完整导入接线);
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 单测覆盖),组件只做装配;
@@ -18,19 +20,22 @@
18
20
  import { useEffect, useRef, useState } from 'react'
19
21
  import type { ChangeEvent } from 'react'
20
22
  import type { TranslateNS } from '../client-types.ts'
21
- import type { ApplyReport, SyncPullReport, SyncPushReport } from '../../sync/sync-engine.ts'
23
+ 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 { SyncApi, SyncStatusResponse } from './sync-api.ts'
27
+ import type {
28
+ AutosyncInterval, AutosyncStatusResponse, SyncApi, SyncSnapshotLite, SyncStartResponse,
29
+ SyncStatusResponse,
30
+ } from './sync-api.ts'
26
31
  import {
27
- computeGithubLoginView, computeSyncButtons, computeSyncStatus, githubPollMessage,
28
- kindLabel, privateRepoHint, pullReportView, pushReportView, severityLabel,
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'
31
37
  import { SyncHistoryView } from './SyncHistoryView.tsx'
32
- import { SyncPullPreviewView } from './SyncPullPreviewView.tsx'
33
- import type { SyncApplyPlan } from '../../sync/risk.ts'
38
+ import { SyncConfirmView } from './SyncConfirmView.tsx'
34
39
  import css from '../config-manager.module.css'
35
40
 
36
41
  export interface SyncSettingsViewProps {
@@ -46,10 +51,23 @@ interface SyncUiState {
46
51
  gitBin: string
47
52
  /** 仅内存:成功后清空(已写入 DSH credentials),绝不持久化 */
48
53
  token: string
49
- busy: 'push' | 'pull' | 'apply' | 'rollback' | null
54
+ busy: 'sync' | 'push' | 'pull' | 'rollback' | null
50
55
  pushReport: SyncPushReport | null
51
56
  pullReport: SyncPullReport | null
52
- applyReport: ApplyReport | 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
53
71
  error: string | null
54
72
  /** GitHub OAuth device flow 状态(flowId/userCode 仅内存,token 只存宿主) */
55
73
  github: GithubUiState
@@ -69,6 +87,8 @@ const initialGithub: GithubUiState = {
69
87
  phase: 'idle', flowId: '', userCode: '', verificationUri: '', interval: 5, error: null,
70
88
  }
71
89
 
90
+ const AUTOSYNC_INTERVAL_OPTIONS: AutosyncInterval[] = ['5m', '15m', '30m', '60m', '6h', '12h', '24h'];
91
+
72
92
  const initial: SyncUiState = {
73
93
  loading: true,
74
94
  loadError: null,
@@ -79,7 +99,13 @@ const initial: SyncUiState = {
79
99
  busy: null,
80
100
  pushReport: null,
81
101
  pullReport: null,
82
- applyReport: null,
102
+ confirmSession: null,
103
+ snapshots: [],
104
+ selectedSnapshotId: '',
105
+ autosync: null,
106
+ autosyncEnabled: false,
107
+ autosyncInterval: '30m',
108
+ lastRestoreId: null,
83
109
  error: null,
84
110
  github: initialGithub,
85
111
  }
@@ -92,17 +118,53 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
92
118
  /** GitHub 轮询定时器(卸载/取消时清理,防止泄漏与跨流程串扰) */
93
119
  const githubPollTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
94
120
 
95
- /** 挂载时读取同步状态(配置回填 + 上次同步时间 + 凭据状态) */
121
+ /** 挂载时读取同步状态(配置回填 + 上次同步时间 + 凭据状态 + autosync) */
96
122
  const loadStatus = async (): Promise<void> => {
97
123
  patch({ loading: true, loadError: null })
98
124
  try {
99
125
  const info = await api.status()
100
- patch({ loading: false, statusInfo: info, repoUrl: info.repoUrl ?? '', gitBin: info.gitBin ?? '' })
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
+ }
101
143
  } catch (err) {
102
144
  patch({ loading: false, loadError: err instanceof Error ? err.message : String(err) })
103
145
  }
104
146
  }
105
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
+
106
168
  useEffect(() => {
107
169
  void loadStatus()
108
170
  // api 为注入单例(注册时创建),生命周期内稳定;仅挂载时加载一次
@@ -204,25 +266,57 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
204
266
  }
205
267
  }
206
268
 
207
- /** P2:自动应用(Host engine.merge + classifyMergePlan + applyMergePlan 走完整链路) */
208
- const runApply = async (): Promise<void> => {
209
- patch({ busy: 'apply', error: null, applyReport: null })
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 })
210
278
  try {
211
- const report = await api.apply(payload())
212
- patch({ busy: null, applyReport: report })
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()
213
286
  } catch (err) {
214
287
  patch({ busy: null, error: err instanceof Error ? err.message : String(err) })
215
288
  }
216
289
  }
217
290
 
218
- /** P2:一键回滚(按 apply 返回的 restoreId 调 Host /sync/rollback) */
219
- const runRollback = async (restoreId: string): Promise<void> => {
220
- patch({ busy: 'rollback', error: null })
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 })
221
305
  try {
222
- await api.rollback({ restoreId })
223
- patch({ busy: null, applyReport: null })
306
+ const updated = await api.autosyncUpdate({ enabled, interval: state.autosyncInterval })
307
+ patch({ autosync: updated, autosyncEnabled: updated.enabled, autosyncInterval: updated.interval })
224
308
  } catch (err) {
225
- patch({ busy: null, error: err instanceof Error ? err.message : String(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) })
226
320
  }
227
321
  }
228
322
 
@@ -237,6 +331,11 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
237
331
  const githubBusy =
238
332
  state.github.phase === 'starting' || state.github.phase === 'waiting' || state.github.phase === 'polling'
239
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
+
240
339
  return (
241
340
  <div className={css.viewBody}>
242
341
  <SectionTitle title={t('section.label')} subtitle={t('section.description')} />
@@ -270,7 +369,7 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
270
369
  className={css.input}
271
370
  value={state.token}
272
371
  autoComplete="off"
273
- placeholder="ghp_…(可选)"
372
+ placeholder={t('config.tokenPlaceholder')}
274
373
  disabled={state.busy !== null}
275
374
  onChange={(e: ChangeEvent<HTMLInputElement>) => { patch({ token: e.target.value }) }}
276
375
  />
@@ -339,9 +438,16 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
339
438
  </div>
340
439
  </Card>
341
440
 
342
- {/* 操作 */}
441
+ {/* 一键同步 + 手动推送/拉取 */}
343
442
  <div className={css.actionRow}>
344
- <Button variant="primary" disabled={!buttons.canPush || githubBusy} onClick={() => { void runPush() }}>
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() }}>
345
451
  {state.busy === 'push' ? <Spinner label={buttons.pushLabel} /> : buttons.pushLabel}
346
452
  </Button>
347
453
  <Button disabled={!buttons.canPull || githubBusy} onClick={() => { void runPull() }}>
@@ -349,8 +455,48 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
349
455
  </Button>
350
456
  </div>
351
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
+
352
483
  {state.error !== null && <ErrorBanner error={state.error} />}
353
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
+
354
500
  {/* 推送结果 */}
355
501
  {pushView !== null && (
356
502
  <Card>
@@ -406,64 +552,61 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
406
552
  </Card>
407
553
  )}
408
554
 
409
- {/* P2:自动应用(拉取后可触发;Host 端合并 + 写本地 + 失败回滚) */}
410
- <div className={css.actionRow}>
411
- <Button
412
- variant="primary"
413
- disabled={state.busy !== null || state.pullReport === null}
414
- onClick={() => { void runApply() }}
415
- >
416
- {state.busy === 'apply' ? <Spinner label="自动应用中…" /> : '自动应用(合并 + 写本地)'}
417
- </Button>
418
- </div>
419
-
420
- {/* P2:自动应用结果 */}
421
- {state.applyReport !== null && (
422
- <Card>
423
- <span className={css.groupLabel}>自动应用结果</span>
424
- <Banner kind={state.applyReport.ok ? 'ok' : 'error'}>
425
- {state.applyReport.ok
426
- ? `已应用 ${state.applyReport.applied.length} 个分区(restoreId=${state.applyReport.restoreId})`
427
- : `自动应用失败(已整体回滚,restoreId=${state.applyReport.restoreId})`}
428
- </Banner>
429
- {state.applyReport.applied.length > 0 && (
430
- <div>
431
- <span className={css.fieldLabel}>已写入</span>
432
- <div className={css.statRow}>
433
- {state.applyReport.applied.map((sid) => <Badge key={sid} kind="ok">{sid}</Badge>)}
434
- </div>
435
- </div>
436
- )}
437
- {state.applyReport.warnings.length > 0 && (
438
- <div>
439
- <span className={css.fieldLabel}>告警</span>
440
- <ul className={css.warnList}>
441
- {state.applyReport.warnings.map((w, i) => <li key={i}>{w}</li>)}
442
- </ul>
443
- </div>
444
- )}
445
- {state.applyReport.review.length > 0 && (
446
- <div>
447
- <span className={css.fieldLabel}>待审(已入 sync-review-queue.json)</span>
448
- <ul className={css.warnList}>
449
- {state.applyReport.review.map((r, i) => <li key={i}>{r.sectionId} — {r.description}</li>)}
450
- </ul>
451
- </div>
452
- )}
453
- {!state.applyReport.ok && state.applyReport.restoreId !== '' && (
454
- <Button
455
- variant="danger"
456
- disabled={state.busy !== null}
457
- onClick={() => { void runRollback(state.applyReport!.restoreId) }}
458
- >
459
- {state.busy === 'rollback' ? <Spinner label="回滚中…" /> : '回滚到应用前'}
460
- </Button>
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>
461
590
  )}
462
- </Card>
463
- )}
591
+ </div>
592
+ </Card>
464
593
 
465
594
  {/* P2:同步历史视图(Host /sync/history 端点) */}
466
- <SyncHistoryView api={api} />
595
+ <SyncHistoryView api={api} t={t} />
467
596
  </div>
468
597
  )
469
- }
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
+ }