deepseek-harness-quota 0.1.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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/cordis.patch.yml +12 -0
- package/docs/assets/dsh-quota-overview.png +0 -0
- package/lib/client.js +931 -0
- package/lib/index.js +106 -0
- package/package.json +62 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## 0.1.0
|
|
6
|
+
|
|
7
|
+
- Display the real DeepSeek account balance in the DSH Web sidebar.
|
|
8
|
+
- Show topped-up and granted balance details, low-balance warnings, and per-turn cost deltas.
|
|
9
|
+
- Support privacy mode, smart polling, request timeouts, keyboard access, and touch long-press.
|
|
10
|
+
- Link to the official DeepSeek Top-up and Usage pages.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-quota contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# dsh-quota 💰
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>DeepSeek Harness(DSH)Web 侧边栏余额插件</strong><br>
|
|
5
|
+
在不打断工作流的前提下查看 DeepSeek 账户余额、额度明细与单轮消耗。
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<img src="https://img.shields.io/badge/version-0.1.0-blue.svg" alt="Version 0.1.0" />
|
|
10
|
+
<img src="https://img.shields.io/badge/Node-%3E%3D20-green.svg" alt="Node.js 20 or later" />
|
|
11
|
+
<img src="https://img.shields.io/badge/platform-DSH%20Web-purple.svg" alt="DSH Web" />
|
|
12
|
+
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License" />
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="docs/assets/dsh-quota-overview.png" alt="dsh-quota 在 DSH 侧边栏展示账户余额与余额明细" width="100%" />
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<p align="center"><sub>余额常驻侧边栏底部;右键可查看明细、切换隐私模式或手动刷新。</sub></p>
|
|
20
|
+
|
|
21
|
+
## 功能
|
|
22
|
+
|
|
23
|
+
- **融入原生侧边栏**:宽栏时余额位于「设置」右侧;收起侧边栏后自动切换为紧凑胶囊。
|
|
24
|
+
- **余额明细**:右键打开卡片,查看总余额、充值余额、赠送余额和更新时间,并可前往 DeepSeek 官方充值页。
|
|
25
|
+
- **官方用量入口**:从明细卡片直接打开 DeepSeek Usage 页面,查看官方统计的 Token 与费用明细。
|
|
26
|
+
- **单轮消耗提示**:检测到余额下降时,显示类似 `-¥0.03` 的上浮提示,2.6 秒后自动消失。
|
|
27
|
+
- **余额状态预警**:人民币余额低于 `¥5`(美元低于 `$1`)时显示橙色;余额为零、欠费或不可用时显示红色。
|
|
28
|
+
- **隐私模式**:双击余额或点击卡片中的「隐藏」,将金额替换为 `¥ ****`;偏好保存在浏览器本地。
|
|
29
|
+
- **智能刷新**:会话结束后自动刷新;前台每 60 秒、后台每 5 分钟轮询;回到前台时按需补刷。
|
|
30
|
+
- **窄栏智能格式化**:根据金额范围保留适当精度,并对千位金额使用 `k` 缩写,避免文本溢出。
|
|
31
|
+
- **版本可见**:明细卡片显示当前插件版本,方便排查与更新。
|
|
32
|
+
|
|
33
|
+
## 快速开始
|
|
34
|
+
|
|
35
|
+
### 环境要求
|
|
36
|
+
|
|
37
|
+
- DSH Web;
|
|
38
|
+
- Node.js 20 或更高版本;
|
|
39
|
+
- 已在 DSH 中配置 DeepSeek API Key,或已设置 `DEEPSEEK_API_KEY` 环境变量。
|
|
40
|
+
|
|
41
|
+
### 安装
|
|
42
|
+
|
|
43
|
+
在本仓库目录执行:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
dsh plugin --profile web add "$PWD"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
也可以传入插件目录的绝对路径:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
dsh plugin --profile web add /path/to/dsh-quota
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
首次添加 bundle 后,停止并重新启动 `dsh web`。后续修改 `lib/client.js` 时,`dsh-client-hmr` 会自动更新浏览器端代码,通常无需再次重启。
|
|
56
|
+
|
|
57
|
+
### 验证安装
|
|
58
|
+
|
|
59
|
+
将 `3000` 替换为当前 DSH Web 端口:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
DSH_QUOTA_PORT=3000
|
|
63
|
+
curl -i "http://127.0.0.1:${DSH_QUOTA_PORT}/plugins/dsh-quota/client.js"
|
|
64
|
+
curl -s "http://127.0.0.1:${DSH_QUOTA_PORT}/dsh-quota/balance"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
客户端地址应返回以 `window.__ModuleLoader__.load` 开头的 JavaScript;余额地址应返回 JSON。
|
|
68
|
+
|
|
69
|
+
## 使用方式
|
|
70
|
+
|
|
71
|
+
| 操作 | 结果 |
|
|
72
|
+
|:---|:---|
|
|
73
|
+
| 左键单击余额 | 立即刷新;刷新图标至少显示 350ms,避免闪烁 |
|
|
74
|
+
| 左键双击余额 | 显示或隐藏金额,且不会选中余额文本 |
|
|
75
|
+
| 右键单击余额 | 展开或收起账户余额明细卡片 |
|
|
76
|
+
| 触屏长按余额 | 打开账户余额明细卡片 |
|
|
77
|
+
| 聚焦后按 `Shift+F10` / `↑` | 通过键盘展开或收起明细卡片 |
|
|
78
|
+
| 点击卡片外部 / 按 `Esc` | 关闭明细卡片 |
|
|
79
|
+
| 点击「充值」 | 在新标签页打开 DeepSeek 官方充值页 |
|
|
80
|
+
| 点击「用量」 | 在新标签页打开 [DeepSeek 官方 Usage 页面](https://platform.deepseek.com/usage) |
|
|
81
|
+
| 对话结束 | 延迟 800ms 自动刷新;余额下降时显示本轮消耗 |
|
|
82
|
+
|
|
83
|
+
隐私模式通过浏览器 `localStorage` 中的 `dsh-quota:privacy` 保存,仅影响界面展示,不改变服务端返回的数据。
|
|
84
|
+
|
|
85
|
+
## 用量查看
|
|
86
|
+
|
|
87
|
+
明细卡片中的「用量」会直接打开 [DeepSeek 官方 Usage 页面](https://platform.deepseek.com/usage)。插件不在本地自行累计 Token 或推算历史费用,原因是余额差值可能同时包含其他设备或 API Key 的调用、充值、赠金变化和延迟结算,无法替代官方账单。
|
|
88
|
+
|
|
89
|
+
余额接口仅用于当前余额、充值余额和赠送余额展示;需要按 API Key 或月份核对详细用量时,请以官方 Usage 页面及其导出数据为准。
|
|
90
|
+
|
|
91
|
+
## 刷新策略
|
|
92
|
+
|
|
93
|
+
| 触发场景 | 行为 |
|
|
94
|
+
|:---|:---|
|
|
95
|
+
| 首次挂载 | 立即请求一次余额 |
|
|
96
|
+
| 任一会话从运行中变为结束 | 800ms 后请求余额,等待上游计费落库 |
|
|
97
|
+
| 页面位于前台 | 每 60 秒请求一次 |
|
|
98
|
+
| 页面位于后台或最小化 | 每 5 分钟请求一次 |
|
|
99
|
+
| 页面回到前台 | 若距离上次定时请求已超过 60 秒,则立即补刷 |
|
|
100
|
+
| 用户手动点击 | 立即请求;连续刷新期间忽略余额胶囊上的重复点击 |
|
|
101
|
+
|
|
102
|
+
只有新余额低于上一次成功读取的余额时才会显示消耗提示。免费额度、缓存命中或上游尚未结算时,不会出现该动画。
|
|
103
|
+
|
|
104
|
+
## 配置
|
|
105
|
+
|
|
106
|
+
默认配置可直接使用:
|
|
107
|
+
|
|
108
|
+
| 字段 | 默认值 | 说明 |
|
|
109
|
+
|:---|:---|:---|
|
|
110
|
+
| `baseURL` | `https://api.deepseek.com` | DeepSeek API 基础地址;末尾的 `/` 会自动移除 |
|
|
111
|
+
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 交给 DSH `credentials.resolve()` 解析的凭据引用 |
|
|
112
|
+
| `timeoutMs` | `10000` | 上游余额请求超时,单位为毫秒,最大 60 秒 |
|
|
113
|
+
|
|
114
|
+
如需代理 DeepSeek API 或改用其他凭据引用,可在 Web profile 的 Cordis patch 中覆盖配置:
|
|
115
|
+
|
|
116
|
+
```yaml
|
|
117
|
+
- patch:
|
|
118
|
+
- id: dsh-quota
|
|
119
|
+
config:
|
|
120
|
+
baseURL: https://api.deepseek.com
|
|
121
|
+
apiKeyEnv: DEEPSEEK_API_KEY
|
|
122
|
+
timeoutMs: 10000
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`apiKeyEnv` 必须是合法的环境变量名称,例如 `DEEPSEEK_API_KEY` 或 `MY_DEEPSEEK_KEY`。
|
|
126
|
+
|
|
127
|
+
## 安全与数据流
|
|
128
|
+
|
|
129
|
+
- API Key 只在 DSH Host 端通过 `credentials.resolve()` 解析,不会注入浏览器 bundle。
|
|
130
|
+
- Client 仅请求同源地址 `/dsh-quota/balance`。
|
|
131
|
+
- Host 使用 Bearer Token 请求 `${baseURL}/user/balance`,再将上游 JSON 包装后返回。
|
|
132
|
+
- 本地路由响应带有 `Cache-Control: no-store`,避免余额被浏览器或中间缓存保存。
|
|
133
|
+
- Host 和 Client 均设置 10 秒请求超时;并发刷新会合并为同一个在途请求。
|
|
134
|
+
- 插件不引入额外的第三方 npm 运行时依赖;Host 使用 Node.js 内置 `fetch`。
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
DSH Client
|
|
138
|
+
└─ GET /dsh-quota/balance(同源,不含 API Key)
|
|
139
|
+
└─ DSH Host + credentials.resolve()
|
|
140
|
+
└─ GET https://api.deepseek.com/user/balance
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
余额字段遵循 [DeepSeek 官方查询余额 API](https://api-docs.deepseek.com/zh-cn/api/get-user-balance) 的 `is_available` 与 `balance_infos` 结构。
|
|
144
|
+
|
|
145
|
+
## 响应与错误
|
|
146
|
+
|
|
147
|
+
Host 路由始终返回 JSON。上游请求成功时,格式为:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"ok": true,
|
|
152
|
+
"status": 200,
|
|
153
|
+
"data": {}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
无法解析凭据或请求失败时,`ok` 为 `false`,并返回可读的 `error.code` 与 `error.message`。为便于 Client 统一处理,这类应用层错误当前仍使用 HTTP `200` 返回。
|
|
158
|
+
|
|
159
|
+
## 版本与更新
|
|
160
|
+
|
|
161
|
+
插件版本会显示在余额明细卡片右上角,并由测试确保与 `package.json` 保持一致。npm 包名为 `deepseek-harness-quota`;内部插件 ID 仍为 `dsh-quota`。
|
|
162
|
+
|
|
163
|
+
发布到 npm 后可通过 DSH plugin 命令安装、更新或移除:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
dsh plugin --profile web add deepseek-harness-quota
|
|
167
|
+
dsh plugin --profile web update deepseek-harness-quota
|
|
168
|
+
dsh plugin --profile web remove deepseek-harness-quota
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
更新或移除 Host bundle 后请重启 `dsh web`。插件不会在运行时自行下载或安装更新。
|
|
172
|
+
|
|
173
|
+
## 常见问题
|
|
174
|
+
|
|
175
|
+
### 为什么显示 `¥ ?`?
|
|
176
|
+
|
|
177
|
+
将鼠标悬停在余额上查看错误详情,并直接请求 `/dsh-quota/balance` 排查。常见原因是未配置 API Key、凭据引用名称错误、上游网络不可达或 DeepSeek API 返回异常。
|
|
178
|
+
|
|
179
|
+
### 为什么添加插件后界面没有变化?
|
|
180
|
+
|
|
181
|
+
确认使用的是 `web` profile,并在首次添加插件后重启 `dsh web`。随后检查客户端 bundle 地址是否能正常返回 JavaScript。
|
|
182
|
+
|
|
183
|
+
### 为什么没有显示本轮消耗?
|
|
184
|
+
|
|
185
|
+
消耗提示只在连续两次成功读取之间检测到余额下降时出现。如果该轮尚未结算、没有实际扣费或余额接口返回失败,则不会触发提示。
|
|
186
|
+
|
|
187
|
+
### API Key 会发送到浏览器吗?
|
|
188
|
+
|
|
189
|
+
不会。浏览器只访问同源余额路由,Bearer Token 仅由 Host 端添加到发往 DeepSeek 的请求中。
|
|
190
|
+
|
|
191
|
+
## 开发与验证
|
|
192
|
+
|
|
193
|
+
修改代码后先进行语法检查:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
npm test
|
|
197
|
+
npm run check
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
运行 DSH Web 后,建议依次检查:
|
|
201
|
+
|
|
202
|
+
1. 宽栏余额是否与「设置」同行并靠右对齐;
|
|
203
|
+
2. 左键刷新、双击隐私模式、右键明细卡片是否正常;
|
|
204
|
+
3. 卡片是否能通过外部点击和 `Esc` 关闭;
|
|
205
|
+
4. 收起侧边栏后金额是否以紧凑格式完整显示;
|
|
206
|
+
5. 前后台切换和会话结束后是否触发预期刷新;
|
|
207
|
+
6. API Key 未配置时,Tooltip 是否显示可诊断的错误信息。
|
|
208
|
+
|
|
209
|
+
## 项目结构
|
|
210
|
+
|
|
211
|
+
```text
|
|
212
|
+
dsh-quota/
|
|
213
|
+
├── .github/workflows/ci.yml # Node.js 20/22 持续集成检查
|
|
214
|
+
├── docs/
|
|
215
|
+
│ └── assets/
|
|
216
|
+
│ └── dsh-quota-overview.png # README 功能展示图
|
|
217
|
+
├── lib/
|
|
218
|
+
│ ├── index.js # Host:凭据解析、余额代理路由
|
|
219
|
+
│ └── client.js # Client:侧边栏组件、交互与刷新逻辑
|
|
220
|
+
├── cordis.patch.yml # Cordis Host 插件挂载声明
|
|
221
|
+
├── package.json # 导出、DSH bundle 与 Web Client 声明
|
|
222
|
+
├── test/ # Client 解析、Host 路由与包校验测试
|
|
223
|
+
├── AGENTS.md # 项目架构与维护约束
|
|
224
|
+
├── CLAUDE.md # 与 AGENTS.md 同步的项目约束
|
|
225
|
+
├── CHANGELOG.md # 版本变更记录
|
|
226
|
+
├── LICENSE # MIT License
|
|
227
|
+
└── README.md
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
本项目基于 [MIT License](LICENSE) 开源。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# dsh-quota bundle patch
|
|
2
|
+
#
|
|
3
|
+
# Mounted when the plugin is installed through the DSH CLI:
|
|
4
|
+
#
|
|
5
|
+
# dsh plugin --profile web add /path/to/dsh-quota
|
|
6
|
+
#
|
|
7
|
+
# The command appends `dsh-quota` to `dsh.profile.bundles` (see the package's
|
|
8
|
+
# `dsh.bundle.patch` declaration), and profile boot merges THIS patch — one
|
|
9
|
+
# insert of the host half — into the Loader entry tree.
|
|
10
|
+
- insert:
|
|
11
|
+
- id: dsh-quota
|
|
12
|
+
name: 'dsh-quota'
|
|
Binary file
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
// dsh-quota client half — a web client bundle registered through
|
|
2
|
+
// window.__ModuleLoader__.load({id, factory}). The factory materializes
|
|
3
|
+
// lazily and returns { apply, inject }; cordis mounts it as a client plugin.
|
|
4
|
+
// apply() registers a small balance readout into the sidebar foot action list
|
|
5
|
+
// (kind 'list') and refreshes it from the host route /dsh-quota/balance.
|
|
6
|
+
window.__ModuleLoader__.load({
|
|
7
|
+
id: "dsh-quota",
|
|
8
|
+
factory: (require) => {
|
|
9
|
+
var module = { exports: {} };
|
|
10
|
+
var exports = module.exports;
|
|
11
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
12
|
+
|
|
13
|
+
let React = require("react");
|
|
14
|
+
|
|
15
|
+
const ENDPOINT = "/dsh-quota/balance";
|
|
16
|
+
const ACTIVE_REFRESH_MS = 60000; // 正常活跃时 60 秒刷新
|
|
17
|
+
const IDLE_REFRESH_MS = 300000; // 窗口后台/闲置时 5 分钟刷新
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 10000;
|
|
19
|
+
const VERSION = "0.1.0";
|
|
20
|
+
const TOP_UP_URL = "https://platform.deepseek.com/top_up";
|
|
21
|
+
const USAGE_URL = "https://platform.deepseek.com/usage";
|
|
22
|
+
|
|
23
|
+
const inject = ["slots"];
|
|
24
|
+
|
|
25
|
+
/** Currency symbol for a DeepSeek balance currency code. */
|
|
26
|
+
function currencySymbol(currency) {
|
|
27
|
+
switch (currency) {
|
|
28
|
+
case "CNY":
|
|
29
|
+
return "¥";
|
|
30
|
+
case "USD":
|
|
31
|
+
return "$";
|
|
32
|
+
default:
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Pick the primary readable balance from the DeepSeek balance payload. */
|
|
38
|
+
function readBalance(json) {
|
|
39
|
+
if (!json || json.ok !== true) {
|
|
40
|
+
const upstreamMessage = json && json.data && (json.data.message || json.data.error);
|
|
41
|
+
const error = json && json.error;
|
|
42
|
+
throw new Error(
|
|
43
|
+
(error && error.message) ||
|
|
44
|
+
(typeof upstreamMessage === "string" ? upstreamMessage : "DeepSeek 余额请求失败")
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const data = json.data;
|
|
49
|
+
const infos = data && Array.isArray(data.balance_infos) ? data.balance_infos : [];
|
|
50
|
+
const info = infos.find((item) => item && item.currency === "CNY")
|
|
51
|
+
|| infos.find((item) => item && item.currency === "USD")
|
|
52
|
+
|| infos.find((item) => item && typeof item.currency === "string");
|
|
53
|
+
|
|
54
|
+
if (!info) {
|
|
55
|
+
throw new Error("DeepSeek 余额响应中缺少 balance_infos");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const amount = (value, field) => {
|
|
59
|
+
const text = String(value ?? "");
|
|
60
|
+
if (text === "" || !Number.isFinite(Number(text))) {
|
|
61
|
+
throw new Error(`DeepSeek 余额响应中的 ${field} 无效`);
|
|
62
|
+
}
|
|
63
|
+
return text;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
currency: info.currency,
|
|
68
|
+
total: amount(info.total_balance, "total_balance"),
|
|
69
|
+
granted: amount(info.granted_balance, "granted_balance"),
|
|
70
|
+
toppedUp: amount(info.topped_up_balance, "topped_up_balance"),
|
|
71
|
+
available: data.is_available === true,
|
|
72
|
+
updatedAt: Date.now()
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatTime(timestamp) {
|
|
77
|
+
if (!timestamp) return "—";
|
|
78
|
+
const d = new Date(timestamp);
|
|
79
|
+
const h = String(d.getHours()).padStart(2, "0");
|
|
80
|
+
const m = String(d.getMinutes()).padStart(2, "0");
|
|
81
|
+
const s = String(d.getSeconds()).padStart(2, "0");
|
|
82
|
+
return `${h}:${m}:${s}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 窄栏精简格式化(智能去小数/大数缩写,彻底避免 ... 截断) */
|
|
86
|
+
function formatRailBalance(value, privacy) {
|
|
87
|
+
if (value == null) return "…";
|
|
88
|
+
if (privacy) return "****";
|
|
89
|
+
const symbol = currencySymbol(value.currency) || "¥";
|
|
90
|
+
const num = parseFloat(value.total);
|
|
91
|
+
if (isNaN(num)) return `${symbol} ?`;
|
|
92
|
+
|
|
93
|
+
if (num >= 10000) {
|
|
94
|
+
return `${symbol}${(num / 1000).toFixed(0)}k`;
|
|
95
|
+
}
|
|
96
|
+
if (num >= 1000) {
|
|
97
|
+
return `${symbol}${(num / 1000).toFixed(1)}k`;
|
|
98
|
+
}
|
|
99
|
+
// >= 100 时去除小数(例如 195.91 -> ¥196),完全不溢出
|
|
100
|
+
if (num >= 100) {
|
|
101
|
+
return `${symbol}${Math.round(num)}`;
|
|
102
|
+
}
|
|
103
|
+
// 10 <= num < 100:若小数为 .00 则精简,否则保留1位小数
|
|
104
|
+
if (num >= 10) {
|
|
105
|
+
const fixed = num.toFixed(1);
|
|
106
|
+
return `${symbol}${fixed.endsWith(".0") ? Math.round(num) : fixed}`;
|
|
107
|
+
}
|
|
108
|
+
// < 10:保留2位小数(例如 ¥5.93, ¥0.50)
|
|
109
|
+
return `${symbol}${num.toFixed(2)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const STYLE_ID = "@dsh-quota/sidebar-balance";
|
|
113
|
+
if (typeof document !== "undefined" && document.querySelector(`style[data-plugin="${STYLE_ID}"]`) === null) {
|
|
114
|
+
const tag = document.createElement("style");
|
|
115
|
+
tag.dataset.plugin = STYLE_ID;
|
|
116
|
+
tag.textContent = `
|
|
117
|
+
@keyframes dsh-quota-spin {
|
|
118
|
+
from { transform: rotate(0deg); }
|
|
119
|
+
to { transform: rotate(360deg); }
|
|
120
|
+
}
|
|
121
|
+
@keyframes dsh-quota-float-up {
|
|
122
|
+
0% { opacity: 0; transform: translateY(4px) scale(0.9); }
|
|
123
|
+
15% { opacity: 1; transform: translateY(0) scale(1); }
|
|
124
|
+
75% { opacity: 1; transform: translateY(0) scale(1); }
|
|
125
|
+
100% { opacity: 0; transform: translateY(-8px) scale(0.92); }
|
|
126
|
+
}
|
|
127
|
+
@keyframes dsh-quota-popover-in {
|
|
128
|
+
from { opacity: 0; transform: translateY(6px) scale(0.96); }
|
|
129
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.dsh-quota-spinning {
|
|
133
|
+
animation: dsh-quota-spin 0.75s linear infinite;
|
|
134
|
+
transform-origin: center center;
|
|
135
|
+
display: inline-block;
|
|
136
|
+
}
|
|
137
|
+
[class*="_footArea"] {
|
|
138
|
+
position: relative !important;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.dsh-quota-balance-wide,
|
|
142
|
+
.dsh-quota-balance-wide *,
|
|
143
|
+
.dsh-quota-balance-rail,
|
|
144
|
+
.dsh-quota-balance-rail * {
|
|
145
|
+
-webkit-user-select: none !important;
|
|
146
|
+
-moz-user-select: none !important;
|
|
147
|
+
-ms-user-select: none !important;
|
|
148
|
+
user-select: none !important;
|
|
149
|
+
-webkit-touch-callout: none !important;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.dsh-quota-balance-wide {
|
|
153
|
+
position: absolute;
|
|
154
|
+
right: 4px;
|
|
155
|
+
top: 50%;
|
|
156
|
+
transform: translateY(-50%);
|
|
157
|
+
height: 28px;
|
|
158
|
+
display: inline-flex;
|
|
159
|
+
align-items: center;
|
|
160
|
+
gap: 5px;
|
|
161
|
+
padding: 0 8px;
|
|
162
|
+
border-radius: 6px;
|
|
163
|
+
font-size: 12px;
|
|
164
|
+
font-weight: 500;
|
|
165
|
+
font-variant-numeric: tabular-nums;
|
|
166
|
+
color: var(--dsw-alias-label-secondary);
|
|
167
|
+
cursor: pointer;
|
|
168
|
+
border: 0;
|
|
169
|
+
background: transparent;
|
|
170
|
+
font-family: inherit;
|
|
171
|
+
white-space: nowrap;
|
|
172
|
+
z-index: 2;
|
|
173
|
+
transition: background-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
|
|
174
|
+
}
|
|
175
|
+
.dsh-quota-balance-wide:hover {
|
|
176
|
+
background-color: var(--dsw-alias-interactive-bg-hover);
|
|
177
|
+
color: var(--dsw-alias-label-primary);
|
|
178
|
+
}
|
|
179
|
+
.dsh-quota-balance-wide.dsh-quota-refreshing {
|
|
180
|
+
opacity: 0.85;
|
|
181
|
+
color: var(--dsw-alias-label-primary);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.dsh-quota-warn {
|
|
185
|
+
color: #faad14 !important;
|
|
186
|
+
}
|
|
187
|
+
.dsh-quota-danger {
|
|
188
|
+
color: #ff4d4f !important;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.dsh-quota-delta-badge {
|
|
192
|
+
position: absolute;
|
|
193
|
+
right: 2px;
|
|
194
|
+
top: -12px;
|
|
195
|
+
background: rgba(255, 77, 79, 0.18);
|
|
196
|
+
color: #ff4d4f;
|
|
197
|
+
font-size: 11px;
|
|
198
|
+
font-weight: 600;
|
|
199
|
+
font-variant-numeric: tabular-nums;
|
|
200
|
+
padding: 1px 5px;
|
|
201
|
+
border-radius: 5px;
|
|
202
|
+
pointer-events: none;
|
|
203
|
+
animation: dsh-quota-float-up 2.6s ease-in-out forwards;
|
|
204
|
+
white-space: nowrap;
|
|
205
|
+
border: 1px solid rgba(255, 77, 79, 0.3);
|
|
206
|
+
z-index: 3;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.dsh-quota-balance-rail {
|
|
210
|
+
display: inline-flex;
|
|
211
|
+
justify-content: center;
|
|
212
|
+
align-items: center;
|
|
213
|
+
height: 26px;
|
|
214
|
+
min-width: 32px;
|
|
215
|
+
max-width: 50px;
|
|
216
|
+
padding: 0 4px;
|
|
217
|
+
box-sizing: border-box;
|
|
218
|
+
border-radius: 6px;
|
|
219
|
+
font-size: 11px;
|
|
220
|
+
font-weight: 600;
|
|
221
|
+
font-variant-numeric: tabular-nums;
|
|
222
|
+
letter-spacing: -0.2px;
|
|
223
|
+
color: var(--dsw-alias-label-secondary);
|
|
224
|
+
cursor: pointer;
|
|
225
|
+
border: 0;
|
|
226
|
+
background: transparent;
|
|
227
|
+
font-family: inherit;
|
|
228
|
+
white-space: nowrap;
|
|
229
|
+
overflow: hidden;
|
|
230
|
+
transition: background-color 0.15s ease, color 0.15s ease;
|
|
231
|
+
}
|
|
232
|
+
.dsh-quota-balance-rail:hover {
|
|
233
|
+
background-color: var(--dsw-alias-interactive-bg-hover);
|
|
234
|
+
color: var(--dsw-alias-label-primary);
|
|
235
|
+
}
|
|
236
|
+
.dsh-quota-balance-rail.dsh-quota-refreshing {
|
|
237
|
+
opacity: 0.85;
|
|
238
|
+
color: var(--dsw-alias-label-primary);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.dsh-quota-popover {
|
|
242
|
+
position: absolute;
|
|
243
|
+
bottom: 48px;
|
|
244
|
+
left: 0;
|
|
245
|
+
right: 0;
|
|
246
|
+
width: auto;
|
|
247
|
+
box-sizing: border-box;
|
|
248
|
+
background: var(--dsw-alias-bg-layer-2, #1e1e20);
|
|
249
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
|
|
250
|
+
box-shadow: var(--dsw-shadow-lv3, 0 10px 25px rgba(0, 0, 0, 0.45));
|
|
251
|
+
border-radius: 12px;
|
|
252
|
+
padding: 12px 14px;
|
|
253
|
+
z-index: 1000;
|
|
254
|
+
animation: dsh-quota-popover-in 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
|
255
|
+
font-size: 12px;
|
|
256
|
+
color: var(--dsw-alias-label-primary, #fff);
|
|
257
|
+
cursor: default;
|
|
258
|
+
user-select: text;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.dsh-quota-popover-rail {
|
|
262
|
+
position: fixed;
|
|
263
|
+
left: 56px;
|
|
264
|
+
bottom: 12px;
|
|
265
|
+
right: auto;
|
|
266
|
+
width: 240px;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.dsh-quota-popover-header {
|
|
270
|
+
display: flex;
|
|
271
|
+
justify-content: space-between;
|
|
272
|
+
align-items: center;
|
|
273
|
+
font-weight: 600;
|
|
274
|
+
font-size: 13px;
|
|
275
|
+
margin-bottom: 10px;
|
|
276
|
+
padding-bottom: 8px;
|
|
277
|
+
border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.08));
|
|
278
|
+
}
|
|
279
|
+
.dsh-quota-popover-meta {
|
|
280
|
+
display: inline-flex;
|
|
281
|
+
align-items: center;
|
|
282
|
+
gap: 6px;
|
|
283
|
+
}
|
|
284
|
+
.dsh-quota-version {
|
|
285
|
+
font-size: 10px;
|
|
286
|
+
font-weight: 500;
|
|
287
|
+
color: var(--dsw-alias-label-tertiary, #888);
|
|
288
|
+
}
|
|
289
|
+
.dsh-quota-popover-row {
|
|
290
|
+
display: flex;
|
|
291
|
+
justify-content: space-between;
|
|
292
|
+
align-items: center;
|
|
293
|
+
margin: 6px 0;
|
|
294
|
+
color: var(--dsw-alias-label-secondary, #aaa);
|
|
295
|
+
}
|
|
296
|
+
.dsh-quota-popover-val {
|
|
297
|
+
font-weight: 500;
|
|
298
|
+
font-variant-numeric: tabular-nums;
|
|
299
|
+
color: var(--dsw-alias-label-primary, #fff);
|
|
300
|
+
}
|
|
301
|
+
.dsh-quota-popover-actions {
|
|
302
|
+
display: flex;
|
|
303
|
+
flex-wrap: wrap;
|
|
304
|
+
gap: 6px;
|
|
305
|
+
margin-top: 12px;
|
|
306
|
+
padding-top: 10px;
|
|
307
|
+
border-top: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.08));
|
|
308
|
+
}
|
|
309
|
+
.dsh-quota-btn {
|
|
310
|
+
flex: 1 1 calc(50% - 3px);
|
|
311
|
+
min-width: 0;
|
|
312
|
+
display: inline-flex;
|
|
313
|
+
align-items: center;
|
|
314
|
+
justify-content: center;
|
|
315
|
+
gap: 5px;
|
|
316
|
+
padding: 6px 6px;
|
|
317
|
+
border-radius: 6px;
|
|
318
|
+
font-size: 11px;
|
|
319
|
+
font-weight: 500;
|
|
320
|
+
text-decoration: none;
|
|
321
|
+
cursor: pointer;
|
|
322
|
+
white-space: nowrap;
|
|
323
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.15));
|
|
324
|
+
background: var(--dsw-alias-interactive-bg, rgba(255, 255, 255, 0.06));
|
|
325
|
+
color: var(--dsw-alias-label-primary, #fff);
|
|
326
|
+
transition: background 0.15s ease, border-color 0.15s ease;
|
|
327
|
+
}
|
|
328
|
+
.dsh-quota-btn:hover {
|
|
329
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.12));
|
|
330
|
+
}
|
|
331
|
+
.dsh-quota-btn-primary {
|
|
332
|
+
background: #1677ff;
|
|
333
|
+
border-color: #1677ff;
|
|
334
|
+
color: #fff;
|
|
335
|
+
}
|
|
336
|
+
.dsh-quota-btn-primary:hover {
|
|
337
|
+
background: #4096ff;
|
|
338
|
+
border-color: #4096ff;
|
|
339
|
+
}
|
|
340
|
+
.dsh-quota-status-tag {
|
|
341
|
+
font-size: 10px;
|
|
342
|
+
padding: 1px 6px;
|
|
343
|
+
border-radius: 4px;
|
|
344
|
+
font-weight: 500;
|
|
345
|
+
white-space: nowrap;
|
|
346
|
+
}
|
|
347
|
+
.dsh-quota-status-tag.normal {
|
|
348
|
+
background: rgba(82, 196, 26, 0.15);
|
|
349
|
+
color: #52c41a;
|
|
350
|
+
}
|
|
351
|
+
.dsh-quota-status-tag.warn {
|
|
352
|
+
background: rgba(250, 173, 20, 0.15);
|
|
353
|
+
color: #faad14;
|
|
354
|
+
}
|
|
355
|
+
.dsh-quota-status-tag.danger {
|
|
356
|
+
background: rgba(255, 77, 79, 0.15);
|
|
357
|
+
color: #ff4d4f;
|
|
358
|
+
}
|
|
359
|
+
`;
|
|
360
|
+
document.head.appendChild(tag);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function SpinnerIcon(props) {
|
|
364
|
+
const size = props.size || 11;
|
|
365
|
+
return React.createElement(
|
|
366
|
+
"svg",
|
|
367
|
+
{
|
|
368
|
+
width: size,
|
|
369
|
+
height: size,
|
|
370
|
+
viewBox: "0 0 16 16",
|
|
371
|
+
fill: "none",
|
|
372
|
+
className: "dsh-quota-spinning",
|
|
373
|
+
style: { flexShrink: 0 }
|
|
374
|
+
},
|
|
375
|
+
React.createElement("circle", {
|
|
376
|
+
cx: 8,
|
|
377
|
+
cy: 8,
|
|
378
|
+
r: 6,
|
|
379
|
+
stroke: "currentColor",
|
|
380
|
+
strokeWidth: 2,
|
|
381
|
+
strokeLinecap: "round",
|
|
382
|
+
strokeDasharray: "28",
|
|
383
|
+
strokeDashoffset: "10",
|
|
384
|
+
opacity: 0.85
|
|
385
|
+
})
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function IconExternalLink(props) {
|
|
390
|
+
const size = props.size || 12;
|
|
391
|
+
return React.createElement(
|
|
392
|
+
"svg",
|
|
393
|
+
{
|
|
394
|
+
width: size,
|
|
395
|
+
height: size,
|
|
396
|
+
viewBox: "0 0 24 24",
|
|
397
|
+
fill: "none",
|
|
398
|
+
stroke: "currentColor",
|
|
399
|
+
strokeWidth: "2",
|
|
400
|
+
strokeLinecap: "round",
|
|
401
|
+
strokeLinejoin: "round",
|
|
402
|
+
style: { flexShrink: 0 }
|
|
403
|
+
},
|
|
404
|
+
React.createElement("path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" }),
|
|
405
|
+
React.createElement("polyline", { points: "15 3 21 3 21 9" }),
|
|
406
|
+
React.createElement("line", { x1: "10", y1: "14", x2: "21", y2: "3" })
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function IconEye(props) {
|
|
411
|
+
const size = props.size || 12;
|
|
412
|
+
return React.createElement(
|
|
413
|
+
"svg",
|
|
414
|
+
{
|
|
415
|
+
width: size,
|
|
416
|
+
height: size,
|
|
417
|
+
viewBox: "0 0 24 24",
|
|
418
|
+
fill: "none",
|
|
419
|
+
stroke: "currentColor",
|
|
420
|
+
strokeWidth: "2",
|
|
421
|
+
strokeLinecap: "round",
|
|
422
|
+
strokeLinejoin: "round",
|
|
423
|
+
style: { flexShrink: 0 }
|
|
424
|
+
},
|
|
425
|
+
React.createElement("path", { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }),
|
|
426
|
+
React.createElement("circle", { cx: "12", cy: "12", r: "3" })
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function IconEyeOff(props) {
|
|
431
|
+
const size = props.size || 12;
|
|
432
|
+
return React.createElement(
|
|
433
|
+
"svg",
|
|
434
|
+
{
|
|
435
|
+
width: size,
|
|
436
|
+
height: size,
|
|
437
|
+
viewBox: "0 0 24 24",
|
|
438
|
+
fill: "none",
|
|
439
|
+
stroke: "currentColor",
|
|
440
|
+
strokeWidth: "2",
|
|
441
|
+
strokeLinecap: "round",
|
|
442
|
+
strokeLinejoin: "round",
|
|
443
|
+
style: { flexShrink: 0 }
|
|
444
|
+
},
|
|
445
|
+
React.createElement("path", { d: "M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" }),
|
|
446
|
+
React.createElement("line", { x1: "1", y1: "1", x2: "23", y2: "23" })
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function IconRefresh(props) {
|
|
451
|
+
const size = props.size || 12;
|
|
452
|
+
return React.createElement(
|
|
453
|
+
"svg",
|
|
454
|
+
{
|
|
455
|
+
width: size,
|
|
456
|
+
height: size,
|
|
457
|
+
viewBox: "0 0 24 24",
|
|
458
|
+
fill: "none",
|
|
459
|
+
stroke: "currentColor",
|
|
460
|
+
strokeWidth: "2",
|
|
461
|
+
strokeLinecap: "round",
|
|
462
|
+
strokeLinejoin: "round",
|
|
463
|
+
style: { flexShrink: 0 }
|
|
464
|
+
},
|
|
465
|
+
React.createElement("path", { d: "M21.5 2v6h-6" }),
|
|
466
|
+
React.createElement("path", { d: "M2.5 22v-6h6" }),
|
|
467
|
+
React.createElement("path", { d: "M2 11.5a10 10 0 0 1 18.8-4.3" }),
|
|
468
|
+
React.createElement("path", { d: "M22 12.5a10 10 0 0 1-18.8 4.2" })
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function QuotaBalance(props) {
|
|
473
|
+
const [state, setState] = React.useState({ status: "loading", refreshing: false, value: null, error: null });
|
|
474
|
+
const [costDelta, setCostDelta] = React.useState(null);
|
|
475
|
+
const [showPopover, setShowPopover] = React.useState(false);
|
|
476
|
+
const [privacy, setPrivacy] = React.useState(() => {
|
|
477
|
+
try {
|
|
478
|
+
return localStorage.getItem("dsh-quota:privacy") === "1";
|
|
479
|
+
} catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const prevBalanceRef = React.useRef(null);
|
|
485
|
+
const deltaTimerRef = React.useRef(null);
|
|
486
|
+
const popoverRef = React.useRef(null);
|
|
487
|
+
const activeRequestRef = React.useRef(null);
|
|
488
|
+
const lastFetchedRef = React.useRef(0);
|
|
489
|
+
const clickTimerRef = React.useRef(null);
|
|
490
|
+
const longPressTimerRef = React.useRef(null);
|
|
491
|
+
const longPressTriggeredRef = React.useRef(false);
|
|
492
|
+
const mountedRef = React.useRef(true);
|
|
493
|
+
const wide = props.wide !== false;
|
|
494
|
+
const useSessions = props.useSessions;
|
|
495
|
+
|
|
496
|
+
const runningCount = typeof useSessions === "function"
|
|
497
|
+
? useSessions((sessionState) => {
|
|
498
|
+
if (!sessionState || !sessionState.byId) return 0;
|
|
499
|
+
let count = 0;
|
|
500
|
+
for (const id in sessionState.byId) {
|
|
501
|
+
if (sessionState.byId[id] && sessionState.byId[id].running) count++;
|
|
502
|
+
}
|
|
503
|
+
return count;
|
|
504
|
+
})
|
|
505
|
+
: 0;
|
|
506
|
+
|
|
507
|
+
const prevRunningCountRef = React.useRef(runningCount);
|
|
508
|
+
|
|
509
|
+
const togglePrivacy = React.useCallback(() => {
|
|
510
|
+
setPrivacy((prev) => {
|
|
511
|
+
const next = !prev;
|
|
512
|
+
try {
|
|
513
|
+
localStorage.setItem("dsh-quota:privacy", next ? "1" : "0");
|
|
514
|
+
} catch {}
|
|
515
|
+
return next;
|
|
516
|
+
});
|
|
517
|
+
}, []);
|
|
518
|
+
|
|
519
|
+
const load = React.useCallback(async () => {
|
|
520
|
+
if (activeRequestRef.current) return activeRequestRef.current.promise;
|
|
521
|
+
|
|
522
|
+
setState((prev) => ({ ...prev, refreshing: true }));
|
|
523
|
+
const startTime = Date.now();
|
|
524
|
+
lastFetchedRef.current = startTime;
|
|
525
|
+
const controller = new AbortController();
|
|
526
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
527
|
+
const waitForSpin = () => new Promise((resolve) => {
|
|
528
|
+
setTimeout(resolve, Math.max(0, 350 - (Date.now() - startTime)));
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const promise = (async () => {
|
|
532
|
+
try {
|
|
533
|
+
const res = await fetch(ENDPOINT, {
|
|
534
|
+
headers: { accept: "application/json" },
|
|
535
|
+
signal: controller.signal
|
|
536
|
+
});
|
|
537
|
+
const json = await res.json();
|
|
538
|
+
const nextVal = readBalance(json);
|
|
539
|
+
await waitForSpin();
|
|
540
|
+
if (!mountedRef.current) return;
|
|
541
|
+
|
|
542
|
+
if (prevBalanceRef.current) {
|
|
543
|
+
const prevTotal = parseFloat(prevBalanceRef.current.total);
|
|
544
|
+
const newTotal = parseFloat(nextVal.total);
|
|
545
|
+
if (!isNaN(prevTotal) && !isNaN(newTotal) && prevTotal > newTotal) {
|
|
546
|
+
const diff = (prevTotal - newTotal).toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
|
|
547
|
+
setCostDelta(`-${currencySymbol(nextVal.currency)}${diff}`);
|
|
548
|
+
clearTimeout(deltaTimerRef.current);
|
|
549
|
+
deltaTimerRef.current = setTimeout(() => setCostDelta(null), 2600);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
prevBalanceRef.current = nextVal;
|
|
553
|
+
setState({ status: "done", refreshing: false, value: nextVal, error: null });
|
|
554
|
+
} catch (err) {
|
|
555
|
+
await waitForSpin();
|
|
556
|
+
if (!mountedRef.current) return;
|
|
557
|
+
const timedOut = err && err.name === "AbortError";
|
|
558
|
+
setState({
|
|
559
|
+
status: "error",
|
|
560
|
+
refreshing: false,
|
|
561
|
+
value: null,
|
|
562
|
+
error: {
|
|
563
|
+
code: timedOut ? "request-timeout" : "client-error",
|
|
564
|
+
message: timedOut
|
|
565
|
+
? `余额请求超过 ${REQUEST_TIMEOUT_MS / 1000} 秒未完成`
|
|
566
|
+
: String(err && err.message ? err.message : err)
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
} finally {
|
|
570
|
+
clearTimeout(timeout);
|
|
571
|
+
if (activeRequestRef.current && activeRequestRef.current.controller === controller) {
|
|
572
|
+
activeRequestRef.current = null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
})();
|
|
576
|
+
|
|
577
|
+
activeRequestRef.current = { controller, promise };
|
|
578
|
+
return promise;
|
|
579
|
+
}, []);
|
|
580
|
+
|
|
581
|
+
React.useEffect(() => {
|
|
582
|
+
mountedRef.current = true;
|
|
583
|
+
return () => {
|
|
584
|
+
mountedRef.current = false;
|
|
585
|
+
if (activeRequestRef.current) activeRequestRef.current.controller.abort();
|
|
586
|
+
clearTimeout(deltaTimerRef.current);
|
|
587
|
+
clearTimeout(clickTimerRef.current);
|
|
588
|
+
clearTimeout(longPressTimerRef.current);
|
|
589
|
+
};
|
|
590
|
+
}, []);
|
|
591
|
+
|
|
592
|
+
// 5. 智能刷新节流:后台闲置 5 分钟轮询,前台 60 秒轮询
|
|
593
|
+
React.useEffect(() => {
|
|
594
|
+
let timer = null;
|
|
595
|
+
const schedule = (ms) => {
|
|
596
|
+
if (timer) clearInterval(timer);
|
|
597
|
+
timer = setInterval(() => {
|
|
598
|
+
load();
|
|
599
|
+
}, ms);
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
load();
|
|
603
|
+
schedule(ACTIVE_REFRESH_MS);
|
|
604
|
+
|
|
605
|
+
const handleVisibilityChange = () => {
|
|
606
|
+
if (document.hidden) {
|
|
607
|
+
schedule(IDLE_REFRESH_MS);
|
|
608
|
+
} else {
|
|
609
|
+
schedule(ACTIVE_REFRESH_MS);
|
|
610
|
+
if (Date.now() - lastFetchedRef.current > ACTIVE_REFRESH_MS) {
|
|
611
|
+
load();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
617
|
+
return () => {
|
|
618
|
+
if (timer) clearInterval(timer);
|
|
619
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
620
|
+
};
|
|
621
|
+
}, [load]);
|
|
622
|
+
|
|
623
|
+
// 对话结束即时刷新
|
|
624
|
+
React.useEffect(() => {
|
|
625
|
+
if (prevRunningCountRef.current > runningCount) {
|
|
626
|
+
const timer = setTimeout(load, 800);
|
|
627
|
+
prevRunningCountRef.current = runningCount;
|
|
628
|
+
return () => clearTimeout(timer);
|
|
629
|
+
}
|
|
630
|
+
prevRunningCountRef.current = runningCount;
|
|
631
|
+
}, [runningCount, load]);
|
|
632
|
+
|
|
633
|
+
// 点击弹窗外部时关闭 Popover
|
|
634
|
+
React.useEffect(() => {
|
|
635
|
+
if (!showPopover) return;
|
|
636
|
+
const onPointerDown = (e) => {
|
|
637
|
+
if (popoverRef.current && !popoverRef.current.contains(e.target)) {
|
|
638
|
+
setShowPopover(false);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
const onKeyDown = (e) => {
|
|
642
|
+
if (e.key === "Escape") setShowPopover(false);
|
|
643
|
+
};
|
|
644
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
645
|
+
document.addEventListener("keydown", onKeyDown);
|
|
646
|
+
return () => {
|
|
647
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
648
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
649
|
+
};
|
|
650
|
+
}, [showPopover]);
|
|
651
|
+
|
|
652
|
+
const value = state.value;
|
|
653
|
+
const numTotal = value ? parseFloat(value.total) : NaN;
|
|
654
|
+
|
|
655
|
+
// 2. 预警状态判定:欠费 (danger) / 余额偏低 (warn) / 正常 (normal)
|
|
656
|
+
let alertLevel = "normal";
|
|
657
|
+
let alertTitle = "正常";
|
|
658
|
+
if (value) {
|
|
659
|
+
if (!value.available || (!isNaN(numTotal) && numTotal <= 0)) {
|
|
660
|
+
alertLevel = "danger";
|
|
661
|
+
alertTitle = "账户已欠费";
|
|
662
|
+
} else if (!isNaN(numTotal) && (value.currency === "CNY" ? numTotal < 5.0 : numTotal < 1.0)) {
|
|
663
|
+
alertLevel = "warn";
|
|
664
|
+
alertTitle = "余额偏低";
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
let label;
|
|
669
|
+
if (value == null) {
|
|
670
|
+
label = state.status === "loading" ? "…" : "¥ ?";
|
|
671
|
+
} else if (privacy) {
|
|
672
|
+
label = `${currencySymbol(value.currency)} ****`;
|
|
673
|
+
} else {
|
|
674
|
+
label = `${currencySymbol(value.currency)}${value.total}`;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const railLabel = formatRailBalance(value, privacy);
|
|
678
|
+
|
|
679
|
+
const tooltip =
|
|
680
|
+
state.refreshing
|
|
681
|
+
? "DeepSeek 余额刷新中…"
|
|
682
|
+
: state.error != null
|
|
683
|
+
? `DeepSeek 余额: ${state.error.message}`
|
|
684
|
+
: value == null
|
|
685
|
+
? "DeepSeek 余额加载中…"
|
|
686
|
+
: `DeepSeek 余额: ${currencySymbol(value.currency)}${value.total}${alertLevel !== "normal" ? ` (${alertTitle})` : ""}\n左键点击刷新 · 右键查看明细 · 双击切换隐私`;
|
|
687
|
+
|
|
688
|
+
const handleClick = (e) => {
|
|
689
|
+
if (e && typeof e.stopPropagation === "function") {
|
|
690
|
+
e.stopPropagation();
|
|
691
|
+
}
|
|
692
|
+
if (longPressTriggeredRef.current) {
|
|
693
|
+
longPressTriggeredRef.current = false;
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
clearTimeout(clickTimerRef.current);
|
|
697
|
+
clickTimerRef.current = setTimeout(load, 220);
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const handleContextMenu = (e) => {
|
|
701
|
+
if (e && typeof e.preventDefault === "function") {
|
|
702
|
+
e.preventDefault();
|
|
703
|
+
e.stopPropagation();
|
|
704
|
+
}
|
|
705
|
+
setShowPopover((prev) => !prev);
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
const handleDoubleClick = (e) => {
|
|
709
|
+
if (e && typeof e.stopPropagation === "function") {
|
|
710
|
+
e.stopPropagation();
|
|
711
|
+
}
|
|
712
|
+
clearTimeout(clickTimerRef.current);
|
|
713
|
+
togglePrivacy();
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const handleKeyDown = (e) => {
|
|
717
|
+
if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10") || e.key === "ArrowUp") {
|
|
718
|
+
e.preventDefault();
|
|
719
|
+
e.stopPropagation();
|
|
720
|
+
setShowPopover((prev) => !prev);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const handlePointerDown = (e) => {
|
|
725
|
+
if (!e || e.pointerType !== "touch") return;
|
|
726
|
+
longPressTriggeredRef.current = false;
|
|
727
|
+
clearTimeout(longPressTimerRef.current);
|
|
728
|
+
longPressTimerRef.current = setTimeout(() => {
|
|
729
|
+
longPressTriggeredRef.current = true;
|
|
730
|
+
setShowPopover(true);
|
|
731
|
+
}, 500);
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
const handlePointerEnd = () => {
|
|
735
|
+
clearTimeout(longPressTimerRef.current);
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const handleMouseDown = (e) => {
|
|
739
|
+
// 防止双击时触发浏览器默认选中文本行为
|
|
740
|
+
if (e && typeof e.preventDefault === "function") {
|
|
741
|
+
if (e.detail > 1) {
|
|
742
|
+
e.preventDefault();
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
// Popover 详情卡片
|
|
748
|
+
const popoverElement = showPopover && React.createElement(
|
|
749
|
+
"div",
|
|
750
|
+
{
|
|
751
|
+
ref: popoverRef,
|
|
752
|
+
className: `dsh-quota-popover${!wide ? " dsh-quota-popover-rail" : ""}`,
|
|
753
|
+
role: "dialog",
|
|
754
|
+
"aria-label": `DeepSeek 账户余额,dsh-quota v${VERSION}`,
|
|
755
|
+
onClick: (e) => e.stopPropagation()
|
|
756
|
+
},
|
|
757
|
+
React.createElement(
|
|
758
|
+
"div",
|
|
759
|
+
{ className: "dsh-quota-popover-header" },
|
|
760
|
+
React.createElement("span", null, "DeepSeek 账户余额"),
|
|
761
|
+
React.createElement(
|
|
762
|
+
"span",
|
|
763
|
+
{ className: "dsh-quota-popover-meta" },
|
|
764
|
+
React.createElement("span", { className: "dsh-quota-version" }, `v${VERSION}`),
|
|
765
|
+
React.createElement(
|
|
766
|
+
"span",
|
|
767
|
+
{ className: `dsh-quota-status-tag ${alertLevel}` },
|
|
768
|
+
alertTitle
|
|
769
|
+
)
|
|
770
|
+
)
|
|
771
|
+
),
|
|
772
|
+
React.createElement(
|
|
773
|
+
"div",
|
|
774
|
+
{ className: "dsh-quota-popover-row" },
|
|
775
|
+
React.createElement("span", null, "总余额"),
|
|
776
|
+
React.createElement("span", { className: "dsh-quota-popover-val" }, value ? `${currencySymbol(value.currency)}${privacy ? "****" : value.total}` : "—")
|
|
777
|
+
),
|
|
778
|
+
React.createElement(
|
|
779
|
+
"div",
|
|
780
|
+
{ className: "dsh-quota-popover-row" },
|
|
781
|
+
React.createElement("span", null, "充值余额"),
|
|
782
|
+
React.createElement("span", { className: "dsh-quota-popover-val" }, value ? `${currencySymbol(value.currency)}${privacy ? "****" : value.toppedUp}` : "—")
|
|
783
|
+
),
|
|
784
|
+
React.createElement(
|
|
785
|
+
"div",
|
|
786
|
+
{ className: "dsh-quota-popover-row" },
|
|
787
|
+
React.createElement("span", null, "赠送赠金"),
|
|
788
|
+
React.createElement("span", { className: "dsh-quota-popover-val" }, value ? `${currencySymbol(value.currency)}${privacy ? "****" : value.granted}` : "—")
|
|
789
|
+
),
|
|
790
|
+
React.createElement(
|
|
791
|
+
"div",
|
|
792
|
+
{ className: "dsh-quota-popover-row", style: { fontSize: "11px", opacity: 0.75, marginTop: "8px" } },
|
|
793
|
+
React.createElement("span", null, "更新时间"),
|
|
794
|
+
React.createElement("span", null, value ? formatTime(value.updatedAt) : "—")
|
|
795
|
+
),
|
|
796
|
+
React.createElement(
|
|
797
|
+
"div",
|
|
798
|
+
{ className: "dsh-quota-popover-actions" },
|
|
799
|
+
React.createElement(
|
|
800
|
+
"a",
|
|
801
|
+
{
|
|
802
|
+
href: TOP_UP_URL,
|
|
803
|
+
target: "_blank",
|
|
804
|
+
rel: "noreferrer",
|
|
805
|
+
className: "dsh-quota-btn dsh-quota-btn-primary"
|
|
806
|
+
},
|
|
807
|
+
React.createElement(IconExternalLink, { size: 12 }),
|
|
808
|
+
React.createElement("span", null, "充值")
|
|
809
|
+
),
|
|
810
|
+
React.createElement(
|
|
811
|
+
"a",
|
|
812
|
+
{
|
|
813
|
+
href: USAGE_URL,
|
|
814
|
+
target: "_blank",
|
|
815
|
+
rel: "noreferrer",
|
|
816
|
+
className: "dsh-quota-btn",
|
|
817
|
+
title: "打开 DeepSeek 官方用量页面"
|
|
818
|
+
},
|
|
819
|
+
React.createElement(IconExternalLink, { size: 12 }),
|
|
820
|
+
React.createElement("span", null, "用量")
|
|
821
|
+
),
|
|
822
|
+
React.createElement(
|
|
823
|
+
"button",
|
|
824
|
+
{
|
|
825
|
+
type: "button",
|
|
826
|
+
className: "dsh-quota-btn",
|
|
827
|
+
onClick: togglePrivacy
|
|
828
|
+
},
|
|
829
|
+
privacy ? React.createElement(IconEye, { size: 12 }) : React.createElement(IconEyeOff, { size: 12 }),
|
|
830
|
+
React.createElement("span", null, privacy ? "显示" : "隐藏")
|
|
831
|
+
),
|
|
832
|
+
React.createElement(
|
|
833
|
+
"button",
|
|
834
|
+
{
|
|
835
|
+
type: "button",
|
|
836
|
+
className: "dsh-quota-btn",
|
|
837
|
+
onClick: load
|
|
838
|
+
},
|
|
839
|
+
state.refreshing
|
|
840
|
+
? React.createElement(SpinnerIcon, { size: 12 })
|
|
841
|
+
: React.createElement(IconRefresh, { size: 12 }),
|
|
842
|
+
React.createElement("span", null, "刷新")
|
|
843
|
+
)
|
|
844
|
+
)
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
if (!wide) {
|
|
848
|
+
return React.createElement(
|
|
849
|
+
React.Fragment,
|
|
850
|
+
null,
|
|
851
|
+
React.createElement(
|
|
852
|
+
"button",
|
|
853
|
+
{
|
|
854
|
+
type: "button",
|
|
855
|
+
className: `dsh-quota-balance-rail ${alertLevel !== "normal" ? `dsh-quota-${alertLevel}` : ""}${state.refreshing ? " dsh-quota-refreshing" : ""}`,
|
|
856
|
+
title: tooltip,
|
|
857
|
+
"aria-label": tooltip,
|
|
858
|
+
"aria-haspopup": "dialog",
|
|
859
|
+
"aria-expanded": showPopover,
|
|
860
|
+
onClick: handleClick,
|
|
861
|
+
onDoubleClick: handleDoubleClick,
|
|
862
|
+
onContextMenu: handleContextMenu,
|
|
863
|
+
onMouseDown: handleMouseDown,
|
|
864
|
+
onKeyDown: handleKeyDown,
|
|
865
|
+
onPointerDown: handlePointerDown,
|
|
866
|
+
onPointerUp: handlePointerEnd,
|
|
867
|
+
onPointerCancel: handlePointerEnd
|
|
868
|
+
},
|
|
869
|
+
state.refreshing
|
|
870
|
+
? React.createElement(SpinnerIcon, { size: 12 })
|
|
871
|
+
: React.createElement("span", null, railLabel)
|
|
872
|
+
),
|
|
873
|
+
popoverElement
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return React.createElement(
|
|
878
|
+
React.Fragment,
|
|
879
|
+
null,
|
|
880
|
+
React.createElement(
|
|
881
|
+
"button",
|
|
882
|
+
{
|
|
883
|
+
type: "button",
|
|
884
|
+
className: `dsh-quota-balance-wide ${alertLevel !== "normal" ? `dsh-quota-${alertLevel}` : ""}${state.refreshing ? " dsh-quota-refreshing" : ""}`,
|
|
885
|
+
title: tooltip,
|
|
886
|
+
"aria-label": tooltip,
|
|
887
|
+
"aria-haspopup": "dialog",
|
|
888
|
+
"aria-expanded": showPopover,
|
|
889
|
+
onClick: handleClick,
|
|
890
|
+
onDoubleClick: handleDoubleClick,
|
|
891
|
+
onContextMenu: handleContextMenu,
|
|
892
|
+
onMouseDown: handleMouseDown,
|
|
893
|
+
onKeyDown: handleKeyDown,
|
|
894
|
+
onPointerDown: handlePointerDown,
|
|
895
|
+
onPointerUp: handlePointerEnd,
|
|
896
|
+
onPointerCancel: handlePointerEnd
|
|
897
|
+
},
|
|
898
|
+
costDelta && React.createElement("span", { className: "dsh-quota-delta-badge" }, costDelta),
|
|
899
|
+
state.refreshing && React.createElement(SpinnerIcon, { size: 12 }),
|
|
900
|
+
React.createElement("span", null, label)
|
|
901
|
+
),
|
|
902
|
+
popoverElement
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function apply(ctx) {
|
|
907
|
+
const slots = ctx.get("slots");
|
|
908
|
+
if (slots === undefined) return;
|
|
909
|
+
slots.inject("sidebar.footer.action", () =>
|
|
910
|
+
slots.register(
|
|
911
|
+
{
|
|
912
|
+
name: "sidebar.footer.action",
|
|
913
|
+
id: "dsh-quota",
|
|
914
|
+
order: 0
|
|
915
|
+
},
|
|
916
|
+
QuotaBalance
|
|
917
|
+
)
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
exports.apply = apply;
|
|
922
|
+
exports.inject = inject;
|
|
923
|
+
exports.__test = Object.freeze({
|
|
924
|
+
VERSION,
|
|
925
|
+
currencySymbol,
|
|
926
|
+
readBalance,
|
|
927
|
+
formatRailBalance
|
|
928
|
+
});
|
|
929
|
+
return module.exports;
|
|
930
|
+
}
|
|
931
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// dsh-quota host half — a cordis object plugin mounted by cordis.patch.yml.
|
|
2
|
+
// Provides one HTTP route on the shared webserver that resolves the configured
|
|
3
|
+
// DeepSeek API key (the same credential seam the deepseek provider uses) and
|
|
4
|
+
// calls GET https://api.deepseek.com/user/balance. The client half fetches
|
|
5
|
+
// this route from the same origin; no RPC framework is involved.
|
|
6
|
+
//
|
|
7
|
+
// This file must not import npm packages. `dsh plugin add /local/path` installs
|
|
8
|
+
// the plugin as a `link:` symlink; Node ESM then realpath-resolves from THIS
|
|
9
|
+
// directory, not from ~/.dsh/profiles/web/node_modules, so undeclared deps
|
|
10
|
+
// (schemastery, @deepseek-ai/dsh-credentials, …) crash the entire plugin tree.
|
|
11
|
+
const name = "dsh-quota";
|
|
12
|
+
|
|
13
|
+
// webServer is a hard dependency so cordis activates this plugin only AFTER the
|
|
14
|
+
// webserver is up (otherwise the route would be registered before the server
|
|
15
|
+
// exists, or not at all). credentials is read softly via ctx.get below, because
|
|
16
|
+
// a missing key is a handled runtime case, not a composition error.
|
|
17
|
+
const inject = ["webServer"];
|
|
18
|
+
|
|
19
|
+
const DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
20
|
+
const PUBLIC_BASE_URL = "https://api.deepseek.com";
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 10000;
|
|
22
|
+
|
|
23
|
+
// Mirrors @deepseek-ai/dsh-credentials `credentialRef` (POSIX env-name brand).
|
|
24
|
+
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
25
|
+
function credentialRef(value) {
|
|
26
|
+
if (!REF_PATTERN.test(value)) {
|
|
27
|
+
throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function apply(ctx, config) {
|
|
33
|
+
const raw = config && typeof config === "object" ? config : {};
|
|
34
|
+
const baseURL = String(raw.baseURL ?? PUBLIC_BASE_URL).replace(/\/+$/, "");
|
|
35
|
+
const apiKeyEnv = String(raw.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
|
|
36
|
+
const configuredTimeout = Number(raw.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
37
|
+
const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0
|
|
38
|
+
? Math.min(Math.round(configuredTimeout), 60000)
|
|
39
|
+
: DEFAULT_TIMEOUT_MS;
|
|
40
|
+
const ref = credentialRef(apiKeyEnv);
|
|
41
|
+
|
|
42
|
+
const webServer = ctx.get("webServer");
|
|
43
|
+
if (webServer === undefined) return;
|
|
44
|
+
|
|
45
|
+
const send = (res, status, payload) => {
|
|
46
|
+
const body = JSON.stringify(payload);
|
|
47
|
+
res.writeHead(status, {
|
|
48
|
+
"content-type": "application/json; charset=utf-8",
|
|
49
|
+
"cache-control": "no-store"
|
|
50
|
+
});
|
|
51
|
+
res.end(body);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
ctx.effect(() =>
|
|
55
|
+
webServer.register({
|
|
56
|
+
kind: "exact",
|
|
57
|
+
path: "/dsh-quota/balance",
|
|
58
|
+
handler: async (_req, res) => {
|
|
59
|
+
try {
|
|
60
|
+
const credentials = ctx.get("credentials");
|
|
61
|
+
const hit = credentials === undefined ? undefined : await credentials.resolve(ref);
|
|
62
|
+
if (hit === undefined) {
|
|
63
|
+
return send(res, 200, {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: {
|
|
66
|
+
code: "no-api-key",
|
|
67
|
+
message:
|
|
68
|
+
`No API key resolved from '${apiKeyEnv}'. ` +
|
|
69
|
+
"Set it in DSH (settings → models, or DEEPSEEK_API_KEY).",
|
|
70
|
+
configurable: true
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const upstream = await fetch(`${baseURL}/user/balance`, {
|
|
75
|
+
method: "GET",
|
|
76
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
77
|
+
headers: {
|
|
78
|
+
authorization: `Bearer ${hit.value}`,
|
|
79
|
+
accept: "application/json"
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
const data = await upstream.json();
|
|
83
|
+
return send(res, 200, {
|
|
84
|
+
ok: upstream.ok,
|
|
85
|
+
status: upstream.status,
|
|
86
|
+
data
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const timedOut = error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
90
|
+
return send(res, 200, {
|
|
91
|
+
ok: false,
|
|
92
|
+
error: {
|
|
93
|
+
code: timedOut ? "upstream-timeout" : "fetch-failed",
|
|
94
|
+
message: timedOut
|
|
95
|
+
? `DeepSeek balance request timed out after ${timeoutMs}ms.`
|
|
96
|
+
: String(error && error.message ? error.message : error),
|
|
97
|
+
configurable: false
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { name, apply, inject };
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "deepseek-harness-quota",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness Web plugin for viewing DeepSeek balance, quota details, and per-turn cost in the sidebar.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test",
|
|
9
|
+
"pack:check": "npm pack --dry-run",
|
|
10
|
+
"check": "node -c lib/index.js && node -c lib/client.js && npm test && npm run pack:check",
|
|
11
|
+
"prepublishOnly": "npm run check"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./lib/index.js",
|
|
15
|
+
"./client": "./lib/client.js",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib/index.js",
|
|
20
|
+
"lib/client.js",
|
|
21
|
+
"docs/assets/dsh-quota-overview.png",
|
|
22
|
+
"cordis.patch.yml",
|
|
23
|
+
"README.md",
|
|
24
|
+
"CHANGELOG.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"dsh": {
|
|
28
|
+
"bundle": {
|
|
29
|
+
"patch": "./cordis.patch.yml"
|
|
30
|
+
},
|
|
31
|
+
"client": {
|
|
32
|
+
"platform": "web",
|
|
33
|
+
"inject": [
|
|
34
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"deepseek",
|
|
43
|
+
"deepseek-harness",
|
|
44
|
+
"dsh",
|
|
45
|
+
"plugin",
|
|
46
|
+
"balance",
|
|
47
|
+
"quota"
|
|
48
|
+
],
|
|
49
|
+
"author": "qx0657",
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/qx0657/deepseek-harness-quota.git"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://github.com/qx0657/deepseek-harness-quota#readme",
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/qx0657/deepseek-harness-quota/issues"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
},
|
|
61
|
+
"license": "MIT"
|
|
62
|
+
}
|