@dougen/dsh-deepseek-usage 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -25
- package/lib/client.js +179 -74
- package/lib/index.js +197 -21
- package/lib/pricing-store.js +253 -0
- package/lib/pricing.js +129 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @dougen/dsh-deepseek-usage
|
|
2
2
|
|
|
3
|
-
A sidebar plugin for **DeepSeek Harness Web UI** that shows your **DeepSeek account balance** (large display)
|
|
3
|
+
A sidebar plugin for **DeepSeek Harness Web UI** that shows your **DeepSeek account balance** (large display), the **current model's unit pricing**, and the **time left until the next peak/off-peak switch** — with a peak/off-peak billing indicator. UI language follows the DSH locale (Chinese UI shows Chinese, other languages show English).
|
|
4
4
|
|
|
5
|
-
DeepSeek **余额侧边栏插件**(DeepSeek Harness Web UI):在 DSH **网页界面**的侧边栏底部常驻显示你的 **DeepSeek
|
|
5
|
+
DeepSeek **余额侧边栏插件**(DeepSeek Harness Web UI):在 DSH **网页界面**的侧边栏底部常驻显示你的 **DeepSeek 账号余额**(放大显示)、**当前模型档单价**,以及**距下次峰谷切换的倒计时**,并提供高峰/空闲计费时段指示灯。界面语言跟随 DSH 语言设置(中文界面显示中文,其他语言显示英文)。
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
@@ -10,20 +10,20 @@ DeepSeek **余额侧边栏插件**(DeepSeek Harness Web UI):在 DSH **网
|
|
|
10
10
|
|
|
11
11
|
- **Balance**: `¥ 110.00` large display (symbol follows account currency: `¥` CNY / `$` USD)
|
|
12
12
|
- **Unit pricing**: `→|1.5 ⚡0.05 |→4.5` (input / input cache / output, bottom-right of the card; switches with the active model and peak/off-peak window — **updates instantly when you switch models**)
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **Peak indicator**: top-right dot
|
|
17
|
-
- **Auto refresh**: every 5 minutes; click the card to refresh instantly
|
|
13
|
+
- **Editable price table**: prices live in a JSON file (`<DSH_HOME>/dsh-deepseek-usage/pricing.json`), not in code. The plugin reads it at startup, so the card prices correctly **immediately after a restart**, and a successful official sync is written back into it. Hand-edit it for a gateway/manual pricing; changes apply without restarting (`autoUpdate: false` also turns the automatic sync off completely)
|
|
14
|
+
- **Auto price sync**: prices are re-fetched from the official DeepSeek pricing page on first use each day and when you click the card; the result is merged back into the price table (official entries overwrite same-named ones, your own entries are kept). The page is parsed **structurally (HTML table cells)**, not by matching surrounding text, so official layout/rewording changes no longer break it; a plain-text strategy remains as a fallback
|
|
15
|
+
- **Rename-proof model mapping**: when DeepSeek renames a model (e.g. `deepseek-v4-flash` → `deepseek-flash`) the old id is charged at the current model's price instead of silently showing an outdated price. (The card stays quiet about this — see *FAQ* below.)
|
|
16
|
+
- **Peak indicator & countdown**: top-right dot — orange during peak hours (Mon–Fri 9:00–12:00, 14:00–18:00 Beijing Time), green otherwise. Beside the dot a countdown shows the time left until the next peak/off-peak switch, in English units (`2d 15h`, `5h 47m`, `23m`).
|
|
17
|
+
- **Auto refresh**: every 5 minutes; click the card to refresh instantly.
|
|
18
18
|
- **i18n**: UI copy follows the DSH locale (Chinese / English)
|
|
19
19
|
- Follows the app light/dark theme
|
|
20
20
|
|
|
21
21
|
- **余额**:`¥ 110.00` 大字显示(符号随账号币种自动切换:人民币 `¥` / 美元 `$`)
|
|
22
22
|
- **档单价**:`→|1.5 ⚡0.05 |→4.5`(输入 / 输入缓存 / 输出,位于卡片右下角;随当前启用模型与峰谷时段自动切换,**切换模型时立即同步**)
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
23
|
+
- **可编辑的价格表**:价格不再写在代码里,而是存在 JSON 文件 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`。插件启动时先读它,所以**重启后卡片立刻就是正确价格**;官方同步成功后会自动回写到这份表里。手改该文件即可用于中转/手动计价,改完**无需重启**(`autoUpdate: false` 还能彻底关掉自动同步)
|
|
24
|
+
- **单价自动同步**:每日首次使用或点击卡片时,自动从官方价格页拉取最新档单价,并把结果**合并回写**到价格表(官方条目覆盖同名项,你自己加的条目保留)。页面解析采用**结构化表格解析**(直接读 HTML 表格单元格),不再依赖"正文里某段文字长什么样",官方改版/改文案不会失效;另保留纯文本兜底策略
|
|
25
|
+
- **抗改名映射**:官方给模型改名(如 `deepseek-v4-flash` → `deepseek-flash`)时,旧名按现名价格计费,不会静默显示旧价(卡片不为此弹提示,见下方 FAQ)
|
|
26
|
+
- **峰谷指示灯与倒计时**:卡片右上角圆点,高峰(周一至周五 9:00–12:00、14:00–18:00 北京时间)橙色,空闲绿色;圆点旁显示距下次峰谷切换的倒计时,统一英文单位(`2d 15h`、`5h 47m`、`23m`)
|
|
27
27
|
- **自动刷新**:每 5 分钟一次;点击卡片任意位置立即刷新
|
|
28
28
|
- **多语言**:界面文案跟随 DSH 语言(中文 / English)
|
|
29
29
|
- 跟随应用明暗主题
|
|
@@ -53,17 +53,51 @@ Restart the web app (`dsh web`), open http://127.0.0.1:3080 , and the usage card
|
|
|
53
53
|
| Data | Source |
|
|
54
54
|
|---|---|
|
|
55
55
|
| Balance | Official DeepSeek API `GET /user/balance` (uses `DEEPSEEK_API_KEY` configured in DSH) |
|
|
56
|
-
| Unit pricing
|
|
56
|
+
| Unit pricing | Local price table `<DSH_HOME>/dsh-deepseek-usage/pricing.json` (read at startup; editable). Refreshed from the official page ([api-docs.deepseek.com/zh-cn/quick_start/pricing](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/)) on first daily use or on card click, then written back. Parsed from the HTML **table cells** (text fallback available); the built-in snapshot in code is only the seed for the very first run |
|
|
57
|
+
| Peak window | Built-in official rule: Mon–Fri 9:00–12:00, 14:00–18:00 Beijing Time (weekends are always off-peak). The card shows it as a coloured dot plus a countdown to the next switch |
|
|
57
58
|
|
|
58
59
|
| 数据 | 来源 |
|
|
59
60
|
|---|---|
|
|
60
61
|
| 余额 | DeepSeek 官方 API `GET /user/balance`(使用 DSH 中已配置的 `DEEPSEEK_API_KEY`) |
|
|
61
|
-
| 档单价
|
|
62
|
+
| 档单价 | 本地价格表 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`(启动即读取,可手动编辑);每日首次使用或点击卡片时从官方价格页 [api-docs.deepseek.com/zh-cn/quick_start/pricing](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/) 同步并**回写**该表。官方页解析**读取 HTML 表格单元格**(另有纯文本兜底策略);代码里的内置快照只在首次运行时用于生成价格表 |
|
|
63
|
+
| 峰谷时段 | 内置官方口径:周一至周五 9:00–12:00、14:00–18:00(北京时间),周末恒为空闲;界面以「指示灯 + 距下次切换倒计时」呈现 |
|
|
62
64
|
|
|
63
65
|
Make sure `DEEPSEEK_API_KEY` is configured in DSH (Model settings page) before use.
|
|
64
66
|
|
|
65
67
|
使用前请确保 DSH 已配置 `DEEPSEEK_API_KEY`(可在 DSH 设置中的模型页面配置)。
|
|
66
68
|
|
|
69
|
+
## Price table / 价格表
|
|
70
|
+
|
|
71
|
+
`<DSH_HOME>/dsh-deepseek-usage/pricing.json` (default `~/.dsh/dsh-deepseek-usage/pricing.json`; override with `DEEPSEEK_USAGE_PRICE_FILE`). Created from the built-in snapshot on first run, then maintained by the plugin:
|
|
72
|
+
|
|
73
|
+
```jsonc
|
|
74
|
+
{
|
|
75
|
+
"autoUpdate": true, // false = manual mode: never fetch, use this table as-is
|
|
76
|
+
"updatedAt": 1789090582880, // last write (ms or ISO string)
|
|
77
|
+
"updatedFrom": "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
|
|
78
|
+
"strategy": "table", // table | flat | seed
|
|
79
|
+
"aliases": { "deepseek-v4-flash": "deepseek-flash" }, // old id → current id
|
|
80
|
+
"models": {
|
|
81
|
+
// input / cacheRead / output are [peak, off-peak] in CNY per 1M tokens
|
|
82
|
+
"deepseek-flash": { "input": [2, 1], "cacheRead": [0.04, 0.02], "cacheWrite": 0, "output": [8, 4] }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- Full-line `//` comments are allowed; a bare `{"model-id": {...}}` map (no `models` wrapper) also works.
|
|
88
|
+
- Every successful official sync **merges** into this file: official entries overwrite same-named ones, your own entries stay.
|
|
89
|
+
- Edits are picked up without restarting the plugin (the file's mtime is checked per request).
|
|
90
|
+
- If the file is corrupt, the plugin keeps the last good prices and **never overwrites** it — fix it and it reloads.
|
|
91
|
+
(The card itself never shows this path — it only shows your balance, the unit prices, the peak dot and the countdown.)
|
|
92
|
+
|
|
93
|
+
价格表文件位于 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`(默认 `~/.dsh/dsh-deepseek-usage/pricing.json`,可用 `DEEPSEEK_USAGE_PRICE_FILE` 覆盖)。首次运行由内置快照生成,之后由插件维护:
|
|
94
|
+
|
|
95
|
+
- 允许**整行** `//` 注释;也接受省略 `models` 外壳的简写形式 `{"deepseek-flash": {...}}`。
|
|
96
|
+
- 每次官方同步成功都会**合并回写**本文件:官方页列出的模型覆盖同名条目,你自己加的条目保留。
|
|
97
|
+
- 手改后无需重启(每次请求会比对文件 mtime),改完点一下卡片即生效。
|
|
98
|
+
- 文件损坏时不会覆盖你的内容,会继续用上一次的价格并写日志;修好后自动重载。
|
|
99
|
+
(卡片上不会显示该路径——卡片只显示余额、档单价、峰谷圆点与切换倒计时。)
|
|
100
|
+
|
|
67
101
|
## FAQ / 常见问题
|
|
68
102
|
|
|
69
103
|
**Balance shows "—"? / 余额显示"—"?**
|
|
@@ -76,25 +110,27 @@ Make sure `DEEPSEEK_API_KEY` is configured in DSH (Model settings page) before u
|
|
|
76
110
|
|
|
77
111
|
**Price differs from the official one? / 单价与官方价格不一致?**
|
|
78
112
|
|
|
79
|
-
- The unit price switches with the **active model** and the **peak/off-peak window
|
|
80
|
-
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
- *Priced as built-in `…`* — the model is not listed on the official page; a built-in entry is used.
|
|
113
|
+
- The unit price switches with the **active model** and the **peak/off-peak window**. Prices come from the local price table (last successful official sync, or your own edits); the plugin re-syncs the official page on first daily use or on card click and writes the result back into that table.
|
|
114
|
+
- **The card deliberately shows no plugin internals**: no sync state, no pricing source, no price-table path. It shows your balance, the current unit prices, the peak dot and the countdown; **click it to refresh**.
|
|
115
|
+
- Want to know where a price comes from? Open `<DSH_HOME>/dsh-deepseek-usage/pricing.json` (see *Price table* above) — official entries are merged there on every successful sync.
|
|
116
|
+
- **After every `dsh` restart the card used to say "prices not synced (built-in table)"** — that was a race: sync state only lived in memory, so the first request after a restart was always answered from the built-in table. Since 0.7.0 the price table is persisted and read at startup, so restarts show real prices right away.
|
|
84
117
|
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
- *按内置 `…` 档计价*:该模型不在官方价格页,用了内置表。
|
|
118
|
+
- 单价按**当前启用模型**与**峰谷时段**自动切换;价格取自本地价格表(上次成功同步到的官方价,或你自己改的价)。插件在每日首次使用或点击卡片时重新同步官方页,并把结果回写到该表。
|
|
119
|
+
- **卡片刻意不显示任何插件内部信息**:不显示同步状态、计价来源、价格表路径。卡片上只有余额、当前档单价、峰谷圆点与切换倒计时;**点击卡片即刷新**。
|
|
120
|
+
- 想知道某个价从哪来?打开 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`(见上文「价格表」)——每次成功同步都会把官方价合并写进去。
|
|
121
|
+
- **以前每次 `dsh` 重启后卡片都会提示"价格未同步(用内置表)"**:那是竞态——同步状态只存在内存里,重启后的第一次请求只能用内置表回答。0.7.0 起价格表落盘并在启动时读取,重启后立即显示真实价格。
|
|
90
122
|
|
|
91
123
|
## Development / 开发与自检
|
|
92
124
|
|
|
93
125
|
```sh
|
|
94
|
-
npm test # 解析 +
|
|
126
|
+
npm test # 解析 + 定价 + 价格表 + 卡片布局 + 宿主路由(node --test)
|
|
95
127
|
npm run check:pricing # 联网抓取官方页并打印解析结果;失败时退出码非 0
|
|
96
128
|
```
|
|
97
129
|
|
|
130
|
+
模块划分:`lib/index.js` 宿主路由(同步节流、余额、状态透出)· `lib/pricing.js` 三层价格
|
|
131
|
+
(remote/file/seed)· `lib/pricing-store.js` 价格表读写(可编辑的 pricing.json)·
|
|
132
|
+
`lib/pricing-remote.js` 官方页抓取与解析 · `lib/client.js` 侧边栏卡片。
|
|
133
|
+
|
|
98
134
|
官方页再次改版时(自检失败 / 卡片提示"价格未同步"):
|
|
99
135
|
|
|
100
136
|
```sh
|
|
@@ -106,11 +142,18 @@ node lib/pricing-remote.js --check --save page.html
|
|
|
106
142
|
node lib/pricing-remote.js --check --html page.html --json
|
|
107
143
|
|
|
108
144
|
# 3. 把新页面存为 test/fixtures/official-pricing-zh.html,跑 npm test 防回归
|
|
145
|
+
# (若解析出的模型集合变了,再同步更新 lib/pricing.js 里的内置快照——
|
|
146
|
+
# 它只用于首次运行生成 pricing.json)
|
|
109
147
|
```
|
|
110
148
|
|
|
149
|
+
> 测试里的"重启"是给模块图打 `?graph=<tag>`(见 `test/host-route.test.mjs`),每个 tag
|
|
150
|
+
> 都拿到全新模块实例;peer 依赖 `@deepseek-ai/dsh-credentials` 由 `test/stubs/` 里的
|
|
151
|
+
> 替身接管,所以本仓库不需要 node_modules 也能跑宿主逻辑。
|
|
152
|
+
>
|
|
111
153
|
> DSH 沙箱内 `node --test test/` 可能因禁止子进程管道而报 `spawn EPERM`,此时用
|
|
112
154
|
> `node --test --test-isolation=none "test/*.test.mjs"`(Node ≥ 22.8),或逐个跑
|
|
113
|
-
> `node test/parse.test.mjs`、`node test/pricing.test.mjs`、`node test/
|
|
155
|
+
> `node test/parse.test.mjs`、`node test/pricing.test.mjs`、`node test/pricing-store.test.mjs`、
|
|
156
|
+
> `node test/client-layout.test.mjs`、`node test/host-route.test.mjs`。
|
|
114
157
|
|
|
115
158
|
## Uninstall / 卸载
|
|
116
159
|
|
package/lib/client.js
CHANGED
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 侧边栏底部用量卡片(注册进 `sidebar.footer.action` 槽位,独占一行,位于
|
|
4
4
|
// Cordis 面板按钮下方)。轮询宿主路由 `/api/deepseek-usage`(见 lib/index.js)
|
|
5
|
-
// 每 5
|
|
5
|
+
// 每 5 分钟一次,点击卡片任意位置可立即刷新;还没拿到余额(宿主刚重启/接口刚起来)
|
|
6
|
+
// 时按 QUICK_RETRY_MS 阶梯快速补几次请求,卡片几秒内收敛。
|
|
6
7
|
// 卡片显示:余额(放大显示,符号随币种 ¥/$)+ 当前模型档单价(输入/输入缓存/
|
|
7
|
-
// 输出,图标 + 数字,位于卡片右下角)+
|
|
8
|
+
// 输出,图标 + 数字,位于卡片右下角)+ 峰谷指示灯(右上角,只有颜色)+ 距下次
|
|
9
|
+
// 峰谷切换的倒计时(`2d 15h` / `5h 47m` / `23m`,与指示灯同行、位于其左侧)。
|
|
10
|
+
//
|
|
11
|
+
// 设计约束(用户只关心"我还能花多少、这会儿什么价"):
|
|
12
|
+
// · 插件的内部信息——计价来源、同步状态、价格表文件名与路径、模型改名——一律
|
|
13
|
+
// 不出现在 UI 上(历史上圆点的 title 里塞过这些,已删除)。
|
|
14
|
+
// · 唯一的交互是「点击卡片刷新」;没有可切换的提示态。
|
|
15
|
+
// · 只有余额类错误(未配置 Key / 余额接口或网络失败)才置空卡片说明原因。
|
|
8
16
|
// 样式仅使用 `--dsw-*` 主题变量(含兜底值),跟随明暗主题。
|
|
9
17
|
window.__ModuleLoader__.load({
|
|
10
18
|
id: "@dougen/dsh-deepseek-usage",
|
|
@@ -22,33 +30,94 @@ window.__ModuleLoader__.load({
|
|
|
22
30
|
// 常态卡片高度:上下 padding 8+8 + 余额行 lineHeight 28 + 边框 2 = 46。
|
|
23
31
|
// 异常态把卡片置空时用它兜住高度,侧边栏高度不跳动。
|
|
24
32
|
const CARD_MIN_HEIGHT = 46;
|
|
33
|
+
// 卡片还没拿到余额时的快速重试节奏(宿主刚重启、接口刚起来、或本次请求失败):
|
|
34
|
+
// 否则要等满 5 分钟轮询才会从空卡片变成真实数字。
|
|
35
|
+
const QUICK_RETRY_MS = [4000, 8000, 15000, 30000];
|
|
25
36
|
|
|
26
37
|
// ---- i18n(DSH locale 服务;zh 为兜底)----
|
|
38
|
+
// 注意:卡片上只出现「输入 / 输入缓存 / 输出」这三个标签。峰谷灯没有文字、
|
|
39
|
+
// 没有 hover;倒计时统一用英文单位(d/h/m)且不带前缀,中英界面完全一致——
|
|
40
|
+
// 插件内部的同步状态、计价来源、价格表位置等一概不进 UI。
|
|
27
41
|
const NS = "dsh-deepseek-usage";
|
|
28
42
|
const ZH = {
|
|
29
43
|
"price.input": "输入",
|
|
30
44
|
"price.cache": "输入缓存",
|
|
31
45
|
"price.output": "输出",
|
|
32
|
-
"peak.peak": "高峰时段",
|
|
33
|
-
"peak.off": "空闲时段",
|
|
34
|
-
"price.stale": "价格未同步(用内置表)",
|
|
35
|
-
"price.lastGood": "同步失败,仍用上次价格",
|
|
36
|
-
"price.alias": "按 {model} 计价",
|
|
37
|
-
"price.builtin": "按内置 {model} 档计价",
|
|
38
46
|
};
|
|
39
47
|
const EN = {
|
|
40
48
|
"price.input": "Input",
|
|
41
49
|
"price.cache": "Input cache",
|
|
42
50
|
"price.output": "Output",
|
|
43
|
-
"peak.peak": "Peak hours",
|
|
44
|
-
"peak.off": "Off-peak hours",
|
|
45
|
-
"price.stale": "Prices not synced (built-in table)",
|
|
46
|
-
"price.lastGood": "Sync failed; using last prices",
|
|
47
|
-
"price.alias": "Priced as {model}",
|
|
48
|
-
"price.builtin": "Priced as built-in {model}",
|
|
49
51
|
};
|
|
50
52
|
|
|
51
|
-
// ----
|
|
53
|
+
// ---- 倒计时文案(语言无关,故不进词典)----
|
|
54
|
+
/** 峰谷灯 hover:切换时刻 / 即将切换。 */
|
|
55
|
+
const SWITCH_AT = "{at} switch";
|
|
56
|
+
const SWITCHING = "switching";
|
|
57
|
+
|
|
58
|
+
// ---- 峰谷规则(必须与 lib/pricing.js 的 isPeak / PEAK_WINDOWS /
|
|
59
|
+
// PEAK_WEEKDAYS_ONLY 保持一致;浏览器侧无法 import 宿主模块,只能镜像)----
|
|
60
|
+
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
61
|
+
const PEAK_WINDOWS = [[9, 12], [14, 18]];
|
|
62
|
+
const PEAK_WEEKDAYS_ONLY = true;
|
|
63
|
+
|
|
64
|
+
/** 某时刻的北京时间小时(0–23)。 */
|
|
65
|
+
function shanghaiHour(ms) {
|
|
66
|
+
return new Date(ms + SHANGHAI_OFFSET_MS).getUTCHours();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 某时刻是否为高峰时段(按北京时间;周末恒为空闲)。与 lib/pricing.js 的 isPeak 等价。 */
|
|
70
|
+
function isPeak(ms) {
|
|
71
|
+
const d = new Date(ms + SHANGHAI_OFFSET_MS);
|
|
72
|
+
if (PEAK_WEEKDAYS_ONLY) {
|
|
73
|
+
const dow = d.getUTCDay();
|
|
74
|
+
if (dow === 0 || dow === 6) return false;
|
|
75
|
+
}
|
|
76
|
+
const h = d.getUTCHours();
|
|
77
|
+
return PEAK_WINDOWS.some(([start, end]) => h >= start && h < end);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 距下一个计费时段边界的剩余时间;与 lib/pricing.js 的 nextChange 等价。
|
|
82
|
+
* 逐分钟向前探测(上限 8 天,覆盖"周五晚 → 下周一早")。
|
|
83
|
+
*/
|
|
84
|
+
function nextChange(ms) {
|
|
85
|
+
const now = Number.isFinite(ms) ? ms : Date.now();
|
|
86
|
+
const current = isPeak(now);
|
|
87
|
+
for (let step = 1; step <= 8 * 24 * 60; step += 1) {
|
|
88
|
+
const at = now + step * 60 * 1000;
|
|
89
|
+
const peak = isPeak(at);
|
|
90
|
+
if (peak !== current) return { peak, at, ms: Math.max(0, at - now) };
|
|
91
|
+
}
|
|
92
|
+
return { peak: !current, at: now, ms: 0 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** m/h/d 三档短标签(语言无关)。 */
|
|
96
|
+
const _u = (v, u) => `${v}${u}`;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 剩余毫秒 → 英文单位时长,最多两级:`2d 15h` / `5h 47m` / `23m` / `switching`。
|
|
100
|
+
* 不足 1 分钟显示 `switching`。向下取整,避免"还剩 59 秒却显示 1m"的反向偏差。
|
|
101
|
+
*/
|
|
102
|
+
function fmtCountdown(ms) {
|
|
103
|
+
const total = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 60000));
|
|
104
|
+
const days = Math.floor(total / 1440);
|
|
105
|
+
const hours = Math.floor((total % 1440) / 60);
|
|
106
|
+
const minutes = total % 60;
|
|
107
|
+
if (days > 0) return _u(days, "d") + " " + _u(hours, "h");
|
|
108
|
+
if (hours > 0) return _u(hours, "h") + " " + _u(minutes, "m");
|
|
109
|
+
return minutes > 0 ? _u(minutes, "m") : SWITCHING;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 时间戳(ms)→ `HH:mm`(本地时区);无效值返回空串。 */
|
|
113
|
+
function fmtClock(value) {
|
|
114
|
+
const n = Number(value);
|
|
115
|
+
if (!Number.isFinite(n) || n <= 0) return "";
|
|
116
|
+
const d = new Date(n);
|
|
117
|
+
const p = (v) => String(v).padStart(2, "0");
|
|
118
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
52
121
|
function money(value) {
|
|
53
122
|
const n = Number(value);
|
|
54
123
|
if (!Number.isFinite(n)) return "—";
|
|
@@ -103,10 +172,12 @@ window.__ModuleLoader__.load({
|
|
|
103
172
|
const wide = !!props.wide;
|
|
104
173
|
const [data, setData] = useState(null);
|
|
105
174
|
const [error, setError] = useState("");
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
const [showNormal, setShowNormal] = useState(false);
|
|
175
|
+
// 倒计时走字用的心跳:每 30 秒 bump 一次,渲染时按当前时刻重算剩余时间。
|
|
176
|
+
const [tick, setTick] = useState(0);
|
|
109
177
|
const mounted = useRef(true);
|
|
178
|
+
// 快速重试:阶梯下标 + 定时器(宿主正在后台同步时,几秒后自己再看一眼)。
|
|
179
|
+
const retryStep = useRef(0);
|
|
180
|
+
const retryTimer = useRef(null);
|
|
110
181
|
const locale = ctx && ctx.locale;
|
|
111
182
|
|
|
112
183
|
// 订阅语言变化(DSH locale 服务;不可用时回退中文)。
|
|
@@ -127,6 +198,9 @@ window.__ModuleLoader__.load({
|
|
|
127
198
|
return ZH[key] || key;
|
|
128
199
|
};
|
|
129
200
|
|
|
201
|
+
// 宿主还在后台忙(重启后首次余额、接口刚起来)时按 QUICK_RETRY_MS 阶梯补几次请求,
|
|
202
|
+
// 让卡片几秒内收敛;封顶后回到 5 分钟轮询。失败同样按此节奏重试。
|
|
203
|
+
// 判断依据只看"有没有拿到余额",不再依赖宿主内部的同步状态字段。
|
|
130
204
|
const load = useCallback(async (force) => {
|
|
131
205
|
try {
|
|
132
206
|
const res = await fetch(USAGE_PATH + "?lang=" + lang + (force ? "&refresh=1" : ""), { cache: "no-store" });
|
|
@@ -135,17 +209,36 @@ window.__ModuleLoader__.load({
|
|
|
135
209
|
body = await res.json();
|
|
136
210
|
} catch {}
|
|
137
211
|
if (!res.ok || body === null || typeof body !== "object" || body.ok !== true) {
|
|
138
|
-
|
|
212
|
+
// 失败原因优先用宿主给的文案(未配置 Key / 余额接口异常,用户看得懂);
|
|
213
|
+
// 拿不到就用一句人话兜底——不把 HTTP 状态码这类传输细节摆到卡片上。
|
|
214
|
+
throw new Error(body && typeof body.message === "string" && body.message !== "" ? body.message : "刷新失败,请稍后重试");
|
|
139
215
|
}
|
|
140
216
|
if (!mounted.current) return;
|
|
141
217
|
setData(body);
|
|
142
218
|
setError("");
|
|
219
|
+
const total = body.balance && body.balance.total;
|
|
220
|
+
if (!Number.isFinite(Number(total))) scheduleRetry();
|
|
221
|
+
else retryStep.current = 0;
|
|
143
222
|
} catch (e) {
|
|
144
223
|
if (!mounted.current) return;
|
|
145
224
|
setError(e instanceof Error ? e.message : String(e));
|
|
225
|
+
scheduleRetry();
|
|
146
226
|
}
|
|
147
227
|
}, [lang]);
|
|
148
228
|
|
|
229
|
+
/** 按 QUICK_RETRY_MS 阶梯安排一次补请求(用 ref 记录进度;卸载时清理)。 */
|
|
230
|
+
const scheduleRetry = () => {
|
|
231
|
+
if (!mounted.current) return;
|
|
232
|
+
const delay = QUICK_RETRY_MS[retryStep.current];
|
|
233
|
+
if (delay === undefined) return;
|
|
234
|
+
retryStep.current += 1;
|
|
235
|
+
if (retryTimer.current !== null) clearTimeout(retryTimer.current);
|
|
236
|
+
retryTimer.current = setTimeout(() => {
|
|
237
|
+
retryTimer.current = null;
|
|
238
|
+
load();
|
|
239
|
+
}, delay);
|
|
240
|
+
};
|
|
241
|
+
|
|
149
242
|
useEffect(() => {
|
|
150
243
|
mounted.current = true;
|
|
151
244
|
load();
|
|
@@ -164,41 +257,45 @@ window.__ModuleLoader__.load({
|
|
|
164
257
|
return () => {
|
|
165
258
|
mounted.current = false;
|
|
166
259
|
clearInterval(timer);
|
|
260
|
+
if (retryTimer.current !== null) {
|
|
261
|
+
clearTimeout(retryTimer.current);
|
|
262
|
+
retryTimer.current = null;
|
|
263
|
+
}
|
|
167
264
|
try {
|
|
168
265
|
if (typeof off === "function") off();
|
|
169
266
|
} catch (e) { /* 忽略退订错误 */ }
|
|
170
267
|
};
|
|
171
268
|
}, [load, ctx]);
|
|
172
269
|
|
|
270
|
+
// 倒计时走字:每 30 秒重算一次渲染(跨过切换瞬间时,不依赖下一次 5 分钟轮询)。
|
|
271
|
+
// 30s 而非 60s 是为了保证"剩余不足 1 分钟"的窗口不会被整分钟跳过。
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
const timer = setInterval(() => setTick((v) => v + 1), 30 * 1000);
|
|
274
|
+
return () => clearInterval(timer);
|
|
275
|
+
}, []);
|
|
276
|
+
|
|
173
277
|
const balance = data && data.balance ? data.balance : null;
|
|
174
278
|
const tier = data && data.tier ? data.tier : null;
|
|
175
|
-
const pricing = data && data.pricing ? data.pricing : null;
|
|
176
279
|
const symbol = balance && balance.currency === "USD" ? "$" : "¥";
|
|
280
|
+
// 卡片只显示"用户要的数字":余额、当前档单价、状态圆点、距下次切换的倒计时。
|
|
281
|
+
// 插件的内部信息(计价来源、同步状态、价格表文件名/路径、模型改名)一律不进 UI,
|
|
282
|
+
// 唯一的交互是点击卡片刷新。只有"余额类错误"(未配置 Key / 接口或网络失败)才
|
|
283
|
+
// 把卡片置空并说明原因——否则余额是空的,用户无从判断。
|
|
177
284
|
const errs = (Array.isArray(data && data.errors) ? data.errors : []).concat(error ? [error] : []);
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// 本次刷新失败,但仍在用上次成功同步到的官方价格
|
|
193
|
-
addHint(t("price.lastGood"), false);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
const hintText = hints.map((x) => x.text).join(" · ");
|
|
197
|
-
// blocking:同步失败/接口错误这类异常,卡片始终置空、点击只刷新;
|
|
198
|
-
// blanked:异常,或"纯良性提示且用户还没点开普通状态"。
|
|
199
|
-
const blocking = errs.length > 0 || hints.some((x) => !x.friendly);
|
|
200
|
-
const blanked = blocking || (hints.length > 0 && !showNormal);
|
|
201
|
-
const hintTitle = pricing && typeof pricing.syncError === "string" ? pricing.syncError : "";
|
|
285
|
+
const blanked = errs.length > 0;
|
|
286
|
+
// 每次渲染都以"当前时刻"求剩余时间;tick 每 30 秒 bump 一次触发这次重算,
|
|
287
|
+
// 所以跨过切换瞬间时不需要等下一次 5 分钟轮询。
|
|
288
|
+
// nextAt 优先用宿主给的 next.at(避免宿主与浏览器时钟不一致导致数字跳变),
|
|
289
|
+
// 宿主没给(旧版本 / 字段缺失)时用同一套规则在本地推算。
|
|
290
|
+
const nowMs = Date.now();
|
|
291
|
+
const nextAt = (() => {
|
|
292
|
+
const raw = data && data.next && Number(data.next.at);
|
|
293
|
+
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
294
|
+
const age = data && Number(data.updatedAt) > 0 ? Math.max(0, nowMs - Number(data.updatedAt)) : 0;
|
|
295
|
+
return nextChange(nowMs - age).at;
|
|
296
|
+
})();
|
|
297
|
+
void tick; // tick 只用来触发重渲染,值本身不参与计算
|
|
298
|
+
const countdown = fmtCountdown(nextAt - nowMs);
|
|
202
299
|
const dot = {
|
|
203
300
|
width: 8,
|
|
204
301
|
height: 8,
|
|
@@ -207,13 +304,11 @@ window.__ModuleLoader__.load({
|
|
|
207
304
|
background: data && data.peak ? C.peak : C.idle,
|
|
208
305
|
boxShadow: "0 0 4px rgba(0,0,0,.3)",
|
|
209
306
|
};
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// 点击卡片:刷新数据;若是纯良性提示,再在「提示态 / 普通态」之间切换。
|
|
307
|
+
// 圆点自身没有任何 hover(历史上的 title 里塞了同步时间、价格表路径等内部信息,
|
|
308
|
+
// 对用户毫无意义,已删除);它只表达"现在是高峰还是空闲",切换时刻由旁边的
|
|
309
|
+
// 倒计时承担。
|
|
310
|
+
// 点击卡片:唯一动作 = 刷新。
|
|
215
311
|
const onCardClick = () => {
|
|
216
|
-
if (hints.length > 0 && !blocking) setShowNormal((v) => !v);
|
|
217
312
|
load(true);
|
|
218
313
|
};
|
|
219
314
|
|
|
@@ -239,14 +334,15 @@ window.__ModuleLoader__.load({
|
|
|
239
334
|
h("span", { style: { display: "inline-flex", alignItems: "baseline", gap: 3 } },
|
|
240
335
|
h("span", { style: { fontWeight: 700, fontSize: 13, color: C.label } }, symbol),
|
|
241
336
|
h("span", { style: { fontWeight: 700, fontSize: 13, color: C.label } }, money(balance && balance.total))),
|
|
242
|
-
h("span", { style: dot
|
|
337
|
+
h("span", { style: dot }));
|
|
243
338
|
}
|
|
244
339
|
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
340
|
+
// 只有"余额类错误"(未配置 Key / 余额接口或网络失败)才走到这里:卡片置空、
|
|
341
|
+
// 只显示原因文字,不画余额/单价/圆点/倒计时。minHeight 兜住常态卡片高度
|
|
342
|
+
// (CARD_MIN_HEIGHT),侧边栏不跳动;文字多行时卡片自然变高。
|
|
343
|
+
// 注意:这里不显示任何插件内部信息(来源、同步时间、价格表路径),历史上那批
|
|
344
|
+
// 良性提示已随"去技术化"一起删除。
|
|
345
|
+
// 窄卡片(!wide)没有文字空间:仍显示余额与圆点,错误只写进日志。
|
|
250
346
|
if (blanked) {
|
|
251
347
|
return h("div", {
|
|
252
348
|
style: {
|
|
@@ -271,17 +367,9 @@ window.__ModuleLoader__.load({
|
|
|
271
367
|
},
|
|
272
368
|
onClick: onCardClick,
|
|
273
369
|
},
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
title: hintTitle || hintText,
|
|
278
|
-
}, hintText)
|
|
279
|
-
: null,
|
|
280
|
-
errs.length
|
|
281
|
-
? h("div", {
|
|
282
|
-
style: { color: C.err, fontSize: 10, lineHeight: "13px", whiteSpace: "pre-wrap", overflowWrap: "anywhere" },
|
|
283
|
-
}, errs.join(" | "))
|
|
284
|
-
: null);
|
|
370
|
+
h("div", {
|
|
371
|
+
style: { color: C.err, fontSize: 12, lineHeight: "16px", whiteSpace: "pre-wrap", overflowWrap: "anywhere" },
|
|
372
|
+
}, errs.join(" | ")));
|
|
285
373
|
}
|
|
286
374
|
|
|
287
375
|
const Price = (icon, label, value) =>
|
|
@@ -311,18 +399,33 @@ window.__ModuleLoader__.load({
|
|
|
311
399
|
},
|
|
312
400
|
onClick: onCardClick,
|
|
313
401
|
},
|
|
314
|
-
// 第一行:余额(24px,符号与数字 3px
|
|
315
|
-
|
|
402
|
+
// 第一行:余额(24px,符号与数字 3px 间距)。右侧留出"圆点 + 倒计时"的位置:
|
|
403
|
+
// 倒计时贴右,圆点再靠右,所以余额右侧 padding 是 100(约 3 个倒计时字符宽)。
|
|
404
|
+
h("div", { style: { display: "flex", alignItems: "baseline", minWidth: 0, paddingRight: 100, gap: 6 } },
|
|
316
405
|
h("span", { style: { fontWeight: 700, fontSize: 24, color: C.label, lineHeight: "28px", marginRight: 3 } }, symbol),
|
|
317
406
|
h("span", { style: { fontWeight: 700, fontSize: 24, color: C.label, lineHeight: "28px" } }, money(balance && balance.total))),
|
|
407
|
+
// 倒计时:距下次峰谷切换的剩余时间(`2d 15h` / `5h 47m` / `23m` / `switching`)。
|
|
408
|
+
// 与圆点同一行、位于圆点左侧;英文单位且无前缀,中英界面一致。
|
|
318
409
|
h("span", {
|
|
319
|
-
style: {
|
|
320
|
-
|
|
321
|
-
|
|
410
|
+
style: {
|
|
411
|
+
position: "absolute",
|
|
412
|
+
top: 9,
|
|
413
|
+
right: 27,
|
|
414
|
+
left: 9,
|
|
415
|
+
textAlign: "right",
|
|
416
|
+
fontSize: 12,
|
|
417
|
+
lineHeight: "14px",
|
|
418
|
+
color: C.sub,
|
|
419
|
+
overflow: "hidden",
|
|
420
|
+
textOverflow: "ellipsis",
|
|
421
|
+
whiteSpace: "nowrap",
|
|
422
|
+
},
|
|
423
|
+
}, countdown),
|
|
424
|
+
// 峰谷指示灯:只有颜色(橙=高峰 / 绿=空闲),没有 title、没有其他文字。
|
|
425
|
+
h("span", { style: { position: "absolute", top: 8, right: 9, ...dot } }),
|
|
322
426
|
// 底部行:只剩当前档单价,恒靠右下角。
|
|
323
427
|
// 用 flex-end 而不是 space-between:容器只有一个子元素时 space-between 会把它
|
|
324
|
-
// 推到最左侧(0.6.0
|
|
325
|
-
// 卡片承担,普通态只剩峰谷灯 hover(peakTitle)。
|
|
428
|
+
// 推到最左侧(0.6.0 的回归点)。
|
|
326
429
|
h("span", {
|
|
327
430
|
style: { position: "absolute", left: 9, right: 9, bottom: 4, display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 6 },
|
|
328
431
|
},
|
|
@@ -359,6 +462,8 @@ window.__ModuleLoader__.load({
|
|
|
359
462
|
|
|
360
463
|
exports.apply = apply;
|
|
361
464
|
exports.inject = inject;
|
|
465
|
+
// 纯函数透出(供 test/client-layout.test.mjs 直接做确定性断言,不必模拟计时器)。
|
|
466
|
+
exports.__internals = { nextChange, isPeak, fmtCountdown };
|
|
362
467
|
return module.exports;
|
|
363
468
|
}
|
|
364
469
|
});
|