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.
Files changed (82) hide show
  1. package/lib/client.d.ts +319 -2
  2. package/lib/client.js +1271 -109
  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 +408 -2
  8. package/lib/index.js.map +1 -1
  9. package/lib/sync/ancestor.d.ts +17 -0
  10. package/lib/sync/ancestor.js +64 -0
  11. package/lib/sync/ancestor.js.map +1 -0
  12. package/lib/sync/autosync-config.d.ts +34 -0
  13. package/lib/sync/autosync-config.js +116 -0
  14. package/lib/sync/autosync-config.js.map +1 -0
  15. package/lib/sync/autosync-scheduler.d.ts +95 -0
  16. package/lib/sync/autosync-scheduler.js +379 -0
  17. package/lib/sync/autosync-scheduler.js.map +1 -0
  18. package/lib/sync/merge.d.ts +42 -0
  19. package/lib/sync/merge.js +325 -0
  20. package/lib/sync/merge.js.map +1 -0
  21. package/lib/sync/review-queue.d.ts +51 -0
  22. package/lib/sync/review-queue.js +119 -0
  23. package/lib/sync/review-queue.js.map +1 -0
  24. package/lib/sync/risk.d.ts +59 -0
  25. package/lib/sync/risk.js +76 -0
  26. package/lib/sync/risk.js.map +1 -0
  27. package/lib/sync/sync-config.d.ts +7 -2
  28. package/lib/sync/sync-config.js +24 -4
  29. package/lib/sync/sync-config.js.map +1 -1
  30. package/lib/sync/sync-engine.d.ts +88 -2
  31. package/lib/sync/sync-engine.js +333 -8
  32. package/lib/sync/sync-engine.js.map +1 -1
  33. package/lib/sync/sync-history.d.ts +39 -0
  34. package/lib/sync/sync-history.js +88 -0
  35. package/lib/sync/sync-history.js.map +1 -0
  36. package/lib/sync/sync-session.d.ts +39 -0
  37. package/lib/sync/sync-session.js +54 -0
  38. package/lib/sync/sync-session.js.map +1 -0
  39. package/lib/sync/sync-state.d.ts +7 -2
  40. package/lib/sync/sync-state.js +9 -5
  41. package/lib/sync/sync-state.js.map +1 -1
  42. package/lib/ui/i18n.d.ts +63 -0
  43. package/lib/ui/i18n.js +130 -1
  44. package/lib/ui/i18n.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/client/config-manager.module.css +18 -0
  47. package/src/client/sync/SyncConfirmView.tsx +301 -0
  48. package/src/client/sync/SyncHistoryView.test.ts +40 -0
  49. package/src/client/sync/SyncHistoryView.tsx +155 -0
  50. package/src/client/sync/SyncSettingsView.tsx +241 -12
  51. package/src/client/sync/history-model.test.ts +82 -0
  52. package/src/client/sync/history-model.ts +112 -0
  53. package/src/client/sync/sync-api.test.ts +155 -0
  54. package/src/client/sync/sync-api.ts +219 -0
  55. package/src/client/sync/sync-locales.ts +162 -1
  56. package/src/client/sync/sync-view-v2.test.ts +131 -0
  57. package/src/client/sync/sync-view.ts +151 -4
  58. package/src/core/run-registry.ts +7 -3
  59. package/src/index.ts +420 -3
  60. package/src/sync/ancestor.test.ts +152 -0
  61. package/src/sync/ancestor.ts +81 -0
  62. package/src/sync/autosync-config.test.ts +93 -0
  63. package/src/sync/autosync-config.ts +137 -0
  64. package/src/sync/autosync-scheduler.test.ts +191 -0
  65. package/src/sync/autosync-scheduler.ts +443 -0
  66. package/src/sync/merge.test.ts +204 -0
  67. package/src/sync/merge.ts +360 -0
  68. package/src/sync/review-queue.test.ts +120 -0
  69. package/src/sync/review-queue.ts +167 -0
  70. package/src/sync/risk.test.ts +131 -0
  71. package/src/sync/risk.ts +122 -0
  72. package/src/sync/sync-config.test.ts +106 -0
  73. package/src/sync/sync-config.ts +23 -4
  74. package/src/sync/sync-engine.test.ts +392 -3
  75. package/src/sync/sync-engine.ts +383 -10
  76. package/src/sync/sync-history.test.ts +85 -0
  77. package/src/sync/sync-history.ts +126 -0
  78. package/src/sync/sync-session.test.ts +137 -0
  79. package/src/sync/sync-session.ts +76 -0
  80. package/src/sync/sync-state.test.ts +48 -2
  81. package/src/sync/sync-state.ts +11 -5
  82. package/src/ui/i18n.ts +132 -3
@@ -177,3 +177,158 @@ test('S-09 api.githubCancel():POST /sync/github/cancel 携带 flowId', async (
177
177
  const sent = JSON.parse(String(calls[0]?.init?.body ?? '{}')) as Record<string, unknown>;
178
178
  assert.equal(sent['flowId'], 'flow-1');
179
179
  });
180
+
181
+ /* ------------------------------------------------ 一键同步(方案 A)端点契约 */
182
+
183
+ test('S-10 api.snapshotsList():POST /sync/snapshots-list,解析倒序快照列表 + currentSnapshotId', async () => {
184
+ const body = {
185
+ ok: true,
186
+ snapshots: [
187
+ { id: 'sync-2', createdAt: '2026-08-17T10:00:00.000Z', sectionCount: 3, platform: 'darwin', dshVersion: '1.0.0' },
188
+ { id: 'sync-1', createdAt: '2026-08-16T10:00:00.000Z', sectionCount: 2, platform: 'darwin', dshVersion: '1.0.0' },
189
+ ],
190
+ currentSnapshotId: 'sync-1',
191
+ };
192
+ const calls: FetchCall[] = [];
193
+ installFetchMock((call) => {
194
+ calls.push(call);
195
+ return jsonResponse(200, body);
196
+ });
197
+ const api = new SyncApi();
198
+ const result = await api.snapshotsList({ repoUrl: 'https://github.com/u/r.git' });
199
+ assert.equal(result.ok, true);
200
+ assert.equal(result.snapshots.length, 2);
201
+ assert.equal(result.snapshots[0]?.id, 'sync-2');
202
+ assert.equal(result.currentSnapshotId, 'sync-1');
203
+ assert.equal(calls[0]?.url, SYNC_API.snapshotsList);
204
+ assert.equal(calls[0]?.init?.method, 'POST');
205
+ });
206
+
207
+ test('S-11 api.sync():POST /sync/sync,请求体携带 snapshotId;响应含 items/needsReview/compatibility', async () => {
208
+ const body = {
209
+ ok: true,
210
+ syncSessionId: 'sess-1',
211
+ snapshotId: 'sync-3',
212
+ items: [{ itemId: 'settings:a', adapter: 'settings', kind: 'Update', description: '更新', severity: 'info', defaultAdopt: true, adopt: true }],
213
+ needsReview: false,
214
+ compatibility: 'good',
215
+ };
216
+ const calls: FetchCall[] = [];
217
+ installFetchMock((call) => {
218
+ calls.push(call);
219
+ return jsonResponse(200, body);
220
+ });
221
+ const api = new SyncApi();
222
+ const result = await api.sync({ repoUrl: 'https://github.com/u/r.git', snapshotId: 'sync-3' });
223
+ assert.equal(result.syncSessionId, 'sess-1');
224
+ assert.equal(result.items.length, 1);
225
+ assert.equal(result.compatibility, 'good');
226
+ assert.equal(calls[0]?.url, SYNC_API.sync);
227
+ const sent = JSON.parse(String(calls[0]?.init?.body ?? '{}')) as Record<string, unknown>;
228
+ assert.equal(sent['snapshotId'], 'sync-3');
229
+ });
230
+
231
+ test('S-12 api.applyItems():POST /sync/apply-items,携带 adoptions(含 Conflict resolution)', async () => {
232
+ const body = {
233
+ ok: true, applied: ['settings'], skipped: ['plugin:x'], needsRestart: false,
234
+ warnings: [], restoreId: 'rest-1', rolledBack: false, failed: [], result: {},
235
+ };
236
+ const calls: FetchCall[] = [];
237
+ installFetchMock((call) => {
238
+ calls.push(call);
239
+ return jsonResponse(200, body);
240
+ });
241
+ const api = new SyncApi();
242
+ const result = await api.applyItems({
243
+ syncSessionId: 'sess-1',
244
+ adoptions: [
245
+ { itemId: 'settings:a', adopt: true },
246
+ { itemId: 'plugin:x', adopt: true, resolution: 'useRemote' },
247
+ ],
248
+ });
249
+ assert.equal(result.applied[0], 'settings');
250
+ assert.equal(result.restoreId, 'rest-1');
251
+ assert.equal(calls[0]?.url, SYNC_API.applyItems);
252
+ const sent = JSON.parse(String(calls[0]?.init?.body ?? '{}')) as Record<string, unknown>;
253
+ assert.equal(sent['syncSessionId'], 'sess-1');
254
+ const adoptions = sent['adoptions'] as Array<Record<string, unknown>>;
255
+ assert.equal(adoptions[1]?.['resolution'], 'useRemote');
256
+ });
257
+
258
+ test('S-13 api.cancel():POST /sync/cancel 携带 syncSessionId', async () => {
259
+ const calls: FetchCall[] = [];
260
+ installFetchMock((call) => {
261
+ calls.push(call);
262
+ return jsonResponse(200, { ok: true });
263
+ });
264
+ const api = new SyncApi();
265
+ const result = await api.cancel('sess-1');
266
+ assert.equal(result.ok, true);
267
+ assert.equal(calls[0]?.url, SYNC_API.cancel);
268
+ const sent = JSON.parse(String(calls[0]?.init?.body ?? '{}')) as Record<string, unknown>;
269
+ assert.equal(sent['syncSessionId'], 'sess-1');
270
+ });
271
+
272
+ /* ------------------------------------------------ 自动同步端点契约 */
273
+
274
+ test('S-14 api.autosyncStatus():GET /sync/autosync,解析 enabled/interval/elapsedMs', async () => {
275
+ const body = {
276
+ enabled: true, interval: '30m', lastRunAt: '2026-08-17T10:00:00.000Z',
277
+ lastRunStatus: 'success', consecutiveFailures: 0, elapsedMs: 60000,
278
+ };
279
+ let called: FetchCall | null = null;
280
+ installFetchMock((call) => {
281
+ called = call;
282
+ return jsonResponse(200, body);
283
+ });
284
+ const lastCall = (): FetchCall | null => called;
285
+ const api = new SyncApi();
286
+ const result = await api.autosyncStatus();
287
+ assert.equal(result.enabled, true);
288
+ assert.equal(result.interval, '30m');
289
+ assert.equal(result.consecutiveFailures, 0);
290
+ assert.equal(result.elapsedMs, 60000);
291
+ assert.equal(lastCall()?.url, SYNC_API.autosync);
292
+ });
293
+
294
+ test('S-15 api.autosyncUpdate():POST /sync/autosync,请求体携带 enabled/interval', async () => {
295
+ const body = {
296
+ enabled: true, interval: '60m', consecutiveFailures: 0, elapsedMs: -1,
297
+ };
298
+ const calls: FetchCall[] = [];
299
+ installFetchMock((call) => {
300
+ calls.push(call);
301
+ return jsonResponse(200, body);
302
+ });
303
+ const api = new SyncApi();
304
+ const result = await api.autosyncUpdate({ enabled: true, interval: '60m' });
305
+ assert.equal(result.enabled, true);
306
+ assert.equal(result.interval, '60m');
307
+ assert.equal(calls[0]?.url, SYNC_API.autosync);
308
+ assert.equal(calls[0]?.init?.method, 'POST');
309
+ const sent = JSON.parse(String(calls[0]?.init?.body ?? '{}')) as Record<string, unknown>;
310
+ assert.equal(sent['enabled'], true);
311
+ assert.equal(sent['interval'], '60m');
312
+ });
313
+
314
+ test('S-16 api.history():GET /sync/history,解析 { entries }(含 autosync 记录)', async () => {
315
+ const body = {
316
+ entries: [
317
+ { id: 'sync-1', createdAt: '2026-08-17T10:00:00.000Z', kind: 'apply', sectionCount: 3, reviewCount: 0 },
318
+ {
319
+ id: '2026-08-17T09:00:00.000Z', createdAt: '2026-08-17T09:00:00.000Z', kind: 'autosync',
320
+ autosync: {
321
+ direction: 'both', status: 'skipped', skipReason: 'conflict',
322
+ conflictedSections: ['settings'], failureCountAtRun: 0, createdAt: '2026-08-17T09:00:00.000Z',
323
+ },
324
+ },
325
+ ],
326
+ };
327
+ installFetchMock(() => jsonResponse(200, body));
328
+ const api = new SyncApi();
329
+ const result = await api.history();
330
+ assert.equal(result.entries.length, 2);
331
+ assert.equal(result.entries[0]?.kind, 'apply');
332
+ assert.equal(result.entries[1]?.kind, 'autosync');
333
+ assert.deepEqual(result.entries[1]?.autosync?.conflictedSections, ['settings']);
334
+ });
@@ -23,6 +23,8 @@
23
23
  * - 本文件不 import 任何 node 模块(纯浏览器 bundle;sync-engine 仅作 type-only 引用)。
24
24
  */
25
25
  import type { SyncPullReport, SyncPushReport } from '../../sync/sync-engine.ts';
26
+ import type { PlanItemKind } from '../../core/types.ts';
27
+ import type { SectionId } from '../../schema/types.ts';
26
28
  import { ConfigManagerApiError } from '../api.ts';
27
29
  import { zhUiT, type UiT } from '../../ui/i18n.ts';
28
30
 
@@ -35,6 +37,13 @@ export const SYNC_API = {
35
37
  githubStart: '/api/dsh-config-manager/sync/github/start',
36
38
  githubPoll: '/api/dsh-config-manager/sync/github/poll',
37
39
  githubCancel: '/api/dsh-config-manager/sync/github/cancel',
40
+ history: '/api/dsh-config-manager/sync/history',
41
+ snapshotsList: '/api/dsh-config-manager/sync/snapshots-list',
42
+ sync: '/api/dsh-config-manager/sync/sync',
43
+ applyItems: '/api/dsh-config-manager/sync/apply-items',
44
+ cancel: '/api/dsh-config-manager/sync/cancel',
45
+ autosync: '/api/dsh-config-manager/sync/autosync',
46
+ rollback: '/api/dsh-config-manager/sync/rollback',
38
47
  } as const;
39
48
 
40
49
  /** DSH credentials 中的同步 token 引用名(Host 半同值;仅供提示文案使用,值由 Host 读写) */
@@ -56,6 +65,8 @@ export interface SyncStatusResponse {
56
65
  /** sync-state.sections 条目数 */
57
66
  sectionCount: number;
58
67
  transport?: { type: string; ref: string };
68
+ /** 自动同步当前状态(供 UI 顶部开关回填;§3.9) */
69
+ autosync?: AutosyncStatusResponse;
59
70
  }
60
71
 
61
72
  /** push 请求体(token 可选:非空则 Host 先写入 DSH credentials 再使用) */
@@ -71,6 +82,172 @@ export interface SyncPullPayload extends SyncPushPayload {
71
82
  snapshotId?: string;
72
83
  }
73
84
 
85
+ /* ---------------------------------------------------------------- 一键同步(方案 A) */
86
+
87
+ /** GET /sync/snapshots-list 响应:远端历史快照列表(按 createdAt 倒序)。 */
88
+ export interface SyncSnapshotsListResponse {
89
+ ok: boolean;
90
+ /** 按 createdAt 倒序(最新在前) */
91
+ snapshots: SyncSnapshotLite[];
92
+ /** 当前本地祖先指针(sync-state.lastSnapshotId),用于高亮当前基线 */
93
+ currentSnapshotId?: string;
94
+ }
95
+
96
+ /** 远端快照摘要(「选择历史快照」下拉项)。 */
97
+ export interface SyncSnapshotLite {
98
+ id: string;
99
+ createdAt: string;
100
+ sectionCount: number;
101
+ platform: string;
102
+ dshVersion: string;
103
+ }
104
+
105
+ /** POST /sync/sync 请求体(一键同步第一步:拉取 → 差异确认会话)。 */
106
+ export interface SyncStartPayload {
107
+ repoUrl: string;
108
+ gitBin?: string;
109
+ token?: string;
110
+ /** 缺省 = 最新快照;传入则对该历史快照拉取 */
111
+ snapshotId?: string;
112
+ }
113
+
114
+ /** POST /sync/sync 响应:差异确认会话(items 供 UI 逐项确认)。 */
115
+ export interface SyncStartResponse {
116
+ ok: boolean;
117
+ /** 差异确认会话 id:后续 apply-items / cancel 引用 */
118
+ syncSessionId: string;
119
+ /** 被拉取的远端快照 id */
120
+ snapshotId: string;
121
+ items: SyncConfirmItem[];
122
+ /** 是否包含任何需人工决策项 */
123
+ needsReview: boolean;
124
+ compatibility: 'excellent' | 'good' | 'partial' | 'unsupported';
125
+ message?: string;
126
+ }
127
+
128
+ /** 单条可确认的差异项(由 ImportPlan.item 投影 + 冲突详情)。 */
129
+ export interface SyncConfirmItem {
130
+ itemId: string;
131
+ adapter: SectionId;
132
+ kind: PlanItemKind;
133
+ description: string;
134
+ severity: 'info' | 'warning' | 'error';
135
+ /** 默认采纳方向;Conflict/MissingSecret 等人工项默认 false */
136
+ defaultAdopt: boolean;
137
+ /** 用户最终决策(缺省 = defaultAdopt) */
138
+ adopt: boolean;
139
+ /** 冲突项内联解决所需详情(仅 Conflict 项非空) */
140
+ conflict?: SyncConflictDetail;
141
+ /** 该项若采用将写入的目标摘要 */
142
+ target?: { adapter: SectionId; ref: string };
143
+ }
144
+
145
+ /** 冲突项内联解决详情(来源 MergeConflict + 可读 diff)。 */
146
+ export interface SyncConflictDetail {
147
+ path: string;
148
+ kind: 'key' | 'file' | 'section';
149
+ local?: unknown;
150
+ remote?: unknown;
151
+ ancestor?: unknown;
152
+ diff?: string;
153
+ }
154
+
155
+ /** POST /sync/apply-items 请求体(一键同步第二步:按逐项决策执行导入)。 */
156
+ export interface ApplyItemsPayload {
157
+ syncSessionId: string;
158
+ /** 每项的最终采纳决策(未列出项视为 adopt=false) */
159
+ adoptions: SyncItemAdoption[];
160
+ }
161
+
162
+ /** 单条采纳决策。 */
163
+ export interface SyncItemAdoption {
164
+ itemId: string;
165
+ adopt: boolean;
166
+ /** 冲突项解决方案(仅当该项是 Conflict 且 adopt=true 时必须) */
167
+ resolution?: 'useRemote' | 'keepLocal' | 'skip';
168
+ }
169
+
170
+ /** POST /sync/apply-items 响应。 */
171
+ export interface ApplyItemsResponse {
172
+ ok: boolean;
173
+ applied: string[];
174
+ skipped: string[];
175
+ needsRestart: boolean;
176
+ warnings: string[];
177
+ /** 应用前快照 id(UI 一键回滚用) */
178
+ restoreId: string;
179
+ /** 任一失败是否整体回滚 */
180
+ rolledBack: boolean;
181
+ failed: { itemId: string; message?: string }[];
182
+ result: unknown;
183
+ }
184
+
185
+ /* ---------------------------------------------------------------- 自动同步 */
186
+
187
+ /** 统一间隔类型。 */
188
+ export type AutosyncInterval = '5m' | '15m' | '30m' | '60m' | '6h' | '12h' | '24h';
189
+
190
+ /** 最近一次自动同步执行状态。 */
191
+ export type AutosyncRunStatus = 'success' | 'skipped' | 'failed' | 'partial';
192
+
193
+ /** GET/POST /sync/autosync 响应:自动同步状态。 */
194
+ export interface AutosyncStatusResponse {
195
+ enabled: boolean;
196
+ interval: AutosyncInterval;
197
+ lastRunAt?: string;
198
+ lastRunStatus?: AutosyncRunStatus;
199
+ lastRunMessage?: string;
200
+ consecutiveFailures: number;
201
+ /** 距上次自动同步已过 ms(host 计算,供 UI 倒计时/立即触发判断) */
202
+ elapsedMs: number;
203
+ lastRunHistoryId?: string;
204
+ }
205
+
206
+ /** POST /sync/autosync 请求体。 */
207
+ export interface AutosyncUpdatePayload {
208
+ enabled: boolean;
209
+ interval?: AutosyncInterval;
210
+ startupMinIntervalMs?: number;
211
+ }
212
+
213
+ /* ---------------------------------------------------------------- 同步历史 */
214
+
215
+ /** 自动同步执行记录(§3.7 AutosyncHistoryEntry)。 */
216
+ export interface AutosyncHistoryEntry {
217
+ direction: 'pull' | 'push' | 'both';
218
+ status: 'success' | 'skipped' | 'failed' | 'partial';
219
+ /** 跳过原因(冲突项 / 缺失依赖 / Install / 错误 / 无远端 / 网络) */
220
+ skipReason?: string;
221
+ /** 被跳过的冲突分区 id(冲突跳过时列出) */
222
+ conflictedSections?: string[];
223
+ /** 本次自动合并实际写入的分区 */
224
+ appliedSections?: string[];
225
+ /** 本次 push 产生的快照 id */
226
+ pushedSnapshotId?: string;
227
+ /** 本次 pull 来源快照 id */
228
+ pulledSnapshotId?: string;
229
+ error?: string;
230
+ notifiedAt?: string;
231
+ /** 本次触发时的连续失败计数 */
232
+ failureCountAtRun: number;
233
+ createdAt: string;
234
+ }
235
+
236
+ /** 同步历史条目(Host 端返回;kind='autosync' 时 autosync 非空)。 */
237
+ export interface SyncHistoryEntry {
238
+ id: string;
239
+ createdAt: string;
240
+ kind: 'push' | 'pull' | 'apply' | 'autosync' | 'rollback';
241
+ sectionCount?: number;
242
+ reviewCount?: number;
243
+ autosync?: AutosyncHistoryEntry;
244
+ }
245
+
246
+ /** GET /sync/history 响应:{ entries }。 */
247
+ export interface SyncHistoryResponse {
248
+ entries: SyncHistoryEntry[];
249
+ }
250
+
74
251
  /* ---------------------------------------------------------------- GitHub OAuth device flow */
75
252
 
76
253
  /** POST /sync/github/start 响应:UI 展示用(device_code 只存宿主,绝不回传) */
@@ -189,4 +366,46 @@ export class SyncApi {
189
366
  async githubCancel(flowId: string): Promise<{ ok: boolean }> {
190
367
  return postJson<{ ok: boolean }>(SYNC_API.githubCancel, { flowId }, this.t);
191
368
  }
369
+
370
+ /** 同步历史:列出本地祖先快照 + 自动同步执行记录(按 createdAt 倒序合并)。 */
371
+ async history(): Promise<SyncHistoryResponse> {
372
+ const response = await fetch(SYNC_API.history);
373
+ return readJson<SyncHistoryResponse>(response, this.t);
374
+ }
375
+
376
+ /** 远端历史快照列表(供「选择历史快照」下拉)。 */
377
+ async snapshotsList(payload: SyncPushPayload): Promise<SyncSnapshotsListResponse> {
378
+ return postJson<SyncSnapshotsListResponse>(SYNC_API.snapshotsList, payload, this.t);
379
+ }
380
+
381
+ /** 一键同步第一步:拉取 → 差异确认会话(items 逐项确认,暂不导入)。 */
382
+ async sync(payload: SyncStartPayload): Promise<SyncStartResponse> {
383
+ return postJson<SyncStartResponse>(SYNC_API.sync, payload, this.t);
384
+ }
385
+
386
+ /** 一键同步第二步:按用户对差异项的逐项决策执行导入。 */
387
+ async applyItems(payload: ApplyItemsPayload): Promise<ApplyItemsResponse> {
388
+ return postJson<ApplyItemsResponse>(SYNC_API.applyItems, payload, this.t);
389
+ }
390
+
391
+ /** 取消/清理差异确认会话(丢弃临时 ZIP,零副作用)。 */
392
+ async cancel(syncSessionId: string): Promise<{ ok: boolean }> {
393
+ return postJson<{ ok: boolean }>(SYNC_API.cancel, { syncSessionId }, this.t);
394
+ }
395
+
396
+ /** 自动同步状态(GET /sync/autosync)。 */
397
+ async autosyncStatus(): Promise<AutosyncStatusResponse> {
398
+ const response = await fetch(SYNC_API.autosync);
399
+ return readJson<AutosyncStatusResponse>(response, this.t);
400
+ }
401
+
402
+ /** 自动同步配置更新(POST /sync/autosync)。 */
403
+ async autosyncUpdate(payload: AutosyncUpdatePayload): Promise<AutosyncStatusResponse> {
404
+ return postJson<AutosyncStatusResponse>(SYNC_API.autosync, payload, this.t);
405
+ }
406
+
407
+ /** 一键回滚:按 restoreId 调用 backup→rollback */
408
+ async rollback(payload: { restoreId: string }): Promise<{ ok: boolean; full: boolean }> {
409
+ return postJson<{ ok: boolean; full: boolean }>(SYNC_API.rollback, payload, this.t);
410
+ }
192
411
  }
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * 远程同步设置区块(config-manager-sync)表面文案:zh 为源语言,en 镜像每个键。
3
3
  * 独立命名空间、独立文件:不触碰共享的 locales.ts(并行会话已改),零冲突。
4
4
  * 键集合经 `SyncKey` 类型在 client/index.ts 注册处做编译期校验。
@@ -17,6 +17,7 @@ export const zh = {
17
17
  'config.token': '认证 token',
18
18
  'config.tokenHint': '将安全写入 DSH credentials(引用名 {ref}),不会写入同步文件或日志。留空表示沿用已保存的凭据。',
19
19
  'config.tokenSaved': '凭据已配置',
20
+ 'config.tokenPlaceholder': 'ghp_…(可选)',
20
21
  'config.gitBin': 'git 可执行文件(可选)',
21
22
  'config.gitBinHint': '缺省使用 PATH 中的 git。',
22
23
  // GitHub OAuth 登录(device flow,无需手动输入 token)
@@ -45,6 +46,87 @@ export const zh = {
45
46
  'change.total': '共 {total} 项变更',
46
47
  'sections.title': '同步分区',
47
48
  'warnings.title': '分区告警',
49
+ // 一键同步(方案 A:拉取 → 逐项确认 → apply-items)
50
+ 'syncflow.title': '一键同步',
51
+ 'syncflow.button': '一键同步',
52
+ 'syncflow.syncing': '正在同步…',
53
+ 'syncflow.syncFailed': '同步失败',
54
+ 'syncflow.snapshotOption': '({date} · {count} 分区)',
55
+ 'syncflow.selectSnapshot': '选择历史快照',
56
+ 'syncflow.latestSnapshot': '最新快照',
57
+ 'syncflow.noSnapshots': '远端暂无快照',
58
+ 'syncflow.confirmImport': '确认导入',
59
+ 'syncflow.cancel': '取消',
60
+ 'syncflow.adoptRemote': '采用远端',
61
+ 'syncflow.adoptHint': '勾选 = 导入该项;取消 = 跳过',
62
+ 'syncflow.needsReviewBadge': '需人工决策',
63
+ 'syncflow.empty': '远端快照与本地一致(无变更)',
64
+ 'syncflow.diffCount': '共 {count} 项差异',
65
+ 'syncflow.conflictTitle': '冲突解决',
66
+ 'syncflow.conflictUseLocal': '用本地',
67
+ 'syncflow.conflictUseRemote': '用远端',
68
+ 'syncflow.conflictSkip': '跳过',
69
+ 'syncflow.conflictLocalLabel': '本地',
70
+ 'syncflow.conflictRemoteLabel': '远端',
71
+ 'syncflow.conflictAncestorLabel': '共同祖先',
72
+ 'syncflow.conflictUnresolved': '冲突项 {itemId} 尚未选择解决方式(用本地 / 用远端 / 跳过)',
73
+ 'syncflow.diff': '差异',
74
+ 'syncflow.importDone': '已导入 {n} 个分区',
75
+ 'syncflow.importFailed': '导入失败(已整体回滚)',
76
+ 'syncflow.importedSections': '已写入',
77
+ 'syncflow.importWarnings': '导入告警',
78
+ 'syncflow.rollback': '回滚到应用前',
79
+ 'syncflow.rollingBack': '正在回滚…',
80
+ 'syncflow.rollbackDone': '已回滚到应用前',
81
+ 'syncflow.needsRestart': '部分改动需要重启 DSH 后生效',
82
+ // 自动同步(方案 A)
83
+ 'autosync.title': '自动同步',
84
+ 'autosync.description': '开启后,DSH 将按固定间隔自动执行双向同步(拉取合并 + 上传);DSH 启动时还会触发一次「自动下载合并(不上传)」以保持本地为最新。仅在无冲突且无需人工干预时才自动写入。',
85
+ 'autosync.enable': '启用自动同步',
86
+ 'autosync.interval': '同步间隔',
87
+ 'autosync.intervalHint': 'DSH 启动时自动下载合并不上传。',
88
+ 'autosync.interval5m': '5 分钟',
89
+ 'autosync.interval15m': '15 分钟',
90
+ 'autosync.interval30m': '30 分钟',
91
+ 'autosync.interval60m': '60 分钟',
92
+ 'autosync.interval6h': '6 小时',
93
+ 'autosync.interval12h': '12 小时',
94
+ 'autosync.interval24h': '24 小时',
95
+ 'autosync.status': '上次运行:{status}({time})',
96
+ 'autosync.statusNever': '从未运行',
97
+ 'autosync.nextRun': '下次约 {time} 后',
98
+ 'autosync.failCount': '连续失败 {n} 次',
99
+ 'autosync.running': '自动同步进行中…',
100
+ 'autosync.success': '成功',
101
+ 'autosync.skipped': '已跳过',
102
+ 'autosync.failed': '失败',
103
+ 'autosync.partial': '部分成功',
104
+ // 同步历史(含自动同步记录)
105
+ 'history.title': '同步历史',
106
+ 'history.empty': '尚无同步历史',
107
+ 'history.emptyHint': '完成首次 push / 一键同步 / 自动同步后,这里会显示记录。',
108
+ 'history.colTime': '时间',
109
+ 'history.colKind': '类型',
110
+ 'history.colDetail': '详情',
111
+ 'history.kindSnapshot': '快照',
112
+ 'history.kindAutosync': '自动同步',
113
+ 'history.sectionCount': '分区',
114
+ 'history.detail': '明细',
115
+ 'history.autosyncDirection': '方向:{direction}',
116
+ 'history.autosyncPull': '下载',
117
+ 'history.autosyncPush': '上传',
118
+ 'history.autosyncBoth': '双向',
119
+ 'history.autosyncSkipReason': '跳过原因:{reason}',
120
+ 'history.autosyncReasonConflict': '冲突',
121
+ 'history.autosyncReasonNoRemote': '无远端快照',
122
+ 'history.autosyncReasonNotConfigured': '未配置仓库',
123
+ 'history.autosyncReasonNetwork': '网络问题',
124
+ 'history.autosyncConflicted': '跳过冲突分区:{sections}',
125
+ 'history.autosyncApplied': '应用分区:{sections}',
126
+ 'history.autosyncError': '错误:{error}',
127
+ 'history.autosyncNotified': '已通知(连续失败 3 次)',
128
+ 'history.column.snapshot': '快照',
129
+ 'history.column.autosync': '自动同步',
48
130
  // 公共
49
131
  'common.close': '关闭',
50
132
  'common.retry': '重试',
@@ -61,6 +143,7 @@ export const en: Record<keyof typeof zh, string> = {
61
143
  'config.token': 'Auth token',
62
144
  'config.tokenHint': 'Securely written into DSH credentials (ref {ref}); never written into sync files or logs. Leave blank to reuse the saved credential.',
63
145
  'config.tokenSaved': 'Credential configured',
146
+ 'config.tokenPlaceholder': 'ghp_… (optional)',
64
147
  'config.gitBin': 'git executable (optional)',
65
148
  'config.gitBinHint': 'Defaults to git on PATH.',
66
149
  'github.title': 'GitHub Sign-in',
@@ -85,6 +168,84 @@ export const en: Record<keyof typeof zh, string> = {
85
168
  'change.total': '{total} change(s)',
86
169
  'sections.title': 'Synced Sections',
87
170
  'warnings.title': 'Section Warnings',
171
+ 'syncflow.title': 'One-Click Sync',
172
+ 'syncflow.button': 'One-click sync',
173
+ 'syncflow.syncing': 'Syncing…',
174
+ 'syncflow.syncFailed': 'Sync failed',
175
+ 'syncflow.snapshotOption': '({date} · {count} sections)',
176
+ 'syncflow.selectSnapshot': 'Select snapshot',
177
+ 'syncflow.latestSnapshot': 'Latest snapshot',
178
+ 'syncflow.noSnapshots': 'No remote snapshots',
179
+ 'syncflow.confirmImport': 'Confirm import',
180
+ 'syncflow.cancel': 'Cancel',
181
+ 'syncflow.adoptRemote': 'Adopt remote',
182
+ 'syncflow.adoptHint': 'Check = import item; uncheck = skip',
183
+ 'syncflow.needsReviewBadge': 'Needs decision',
184
+ 'syncflow.empty': 'Remote snapshot matches local (no changes)',
185
+ 'syncflow.diffCount': '{count} change(s)',
186
+ 'syncflow.conflictTitle': 'Resolve conflict',
187
+ 'syncflow.conflictUseLocal': 'Use local',
188
+ 'syncflow.conflictUseRemote': 'Use remote',
189
+ 'syncflow.conflictSkip': 'Skip',
190
+ 'syncflow.conflictLocalLabel': 'Local',
191
+ 'syncflow.conflictRemoteLabel': 'Remote',
192
+ 'syncflow.conflictAncestorLabel': 'Common ancestor',
193
+ 'syncflow.conflictUnresolved': 'Conflict item {itemId} has no resolution yet (use local / use remote / skip)',
194
+ 'syncflow.diff': 'Diff',
195
+ 'syncflow.importDone': 'Imported {n} section(s)',
196
+ 'syncflow.importFailed': 'Import failed (rolled back)',
197
+ 'syncflow.importedSections': 'Written',
198
+ 'syncflow.importWarnings': 'Import warnings',
199
+ 'syncflow.rollback': 'Rollback',
200
+ 'syncflow.rollingBack': 'Rolling back…',
201
+ 'syncflow.rollbackDone': 'Rolled back',
202
+ 'syncflow.needsRestart': 'Some changes require a DSH restart to take effect',
203
+ 'autosync.title': 'Auto Sync',
204
+ 'autosync.description': 'When enabled, DSH will run two-way sync (pull merge + push) on a fixed interval; on startup it also triggers a download-merge (no upload) to keep local up to date. Local writes happen only when there are no conflicts or manual-decision items.',
205
+ 'autosync.enable': 'Enable auto sync',
206
+ 'autosync.interval': 'Sync interval',
207
+ 'autosync.intervalHint': 'Auto download-merge on DSH startup, no upload.',
208
+ 'autosync.interval5m': '5 min',
209
+ 'autosync.interval15m': '15 min',
210
+ 'autosync.interval30m': '30 min',
211
+ 'autosync.interval60m': '60 min',
212
+ 'autosync.interval6h': '6 hr',
213
+ 'autosync.interval12h': '12 hr',
214
+ 'autosync.interval24h': '24 hr',
215
+ 'autosync.status': 'Last run: {status} ({time})',
216
+ 'autosync.statusNever': 'Never run',
217
+ 'autosync.nextRun': 'Next in ~{time}',
218
+ 'autosync.failCount': '{n} consecutive failure(s)',
219
+ 'autosync.running': 'Auto sync running…',
220
+ 'autosync.success': 'Success',
221
+ 'autosync.skipped': 'Skipped',
222
+ 'autosync.failed': 'Failed',
223
+ 'autosync.partial': 'Partial',
224
+ 'history.title': 'Sync History',
225
+ 'history.empty': 'No sync history yet',
226
+ 'history.emptyHint': 'Records appear here after your first push / one-click sync / auto sync.',
227
+ 'history.colTime': 'Time',
228
+ 'history.colKind': 'Kind',
229
+ 'history.colDetail': 'Detail',
230
+ 'history.kindSnapshot': 'Snapshot',
231
+ 'history.kindAutosync': 'Auto Sync',
232
+ 'history.sectionCount': 'sections',
233
+ 'history.detail': 'Detail',
234
+ 'history.autosyncDirection': 'Direction: {direction}',
235
+ 'history.autosyncPull': 'Pull',
236
+ 'history.autosyncPush': 'Push',
237
+ 'history.autosyncBoth': 'Both',
238
+ 'history.autosyncSkipReason': 'Skip reason: {reason}',
239
+ 'history.autosyncReasonConflict': 'Conflict',
240
+ 'history.autosyncReasonNoRemote': 'No remote snapshot',
241
+ 'history.autosyncReasonNotConfigured': 'No repository configured',
242
+ 'history.autosyncReasonNetwork': 'Network issue',
243
+ 'history.autosyncConflicted': 'Skipped conflict sections: {sections}',
244
+ 'history.autosyncApplied': 'Applied sections: {sections}',
245
+ 'history.autosyncError': 'Error: {error}',
246
+ 'history.autosyncNotified': 'Notified (3 consecutive failures)',
247
+ 'history.column.snapshot': 'Snapshot',
248
+ 'history.column.autosync': 'Auto Sync',
88
249
  'common.close': 'Close',
89
250
  'common.retry': 'Retry',
90
251
  'common.loading': 'Loading…',