@pandablessing/dsh-user-sync 1.0.3 → 1.0.5
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/README.md +29 -8
- package/lib/client.js +517 -492
- package/lib/index.js +68 -28
- package/lib/sync.js +70 -5
- package/lib/transport/api.js +13 -8
- package/package.json +9 -7
package/lib/client.js
CHANGED
|
@@ -1,525 +1,550 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// client.ts —— 浏览器 bundle,由 DSH 的 module loader 执行;不是 ESM 模块。
|
|
3
3
|
// React 由宿主运行时注入(factory 的 require);宿主服务的形状在下面用最小接口声明。
|
|
4
|
+
/** client 注册 id 必须等于 npm 包名(module-loader 契约,见 design §9.1)。 */
|
|
5
|
+
const PACKAGE_NAME = '@pandablessing/dsh-user-sync';
|
|
4
6
|
// 浏览器内由 DSH 注入 __ModuleLoader__ 后注册面板;
|
|
5
7
|
// 在 Node 侧(DSH 启动时预检 client 入口)import 时不抛错,避免 web boot 失败。
|
|
6
8
|
if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
React = require('react');
|
|
15
|
-
}
|
|
16
|
-
catch (error) {
|
|
17
|
-
console.error('[dsh-user-sync] 无法加载 react,面板已禁用', error);
|
|
18
|
-
}
|
|
19
|
-
// 最坏情况只失去面板,绝不让 client entry 失败拖垮 web boot。
|
|
20
|
-
if (!React) {
|
|
21
|
-
exports.apply = () => { };
|
|
22
|
-
exports.inject = [];
|
|
23
|
-
return module.exports;
|
|
24
|
-
}
|
|
25
|
-
const R = React;
|
|
26
|
-
const API = '/api/dsh-user-sync';
|
|
27
|
-
const MODULE_LABELS = {
|
|
28
|
-
skills: '技能',
|
|
29
|
-
plugins: '插件清单',
|
|
30
|
-
config: '配置',
|
|
31
|
-
userMemory: '用户记忆',
|
|
32
|
-
projectMemory: '项目记忆',
|
|
33
|
-
sessions: '会话',
|
|
34
|
-
};
|
|
35
|
-
const USER_LEVEL = ['skills', 'plugins', 'config', 'userMemory'];
|
|
36
|
-
/** 每个模块同步的内容简介(按钮悬停与下方小字)。 */
|
|
37
|
-
const MODULE_HINTS = {
|
|
38
|
-
skills: '~/.agents/skills 下的技能包(需含 SKILL.md)',
|
|
39
|
-
plugins: 'profile 的 package.json 与依赖锁文件',
|
|
40
|
-
config: 'cordis.patch.yml 与 AGENTS.md',
|
|
41
|
-
userMemory: 'AGENTS.md 与 memories/ 下的用户级记忆(MEMORY/USER/KEY)',
|
|
42
|
-
projectMemory: 'memories/projects 与 daily 下的项目记忆',
|
|
43
|
-
sessions: 'sessions/ 下的会话记录',
|
|
44
|
-
};
|
|
45
|
-
const styles = {
|
|
46
|
-
section: { display: 'flex', flexDirection: 'column', gap: 12 },
|
|
47
|
-
heading: { margin: 0, fontSize: 18, fontWeight: 600 },
|
|
48
|
-
card: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, padding: 12, display: 'flex', flexDirection: 'column', gap: 6 },
|
|
49
|
-
row: { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 },
|
|
50
|
-
muted: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' },
|
|
51
|
-
moduleRow: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 },
|
|
52
|
-
moduleWrap: { display: 'flex', flexWrap: 'wrap', gap: 8 },
|
|
53
|
-
moduleGrid: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8 },
|
|
54
|
-
moduleItem: { display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 },
|
|
55
|
-
moduleHint: { fontSize: 11, lineHeight: 1.4, color: 'var(--dsw-alias-label-tertiary)' },
|
|
56
|
-
linkButton: { alignSelf: 'flex-start', padding: 0, border: 0, background: 'transparent', color: 'var(--dsw-alias-state-business-primary)', fontSize: 11, cursor: 'pointer', textDecoration: 'underline' },
|
|
57
|
-
warn: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-warning-primary, #b26b00)' },
|
|
58
|
-
fileList: { margin: 0, padding: '4px 0 0 16px', listStyle: 'disc', maxHeight: 160, overflowY: 'auto' },
|
|
59
|
-
fileItem: { fontSize: 11, lineHeight: 1.5, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', wordBreak: 'break-all' },
|
|
60
|
-
moduleButton: {
|
|
61
|
-
padding: '5px 12px',
|
|
62
|
-
borderRadius: 999,
|
|
63
|
-
border: '1px solid var(--dsw-alias-border-l2)',
|
|
64
|
-
background: 'transparent',
|
|
65
|
-
color: 'inherit',
|
|
66
|
-
fontSize: 13,
|
|
67
|
-
cursor: 'pointer',
|
|
68
|
-
userSelect: 'none',
|
|
69
|
-
WebkitUserSelect: 'none',
|
|
70
|
-
},
|
|
71
|
-
moduleButtonActive: {
|
|
72
|
-
padding: '5px 12px',
|
|
73
|
-
borderRadius: 999,
|
|
74
|
-
border: '1px solid var(--dsw-alias-state-business-primary)',
|
|
75
|
-
background: 'var(--dsw-alias-state-business-primary)',
|
|
76
|
-
color: '#fff',
|
|
77
|
-
fontSize: 13,
|
|
78
|
-
cursor: 'pointer',
|
|
79
|
-
userSelect: 'none',
|
|
80
|
-
WebkitUserSelect: 'none',
|
|
81
|
-
},
|
|
82
|
-
actions: { display: 'flex', gap: 8, flexWrap: 'wrap' },
|
|
83
|
-
primary: { padding: '6px 14px', borderRadius: 6, border: 0, cursor: 'pointer', background: 'var(--dsw-alias-state-business-primary)', color: '#fff', fontSize: 13 },
|
|
84
|
-
secondary: { padding: '6px 14px', borderRadius: 6, cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'inherit', fontSize: 13 },
|
|
85
|
-
error: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-state-error-primary, #d33)' },
|
|
86
|
-
overlay: {
|
|
87
|
-
position: 'fixed',
|
|
88
|
-
inset: 0,
|
|
89
|
-
background: 'rgba(0, 0, 0, 0.4)',
|
|
90
|
-
display: 'flex',
|
|
91
|
-
alignItems: 'center',
|
|
92
|
-
justifyContent: 'center',
|
|
93
|
-
zIndex: 1000,
|
|
94
|
-
},
|
|
95
|
-
dialog: {
|
|
96
|
-
width: 360,
|
|
97
|
-
maxWidth: 'calc(100vw - 48px)',
|
|
98
|
-
background: 'var(--dsw-alias-bg-elevated, #fff)',
|
|
99
|
-
color: 'inherit',
|
|
100
|
-
borderRadius: 10,
|
|
101
|
-
padding: 16,
|
|
102
|
-
display: 'flex',
|
|
103
|
-
flexDirection: 'column',
|
|
104
|
-
gap: 10,
|
|
105
|
-
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
|
|
106
|
-
},
|
|
107
|
-
};
|
|
108
|
-
async function fetchJson(url, options = {}) {
|
|
109
|
-
// no-store:GET 结果可能被渲染进程缓存,导致「刚写入的状态读回旧值」
|
|
110
|
-
const response = await fetch(url, { cache: 'no-store', ...options });
|
|
111
|
-
const text = await response.text();
|
|
9
|
+
try {
|
|
10
|
+
window.__ModuleLoader__.load({
|
|
11
|
+
id: PACKAGE_NAME,
|
|
12
|
+
factory: (require) => {
|
|
13
|
+
const module = { exports: {} };
|
|
14
|
+
const exports = module.exports;
|
|
15
|
+
let React = null;
|
|
112
16
|
try {
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
return { error: `HTTP ${response.status}`, detail: text.slice(0, 200) };
|
|
17
|
+
React = require('react');
|
|
117
18
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const n = Number(bytes ?? 0);
|
|
121
|
-
if (!Number.isFinite(n))
|
|
122
|
-
return '-';
|
|
123
|
-
if (n < 1024)
|
|
124
|
-
return `${n} B`;
|
|
125
|
-
if (n < 1024 * 1024)
|
|
126
|
-
return `${(n / 1024).toFixed(1)} KB`;
|
|
127
|
-
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
128
|
-
}
|
|
129
|
-
/** ISO 时间 → 本地 `MM-DD HH:mm`。 */
|
|
130
|
-
function fmtTime(iso) {
|
|
131
|
-
if (!iso)
|
|
132
|
-
return '—';
|
|
133
|
-
const date = new Date(iso);
|
|
134
|
-
if (Number.isNaN(date.getTime()))
|
|
135
|
-
return String(iso);
|
|
136
|
-
const pad = (value) => String(value).padStart(2, '0');
|
|
137
|
-
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
138
|
-
}
|
|
139
|
-
const DECISION_LABELS = {
|
|
140
|
-
pull: '发现更新',
|
|
141
|
-
push: '本机有更新',
|
|
142
|
-
skip: '已是最新',
|
|
143
|
-
confirm: '需人工确认',
|
|
144
|
-
'remote-missing': '远端无包',
|
|
145
|
-
};
|
|
146
|
-
/** 最近一次同步的摘要:上传 / 应用 / 检查。 */
|
|
147
|
-
function lastSyncSummary(status) {
|
|
148
|
-
if (!status)
|
|
149
|
-
return '尚未同步';
|
|
150
|
-
const parts = [];
|
|
151
|
-
if (status.lastUploaded?.createdAt)
|
|
152
|
-
parts.push(`上次上传 ${fmtTime(status.lastUploaded.createdAt)}`);
|
|
153
|
-
if (status.lastApplied?.createdAt)
|
|
154
|
-
parts.push(`上次应用 ${fmtTime(status.lastApplied.createdAt)}`);
|
|
155
|
-
if (status.scheduler?.lastCheckAt) {
|
|
156
|
-
const decision = status.scheduler.lastDecision;
|
|
157
|
-
const label = decision?.action && DECISION_LABELS[decision.action] ? DECISION_LABELS[decision.action] : '';
|
|
158
|
-
parts.push(`上次检查 ${fmtTime(status.scheduler.lastCheckAt)}${label ? `(${label})` : ''}`);
|
|
159
|
-
}
|
|
160
|
-
return parts.length > 0 ? parts.join(' · ') : '尚未同步';
|
|
161
|
-
}
|
|
162
|
-
function ResultCard({ notice, onApply, onConfirmModules, onDismiss, onInstallPlugins }) {
|
|
163
|
-
if (!notice)
|
|
164
|
-
return null;
|
|
165
|
-
const { kind, data } = notice;
|
|
166
|
-
if (kind === 'check') {
|
|
167
|
-
const decision = data.decision ?? {};
|
|
168
|
-
if (decision.action === 'pull' || decision.action === 'confirm') {
|
|
169
|
-
const plan = data.plan ?? { added: [], overwritten: [], skipped: [] };
|
|
170
|
-
const missing = data.plugins?.missing ?? [];
|
|
171
|
-
const linkDeps = data.plugins?.linkDeps ?? [];
|
|
172
|
-
return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'confirm' ? '两侧都有更新,需要你决定' : '远端有更新'), R.createElement('p', { style: styles.muted }, `远端包 ${data.latest?.name ?? ''}(${data.meta?.createdAt ?? '时间未知'}),新增 ${(plan.added ?? []).length} / 覆盖 ${(plan.overwritten ?? []).length} / 跳过 ${(plan.skipped ?? []).length} 个文件。`), missing.length > 0
|
|
173
|
-
? R.createElement('p', { style: styles.muted }, `缺失插件:${missing.map((item) => `${item.name}@${item.spec}`).join('、')}`)
|
|
174
|
-
: null, linkDeps.length > 0
|
|
175
|
-
? R.createElement('p', { style: styles.muted }, `本地包需手动放回:${linkDeps.map((item) => item.name).join('、')}`)
|
|
176
|
-
: null, R.createElement('div', { style: styles.actions }, missing.length > 0
|
|
177
|
-
? R.createElement('button', { style: styles.primary, onClick: () => onInstallPlugins(missing) }, '先安装缺失插件')
|
|
178
|
-
: null, R.createElement('button', { style: styles.primary, onClick: () => onApply(data.latest?.name ?? '') }, '用远端覆盖本机'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消')));
|
|
179
|
-
}
|
|
180
|
-
return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'skip' ? '本机已是最新' : '本机有改动'), R.createElement('p', { style: styles.muted }, `判定:${decision.reason ?? decision.action}`));
|
|
19
|
+
catch (error) {
|
|
20
|
+
console.error('[dsh-user-sync] 无法加载 react,面板已禁用', error);
|
|
181
21
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
22
|
+
// 最坏情况只失去面板,绝不让 client entry 失败拖垮 web boot。
|
|
23
|
+
if (!React) {
|
|
24
|
+
exports.apply = () => { };
|
|
25
|
+
exports.inject = [];
|
|
26
|
+
return module.exports;
|
|
186
27
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
28
|
+
const R = React;
|
|
29
|
+
const API = '/api/dsh-user-sync';
|
|
30
|
+
const MODULE_LABELS = {
|
|
31
|
+
skills: '技能',
|
|
32
|
+
plugins: '插件清单',
|
|
33
|
+
config: '配置',
|
|
34
|
+
userMemory: '用户记忆',
|
|
35
|
+
projectMemory: '项目记忆',
|
|
36
|
+
sessions: '会话',
|
|
37
|
+
};
|
|
38
|
+
const USER_LEVEL = ['skills', 'plugins', 'config', 'userMemory'];
|
|
39
|
+
/** 每个模块同步的内容简介(按钮悬停与下方小字)。 */
|
|
40
|
+
const MODULE_HINTS = {
|
|
41
|
+
skills: '~/.agents/skills 下的技能包(需含 SKILL.md)',
|
|
42
|
+
plugins: 'profile 的 package.json 与依赖锁文件',
|
|
43
|
+
config: 'cordis.patch.yml 与 AGENTS.md',
|
|
44
|
+
userMemory: 'AGENTS.md 与 memories/ 下的用户级记忆(MEMORY/USER/KEY)',
|
|
45
|
+
projectMemory: 'memories/projects 与 daily 下的项目记忆',
|
|
46
|
+
sessions: 'sessions/ 下的会话记录',
|
|
47
|
+
};
|
|
48
|
+
const styles = {
|
|
49
|
+
section: { display: 'flex', flexDirection: 'column', gap: 12 },
|
|
50
|
+
heading: { margin: 0, fontSize: 18, fontWeight: 600 },
|
|
51
|
+
card: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, padding: 12, display: 'flex', flexDirection: 'column', gap: 6 },
|
|
52
|
+
row: { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 },
|
|
53
|
+
muted: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' },
|
|
54
|
+
moduleRow: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 },
|
|
55
|
+
moduleWrap: { display: 'flex', flexWrap: 'wrap', gap: 8 },
|
|
56
|
+
moduleGrid: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8 },
|
|
57
|
+
moduleItem: { display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 },
|
|
58
|
+
moduleHint: { fontSize: 11, lineHeight: 1.4, color: 'var(--dsw-alias-label-tertiary)' },
|
|
59
|
+
linkButton: { alignSelf: 'flex-start', padding: 0, border: 0, background: 'transparent', color: 'var(--dsw-alias-state-business-primary)', fontSize: 11, cursor: 'pointer', textDecoration: 'underline' },
|
|
60
|
+
warn: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-warning-primary, #b26b00)' },
|
|
61
|
+
fileList: { margin: 0, padding: '4px 0 0 16px', listStyle: 'disc', maxHeight: 160, overflowY: 'auto' },
|
|
62
|
+
fileItem: { fontSize: 11, lineHeight: 1.5, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', wordBreak: 'break-all' },
|
|
63
|
+
moduleButton: {
|
|
64
|
+
padding: '5px 12px',
|
|
65
|
+
borderRadius: 999,
|
|
66
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
67
|
+
background: 'transparent',
|
|
68
|
+
color: 'inherit',
|
|
69
|
+
fontSize: 13,
|
|
70
|
+
cursor: 'pointer',
|
|
71
|
+
userSelect: 'none',
|
|
72
|
+
WebkitUserSelect: 'none',
|
|
73
|
+
},
|
|
74
|
+
moduleButtonActive: {
|
|
75
|
+
padding: '5px 12px',
|
|
76
|
+
borderRadius: 999,
|
|
77
|
+
border: '1px solid var(--dsw-alias-state-business-primary)',
|
|
78
|
+
background: 'var(--dsw-alias-state-business-primary)',
|
|
79
|
+
color: '#fff',
|
|
80
|
+
fontSize: 13,
|
|
81
|
+
cursor: 'pointer',
|
|
82
|
+
userSelect: 'none',
|
|
83
|
+
WebkitUserSelect: 'none',
|
|
84
|
+
},
|
|
85
|
+
actions: { display: 'flex', gap: 8, flexWrap: 'wrap' },
|
|
86
|
+
primary: { padding: '6px 14px', borderRadius: 6, border: 0, cursor: 'pointer', background: 'var(--dsw-alias-state-business-primary)', color: '#fff', fontSize: 13 },
|
|
87
|
+
secondary: { padding: '6px 14px', borderRadius: 6, cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'inherit', fontSize: 13 },
|
|
88
|
+
error: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-state-error-primary, #d33)' },
|
|
89
|
+
overlay: {
|
|
90
|
+
position: 'fixed',
|
|
91
|
+
inset: 0,
|
|
92
|
+
background: 'rgba(0, 0, 0, 0.4)',
|
|
93
|
+
display: 'flex',
|
|
94
|
+
alignItems: 'center',
|
|
95
|
+
justifyContent: 'center',
|
|
96
|
+
zIndex: 1000,
|
|
97
|
+
},
|
|
98
|
+
dialog: {
|
|
99
|
+
width: 360,
|
|
100
|
+
maxWidth: 'calc(100vw - 48px)',
|
|
101
|
+
background: 'var(--dsw-alias-bg-elevated, #fff)',
|
|
102
|
+
color: 'inherit',
|
|
103
|
+
borderRadius: 10,
|
|
104
|
+
padding: 16,
|
|
105
|
+
display: 'flex',
|
|
106
|
+
flexDirection: 'column',
|
|
107
|
+
gap: 10,
|
|
108
|
+
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
async function fetchJson(url, options = {}) {
|
|
112
|
+
// no-store:GET 结果可能被渲染进程缓存,导致「刚写入的状态读回旧值」
|
|
113
|
+
const response = await fetch(url, { cache: 'no-store', ...options });
|
|
114
|
+
const text = await response.text();
|
|
209
115
|
try {
|
|
210
|
-
return
|
|
116
|
+
return JSON.parse(text);
|
|
211
117
|
}
|
|
212
118
|
catch {
|
|
213
|
-
return
|
|
119
|
+
return { error: `HTTP ${response.status}`, detail: text.slice(0, 200) };
|
|
214
120
|
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
setModules(data.modules);
|
|
243
|
-
})
|
|
244
|
-
.catch((error) => setScan({ error: 'scan-fetch-failed', detail: String(error) }));
|
|
121
|
+
}
|
|
122
|
+
function fmtBytes(bytes) {
|
|
123
|
+
const n = Number(bytes ?? 0);
|
|
124
|
+
if (!Number.isFinite(n))
|
|
125
|
+
return '-';
|
|
126
|
+
if (n < 1024)
|
|
127
|
+
return `${n} B`;
|
|
128
|
+
if (n < 1024 * 1024)
|
|
129
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
130
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
131
|
+
}
|
|
132
|
+
/** ISO 时间 → 本地 `MM-DD HH:mm`。 */
|
|
133
|
+
function fmtTime(iso) {
|
|
134
|
+
if (!iso)
|
|
135
|
+
return '—';
|
|
136
|
+
const date = new Date(iso);
|
|
137
|
+
if (Number.isNaN(date.getTime()))
|
|
138
|
+
return String(iso);
|
|
139
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
140
|
+
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
141
|
+
}
|
|
142
|
+
const DECISION_LABELS = {
|
|
143
|
+
pull: '发现更新',
|
|
144
|
+
push: '本机有更新',
|
|
145
|
+
skip: '已是最新',
|
|
146
|
+
confirm: '需人工确认',
|
|
147
|
+
'remote-missing': '远端无包',
|
|
245
148
|
};
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
else {
|
|
280
|
-
setNotice({ kind: 'result', data: { detail: '自动检查设置已保存' } });
|
|
149
|
+
/** 最近一次同步的摘要:上传 / 应用 / 检查。 */
|
|
150
|
+
function lastSyncSummary(status) {
|
|
151
|
+
if (!status)
|
|
152
|
+
return '尚未同步';
|
|
153
|
+
const parts = [];
|
|
154
|
+
if (status.lastUploaded?.createdAt)
|
|
155
|
+
parts.push(`上次上传 ${fmtTime(status.lastUploaded.createdAt)}`);
|
|
156
|
+
if (status.lastApplied?.createdAt)
|
|
157
|
+
parts.push(`上次应用 ${fmtTime(status.lastApplied.createdAt)}`);
|
|
158
|
+
if (status.scheduler?.lastCheckAt) {
|
|
159
|
+
const decision = status.scheduler.lastDecision;
|
|
160
|
+
const label = decision?.action && DECISION_LABELS[decision.action] ? DECISION_LABELS[decision.action] : '';
|
|
161
|
+
parts.push(`上次检查 ${fmtTime(status.scheduler.lastCheckAt)}${label ? `(${label})` : ''}`);
|
|
162
|
+
}
|
|
163
|
+
return parts.length > 0 ? parts.join(' · ') : '尚未同步';
|
|
164
|
+
}
|
|
165
|
+
function ResultCard({ notice, onApply, onConfirmModules, onDismiss, onInstallPlugins, onForceUpload }) {
|
|
166
|
+
if (!notice)
|
|
167
|
+
return null;
|
|
168
|
+
const { kind, data } = notice;
|
|
169
|
+
if (kind === 'check') {
|
|
170
|
+
const decision = data.decision ?? {};
|
|
171
|
+
if (decision.action === 'pull' || decision.action === 'confirm') {
|
|
172
|
+
const plan = data.plan ?? { added: [], overwritten: [], skipped: [] };
|
|
173
|
+
const missing = data.plugins?.missing ?? [];
|
|
174
|
+
const linkDeps = data.plugins?.linkDeps ?? [];
|
|
175
|
+
return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'confirm' ? '两侧都有更新,需要你决定' : '远端有更新'), R.createElement('p', { style: styles.muted }, `远端包 ${data.latest?.name ?? ''}(${data.meta?.createdAt ?? '时间未知'}),新增 ${(plan.added ?? []).length} / 覆盖 ${(plan.overwritten ?? []).length} / 跳过 ${(plan.skipped ?? []).length} 个文件。`), missing.length > 0
|
|
176
|
+
? R.createElement('p', { style: styles.muted }, `缺失插件:${missing.map((item) => `${item.name}@${item.spec}`).join('、')}`)
|
|
177
|
+
: null, linkDeps.length > 0
|
|
178
|
+
? R.createElement('p', { style: styles.muted }, `本地包需手动放回:${linkDeps.map((item) => item.name).join('、')}`)
|
|
179
|
+
: null, R.createElement('div', { style: styles.actions }, missing.length > 0
|
|
180
|
+
? R.createElement('button', { style: styles.primary, onClick: () => onInstallPlugins(missing) }, '先安装缺失插件')
|
|
181
|
+
: null, R.createElement('button', { style: styles.primary, onClick: () => onApply(data.latest?.name ?? '') }, '用远端覆盖本机'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消')));
|
|
281
182
|
}
|
|
282
|
-
|
|
283
|
-
refresh();
|
|
183
|
+
return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'skip' ? '本机已是最新' : '本机有改动'), R.createElement('p', { style: styles.muted }, `判定:${decision.reason ?? decision.action}`));
|
|
284
184
|
}
|
|
285
|
-
|
|
286
|
-
|
|
185
|
+
if (kind === 'error') {
|
|
186
|
+
const remoteNewer = data.code === 'E_REMOTE_NEWER';
|
|
187
|
+
return R.createElement('div', { style: styles.card }, R.createElement('strong', { style: styles.error }, remoteNewer ? '远端有更新的包' : '操作失败'), R.createElement('p', { style: styles.muted }, data.detail ?? data.error ?? '未知错误'), data.hits
|
|
188
|
+
? R.createElement('p', { style: styles.error }, `敏感扫描命中 ${data.hits.length} 处:${data.hits.map((h) => `${h.file}:${h.line}(${h.pattern})`).join(';')}`)
|
|
189
|
+
: null,
|
|
190
|
+
// 上传前发现远端更新:默认不覆盖,用户确认后才用本机覆盖远端(G2)
|
|
191
|
+
remoteNewer
|
|
192
|
+
? R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => onForceUpload() }, '用本机覆盖远端'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消'))
|
|
193
|
+
: null);
|
|
287
194
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
195
|
+
return R.createElement('div', { style: styles.card }, R.createElement('strong', null, '完成'), R.createElement('p', { style: styles.muted }, data.detail
|
|
196
|
+
? String(data.detail)
|
|
197
|
+
: data.packageId
|
|
198
|
+
? `包 ${data.packageId} · ${fmtBytes(data.size ?? 0)}`
|
|
199
|
+
: `写入 ${data.written?.length ?? 0} 个文件,跳过 ${data.skipped?.length ?? 0} 个`));
|
|
200
|
+
}
|
|
201
|
+
function UserSyncSection() {
|
|
202
|
+
const [status, setStatus] = R.useState(null);
|
|
203
|
+
const [scan, setScan] = R.useState(null);
|
|
204
|
+
// 默认即「四类用户级选中、项目/会话不选」,不依赖 scan 是否成功(G1)。
|
|
205
|
+
const [modules, setModules] = R.useState(['skills', 'plugins', 'config', 'userMemory']);
|
|
206
|
+
const [settings, setSettings] = R.useState({ transport: 'folder', baseUrl: '', autoCheck: true, intervalHours: 6 });
|
|
207
|
+
const [busy, setBusy] = R.useState(false);
|
|
208
|
+
const [notice, setNotice] = R.useState(null);
|
|
209
|
+
// 非用户级模块确认走弹窗(dialog),不再用底部结果卡
|
|
210
|
+
const [confirmDialog, setConfirmDialog] = R.useState(null);
|
|
211
|
+
// 当前展开文件清单的模块(null 表示都收起)
|
|
212
|
+
const [expandedModule, setExpandedModule] = R.useState(null);
|
|
213
|
+
// 设置改动后自动保存(600ms 防抖),不再提供「保存设置」按钮
|
|
214
|
+
const [settingsDirty, setSettingsDirty] = R.useState(false);
|
|
215
|
+
// 本地兜底:status 读不到时也能记住「已同意」,不再重复显示同意卡
|
|
216
|
+
const [localConsent, setLocalConsent] = R.useState(() => {
|
|
217
|
+
try {
|
|
218
|
+
return localStorage.getItem('dsh-user-sync:consent') === '1';
|
|
301
219
|
}
|
|
302
|
-
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
const consentGiven = Boolean(status?.consent?.acceptedAt) || localConsent;
|
|
225
|
+
const refresh = () => {
|
|
226
|
+
fetchJson(`${API}/status`)
|
|
227
|
+
.then((data) => {
|
|
228
|
+
if (data?.error) {
|
|
229
|
+
// 保留已乐观更新的 consent,只补充错误信息
|
|
230
|
+
setStatus((previous) => ({ ...(previous ?? {}), error: data.error, detail: data.detail }));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
setStatus(data);
|
|
234
|
+
setSettings({
|
|
235
|
+
transport: data.transportSetting ?? 'folder',
|
|
236
|
+
baseUrl: data.baseUrl ?? '',
|
|
237
|
+
autoCheck: data.scheduler?.autoCheck !== false,
|
|
238
|
+
intervalHours: data.scheduler?.intervalHours ?? 6,
|
|
239
|
+
});
|
|
240
|
+
})
|
|
241
|
+
.catch((error) => setStatus((previous) => ({ ...(previous ?? {}), error: 'status-fetch-failed', detail: String(error) })));
|
|
242
|
+
fetchJson(`${API}/scan`)
|
|
243
|
+
.then((data) => {
|
|
244
|
+
if (data?.error) {
|
|
245
|
+
setScan({ error: data.error, detail: data.detail });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
setScan(data);
|
|
249
|
+
if (Array.isArray(data.modules))
|
|
250
|
+
setModules(data.modules);
|
|
251
|
+
})
|
|
252
|
+
.catch((error) => setScan({ error: 'scan-fetch-failed', detail: String(error) }));
|
|
253
|
+
};
|
|
254
|
+
R.useEffect(() => {
|
|
303
255
|
refresh();
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
256
|
+
}, []);
|
|
257
|
+
// 设置改动后 600ms 自动保存(输入间隔时不会每敲一下都发请求)
|
|
258
|
+
R.useEffect(() => {
|
|
259
|
+
if (!settingsDirty)
|
|
260
|
+
return undefined;
|
|
261
|
+
const timer = setTimeout(() => {
|
|
262
|
+
void saveSettings();
|
|
263
|
+
}, 600);
|
|
264
|
+
return () => clearTimeout(timer);
|
|
265
|
+
}, [settings, settingsDirty]);
|
|
266
|
+
// 操作提示(完成 / 已保存)3 秒后自动消失;覆盖确认与错误卡保留
|
|
267
|
+
R.useEffect(() => {
|
|
268
|
+
if (notice?.kind !== 'result')
|
|
269
|
+
return undefined;
|
|
270
|
+
const timer = setTimeout(() => setNotice(null), 3000);
|
|
271
|
+
return () => clearTimeout(timer);
|
|
272
|
+
}, [notice]);
|
|
273
|
+
// 自动保存不占用 busy:否则底部「上传/同步」按钮会因 disabled 切换而闪烁
|
|
274
|
+
const saveSettings = async () => {
|
|
275
|
+
try {
|
|
276
|
+
const result = await fetchJson(`${API}/config`, {
|
|
277
|
+
method: 'POST',
|
|
278
|
+
headers: { 'content-type': 'application/json' },
|
|
279
|
+
body: JSON.stringify({
|
|
280
|
+
autoCheck: settings.autoCheck,
|
|
281
|
+
intervalHours: Number(settings.intervalHours) || 6,
|
|
282
|
+
}),
|
|
283
|
+
});
|
|
284
|
+
if (result.error) {
|
|
285
|
+
setNotice({ kind: 'error', data: { detail: `设置保存失败:${result.error}` } });
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
setNotice({ kind: 'result', data: { detail: '自动检查设置已保存' } });
|
|
289
|
+
}
|
|
290
|
+
setSettingsDirty(false);
|
|
291
|
+
refresh();
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
setNotice({ kind: 'error', data: { detail: String(error) } });
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const toggleModule = async (id, checked) => {
|
|
298
|
+
const base = modules ?? [];
|
|
299
|
+
const next = checked ? [...new Set([...base, id])] : base.filter((item) => item !== id);
|
|
300
|
+
try {
|
|
301
|
+
const result = await fetchJson(`${API}/config`, {
|
|
302
|
+
method: 'POST',
|
|
303
|
+
headers: { 'content-type': 'application/json' },
|
|
304
|
+
body: JSON.stringify({ modules: next }),
|
|
305
|
+
});
|
|
306
|
+
if (result.warning) {
|
|
307
|
+
setConfirmDialog({ modules: next, warning: result.warning });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
setModules(next);
|
|
311
|
+
refresh();
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
setNotice({ kind: 'error', data: { detail: String(error) } });
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
const confirmModules = async (next) => {
|
|
318
|
+
try {
|
|
319
|
+
await fetchJson(`${API}/config`, {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: { 'content-type': 'application/json' },
|
|
322
|
+
body: JSON.stringify({ modules: next, confirmNonUserLevel: true }),
|
|
323
|
+
});
|
|
324
|
+
setModules(next);
|
|
325
|
+
setNotice(null);
|
|
326
|
+
setConfirmDialog(null);
|
|
327
|
+
refresh();
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
setNotice({ kind: 'error', data: { detail: String(error) } });
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const acceptConsent = async () => {
|
|
334
|
+
setBusy(true);
|
|
335
|
+
try {
|
|
336
|
+
const result = await fetchJson(`${API}/consent`, {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
headers: { 'content-type': 'application/json' },
|
|
339
|
+
body: JSON.stringify({ accepted: true }),
|
|
340
|
+
});
|
|
341
|
+
if (result?.error) {
|
|
342
|
+
setNotice({ kind: 'error', data: result });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// 乐观更新:不等 status 刷新,卡片立即消失
|
|
346
|
+
const consent = result?.consent ?? { acceptedAt: new Date().toISOString() };
|
|
347
|
+
setStatus((previous) => ({ ...(previous ?? {}), consent }));
|
|
348
|
+
try {
|
|
349
|
+
localStorage.setItem('dsh-user-sync:consent', '1');
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// 隐私模式等场景写不进去,不影响本次会话
|
|
353
|
+
}
|
|
354
|
+
setLocalConsent(true);
|
|
355
|
+
setNotice({ kind: 'result', data: { detail: '已同意' } });
|
|
356
|
+
refresh();
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
setNotice({ kind: 'error', data: { detail: String(error) } });
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
setBusy(false);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
/** force=true 表示用户已确认「用本机覆盖远端」(远端更新时才需要)。 */
|
|
366
|
+
const upload = async (force = false) => {
|
|
367
|
+
setBusy(true);
|
|
317
368
|
setNotice(null);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
method: 'POST',
|
|
330
|
-
headers: { 'content-type': 'application/json' },
|
|
331
|
-
body: JSON.stringify({ accepted: true }),
|
|
332
|
-
});
|
|
333
|
-
if (result?.error) {
|
|
334
|
-
setNotice({ kind: 'error', data: result });
|
|
335
|
-
return;
|
|
369
|
+
try {
|
|
370
|
+
const result = await fetchJson(`${API}/upload`, {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: { 'content-type': 'application/json' },
|
|
373
|
+
body: JSON.stringify({ force }),
|
|
374
|
+
});
|
|
375
|
+
setNotice({ kind: result.ok ? 'result' : 'error', data: result });
|
|
376
|
+
refresh();
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
setNotice({ kind: 'error', data: { detail: String(error) } });
|
|
336
380
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
381
|
+
finally {
|
|
382
|
+
setBusy(false);
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
const installPlugins = async (packages) => {
|
|
386
|
+
setBusy(true);
|
|
340
387
|
try {
|
|
341
|
-
|
|
388
|
+
const result = await fetchJson(`${API}/install`, {
|
|
389
|
+
method: 'POST',
|
|
390
|
+
headers: { 'content-type': 'application/json' },
|
|
391
|
+
body: JSON.stringify({ packages }),
|
|
392
|
+
});
|
|
393
|
+
if (result.ok) {
|
|
394
|
+
setNotice({ kind: 'result', data: { detail: `已安装 ${result.results?.length ?? 0} 个插件,重启 DSH 后生效` } });
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
setNotice({ kind: 'error', data: { detail: `安装失败:${(result.results ?? []).filter((item) => !item.ok).map((item) => `${item.name}(${item.error})`).join(';') || result.error}` } });
|
|
398
|
+
}
|
|
399
|
+
refresh();
|
|
342
400
|
}
|
|
343
|
-
catch {
|
|
344
|
-
|
|
401
|
+
catch (error) {
|
|
402
|
+
setNotice({ kind: 'error', data: { detail: String(error) } });
|
|
345
403
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
refresh();
|
|
349
|
-
}
|
|
350
|
-
catch (error) {
|
|
351
|
-
setNotice({ kind: 'error', data: { detail: String(error) } });
|
|
352
|
-
}
|
|
353
|
-
finally {
|
|
354
|
-
setBusy(false);
|
|
355
|
-
}
|
|
356
|
-
};
|
|
357
|
-
const upload = async () => {
|
|
358
|
-
setBusy(true);
|
|
359
|
-
setNotice(null);
|
|
360
|
-
try {
|
|
361
|
-
const result = await fetchJson(`${API}/upload`, { method: 'POST' });
|
|
362
|
-
setNotice({ kind: result.ok ? 'result' : 'error', data: result });
|
|
363
|
-
refresh();
|
|
364
|
-
}
|
|
365
|
-
catch (error) {
|
|
366
|
-
setNotice({ kind: 'error', data: { detail: String(error) } });
|
|
367
|
-
}
|
|
368
|
-
finally {
|
|
369
|
-
setBusy(false);
|
|
370
|
-
}
|
|
371
|
-
};
|
|
372
|
-
const installPlugins = async (packages) => {
|
|
373
|
-
setBusy(true);
|
|
374
|
-
try {
|
|
375
|
-
const result = await fetchJson(`${API}/install`, {
|
|
376
|
-
method: 'POST',
|
|
377
|
-
headers: { 'content-type': 'application/json' },
|
|
378
|
-
body: JSON.stringify({ packages }),
|
|
379
|
-
});
|
|
380
|
-
if (result.ok) {
|
|
381
|
-
setNotice({ kind: 'result', data: { detail: `已安装 ${result.results?.length ?? 0} 个插件,重启 DSH 后生效` } });
|
|
404
|
+
finally {
|
|
405
|
+
setBusy(false);
|
|
382
406
|
}
|
|
383
|
-
|
|
384
|
-
|
|
407
|
+
};
|
|
408
|
+
const checkRemote = async () => {
|
|
409
|
+
setBusy(true);
|
|
410
|
+
setNotice(null);
|
|
411
|
+
try {
|
|
412
|
+
const result = await fetchJson(`${API}/check`);
|
|
413
|
+
setNotice(result.error ? { kind: 'error', data: result } : { kind: 'check', data: result });
|
|
385
414
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
415
|
+
catch (error) {
|
|
416
|
+
setNotice({ kind: 'error', data: { detail: String(error) } });
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
setBusy(false);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
const applyRemote = async (name) => {
|
|
423
|
+
if (!name)
|
|
424
|
+
return;
|
|
425
|
+
setBusy(true);
|
|
426
|
+
try {
|
|
427
|
+
const result = await fetchJson(`${API}/apply`, {
|
|
428
|
+
method: 'POST',
|
|
429
|
+
headers: { 'content-type': 'application/json' },
|
|
430
|
+
body: JSON.stringify({ name }),
|
|
431
|
+
});
|
|
432
|
+
setNotice({ kind: result.ok ? 'result' : 'error', data: result });
|
|
433
|
+
refresh();
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
setNotice({ kind: 'error', data: { detail: String(error) } });
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
setBusy(false);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
// 会话不参与同步:同步范围内不展示会话模块
|
|
443
|
+
const moduleIds = (scan?.allModules ?? [...USER_LEVEL, 'projectMemory', 'sessions']).filter((id) => id !== 'sessions');
|
|
444
|
+
// 计数优先用 status 的宿主探针(与 /scan 同一条扫描路径),
|
|
445
|
+
// 这样 /scan 请求失败时面板仍能显示真实数量。
|
|
446
|
+
const counts = status?.probe?.counts ?? scan?.counts ?? {};
|
|
447
|
+
return R.createElement('div', { style: styles.section }, R.createElement('h2', { style: styles.heading }, '设置同步'), status?.scheduler?.lastDecision?.action === 'pull' && consentGiven
|
|
448
|
+
? R.createElement('div', { style: styles.card }, R.createElement('strong', null, '远端有更新'), R.createElement('p', { style: styles.muted }, `检测到 ${fmtTime(status.scheduler.lastCheckAt)} 的远端更新(来自 ${status.scheduler.lastDecision.packageId ?? '远端'})。`), R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '立即同步')))
|
|
449
|
+
: null, !consentGiven
|
|
450
|
+
? R.createElement('div', { style: styles.card }, R.createElement('strong', null, '启用前请确认'), R.createElement('p', { style: styles.muted }, '上传内容:技能、插件清单、配置、用户记忆。数据经运营方服务中转并存放在运营方账号(非 DSH 官方服务器);远端只保留 1 份,上传即覆盖。'), R.createElement('div', { style: styles.actions },
|
|
451
|
+
// 用普通按钮:主色按钮看起来像「已选中」,同意是主动作但不应暗示状态
|
|
452
|
+
R.createElement('button', { style: styles.secondary, disabled: busy, onClick: acceptConsent }, '同意并启用上传')))
|
|
453
|
+
: null, R.createElement('div', { style: styles.card }, R.createElement('div', { style: styles.row }, R.createElement('span', null, 'DSH 主目录'), R.createElement('code', null, status?.dshHome ?? '…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, 'Profile'), R.createElement('span', null, status?.profile ?? '…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '账号'), R.createElement('span', null, status?.account?.hint ? `已配对 · ${status.account.hint}` : '未登录官方账号')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '传输'), R.createElement('span', null, status?.transport ?? '…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '版本'), R.createElement('span', null, status?.version ? `v${status.version}` : '加载中…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '接口'), R.createElement('span', { style: status?.error ? styles.error : undefined }, status?.error ? `错误:${status.error}${status.detail ? `(${status.detail})` : ''}` : status?.installed ? '正常' : '加载中…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '扫描'), R.createElement('span', { style: scan?.error ? styles.error : undefined }, scan?.error
|
|
454
|
+
? `失败:${scan.error}${scan.detail ? `:${String(scan.detail).slice(0, 160)}` : ''}`
|
|
455
|
+
: `已选 ${scan?.selectedFiles ?? scan?.totalFiles ?? 0} 个文件 · ${fmtBytes(scan?.selectedBytes ?? scan?.totalBytes ?? 0)}(同步范围 ${scan?.syncableFiles ?? scan?.totalFiles ?? 0} 个 · ${fmtBytes(scan?.syncableBytes ?? scan?.totalBytes ?? 0)})`))), R.createElement('div', { style: styles.card }, R.createElement('strong', null, '同步范围'), (scan?.selectedBytes ?? 0) > 10 * 1024 * 1024
|
|
456
|
+
? R.createElement('p', { style: styles.warn }, `预计 ${fmtBytes(scan?.selectedBytes)},超过 10 MiB 单包上限;上传会被拒绝,请取消「会话」或减少同步范围。`)
|
|
457
|
+
: null, R.createElement('div', { style: styles.moduleGrid }, ...moduleIds.map((id) => {
|
|
458
|
+
const checked = (modules ?? []).includes(id);
|
|
459
|
+
// 插件清单展示「装了什么插件」(profile dependencies);无 plugins 数据时退回文件数
|
|
460
|
+
const usePluginList = id === 'plugins' && Array.isArray(scan?.plugins);
|
|
461
|
+
const moduleCount = usePluginList ? (scan?.plugins?.length ?? 0) : counts[id] ?? 0;
|
|
462
|
+
const moduleFiles = (scan?.files ?? []).filter((file) => file.module === id);
|
|
463
|
+
// 插件清单展示「装了什么插件」,其余模块展示文件路径;会话模块优先展示标题
|
|
464
|
+
const moduleEntries = usePluginList
|
|
465
|
+
? (scan?.plugins ?? []).map((plugin) => `${plugin.name}@${plugin.spec}`)
|
|
466
|
+
: moduleFiles.map((file) => (id === 'sessions' && file.title ? file.title : file.path));
|
|
467
|
+
return R.createElement('div', { key: id, style: styles.moduleItem }, R.createElement('button', {
|
|
468
|
+
type: 'button',
|
|
469
|
+
'aria-pressed': checked,
|
|
470
|
+
title: MODULE_HINTS[id] ?? '',
|
|
471
|
+
onClick: () => toggleModule(id, !checked),
|
|
472
|
+
style: checked ? styles.moduleButtonActive : styles.moduleButton,
|
|
473
|
+
}, `${MODULE_LABELS[id] ?? id}(${moduleCount})${USER_LEVEL.includes(id) ? '' : ' · 非用户级'}`), R.createElement('span', { style: styles.moduleHint }, MODULE_HINTS[id] ?? ''), moduleEntries.length > 0
|
|
474
|
+
? R.createElement('button', {
|
|
475
|
+
type: 'button',
|
|
476
|
+
style: styles.linkButton,
|
|
477
|
+
onClick: () => setExpandedModule(expandedModule === id ? null : id),
|
|
478
|
+
}, expandedModule === id
|
|
479
|
+
? '收起清单'
|
|
480
|
+
: usePluginList
|
|
481
|
+
? `查看 ${moduleEntries.length} 个插件`
|
|
482
|
+
: id === 'sessions'
|
|
483
|
+
? `查看 ${moduleEntries.length} 个会话`
|
|
484
|
+
: `查看 ${moduleEntries.length} 个文件`)
|
|
485
|
+
: null, expandedModule === id
|
|
486
|
+
? R.createElement('ul', { style: styles.fileList }, ...moduleEntries.map((entry) => R.createElement('li', { key: entry, style: styles.fileItem }, entry)))
|
|
487
|
+
: null);
|
|
488
|
+
}))), R.createElement('div', { style: styles.card }, R.createElement('strong', null, '自动检查'), R.createElement('label', { style: styles.moduleRow }, R.createElement('input', {
|
|
489
|
+
type: 'checkbox',
|
|
490
|
+
checked: settings.autoCheck,
|
|
491
|
+
onChange: (event) => {
|
|
492
|
+
setSettings({ ...settings, autoCheck: Boolean(event.target.checked) });
|
|
493
|
+
setSettingsDirty(true);
|
|
494
|
+
},
|
|
495
|
+
}), '启动与定时检查远端(只提醒,不自动覆盖)'), R.createElement('label', { style: styles.moduleRow }, R.createElement('span', null, '间隔(小时)'), R.createElement('input', {
|
|
496
|
+
type: 'number',
|
|
497
|
+
min: 1,
|
|
498
|
+
max: 168,
|
|
499
|
+
value: settings.intervalHours,
|
|
500
|
+
onChange: (event) => {
|
|
501
|
+
setSettings({ ...settings, intervalHours: Number(event.target.value ?? 6) });
|
|
502
|
+
setSettingsDirty(true);
|
|
503
|
+
},
|
|
504
|
+
style: { width: 72, padding: '2px 6px', fontSize: 13 },
|
|
505
|
+
}))), R.createElement('div', { style: styles.actions },
|
|
506
|
+
// 两个操作都用普通按钮:主色高亮会被看成「已选中」
|
|
507
|
+
R.createElement('button', { style: styles.secondary, disabled: busy || !consentGiven, onClick: () => upload() }, busy ? '处理中…' : '上传用户配置'), R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '从远端同步')), R.createElement('p', { style: styles.muted }, lastSyncSummary(status)),
|
|
508
|
+
// 服务地址只读展示:默认内置,用户覆盖后标注来源(面板不再提供输入框)
|
|
509
|
+
R.createElement('p', { style: styles.muted }, `服务地址:${status?.baseUrl ?? '(未解析)'}${status?.baseUrlSource === 'user' ? '(自定义)' : '(内置)'}`), R.createElement(ResultCard, {
|
|
510
|
+
notice,
|
|
511
|
+
onApply: applyRemote,
|
|
512
|
+
onConfirmModules: confirmModules,
|
|
513
|
+
onDismiss: () => setNotice(null),
|
|
514
|
+
onInstallPlugins: installPlugins,
|
|
515
|
+
onForceUpload: () => upload(true),
|
|
516
|
+
}), confirmDialog
|
|
517
|
+
? R.createElement('div', { style: styles.overlay, onClick: () => setConfirmDialog(null) }, R.createElement('div', { style: styles.dialog, onClick: (event) => event.stopPropagation?.() }, R.createElement('strong', null, '同步项目/会话内容?'), R.createElement('p', { style: styles.muted }, `${confirmDialog.warning.map((id) => MODULE_LABELS[id] ?? id).join('、')} 属于项目/会话级,可能包含具体工作内容。确认要跨机器同步吗?`), R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => confirmModules(confirmDialog.modules) }, '仍要同步'), R.createElement('button', { style: styles.secondary, onClick: () => setConfirmDialog(null) }, '取消'))))
|
|
518
|
+
: null);
|
|
519
|
+
}
|
|
520
|
+
function apply(ctx) {
|
|
413
521
|
try {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
refresh();
|
|
522
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
523
|
+
name: 'settings.section',
|
|
524
|
+
id: 'user-sync',
|
|
525
|
+
order: 50,
|
|
526
|
+
label: '设置同步',
|
|
527
|
+
}, UserSyncSection));
|
|
421
528
|
}
|
|
422
529
|
catch (error) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
finally {
|
|
426
|
-
setBusy(false);
|
|
530
|
+
// 面板注册失败只影响自己,不抛给 fiber,避免 web boot 失败。
|
|
531
|
+
console.error('[dsh-user-sync] 面板注册失败', error);
|
|
427
532
|
}
|
|
428
|
-
};
|
|
429
|
-
// 会话不参与同步:同步范围内不展示会话模块
|
|
430
|
-
const moduleIds = (scan?.allModules ?? [...USER_LEVEL, 'projectMemory', 'sessions']).filter((id) => id !== 'sessions');
|
|
431
|
-
// 计数优先用 status 的宿主探针(与 /scan 同一条扫描路径),
|
|
432
|
-
// 这样 /scan 请求失败时面板仍能显示真实数量。
|
|
433
|
-
const counts = status?.probe?.counts ?? scan?.counts ?? {};
|
|
434
|
-
return R.createElement('div', { style: styles.section }, R.createElement('h2', { style: styles.heading }, '设置同步'), status?.scheduler?.lastDecision?.action === 'pull' && consentGiven
|
|
435
|
-
? R.createElement('div', { style: styles.card }, R.createElement('strong', null, '远端有更新'), R.createElement('p', { style: styles.muted }, `检测到 ${fmtTime(status.scheduler.lastCheckAt)} 的远端更新(来自 ${status.scheduler.lastDecision.packageId ?? '远端'})。`), R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '立即同步')))
|
|
436
|
-
: null, !consentGiven
|
|
437
|
-
? R.createElement('div', { style: styles.card }, R.createElement('strong', null, '启用前请确认'), R.createElement('p', { style: styles.muted }, '上传内容:技能、插件清单、配置、用户记忆。数据经运营方服务中转并存放在运营方账号(非 DSH 官方服务器);远端只保留 1 份,上传即覆盖。'), R.createElement('div', { style: styles.actions },
|
|
438
|
-
// 用普通按钮:主色按钮看起来像「已选中」,同意是主动作但不应暗示状态
|
|
439
|
-
R.createElement('button', { style: styles.secondary, disabled: busy, onClick: acceptConsent }, '同意并启用上传')))
|
|
440
|
-
: null, R.createElement('div', { style: styles.card }, R.createElement('div', { style: styles.row }, R.createElement('span', null, 'DSH 主目录'), R.createElement('code', null, status?.dshHome ?? '…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, 'Profile'), R.createElement('span', null, status?.profile ?? '…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '账号'), R.createElement('span', null, status?.account?.hint ? `已配对 · ${status.account.hint}` : '未登录官方账号')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '传输'), R.createElement('span', null, status?.transport ?? '…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '版本'), R.createElement('span', null, status?.version ? `v${status.version}` : '加载中…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '接口'), R.createElement('span', { style: status?.error ? styles.error : undefined }, status?.error ? `错误:${status.error}${status.detail ? `(${status.detail})` : ''}` : status?.installed ? '正常' : '加载中…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '扫描'), R.createElement('span', { style: scan?.error ? styles.error : undefined }, scan?.error
|
|
441
|
-
? `失败:${scan.error}${scan.detail ? `:${String(scan.detail).slice(0, 160)}` : ''}`
|
|
442
|
-
: `已选 ${scan?.selectedFiles ?? scan?.totalFiles ?? 0} 个文件 · ${fmtBytes(scan?.selectedBytes ?? scan?.totalBytes ?? 0)}(同步范围 ${scan?.syncableFiles ?? scan?.totalFiles ?? 0} 个 · ${fmtBytes(scan?.syncableBytes ?? scan?.totalBytes ?? 0)})`))), R.createElement('div', { style: styles.card }, R.createElement('strong', null, '同步范围'), (scan?.selectedBytes ?? 0) > 10 * 1024 * 1024
|
|
443
|
-
? R.createElement('p', { style: styles.warn }, `预计 ${fmtBytes(scan?.selectedBytes)},超过 10 MiB 单包上限;上传会被拒绝,请取消「会话」或减少同步范围。`)
|
|
444
|
-
: null, R.createElement('div', { style: styles.moduleGrid }, ...moduleIds.map((id) => {
|
|
445
|
-
const checked = (modules ?? []).includes(id);
|
|
446
|
-
// 插件清单展示「装了什么插件」(profile dependencies);无 plugins 数据时退回文件数
|
|
447
|
-
const usePluginList = id === 'plugins' && Array.isArray(scan?.plugins);
|
|
448
|
-
const moduleCount = usePluginList ? (scan?.plugins?.length ?? 0) : counts[id] ?? 0;
|
|
449
|
-
const moduleFiles = (scan?.files ?? []).filter((file) => file.module === id);
|
|
450
|
-
// 插件清单展示「装了什么插件」,其余模块展示文件路径;会话模块优先展示标题
|
|
451
|
-
const moduleEntries = usePluginList
|
|
452
|
-
? (scan?.plugins ?? []).map((plugin) => `${plugin.name}@${plugin.spec}`)
|
|
453
|
-
: moduleFiles.map((file) => (id === 'sessions' && file.title ? file.title : file.path));
|
|
454
|
-
return R.createElement('div', { key: id, style: styles.moduleItem }, R.createElement('button', {
|
|
455
|
-
type: 'button',
|
|
456
|
-
'aria-pressed': checked,
|
|
457
|
-
title: MODULE_HINTS[id] ?? '',
|
|
458
|
-
onClick: () => toggleModule(id, !checked),
|
|
459
|
-
style: checked ? styles.moduleButtonActive : styles.moduleButton,
|
|
460
|
-
}, `${MODULE_LABELS[id] ?? id}(${moduleCount})${USER_LEVEL.includes(id) ? '' : ' · 非用户级'}`), R.createElement('span', { style: styles.moduleHint }, MODULE_HINTS[id] ?? ''), moduleEntries.length > 0
|
|
461
|
-
? R.createElement('button', {
|
|
462
|
-
type: 'button',
|
|
463
|
-
style: styles.linkButton,
|
|
464
|
-
onClick: () => setExpandedModule(expandedModule === id ? null : id),
|
|
465
|
-
}, expandedModule === id
|
|
466
|
-
? '收起清单'
|
|
467
|
-
: usePluginList
|
|
468
|
-
? `查看 ${moduleEntries.length} 个插件`
|
|
469
|
-
: id === 'sessions'
|
|
470
|
-
? `查看 ${moduleEntries.length} 个会话`
|
|
471
|
-
: `查看 ${moduleEntries.length} 个文件`)
|
|
472
|
-
: null, expandedModule === id
|
|
473
|
-
? R.createElement('ul', { style: styles.fileList }, ...moduleEntries.map((entry) => R.createElement('li', { key: entry, style: styles.fileItem }, entry)))
|
|
474
|
-
: null);
|
|
475
|
-
}))), R.createElement('div', { style: styles.card }, R.createElement('strong', null, '自动检查'), R.createElement('label', { style: styles.moduleRow }, R.createElement('input', {
|
|
476
|
-
type: 'checkbox',
|
|
477
|
-
checked: settings.autoCheck,
|
|
478
|
-
onChange: (event) => {
|
|
479
|
-
setSettings({ ...settings, autoCheck: Boolean(event.target.checked) });
|
|
480
|
-
setSettingsDirty(true);
|
|
481
|
-
},
|
|
482
|
-
}), '启动与定时检查远端(只提醒,不自动覆盖)'), R.createElement('label', { style: styles.moduleRow }, R.createElement('span', null, '间隔(小时)'), R.createElement('input', {
|
|
483
|
-
type: 'number',
|
|
484
|
-
min: 1,
|
|
485
|
-
max: 168,
|
|
486
|
-
value: settings.intervalHours,
|
|
487
|
-
onChange: (event) => {
|
|
488
|
-
setSettings({ ...settings, intervalHours: Number(event.target.value ?? 6) });
|
|
489
|
-
setSettingsDirty(true);
|
|
490
|
-
},
|
|
491
|
-
style: { width: 72, padding: '2px 6px', fontSize: 13 },
|
|
492
|
-
}))), R.createElement('div', { style: styles.actions },
|
|
493
|
-
// 两个操作都用普通按钮:主色高亮会被看成「已选中」
|
|
494
|
-
R.createElement('button', { style: styles.secondary, disabled: busy || !consentGiven, onClick: upload }, busy ? '处理中…' : '上传用户配置'), R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '从远端同步')), R.createElement('p', { style: styles.muted }, lastSyncSummary(status)), R.createElement(ResultCard, {
|
|
495
|
-
notice,
|
|
496
|
-
onApply: applyRemote,
|
|
497
|
-
onConfirmModules: confirmModules,
|
|
498
|
-
onDismiss: () => setNotice(null),
|
|
499
|
-
onInstallPlugins: installPlugins,
|
|
500
|
-
}), confirmDialog
|
|
501
|
-
? R.createElement('div', { style: styles.overlay, onClick: () => setConfirmDialog(null) }, R.createElement('div', { style: styles.dialog, onClick: (event) => event.stopPropagation?.() }, R.createElement('strong', null, '同步项目/会话内容?'), R.createElement('p', { style: styles.muted }, `${confirmDialog.warning.map((id) => MODULE_LABELS[id] ?? id).join('、')} 属于项目/会话级,可能包含具体工作内容。确认要跨机器同步吗?`), R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => confirmModules(confirmDialog.modules) }, '仍要同步'), R.createElement('button', { style: styles.secondary, onClick: () => setConfirmDialog(null) }, '取消'))))
|
|
502
|
-
: null);
|
|
503
|
-
}
|
|
504
|
-
function apply(ctx) {
|
|
505
|
-
try {
|
|
506
|
-
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
507
|
-
name: 'settings.section',
|
|
508
|
-
id: 'user-sync',
|
|
509
|
-
order: 50,
|
|
510
|
-
label: '设置同步',
|
|
511
|
-
}, UserSyncSection));
|
|
512
533
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
534
|
+
exports.apply = apply;
|
|
535
|
+
// client 半边的 inject 是服务名(slots / locale),不是包名;
|
|
536
|
+
// 不声明 slots 时 ctx.slots 为 undefined,apply 会拿不到服务。
|
|
537
|
+
exports.inject = ['slots'];
|
|
538
|
+
return module.exports;
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
// 同一 bundle 被两个 owner 加载(旧 junction 与 npm 包并存)时,loader 会抛
|
|
544
|
+
// duplicate factory registration;第二次注册忽略即可,绝不能让 web boot 失败。
|
|
545
|
+
const message = String(error?.message ?? error);
|
|
546
|
+
if (!message.includes('duplicate factory registration'))
|
|
547
|
+
throw error;
|
|
548
|
+
console.warn('[dsh-user-sync] client bundle 已注册过,忽略重复注册');
|
|
549
|
+
}
|
|
525
550
|
}
|