dsh-desktop-statusbar 2.2.0 → 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 +14 -1
- package/README.md +14 -2
- 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 +6 -1
- package/lib/self-update.js +243 -44
- package/package.json +1 -1
- package/tools/shot.cjs +5 -1
package/README.en.md
CHANGED
|
@@ -38,7 +38,7 @@ Replaces the stats line under the message input in the DSH desktop app with a co
|
|
|
38
38
|
| Session cost | Cumulative token breakdown for this session |
|
|
39
39
|
| Balance | Top-up balance / granted balance / quota left |
|
|
40
40
|
|
|
41
|
-
- **Session data drill-down**: context compactions mark the node they happened at, skills injected counts the skills injected in this session, and tool calls list every tool the session used
|
|
41
|
+
- **Session data drill-down**: context compactions mark the node they happened at, skills injected counts the skills injected in this session, and tool calls list every tool the session used with its call count; click the title to go back up
|
|
42
42
|
- **Version check and update**: DSH checks for the latest version on startup, shows a hint next to the "Status bar" nav item when one is available, and offers a one-click update button in settings
|
|
43
43
|
- **GitHub support**: your support is the biggest motivation for the developer to keep going! Click the button to open the GitHub project page and help with a star — thanks, everyone!
|
|
44
44
|
- **Cost estimation**: priced with the official peak/off-peak rules; each call is computed from the moment it happened and the model it used
|
|
@@ -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
|
|
@@ -98,6 +110,7 @@ Peak hours are **09:00–12:00 and 14:00–18:00 Beijing time, Monday to Friday*
|
|
|
98
110
|
|
|
99
111
|
- Fastest / slowest TTFT and speed come from a new host-side projection: **you must fully quit and restart DSH** before it starts collecting
|
|
100
112
|
- A model with no price is not billed; adding the price back-fills its earlier calls
|
|
113
|
+
- One-click update relies on `pnpm`: the desktop app uses the copy bundled with the host, while CLI / web usage needs `pnpm` on the system. If neither is found, the update says so explicitly **and logs every location it tried**; you can also point it at a specific command with the `DSH_DESKTOP_STATUSBAR_PNPM` environment variable
|
|
101
114
|
|
|
102
115
|
## Development
|
|
103
116
|
|
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
| 总计费用 | 当前会话的累计 token 明细 |
|
|
39
39
|
| 余额 | 充值余额 / 赠金余额 / 剩余配额 |
|
|
40
40
|
|
|
41
|
-
-
|
|
41
|
+
- **会话数据下钻**:上下文压缩逐次标注发生节点,技能注入统计当前会话注入的技能,工具调用列出会话使用的所有工具与各自次数;点击标题返回上一层
|
|
42
42
|
- **版本自检与更新**:DSH 启动时自动检测最新版本,发现新版时在「状态栏」导航项旁显示提示,并在设置页提供一键更新按钮
|
|
43
43
|
- **费用估算**:按官方峰谷口径计价,每条调用按其发生时刻与所用模型分别计算
|
|
44
44
|
- **价格库**:自定义单价、峰谷分档、CNY / USD 计价单位
|
|
@@ -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
|
## 使用
|
|
@@ -98,7 +110,7 @@ npm view dsh-desktop-statusbar version
|
|
|
98
110
|
|
|
99
111
|
- 最快 / 最慢首字与速度来自 host 新增的投影,**升级后需完全退出并重启 DSH** 才开始统计
|
|
100
112
|
- 价格库中缺少单价的模型不计费,补充单价后自动补算
|
|
101
|
-
- 一键更新依赖 pnpm 可执行文件:桌面端用宿主随包提供的 pnpm,命令行 /
|
|
113
|
+
- 一键更新依赖 pnpm 可执行文件:桌面端用宿主随包提供的 pnpm,命令行 / 网页端需要系统里有 `pnpm`。都找不到时会明确提示「找不到 pnpm」,并把**尝试过的每个位置**一并写进日志;也可以用环境变量 `DSH_DESKTOP_STATUSBAR_PNPM` 直接指定要用的命令
|
|
102
114
|
|
|
103
115
|
## 开发
|
|
104
116
|
|
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')
|
|
@@ -4077,6 +4079,9 @@ async function handleApi(req, res, ctx) {
|
|
|
4077
4079
|
reason: last.reason === undefined ? null : last.reason,
|
|
4078
4080
|
from: last.from === undefined ? null : last.from,
|
|
4079
4081
|
to: last.to === undefined ? null : last.to,
|
|
4082
|
+
/* 找不到 pnpm 时把「试过哪些路径」一并交给界面:排查这类问题时最费时间的就是
|
|
4083
|
+
手工逐条 Test-Path,这里直接摊开。其它失败原因下是空数组。 */
|
|
4084
|
+
tried: Array.isArray(last.tried) === true ? last.tried : [],
|
|
4080
4085
|
},
|
|
4081
4086
|
})
|
|
4082
4087
|
return
|
package/lib/self-update.js
CHANGED
|
@@ -89,54 +89,237 @@ export function resolveSelfDir(moduleUrl) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
92
|
+
* 取一个「可能是字符串」的注入值:类型不对时退回默认值。
|
|
93
|
+
* 传 `null` / 数字 / 对象都按「显式给了一个不可用的值」处理,不回退。
|
|
93
94
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
95
|
+
* @param {unknown} value 注入值
|
|
96
|
+
* @param {unknown} fallback 默认值
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
function asText(value, fallback) {
|
|
100
|
+
if (value === undefined) return typeof fallback === 'string' ? fallback : ''
|
|
101
|
+
return typeof value === 'string' ? value : ''
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 判断一段文本看起来是不是绝对路径。
|
|
103
106
|
*
|
|
104
|
-
*
|
|
107
|
+
* 不用 `node:path` 的 `isAbsolute()`:它按**当前进程**的平台判断,而这里允许测试
|
|
108
|
+
* 注入 `options.platform`,两者可能对不上。两种平台的写法都认:
|
|
109
|
+
* `C:\x`(盘符)、`\\server\share`(UNC)、`/x`(POSIX 根)。
|
|
110
|
+
*
|
|
111
|
+
* @param {string} text
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
function looksAbsolutePath(text) {
|
|
115
|
+
return /^[A-Za-z]:[\\/]/.test(text)
|
|
116
|
+
|| text.startsWith('\\\\')
|
|
117
|
+
|| text.startsWith('/')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 去掉路径结尾的分隔符;盘符根(`C:\`)原样保留 —— 去掉会变成 `C:`。
|
|
122
|
+
*
|
|
123
|
+
* @param {string} text
|
|
124
|
+
* @returns {string}
|
|
125
|
+
*/
|
|
126
|
+
function trimTrailingSeparators(text) {
|
|
127
|
+
let out = text
|
|
128
|
+
while (out.length > 1 && /[\\/]$/.test(out) === true) {
|
|
129
|
+
if (/^[A-Za-z]:[\\/]$/.test(out) === true) break
|
|
130
|
+
out = out.slice(0, -1)
|
|
131
|
+
}
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 解析安装根下的 `resources` 目录。
|
|
137
|
+
*
|
|
138
|
+
* 优先级(与宿主自己的 `runtimeResources()` 取值顺序一致):
|
|
139
|
+
* 1. 显式注入的 `resourcesPath`(测试用,也可以是调用方认定的权威值);
|
|
140
|
+
* 2. `process.resourcesPath` —— Electron 打包版给的权威值,比推断更稳;
|
|
141
|
+
* 3. 由 `execPath` 推断:`dirname(execPath)/resources`(非 Electron 环境兜底)。
|
|
142
|
+
*
|
|
143
|
+
* @param {string} resourcesPath 已处理过默认值(可能是空字符串)
|
|
144
|
+
* @param {string} execPath 已处理过默认值(可能是空字符串)
|
|
145
|
+
* @returns {string|null} 推断不出来时返回 null
|
|
146
|
+
*/
|
|
147
|
+
function resolveResourcesDir(resourcesPath, execPath) {
|
|
148
|
+
if (resourcesPath.length > 0 && looksAbsolutePath(resourcesPath) === true) {
|
|
149
|
+
return trimTrailingSeparators(resourcesPath)
|
|
150
|
+
}
|
|
151
|
+
if (execPath.length > 0) return trimTrailingSeparators(join(dirname(execPath), 'resources'))
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 定位 pnpm,返回**结构化结果**(永不抛异常),把「试过哪些候选」一并带回。
|
|
157
|
+
*
|
|
158
|
+
* 只做定位,不执行任何外部命令。
|
|
159
|
+
*
|
|
160
|
+
* 候选顺序(命中即返回,逐个记进 `tried`):
|
|
161
|
+
* 1. `DSH_DESKTOP_STATUSBAR_PNPM`:插件自己的覆盖口,优先级最高。可以是 JSON 数组
|
|
162
|
+
* (`["node","/path/pnpm.mjs"]`,用别的 node 跑打包的 pnpm 时用),也可以是
|
|
163
|
+
* 单个可执行文件路径。**不检查文件是否存在** —— 用户显式指定的入口就该照办。
|
|
164
|
+
* 2. `DSH_DESKTOP_PNPM_ENTRY`:宿主自己的口径(宿主 `runtimeResources()` 在开发
|
|
165
|
+
* 模式下读它)。排在文件候选之前,宿主以后挪位置也不会失配。
|
|
166
|
+
* 3. 宿主随包提供的 pnpm,按 `resources` 目录拼出来,三条候选依次尝试:
|
|
167
|
+
* - `runtime/primary-runtime/dependencies/pnpm/bin/pnpm.mjs`:**当前宿主布局**。
|
|
168
|
+
* 优先用同一套运行时里的 node(`runtime/primary-runtime/dependencies/node/bin/node.exe`)
|
|
169
|
+
* 去跑它,不依赖 Electron 的 `ELECTRON_RUN_AS_NODE`;那套 node 不在时退回宿主可执行文件
|
|
170
|
+
* (此时才需要 `ELECTRON_RUN_AS_NODE=1`)。
|
|
171
|
+
* - `runtime/pnpm/bin/pnpm.mjs`:更旧的宿主布局,保留兼容。
|
|
172
|
+
* - `app.asar.unpacked/dsh/node_modules/pnpm/bin/pnpm.mjs`:再旧的打包布局,保留兼容。
|
|
173
|
+
*
|
|
174
|
+
* 找不到时**不做 PATH 搜索**:Windows 上 PATH 里多半是 `pnpm.cmd`,spawn 它需要
|
|
175
|
+
* `shell: true`,等于把命令解释权交给 shell,风险面大于收益。真需要时请用户用
|
|
176
|
+
* `DSH_DESKTOP_STATUSBAR_PNPM` 直接指定(issue 里的绕过写法就是这么用的)。
|
|
177
|
+
*
|
|
178
|
+
* @param {{
|
|
179
|
+
* env?: {[key: string]: string|undefined},
|
|
180
|
+
* execPath?: string,
|
|
181
|
+
* resourcesPath?: string,
|
|
182
|
+
* exists?: (path: string) => boolean,
|
|
183
|
+
* electron?: boolean,
|
|
184
|
+
* platform?: string,
|
|
185
|
+
* }} [options] 全部可选;只有测试会传
|
|
186
|
+
* @returns {{
|
|
187
|
+
* ok: boolean,
|
|
188
|
+
* runner: {command: string, args: string[], electron: boolean}|null,
|
|
189
|
+
* source: 'custom-env'|'host-env'|'primary-runtime'|'runtime-legacy'|'asar-unpacked'|null,
|
|
190
|
+
* tried: Array<{source: string, path: string|null, note: string}>,
|
|
191
|
+
* }}
|
|
105
192
|
*/
|
|
106
|
-
export function
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
193
|
+
export function resolvePnpmDetailed(options) {
|
|
194
|
+
const opts = options === null || typeof options !== 'object' ? {} : options
|
|
195
|
+
/** 环境变量来源;默认 process.env。 */
|
|
196
|
+
const env = opts.env === undefined ? process.env : (opts.env === null ? {} : opts.env)
|
|
197
|
+
/** 宿主可执行文件;空字符串表示「拿不到」。 */
|
|
198
|
+
const execPath = asText(opts.execPath, process.execPath)
|
|
199
|
+
/** 注入的 resources 目录;未传时才回落到 process.resourcesPath。 */
|
|
200
|
+
const resourcesPath = asText(opts.resourcesPath, process.resourcesPath)
|
|
201
|
+
/** 是否跑在 Electron 里 —— 只有退回宿主可执行文件当 node 用时才需要它。 */
|
|
202
|
+
const electron = opts.electron === undefined
|
|
203
|
+
? (typeof process.versions === 'object'
|
|
204
|
+
&& process.versions !== null
|
|
205
|
+
&& typeof process.versions.electron === 'string'
|
|
206
|
+
&& process.versions.electron.length > 0)
|
|
207
|
+
: opts.electron === true
|
|
208
|
+
/** 平台;只用来决定同套运行时里的 node 叫 node.exe 还是 node。 */
|
|
209
|
+
const platform = asText(opts.platform, process.platform)
|
|
210
|
+
|
|
211
|
+
/** 每次尝试的记录:命中的也记,失败时一并带回给调用方写日志。 */
|
|
212
|
+
const tried = []
|
|
213
|
+
/** 记一条命中并直接返回成功结果。 */
|
|
214
|
+
const hit = (source, runner, path) => {
|
|
215
|
+
tried.push({ source, path: typeof path === 'string' ? path : null, note: '命中' })
|
|
216
|
+
return { ok: true, runner, source, tried }
|
|
217
|
+
}
|
|
218
|
+
/** 记一条未命中,继续试下一个候选。 */
|
|
219
|
+
const miss = (source, path, note) => {
|
|
220
|
+
tried.push({ source, path: typeof path === 'string' ? path : null, note })
|
|
221
|
+
}
|
|
222
|
+
/** 全部落空。 */
|
|
223
|
+
const fail = () => ({ ok: false, runner: null, source: null, tried })
|
|
224
|
+
/** 文件存在性检查;注入的实现抛错也当成「不存在」,保证本函数永不抛。 */
|
|
225
|
+
const exists = (target) => {
|
|
226
|
+
if (typeof opts.exists === 'function') {
|
|
227
|
+
try { return opts.exists(target) === true } catch (error) { return false }
|
|
228
|
+
}
|
|
229
|
+
try { return existsSync(target) === true } catch (error) { return false }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* 1) 插件自己的覆盖口:完全不检查文件是否存在(用户显式指定的入口) */
|
|
233
|
+
const custom = asText(env.DSH_DESKTOP_STATUSBAR_PNPM, '')
|
|
234
|
+
if (custom.trim().length > 0) {
|
|
235
|
+
const text = custom.trim()
|
|
236
|
+
if (text.startsWith('[') === true) {
|
|
237
|
+
let parsed = null
|
|
111
238
|
try {
|
|
112
|
-
const
|
|
113
|
-
if (Array.isArray(
|
|
114
|
-
|
|
239
|
+
const value = JSON.parse(text)
|
|
240
|
+
if (Array.isArray(value) === true && value.length > 0 && value.every((one) => typeof one === 'string') === true) {
|
|
241
|
+
parsed = value
|
|
115
242
|
}
|
|
116
243
|
} catch (error) {
|
|
117
|
-
/*
|
|
244
|
+
/* 不是合法数组,落到下面的单路径处理 */
|
|
118
245
|
}
|
|
246
|
+
if (parsed !== null) {
|
|
247
|
+
return hit('custom-env', { command: parsed[0], args: parsed.slice(1), electron: false }, custom)
|
|
248
|
+
}
|
|
249
|
+
/* `'[]'` 也走这里:空数组不够当命令用,退化成单路径(保持旧行为) */
|
|
250
|
+
miss('custom-env', custom, '不是合法 JSON 数组,按单路径处理')
|
|
119
251
|
}
|
|
120
|
-
return { command: text, args: [], electron: false }
|
|
252
|
+
return hit('custom-env', { command: text, args: [], electron: false }, custom)
|
|
121
253
|
}
|
|
122
254
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
/* 试下一个 */
|
|
135
|
-
}
|
|
255
|
+
/* 2) 宿主自己的口径:只存在于开发模式,但插件照样优先认它 */
|
|
256
|
+
const hostEntry = asText(env.DSH_DESKTOP_PNPM_ENTRY, '')
|
|
257
|
+
if (hostEntry.trim().length > 0) {
|
|
258
|
+
const entry = hostEntry.trim()
|
|
259
|
+
if (execPath.length === 0) {
|
|
260
|
+
miss('host-env', hostEntry, '没有可用的宿主可执行文件')
|
|
261
|
+
} else if (exists(entry) === true) {
|
|
262
|
+
/* tried.path 记变量原值(契约口径),实际执行用去掉首尾空白的路径 */
|
|
263
|
+
return hit('host-env', { command: execPath, args: [entry], electron }, hostEntry)
|
|
264
|
+
} else {
|
|
265
|
+
miss('host-env', hostEntry, '不存在')
|
|
136
266
|
}
|
|
137
267
|
}
|
|
138
268
|
|
|
139
|
-
|
|
269
|
+
/* 3) 按 resources 目录拼出来的文件候选 */
|
|
270
|
+
const resources = resolveResourcesDir(resourcesPath, execPath)
|
|
271
|
+
if (resources === null) {
|
|
272
|
+
miss('primary-runtime', null, '无法推断 resources 目录')
|
|
273
|
+
return fail()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* 3.1 当前宿主布局:优先用同一套运行时里的 node,不碰 ELECTRON_RUN_AS_NODE */
|
|
277
|
+
const primaryPnpm = join(resources, 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')
|
|
278
|
+
if (exists(primaryPnpm) === true) {
|
|
279
|
+
const primaryNode = join(resources, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin', platform === 'win32' ? 'node.exe' : 'node')
|
|
280
|
+
if (exists(primaryNode) === true) {
|
|
281
|
+
return hit('primary-runtime', { command: primaryNode, args: [primaryPnpm], electron: false }, primaryPnpm)
|
|
282
|
+
}
|
|
283
|
+
if (execPath.length > 0) {
|
|
284
|
+
return hit('primary-runtime', { command: execPath, args: [primaryPnpm], electron }, primaryPnpm)
|
|
285
|
+
}
|
|
286
|
+
miss('primary-runtime', primaryPnpm, '找到 pnpm,但同套运行时里的 node 与宿主可执行文件都不可用')
|
|
287
|
+
} else {
|
|
288
|
+
miss('primary-runtime', primaryPnpm, '不存在')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* 3.2 / 3.3 旧布局:向后兼容,用宿主可执行文件加 ELECTRON_RUN_AS_NODE 去跑 */
|
|
292
|
+
const legacyCandidates = [
|
|
293
|
+
{ source: 'runtime-legacy', file: join(resources, 'runtime', 'pnpm', 'bin', 'pnpm.mjs') },
|
|
294
|
+
{ source: 'asar-unpacked', file: join(resources, 'app.asar.unpacked', 'dsh', 'node_modules', 'pnpm', 'bin', 'pnpm.mjs') },
|
|
295
|
+
]
|
|
296
|
+
for (const candidate of legacyCandidates) {
|
|
297
|
+
if (exists(candidate.file) !== true) {
|
|
298
|
+
miss(candidate.source, candidate.file, '不存在')
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
if (execPath.length === 0) {
|
|
302
|
+
miss(candidate.source, candidate.file, '找到 pnpm,但没有可用的宿主可执行文件')
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
return hit(candidate.source, { command: execPath, args: [candidate.file], electron }, candidate.file)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return fail()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 定位 pnpm 可执行文件(向后兼容的薄包装)。
|
|
313
|
+
*
|
|
314
|
+
* 内部走 `resolvePnpmDetailed()`,只关心「最终能不能跑」,不关心试过哪些候选。
|
|
315
|
+
* 现有测试与 `verify-self-update.js --online` 用的都是这个签名。
|
|
316
|
+
*
|
|
317
|
+
* @param {object} [options] 透传给 resolvePnpmDetailed(测试注入用)
|
|
318
|
+
* @returns {{command: string, args: string[], electron: boolean}|null}
|
|
319
|
+
*/
|
|
320
|
+
export function resolvePnpm(options) {
|
|
321
|
+
const detail = resolvePnpmDetailed(options)
|
|
322
|
+
return detail.ok === true ? detail.runner : null
|
|
140
323
|
}
|
|
141
324
|
|
|
142
325
|
/**
|
|
@@ -231,9 +414,9 @@ export const RELEASE_AGE_OVERRIDE = '--config.minimum-release-age=0'
|
|
|
231
414
|
* - `--save-exact`:清单里写死精确版本,避免 `^` 范围日后漂移。
|
|
232
415
|
* - 发布年龄归零:见 RELEASE_AGE_OVERRIDE。
|
|
233
416
|
*
|
|
234
|
-
* 不加 `--no-frozen-lockfile`:pnpm 11
|
|
235
|
-
* (实测报 `Unknown option: 'frozen-lockfile'
|
|
236
|
-
*
|
|
417
|
+
* 不加 `--no-frozen-lockfile`:pnpm 11 的 `add` 不认这个选项
|
|
418
|
+
* (实测报 `Unknown option: 'frozen-lockfile'`;宿主当前自带 pnpm 11.28.5,同样如此)。
|
|
419
|
+
* `add` 本来就会更新锁文件,不需要它。
|
|
237
420
|
*
|
|
238
421
|
* @param {string[]} base pnpm 自身的参数(例如打包 pnpm 的 .mjs 路径)
|
|
239
422
|
* @param {string} target 安装目标 `包名@版本`
|
|
@@ -257,14 +440,30 @@ export async function runSelfUpdate(options) {
|
|
|
257
440
|
刷新页面不算数 —— 插件包元数据(含 client.js 的 revision)在启动时缓存、直到重启才更新
|
|
258
441
|
(见 @deepseek-ai/dsh-hmr 的已知限制与 dsh-client-modules 的说明)。
|
|
259
442
|
不用「要不要重启」去猜,直接如实告诉界面。 */
|
|
260
|
-
const result = { ok: false, from, to: options.version, reason: null, detail: null, profileDir: null, changedExclude: false, restartRequired: true }
|
|
443
|
+
const result = { ok: false, from, to: options.version, reason: null, detail: null, profileDir: null, changedExclude: false, restartRequired: true, tried: [] }
|
|
261
444
|
|
|
262
|
-
|
|
263
|
-
|
|
445
|
+
/* 注意:必须在找 profile 之前就报 pnpm-not-found —— 找不到 pnpm 时 profile
|
|
446
|
+
目录是否存在无关紧要,先告诉用户真正的卡点。 */
|
|
447
|
+
const pnpm = resolvePnpmDetailed()
|
|
448
|
+
if (pnpm.ok !== true) {
|
|
264
449
|
result.reason = 'pnpm-not-found'
|
|
450
|
+
/* 把试过的候选写进结果和日志:issue 里那次排查全靠手工逐条 Test-Path,
|
|
451
|
+
以后用户/开发者看日志就能定位,不用再猜插件找的是哪条路径。 */
|
|
452
|
+
result.tried = pnpm.tried
|
|
453
|
+
.slice(0, 12)
|
|
454
|
+
.map((one) => {
|
|
455
|
+
const where = one.path === null ? '—' : one.path
|
|
456
|
+
const line = one.source + ': ' + where + '(' + one.note + ')'
|
|
457
|
+
return line.length > 300 ? line.slice(0, 300) + '…' : line
|
|
458
|
+
})
|
|
459
|
+
log('update: pnpm-not-found,尝试过:')
|
|
460
|
+
for (const line of result.tried) log(' - ' + line)
|
|
265
461
|
return result
|
|
266
462
|
}
|
|
267
463
|
|
|
464
|
+
const runner = pnpm.runner
|
|
465
|
+
log('update: pnpm 来源 ' + pnpm.source + ' → ' + runner.command + ' ' + runner.args.join(' ') + (runner.electron === true ? '(ELECTRON_RUN_AS_NODE)' : ''))
|
|
466
|
+
|
|
268
467
|
const profileDir = findProfileDir(selfDir)
|
|
269
468
|
result.profileDir = profileDir
|
|
270
469
|
if (profileDir === null) {
|
|
@@ -285,13 +484,13 @@ export async function runSelfUpdate(options) {
|
|
|
285
484
|
/* 精确版本,不用 @latest:后者在发布年龄策略下会静默装成旧的成熟版 */
|
|
286
485
|
const target = SELF_NAME + '@' + options.version
|
|
287
486
|
const env = { ...process.env }
|
|
288
|
-
if (
|
|
487
|
+
if (runner.electron === true) env.ELECTRON_RUN_AS_NODE = '1'
|
|
289
488
|
/* CI=true:pnpm 在非交互环境下不会弹提示卡住(与官方插件管理器同一做法) */
|
|
290
489
|
env.CI = 'true'
|
|
291
|
-
const args = updateArgs(
|
|
490
|
+
const args = updateArgs(runner.args, target)
|
|
292
491
|
|
|
293
492
|
log('update: 开始安装 ' + target)
|
|
294
|
-
const run = await runCommand(
|
|
493
|
+
const run = await runCommand(runner.command, args, {
|
|
295
494
|
cwd: profileDir,
|
|
296
495
|
env,
|
|
297
496
|
timeoutMs: options.timeoutMs,
|
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
|
};
|