@dougen/dsh-deepseek-usage 0.7.0 → 0.9.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 +25 -24
- package/lib/client.js +189 -158
- package/lib/index.js +18 -10
- package/lib/pricing.js +32 -1
- 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
|
|
|
@@ -12,10 +12,9 @@ DeepSeek **余额侧边栏插件**(DeepSeek Harness Web UI):在 DSH **网
|
|
|
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
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
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
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **Auto refresh**: every 5 minutes; click the card to refresh instantly — plus a few quick retries while the host is still syncing, so the card converges within seconds
|
|
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.
|
|
19
18
|
- **i18n**: UI copy follows the DSH locale (Chinese / English)
|
|
20
19
|
- Follows the app light/dark theme
|
|
21
20
|
|
|
@@ -23,10 +22,9 @@ DeepSeek **余额侧边栏插件**(DeepSeek Harness Web UI):在 DSH **网
|
|
|
23
22
|
- **档单价**:`→|1.5 ⚡0.05 |→4.5`(输入 / 输入缓存 / 输出,位于卡片右下角;随当前启用模型与峰谷时段自动切换,**切换模型时立即同步**)
|
|
24
23
|
- **可编辑的价格表**:价格不再写在代码里,而是存在 JSON 文件 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`。插件启动时先读它,所以**重启后卡片立刻就是正确价格**;官方同步成功后会自动回写到这份表里。手改该文件即可用于中转/手动计价,改完**无需重启**(`autoUpdate: false` 还能彻底关掉自动同步)
|
|
25
24
|
- **单价自动同步**:每日首次使用或点击卡片时,自动从官方价格页拉取最新档单价,并把结果**合并回写**到价格表(官方条目覆盖同名项,你自己加的条目保留)。页面解析采用**结构化表格解析**(直接读 HTML 表格单元格),不再依赖"正文里某段文字长什么样",官方改版/改文案不会失效;另保留纯文本兜底策略
|
|
26
|
-
- **抗改名映射**:官方给模型改名(如 `deepseek-v4-flash` → `deepseek-flash
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
- **自动刷新**:每 5 分钟一次;点击卡片任意位置立即刷新;宿主正在后台同步时还会在几秒内快速补几次请求,卡片很快收敛到最新官方价
|
|
25
|
+
- **抗改名映射**:官方给模型改名(如 `deepseek-v4-flash` → `deepseek-flash`)时,旧名按现名价格计费,不会静默显示旧价(卡片不为此弹提示,见下方 FAQ)
|
|
26
|
+
- **峰谷指示灯与倒计时**:卡片右上角圆点,高峰(周一至周五 9:00–12:00、14:00–18:00 北京时间)橙色,空闲绿色;圆点旁显示距下次峰谷切换的倒计时,统一英文单位(`2d 15h`、`5h 47m`、`23m`)
|
|
27
|
+
- **自动刷新**:每 5 分钟一次;点击卡片任意位置立即刷新
|
|
30
28
|
- **多语言**:界面文案跟随 DSH 语言(中文 / English)
|
|
31
29
|
- 跟随应用明暗主题
|
|
32
30
|
|
|
@@ -56,13 +54,13 @@ Restart the web app (`dsh web`), open http://127.0.0.1:3080 , and the usage card
|
|
|
56
54
|
|---|---|
|
|
57
55
|
| Balance | Official DeepSeek API `GET /user/balance` (uses `DEEPSEEK_API_KEY` configured in DSH) |
|
|
58
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 |
|
|
59
|
-
| Peak window | Built-in official rule: Mon–Fri 9:00–12:00, 14:00–18:00 Beijing Time (weekends are always off-peak) |
|
|
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 |
|
|
60
58
|
|
|
61
59
|
| 数据 | 来源 |
|
|
62
60
|
|---|---|
|
|
63
61
|
| 余额 | DeepSeek 官方 API `GET /user/balance`(使用 DSH 中已配置的 `DEEPSEEK_API_KEY`) |
|
|
64
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 表格单元格**(另有纯文本兜底策略);代码里的内置快照只在首次运行时用于生成价格表 |
|
|
65
|
-
| 峰谷时段 | 内置官方口径:周一至周五 9:00–12:00、14:00–18:00
|
|
63
|
+
| 峰谷时段 | 内置官方口径:周一至周五 9:00–12:00、14:00–18:00(北京时间),周末恒为空闲;界面以「指示灯 + 距下次切换倒计时」呈现 |
|
|
66
64
|
|
|
67
65
|
Make sure `DEEPSEEK_API_KEY` is configured in DSH (Model settings page) before use.
|
|
68
66
|
|
|
@@ -90,7 +88,7 @@ Make sure `DEEPSEEK_API_KEY` is configured in DSH (Model settings page) before u
|
|
|
90
88
|
- Every successful official sync **merges** into this file: official entries overwrite same-named ones, your own entries stay.
|
|
91
89
|
- Edits are picked up without restarting the plugin (the file's mtime is checked per request).
|
|
92
90
|
- If the file is corrupt, the plugin keeps the last good prices and **never overwrites** it — fix it and it reloads.
|
|
93
|
-
|
|
91
|
+
(The card itself never shows this path — it only shows your balance, the unit prices, the peak dot and the countdown.)
|
|
94
92
|
|
|
95
93
|
价格表文件位于 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`(默认 `~/.dsh/dsh-deepseek-usage/pricing.json`,可用 `DEEPSEEK_USAGE_PRICE_FILE` 覆盖)。首次运行由内置快照生成,之后由插件维护:
|
|
96
94
|
|
|
@@ -98,7 +96,7 @@ Make sure `DEEPSEEK_API_KEY` is configured in DSH (Model settings page) before u
|
|
|
98
96
|
- 每次官方同步成功都会**合并回写**本文件:官方页列出的模型覆盖同名条目,你自己加的条目保留。
|
|
99
97
|
- 手改后无需重启(每次请求会比对文件 mtime),改完点一下卡片即生效。
|
|
100
98
|
- 文件损坏时不会覆盖你的内容,会继续用上一次的价格并写日志;修好后自动重载。
|
|
101
|
-
|
|
99
|
+
(卡片上不会显示该路径——卡片只显示余额、档单价、峰谷圆点与切换倒计时。)
|
|
102
100
|
|
|
103
101
|
## FAQ / 常见问题
|
|
104
102
|
|
|
@@ -113,19 +111,13 @@ Make sure `DEEPSEEK_API_KEY` is configured in DSH (Model settings page) before u
|
|
|
113
111
|
**Price differs from the official one? / 单价与官方价格不一致?**
|
|
114
112
|
|
|
115
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.
|
|
116
|
-
-
|
|
117
|
-
|
|
118
|
-
- *Sync failed; using last prices (09-11 09:36)* — the price table has the last good official prices; only the refresh failed.
|
|
119
|
-
- *Priced as `deepseek-flash`* — the active model id was renamed by DeepSeek; the current model's price is used.
|
|
120
|
-
- *Priced as built-in `…`* — the model is neither in the official page nor in the price table; the built-in entry is used.
|
|
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.
|
|
121
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.
|
|
122
117
|
|
|
123
118
|
- 单价按**当前启用模型**与**峰谷时段**自动切换;价格取自本地价格表(上次成功同步到的官方价,或你自己改的价)。插件在每日首次使用或点击卡片时重新同步官方页,并把结果回写到该表。
|
|
124
|
-
-
|
|
125
|
-
|
|
126
|
-
- *同步失败,仍用上次价格(09-11 09:36)*:价格表里是上次同步到的官方价,只是这次刷新失败了。
|
|
127
|
-
- *按 `deepseek-flash` 计价*:官方给该模型改名了,已按现名价格计费。
|
|
128
|
-
- *按内置 `…` 档计价*:该模型既不在官方价格页,也不在价格表里,用了内置条目。
|
|
119
|
+
- **卡片刻意不显示任何插件内部信息**:不显示同步状态、计价来源、价格表路径。卡片上只有余额、当前档单价、峰谷圆点与切换倒计时;**点击卡片即刷新**。
|
|
120
|
+
- 想知道某个价从哪来?打开 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`(见上文「价格表」)——每次成功同步都会把官方价合并写进去。
|
|
129
121
|
- **以前每次 `dsh` 重启后卡片都会提示"价格未同步(用内置表)"**:那是竞态——同步状态只存在内存里,重启后的第一次请求只能用内置表回答。0.7.0 起价格表落盘并在启动时读取,重启后立即显示真实价格。
|
|
130
122
|
|
|
131
123
|
## Development / 开发与自检
|
|
@@ -162,6 +154,15 @@ node lib/pricing-remote.js --check --html page.html --json
|
|
|
162
154
|
> `node --test --test-isolation=none "test/*.test.mjs"`(Node ≥ 22.8),或逐个跑
|
|
163
155
|
> `node test/parse.test.mjs`、`node test/pricing.test.mjs`、`node test/pricing-store.test.mjs`、
|
|
164
156
|
> `node test/client-layout.test.mjs`、`node test/host-route.test.mjs`。
|
|
157
|
+
>
|
|
158
|
+
> 两种偶发失败都属于环境限制,不是插件行为,逐文件运行可稳定复现通过:
|
|
159
|
+
> - `host-route` 的"合并写回"用例依赖点击时的 `FORCED_GAP_MS`(30s)节流门;多个测试文件
|
|
160
|
+
> 共用同一进程(`--test-isolation=none`)时那条"强制同步"可能被门挡掉。
|
|
161
|
+
> - Windows 上原子写(写 `.tmp` 后 `rename`)可能被安全软件短暂占用而报
|
|
162
|
+
> `EPERM ... rename`;重跑即过。
|
|
163
|
+
>
|
|
164
|
+
> 另:`test/host-route.test.mjs` 与 `test/pricing-store.test.mjs` 都只需要一个可写的临时目录
|
|
165
|
+
> (`os.tmpdir()`),若该目录不可写(`mkdtemp ... EPERM`),把 `TEMP`/`TMP` 指到任意可写目录即可。
|
|
165
166
|
|
|
166
167
|
## Uninstall / 卸载
|
|
167
168
|
|
package/lib/client.js
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 侧边栏底部用量卡片(注册进 `sidebar.footer.action` 槽位,独占一行,位于
|
|
4
4
|
// Cordis 面板按钮下方)。轮询宿主路由 `/api/deepseek-usage`(见 lib/index.js)
|
|
5
|
-
// 每 5
|
|
6
|
-
//
|
|
5
|
+
// 每 5 分钟一次,点击卡片任意位置可立即刷新;还没拿到余额(宿主刚重启/接口刚起来)
|
|
6
|
+
// 时按 QUICK_RETRY_MS 阶梯快速补几次请求,卡片几秒内收敛。
|
|
7
7
|
// 卡片显示:余额(放大显示,符号随币种 ¥/$)+ 当前模型档单价(输入/输入缓存/
|
|
8
|
-
// 输出,图标 + 数字,位于卡片右下角)+
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// 输出,图标 + 数字,位于卡片右下角)+ 峰谷指示灯(右上角,只有颜色)+ 距下次
|
|
9
|
+
// 峰谷切换的倒计时(`2d 15h` / `5h 47m` / `23m`,与指示灯同行、位于其左侧)。
|
|
10
|
+
//
|
|
11
|
+
// 设计约束(用户只关心"我还能花多少、这会儿什么价"):
|
|
12
|
+
// · 插件的内部信息——计价来源、同步状态、价格表文件名与路径、模型改名——一律
|
|
13
|
+
// 不出现在 UI 上(历史上圆点的 title 里塞过这些,已删除)。
|
|
14
|
+
// · 唯一的交互是「点击卡片刷新」;没有可切换的提示态。
|
|
15
|
+
// · 只有余额类错误(未配置 Key / 余额接口或网络失败)才置空卡片说明原因。
|
|
12
16
|
// 样式仅使用 `--dsw-*` 主题变量(含兜底值),跟随明暗主题。
|
|
13
17
|
window.__ModuleLoader__.load({
|
|
14
18
|
id: "@dougen/dsh-deepseek-usage",
|
|
@@ -23,45 +27,99 @@ window.__ModuleLoader__.load({
|
|
|
23
27
|
// ---- constants -------------------------------------------------
|
|
24
28
|
const POLL_MS = 5 * 60 * 1000;
|
|
25
29
|
const USAGE_PATH = "/api/deepseek-usage";
|
|
26
|
-
// 常态卡片高度:上下 padding 8+
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
30
|
+
// 常态卡片高度:上下 padding 8+16 + 金额行 minHeight 28 + 边框 2 = 54。
|
|
31
|
+
// 底部多留的 8px 是为了让倒计时那一行与下方单价行之间有呼吸感。
|
|
32
|
+
// 倒计时与圆点都在金额行内定位,不额外增高(无头 Chromium 实测 cardH=54)。
|
|
33
|
+
// 异常态把卡片置空时用它兜住同一个高度,侧边栏高度不跳动。
|
|
34
|
+
const CARD_MIN_HEIGHT = 54;
|
|
35
|
+
// 卡片还没拿到余额时的快速重试节奏(宿主刚重启、接口刚起来、或本次请求失败):
|
|
36
|
+
// 否则要等满 5 分钟轮询才会从空卡片变成真实数字。
|
|
31
37
|
const QUICK_RETRY_MS = [4000, 8000, 15000, 30000];
|
|
32
38
|
|
|
33
39
|
// ---- i18n(DSH locale 服务;zh 为兜底)----
|
|
40
|
+
// 注意:卡片上只出现「输入 / 输入缓存 / 输出」这三个标签。峰谷灯没有文字、
|
|
41
|
+
// 没有 hover;倒计时统一用英文单位(d/h/m)且不带前缀,中英界面完全一致——
|
|
42
|
+
// 插件内部的同步状态、计价来源、价格表位置等一概不进 UI。
|
|
34
43
|
const NS = "dsh-deepseek-usage";
|
|
35
44
|
const ZH = {
|
|
36
45
|
"price.input": "输入",
|
|
37
46
|
"price.cache": "输入缓存",
|
|
38
47
|
"price.output": "输出",
|
|
39
|
-
"peak.peak": "高峰时段",
|
|
40
|
-
"peak.off": "空闲时段",
|
|
41
|
-
"price.stale": "价格未同步(用内置表)",
|
|
42
|
-
"price.lastGood": "同步失败,仍用上次价格({at})",
|
|
43
|
-
"price.alias": "按 {model} 计价",
|
|
44
|
-
"price.builtin": "按内置 {model} 档计价",
|
|
45
|
-
"price.file": "本地价格表({at} 同步)",
|
|
46
|
-
"price.manual": "本地价格表(自动同步已关闭)",
|
|
47
|
-
"price.filePath": "价格表:{path}",
|
|
48
48
|
};
|
|
49
49
|
const EN = {
|
|
50
50
|
"price.input": "Input",
|
|
51
51
|
"price.cache": "Input cache",
|
|
52
52
|
"price.output": "Output",
|
|
53
|
-
"peak.peak": "Peak hours",
|
|
54
|
-
"peak.off": "Off-peak hours",
|
|
55
|
-
"price.stale": "Prices not synced (built-in table)",
|
|
56
|
-
"price.lastGood": "Sync failed; using last prices ({at})",
|
|
57
|
-
"price.alias": "Priced as {model}",
|
|
58
|
-
"price.builtin": "Priced as built-in {model}",
|
|
59
|
-
"price.file": "Local price table (synced {at})",
|
|
60
|
-
"price.manual": "Local price table (auto-sync off)",
|
|
61
|
-
"price.filePath": "Price table: {path}",
|
|
62
53
|
};
|
|
63
54
|
|
|
64
|
-
// ----
|
|
55
|
+
// ---- 倒计时文案(语言无关,故不进词典)----
|
|
56
|
+
/** 峰谷灯 hover:切换时刻 / 即将切换。 */
|
|
57
|
+
const SWITCH_AT = "{at} switch";
|
|
58
|
+
const SWITCHING = "switching";
|
|
59
|
+
|
|
60
|
+
// ---- 峰谷规则(必须与 lib/pricing.js 的 isPeak / PEAK_WINDOWS /
|
|
61
|
+
// PEAK_WEEKDAYS_ONLY 保持一致;浏览器侧无法 import 宿主模块,只能镜像)----
|
|
62
|
+
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
63
|
+
const PEAK_WINDOWS = [[9, 12], [14, 18]];
|
|
64
|
+
const PEAK_WEEKDAYS_ONLY = true;
|
|
65
|
+
|
|
66
|
+
/** 某时刻的北京时间小时(0–23)。 */
|
|
67
|
+
function shanghaiHour(ms) {
|
|
68
|
+
return new Date(ms + SHANGHAI_OFFSET_MS).getUTCHours();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 某时刻是否为高峰时段(按北京时间;周末恒为空闲)。与 lib/pricing.js 的 isPeak 等价。 */
|
|
72
|
+
function isPeak(ms) {
|
|
73
|
+
const d = new Date(ms + SHANGHAI_OFFSET_MS);
|
|
74
|
+
if (PEAK_WEEKDAYS_ONLY) {
|
|
75
|
+
const dow = d.getUTCDay();
|
|
76
|
+
if (dow === 0 || dow === 6) return false;
|
|
77
|
+
}
|
|
78
|
+
const h = d.getUTCHours();
|
|
79
|
+
return PEAK_WINDOWS.some(([start, end]) => h >= start && h < end);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 距下一个计费时段边界的剩余时间;与 lib/pricing.js 的 nextChange 等价。
|
|
84
|
+
* 逐分钟向前探测(上限 8 天,覆盖"周五晚 → 下周一早")。
|
|
85
|
+
*/
|
|
86
|
+
function nextChange(ms) {
|
|
87
|
+
const now = Number.isFinite(ms) ? ms : Date.now();
|
|
88
|
+
const current = isPeak(now);
|
|
89
|
+
for (let step = 1; step <= 8 * 24 * 60; step += 1) {
|
|
90
|
+
const at = now + step * 60 * 1000;
|
|
91
|
+
const peak = isPeak(at);
|
|
92
|
+
if (peak !== current) return { peak, at, ms: Math.max(0, at - now) };
|
|
93
|
+
}
|
|
94
|
+
return { peak: !current, at: now, ms: 0 };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** m/h/d 三档短标签(语言无关)。 */
|
|
98
|
+
const _u = (v, u) => `${v}${u}`;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 剩余毫秒 → 英文单位时长,最多两级:`2d 15h` / `5h 47m` / `23m` / `switching`。
|
|
102
|
+
* 不足 1 分钟显示 `switching`。向下取整,避免"还剩 59 秒却显示 1m"的反向偏差。
|
|
103
|
+
*/
|
|
104
|
+
function fmtCountdown(ms) {
|
|
105
|
+
const total = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 60000));
|
|
106
|
+
const days = Math.floor(total / 1440);
|
|
107
|
+
const hours = Math.floor((total % 1440) / 60);
|
|
108
|
+
const minutes = total % 60;
|
|
109
|
+
if (days > 0) return _u(days, "d") + " " + _u(hours, "h");
|
|
110
|
+
if (hours > 0) return _u(hours, "h") + " " + _u(minutes, "m");
|
|
111
|
+
return minutes > 0 ? _u(minutes, "m") : SWITCHING;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 时间戳(ms)→ `HH:mm`(本地时区);无效值返回空串。 */
|
|
115
|
+
function fmtClock(value) {
|
|
116
|
+
const n = Number(value);
|
|
117
|
+
if (!Number.isFinite(n) || n <= 0) return "";
|
|
118
|
+
const d = new Date(n);
|
|
119
|
+
const p = (v) => String(v).padStart(2, "0");
|
|
120
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
65
123
|
function money(value) {
|
|
66
124
|
const n = Number(value);
|
|
67
125
|
if (!Number.isFinite(n)) return "—";
|
|
@@ -74,15 +132,6 @@ window.__ModuleLoader__.load({
|
|
|
74
132
|
return String(parseFloat(n.toFixed(2)));
|
|
75
133
|
}
|
|
76
134
|
|
|
77
|
-
/** 时间戳(ms)→ `MM-DD HH:mm`(本地时区);无效值返回空串。 */
|
|
78
|
-
function fmtWhen(value) {
|
|
79
|
-
const n = Number(value);
|
|
80
|
-
if (!Number.isFinite(n) || n <= 0) return "";
|
|
81
|
-
const d = new Date(n);
|
|
82
|
-
const p = (v) => String(v).padStart(2, "0");
|
|
83
|
-
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
135
|
// 主题变量(带兜底值)
|
|
87
136
|
const C = {
|
|
88
137
|
bg: "var(--dsw-alias-bg-layer-1, rgba(127,127,127,.12))",
|
|
@@ -125,9 +174,8 @@ window.__ModuleLoader__.load({
|
|
|
125
174
|
const wide = !!props.wide;
|
|
126
175
|
const [data, setData] = useState(null);
|
|
127
176
|
const [error, setError] = useState("");
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
const [showNormal, setShowNormal] = useState(false);
|
|
177
|
+
// 倒计时走字用的心跳:每 30 秒 bump 一次,渲染时按当前时刻重算剩余时间。
|
|
178
|
+
const [tick, setTick] = useState(0);
|
|
131
179
|
const mounted = useRef(true);
|
|
132
180
|
// 快速重试:阶梯下标 + 定时器(宿主正在后台同步时,几秒后自己再看一眼)。
|
|
133
181
|
const retryStep = useRef(0);
|
|
@@ -152,20 +200,9 @@ window.__ModuleLoader__.load({
|
|
|
152
200
|
return ZH[key] || key;
|
|
153
201
|
};
|
|
154
202
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
if (!mounted.current) return;
|
|
159
|
-
const delay = QUICK_RETRY_MS[retryStep.current];
|
|
160
|
-
if (delay === undefined) return;
|
|
161
|
-
retryStep.current += 1;
|
|
162
|
-
if (retryTimer.current !== null) clearTimeout(retryTimer.current);
|
|
163
|
-
retryTimer.current = setTimeout(() => {
|
|
164
|
-
retryTimer.current = null;
|
|
165
|
-
load();
|
|
166
|
-
}, delay);
|
|
167
|
-
};
|
|
168
|
-
|
|
203
|
+
// 宿主还在后台忙(重启后首次余额、接口刚起来)时按 QUICK_RETRY_MS 阶梯补几次请求,
|
|
204
|
+
// 让卡片几秒内收敛;封顶后回到 5 分钟轮询。失败同样按此节奏重试。
|
|
205
|
+
// 判断依据只看"有没有拿到余额",不再依赖宿主内部的同步状态字段。
|
|
169
206
|
const load = useCallback(async (force) => {
|
|
170
207
|
try {
|
|
171
208
|
const res = await fetch(USAGE_PATH + "?lang=" + lang + (force ? "&refresh=1" : ""), { cache: "no-store" });
|
|
@@ -174,15 +211,15 @@ window.__ModuleLoader__.load({
|
|
|
174
211
|
body = await res.json();
|
|
175
212
|
} catch {}
|
|
176
213
|
if (!res.ok || body === null || typeof body !== "object" || body.ok !== true) {
|
|
177
|
-
|
|
214
|
+
// 失败原因优先用宿主给的文案(未配置 Key / 余额接口异常,用户看得懂);
|
|
215
|
+
// 拿不到就用一句人话兜底——不把 HTTP 状态码这类传输细节摆到卡片上。
|
|
216
|
+
throw new Error(body && typeof body.message === "string" && body.message !== "" ? body.message : "刷新失败,请稍后重试");
|
|
178
217
|
}
|
|
179
218
|
if (!mounted.current) return;
|
|
180
219
|
setData(body);
|
|
181
220
|
setError("");
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const pricing = body.pricing && typeof body.pricing === "object" ? body.pricing : null;
|
|
185
|
-
if (pricing && pricing.mode !== "manual" && pricing.syncing === true) scheduleRetry();
|
|
221
|
+
const total = body.balance && body.balance.total;
|
|
222
|
+
if (!Number.isFinite(Number(total))) scheduleRetry();
|
|
186
223
|
else retryStep.current = 0;
|
|
187
224
|
} catch (e) {
|
|
188
225
|
if (!mounted.current) return;
|
|
@@ -191,6 +228,19 @@ window.__ModuleLoader__.load({
|
|
|
191
228
|
}
|
|
192
229
|
}, [lang]);
|
|
193
230
|
|
|
231
|
+
/** 按 QUICK_RETRY_MS 阶梯安排一次补请求(用 ref 记录进度;卸载时清理)。 */
|
|
232
|
+
const scheduleRetry = () => {
|
|
233
|
+
if (!mounted.current) return;
|
|
234
|
+
const delay = QUICK_RETRY_MS[retryStep.current];
|
|
235
|
+
if (delay === undefined) return;
|
|
236
|
+
retryStep.current += 1;
|
|
237
|
+
if (retryTimer.current !== null) clearTimeout(retryTimer.current);
|
|
238
|
+
retryTimer.current = setTimeout(() => {
|
|
239
|
+
retryTimer.current = null;
|
|
240
|
+
load();
|
|
241
|
+
}, delay);
|
|
242
|
+
};
|
|
243
|
+
|
|
194
244
|
useEffect(() => {
|
|
195
245
|
mounted.current = true;
|
|
196
246
|
load();
|
|
@@ -219,55 +269,35 @@ window.__ModuleLoader__.load({
|
|
|
219
269
|
};
|
|
220
270
|
}, [load, ctx]);
|
|
221
271
|
|
|
272
|
+
// 倒计时走字:每 30 秒重算一次渲染(跨过切换瞬间时,不依赖下一次 5 分钟轮询)。
|
|
273
|
+
// 30s 而非 60s 是为了保证"剩余不足 1 分钟"的窗口不会被整分钟跳过。
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
const timer = setInterval(() => setTick((v) => v + 1), 30 * 1000);
|
|
276
|
+
return () => clearInterval(timer);
|
|
277
|
+
}, []);
|
|
278
|
+
|
|
222
279
|
const balance = data && data.balance ? data.balance : null;
|
|
223
280
|
const tier = data && data.tier ? data.tier : null;
|
|
224
|
-
const pricing = data && data.pricing ? data.pricing : null;
|
|
225
281
|
const symbol = balance && balance.currency === "USD" ? "$" : "¥";
|
|
282
|
+
// 卡片只显示"用户要的数字":余额、当前档单价、状态圆点、距下次切换的倒计时。
|
|
283
|
+
// 插件的内部信息(计价来源、同步状态、价格表文件名/路径、模型改名)一律不进 UI,
|
|
284
|
+
// 唯一的交互是点击卡片刷新。只有"余额类错误"(未配置 Key / 接口或网络失败)才
|
|
285
|
+
// 把卡片置空并说明原因——否则余额是空的,用户无从判断。
|
|
226
286
|
const errs = (Array.isArray(data && data.errors) ? data.errors : []).concat(error ? [error] : []);
|
|
227
|
-
|
|
228
|
-
// "
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
const withAt = (key) => t(key).replace("{at}", at || "—");
|
|
242
|
-
if (pricing && typeof pricing === "object") {
|
|
243
|
-
const used = pricing.used ? String(pricing.used) : "";
|
|
244
|
-
const via = String(pricing.via || "");
|
|
245
|
-
if (via === "remote-alias" || via === "remote-family" || via === "file-alias" || via === "file-family") {
|
|
246
|
-
addHint(t("price.alias").replace("{model}", used), true);
|
|
247
|
-
} else if (pricing.syncing === true) {
|
|
248
|
-
// 后台正在拉官方页:此刻显示的是本地价格表里的价,属于正常过程,不提示。
|
|
249
|
-
} else if (pricing.source === "file") {
|
|
250
|
-
if (pricing.mode !== "manual" && pricing.syncOk === false) addHint(withAt("price.lastGood"), false);
|
|
251
|
-
} else if (pricing.source === "seed" && via !== "fallback") {
|
|
252
|
-
if (pricing.mode === "manual") {
|
|
253
|
-
// 手动模式下不联网,模型不在表里只是"用了内置条目",不是同步失败
|
|
254
|
-
addHint(t("price.builtin").replace("{model}", used), true);
|
|
255
|
-
} else if (pricing.syncOk === false) {
|
|
256
|
-
addHint(t("price.stale"), false);
|
|
257
|
-
} else {
|
|
258
|
-
addHint(t("price.builtin").replace("{model}", used), true);
|
|
259
|
-
}
|
|
260
|
-
} else if (pricing.syncOk === false && pricing.syncedAt) {
|
|
261
|
-
// 本次刷新失败,但仍在用上次成功同步到的官方价格
|
|
262
|
-
addHint(withAt("price.lastGood"), false);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
const hintText = hints.map((x) => x.text).join(" · ");
|
|
266
|
-
// blocking:同步失败/接口错误这类异常,卡片始终置空、点击只刷新;
|
|
267
|
-
// blanked:异常,或"纯良性提示且用户还没点开普通状态"。
|
|
268
|
-
const blocking = errs.length > 0 || hints.some((x) => !x.friendly);
|
|
269
|
-
const blanked = blocking || (hints.length > 0 && !showNormal);
|
|
270
|
-
const hintTitle = pricing && typeof pricing.syncError === "string" ? pricing.syncError : "";
|
|
287
|
+
const blanked = errs.length > 0;
|
|
288
|
+
// 每次渲染都以"当前时刻"求剩余时间;tick 每 30 秒 bump 一次触发这次重算,
|
|
289
|
+
// 所以跨过切换瞬间时不需要等下一次 5 分钟轮询。
|
|
290
|
+
// nextAt 优先用宿主给的 next.at(避免宿主与浏览器时钟不一致导致数字跳变),
|
|
291
|
+
// 宿主没给(旧版本 / 字段缺失)时用同一套规则在本地推算。
|
|
292
|
+
const nowMs = Date.now();
|
|
293
|
+
const nextAt = (() => {
|
|
294
|
+
const raw = data && data.next && Number(data.next.at);
|
|
295
|
+
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
296
|
+
const age = data && Number(data.updatedAt) > 0 ? Math.max(0, nowMs - Number(data.updatedAt)) : 0;
|
|
297
|
+
return nextChange(nowMs - age).at;
|
|
298
|
+
})();
|
|
299
|
+
void tick; // tick 只用来触发重渲染,值本身不参与计算
|
|
300
|
+
const countdown = fmtCountdown(nextAt - nowMs);
|
|
271
301
|
const dot = {
|
|
272
302
|
width: 8,
|
|
273
303
|
height: 8,
|
|
@@ -276,29 +306,11 @@ window.__ModuleLoader__.load({
|
|
|
276
306
|
background: data && data.peak ? C.peak : C.idle,
|
|
277
307
|
boxShadow: "0 0 4px rgba(0,0,0,.3)",
|
|
278
308
|
};
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
const sourceNote = !pricing
|
|
284
|
-
? ""
|
|
285
|
-
: pricing.mode === "manual"
|
|
286
|
-
? t("price.manual")
|
|
287
|
-
: pricing.source === "file"
|
|
288
|
-
? t("price.file").replace("{at}", at || "—")
|
|
289
|
-
: "";
|
|
290
|
-
const pathNote = fileInfo && typeof fileInfo.path === "string" && fileInfo.path !== ""
|
|
291
|
-
? t("price.filePath").replace("{path}", fileInfo.path)
|
|
292
|
-
: "";
|
|
293
|
-
const peakTitle = [
|
|
294
|
-
data && data.peak ? t("peak.peak") : t("peak.off"),
|
|
295
|
-
hintText,
|
|
296
|
-
sourceNote,
|
|
297
|
-
pathNote,
|
|
298
|
-
].filter((x) => x !== "").join(" · ");
|
|
299
|
-
// 点击卡片:刷新数据;若是纯良性提示,再在「提示态 / 普通态」之间切换。
|
|
309
|
+
// 圆点自身没有任何 hover(历史上的 title 里塞了同步时间、价格表路径等内部信息,
|
|
310
|
+
// 对用户毫无意义,已删除);它只表达"现在是高峰还是空闲",切换时刻由旁边的
|
|
311
|
+
// 倒计时承担。
|
|
312
|
+
// 点击卡片:唯一动作 = 刷新。
|
|
300
313
|
const onCardClick = () => {
|
|
301
|
-
if (hints.length > 0 && !blocking) setShowNormal((v) => !v);
|
|
302
314
|
load(true);
|
|
303
315
|
};
|
|
304
316
|
|
|
@@ -324,16 +336,15 @@ window.__ModuleLoader__.load({
|
|
|
324
336
|
h("span", { style: { display: "inline-flex", alignItems: "baseline", gap: 3 } },
|
|
325
337
|
h("span", { style: { fontWeight: 700, fontSize: 13, color: C.label } }, symbol),
|
|
326
338
|
h("span", { style: { fontWeight: 700, fontSize: 13, color: C.label } }, money(balance && balance.total))),
|
|
327
|
-
h("span", { style: dot
|
|
339
|
+
h("span", { style: dot }));
|
|
328
340
|
}
|
|
329
341
|
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// 窄卡片(!wide)没有文字空间,维持原样(提示在峰谷灯 hover 里)。
|
|
342
|
+
// 只有"余额类错误"(未配置 Key / 余额接口或网络失败)才走到这里:卡片置空、
|
|
343
|
+
// 只显示原因文字,不画余额/单价/圆点/倒计时。minHeight 兜住常态卡片高度
|
|
344
|
+
// (CARD_MIN_HEIGHT),侧边栏不跳动;文字多行时卡片自然变高。
|
|
345
|
+
// 注意:这里不显示任何插件内部信息(来源、同步时间、价格表路径),历史上那批
|
|
346
|
+
// 良性提示已随"去技术化"一起删除。
|
|
347
|
+
// 窄卡片(!wide)没有文字空间:仍显示余额与圆点,错误只写进日志。
|
|
337
348
|
if (blanked) {
|
|
338
349
|
return h("div", {
|
|
339
350
|
style: {
|
|
@@ -358,17 +369,9 @@ window.__ModuleLoader__.load({
|
|
|
358
369
|
},
|
|
359
370
|
onClick: onCardClick,
|
|
360
371
|
},
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
title: hintTitle || hintText,
|
|
365
|
-
}, hintText)
|
|
366
|
-
: null,
|
|
367
|
-
errs.length
|
|
368
|
-
? h("div", {
|
|
369
|
-
style: { color: C.err, fontSize: 12, lineHeight: "16px", whiteSpace: "pre-wrap", overflowWrap: "anywhere" },
|
|
370
|
-
}, errs.join(" | "))
|
|
371
|
-
: null);
|
|
372
|
+
h("div", {
|
|
373
|
+
style: { color: C.err, fontSize: 12, lineHeight: "16px", whiteSpace: "pre-wrap", overflowWrap: "anywhere" },
|
|
374
|
+
}, errs.join(" | ")));
|
|
372
375
|
}
|
|
373
376
|
|
|
374
377
|
const Price = (icon, label, value) =>
|
|
@@ -381,7 +384,9 @@ window.__ModuleLoader__.load({
|
|
|
381
384
|
boxSizing: "border-box",
|
|
382
385
|
flex: "1 1 100%",
|
|
383
386
|
minWidth: 0,
|
|
384
|
-
|
|
387
|
+
// 上 8 下 10(配合单价行 bottom:2,这组是用户看过后选定的观感)。
|
|
388
|
+
// 顶部保持 8px 不动,圆点才能继续紧贴右上角。
|
|
389
|
+
padding: "8px 10px 12px",
|
|
385
390
|
borderRadius: 10,
|
|
386
391
|
border: "1px solid " + C.border,
|
|
387
392
|
background: C.bg,
|
|
@@ -398,20 +403,44 @@ window.__ModuleLoader__.load({
|
|
|
398
403
|
},
|
|
399
404
|
onClick: onCardClick,
|
|
400
405
|
},
|
|
401
|
-
//
|
|
402
|
-
|
|
406
|
+
// 金额行:余额 + 右侧「倒计时 + 峰谷圆点」。
|
|
407
|
+
// 倒计时与圆点放在同一个 flex 行里、alignItems:center,两者圆心必然重合;
|
|
408
|
+
// 整组 alignSelf:flex-start + 负 margin,让圆点距卡片顶/右各 8px。
|
|
409
|
+
// 两个反面做法都试过并实测过:
|
|
410
|
+
// · alignSelf:center → 圆点被压到卡片中线,不再是"右上角";
|
|
411
|
+
// · paddingTop:6 → 金额行被撑高,卡片被撑大,圆点反而更靠下。
|
|
412
|
+
// 倒计时 minWidth:0 + overflow:hidden:窄侧边栏下先省略号截断,不挤压余额
|
|
413
|
+
// (实测 "2d 23h" 在 170px 宽的卡片里仍放得下)。
|
|
414
|
+
h("div", { style: { display: "flex", alignItems: "baseline", minHeight: 28, minWidth: 0, gap: 6 } },
|
|
403
415
|
h("span", { style: { fontWeight: 700, fontSize: 24, color: C.label, lineHeight: "28px", marginRight: 3 } }, symbol),
|
|
404
|
-
h("span", { style: { fontWeight: 700, fontSize: 24, color: C.label, lineHeight: "28px" } }, money(balance && balance.total))
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
416
|
+
h("span", { style: { fontWeight: 700, fontSize: 24, color: C.label, lineHeight: "28px" } }, money(balance && balance.total)),
|
|
417
|
+
// 负 margin 只挪动整组,不改变金额行高度(卡片高度由内边距决定)。
|
|
418
|
+
// 实测:marginTop -4 与 marginRight -3 时,圆点距卡片顶、右各 8px
|
|
419
|
+
// (右侧不能按"内边距 10 − 8"去推,内边距本就含在行宽里)。
|
|
420
|
+
h("span", { style: { display: "flex", alignItems: "center", gap: 6, minWidth: 0, marginLeft: "auto", flex: "0 1 auto", alignSelf: "flex-start", marginTop: -4, marginRight: -3 } },
|
|
421
|
+
// 倒计时:距下次峰谷切换的剩余时间(`2d 15h` / `5h 47m` / `23m` / `switching`)。
|
|
422
|
+
// 纯英文单位、无前缀,中英界面一致。
|
|
423
|
+
// 字号/字重刻意与右下角单价数字对齐(继承卡片正文 12px、fontWeight 600),
|
|
424
|
+
// 让右上角这行和右下角单价行是同一套文字样式。
|
|
425
|
+
h("span", {
|
|
426
|
+
style: {
|
|
427
|
+
fontWeight: 600,
|
|
428
|
+
color: C.sub,
|
|
429
|
+
minWidth: 0,
|
|
430
|
+
overflow: "hidden",
|
|
431
|
+
textOverflow: "ellipsis",
|
|
432
|
+
whiteSpace: "nowrap",
|
|
433
|
+
},
|
|
434
|
+
}, countdown),
|
|
435
|
+
// 峰谷指示灯:只有颜色(橙=高峰 / 绿=空闲),没有 title、没有其他文字。
|
|
436
|
+
h("span", { style: dot }))),
|
|
409
437
|
// 底部行:只剩当前档单价,恒靠右下角。
|
|
438
|
+
// 这组 bottom:2 + 卡片下内边距 10 是用户看过多组对比后选定的观感(价格略微下移、
|
|
439
|
+
// 下方不再空一大块)。注意绝对定位的行不参与卡片高度计算,改内边距时两者要一起看。
|
|
410
440
|
// 用 flex-end 而不是 space-between:容器只有一个子元素时 space-between 会把它
|
|
411
|
-
// 推到最左侧(0.6.0
|
|
412
|
-
// 卡片承担,普通态只剩峰谷灯 hover(peakTitle)。
|
|
441
|
+
// 推到最左侧(0.6.0 的回归点)。
|
|
413
442
|
h("span", {
|
|
414
|
-
style: { position: "absolute", left: 9, right: 9, bottom:
|
|
443
|
+
style: { position: "absolute", left: 9, right: 9, bottom: 2, display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 6 },
|
|
415
444
|
},
|
|
416
445
|
h("span", { style: { display: "inline-flex", alignItems: "center", gap: 5, flex: "none" } },
|
|
417
446
|
Price(h(IconIn, null), t("price.input"), tier && tier.input),
|
|
@@ -446,6 +475,8 @@ window.__ModuleLoader__.load({
|
|
|
446
475
|
|
|
447
476
|
exports.apply = apply;
|
|
448
477
|
exports.inject = inject;
|
|
478
|
+
// 纯函数透出(供 test/client-layout.test.mjs 直接做确定性断言,不必模拟计时器)。
|
|
479
|
+
exports.__internals = { nextChange, isPeak, fmtCountdown };
|
|
449
480
|
return module.exports;
|
|
450
481
|
}
|
|
451
482
|
});
|
package/lib/index.js
CHANGED
|
@@ -10,12 +10,17 @@
|
|
|
10
10
|
* ok: true,
|
|
11
11
|
* balance: { isAvailable, total, currency, granted, toppedUp }, // 官方余额接口(API Key)
|
|
12
12
|
* tier: { input, cacheRead, cacheWrite, output, model, known }, // 当前模型当前档单价
|
|
13
|
-
*
|
|
14
|
-
* mode, file: { path, updatedAt, ... } }, // 价格来源/同步状态
|
|
13
|
+
* next: { peak, at, ms }, // 距下次峰谷切换(界面倒计时)
|
|
15
14
|
* peak, peakHours, // 峰谷时段(北京时间)
|
|
16
|
-
* updatedAt, errors
|
|
15
|
+
* updatedAt, errors,
|
|
16
|
+
* pricing: { source, via, requested, used, syncOk, syncing, syncedAt, layerAt,
|
|
17
|
+
* mode, file: { path, updatedAt, ... } }, // 【内部诊断】界面不再消费
|
|
17
18
|
* }
|
|
18
19
|
*
|
|
20
|
+
* 界面只使用 balance / tier / next / peak / errors:插件内部的计价来源、同步状态、
|
|
21
|
+
* 价格表位置属于诊断信息,保留在响应里供排查与测试,但不出现在 UI 上。
|
|
22
|
+
* errors 只放"用户能理解且需要行动"的余额类失败,不再包含"模型不在价格表"这类内部细节。
|
|
23
|
+
*
|
|
19
24
|
* `lang` 参数(zh 默认)控制错误消息与峰谷文案的语言;UI 其余文案由
|
|
20
25
|
* client 端(DSH locale 服务)本地化。
|
|
21
26
|
*
|
|
@@ -51,6 +56,7 @@ import {
|
|
|
51
56
|
PEAK_HOURS_TEXT_EN,
|
|
52
57
|
filePriceMeta,
|
|
53
58
|
isPeak,
|
|
59
|
+
nextChange,
|
|
54
60
|
remotePriceMeta,
|
|
55
61
|
seedAliases,
|
|
56
62
|
seedPrices,
|
|
@@ -84,19 +90,18 @@ const JSON_HEADERS = {
|
|
|
84
90
|
"cache-control": "no-store",
|
|
85
91
|
};
|
|
86
92
|
|
|
87
|
-
/** 错误消息字典(zh/en
|
|
93
|
+
/** 错误消息字典(zh/en)。只保留"余额为什么拿不到"这类用户能理解、且需要行动的信息;
|
|
94
|
+
* 插件内部细节(计价来源、同步状态等)不再作为错误推给界面。 */
|
|
88
95
|
const MESSAGES = {
|
|
89
96
|
zh: {
|
|
90
97
|
noKey: "未配置 DEEPSEEK_API_KEY",
|
|
91
98
|
balanceHttp: (status) => `余额接口 HTTP ${status}`,
|
|
92
99
|
balanceFail: (detail) => `余额获取失败: ${detail}`,
|
|
93
|
-
modelUnknown: (model) => `当前模型 ${model} 不在价格表,档单价按 deepseek-chat 展示`,
|
|
94
100
|
},
|
|
95
101
|
en: {
|
|
96
102
|
noKey: "DEEPSEEK_API_KEY is not configured",
|
|
97
103
|
balanceHttp: (status) => `Balance API HTTP ${status}`,
|
|
98
104
|
balanceFail: (detail) => `Failed to fetch balance: ${detail}`,
|
|
99
|
-
modelUnknown: (model) => `Model ${model} is not in the price table, showing deepseek-chat pricing`,
|
|
100
105
|
},
|
|
101
106
|
};
|
|
102
107
|
|
|
@@ -415,14 +420,17 @@ function apply(ctx) {
|
|
|
415
420
|
errors.push(msg.balanceFail(error instanceof Error ? error.message : String(error)));
|
|
416
421
|
}
|
|
417
422
|
|
|
418
|
-
// ----
|
|
419
|
-
|
|
423
|
+
// ---- 档单价:未知模型静默回退 chat 档(属内部兜底,不再作为错误推给界面)----
|
|
424
|
+
const next = nextChange(now);
|
|
420
425
|
|
|
421
426
|
sendJson(res, 200, {
|
|
422
427
|
ok: true,
|
|
423
428
|
balance,
|
|
424
429
|
peak: isPeak(now),
|
|
425
430
|
peakHours: lang === "en" ? PEAK_HOURS_TEXT_EN : PEAK_HOURS_TEXT,
|
|
431
|
+
// 距下一次峰谷切换的剩余时间(界面用它在指示灯旁显示倒计时):
|
|
432
|
+
// peak = 切换后所处的时段,at = 切换时刻,ms = 剩余毫秒
|
|
433
|
+
next,
|
|
426
434
|
tier: {
|
|
427
435
|
input: unit.input,
|
|
428
436
|
cacheRead: unit.cacheRead,
|
|
@@ -431,8 +439,8 @@ function apply(ctx) {
|
|
|
431
439
|
model: unit.model,
|
|
432
440
|
known: unit.known,
|
|
433
441
|
},
|
|
434
|
-
//
|
|
435
|
-
//
|
|
442
|
+
// 【内部诊断】价格来源与同步状态:界面不再展示(卡片去技术化),保留字段
|
|
443
|
+
// 供排查问题与回归测试使用。
|
|
436
444
|
pricing: {
|
|
437
445
|
source: unit.source, // remote=本次官方页同步 / file=本地价格表 / seed=内置兜底
|
|
438
446
|
via: unit.via, // remote* | file* | alias | seed | fallback
|
package/lib/pricing.js
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* 峰谷(官方口径):高峰时段为北京时间周一至周五 9:00–12:00、14:00–18:00,
|
|
24
24
|
* 其余(含整个周末)为空闲时段;缓存写暂不计费(cacheWrite = 0)。
|
|
25
25
|
*
|
|
26
|
-
* 本文件用于卡片"当前档单价"
|
|
26
|
+
* 本文件用于卡片"当前档单价"展示、峰谷指示灯,以及 nextChange() 给出的
|
|
27
|
+
* 「距下次峰谷切换倒计时」(卡片上显示为 `2d 15h` / `5h 47m` 这类英文单位时长)。
|
|
27
28
|
*
|
|
28
29
|
* 时区(0.1.7 起):峰谷判定与"每日"边界均按北京时间(Asia/Shanghai,固定
|
|
29
30
|
* UTC+8,无夏令时)计算,不随服务器本地时区变化——在任何时区部署的 DSH 上
|
|
@@ -95,6 +96,36 @@ export function isPeak(ms) {
|
|
|
95
96
|
return PEAK_WINDOWS.some(([start, end]) => h >= start && h < end);
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* 距下一个计费时段边界的剩余时间(供卡片显示倒计时)。
|
|
101
|
+
*
|
|
102
|
+
* 与 isPeak 同源同口径(北京时间、工作日、PEAK_WINDOWS 半开区间),只是换一个视图:
|
|
103
|
+
* 返回「下一次 isPeak 结果发生翻转」的时刻。注意空闲时段里的下一次切换**不等于**下一个
|
|
104
|
+
* 整点:
|
|
105
|
+
* · 12:00–14:00 午休 → 下一次切换是 14:00(高峰开始);
|
|
106
|
+
* · 周五 18:00 之后、整个周末 → 下一次切换是下周一 09:00(跨 2 天多)。
|
|
107
|
+
* 因此倒计时必须能显示「天」。
|
|
108
|
+
*
|
|
109
|
+
* 实现:逐分钟向前探测(上限 8 天),纯算术,无时区/日历依赖。
|
|
110
|
+
* 任何口径变更必须同时改 isPeak / PEAK_WINDOWS / PEAK_WEEKDAYS_ONLY,以及
|
|
111
|
+
* lib/client.js 里为浏览器侧镜像的同名常量(那边无法 import 本模块)。
|
|
112
|
+
*
|
|
113
|
+
* @returns {{ peak: boolean, at: number, ms: number }} peak=切换后所处的时段,
|
|
114
|
+
* at=切换发生的时刻(ms 时间戳,新时段在此刻生效),ms=剩余毫秒(不会为负)。
|
|
115
|
+
*/
|
|
116
|
+
export function nextChange(ms) {
|
|
117
|
+
const now = Number.isFinite(ms) ? ms : Date.now();
|
|
118
|
+
const current = isPeak(now);
|
|
119
|
+
// 逐分钟探测(上限 8 天:覆盖"周五晚 → 下周一早"的最长空档)
|
|
120
|
+
for (let step = 1; step <= 8 * 24 * 60; step += 1) {
|
|
121
|
+
const at = now + step * 60 * 1000;
|
|
122
|
+
const peak = isPeak(at);
|
|
123
|
+
if (peak !== current) return { peak, at, ms: Math.max(0, at - now) };
|
|
124
|
+
}
|
|
125
|
+
// 理论上不可达(不变量:一周内必有边界);兜底给"下一天 09:00"方向的最小值
|
|
126
|
+
return { peak: !current, at: now, ms: 0 };
|
|
127
|
+
}
|
|
128
|
+
|
|
98
129
|
/** 清洗一张价格表(形状校验 + 档位回退),供 remote / file 两层共用。 */
|
|
99
130
|
function cleanTable(table) {
|
|
100
131
|
if (!table || typeof table !== 'object') return null;
|