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.
- package/lib/client.d.ts +312 -46
- package/lib/client.js +1172 -205
- 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 +292 -19
- package/lib/index.js.map +1 -1
- 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/sync-engine.d.ts +59 -4
- package/lib/sync/sync-engine.js +138 -12
- 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/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.tsx +107 -25
- package/src/client/sync/SyncSettingsView.tsx +226 -83
- package/src/client/sync/history-model.test.ts +82 -0
- package/src/client/sync/history-model.ts +89 -2
- package/src/client/sync/sync-api.test.ts +155 -0
- package/src/client/sync/sync-api.ts +208 -16
- 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 +314 -22
- 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/sync-engine.test.ts +107 -7
- package/src/sync/sync-engine.ts +176 -14
- 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/ui/i18n.ts +132 -3
- package/src/client/sync/SyncPullPreviewView.test.ts +0 -54
- package/src/client/sync/SyncPullPreviewView.tsx +0 -165
- package/src/client/sync/pull-preview-model.ts +0 -44
|
@@ -1,29 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (
|
|
2
|
+
* 同步历史视图(方案 A):列出本地祖先快照目录(kind=apply)+ 自动同步执行记录
|
|
3
|
+
* (kind=autosync)。自动同步行显示时间/方向/状态/跳过冲突/应用分区,点开可看被跳过
|
|
4
|
+
* 冲突分区明细。
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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<
|
|
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(() =>
|
|
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)
|
|
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={
|
|
79
|
+
<SectionTitle title={`${t('history.title')}(${rows.length})`} />
|
|
56
80
|
<table className="sync-history-table">
|
|
57
81
|
<thead>
|
|
58
|
-
<tr><th
|
|
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
|
-
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
*
|
|
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 {
|
|
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 {
|
|
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'
|
|
31
37
|
import { SyncHistoryView } from './SyncHistoryView.tsx'
|
|
32
|
-
import {
|
|
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: '
|
|
54
|
+
busy: 'sync' | 'push' | 'pull' | 'rollback' | null
|
|
50
55
|
pushReport: SyncPushReport | null
|
|
51
56
|
pullReport: SyncPullReport | null
|
|
52
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
212
|
-
|
|
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
|
-
/**
|
|
219
|
-
const
|
|
220
|
-
patch({
|
|
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.
|
|
223
|
-
patch({
|
|
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({
|
|
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=
|
|
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
|
|
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
|
-
{/*
|
|
410
|
-
<
|
|
411
|
-
<
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
<
|
|
423
|
-
<span className={css.
|
|
424
|
-
<
|
|
425
|
-
{
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
)}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
</
|
|
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
|
-
</
|
|
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
|
+
}
|