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
@@ -0,0 +1,137 @@
1
+ /**
2
+ * SyncSessionStore 测试:内存登记临时 ZIP + ImportPlan,TTL 30 分钟,惰性清理,同 id 覆盖。
3
+ */
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import crypto from 'node:crypto';
7
+
8
+ import { SyncSessionStore } from './sync-session.ts';
9
+ import type { ImportPlan } from '../core/types.ts';
10
+ import type { SectionId } from '../schema/types.ts';
11
+
12
+ const DEFAULT_TTL_MS = 30 * 60 * 1000;
13
+
14
+ function makePlan(seed: string): ImportPlan {
15
+ return {
16
+ items: [{ id: `item-${seed}`, kind: 'Update', adapter: 'settings', description: `desc-${seed}`, severity: 'info', target: { adapter: 'settings', ref: 'general' } }],
17
+ globalStrategy: 'merge',
18
+ pathMappings: [],
19
+ missingSecrets: [],
20
+ needsRestart: false,
21
+ estimatedActions: { settings: 1 } as unknown as Record<SectionId, number>,
22
+ };
23
+ }
24
+
25
+ function makeAnalysis(seed: string): { valid: boolean; errors: string[]; warnings: string[]; compatibility: 'excellent' | 'good' | 'partial' | 'unsupported'; sectionsInZip: SectionId[]; pluginSummary: { installed: number; toInstall: number }; pathIssues: []; secretCount: number; dependencyIssues: [] } {
26
+ return {
27
+ valid: true,
28
+ errors: [],
29
+ warnings: [],
30
+ compatibility: 'excellent',
31
+ sectionsInZip: ['settings'] as SectionId[],
32
+ pluginSummary: { installed: 0, toInstall: 0 },
33
+ pathIssues: [],
34
+ secretCount: 0,
35
+ dependencyIssues: [],
36
+ };
37
+ }
38
+
39
+ function makeSession(store: SyncSessionStore, opts: { seed: string; nowMs?: number }): string {
40
+ const now = opts.nowMs ?? Date.now();
41
+ const id = crypto.randomUUID();
42
+ store.set({
43
+ id,
44
+ zipPath: `/tmp/session-${opts.seed}.zip`,
45
+ plan: makePlan(opts.seed),
46
+ analysis: makeAnalysis(opts.seed),
47
+ snapshotId: `snap-${opts.seed}`,
48
+ repoUrl: 'https://github.com/foo/bar.git',
49
+ createdAt: now,
50
+ expiresAt: now + DEFAULT_TTL_MS,
51
+ });
52
+ return id;
53
+ }
54
+
55
+ test('SyncSessionStore: set/get 往返;同 id 覆盖', () => {
56
+ const store = new SyncSessionStore({ now: () => 5_000_000 });
57
+ const id = makeSession(store, { seed: 'a', nowMs: 5_000_000 });
58
+ const s1 = store.get(id);
59
+ assert.ok(s1, 'get 应返回 session');
60
+ assert.equal(s1!.zipPath, '/tmp/session-a.zip');
61
+ assert.equal(s1!.plan.items[0]!.id, 'item-a');
62
+ assert.equal(s1!.snapshotId, 'snap-a');
63
+
64
+ // 同 id 覆盖(仍在有效期内)
65
+ store.set({
66
+ id,
67
+ zipPath: '/tmp/session-b.zip',
68
+ plan: makePlan('b'),
69
+ analysis: makeAnalysis('b'),
70
+ snapshotId: 'snap-b',
71
+ repoUrl: 'https://github.com/foo/bar.git',
72
+ createdAt: 5_000_000,
73
+ expiresAt: 5_000_000 + DEFAULT_TTL_MS,
74
+ });
75
+ const s2 = store.get(id);
76
+ assert.equal(s2!.zipPath, '/tmp/session-b.zip');
77
+ assert.equal(s2!.plan.items[0]!.id, 'item-b');
78
+ assert.equal(s2!.snapshotId, 'snap-b');
79
+ });
80
+
81
+ test('SyncSessionStore: get 过期条目 → undefined(惰性清理)', () => {
82
+ const store = new SyncSessionStore();
83
+ const now = 1_000_000;
84
+ const id = crypto.randomUUID();
85
+ store.set({
86
+ id,
87
+ zipPath: '/tmp/expired.zip',
88
+ plan: makePlan('x'),
89
+ analysis: makeAnalysis('x'),
90
+ snapshotId: 'snap-x',
91
+ repoUrl: 'https://github.com/foo/bar.git',
92
+ createdAt: now - DEFAULT_TTL_MS - 1,
93
+ expiresAt: now - 1,
94
+ });
95
+ const s = store.get(id);
96
+ assert.equal(s, undefined, '过期条目应返回 undefined');
97
+ });
98
+
99
+ test('SyncSessionStore: delete 移除条目', () => {
100
+ const store = new SyncSessionStore({ now: () => 5_000_000 });
101
+ const id = makeSession(store, { seed: 'del', nowMs: 5_000_000 });
102
+ assert.ok(store.get(id), 'set 后存在');
103
+ store.delete(id);
104
+ assert.equal(store.get(id), undefined, 'delete 后不存在');
105
+ });
106
+
107
+ test('SyncSessionStore: TTL 边界——过期临界点', () => {
108
+ const store = new SyncSessionStore({ now: () => 2_000_000 });
109
+ const now = 2_000_000;
110
+ const id = crypto.randomUUID();
111
+ // 恰好在 expiresAt(now === expiresAt)→ 视为过期(惰性清理)
112
+ store.set({
113
+ id,
114
+ zipPath: '/tmp/boundary.zip',
115
+ plan: makePlan('boundary'),
116
+ analysis: makeAnalysis('boundary'),
117
+ snapshotId: 'snap-b',
118
+ repoUrl: 'https://github.com/foo/bar.git',
119
+ createdAt: now - DEFAULT_TTL_MS,
120
+ expiresAt: now,
121
+ });
122
+ assert.equal(store.get(id), undefined, 'expiresAt === now 视为过期');
123
+
124
+ // 还差 1ms → 仍有效
125
+ const id2 = crypto.randomUUID();
126
+ store.set({
127
+ id: id2,
128
+ zipPath: '/tmp/boundary2.zip',
129
+ plan: makePlan('boundary2'),
130
+ analysis: makeAnalysis('boundary2'),
131
+ snapshotId: 'snap-b2',
132
+ repoUrl: 'https://github.com/foo/bar.git',
133
+ createdAt: now - DEFAULT_TTL_MS,
134
+ expiresAt: now + 1,
135
+ });
136
+ assert.ok(store.get(id2), 'expiresAt > now 仍应有效');
137
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * SyncSessionStore:一键同步差异确认会话的内存登记。
3
+ *
4
+ * 会话把「拉取预览」与「逐项执行导入」解耦:一次 /sync/sync 在内存持有一份
5
+ * 临时 ZIP 路径 + ImportPlan + ImportAnalysis,配一个 syncSessionId;
6
+ * /sync/apply-items 凭 id 复用这些数据,避免重复拉取/重复 build ZIP。
7
+ *
8
+ * 生命周期:
9
+ * - 进程内存(不落盘),默认 TTL 30 分钟,过期条目惰性清理(get 时检查);
10
+ * - 同 id 覆盖(set 后旧 session 被新 session 替换,旧 ZIP 由上层负责清理);
11
+ * - delete 显式移除(/sync/cancel 与 apply-items 消费后调用)。
12
+ */
13
+ import crypto from 'node:crypto';
14
+ import type { ImportAnalysis, ImportPlan } from '../core/types.ts';
15
+
16
+ /** 默认会话 TTL(30 分钟) */
17
+ export const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
18
+
19
+ /** 一次差异确认会话的完整数据 */
20
+ export interface SyncSession {
21
+ id: string;
22
+ /** 临时标准 ZIP(apply-items 执行 executeImportPlan 需要) */
23
+ zipPath: string;
24
+ plan: ImportPlan;
25
+ analysis: ImportAnalysis;
26
+ /** 被拉取的远端快照 id */
27
+ snapshotId: string;
28
+ repoUrl: string;
29
+ gitBin?: string;
30
+ /** 创建时间(epoch ms) */
31
+ createdAt: number;
32
+ /** 过期时间(epoch ms;过期条目惰性清理) */
33
+ expiresAt: number;
34
+ }
35
+
36
+ /** 内存会话存储;同 id 覆盖;过期条目视为不存在。 */
37
+ export class SyncSessionStore {
38
+ private readonly sessions = new Map<string, SyncSession>();
39
+ private readonly ttlMs: number;
40
+ private readonly now: () => number;
41
+
42
+ constructor(opts: { ttlMs?: number; now?: () => number } = {}) {
43
+ this.ttlMs = opts.ttlMs ?? DEFAULT_SESSION_TTL_MS;
44
+ this.now = opts.now ?? (() => Date.now());
45
+ }
46
+
47
+ /** 登记(或覆盖)会话;id 缺省时自动生成。createdAt/expiresAt 缺省自动填充。返回会话 id。 */
48
+ set(session: Omit<SyncSession, 'id' | 'createdAt' | 'expiresAt'> & { id?: string; createdAt?: number; expiresAt?: number }): string {
49
+ const id = session.id ?? `sync-session-${crypto.randomUUID()}`;
50
+ const createdAt = session.createdAt ?? this.now();
51
+ const expiresAt = session.expiresAt ?? createdAt + this.ttlMs;
52
+ this.sessions.set(id, {
53
+ ...session,
54
+ id,
55
+ createdAt,
56
+ expiresAt,
57
+ });
58
+ return id;
59
+ }
60
+
61
+ /** 读取会话;过期条目返回 undefined(惰性清理)。 */
62
+ get(id: string): SyncSession | undefined {
63
+ const s = this.sessions.get(id);
64
+ if (s === undefined) return undefined;
65
+ if (this.now() >= s.expiresAt) {
66
+ this.sessions.delete(id);
67
+ return undefined;
68
+ }
69
+ return { ...s };
70
+ }
71
+
72
+ /** 删除会话(cancel / apply-items 消费后调用)。 */
73
+ delete(id: string): void {
74
+ this.sessions.delete(id);
75
+ }
76
+ }
@@ -62,6 +62,7 @@ test('saveSyncState / loadSyncState: 完整往返(含 transport 字段)', as
62
62
  skills: { hash: 'b'.repeat(64), updatedAt: '2026-08-16T12:00:00.000Z' },
63
63
  },
64
64
  transport: { type: 'git', ref: 'main' },
65
+ lastSnapshotId: 'sync-abc',
65
66
  };
66
67
  await saveSyncState(tmp, state);
67
68
  const raw = JSON.parse(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'));
@@ -72,12 +73,13 @@ test('saveSyncState / loadSyncState: 完整往返(含 transport 字段)', as
72
73
  }
73
74
  });
74
75
 
75
- test('loadSyncState: 文件不存在 → 返回缺省空状态(lastSyncAt 为空串)', async () => {
76
+ test('loadSyncState: 文件不存在 → 返回缺省空状态(lastSyncAt 为空串,lastSnapshotId 为空串)', async () => {
76
77
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-missing-'));
77
78
  try {
78
79
  const state = await loadSyncState(tmp);
79
80
  assert.equal(state.schemaVersion, SYNC_STATE_SCHEMA_VERSION);
80
81
  assert.equal(state.lastSyncAt, '');
82
+ assert.equal(state.lastSnapshotId, '');
81
83
  assert.deepEqual(state.sections, {});
82
84
  assert.equal(state.transport, undefined);
83
85
  } finally {
@@ -109,10 +111,54 @@ test('saveSyncState: 自动创建目录', async () => {
109
111
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-mkdir-'));
110
112
  try {
111
113
  const dir = path.join(tmp, 'nested', 'sync');
112
- await saveSyncState(dir, { schemaVersion: SYNC_STATE_SCHEMA_VERSION, lastSyncAt: '', sections: {} });
114
+ await saveSyncState(dir, { schemaVersion: SYNC_STATE_SCHEMA_VERSION, lastSyncAt: '', sections: {}, lastSnapshotId: '' });
113
115
  const state = await loadSyncState(dir);
114
116
  assert.equal(state.schemaVersion, SYNC_STATE_SCHEMA_VERSION);
115
117
  } finally {
116
118
  await fs.rm(tmp, { recursive: true, force: true });
117
119
  }
118
120
  });
121
+
122
+ test('loadSyncState: v1 文件 → 内存迁移到 v2 形态(lastSnapshotId=""),不动磁盘', async () => {
123
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-v1-'));
124
+ try {
125
+ // 写入一个 schemaVersion=1 的旧文件(缺 lastSnapshotId)
126
+ await fs.writeFile(
127
+ path.join(tmp, SYNC_STATE_FILE),
128
+ JSON.stringify({
129
+ schemaVersion: 1,
130
+ lastSyncAt: '2026-08-15T00:00:00.000Z',
131
+ sections: { settings: { hash: 'c'.repeat(64), updatedAt: '2026-08-15T00:00:00.000Z' } },
132
+ }),
133
+ 'utf8',
134
+ );
135
+ const state = await loadSyncState(tmp);
136
+ assert.equal(state.schemaVersion, SYNC_STATE_SCHEMA_VERSION, '内存中应已升级为 v2');
137
+ assert.equal(state.lastSnapshotId, '', 'v1 文件缺祖先指针时迁移为 ""');
138
+ assert.equal(state.lastSyncAt, '2026-08-15T00:00:00.000Z');
139
+ assert.equal(state.sections.settings?.hash, 'c'.repeat(64));
140
+ // 磁盘上仍是 v1(不就地升级)
141
+ const onDisk = JSON.parse(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'));
142
+ assert.equal(onDisk.schemaVersion, 1);
143
+ } finally {
144
+ await fs.rm(tmp, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ test('saveSyncState / loadSyncState: lastSnapshotId 非空往返', async () => {
149
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-ancestor-'));
150
+ try {
151
+ const state: SyncState = {
152
+ schemaVersion: SYNC_STATE_SCHEMA_VERSION,
153
+ lastSyncAt: '2026-08-16T12:00:00.000Z',
154
+ sections: {},
155
+ lastSnapshotId: 'sync-deadbeef-1234',
156
+ };
157
+ await saveSyncState(tmp, state);
158
+ const loaded = await loadSyncState(tmp);
159
+ assert.equal(loaded.lastSnapshotId, 'sync-deadbeef-1234');
160
+ assert.deepEqual(loaded, state);
161
+ } finally {
162
+ await fs.rm(tmp, { recursive: true, force: true });
163
+ }
164
+ });
@@ -11,7 +11,9 @@ import type { FilesSection, SectionData, SectionId } from '../schema/types.ts';
11
11
  import { createSnapshotFs, joinFs } from './fs.ts';
12
12
  import type { SnapshotFs } from './fs.ts';
13
13
 
14
- export const SYNC_STATE_SCHEMA_VERSION = 1;
14
+ export const SYNC_STATE_SCHEMA_VERSION = 2;
15
+ /** 历史可读取版本:v1 缺 lastSnapshotId 时做内存迁移到 v2(lastSnapshotId='')。 */
16
+ export const SYNC_STATE_SUPPORTED_VERSIONS: readonly number[] = [1, 2];
15
17
  export const SYNC_STATE_FILE = 'sync-state.json';
16
18
 
17
19
  /** 单个分区的同步状态:内容 hash + 最近变更时间 */
@@ -27,6 +29,8 @@ export interface SyncState {
27
29
  sections: Partial<Record<SectionId, SyncSectionState>>;
28
30
  /** 当前绑定的传输通道(type = SyncTransport.type,ref = 通道内引用,如 git 分支) */
29
31
  transport?: { type: string; ref: string };
32
+ /** 最近一次同步的快照 ID(= 共同祖先指针);'' = 从未同步或无祖先 */
33
+ lastSnapshotId: string;
30
34
  }
31
35
 
32
36
  /** 文件类分区判定:{ version: 1, files: [...] }(duck-typing,与 JSON 分区区分) */
@@ -71,11 +75,12 @@ export function hashSection(data: SectionData): string {
71
75
  return sha256Hex(canonicalJson(data));
72
76
  }
73
77
 
74
- /** 读取同步状态;文件不存在 → 返回缺省空状态(从未同步) */
78
+ /** 读取同步状态;文件不存在 → 返回缺省空状态(从未同步)。
79
+ * v1 文件 → 内存迁移到 v2(lastSnapshotId=''),不在磁盘上就地升级。 */
75
80
  export async function loadSyncState(dir: string, fsx: SnapshotFs = createSnapshotFs(), msg: MsgFunc = zhMsg): Promise<SyncState> {
76
81
  const file = joinFs(dir, SYNC_STATE_FILE);
77
82
  if (!(await fsx.exists(file))) {
78
- return { schemaVersion: SYNC_STATE_SCHEMA_VERSION, lastSyncAt: '', sections: {} };
83
+ return { schemaVersion: SYNC_STATE_SCHEMA_VERSION, lastSyncAt: '', sections: {}, lastSnapshotId: '' };
79
84
  }
80
85
  const raw = Buffer.from(await fsx.readFile(file)).toString('utf8');
81
86
  const parsed = parseJsonSafe(raw);
@@ -83,8 +88,8 @@ export async function loadSyncState(dir: string, fsx: SnapshotFs = createSnapsho
83
88
  throw new Error(msg('sync.state.notObject'));
84
89
  }
85
90
  const obj = parsed as Record<string, unknown>;
86
- if (obj['schemaVersion'] !== SYNC_STATE_SCHEMA_VERSION) {
87
- throw new Error(msg('sync.state.schemaUnsupported', { version: String(obj['schemaVersion']), expected: String(SYNC_STATE_SCHEMA_VERSION) }));
91
+ if (typeof obj['schemaVersion'] !== 'number' || !SYNC_STATE_SUPPORTED_VERSIONS.includes(obj['schemaVersion'])) {
92
+ throw new Error(msg('sync.state.schemaUnsupported', { version: String(obj['schemaVersion']), expected: String(SYNC_STATE_SUPPORTED_VERSIONS.join('/')) }));
88
93
  }
89
94
  if (typeof obj['lastSyncAt'] !== 'string') {
90
95
  throw new Error(msg('sync.state.lastSyncAt'));
@@ -107,6 +112,7 @@ export async function loadSyncState(dir: string, fsx: SnapshotFs = createSnapsho
107
112
  schemaVersion: SYNC_STATE_SCHEMA_VERSION,
108
113
  lastSyncAt: obj['lastSyncAt'],
109
114
  sections,
115
+ lastSnapshotId: typeof obj['lastSnapshotId'] === 'string' ? obj['lastSnapshotId'] : '',
110
116
  };
111
117
  const t = obj['transport'];
112
118
  if (t !== undefined) {
package/src/ui/i18n.ts CHANGED
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * 客户端展示层(src/ui/*、src/client/common/*)的渲染文案目录(zh 源语言 / en 镜像)。
3
3
  *
4
4
  * 与 core 的 messages.ts 分工:本目录负责「客户端纯渲染层」(ReportView / ErrorBanner /
@@ -158,6 +158,72 @@ export const uiZh = {
158
158
  'sync.pullOk': '远端快照 {id} 差异预览:共 {count} 项变更',
159
159
  'sync.pullEmpty': '远端快照与本地一致(无变更)',
160
160
  'sync.previewHint': '以上为只读差异预览,不会执行导入。v1 暂不提供一键导入接线;如需应用远端配置,请使用「导入恢复」向导手动导入导出的备份。',
161
+ // 一键同步(方案 A:拉取 → 逐项确认 → apply-items)
162
+ 'sync.syncTitle': '一键同步',
163
+ 'sync.syncButton': '一键同步',
164
+ 'sync.syncing': '正在同步…',
165
+ 'sync.syncHeadline': '远端快照 {id} 差异确认:共 {count} 项',
166
+ 'sync.syncEmpty': '远端快照与本地一致(无变更)',
167
+ 'sync.syncFailed': '同步失败',
168
+ 'sync.confirmAdopt': '采用远端',
169
+ 'sync.confirmSkip': '跳过',
170
+ 'sync.confirmRequired': '需要人工决策',
171
+ 'sync.needsReviewBadge': '需人工决策',
172
+ 'sync.selectSnapshot': '选择历史快照',
173
+ 'sync.latestSnapshot': '最新快照',
174
+ 'sync.noSnapshots': '远端暂无快照',
175
+ 'sync.confirmImport': '确认导入',
176
+ 'sync.cancelConfirm': '取消',
177
+ 'sync.conflictResolve': '冲突解决',
178
+ 'sync.conflictUseLocal': '用本地',
179
+ 'sync.conflictUseRemote': '用远端',
180
+ 'sync.conflictSkip': '跳过',
181
+ 'sync.conflictResolvedLocal': '已选:本地',
182
+ 'sync.conflictResolvedRemote': '已选:远端',
183
+ 'sync.conflictResolvedSkip': '已选:跳过',
184
+ 'sync.importDone': '已导入 {n} 个分区',
185
+ 'sync.importSkipped': '已跳过 {n} 项',
186
+ 'sync.importFailed': '导入失败(已整体回滚)',
187
+ 'sync.rollbackButton': '回滚到应用前',
188
+ 'sync.rollingBack': '正在回滚…',
189
+ 'sync.rollbackDone': '已回滚到应用前',
190
+ 'sync.appliedSections': '已写入',
191
+ 'sync.importWarnings': '导入告警',
192
+ // 自动同步(方案 A:宿主后台调度)
193
+ 'sync.autosyncTitle': '自动同步',
194
+ 'sync.autosyncDesc': '开启后,DSH 将按固定间隔自动执行双向同步(拉取合并 + 上传);DSH 启动时还会触发一次「自动下载合并(不上传)」以保持本地为最新。仅在无冲突且无需人工干预时才自动写入。',
195
+ 'sync.autosyncEnabled': '启用自动同步',
196
+ 'sync.autosyncInterval': '同步间隔',
197
+ 'sync.autosyncIntervalHint': 'DSH 启动时自动下载合并不上传。',
198
+ 'sync.autosyncNever': '从未运行',
199
+ 'sync.autosyncLastRun': '上次运行:{time}',
200
+ 'sync.autosyncNextIn': '下次约 {time} 后',
201
+ 'sync.autosyncSuccess': '成功',
202
+ 'sync.autosyncSkipped': '已跳过',
203
+ 'sync.autosyncFailed': '失败',
204
+ 'sync.autosyncPartial': '部分成功',
205
+ 'sync.autosyncFailCount': '连续失败 {n} 次',
206
+ 'sync.autosyncSyncing': '自动同步进行中…',
207
+ 'sync.interval.5m': '5 分钟',
208
+ 'sync.interval.15m': '15 分钟',
209
+ 'sync.interval.30m': '30 分钟',
210
+ 'sync.interval.60m': '60 分钟',
211
+ 'sync.interval.6h': '6 小时',
212
+ 'sync.interval.12h': '12 小时',
213
+ 'sync.interval.24h': '24 小时',
214
+ // 自动同步历史(kind=autosync 行渲染)
215
+ 'sync.hist.autosync': '自动同步',
216
+ 'sync.hist.directionPull': '下载',
217
+ 'sync.hist.directionPush': '上传',
218
+ 'sync.hist.directionBoth': '双向',
219
+ 'sync.hist.skipReasonConflict': '冲突项被跳过',
220
+ 'sync.hist.skipReasonNoRemote': '远端无快照',
221
+ 'sync.hist.skipReasonNotConfigured': '未配置仓库',
222
+ 'sync.hist.skipReasonNetwork': '网络问题',
223
+ 'sync.hist.conflictedSections': '跳过冲突分区:{sections}',
224
+ 'sync.hist.appliedSections': '应用分区:{sections}',
225
+ 'sync.hist.failedError': '错误:{error}',
226
+ 'sync.hist.notifiedAt': '已通知',
161
227
  // GitHub OAuth 区块
162
228
  'sync.github.starting': '正在发起 GitHub 授权…',
163
229
  'sync.github.waitingNoCode': '请在浏览器中打开授权页面并完成授权…',
@@ -318,7 +384,70 @@ export const uiEn: Record<UiTextKey, string> = {
318
384
  'sync.pullOk': 'Remote snapshot {id} diff preview: {count} change(s)',
319
385
  'sync.pullEmpty': 'Remote snapshot matches local (no changes)',
320
386
  'sync.previewHint': 'Read-only diff preview above; nothing is imported. v1 does not wire one-click import yet — use the Import wizard to apply a downloaded backup if needed.',
321
- 'sync.github.starting': 'Starting GitHub authorization…',
387
+ 'sync.syncTitle': 'One-Click Sync',
388
+ 'sync.syncButton': 'One-click sync',
389
+ 'sync.syncing': 'Syncing…',
390
+ 'sync.syncHeadline': 'Remote snapshot {id} diff confirm: {count} item(s)',
391
+ 'sync.syncEmpty': 'Remote snapshot matches local (no changes)',
392
+ 'sync.syncFailed': 'Sync failed',
393
+ 'sync.confirmAdopt': 'Adopt remote',
394
+ 'sync.confirmSkip': 'Skip',
395
+ 'sync.confirmRequired': 'Needs decision',
396
+ 'sync.needsReviewBadge': 'Needs decision',
397
+ 'sync.selectSnapshot': 'Select snapshot',
398
+ 'sync.latestSnapshot': 'Latest snapshot',
399
+ 'sync.noSnapshots': 'No remote snapshots',
400
+ 'sync.confirmImport': 'Confirm import',
401
+ 'sync.cancelConfirm': 'Cancel',
402
+ 'sync.conflictResolve': 'Resolve conflict',
403
+ 'sync.conflictUseLocal': 'Use local',
404
+ 'sync.conflictUseRemote': 'Use remote',
405
+ 'sync.conflictSkip': 'Skip',
406
+ 'sync.conflictResolvedLocal': 'Chosen: local',
407
+ 'sync.conflictResolvedRemote': 'Chosen: remote',
408
+ 'sync.conflictResolvedSkip': 'Chosen: skip',
409
+ 'sync.importDone': 'Imported {n} section(s)',
410
+ 'sync.importSkipped': 'Skipped {n} item(s)',
411
+ 'sync.importFailed': 'Import failed (rolled back)',
412
+ 'sync.rollbackButton': 'Rollback',
413
+ 'sync.rollingBack': 'Rolling back…',
414
+ 'sync.rollbackDone': 'Rolled back',
415
+ 'sync.appliedSections': 'Written',
416
+ 'sync.importWarnings': 'Import warnings',
417
+ 'sync.autosyncTitle': 'Auto Sync',
418
+ 'sync.autosyncDesc': '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.',
419
+ 'sync.autosyncEnabled': 'Enable auto sync',
420
+ 'sync.autosyncInterval': 'Sync interval',
421
+ 'sync.autosyncIntervalHint': 'Auto download-merge on DSH startup, no upload.',
422
+ 'sync.autosyncNever': 'Never run',
423
+ 'sync.autosyncLastRun': 'Last run: {time}',
424
+ 'sync.autosyncNextIn': 'Next in ~{time}',
425
+ 'sync.autosyncSuccess': 'Success',
426
+ 'sync.autosyncSkipped': 'Skipped',
427
+ 'sync.autosyncFailed': 'Failed',
428
+ 'sync.autosyncPartial': 'Partial',
429
+ 'sync.autosyncFailCount': '{n} consecutive failure(s)',
430
+ 'sync.autosyncSyncing': 'Auto sync running…',
431
+ 'sync.interval.5m': '5 min',
432
+ 'sync.interval.15m': '15 min',
433
+ 'sync.interval.30m': '30 min',
434
+ 'sync.interval.60m': '60 min',
435
+ 'sync.interval.6h': '6 hr',
436
+ 'sync.interval.12h': '12 hr',
437
+ 'sync.interval.24h': '24 hr',
438
+ 'sync.hist.autosync': 'Auto Sync',
439
+ 'sync.hist.directionPull': 'Pull',
440
+ 'sync.hist.directionPush': 'Push',
441
+ 'sync.hist.directionBoth': 'Both',
442
+ 'sync.hist.skipReasonConflict': 'Conflict items skipped',
443
+ 'sync.hist.skipReasonNoRemote': 'No remote snapshot',
444
+ 'sync.hist.skipReasonNotConfigured': 'No repository configured',
445
+ 'sync.hist.skipReasonNetwork': 'Network issue',
446
+ 'sync.hist.conflictedSections': 'Skipped conflict sections: {sections}',
447
+ 'sync.hist.appliedSections': 'Applied sections: {sections}',
448
+ 'sync.hist.failedError': 'Error: {error}',
449
+ 'sync.hist.notifiedAt': 'Notified',
450
+ 'sync.github.starting': 'Starting GitHub a​u​t​h​o​r​i​z​a​t​i​o​n​…',
322
451
  'sync.github.waitingNoCode': 'Open the authorization page in your browser and complete the flow…',
323
452
  'sync.github.waiting': 'Open the authorization page in your browser, enter the one-time code {code} to finish (waiting for confirmation).',
324
453
  'sync.github.polling': 'Confirming GitHub authorization…',
@@ -352,4 +481,4 @@ export function makeUiT(lang: 'zh' | 'en'): UiT {
352
481
  }
353
482
 
354
483
  /** 缺省 UI 翻译(zh):未注入的渲染路径行为与改造前完全一致。 */
355
- export const zhUiT: UiT = makeUiT('zh');
484
+ export const zhUiT: UiT = makeUiT('zh');