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