dsh-github-hot 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/lib/client.js +988 -182
- package/lib/index.js +389 -45
- package/package.json +7 -2
package/lib/client.js
CHANGED
|
@@ -47,6 +47,18 @@
|
|
|
47
47
|
|
|
48
48
|
/** 侧栏图标(走文字色,深浅主题自动适配)。 */
|
|
49
49
|
const SIDEBAR_ICON_SVG = LINE_ICON(18);
|
|
50
|
+
|
|
51
|
+
/** 关闭图标(×):设置面板右上角。 */
|
|
52
|
+
const CLOSE_ICON_SVG =
|
|
53
|
+
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
|
|
54
|
+
'<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>';
|
|
55
|
+
|
|
56
|
+
/** 齿轮图标:设置按钮用,走 currentColor 适配深浅主题。 */
|
|
57
|
+
const GEAR_ICON_SVG =
|
|
58
|
+
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
|
|
59
|
+
'<circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="2"/>' +
|
|
60
|
+
'<path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.7 5.3l-1.6 1.6M6.9 17.1l-1.6 1.6M18.7 18.7l-1.6-1.6M6.9 6.9L5.3 5.3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>' +
|
|
61
|
+
'</svg>';
|
|
50
62
|
/** 面板标题图标,稍大一点。 */
|
|
51
63
|
const BRAND_ICON_SVG = LINE_ICON(18);
|
|
52
64
|
|
|
@@ -86,6 +98,272 @@
|
|
|
86
98
|
try { localStorage.setItem(LANG_KEY, v); } catch (e) { /* 忽略 */ }
|
|
87
99
|
}
|
|
88
100
|
|
|
101
|
+
/* ---------------------------------------------------------------- 界面文案国际化 */
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 界面文案表。
|
|
105
|
+
*
|
|
106
|
+
* 为什么集中在这里:切换语言时**整个界面**都要跟着变(不只是简介),
|
|
107
|
+
* 散落在各组件里的中文字面量没法统一翻译。这里用键值对,组件通过 t() 取。
|
|
108
|
+
*
|
|
109
|
+
* 约定:
|
|
110
|
+
* · 键名用语义化的英文(title.summary 这种),不要用中文当键;
|
|
111
|
+
* · 占位符用 {n} 形式,通过 t('key', { n: 42 }) 填;
|
|
112
|
+
* · 中文/英文都必须写全,缺一个就会 fallback 到中文。
|
|
113
|
+
*/
|
|
114
|
+
const COPY = {
|
|
115
|
+
zh: {
|
|
116
|
+
'brand.title': 'GitHub Hot',
|
|
117
|
+
'brand.subtitle': '多项目涨星走势对比',
|
|
118
|
+
'period.day': '日榜',
|
|
119
|
+
'period.week': '周榜',
|
|
120
|
+
'period.month': '月榜',
|
|
121
|
+
'view.board': '榜单',
|
|
122
|
+
'view.stats': '统计',
|
|
123
|
+
'lang.zh': '中',
|
|
124
|
+
'lang.en': 'EN',
|
|
125
|
+
'lang.tipZh': '当前显示中文。点击切到英文',
|
|
126
|
+
'lang.tipEn': '当前显示英文。点击切回中文',
|
|
127
|
+
'theme.dark': '深色',
|
|
128
|
+
'theme.paper': '纸感',
|
|
129
|
+
'theme.tipDark': '切到深色(融入界面)',
|
|
130
|
+
'theme.tipPaper': '切到纸感贴纸风',
|
|
131
|
+
'action.refresh': '刷新',
|
|
132
|
+
'action.refreshing': '刷新中…',
|
|
133
|
+
'action.close': '关闭',
|
|
134
|
+
'status.updatedTo': '数据更新到 {date}',
|
|
135
|
+
'status.today': '今日',
|
|
136
|
+
'status.justNow': '刚刚',
|
|
137
|
+
'status.minutesAgo': '{n} 分钟前',
|
|
138
|
+
'status.hoursAgo': '{n} 小时前',
|
|
139
|
+
'status.daysAgo': '{n} 天前',
|
|
140
|
+
'board.heroLabel': '{period} · TOP20 合计涨星',
|
|
141
|
+
'board.heroHint': '最高 {repo} 单项涨 {gain} 星',
|
|
142
|
+
'board.statAvg': 'TOP20 平均',
|
|
143
|
+
'board.statTop': '最高单项',
|
|
144
|
+
'board.statCount': '展示条数',
|
|
145
|
+
'board.countUnit': '{n} 个',
|
|
146
|
+
'board.topGain': '本期最猛',
|
|
147
|
+
'detail.introTitle': '项目简介',
|
|
148
|
+
'detail.chartTitle': '星数曲线',
|
|
149
|
+
'detail.loading': '加载中…',
|
|
150
|
+
'detail.loadingIntro': '正在读取并翻译中…',
|
|
151
|
+
'detail.noIntro': '暂无简介',
|
|
152
|
+
'detail.noChart': '暂无星数曲线数据',
|
|
153
|
+
'detail.chartError': '曲线读取失败:{msg}',
|
|
154
|
+
'detail.readError': '读取失败:{msg}',
|
|
155
|
+
'detail.sourceConclusion': '编辑结论',
|
|
156
|
+
'detail.sourceOverview': '项目概览',
|
|
157
|
+
'detail.sourceCredit': ' · 引用自 ',
|
|
158
|
+
'detail.sourceLicense': '(CC BY 4.0)',
|
|
159
|
+
'detail.translatedBy': ' · 中译由 DSH 模型生成,英文原文见下方',
|
|
160
|
+
'detail.analysisUpdated': '分析更新于 {date}',
|
|
161
|
+
'detail.kpiGained': '期间涨星',
|
|
162
|
+
'detail.kpiStart': '起点星数',
|
|
163
|
+
'detail.kpiEnd': '最新星数',
|
|
164
|
+
'detail.chartStart': '起点 {n}',
|
|
165
|
+
'detail.chartNow': '现在 {n}',
|
|
166
|
+
'detail.linkGithub': '↗ GitHub',
|
|
167
|
+
'detail.linkSource': '↗ hysenlabs',
|
|
168
|
+
'card.translating': '译',
|
|
169
|
+
'card.translatingTip': '中文翻译生成中',
|
|
170
|
+
'stats.pickerTitle': '选择项目',
|
|
171
|
+
'stats.pickTop': '前 {n}',
|
|
172
|
+
'stats.pickAll': '全选',
|
|
173
|
+
'stats.pickClear': '清空',
|
|
174
|
+
'stats.chartTitle': '本期涨幅走势对比',
|
|
175
|
+
'stats.axisHint': '纵轴:采样涨星(对称对数)',
|
|
176
|
+
'stats.linearAxis': '纵轴:采样涨星(线性 · 自动)',
|
|
177
|
+
'stats.gainTitle': '本期涨星对比',
|
|
178
|
+
'stats.gainHint': '榜单原始涨星数 · 横条从零起算',
|
|
179
|
+
'stats.sampleNote': '曲线由来源 SVG 还原,仅供趋势参考;各线从期内首个可用采样日起算,起始日见图例。精确涨星以上方榜单为准。坐标轴按当前所选项目自适应:同量级用线性,末端涨幅相差超过 10 倍用对称对数;头像引导线连接真实终点。',
|
|
180
|
+
'stats.unknownWindow': '未获得榜单日期范围,以下展示历史采样曲线。',
|
|
181
|
+
'stats.showAll': '展开全部 {n} 项',
|
|
182
|
+
'stats.showLess': '收起',
|
|
183
|
+
'stats.dailyTitle': '日涨星对比',
|
|
184
|
+
'stats.dailyAxis': '纵轴:当日涨星数(线性)',
|
|
185
|
+
'stats.dailyNote': '每个头像代表一个项目,位置越高,当日涨星越多;横轴按榜单名次排列。悬停或聚焦头像可查看项目及精确涨星数。',
|
|
186
|
+
'stats.emptyHint': '勾选左侧项目后这里会画出它们的走势对比',
|
|
187
|
+
'error.title': '取数失败',
|
|
188
|
+
'error.hint': '检查网络连接,然后点右上角「刷新」。',
|
|
189
|
+
'error.dailyHint': '数据每天更新一次,稍后重试即可。',
|
|
190
|
+
'error.stale': '本次取数失败({err}),显示的是 {when} 的数据。',
|
|
191
|
+
'empty.noData': '暂无数据',
|
|
192
|
+
'empty.notFetched': '暂未取到数据',
|
|
193
|
+
'empty.partial': '暂未取到 · 显示 {n} 条',
|
|
194
|
+
'misc.stars': '星',
|
|
195
|
+
'misc.gainUnit': '涨',
|
|
196
|
+
'misc.topByRank': '按名次看每个项目',
|
|
197
|
+
'attribution': '',
|
|
198
|
+
'glance.translating': '正在努力翻译成中文…',
|
|
199
|
+
'glance.title': '一眼看懂',
|
|
200
|
+
'glance.what': '它是什么?',
|
|
201
|
+
'glance.who': '适合谁用?',
|
|
202
|
+
'glance.commercial': '可以商用吗?',
|
|
203
|
+
'glance.maintained': '还在维护吗?',
|
|
204
|
+
'glance.writtenIn': '用什么写的?',
|
|
205
|
+
'glance.footer': '内容来自项目公开数据与编辑分析,不构成法律意见。',
|
|
206
|
+
'settings.title': '设置',
|
|
207
|
+
'settings.tip': '插件设置',
|
|
208
|
+
'settings.version': '版本信息',
|
|
209
|
+
'settings.current': '当前版本',
|
|
210
|
+
'settings.latest': '最新版本',
|
|
211
|
+
'settings.checking': '检查中…',
|
|
212
|
+
'settings.upToDate': '已是最新',
|
|
213
|
+
'settings.localAhead': '本地为开发版(npm 上最新为 v{v})',
|
|
214
|
+
'settings.hasUpdate': '有新版本',
|
|
215
|
+
'settings.updateTip': '有新版本 v{v} 可用',
|
|
216
|
+
'settings.updateHint': '升级命令:',
|
|
217
|
+
'settings.checkNow': '立即检查',
|
|
218
|
+
'settings.checkFailed': '检查失败:{msg}',
|
|
219
|
+
'settings.noNetwork': '暂时无法连接 npm',
|
|
220
|
+
'settings.close': '关闭',
|
|
221
|
+
'settings.repo': '项目主页',
|
|
222
|
+
},
|
|
223
|
+
en: {
|
|
224
|
+
'brand.title': 'GitHub Hot',
|
|
225
|
+
'brand.subtitle': 'Multi-project star growth',
|
|
226
|
+
'period.day': 'Daily',
|
|
227
|
+
'period.week': 'Weekly',
|
|
228
|
+
'period.month': 'Monthly',
|
|
229
|
+
'view.board': 'Ranking',
|
|
230
|
+
'view.stats': 'Stats',
|
|
231
|
+
'lang.zh': '中',
|
|
232
|
+
'lang.en': 'EN',
|
|
233
|
+
'lang.tipZh': 'Showing Chinese. Click for English',
|
|
234
|
+
'lang.tipEn': 'Showing English. Click for Chinese',
|
|
235
|
+
'theme.dark': 'Dark',
|
|
236
|
+
'theme.paper': 'Paper',
|
|
237
|
+
'theme.tipDark': 'Switch to dark theme',
|
|
238
|
+
'theme.tipPaper': 'Switch to paper sticker theme',
|
|
239
|
+
'action.refresh': 'Refresh',
|
|
240
|
+
'action.refreshing': 'Refreshing…',
|
|
241
|
+
'action.close': 'Close',
|
|
242
|
+
'status.updatedTo': 'Updated {date}',
|
|
243
|
+
'status.today': 'Today',
|
|
244
|
+
'status.justNow': 'just now',
|
|
245
|
+
'status.minutesAgo': '{n} min ago',
|
|
246
|
+
'status.hoursAgo': '{n} h ago',
|
|
247
|
+
'status.daysAgo': '{n} d ago',
|
|
248
|
+
'board.heroLabel': '{period} · TOP20 total gain',
|
|
249
|
+
'board.heroHint': 'Top: {repo} with {gain} stars',
|
|
250
|
+
'board.statAvg': 'TOP20 avg',
|
|
251
|
+
'board.statTop': 'Top gainer',
|
|
252
|
+
'board.statCount': 'Showing',
|
|
253
|
+
'board.countUnit': '{n}',
|
|
254
|
+
'board.topGain': 'Top gainer',
|
|
255
|
+
'detail.introTitle': 'About',
|
|
256
|
+
'detail.chartTitle': 'Star history',
|
|
257
|
+
'detail.loading': 'Loading…',
|
|
258
|
+
'detail.loadingIntro': 'Fetching and translating…',
|
|
259
|
+
'detail.noIntro': 'No description yet',
|
|
260
|
+
'detail.noChart': 'No star history available',
|
|
261
|
+
'detail.chartError': 'Failed to load chart: {msg}',
|
|
262
|
+
'detail.readError': 'Failed to load: {msg}',
|
|
263
|
+
'detail.sourceConclusion': 'Editorial take',
|
|
264
|
+
'detail.sourceOverview': 'Project overview',
|
|
265
|
+
'detail.sourceCredit': ' · via ',
|
|
266
|
+
'detail.sourceLicense': ' (CC BY 4.0)',
|
|
267
|
+
'detail.translatedBy': '',
|
|
268
|
+
'detail.analysisUpdated': 'Analysis updated {date}',
|
|
269
|
+
'detail.kpiGained': 'Gained',
|
|
270
|
+
'detail.kpiStart': 'Starting stars',
|
|
271
|
+
'detail.kpiEnd': 'Current stars',
|
|
272
|
+
'detail.chartStart': 'Start {n}',
|
|
273
|
+
'detail.chartNow': 'Now {n}',
|
|
274
|
+
'detail.linkGithub': '↗ GitHub',
|
|
275
|
+
'detail.linkSource': '↗ hysenlabs',
|
|
276
|
+
'card.translating': 'zh',
|
|
277
|
+
'card.translatingTip': 'Translating to Chinese',
|
|
278
|
+
'stats.pickerTitle': 'Select projects',
|
|
279
|
+
'stats.pickTop': 'Top {n}',
|
|
280
|
+
'stats.pickAll': 'All',
|
|
281
|
+
'stats.pickClear': 'Clear',
|
|
282
|
+
'stats.chartTitle': 'Growth comparison',
|
|
283
|
+
'stats.axisHint': 'Y-axis: sampled gain (symmetric log)',
|
|
284
|
+
'stats.linearAxis': 'Y-axis: sampled gain (linear · auto)',
|
|
285
|
+
'stats.gainTitle': 'Period star gains',
|
|
286
|
+
'stats.gainHint': 'Original ranking values · bars start at zero',
|
|
287
|
+
'stats.sampleNote': 'Trends are reconstructed from source SVGs. Each line starts at its first available sample in the period; see legend dates. Use the ranking above for exact gains. The axis adapts to selected projects: linear for comparable values, symmetric log when nonzero endpoint magnitudes differ by over 10×. Avatar guides connect to the true endpoints.',
|
|
288
|
+
'stats.unknownWindow': 'Ranking dates are unavailable; historical samples are shown below.',
|
|
289
|
+
'stats.showAll': 'Show all {n} projects',
|
|
290
|
+
'stats.showLess': 'Show less',
|
|
291
|
+
'stats.dailyTitle': 'Daily star gains',
|
|
292
|
+
'stats.dailyAxis': 'Y-axis: daily stars gained (linear)',
|
|
293
|
+
'stats.dailyNote': 'One avatar per project: higher means more stars gained that day. The x-axis follows ranking order. Hover or focus an avatar for its project and exact gain.',
|
|
294
|
+
'stats.emptyHint': 'Select projects to compare their trends',
|
|
295
|
+
'error.title': 'Failed to fetch',
|
|
296
|
+
'error.hint': 'Check your network, then click "Refresh".',
|
|
297
|
+
'error.dailyHint': 'Data updates once a day. Try again later.',
|
|
298
|
+
'error.stale': 'Fetch failed ({err}). Showing data from {when}.',
|
|
299
|
+
'empty.noData': 'No data',
|
|
300
|
+
'empty.notFetched': 'Not fetched yet',
|
|
301
|
+
'empty.partial': 'Partial · showing {n}',
|
|
302
|
+
'misc.stars': 'stars',
|
|
303
|
+
'misc.gainUnit': 'gain',
|
|
304
|
+
'misc.topByRank': 'Each project by rank',
|
|
305
|
+
'attribution': '',
|
|
306
|
+
'glance.translating': 'Translating to Chinese…',
|
|
307
|
+
'glance.title': 'At a glance',
|
|
308
|
+
'glance.what': 'What is it?',
|
|
309
|
+
'glance.who': 'Who is it for?',
|
|
310
|
+
'glance.commercial': 'Can I use it commercially?',
|
|
311
|
+
'glance.maintained': 'Is it still maintained?',
|
|
312
|
+
'glance.writtenIn': 'What is it written in?',
|
|
313
|
+
'glance.footer': 'Answers come from public project data and editorial analysis. Not legal advice.',
|
|
314
|
+
'settings.title': 'Settings',
|
|
315
|
+
'settings.tip': 'Plugin settings',
|
|
316
|
+
'settings.version': 'Version',
|
|
317
|
+
'settings.current': 'Current',
|
|
318
|
+
'settings.latest': 'Latest',
|
|
319
|
+
'settings.checking': 'Checking…',
|
|
320
|
+
'settings.upToDate': 'Up to date',
|
|
321
|
+
'settings.localAhead': 'Local dev build (npm latest v{v})',
|
|
322
|
+
'settings.hasUpdate': 'Update available',
|
|
323
|
+
'settings.updateTip': 'Version v{v} available',
|
|
324
|
+
'settings.updateHint': 'Upgrade with:',
|
|
325
|
+
'settings.checkNow': 'Check now',
|
|
326
|
+
'settings.checkFailed': 'Check failed: {msg}',
|
|
327
|
+
'settings.noNetwork': 'Cannot reach npm',
|
|
328
|
+
'settings.close': 'Close',
|
|
329
|
+
'settings.repo': 'Repository',
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 取界面文案。找不到键时回退到中文,再找不到就返回键名本身
|
|
335
|
+
* (返回键名而不是空串,便于发现漏翻的地方)。
|
|
336
|
+
*/
|
|
337
|
+
function translate(lang, key, vars) {
|
|
338
|
+
const table = COPY[lang] || COPY.zh;
|
|
339
|
+
let text = table[key];
|
|
340
|
+
if (text === undefined) text = COPY.zh[key];
|
|
341
|
+
if (text === undefined) return key;
|
|
342
|
+
if (vars) {
|
|
343
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
344
|
+
text = text.split('{' + k + '}').join(String(v));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return text;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 比较两个版本号(忽略预发布后缀)。返回 1/0/-1。
|
|
352
|
+
* 与宿主侧 compareVersion 保持同一口径。
|
|
353
|
+
*/
|
|
354
|
+
function cmpVersion(a, b) {
|
|
355
|
+
const norm = (v) => String(v || '').split('-')[0].split('.').map((x) => Number(x) || 0);
|
|
356
|
+
const pa = norm(a);
|
|
357
|
+
const pb = norm(b);
|
|
358
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
359
|
+
const x = pa[i] || 0;
|
|
360
|
+
const y = pb[i] || 0;
|
|
361
|
+
if (x > y) return 1;
|
|
362
|
+
if (x < y) return -1;
|
|
363
|
+
}
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
89
367
|
/**
|
|
90
368
|
* 紧凑数字格式:1000 以上一律走 k,避免卡片里「3,988」和「13.2k」混排显得参差。
|
|
91
369
|
* 注意保留一位小数但去掉多余的 .0(3,988 → 4.0k → 4k)。
|
|
@@ -186,13 +464,28 @@
|
|
|
186
464
|
* 在 UTC-x 的时区里显示成「11月2日」。这里**直接切字符串**,日期串本来就是
|
|
187
465
|
* 数据源的日期,不需要经过任何时区换算。
|
|
188
466
|
*/
|
|
189
|
-
function dataDateText(board) {
|
|
190
|
-
|
|
467
|
+
function dataDateText(board, lang) {
|
|
468
|
+
const en = lang === 'en';
|
|
469
|
+
if (!board) return en ? 'today' : '今日';
|
|
191
470
|
const raw = board.window && board.window.end ? board.window.end : board.week;
|
|
192
|
-
if (!raw) return '今日';
|
|
471
|
+
if (!raw) return en ? 'today' : '今日';
|
|
193
472
|
const m = String(raw).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
194
|
-
if (!m) return '今日';
|
|
195
|
-
|
|
473
|
+
if (!m) return en ? 'today' : '今日';
|
|
474
|
+
const y = Number(m[1]);
|
|
475
|
+
const mo = Number(m[2]);
|
|
476
|
+
const d = Number(m[3]);
|
|
477
|
+
/**
|
|
478
|
+
* 日期写法按语言习惯分开 —— 直译会读着别扭:
|
|
479
|
+
* 中文:2026年11月4日(先说年,量词用「年/月/日」)
|
|
480
|
+
* 英文:Nov 4, 2026(月在前、日用序数直觉、年份放最后)
|
|
481
|
+
* 两者都精确到年,不会出现「11月3日」这种看不出是哪年的写法。
|
|
482
|
+
*/
|
|
483
|
+
if (en) {
|
|
484
|
+
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
485
|
+
return MON[mo - 1] + ' ' + d + ', ' + y;
|
|
486
|
+
}
|
|
487
|
+
// 中文用紧凑写法「26.11.4」:两位年份 + 点分隔,一眼看完不占地方
|
|
488
|
+
return String(y).slice(2) + '.' + mo + '.' + d;
|
|
196
489
|
}
|
|
197
490
|
|
|
198
491
|
function fmtTime(iso) {
|
|
@@ -270,6 +563,11 @@
|
|
|
270
563
|
--hp-shadow-hover:0 8px 24px rgba(0,0,0,.28);
|
|
271
564
|
--hp-card-bg:var(--hp-surface);
|
|
272
565
|
--hp-radius:13px;
|
|
566
|
+
/**
|
|
567
|
+
* 弹层(设置面板)的底色:**必须不透明**。
|
|
568
|
+
* 之前用了一个没定义的变量,回退到 transparent,面板直接透出下面的卡片内容。
|
|
569
|
+
*/
|
|
570
|
+
--hp-panel:var(--dsw-alias-bg-layer-2,#1b1e24);
|
|
273
571
|
}
|
|
274
572
|
|
|
275
573
|
/* ---------- 纸感贴纸主题 ---------- */
|
|
@@ -290,6 +588,7 @@
|
|
|
290
588
|
--hp-fg:var(--hp-ink);
|
|
291
589
|
--hp-fg-2:#4a3f33;
|
|
292
590
|
--hp-fg-3:#8a7a68;
|
|
591
|
+
--hp-panel:var(--hp-card);
|
|
293
592
|
--hp-line:var(--hp-ink);
|
|
294
593
|
--hp-sep:rgba(38,32,26,.16);
|
|
295
594
|
--hp-border:2px solid var(--hp-ink);
|
|
@@ -366,6 +665,26 @@
|
|
|
366
665
|
/* 上图下选:图表独占一行,勾选列表在下方横向铺开 */
|
|
367
666
|
.hp-dash{display:flex;flex-direction:column;gap:12px;padding:16px 18px 28px}
|
|
368
667
|
.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}
|
|
668
|
+
/* 快捷选择按钮行 */
|
|
669
|
+
.hp-pick-actions{
|
|
670
|
+
display:flex;align-items:center;gap:5px;flex-wrap:wrap;
|
|
671
|
+
padding:0 10px 9px;margin-bottom:6px;border-bottom:1px solid var(--hp-sep);
|
|
672
|
+
}
|
|
673
|
+
.hp-pick-btn{
|
|
674
|
+
padding:4px 10px;border-radius:7px;cursor:pointer;
|
|
675
|
+
font-size:11px;font-weight:550;line-height:1.5;
|
|
676
|
+
border:1px solid var(--hp-line);background:var(--hp-surface-2);color:var(--hp-fg-2);
|
|
677
|
+
transition:background .14s,border-color .14s,color .14s;
|
|
678
|
+
}
|
|
679
|
+
.hp-pick-btn:hover:not(:disabled){background:var(--hp-surface);border-color:var(--hp-fg-3);color:var(--hp-fg)}
|
|
680
|
+
.hp-pick-btn:active:not(:disabled){transform:translateY(1px)}
|
|
681
|
+
.hp-pick-btn:disabled{opacity:.4;cursor:default}
|
|
682
|
+
.hp-pick-btn.clear{margin-left:auto;color:var(--hp-fg-3)}
|
|
683
|
+
.hp-pick-btn.clear:hover:not(:disabled){color:var(--hp-up);border-color:var(--hp-up)}
|
|
684
|
+
.hp-pick-sep{flex:1}
|
|
685
|
+
.hp-root.hp-paper .hp-pick-btn{border:1.5px solid var(--hp-ink);background:var(--hp-card);color:var(--hp-ink)}
|
|
686
|
+
.hp-root.hp-paper .hp-pick-btn.clear{border-color:rgba(38,32,26,.3)}
|
|
687
|
+
|
|
369
688
|
.hp-picker-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:1px 4px}
|
|
370
689
|
.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
690
|
.hp-picker-n{font-variant-numeric:tabular-nums}
|
|
@@ -398,6 +717,13 @@
|
|
|
398
717
|
.hp-legend-item:hover,.hp-legend-item.act{background:var(--hp-surface-2);color:var(--hp-fg)}
|
|
399
718
|
.hp-legend-line{width:16px;height:3px;border-radius:2px;flex:0 0 auto}
|
|
400
719
|
.hp-mchart-note{font-size:11px;color:var(--hp-fg-3);margin-top:9px;line-height:1.5}
|
|
720
|
+
.hp-gain-list{display:grid;gap:8px;margin-top:14px}
|
|
721
|
+
.hp-gain-row{display:grid;grid-template-columns:minmax(120px,1fr) minmax(80px,2fr) 78px;gap:12px;align-items:center;font-size:12px}
|
|
722
|
+
.hp-gain-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--hp-fg-2)}
|
|
723
|
+
.hp-gain-track{height:7px;background:var(--hp-sep);border-radius:3px;overflow:hidden}
|
|
724
|
+
.hp-gain-bar{height:100%;background:var(--hp-accent)}
|
|
725
|
+
.hp-gain-value{text-align:right;font-variant-numeric:tabular-nums;color:var(--hp-fg)}
|
|
726
|
+
@media(max-width:600px){.hp-gain-row{grid-template-columns:minmax(100px,1fr) minmax(40px,1fr) 64px;gap:8px}}
|
|
401
727
|
|
|
402
728
|
|
|
403
729
|
/* ---------- 头像 ---------- */
|
|
@@ -406,6 +732,149 @@
|
|
|
406
732
|
.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
733
|
|
|
408
734
|
/* ---------- 右侧操作区 ---------- */
|
|
735
|
+
/* 未译时的提示条:低调但明确,告诉用户「在翻了」而不是卡住 */
|
|
736
|
+
.hp-tr-hint{
|
|
737
|
+
display:block;margin-bottom:4px;font-size:11px;font-weight:600;
|
|
738
|
+
color:var(--hp-accent);opacity:.9;letter-spacing:.01em;
|
|
739
|
+
}
|
|
740
|
+
.hp-tr-hint::after{content:'';display:inline-block;width:10px;text-align:left;animation:hp-dots 1.2s steps(4,end) infinite}
|
|
741
|
+
@keyframes hp-dots{0%{content:''}25%{content:'.'}50%{content:'..'}75%{content:'...'}}
|
|
742
|
+
@media (prefers-reduced-motion:reduce){.hp-tr-hint::after{animation:none;content:'…'}}
|
|
743
|
+
.hp-glance-a.pending{opacity:.86}
|
|
744
|
+
|
|
745
|
+
/* ---------- 详情页「一眼看懂」问答表 ---------- */
|
|
746
|
+
.hp-glance{margin:0;padding:0;display:flex;flex-direction:column}
|
|
747
|
+
.hp-glance-row{
|
|
748
|
+
display:grid;grid-template-columns:132px 1fr;gap:14px;
|
|
749
|
+
padding:11px 0;border-bottom:1px dashed var(--hp-sep);
|
|
750
|
+
}
|
|
751
|
+
.hp-glance-row:first-child{padding-top:2px}
|
|
752
|
+
.hp-glance-row:last-child{border-bottom:none;padding-bottom:2px}
|
|
753
|
+
.hp-glance-q{
|
|
754
|
+
font-size:11.5px;font-weight:600;color:var(--hp-fg-3);
|
|
755
|
+
line-height:1.5;margin:0;
|
|
756
|
+
}
|
|
757
|
+
.hp-glance-a{
|
|
758
|
+
margin:0;font-size:12.5px;line-height:1.65;color:var(--hp-fg);
|
|
759
|
+
/* 答案完整显示,不截断 —— 截断会丢掉「能不能商用」这类关键信息 */
|
|
760
|
+
overflow-wrap:anywhere;
|
|
761
|
+
}
|
|
762
|
+
.hp-root.hp-paper .hp-glance-q{color:var(--hp-ink);opacity:.62}
|
|
763
|
+
.hp-root.hp-paper .hp-glance-row{border-bottom:1px dashed rgba(38,32,26,.22)}
|
|
764
|
+
@media (max-width:760px){
|
|
765
|
+
.hp-glance-row{grid-template-columns:1fr;gap:3px}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/* ---------- 设置按钮与面板 ---------- */
|
|
769
|
+
.hp-settings-btn{display:inline-flex;align-items:center;gap:6px;position:relative}
|
|
770
|
+
.hp-gear{display:inline-flex;align-items:center;opacity:.75}
|
|
771
|
+
.hp-settings-btn:hover .hp-gear{opacity:1}
|
|
772
|
+
.hp-btn.hp-btn-update{
|
|
773
|
+
border-color:var(--hp-accent);color:var(--hp-accent);font-weight:600;
|
|
774
|
+
background:color-mix(in srgb, var(--hp-accent) 12%, transparent);
|
|
775
|
+
}
|
|
776
|
+
.hp-update-dot{
|
|
777
|
+
width:7px;height:7px;border-radius:50%;background:var(--hp-accent);display:inline-block;
|
|
778
|
+
box-shadow:0 0 0 0 color-mix(in srgb, var(--hp-accent) 60%, transparent);
|
|
779
|
+
animation:hp-pulse 1.8s ease-out infinite;
|
|
780
|
+
}
|
|
781
|
+
@keyframes hp-pulse{
|
|
782
|
+
0%{box-shadow:0 0 0 0 color-mix(in srgb, var(--hp-accent) 60%, transparent)}
|
|
783
|
+
70%{box-shadow:0 0 0 7px transparent}
|
|
784
|
+
100%{box-shadow:0 0 0 0 transparent}
|
|
785
|
+
}
|
|
786
|
+
@media (prefers-reduced-motion:reduce){.hp-update-dot{animation:none}}
|
|
787
|
+
/* ---------- 设置面板(重新设计)---------- */
|
|
788
|
+
.hp-settings-mask{
|
|
789
|
+
position:fixed;inset:0;z-index:70;display:flex;align-items:flex-start;justify-content:flex-end;
|
|
790
|
+
padding:54px 20px 0;background:rgba(10,12,16,.46);backdrop-filter:blur(4px);
|
|
791
|
+
animation:hp-fade .14s ease-out;
|
|
792
|
+
}
|
|
793
|
+
.hp-root.hp-paper .hp-settings-mask{background:rgba(38,32,26,.38)}
|
|
794
|
+
@keyframes hp-fade{from{opacity:0}to{opacity:1}}
|
|
795
|
+
|
|
796
|
+
.hp-settings{
|
|
797
|
+
width:340px;max-width:calc(100vw - 40px);max-height:calc(100vh - 92px);overflow:auto;
|
|
798
|
+
background:var(--hp-panel,#1b1e24);
|
|
799
|
+
border:1px solid var(--hp-line);border-radius:16px;
|
|
800
|
+
box-shadow:0 24px 64px rgba(0,0,0,.5),0 2px 8px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.04);
|
|
801
|
+
position:relative;z-index:1;
|
|
802
|
+
animation:hp-rise .2s cubic-bezier(.22,.61,.36,1);
|
|
803
|
+
}
|
|
804
|
+
.hp-root.hp-paper .hp-settings{
|
|
805
|
+
background:var(--hp-card);border:2px solid var(--hp-ink);
|
|
806
|
+
box-shadow:7px 7px 0 var(--hp-ink);
|
|
807
|
+
}
|
|
808
|
+
@keyframes hp-rise{from{opacity:0;transform:translateY(-8px) scale(.97)}to{opacity:1;transform:none}}
|
|
809
|
+
|
|
810
|
+
/* 头部 */
|
|
811
|
+
.hp-set-head{
|
|
812
|
+
display:flex;align-items:center;justify-content:space-between;
|
|
813
|
+
padding:15px 18px 12px;
|
|
814
|
+
}
|
|
815
|
+
.hp-set-title{font-size:14px;font-weight:650;letter-spacing:.01em;color:var(--hp-fg)}
|
|
816
|
+
.hp-set-x{
|
|
817
|
+
display:inline-flex;align-items:center;justify-content:center;
|
|
818
|
+
width:26px;height:26px;border-radius:8px;border:none;cursor:pointer;
|
|
819
|
+
background:transparent;color:var(--hp-fg-3);transition:background .15s,color .15s;
|
|
820
|
+
}
|
|
821
|
+
.hp-set-x:hover{background:var(--hp-surface-2);color:var(--hp-fg)}
|
|
822
|
+
|
|
823
|
+
/* 版本对比卡片:当前 → 最新 并排 */
|
|
824
|
+
.hp-set-ver{
|
|
825
|
+
display:flex;align-items:center;gap:10px;
|
|
826
|
+
margin:0 18px;padding:13px 15px;border-radius:12px;
|
|
827
|
+
background:var(--hp-surface-2);
|
|
828
|
+
}
|
|
829
|
+
.hp-root.hp-paper .hp-set-ver{background:#fff9ec;border:1.5px solid rgba(38,32,26,.1)}
|
|
830
|
+
.hp-set-ver-item{flex:1;min-width:0}
|
|
831
|
+
.hp-set-ver-k{font-size:10.5px;color:var(--hp-fg-3);margin-bottom:3px;letter-spacing:.02em}
|
|
832
|
+
.hp-set-ver-v{
|
|
833
|
+
font-family:ui-monospace,'SF Mono',Menlo,monospace;
|
|
834
|
+
font-size:16px;font-weight:650;color:var(--hp-fg);line-height:1.2;
|
|
835
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
|
836
|
+
}
|
|
837
|
+
.hp-set-ver-v.new{color:var(--hp-accent)}
|
|
838
|
+
.hp-set-ver-arrow{color:var(--hp-fg-3);font-size:13px;opacity:.55;flex:0 0 auto}
|
|
839
|
+
|
|
840
|
+
/* 状态条 */
|
|
841
|
+
.hp-set-note{
|
|
842
|
+
display:flex;align-items:center;gap:7px;flex-wrap:wrap;
|
|
843
|
+
margin:12px 18px 0;font-size:11.5px;line-height:1.5;color:var(--hp-fg-2);
|
|
844
|
+
}
|
|
845
|
+
.hp-set-note.ok{color:var(--hp-down)}
|
|
846
|
+
.hp-set-note.update{color:var(--hp-accent);font-weight:600}
|
|
847
|
+
.hp-set-note.local{color:var(--hp-fg-2)}
|
|
848
|
+
.hp-set-note.warn{color:var(--hp-up)}
|
|
849
|
+
.hp-set-dot{width:6px;height:6px;border-radius:50%;flex:0 0 auto;background:currentColor}
|
|
850
|
+
|
|
851
|
+
/* 升级命令 */
|
|
852
|
+
.hp-set-cmd{margin:10px 18px 0}
|
|
853
|
+
.hp-set-cmd-k{font-size:10.5px;color:var(--hp-fg-3);margin-bottom:5px}
|
|
854
|
+
.hp-set-cmd code{
|
|
855
|
+
display:block;padding:9px 11px;border-radius:9px;
|
|
856
|
+
background:var(--hp-surface-2);border:1px solid var(--hp-sep);
|
|
857
|
+
font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:10.5px;
|
|
858
|
+
line-height:1.6;word-break:break-all;color:var(--hp-fg);
|
|
859
|
+
}
|
|
860
|
+
.hp-root.hp-paper .hp-set-cmd code{background:#fff9ec}
|
|
861
|
+
|
|
862
|
+
/* 底部操作 */
|
|
863
|
+
.hp-set-foot{display:flex;gap:8px;padding:16px 18px 17px}
|
|
864
|
+
.hp-set-btn{
|
|
865
|
+
flex:1;padding:8px 12px;border-radius:9px;cursor:pointer;
|
|
866
|
+
font-size:11.5px;font-weight:550;text-align:center;text-decoration:none;
|
|
867
|
+
border:1px solid var(--hp-line);background:var(--hp-surface-2);color:var(--hp-fg);
|
|
868
|
+
transition:background .15s,border-color .15s,transform .1s;
|
|
869
|
+
}
|
|
870
|
+
.hp-set-btn:hover:not(:disabled){background:var(--hp-surface);border-color:var(--hp-fg-3)}
|
|
871
|
+
.hp-set-btn:active:not(:disabled){transform:translateY(1px)}
|
|
872
|
+
.hp-set-btn:disabled{opacity:.55;cursor:default}
|
|
873
|
+
.hp-set-btn.ghost{background:transparent;color:var(--hp-fg-2)}
|
|
874
|
+
.hp-set-btn.ghost:hover{color:var(--hp-fg)}
|
|
875
|
+
.hp-root.hp-paper .hp-set-btn{border:1.5px solid var(--hp-ink);box-shadow:2px 2px 0 var(--hp-ink);background:var(--hp-card)}
|
|
876
|
+
.hp-root.hp-paper .hp-set-btn.ghost{box-shadow:none;border-color:rgba(38,32,26,.35)}
|
|
877
|
+
|
|
409
878
|
/* ---------- 数据署名(CC BY 4.0 要求,放右下角不干扰主视觉)---------- */
|
|
410
879
|
.hp-attr{
|
|
411
880
|
display:flex;justify-content:flex-end;align-items:center;gap:5px;
|
|
@@ -432,6 +901,11 @@
|
|
|
432
901
|
.hp-desc.zh{font-family:var(--dsw-font,-apple-system,'PingFang SC','Microsoft YaHei',sans-serif);letter-spacing:.01em;line-height:1.65}
|
|
433
902
|
.hp-intro.zh{font-size:13.5px;line-height:1.75;color:var(--hp-fg)}
|
|
434
903
|
.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)}
|
|
904
|
+
@media(max-width:900px){
|
|
905
|
+
.hp-head{flex-wrap:wrap;overflow:visible}
|
|
906
|
+
.hp-head .hp-spacer{display:none}
|
|
907
|
+
.hp-head>.hp-actions{flex:1 1 auto;flex-wrap:wrap;margin-left:0}
|
|
908
|
+
}
|
|
435
909
|
|
|
436
910
|
/* ---------- 首屏主角(先给答案)---------- */
|
|
437
911
|
.hp-hero{display:flex;gap:18px;align-items:stretch;padding:18px 20px 6px;flex-wrap:wrap}
|
|
@@ -681,7 +1155,8 @@
|
|
|
681
1155
|
const { React, chart } = props;
|
|
682
1156
|
const h = React.createElement;
|
|
683
1157
|
if (!chart || !Array.isArray(chart.series) || chart.series.length < 3) {
|
|
684
|
-
|
|
1158
|
+
const t = (k, v) => translate(props.lang, k, v);
|
|
1159
|
+
return h('div', { className: 'hp-chart-empty' }, t('detail.noChart'));
|
|
685
1160
|
}
|
|
686
1161
|
const W = 560;
|
|
687
1162
|
const H = 190;
|
|
@@ -747,9 +1222,13 @@
|
|
|
747
1222
|
h(
|
|
748
1223
|
'div',
|
|
749
1224
|
{ className: 'hp-chart-legend' },
|
|
750
|
-
h('span', null,
|
|
1225
|
+
h('span', null, (props.lang === 'en'
|
|
1226
|
+
? 'Start ' + fmtCount(first)
|
|
1227
|
+
: '起点 ' + fmtCount(first))),
|
|
751
1228
|
h('span', { style: { color: '#ff8a90', fontWeight: 650 } }, '+' + fmtCount(gained) + ' ★'),
|
|
752
|
-
h('span', null,
|
|
1229
|
+
h('span', null, (props.lang === 'en'
|
|
1230
|
+
? 'Now ' + fmtCount(last)
|
|
1231
|
+
: '现在 ' + fmtCount(last))),
|
|
753
1232
|
),
|
|
754
1233
|
);
|
|
755
1234
|
}
|
|
@@ -892,13 +1371,13 @@
|
|
|
892
1371
|
'div',
|
|
893
1372
|
{ className: 'hp-gain' },
|
|
894
1373
|
h('div', { className: 'hp-gain-v ' + gainTone(item.starsGained) }, '+' + fmtCount(item.starsGained)),
|
|
895
|
-
h('div', { className: 'hp-gain-s' }, share >= 60 ? '
|
|
1374
|
+
h('div', { className: 'hp-gain-s' }, share >= 60 ? translate(props.lang, 'board.topGain') : item.endStars ? fmtCount(item.endStars) + ' stars' : ''),
|
|
896
1375
|
),
|
|
897
1376
|
),
|
|
898
1377
|
// ── 简介:有才渲染 ──
|
|
899
1378
|
desc
|
|
900
1379
|
? h('p', { className: 'hp-desc' + (descZh ? ' zh' : '') }, desc,
|
|
901
|
-
translating ? h('span', { className: 'hp-zh-pending', title: '
|
|
1380
|
+
translating ? h('span', { className: 'hp-zh-pending', title: translate(props.lang, 'card.translatingTip') }, translate(props.lang, 'card.translating')) : null)
|
|
902
1381
|
: // 还没取完:骨架;取完了却什么都没有(没有该项目分析):留空不占位
|
|
903
1382
|
detail && detail.settled
|
|
904
1383
|
? null
|
|
@@ -936,7 +1415,7 @@
|
|
|
936
1415
|
{ className: 'hp-sub' },
|
|
937
1416
|
item.language ? h('span', { className: 'hp-chip' }, item.language) : null,
|
|
938
1417
|
item.license ? h('span', { className: 'hp-chip' }, item.license) : null,
|
|
939
|
-
d.analysisUpdated ? h('span', { style: { opacity: 0.7 } },
|
|
1418
|
+
d.analysisUpdated ? h('span', { style: { opacity: 0.7 } }, translate(props.lang, 'detail.analysisUpdated', { date: d.analysisUpdated })) : null,
|
|
940
1419
|
),
|
|
941
1420
|
),
|
|
942
1421
|
h('div', { className: 'hp-gain', style: { marginLeft: 'auto' } },
|
|
@@ -948,27 +1427,74 @@
|
|
|
948
1427
|
h(
|
|
949
1428
|
'div',
|
|
950
1429
|
{ className: 'hp-detail-body' },
|
|
951
|
-
//
|
|
1430
|
+
// 左:一眼看懂(结构化问答)
|
|
952
1431
|
h(
|
|
953
1432
|
'div',
|
|
954
1433
|
{ className: 'hp-pane' },
|
|
955
|
-
h('div', { className: 'hp-pane-title' }, '
|
|
1434
|
+
h('div', { className: 'hp-pane-title' }, translate(props.lang, 'glance.title')),
|
|
956
1435
|
loading
|
|
957
|
-
? h('div', { className: 'hp-intro none' }, '
|
|
958
|
-
:
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1436
|
+
? h('div', { className: 'hp-intro none' }, translate(props.lang, 'detail.loadingIntro'))
|
|
1437
|
+
: (() => {
|
|
1438
|
+
/**
|
|
1439
|
+
* 把 glance 的问答渲染成表格式。
|
|
1440
|
+
*
|
|
1441
|
+
* 只显示一段「What is it?」会把「能不能商用」「还在维护吗」这些
|
|
1442
|
+
* 用户最关心的信息丢掉,而且长段落还会被截断。改成一组问答,
|
|
1443
|
+
* 每条答案完整显示(不截断),扫一眼就知道项目底细。
|
|
1444
|
+
*
|
|
1445
|
+
* 问题文本按语言映射到本地化标题;映射不到的原样显示(保证不漏内容)。
|
|
1446
|
+
*/
|
|
1447
|
+
const items = Array.isArray(d.glance) ? d.glance : [];
|
|
1448
|
+
if (items.length === 0) {
|
|
1449
|
+
return d.desc
|
|
1450
|
+
? h('div', { className: 'hp-intro' }, d.desc)
|
|
1451
|
+
: h('div', { className: 'hp-intro none' }, error ? translate(props.lang, 'detail.readError', { msg: error }) : translate(props.lang, 'detail.noIntro'));
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* 只展示这三条。
|
|
1455
|
+
*
|
|
1456
|
+
* 「可以商用吗」「还在维护吗」去掉了:前者是法律判断、后者是会过期的
|
|
1457
|
+
* 时效信息,放在这个面板里容易被当成保证。项目主页/许可证标签已经
|
|
1458
|
+
* 足够回答这两个问题,想深究的人可以点 ↗ hysenlabs 看原文。
|
|
1459
|
+
*/
|
|
1460
|
+
const QMAP = {
|
|
1461
|
+
'what is it?': 'glance.what',
|
|
1462
|
+
'who is it for?': 'glance.who',
|
|
1463
|
+
'what is it written in?': 'glance.writtenIn',
|
|
1464
|
+
};
|
|
1465
|
+
// 只保留这三条;译文按原始下标对齐(glanceZh 与 items 等长)
|
|
1466
|
+
const zhList = Array.isArray(d.glanceZh) ? d.glanceZh : [];
|
|
1467
|
+
const show = items
|
|
1468
|
+
.map((qa, idx) => ({ qa, idx }))
|
|
1469
|
+
.filter(({ qa }) => QMAP[String(qa.q).trim().toLowerCase()]);
|
|
1470
|
+
return h(
|
|
1471
|
+
'dl',
|
|
1472
|
+
{ className: 'hp-glance' },
|
|
1473
|
+
...show.map(({ qa, idx }) => {
|
|
1474
|
+
const key = QMAP[String(qa.q).trim().toLowerCase()];
|
|
1475
|
+
const label = translate(props.lang, key) || qa.q;
|
|
1476
|
+
/**
|
|
1477
|
+
* 中文模式下优先显示译文;没译文就先用英文顶着(不空白、不等待)。
|
|
1478
|
+
* 英文模式始终显示原文。
|
|
1479
|
+
*/
|
|
1480
|
+
const zh = zhList[idx];
|
|
1481
|
+
const answer = props.lang === 'en' ? qa.a : (zh || qa.a);
|
|
1482
|
+
// 中文模式但这条还没译文 → 在答案上方标一行「正在努力翻译」
|
|
1483
|
+
const pending = props.lang !== 'en' && !zh;
|
|
1484
|
+
return h(
|
|
966
1485
|
'div',
|
|
967
|
-
{ className:
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1486
|
+
{ className: 'hp-glance-row', key: idx },
|
|
1487
|
+
h('dt', { className: 'hp-glance-q' }, label),
|
|
1488
|
+
h(
|
|
1489
|
+
'dd',
|
|
1490
|
+
{ className: 'hp-glance-a' + (pending ? ' pending' : '') },
|
|
1491
|
+
pending ? h('span', { className: 'hp-tr-hint' }, translate(props.lang, 'glance.translating')) : null,
|
|
1492
|
+
answer,
|
|
1493
|
+
),
|
|
1494
|
+
);
|
|
1495
|
+
}),
|
|
1496
|
+
);
|
|
1497
|
+
})(),
|
|
972
1498
|
/**
|
|
973
1499
|
* 简介的来源说明。
|
|
974
1500
|
*
|
|
@@ -976,23 +1502,21 @@
|
|
|
976
1502
|
* 它不是本项目原创。所以明确写出「引用自 Hysen Labs」并带上链接,
|
|
977
1503
|
* 同时说明中译是谁做的、原文在哪。
|
|
978
1504
|
*/
|
|
979
|
-
d.
|
|
1505
|
+
d.glance && d.glance.length > 0
|
|
980
1506
|
? h(
|
|
981
1507
|
'div',
|
|
982
1508
|
{ className: 'hp-srcline' },
|
|
983
|
-
|
|
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 模型生成,英文原文见下方' : '',
|
|
1509
|
+
translate(props.lang, 'glance.footer'),
|
|
1510
|
+
' ',
|
|
1511
|
+
h('a', { href: item.hysenUrl || 'https://hysenlabs.com', target: '_blank', rel: 'noreferrer' }, 'Hysen Labs · CC BY 4.0'),
|
|
988
1512
|
)
|
|
989
1513
|
: null,
|
|
990
1514
|
h(
|
|
991
1515
|
'div',
|
|
992
1516
|
{ className: 'hp-kpis' },
|
|
993
|
-
h('div', { className: 'hp-kpi' }, h('div', { className: 'hp-kpi-k' }, '
|
|
994
|
-
h('div', { className: 'hp-kpi' }, h('div', { className: 'hp-kpi-k' }, '
|
|
995
|
-
h('div', { className: 'hp-kpi' }, h('div', { className: 'hp-kpi-k' }, '
|
|
1517
|
+
h('div', { className: 'hp-kpi' }, h('div', { className: 'hp-kpi-k' }, translate(props.lang, 'detail.kpiGained')), h('div', { className: 'hp-kpi-v up' }, '+' + fmtCount(item.starsGained))),
|
|
1518
|
+
h('div', { className: 'hp-kpi' }, h('div', { className: 'hp-kpi-k' }, translate(props.lang, 'detail.kpiStart')), h('div', { className: 'hp-kpi-v' }, fmtCount(item.startStars))),
|
|
1519
|
+
h('div', { className: 'hp-kpi' }, h('div', { className: 'hp-kpi-k' }, translate(props.lang, 'detail.kpiEnd')), h('div', { className: 'hp-kpi-v' }, fmtCount(item.endStars))),
|
|
996
1520
|
),
|
|
997
1521
|
h(
|
|
998
1522
|
'div',
|
|
@@ -1008,9 +1532,14 @@
|
|
|
1008
1532
|
h(
|
|
1009
1533
|
'div',
|
|
1010
1534
|
{ className: 'hp-pane' },
|
|
1011
|
-
h('div', { className: 'hp-pane-title' }, '
|
|
1012
|
-
loading
|
|
1013
|
-
|
|
1535
|
+
h('div', { className: 'hp-pane-title' }, translate(props.lang, 'detail.chartTitle')),
|
|
1536
|
+
loading || d.chartPending
|
|
1537
|
+
? h('div', { className: 'hp-chart-empty' }, translate(props.lang, 'detail.loading'))
|
|
1538
|
+
: h(StarChart, { React, lang: props.lang, chart: d.chart }),
|
|
1539
|
+
// 只有真的失败了才报错;pending 时上面已经显示「加载中」,不重复报
|
|
1540
|
+
d.chartError && !d.chart && !d.chartPending
|
|
1541
|
+
? h('div', { className: 'hp-chart-empty' }, translate(props.lang, 'detail.chartError', { msg: d.chartError }))
|
|
1542
|
+
: null,
|
|
1014
1543
|
),
|
|
1015
1544
|
),
|
|
1016
1545
|
);
|
|
@@ -1021,25 +1550,36 @@
|
|
|
1021
1550
|
/** 多曲线对比用的色板(8 条以内,之后循环)。 */
|
|
1022
1551
|
const SERIES_COLORS = ['#3a6fe0', '#30a46c', '#e8b53a', '#e5484d', '#7b5cff', '#12a594', '#f76b15', '#8e4ec6'];
|
|
1023
1552
|
|
|
1553
|
+
function comparisonSamples(chart, start, end) {
|
|
1554
|
+
const byDate = new Map();
|
|
1555
|
+
for (const p of chart && Array.isArray(chart.series) ? chart.series : []) {
|
|
1556
|
+
const ts = p && Date.parse(p.date);
|
|
1557
|
+
if (!Number.isFinite(ts) || !Number.isFinite(p.stars)) continue;
|
|
1558
|
+
if (Number.isFinite(start) && ts < start) continue;
|
|
1559
|
+
if (Number.isFinite(end) && ts > end) continue;
|
|
1560
|
+
byDate.set(p.date, p);
|
|
1561
|
+
}
|
|
1562
|
+
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1024
1565
|
/**
|
|
1025
1566
|
* 统计视图 —— 多项目星数曲线对比。
|
|
1026
1567
|
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
* 两个必须守住的点:
|
|
1031
|
-
* 1. **按日期对齐**,不是按点序号 —— 各项目起始日不同,按序号叠会错位。
|
|
1032
|
-
* 2. **两种量纲分开**:绝对星数(谁体量大)与本期涨幅(谁涨得猛)是两个问题,
|
|
1033
|
-
* 用分段器切换,不混在一张图里比。
|
|
1568
|
+
* 先用榜单原始数值比较本期涨星,再用期内采样曲线观察趋势。
|
|
1569
|
+
* 曲线按真实日期对齐;来源是 SVG 还原值,不能替代榜单精确值。
|
|
1034
1570
|
*/
|
|
1035
1571
|
function Dashboard(props) {
|
|
1036
1572
|
const { React } = props;
|
|
1037
1573
|
const h = React.createElement;
|
|
1038
1574
|
const { rows, charts, lang, periodKey } = props;
|
|
1575
|
+
const isDaily = periodKey === 'day';
|
|
1576
|
+
const windowStart = props.window && props.window.start ? Date.parse(props.window.start) : NaN;
|
|
1577
|
+
const windowEnd = props.window && props.window.end ? Date.parse(props.window.end) : NaN;
|
|
1039
1578
|
|
|
1040
1579
|
// 选项给全部(最多 20 条榜单),默认对比前 10 个 —— 再多线会糊成一团
|
|
1041
1580
|
const [picked, setPicked] = React.useState(() => new Set((rows || []).slice(0, DEFAULT_COMPARE).map((r) => r.repository)));
|
|
1042
1581
|
const [hover, setHover] = React.useState(null);
|
|
1582
|
+
const [showAllGains, setShowAllGains] = React.useState(false);
|
|
1043
1583
|
|
|
1044
1584
|
// 数据换了一批(切榜单)时重置选择
|
|
1045
1585
|
React.useEffect(() => {
|
|
@@ -1055,72 +1595,51 @@
|
|
|
1055
1595
|
});
|
|
1056
1596
|
};
|
|
1057
1597
|
|
|
1058
|
-
/** 取出选中项目的序列,并统一到全局时间轴。 */
|
|
1059
1598
|
/**
|
|
1060
|
-
*
|
|
1599
|
+
* 批量勾选:0 = 清空,其他 = 选中名次最前的 n 个。
|
|
1061
1600
|
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1064
|
-
* 这样 9 月一起爆发和 8 月就起量的项目,是在同一条时间尺度上比。
|
|
1065
|
-
* 2. **不从 0 开始也没关系**:每条线从它自己第一次有数据那天开始画,
|
|
1066
|
-
* 中途才起来的项目就是坐标轴中间才冒出来的一条线,不需要从最左边硬接。
|
|
1067
|
-
* 3. 某个项目在基准日**还没有数据**时,用它最早那个点补齐起算
|
|
1068
|
-
* (不做插值,不编数据;就是"它进榜那天"起算)。
|
|
1601
|
+
* 只选**有曲线的**项目 —— 没曲线的勾上也不会画线,等于白勾,
|
|
1602
|
+
* 用户会以为按钮没生效。
|
|
1069
1603
|
*/
|
|
1604
|
+
const onPickPreset = React.useCallback((n) => {
|
|
1605
|
+
if (!n || n <= 0) {
|
|
1606
|
+
setPicked(new Set());
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
const candidates = (rows || []).filter((r) => {
|
|
1610
|
+
if (isDaily) return true;
|
|
1611
|
+
// charts[repo] 是 { series: [...] },曲线数组在 .series 上(不是对象本身)
|
|
1612
|
+
const c = charts && charts[r.repository];
|
|
1613
|
+
return Array.isArray(c && c.series) && comparisonSamples(c, windowStart, windowEnd).length >= 2;
|
|
1614
|
+
});
|
|
1615
|
+
setPicked(new Set(candidates.slice(0, Math.min(n, MAX_COMPARE)).map((r) => r.repository)));
|
|
1616
|
+
}, [rows, charts, windowStart, windowEnd, isDaily]);
|
|
1617
|
+
|
|
1618
|
+
// 只用榜单周期内的实际采样点;不补造缺失的日期或星数。
|
|
1070
1619
|
const seriesList = React.useMemo(() => {
|
|
1071
1620
|
const raw = [];
|
|
1072
1621
|
for (const row of rows || []) {
|
|
1073
1622
|
if (!picked.has(row.repository)) continue;
|
|
1074
1623
|
const chart = charts && charts[row.repository];
|
|
1075
|
-
if (!chart || !Array.isArray(chart.series)
|
|
1076
|
-
const
|
|
1077
|
-
if (
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
for (const series of raw) {
|
|
1087
|
-
const first = series.points[0].date;
|
|
1088
|
-
if (first > baseDate) baseDate = first;
|
|
1624
|
+
if (!chart || !Array.isArray(chart.series)) continue;
|
|
1625
|
+
const points = comparisonSamples(chart, windowStart, windowEnd);
|
|
1626
|
+
if (points.length < 2) continue;
|
|
1627
|
+
const baseStars = points[0].stars;
|
|
1628
|
+
raw.push({
|
|
1629
|
+
repo: row.repository,
|
|
1630
|
+
color: SERIES_COLORS[(rows || []).indexOf(row) % SERIES_COLORS.length],
|
|
1631
|
+
points: points.map((p) => ({ date: p.date, stars: p.stars, gained: p.stars - baseStars })),
|
|
1632
|
+
baseDate: points[0].date,
|
|
1633
|
+
row,
|
|
1634
|
+
});
|
|
1089
1635
|
}
|
|
1636
|
+
return raw;
|
|
1637
|
+
}, [rows, charts, picked, windowStart, windowEnd]);
|
|
1090
1638
|
|
|
1091
|
-
|
|
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
|
-
*/
|
|
1639
|
+
// 对称对数保留掉星;普通 log(max(0, v)) 会把所有负增长画成零。
|
|
1122
1640
|
const LOG_BASE = 1;
|
|
1123
|
-
const logV = (v) => Math.log10(Math.
|
|
1641
|
+
const logV = (v) => Math.sign(v) * Math.log10(Math.abs(v) + LOG_BASE);
|
|
1642
|
+
const signed = (v) => (v > 0 ? '+' : '') + fmtCount(v);
|
|
1124
1643
|
const frame = React.useMemo(() => {
|
|
1125
1644
|
if (seriesList.length === 0) return null;
|
|
1126
1645
|
let minDate = Infinity;
|
|
@@ -1137,37 +1656,64 @@
|
|
|
1137
1656
|
if (v > maxV) maxV = v;
|
|
1138
1657
|
}
|
|
1139
1658
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1659
|
+
const magnitudes = seriesList.map((s) => Math.abs(s.points[s.points.length - 1].gained)).filter((v) => v > 0);
|
|
1660
|
+
const useLog = magnitudes.length > 1 && Math.max(...magnitudes) / Math.min(...magnitudes) > 10;
|
|
1661
|
+
if (!useLog) {
|
|
1662
|
+
const rawStep = Math.max(1, (maxV - minV) / 4);
|
|
1663
|
+
const unit = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
|
1664
|
+
const step = [1, 2, 5, 10].find((n) => n * unit >= rawStep) * unit;
|
|
1665
|
+
const lo = Math.floor(Math.min(0, minV) / step) * step;
|
|
1666
|
+
const hi = Math.max(lo + step, Math.ceil(Math.max(0, maxV) / step) * step);
|
|
1667
|
+
return { minDate, maxDate, minV, maxV, lo, hi, step, useLog };
|
|
1668
|
+
}
|
|
1149
1669
|
const loRaw = logV(minV);
|
|
1150
1670
|
const hiRaw = logV(maxV);
|
|
1151
1671
|
const pad = Math.max(0.12, (hiRaw - loRaw) * 0.06);
|
|
1152
1672
|
const lo = loRaw - pad;
|
|
1153
1673
|
const hi = hiRaw > loRaw ? hiRaw + pad : loRaw + 1;
|
|
1154
|
-
return { minDate, maxDate, minV, maxV, lo, hi };
|
|
1674
|
+
return { minDate, maxDate, minV, maxV, lo, hi, useLog };
|
|
1155
1675
|
}, [seriesList]);
|
|
1156
1676
|
|
|
1157
1677
|
const ready = rows && rows.length > 0;
|
|
1678
|
+
const maxGain = Math.max(1, ...(rows || []).map((r) => Math.abs(r.starsGained)));
|
|
1158
1679
|
|
|
1159
1680
|
// ── 左栏:项目勾选列表 ──
|
|
1160
1681
|
const picker = h(
|
|
1161
1682
|
'aside',
|
|
1162
1683
|
{ className: 'hp-picker' },
|
|
1163
|
-
|
|
1684
|
+
// 快捷按钮需要回调,见下方 onPickPreset
|
|
1685
|
+
h('div', { className: 'hp-picker-title' }, translate(props.lang, 'stats.pickerTitle'), h('span', { className: 'hp-picker-n' }, picked.size + ' / ' + MAX_COMPARE)),
|
|
1686
|
+
/**
|
|
1687
|
+
* 快捷选择:一次勾 5 个 / 10 个 / 全部,或者一键清空。
|
|
1688
|
+
* 20 个项目逐个点太累,这里按「名次顺序」批量选中(前 N 名通常才是最值得比的)。
|
|
1689
|
+
*/
|
|
1690
|
+
h('div', { className: 'hp-pick-actions' },
|
|
1691
|
+
h('button', {
|
|
1692
|
+
className: 'hp-pick-btn',
|
|
1693
|
+
onClick: () => onPickPreset(5),
|
|
1694
|
+
}, translate(props.lang, 'stats.pickTop', { n: 5 })),
|
|
1695
|
+
h('button', {
|
|
1696
|
+
className: 'hp-pick-btn',
|
|
1697
|
+
onClick: () => onPickPreset(10),
|
|
1698
|
+
}, translate(props.lang, 'stats.pickTop', { n: 10 })),
|
|
1699
|
+
h('button', {
|
|
1700
|
+
className: 'hp-pick-btn',
|
|
1701
|
+
onClick: () => onPickPreset(MAX_COMPARE),
|
|
1702
|
+
}, translate(props.lang, 'stats.pickAll')),
|
|
1703
|
+
h('span', { className: 'hp-pick-sep' }),
|
|
1704
|
+
h('button', {
|
|
1705
|
+
className: 'hp-pick-btn clear',
|
|
1706
|
+
onClick: () => onPickPreset(0),
|
|
1707
|
+
disabled: picked.size === 0,
|
|
1708
|
+
}, translate(props.lang, 'stats.pickClear')),
|
|
1709
|
+
),
|
|
1164
1710
|
h(
|
|
1165
1711
|
'div',
|
|
1166
1712
|
{ className: 'hp-picker-list' },
|
|
1167
1713
|
(rows || []).map((row) => {
|
|
1168
1714
|
const on = picked.has(row.repository);
|
|
1169
1715
|
// charts 是 details[repo] = { desc, chart, ... },曲线在 .chart.series
|
|
1170
|
-
const hasChart = charts && charts[row.repository]
|
|
1716
|
+
const hasChart = isDaily || comparisonSamples(charts && charts[row.repository], windowStart, windowEnd).length >= 2;
|
|
1171
1717
|
const color = seriesList.find((s) => s.repo === row.repository)?.color;
|
|
1172
1718
|
return h(
|
|
1173
1719
|
'label',
|
|
@@ -1175,7 +1721,7 @@
|
|
|
1175
1721
|
h('input', {
|
|
1176
1722
|
type: 'checkbox',
|
|
1177
1723
|
checked: on,
|
|
1178
|
-
disabled: !hasChart,
|
|
1724
|
+
disabled: !hasChart && !on,
|
|
1179
1725
|
onChange: () => toggle(row.repository),
|
|
1180
1726
|
}),
|
|
1181
1727
|
h('span', { className: 'hp-pick-dot', style: color ? { background: color } : undefined }),
|
|
@@ -1189,17 +1735,59 @@
|
|
|
1189
1735
|
|
|
1190
1736
|
// ── 右栏:多曲线图 ──
|
|
1191
1737
|
const W = 760;
|
|
1192
|
-
const H = 340;
|
|
1193
|
-
const PAD = { l: 54, r: 16, t: 18, b: 30 };
|
|
1738
|
+
const H = isDaily ? 340 : Math.max(340, seriesList.length * 22 + 48);
|
|
1739
|
+
const PAD = { l: 54, r: isDaily ? 16 : 52, t: 18, b: 30 };
|
|
1194
1740
|
let chartNode = null;
|
|
1195
|
-
if (
|
|
1196
|
-
|
|
1741
|
+
if (isDaily && picked.size > 0) {
|
|
1742
|
+
const dailyRows = (rows || []).filter((r) => picked.has(r.repository));
|
|
1743
|
+
const rawMin = Math.min(0, ...dailyRows.map((r) => r.starsGained));
|
|
1744
|
+
const rawMax = Math.max(1, ...dailyRows.map((r) => r.starsGained));
|
|
1745
|
+
const tickBase = Math.pow(10, Math.floor(Math.log10((rawMax - rawMin) / 4)));
|
|
1746
|
+
const tickStep = Math.max(1, Math.ceil((rawMax - rawMin) / 4 / tickBase) * tickBase);
|
|
1747
|
+
const min = Math.floor(rawMin / tickStep) * tickStep;
|
|
1748
|
+
const max = Math.ceil(rawMax / tickStep) * tickStep;
|
|
1749
|
+
const span = max - min;
|
|
1750
|
+
const py = (value) => H - PAD.b - (value - min) / span * (H - PAD.t - PAD.b - 18);
|
|
1751
|
+
const px = (row) => PAD.l + ((dailyRows.indexOf(row) + 0.5) / dailyRows.length) * (W - PAD.l - PAD.r);
|
|
1752
|
+
const hovered = dailyRows.find((r) => r.repository === hover);
|
|
1753
|
+
chartNode = h('div', { className: 'hp-mchart' },
|
|
1754
|
+
h('svg', { viewBox: '0 0 ' + W + ' ' + H, className: 'hp-mchart-svg', role: 'img', 'aria-label': translate(lang, 'stats.dailyTitle') },
|
|
1755
|
+
Array.from({ length: Math.round(span / tickStep) + 1 }, (_, i) => min + i * tickStep).map((value) => {
|
|
1756
|
+
return h('g', { key: value },
|
|
1757
|
+
h('line', { x1: PAD.l, x2: W - PAD.r, y1: py(value), y2: py(value), className: value === 0 ? 'hp-zero' : 'hp-grid' }),
|
|
1758
|
+
h('text', { x: PAD.l - 8, y: py(value) + 3.5, textAnchor: 'end', className: 'hp-axis' }, signed(value)),
|
|
1759
|
+
);
|
|
1760
|
+
}),
|
|
1761
|
+
dailyRows.map((row) => {
|
|
1762
|
+
const x = px(row), y = py(row.starsGained);
|
|
1763
|
+
const owner = row.repository.split('/')[0];
|
|
1764
|
+
const label = row.repository + ' · ' + (row.starsGained > 0 ? '+' : '') + row.starsGained.toLocaleString(lang === 'en' ? 'en-US' : 'zh-CN');
|
|
1765
|
+
const color = SERIES_COLORS[rows.indexOf(row) % SERIES_COLORS.length];
|
|
1766
|
+
const clipId = 'hp-daily-av-' + row.rank;
|
|
1767
|
+
return h('g', { key: row.repository, tabIndex: 0, role: 'img', 'aria-label': label,
|
|
1768
|
+
onMouseEnter: () => setHover(row.repository), onMouseLeave: () => setHover(null),
|
|
1769
|
+
onFocus: () => setHover(row.repository), onBlur: () => setHover(null),
|
|
1770
|
+
opacity: hover && hover !== row.repository ? 0.35 : 1 },
|
|
1771
|
+
h('title', null, label),
|
|
1772
|
+
h('defs', null, h('clipPath', { id: clipId }, h('circle', { cx: x, cy: y, r: 11 }))),
|
|
1773
|
+
h('circle', { cx: x, cy: y, r: 12, fill: color }),
|
|
1774
|
+
h('text', { x, y: y + 3.5, textAnchor: 'middle', fill: '#fff', fontSize: 10 }, owner.slice(0, 1).toUpperCase()),
|
|
1775
|
+
h('image', { className: 'hp-daily-avatar', href: BASE + '/avatar?owner=' + encodeURIComponent(owner), x: x - 11, y: y - 11, width: 22, height: 22, clipPath: 'url(#' + clipId + ')', preserveAspectRatio: 'xMidYMid slice' }),
|
|
1776
|
+
h('circle', { cx: x, cy: y, r: 12, fill: 'none', stroke: color, strokeWidth: hover === row.repository ? 3 : 1.5 }),
|
|
1777
|
+
h('text', { x, y: H - 9, textAnchor: 'middle', className: 'hp-axis' }, '#' + row.rank),
|
|
1778
|
+
);
|
|
1779
|
+
}),
|
|
1780
|
+
hovered ? h('text', { x: W / 2, y: 15, textAnchor: 'middle', className: 'hp-axis' }, hovered.repository + ' · ' + (hovered.starsGained > 0 ? '+' : '') + hovered.starsGained.toLocaleString(lang === 'en' ? 'en-US' : 'zh-CN')) : null,
|
|
1781
|
+
),
|
|
1782
|
+
);
|
|
1783
|
+
} else if (isDaily || seriesList.length === 0) {
|
|
1784
|
+
chartNode = h('div', { className: 'hp-chart-empty' }, translate(props.lang, 'stats.emptyHint'));
|
|
1197
1785
|
} else if (frame) {
|
|
1198
|
-
const { minDate, maxDate, minV, maxV, lo, hi } = frame;
|
|
1786
|
+
const { minDate, maxDate, minV, maxV, lo, hi, step, useLog } = frame;
|
|
1199
1787
|
const spanX = maxDate - minDate || 1;
|
|
1200
1788
|
const px = (date) => PAD.l + ((Date.parse(date) - minDate) / spanX) * (W - PAD.l - PAD.r);
|
|
1201
1789
|
/** 值 → 屏幕 y(对数)。 */
|
|
1202
|
-
const py = (v) => H - PAD.b - ((logV(v) - lo) / (hi - lo)) * (H - PAD.t - PAD.b);
|
|
1790
|
+
const py = (v) => H - PAD.b - (((useLog ? logV(v) : v) - lo) / (hi - lo)) * (H - PAD.t - PAD.b);
|
|
1203
1791
|
const val = (p) => p.gained;
|
|
1204
1792
|
|
|
1205
1793
|
/**
|
|
@@ -1207,29 +1795,52 @@
|
|
|
1207
1795
|
* 只保留落在可视范围内的那些,最多 7 条 —— 这就是对数图的读法。
|
|
1208
1796
|
*/
|
|
1209
1797
|
const decadeTicks = [];
|
|
1798
|
+
if (lo <= 0 && hi >= 0) decadeTicks.push({ value: 0, y: py(0) });
|
|
1210
1799
|
for (let e = 0; e <= 7; e += 1) {
|
|
1211
1800
|
const value = Math.pow(10, e);
|
|
1212
1801
|
if (logV(value) >= lo - 0.001 && logV(value) <= hi + 0.001) {
|
|
1213
1802
|
decadeTicks.push({ value, y: py(value) });
|
|
1214
1803
|
}
|
|
1804
|
+
if (logV(-value) >= lo - 0.001 && logV(-value) <= hi + 0.001) {
|
|
1805
|
+
decadeTicks.push({ value: -value, y: py(-value) });
|
|
1806
|
+
}
|
|
1215
1807
|
}
|
|
1216
1808
|
// 范围太窄(一个量级以内)时补中间刻度,避免只有一条线
|
|
1217
|
-
const gridLines =
|
|
1809
|
+
const gridLines = !useLog
|
|
1810
|
+
? Array.from({ length: Math.round((hi - lo) / step) + 1 }, (_, i) => ({ value: lo + i * step, y: py(lo + i * step) }))
|
|
1811
|
+
: decadeTicks.length >= 2
|
|
1218
1812
|
? decadeTicks
|
|
1219
1813
|
: [0, 0.25, 0.5, 0.75, 1].map((t) => {
|
|
1220
1814
|
const target = lo + t * (hi - lo);
|
|
1221
|
-
const value = Math.pow(10, target) - LOG_BASE;
|
|
1222
|
-
return { value
|
|
1815
|
+
const value = Math.sign(target) * (Math.pow(10, Math.abs(target)) - LOG_BASE);
|
|
1816
|
+
return { value, y: py(value) };
|
|
1223
1817
|
});
|
|
1224
1818
|
|
|
1225
1819
|
// x 轴日期刻度(5 个)
|
|
1226
|
-
const
|
|
1227
|
-
|
|
1820
|
+
const tickDates = [...new Set([0, 0.25, 0.5, 0.75, 1].map((t) =>
|
|
1821
|
+
new Date(minDate + t * spanX).toISOString().slice(0, 10)))];
|
|
1822
|
+
const xTicks = tickDates.map((date) => {
|
|
1823
|
+
const ts = Date.parse(date);
|
|
1228
1824
|
const d = new Date(ts);
|
|
1229
1825
|
// 用 UTC 取月/日,避免本地时区把刻度挪一天
|
|
1230
1826
|
return { x: px(new Date(ts).toISOString().slice(0, 10)), label: (d.getUTCMonth() + 1) + '/' + d.getUTCDate() };
|
|
1231
1827
|
});
|
|
1232
1828
|
|
|
1829
|
+
// 曲线终点保持真实坐标,只把头像移到右侧等间距排布,用引导线关联。
|
|
1830
|
+
const endpointLabels = seriesList.map((s) => {
|
|
1831
|
+
const last = s.points[s.points.length - 1];
|
|
1832
|
+
return { s, cx: px(last.date), cy: py(last.gained), labelY: py(last.gained) };
|
|
1833
|
+
}).sort((a, b) => a.cy - b.cy);
|
|
1834
|
+
for (let i = 0; i < endpointLabels.length; i++) {
|
|
1835
|
+
endpointLabels[i].labelY = Math.max(endpointLabels[i].cy, PAD.t + 10, i ? endpointLabels[i - 1].labelY + 22 : 0);
|
|
1836
|
+
}
|
|
1837
|
+
if (endpointLabels.length) {
|
|
1838
|
+
endpointLabels.at(-1).labelY = Math.min(endpointLabels.at(-1).labelY, H - PAD.b - 10);
|
|
1839
|
+
for (let i = endpointLabels.length - 2; i >= 0; i--) {
|
|
1840
|
+
endpointLabels[i].labelY = Math.min(endpointLabels[i].labelY, endpointLabels[i + 1].labelY - 22);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1233
1844
|
chartNode = h(
|
|
1234
1845
|
'div',
|
|
1235
1846
|
// key 里带周期:换榜单时整个图重建一次,配 CSS 淡入,避免生硬跳变
|
|
@@ -1244,7 +1855,7 @@
|
|
|
1244
1855
|
{ key: 'g' + i },
|
|
1245
1856
|
h('line', { x1: PAD.l, x2: W - PAD.r, y1: g.y, y2: g.y, className: 'hp-grid' }),
|
|
1246
1857
|
h('text', { x: PAD.l - 8, y: g.y + 3.5, textAnchor: 'end', className: 'hp-axis' },
|
|
1247
|
-
|
|
1858
|
+
signed(g.value)),
|
|
1248
1859
|
),
|
|
1249
1860
|
),
|
|
1250
1861
|
// x 轴
|
|
@@ -1255,12 +1866,13 @@
|
|
|
1255
1866
|
: null,
|
|
1256
1867
|
// 每条曲线
|
|
1257
1868
|
seriesList.map((s) => {
|
|
1258
|
-
const d =
|
|
1869
|
+
const d = s.points.map((p, i) => (i ? 'L' : 'M') + px(p.date).toFixed(1) + ' ' + py(val(p)).toFixed(1)).join(' ');
|
|
1259
1870
|
const dim = hover !== null && hover !== s.repo;
|
|
1260
1871
|
return h('path', {
|
|
1261
1872
|
d,
|
|
1262
1873
|
fill: 'none',
|
|
1263
1874
|
stroke: s.color,
|
|
1875
|
+
strokeDasharray: [undefined, '6 4', '2 3'][Math.floor((rows || []).findIndex((r) => r.repository === s.repo) / SERIES_COLORS.length)],
|
|
1264
1876
|
strokeWidth: hover === s.repo ? 2.8 : 1.9,
|
|
1265
1877
|
strokeLinejoin: 'round',
|
|
1266
1878
|
strokeLinecap: 'round',
|
|
@@ -1274,17 +1886,22 @@
|
|
|
1274
1886
|
// 末端圆点
|
|
1275
1887
|
// 每条线末端用**项目头像**代替圆点(像行情软件在线尾标品牌)。
|
|
1276
1888
|
// 用 <clipPath> 裁成圆形;头像可能加载失败,失败时退化成同色圆点,绝不出现空白。
|
|
1277
|
-
|
|
1278
|
-
const
|
|
1279
|
-
const
|
|
1280
|
-
const cy = py(val(last));
|
|
1889
|
+
endpointLabels.map(({ s, cx: trueX, cy: trueY, labelY }, i) => {
|
|
1890
|
+
const cx = W - 14;
|
|
1891
|
+
const cy = labelY;
|
|
1281
1892
|
const r = 9;
|
|
1282
1893
|
const owner = String(s.repo).split('/')[0] || '?';
|
|
1283
1894
|
const clipId = 'hp-av-' + i;
|
|
1284
1895
|
const dim = hover !== null && hover !== s.repo;
|
|
1285
1896
|
return h(
|
|
1286
1897
|
'g',
|
|
1287
|
-
{ key: 'end' + s.repo, opacity: dim ? 0.25 : 1, style: { transition: 'opacity .15s' }
|
|
1898
|
+
{ key: 'end' + s.repo, opacity: dim ? 0.25 : 1, style: { transition: 'opacity .15s' },
|
|
1899
|
+
tabIndex: 0, role: 'img', 'aria-label': s.repo + ' · ≈' + signed(s.points.at(-1).gained),
|
|
1900
|
+
onMouseEnter: () => setHover(s.repo), onMouseLeave: () => setHover(null),
|
|
1901
|
+
onFocus: () => setHover(s.repo), onBlur: () => setHover(null) },
|
|
1902
|
+
h('title', null, s.repo + ' · ≈' + signed(s.points.at(-1).gained)),
|
|
1903
|
+
h('line', { className: 'hp-endpoint-link', x1: trueX, y1: trueY, x2: cx - r, y2: cy, stroke: s.color, strokeWidth: 1, opacity: 0.6 }),
|
|
1904
|
+
h('circle', { className: 'hp-endpoint-dot', cx: trueX, cy: trueY, r: 2.3, fill: s.color }),
|
|
1288
1905
|
h(
|
|
1289
1906
|
'defs',
|
|
1290
1907
|
null,
|
|
@@ -1294,6 +1911,7 @@
|
|
|
1294
1911
|
h('circle', { cx, cy, r: r + 1.6, fill: 'var(--hp-card-bg, #fff)', opacity: 0.9 }),
|
|
1295
1912
|
h('circle', { cx, cy, r, fill: s.color, opacity: 0.18 }),
|
|
1296
1913
|
h('image', {
|
|
1914
|
+
className: 'hp-endpoint-avatar',
|
|
1297
1915
|
href: BASE + '/avatar?owner=' + encodeURIComponent(owner),
|
|
1298
1916
|
x: cx - r,
|
|
1299
1917
|
y: cy - r,
|
|
@@ -1318,11 +1936,13 @@
|
|
|
1318
1936
|
className: 'hp-legend-item' + (hover === s.repo ? ' act' : ''),
|
|
1319
1937
|
onMouseEnter: () => setHover(s.repo),
|
|
1320
1938
|
onMouseLeave: () => setHover(null),
|
|
1939
|
+
onFocus: () => setHover(s.repo),
|
|
1940
|
+
onBlur: () => setHover(null),
|
|
1321
1941
|
onClick: () => toggle(s.repo),
|
|
1322
|
-
title: '
|
|
1942
|
+
title: s.repo + ' · ' + s.baseDate + ' → ' + s.points[s.points.length - 1].date,
|
|
1323
1943
|
},
|
|
1324
1944
|
h('span', { className: 'hp-legend-line', style: { background: s.color } }),
|
|
1325
|
-
h('span', null, s.repo.
|
|
1945
|
+
h('span', null, s.repo + ' · ' + s.baseDate + ' · ≈' + signed(s.points[s.points.length - 1].gained)),
|
|
1326
1946
|
),
|
|
1327
1947
|
),
|
|
1328
1948
|
),
|
|
@@ -1333,23 +1953,39 @@
|
|
|
1333
1953
|
return h(
|
|
1334
1954
|
'div',
|
|
1335
1955
|
{ className: 'hp-dash' },
|
|
1956
|
+
isDaily ? null : h('section', { className: 'hp-mchart-panel' },
|
|
1957
|
+
h('div', { className: 'hp-mchart-title' }, translate(lang, 'stats.gainTitle')),
|
|
1958
|
+
h('div', { className: 'hp-mchart-hint' }, translate(lang, 'stats.gainHint')),
|
|
1959
|
+
h('div', { className: 'hp-gain-list' }, (showAllGains ? rows || [] : (rows || []).slice(0, 5)).map((row) =>
|
|
1960
|
+
h('div', { className: 'hp-gain-row', key: row.repository },
|
|
1961
|
+
h('span', { className: 'hp-gain-name', title: row.repository }, row.rank + '. ' + row.repository),
|
|
1962
|
+
h('div', { className: 'hp-gain-track' }, h('div', { className: 'hp-gain-bar', style: { width: (Math.abs(row.starsGained) / maxGain * 100) + '%' } })),
|
|
1963
|
+
h('span', { className: 'hp-gain-value' }, (row.starsGained > 0 ? '+' : '') + row.starsGained.toLocaleString(lang === 'en' ? 'en-US' : 'zh-CN')),
|
|
1964
|
+
),
|
|
1965
|
+
)),
|
|
1966
|
+
rows && rows.length > 5 ? h('button', { className: 'hp-pick-btn', style: { marginTop: 12 }, onClick: () => setShowAllGains((v) => !v) },
|
|
1967
|
+
translate(lang, showAllGains ? 'stats.showLess' : 'stats.showAll', { n: rows.length })) : null,
|
|
1968
|
+
),
|
|
1336
1969
|
h(
|
|
1337
1970
|
'section',
|
|
1338
1971
|
{ className: 'hp-mchart-panel' },
|
|
1339
1972
|
h(
|
|
1340
1973
|
'div',
|
|
1341
1974
|
{ className: 'hp-mchart-head' },
|
|
1342
|
-
h('div', { className: 'hp-mchart-title' }, '
|
|
1975
|
+
h('div', { className: 'hp-mchart-title' }, translate(props.lang, isDaily ? 'stats.dailyTitle' : 'stats.chartTitle')),
|
|
1343
1976
|
// 不再放「总星数 / 本期涨幅」切换:上面的「日/周/月」已经决定了周期,
|
|
1344
1977
|
// 这里再让人选一次量纲就是重复决策。统一看「相对起点涨了多少」——
|
|
1345
1978
|
// 它才是「谁这段涨得猛」的可比量(总星数比的是历史体量,与周期无关)。
|
|
1346
|
-
h('div', { className: 'hp-mchart-hint' }, '
|
|
1979
|
+
h('div', { className: 'hp-mchart-hint' }, translate(props.lang, isDaily ? 'stats.dailyAxis' : frame && !frame.useLog ? 'stats.linearAxis' : 'stats.axisHint')),
|
|
1347
1980
|
),
|
|
1981
|
+
!Number.isFinite(windowStart) || !Number.isFinite(windowEnd)
|
|
1982
|
+
? h('div', { className: 'hp-mchart-note' }, translate(lang, 'stats.unknownWindow'))
|
|
1983
|
+
: h('div', { className: 'hp-mchart-note' }, props.window.start + ' → ' + props.window.end),
|
|
1348
1984
|
chartNode,
|
|
1349
1985
|
h(
|
|
1350
1986
|
'div',
|
|
1351
1987
|
{ className: 'hp-mchart-note' },
|
|
1352
|
-
'
|
|
1988
|
+
translate(lang, isDaily ? 'stats.dailyNote' : 'stats.sampleNote'),
|
|
1353
1989
|
),
|
|
1354
1990
|
),
|
|
1355
1991
|
ready ? picker : null,
|
|
@@ -1372,8 +2008,18 @@
|
|
|
1372
2008
|
const [detail, setDetail] = React.useState(null);
|
|
1373
2009
|
const [detailLoading, setDetailLoading] = React.useState(false);
|
|
1374
2010
|
const [detailError, setDetailError] = React.useState(null);
|
|
2011
|
+
const detailRequestRef = React.useRef(0);
|
|
1375
2012
|
const [theme, setTheme] = React.useState(readTheme);
|
|
1376
2013
|
const [lang, setLang] = React.useState(readLang);
|
|
2014
|
+
/**
|
|
2015
|
+
* 设置面板开关,以及「有没有新版本」。
|
|
2016
|
+
*
|
|
2017
|
+
* 更新检查每天最多一次(后端 /update 自己有 24h 缓存,这里再挡一层
|
|
2018
|
+
* localStorage,避免每次开面板都打请求)。
|
|
2019
|
+
* 有新版时设置按钮变高亮色,让用户一眼看到。
|
|
2020
|
+
*/
|
|
2021
|
+
const [showSettings, setShowSettings] = React.useState(false);
|
|
2022
|
+
const [updateInfo, setUpdateInfo] = React.useState(null);
|
|
1377
2023
|
const [lastFetch, setLastFetch] = React.useState(null);
|
|
1378
2024
|
/** { [repository]: { desc, chart } } —— 列表页卡片的简介与迷你走势 */
|
|
1379
2025
|
const [details, setDetails] = React.useState(readDetailsCache);
|
|
@@ -1451,10 +2097,11 @@
|
|
|
1451
2097
|
const generationRef = React.useRef(0);
|
|
1452
2098
|
const CHUNK = 6; // 每批条数:一屏 3 行卡片,够快看到结果
|
|
1453
2099
|
|
|
1454
|
-
const loadDetails = React.useCallback(async (repos, known, wantLang) => {
|
|
2100
|
+
const loadDetails = React.useCallback(async (repos, known, wantLang, attempt = 0) => {
|
|
1455
2101
|
if (!repos || repos.length === 0) return;
|
|
1456
2102
|
const list = repos.slice(0, 20);
|
|
1457
2103
|
const gen = generationRef.current;
|
|
2104
|
+
const pendingCharts = [];
|
|
1458
2105
|
|
|
1459
2106
|
// 把 20 个切成若干 6 个一批
|
|
1460
2107
|
const chunks = [];
|
|
@@ -1464,7 +2111,7 @@
|
|
|
1464
2111
|
// 每批开始前检查代际:用户中途换了榜单/语言,剩下的批次直接放弃
|
|
1465
2112
|
if (generationRef.current !== gen) return;
|
|
1466
2113
|
const chunk = chunks[ci];
|
|
1467
|
-
const batchKey = wantLang + '|' + chunk.join(',');
|
|
2114
|
+
const batchKey = gen + '|' + wantLang + '|' + chunk.join(',');
|
|
1468
2115
|
if (inflightRef.current.has(batchKey)) continue;
|
|
1469
2116
|
inflightRef.current.add(batchKey);
|
|
1470
2117
|
try {
|
|
@@ -1476,6 +2123,7 @@
|
|
|
1476
2123
|
// 这一批回来就立刻合并 → 界面马上多出 6 张有内容的卡
|
|
1477
2124
|
const next = {};
|
|
1478
2125
|
for (const it of body.items || []) {
|
|
2126
|
+
if (it.chartPending) pendingCharts.push(it.repository);
|
|
1479
2127
|
next[it.repository] = {
|
|
1480
2128
|
desc: it.desc,
|
|
1481
2129
|
descZh: it.descZh,
|
|
@@ -1492,10 +2140,17 @@
|
|
|
1492
2140
|
const keepOld = !fresh.desc && old && old.desc;
|
|
1493
2141
|
merged[repo] = keepOld
|
|
1494
2142
|
? { ...old, chart: fresh.chart || old.chart, avatar: fresh.avatar || old.avatar, settled: true }
|
|
1495
|
-
: { ...fresh, descZh: fresh.descZh || (old && old.descZh) || null };
|
|
2143
|
+
: { ...fresh, descZh: fresh.descZh || (old && old.desc === fresh.desc && old.descZh) || null };
|
|
1496
2144
|
}
|
|
1497
2145
|
return merged;
|
|
1498
2146
|
});
|
|
2147
|
+
if (wantLang === 'zh') {
|
|
2148
|
+
const needZh = chunk.filter((r) => {
|
|
2149
|
+
const entry = next[r] || known[r];
|
|
2150
|
+
return entry && entry.desc && !entry.descZh;
|
|
2151
|
+
});
|
|
2152
|
+
if (needZh.length > 0) void loadTranslations(needZh, gen);
|
|
2153
|
+
}
|
|
1499
2154
|
} catch (e) {
|
|
1500
2155
|
if (generationRef.current !== gen) return;
|
|
1501
2156
|
// 这一批失败:把还没取过的标成 settled,避免永远转圈
|
|
@@ -1510,32 +2165,26 @@
|
|
|
1510
2165
|
inflightRef.current.delete(batchKey);
|
|
1511
2166
|
}
|
|
1512
2167
|
}
|
|
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);
|
|
2168
|
+
if (pendingCharts.length > 0 && attempt < 3 && generationRef.current === gen) {
|
|
2169
|
+
setTimeout(() => {
|
|
2170
|
+
if (generationRef.current === gen) void loadDetails(pendingCharts, known, wantLang, attempt + 1);
|
|
2171
|
+
}, 2500);
|
|
1528
2172
|
}
|
|
1529
2173
|
}, [loadTranslations]);
|
|
1530
2174
|
|
|
2175
|
+
React.useEffect(() => () => {
|
|
2176
|
+
generationRef.current += 1;
|
|
2177
|
+
detailRequestRef.current += 1;
|
|
2178
|
+
}, []);
|
|
2179
|
+
|
|
1531
2180
|
React.useEffect(() => {
|
|
1532
2181
|
void loadBoards(false);
|
|
1533
2182
|
// 数据源每天只更新一次,所以不做分钟级轮询;只在**跨天**时自动静默刷新一次。
|
|
1534
2183
|
// 判断方式:记住首次加载那天的日期,页面开着跨过零点才重新取。
|
|
1535
|
-
|
|
2184
|
+
let openedDay = localDayKey();
|
|
1536
2185
|
const timer = setInterval(() => {
|
|
1537
2186
|
if (localDayKey() !== openedDay) {
|
|
1538
|
-
|
|
2187
|
+
openedDay = localDayKey();
|
|
1539
2188
|
void loadBoards(true);
|
|
1540
2189
|
}
|
|
1541
2190
|
}, 60 * 1000);
|
|
@@ -1548,19 +2197,22 @@
|
|
|
1548
2197
|
}, [boards, period]);
|
|
1549
2198
|
|
|
1550
2199
|
const openDetail = React.useCallback(async (item) => {
|
|
2200
|
+
const request = ++detailRequestRef.current;
|
|
1551
2201
|
setSelected(item);
|
|
1552
2202
|
setDetail(null);
|
|
1553
2203
|
setDetailError(null);
|
|
1554
2204
|
setDetailLoading(true);
|
|
1555
2205
|
try {
|
|
1556
|
-
const body = await getJson('/detail?repo=' + encodeURIComponent(item.repository));
|
|
2206
|
+
const body = await getJson('/detail?repo=' + encodeURIComponent(item.repository) + (lang === 'en' ? '&translate=0' : ''));
|
|
2207
|
+
if (request !== detailRequestRef.current) return;
|
|
1557
2208
|
setDetail(body.detail || null);
|
|
1558
2209
|
} catch (e) {
|
|
2210
|
+
if (request !== detailRequestRef.current) return;
|
|
1559
2211
|
setDetailError(String(e && e.message ? e.message : e));
|
|
1560
2212
|
} finally {
|
|
1561
|
-
setDetailLoading(false);
|
|
2213
|
+
if (request === detailRequestRef.current) setDetailLoading(false);
|
|
1562
2214
|
}
|
|
1563
|
-
}, []);
|
|
2215
|
+
}, [lang]);
|
|
1564
2216
|
|
|
1565
2217
|
const rows = board && Array.isArray(board.rows) ? board.rows : [];
|
|
1566
2218
|
const maxGain = React.useMemo(() => rows.reduce((m, r) => Math.max(m, r.starsGained || 0), 0), [rows]);
|
|
@@ -1597,6 +2249,24 @@
|
|
|
1597
2249
|
void loadDetails(repoListKey.split('|'), detailsRef.current, lang);
|
|
1598
2250
|
}, [repoListKey, lang, loadDetails]);
|
|
1599
2251
|
|
|
2252
|
+
/**
|
|
2253
|
+
* 榜单出来后就通知宿主**按排名顺序预翻译**。
|
|
2254
|
+
*
|
|
2255
|
+
* 用户此刻看到的是英文;等他点开第 1 名时,第 2、3、4 名正在依次翻译。
|
|
2256
|
+
* 只在中文模式下触发(英文模式不需要译文,省模型额度)。
|
|
2257
|
+
*/
|
|
2258
|
+
React.useEffect(() => {
|
|
2259
|
+
if (lang !== 'zh') return;
|
|
2260
|
+
if (!repoListKey) return;
|
|
2261
|
+
const key = 'dsh-github-hot:prewarm:' + localDayKey() + ':' + repoListKey.slice(0, 80);
|
|
2262
|
+
try {
|
|
2263
|
+
if (localStorage.getItem(key) === '1') return; // 今天这批已经预热过
|
|
2264
|
+
localStorage.setItem(key, '1');
|
|
2265
|
+
} catch (e) { /* 忽略 */ }
|
|
2266
|
+
const q = encodeURIComponent(repoListKey.split('|').join(','));
|
|
2267
|
+
void getJson('/prewarm?repos=' + q + '&limit=20').catch(() => {});
|
|
2268
|
+
}, [repoListKey, lang]);
|
|
2269
|
+
|
|
1600
2270
|
// 详情变化就落本地缓存(节流交给浏览器,量很小)
|
|
1601
2271
|
React.useEffect(() => {
|
|
1602
2272
|
if (Object.keys(details).length > 0) writeDetailsCache(details);
|
|
@@ -1620,6 +2290,51 @@
|
|
|
1620
2290
|
});
|
|
1621
2291
|
}, []);
|
|
1622
2292
|
|
|
2293
|
+
/**
|
|
2294
|
+
* 版本检查:打开面板时查一次,结果按天缓存。
|
|
2295
|
+
*
|
|
2296
|
+
* 只在「今天还没查过」时发请求;查失败不影响任何功能。
|
|
2297
|
+
*/
|
|
2298
|
+
React.useEffect(() => {
|
|
2299
|
+
let alive = true;
|
|
2300
|
+
const UPDATE_KEY = 'dsh-github-hot:update';
|
|
2301
|
+
const today = localDayKey();
|
|
2302
|
+
try {
|
|
2303
|
+
const raw = localStorage.getItem(UPDATE_KEY);
|
|
2304
|
+
if (raw) {
|
|
2305
|
+
const saved = JSON.parse(raw);
|
|
2306
|
+
// 同一天且查到过结果 → 直接用缓存
|
|
2307
|
+
if (saved && saved.day === today && saved.latest) {
|
|
2308
|
+
setUpdateInfo(saved);
|
|
2309
|
+
return undefined;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
} catch (e) { /* 忽略 */ }
|
|
2313
|
+
|
|
2314
|
+
getJson('/update')
|
|
2315
|
+
.then((r) => {
|
|
2316
|
+
if (!alive || !r || !r.latest) return;
|
|
2317
|
+
const next = { day: today, current: r.current, latest: r.latest, hasUpdate: r.hasUpdate };
|
|
2318
|
+
setUpdateInfo(next);
|
|
2319
|
+
try { localStorage.setItem(UPDATE_KEY, JSON.stringify(next)); } catch (e) { /* 忽略 */ }
|
|
2320
|
+
})
|
|
2321
|
+
.catch(() => { /* 检查失败静默 */ });
|
|
2322
|
+
|
|
2323
|
+
return () => { alive = false; };
|
|
2324
|
+
}, []);
|
|
2325
|
+
|
|
2326
|
+
/** 手动再查一次(点「立即检查」)。 */
|
|
2327
|
+
const recheckUpdate = React.useCallback(() => {
|
|
2328
|
+
setUpdateInfo((cur) => ({ ...(cur || {}), checking: true }));
|
|
2329
|
+
getJson('/update?force=1')
|
|
2330
|
+
.then((r) => {
|
|
2331
|
+
const next = { day: localDayKey(), current: r.current, latest: r.latest, hasUpdate: r.hasUpdate, error: r.error || null };
|
|
2332
|
+
setUpdateInfo(next);
|
|
2333
|
+
try { localStorage.setItem('dsh-github-hot:update', JSON.stringify(next)); } catch (e) { /* 忽略 */ }
|
|
2334
|
+
})
|
|
2335
|
+
.catch((e) => setUpdateInfo((cur) => ({ ...(cur || {}), checking: false, error: String(e && e.message ? e.message : e) })));
|
|
2336
|
+
}, []);
|
|
2337
|
+
|
|
1623
2338
|
/**
|
|
1624
2339
|
* 切到「中文」时,立刻给已有内容补译文 —— **不等重新取数**。
|
|
1625
2340
|
*
|
|
@@ -1645,6 +2360,8 @@
|
|
|
1645
2360
|
});
|
|
1646
2361
|
}, []);
|
|
1647
2362
|
|
|
2363
|
+
// 界面文案统一走 t(),切换语言时整个界面跟着变
|
|
2364
|
+
const t = (key, vars) => translate(lang, key, vars);
|
|
1648
2365
|
const periodLabel = board ? board.label : '—';
|
|
1649
2366
|
const dataOk = board && board.ok;
|
|
1650
2367
|
const dataStale = board && !board.ok && board.staleAt;
|
|
@@ -1664,15 +2381,15 @@
|
|
|
1664
2381
|
h(
|
|
1665
2382
|
'div',
|
|
1666
2383
|
{ 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') }, '
|
|
2384
|
+
h('button', { className: period === 'day' ? 'on' : '', onClick: () => setPeriod('day') }, t('period.day')),
|
|
2385
|
+
h('button', { className: period === 'week' ? 'on' : '', onClick: () => setPeriod('week') }, t('period.week')),
|
|
2386
|
+
h('button', { className: period === 'month' ? 'on' : '', onClick: () => setPeriod('month') }, t('period.month')),
|
|
1670
2387
|
),
|
|
1671
2388
|
h(
|
|
1672
2389
|
'div',
|
|
1673
2390
|
{ className: 'hp-seg' },
|
|
1674
|
-
h('button', { className: view === 'cards' ? 'on' : '', onClick: () => setView('cards'), title: '
|
|
1675
|
-
h('button', { className: view === 'dash' ? 'on' : '', onClick: () => setView('dash')
|
|
2391
|
+
h('button', { className: view === 'cards' ? 'on' : '', onClick: () => setView('cards'), title: t('misc.topByRank') }, t('view.board')),
|
|
2392
|
+
h('button', { className: view === 'dash' ? 'on' : '', onClick: () => setView('dash') }, t('view.stats')),
|
|
1676
2393
|
),
|
|
1677
2394
|
),
|
|
1678
2395
|
// 一句话说清「数据是哪天的」。不再罗列取回时刻与起止日期 —— 那对读的人没有意义。
|
|
@@ -1684,10 +2401,10 @@
|
|
|
1684
2401
|
{ title: lastFetch ? '本机取回于 ' + fmtTime(lastFetch) : '' },
|
|
1685
2402
|
h('span', { className: 'hp-dot ' + (dataOk ? 'ok' : dataStale ? 'warn' : 'bad') }),
|
|
1686
2403
|
dataOk
|
|
1687
|
-
? '
|
|
2404
|
+
? t('status.updatedTo', { date: dataDateText(board, lang) })
|
|
1688
2405
|
: dataStale
|
|
1689
|
-
? '
|
|
1690
|
-
: '
|
|
2406
|
+
? t('error.stale', { err: '', when: ago(board.staleAt) }).replace('()', '').replace('()', '')
|
|
2407
|
+
: t('empty.notFetched'),
|
|
1691
2408
|
),
|
|
1692
2409
|
),
|
|
1693
2410
|
h(
|
|
@@ -1698,16 +2415,30 @@
|
|
|
1698
2415
|
{
|
|
1699
2416
|
className: 'hp-btn hp-lang-btn',
|
|
1700
2417
|
onClick: toggleLang,
|
|
1701
|
-
title: lang === 'zh' ? '
|
|
2418
|
+
title: lang === 'zh' ? t('lang.tipZh') : t('lang.tipEn'),
|
|
1702
2419
|
},
|
|
1703
|
-
lang === 'zh' ? '
|
|
2420
|
+
lang === 'zh' ? t('lang.zh') : t('lang.en'),
|
|
1704
2421
|
),
|
|
1705
2422
|
h(
|
|
1706
2423
|
'button',
|
|
1707
|
-
{ className: 'hp-btn hp-theme-btn', onClick: toggleTheme, title: theme === 'paper' ? '
|
|
1708
|
-
theme === 'paper' ? '◐
|
|
2424
|
+
{ className: 'hp-btn hp-theme-btn', onClick: toggleTheme, title: theme === 'paper' ? t('theme.tipDark') : t('theme.tipPaper') },
|
|
2425
|
+
theme === 'paper' ? '◐ ' + t('theme.dark') : '◑ ' + t('theme.paper'),
|
|
2426
|
+
),
|
|
2427
|
+
h(
|
|
2428
|
+
'button',
|
|
2429
|
+
{
|
|
2430
|
+
// 有新版本时按钮变高亮(hp-btn-update),提示用户点进来
|
|
2431
|
+
className: 'hp-btn hp-settings-btn' + (updateInfo && updateInfo.hasUpdate ? ' hp-btn-update' : ''),
|
|
2432
|
+
onClick: () => setShowSettings((v) => !v),
|
|
2433
|
+
title: updateInfo && updateInfo.hasUpdate
|
|
2434
|
+
? t('settings.updateTip', { v: updateInfo.latest })
|
|
2435
|
+
: t('settings.tip'),
|
|
2436
|
+
},
|
|
2437
|
+
h('span', { className: 'hp-gear', dangerouslySetInnerHTML: { __html: GEAR_ICON_SVG } }),
|
|
2438
|
+
t('settings.title'),
|
|
2439
|
+
updateInfo && updateInfo.hasUpdate ? h('span', { className: 'hp-update-dot' }) : null,
|
|
1709
2440
|
),
|
|
1710
|
-
h('button', { className: 'hp-btn', onClick: () => void loadBoards(false), disabled: loading }, loading ? '
|
|
2441
|
+
h('button', { className: 'hp-btn', onClick: () => void loadBoards(false), disabled: loading }, loading ? t('action.refreshing') : t('action.refresh')),
|
|
1711
2442
|
),
|
|
1712
2443
|
);
|
|
1713
2444
|
|
|
@@ -1722,9 +2453,9 @@
|
|
|
1722
2453
|
body = h(
|
|
1723
2454
|
'div',
|
|
1724
2455
|
{ className: 'hp-empty' },
|
|
1725
|
-
h('strong', null, '
|
|
2456
|
+
h('strong', null, t('error.title')),
|
|
1726
2457
|
h('div', null, error),
|
|
1727
|
-
h('div', null, '
|
|
2458
|
+
h('div', null, t('error.hint')),
|
|
1728
2459
|
);
|
|
1729
2460
|
} else if (!dataOk && rows.length === 0) {
|
|
1730
2461
|
body = h(
|
|
@@ -1737,7 +2468,7 @@
|
|
|
1737
2468
|
} else if (view === 'dash') {
|
|
1738
2469
|
// 统计只看展示的那一批,与榜单页口径一致。
|
|
1739
2470
|
// details 是 { repo: { desc, chart } },这里抽成 { repo: chart } 再传 —— 图表组件只认曲线。
|
|
1740
|
-
body = h(Dashboard, { React, rows: visible, charts: chartMap, lang, periodKey: period });
|
|
2471
|
+
body = h(Dashboard, { React, rows: visible, charts: chartMap, lang, periodKey: period, window: board && board.window });
|
|
1741
2472
|
} else {
|
|
1742
2473
|
body = h(
|
|
1743
2474
|
'div',
|
|
@@ -1749,7 +2480,7 @@
|
|
|
1749
2480
|
h(
|
|
1750
2481
|
'div',
|
|
1751
2482
|
{ className: 'hp-hero-main' },
|
|
1752
|
-
h('div', { className: 'hp-hero-k' },
|
|
2483
|
+
h('div', { className: 'hp-hero-k' }, t('board.heroLabel', { period: periodLabel })),
|
|
1753
2484
|
h('div', { className: 'hp-hero-v' }, '+' + fmtCount(top20Gain)),
|
|
1754
2485
|
h(
|
|
1755
2486
|
'div',
|
|
@@ -1762,9 +2493,9 @@
|
|
|
1762
2493
|
h(
|
|
1763
2494
|
'div',
|
|
1764
2495
|
{ className: 'hp-hero-side' },
|
|
1765
|
-
h('div', { className: 'hp-stat-card rose' }, h('div', { className: 'hp-stat-k' }, '
|
|
1766
|
-
h('div', { className: 'hp-stat-card sky' }, h('div', { className: 'hp-stat-k' }, '
|
|
1767
|
-
h('div', { className: 'hp-stat-card sun' }, h('div', { className: 'hp-stat-k' }, '
|
|
2496
|
+
h('div', { className: 'hp-stat-card rose' }, h('div', { className: 'hp-stat-k' }, t('board.statAvg')), h('div', { className: 'hp-stat-v' }, '+' + fmtCount(Math.round(top20Gain / (visible.length || 1))))),
|
|
2497
|
+
h('div', { className: 'hp-stat-card sky' }, h('div', { className: 'hp-stat-k' }, t('board.statTop')), h('div', { className: 'hp-stat-v' }, visible.length ? '+' + fmtCount(visible[0].starsGained) : '—')),
|
|
2498
|
+
h('div', { className: 'hp-stat-card sun' }, h('div', { className: 'hp-stat-k' }, t('board.statCount')), h('div', { className: 'hp-stat-v' }, t('board.countUnit', { n: visible.length }))),
|
|
1768
2499
|
),
|
|
1769
2500
|
),
|
|
1770
2501
|
h(
|
|
@@ -1790,8 +2521,83 @@
|
|
|
1790
2521
|
'div',
|
|
1791
2522
|
{ className: 'hp-root hp-' + theme },
|
|
1792
2523
|
header,
|
|
1793
|
-
dataStale ? h('div', { className: 'hp-banner warn' }, '
|
|
1794
|
-
selected ? h(Detail, { React, item: selected, detail, loading: detailLoading, error: detailError, onClose: () => setSelected(null) }) : null,
|
|
2524
|
+
dataStale ? h('div', { className: 'hp-banner warn' }, t('error.stale', { err: board.error, when: ago(board.staleAt) })) : null,
|
|
2525
|
+
selected ? h(Detail, { React, lang, item: selected, detail, loading: detailLoading, error: detailError, onClose: () => { detailRequestRef.current += 1; setSelected(null); } }) : null,
|
|
2526
|
+
/**
|
|
2527
|
+
* 设置面板:点顶栏齿轮弹出,浮在内容之上。
|
|
2528
|
+
* 目前只放版本信息与更新提示,后续功能(显示偏好、缓存管理)加在这里。
|
|
2529
|
+
*/
|
|
2530
|
+
showSettings
|
|
2531
|
+
? h(
|
|
2532
|
+
'div',
|
|
2533
|
+
{ className: 'hp-settings-mask', onClick: (e) => { if (e.target === e.currentTarget) setShowSettings(false); } },
|
|
2534
|
+
h(
|
|
2535
|
+
'div',
|
|
2536
|
+
{ className: 'hp-settings' },
|
|
2537
|
+
// 头部:标题 + 关闭
|
|
2538
|
+
h('div', { className: 'hp-set-head' },
|
|
2539
|
+
h('span', { className: 'hp-set-title' }, t('settings.title')),
|
|
2540
|
+
h('button', { className: 'hp-set-x', onClick: () => setShowSettings(false), title: t('settings.close'), 'aria-label': t('settings.close') },
|
|
2541
|
+
h('span', { dangerouslySetInnerHTML: { __html: CLOSE_ICON_SVG } })),
|
|
2542
|
+
),
|
|
2543
|
+
// 版本卡片:当前版本是大字号主信息,与 npm 版本并排对比
|
|
2544
|
+
h('div', { className: 'hp-set-ver' },
|
|
2545
|
+
h('div', { className: 'hp-set-ver-item' },
|
|
2546
|
+
h('div', { className: 'hp-set-ver-k' }, t('settings.current')),
|
|
2547
|
+
h('div', { className: 'hp-set-ver-v' }, updateInfo && updateInfo.current ? updateInfo.current : '—'),
|
|
2548
|
+
),
|
|
2549
|
+
h('div', { className: 'hp-set-ver-arrow' }, '→'),
|
|
2550
|
+
h('div', { className: 'hp-set-ver-item' },
|
|
2551
|
+
h('div', { className: 'hp-set-ver-k' }, t('settings.latest')),
|
|
2552
|
+
h('div', {
|
|
2553
|
+
className: 'hp-set-ver-v' + (updateInfo && updateInfo.hasUpdate ? ' new' : ''),
|
|
2554
|
+
}, updateInfo && updateInfo.checking ? '…' : (updateInfo && updateInfo.latest ? updateInfo.latest : '—')),
|
|
2555
|
+
),
|
|
2556
|
+
),
|
|
2557
|
+
// 状态条:一眼看出「要不要升级」
|
|
2558
|
+
(() => {
|
|
2559
|
+
if (!updateInfo || !updateInfo.latest) {
|
|
2560
|
+
return updateInfo && updateInfo.error
|
|
2561
|
+
? h('div', { className: 'hp-set-note warn' }, t('settings.checkFailed', { msg: updateInfo.error }))
|
|
2562
|
+
: h('div', { className: 'hp-set-note' }, t('settings.checking'));
|
|
2563
|
+
}
|
|
2564
|
+
if (updateInfo.hasUpdate) {
|
|
2565
|
+
return h('div', { className: 'hp-set-note update' },
|
|
2566
|
+
h('span', { className: 'hp-update-dot' }),
|
|
2567
|
+
h('span', null, t('settings.updateTip', { v: updateInfo.latest })));
|
|
2568
|
+
}
|
|
2569
|
+
const cmp = cmpVersion(updateInfo.current, updateInfo.latest);
|
|
2570
|
+
if (cmp > 0) {
|
|
2571
|
+
return h('div', { className: 'hp-set-note local' },
|
|
2572
|
+
h('span', { className: 'hp-set-dot ok' }),
|
|
2573
|
+
h('span', null, t('settings.localAhead', { v: updateInfo.latest })));
|
|
2574
|
+
}
|
|
2575
|
+
return h('div', { className: 'hp-set-note ok' },
|
|
2576
|
+
h('span', { className: 'hp-set-dot ok' }),
|
|
2577
|
+
h('span', null, t('settings.upToDate')));
|
|
2578
|
+
})(),
|
|
2579
|
+
// 升级命令(仅在有新版时出现)
|
|
2580
|
+
updateInfo && updateInfo.hasUpdate
|
|
2581
|
+
? h('div', { className: 'hp-set-cmd' },
|
|
2582
|
+
h('div', { className: 'hp-set-cmd-k' }, t('settings.updateHint')),
|
|
2583
|
+
h('code', null, 'dsh plugin --profile desktop add dsh-github-hot@' + updateInfo.latest))
|
|
2584
|
+
: null,
|
|
2585
|
+
// 底部操作
|
|
2586
|
+
h('div', { className: 'hp-set-foot' },
|
|
2587
|
+
h('button', {
|
|
2588
|
+
className: 'hp-set-btn',
|
|
2589
|
+
onClick: () => void recheckUpdate(),
|
|
2590
|
+
disabled: updateInfo && updateInfo.checking,
|
|
2591
|
+
}, updateInfo && updateInfo.checking ? t('settings.checking') : t('settings.checkNow')),
|
|
2592
|
+
h('a', {
|
|
2593
|
+
className: 'hp-set-btn ghost',
|
|
2594
|
+
href: 'https://github.com/chiinsight/dsh-github-hot',
|
|
2595
|
+
target: '_blank', rel: 'noreferrer',
|
|
2596
|
+
}, t('settings.repo') + ' ↗'),
|
|
2597
|
+
),
|
|
2598
|
+
),
|
|
2599
|
+
)
|
|
2600
|
+
: null,
|
|
1795
2601
|
h('div', { className: 'hp-scroll' }, body),
|
|
1796
2602
|
/**
|
|
1797
2603
|
* 数据署名 —— **不是装饰,是法律要求**。
|