dsh-api-dashboard 1.4.0 → 1.4.1

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 CHANGED
@@ -5,7 +5,7 @@
5
5
  > [!CAUTION]
6
6
  > **给安装/维护本插件的 AI 代理的一则警告 —— 动手前必读 [`AGENTS.md`](AGENTS.md)。**
7
7
  >
8
- > 1. **推荐安装方式**:`dsh plugin --profile web add dsh-api-dashboard`(已发布到 npm,一行命令搞定)。也可用「下载→解压→`dsh plugin add file:`」三步法。`pnpm add <远程tarball>` **装不上**。
8
+ > 1. **安装方式(v1.4.1 实测重写)**:**手机版 DSHA 请用「下载 → 解压 → 建 node_modules 软链 → `dsh plugin add link:`」**(见下方「安装」一节,可复制命令;仓库根目录另有 `install.sh` 一键脚本)。`dsh plugin --profile web add dsh-api-dashboard`(npm 裸包名)在 DSHA 上**会失败**:pnpm 把包文件软链到全局 store(proot 的 `--link2symlink` 把硬链接降级成软链),Node 的 ESM 按 realpath 解析后找不到宿主的 peer 依赖。v1.4.1 已加解析回退让**宿主半身不再把整个 `dsh web` 拖死**,但**客户端 UI 仍不会出现**(框架按模块路径找不到 package.json)。`pnpm add <远程tarball>` 同样**装不上**。
9
9
  > 2. **数据准确性红线**:部分平台余额解析有待验证项(OpenRouter/SiliconFlow/Novita/one-api/xAI),且「限流窗口 ≠ 真实余额」——**不要伪造或硬塞一个数字**,查不到就如实显示「未开放」。
10
10
  > 3. **推送前先问用户**;改 `client/client.js` 后需**重启 dsh web GUI** 才生效。
11
11
  >
@@ -81,44 +81,54 @@ OpenRouter、Novita AI、xAI Grok
81
81
 
82
82
  ## 安装
83
83
 
84
- ### ✅ 推荐方式:npm 一行命令
85
-
86
- ```sh
87
- dsh plugin --profile web add dsh-api-dashboard
88
- ```
89
-
90
- > 从 npm 拉取最新版本,自动安装依赖、注册 bundle 层,无需手动下载或建软链。
91
-
92
- ### 备选:下载源码后安装(无 npm registry 环境时)
84
+ ### ✅ 手机版 DSHA(Android)—— 实测可用,照抄即可
93
85
 
94
86
  ```sh
95
87
  # 1. 下载源码(codeload 地址,github.com 主站不可达时也能用)
96
- curl -L "https://codeload.github.com/133563825as-ai/dsh-api-dashboard/tar.gz/refs/heads/main" -o /tmp/dsh-api-dashboard.tar.gz
88
+ curl -L "https://codeload.github.com/133563825as-ai/dsh-api-dashboard/tar.gz/refs/heads/main" \
89
+ -o /tmp/dsh-api-dashboard.tar.gz
97
90
 
98
- # 2. 解压到固定位置(--strip-components=1 去掉顶层目录,必须带)
99
- mkdir -p /root/dsha-api-dashboard
91
+ # 2. 解压到固定位置(--strip-components=1 必须带,否则会多一层目录)
92
+ rm -rf /root/dsha-api-dashboard && mkdir -p /root/dsha-api-dashboard
100
93
  tar xzf /tmp/dsh-api-dashboard.tar.gz -C /root/dsha-api-dashboard --strip-components=1
101
94
 
102
- # 3. 安装进 web profile:自动装依赖 + 自动注册 bundle 层
103
- dsh plugin --profile web add file:/root/dsha-api-dashboard
95
+ # 3. node_modules 软链 —— 这一步不能省!
96
+ # 插件的 peer 依赖(@deepseek-ai/schemastery / zod)由宿主 DSH 提供,
97
+ # 软链让它从源码目录就能解析到它们。
98
+ ln -sfn /usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules /root/dsha-api-dashboard/node_modules
104
99
 
105
- # 4. 重启 dsh web 生效
100
+ # 4. 装进 web profile(link: 方式,文件保持真实路径,客户端 UI 才能被框架找到)
101
+ dsh plugin --profile web add link:/root/dsha-api-dashboard
102
+
103
+ # 5. 重启 dsh web
106
104
  ```
107
105
 
106
+ > 仓库根目录的 `install.sh` 把上面 5 步做完了,也可以直接 `sh install.sh`。
107
+
108
+ ### 桌面版 DSH(Linux/macOS/Windows)
109
+
110
+ 同样推荐上面的「源码 + `link:`」方式(把路径换成你自己的)。
111
+ npm 一行命令 `dsh plugin --profile web add dsh-api-dashboard` 在**桌面版**上通常可用
112
+ (桌面文件系统支持硬链接,pnpm 不会把包文件软链到 store);但 v1.4.1 起仍建议用 `link:`,
113
+ 因为自更新与资产路径都按真实路径工作。
114
+
108
115
  ### 升级到新版本
109
116
 
110
- 1. **推荐**:插件设置面板 →「检查更新」→「一键自更新」(内置更新引擎,下载→校验→备份→原子替换→失败回滚,重启 web 生效);
111
- 2. 或:`dsh plugin --profile web remove dsh-api-dashboard` 后重新执行上面的推荐/备选安装;
112
- 3. 兜底:重复「下载源码」步骤 1-2 覆盖目录,再执行第 3 步。
117
+ 1. **推荐**:重新执行上面第 1、2 步(覆盖源码目录)→ 重启 `dsh web`;
118
+ 2. 插件设置面板里的「一键自更新」也可用(下载→校验→备份→替换→失败回滚,重启生效);
119
+ ⚠️ 它会**保留 `.git` 与 `node_modules`**(v1.4.1 修复:此前会把 `.git` 一起删掉,历史无法恢复);
120
+ 3. 或 `dsh plugin --profile web remove dsh-api-dashboard` 后重新安装。
113
121
 
114
- ### ⚠️ 安装姿势对照(实测)
122
+ ### ⚠️ 安装姿势对照(2026-09-11 在 DSHA 上逐条实测)
115
123
 
116
124
  | 方式 | 结果 |
117
125
  |------|------|
118
- | `dsh plugin --profile web add dsh-api-dashboard`(npm 裸包名) | ✅ 推荐,一行命令 |
119
- | `dsh plugin --profile web add file:/root/dsha-api-dashboard`(源码方式) | ✅ 备选 |
126
+ | 源码 + `node_modules` 软链 + `dsh plugin add link:<dir>` | ✅ **可用**(推荐) |
127
+ | 包目录放在 `$DSH_HOME/profiles/` 之下再 `add file:` | ✅ 可用 |
128
+ | `dsh plugin --profile web add dsh-api-dashboard`(npm 裸包名,**手机版 DSHA**) | ❌ 客户端 UI 不会出现;v1.4.1 之前更糟 —— **整个 `dsh web` 启动失败** |
129
+ | `dsh plugin --profile web add file:/源码目录`(目录里**没有** node_modules) | ❌ 同上 |
120
130
  | `pnpm add <远程 tarball URL>` | ❌ pnpm 不剥离 codeload 压缩包顶层目录,装出来是空壳 |
121
- | 手动软链 node_modules | ⚠️ 可用但繁琐,层级写错会被启动校准摘除,不推荐 |
131
+ | 手动软链整个插件目录到 `node_modules` | ⚠️ 会被启动校准摘除,别用;软链 `node_modules` 子目录是**必须**的 |
122
132
 
123
133
  ## 配置
124
134
 
@@ -190,6 +200,26 @@ dsh-api-dashboard/
190
200
 
191
201
  ## 版本
192
202
 
203
+ v1.4.1 — **审计修复版**(全部先在真机/隔离环境复现再修,新增 `test/test-v141-fixes.mjs` 62 条回归钉子):
204
+ 🔴 **状态文件形状损坏不再拖死整个 GUI** —— `{"customRelays": 5}` 这类「合法 JSON 但字段类型跑偏」以前会让 `apply()` 抛 `TypeError`,进而**整个 `dsh web` 启动失败**(不是插件不显示,是 GUI 打不开)。现在加载时统一消毒形状并夹取数值范围。
205
+ 🔴 **一键自更新不再删 `.git`** —— codeload 的 tarball 里没有 `.git`,旧代码 `rmSync` 掉除 `node_modules` 外的一切再覆盖,点一次更新就抹掉 git 历史且无法恢复。现在保留 `.git`;`~/dsha-api-dashboard` 的自动同步**只对非 git 工作区生效**;target 是符号链接时直接拒绝(旧行为会删光软链指向的真实目录,且回滚不回来)。
206
+ 🔴 **插件路由补鉴权** —— `/api-dashboard/*` 以前完全绕过 DSH 的浏览器鉴权:无 token、无 cookie 就能读配置/改配置/触发自更新;且 `Content-Type: text/plain` 属于浏览器**不预检的 simple request**,实测带 `Origin: https://evil.example` 的跨域 POST 返回 **200 且配置真的被改了**。现在复用 `connection.requestRejection`(与 `dsh-web-mobile` 同一闸门):无 cookie → 401、跨域 → 403。
207
+ 🔴 **peer 依赖解析回退** —— npm 安装后宿主半身不再 `Cannot find package '@deepseek-ai/schemastery'`;另修 `SELF_ROOT` 走 realpath 导致 npm 布局下图标/挂件资产 404、版本号读不到的问题。
208
+ 🐛 **「刷新间隔 1 秒」真正生效** —— 输入框与服务端都允许 1 秒,只有 `save()` 悄悄夹成 5 秒;旧回归断言钉的是**旧变量名**,换个名字就绕过(假绿)。四处下限现已一致。
209
+ 🐛 **冷启动打开设置面板不再覆盖用户配置** —— 面板以前只从 `/balances` 拿阈值/币种,冷启动要等 8~10s,那期间点「保存并生效」会把真实阈值写成默认 50/10、把 `overseasCurrency` 写回 `'follow'`。`/config` 现在返回阈值,客户端也补读。
210
+ 🐛 **`openai` 分支不再伪造负数余额**(`total_granted` 为 null/非数字时 `0 - used` → 面板红色「-5」),与已修的 `openrouter` 同类。
211
+ 🐛 **冷启动不再「等半天 + 报失败」** —— 实测维护者真实配置下一次全量刷新要 **8~13.6 秒**(5 个中转站的 auto 探测是串行试 3 个候选端点,每个跑满 8s)。
212
+ v1.4.1 第一版给 fetch 统一加了 15s 超时,正好卡在这中间 → **冷启动首屏必超时 → 面板显示「余额接口请求失败」**。现已:① 余额端点超时放宽到 90s(超时只用来兜底卡死的 socket,不是给正常慢启动设上限);② 冷启动改成**后台刷新**——服务端立刻回 `loading:true` + 空列表,客户端保持骨架屏并把轮询压到 1.5 秒,数据一到就上屏(实测前 5 发 2~3ms 返回,第 6 发拿到数据);③ 加载超 4 秒补一句「首次加载要逐个平台拉取余额…请稍候」;④ **记住每个中转站上次命中的余额端点**并优先试它(存状态文件 `relayEndpoints`,重启后仍生效),稳态下每个中转站只打 1 个请求。
213
+ 🐛 **错误路径有了出口** —— fetch 全部加 15s 超时(以前一个 `AbortController` 都没有,请求挂住就永久骨架屏且从此不再轮询);抽屉在请求失败时显示错误文案 + 「重试」按钮(以前只画空看板,`snapshot.message` 从不渲染);保存与配置请求补 `catch`。
214
+ 🐛 **状态条金额三色类名补上 CSS**(`.dshadb_bar_ok/warn/err` 以前只有使用处、没有规则 = 死类名,负余额金额从不标红)。
215
+ 🐛 **子代理胶囊监听器不再累加**(内联 ref 使每次 render 都挂 7 个 `addEventListener`,5 秒轮询下每小时约 5000 个)。
216
+ 🐛 **强刷加 2 秒节流 + 响应序列号**(连点刷新会并发,且旧响应会覆盖新响应造成数据倒退)。
217
+ 🐛 **中文名不再被写坏**:请求体改为按字节收集后整体 `utf8` 解码(跨 chunk 切分多字节字符会产生 U+FFFD 并存进状态文件)。
218
+ 🐛 持久化的 `refreshIntervalMs` 加载时夹取(曾经能是 -1 → 3 秒内 1794 次上游请求);状态文件保存前建父目录,失败时如实上报。
219
+ 🐛 **设置面板开着时大肥鱼可以拖动了** —— 旧逻辑把「设置面板」也算进 `overlayOpen`,一进设置挂件立刻被 `pointer-events:none` 锁死,必须关掉才能拖(维护者反馈)。而设置里的「大肥鱼」页恰恰是要边看实时预览边拖位置/调大小的地方。现在只对会盖住挂件的**看板 / 详情**两个整屏抽屉上锁。
220
+ 🎨 **UI:余额条恢复成独立药丸,整块水平居中**(子代理行同样居中;不再塞进一个容器)。子代理行真正溢出时右侧渐隐,不再被屏幕齐口切一半。
221
+ ⚠️ **已知未修**:会话消耗按「查看时刻」的峰谷单价重算(同一会话在峰谷切换时会显示 1×/2× 两个数)。要修得在事件折叠时按 `isPeakTime(事件时刻)` 分桶存储并 bump `stateVersion`,属投影状态结构变更,留待下个版本。
222
+ v1.4.0 — 价格表改原生币种 + 会话消耗漏计/前缀吞模型修复 + 子代理消耗可见 + DSH provider 自动入列 + 设置界面统一 + 三处交互修复
193
223
  v0.2.0 — 最初版本
194
224
  v0.2.1 — 修复图标映射,加入 DeepSeek 峰谷计费
195
225
  v0.3.0 — 完整三层 UI,国内外分组,三色阈值,趣味峰谷,设置面板
package/client/client.js CHANGED
@@ -191,6 +191,11 @@ window.__ModuleLoader__.load({
191
191
  .dshadb_bar_logo{width:16px;height:16px;border-radius:5px;background:#eef2ff;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:800;color:#4f7cff}
192
192
  .dshadb_bar_name{font-weight:700;color:#17181c;font-size:11px}
193
193
  .dshadb_bar_amount{font-weight:800;font-variant-numeric:tabular-nums;color:#17181c}
194
+ /* C-4a (v1.4.1): .dshadb_bar_ok/warn/err 以前只有使用处、没有 CSS 规则(死类名),
195
+ 于是"负余额/低余额金额变红"从来没生效过, 只有小圆点变色。 */
196
+ .dshadb_bar_ok{color:#2e9e5b}
197
+ .dshadb_bar_warn{color:#c8891a}
198
+ .dshadb_bar_err{color:#d8443f}
194
199
  .dshadb_bar_dot{width:6px;height:6px;border-radius:50%;flex:none}
195
200
  .dshadb_bar_dot_ok{background:#35b56b}
196
201
  .dshadb_bar_dot_warn{background:#e6a72f}
@@ -367,9 +372,15 @@ window.__ModuleLoader__.load({
367
372
  见 bindSubagentTip()。浮层必须挂在 document.body 上 —— 挂在胶囊里会被
368
373
  .dshadb_subs 的 overflow-x:auto 裁掉。
369
374
  ⚠️ 本段在 JS 模板字符串里, 注释里**不能出现反引号**(会提前结束模板串)。 */
370
- .dshadb_barwrap{display:inline-flex;flex-direction:column;align-items:flex-start;gap:0;max-width:100%;min-width:0}
371
- .dshadb_barrow{display:inline-flex;align-items:center;gap:2px;max-width:100%}
372
- .dshadb_subs{display:flex;flex-wrap:nowrap;align-items:center;gap:4px;margin:3px 0 0 2px;max-width:100%;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch}
375
+ /* UI v1.4.1(按维护者反馈改):**不再把两行塞进一个容器** —— 余额条恢复成独立的药丸,
376
+ 整块(余额条 + 子代理行)**水平居中**:wrap 用 fit-content + margin:auto,
377
+ 子项 align-items:center,于是窄的时候两行都居中,宽的时候 wrap 撑到 100% 子代理行在内部横滑。 */
378
+ .dshadb_barwrap{display:flex;flex-direction:column;align-items:center;gap:0;width:fit-content;max-width:100%;min-width:0;margin:0 auto}
379
+ .dshadb_barrow{display:inline-flex;align-items:center;gap:2px;max-width:100%;min-width:0}
380
+ .dshadb_subs{display:flex;flex-wrap:nowrap;align-items:center;gap:4px;margin:3px 0 0 0;max-width:100%;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch}
381
+ /* UI v1.4.1: 子代理行只在**真的溢出**时右侧渐隐(由 syncSubsOverflow 打类名),
382
+ 这样"被切一半"看起来是有意的可滑动提示, 而不是被屏幕齐口截断。 */
383
+ .dshadb_subs_overflow{-webkit-mask-image:linear-gradient(to right,#000 calc(100% - 20px),transparent);mask-image:linear-gradient(to right,#000 calc(100% - 20px),transparent)}
373
384
  .dshadb_subs::-webkit-scrollbar{display:none}
374
385
  .dshadb_sub{flex:none;display:inline-flex;align-items:center;gap:4px;font-size:10px;line-height:1.15;padding:2px 7px;border-radius:8px;background:#f5f6f8;border:1px solid #e7e8ec;color:#777b84;white-space:nowrap;-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}
375
386
  .dshadb_sub_dot{width:5px;height:5px;border-radius:50%;background:#8b93a7;flex:none}
@@ -625,6 +636,7 @@ window.__ModuleLoader__.load({
625
636
  let snapshot = { status: "loading", balances: [], config: { safeThreshold: 50, warnThreshold: 10 } };
626
637
  const listeners = new Set();
627
638
  let timer = null, pollMs = DEFAULT_POLL_MS, inflight = null, started = false;
639
+ let lastForceAt = 0, seq = 0, appliedSeq = 0;
628
640
  function notify() { for (const fn of [...listeners]) fn(); }
629
641
  /**
630
642
  * @param force 显式强刷 (手动刷新按钮 / 保存设置后) —— **阻塞**, 等服务端拉完最新数据
@@ -632,8 +644,36 @@ window.__ModuleLoader__.load({
632
644
  * 刷新丢后台。force 那条路要等服务端跑完一次全量轮询(最慢端点可到 8s),
633
645
  * 应用切回前台时用它就会「等一段时间」。
634
646
  */
647
+ /**
648
+ * C-3a (v1.4.1): fetch 必须带超时。
649
+ * 以前整份客户端一个 AbortController 都没有 —— 请求一旦既不 resolve 也不 reject
650
+ * (弱网黑洞、WebView 被冻结、服务端被最慢端点拖住), inflight 永远不为 null,
651
+ * schedule() 又只在 promise settle 后才续期 → 插件永久停在 4 条骨架屏, 手动刷新也救不回来。
652
+ */
653
+ const FETCH_TIMEOUT_MS = 15000;
654
+ /**
655
+ * ⚠️ 余额端点的超时**必须远大于**其他端点。
656
+ * 实测(维护者真实配置:5 个中转站 + 16 个预设平台):服务端一次全量刷新要 **8~13.6 秒** ——
657
+ * `queryCustomRelay` 的 auto 探测是**串行**试 3 个候选端点(billing/subscription → api/user/self
658
+ * → credit_grants),每个都要跑到 `timeoutMs`(默认 8s) 才放弃;冷启动还要叠加 DNS/TLS 首次握手。
659
+ * v1.4.1 第一版把这里也写成 15s,结果**冷启动首屏必超时** → 面板直接显示「余额接口请求失败」。
660
+ * 超时的意义只是"别让一个彻底卡死的 socket 永远挂着",不是给正常慢启动设上限。
661
+ */
662
+ const BALANCES_TIMEOUT_MS = 90000;
663
+ const fetchT = (url, opts, ms) => {
664
+ const timeout = typeof ms === "number" ? ms : FETCH_TIMEOUT_MS;
665
+ try { return fetch(url, { ...(opts || {}), signal: AbortSignal.timeout(timeout) }); }
666
+ catch (e) { return fetch(url, opts); }
667
+ };
635
668
  async function refresh(force = false, peek = false) {
636
669
  if (inflight !== null && !force && !peek) return inflight;
670
+ // C-4c (v1.4.1): 强刷节流 2s(AGENTS 里写了「强刷 2 秒节流」, 但代码里一直没有);
671
+ // 并且在飞时直接复用同一个请求, 避免连点刷新打出一串并发。
672
+ if (force) {
673
+ const now = Date.now();
674
+ if (inflight !== null && now - lastForceAt < 2000) return inflight;
675
+ lastForceAt = now;
676
+ }
637
677
  if ((force || peek) && snapshot.isRefreshing !== true) { snapshot = { ...snapshot, isRefreshing: true }; notify(); }
638
678
  inflight = (async () => {
639
679
  try {
@@ -641,16 +681,26 @@ window.__ModuleLoader__.load({
641
681
  : force ? "/api-dashboard/balances?force=1&_t=" + Date.now()
642
682
  : "/api-dashboard/balances";
643
683
  // v0.5.0: 走浏览器默认缓存 (配合服务端 ETag), 数据没变时 304 空响应; force 用独立 URL + no-store 强刷
644
- const res = await fetch(url, { headers: { accept: "application/json" }, cache: force ? "no-store" : "default" });
684
+ const mySeq = ++seq;
685
+ const res = await fetchT(url, { headers: { accept: "application/json" }, cache: force ? "no-store" : "default" }, BALANCES_TIMEOUT_MS);
645
686
  if (!res.ok) throw new Error("HTTP " + res.status);
646
687
  const data = await res.json();
688
+ // C-4c: 只接受最新一次请求的结果 —— 否则「新请求先回、旧请求后回」会把界面数据倒退回去
689
+ if (mySeq <= appliedSeq) { inflight = null; return; }
690
+ appliedSeq = mySeq;
647
691
  if (data.config?.clientPollIntervalMs >= 1000) pollMs = Math.min(data.config.clientPollIntervalMs, 3600000);
648
692
  // v0.5.0 性能适配: 内容无变化就不重建快照引用, useSyncExternalStore 判定 Object.is 相同直接跳过整棵树重渲
649
693
  const newBalances = data.balances || [];
650
694
  const oldBalances = Array.isArray(snapshot.balances) ? snapshot.balances : [];
651
695
  const changed = JSON.stringify(newBalances) !== JSON.stringify(oldBalances) || snapshot.status !== "ok";
652
696
  const nextFetchedAt = data.fetchedAt || Date.now();
653
- if (!changed) {
697
+ // v1.4.1: 服务端冷启动不再阻塞首屏 —— 立刻回 `loading:true` + 空列表, 刷新丢后台。
698
+ // 这里保持「加载中」状态(骨架屏), 并把轮询临时压到 1.5 秒, 数据一到就上屏。
699
+ // 注意这不是假数据: 显示的是"加载中", 不是 0 元。
700
+ if (data.loading === true) {
701
+ snapshot = { ...snapshot, status: "loading", balances: [], config: data.config || snapshot.config, fetchedAt: nextFetchedAt, isRefreshing: false };
702
+ pollMs = Math.min(pollMs, 1500);
703
+ } else if (!changed) {
654
704
  // v1.1.0: config 必须始终跟随最新响应 —— 旧写法在余额未变时保留旧 config,
655
705
  // 导致设置里改完开关(如 whaleEnabled)后前端永远读不到新值, 表现为"开了又变回关"
656
706
  snapshot = { ...snapshot, config: data.config || snapshot.config, fetchedAt: nextFetchedAt, isRefreshing: false };
@@ -757,6 +807,12 @@ window.__ModuleLoader__.load({
757
807
  }
758
808
  /** 给一个胶囊挂长按: 位移 > 8px(其实是在滑动这一行) 或抬手都取消; 长按抬手后的 click 吞掉。 */
759
809
  function bindSubagentTip(node, text) {
810
+ // C-4b (v1.4.1): ref 是内联箭头函数 → 每次 render 都重新调用本函数,
811
+ // 而这里挂 7 个 addEventListener 却从不 remove → 5 秒轮询下每小时约 5000 个监听器
812
+ // 堆在同一个胶囊节点上。改成"同一个节点只挂一次", tip 存在节点上按需读。
813
+ node.__dshadbTip = text;
814
+ if (node.__dshadbTipBound === true) return;
815
+ node.__dshadbTipBound = true;
760
816
  let timer = 0, sx = 0, sy = 0, fired = false;
761
817
  const cancel = () => { if (timer) { clearTimeout(timer); timer = 0; } };
762
818
  node.addEventListener("pointerdown", (e) => {
@@ -764,7 +820,7 @@ window.__ModuleLoader__.load({
764
820
  fired = false;
765
821
  sx = e.clientX; sy = e.clientY;
766
822
  cancel();
767
- timer = setTimeout(() => { timer = 0; fired = true; showSubagentTip(node, text); }, SUBTIP_LONG_PRESS_MS);
823
+ timer = setTimeout(() => { timer = 0; fired = true; showSubagentTip(node, node.__dshadbTip || text); }, SUBTIP_LONG_PRESS_MS);
768
824
  });
769
825
  node.addEventListener("pointermove", (e) => {
770
826
  if (timer && (Math.abs(e.clientX - sx) > 8 || Math.abs(e.clientY - sy) > 8)) cancel();
@@ -789,7 +845,18 @@ window.__ModuleLoader__.load({
789
845
  const list = Array.isArray(cost && cost.subagents) ? cost.subagents : [];
790
846
  if (list.length === 0) return null;
791
847
  const mainCur = (cost && cost.currency) || "CNY";
792
- return react.createElement("span", { className: "dshadb_subs", key: "subs" },
848
+ // UI v1.4.1: 只在真的横向溢出时才加渐隐类名, 避免内容没满也糊一条边
849
+ const syncSubsOverflow = (n) => {
850
+ if (!n) return;
851
+ try {
852
+ const over = n.scrollWidth > n.clientWidth + 1;
853
+ if (over) n.classList.add("dshadb_subs_overflow");
854
+ else n.classList.remove("dshadb_subs_overflow");
855
+ } catch (e) { /* 忽略 */ }
856
+ };
857
+ return react.createElement("span", { className: "dshadb_subs", key: "subs",
858
+ ref: (n) => { if (n) requestAnimationFrame(() => syncSubsOverflow(n)); },
859
+ onScroll: (e) => syncSubsOverflow(e.currentTarget) },
793
860
  list.map((s, i) => {
794
861
  const f = formatSessionCost({ cost: s.cost, currency: mainCur, costByCurrency: s.costByCurrency, waiting: false });
795
862
  const amt = f !== null && f.hasValue ? "~" + f.text : "~—";
@@ -1043,6 +1110,13 @@ window.__ModuleLoader__.load({
1043
1110
  const handleRefresh = () => { setRefreshing(true); store.forceRefresh().then(() => setTimeout(() => setRefreshing(false), 500)); };
1044
1111
  const balances = data.balances || [];
1045
1112
  const isLoading = data.status === "loading" && balances.length === 0;
1113
+ // v1.4.1: 加载超过 4 秒就在骨架屏下面补一句「首次加载较慢」的解释(冷启动全量刷新实测 8~30s)
1114
+ const [slowLoad, setSlowLoad] = react.useState(false);
1115
+ react.useEffect(() => {
1116
+ if (!isLoading) { setSlowLoad(false); return undefined; }
1117
+ const timer = setTimeout(() => setSlowLoad(true), 4000);
1118
+ return () => clearTimeout(timer);
1119
+ }, [isLoading]);
1046
1120
  const okCount = balances.filter(b => b.status === "ok").length;
1047
1121
 
1048
1122
  const groups = { domestic: [], abroad: [], relay: [], custom: [] };
@@ -1119,19 +1193,35 @@ window.__ModuleLoader__.load({
1119
1193
  ]);
1120
1194
  };
1121
1195
 
1196
+ const slowHint = isLoading && slowLoad;
1122
1197
  let bodyContent;
1123
1198
  if (isLoading) {
1124
- bodyContent = react.createElement("div", null, [0,1,2,3].map(i => react.createElement("div", { className: "dshadb_skeleton", key: i }, [
1125
- react.createElement("div", { className: "dshadb_skeleton_logo", key: "logo" }),
1126
- react.createElement("div", { className: "dshadb_skeleton_lines", key: "lines" }, [
1127
- react.createElement("div", { className: "dshadb_skeleton_line dshadb_skeleton_line_long", key: "l1" }),
1128
- react.createElement("div", { className: "dshadb_skeleton_line dshadb_skeleton_line_short", key: "l2" }),
1129
- ]),
1130
- ])));
1199
+ // v1.4.1: 冷启动全量刷新实测要 8~30 秒(中转站 auto 探测是串行的),
1200
+ // 光画骨架屏用户会以为卡死 超过 4 秒补一句解释。
1201
+ bodyContent = react.createElement("div", null, [
1202
+ ...([0,1,2,3].map(i => react.createElement("div", { className: "dshadb_skeleton", key: i }, [
1203
+ react.createElement("div", { className: "dshadb_skeleton_logo", key: "logo" }),
1204
+ react.createElement("div", { className: "dshadb_skeleton_lines", key: "lines" }, [
1205
+ react.createElement("div", { className: "dshadb_skeleton_line dshadb_skeleton_line_long", key: "l1" }),
1206
+ react.createElement("div", { className: "dshadb_skeleton_line dshadb_skeleton_line_short", key: "l2" }),
1207
+ ]),
1208
+ ]))),
1209
+ slowHint ? react.createElement("div", { key: "slow", style: { fontSize: 11, color: "#8b91a0", textAlign: "center", lineHeight: 1.6, margin: "10px 4px 2px" } },
1210
+ "首次加载要逐个平台拉取余额(含中转站探测),通常 10~30 秒,请稍候…") : null,
1211
+ ]);
1131
1212
  } else if (balances.length === 0) {
1213
+ // C-3c (v1.4.1): 请求失败时以前只画"空看板" —— 用户分不清「没有平台」和「请求失败」。
1214
+ // 实测 snapshot.message 全文件只被赋值、从不渲染。
1215
+ const isErr = snapshot.status === "error";
1132
1216
  bodyContent = react.createElement("div", { className: "dshadb_empty" }, [
1133
- react.createElement("span", { key: "icon", style: { fontSize: 30, opacity: 0.5 } }, "📊"),
1134
- react.createElement("span", { key: "title" }, t("title")),
1217
+ react.createElement("span", { key: "icon", style: { fontSize: 30, opacity: 0.5 } }, isErr ? "⚠️" : "📊"),
1218
+ react.createElement("span", { key: "title" }, isErr ? "余额接口请求失败" : t("title")),
1219
+ isErr && snapshot.message ? react.createElement("span", { key: "msg", style: { fontSize: 11, color: "#e05252", wordBreak: "break-all" } }, String(snapshot.message)) : null,
1220
+ isErr ? react.createElement("button", {
1221
+ key: "retry", type: "button", className: "dshadb_add_btn",
1222
+ style: { width: "auto", padding: "6px 18px", marginTop: "4px" },
1223
+ onClick: () => { try { store.forceRefresh(); } catch (e) { /* 忽略 */ } },
1224
+ }, "重试") : null,
1135
1225
  ]);
1136
1226
  } else {
1137
1227
  bodyContent = [
@@ -1214,7 +1304,7 @@ window.__ModuleLoader__.load({
1214
1304
  react.useEffect(() => {
1215
1305
  if (!isOpen || platformId !== "deepseek") return;
1216
1306
  let live = true;
1217
- fetch("/api-dashboard/prices", { headers: { accept: "application/json" } })
1307
+ fetchT("/api-dashboard/prices", { headers: { accept: "application/json" } })
1218
1308
  .then((r) => (r.ok ? r.json() : null))
1219
1309
  .then((d) => { if (live && d && d.ok) setPriceData(d); })
1220
1310
  .catch(() => {});
@@ -1398,7 +1488,7 @@ window.__ModuleLoader__.load({
1398
1488
  });
1399
1489
  const checkUpdate = react.useCallback((force) => {
1400
1490
  setUpd(s => ({ ...s, phase: "checking" }));
1401
- fetch("/api-dashboard/update" + (force ? "?force=1" : ""), { headers: { accept: "application/json" } })
1491
+ fetchT("/api-dashboard/update" + (force ? "?force=1" : ""), { headers: { accept: "application/json" } })
1402
1492
  .then(r => r.json())
1403
1493
  .then(d => {
1404
1494
  // v0.6.2: 检查完必有反馈 —— 已是最新也提示, 有新版提示版本号, 失败给固定文案
@@ -1411,7 +1501,7 @@ window.__ModuleLoader__.load({
1411
1501
  react.useEffect(() => { if (isOpen) checkUpdate(false); }, [isOpen]); // 打开面板自动检查
1412
1502
  const installUpdate = () => {
1413
1503
  setUpd(s => ({ ...s, phase: "installing" }));
1414
- fetch("/api-dashboard/update/install", { method: "POST" })
1504
+ fetchT("/api-dashboard/update/install", { method: "POST" }, 120000)
1415
1505
  .then(r => r.json().then(d => ({ code: r.status, body: d })))
1416
1506
  .then(({ code, body }) => {
1417
1507
  if (code === 200 && body.ok) {
@@ -1443,12 +1533,18 @@ window.__ModuleLoader__.load({
1443
1533
  setRefreshSec(Math.round((config?.clientPollIntervalMs || 5000) / 1000) || 5);
1444
1534
  setWhaleOn(!!(config && config.whaleEnabled));
1445
1535
  let cancelled = false;
1446
- fetch("/api-dashboard/config", { cache: "no-store" }).then(r => r.json()).then(d => {
1536
+ fetchT("/api-dashboard/config", { cache: "no-store" }).then(r => r.json()).then(d => {
1447
1537
  if (cancelled) return;
1448
1538
  if (d.ok) {
1449
1539
  setRelays(d.customRelays || []);
1450
1540
  setModels(d.customModels || []);
1451
1541
  if (d.refreshIntervalSec) setRefreshSec(d.refreshIntervalSec);
1542
+ // C-2 (v1.4.1): 阈值/主币种也必须从 /config 补一次。
1543
+ // 以前只从 /balances 的 config 读, 而冷启动 /balances 要等 8~10s →
1544
+ // 那期间打开面板看到的是默认 50/10/CNY, 一点"保存并生效"就把用户真实配置覆盖了。
1545
+ if (typeof d.safeThreshold === "number") setSafe(d.safeThreshold);
1546
+ if (typeof d.warnThreshold === "number") setWarn(d.warnThreshold);
1547
+ if (typeof d.currency === "string") setCurrency(d.currency);
1452
1548
  if (typeof d.overseasCurrency === "string") setOverseasCurrency(d.overseasCurrency);
1453
1549
  if (typeof d.whaleEnabled === "boolean") setWhaleOn(d.whaleEnabled);
1454
1550
  if (Array.isArray(d.officialProviders)) setOfficialText(d.officialProviders.join(", "));
@@ -1458,7 +1554,7 @@ window.__ModuleLoader__.load({
1458
1554
  }
1459
1555
  }).finally(() => { if (!cancelled) setLoading(false); });
1460
1556
  // 大肥鱼细项与主配置并行拉取, 失败保持默认值
1461
- fetch("/api-dashboard/whale/settings", { cache: "no-store" }).then(r => r.json()).then(d => {
1557
+ fetchT("/api-dashboard/whale/settings", { cache: "no-store" }).then(r => r.json()).then(d => {
1462
1558
  if (cancelled || !d || !d.ok || !d.settings) return;
1463
1559
  const s = d.settings;
1464
1560
  setWf(prev => ({
@@ -1476,11 +1572,13 @@ window.__ModuleLoader__.load({
1476
1572
  }, [isOpen]);
1477
1573
  if (!isOpen) return null;
1478
1574
  const save = async () => {
1479
- const nextRefreshSec = Math.min(Math.max(Number(refreshSec) || 5, 5), 60)
1575
+ // C-1 (v1.4.1): 原来这里夹的是 5 —— 输入框允许 1 秒、服务端 clampRefreshSec 也吃 1 秒,
1576
+ // 只有 save() 把它悄悄改回 5 秒(用户设 1 秒保存后变 5 秒)。四处下限现在一致。
1577
+ const nextRefreshSec = Math.min(Math.max(Number(refreshSec) || 1, 1), 60)
1480
1578
  setRefreshSec(nextRefreshSec)
1481
1579
  setSaving(true);
1482
1580
  try {
1483
- await fetch("/api-dashboard/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({
1581
+ await fetchT("/api-dashboard/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({
1484
1582
  customRelays: relays, customModels: models,
1485
1583
  safeThreshold: Number(safe), warnThreshold: Number(warn), currency, overseasCurrency,
1486
1584
  refreshIntervalSec: nextRefreshSec,
@@ -1514,7 +1612,7 @@ window.__ModuleLoader__.load({
1514
1612
  setWhaleOn(next);
1515
1613
  if (next) ensureWhaleWidget(); else removeWhaleWidget();
1516
1614
  try {
1517
- await fetch("/api-dashboard/config", {
1615
+ await fetchT("/api-dashboard/config", {
1518
1616
  method: "POST", headers: { "Content-Type": "application/json" },
1519
1617
  body: JSON.stringify({ whaleEnabled: !!next }),
1520
1618
  });
@@ -1525,7 +1623,7 @@ window.__ModuleLoader__.load({
1525
1623
  const toggleBrands = async (next) => {
1526
1624
  setShowBrands(next);
1527
1625
  try {
1528
- await fetch("/api-dashboard/config", {
1626
+ await fetchT("/api-dashboard/config", {
1529
1627
  method: "POST", headers: { "Content-Type": "application/json" },
1530
1628
  body: JSON.stringify({ showNoBalanceBrands: !!next }),
1531
1629
  });
@@ -1539,7 +1637,7 @@ window.__ModuleLoader__.load({
1539
1637
  setWf(prev => ({ ...prev, ...patch }));
1540
1638
  patchWhaleWidget(patch);
1541
1639
  try {
1542
- fetch("/api-dashboard/whale/settings", {
1640
+ fetchT("/api-dashboard/whale/settings", {
1543
1641
  method: "PUT", headers: { "Content-Type": "application/json" },
1544
1642
  body: JSON.stringify(patch),
1545
1643
  }).catch(() => {});
@@ -2052,7 +2150,7 @@ window.__ModuleLoader__.load({
2052
2150
  peekRatio: st.peekRatio, left: st.left, top: st.top, side: st.side,
2053
2151
  };
2054
2152
  try {
2055
- fetch("/api-dashboard/whale/settings", {
2153
+ fetchT("/api-dashboard/whale/settings", {
2056
2154
  method: "PUT", headers: { "Content-Type": "application/json" },
2057
2155
  body: JSON.stringify(lastWhaleSettings),
2058
2156
  }).catch(function () {});
@@ -2362,7 +2460,7 @@ window.__ModuleLoader__.load({
2362
2460
  }
2363
2461
  revealTimer = setTimeout(reveal, 2000); // 兜底: 请求挂住也不能让鱼永远不出现
2364
2462
  applyAll();
2365
- fetch("/api-dashboard/whale/settings", { cache: "no-store" })
2463
+ fetchT("/api-dashboard/whale/settings", { cache: "no-store" })
2366
2464
  .then(function (r) { return r.json(); })
2367
2465
  .then(function (d) {
2368
2466
  if (disposed) return;
@@ -2451,11 +2549,23 @@ window.__ModuleLoader__.load({
2451
2549
  // v1.4.0: 看板/详情/设置任一开着 → 锁住大肥鱼挂件的触摸。
2452
2550
  // 不这么做的话, 挂件(在 body 上, z-index 9600)会盖在设置面板上面,
2453
2551
  // 拖「身体大小 / 露出比例」滑块时实际拖的是挂件。
2454
- const overlayOpen = isSettingsOpen || view !== "bar";
2552
+ /**
2553
+ * 大肥鱼挂件锁定(v1.4.1 定稿):**任何界面下都不锁**。
2554
+ *
2555
+ * 演进过程(别再走回头路):
2556
+ * v1.4.0 `overlayOpen = isSettingsOpen || view !== "bar"` —— 一开设置就锁死,
2557
+ * 维护者反馈「在设置里开启大肥鱼,图片动不了,得关掉才能动」。
2558
+ * v1.4.1a 改成只锁「看板 / 详情」两个整屏抽屉 —— 维护者再次要求全部放开。
2559
+ * v1.4.1b **本版:永不锁定**,挂件在任何界面下都能拖。
2560
+ *
2561
+ * 锁的机制本身(`setWhaleLocked` / `.dshadb-whale-locked` CSS / `onDown` 里那道兜底)
2562
+ * **保留着**:万一以后又出现「面板开着时拖滑块被挂件吃掉」这类反馈,
2563
+ * 把下面这行换成 `setWhaleLocked(view !== "bar")` 即可恢复按界面锁定。
2564
+ */
2455
2565
  react.useEffect(() => {
2456
- setWhaleLocked(overlayOpen);
2566
+ setWhaleLocked(false);
2457
2567
  return () => setWhaleLocked(false);
2458
- }, [overlayOpen]);
2568
+ }, []);
2459
2569
  // 峰谷时段变化同步给挂件 (台词里的「当前时间段」用它, 不涉及任何金额)
2460
2570
  react.useEffect(() => {
2461
2571
  updateWhaleContext({ isPeak: !!(config && config.isPeak) });
@@ -2514,7 +2624,7 @@ window.__ModuleLoader__.load({
2514
2624
  react.useEffect(() => {
2515
2625
  const cachedAt = window.__dshadbUpdateCheckedAt || 0;
2516
2626
  if (Date.now() - cachedAt < 5 * 60 * 1000) return; // 会话内查过就不重复
2517
- fetch("/api-dashboard/update", { headers: { accept: "application/json" } })
2627
+ fetchT("/api-dashboard/update", { headers: { accept: "application/json" } })
2518
2628
  .then((r) => r.json())
2519
2629
  .then((d) => { if (d && d.ok) { window.__dshadbUpdateInfo = d; window.__dshadbUpdateCheckedAt = Date.now(); } })
2520
2630
  .catch(() => { /* 网络异常静默, 打开设置时可见固定错误文案 */ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-api-dashboard",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "多平台 API 余额/用量看板插件:官方真实品牌图标,三层 UI 直接在输入框下方显示当前选中平台余额与估算消耗,点开底部抽屉切换所有平台;支持国内外平台与自定义中转站自动探测余额(查不到标未开放);自定义安全/预警阈值,余额告警提醒;进入页面自动刷新",
5
5
  "keywords": [
6
6
  "dsh",
@@ -67,4 +67,4 @@
67
67
  }
68
68
  },
69
69
  "license": "MIT"
70
- }
70
+ }
package/src/index.js CHANGED
@@ -13,14 +13,53 @@
13
13
  * - sessionProjections 单元 queryBalanceCost 估算本会话消耗 (按模型单价)。
14
14
  */
15
15
 
16
- import Schema from '@deepseek-ai/schemastery'
17
- import { z } from 'zod'
18
- import { readFileSync, writeFileSync, renameSync, chmodSync, existsSync, mkdirSync, rmSync, cpSync, statSync, readdirSync, realpathSync } from 'node:fs'
19
- import { fileURLToPath } from 'node:url'
16
+ import { createRequire } from 'node:module'
17
+ import { readFileSync, writeFileSync, renameSync, chmodSync, existsSync, mkdirSync, rmSync, cpSync, statSync, lstatSync, readdirSync, realpathSync } from 'node:fs'
18
+ import { fileURLToPath, pathToFileURL } from 'node:url'
20
19
  import { execFileSync } from 'node:child_process'
21
20
  import { tmpdir, homedir } from 'node:os'
22
21
  import { join, dirname, basename } from 'node:path'
23
22
 
23
+ /**
24
+ * peer 依赖加载器(v1.4.1)—— 这一条直接决定「别人下载后能不能用」。
25
+ *
26
+ * 病症(真机实测复现):用户按 README 执行 `dsh plugin --profile web add dsh-api-dashboard`,
27
+ * 安装成功,但启动时 **整个 dsh web 起不来**:
28
+ *
29
+ * Cannot find package '@deepseek-ai/schemastery' imported from
30
+ * /root/.local/share/pnpm/store/v10/files/42/e96689...
31
+ *
32
+ * 链路:profile 用 hoisted linker + autoInstallPeers:false(插件把它声明成 optional peer),
33
+ * DSHA 的 proot 带 `--link2symlink`,于是 pnpm 的硬链接被降级成**指向全局 store 的软链**;
34
+ * Node 的 ESM 会先把模块解析成 realpath,再从那开始向上找 node_modules ——
35
+ * 从 store 目录往上永远也走不到 `$DSH_HOME/profiles/node_modules`(DSH 放宿主依赖的地方)。
36
+ *
37
+ * 修法:先按常规 import;失败时改用宿主自己的模块回退目录做 CJS 解析
38
+ * (`createRequire` 用的是**给定路径**而不是 realpath,因此能穿透这层软链)。
39
+ * 这样 npm 安装、源码安装、软链安装三种布局都能加载。
40
+ */
41
+ const resolvePeer = async (spec) => {
42
+ let primary = null
43
+ try { return await import(spec) } catch (e) { primary = e }
44
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
45
+ const bases = [
46
+ join(home, 'profiles', 'node_modules', '__dshadb_resolver__.cjs'),
47
+ join(home, 'profiles', process.env.DSHA_STARTUP_PROFILE || 'web', 'node_modules', '__dshadb_resolver__.cjs'),
48
+ ]
49
+ for (const base of bases) {
50
+ try {
51
+ const entry = createRequire(base).resolve(spec)
52
+ return await import(pathToFileURL(entry).href)
53
+ } catch { /* 试下一个基准目录 */ }
54
+ }
55
+ throw primary
56
+ }
57
+
58
+ const schemaMod = await resolvePeer('@deepseek-ai/schemastery')
59
+ const Schema = schemaMod.default ?? schemaMod
60
+ const zodMod = await resolvePeer('zod')
61
+ const z = zodMod.z ?? zodMod.default?.z ?? zodMod
62
+
24
63
  export const name = 'dsh-api-dashboard'
25
64
 
26
65
  // ============================================================
@@ -36,7 +75,33 @@ const MANIFEST_API = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/co
36
75
  const TARBALL_URL = `https://codeload.github.com/${REPO_OWNER}/${REPO_NAME}/tar.gz/refs/heads/${REPO_BRANCH}`
37
76
 
38
77
  /** 插件运行实体的安装根目录 (src/index.js 上两级; ESM 默认按 realpath 加载) */
39
- const SELF_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
78
+ /**
79
+ * 插件自身的安装根目录(v1.4.1)。
80
+ *
81
+ * 不能只用 `dirname(dirname(import.meta.url))`:ESM 会把符号链接解析成 realpath,
82
+ * 而 npm 装进 profile 后文件常常是**指向 pnpm store 的软链** → 算出来的根目录是
83
+ * `.../store/v10/files`,于是 assets(图标 / 大肥鱼贴图 / 音效)全部 404、
84
+ * 版本号也读不到(一键更新会误判)。
85
+ * 这里按候选顺序找第一个「package.json 里 name 就是本插件」的目录。
86
+ */
87
+ const resolveSelfRoot = () => {
88
+ const fromUrl = dirname(dirname(fileURLToPath(import.meta.url)))
89
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
90
+ const profile = process.env.DSHA_STARTUP_PROFILE || 'web'
91
+ const candidates = [
92
+ fromUrl,
93
+ join(home, 'profiles', profile, 'node_modules', 'dsh-api-dashboard'),
94
+ join(home, 'profiles', 'node_modules', 'dsh-api-dashboard'),
95
+ ]
96
+ for (const dir of candidates) {
97
+ try {
98
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
99
+ if (pkg && pkg.name === 'dsh-api-dashboard') return dir
100
+ } catch { /* 试下一个 */ }
101
+ }
102
+ return fromUrl
103
+ }
104
+ const SELF_ROOT = resolveSelfRoot()
40
105
 
41
106
  /** 读取指定目录中 package.json 的 version, 异常返回 null */
42
107
  const readVersionAt = (dir) => {
@@ -96,7 +161,12 @@ export async function applyUpdate({ targets = null, timeoutMs = 30000, remoteVer
96
161
  try {
97
162
  const legacyReal = realPathSafe(join(homedir(), 'dsha-api-dashboard'))
98
163
  const selfReal = realPathSafe(SELF_ROOT)
99
- if (legacyReal && selfReal && legacyReal !== selfReal && existsSync(join(legacyReal, 'package.json'))) {
164
+ // H-2 (v1.4.1): 自动同步 ~/dsha-api-dashboard 只对「非 git 工作区」生效。
165
+ // 以前不判断, 于是维护者/开发者的 git clone 会在一次"一键更新"后被 tarball 覆写 ——
166
+ // codeload 的 tarball 里**没有 .git**(已实测), 删掉就再也回不来。
167
+ if (legacyReal && selfReal && legacyReal !== selfReal
168
+ && existsSync(join(legacyReal, 'package.json'))
169
+ && !existsSync(join(legacyReal, '.git'))) {
100
170
  dirs.push(legacyReal)
101
171
  }
102
172
  } catch { /* 探测失败不影响主流程 */ }
@@ -104,6 +174,16 @@ export async function applyUpdate({ targets = null, timeoutMs = 30000, remoteVer
104
174
  for (const dir of dirs) {
105
175
  if (!existsSync(join(dir, 'package.json'))) throw new Error(`target missing: ${dir}`)
106
176
  }
177
+ // H-2 (v1.4.1): target 是符号链接时, 下面的 rmSync 会**穿透软链删光真实目录的内容**,
178
+ // 而 cpSync 对软链会抛 ERR_FS_CP_DIR_TO_NON_DIR; 备份 tar 里存的又只是软链本身 →
179
+ // 真实目录永久损坏且回滚不回来。遇到软链直接拒绝, 让用户改用真实路径。
180
+ for (const dir of dirs) {
181
+ let st = null
182
+ try { st = lstatSync(dir) } catch { st = null }
183
+ if (st && st.isSymbolicLink()) {
184
+ throw new Error(`refusing to update a symbolic-link target: ${dir} (use its real path instead)`)
185
+ }
186
+ }
107
187
 
108
188
  // 1) 下载 tarball 到临时文件
109
189
  const tmpBase = join(tmpdir(), `dshadb-update-${Date.now()}`)
@@ -138,7 +218,9 @@ export async function applyUpdate({ targets = null, timeoutMs = 30000, remoteVer
138
218
  // 5) 交换: 删旧内容 → 拷新内容 (node_modules 保留, 避免重装依赖)
139
219
  for (const dir of dirs) {
140
220
  swapped = true
141
- const keep = new Set(['node_modules'])
221
+ // H-2 (v1.4.1): 必须保留 .git —— codeload 的 tarball 里没有它(实测),
222
+ // 删掉就等于把用户的 git 历史抹了, 且没有任何恢复途径。
223
+ const keep = new Set(['node_modules', '.git'])
142
224
  for (const entry of readdirSafe(dir)) {
143
225
  if (!keep.has(entry)) rmSync(join(dir, entry), { recursive: true, force: true })
144
226
  }
@@ -202,6 +284,15 @@ async function getUpdateStatus(force = false) {
202
284
  // ============================================================
203
285
  const STATE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-api-dashboard.json')
204
286
 
287
+ /**
288
+ * A(v1.4.1): 每个中转站「上次探到的可用余额端点」的记忆表。
289
+ * `queryCustomRelay` 的 auto 探测是**串行**试 3 个候选端点, 每个都要跑满 timeoutMs ——
290
+ * 实测一次全量刷新因此要 8~13.6 秒。记住命中过的端点并优先试它, 稳态下每个中转站只打 1 个请求。
291
+ * 只做**排序提示**, 候选全表仍然会依次试, 所以某个中转站换了端点也能自动跟上。
292
+ * 持久化在状态文件 `relayEndpoints`(重启后依然生效)。
293
+ */
294
+ const relayEndpointHints = new Map()
295
+
205
296
  /**
206
297
  * 状态文件结构版本。**改动已持久化字段的默认值时必须 +1 并补一段迁移**,
207
298
  * 否则老用户的状态文件会把字段钉死在旧值上 —— 新默认值对老用户永远不生效。
@@ -211,8 +302,44 @@ const STATE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-ap
211
302
  */
212
303
  const CONFIG_VERSION = 2
213
304
 
305
+ /**
306
+ * 形状消毒 (v1.4.1): 状态文件是**我们自己的代码**写的, 但写盘可能被打断
307
+ * (磁盘满/进程被杀/UTF-8 截断), 用户也可能手改。字段形状一旦跑偏,
308
+ * 后面的 `(persisted.customRelays ?? []).map(...)` 会抛 TypeError —— 而 apply() 抛出
309
+ * 会让 **整个 dsh web 启动失败**(不是插件不显示, 是 GUI 打不开), 用户完全无从下手。
310
+ * 所以这里把所有"本该是数组/对象/数字"的字段统一消毒, 消毒不了就丢弃, 绝不让 apply() 抛。
311
+ * ⚠️ 新增持久化字段时, 记得同步登记到这里。
312
+ */
313
+ const ARRAY_FIELDS = ['customRelays', 'customModels', 'officialProviders', 'dshProviderOptOut', 'presets']
314
+ const OBJECT_FIELDS = ['whaleSettings', 'prices', 'relayEndpoints']
315
+ const NUMBER_FIELDS = [
316
+ ['refreshIntervalMs', 1000, 60000], // H-4b: 曾经能持久化成 -1 → 3 秒内 1794 次上游请求
317
+ ['clientPollIntervalMs', 1000, 60000],
318
+ ['timeoutMs', 1000, 60000],
319
+ ['safeThreshold', 0, Number.MAX_SAFE_INTEGER],
320
+ ['warnThreshold', 0, Number.MAX_SAFE_INTEGER],
321
+ ['configVersion', 0, Number.MAX_SAFE_INTEGER],
322
+ ]
323
+
324
+ const sanitizePersistedShape = (s) => {
325
+ const out = { ...s }
326
+ for (const k of ARRAY_FIELDS) {
327
+ if (k in out && !Array.isArray(out[k])) delete out[k]
328
+ }
329
+ for (const k of OBJECT_FIELDS) {
330
+ if (k in out && (out[k] === null || typeof out[k] !== 'object' || Array.isArray(out[k]))) delete out[k]
331
+ }
332
+ for (const [k, min, max] of NUMBER_FIELDS) {
333
+ if (!(k in out)) continue
334
+ const n = Number(out[k])
335
+ if (!Number.isFinite(n)) { delete out[k]; continue }
336
+ out[k] = Math.min(Math.max(Math.round(n), min), max)
337
+ }
338
+ return out
339
+ }
340
+
214
341
  const migratePersistedState = (parsed) => {
215
- const s = (parsed && typeof parsed === 'object') ? parsed : {}
342
+ const s = sanitizePersistedShape((parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {})
216
343
  const ver = Number.isFinite(s.configVersion) ? s.configVersion : 1
217
344
  let out = s
218
345
  if (ver < 2) {
@@ -234,6 +361,9 @@ const loadPersistedState = () => {
234
361
  const savePersistedState = (state) => {
235
362
  try {
236
363
  const merged = { ...loadPersistedState(), ...state }
364
+ // H-4c (v1.4.1): 以前这里从不建父目录, 且吞掉所有异常 → ~/.dsh 不存在时
365
+ // 接口照样回 ok:true, 用户配置"保存成功"却没落盘, 重启即丢。现在先建目录。
366
+ mkdirSync(dirname(STATE_FILE), { recursive: true })
237
367
  // mode 0o600: 状态文件含自定义中转站/模型的 API Key 明文, 必须限定本用户可读
238
368
  // (不能依赖 umask —— 默认 umask 0022 的桌面机会落成 0644); chmod 兜底修正旧文件
239
369
  writeFileSync(STATE_FILE + '.tmp', JSON.stringify(merged, null, 2), { encoding: 'utf8', mode: 0o600 })
@@ -471,7 +601,15 @@ export const selectDshProviders = (entries, kinds, optOut) => {
471
601
  */
472
602
  export const planBalancesFetch = ({ force = false, peek = false, hasData = false, age = 0, intervalMs = 5000 } = {}) => {
473
603
  const stale = age > (intervalMs || 300000) // 兼容旧行为: intervalMs 缺失时用 5 分钟
474
- if (!hasData) return 'wait' // 冷启动(服务端刚重启): 确实没东西可显示, 只能等
604
+ /**
605
+ * v1.4.1: 冷启动**不再阻塞首屏**。
606
+ * 旧行为是 `return 'wait'` —— 服务端刚重启时缓存为空, 第一个请求要 await 一次**全量**刷新,
607
+ * 而实测全量刷新要 8~13.6 秒(中转站 auto 探测是串行试 3 个端点), 用户看到的就是
608
+ * 「重启进来等半天」。现在改成: 立刻回「还在加载」+ 把刷新丢后台, 客户端保持骨架屏并**1.5 秒后重问**,
609
+ * 数据一到就上屏。注意这**不是假数据** —— 返回的是空列表 + loading 标记, 界面显示的是"加载中"而非 0。
610
+ * 只有显式强刷(force, 用户主动要新数据)才继续阻塞等。
611
+ */
612
+ if (!hasData) return force ? 'wait' : 'background'
475
613
  if (!force && !stale) return 'none'
476
614
  if (peek) return age > 1000 ? 'background' : 'none' // 1 秒内刚拉过就不重复打
477
615
  return age > 2000 ? 'wait' : 'none' // 显式强刷留 2 秒节流, 防连点打爆平台接口
@@ -945,12 +1083,18 @@ async function fetchWithTimeout(url, headers, timeoutMs, method = 'GET') {
945
1083
 
946
1084
  /** 读取请求体并限制大小 (默认 256KB) —— 防持有 token 者灌大包打爆内存。超限抛错。 */
947
1085
  async function readBody(req, limit = 256 * 1024) {
948
- let body = ''
1086
+ // H-4d (v1.4.1): 不能对每个 chunk 单独 toString —— 一个汉字的 3 个字节被 TCP 分到两个 chunk 时,
1087
+ // 两边都会解出替换字符 U+FFFD, 中文中转站名/自定义模型名会被写坏并持久化(实测)。
1088
+ // 改成先收集 Buffer 再整体解码。
1089
+ const chunks = []
1090
+ let size = 0
949
1091
  for await (const chunk of req) {
950
- body += chunk
951
- if (body.length > limit) throw Object.assign(new Error('request body too large'), { statusCode: 413 })
1092
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
1093
+ size += buf.length
1094
+ if (size > limit) throw Object.assign(new Error('request body too large'), { statusCode: 413 })
1095
+ chunks.push(buf)
952
1096
  }
953
- return body
1097
+ return Buffer.concat(chunks).toString('utf8')
954
1098
  }
955
1099
 
956
1100
  /** 字符串清洗: 截断到 max 长度 (设置面板传入的任意字段统一过这里) */
@@ -1140,11 +1284,22 @@ export function parseResponse(queryType, json) {
1140
1284
  if (!json || typeof json !== 'object') return null
1141
1285
  const hasAny = 'total_granted' in json || 'total_available' in json || 'total_used' in json
1142
1286
  if (!hasAny) return null
1143
- const total = toAmount(json?.total_granted)
1144
- const used = toAmount(json?.total_used)
1145
- const hasAvail = json?.total_available != null
1146
- const available = hasAvail ? toAmount(json.total_available) : null
1147
- return { total: hasAvail ? available : (total - used), currency: 'USD', available, used, note: 'OpenAI 兼容额度' }
1287
+ // H-4a (v1.4.1): 字段**存在但值无效**(null / 非数字)时, toAmount 会归 0,
1288
+ // 于是 total = 0 - used 得到一个**负数余额** —— 与 openrouter 那次是同一类漏洞
1289
+ // (红线: 不许把解析失败冒充成真实数字)。这里改成「值无效就不表态」。
1290
+ // ⚠️ Number(null) === 0、Number('') === 0 —— 必须先把「空值」挡掉, 否则等于没挡
1291
+ const numOrNull = (v) => {
1292
+ if (v === null || v === undefined || v === '') return null
1293
+ const n = Number(v)
1294
+ return Number.isFinite(n) ? n : null
1295
+ }
1296
+ const grantedN = numOrNull(json?.total_granted)
1297
+ const usedN = numOrNull(json?.total_used)
1298
+ const availN = numOrNull(json?.total_available)
1299
+ if (availN === null && (grantedN === null || usedN === null)) return null
1300
+ const used = usedN ?? 0
1301
+ const hasAvail = availN !== null
1302
+ return { total: hasAvail ? availN : (grantedN - used), currency: 'USD', available: hasAvail ? availN : null, used, note: 'OpenAI 兼容额度' }
1148
1303
  }
1149
1304
  case 'siliconflow': {
1150
1305
  const d = json?.data
@@ -1398,6 +1553,13 @@ async function queryCustomRelay(relay, config) {
1398
1553
  )
1399
1554
  }
1400
1555
 
1556
+ // A: 命中过的端点排到最前(只影响顺序, 不影响"全都会试一遍"的语义)
1557
+ if (candidates.length > 1 && relayEndpointHints.has(id)) {
1558
+ const hint = relayEndpointHints.get(id)
1559
+ const idx = candidates.findIndex((c) => c.type === hint)
1560
+ if (idx > 0) candidates.unshift(candidates.splice(idx, 1)[0])
1561
+ }
1562
+
1401
1563
  for (const cand of candidates) {
1402
1564
  const headers = { Accept: 'application/json', Authorization: `Bearer ${apiKey}` }
1403
1565
  const method = cand.type === 'quota' ? 'POST' : 'GET'
@@ -1409,6 +1571,11 @@ async function queryCustomRelay(relay, config) {
1409
1571
  try { json = JSON.parse(text) } catch { continue }
1410
1572
  const parsed = parseResponse(cand.type, json)
1411
1573
  if (parsed) {
1574
+ // A: 记住这次命中的端点(变了才落盘, 避免每次刷新都写状态文件)
1575
+ if (relayEndpointHints.get(id) !== cand.type) {
1576
+ relayEndpointHints.set(id, cand.type)
1577
+ try { savePersistedState({ relayEndpoints: Object.fromEntries(relayEndpointHints) }) } catch { /* 落盘失败不影响本次结果 */ }
1578
+ }
1412
1579
  return {
1413
1580
  platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站',
1414
1581
  status: 'ok', total: parsed.total, currency: parsed.currency, available: parsed.available,
@@ -1914,13 +2081,25 @@ export function makeCostProjection(configOrGetter, services) {
1914
2081
  export function apply(ctx, config) {
1915
2082
  // 用户保存的配置优先于 cordis.patch.yml 的默认 config (持久化状态)
1916
2083
  const persisted = loadPersistedState()
2084
+ // A: 载入上次记住的中转站端点
2085
+ try {
2086
+ relayEndpointHints.clear()
2087
+ const saved = persisted.relayEndpoints
2088
+ if (saved && typeof saved === 'object' && !Array.isArray(saved)) {
2089
+ for (const [k, v] of Object.entries(saved)) {
2090
+ if (typeof k === 'string' && k.length <= 128 && typeof v === 'string' && v.length <= 32) relayEndpointHints.set(k, v)
2091
+ }
2092
+ }
2093
+ } catch { /* 忽略 */ }
1917
2094
  const runtimeConfig = {
1918
2095
  refreshIntervalMs: persisted.refreshIntervalMs ?? config.refreshIntervalMs ?? 5000,
1919
2096
  clientPollIntervalMs: persisted.clientPollIntervalMs ?? config.clientPollIntervalMs ?? 5000,
1920
2097
  timeoutMs: persisted.timeoutMs ?? config.timeoutMs ?? 8000,
1921
2098
  presets: config.presets ?? PLATFORM_PRESETS.map(p => p.id),
1922
- customRelays: (persisted.customRelays ?? config.customRelays ?? []).map(r => ({ ...r })),
1923
- customModels: (persisted.customModels ?? config.customModels ?? []).map(m => ({ ...m })),
2099
+ // H-1 (v1.4.1): 这里必须带 Array.isArray —— 状态文件形状跑偏时 apply() 抛出 =
2100
+ // 整个 dsh web 启动失败(migratePersistedState 已消毒, 这里是第二道防线)
2101
+ customRelays: (Array.isArray(persisted.customRelays) ? persisted.customRelays : (Array.isArray(config.customRelays) ? config.customRelays : [])).map(r => ({ ...r })),
2102
+ customModels: (Array.isArray(persisted.customModels) ? persisted.customModels : (Array.isArray(config.customModels) ? config.customModels : [])).map(m => ({ ...m })),
1924
2103
  prices: config.prices ?? { 'deepseek-chat': { cacheHit: 0.1, cacheMiss: 1, output: 2 } },
1925
2104
  defaultPrices: config.defaultPrices ?? { cacheHit: 0.1, cacheMiss: 1, output: 2 },
1926
2105
  currency: persisted.currency ?? config.currency ?? 'CNY',
@@ -2129,9 +2308,51 @@ export function apply(ctx, config) {
2129
2308
  res.end(body)
2130
2309
  }
2131
2310
 
2311
+ /**
2312
+ * H-3 (v1.4.1): 插件路由的鉴权闸门。
2313
+ *
2314
+ * 为什么必须有这道闸: dsh 的 webserver 是「先查 exact 路由表, 再走 fallback」,
2315
+ * 而浏览器的登录 cookie 校验只写在 fallback 里(dsh-host-frontend-static) ——
2316
+ * 于是 `/api-dashboard/*` 全部绕过鉴权: 无需 token、无需 cookie 就能读配置、
2317
+ * 改配置、改挂件、甚至触发 `/update/install`(会重写插件目录)。
2318
+ * 更糟的是 `Content-Type: text/plain` 属于**浏览器不预检的 simple request**,
2319
+ * 用户手机上随便打开一个网页, 那个网页就能 POST 过来改配置(实测 HTTP 200 且真的改了)。
2320
+ *
2321
+ * 老版本 dsh 没有 connection 服务时放行(那时本来也没有鉴权概念), 避免把插件打死。
2322
+ */
2323
+ /**
2324
+ * H-3 (v1.4.1): 插件路由的鉴权闸门。
2325
+ *
2326
+ * 为什么必须有这道闸: dsh 的 webserver 是「先查 exact 路由表, 再走 fallback」,
2327
+ * 而浏览器的登录 cookie 校验只写在 fallback 里(dsh-host-frontend-static) ——
2328
+ * 于是 `/api-dashboard/*` 全部绕过鉴权: 无需 token、无需 cookie 就能读配置、
2329
+ * 改配置、改挂件、甚至触发 `/update/install`(会重写插件目录)。
2330
+ * 更糟的是 `Content-Type: text/plain` 属于**浏览器不预检的 simple request**,
2331
+ * 用户手机上随便打开一个网页, 那个网页就能 POST 过来改配置(实测 HTTP 200 且真的改了)。
2332
+ *
2333
+ * 用 `connection.requestRejection(req)`: 与 dsh-web-mobile 同一个闸门,
2334
+ * 同时覆盖「浏览器 cookie 鉴权」与「Host/来源可信」两项检查。
2335
+ * ⚠️ 不能写 `ctx.get('connection')` —— 实测在插件 fiber 上取不到(返回 undefined),
2336
+ * 必须用嵌套 inject 拿服务实例。
2337
+ */
2338
+ let connectionSvc = null
2339
+ ctx.inject(['connection'], (c) => { connectionSvc = c.connection })
2340
+ const allowRequest = (req, res) => {
2341
+ const conn = connectionSvc
2342
+ // 老版本 dsh 没有 connection 服务时放行(那时本来也没有鉴权概念), 避免把插件打死
2343
+ if (!conn || typeof conn.requestRejection !== 'function') return true
2344
+ let rejection
2345
+ try { rejection = conn.requestRejection(req) } catch { rejection = undefined }
2346
+ if (rejection === undefined) return true
2347
+ res.writeHead(rejection, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
2348
+ res.end(rejection === 401 ? 'dsh web authentication required; reopen the URL printed by dsh web.\n' : 'forbidden\n')
2349
+ return false
2350
+ }
2351
+
2132
2352
  webCtx.effect(() => webCtx.webServer.register({
2133
2353
  kind: 'exact', path: '/api-dashboard/balances',
2134
2354
  async handler(req, res) {
2355
+ if (!allowRequest(req, res)) return
2135
2356
  if (!['GET', 'HEAD', 'POST'].includes(req.method)) { res.writeHead(405, { Allow: 'GET, HEAD, POST' }); res.end(); return }
2136
2357
  const params = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams
2137
2358
  const force = req.method === 'POST' || params.get('force') === '1'
@@ -2160,7 +2381,7 @@ export function apply(ctx, config) {
2160
2381
  res.end()
2161
2382
  return
2162
2383
  }
2163
- const body = JSON.stringify({ ok: true, balances: cache.balances, fetchedAt: cache.fetchedAt, config: cache.config })
2384
+ const body = JSON.stringify({ ok: true, balances: cache.balances, fetchedAt: cache.fetchedAt, config: cache.config, loading: cache.balances.length === 0 })
2164
2385
  res.writeHead(200, {
2165
2386
  'Content-Type': 'application/json; charset=utf-8',
2166
2387
  'Cache-Control': 'private, no-cache',
@@ -2175,6 +2396,7 @@ export function apply(ctx, config) {
2175
2396
  webCtx.effect(() => webCtx.webServer.register({
2176
2397
  kind: 'exact', path: '/api-dashboard/prices',
2177
2398
  async handler(req, res) {
2399
+ if (!allowRequest(req, res)) return
2178
2400
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2179
2401
  const cfg = runtimeConfig
2180
2402
  const cur = (cfg.currency ?? 'CNY').toUpperCase() === 'USD' ? 'USD' : 'CNY'
@@ -2195,6 +2417,7 @@ export function apply(ctx, config) {
2195
2417
  webCtx.effect(() => webCtx.webServer.register({
2196
2418
  kind: 'exact', path: '/api-dashboard/platforms',
2197
2419
  async handler(req, res) {
2420
+ if (!allowRequest(req, res)) return
2198
2421
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2199
2422
  const presets = PLATFORM_PRESETS.filter(p => runtimeConfig.presets.includes(p.id)).map(p => ({
2200
2423
  id: p.id, name: p.label, icon: p.icon, color: p.color, category: p.category, queryType: p.queryType,
@@ -2207,6 +2430,7 @@ export function apply(ctx, config) {
2207
2430
  webCtx.effect(() => webCtx.webServer.register({
2208
2431
  kind: 'exact', path: '/api-dashboard/update',
2209
2432
  async handler(req, res) {
2433
+ if (!allowRequest(req, res)) return
2210
2434
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2211
2435
  const force = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('force') === '1'
2212
2436
  try {
@@ -2221,6 +2445,7 @@ export function apply(ctx, config) {
2221
2445
  webCtx.effect(() => webCtx.webServer.register({
2222
2446
  kind: 'exact', path: '/api-dashboard/update/install',
2223
2447
  async handler(req, res) {
2448
+ if (!allowRequest(req, res)) return
2224
2449
  if (req.method !== 'POST') { res.writeHead(405, { Allow: 'POST' }); res.end(); return }
2225
2450
  try {
2226
2451
  const result = await applyUpdate({})
@@ -2242,6 +2467,7 @@ export function apply(ctx, config) {
2242
2467
  webCtx.effect(() => webCtx.webServer.register({
2243
2468
  kind: 'exact', path: '/api-dashboard/icon',
2244
2469
  async handler(req, res) {
2470
+ if (!allowRequest(req, res)) return
2245
2471
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2246
2472
  try {
2247
2473
  if (!iconCache) {
@@ -2267,6 +2493,7 @@ export function apply(ctx, config) {
2267
2493
  webCtx.effect(() => webCtx.webServer.register({
2268
2494
  kind: 'exact', path: '/api-dashboard/whale/image.png',
2269
2495
  async handler(req, res) {
2496
+ if (!allowRequest(req, res)) return
2270
2497
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2271
2498
  try {
2272
2499
  if (!whaleImgCache) whaleImgCache = readFileSync(whaleAsset('DSniang1.png'))
@@ -2284,6 +2511,7 @@ export function apply(ctx, config) {
2284
2511
  webCtx.effect(() => webCtx.webServer.register({
2285
2512
  kind: 'exact', path: '/api-dashboard/whale/rua.gif',
2286
2513
  async handler(req, res) {
2514
+ if (!allowRequest(req, res)) return
2287
2515
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2288
2516
  try {
2289
2517
  if (!whaleGifCache) whaleGifCache = readFileSync(whaleAsset('rua.gif'))
@@ -2301,6 +2529,7 @@ export function apply(ctx, config) {
2301
2529
  webCtx.effect(() => webCtx.webServer.register({
2302
2530
  kind: 'exact', path: `/api-dashboard/whale/sound/${kind}.mp3`,
2303
2531
  async handler(req, res) {
2532
+ if (!allowRequest(req, res)) return
2304
2533
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2305
2534
  try {
2306
2535
  const set = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('set') === 'fx1' ? 'fx1' : 'duck'
@@ -2322,6 +2551,7 @@ export function apply(ctx, config) {
2322
2551
  webCtx.effect(() => webCtx.webServer.register({
2323
2552
  kind: 'exact', path: '/api-dashboard/whale/settings',
2324
2553
  async handler(req, res) {
2554
+ if (!allowRequest(req, res)) return
2325
2555
  if (req.method === 'GET') { sendJson(res, 200, { ok: true, settings: runtimeConfig.whaleSettings }); return }
2326
2556
  if (req.method === 'PUT' || req.method === 'POST') {
2327
2557
  try {
@@ -2372,6 +2602,7 @@ export function apply(ctx, config) {
2372
2602
  webCtx.effect(() => webCtx.webServer.register({
2373
2603
  kind: 'exact', path: '/api-dashboard/config',
2374
2604
  async handler(req, res) {
2605
+ if (!allowRequest(req, res)) return
2375
2606
  if (req.method === 'GET') {
2376
2607
  sendJson(res, 200, {
2377
2608
  ok: true,
@@ -2380,6 +2611,11 @@ export function apply(ctx, config) {
2380
2611
  presets: runtimeConfig.presets,
2381
2612
  refreshIntervalSec: Math.round(runtimeConfig.refreshIntervalMs / 1000),
2382
2613
  currency: runtimeConfig.currency,
2614
+ // C-2 (v1.4.1): 以前这里不返回阈值, 设置面板只能等 /balances 带过来;
2615
+ // 冷启动那几秒(/balances 可能要等 8~10s)打开面板 → 显示默认 50/10 →
2616
+ // 用户一点"保存并生效"就把自己存的阈值覆盖掉了。补上。
2617
+ safeThreshold: runtimeConfig.safeThreshold,
2618
+ warnThreshold: runtimeConfig.warnThreshold,
2383
2619
  overseasCurrency: runtimeConfig.overseasCurrency,
2384
2620
  whaleEnabled: !!runtimeConfig.whaleEnabled,
2385
2621
  showNoBalanceBrands: !!runtimeConfig.showNoBalanceBrands,