dsh-github-hot 0.0.0-stage → 0.1.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/lib/client.js ADDED
@@ -0,0 +1,1857 @@
1
+ /**
2
+ * dsh-github-hot —— 浏览器半侧(client half)
3
+ *
4
+ * ## 契约(与 dsh-pet / 官方插件一致)
5
+ * · 本文件由 DSH 当**普通副作用脚本**加载(`/plugins/dsh-github-hot/client.js`):
6
+ * 顶层**不能有 import / export**;
7
+ * · 必须调 `window.__ModuleLoader__.load({ id, factory })` 自我注册;
8
+ * · react / react-dom 由 factory 的 `require` 取得;factory 返回 `{ name, inject, apply }`。
9
+ *
10
+ * ## 这个插件的主打:可视化
11
+ *
12
+ * 三个视图,一套数据:
13
+ *
14
+ * ① 卡片流(默认)—— 榜单像一排排「成绩卡」:大号涨星数、涨幅条、名次徽章、
15
+ * 语言/许可证小标签。前三名金银铜,涨星量渲染成粗细不同的进度条。
16
+ * ② 详情视图 —— 点一张卡,右侧展开:项目独立简介 +
17
+ * **星数曲线(折线趋势图)**:从 SVG 解析出的真实星数,画成带面积填充的趋势图,
18
+ * 标注起点/终点/期间涨了多少。
19
+ * ③ 大盘视图 —— 当前榜单的宏观统计:涨星分布、语言占比、涨幅分布(纯 CSS/内联 SVG)。
20
+ *
21
+ * 三个周期(日/周/月)在顶部一个分段器里切换,切换即换数据,不重载页面。
22
+ *
23
+ * ## 颜色
24
+ * 全部走 DSH 设计 token(`--dsw-*`)并带兜底值;涨跌色用插件自己的局部变量
25
+ * (只在 `.hp-root` 内定义,不碰全局,避免和皮肤插件打架)。
26
+ */
27
+ (function () {
28
+ /** 宿主路由前缀,必须与 lib/index.js 的 ROUTE_PREFIX 一致。 */
29
+ const BASE = '/dsh-github-hot';
30
+ const STYLE_ID = 'dsh-github-hot/styles';
31
+ const PLUGIN_NAME = 'dsh-github-hot';
32
+
33
+ /**
34
+ * 折线上涨图标 —— 就一条折线 + 末端箭头,跟侧栏里「GitHub 热榜 / 趋势」同款画法。
35
+ *
36
+ * 之前那版是三条并行线 + 彩色 + 圆点,塞进 18px 的侧栏里糊成一团。
37
+ * 单线条在这么小的尺寸下才认得出来,也才跟旁边两个插件看起来是一套的。
38
+ */
39
+ const LINE_ICON = (size) =>
40
+ '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" ' +
41
+ 'xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
42
+ '<path d="M3 17.5 L8.5 12 L12 15.5 L21 6.5" stroke="currentColor" stroke-width="2.2" ' +
43
+ 'stroke-linecap="round" stroke-linejoin="round"/>' +
44
+ '<path d="M15.5 6.5 L21 6.5 L21 12" stroke="currentColor" stroke-width="2.2" ' +
45
+ 'stroke-linecap="round" stroke-linejoin="round"/>' +
46
+ '</svg>';
47
+
48
+ /** 侧栏图标(走文字色,深浅主题自动适配)。 */
49
+ const SIDEBAR_ICON_SVG = LINE_ICON(18);
50
+ /** 面板标题图标,稍大一点。 */
51
+ const BRAND_ICON_SVG = LINE_ICON(18);
52
+
53
+ /** 榜单最多展示多少项。超过 20 条会变成信息噪音,所以硬性截断,不提供「再显示」。 */
54
+ const MAX_ITEMS = 20;
55
+ /** 统计页默认对比前几个(再多线就分不清了)。 */
56
+ const DEFAULT_COMPARE = 10;
57
+ /** 统计页最多允许同时对比几个。 */
58
+ const MAX_COMPARE = 20;
59
+
60
+ /* ============================================================ 工具函数 */
61
+
62
+ /** 主题:dark(融入 DSH)/ paper(纸感贴纸)。存 localStorage,默认跟随深色。 */
63
+ const THEME_KEY = 'dsh-github-hot:theme';
64
+ function readTheme() {
65
+ try {
66
+ const v = localStorage.getItem(THEME_KEY);
67
+ return v === 'paper' ? 'paper' : 'dark';
68
+ } catch (e) { return 'dark'; }
69
+ }
70
+ function writeTheme(t) {
71
+ try { localStorage.setItem(THEME_KEY, t); } catch (e) { /* 隐私模式忽略 */ }
72
+ }
73
+
74
+ /**
75
+ * 语言偏好:zh = 等中文(不闪,但首屏要等模型几秒)
76
+ * en = 直接显示英文原文(快,不问模型)
77
+ * 存在 localStorage 里,下次打开沿用。
78
+ */
79
+ const LANG_KEY = 'dsh-github-hot:lang';
80
+ function readLang() {
81
+ try {
82
+ return localStorage.getItem(LANG_KEY) === 'en' ? 'en' : 'zh';
83
+ } catch (e) { return 'zh'; }
84
+ }
85
+ function writeLang(v) {
86
+ try { localStorage.setItem(LANG_KEY, v); } catch (e) { /* 忽略 */ }
87
+ }
88
+
89
+ /**
90
+ * 紧凑数字格式:1000 以上一律走 k,避免卡片里「3,988」和「13.2k」混排显得参差。
91
+ * 注意保留一位小数但去掉多余的 .0(3,988 → 4.0k → 4k)。
92
+ */
93
+ function fmtCount(n) {
94
+ if (n === null || n === undefined || Number.isNaN(Number(n))) return '—';
95
+ const v = Number(n);
96
+ const abs = Math.abs(v);
97
+ if (abs >= 1000000) return (v / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
98
+ if (abs >= 1000) return (v / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
99
+ return v.toLocaleString('en-US');
100
+ }
101
+
102
+ /** 涨星数带正号,并分档给颜色。 */
103
+ function gainTone(n) {
104
+ if (n === null || n === undefined) return 'flat';
105
+ return n > 0 ? 'up' : n < 0 ? 'down' : 'flat';
106
+ }
107
+
108
+ function fmtPct(n) {
109
+ if (n === null || n === undefined || Number.isNaN(Number(n))) return '—';
110
+ const v = Number(n);
111
+ return (v > 0 ? '+' : '') + (Math.abs(v) >= 100 ? v.toFixed(0) : v.toFixed(1)) + '%';
112
+ }
113
+
114
+ /**
115
+ * 详情本地缓存(localStorage)。
116
+ *
117
+ * 为什么需要:后端虽然缓存了,但前端每次进入插件都会重建组件、重新请求一遍,
118
+ * 用户看到的是「骨架 → 内容」的闪烁,感觉像在重新下载。这里把取回的详情按
119
+ * 自然日存在浏览器里 —— 同一自然日再进来直接秒出,后台再静默核对一次。
120
+ *
121
+ * 只存到当天:数据源每天才更新一次,跨天必须重取,不能拿昨天的当今天。
122
+ */
123
+ const DETAILS_KEY = 'dsh-github-hot:details';
124
+ const DETAILS_MAX = 60;
125
+
126
+ function readDetailsCache() {
127
+ try {
128
+ const raw = localStorage.getItem(DETAILS_KEY);
129
+ if (!raw) return {};
130
+ const data = JSON.parse(raw);
131
+ if (!data || data.day !== localDayKey() || !data.entries) return {}; // 跨天或格式不对 → 丢弃
132
+ // 逐条校验结构:settled 必须是 true(否则会一直显示骨架),
133
+ // 有曲线的必须每个点带 date(否则统计页画不出来)。不合格的直接丢掉重取。
134
+ const out = {};
135
+ for (const [repo, entry] of Object.entries(data.entries)) {
136
+ if (!entry || entry.settled !== true) continue;
137
+ const chart = entry.chart;
138
+ if (chart && Array.isArray(chart.series)) {
139
+ const okShape = chart.series.length === 0 || chart.series.every((p) => p && typeof p.date === 'string');
140
+ if (!okShape) continue;
141
+ }
142
+ out[repo] = entry;
143
+ }
144
+ return out;
145
+ } catch (e) {
146
+ return {};
147
+ }
148
+ }
149
+
150
+ function writeDetailsCache(entries) {
151
+ try {
152
+ // 只留最近的若干条,避免 localStorage 撑爆
153
+ const all = Object.entries(entries || {});
154
+ const keep = all.slice(-DETAILS_MAX);
155
+ localStorage.setItem(DETAILS_KEY, JSON.stringify({ day: localDayKey(), entries: Object.fromEntries(keep) }));
156
+ } catch (e) {
157
+ /* 配额满/隐私模式:忽略,退化成纯内存 */
158
+ }
159
+ }
160
+
161
+ /**
162
+ * 数据源时区(Asia/Shanghai)下的日期串 YYYY-MM-DD。
163
+ *
164
+ * 前端也要用数据源时区判断「是不是同一天」:命令里的缓存是否失效、数据是否更新,
165
+ * 都应该跟着**数据源**的日历走,而不是用户所在时区。否则纽约用户在晚上看到的
166
+ * 「今天」和上海用户看到的不一样,缓存也会在奇怪的时间点失效。
167
+ */
168
+ const SOURCE_TZ = 'Asia/Shanghai';
169
+ function localDayKey(ts) {
170
+ const d = ts ? new Date(ts) : new Date();
171
+ const parts = new Intl.DateTimeFormat('en-CA', {
172
+ timeZone: SOURCE_TZ, year: 'numeric', month: '2-digit', day: '2-digit',
173
+ }).formatToParts(d);
174
+ const g = (t) => (parts.find((x) => x.type === t) || {}).value;
175
+ return g('year') + '-' + g('month') + '-' + g('day');
176
+ }
177
+
178
+ /**
179
+ * 把数据日期说成一句人话:「11月3日」。
180
+ * 日榜/月榜窗口的 end 是 YYYY-MM-DD;周榜拿 week 的周一开始日算周日。
181
+ */
182
+ /**
183
+ * 把数据日期说成一句人话,例如「11月3日」。
184
+ *
185
+ * ⚠️ 不要用 new Date('2026-11-03') 再取本地月份 —— 那会按 UTC 解析,
186
+ * 在 UTC-x 的时区里显示成「11月2日」。这里**直接切字符串**,日期串本来就是
187
+ * 数据源的日期,不需要经过任何时区换算。
188
+ */
189
+ function dataDateText(board) {
190
+ if (!board) return '今日';
191
+ const raw = board.window && board.window.end ? board.window.end : board.week;
192
+ if (!raw) return '今日';
193
+ const m = String(raw).match(/^(\d{4})-(\d{2})-(\d{2})/);
194
+ if (!m) return '今日';
195
+ return Number(m[2]) + '月' + Number(m[3]) + '日';
196
+ }
197
+
198
+ function fmtTime(iso) {
199
+ if (!iso) return '—';
200
+ const d = new Date(iso);
201
+ if (Number.isNaN(d.getTime())) return '—';
202
+ return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
203
+ }
204
+
205
+ function ago(iso) {
206
+ if (!iso) return '—';
207
+ const ms = Date.now() - new Date(iso).getTime();
208
+ if (!Number.isFinite(ms) || ms < 0) return '—';
209
+ const min = Math.floor(ms / 60000);
210
+ if (min < 1) return '刚刚';
211
+ if (min < 60) return min + ' 分钟前';
212
+ const h = Math.floor(min / 60);
213
+ if (h < 24) return h + ' 小时前';
214
+ return Math.floor(h / 24) + ' 天前';
215
+ }
216
+
217
+ /** 简易 fetch JSON(带超时)。 */
218
+ async function getJson(path) {
219
+ const controller = new AbortController();
220
+ const timer = setTimeout(() => controller.abort(), 30000);
221
+ try {
222
+ const response = await fetch(BASE + path, { signal: controller.signal, headers: { Accept: 'application/json' } });
223
+ const body = await response.json().catch(() => ({}));
224
+ if (!response.ok || body.ok === false) {
225
+ throw new Error(body.error || 'HTTP ' + response.status);
226
+ }
227
+ return body;
228
+ } finally {
229
+ clearTimeout(timer);
230
+ }
231
+ }
232
+
233
+ /* ============================================================ 样式表 */
234
+
235
+ function ensureCss(doc) {
236
+ if (doc.getElementById(STYLE_ID)) return;
237
+ const style = doc.createElement('style');
238
+ style.id = STYLE_ID;
239
+ style.textContent = `
240
+ /* ---------- 根容器:局部色板,不碰全局 --dsw-* ---------- */
241
+ /* ============================================================
242
+ 两套主题
243
+ ============================================================
244
+ .hp-dark —— 深色:融进 DSH 原生界面(默认)
245
+ .hp-paper —— 纸感贴纸风:暖米白纸 + 墨黑粗框 + 实体阴影
246
+
247
+ 两套只换变量,不改结构;所有组件都只读 --hp-* 变量,天然双主题。
248
+ ============================================================ */
249
+ .hp-root{height:100%;display:flex;flex-direction:column;overflow:hidden;font-size:14px}
250
+
251
+ /* ---------- 深色主题(默认)---------- */
252
+ .hp-root.hp-dark{
253
+ --hp-up:#e5484d;
254
+ --hp-up-soft:rgba(229,72,77,.13);
255
+ --hp-down:#30a46c;
256
+ --hp-gold:#d9a52b;
257
+ --hp-silver:#9aa4b2;
258
+ --hp-bronze:#c07a3e;
259
+ --hp-accent:#7b5cff;
260
+ --hp-bg:var(--dsw-alias-bg-layer-1,#14161a);
261
+ --hp-surface:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.035));
262
+ --hp-surface-2:var(--dsw-alias-bg-layer-3,rgba(255,255,255,.07));
263
+ --hp-fg:var(--dsw-alias-label-primary,#e8eaed);
264
+ --hp-fg-2:var(--dsw-alias-label-secondary,rgba(255,255,255,.72));
265
+ --hp-fg-3:var(--dsw-alias-label-tertiary,rgba(255,255,255,.45));
266
+ --hp-line:var(--dsw-alias-border-l2,rgba(255,255,255,.09));
267
+ --hp-sep:var(--dsw-alias-separator-primary,rgba(255,255,255,.08));
268
+ --hp-border:1px solid var(--hp-line);
269
+ --hp-shadow:none;
270
+ --hp-shadow-hover:0 8px 24px rgba(0,0,0,.28);
271
+ --hp-card-bg:var(--hp-surface);
272
+ --hp-radius:13px;
273
+ }
274
+
275
+ /* ---------- 纸感贴纸主题 ---------- */
276
+ .hp-root.hp-paper{
277
+ --hp-up:#ff5c2b;
278
+ --hp-up-soft:rgba(255,92,43,.14);
279
+ --hp-down:#3f8f2e;
280
+ --hp-gold:#ffd84d;
281
+ --hp-silver:#cfc8bb;
282
+ --hp-bronze:#ffb07a;
283
+ --hp-accent:#ff5c2b;
284
+ --hp-paper:#fff4dd;
285
+ --hp-ink:#26201a;
286
+ --hp-card:#fffdf7;
287
+ --hp-bg:var(--hp-paper);
288
+ --hp-surface:var(--hp-card);
289
+ --hp-surface-2:#fff9ec;
290
+ --hp-fg:var(--hp-ink);
291
+ --hp-fg-2:#4a3f33;
292
+ --hp-fg-3:#8a7a68;
293
+ --hp-line:var(--hp-ink);
294
+ --hp-sep:rgba(38,32,26,.16);
295
+ --hp-border:2px solid var(--hp-ink);
296
+ --hp-shadow:4px 4px 0 var(--hp-ink);
297
+ --hp-shadow-hover:6px 6px 0 var(--hp-ink);
298
+ --hp-card-bg:var(--hp-card);
299
+ --hp-radius:14px;
300
+ }
301
+
302
+ .hp-root{
303
+ font-family:var(--dsw-font,-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif);
304
+ color:var(--hp-fg);
305
+ background:var(--hp-bg);
306
+ }
307
+ .hp-root.hp-paper .hp-head{background:#fff9ec}
308
+ .hp-root.hp-paper .hp-seg{border:2px solid var(--hp-ink);box-shadow:2px 2px 0 var(--hp-ink);background:var(--hp-card)}
309
+ .hp-root.hp-paper .hp-brand-dot{box-shadow:none;border:2px solid var(--hp-ink)}
310
+ .hp-root.hp-paper{font-family:'Baloo 2',var(--dsw-font,-apple-system,'PingFang SC','Microsoft YaHei',sans-serif)}
311
+ .hp-root.hp-paper .hp-num,.hp-root.hp-paper .hp-gain-v,.hp-root.hp-paper .hp-kpi-v{
312
+ font-family:ui-monospace,'SF Mono',Menlo,Consolas,monospace;
313
+ letter-spacing:-.02em;
314
+ }
315
+ .hp-scroll{overflow:auto;flex:1;scrollbar-width:thin}
316
+ .hp-scroll::-webkit-scrollbar{width:8px;height:8px}
317
+ .hp-scroll::-webkit-scrollbar-thumb{background:var(--dsw-alias-scrollbar-bg-l2,rgba(255,255,255,.12));border-radius:4px}
318
+
319
+ /* ---------- 顶栏 ---------- */
320
+ .hp-head{display:flex;align-items:center;gap:10px;padding:11px 18px;border-bottom:1px solid var(--hp-sep);flex-wrap:nowrap;overflow:hidden}
321
+ .hp-brand{display:flex;align-items:center;gap:8px;font-weight:650;font-size:14.5px;letter-spacing:.2px;flex:0 0 auto;white-space:nowrap}
322
+ .hp-brand-icon{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px}
323
+ .hp-brand-icon svg{display:block}
324
+ /* 侧栏图标:与同侧栏其它插件一致,只吃文字色 */
325
+ .hp-side-icon{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px}
326
+ .hp-side-icon svg{display:block}
327
+ .hp-brand-dot{width:9px;height:9px;border-radius:50%;background:linear-gradient(135deg,var(--hp-grad-a),var(--hp-grad-b));box-shadow:0 0 10px rgba(122,92,255,.7)}
328
+ .hp-brand-sub{font-size:10.5px;font-weight:400;color:var(--hp-fg-3);margin-left:2px;white-space:nowrap}
329
+ @media (max-width:1180px){.hp-brand-sub{display:none}}
330
+ @media (max-width:980px){.hp-head .hp-meta{display:none}}
331
+ .hp-seg{display:flex;background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.05));border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.08));border-radius:9px;padding:3px;gap:2px}
332
+ .hp-seg button{border:0;background:transparent;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.65));font:inherit;font-size:12.5px;padding:5px 11px;border-radius:7px;cursor:pointer;transition:all .16s}
333
+ .hp-seg button:hover{color:var(--dsw-alias-label-primary,#fff);background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06))}
334
+ .hp-seg button.on{background:var(--dsw-alias-button-primary-fill,#4f7cff);color:#fff;font-weight:600}
335
+ .hp-root.hp-paper .hp-seg button{color:var(--hp-ink)}
336
+ .hp-root.hp-paper .hp-seg button.on{background:var(--hp-ink);color:var(--hp-gold);font-weight:800}
337
+ .hp-root.hp-paper .hp-seg button:hover:not(.on){background:#f0e6d2;color:var(--hp-ink)}
338
+ .hp-seg button:disabled{opacity:.45;cursor:default}
339
+ .hp-spacer{flex:1}
340
+ .hp-meta{font-size:12px;color:var(--hp-fg-3);display:flex;align-items:center;gap:10px;flex-wrap:wrap}
341
+ .hp-dot{width:6px;height:6px;border-radius:50%;display:inline-block;margin-right:5px}
342
+ .hp-dot.ok{background:#30a46c;box-shadow:0 0 6px rgba(48,164,108,.8)}
343
+ .hp-dot.bad{background:#e5484d}
344
+ .hp-dot.warn{background:#f5a524}
345
+ .hp-btn{border:1px solid var(--hp-line);background:var(--hp-surface);color:var(--hp-fg);font:inherit;font-size:12.5px;padding:5px 11px;white-space:nowrap;border-radius:8px;cursor:pointer;transition:all .16s}
346
+ .hp-btn:hover:not(:disabled){background:var(--hp-surface-2)}
347
+ .hp-root.hp-paper .hp-btn{border:2px solid var(--hp-ink);box-shadow:2px 2px 0 var(--hp-ink);font-weight:700}
348
+ .hp-root.hp-paper .hp-btn:hover:not(:disabled){transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--hp-ink)}
349
+ .hp-root.hp-paper .hp-btn:active:not(:disabled){transform:translate(1px,1px);box-shadow:1px 1px 0 var(--hp-ink)}
350
+ .hp-btn:disabled{opacity:.5;cursor:default}
351
+ .hp-btn.primary{background:var(--hp-accent);border-color:transparent;color:#fff;font-weight:600}
352
+ .hp-root.hp-paper .hp-btn.primary{background:var(--hp-gold);color:var(--hp-ink);border:2px solid var(--hp-ink)}
353
+
354
+ /* ---------- 空/错状态 ---------- */
355
+ .hp-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:70px 24px;color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.5));text-align:center}
356
+ .hp-empty strong{color:var(--dsw-alias-label-secondary,rgba(255,255,255,.7));font-size:15px}
357
+ .hp-empty code{font-size:12px;background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.06));padding:2px 7px;border-radius:5px}
358
+ .hp-banner{margin:12px 20px 0;padding:10px 14px;border-radius:9px;font-size:12.5px;line-height:1.55}
359
+ .hp-banner.err{background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.35);color:#ff9ba0}
360
+ .hp-banner.warn{background:rgba(245,165,36,.1);border:1px solid rgba(245,165,36,.35);color:#ffcf80}
361
+
362
+ /* ---------- 榜单卡片流 ---------- */
363
+
364
+
365
+ /* ---------- 统计视图:多曲线对比 ---------- */
366
+ /* 上图下选:图表独占一行,勾选列表在下方横向铺开 */
367
+ .hp-dash{display:flex;flex-direction:column;gap:12px;padding:16px 18px 28px}
368
+ .hp-picker{border:var(--hp-border);border-radius:var(--hp-radius);background:var(--hp-card-bg);box-shadow:var(--hp-shadow);padding:11px 6px 8px;max-height:230px;overflow:auto}
369
+ .hp-picker-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:1px 4px}
370
+ .hp-picker-title{display:flex;justify-content:space-between;align-items:baseline;font-size:11.5px;font-weight:650;color:var(--hp-fg-3);padding:0 10px 8px;border-bottom:1px solid var(--hp-sep);margin-bottom:6px}
371
+ .hp-picker-n{font-variant-numeric:tabular-nums}
372
+ .hp-pick{display:flex;align-items:center;gap:7px;padding:6px 10px;border-radius:8px;cursor:pointer;font-size:12.5px;transition:background .12s}
373
+ .hp-pick:hover{background:var(--hp-surface-2)}
374
+ .hp-pick input{accent-color:var(--hp-accent);margin:0;cursor:pointer}
375
+ .hp-pick-dot{width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--hp-sep)}
376
+ .hp-pick-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--hp-fg-2)}
377
+ .hp-pick.on .hp-pick-name{color:var(--hp-fg);font-weight:600}
378
+ .hp-pick-v{font-size:11.5px;font-variant-numeric:tabular-nums;color:var(--hp-up);font-weight:650}
379
+ .hp-pick.no-data{opacity:.42;cursor:not-allowed}
380
+ .hp-mchart-panel{border:var(--hp-border);border-radius:var(--hp-radius);background:var(--hp-card-bg);box-shadow:var(--hp-shadow);padding:13px 15px 11px;min-width:0}
381
+ .hp-mchart-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px;flex-wrap:wrap}
382
+ .hp-mchart-title{font-size:13px;font-weight:700;color:var(--hp-fg)}
383
+ .hp-mchart-hint{font-size:11px;color:var(--hp-fg-3)}
384
+ .hp-seg.tiny button{font-size:11.5px;padding:3px 9px}
385
+ .hp-mchart-svg{width:100%;height:auto;display:block}
386
+ /* 换榜单时图表淡入:直接替换会「啪」地跳一下 */
387
+ .hp-mchart{animation:hp-chart-in .32s cubic-bezier(.22,.61,.36,1)}
388
+ @keyframes hp-chart-in{
389
+ from{opacity:0;transform:translateY(6px)}
390
+ to{opacity:1;transform:translateY(0)}
391
+ }
392
+ @media (prefers-reduced-motion:reduce){.hp-mchart{animation:none}}
393
+ .hp-grid{stroke:var(--hp-sep);stroke-width:1;stroke-dasharray:3 4}
394
+ .hp-zero{stroke:var(--hp-fg-3);stroke-width:1.2}
395
+ .hp-axis{fill:var(--hp-fg-3);font-size:10.5px;font-family:ui-monospace,Menlo,monospace}
396
+ .hp-legend{display:flex;flex-wrap:wrap;gap:8px 14px;margin-top:9px;padding-top:9px;border-top:1px solid var(--hp-sep)}
397
+ .hp-legend-item{display:flex;align-items:center;gap:6px;border:0;background:transparent;color:var(--hp-fg-2);font:inherit;font-size:11.5px;cursor:pointer;padding:2px 4px;border-radius:6px;transition:all .12s}
398
+ .hp-legend-item:hover,.hp-legend-item.act{background:var(--hp-surface-2);color:var(--hp-fg)}
399
+ .hp-legend-line{width:16px;height:3px;border-radius:2px;flex:0 0 auto}
400
+ .hp-mchart-note{font-size:11px;color:var(--hp-fg-3);margin-top:9px;line-height:1.5}
401
+
402
+
403
+ /* ---------- 头像 ---------- */
404
+ .hp-avatar{flex:0 0 auto;border-radius:9px;object-fit:cover;background:var(--hp-surface-2);display:block}
405
+ .hp-root.hp-paper .hp-avatar{border:2px solid var(--hp-ink)}
406
+ .hp-avatar-fb{display:inline-flex;align-items:center;justify-content:center;color:#fff;font-weight:700;border-radius:9px;flex:0 0 auto;user-select:none}
407
+
408
+ /* ---------- 右侧操作区 ---------- */
409
+ /* ---------- 数据署名(CC BY 4.0 要求,放右下角不干扰主视觉)---------- */
410
+ .hp-attr{
411
+ display:flex;justify-content:flex-end;align-items:center;gap:5px;
412
+ padding:10px 22px 12px;margin-top:auto;
413
+ font-family:ui-monospace,'SF Mono',Menlo,Consolas,monospace;
414
+ font-size:10px;letter-spacing:.02em;
415
+ color:var(--hp-fg-3);opacity:.6;
416
+ transition:opacity .18s;
417
+ }
418
+ .hp-attr:hover{opacity:.95}
419
+ .hp-attr-dot{opacity:.5;margin:0 1px}
420
+ .hp-attr a{
421
+ color:inherit;text-decoration:none;
422
+ border-bottom:1px solid transparent;transition:border-color .18s;
423
+ }
424
+ .hp-attr a:hover{border-bottom-color:currentColor}
425
+ /* 纸感主题下用墨色细线,不突兀 */
426
+ .hp-root.hp-paper .hp-attr{opacity:.55}
427
+ .hp-root.hp-paper .hp-attr:hover{opacity:.85}
428
+ .hp-actions{display:flex;align-items:center;gap:8px;flex-wrap:nowrap;flex:0 0 auto;margin-left:auto}
429
+ .hp-head .hp-meta{flex:1 1 auto;min-width:0;justify-content:flex-end;overflow:hidden;white-space:nowrap}
430
+ /* ---------- 简介:中文优先 ---------- */
431
+ .hp-zh-pending{display:inline-block;margin-left:6px;font-size:10px;padding:0 4px;border-radius:4px;background:var(--hp-surface-2);color:var(--hp-fg-3);vertical-align:middle}
432
+ .hp-desc.zh{font-family:var(--dsw-font,-apple-system,'PingFang SC','Microsoft YaHei',sans-serif);letter-spacing:.01em;line-height:1.65}
433
+ .hp-intro.zh{font-size:13.5px;line-height:1.75;color:var(--hp-fg)}
434
+ .hp-intro-en{font-size:12px;line-height:1.65;color:var(--hp-fg-3);margin-top:10px;padding-top:9px;border-top:1px dashed var(--hp-sep)}
435
+
436
+ /* ---------- 首屏主角(先给答案)---------- */
437
+ .hp-hero{display:flex;gap:18px;align-items:stretch;padding:18px 20px 6px;flex-wrap:wrap}
438
+ .hp-hero-main{flex:1 1 320px;min-width:260px;border:var(--hp-border);border-radius:var(--hp-radius);background:var(--hp-card-bg);box-shadow:var(--hp-shadow);padding:16px 20px 15px}
439
+ .hp-hero-k{font-size:11.5px;font-weight:600;letter-spacing:.06em;color:var(--hp-fg-3);margin-bottom:2px}
440
+ .hp-hero-v{font-size:clamp(34px,5vw,52px);font-weight:800;line-height:1.04;letter-spacing:-.03em;color:var(--hp-up);font-variant-numeric:tabular-nums}
441
+ .hp-hero-sub{font-size:12.5px;color:var(--hp-fg-2);margin-top:7px}
442
+ .hp-hero-side{display:grid;grid-template-columns:repeat(3,minmax(96px,1fr));gap:10px;flex:0 1 auto}
443
+ .hp-stat-card{border:var(--hp-border);border-radius:11px;padding:10px 13px;background:var(--hp-surface);box-shadow:var(--hp-shadow);min-width:96px;display:flex;flex-direction:column;justify-content:center}
444
+ .hp-stat-k{font-size:10.5px;color:var(--hp-fg-3);margin-bottom:3px;white-space:nowrap}
445
+ .hp-stat-v{font-size:17px;font-weight:750;font-variant-numeric:tabular-nums;letter-spacing:-.02em}
446
+ /* 纸感主题:三张统计卡上色,像便签 */
447
+ .hp-root.hp-paper .hp-stat-card.rose{background:#ffb9cc}
448
+ .hp-root.hp-paper .hp-stat-card.sky{background:#a5dcff}
449
+ .hp-root.hp-paper .hp-stat-card.sun{background:#ffd84d}
450
+ .hp-root.hp-paper .hp-hero-main{background:var(--hp-card)}
451
+
452
+ .hp-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px;padding:16px 20px 28px}
453
+ .hp-card{position:relative;border:var(--hp-border);border-radius:var(--hp-radius);padding:13px 15px 11px;display:flex;flex-direction:column;background:var(--hp-card-bg);box-shadow:var(--hp-shadow);cursor:pointer;transition:transform .14s ease,box-shadow .14s,border-color .14s;overflow:hidden}
454
+ .hp-card:hover{transform:translateY(-2px);box-shadow:var(--hp-shadow-hover)}
455
+ .hp-root.hp-dark .hp-card:hover{border-color:rgba(122,92,255,.5)}
456
+ .hp-card.sel{box-shadow:0 0 0 2px var(--hp-accent),var(--hp-shadow-hover)}
457
+ .hp-card.top1{background:linear-gradient(135deg,rgba(217,165,43,.13),var(--hp-card-bg) 55%)}
458
+ .hp-card.top2{background:linear-gradient(135deg,rgba(154,164,178,.12),var(--hp-card-bg) 55%)}
459
+ .hp-card.top3{background:linear-gradient(135deg,rgba(192,122,62,.13),var(--hp-card-bg) 55%)}
460
+ /* 纸感主题下前三名用实色贴纸,而不是渐变 */
461
+ .hp-root.hp-paper .hp-card.top1{background:linear-gradient(135deg,#fff6d6,#fffdf7 60%)}
462
+ .hp-root.hp-paper .hp-card.top2{background:linear-gradient(135deg,#f3f1ec,#fffdf7 60%)}
463
+ .hp-root.hp-paper .hp-card.top3{background:linear-gradient(135deg,#ffeadb,#fffdf7 60%)}
464
+ .hp-card-head{display:flex;align-items:flex-start;gap:11px}
465
+ .hp-rank{flex:0 0 auto;width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;font-variant-numeric:tabular-nums;background:var(--hp-surface-2);color:var(--hp-fg-2)}
466
+ .hp-root.hp-paper .hp-rank{border:2px solid var(--hp-ink);box-shadow:2px 2px 0 var(--hp-ink)}
467
+ .hp-rank.r1{background:linear-gradient(135deg,#e8b53a,#c9901f);color:#2a1c00;box-shadow:0 2px 10px rgba(217,165,43,.45)}
468
+ .hp-rank.r2{background:linear-gradient(135deg,#c8d0da,#95a0ad);color:#1d242c}
469
+ .hp-rank.r3{background:linear-gradient(135deg,#d4945a,#a9652f);color:#2a1400}
470
+ .hp-title{flex:1;min-width:0}
471
+ .hp-name{font-size:14.5px;font-weight:650;letter-spacing:.1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
472
+ .hp-name .owner{color:var(--hp-fg-3);font-weight:450}
473
+ .hp-sub{font-size:11.5px;color:var(--hp-fg-3);margin-top:3px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
474
+ .hp-chip{font-size:10.5px;padding:1px 7px;border-radius:999px;background:var(--hp-surface-2);color:var(--hp-fg-2);white-space:nowrap}
475
+ .hp-root.hp-paper .hp-chip{border:1.5px solid var(--hp-ink);background:#fff;font-weight:600}
476
+ .hp-root.hp-paper .hp-chip.pct{background:var(--hp-gold)}
477
+ .hp-root.hp-paper .hp-chip.lic{background:#a5dcff}
478
+ .hp-gain{text-align:right;font-variant-numeric:tabular-nums;flex:0 0 auto;padding-left:6px}
479
+ .hp-gain-v{font-size:21px;font-weight:780;line-height:1.05;letter-spacing:-.5px}
480
+ .hp-gain-v.up{color:var(--hp-up)}
481
+ .hp-root.hp-dark .hp-gain-v.up{text-shadow:0 0 18px rgba(229,72,77,.35)}
482
+ .hp-gain-v.flat{color:var(--hp-fg-2)}
483
+ .hp-gain-s{font-size:10.5px;color:var(--hp-fg-3);margin-top:2px}
484
+ /* 简介:取不到就整块不渲染,所以这里**不设 min-height**,避免空灰色占位 */
485
+ .hp-desc{margin:9px 0 0;font-size:12.5px;line-height:1.6;color:var(--hp-fg-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
486
+ .hp-desc-loading{margin-top:10px;display:flex;flex-direction:column;gap:5px}
487
+ .hp-desc-loading i{display:block;height:8px;border-radius:4px;background:linear-gradient(100deg,var(--hp-surface-2) 30%,var(--hp-sep) 50%,var(--hp-surface-2) 70%);background-size:220% 100%;animation:hp-shimmer 1.4s infinite linear}
488
+ .hp-desc-loading i:last-child{width:62%}
489
+ /* 卡片底部:右下角迷你走势 */
490
+ .hp-card-foot{display:flex;justify-content:flex-end;margin-top:6px;margin-bottom:-2px}
491
+ .hp-spark{width:132px;height:34px;opacity:.85;transition:opacity .16s}
492
+ .hp-card:hover .hp-spark{opacity:1}
493
+ .hp-card-loading{height:120px;border-radius:var(--hp-radius);border:var(--hp-border);background:linear-gradient(100deg,var(--hp-surface) 30%,var(--hp-surface-2) 50%,var(--hp-surface) 70%);background-size:220% 100%;animation:hp-shimmer 1.5s infinite linear}
494
+ @keyframes hp-shimmer{0%{background-position:130% 0}100%{background-position:-30% 0}}
495
+ .hp-more{display:flex;justify-content:center;padding:0 20px 30px}
496
+
497
+ /* ---------- 详情面板 ---------- */
498
+ .hp-detail{margin:16px 20px 28px;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.09));border-radius:15px;background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.035));overflow:hidden}
499
+ .hp-detail-head{display:flex;align-items:center;gap:12px;padding:15px 18px;border-bottom:1px solid var(--dsw-alias-separator-primary,rgba(255,255,255,.07))}
500
+ .hp-detail-head .hp-name{font-size:16px}
501
+ .hp-close{margin-left:auto;border:0;background:transparent;color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.5));font-size:19px;cursor:pointer;line-height:1;padding:4px 7px;border-radius:7px}
502
+ .hp-close:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08));color:#fff}
503
+ .hp-detail-body{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(240px,.85fr);gap:0}
504
+ @media (max-width:820px){.hp-detail-body{grid-template-columns:1fr}}
505
+ .hp-pane{padding:16px 18px}
506
+ .hp-pane + .hp-pane{border-left:1px solid var(--dsw-alias-separator-primary,rgba(255,255,255,.07))}
507
+ @media (max-width:820px){.hp-pane + .hp-pane{border-left:0;border-top:1px solid var(--dsw-alias-separator-primary,rgba(255,255,255,.07))}}
508
+ .hp-pane-title{font-size:11px;font-weight:650;letter-spacing:.09em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.42));margin-bottom:9px;display:flex;align-items:center;gap:7px}
509
+ .hp-pane-title::after{content:'';flex:1;height:1px;background:var(--dsw-alias-separator-primary,rgba(255,255,255,.07))}
510
+ .hp-intro{font-size:13px;line-height:1.68;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.78))}
511
+ .hp-intro.none{color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.4));font-style:italic}
512
+ .hp-srcline{font-size:11px;margin-top:9px;line-height:1.5;color:var(--hp-fg-3)}
513
+ .hp-srcline a{color:inherit;text-decoration:none;border-bottom:1px dotted currentColor}
514
+ .hp-srcline a:hover{color:var(--hp-fg)}
515
+ .hp-kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin-top:14px}
516
+ .hp-kpi{border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.07));border-radius:9px;padding:8px 10px;background:var(--dsw-alias-bg-layer-1,rgba(0,0,0,.18))}
517
+ .hp-kpi-k{font-size:10.5px;color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.4));margin-bottom:3px}
518
+ .hp-kpi-v{font-size:15px;font-weight:700;font-variant-numeric:tabular-nums}
519
+ .hp-kpi-v.up{color:var(--hp-up)}
520
+ .hp-links{display:flex;gap:8px;flex-wrap:wrap;margin-top:13px}
521
+ .hp-link{font-size:12px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.12));color:var(--dsw-alias-label-secondary,rgba(255,255,255,.72));text-decoration:none;transition:all .15s}
522
+ .hp-link:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08));color:#fff}
523
+
524
+ /* ---------- 趋势图 ---------- */
525
+ .hp-chart{width:100%;height:auto;display:block;overflow:visible}
526
+ .hp-chart-wrap{position:relative;margin-top:2px}
527
+ .hp-chart-empty{font-size:12px;color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.4));padding:22px 0;text-align:center;font-style:italic}
528
+ .hp-chart-legend{display:flex;justify-content:space-between;font-size:11px;color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.45));font-variant-numeric:tabular-nums;margin-top:5px}
529
+
530
+ /* ---------- 头像 ---------- */
531
+ .hp-avatar{flex:0 0 auto;border-radius:9px;object-fit:cover;background:var(--hp-surface-2);display:block}
532
+ .hp-root.hp-paper .hp-avatar{border:2px solid var(--hp-ink)}
533
+ .hp-avatar-fb{display:inline-flex;align-items:center;justify-content:center;color:#fff;font-weight:700;border-radius:9px;flex:0 0 auto;user-select:none}
534
+
535
+ /* ---------- 右侧操作区 ---------- */
536
+ .hp-actions{display:flex;align-items:center;gap:8px;flex-wrap:nowrap;flex:0 0 auto;margin-left:auto}
537
+ .hp-head .hp-meta{flex:1 1 auto;min-width:0;justify-content:flex-end;overflow:hidden;white-space:nowrap}
538
+ /* ---------- 简介:中文优先 ---------- */
539
+ .hp-zh-pending{display:inline-block;margin-left:6px;font-size:10px;padding:0 4px;border-radius:4px;background:var(--hp-surface-2);color:var(--hp-fg-3);vertical-align:middle}
540
+ .hp-desc.zh{font-family:var(--dsw-font,-apple-system,'PingFang SC','Microsoft YaHei',sans-serif);letter-spacing:.01em;line-height:1.65}
541
+ .hp-intro.zh{font-size:13.5px;line-height:1.75;color:var(--hp-fg)}
542
+ .hp-intro-en{font-size:12px;line-height:1.65;color:var(--hp-fg-3);margin-top:10px;padding-top:9px;border-top:1px dashed var(--hp-sep)}
543
+
544
+ /* ---------- 首屏主角(先给答案)---------- */
545
+ .hp-hero{display:flex;gap:18px;align-items:stretch;padding:18px 20px 6px;flex-wrap:wrap}
546
+ .hp-hero-main{flex:1 1 320px;min-width:260px;border:var(--hp-border);border-radius:var(--hp-radius);background:var(--hp-card-bg);box-shadow:var(--hp-shadow);padding:16px 20px 15px}
547
+ .hp-hero-k{font-size:11.5px;font-weight:600;letter-spacing:.06em;color:var(--hp-fg-3);margin-bottom:2px}
548
+ .hp-hero-v{font-size:clamp(34px,5vw,52px);font-weight:800;line-height:1.04;letter-spacing:-.03em;color:var(--hp-up);font-variant-numeric:tabular-nums}
549
+ .hp-hero-sub{font-size:12.5px;color:var(--hp-fg-2);margin-top:7px}
550
+ .hp-hero-side{display:grid;grid-template-columns:repeat(3,minmax(96px,1fr));gap:10px;flex:0 1 auto}
551
+ .hp-stat-card{border:var(--hp-border);border-radius:11px;padding:10px 13px;background:var(--hp-surface);box-shadow:var(--hp-shadow);min-width:96px;display:flex;flex-direction:column;justify-content:center}
552
+ .hp-stat-k{font-size:10.5px;color:var(--hp-fg-3);margin-bottom:3px;white-space:nowrap}
553
+ .hp-stat-v{font-size:17px;font-weight:750;font-variant-numeric:tabular-nums;letter-spacing:-.02em}
554
+ /* 纸感主题:三张统计卡上色,像便签 */
555
+ .hp-root.hp-paper .hp-stat-card.rose{background:#ffb9cc}
556
+ .hp-root.hp-paper .hp-stat-card.sky{background:#a5dcff}
557
+ .hp-root.hp-paper .hp-stat-card.sun{background:#ffd84d}
558
+ .hp-root.hp-paper .hp-hero-main{background:var(--hp-card)}
559
+
560
+ .hp-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px;padding:16px 20px 28px}
561
+ .hp-card{position:relative;border:var(--hp-border);border-radius:var(--hp-radius);padding:13px 15px 11px;display:flex;flex-direction:column;background:var(--hp-card-bg);box-shadow:var(--hp-shadow);cursor:pointer;transition:transform .14s ease,box-shadow .14s,border-color .14s;overflow:hidden}
562
+ .hp-card:hover{transform:translateY(-2px);box-shadow:var(--hp-shadow-hover)}
563
+ .hp-root.hp-dark .hp-card:hover{border-color:rgba(122,92,255,.5)}
564
+ .hp-card.sel{box-shadow:0 0 0 2px var(--hp-accent),var(--hp-shadow-hover)}
565
+ .hp-card.top1{background:linear-gradient(135deg,rgba(217,165,43,.13),var(--hp-card-bg) 55%)}
566
+ .hp-card.top2{background:linear-gradient(135deg,rgba(154,164,178,.12),var(--hp-card-bg) 55%)}
567
+ .hp-card.top3{background:linear-gradient(135deg,rgba(192,122,62,.13),var(--hp-card-bg) 55%)}
568
+ /* 纸感主题下前三名用实色贴纸,而不是渐变 */
569
+ .hp-root.hp-paper .hp-card.top1{background:linear-gradient(135deg,#fff6d6,#fffdf7 60%)}
570
+ .hp-root.hp-paper .hp-card.top2{background:linear-gradient(135deg,#f3f1ec,#fffdf7 60%)}
571
+ .hp-root.hp-paper .hp-card.top3{background:linear-gradient(135deg,#ffeadb,#fffdf7 60%)}
572
+ .hp-card-head{display:flex;align-items:flex-start;gap:11px}
573
+ .hp-rank{flex:0 0 auto;width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;font-variant-numeric:tabular-nums;background:var(--hp-surface-2);color:var(--hp-fg-2)}
574
+ .hp-root.hp-paper .hp-rank{border:2px solid var(--hp-ink);box-shadow:2px 2px 0 var(--hp-ink)}
575
+ .hp-rank.r1{background:linear-gradient(135deg,#e8b53a,#c9901f);color:#2a1c00;box-shadow:0 2px 10px rgba(217,165,43,.45)}
576
+ .hp-rank.r2{background:linear-gradient(135deg,#c8d0da,#95a0ad);color:#1d242c}
577
+ .hp-rank.r3{background:linear-gradient(135deg,#d4945a,#a9652f);color:#2a1400}
578
+ .hp-title{flex:1;min-width:0}
579
+ .hp-name{font-size:14.5px;font-weight:650;letter-spacing:.1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
580
+ .hp-name .owner{color:var(--hp-fg-3);font-weight:450}
581
+ .hp-sub{font-size:11.5px;color:var(--hp-fg-3);margin-top:3px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
582
+ .hp-chip{font-size:10.5px;padding:1px 7px;border-radius:999px;background:var(--hp-surface-2);color:var(--hp-fg-2);white-space:nowrap}
583
+ .hp-root.hp-paper .hp-chip{border:1.5px solid var(--hp-ink);background:#fff;font-weight:600}
584
+ .hp-root.hp-paper .hp-chip.pct{background:var(--hp-gold)}
585
+ .hp-root.hp-paper .hp-chip.lic{background:#a5dcff}
586
+ .hp-gain{text-align:right;font-variant-numeric:tabular-nums;flex:0 0 auto;padding-left:6px}
587
+ .hp-gain-v{font-size:21px;font-weight:780;line-height:1.05;letter-spacing:-.5px}
588
+ .hp-gain-v.up{color:var(--hp-up)}
589
+ .hp-root.hp-dark .hp-gain-v.up{text-shadow:0 0 18px rgba(229,72,77,.35)}
590
+ .hp-gain-v.flat{color:var(--hp-fg-2)}
591
+ .hp-gain-s{font-size:10.5px;color:var(--hp-fg-3);margin-top:2px}
592
+ /* 简介:取不到就整块不渲染,所以这里**不设 min-height**,避免空灰色占位 */
593
+ .hp-desc{margin:9px 0 0;font-size:12.5px;line-height:1.6;color:var(--hp-fg-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
594
+ .hp-desc-loading{margin-top:10px;display:flex;flex-direction:column;gap:5px}
595
+ .hp-desc-loading i{display:block;height:8px;border-radius:4px;background:linear-gradient(100deg,var(--hp-surface-2) 30%,var(--hp-sep) 50%,var(--hp-surface-2) 70%);background-size:220% 100%;animation:hp-shimmer 1.4s infinite linear}
596
+ .hp-desc-loading i:last-child{width:62%}
597
+ /* 卡片底部:右下角迷你走势 */
598
+ .hp-card-foot{display:flex;justify-content:flex-end;margin-top:6px;margin-bottom:-2px}
599
+ .hp-spark{width:132px;height:34px;opacity:.85;transition:opacity .16s}
600
+ .hp-card:hover .hp-spark{opacity:1}
601
+ .hp-card-loading{height:120px;border-radius:var(--hp-radius);border:var(--hp-border);background:linear-gradient(100deg,var(--hp-surface) 30%,var(--hp-surface-2) 50%,var(--hp-surface) 70%);background-size:220% 100%;animation:hp-shimmer 1.5s infinite linear}
602
+ @keyframes hp-shimmer{0%{background-position:130% 0}100%{background-position:-30% 0}}
603
+ .hp-more{display:flex;justify-content:center;padding:0 20px 30px}
604
+
605
+ /* ---------- 详情面板 ---------- */
606
+ .hp-detail{margin:16px 20px 28px;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.09));border-radius:15px;background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.035));overflow:hidden}
607
+ .hp-detail-head{display:flex;align-items:center;gap:12px;padding:15px 18px;border-bottom:1px solid var(--dsw-alias-separator-primary,rgba(255,255,255,.07))}
608
+ .hp-detail-head .hp-name{font-size:16px}
609
+ .hp-close{margin-left:auto;border:0;background:transparent;color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.5));font-size:19px;cursor:pointer;line-height:1;padding:4px 7px;border-radius:7px}
610
+ .hp-close:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08));color:#fff}
611
+ .hp-detail-body{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(240px,.85fr);gap:0}
612
+ @media (max-width:820px){.hp-detail-body{grid-template-columns:1fr}}
613
+ .hp-pane{padding:16px 18px}
614
+ .hp-pane + .hp-pane{border-left:1px solid var(--dsw-alias-separator-primary,rgba(255,255,255,.07))}
615
+ @media (max-width:820px){.hp-pane + .hp-pane{border-left:0;border-top:1px solid var(--dsw-alias-separator-primary,rgba(255,255,255,.07))}}
616
+ .hp-pane-title{font-size:11px;font-weight:650;letter-spacing:.09em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.42));margin-bottom:9px;display:flex;align-items:center;gap:7px}
617
+ .hp-pane-title::after{content:'';flex:1;height:1px;background:var(--dsw-alias-separator-primary,rgba(255,255,255,.07))}
618
+ .hp-intro{font-size:13px;line-height:1.68;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.78))}
619
+ .hp-intro.none{color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.4));font-style:italic}
620
+ .hp-srcline{font-size:11px;margin-top:9px;line-height:1.5;color:var(--hp-fg-3)}
621
+ .hp-srcline a{color:inherit;text-decoration:none;border-bottom:1px dotted currentColor}
622
+ .hp-srcline a:hover{color:var(--hp-fg)}
623
+ .hp-kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin-top:14px}
624
+ .hp-kpi{border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.07));border-radius:9px;padding:8px 10px;background:var(--dsw-alias-bg-layer-1,rgba(0,0,0,.18))}
625
+ .hp-kpi-k{font-size:10.5px;color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.4));margin-bottom:3px}
626
+ .hp-kpi-v{font-size:15px;font-weight:700;font-variant-numeric:tabular-nums}
627
+ .hp-kpi-v.up{color:var(--hp-up)}
628
+ .hp-links{display:flex;gap:8px;flex-wrap:wrap;margin-top:13px}
629
+ .hp-link{font-size:12px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.12));color:var(--dsw-alias-label-secondary,rgba(255,255,255,.72));text-decoration:none;transition:all .15s}
630
+ .hp-link:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08));color:#fff}
631
+
632
+ /* ---------- 趋势图 ---------- */
633
+ .hp-chart{width:100%;height:auto;display:block;overflow:visible}
634
+ .hp-chart-wrap{position:relative;margin-top:2px}
635
+ .hp-chart-empty{font-size:12px;color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.4));padding:22px 0;text-align:center;font-style:italic}
636
+ .hp-chart-legend{display:flex;justify-content:space-between;font-size:11px;color:var(--dsw-alias-label-tertiary,rgba(255,255,255,.45));font-variant-numeric:tabular-nums;margin-top:5px}
637
+
638
+ `;
639
+ doc.head.appendChild(style);
640
+ }
641
+
642
+ /* ============================================================ 图表组件 */
643
+
644
+ /**
645
+ * 把一串点连成**平滑曲线**(Catmull-Rom 转三次贝塞尔)。
646
+ *
647
+ * 为什么不用折线:星数快照之间是每天一个点,直连会是一串生硬的折角,
648
+ * 看不出「加速 / 放缓」的趋势形状。平滑后曲线读起来更像行情图。
649
+ *
650
+ * 注意:平滑只是**画法**,不改变数据点位置 —— 点仍然落在原坐标上。
651
+ */
652
+ function smoothPath(points) {
653
+ if (!points || points.length === 0) return '';
654
+ if (points.length === 1) return 'M' + points[0][0] + ' ' + points[0][1];
655
+ if (points.length === 2) return 'M' + points[0][0] + ' ' + points[0][1] + ' L' + points[1][0] + ' ' + points[1][1];
656
+ let d = 'M' + points[0][0] + ' ' + points[0][1];
657
+ for (let i = 0; i < points.length - 1; i += 1) {
658
+ const p0 = points[i - 1] || points[i];
659
+ const p1 = points[i];
660
+ const p2 = points[i + 1];
661
+ const p3 = points[i + 2] || p2;
662
+ // 控制点系数 1/6 是 Catmull-Rom → 贝塞尔的经典换算
663
+ const c1x = p1[0] + (p2[0] - p0[0]) / 6;
664
+ const c1y = p1[1] + (p2[1] - p0[1]) / 6;
665
+ const c2x = p2[0] - (p3[0] - p1[0]) / 6;
666
+ const c2y = p2[1] - (p3[1] - p1[1]) / 6;
667
+ d += ' C' + c1x.toFixed(1) + ' ' + c1y.toFixed(1) +
668
+ ' ' + c2x.toFixed(1) + ' ' + c2y.toFixed(1) +
669
+ ' ' + p2[0] + ' ' + p2[1];
670
+ }
671
+ return d;
672
+ }
673
+
674
+ /**
675
+ * 星数曲线:带面积填充、端点标记、起止标注。
676
+ *
677
+ * 注意:这是**组件**,必须用 `h(StarChart, { chart })` 渲染,
678
+ * 不能直接调用 —— 直接调用返回的 React 元素会被当成普通对象塞进子节点而报错。
679
+ */
680
+ function StarChart(props) {
681
+ const { React, chart } = props;
682
+ const h = React.createElement;
683
+ if (!chart || !Array.isArray(chart.series) || chart.series.length < 3) {
684
+ return h('div', { className: 'hp-chart-empty' }, '暂无星数曲线数据');
685
+ }
686
+ const W = 560;
687
+ const H = 190;
688
+ const PAD = { l: 8, r: 8, t: 12, b: 24 };
689
+ const series = chart.series;
690
+ const xs = series.map((p) => p.x);
691
+ const ys = series.map((p) => p.stars);
692
+ const xmin = Math.min(...xs);
693
+ const xmax = Math.max(...xs);
694
+ const ymin = Math.min(...ys);
695
+ const ymax = Math.max(...ys);
696
+ const spanX = xmax - xmin || 1;
697
+ const spanY = ymax - ymin || 1;
698
+ const px = (x) => PAD.l + ((x - xmin) / spanX) * (W - PAD.l - PAD.r);
699
+ const py = (v) => H - PAD.b - ((v - ymin) / spanY) * (H - PAD.t - PAD.b);
700
+
701
+ const line = series.map((p, i) => (i === 0 ? 'M' : 'L') + px(p.x).toFixed(1) + ' ' + py(p.stars).toFixed(1)).join(' ');
702
+ const area = line + ' L' + px(xmax).toFixed(1) + ' ' + (H - PAD.b) + ' L' + px(xmin).toFixed(1) + ' ' + (H - PAD.b) + ' Z';
703
+
704
+ const first = ys[0];
705
+ const last = ys[ys.length - 1];
706
+ const gained = last - first;
707
+ const label = (p) => (p && p.label ? p.label : '');
708
+
709
+ return h(
710
+ 'div',
711
+ { className: 'hp-chart-wrap' },
712
+ h(
713
+ 'svg',
714
+ { className: 'hp-chart', viewBox: '0 0 ' + W + ' ' + H, preserveAspectRatio: 'xMidYMid meet', role: 'img' },
715
+ h(
716
+ 'defs',
717
+ null,
718
+ h(
719
+ 'linearGradient',
720
+ { id: 'hp-area-grad', x1: '0', y1: '0', x2: '0', y2: '1' },
721
+ h('stop', { offset: '0%', stopColor: '#e5484d', stopOpacity: '0.42' }),
722
+ h('stop', { offset: '100%', stopColor: '#e5484d', stopOpacity: '0.02' }),
723
+ ),
724
+ ),
725
+ // 水平网格
726
+ [0, 0.25, 0.5, 0.75, 1].map((t, i) => {
727
+ const y = (H - PAD.b) - t * (H - PAD.t - PAD.b);
728
+ const v = ymin + t * spanY;
729
+ return h(
730
+ 'g',
731
+ { key: 'g' + i },
732
+ h('line', { x1: PAD.l, x2: W - PAD.r, y1: y, y2: y, stroke: 'rgba(255,255,255,.07)', strokeDasharray: '3 4' }),
733
+ h('text', { x: W - PAD.r, y: y - 3, textAnchor: 'end', fontSize: '9.5', fill: 'rgba(255,255,255,.32)' }, fmtCount(v)),
734
+ );
735
+ }),
736
+ // 面积 + 曲线
737
+ h('path', { d: area, fill: 'url(#hp-area-grad)' }),
738
+ h('path', { d: line, fill: 'none', stroke: '#e5484d', strokeWidth: '2', strokeLinejoin: 'round', strokeLinecap: 'round' }),
739
+ // 端点
740
+ h('circle', { cx: px(xs[0]), cy: py(first), r: '3.2', fill: '#e5484d' }),
741
+ h('circle', { cx: px(xmax), cy: py(last), r: '4', fill: '#e5484d', stroke: '#fff', strokeWidth: '1.4' }),
742
+ // x 轴标签
743
+ (chart.xLabels || []).map((lab, i) =>
744
+ h('text', { key: 'x' + i, x: px(lab.x), y: H - 7, textAnchor: 'middle', fontSize: '9.5', fill: 'rgba(255,255,255,.32)' }, label(lab)),
745
+ ),
746
+ ),
747
+ h(
748
+ 'div',
749
+ { className: 'hp-chart-legend' },
750
+ h('span', null, '起点 ' + fmtCount(first)),
751
+ h('span', { style: { color: '#ff8a90', fontWeight: 650 } }, '+' + fmtCount(gained) + ' ★'),
752
+ h('span', null, '现在 ' + fmtCount(last)),
753
+ ),
754
+ );
755
+ }
756
+
757
+ /* ============================================================ 卡片 */
758
+
759
+ /**
760
+ * 仓库所有者头像。
761
+ *
762
+ * 走宿主代理接口(/avatar),不是直连 github.com —— 一张卡一次外网请求太快烧钱了。
763
+ * 取不到时退化成「首字母块」,与 GitHub 自己的降级行为一致,不会出现破图标。
764
+ */
765
+ function Avatar(props) {
766
+ const { React, repository, size } = props;
767
+ const h = React.createElement;
768
+ const owner = String(repository || '').split('/')[0] || '?';
769
+ const px = size || 34;
770
+ const [failed, setFailed] = React.useState(false);
771
+ const initial = owner.replace(/^[^a-zA-Z0-9]/, '').slice(0, 1).toUpperCase() || '?';
772
+ // 用固定色相区分不同 owner,避免整屏同色
773
+ let hue = 0;
774
+ for (let i = 0; i < owner.length; i += 1) hue = (hue * 31 + owner.charCodeAt(i)) % 360;
775
+ if (failed) {
776
+ return h(
777
+ 'span',
778
+ {
779
+ className: 'hp-avatar hp-avatar-fb',
780
+ style: { width: px, height: px, background: 'hsl(' + hue + ' 45% 38%)', fontSize: Math.round(px * 0.42) },
781
+ title: owner,
782
+ },
783
+ initial,
784
+ );
785
+ }
786
+ // 优先用 /batch 里内联的 data URI(不发额外请求);
787
+ // 没有才退回 /avatar 接口(每个项目一个请求,只在详情页等少数场景发生)。
788
+ const inline = props.src || null;
789
+ return h('img', {
790
+ className: 'hp-avatar',
791
+ src: inline || BASE + '/avatar?repo=' + encodeURIComponent(repository || owner),
792
+ width: px,
793
+ height: px,
794
+ alt: owner,
795
+ loading: 'lazy',
796
+ onError: () => setFailed(true),
797
+ });
798
+ }
799
+
800
+ /**
801
+ * 迷你走势:卡片右下角的一小段星数曲线。
802
+ * 没有曲线数据时**什么都不画** —— 绝不画一条假的水平线充数。
803
+ */
804
+ function Sparkline(props) {
805
+ const { React, chart, tone } = props;
806
+ const h = React.createElement;
807
+ if (!chart || !Array.isArray(chart.series) || chart.series.length < 3) return null;
808
+ const W = 132;
809
+ const H = 34;
810
+ const ys = chart.series.map((p) => p.stars);
811
+ const ymin = Math.min(...ys);
812
+ const ymax = Math.max(...ys);
813
+ const span = ymax - ymin || 1;
814
+ const pts = chart.series.map((p, i) =>
815
+ ((i / (chart.series.length - 1)) * W).toFixed(1) + ',' + (H - 2 - ((p.stars - ymin) / span) * (H - 6)).toFixed(1),
816
+ );
817
+ const color = tone === 'gold' ? '#e8b53a' : '#e5484d';
818
+ return h(
819
+ 'svg',
820
+ { className: 'hp-spark', viewBox: '0 0 ' + W + ' ' + H, preserveAspectRatio: 'none', 'aria-hidden': 'true' },
821
+ h('polyline', { points: pts.join(' '), fill: 'none', stroke: color, strokeWidth: '1.6', strokeLinejoin: 'round', strokeLinecap: 'round', opacity: '0.9' }),
822
+ h('circle', { cx: W, cy: (H - 2 - ((ys[ys.length - 1] - ymin) / span) * (H - 6)).toFixed(1), r: '2.4', fill: color }),
823
+ );
824
+ }
825
+
826
+ /**
827
+ * 一张成绩卡。
828
+ *
829
+ * 视觉重心刻意放在**涨星数**上(大号、涨色、带光晕),名次徽章与仓库名次之;
830
+ * 简介只显示两行、取不到就**整块不渲染**(不留「暂无简介」那种灰色占位);
831
+ * 右下角用迷你走势代替进度条 —— 有曲线才画,没有就不占位。
832
+ */
833
+ function Card(props) {
834
+ const { React } = props;
835
+ const h = React.createElement;
836
+ const { item, maxGain, isSel, onSelect, detail } = props;
837
+ const isTop = item.rank <= 3;
838
+ const cls = 'hp-card' + (isTop ? ' top' + item.rank : '') + (isSel ? ' sel' : '');
839
+ const [owner, repo] = item.repository.split('/');
840
+ const share = maxGain > 0 ? Math.round((item.starsGained / maxGain) * 100) : 0;
841
+ /**
842
+ * 简介显示策略(优先避免等待与空白):
843
+ *
844
+ * · 有译文 → 显示中文
845
+ * · 没译文但有英文 → **先显示英文**(不让用户对着骨架干等模型)
846
+ * · 什么都没有 → 骨架
847
+ *
848
+ * 之前选「中文」时会等译文、期间显示骨架,结果点了中文要等十几秒才出字 ——
849
+ * 那个体验很差。现在英文秒到就先给内容,中文来了原地替换。
850
+ * 英文模式下也一样,只是不再触发翻译。
851
+ */
852
+ const descZh = detail && detail.descZh ? detail.descZh : null;
853
+ const descEn = detail && detail.desc ? detail.desc : null;
854
+ const lang = props.lang === 'en' ? 'en' : 'zh';
855
+ /**
856
+ * 严格按语言选:英文模式就显示英文(哪怕库里还留着中文译文),
857
+ * 中文模式优先中文、没译文时先用英文顶着。
858
+ * 之前写成 `descZh || descEn`,结果英文模式下也会显示中文 —— 与按钮状态矛盾。
859
+ */
860
+ const desc = lang === 'en' ? descEn || descZh : descZh || descEn;
861
+ // 中文模式下有英文但还没译文 → 标记「翻译中」,界面上给个小提示
862
+ const translating = lang === 'zh' && !descZh && !!descEn;
863
+ return h(
864
+ 'article',
865
+ {
866
+ className: cls,
867
+ onClick: () => onSelect(item),
868
+ onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(item); } },
869
+ role: 'button',
870
+ tabIndex: 0,
871
+ title: item.repository + (desc ? '\n' + desc : ''),
872
+ },
873
+ // ── 顶部:名次 + 仓库名 + 涨星 ──
874
+ h(
875
+ 'div',
876
+ { className: 'hp-card-head' },
877
+ h(Avatar, { React, repository: item.repository, size: isTop ? 40 : 34, src: detail && detail.avatar }),
878
+ h('div', { className: 'hp-rank' + (isTop ? ' r' + item.rank : '') }, item.rank),
879
+ h(
880
+ 'div',
881
+ { className: 'hp-title' },
882
+ h('div', { className: 'hp-name' }, h('span', { className: 'owner' }, owner + '/'), repo),
883
+ h(
884
+ 'div',
885
+ { className: 'hp-sub' },
886
+ item.language ? h('span', { className: 'hp-chip' }, item.language) : null,
887
+ item.growthPct !== null ? h('span', { className: 'hp-chip pct' }, fmtPct(item.growthPct)) : null,
888
+ item.license ? h('span', { className: 'hp-chip lic' }, item.license) : null,
889
+ ),
890
+ ),
891
+ h(
892
+ 'div',
893
+ { className: 'hp-gain' },
894
+ h('div', { className: 'hp-gain-v ' + gainTone(item.starsGained) }, '+' + fmtCount(item.starsGained)),
895
+ h('div', { className: 'hp-gain-s' }, share >= 60 ? '本期最猛' : item.endStars ? fmtCount(item.endStars) + ' stars' : ''),
896
+ ),
897
+ ),
898
+ // ── 简介:有才渲染 ──
899
+ desc
900
+ ? h('p', { className: 'hp-desc' + (descZh ? ' zh' : '') }, desc,
901
+ translating ? h('span', { className: 'hp-zh-pending', title: '中文翻译生成中' }, ' 译') : null)
902
+ : // 还没取完:骨架;取完了却什么都没有(没有该项目分析):留空不占位
903
+ detail && detail.settled
904
+ ? null
905
+ : h('div', { className: 'hp-desc-loading' }, h('i'), h('i')),
906
+ // ── 底部:迷你走势 ──
907
+ detail && detail.chart
908
+ ? h('div', { className: 'hp-card-foot' }, h(Sparkline, { React, chart: detail.chart, tone: isTop ? 'gold' : 'red' }))
909
+ : null,
910
+ );
911
+ }
912
+
913
+ /* ============================================================ 详情视图 */
914
+
915
+ function Detail(props) {
916
+ const { React } = props;
917
+ const h = React.createElement;
918
+ const { item, detail, loading, error, onClose } = props;
919
+ if (!item) return null;
920
+ const [owner, repo] = item.repository.split('/');
921
+ const d = detail || {};
922
+ return h(
923
+ 'div',
924
+ { className: 'hp-detail' },
925
+ h(
926
+ 'div',
927
+ { className: 'hp-detail-head' },
928
+ h(Avatar, { React, repository: item.repository, size: 40 }),
929
+ h('div', { className: 'hp-rank' + (item.rank <= 3 ? ' r' + item.rank : '') }, item.rank),
930
+ h(
931
+ 'div',
932
+ { className: 'hp-title' },
933
+ h('div', { className: 'hp-name' }, h('span', { className: 'owner' }, owner + ' / '), repo),
934
+ h(
935
+ 'div',
936
+ { className: 'hp-sub' },
937
+ item.language ? h('span', { className: 'hp-chip' }, item.language) : null,
938
+ item.license ? h('span', { className: 'hp-chip' }, item.license) : null,
939
+ d.analysisUpdated ? h('span', { style: { opacity: 0.7 } }, '分析更新于 ' + d.analysisUpdated) : null,
940
+ ),
941
+ ),
942
+ h('div', { className: 'hp-gain', style: { marginLeft: 'auto' } },
943
+ h('div', { className: 'hp-gain-v ' + gainTone(item.starsGained) }, '+' + fmtCount(item.starsGained)),
944
+ h('div', { className: 'hp-gain-s' }, item.endStars ? fmtCount(item.endStars) + ' 星' : ''),
945
+ ),
946
+ h('button', { className: 'hp-close', onClick: onClose, title: '关闭' }, '×'),
947
+ ),
948
+ h(
949
+ 'div',
950
+ { className: 'hp-detail-body' },
951
+ // 左:简介
952
+ h(
953
+ 'div',
954
+ { className: 'hp-pane' },
955
+ h('div', { className: 'hp-pane-title' }, '项目简介'),
956
+ loading
957
+ ? h('div', { className: 'hp-intro none' }, '正在读取并翻译中…')
958
+ : h(
959
+ 'div',
960
+ null,
961
+ d.descZh
962
+ ? h('div', { className: 'hp-intro zh' }, d.descZh)
963
+ : null,
964
+ d.desc
965
+ ? h(
966
+ 'div',
967
+ { className: d.descZh ? 'hp-intro-en' : 'hp-intro' },
968
+ d.desc,
969
+ )
970
+ : h('div', { className: 'hp-intro none' }, error ? '读取失败:' + error : '暂无简介'),
971
+ ),
972
+ /**
973
+ * 简介的来源说明。
974
+ *
975
+ * 合规要点:这段文字是转载的第三方分析(CC BY 4.0),必须让读者知道
976
+ * 它不是本项目原创。所以明确写出「引用自 Hysen Labs」并带上链接,
977
+ * 同时说明中译是谁做的、原文在哪。
978
+ */
979
+ d.descSource === 'conclusion' || d.descSource === 'overview'
980
+ ? h(
981
+ 'div',
982
+ { className: 'hp-srcline' },
983
+ h('span', null, d.descSource === 'conclusion' ? '编辑结论' : '项目概览'),
984
+ ' · 引用自 ',
985
+ h('a', { href: item.hysenUrl || 'https://hysenlabs.com', target: '_blank', rel: 'noreferrer' }, 'Hysen Labs'),
986
+ '(CC BY 4.0)',
987
+ d.descZh ? ' · 中译由 DSH 模型生成,英文原文见下方' : '',
988
+ )
989
+ : null,
990
+ h(
991
+ 'div',
992
+ { className: 'hp-kpis' },
993
+ h('div', { className: 'hp-kpi' }, h('div', { className: 'hp-kpi-k' }, '期间涨星'), h('div', { className: 'hp-kpi-v up' }, '+' + fmtCount(item.starsGained))),
994
+ h('div', { className: 'hp-kpi' }, h('div', { className: 'hp-kpi-k' }, '起点星数'), h('div', { className: 'hp-kpi-v' }, fmtCount(item.startStars))),
995
+ h('div', { className: 'hp-kpi' }, h('div', { className: 'hp-kpi-k' }, '最新星数'), h('div', { className: 'hp-kpi-v' }, fmtCount(item.endStars))),
996
+ ),
997
+ h(
998
+ 'div',
999
+ { className: 'hp-links' },
1000
+ h('a', { className: 'hp-link', href: item.githubUrl, target: '_blank', rel: 'noreferrer' }, '↗ GitHub'),
1001
+ // 数据源详情页外链:直接标出站点名,用户一眼知道点过去是什么
1002
+ item.hysenUrl
1003
+ ? h('a', { className: 'hp-link', href: item.hysenUrl, target: '_blank', rel: 'noreferrer' }, '↗ hysenlabs')
1004
+ : null,
1005
+ ),
1006
+ ),
1007
+ // 右:曲线
1008
+ h(
1009
+ 'div',
1010
+ { className: 'hp-pane' },
1011
+ h('div', { className: 'hp-pane-title' }, '星数曲线'),
1012
+ loading ? h('div', { className: 'hp-chart-empty' }, '加载中…') : h(StarChart, { React, chart: d.chart }),
1013
+ d.chartError && !d.chart ? h('div', { className: 'hp-chart-empty' }, '曲线读取失败:' + d.chartError) : null,
1014
+ ),
1015
+ ),
1016
+ );
1017
+ }
1018
+
1019
+ /* ============================================================ 大盘视图 */
1020
+
1021
+ /** 多曲线对比用的色板(8 条以内,之后循环)。 */
1022
+ const SERIES_COLORS = ['#3a6fe0', '#30a46c', '#e8b53a', '#e5484d', '#7b5cff', '#12a594', '#f76b15', '#8e4ec6'];
1023
+
1024
+ /**
1025
+ * 统计视图 —— 多项目星数曲线对比。
1026
+ *
1027
+ * 左栏:勾选要看哪些项目(默认前 6 个),每项显示本期涨星;
1028
+ * 右栏:把选中项目的曲线**按真实日期对齐**后叠在同一张图上。
1029
+ *
1030
+ * 两个必须守住的点:
1031
+ * 1. **按日期对齐**,不是按点序号 —— 各项目起始日不同,按序号叠会错位。
1032
+ * 2. **两种量纲分开**:绝对星数(谁体量大)与本期涨幅(谁涨得猛)是两个问题,
1033
+ * 用分段器切换,不混在一张图里比。
1034
+ */
1035
+ function Dashboard(props) {
1036
+ const { React } = props;
1037
+ const h = React.createElement;
1038
+ const { rows, charts, lang, periodKey } = props;
1039
+
1040
+ // 选项给全部(最多 20 条榜单),默认对比前 10 个 —— 再多线会糊成一团
1041
+ const [picked, setPicked] = React.useState(() => new Set((rows || []).slice(0, DEFAULT_COMPARE).map((r) => r.repository)));
1042
+ const [hover, setHover] = React.useState(null);
1043
+
1044
+ // 数据换了一批(切榜单)时重置选择
1045
+ React.useEffect(() => {
1046
+ setPicked(new Set((rows || []).slice(0, DEFAULT_COMPARE).map((r) => r.repository)));
1047
+ }, [rows]);
1048
+
1049
+ const toggle = (repo) => {
1050
+ setPicked((prev) => {
1051
+ const next = new Set(prev);
1052
+ if (next.has(repo)) next.delete(repo);
1053
+ else if (next.size < MAX_COMPARE) next.add(repo);
1054
+ return next;
1055
+ });
1056
+ };
1057
+
1058
+ /** 取出选中项目的序列,并统一到全局时间轴。 */
1059
+ /**
1060
+ * 把选中项目的曲线整理成可对比的序列。
1061
+ *
1062
+ * 基准口径:
1063
+ * 1. 所有曲线用**同一天**当零点 —— 取所有选中项目里「最早出现的公共日期」。
1064
+ * 这样 9 月一起爆发和 8 月就起量的项目,是在同一条时间尺度上比。
1065
+ * 2. **不从 0 开始也没关系**:每条线从它自己第一次有数据那天开始画,
1066
+ * 中途才起来的项目就是坐标轴中间才冒出来的一条线,不需要从最左边硬接。
1067
+ * 3. 某个项目在基准日**还没有数据**时,用它最早那个点补齐起算
1068
+ * (不做插值,不编数据;就是"它进榜那天"起算)。
1069
+ */
1070
+ const seriesList = React.useMemo(() => {
1071
+ const raw = [];
1072
+ for (const row of rows || []) {
1073
+ if (!picked.has(row.repository)) continue;
1074
+ const chart = charts && charts[row.repository];
1075
+ if (!chart || !Array.isArray(chart.series) || chart.series.length < 3) continue;
1076
+ const dated = chart.series.filter((p) => p && typeof p.date === 'string');
1077
+ if (dated.length < 3) continue;
1078
+ raw.push({ row, points: dated.slice().sort((a, b) => (a.date < b.date ? -1 : 1)) });
1079
+ }
1080
+ if (raw.length === 0) return [];
1081
+
1082
+ // 基准日 = 所有曲线里「最晚的起始日」(保证每条线在这个日子上都有或即将有数据)
1083
+ // 与「最早的公共日」相比,取**最晚起始日**能让每条线都有完整的一段,
1084
+ // 不会出现某条线在基准日还没出生、导致整段缺口。
1085
+ let baseDate = raw[0].points[0].date;
1086
+ for (const series of raw) {
1087
+ const first = series.points[0].date;
1088
+ if (first > baseDate) baseDate = first;
1089
+ }
1090
+
1091
+ return raw.map((series, idx) => {
1092
+ // 基准日之前没有数据的项目:用它的第一个点作为自己的起算点(不从 0 硬补)
1093
+ const startIdx = series.points.findIndex((p) => p.date >= baseDate);
1094
+ const from = startIdx >= 0 ? startIdx : 0;
1095
+ const baseStars = series.points[from].stars;
1096
+ return {
1097
+ repo: series.row.repository,
1098
+ color: SERIES_COLORS[idx % SERIES_COLORS.length],
1099
+ // 每条线只保留**基准日之后**的点;起点那天值记 0
1100
+ points: series.points.slice(from).map((p) => ({
1101
+ date: p.date,
1102
+ stars: p.stars,
1103
+ gained: p.stars - baseStars,
1104
+ })),
1105
+ baseDate: series.points[from].date,
1106
+ row: series.row,
1107
+ };
1108
+ });
1109
+ }, [rows, charts, picked]);
1110
+
1111
+ /** 全局时间范围与量程。 */
1112
+ /**
1113
+ * 纵轴用**对数坐标**。
1114
+ *
1115
+ * 为什么:单条项目的涨幅可能比别人大一个量级(+425k vs +30k),线性坐标会把
1116
+ * 其余九条全压成贴着底边的一条线,完全看不出形状。对数下 1k→10k→100k 等距,
1117
+ * 大项目和小项目各自的走势都读得出来。
1118
+ *
1119
+ * 关键细节:涨幅可能从 0 开始,而 log(0) 无定义 —— 所以统一用 log(1 + 涨幅),
1120
+ * 起点处正好是 0,不会出现 NaN。
1121
+ */
1122
+ const LOG_BASE = 1;
1123
+ const logV = (v) => Math.log10(Math.max(0, v) + LOG_BASE);
1124
+ const frame = React.useMemo(() => {
1125
+ if (seriesList.length === 0) return null;
1126
+ let minDate = Infinity;
1127
+ let maxDate = -Infinity;
1128
+ let minV = Infinity;
1129
+ let maxV = -Infinity;
1130
+ for (const s of seriesList) {
1131
+ for (const p of s.points) {
1132
+ const t = Date.parse(p.date);
1133
+ if (t < minDate) minDate = t;
1134
+ if (t > maxDate) maxDate = t;
1135
+ const v = p.gained;
1136
+ if (v < minV) minV = v;
1137
+ if (v > maxV) maxV = v;
1138
+ }
1139
+ }
1140
+ /**
1141
+ * 对数轴的可视范围。
1142
+ *
1143
+ * 关键:**下界不取 0**。所有曲线都从 0 出发会让十条线在最左边挤成一坨,
1144
+ * 而且下面那一大片空白完全是浪费。这里从「最小涨幅」再往下留半个数量级的余量,
1145
+ * 曲线就能撑满整个绘图区。
1146
+ *
1147
+ * 上界同理留一点余量,免得最高的那条贴顶被裁掉。
1148
+ */
1149
+ const loRaw = logV(minV);
1150
+ const hiRaw = logV(maxV);
1151
+ const pad = Math.max(0.12, (hiRaw - loRaw) * 0.06);
1152
+ const lo = loRaw - pad;
1153
+ const hi = hiRaw > loRaw ? hiRaw + pad : loRaw + 1;
1154
+ return { minDate, maxDate, minV, maxV, lo, hi };
1155
+ }, [seriesList]);
1156
+
1157
+ const ready = rows && rows.length > 0;
1158
+
1159
+ // ── 左栏:项目勾选列表 ──
1160
+ const picker = h(
1161
+ 'aside',
1162
+ { className: 'hp-picker' },
1163
+ h('div', { className: 'hp-picker-title' }, '选择项目', h('span', { className: 'hp-picker-n' }, picked.size + ' / ' + MAX_COMPARE)),
1164
+ h(
1165
+ 'div',
1166
+ { className: 'hp-picker-list' },
1167
+ (rows || []).map((row) => {
1168
+ const on = picked.has(row.repository);
1169
+ // charts 是 details[repo] = { desc, chart, ... },曲线在 .chart.series
1170
+ const hasChart = charts && charts[row.repository] && Array.isArray(charts[row.repository].series);
1171
+ const color = seriesList.find((s) => s.repo === row.repository)?.color;
1172
+ return h(
1173
+ 'label',
1174
+ { key: row.repository, className: 'hp-pick' + (on ? ' on' : '') + (hasChart ? '' : ' no-data') },
1175
+ h('input', {
1176
+ type: 'checkbox',
1177
+ checked: on,
1178
+ disabled: !hasChart,
1179
+ onChange: () => toggle(row.repository),
1180
+ }),
1181
+ h('span', { className: 'hp-pick-dot', style: color ? { background: color } : undefined }),
1182
+ h(Avatar, { React, repository: row.repository, size: 18 }),
1183
+ h('span', { className: 'hp-pick-name', title: row.repository }, row.repository),
1184
+ h('span', { className: 'hp-pick-v' }, '+' + fmtCount(row.starsGained)),
1185
+ );
1186
+ }),
1187
+ ),
1188
+ );
1189
+
1190
+ // ── 右栏:多曲线图 ──
1191
+ const W = 760;
1192
+ const H = 340;
1193
+ const PAD = { l: 54, r: 16, t: 18, b: 30 };
1194
+ let chartNode = null;
1195
+ if (seriesList.length === 0) {
1196
+ chartNode = h('div', { className: 'hp-chart-empty' }, '勾选左侧项目后这里会画出它们的走势对比');
1197
+ } else if (frame) {
1198
+ const { minDate, maxDate, minV, maxV, lo, hi } = frame;
1199
+ const spanX = maxDate - minDate || 1;
1200
+ const px = (date) => PAD.l + ((Date.parse(date) - minDate) / spanX) * (W - PAD.l - PAD.r);
1201
+ /** 值 → 屏幕 y(对数)。 */
1202
+ const py = (v) => H - PAD.b - ((logV(v) - lo) / (hi - lo)) * (H - PAD.t - PAD.b);
1203
+ const val = (p) => p.gained;
1204
+
1205
+ /**
1206
+ * y 轴刻度取「10 的整数次幂」:1 / 10 / 100 / 1k / 10k / 100k / 1M…
1207
+ * 只保留落在可视范围内的那些,最多 7 条 —— 这就是对数图的读法。
1208
+ */
1209
+ const decadeTicks = [];
1210
+ for (let e = 0; e <= 7; e += 1) {
1211
+ const value = Math.pow(10, e);
1212
+ if (logV(value) >= lo - 0.001 && logV(value) <= hi + 0.001) {
1213
+ decadeTicks.push({ value, y: py(value) });
1214
+ }
1215
+ }
1216
+ // 范围太窄(一个量级以内)时补中间刻度,避免只有一条线
1217
+ const gridLines = decadeTicks.length >= 2
1218
+ ? decadeTicks
1219
+ : [0, 0.25, 0.5, 0.75, 1].map((t) => {
1220
+ const target = lo + t * (hi - lo);
1221
+ const value = Math.pow(10, target) - LOG_BASE;
1222
+ return { value: Math.max(0, value), y: py(Math.max(0, value)) };
1223
+ });
1224
+
1225
+ // x 轴日期刻度(5 个)
1226
+ const xTicks = [0, 0.25, 0.5, 0.75, 1].map((t) => {
1227
+ const ts = minDate + t * spanX;
1228
+ const d = new Date(ts);
1229
+ // 用 UTC 取月/日,避免本地时区把刻度挪一天
1230
+ return { x: px(new Date(ts).toISOString().slice(0, 10)), label: (d.getUTCMonth() + 1) + '/' + d.getUTCDate() };
1231
+ });
1232
+
1233
+ chartNode = h(
1234
+ 'div',
1235
+ // key 里带周期:换榜单时整个图重建一次,配 CSS 淡入,避免生硬跳变
1236
+ { className: 'hp-mchart', key: 'chart-' + periodKey },
1237
+ h(
1238
+ 'svg',
1239
+ { viewBox: '0 0 ' + W + ' ' + H, className: 'hp-mchart-svg', role: 'img' },
1240
+ // 网格
1241
+ gridLines.map((g, i) =>
1242
+ h(
1243
+ 'g',
1244
+ { key: 'g' + i },
1245
+ h('line', { x1: PAD.l, x2: W - PAD.r, y1: g.y, y2: g.y, className: 'hp-grid' }),
1246
+ h('text', { x: PAD.l - 8, y: g.y + 3.5, textAnchor: 'end', className: 'hp-axis' },
1247
+ g.value === 0 ? '0' : '+' + fmtCount(g.value)),
1248
+ ),
1249
+ ),
1250
+ // x 轴
1251
+ xTicks.map((t, i) => h('text', { key: 'x' + i, x: t.x, y: H - 9, textAnchor: 'middle', className: 'hp-axis' }, t.label)),
1252
+ // 零线(涨幅模式下有意义)
1253
+ minV < 0 && maxV > 0
1254
+ ? h('line', { x1: PAD.l, x2: W - PAD.r, y1: py(0), y2: py(0), className: 'hp-zero' })
1255
+ : null,
1256
+ // 每条曲线
1257
+ seriesList.map((s) => {
1258
+ const d = smoothPath(s.points.map((p) => [Number(px(p.date).toFixed(1)), Number(py(val(p)).toFixed(1))]));
1259
+ const dim = hover !== null && hover !== s.repo;
1260
+ return h('path', {
1261
+ d,
1262
+ fill: 'none',
1263
+ stroke: s.color,
1264
+ strokeWidth: hover === s.repo ? 2.8 : 1.9,
1265
+ strokeLinejoin: 'round',
1266
+ strokeLinecap: 'round',
1267
+ opacity: dim ? 0.22 : 1,
1268
+ // 换榜单时让线「重新画一遍」,视觉上说明数据换了
1269
+ className: 'hp-series',
1270
+ key: 'p-' + s.repo + '-' + periodKey,
1271
+ style: { transition: 'opacity .15s, stroke-width .15s' },
1272
+ });
1273
+ }),
1274
+ // 末端圆点
1275
+ // 每条线末端用**项目头像**代替圆点(像行情软件在线尾标品牌)。
1276
+ // 用 <clipPath> 裁成圆形;头像可能加载失败,失败时退化成同色圆点,绝不出现空白。
1277
+ seriesList.map((s, i) => {
1278
+ const last = s.points[s.points.length - 1];
1279
+ const cx = px(last.date);
1280
+ const cy = py(val(last));
1281
+ const r = 9;
1282
+ const owner = String(s.repo).split('/')[0] || '?';
1283
+ const clipId = 'hp-av-' + i;
1284
+ const dim = hover !== null && hover !== s.repo;
1285
+ return h(
1286
+ 'g',
1287
+ { key: 'end' + s.repo, opacity: dim ? 0.25 : 1, style: { transition: 'opacity .15s' } },
1288
+ h(
1289
+ 'defs',
1290
+ null,
1291
+ h('clipPath', { id: clipId }, h('circle', { cx, cy, r })),
1292
+ ),
1293
+ // 白色描边:让头像从深色/米色底上都浮出来
1294
+ h('circle', { cx, cy, r: r + 1.6, fill: 'var(--hp-card-bg, #fff)', opacity: 0.9 }),
1295
+ h('circle', { cx, cy, r, fill: s.color, opacity: 0.18 }),
1296
+ h('image', {
1297
+ href: BASE + '/avatar?owner=' + encodeURIComponent(owner),
1298
+ x: cx - r,
1299
+ y: cy - r,
1300
+ width: r * 2,
1301
+ height: r * 2,
1302
+ clipPath: 'url(#' + clipId + ')',
1303
+ preserveAspectRatio: 'xMidYMid slice',
1304
+ }),
1305
+ h('circle', { cx, cy, r, fill: 'none', stroke: s.color, strokeWidth: 1.6 }),
1306
+ );
1307
+ }),
1308
+ ),
1309
+ // 图例
1310
+ h(
1311
+ 'div',
1312
+ { className: 'hp-legend' },
1313
+ seriesList.map((s) =>
1314
+ h(
1315
+ 'button',
1316
+ {
1317
+ key: s.repo,
1318
+ className: 'hp-legend-item' + (hover === s.repo ? ' act' : ''),
1319
+ onMouseEnter: () => setHover(s.repo),
1320
+ onMouseLeave: () => setHover(null),
1321
+ onClick: () => toggle(s.repo),
1322
+ title: '点击移出对比',
1323
+ },
1324
+ h('span', { className: 'hp-legend-line', style: { background: s.color } }),
1325
+ h('span', null, s.repo.split('/')[1] || s.repo),
1326
+ ),
1327
+ ),
1328
+ ),
1329
+ );
1330
+ }
1331
+
1332
+ // 上图下选:先给结论(走势图),再给操作(勾选)
1333
+ return h(
1334
+ 'div',
1335
+ { className: 'hp-dash' },
1336
+ h(
1337
+ 'section',
1338
+ { className: 'hp-mchart-panel' },
1339
+ h(
1340
+ 'div',
1341
+ { className: 'hp-mchart-head' },
1342
+ h('div', { className: 'hp-mchart-title' }, '本期涨幅走势对比'),
1343
+ // 不再放「总星数 / 本期涨幅」切换:上面的「日/周/月」已经决定了周期,
1344
+ // 这里再让人选一次量纲就是重复决策。统一看「相对起点涨了多少」——
1345
+ // 它才是「谁这段涨得猛」的可比量(总星数比的是历史体量,与周期无关)。
1346
+ h('div', { className: 'hp-mchart-hint' }, '纵轴:本期涨幅(对数)'),
1347
+ ),
1348
+ chartNode,
1349
+ h(
1350
+ 'div',
1351
+ { className: 'hp-mchart-note' },
1352
+ '纵轴为对数刻度(跨度大时也能看清各自走势);所有曲线以同期为基准起算,中途才起来的项目从它出现那天开始画。',
1353
+ ),
1354
+ ),
1355
+ ready ? picker : null,
1356
+ );
1357
+ }
1358
+
1359
+ /* ============================================================ 主面板 */
1360
+
1361
+ function factory(require) {
1362
+ const React = require('react');
1363
+ const h = React.createElement;
1364
+
1365
+ function Panel() {
1366
+ const [view, setView] = React.useState('cards'); // cards | dash
1367
+ const [period, setPeriod] = React.useState('day');
1368
+ const [boards, setBoards] = React.useState(null);
1369
+ const [loading, setLoading] = React.useState(true);
1370
+ const [error, setError] = React.useState(null);
1371
+ const [selected, setSelected] = React.useState(null);
1372
+ const [detail, setDetail] = React.useState(null);
1373
+ const [detailLoading, setDetailLoading] = React.useState(false);
1374
+ const [detailError, setDetailError] = React.useState(null);
1375
+ const [theme, setTheme] = React.useState(readTheme);
1376
+ const [lang, setLang] = React.useState(readLang);
1377
+ const [lastFetch, setLastFetch] = React.useState(null);
1378
+ /** { [repository]: { desc, chart } } —— 列表页卡片的简介与迷你走势 */
1379
+ const [details, setDetails] = React.useState(readDetailsCache);
1380
+
1381
+ const loadBoards = React.useCallback(async (silent) => {
1382
+ if (!silent) setLoading(true);
1383
+ setError(null);
1384
+ try {
1385
+ const body = await getJson('/boards');
1386
+ setBoards(body.boards || []);
1387
+ setLastFetch(body.fetchedAt || null);
1388
+ } catch (e) {
1389
+ setError(String(e && e.message ? e.message : e));
1390
+ } finally {
1391
+ setLoading(false);
1392
+ }
1393
+ }, []);
1394
+
1395
+ /**
1396
+ * 给当前可见的卡片批量补详情(简介 + 曲线),只取**还没有**的那些。
1397
+ * 宿主侧串行取、单条失败不影响其它;这里失败就静默跳过,卡片自动退回「无简介」形态。
1398
+ */
1399
+ /**
1400
+ * 详情加载:**缓存优先,后台更新**(stale-while-revalidate)。
1401
+ *
1402
+ * 加载行为分两步:
1403
+ * ① 先从本地缓存把已有的(英文 + 中文)直接摆上屏 —— 不闪骨架、不等网络;
1404
+ * ② 然后**不管有没有缓存**都去后台问一次,拿回新描述就更新。
1405
+ *
1406
+ * 注意:不能「有缓存就直接返回」,否则缓存永远不刷新,
1407
+ * 源站更新了描述也看不到。有缓存时同样需要发起更新请求。
1408
+ *
1409
+ * 并发控制:同一批 repo 在同一时刻只发一次请求(避免 effect 抖动打爆)。
1410
+ */
1411
+ /**
1412
+ * 分批加载详情 —— **先给前几屏,再补后面的**。
1413
+ *
1414
+ * 之前是一次性请求 20 个项目的简介+曲线+头像,再等翻译,
1415
+ * 结果首屏要等很久才出现内容。现在拆成 6 个一批:
1416
+ * · 第 1 批(1~6)回来就立刻渲染,用户马上看到东西;
1417
+ * · 后台接着请求 7~12、13~18、19~20,每批回来就补上。
1418
+ *
1419
+ * 关键:`known` 只用来判断「已经有的不用重复翻」,**不影响要不要发请求** ——
1420
+ * 有缓存也照样刷新(stale-while-revalidate)。
1421
+ */
1422
+ /**
1423
+ * 统一补中文译文:只翻还没有译文的那几条。
1424
+ * 单独抽出来,是因为它要在「所有数据批次取完」之后才跑,
1425
+ * 而且失败不该影响已显示的内容。
1426
+ */
1427
+ const loadTranslations = React.useCallback(async (needZh, gen) => {
1428
+ if (!needZh || needZh.length === 0) return;
1429
+ try {
1430
+ const tr = await getJson('/translate?repos=' + needZh.map(encodeURIComponent).join(',') + '&limit=20');
1431
+ if (generationRef.current !== gen) return;
1432
+ const zhMap = {};
1433
+ for (const it of tr.items || []) {
1434
+ if (it.descZh) zhMap[it.repository] = it.descZh;
1435
+ }
1436
+ if (Object.keys(zhMap).length > 0) {
1437
+ setDetails((prev) => {
1438
+ const merged = { ...prev };
1439
+ for (const [repo, descZh] of Object.entries(zhMap)) {
1440
+ if (merged[repo]) merged[repo] = { ...merged[repo], descZh };
1441
+ }
1442
+ return merged;
1443
+ });
1444
+ }
1445
+ } catch (e) {
1446
+ /* 翻译失败:留在英文原文 */
1447
+ }
1448
+ }, []);
1449
+
1450
+ const inflightRef = React.useRef(new Set());
1451
+ const generationRef = React.useRef(0);
1452
+ const CHUNK = 6; // 每批条数:一屏 3 行卡片,够快看到结果
1453
+
1454
+ const loadDetails = React.useCallback(async (repos, known, wantLang) => {
1455
+ if (!repos || repos.length === 0) return;
1456
+ const list = repos.slice(0, 20);
1457
+ const gen = generationRef.current;
1458
+
1459
+ // 把 20 个切成若干 6 个一批
1460
+ const chunks = [];
1461
+ for (let i = 0; i < list.length; i += CHUNK) chunks.push(list.slice(i, i + CHUNK));
1462
+
1463
+ for (let ci = 0; ci < chunks.length; ci += 1) {
1464
+ // 每批开始前检查代际:用户中途换了榜单/语言,剩下的批次直接放弃
1465
+ if (generationRef.current !== gen) return;
1466
+ const chunk = chunks[ci];
1467
+ const batchKey = wantLang + '|' + chunk.join(',');
1468
+ if (inflightRef.current.has(batchKey)) continue;
1469
+ inflightRef.current.add(batchKey);
1470
+ try {
1471
+ const q = chunk.map(encodeURIComponent).join(',');
1472
+ const noZh = wantLang === 'en' ? '&translate=0' : '';
1473
+ const body = await getJson('/batch?repos=' + q + '&limit=' + chunk.length + noZh);
1474
+ if (generationRef.current !== gen) return;
1475
+
1476
+ // 这一批回来就立刻合并 → 界面马上多出 6 张有内容的卡
1477
+ const next = {};
1478
+ for (const it of body.items || []) {
1479
+ next[it.repository] = {
1480
+ desc: it.desc,
1481
+ descZh: it.descZh,
1482
+ chart: it.chart,
1483
+ descSource: it.descSource,
1484
+ avatar: it.avatar || null,
1485
+ settled: true,
1486
+ };
1487
+ }
1488
+ setDetails((prev) => {
1489
+ const merged = { ...prev };
1490
+ for (const [repo, fresh] of Object.entries(next)) {
1491
+ const old = prev[repo];
1492
+ const keepOld = !fresh.desc && old && old.desc;
1493
+ merged[repo] = keepOld
1494
+ ? { ...old, chart: fresh.chart || old.chart, avatar: fresh.avatar || old.avatar, settled: true }
1495
+ : { ...fresh, descZh: fresh.descZh || (old && old.descZh) || null };
1496
+ }
1497
+ return merged;
1498
+ });
1499
+ } catch (e) {
1500
+ if (generationRef.current !== gen) return;
1501
+ // 这一批失败:把还没取过的标成 settled,避免永远转圈
1502
+ const fallback = {};
1503
+ for (const r of chunk) {
1504
+ if (!known[r]) fallback[r] = { desc: null, descZh: null, chart: null, settled: true };
1505
+ }
1506
+ if (Object.keys(fallback).length > 0) {
1507
+ setDetails((prev) => ({ ...fallback, ...prev }));
1508
+ }
1509
+ } finally {
1510
+ inflightRef.current.delete(batchKey);
1511
+ }
1512
+ }
1513
+
1514
+ /**
1515
+ * 全部批次取完后,补一次中文。
1516
+ *
1517
+ * 这里必须**从 known 快照算需要翻译的条目**,不能在 setDetails 的
1518
+ * 回调里做副作用 —— 那样在 React 严格模式下会执行两次,
1519
+ * 而且 setter 应当保持纯函数。
1520
+ */
1521
+ if (wantLang === 'zh' && generationRef.current === gen) {
1522
+ const needZh = list.filter((r) => {
1523
+ const entry = known[r];
1524
+ // 有简介、但还没有译文 → 需要翻
1525
+ return entry && entry.desc && !entry.descZh;
1526
+ });
1527
+ if (needZh.length > 0) void loadTranslations(needZh, gen);
1528
+ }
1529
+ }, [loadTranslations]);
1530
+
1531
+ React.useEffect(() => {
1532
+ void loadBoards(false);
1533
+ // 数据源每天只更新一次,所以不做分钟级轮询;只在**跨天**时自动静默刷新一次。
1534
+ // 判断方式:记住首次加载那天的日期,页面开着跨过零点才重新取。
1535
+ const openedDay = localDayKey();
1536
+ const timer = setInterval(() => {
1537
+ if (localDayKey() !== openedDay) {
1538
+ clearInterval(timer);
1539
+ void loadBoards(true);
1540
+ }
1541
+ }, 60 * 1000);
1542
+ return () => clearInterval(timer);
1543
+ }, [loadBoards]);
1544
+
1545
+ const board = React.useMemo(() => {
1546
+ if (!boards) return null;
1547
+ return boards.find((b) => b.id === period) || boards[0] || null;
1548
+ }, [boards, period]);
1549
+
1550
+ const openDetail = React.useCallback(async (item) => {
1551
+ setSelected(item);
1552
+ setDetail(null);
1553
+ setDetailError(null);
1554
+ setDetailLoading(true);
1555
+ try {
1556
+ const body = await getJson('/detail?repo=' + encodeURIComponent(item.repository));
1557
+ setDetail(body.detail || null);
1558
+ } catch (e) {
1559
+ setDetailError(String(e && e.message ? e.message : e));
1560
+ } finally {
1561
+ setDetailLoading(false);
1562
+ }
1563
+ }, []);
1564
+
1565
+ const rows = board && Array.isArray(board.rows) ? board.rows : [];
1566
+ const maxGain = React.useMemo(() => rows.reduce((m, r) => Math.max(m, r.starsGained || 0), 0), [rows]);
1567
+ /** 只看前 20 —— 再多就是噪音,所以这里硬截断。 */
1568
+ const visible = React.useMemo(() => rows.slice(0, MAX_ITEMS), [rows]);
1569
+ const top20Gain = React.useMemo(() => visible.reduce((sum, r) => sum + (r.starsGained || 0), 0), [visible]);
1570
+ /** { repo: chart } —— 只挑出真正有曲线的那些,图表组件不认识 desc 之类 */
1571
+ const chartMap = React.useMemo(() => {
1572
+ const out = {};
1573
+ for (const [repo, entry] of Object.entries(details || {})) {
1574
+ if (entry && entry.chart && Array.isArray(entry.chart.series)) out[repo] = entry.chart;
1575
+ }
1576
+ return out;
1577
+ }, [details]);
1578
+
1579
+ /**
1580
+ * 可见项目变了(换榜单/换周期)或语言变了 → 去取一次详情。
1581
+ *
1582
+ * ⚠️ 这里**只依赖项目列表与语言**,不依赖 details —— 否则
1583
+ * 「取回 → setDetails → 触发 effect → 又取回」会转圈。
1584
+ * 是否有缓存由 loadDetails 内部判断(有缓存也照样更新,见它的注释)。
1585
+ */
1586
+ const repoListKey = React.useMemo(() => visible.map((r) => r.repository).join('|'), [visible]);
1587
+
1588
+ // details 的最新值放 ref 里:effect 要读它判断「哪些还没有」,
1589
+ // 但不能把它写进依赖(否则取回→setDetails→再取回 会转圈)。
1590
+ const detailsRef = React.useRef(details);
1591
+ React.useEffect(() => { detailsRef.current = details; }, [details]);
1592
+
1593
+ React.useEffect(() => {
1594
+ if (!repoListKey) return;
1595
+ // 每次换榜单/语言:代际 +1,让在途的旧请求作废
1596
+ generationRef.current += 1;
1597
+ void loadDetails(repoListKey.split('|'), detailsRef.current, lang);
1598
+ }, [repoListKey, lang, loadDetails]);
1599
+
1600
+ // 详情变化就落本地缓存(节流交给浏览器,量很小)
1601
+ React.useEffect(() => {
1602
+ if (Object.keys(details).length > 0) writeDetailsCache(details);
1603
+ }, [details]);
1604
+
1605
+ /** 切换语言:换偏好、清掉已缓存的详情(否则拿的还是旧语言那批),再重取一次。 */
1606
+ /**
1607
+ * 切换语言。
1608
+ *
1609
+ * ⚠️ 这里**不能清空 details**:清空会让所有卡片回到骨架并重新请求,
1610
+ * 表现为切换瞬间内容全消失。
1611
+ *
1612
+ * 正确做法:保留已有内容(英文/中文都在 details 里),只改变「显示哪一份」,
1613
+ * 需要译文时由 effect 触发补译。内容不清空 → 不闪。
1614
+ */
1615
+ const toggleLang = React.useCallback(() => {
1616
+ setLang((cur) => {
1617
+ const next = cur === 'zh' ? 'en' : 'zh';
1618
+ writeLang(next);
1619
+ return next;
1620
+ });
1621
+ }, []);
1622
+
1623
+ /**
1624
+ * 切到「中文」时,立刻给已有内容补译文 —— **不等重新取数**。
1625
+ *
1626
+ * 为什么单独做:切语言时数据已经在了(英文简介在 details 里),
1627
+ * 如果等 loadDetails 重新跑一遍批次才去翻译,会绕一大圈还可能被代际作废。
1628
+ * 这里直接看「哪些有英文没中文」,发一次翻译请求就够了。
1629
+ */
1630
+ React.useEffect(() => {
1631
+ if (lang !== 'zh') return;
1632
+ const gen = generationRef.current;
1633
+ const need = [];
1634
+ for (const [repo, entry] of Object.entries(detailsRef.current || {})) {
1635
+ if (entry && entry.desc && !entry.descZh) need.push(repo);
1636
+ }
1637
+ if (need.length > 0) void loadTranslations(need.slice(0, 20), gen);
1638
+ }, [lang, loadTranslations]);
1639
+
1640
+ const toggleTheme = React.useCallback(() => {
1641
+ setTheme((cur) => {
1642
+ const next = cur === 'paper' ? 'dark' : 'paper';
1643
+ writeTheme(next);
1644
+ return next;
1645
+ });
1646
+ }, []);
1647
+
1648
+ const periodLabel = board ? board.label : '—';
1649
+ const dataOk = board && board.ok;
1650
+ const dataStale = board && !board.ok && board.staleAt;
1651
+
1652
+ const header = h(
1653
+ 'div',
1654
+ { className: 'hp-head' },
1655
+ h('div', { className: 'hp-brand' },
1656
+ h('span', { className: 'hp-brand-icon', dangerouslySetInnerHTML: { __html: BRAND_ICON_SVG } }),
1657
+ 'GitHub Hot',
1658
+ ),
1659
+ h('div', { className: 'hp-spacer' }),
1660
+ // 右侧操作区:周期切换 → 视图切换 → 主题 → 刷新
1661
+ h(
1662
+ 'div',
1663
+ { className: 'hp-actions' },
1664
+ h(
1665
+ 'div',
1666
+ { className: 'hp-seg' },
1667
+ h('button', { className: period === 'day' ? 'on' : '', onClick: () => setPeriod('day') }, '日榜'),
1668
+ h('button', { className: period === 'week' ? 'on' : '', onClick: () => setPeriod('week') }, '周榜'),
1669
+ h('button', { className: period === 'month' ? 'on' : '', onClick: () => setPeriod('month') }, '月榜'),
1670
+ ),
1671
+ h(
1672
+ 'div',
1673
+ { className: 'hp-seg' },
1674
+ h('button', { className: view === 'cards' ? 'on' : '', onClick: () => setView('cards'), title: '按名次看每个项目' }, '榜单'),
1675
+ h('button', { className: view === 'dash' ? 'on' : '', onClick: () => setView('dash'), title: '汇总统计:多少仓库、多少星、什么语言' }, '统计'),
1676
+ ),
1677
+ ),
1678
+ // 一句话说清「数据是哪天的」。不再罗列取回时刻与起止日期 —— 那对读的人没有意义。
1679
+ h(
1680
+ 'div',
1681
+ { className: 'hp-meta' },
1682
+ h(
1683
+ 'span',
1684
+ { title: lastFetch ? '本机取回于 ' + fmtTime(lastFetch) : '' },
1685
+ h('span', { className: 'hp-dot ' + (dataOk ? 'ok' : dataStale ? 'warn' : 'bad') }),
1686
+ dataOk
1687
+ ? '数据更新到 ' + dataDateText(board)
1688
+ : dataStale
1689
+ ? '暂未取到 · 显示 ' + ago(board.staleAt) + ' 的数据'
1690
+ : '暂未取到数据',
1691
+ ),
1692
+ ),
1693
+ h(
1694
+ 'div',
1695
+ { className: 'hp-actions' },
1696
+ h(
1697
+ 'button',
1698
+ {
1699
+ className: 'hp-btn hp-lang-btn',
1700
+ onClick: toggleLang,
1701
+ title: lang === 'zh' ? '当前显示中文(等翻译完成)。点击切到英文,加载更快' : '当前显示英文原文。点击切回中文',
1702
+ },
1703
+ lang === 'zh' ? '中' : 'EN',
1704
+ ),
1705
+ h(
1706
+ 'button',
1707
+ { className: 'hp-btn hp-theme-btn', onClick: toggleTheme, title: theme === 'paper' ? '切到深色(融入界面)' : '切到纸感贴纸风' },
1708
+ theme === 'paper' ? '◐ 深色' : '◑ 纸感',
1709
+ ),
1710
+ h('button', { className: 'hp-btn', onClick: () => void loadBoards(false), disabled: loading }, loading ? '刷新中…' : '刷新'),
1711
+ ),
1712
+ );
1713
+
1714
+ let body;
1715
+ if (loading && !boards) {
1716
+ body = h(
1717
+ 'div',
1718
+ { className: 'hp-cards' },
1719
+ Array.from({ length: 6 }, (_, i) => h('div', { className: 'hp-card-loading', key: i })),
1720
+ );
1721
+ } else if (error && !boards) {
1722
+ body = h(
1723
+ 'div',
1724
+ { className: 'hp-empty' },
1725
+ h('strong', null, '取数失败'),
1726
+ h('div', null, error),
1727
+ h('div', null, '检查网络连接,然后点右上角「刷新」。'),
1728
+ );
1729
+ } else if (!dataOk && rows.length === 0) {
1730
+ body = h(
1731
+ 'div',
1732
+ { className: 'hp-empty' },
1733
+ h('strong', null, periodLabel + ' 暂时拿不到'),
1734
+ h('div', null, board && board.error ? String(board.error) : '未知原因'),
1735
+ h('div', null, '数据每天更新一次,稍后重试即可。'),
1736
+ );
1737
+ } else if (view === 'dash') {
1738
+ // 统计只看展示的那一批,与榜单页口径一致。
1739
+ // details 是 { repo: { desc, chart } },这里抽成 { repo: chart } 再传 —— 图表组件只认曲线。
1740
+ body = h(Dashboard, { React, rows: visible, charts: chartMap, lang, periodKey: period });
1741
+ } else {
1742
+ body = h(
1743
+ 'div',
1744
+ null,
1745
+ // ── 首屏唯一主角:本期合计涨星(先给答案,再给证据)──
1746
+ h(
1747
+ 'div',
1748
+ { className: 'hp-hero' },
1749
+ h(
1750
+ 'div',
1751
+ { className: 'hp-hero-main' },
1752
+ h('div', { className: 'hp-hero-k' }, periodLabel + ' · TOP ' + Math.min(MAX_ITEMS, rows.length) + ' 合计涨星'),
1753
+ h('div', { className: 'hp-hero-v' }, '+' + fmtCount(top20Gain)),
1754
+ h(
1755
+ 'div',
1756
+ { className: 'hp-hero-sub' },
1757
+ dataOk && rows.length > 0
1758
+ ? '最高 ' + rows[0].repository.replace('/', ' / ') + ' 单项涨 ' + fmtCount(rows[0].starsGained) + ' 星'
1759
+ : '暂无数据',
1760
+ ),
1761
+ ),
1762
+ h(
1763
+ 'div',
1764
+ { className: 'hp-hero-side' },
1765
+ h('div', { className: 'hp-stat-card rose' }, h('div', { className: 'hp-stat-k' }, 'TOP20 平均'), h('div', { className: 'hp-stat-v' }, '+' + fmtCount(Math.round(top20Gain / (visible.length || 1))))),
1766
+ h('div', { className: 'hp-stat-card sky' }, h('div', { className: 'hp-stat-k' }, '最高单项'), h('div', { className: 'hp-stat-v' }, visible.length ? '+' + fmtCount(visible[0].starsGained) : '—')),
1767
+ h('div', { className: 'hp-stat-card sun' }, h('div', { className: 'hp-stat-k' }, '展示条数'), h('div', { className: 'hp-stat-v' }, visible.length + ' 个')),
1768
+ ),
1769
+ ),
1770
+ h(
1771
+ 'div',
1772
+ { className: 'hp-cards' },
1773
+ visible.map((item) =>
1774
+ h(Card, {
1775
+ key: item.repository + ':' + item.rank,
1776
+ React,
1777
+ lang,
1778
+ detail: details[item.repository] || null,
1779
+ item,
1780
+ maxGain,
1781
+ isSel: selected && selected.repository === item.repository,
1782
+ onSelect: (it) => void openDetail(it),
1783
+ }),
1784
+ ),
1785
+ ),
1786
+ );
1787
+ }
1788
+
1789
+ return h(
1790
+ 'div',
1791
+ { className: 'hp-root hp-' + theme },
1792
+ header,
1793
+ dataStale ? h('div', { className: 'hp-banner warn' }, '本次取数失败(' + board.error + '),显示的是 ' + ago(board.staleAt) + ' 的数据。') : null,
1794
+ selected ? h(Detail, { React, item: selected, detail, loading: detailLoading, error: detailError, onClose: () => setSelected(null) }) : null,
1795
+ h('div', { className: 'hp-scroll' }, body),
1796
+ /**
1797
+ * 数据署名 —— **不是装饰,是法律要求**。
1798
+ *
1799
+ * 榜单数据来自 Hysen Labs,按 CC BY 4.0 授权:可以免费用,但必须
1800
+ * · credit(署名):写出 Hysen Labs
1801
+ * · link(链接):链到来源页
1802
+ * 去掉这行就是侵权。放在底部小字,不影响主视觉。
1803
+ */
1804
+ h(
1805
+ 'footer',
1806
+ { className: 'hp-attr', title: '榜单数据来自 Hysen Labs,按 CC BY 4.0 许可使用' },
1807
+ h('a', { href: 'https://hysenlabs.com', target: '_blank', rel: 'noreferrer' }, 'Hysen Labs'),
1808
+ h('span', { className: 'hp-attr-dot' }, '·'),
1809
+ 'CC BY 4.0',
1810
+ ),
1811
+ selected
1812
+ ? h('div', { className: 'hp-scroll', style: { flex: '0 0 auto', maxHeight: 0 } })
1813
+ : null,
1814
+ );
1815
+ }
1816
+
1817
+ return {
1818
+ name: PLUGIN_NAME,
1819
+ inject: ['slots'],
1820
+ apply(ctx) {
1821
+ try {
1822
+ ensureCss(document);
1823
+ } catch (e) {
1824
+ console.warn('[dsh-github-hot] 样式注入失败:' + (e instanceof Error ? e.message : String(e)));
1825
+ }
1826
+ ctx.slots.inject('main', function* () {
1827
+ yield ctx.slots.register({ name: 'main', key: PLUGIN_NAME, order: 45 }, Panel);
1828
+ });
1829
+ ctx.slots.inject('sidebar.panellist', function* () {
1830
+ yield ctx.slots.register(
1831
+ { name: 'sidebar.panellist', id: PLUGIN_NAME, order: 45, label: () => 'GitHub Hot' },
1832
+ function SidebarIcon() {
1833
+ // 用内联 SVG 线条图标,而不是 emoji —— 侧栏里其它插件都是线条图标,
1834
+ // 一个彩色 emoji 摆进去会显眼得突兀。
1835
+ // 三条并行上升线:与「GitHub 热榜 / GitHub 趋势」的单折线同风格,但图形不同。
1836
+ return h('span', {
1837
+ className: 'hp-side-icon',
1838
+ title: 'GitHub Hot · 多项目涨星走势对比',
1839
+ dangerouslySetInnerHTML: { __html: SIDEBAR_ICON_SVG },
1840
+ });
1841
+ },
1842
+ );
1843
+ });
1844
+ },
1845
+ };
1846
+ }
1847
+
1848
+ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__ModuleLoader__.load === 'function') {
1849
+ window.__ModuleLoader__.load({ id: PLUGIN_NAME, factory });
1850
+ }
1851
+
1852
+ // 仅供测试:把这些纯渲染组件挂在全局名下一个不冲突的键上,
1853
+ // 测试脚本可以直接对**真实组件**做同构渲染断言,而不是复制一份等价逻辑去测。
1854
+ if (typeof globalThis !== 'undefined') {
1855
+ globalThis.__DSH_HYSEN_PULSE_TEST__ = { StarChart, Card, Detail, Dashboard, fmtCount, fmtPct, gainTone };
1856
+ }
1857
+ })();