dsh-desktop-statusbar 2.2.1 → 2.2.2
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.en.md +12 -0
- package/README.md +12 -0
- package/docs/pricing.en.png +0 -0
- package/lib/balance-api.js +45 -3
- package/lib/client.js +118 -63
- package/lib/holiday.js +39 -6
- package/lib/index.js +3 -1
- package/package.json +1 -1
- package/tools/shot.cjs +5 -1
package/README.en.md
CHANGED
|
@@ -73,6 +73,18 @@ Both ways above install from the npm registry; the package name is `dsh-desktop-
|
|
|
73
73
|
npm view dsh-desktop-statusbar version
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
### When the built-in updater fails
|
|
77
|
+
|
|
78
|
+
The one-click update button inside the plugin can fail on some desktop builds — it either does nothing or reports a failure. Do not keep clicking it. Use the official install entry instead and **append `@version` to the package name** to pin the exact version:
|
|
79
|
+
|
|
80
|
+
```powershell
|
|
81
|
+
dsh plugin --profile desktop add dsh-desktop-statusbar@2.2.2
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The same string works in the plugin panel — type `dsh-desktop-statusbar@2.2.2` into the **Add plugin** field.
|
|
85
|
+
|
|
86
|
+
Use whatever version you see (the version on the release page, or the output of the `npm view` command above). Pinning the version has a second benefit: **you get exactly the build you asked for**, with no chance of a stale cached package.
|
|
87
|
+
|
|
76
88
|
Also listed on [dshmarket](https://awesome-dsh-plugin.com/p/raphael-y7/dsh-desktop-statusbar/), [dshfind](https://dshfind.com/zh/plugins/raphael-y7/dsh-desktop-statusbar) and [1024Store](https://deepseek1024.com/plugins/Rapheal-Y7/dsh-desktop-statusbar).
|
|
77
89
|
|
|
78
90
|
## Usage
|
package/README.md
CHANGED
|
@@ -73,6 +73,18 @@ dsh plugin --profile desktop add dsh-desktop-statusbar
|
|
|
73
73
|
npm view dsh-desktop-statusbar version
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
### 插件自带的更新失效时
|
|
77
|
+
|
|
78
|
+
插件里那个一键更新按钮,在某些桌面端版本上会失败(点了没反应、或者直接显示失败)。这时不要反复点,改用官方安装入口、**在包名后面加上 `@版本号`** 指定到具体版本,就能精确装上:
|
|
79
|
+
|
|
80
|
+
```powershell
|
|
81
|
+
dsh plugin --profile desktop add dsh-desktop-statusbar@2.2.2
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
插件面板里同样可以这样填 —— 在「添加插件」的输入框里写 `dsh-desktop-statusbar@2.2.2`。
|
|
85
|
+
|
|
86
|
+
版本号填你看到的最新版本即可(发布页右侧的版本号,或上面那条 `npm view` 命令的输出)。带上版本号还有一个好处:**装上的就是你想要的那一版**,不会因为缓存拿到旧包。
|
|
87
|
+
|
|
76
88
|
本插件亦收录于 [dshmarket](https://awesome-dsh-plugin.com/p/raphael-y7/dsh-desktop-statusbar/)、[dshfind](https://dshfind.com/zh/plugins/raphael-y7/dsh-desktop-statusbar) 与 [1024Store](https://deepseek1024.com/plugins/Rapheal-Y7/dsh-desktop-statusbar)。
|
|
77
89
|
|
|
78
90
|
## 使用
|
package/docs/pricing.en.png
CHANGED
|
Binary file
|
package/lib/balance-api.js
CHANGED
|
@@ -11,6 +11,26 @@
|
|
|
11
11
|
* { currency: 'CNY' | 'USD' | ..., total: '12.34', parts: [{ label, amount }] }
|
|
12
12
|
* currency 缺失时由调用方按站点/域名推断;parts 用来填气泡里的构成行(充值 / 赠金等)。 */
|
|
13
13
|
|
|
14
|
+
/* 积分套餐的上游数据上限(见下面 workbuddy 的 parse)。
|
|
15
|
+
* 名字长度限制的是下游 creditDisplayName 的前缀剥离开销(O(n²)):实测 500KB 名字
|
|
16
|
+
* 会让浏览器主线程卡 18 秒。200 字符远大于任何真实包名(最长的也只有 16 字)。
|
|
17
|
+
* 条数限制的是渲染开销:这个列表在底栏的每秒重渲染路径上,没有 memo。 */
|
|
18
|
+
const CREDITS_NAME_MAX = 200;
|
|
19
|
+
const CREDITS_MAX_PACKAGES = 256;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 字符串的短哈希(base36,约 6~7 位),O(n)、无依赖。
|
|
23
|
+
* 只用于给截断后的超长包名加一个「不同原名不会碰撞」的标记 —— 不是密码学哈希,
|
|
24
|
+
* 也不需要抗碰撞攻击:这里要防的是「两个正常上游数据被截成同名」,不是恶意构造。
|
|
25
|
+
*/
|
|
26
|
+
function nameHash(text) {
|
|
27
|
+
let hash = 5381;
|
|
28
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
29
|
+
hash = ((hash * 33) ^ text.charCodeAt(i)) >>> 0;
|
|
30
|
+
}
|
|
31
|
+
return hash.toString(36);
|
|
32
|
+
}
|
|
33
|
+
|
|
14
34
|
const adapters = [
|
|
15
35
|
{
|
|
16
36
|
id: 'deepseek',
|
|
@@ -27,6 +47,9 @@ const adapters = [
|
|
|
27
47
|
const infos = Array.isArray(data && data.balance_infos) ? data.balance_infos : [];
|
|
28
48
|
if (infos.length === 0) return null;
|
|
29
49
|
const one = infos[0];
|
|
50
|
+
/* 数组元素可能是 null(上游坏数据):直接读 total_balance 会抛 TypeError,
|
|
51
|
+
整条余额就永久变横杠,所以先挡一道。 */
|
|
52
|
+
if (one === null || one === undefined || typeof one !== 'object') return null;
|
|
30
53
|
const total = typeof one.total_balance === 'string' ? one.total_balance : null;
|
|
31
54
|
if (total === null || total.length === 0) return null;
|
|
32
55
|
const parts = [];
|
|
@@ -194,6 +217,9 @@ const adapters = [
|
|
|
194
217
|
let percent = null;
|
|
195
218
|
for (let i = 0; i < limits.length; i += 1) {
|
|
196
219
|
const one = limits[i];
|
|
220
|
+
/* 数组里可能是 null(上游坏数据):直接读 one.type 会抛 TypeError,
|
|
221
|
+
整条余额就永久变横杠了,所以先挡一道。 */
|
|
222
|
+
if (one === null || one === undefined || typeof one !== 'object') continue;
|
|
197
223
|
const kind = typeof one.type === 'string' ? one.type.toUpperCase() : '';
|
|
198
224
|
if (kind !== 'TOKENS_LIMIT' && kind !== 'CREDIT_LIMIT') continue;
|
|
199
225
|
const value = Number(one.percentage);
|
|
@@ -249,7 +275,11 @@ const adapters = [
|
|
|
249
275
|
const packages = Array.isArray(credits.packages) ? credits.packages : null;
|
|
250
276
|
if (packages === null) return out;
|
|
251
277
|
const rewards = [];
|
|
252
|
-
|
|
278
|
+
/* 条数上限:上游返回多少条不由我们决定,而列表渲染在底栏的每秒重渲染路径上
|
|
279
|
+
(无 memo),几万条会把界面拖死(实测 10 万条 ≈ 0.7 秒/帧)。
|
|
280
|
+
真实账号最多二三十个包,256 足够宽松,超出的直接忽略。 */
|
|
281
|
+
const count = Math.min(packages.length, CREDITS_MAX_PACKAGES);
|
|
282
|
+
for (let i = 0; i < count; i += 1) {
|
|
253
283
|
const one = packages[i];
|
|
254
284
|
if (one === null || one === undefined || typeof one !== 'object') continue;
|
|
255
285
|
const size = Number(one.size);
|
|
@@ -257,8 +287,20 @@ const adapters = [
|
|
|
257
287
|
/* size/remain 缺一个就没法算「已用/总量」:跳过这个包,
|
|
258
288
|
不让一条坏数据把整条气泡弄崩(旧插件/上游漂移都可能缺字段)。 */
|
|
259
289
|
if (Number.isFinite(size) !== true || Number.isFinite(remain) !== true || size < 0) continue;
|
|
260
|
-
const
|
|
261
|
-
if (
|
|
290
|
+
const rawName = typeof one.packageName === 'string' ? one.packageName : '';
|
|
291
|
+
if (rawName.length === 0) continue;
|
|
292
|
+
/* 包名长度上限:上游是一条外部 API 响应,名字多长不由我们决定。
|
|
293
|
+
下游 creditDisplayName 要按前缀反复剥,超长名字是 O(n²)(实测 500KB 名字
|
|
294
|
+
能让浏览器主线程卡 18 秒);标签行也放不下。200 字符足够覆盖任何真实包名
|
|
295
|
+
(最长的「CodeBuddy个人版国内运营裂变包」也只有 16 字)。
|
|
296
|
+
**截断必须保证不同原名不碰撞 + 保持截断开销 O(n)**:客户端会把「显示名相邻
|
|
297
|
+
且相同」的包合并成 ×N 一行,若两个前缀相同、后缀不同的超长包被截成同名,
|
|
298
|
+
就会被误合并、显示伪造的合计(防御代码自己制造错误数据)。
|
|
299
|
+
只用「原始长度」不够 —— 长度相同的两个不同名字照样碰撞,所以带上一段
|
|
300
|
+
由全文算出的短哈希。 */
|
|
301
|
+
const name = rawName.length > CREDITS_NAME_MAX
|
|
302
|
+
? rawName.slice(0, CREDITS_NAME_MAX) + '…(' + rawName.length + ':' + nameHash(rawName) + ')'
|
|
303
|
+
: rawName;
|
|
262
304
|
const monthly = one.monthly === true;
|
|
263
305
|
rewards.push({
|
|
264
306
|
name,
|
package/lib/client.js
CHANGED
|
@@ -74,7 +74,7 @@ window.__ModuleLoader__.load({
|
|
|
74
74
|
*/
|
|
75
75
|
const CONFIG_VERSION = 6;
|
|
76
76
|
/** 插件版本:显示在设置页底部,用来确认页面加载的是哪一版客户端代码。 */
|
|
77
|
-
const VERSION = "2.2.
|
|
77
|
+
const VERSION = "2.2.2";
|
|
78
78
|
/** 官方参考价的版本:官方调价时改这个数字,老价格库会自动并入新参考价。 */
|
|
79
79
|
const PRICE_VERSION = 2;
|
|
80
80
|
/** 项目主页:设置页那个「去 GitHub 点 star」按钮跳这里(与 package.json 的 repository 同址)。 */
|
|
@@ -2388,13 +2388,19 @@ window.__ModuleLoader__.load({
|
|
|
2388
2388
|
}
|
|
2389
2389
|
|
|
2390
2390
|
/**
|
|
2391
|
-
*
|
|
2391
|
+
* 纯文字提示能不能完整放进「可用区间」(用户拍板:**放不下就不显示,绝不缩小**)。
|
|
2392
|
+
*
|
|
2393
|
+
* 历史:以前放不下时会等比缩小到放得下(下限 0.5)。后来用户明确要求
|
|
2394
|
+
* 「工具名很长而气泡不够长时,所有悬停提示一律不出现」,缩小这条路就没意义了 ——
|
|
2395
|
+
* 缩到 5.5px 的文字本来就是不可读的,不如整组不显示。所以这里只回答「放得下吗」,
|
|
2396
|
+
* 不再算缩放比例。
|
|
2397
|
+
*
|
|
2392
2398
|
* 可用区间 = plainBoundsOf 给的 min~max(再与视口夹);没有 bounds 就退回视口两侧各留 8px。
|
|
2393
|
-
*
|
|
2394
|
-
*
|
|
2399
|
+
* 拿不到可用宽度(无 bounds 且无视口 / 区间为负)时返回 true:
|
|
2400
|
+
* 量不出来就别关,宁可显示也不误伤。
|
|
2395
2401
|
*/
|
|
2396
|
-
function
|
|
2397
|
-
if (typeof textWidth !== "number" || Number.isFinite(textWidth) !== true || textWidth <= 0) return
|
|
2402
|
+
function plainFits(textWidth, bounds, viewportWidth) {
|
|
2403
|
+
if (typeof textWidth !== "number" || Number.isFinite(textWidth) !== true || textWidth <= 0) return true;
|
|
2398
2404
|
const viewport = typeof viewportWidth === "number" && viewportWidth > 0 ? viewportWidth : 0;
|
|
2399
2405
|
let min = 8;
|
|
2400
2406
|
let max = viewport > 0 ? viewport - 8 : null;
|
|
@@ -2402,12 +2408,11 @@ window.__ModuleLoader__.load({
|
|
|
2402
2408
|
min = Math.max(min, bounds.min);
|
|
2403
2409
|
if (max === null || bounds.max < max) max = bounds.max;
|
|
2404
2410
|
}
|
|
2405
|
-
if (max === null) return
|
|
2411
|
+
if (max === null) return true;
|
|
2406
2412
|
const available = max - min;
|
|
2407
|
-
/*
|
|
2408
|
-
if (available <= 0) return
|
|
2409
|
-
|
|
2410
|
-
return Math.max(0.5, available / textWidth);
|
|
2413
|
+
/* 区间本身就是负的(标题右边界已经越过色条右边):没有可用的横向空间,量不出结论 */
|
|
2414
|
+
if (available <= 0) return true;
|
|
2415
|
+
return textWidth <= available;
|
|
2411
2416
|
}
|
|
2412
2417
|
|
|
2413
2418
|
/**
|
|
@@ -2844,7 +2849,7 @@ window.__ModuleLoader__.load({
|
|
|
2844
2849
|
style: { width: one.width + "%", backgroundColor: one.color }
|
|
2845
2850
|
};
|
|
2846
2851
|
if (opened !== null && sub !== undefined && sub !== null && typeof sub.hover === "function"
|
|
2847
|
-
&& one.name !== undefined) {
|
|
2852
|
+
&& one.name !== undefined && sub.hoverSuppressed !== true) {
|
|
2848
2853
|
/* 用户拍板:不要气泡,只显示一行文字(工具名 + 占比),贴在鼠标上方最近处 */
|
|
2849
2854
|
segProps.onMouseEnter = (event) => sub.hover(event, {
|
|
2850
2855
|
plain: one.name + " " + one.percentText,
|
|
@@ -3232,6 +3237,46 @@ window.__ModuleLoader__.load({
|
|
|
3232
3237
|
return out.length === 0 ? name : out;
|
|
3233
3238
|
}
|
|
3234
3239
|
|
|
3240
|
+
/* 裂变包的名字特征:同名包会聚合成「×N」一行、动辄十几条,排在中间会把正经套餐挤下去。
|
|
3241
|
+
只按名字里有没有这两个字判断,不查任何外部清单 —— 上游新增什么裂变包都不用改代码。 */
|
|
3242
|
+
const CREDITS_SPLIT_KEYWORD = "裂变";
|
|
3243
|
+
|
|
3244
|
+
/**
|
|
3245
|
+
* 积分套餐的显示顺序(用户拍板,三档):
|
|
3246
|
+
* ① **每月固定刷新的套餐永远第一行** —— 月度包是天天要看的那个,位置不能漂;
|
|
3247
|
+
* ② **裂变包永远最后** —— 它们会聚合成「×11」这种一行,放在中间太占位;
|
|
3248
|
+
* ③ 其余按**过期时间先后**排,最先要过期的排最前 —— 快用完的用户得先看见。
|
|
3249
|
+
* 每一档内部同样按日期升序;**没有日期的排在同档有日期的之后**(不猜日期,也不让它插到前面)。
|
|
3250
|
+
* 日期取哪个字段跟着套餐性质走:月度包看「下期刷新」,一次性包看「到期」。
|
|
3251
|
+
* Array.prototype.sort 稳定,日期相同或都缺日期时保持上游原顺序,不会每次刷新跳来跳去。
|
|
3252
|
+
*/
|
|
3253
|
+
function creditsSortGroup(one) {
|
|
3254
|
+
if (one.monthly === true) return 0;
|
|
3255
|
+
if (String(one.name).indexOf(CREDITS_SPLIT_KEYWORD) !== -1) return 2;
|
|
3256
|
+
return 1;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
/** 排序用的日期:月度包取刷新日、其余取到期日;都没有给空串(排在有日期的之后)。 */
|
|
3260
|
+
function creditsSortDate(one) {
|
|
3261
|
+
const value = one.monthly === true ? one.refreshAt : one.expireAt;
|
|
3262
|
+
return typeof value === "string" && value.length > 0 ? value : "";
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
function creditsSort(rows) {
|
|
3266
|
+
return rows.slice().sort((a, b) => {
|
|
3267
|
+
const groupA = creditsSortGroup(a);
|
|
3268
|
+
const groupB = creditsSortGroup(b);
|
|
3269
|
+
if (groupA !== groupB) return groupA - groupB;
|
|
3270
|
+
const dateA = creditsSortDate(a);
|
|
3271
|
+
const dateB = creditsSortDate(b);
|
|
3272
|
+
if (dateA === dateB) return 0;
|
|
3273
|
+
/* 空串(没有日期)恒排在最后 */
|
|
3274
|
+
if (dateA.length === 0) return 1;
|
|
3275
|
+
if (dateB.length === 0) return -1;
|
|
3276
|
+
return dateA < dateB ? -1 : 1;
|
|
3277
|
+
});
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3235
3280
|
/** 两个 ISO 日期里取更早的那个(null 不参与);合并相邻同名包时「只显示最近到期日」。 */
|
|
3236
3281
|
function earliestDate(a, b) {
|
|
3237
3282
|
if (a === null || a === undefined) return b === undefined ? null : b;
|
|
@@ -3243,8 +3288,9 @@ window.__ModuleLoader__.load({
|
|
|
3243
3288
|
* 积分套餐面板数据:`{ title, totalText, rewards[] }`。
|
|
3244
3289
|
* 每个套餐带上显示用字段(label / valueText / percent),渲染函数只负责画。
|
|
3245
3290
|
* **相邻且显示名相同**的套餐合并成一行:used/total 相加、percent 按合计重算、count 记原条数;
|
|
3246
|
-
* 日期只取最近的一个(monthly 取最早 refreshAt,否则取最早 expireAt
|
|
3247
|
-
*
|
|
3291
|
+
* 日期只取最近的一个(monthly 取最早 refreshAt,否则取最早 expireAt),不显示区间。
|
|
3292
|
+
* 顺序:合并完再交给 `creditsSort` 排三档(月度置顶 / 其余按到期升序 / 裂变包置底)。
|
|
3293
|
+
* 字段缺失(name 不是非空字符串)的套餐直接跳过。
|
|
3248
3294
|
* percent = clamp(used / total × 100, 0, 100),total<=0 时给 0(画空轨道)。
|
|
3249
3295
|
*/
|
|
3250
3296
|
function creditsView(rewards, totalText, t) {
|
|
@@ -3287,7 +3333,9 @@ window.__ModuleLoader__.load({
|
|
|
3287
3333
|
expireAt: expireAt, refreshAt: refreshAt, monthly: monthly, items: [item]
|
|
3288
3334
|
});
|
|
3289
3335
|
});
|
|
3290
|
-
|
|
3336
|
+
/* 顺序:**先按原顺序聚合相邻同名包,最后再整体排序** —— 不能先排序,
|
|
3337
|
+
排序会把这 11 个裂变包按日期打散,聚合就失效了(它们必须相邻才能并成一行)。 */
|
|
3338
|
+
const rows = creditsSort(merged.map((one) => {
|
|
3291
3339
|
/* 该组剩余积分 = 总容量 - 已用(不小于 0);下钻标题右侧显示它,随用量动态变化 */
|
|
3292
3340
|
const remaining = Math.max(0, one.total - one.used);
|
|
3293
3341
|
return {
|
|
@@ -3305,7 +3353,7 @@ window.__ModuleLoader__.load({
|
|
|
3305
3353
|
valueText: t("tipCreditsUsed", { used: formatCredits(one.used), total: formatCredits(one.total) }),
|
|
3306
3354
|
percent: one.total > 0 ? Math.max(0, Math.min(100, (one.used / one.total) * 100)) : 0
|
|
3307
3355
|
};
|
|
3308
|
-
});
|
|
3356
|
+
}));
|
|
3309
3357
|
return { title: t("tipCreditsTitle"), totalText: totalText, rewards: rows };
|
|
3310
3358
|
}
|
|
3311
3359
|
|
|
@@ -4115,7 +4163,14 @@ window.__ModuleLoader__.load({
|
|
|
4115
4163
|
/* box-sizing:border-box 必须留着:内联宽度要含 padding,否则面板右边缘会比对齐点偏出 22px */
|
|
4116
4164
|
".dsb-tip{position:fixed;box-sizing:border-box;padding:6px 10px;border-radius:var(--dsw-radius-lg,16px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f));background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3));white-space:nowrap;z-index:40;pointer-events:auto}",
|
|
4117
4165
|
".dsb-sep{color:var(--dsw-alias-separator-primary);margin:0 6px}",
|
|
4118
|
-
/*
|
|
4166
|
+
/* 整块禁选(用户拍板):底栏、底栏气泡、设置页按住鼠标拖动会刷出蓝色选中底,
|
|
4167
|
+
看着像出了 bug。这三处都是插件自己画的纯展示/操作界面,用户没有复制文字的需求;
|
|
4168
|
+
::selection 一并写成透明 —— 只写 user-select:none 时,某些内核在
|
|
4169
|
+
"先按下再拖动"的路径上仍会画一层选中色。 */
|
|
4170
|
+
".dsb-root,.dsb-tip,.dsb-settings,.dsb-hover-plain,.dsb-drag-card{user-select:none;-webkit-user-select:none}",
|
|
4171
|
+
".dsb-root ::selection,.dsb-tip ::selection,.dsb-settings ::selection,.dsb-hover-plain::selection,.dsb-drag-card ::selection{background:transparent;color:inherit}",
|
|
4172
|
+
/* 设置页里的输入框要能选中/编辑(改价格那几个 number 框):单独放开 */
|
|
4173
|
+
".dsb-settings input,.dsb-settings textarea{user-select:text;-webkit-user-select:text}",
|
|
4119
4174
|
/* 这里的 14px 是面板内所有文字的继承来源;挂到 body 的跟手浮层继承不到它,
|
|
4120
4175
|
那边(.dsb-drag-card)自己写死了一份 —— 改这里要同步那边。 */
|
|
4121
4176
|
".dsb-settings{display:flex;flex-direction:column;gap:18px;font-size:14px}",
|
|
@@ -4761,14 +4816,13 @@ window.__ModuleLoader__.load({
|
|
|
4761
4816
|
const [hoverPos, setHoverPos] = react.useState(null);
|
|
4762
4817
|
/* 工具彩条段悬停的「无气泡纯文字」内容(工具名 + 占比);有值时只渲染一行文字 */
|
|
4763
4818
|
const [hoverPlain, setHoverPlain] = react.useState(null);
|
|
4819
|
+
/* 工具名悬停提示**整组开关**(用户拍板):当前气泡放不下那行小字时置 true,
|
|
4820
|
+
此后所有工具名的悬停都不再挂 onMouseEnter。由气泡定位 effect 量出实际宽度后判定
|
|
4821
|
+
(见 plainFits);换气泡 / 换子视图时由那条 effect 重新得出结论。 */
|
|
4822
|
+
const [hoverPlainOff, setHoverPlainOff] = react.useState(false);
|
|
4764
4823
|
/* 纯文字提示的固定垂直位置(与气泡标题行文字居中对齐):定位 effect 算好后存这里,
|
|
4765
4824
|
段内左右移动时直接复用,保证「只左右移动、不上下移动」且不抖。 */
|
|
4766
4825
|
const hoverPlainTopRef = react.useRef(null);
|
|
4767
|
-
/* 纯文字提示的**未缩放原始宽度**(px):段内移动时按它算缩放比例。
|
|
4768
|
-
不能拿 getBoundingClientRect().width —— 那个节点此时已经带 `transform: scale(x)`,
|
|
4769
|
-
量到的是**缩放后**的视觉宽,再乘一次比例就会越缩越小、来回振荡(长 MCP 工具名
|
|
4770
|
-
+ 窄窗时可见)。定位 effect 在还没写 transform 的那一帧量一次存这里。 */
|
|
4771
|
-
const hoverPlainWidthRef = react.useRef(0);
|
|
4772
4826
|
/* 纯文字提示的水平夹取范围:左 = 「工具调用」标题右边界 + 1ch,右 = 色条右边界
|
|
4773
4827
|
(再与视口右缘取小)。定位 effect 里算一次存这里,段内移动时复用。 */
|
|
4774
4828
|
const hoverPlainBoundsRef = react.useRef(null);
|
|
@@ -4833,25 +4887,13 @@ window.__ModuleLoader__.load({
|
|
|
4833
4887
|
if (node.style === undefined || node.style === null) return;
|
|
4834
4888
|
const size = node.getBoundingClientRect();
|
|
4835
4889
|
if (hoverPlain !== null) {
|
|
4836
|
-
/* 纯文字提示:水平夹在「标题右边界 ~
|
|
4837
|
-
|
|
4838
|
-
一般情况 scale 就是 1、不做任何缩放。
|
|
4839
|
-
⚠ 宽度必须用**未缩放**的原始宽,两个来源都行、但都不能含 transform:
|
|
4840
|
-
① hoverPlainWidthRef(定位 effect 用 offsetWidth 存好的);
|
|
4841
|
-
② 现场量 node.offsetWidth(同样不含 transform)。
|
|
4842
|
-
绝不能用 getBoundingClientRect().width —— 此时节点可能已经带着
|
|
4843
|
-
transform: scale(),量到的是缩放后的视觉宽,再乘一次比例就会越缩越小。 */
|
|
4890
|
+
/* 纯文字提示:水平夹在「标题右边界 ~ 色条右边界」之间(放不放得下已在定位 effect
|
|
4891
|
+
判过,能走到这里就说明放得下,不做任何缩放)。宽度用 offsetWidth(不含 transform)。 */
|
|
4844
4892
|
const layoutWidth = typeof node.offsetWidth === "number" && node.offsetWidth > 0
|
|
4845
4893
|
? node.offsetWidth
|
|
4846
4894
|
: size.width;
|
|
4847
|
-
const
|
|
4848
|
-
const scale = plainFitScale(natural, hoverPlainBoundsRef.current, window.innerWidth);
|
|
4849
|
-
const shown = scale === 1 ? natural : Math.round(natural * scale);
|
|
4850
|
-
const left = clampPlainLeft(event.clientX - shown / 2, shown, hoverPlainBoundsRef.current, window.innerWidth);
|
|
4895
|
+
const left = clampPlainLeft(event.clientX - layoutWidth / 2, layoutWidth, hoverPlainBoundsRef.current, window.innerWidth);
|
|
4851
4896
|
node.style.left = left + "px";
|
|
4852
|
-
/* 缩放以左端为基准:left 已经按缩小后的宽度算好,transform 原点放左上角才对得上 */
|
|
4853
|
-
node.style.transform = scale === 1 ? "" : "scale(" + scale + ")";
|
|
4854
|
-
node.style.transformOrigin = scale === 1 ? "" : "left center";
|
|
4855
4897
|
/* 上下固定:沿用 title 行居中算出的 top */
|
|
4856
4898
|
if (hoverPlainTopRef.current !== null) node.style.top = hoverPlainTopRef.current + "px";
|
|
4857
4899
|
} else {
|
|
@@ -5143,7 +5185,8 @@ window.__ModuleLoader__.load({
|
|
|
5143
5185
|
只升不降(回退会让行数在两档之间来回跳);换字段 / 开子视图 / 返回时都重置回 3。
|
|
5144
5186
|
到 12 行仍放不下就停在 12,横向滚动兜底(不再折行)。
|
|
5145
5187
|
量的是 DOM 几何,测试替身里拿不到就一律当放得下(量测函数自己兜住)。 */
|
|
5146
|
-
const
|
|
5188
|
+
const overflow = toolColumnsOverflow(node);
|
|
5189
|
+
const nextRows = nextToolRows(toolRows, overflow);
|
|
5147
5190
|
if (nextRows !== toolRows) setToolRows(nextRows);
|
|
5148
5191
|
const centered = anchor.left + anchor.width / 2 - width / 2;
|
|
5149
5192
|
/* 左右两条硬约束,任何气泡都适用,而且是**对齐**不是仅仅不越界:
|
|
@@ -5243,25 +5286,28 @@ window.__ModuleLoader__.load({
|
|
|
5243
5286
|
: panel.getBoundingClientRect();
|
|
5244
5287
|
const bounds = plainBoundsOf(titleBox, barBox, window.innerWidth);
|
|
5245
5288
|
hoverPlainBoundsRef.current = bounds;
|
|
5246
|
-
/*
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
所以它会重跑;重跑时节点上还留着上一帧写的 transform: scale(x),
|
|
5250
|
-
用 getBoundingClientRect 量到的是缩放后的宽 → ref 被写小 → 下次更小
|
|
5251
|
-
→ 悬停文字一路缩到 0.5 下限(实测自然宽 600 被写成 300)。
|
|
5252
|
-
offsetWidth 不受 transform 影响,重跑多少次都是自然宽。
|
|
5253
|
-
offsetWidth 是整数且老浏览器/替身里可能没有,取不到就退回 size.width。 */
|
|
5289
|
+
/* 提示文字的自然宽度。用 offsetWidth(布局宽度)而不是
|
|
5290
|
+
getBoundingClientRect().width:后者含 transform,而这条 effect 的依赖里有
|
|
5291
|
+
hoverMouse(鼠标一动就重跑),量到缩放后的宽会越缩越小。 */
|
|
5254
5292
|
const layoutWidth = typeof node.offsetWidth === "number" && node.offsetWidth > 0
|
|
5255
5293
|
? node.offsetWidth
|
|
5256
5294
|
: size.width;
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5295
|
+
/* 放不下就**整组不显示**(用户拍板):工具名很长、气泡不够长时,
|
|
5296
|
+
这行小字会压到别的内容上或被裁掉。以前是等比缩小硬塞进去,
|
|
5297
|
+
现在改成关掉 —— 缩到 5.5px 本来就读不清,不如不显示。
|
|
5298
|
+
一旦这个气泡放不下,**所有**工具名的悬停提示都关(不是只关超长那个),
|
|
5299
|
+
否则只有一部分能弹,看起来更像故障。 */
|
|
5300
|
+
if (plainFits(layoutWidth, bounds, window.innerWidth) !== true) {
|
|
5301
|
+
if (hoverPlainOff !== true) setHoverPlainOff(true);
|
|
5302
|
+
/* 内容也当场清掉:不留半行字孤零零挂在屏幕上 */
|
|
5303
|
+
setHoverPlain(null);
|
|
5304
|
+
setHoverPos(null);
|
|
5305
|
+
return;
|
|
5306
|
+
}
|
|
5307
|
+
if (hoverPlainOff === true) setHoverPlainOff(false);
|
|
5261
5308
|
setHoverPos({
|
|
5262
|
-
left: clampPlainLeft(one.x -
|
|
5263
|
-
top: Math.max(8, fixedTop)
|
|
5264
|
-
scale: scale
|
|
5309
|
+
left: clampPlainLeft(one.x - layoutWidth / 2, layoutWidth, bounds, window.innerWidth),
|
|
5310
|
+
top: Math.max(8, fixedTop)
|
|
5265
5311
|
});
|
|
5266
5312
|
return;
|
|
5267
5313
|
}
|
|
@@ -5273,11 +5319,15 @@ window.__ModuleLoader__.load({
|
|
|
5273
5319
|
});
|
|
5274
5320
|
}, [hoverCard, hoverPlain, hoverMouse]);
|
|
5275
5321
|
|
|
5276
|
-
/*
|
|
5322
|
+
/* 主气泡收起时顺手把悬停浮层也收掉,避免它孤零零留在屏幕上。
|
|
5323
|
+
`hoverPlainOff` 也必须在这里复位:它是「本气泡放不下所以整组不显示」的一次性结论,
|
|
5324
|
+
换气泡 / 关气泡后必须重新评估 —— 不复位的话,用户碰到一次长工具名之后,
|
|
5325
|
+
此后所有气泡的工具名悬停都会永久失效(2026-10 实测发现)。 */
|
|
5277
5326
|
react.useEffect(() => {
|
|
5278
5327
|
if (tipId === null) {
|
|
5279
5328
|
setHoverCard(null);
|
|
5280
5329
|
setHoverPos(null);
|
|
5330
|
+
setHoverPlainOff(false);
|
|
5281
5331
|
}
|
|
5282
5332
|
}, [tipId]);
|
|
5283
5333
|
|
|
@@ -5399,6 +5449,11 @@ window.__ModuleLoader__.load({
|
|
|
5399
5449
|
setCreditSub(null);
|
|
5400
5450
|
/* 工具调用子视图的行数也回到默认 3:换字段 / 重开都重新量 */
|
|
5401
5451
|
setToolRows(LIST_ROWS_PER_COLUMN);
|
|
5452
|
+
/* 悬停整组开关也必须复位(用户拍板的那条「放不下就全不显示」)。
|
|
5453
|
+
换字段时 tipId 是**直接切换**("counts" → "cost")、不经过 null,
|
|
5454
|
+
只靠 tipId === null 那条 effect 复位不到 —— 结果是:碰到一次长工具名后,
|
|
5455
|
+
点任何一个字段都会让本次会话的工具名悬停永久失效(2026-10 实测确认的死锁)。 */
|
|
5456
|
+
setHoverPlainOff(false);
|
|
5402
5457
|
if (tipId === id) {
|
|
5403
5458
|
setTipId(null);
|
|
5404
5459
|
setTipPos(null);
|
|
@@ -5433,16 +5488,22 @@ window.__ModuleLoader__.load({
|
|
|
5433
5488
|
if (tip.kind === "bars") return weekBarsPanel(tip, t, tipPos, tipRef, onHover, { week: hoverWeek, bar: hoverBar });
|
|
5434
5489
|
return rowsPanel(tip, tipPos, tipRef, {
|
|
5435
5490
|
id: tipSub,
|
|
5436
|
-
/* 打开 / 返回都把行数重置回 3
|
|
5491
|
+
/* 打开 / 返回都把行数重置回 3:另一个子视图不该沿用上一条量出来的行数。
|
|
5492
|
+
`hoverPlainOff` 同理必须复位:在工具子视图里碰到长工具名被关掉之后,
|
|
5493
|
+
点标题返回再进别的子视图,如果不复位,新视图的色条段也一律不挂悬停。 */
|
|
5437
5494
|
open: (index) => {
|
|
5438
5495
|
setToolRows(LIST_ROWS_PER_COLUMN);
|
|
5496
|
+
setHoverPlainOff(false);
|
|
5439
5497
|
setTipSub(index);
|
|
5440
5498
|
},
|
|
5441
5499
|
back: () => {
|
|
5442
5500
|
setToolRows(LIST_ROWS_PER_COLUMN);
|
|
5501
|
+
setHoverPlainOff(false);
|
|
5443
5502
|
setTipSub(null);
|
|
5444
5503
|
},
|
|
5445
5504
|
rowsPerColumn: toolRows,
|
|
5505
|
+
/* 工具名悬停提示整组开关:这个气泡放不下那行小字时一律不挂(用户拍板) */
|
|
5506
|
+
hoverSuppressed: hoverPlainOff,
|
|
5446
5507
|
/* 工具调用彩条每段的悬停浮层(工具名 + 占比)走同一套 onHover,与柱状图互不干扰;
|
|
5447
5508
|
onHoverMove 只更新鼠标坐标,让浮层在同一个色段里跟着鼠标走而不闪断。 */
|
|
5448
5509
|
hover: onHover,
|
|
@@ -5483,17 +5544,11 @@ window.__ModuleLoader__.load({
|
|
|
5483
5544
|
});
|
|
5484
5545
|
/* 工具彩条悬停:**不要气泡**,只渲染一行文字(工具名 + 占比),贴在鼠标上方。
|
|
5485
5546
|
字号 11px(比悬停浮层的 10px 大、比标题 12px 小),无背景/圆角/描边。
|
|
5486
|
-
|
|
5547
|
+
放不下时整组不显示(见 plainFits),**不做缩放**,所以这里只给 left/top。 */
|
|
5487
5548
|
if (hoverPlain !== null) {
|
|
5488
5549
|
const hoverStyle = hoverPos === null
|
|
5489
5550
|
? undefined
|
|
5490
|
-
:
|
|
5491
|
-
? { left: hoverPos.left + "px", top: hoverPos.top + "px" }
|
|
5492
|
-
: {
|
|
5493
|
-
left: hoverPos.left + "px", top: hoverPos.top + "px",
|
|
5494
|
-
transform: "scale(" + hoverPos.scale + ")",
|
|
5495
|
-
transformOrigin: "left center"
|
|
5496
|
-
});
|
|
5551
|
+
: { left: hoverPos.left + "px", top: hoverPos.top + "px" };
|
|
5497
5552
|
children.push(h("span", {
|
|
5498
5553
|
className: "dsb-hover-plain",
|
|
5499
5554
|
key: "__hoverplain",
|
package/lib/holiday.js
CHANGED
|
@@ -17,15 +17,46 @@ const NOTICE_DATE = /(?:(\d{4})年)?(?:(\d{1,2})月)?(\d{1,2})日/g
|
|
|
17
17
|
const DAY_MS = 86400000
|
|
18
18
|
/** 一段假期最长不会超过这个天数,防正则意外把超长区间展开 */
|
|
19
19
|
const MAX_RANGE_DAYS = 40
|
|
20
|
+
/** HTML 解析的体积上限(字符):真实通知页 100~300KB,2MB 已经非常宽松。
|
|
21
|
+
* 超过就只看前 2MB —— 宁可解析不全,也不能让 host 主线程卡住(见 holidayHtmlToText)。 */
|
|
22
|
+
const HOLIDAY_HTML_MAX = 2 * 1024 * 1024
|
|
20
23
|
|
|
21
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* 线性切掉成对标签里的整段内容(`<script ...>...</script>` 这类)。
|
|
26
|
+
* 用 indexOf 前进而不是懒惰量词:找不到闭合标签时直接放弃剩余部分,
|
|
27
|
+
* 复杂度严格 O(n);`/<script[\s\S]*?<\/script>/gi` 在缺闭合时会从每个 `<` 往后扫到文末,
|
|
28
|
+
* 实测 40 万个未闭合 `<script>` 要 6 秒(卡死 host 事件循环)。
|
|
29
|
+
*/
|
|
30
|
+
function stripBlocks(text, tag) {
|
|
31
|
+
const open = '<' + tag
|
|
32
|
+
const close = '</' + tag
|
|
33
|
+
const lower = text.toLowerCase()
|
|
34
|
+
let out = ''
|
|
35
|
+
let at = 0
|
|
36
|
+
for (;;) {
|
|
37
|
+
const start = lower.indexOf(open, at)
|
|
38
|
+
if (start === -1) return out + text.slice(at)
|
|
39
|
+
out += text.slice(at, start)
|
|
40
|
+
const end = lower.indexOf(close, start)
|
|
41
|
+
if (end === -1) return out /* 没有闭合:整段丢弃,不再往后扫 */
|
|
42
|
+
const gt = text.indexOf('>', end)
|
|
43
|
+
if (gt === -1) return out
|
|
44
|
+
at = gt + 1
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** HTML → 纯文本:去脚本、样式与标签,解常见实体。
|
|
49
|
+
*
|
|
50
|
+
* 这段处理的是**外部网页**(国务院通知),内容不受我们控制,所以两处做了防御:
|
|
51
|
+
* ① 先按长度截断,避免超大文档把主线程占满;
|
|
52
|
+
* ② `<[^<>]+>` 而不是 `<[^>]+>` —— 后者不排除 `<`,大量孤立 `<` 时会反复跨越大段文本,
|
|
53
|
+
* 实测 20 万字符要 12 秒。 */
|
|
22
54
|
export function holidayHtmlToText(html) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
55
|
+
const raw = String(html === null || html === undefined ? '' : html).slice(0, HOLIDAY_HTML_MAX)
|
|
56
|
+
return stripBlocks(stripBlocks(raw, 'script'), 'style')
|
|
26
57
|
.replace(/<br\s*\/?>/gi, '\n')
|
|
27
58
|
.replace(/<\/p>/gi, '\n')
|
|
28
|
-
.replace(/<[
|
|
59
|
+
.replace(/<[^<>]+>/g, '')
|
|
29
60
|
.replace(/ /g, ' ')
|
|
30
61
|
.replace(/&/g, '&')
|
|
31
62
|
.replace(/</g, '<')
|
|
@@ -49,7 +80,9 @@ const pad = (n) => (n < 10 ? '0' + String(n) : String(n))
|
|
|
49
80
|
export function parseHolidayNotice(text, year) {
|
|
50
81
|
const days = new Set()
|
|
51
82
|
const flat = String(text === null || text === undefined ? '' : text)
|
|
52
|
-
.
|
|
83
|
+
.slice(0, HOLIDAY_HTML_MAX)
|
|
84
|
+
/* `[^<>]` 而不是 `[^>]`:理由同 holidayHtmlToText —— 大量孤立 `<` 时会退化 */
|
|
85
|
+
.replace(/<[^<>]+>/g, ' ')
|
|
53
86
|
.replace(/\s+/g, ' ')
|
|
54
87
|
/* 只认含"放假"的句子;含"上班"的句子是调休上班日,不能算进空闲 */
|
|
55
88
|
flat.split(/[。;;\n]/).forEach((sentence) => {
|
package/lib/index.js
CHANGED
|
@@ -1626,7 +1626,9 @@ async function queryBalance(ctx, force, provider, model) {
|
|
|
1626
1626
|
}
|
|
1627
1627
|
const data = await response.json()
|
|
1628
1628
|
const infos = Array.isArray(data.balance_infos) ? data.balance_infos : []
|
|
1629
|
-
|
|
1629
|
+
/* 元素可能是 null(上游坏数据):在 filter 回调里直接读 info.currency 会抛 TypeError */
|
|
1630
|
+
const picked = infos.filter((info) => info !== null && typeof info === 'object' && info.currency === 'CNY')[0]
|
|
1631
|
+
?? infos.filter((info) => info !== null && typeof info === 'object')[0]
|
|
1630
1632
|
if (picked === undefined) {
|
|
1631
1633
|
const empty = { ok: false, at: now, reason: 'no-balance-info', source: found.source }
|
|
1632
1634
|
logHost(ctx, 'balance: 响应里没有 balance_infos')
|
package/package.json
CHANGED
package/tools/shot.cjs
CHANGED
|
@@ -236,15 +236,19 @@ function pricingShot(locale) {
|
|
|
236
236
|
教训:**下标是脆的**,加 hook 必须回来重数,并用 `node tools/shot.cjs` 实际生成一次确认。 */
|
|
237
237
|
const presets = [];
|
|
238
238
|
presets[4] = 'deepseek-flash';
|
|
239
|
-
/*
|
|
239
|
+
/* 浮层里的数字也要跟着币种走:预设草稿按语言挑人民币或美元那套。
|
|
240
|
+
**`currency` 必须一起写**:下拉按钮显示的正是 `draft.currency`(不是 config.currency),
|
|
241
|
+
漏了它英文截图就会退回默认的 CNY —— 与整页英文界面自相矛盾(2026-10 实际踩过)。 */
|
|
240
242
|
presets[5] = locale === 'en'
|
|
241
243
|
? {
|
|
242
244
|
tiered: true,
|
|
245
|
+
currency: 'USD',
|
|
243
246
|
peak: { input: '0.3', cacheRead: '0.006', cacheWrite: '0', output: '1.2' },
|
|
244
247
|
offPeak: { input: '0.15', cacheRead: '0.003', cacheWrite: '0', output: '0.6' }
|
|
245
248
|
}
|
|
246
249
|
: {
|
|
247
250
|
tiered: true,
|
|
251
|
+
currency: 'CNY',
|
|
248
252
|
peak: { input: '2', cacheRead: '0.04', cacheWrite: '0', output: '8' },
|
|
249
253
|
offPeak: { input: '1', cacheRead: '0.02', cacheWrite: '0', output: '4' }
|
|
250
254
|
};
|