dsh-pick 0.0.0-stage → 0.2.0

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/web/panel.js ADDED
@@ -0,0 +1,428 @@
1
+ /* dsh-pick 面板逻辑 —— 选插件四步:说需求 → 找候选 → 多维比较 → 出结论 */
2
+
3
+ // 两种宿主的资源路径不同,这里集中定义,别的地方只认这两个键
4
+ // dsh-pick serve(独立预览)→ /lib/words.js、/data/catalog.json
5
+ // DSH 插件内嵌 iframe → /api-dsh-pick/words.js、/api-dsh-pick/catalog.json
6
+ const IS_PLUGIN = location.pathname.indexOf('/api-dsh-pick') === 0
7
+ const P = {
8
+ words: IS_PLUGIN ? '/api-dsh-pick/words.js' : '/lib/words.js',
9
+ catalog: IS_PLUGIN ? '/api-dsh-pick/catalog.json' : '/data/catalog.json',
10
+ }
11
+
12
+ const $ = s => document.querySelector(s)
13
+ let CAT = null
14
+ let cands = []
15
+ let picked = []
16
+ const WATCH_KEY = 'dsh-pick-watch-v1'
17
+
18
+ const PALETTE = ['#2f6bff', '#00a39a', '#f5a524', '#e2557b', '#8b5cf6', '#0f9d58', '#d97a06']
19
+ const colorOf = i => PALETTE[i % PALETTE.length]
20
+
21
+ const num = n => (n === null || n === undefined) ? '—' : Number(n).toLocaleString('en-US')
22
+ const pct = n => (n === null || n === undefined) ? '—' : (n >= 0 ? '+' : '') + Math.round(n * 100) + '%'
23
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
24
+
25
+ /* ---------------- 排名文案与外链按钮 ---------------- */
26
+ function rankText(rec) {
27
+ if (!rec.rank) return ''
28
+ const total = `总榜 #<b>${num(rec.rank)}</b>`
29
+ const cat = rec.crank ? ` · ${esc(rec.cat)} #<b>${num(rec.crank)}</b>` : ''
30
+ return total + cat
31
+ }
32
+
33
+ const GH_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.4 7.4 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A7.99 7.99 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>'
34
+ const NPM_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor"><path d="M0 0h16v16H0V0zm3.2 12.8V3.2h2.4v9.6H3.2zm3.6 0V3.2h5.6v6.4H8.8v3.2H6.8zM8.8 6.4v3.2h1.6V6.4H8.8z"/></svg>'
35
+
36
+ function linkBtns(rec) {
37
+ let h = ''
38
+ if (rec.ghUrl) {
39
+ h += `<a class="lk" href="${esc(rec.ghUrl)}" target="_blank" rel="noopener" title="GitHub:${esc(rec.ghUrl)}">${GH_SVG}GitHub</a>`
40
+ }
41
+ h += `<a class="lk" href="${esc(rec.npmUrl || ('https://www.npmjs.com/package/' + rec.n))}" target="_blank" rel="noopener" title="npm:${esc(rec.npmUrl || rec.n)}">${NPM_SVG}npm</a>`
42
+ return h || '<span style="font-size:11px;color:var(--t3)">无外链</span>'
43
+ }
44
+
45
+ /* ---------------- 搜索(与 lib/engine.js 共用同一份词典 lib/words.js) --------------- */
46
+ const { expandWords } = await import(P.words)
47
+
48
+ function searchByNeed(need, limit = 10) {
49
+ const terms = expandWords(need)
50
+ if (!terms.length || !CAT) return []
51
+
52
+ const maxWeight = terms.reduce((t, s) => t + s.weight, 0)
53
+ const out = []
54
+ for (const rec of Object.values(CAT.packages)) {
55
+ const hay = ((rec.n || '') + ' ' + (rec.kw || []).join(' ') + ' ' + (rec.d || '')).toLowerCase()
56
+ let hit = 0, any = false
57
+ for (const t of terms) if (hay.includes(t.w)) { hit += t.weight; any = true }
58
+ if (!any) continue
59
+ const relevance = hit / maxWeight
60
+ const pop = Math.log10(Math.max(rec.w, 1)) / 6 // 下载量只做微弱加权
61
+ out.push({ rec, relevance, score: relevance * 0.85 + pop * 0.15 })
62
+ }
63
+ out.sort((a, b) => b.score - a.score)
64
+ return out.slice(0, limit).map(x => x.rec)
65
+ }
66
+
67
+ /* ---------------- 启动 ---------------- */
68
+ async function boot() {
69
+ const res = await fetch(P.catalog, { cache: 'no-store' })
70
+ if (!res.ok) { $('#meta').textContent = '目录缺失,先跑 dsh-pick build'; return }
71
+ CAT = await res.json()
72
+
73
+ const deepN = Object.values(CAT.packages).filter(p => p.deep).length
74
+ $('#meta').innerHTML =
75
+ `目录 <b>${num(CAT.total)}</b> 个插件 · 去噪指标 <b>${num(deepN)}</b> 个<br>` +
76
+ `数据截止 <b>${CAT.anchor}</b>(npm 统计滞后约 5 天)`
77
+
78
+ $('#foot').innerHTML =
79
+ `<b>口径:</b>下载量含 CI、镜像、爬虫等非人类流量,不等于用户数;<b>日常基线</b>= 扣掉版本发布日与次日后的每日中位数,比原始下载量更接近真实使用量。<br>` +
80
+ `<b>覆盖范围:</b>npm 上带 <code>dsh-plugin</code> 关键字的 ${num(CAT.total)} 个包;去噪指标只算了周下载 Top ${num(deepN)}。目录每天重建一次,重跑 <code>dsh-pick build</code> 即可。<br>` +
81
+ `<b>当前结论由本地规则生成</b>(依据就是对比表里的数字)。装上 DSH 插件版后,这一步由 AI 生成,可结合你的具体场景解释取舍。`
82
+
83
+ buildCategoryFilter()
84
+ renderRank()
85
+ renderWatch()
86
+ bindEvents()
87
+
88
+ // 支持 ?q=需求 直接带上搜索结果,方便分享「结论链接」
89
+ const q = new URLSearchParams(location.search).get('q')
90
+ if (q) {
91
+ $('#need').value = q
92
+ doSearch()
93
+ }
94
+ }
95
+
96
+ function bindEvents() {
97
+ $('#btnFind').addEventListener('click', doSearch)
98
+ $('#need').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch() })
99
+
100
+ document.querySelectorAll('.examples .chip').forEach(c => {
101
+ c.addEventListener('click', () => { $('#need').value = c.textContent; doSearch() })
102
+ })
103
+
104
+ document.querySelectorAll('#tabs button').forEach(b => {
105
+ b.addEventListener('click', () => {
106
+ document.querySelectorAll('#tabs button').forEach(x => x.classList.toggle('on', x === b))
107
+ const t = b.dataset.tab
108
+ $('#tab-pick').classList.toggle('hidden', t !== 'pick')
109
+ $('#tab-watch').classList.toggle('hidden', t !== 'watch')
110
+ })
111
+ })
112
+
113
+ document.querySelectorAll('#rankFilters .chip').forEach(c => {
114
+ c.addEventListener('click', () => {
115
+ document.querySelectorAll('#rankFilters .chip').forEach(x => x.classList.toggle('on', x === c))
116
+ renderRank()
117
+ })
118
+ })
119
+ $('#rankCat').addEventListener('change', renderRank)
120
+
121
+ $('#btnExport').addEventListener('click', exportHtml)
122
+ $('#btnWatch').addEventListener('click', addPickedToWatch)
123
+ }
124
+
125
+ /* ---------------- 第 2 步:候选 ---------------- */
126
+ function doSearch() {
127
+ const need = $('#need').value.trim()
128
+ if (!need) return
129
+ cands = searchByNeed(need, 10)
130
+ renderCands(need)
131
+ }
132
+
133
+ function renderCands(need) {
134
+ const host = $('#cands')
135
+ host.innerHTML = ''
136
+ if (!cands.length) {
137
+ host.innerHTML = '<div class="empty">目录里没找到相关的。换个说法试试,比如「数据库」「微信」「记忆」。</div>'
138
+ $('#secCand').classList.remove('hidden')
139
+ return
140
+ }
141
+
142
+ cands.forEach((rec, i) => {
143
+ const on = picked.includes(rec.n)
144
+ const d = rec.deep
145
+ const el = document.createElement('div')
146
+ el.className = 'cand' + (on ? ' on' : '')
147
+ el.innerHTML =
148
+ `<div class="box">${on ? '✓' : ''}</div>` +
149
+ `<div class="body">` +
150
+ `<div class="nm">${esc(rec.n)} <span class="v">v${esc(rec.v)}</span>` +
151
+ `<span class="rel">${esc(rec.cat)}</span>` +
152
+ (d ? '<span class="ok" title="有去噪指标,可参与健康度比较">✓ 可比较</span>' : '<span class="nook" title="未进入去噪范围(周下载 Top 之外),缺基线/环比/节奏">仅基础</span>') +
153
+ `</div>` +
154
+ `<div class="rk">${rankText(rec)}</div>` +
155
+ `<div class="d">${esc(rec.d)}</div>` +
156
+ `<div class="kv">` +
157
+ `<span>周 <b>${num(rec.w)}</b></span>` +
158
+ `<span>月 <b>${num(rec.m)}</b></span>` +
159
+ (d ? `<span>基线 <b>${num(d.base)}</b></span><span>环比 <b>${pct(d.growth)}</b></span>` : '') +
160
+ `</div>` +
161
+ `<div class="links">${linkBtns(rec)}</div>` +
162
+ `</div>`
163
+ el.addEventListener('click', () => togglePick(rec.n))
164
+ host.appendChild(el)
165
+ })
166
+
167
+ $('#secCand').classList.remove('hidden')
168
+ $('#candCount').textContent = `「${esc(need)}」命中 ${cands.length} 个 · 已选 ${picked.length}`
169
+ }
170
+
171
+ function togglePick(name) {
172
+ if (picked.includes(name)) picked = picked.filter(x => x !== name)
173
+ else {
174
+ if (picked.length >= 6) { alert('最多比较 6 个'); return }
175
+ picked.push(name)
176
+ }
177
+ renderCands($('#need').value.trim())
178
+ renderCompare()
179
+ }
180
+
181
+ /* ---------------- 第 3 步:比较 ---------------- */
182
+ const ROWS = [
183
+ { k: 'w', label: '周下载', get: r => r.w, fmt: num, best: 'max' },
184
+ { k: 'm', label: '月下载', get: r => r.m, fmt: num, best: 'max' },
185
+ { k: 'base', label: '日常基线', get: r => r.deep && r.deep.base, fmt: num, best: 'max' },
186
+ { k: 'growth', label: '近 7 日环比', get: r => r.deep && r.deep.growth, fmt: pct, best: 'max' },
187
+ { k: 'ver', label: '版本数', get: r => r.deep && r.deep.versions, fmt: num, best: 'max' },
188
+ { k: 'cad', label: '版本节奏', get: r => r.deep && r.deep.cadence, fmt: r => r == null ? '—' : r + ' 天/版', best: 'min' },
189
+ { k: 'age', label: '最近更新', get: r => r.deep && r.deep.age, fmt: r => r == null ? '—' : r + ' 天前', best: 'min' },
190
+ { k: 's', label: '健康度', get: r => r.deep && r.deep.s, fmt: num, best: 'max' },
191
+ ]
192
+
193
+ function renderCompare() {
194
+ const sec = $('#secCmp')
195
+ if (picked.length < 2) {
196
+ sec.classList.add('hidden')
197
+ $('#secVerdict').classList.add('hidden')
198
+ $('#candCount').textContent = `「${esc($('#need').value.trim())}」命中 ${cands.length} 个 · 已选 ${picked.length}(选满 2 个开始比较)`
199
+ return
200
+ }
201
+
202
+ const rows = picked.map(n => CAT.packages[n]).filter(Boolean)
203
+ const missingDeep = rows.filter(r => !r.deep).length
204
+
205
+ $('#cmpHead').innerHTML = '<tr><th>指标</th>' +
206
+ rows.map((r, i) => `<th><i style="background:${colorOf(picked.indexOf(r.n))}"></i>${esc(r.n)}</th>`).join('') +
207
+ '</tr>'
208
+
209
+ let body = ''
210
+ for (const row of ROWS) {
211
+ const vals = rows.map(r => row.get(r))
212
+ const valid = vals.filter(v => v !== null && v !== undefined && !Number.isNaN(v))
213
+ let bestV = null
214
+ if (valid.length) bestV = row.best === 'max' ? Math.max(...valid) : Math.min(...valid)
215
+
216
+ body += `<tr><td>${row.label}</td>` + vals.map(v => {
217
+ const isBest = bestV !== null && v === bestV
218
+ const cls = v === null || v === undefined ? 'miss' : (isBest ? 'best num' : 'num')
219
+ return `<td class="${cls}">${row.fmt(v)}</td>`
220
+ }).join('') + '</tr>'
221
+ }
222
+ $('#cmpBody').innerHTML = body
223
+
224
+ $('#cmpNote').innerHTML =
225
+ `健康度 = 日常基线 40% + 增速 25% + 新鲜度 20% + 版本节奏 15%,满分 100。` +
226
+ (missingDeep ? `<br><span style="color:#b4530a">其中 ${missingDeep} 个未进入深挖范围(周下载 Top 榜外),去噪指标缺失,无法参与健康度比较。</span>` : '')
227
+
228
+ sec.classList.remove('hidden')
229
+ renderVerdict()
230
+ }
231
+
232
+ /* ---------------- 第 4 步:结论 ---------------- */
233
+ function renderVerdict() {
234
+ const rows = picked.map(n => CAT.packages[n]).filter(Boolean)
235
+ const withDeep = rows.filter(r => r.deep)
236
+ const noDeep = rows.filter(r => !r.deep)
237
+
238
+ if (withDeep.length < 2) {
239
+ // 不静默消失:说清楚为什么给不出结论,并指路
240
+ $('#verdicts').innerHTML =
241
+ `<div class="verdict alt" style="margin:0"><h4>给不出结论</h4><p>` +
242
+ `已选 ${rows.length} 个,其中 <b>${withDeep.length}</b> 个有去噪指标。` +
243
+ (noDeep.length ? `<b>${noDeep.map(r => esc(r.n)).join('、')}</b> 未进入去噪范围(周下载 Top 之外),缺日常基线、环比、版本节奏,无法参与健康度比较。<br>` : '') +
244
+ `至少需要 2 个带「✓ 可比较」标记的包。上面排行榜里按「日常基线 / 健康度」排序的就是这批。</p></div>`
245
+ $('#secVerdict').classList.remove('hidden')
246
+ return
247
+ }
248
+
249
+ byScore = withDeep.slice().sort((a, b) => b.deep.s - a.deep.s)
250
+ const win = byScore[0]
251
+ const others = byScore.slice(1)
252
+
253
+ // 备选:基线最高但不是首选的(「更稳的那个」)
254
+ const alt = others.slice().sort((a, b) => b.deep.base - a.deep.base)[0]
255
+ // 不推荐:健康度最低,且要么久不更新,要么基线明显低于首选
256
+ const no = byScore[byScore.length - 1]
257
+
258
+ const winName = esc(win.n)
259
+ const html = []
260
+
261
+ html.push(`<div class="verdict win"><h4>✅ 首选 ${winName}</h4><p>` +
262
+ `日常基线 <b>${num(win.deep.base)}</b> 次/天(已扣掉发布拉取),` +
263
+ `近 7 日环比 <b>${pct(win.deep.growth)}</b>,` +
264
+ `${win.deep.versions} 个版本、约 <b>${win.deep.cadence === null ? '—' : win.deep.cadence + ' 天'}</b>一个,` +
265
+ `最近更新在 <b>${win.deep.age} 天前</b>,健康度 <b>${win.deep.s}</b>。` +
266
+ `${win.deep.growth !== null && win.deep.growth > 0 ? '仍在涨。' : '体量最大且维护稳定。'}</p></div>`)
267
+
268
+ if (alt && alt !== win) {
269
+ html.push(`<div class="verdict alt"><h4>⚡ 备选 ${esc(alt.n)}</h4><p>` +
270
+ `日常基线 <b>${num(alt.deep.base)}</b> 次/天,环比 <b>${pct(alt.deep.growth)}</b>,` +
271
+ `最近更新 <b>${alt.deep.age} 天前</b>,健康度 <b>${alt.deep.s}</b>。` +
272
+ (alt.deep.growth !== null && alt.deep.growth > (win.deep.growth || 0)
273
+ ? '如果你更看重势头而不是体量,选它。'
274
+ : '如果你更看重大家已经在用的稳定度,可以改选它。') + `</p></div>`)
275
+ }
276
+
277
+ if (no !== win && byScore.length >= 3) {
278
+ const baseTxt = no.deep.base > 0
279
+ ? `,日常基线 ${num(no.deep.base)} 次/天,只有首选的 ${Math.round(no.deep.base / Math.max(win.deep.base, 1) * 100)}%`
280
+ : `,日常基线为 0 次/天`
281
+ html.push(`<div class="verdict no"><h4>⚠️ 不推荐 ${esc(no.n)}</h4><p>` +
282
+ `健康度 <b>${no.deep.s}</b>(本组最低)` +
283
+ (no.deep.age > 90 ? `,<b>${no.deep.age} 天没发新版本</b>,可能已进入维护停滞。` : '') +
284
+ baseTxt + `。</p></div>`)
285
+ }
286
+
287
+ $('#verdicts').innerHTML = html.join('')
288
+ $('#secVerdict').classList.remove('hidden')
289
+ }
290
+
291
+ var byScore = []
292
+
293
+ /* ---------------- 排行榜 ---------------- */
294
+ function buildCategoryFilter() {
295
+ const sel = $('#rankCat')
296
+ const cats = [...new Set(Object.values(CAT.packages).map(p => p.cat))].filter(Boolean).sort()
297
+ sel.innerHTML = '<option value="">全部分类</option>' + cats.map(c => `<option>${esc(c)}</option>`).join('')
298
+ }
299
+
300
+ function renderRank() {
301
+ const metric = document.querySelector('#rankFilters .chip.on').dataset.m
302
+ const cat = $('#rankCat').value
303
+ const onlyDeep = metric !== 'w'
304
+
305
+ let rows = Object.values(CAT.packages).filter(r => (!cat || r.cat === cat) && (!onlyDeep || r.deep))
306
+ const val = r => metric === 'base' ? r.deep.base
307
+ : metric === 'growth' ? (r.deep.growth === null ? -Infinity : r.deep.growth)
308
+ : metric === 's' ? r.deep.s
309
+ : r.w
310
+ rows.sort((a, b) => val(b) - val(a))
311
+ rows = rows.slice(0, 40)
312
+
313
+ $('#rankBody').innerHTML = rows.map((r, i) => {
314
+ const d = r.deep
315
+ const g = d ? d.growth : null
316
+ return `<tr>` +
317
+ `<td style="color:var(--t3)">${i + 1}</td>` +
318
+ `<td class="nm">${esc(r.n)} <span style="color:var(--t3);font-weight:400;font-size:11px">v${esc(r.v)}</span></td>` +
319
+ `<td><span class="catpill">${esc(r.cat)}</span></td>` +
320
+ `<td class="r"><b>${num(val(r))}</b></td>` +
321
+ `<td class="r">${num(r.w)}</td>` +
322
+ `<td class="r ${g === null ? '' : (g >= 0 ? 'up' : 'down')}">${pct(g)}</td>` +
323
+ `<td class="r">${d && d.cadence !== null ? d.cadence + ' 天' : '—'}</td>` +
324
+ `<td class="r">${d ? d.age + ' 天前' : '—'}</td>` +
325
+ `<td style="white-space:nowrap">${linkBtns(r)}</td>` +
326
+ `</tr>`
327
+ }).join('')
328
+ }
329
+
330
+ /* ---------------- 我的监控 ---------------- */
331
+ function watch() {
332
+ try { return JSON.parse(localStorage.getItem(WATCH_KEY)) || [] } catch { return [] }
333
+ }
334
+ function saveWatch(list) { localStorage.setItem(WATCH_KEY, JSON.stringify(list)) }
335
+
336
+ function addPickedToWatch() {
337
+ const list = watch()
338
+ for (const n of picked) if (!list.includes(n)) list.push(n)
339
+ saveWatch(list.slice(0, 30))
340
+ renderWatch()
341
+ alert('已加入我的监控(' + picked.join('、') + ')')
342
+ }
343
+
344
+ function renderWatch() {
345
+ const list = watch()
346
+ $('#watchCount').textContent = list.length ? ` (${list.length})` : ''
347
+ const host = $('#watchList')
348
+ if (!list.length) {
349
+ host.innerHTML = '<div class="empty">还没有监控的插件。在「选插件」里挑几个,点「加入我的监控」。</div>'
350
+ $('#watchChart').innerHTML = ''
351
+ return
352
+ }
353
+
354
+ host.innerHTML = list.map(n => {
355
+ const r = CAT.packages[n]
356
+ if (!r) return `<div class="mon"><span class="nm">${esc(n)}</span><span style="color:var(--t3);font-size:11.5px">不在目录中</span><button class="x" data-rm="${esc(n)}">×</button></div>`
357
+ const d = r.deep
358
+ return `<div class="mon">` +
359
+ `<span class="nm">${esc(n)}</span>` +
360
+ `<span class="mrank">${rankText(r)}</span>` +
361
+ `<span style="font-size:11.5px;color:var(--t3)">${esc(r.cat)}</span>` +
362
+ `<span style="font-size:12px"><b>${num(r.w)}</b>/周</span>` +
363
+ `<span style="font-size:12px;color:var(--t2)">基线 ${d ? num(d.base) : '—'}</span>` +
364
+ `<span class="mlinks">${linkBtns(r)}</span>` +
365
+ `<button class="x" data-rm="${esc(n)}">×</button></div>`
366
+ }).join('')
367
+
368
+ host.querySelectorAll('[data-rm]').forEach(b =>
369
+ b.addEventListener('click', () => saveWatch(watch().filter(x => x !== b.dataset.rm)) || renderWatch()))
370
+
371
+ // 对数刻度横条图
372
+ const rows = list.map(n => CAT.packages[n]).filter(Boolean)
373
+ const max = Math.max(...rows.map(r => r.w), 1)
374
+ $('#watchChart').innerHTML = rows.map((r, i) => {
375
+ const w = Math.max(Math.log10(r.w + 1) / Math.log10(max + 1) * 100, 1.5)
376
+ return `<div class="hrow" style="display:grid;grid-template-columns:210px 1fr 78px;align-items:center;gap:12px;padding:6px 0">` +
377
+ `<div style="font-size:12px;color:var(--t2);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"><i style="display:inline-block;width:8px;height:8px;border-radius:2px;background:${colorOf(i)};margin-right:6px"></i>${esc(r.n)} <span style="color:var(--t3);font-size:10px">#${num(r.rank)}</span></div>` +
378
+ `<div style="height:14px;background:#f1f3f6;border-radius:4px"><div style="height:100%;width:${w}%;background:${colorOf(i)};border-radius:4px"></div></div>` +
379
+ `<div style="font-size:12px;font-weight:650;text-align:right">${num(r.w)}</div></div>`
380
+ }).join('')
381
+ }
382
+
383
+ /* ---------------- 导出结论(网页版) ---------------- */
384
+ function exportHtml() {
385
+ const rows = picked.map(n => CAT.packages[n]).filter(Boolean)
386
+ if (!rows.length) return
387
+ const need = $('#need').value.trim()
388
+ const verdicts = $('#verdicts').innerHTML
389
+
390
+ const body = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">
391
+ <title>dsh-pick 选型结论 · ${esc(need)}</title>
392
+ <style>body{font-family:-apple-system,"PingFang SC",sans-serif;max-width:860px;margin:40px auto;padding:0 20px;color:#1a1e26;line-height:1.7}
393
+ h1{font-size:20px}h2{font-size:15px;margin:26px 0 10px}
394
+ table{width:100%;border-collapse:collapse;font-size:13px}th,td{padding:9px 10px;border-bottom:1px solid #eee;text-align:right;white-space:nowrap}
395
+ th:first-child,td:first-child{text-align:left;color:#8b95a4}thead th{color:#1a1e26}
396
+ .verdict{border-radius:10px;padding:13px 15px;margin-bottom:10px;border:1px solid}
397
+ .win{background:#e7f6ee;border-color:#bfe6d1}.alt{background:#fdf3e3;border-color:#f2ddb6}.no{background:#fdecec;border-color:#f2cccc}
398
+ .verdict h4{margin:0 0 6px;font-size:13px}.verdict p{margin:0;font-size:12.5px;color:#58606e}
399
+ .foot{font-size:11.5px;color:#8b95a4;margin-top:28px;border-top:1px dashed #ddd;padding-top:12px}
400
+ .best{color:#0f9d58;font-weight:700}.miss{color:#b9c2ce}
401
+ </style></head><body>
402
+ <h1>dsh-pick 选型结论</h1>
403
+ <p style="color:#58606e;font-size:13px">需求:<b>${esc(need)}</b></p>
404
+ <h2>比较</h2>
405
+ <table><thead><tr><th>指标</th>${rows.map(r => `<th>${esc(r.n)}</th>`).join('')}</tr></thead><tbody>
406
+ ${ROWS.map(row => {
407
+ const vals = rows.map(r => row.get(r))
408
+ const valid = vals.filter(v => v !== null && v !== undefined && !Number.isNaN(v))
409
+ const bv = valid.length ? (row.best === 'max' ? Math.max(...valid) : Math.min(...valid)) : null
410
+ return `<tr><td>${row.label}</td>` + vals.map(v =>
411
+ `<td class="${v === null || v === undefined ? 'miss' : (v === bv ? 'best' : '')}">${row.fmt(v)}</td>`).join('') + '</tr>'
412
+ }).join('')}
413
+ </tbody></table>
414
+ <h2>结论</h2>${verdicts}
415
+ <div class="foot">数据来源 api.npmjs.org / registry.npmjs.org,截止 <b>${CAT.anchor}</b>(npm 统计滞后约 5 天)。
416
+ 下载量含 CI、镜像、爬虫等非人类流量,不等于用户数;<b>日常基线</b>=扣掉版本发布日与次日后的每日中位数。
417
+ 本页由 dsh-pick 面板导出。</div>
418
+ </body></html>`
419
+
420
+ const blob = new Blob([body], { type: 'text/html' })
421
+ const a = document.createElement('a')
422
+ a.href = URL.createObjectURL(blob)
423
+ a.download = `dsh-pick-${(need || '结论').slice(0, 12).replace(/\s+/g, '_')}.html`
424
+ a.click()
425
+ URL.revokeObjectURL(a.href)
426
+ }
427
+
428
+ boot()