@wang121ye/skillmanager 0.0.1

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.
@@ -0,0 +1,321 @@
1
+ const express = require('express');
2
+ const { spawn } = require('child_process');
3
+
4
+ function htmlEscape(s) {
5
+ return String(s)
6
+ .replace(/&/g, '&')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;')
9
+ .replace(/"/g, '&quot;')
10
+ .replace(/'/g, '&#039;');
11
+ }
12
+
13
+ function buildHtml({ title }) {
14
+ const t = htmlEscape(title || 'skillmanager');
15
+ return `<!doctype html>
16
+ <html lang="zh-CN">
17
+ <head>
18
+ <meta charset="utf-8" />
19
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
20
+ <title>${t}</title>
21
+ <style>
22
+ body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; margin: 0; background: #0b1220; color: #e7eaf0; }
23
+ header { position: sticky; top: 0; backdrop-filter: blur(8px); background: rgba(11,18,32,0.85); border-bottom: 1px solid rgba(255,255,255,0.08); padding: 14px 16px; }
24
+ h1 { font-size: 16px; margin: 0 0 10px; font-weight: 650; }
25
+ .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
26
+ input[type="search"]{ width: min(520px, 100%); padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: #e7eaf0; }
27
+ button { padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.08); color: #e7eaf0; cursor: pointer; }
28
+ button.primary { background: #3b82f6; border-color: rgba(59,130,246,0.6); }
29
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
30
+ main { padding: 14px 16px 40px; }
31
+ .meta { opacity: 0.8; font-size: 12px; }
32
+ .group { margin-top: 16px; border: 1px solid rgba(255,255,255,0.10); border-radius: 12px; overflow: hidden; }
33
+ .groupHeader { display:flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: rgba(255,255,255,0.05); border-bottom: 1px solid rgba(255,255,255,0.08); }
34
+ .groupTitle { font-weight: 650; }
35
+ .list { display: grid; grid-template-columns: 1fr; }
36
+ .item { display:flex; gap: 10px; padding: 10px 12px; border-top: 1px solid rgba(255,255,255,0.08); }
37
+ .item:first-child { border-top: 0; }
38
+ .name { font-weight: 600; }
39
+ .desc { opacity: 0.85; font-size: 12px; margin-top: 2px; }
40
+ .right { margin-left: auto; display:flex; gap: 8px; align-items: center; }
41
+ .pill { font-size: 11px; opacity: 0.85; border: 1px solid rgba(255,255,255,0.14); padding: 3px 8px; border-radius: 999px; }
42
+ .footer { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(11,18,32,0.92); border-top: 1px solid rgba(255,255,255,0.08); padding: 10px 16px; display:flex; justify-content: space-between; align-items: center; gap: 12px; }
43
+ .status { font-size: 12px; opacity: 0.9; }
44
+ a { color: #93c5fd; }
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <header>
49
+ <h1>skillmanager 选择要安装的 skills</h1>
50
+ <div class="row">
51
+ <input id="q" type="search" placeholder="搜索 name / description / source..." />
52
+ <button id="all">全选</button>
53
+ <button id="none">全不选</button>
54
+ <button id="invert">反选</button>
55
+ <span class="meta" id="meta"></span>
56
+ </div>
57
+ </header>
58
+ <main id="app"></main>
59
+ <div class="footer">
60
+ <div class="status" id="status">加载中…</div>
61
+ <div class="right">
62
+ <span class="pill" id="count">0 selected</span>
63
+ <button class="primary" id="submit" disabled>保存并继续</button>
64
+ </div>
65
+ </div>
66
+ <script>
67
+ const state = {
68
+ skills: [],
69
+ selected: new Set(),
70
+ query: ''
71
+ };
72
+
73
+ const elApp = document.getElementById('app');
74
+ const elQ = document.getElementById('q');
75
+ const elAll = document.getElementById('all');
76
+ const elNone = document.getElementById('none');
77
+ const elInvert = document.getElementById('invert');
78
+ const elSubmit = document.getElementById('submit');
79
+ const elStatus = document.getElementById('status');
80
+ const elCount = document.getElementById('count');
81
+ const elMeta = document.getElementById('meta');
82
+
83
+ function norm(s){ return (s || '').toLowerCase(); }
84
+
85
+ function matches(skill) {
86
+ if (!state.query) return true;
87
+ const q = state.query;
88
+ return norm(skill.name).includes(q) || norm(skill.description).includes(q) || norm(skill.sourceName).includes(q) || norm(skill.sourceId).includes(q);
89
+ }
90
+
91
+ function groupBySource(skills) {
92
+ const map = new Map();
93
+ for (const s of skills) {
94
+ const k = s.sourceId;
95
+ if (!map.has(k)) map.set(k, { sourceId: s.sourceId, sourceName: s.sourceName, items: [] });
96
+ map.get(k).items.push(s);
97
+ }
98
+ return Array.from(map.values());
99
+ }
100
+
101
+ function render() {
102
+ const filtered = state.skills.filter(matches);
103
+ const groups = groupBySource(filtered);
104
+ elApp.innerHTML = '';
105
+
106
+ for (const g of groups) {
107
+ const group = document.createElement('div');
108
+ group.className = 'group';
109
+
110
+ const header = document.createElement('div');
111
+ header.className = 'groupHeader';
112
+ header.innerHTML = '<div class="groupTitle"></div><div class="right"><button class="btnSelAll">本源全选</button><button class="btnSelNone">本源全不选</button></div>';
113
+ header.querySelector('.groupTitle').textContent = g.sourceName + ' (' + g.items.length + ')';
114
+
115
+ header.querySelector('.btnSelAll').onclick = () => { for (const s of g.items) state.selected.add(s.id); updateFooter(); render(); };
116
+ header.querySelector('.btnSelNone').onclick = () => { for (const s of g.items) state.selected.delete(s.id); updateFooter(); render(); };
117
+
118
+ const list = document.createElement('div');
119
+ list.className = 'list';
120
+
121
+ for (const s of g.items) {
122
+ const row = document.createElement('div');
123
+ row.className = 'item';
124
+
125
+ const cb = document.createElement('input');
126
+ cb.type = 'checkbox';
127
+ cb.checked = state.selected.has(s.id);
128
+ cb.onchange = () => {
129
+ if (cb.checked) state.selected.add(s.id); else state.selected.delete(s.id);
130
+ updateFooter();
131
+ };
132
+
133
+ const text = document.createElement('div');
134
+ const name = document.createElement('div');
135
+ name.className = 'name';
136
+ name.textContent = s.name;
137
+ const desc = document.createElement('div');
138
+ desc.className = 'desc';
139
+ desc.textContent = s.description || '';
140
+ text.appendChild(name);
141
+ text.appendChild(desc);
142
+
143
+ const right = document.createElement('div');
144
+ right.className = 'right';
145
+ const pill = document.createElement('span');
146
+ pill.className = 'pill';
147
+ pill.textContent = s.sourceId;
148
+ right.appendChild(pill);
149
+
150
+ row.appendChild(cb);
151
+ row.appendChild(text);
152
+ row.appendChild(right);
153
+ list.appendChild(row);
154
+ }
155
+
156
+ group.appendChild(header);
157
+ group.appendChild(list);
158
+ elApp.appendChild(group);
159
+ }
160
+
161
+ elMeta.textContent = filtered.length + ' / ' + state.skills.length + ' shown';
162
+ }
163
+
164
+ function updateFooter() {
165
+ elCount.textContent = state.selected.size + ' selected';
166
+ elSubmit.disabled = state.selected.size === 0;
167
+ }
168
+
169
+ async function load() {
170
+ const r = await fetch('/api/skills');
171
+ const j = await r.json();
172
+ state.skills = j.skills || [];
173
+ state.selected = new Set(j.selectedSkillIds || []);
174
+ updateFooter();
175
+ render();
176
+ elStatus.textContent = '就绪:请选择并点击“保存并继续”';
177
+ }
178
+
179
+ elQ.oninput = () => { state.query = norm(elQ.value).trim(); render(); };
180
+ elAll.onclick = () => { for (const s of state.skills) state.selected.add(s.id); updateFooter(); render(); };
181
+ elNone.onclick = () => { state.selected.clear(); updateFooter(); render(); };
182
+ elInvert.onclick = () => {
183
+ const next = new Set();
184
+ for (const s of state.skills) if (!state.selected.has(s.id)) next.add(s.id);
185
+ state.selected = next;
186
+ updateFooter();
187
+ render();
188
+ };
189
+
190
+ elSubmit.onclick = async () => {
191
+ elSubmit.disabled = true;
192
+ elStatus.textContent = '已提交,正在处理…(你可以回到终端查看后续安装输出)';
193
+ const body = { selectedSkillIds: Array.from(state.selected) };
194
+ const payload = JSON.stringify(body);
195
+ try {
196
+ // Best-effort: ensure request reaches server even if page closes quickly.
197
+ let beaconOk = false;
198
+ try {
199
+ if (navigator.sendBeacon) {
200
+ const blob = new Blob([payload], { type: 'application/json' });
201
+ beaconOk = navigator.sendBeacon('/api/submit', blob);
202
+ }
203
+ } catch {}
204
+
205
+ // Secondary path: fetch. In some cases the server may already close after receiving the beacon;
206
+ // if so, treat fetch failure as non-fatal.
207
+ try {
208
+ await fetch('/api/submit', {
209
+ method: 'POST',
210
+ headers: { 'content-type': 'application/json' },
211
+ body: payload,
212
+ keepalive: true
213
+ });
214
+ } catch (e) {
215
+ if (!beaconOk) throw e;
216
+ }
217
+
218
+ elStatus.textContent = '已提交完成:正在关闭页面…(若未自动关闭,请手动关闭此标签页)';
219
+ // Best-effort: browsers often only allow closing windows opened by script.
220
+ setTimeout(() => {
221
+ try { window.close(); } catch {}
222
+ }, 500);
223
+
224
+ // Fallback UI if close is blocked
225
+ setTimeout(() => {
226
+ const hint = document.createElement('div');
227
+ hint.style.marginTop = '10px';
228
+ hint.innerHTML = '<button id="closeBtn" style="padding:10px 12px;border-radius:10px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.08);color:#e7eaf0;cursor:pointer;">关闭页面</button>';
229
+ elStatus.appendChild(hint);
230
+ const btn = document.getElementById('closeBtn');
231
+ if (btn) btn.onclick = () => { try { window.close(); } catch {} };
232
+ }, 800);
233
+ } catch (e) {
234
+ elStatus.textContent = '提交失败:' + (e && e.message ? e.message : String(e));
235
+ elSubmit.disabled = false;
236
+ }
237
+ };
238
+
239
+ load().catch((e) => {
240
+ elStatus.textContent = '加载失败:' + (e && e.message ? e.message : String(e));
241
+ });
242
+ </script>
243
+ </body>
244
+ </html>`;
245
+ }
246
+
247
+ async function openInBrowser(url) {
248
+ const platform = process.platform;
249
+ let cmd;
250
+ let args;
251
+ if (platform === 'win32') {
252
+ cmd = 'cmd';
253
+ args = ['/c', 'start', '', url];
254
+ } else if (platform === 'darwin') {
255
+ cmd = 'open';
256
+ args = [url];
257
+ } else {
258
+ cmd = 'xdg-open';
259
+ args = [url];
260
+ }
261
+
262
+ // best-effort: detached so CLI can keep running
263
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true });
264
+ // If open command doesn't exist (e.g. minimal Linux without xdg-open), don't crash.
265
+ child.on('error', () => {});
266
+ child.unref();
267
+ }
268
+
269
+ async function launchSelectionUi({ skills, selectedSkillIds, title }) {
270
+ const app = express();
271
+ app.use(express.json({ limit: '1mb' }));
272
+
273
+ let resolveSubmit;
274
+ const submitted = new Promise((resolve) => (resolveSubmit = resolve));
275
+ let submittedOnce = false;
276
+
277
+ app.get('/', (_req, res) => {
278
+ res.setHeader('content-type', 'text/html; charset=utf-8');
279
+ res.send(buildHtml({ title }));
280
+ });
281
+
282
+ app.get('/api/skills', (_req, res) => {
283
+ res.json({
284
+ skills,
285
+ selectedSkillIds
286
+ });
287
+ });
288
+
289
+ app.post('/api/submit', (req, res) => {
290
+ const ids = Array.isArray(req.body?.selectedSkillIds) ? req.body.selectedSkillIds : [];
291
+ if (!submittedOnce) {
292
+ submittedOnce = true;
293
+ resolveSubmit(ids);
294
+ }
295
+ res.json({ ok: true });
296
+ // graceful shutdown is handled by caller after promise resolves
297
+ });
298
+
299
+ const server = await new Promise((resolve) => {
300
+ const s = app.listen(0, '127.0.0.1', () => resolve(s));
301
+ });
302
+
303
+ const address = server.address();
304
+ const url = `http://127.0.0.1:${address.port}/`;
305
+
306
+ // eslint-disable-next-line no-console
307
+ console.log(`\n已启动 Web UI:${url}`);
308
+ // eslint-disable-next-line no-console
309
+ console.log('如果没有自动打开浏览器,请复制上面的地址手动打开;选择完成后点击“保存并继续”,终端才会继续。\n');
310
+
311
+ await openInBrowser(url);
312
+
313
+ const chosen = await submitted;
314
+ // Give the browser a brief window to finish any in-flight fetch after submit (beacon/fetch race).
315
+ await new Promise((r) => setTimeout(r, 800));
316
+ await new Promise((resolve) => server.close(() => resolve()));
317
+ return chosen;
318
+ }
319
+
320
+ module.exports = { launchSelectionUi };
321
+