dsh-api-dashboard 1.0.2 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,7 +78,15 @@ OpenRouter、Novita AI、xAI Grok
78
78
 
79
79
  ## 安装
80
80
 
81
- ### 推荐方式:下载后一条命令安装
81
+ ### ✅ 推荐方式:npm 一行命令
82
+
83
+ ```sh
84
+ dsh plugin --profile web add dsh-api-dashboard
85
+ ```
86
+
87
+ > 从 npm 拉取最新版本,自动安装依赖、注册 bundle 层,无需手动下载或建软链。
88
+
89
+ ### 备选:下载源码后安装(无 npm registry 环境时)
82
90
 
83
91
  ```sh
84
92
  # 1. 下载源码(codeload 地址,github.com 主站不可达时也能用)
@@ -94,18 +102,20 @@ dsh plugin --profile web add file:/root/dsha-api-dashboard
94
102
  # 4. 重启 dsh web 生效
95
103
  ```
96
104
 
97
- > 第 3 步等价于 `pnpm add file:...`,会自动安装依赖并把插件加入
98
- > `dsh.profile.bundles` 列表,无需手动建软链或编辑 profile 的 package.json。
99
- >
100
- > 更新版本:重复执行步骤 1-2 覆盖目录,再执行一次第 3 步即可。
105
+ ### 升级到新版本
101
106
 
102
- ### ⚠️ 已知不可用的安装姿势(实测踩坑记录)
107
+ 1. **推荐**:插件设置面板 →「检查更新」→「一键自更新」(内置更新引擎,下载→校验→备份→原子替换→失败回滚,重启 web 生效);
108
+ 2. 或:`dsh plugin --profile web remove dsh-api-dashboard` 后重新执行上面的推荐/备选安装;
109
+ 3. 兜底:重复「下载源码」步骤 1-2 覆盖目录,再执行第 3 步。
110
+
111
+ ### ⚠️ 安装姿势对照(实测)
103
112
 
104
113
  | 方式 | 结果 |
105
114
  |------|------|
106
- | `dsh plugin add dsh-api-dashboard`(裸包名) | ✅ 推荐!已发布到 npm,一行命令安装 |
107
- | `pnpm add <远程 tarball URL>` | pnpm 不剥离 codeload 压缩包的顶层目录,装出来是空壳 |
108
- | 手动软链 node_modules | 可用但繁琐,层级写错会被启动校准摘除,不推荐 |
115
+ | `dsh plugin --profile web add dsh-api-dashboard`(npm 裸包名) | ✅ 推荐,一行命令 |
116
+ | `dsh plugin --profile web add file:/root/dsha-api-dashboard`(源码方式) | 备选 |
117
+ | `pnpm add <远程 tarball URL>` | pnpm 不剥离 codeload 压缩包顶层目录,装出来是空壳 |
118
+ | 手动软链 node_modules | ⚠️ 可用但繁琐,层级写错会被启动校准摘除,不推荐 |
109
119
 
110
120
  ## 配置
111
121
 
@@ -187,7 +197,15 @@ v0.7.0 — 玻璃 UI 改造(方案 A 第一阶段):半屏抽屉玻璃拟
187
197
  v0.7.1 — 修复负余额显示正常(负数→红);智谱解析保留百分比(不把限流窗口当余额);设置面板新增「返回」按钮与 IconBack;新增 AGENTS.md(给维护 AI 的必读须知)+ README 顶部 AI 警告块
188
198
  v1.0.0 — 正式版:插件改名「哦鲸鲸」+ 自定义图标(/api-dashboard/icon);全量 UI 改版(固定色值去主题变量、三段式头部、卡片右栏上下结构、选中模型置顶、分组折叠、详情/设置/版本更新区重排);若干样式补全与清理
189
199
  v1.0.2 — 余额解析稳健性(平台字段存在性校验,缺字段时如实显示「无法解析/未开放」而非误报 0);智谱配额解析重写(按 `limits.unit` 区分 5 小时窗口、周配额、工具维度,`percentage` 修正为「已用/填充度」,不再把「已用完」误显示为「余额 100%」);价格表更新(DeepSeek V4 新增 `deepseek-v4-flash-vision-exp`、`deepseek-chat/reasoner/r1` 标记为已退役;通用价格表新增现役主力 OpenAI GPT-5.6 / Claude 4.x / Gemini 3.x / Kimi K3 / StepFun 3.x,旧模型标为历史参考);自更新下载包增加包名校验
200
+ v1.0.3 — 文档更新:安装与升级指引改为以 npm 一行命令为主、源码方式为备选,并补充升级路径;无功能变更
201
+ v1.1.0 — 🐋 新增「收养大肥鱼」互动挂件:设置 → 基础设置 → 开启后屏幕边缘探出一只大肥鱼(露出约一半),点击弹全身 + 随机台词泡泡(含峰谷时段文案、gif、卖萌吐槽)+ 按压 Q 弹与音效;**可全屏拖拽**,松手自动吸附最近边缘(挂件菜单可关掉「自动靠边」改为自由摆放),左侧吸附时整体镜像翻转;挂件自带汉堡菜单(大小 0.6~2.5 倍 / 探出比例 / 音效开关 / 音效组 / 音量 / 气泡开关 / 峰谷文案 / 自动靠边),位置与全部设置持久化;开关即拨即存(不必点保存);纯互动不显示任何余额与消耗(那部分照旧在输入框下方);互动部分移植自 [MeteorNOX/DeepSeek-Balance-Whale-Widget](https://github.com/MeteorNOX/DeepSeek-Balance-Whale-Widget)(MIT License, Copyright (c) 2026 MeteorNOX),许可副本见 `assets/whale/LICENSE-whale-widget.txt`
202
+ v1.1.2 — 大肥鱼交互打磨:设置面板新增独立「大肥鱼」页签,总开关与全部细项(大小 / 探出 / 自动靠边 / 气泡 / 峰谷文案 / 音效 / 音效组 / 音量)集中到这一页,全部即改即存无需点保存,并去掉挂件本体右上角的浮层菜单;开关行图标改用鲸鱼本体缩略图;收回改为**停手 3 秒**自动缩回(连续点击持续续命,不再"点第二下立刻收回");台词气泡改为运行时避让,贴边时不再有一半在屏幕外,上方空间不足自动翻到下方;吸附收窄为**只有左右两侧边缘带**触发,停在屏幕中间保持整只显示;贴边时**纵向滑动不再展开**,沿边上下挪位,横向拉开才脱轨;连点节流 420ms,快速点击气泡稳定不闪
190
203
 
191
204
  ## License
192
205
 
193
- [MIT](./LICENSE)
206
+ [MIT](./LICENSE)
207
+
208
+ 本项目「收养大肥鱼」挂件的互动部分(拖拽吸附、Q 弹按压、台词气泡、音效、菜单)移植自
209
+ [MeteorNOX/DeepSeek-Balance-Whale-Widget](https://github.com/MeteorNOX/DeepSeek-Balance-Whale-Widget),
210
+ 原作者 Copyright (c) 2026 MeteorNOX,MIT License,许可全文见 `assets/whale/LICENSE-whale-widget.txt`。
211
+ 鲸鱼形象与音效素材同样来自该项目。
Binary file
Binary file
Binary file
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MeteorNOX
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.
Binary file
Binary file
Binary file
package/client/client.js CHANGED
@@ -239,8 +239,9 @@ window.__ModuleLoader__.load({
239
239
  .dshadb_switch_on .dshadb_switch_knob{transform:translateX(17px)}
240
240
 
241
241
  /* Tab 导航 */
242
- .dshadb_tabs{display:flex;gap:7px;padding:4px 0 12px;overflow:auto}
243
- .dshadb_tab{border:0;background:#e9eaf0;border-radius:12px;padding:8px 13px;font-size:11px;color:#686c76;white-space:nowrap;cursor:pointer;font-family:inherit;transition:all .12s ease}
242
+ .dshadb_tabs{display:flex;gap:7px;padding:4px 0 12px;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none}
243
+ .dshadb_tabs::-webkit-scrollbar{display:none}
244
+ .dshadb_tab{flex:none;border:0;background:#e9eaf0;border-radius:12px;padding:8px 13px;font-size:11px;color:#686c76;white-space:nowrap;cursor:pointer;font-family:inherit;transition:all .12s ease}
244
245
  .dshadb_tab_active{background:#4f7cff;color:#fff}
245
246
 
246
247
  /* 输入框 */
@@ -296,7 +297,60 @@ window.__ModuleLoader__.load({
296
297
  .dshadb_icon_btn{display:inline-flex;align-items:center;justify-content:center;min-width:38px;min-height:38px;padding:8px;border-radius:10px;background:transparent;border:none;color:#777b84;cursor:pointer;transition:background .1s ease}
297
298
  .dshadb_icon_btn:active{background:#f0f2f8;opacity:0.85}
298
299
  .dshadb_refresh_badge{font-size:9.5px;font-weight:600;color:#9ca0aa;background:#f0f2f8;padding:1px 7px;border-radius:999px;line-height:15px;white-space:nowrap}
299
- .dshadb_title_ic{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:7px;overflow:hidden;flex:none}`
300
+ .dshadb_title_ic{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:7px;overflow:hidden;flex:none}
301
+ /* ===== 设置面板 · 开关行 (v1.1.0) ===== */
302
+ .dshadb_settings_row{display:flex;align-items:center;gap:12px;padding:12px 14px;margin:14px 0 4px;border-radius:16px;background:#ffffff;border:1px solid #e7e8ec;box-shadow:0 6px 20px rgba(0,0,0,0.03)}
303
+ .dshadb_settings_row_main{flex:1;min-width:0}
304
+ .dshadb_settings_row_title{display:block;font-size:13px;font-weight:750;color:#17181c;line-height:1.3}
305
+ .dshadb_settings_row_sub{display:block;font-size:10.5px;font-weight:500;color:#9ca0aa;line-height:1.45;margin-top:3px}
306
+ /* ===== 设置面板 · 大肥鱼页 (v1.1.0) ===== */
307
+ .dshadb_wf_row{display:flex;align-items:center;gap:10px;padding:11px 14px;margin-bottom:8px;border-radius:14px;background:#ffffff;border:1px solid #e7e8ec}
308
+ .dshadb_wf_row_col{display:block;padding:11px 14px;margin-bottom:8px;border-radius:14px;background:#ffffff;border:1px solid #e7e8ec}
309
+ .dshadb_wf_name{font-size:12.5px;font-weight:700;color:#17181c;flex:none}
310
+ .dshadb_wf_sub{display:block;font-size:10px;font-weight:500;color:#9ca0aa;line-height:1.4;margin-top:2px}
311
+ .dshadb_wf_head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}
312
+ .dshadb_wf_val{font-size:11px;font-weight:800;color:#4f7cff;font-variant-numeric:tabular-nums;flex:none}
313
+ .dshadb_wf_range{width:100%;height:24px;accent-color:#4f7cff;margin:0}
314
+ .dshadb_wf_seg{display:flex;gap:6px;margin-left:auto;flex:none}
315
+ .dshadb_wf_segbtn{font-size:11px;font-weight:700;font-family:inherit;padding:6px 11px;border-radius:10px;border:1px solid #e7e8ec;background:#f5f6f8;color:#9ca0aa;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:background .15s ease,color .15s ease}
316
+ .dshadb_wf_segbtn_on{background:#4f7cff;border-color:#4f7cff;color:#ffffff}
317
+ .dshadb_wf_disabled{opacity:.45;pointer-events:none}
318
+ .dshadb_wf_tip{font-size:10.5px;color:#9ca0aa;line-height:1.5;padding:2px 4px 0}
319
+ .dshadb_wf_avatar{flex:none;width:34px;height:34px;object-fit:contain;object-position:center;display:block;-webkit-user-drag:none;filter:drop-shadow(0 2px 4px rgba(0,0,0,.12))}
320
+ @media (prefers-color-scheme:dark){
321
+ .dshadb_wf_row,.dshadb_wf_row_col{background:#262a33;border-color:#363c48}
322
+ .dshadb_wf_name{color:#f2f4f8}
323
+ .dshadb_wf_segbtn{background:#1e222a;border-color:#363c48;color:#8b91a0}
324
+ .dshadb_wf_segbtn_on{background:#4f7cff;border-color:#4f7cff;color:#fff}}
325
+ /* ===== 大肥鱼互动挂件 (v1.1.0) ===== */
326
+ .dshadb-whale{position:fixed;left:0;top:60vh;z-index:9600;--dshw-scale:1;--dshw-size:clamp(96px,calc(min(150px,min(100vw,100vh) * 0.30) * var(--dshw-scale)),420px);width:var(--dshw-size);height:var(--dshw-size);pointer-events:none;-webkit-user-select:none;user-select:none;touch-action:none;transition:transform .45s cubic-bezier(.34,1.4,.64,1),left .3s ease,top .3s ease}
327
+ .dshadb-whale.dshadb-whale-dragging{transition:none}
328
+ .dshadb-whale.dshadb-whale-flip .dshadb-whale-sprite{transform:scaleX(-1)}
329
+ .dshadb-whale-sprite{position:absolute;left:0;top:0;width:100%;height:100%;transition:transform .3s ease}
330
+ .dshadb-whale-body{position:absolute;left:0;top:0;width:100%;height:100%;transform-origin:50% 92%;transition:transform .2s cubic-bezier(.34,1.56,.64,1);pointer-events:auto;cursor:grab;-webkit-tap-highlight-color:transparent}
331
+ .dshadb-whale-press .dshadb-whale-body{transform:scale(1.1,.88);cursor:grabbing}
332
+ .dshadb-whale-img{width:100%;height:100%;object-fit:contain;display:block;pointer-events:none;-webkit-user-drag:none;filter:drop-shadow(0 6px 14px rgba(0,0,0,.18))}
333
+ .dshadb-whale-bubble{position:absolute;left:50%;bottom:calc(100% - 6px);--dshw-bdx:0px;--dshw-btx:50%;transform:translateX(calc(-50% + var(--dshw-bdx))) translateY(10px) scale(.82);transform-origin:var(--dshw-btx) 100%;opacity:0;pointer-events:none;background:#fff;border-radius:16px;padding:10px 14px;min-width:112px;max-width:min(74vw,248px);box-shadow:0 8px 28px rgba(0,0,0,.16);transition:opacity .2s ease,transform .26s cubic-bezier(.34,1.5,.64,1);z-index:2;text-align:center;box-sizing:border-box}
334
+ .dshadb-whale-bubble::after{content:"";position:absolute;left:var(--dshw-btx);top:100%;transform:translateX(-50%);border:8px solid transparent;border-top-color:#fff}
335
+ .dshadb-whale-bubble-on{opacity:1;transform:translateX(calc(-50% + var(--dshw-bdx))) translateY(0) scale(1)}
336
+ /* 连点时气泡不做淡出淡入的来回, 只跟位置走 —— 避免闪烁 */
337
+ .dshadb-whale-bubble-on.dshadb-whale-bubble-keep{transition:transform .16s ease}
338
+ /* 气泡在上方放不下时改到下方, 尾巴翻到顶边 */
339
+ .dshadb-whale-bubble-below{bottom:auto;top:calc(100% - 6px);transform-origin:var(--dshw-btx) 0}
340
+ .dshadb-whale-bubble-below::after{top:auto;bottom:100%;border-top-color:transparent;border-bottom-color:#fff}
341
+ /* 镜像只作用于鲸鱼本体, 气泡与文字保持正向 */
342
+ .dshadb-whale-flip .dshadb-whale-bubble{transform:translateX(calc(-50% + var(--dshw-bdx))) translateY(10px) scale(.82)}
343
+ .dshadb-whale-flip .dshadb-whale-bubble-on{transform:translateX(calc(-50% + var(--dshw-bdx))) translateY(0) scale(1)}
344
+ .dshadb-whale-btext{display:block}
345
+ .dshadb-whale-gif{display:none;width:100%;max-width:190px;border-radius:10px}
346
+ .dshadb-whale-l-a{font-size:12.5px;font-weight:600;color:#3b4356;letter-spacing:.02em;line-height:1.4;white-space:nowrap}
347
+ .dshadb-whale-l-b{font-size:21px;font-weight:800;color:#17181c;line-height:1.15;white-space:nowrap}
348
+ .dshadb-whale-l-p{font-size:18px;font-weight:800;line-height:1.2;white-space:nowrap}
349
+ .dshadb-whale-l-c{font-size:11px;font-weight:600;color:#9ca0aa;line-height:1.4;white-space:nowrap}
350
+ .dshadb-whale-wrap{white-space:normal;word-break:break-word}
351
+ @media (prefers-color-scheme:dark){
352
+ .dshadb-whale-bubble{background:#262a33}.dshadb-whale-bubble::after{border-top-color:#262a33}
353
+ .dshadb-whale-l-a{color:#d3d7e0}.dshadb-whale-l-b{color:#f2f4f8}.dshadb-whale-l-c{color:#9298a6}}`
300
354
 
301
355
  document.head.appendChild(tag);
302
356
  /* 渐进增强: 高端机 (逻辑核 >= 6) 才启用毛玻璃等特效 */
@@ -331,6 +385,17 @@ window.__ModuleLoader__.load({
331
385
  "update.done": "已更新, 重启 Web GUI 生效", "update.fail": "更新失败, 已保持原版本",
332
386
  "update.checkfail": "检查失败 (网络异常)",
333
387
  "settings.section.basic": "基础设置", "settings.section.relays": "中转站", "settings.section.models": "自定义模型",
388
+ "settings.section.whale": "大肥鱼",
389
+ "whale.enable": "收养大肥鱼", "whale.enableHint": "在屏幕边缘养一只会探头的大肥鱼(纯互动,不显示余额)",
390
+ "whale.scale": "身体大小", "whale.peek": "探出多少",
391
+ "whale.sound": "按压音效", "whale.soundSet": "音效组", "whale.volume": "音量",
392
+ "whale.bubble": "台词气泡", "whale.peakMode": "峰谷文案", "whale.snap": "自动靠边",
393
+ "whale.snapHint": "松手后吸附到最近的屏幕边缘;关掉就停在你放的位置",
394
+ "whale.on": "开", "whale.off": "关",
395
+ "whale.duck": "小黄鸭", "whale.fx1": "音效1",
396
+ "whale.peak.default": "默认", "whale.peak.liangwen": "梁文峰谷", "whale.peak.qiangqiang": "!?强强?!",
397
+ "whale.tip": "拖着大肥鱼可以在屏幕上随意移动;点一下弹全身和台词,再点一下收回。",
398
+ "whale.offTip": "先打开上面的「收养大肥鱼」,才能调下面这些。",
334
399
  "settings.modelName": "模型名称", "settings.apiUrl": "余额接口 URL", "settings.queryType": "解析方式",
335
400
  "settings.totalPath": "总余额字段(可选)", "settings.usedPath": "已用字段(可选)",
336
401
  "settings.modelHint": "填你的余额接口(返回 JSON),可选填 API Key 与字段路径,如 data.balance",
@@ -367,6 +432,17 @@ window.__ModuleLoader__.load({
367
432
  "update.done": "Updated. Restart Web GUI to apply", "update.fail": "Update failed, version unchanged",
368
433
  "update.checkfail": "Check failed (network)",
369
434
  "settings.section.basic": "Basic", "settings.section.relays": "Relays", "settings.section.models": "Custom Models",
435
+ "settings.section.whale": "Whale",
436
+ "whale.enable": "Adopt Big Whale", "whale.enableHint": "Keep a peeking whale on the screen edge (interactive only)",
437
+ "whale.scale": "Body size", "whale.peek": "Peek amount",
438
+ "whale.sound": "Press sound", "whale.soundSet": "Sound set", "whale.volume": "Volume",
439
+ "whale.bubble": "Speech bubble", "whale.peakMode": "Peak wording", "whale.snap": "Snap to edge",
440
+ "whale.snapHint": "Snaps to the nearest edge on release; turn off to keep it where you drop it",
441
+ "whale.on": "On", "whale.off": "Off",
442
+ "whale.duck": "Duck", "whale.fx1": "FX 1",
443
+ "whale.peak.default": "Default", "whale.peak.liangwen": "Liangwen", "whale.peak.qiangqiang": "!?Qiang?!",
444
+ "whale.tip": "Drag the whale anywhere on screen; tap once for full body and a line, tap again to tuck it back.",
445
+ "whale.offTip": "Turn on \"Adopt Big Whale\" above to edit these.",
370
446
  "settings.modelName": "Model name", "settings.apiUrl": "Balance API URL", "settings.queryType": "Parse type",
371
447
  "settings.totalPath": "Total field (opt)", "settings.usedPath": "Used field (opt)",
372
448
  "settings.modelHint": "A JSON endpoint for your balance; optional API key & field paths like data.balance",
@@ -407,7 +483,9 @@ window.__ModuleLoader__.load({
407
483
  const changed = JSON.stringify(newBalances) !== JSON.stringify(oldBalances) || snapshot.status !== "ok";
408
484
  const nextFetchedAt = data.fetchedAt || Date.now();
409
485
  if (!changed) {
410
- snapshot = { ...snapshot, fetchedAt: nextFetchedAt, isRefreshing: false };
486
+ // v1.1.0: config 必须始终跟随最新响应 —— 旧写法在余额未变时保留旧 config,
487
+ // 导致设置里改完开关(如 whaleEnabled)后前端永远读不到新值, 表现为"开了又变回关"
488
+ snapshot = { ...snapshot, config: data.config || snapshot.config, fetchedAt: nextFetchedAt, isRefreshing: false };
411
489
  } else {
412
490
  snapshot = { status: "ok", balances: newBalances, config: data.config || snapshot.config, fetchedAt: nextFetchedAt, isRefreshing: false };
413
491
  }
@@ -929,6 +1007,12 @@ window.__ModuleLoader__.load({
929
1007
  const [warn, setWarn] = react.useState(10);
930
1008
  const [currency, setCurrency] = react.useState("CNY");
931
1009
  const [refreshSec, setRefreshSec] = react.useState(5);
1010
+ const [whaleOn, setWhaleOn] = react.useState(!!(config && config.whaleEnabled));
1011
+ // v1.1.0: 大肥鱼细项 (独立页签, 走 /api-dashboard/whale/settings, 与看板主配置分开)
1012
+ const [wf, setWf] = react.useState({
1013
+ scale: 1, peekRatio: 0.5, soundOn: true, soundSet: "duck",
1014
+ volume: 0.5, bubbleOn: true, peakMode: "default", snapOn: true,
1015
+ });
932
1016
  const [form, setForm] = react.useState({ name: "", baseUrl: "", apiKey: "", queryType: "auto" });
933
1017
  const [mform, setMform] = react.useState({ name: "", apiUrl: "", apiKey: "", queryType: "auto", totalPath: "", usedPath: "", currency: "" });
934
1018
  const [loading, setLoading] = react.useState(false);
@@ -982,6 +1066,7 @@ window.__ModuleLoader__.load({
982
1066
  setWarn(config?.warnThreshold ?? 10);
983
1067
  setCurrency(config?.currency ?? "CNY");
984
1068
  setRefreshSec(Math.round((config?.clientPollIntervalMs || 5000) / 1000) || 5);
1069
+ setWhaleOn(!!(config && config.whaleEnabled));
985
1070
  let cancelled = false;
986
1071
  fetch("/api-dashboard/config", { cache: "no-store" }).then(r => r.json()).then(d => {
987
1072
  if (cancelled) return;
@@ -989,8 +1074,24 @@ window.__ModuleLoader__.load({
989
1074
  setRelays(d.customRelays || []);
990
1075
  setModels(d.customModels || []);
991
1076
  if (d.refreshIntervalSec) setRefreshSec(d.refreshIntervalSec);
1077
+ if (typeof d.whaleEnabled === "boolean") setWhaleOn(d.whaleEnabled);
992
1078
  }
993
1079
  }).finally(() => { if (!cancelled) setLoading(false); });
1080
+ // 大肥鱼细项与主配置并行拉取, 失败保持默认值
1081
+ fetch("/api-dashboard/whale/settings", { cache: "no-store" }).then(r => r.json()).then(d => {
1082
+ if (cancelled || !d || !d.ok || !d.settings) return;
1083
+ const s = d.settings;
1084
+ setWf(prev => ({
1085
+ scale: typeof s.scale === "number" ? s.scale : prev.scale,
1086
+ peekRatio: typeof s.peekRatio === "number" ? s.peekRatio : prev.peekRatio,
1087
+ soundOn: typeof s.soundOn === "boolean" ? s.soundOn : prev.soundOn,
1088
+ soundSet: s.soundSet || prev.soundSet,
1089
+ volume: typeof s.volume === "number" ? s.volume : prev.volume,
1090
+ bubbleOn: typeof s.bubbleOn === "boolean" ? s.bubbleOn : prev.bubbleOn,
1091
+ peakMode: s.peakMode || prev.peakMode,
1092
+ snapOn: typeof s.snapOn === "boolean" ? s.snapOn : prev.snapOn,
1093
+ }));
1094
+ }).catch(() => {});
994
1095
  return () => { cancelled = true; };
995
1096
  }, [isOpen]);
996
1097
  if (!isOpen) return null;
@@ -1001,7 +1102,12 @@ window.__ModuleLoader__.load({
1001
1102
  customRelays: relays, customModels: models,
1002
1103
  safeThreshold: Number(safe), warnThreshold: Number(warn), currency,
1003
1104
  refreshIntervalSec: Math.min(Math.max(Number(refreshSec) || 5, 5), 60),
1105
+ whaleEnabled: !!whaleOn,
1004
1106
  }) });
1107
+ // v1.1.0: 保存后立刻强拉一次, 让开关类设置(大肥鱼)即时生效, 不必等下一轮轮询
1108
+ try { await store.forceRefresh(); } catch (e) { /* 忽略 */ }
1109
+ // 双保险: 快照万一没及时更新, 也按用户刚保存的值直接挂载/卸载 (两处均幂等)
1110
+ if (whaleOn) ensureWhaleWidget(); else removeWhaleWidget();
1005
1111
  onClose();
1006
1112
  } finally { setSaving(false); }
1007
1113
  };
@@ -1017,13 +1123,39 @@ window.__ModuleLoader__.load({
1017
1123
  setMform({ name: "", apiUrl: "", apiKey: "", queryType: "auto", totalPath: "", usedPath: "", currency: "" });
1018
1124
  };
1019
1125
  const removeModel = (id) => setModels(models.filter(m => m.id !== id));
1126
+ // v1.1.0 修复: 开关类设置即时保存 —— 不能要求用户必须点「保存并生效」,
1127
+ // 否则拨完开关直接返回/关闭就丢了, 表现为"开了又变回关"
1128
+ const toggleWhale = async (next) => {
1129
+ setWhaleOn(next);
1130
+ if (next) ensureWhaleWidget(); else removeWhaleWidget();
1131
+ try {
1132
+ await fetch("/api-dashboard/config", {
1133
+ method: "POST", headers: { "Content-Type": "application/json" },
1134
+ body: JSON.stringify({ whaleEnabled: !!next }),
1135
+ });
1136
+ await store.forceRefresh();
1137
+ } catch (e) { /* 网络失败: 本地仍生效, 下次保存/刷新会对齐 */ }
1138
+ };
1020
1139
  const qtLabel = (q) => t("qt." + (q || "auto"));
1021
1140
 
1141
+ // v1.1.0: 大肥鱼细项即时生效 + 即时落盘 (滑块/开关都不依赖「保存并生效」按钮)
1142
+ const patchWf = (patch) => {
1143
+ setWf(prev => ({ ...prev, ...patch }));
1144
+ patchWhaleWidget(patch);
1145
+ try {
1146
+ fetch("/api-dashboard/whale/settings", {
1147
+ method: "PUT", headers: { "Content-Type": "application/json" },
1148
+ body: JSON.stringify(patch),
1149
+ }).catch(() => {});
1150
+ } catch (e) { /* 忽略 */ }
1151
+ };
1152
+
1022
1153
  const tab = (id, label) => react.createElement("button", { type: "button", className: "dshadb_tab" + (section === id ? " dshadb_tab_active" : ""), onClick: () => setSection(id), key: id }, label);
1023
1154
  const tabsNode = react.createElement("div", { className: "dshadb_tabs", key: "tabs" }, [
1024
1155
  tab("basic", t("settings.section.basic")),
1025
1156
  tab("relays", t("settings.section.relays")),
1026
1157
  tab("models", t("settings.section.models")),
1158
+ tab("whale", t("settings.section.whale")),
1027
1159
  ]);
1028
1160
 
1029
1161
  // 基础设置: 阈值 / 币种 / 刷新间隔
@@ -1144,6 +1276,71 @@ window.__ModuleLoader__.load({
1144
1276
  ]) : null,
1145
1277
  ]);
1146
1278
 
1279
+ // v1.1.0: 大肥鱼页 —— 总开关 + 全部细项, 全部即改即存, 不依赖底部保存按钮
1280
+ const wfRow = (key, label, control, sub) => react.createElement("div", { className: "dshadb_wf_row", key }, [
1281
+ react.createElement("div", { key: "n", style: { minWidth: 0 } }, [
1282
+ react.createElement("span", { className: "dshadb_wf_name", key: "t" }, label),
1283
+ sub ? react.createElement("span", { className: "dshadb_wf_sub", key: "s" }, sub) : null,
1284
+ ]),
1285
+ control,
1286
+ ]);
1287
+ const wfSlider = (key, label, valText, min, max, step, value, onCommit) =>
1288
+ react.createElement("div", { className: "dshadb_wf_row_col", key }, [
1289
+ react.createElement("div", { className: "dshadb_wf_head", key: "h" }, [
1290
+ react.createElement("span", { className: "dshadb_wf_name", key: "n" }, label),
1291
+ react.createElement("span", { className: "dshadb_wf_val", key: "v" }, valText),
1292
+ ]),
1293
+ react.createElement("input", {
1294
+ className: "dshadb_wf_range", type: "range", min, max, step, value, key: "r",
1295
+ onChange: (e) => onCommit(Number(e.target.value)),
1296
+ }),
1297
+ ]);
1298
+ const wfSeg = (key, label, options, value, onPick) =>
1299
+ wfRow(key, label, react.createElement("div", { className: "dshadb_wf_seg", key: "seg" },
1300
+ options.map(([v, lb]) => react.createElement("button", {
1301
+ type: "button", key: String(v),
1302
+ className: "dshadb_wf_segbtn" + (value === v ? " dshadb_wf_segbtn_on" : ""),
1303
+ onClick: () => onPick(v),
1304
+ }, lb))));
1305
+
1306
+ const whaleSection = react.createElement("div", { className: "dshadb_settings_section", key: "s_whale" }, [
1307
+ // 总开关 (从基础设置迁移过来); 图标用鲸鱼本体缩略图, 不用 emoji
1308
+ react.createElement("div", { className: "dshadb_settings_row", key: "en", style: { margin: "2px 0 10px" } }, [
1309
+ react.createElement("img", {
1310
+ className: "dshadb_wf_avatar", src: "/api-dashboard/whale/image.png",
1311
+ alt: "", draggable: false, key: "av",
1312
+ }),
1313
+ react.createElement("div", { className: "dshadb_settings_row_main", key: "m" }, [
1314
+ react.createElement("span", { className: "dshadb_settings_row_title", key: "t" }, t("whale.enable")),
1315
+ react.createElement("span", { className: "dshadb_settings_row_sub", key: "s" }, t("whale.enableHint")),
1316
+ ]),
1317
+ react.createElement(Switch, { checked: whaleOn, onChange: toggleWhale, key: "sw" }),
1318
+ ]),
1319
+ // 细项: 未开启时置灰不可点
1320
+ react.createElement("div", { className: whaleOn ? "" : "dshadb_wf_disabled", key: "detail" }, [
1321
+ !whaleOn ? react.createElement("div", { className: "dshadb_wf_tip", key: "offtip", style: { marginBottom: "8px" } }, t("whale.offTip")) : null,
1322
+ wfSlider("scale", t("whale.scale"), Math.round(wf.scale * 100) + "%", 0.6, 2.5, 0.05, wf.scale,
1323
+ (v) => patchWf({ scale: v })),
1324
+ wfSlider("peek", t("whale.peek"), Math.round(wf.peekRatio * 100) + "%", 0.15, 0.9, 0.05, wf.peekRatio,
1325
+ (v) => patchWf({ peekRatio: v })),
1326
+ wfSeg("snap", t("whale.snap"), [[true, t("whale.on")], [false, t("whale.off")]], wf.snapOn,
1327
+ (v) => patchWf({ snapOn: v })),
1328
+ react.createElement("div", { className: "dshadb_wf_tip", key: "snaphint", style: { margin: "-2px 0 8px" } }, t("whale.snapHint")),
1329
+ wfSeg("bubble", t("whale.bubble"), [[true, t("whale.on")], [false, t("whale.off")]], wf.bubbleOn,
1330
+ (v) => patchWf({ bubbleOn: v })),
1331
+ wfSeg("peak", t("whale.peakMode"), [
1332
+ ["default", t("whale.peak.default")], ["liangwen", t("whale.peak.liangwen")], ["qiangqiang", t("whale.peak.qiangqiang")],
1333
+ ], wf.peakMode, (v) => patchWf({ peakMode: v })),
1334
+ wfSeg("sound", t("whale.sound"), [[true, t("whale.on")], [false, t("whale.off")]], wf.soundOn,
1335
+ (v) => patchWf({ soundOn: v })),
1336
+ wfSeg("soundset", t("whale.soundSet"), [["duck", t("whale.duck")], ["fx1", t("whale.fx1")]], wf.soundSet,
1337
+ (v) => patchWf({ soundSet: v })),
1338
+ wfSlider("vol", t("whale.volume"), Math.round(wf.volume * 100) + "%", 0, 1, 0.05, wf.volume,
1339
+ (v) => patchWf({ volume: v })),
1340
+ react.createElement("div", { className: "dshadb_wf_tip", key: "tip" }, t("whale.tip")),
1341
+ ]),
1342
+ ]);
1343
+
1147
1344
  const output = react.createElement("div", { className: "dshadb_drawer", style: { maxHeight: "86vh" }, onClick: (e) => e.stopPropagation(), key: "drawer" }, [
1148
1345
  react.createElement("div", { className: "dshadb_handle", key: "handle" }, react.createElement("div", { className: "dshadb_handle_bar" })),
1149
1346
  react.createElement("div", { className: "dshadb_header", key: "header" }, [
@@ -1157,8 +1354,9 @@ window.__ModuleLoader__.load({
1157
1354
  ]),
1158
1355
  react.createElement("div", { className: "dshadb_body", key: "body" }, [
1159
1356
  tabsNode,
1160
- section === "basic" ? basicSection : section === "relays" ? relaySection : modelSection,
1161
- react.createElement("button", { type: "button", className: "dshadb_add_btn", onClick: save, disabled: saving, key: "save", style: { width: "100%", borderStyle: "solid", background: "#17181c", color: "#fff", borderColor: "#17181c" } }, saving ? t("refreshing") : t("settings.save")),
1357
+ section === "basic" ? basicSection : section === "relays" ? relaySection : section === "whale" ? whaleSection : modelSection,
1358
+ // 大肥鱼页全部即改即存, 不需要保存按钮
1359
+ section === "whale" ? null : react.createElement("button", { type: "button", className: "dshadb_add_btn", onClick: save, disabled: saving, key: "save", style: { width: "100%", borderStyle: "solid", background: "#17181c", color: "#fff", borderColor: "#17181c" } }, saving ? t("refreshing") : t("settings.save")),
1162
1360
  // v0.6.0: 版本与更新区块
1163
1361
  react.createElement("div", { key: "upd", style: { marginTop: "12px", paddingTop: "10px", borderTop: "1px solid #e7e8ec" } }, [
1164
1362
  react.createElement("div", { style: { fontSize: "11px", fontWeight: "700", color: "#777b84", marginBottom: "8px" }, key: "upd_t" }, t("update.title")),
@@ -1182,6 +1380,472 @@ window.__ModuleLoader__.load({
1182
1380
  }
1183
1381
  //#endregion
1184
1382
 
1383
+ //#region 大肥鱼互动挂件 (v1.1.0)
1384
+ // 移植自 MeteorNOX/DeepSeek-Balance-Whale-Widget (MIT License, Copyright (c) 2026 MeteorNOX)
1385
+ // 保留原版全部互动能力: 拖拽 / 四边吸附 / 左吸附镜像 / 按压Q弹 / 音效 / 随机台词 / 汉堡菜单;
1386
+ // 去掉的只有余额与消耗显示 (那部分由输入框下方的看板负责)。
1387
+ function Switch({ checked, onChange }) {
1388
+ return react.createElement("button", {
1389
+ type: "button", role: "switch", "aria-checked": !!checked,
1390
+ className: "dshadb_switch" + (checked ? " dshadb_switch_on" : ""),
1391
+ onClick: () => onChange(!checked), style: { marginLeft: "auto" },
1392
+ }, react.createElement("span", { className: "dshadb_switch_knob" }));
1393
+ }
1394
+
1395
+ var WHALE_DEFAULTS = {
1396
+ scale: 1, soundOn: true, soundSet: "duck", volume: 0.5, bubbleOn: true,
1397
+ peakMode: "default", snapOn: true, peekRatio: 0.5, left: null, top: null, side: "right",
1398
+ };
1399
+ var whaleCtxInfo = { isPeak: false };
1400
+ function updateWhaleContext(info) {
1401
+ whaleCtxInfo = { isPeak: !!(info && info.isPeak) };
1402
+ if (whaleWidget && whaleWidget.onContext) whaleWidget.onContext();
1403
+ }
1404
+ function whalePeakWords(peakMode, isPeak) {
1405
+ if (peakMode === "liangwen") return isPeak ? "梁文峰" : "梁文谷";
1406
+ if (peakMode === "qiangqiang") return isPeak ? "!?峰峰?!" : "!?谷谷?!";
1407
+ return isPeak ? "高峰时段" : "空闲时段";
1408
+ }
1409
+ function whalePickOne(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
1410
+ // 台词组: [权重, 生成函数] —— 生成 {gif:true} 或 [{t,s,c}] 三行 (s: A小字 / B大字 / P峰谷 / C灰注)
1411
+ var WHALE_GROUPS = [
1412
+ [40, function (st) {
1413
+ var isPeak = !!whaleCtxInfo.isPeak;
1414
+ return [
1415
+ { t: "当前时间段为:", s: "A" },
1416
+ { t: whalePeakWords(st.peakMode, isPeak), s: "P", c: isPeak ? "#e0433f" : "#2fa24c" },
1417
+ { t: isPeak ? "09:00~12:00 / 14:00~18:00" : "其余时段 / 周末全天", s: "C" },
1418
+ ];
1419
+ }],
1420
+ [10, function () { return { gif: true }; }],
1421
+ [8, function () { return [null, { t: whalePickOne(["好模型... ↓", "好女孩...↓"]), s: "B" }, null]; }],
1422
+ [8, function () {
1423
+ return [null, { t: whalePickOne([
1424
+ "不知道用户有什么用,先赶走吧~", "我...我...我也要挣钱吗?", "我去吃饭啦,测完叫我",
1425
+ "压力一只蓝色大肥鱼?!", "DeepSleep...", "坏了...用户彻底怒了!",
1426
+ "摸头摸头~ 再摸摸嘛", "尾巴被你拖到墙角啦", "偷看你打代码半天啦",
1427
+ ]), s: "A", w: true }, null]; }],
1428
+ [4, function () {
1429
+ return [null, { t: whalePickOne([
1430
+ "你目录里的dsh是什么...大烧货吗...?", "恭喜你实现token自由!token全跑了!", "真当我是便宜货啊...",
1431
+ ]), s: "A", w: true }, null]; }],
1432
+ [2, function () { return [{ t: "这个", s: "A" }, { t: "凶", s: "B" }, { t: "是什么意思呀...", s: "A" }]; }],
1433
+ [1, function () { return [null, { t: "哦鲸鲸... ", s: "B" }, null]; }],
1434
+ ];
1435
+ function whalePickLines(st) {
1436
+ var total = 0, i;
1437
+ for (i = 0; i < WHALE_GROUPS.length; i++) total += WHALE_GROUPS[i][0];
1438
+ var r = Math.random() * total;
1439
+ for (i = 0; i < WHALE_GROUPS.length; i++) { r -= WHALE_GROUPS[i][0]; if (r < 0) return WHALE_GROUPS[i][1](st); }
1440
+ return WHALE_GROUPS[0][1](st);
1441
+ }
1442
+
1443
+ var whaleWidget = null;
1444
+ // 停止互动后多久缩回 / 气泡存活时长 (用户要求: 3 秒)
1445
+ var WHALE_IDLE_MS = 3000;
1446
+ // 左右「边缘带」占屏宽比例: 松手时中心落在带内才吸附缩回, 中间区域原地不动
1447
+ var WHALE_EDGE_ZONE = 0.22;
1448
+ // 贴边时横向要拉开多少 (占鲸鱼宽比例) 才脱离边轨、转为自由拖拽并展开
1449
+ var WHALE_RAIL_BREAK = 0.42;
1450
+ // 连点节流: 间隔小于此值只续命不换台词 (换词会重建气泡, 连点时会闪没)
1451
+ var WHALE_LINE_MIN_MS = 420;
1452
+ function ensureWhaleWidget() {
1453
+ if (whaleWidget) return;
1454
+ if (typeof document === "undefined") return;
1455
+ var st = {};
1456
+ for (var k in WHALE_DEFAULTS) st[k] = WHALE_DEFAULTS[k];
1457
+
1458
+ // ---------- DOM ----------
1459
+ var root = document.createElement("div");
1460
+ root.className = "dshadb-whale";
1461
+ var sprite = document.createElement("div");
1462
+ sprite.className = "dshadb-whale-sprite";
1463
+ var body = document.createElement("div");
1464
+ body.className = "dshadb-whale-body";
1465
+ var img = document.createElement("img");
1466
+ img.className = "dshadb-whale-img";
1467
+ img.src = "/api-dashboard/whale/image.png";
1468
+ img.alt = "大肥鱼"; img.draggable = false;
1469
+ body.appendChild(img);
1470
+ sprite.appendChild(body);
1471
+ var bubble = document.createElement("div");
1472
+ bubble.className = "dshadb-whale-bubble";
1473
+ var bText = document.createElement("div");
1474
+ bText.className = "dshadb-whale-btext";
1475
+ var gif = document.createElement("img");
1476
+ gif.className = "dshadb-whale-gif";
1477
+ gif.src = "/api-dashboard/whale/rua.gif";
1478
+ gif.alt = ""; gif.draggable = false;
1479
+ var gifBroken = false;
1480
+ gif.addEventListener("error", function () { gifBroken = true; });
1481
+ bubble.appendChild(gif);
1482
+ bubble.appendChild(bText);
1483
+ root.appendChild(bubble);
1484
+ root.appendChild(sprite);
1485
+ document.body.appendChild(root);
1486
+
1487
+ // ---------- 音效 ----------
1488
+ var audio = { press: null, release: null };
1489
+ function reloadAudio() {
1490
+ audio.press = new Audio("/api-dashboard/whale/sound/press.mp3?set=" + st.soundSet);
1491
+ audio.release = new Audio("/api-dashboard/whale/sound/release.mp3?set=" + st.soundSet);
1492
+ audio.press.volume = st.volume; audio.release.volume = st.volume;
1493
+ }
1494
+ reloadAudio();
1495
+ function playSound(which) {
1496
+ if (!st.soundOn || st.volume <= 0) return;
1497
+ var a = audio[which]; if (!a) return;
1498
+ try { a.volume = st.volume; a.currentTime = 0; var p = a.play(); if (p && p.catch) p.catch(function () {}); } catch (e) {}
1499
+ }
1500
+
1501
+ // ---------- 状态 ----------
1502
+ var full = false; // 是否整只露出
1503
+ var bubbleOpen = false;
1504
+ var hideTimer = null, bubbleTimer = null, saveTimer = null;
1505
+ var drag = null;
1506
+ var lastLineAt = 0; // 上次换台词的时刻 (连点节流用)
1507
+
1508
+ function sizePx() { return root.getBoundingClientRect().width || 120; }
1509
+ function vw() { return window.innerWidth || document.documentElement.clientWidth; }
1510
+ function vh() { return window.innerHeight || document.documentElement.clientHeight; }
1511
+
1512
+ function applyScale() { root.style.setProperty("--dshw-scale", String(st.scale)); }
1513
+
1514
+ /** 把 state 的位置写进 left/top; 探头位移交给 transform, 所以这里一律夹在屏幕内 */
1515
+ function applyPos() {
1516
+ var s = sizePx();
1517
+ if (st.left === null || st.top === null) {
1518
+ st.left = Math.max(0, vw() - s);
1519
+ st.top = Math.round(vh() * 0.62);
1520
+ }
1521
+ st.left = Math.min(Math.max(st.left, 0), Math.max(0, vw() - s));
1522
+ st.top = Math.min(Math.max(st.top, 0), Math.max(0, vh() - s));
1523
+ root.style.left = st.left + "px";
1524
+ root.style.top = st.top + "px";
1525
+ }
1526
+
1527
+ /** 探头位移: 只有吸在左右两侧时才把身子推出屏幕; 中间自由摆放时整只显示 */
1528
+ function applyPeek() {
1529
+ if (full || !st.side) { root.style.transform = "translate(0px, 0px)"; return; }
1530
+ var s = sizePx();
1531
+ var out = Math.round(s * (1 - st.peekRatio));
1532
+ var dx = st.side === "left" ? -out : st.side === "right" ? out : 0;
1533
+ root.style.transform = "translate(" + dx + "px, 0px)";
1534
+ }
1535
+ function applyFlip() {
1536
+ if (st.side === "left") root.classList.add("dshadb-whale-flip");
1537
+ else root.classList.remove("dshadb-whale-flip");
1538
+ }
1539
+ function applyAll() { applyScale(); applyPos(); applyFlip(); applyPeek(); }
1540
+
1541
+ // ---------- 持久化 ----------
1542
+ function saveSoon() {
1543
+ if (saveTimer) clearTimeout(saveTimer);
1544
+ saveTimer = setTimeout(function () {
1545
+ saveTimer = null;
1546
+ try {
1547
+ fetch("/api-dashboard/whale/settings", {
1548
+ method: "PUT", headers: { "Content-Type": "application/json" },
1549
+ body: JSON.stringify({
1550
+ scale: st.scale, soundOn: st.soundOn, soundSet: st.soundSet, volume: st.volume,
1551
+ bubbleOn: st.bubbleOn, peakMode: st.peakMode, snapOn: st.snapOn,
1552
+ peekRatio: st.peekRatio, left: st.left, top: st.top, side: st.side,
1553
+ }),
1554
+ }).catch(function () {});
1555
+ } catch (e) {}
1556
+ }, 400);
1557
+ }
1558
+
1559
+ // ---------- 气泡 ----------
1560
+ var STYLE_CLASS = { A: "dshadb-whale-l-a", B: "dshadb-whale-l-b", P: "dshadb-whale-l-p", C: "dshadb-whale-l-c" };
1561
+ function renderLines(lines) {
1562
+ bText.textContent = "";
1563
+ if (lines && lines.gif && !gifBroken) { gif.style.display = "block"; bText.style.display = "none"; return; }
1564
+ gif.style.display = "none"; bText.style.display = "block";
1565
+ var arr = (lines && lines.gif) ? [null, { t: "动图跑掉了...", s: "A", w: true }, null] : lines;
1566
+ for (var i = 0; i < arr.length; i++) {
1567
+ var ln = arr[i]; if (!ln) continue;
1568
+ var el = document.createElement("div");
1569
+ el.className = STYLE_CLASS[ln.s] || STYLE_CLASS.A;
1570
+ if (ln.w) el.className += " dshadb-whale-wrap";
1571
+ if (ln.c) el.style.color = ln.c;
1572
+ el.textContent = ln.t;
1573
+ bText.appendChild(el);
1574
+ }
1575
+ }
1576
+ /**
1577
+ * 鲸鱼的「目标」位置 —— 不用 getBoundingClientRect。
1578
+ * 探头/展开是 0.45s transform 过渡, 点击瞬间读实时 rect 拿到的是过渡中的中间态,
1579
+ * 气泡会按旧位置算避让, 连点时越算越偏, 最后整个飘到屏幕外 = 看着"不显示"。
1580
+ */
1581
+ function targetRect() {
1582
+ var s = sizePx();
1583
+ var dx = 0;
1584
+ if (!full && st.side) {
1585
+ var out = Math.round(s * (1 - st.peekRatio));
1586
+ dx = st.side === "left" ? -out : st.side === "right" ? out : 0;
1587
+ }
1588
+ return { left: st.left + dx, top: st.top, size: s };
1589
+ }
1590
+ /**
1591
+ * 把气泡摆到不出屏的位置:
1592
+ * 1) 水平方向整体平移, 保证左右都留 8px 边距 (贴边时不会有一半在屏幕外)
1593
+ * 2) 尾巴单独定位, 仍然指向鲸鱼中心
1594
+ * 3) 上方空间不够时翻到鲸鱼下方
1595
+ */
1596
+ function placeBubble() {
1597
+ var margin = 8;
1598
+ bubble.classList.remove("dshadb-whale-bubble-below");
1599
+ bubble.style.setProperty("--dshw-bdx", "0px");
1600
+ bubble.style.setProperty("--dshw-btx", "50%");
1601
+ var r = targetRect();
1602
+ var bw = bubble.offsetWidth, bh = bubble.offsetHeight;
1603
+ if (!bw) return;
1604
+ var cx = r.left + r.size / 2;
1605
+ var want = cx - bw / 2;
1606
+ var shift = 0;
1607
+ if (want < margin) shift = margin - want;
1608
+ else if (want + bw > vw() - margin) shift = (vw() - margin) - (want + bw);
1609
+ bubble.style.setProperty("--dshw-bdx", Math.round(shift) + "px");
1610
+ var tail = bw / 2 - shift;
1611
+ tail = Math.min(Math.max(tail, 18), bw - 18);
1612
+ bubble.style.setProperty("--dshw-btx", Math.round(tail) + "px");
1613
+ if (r.top - bh - 6 < margin) bubble.classList.add("dshadb-whale-bubble-below");
1614
+ }
1615
+ function openBubble() {
1616
+ if (!st.bubbleOn) return;
1617
+ renderLines(whalePickLines(st));
1618
+ bubble.classList.remove("dshadb-whale-bubble-keep");
1619
+ bubble.classList.add("dshadb-whale-bubble-on");
1620
+ bubbleOpen = true;
1621
+ placeBubble();
1622
+ if (bubbleTimer) clearTimeout(bubbleTimer);
1623
+ bubbleTimer = setTimeout(closeBubble, WHALE_IDLE_MS);
1624
+ }
1625
+ /**
1626
+ * 连点续命: 保持当前台词不变, 只把气泡留住并重新计时。
1627
+ * 连点时不重建内容 —— 重建会让气泡宽度反复跳变、重新测量, 视觉上就是闪一下没了。
1628
+ */
1629
+ function keepBubble() {
1630
+ if (!st.bubbleOn) return;
1631
+ if (!bubbleOpen) { openBubble(); return; }
1632
+ bubble.classList.add("dshadb-whale-bubble-on");
1633
+ bubble.classList.add("dshadb-whale-bubble-keep");
1634
+ placeBubble();
1635
+ if (bubbleTimer) clearTimeout(bubbleTimer);
1636
+ bubbleTimer = setTimeout(closeBubble, WHALE_IDLE_MS);
1637
+ }
1638
+ function closeBubble() {
1639
+ bubble.classList.remove("dshadb-whale-bubble-on");
1640
+ bubble.classList.remove("dshadb-whale-bubble-keep");
1641
+ bubbleOpen = false;
1642
+ if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null; }
1643
+ }
1644
+
1645
+ // ---------- 探头 / 全身 ----------
1646
+ function toFull() {
1647
+ full = true; applyPeek();
1648
+ lastLineAt = Date.now();
1649
+ openBubble();
1650
+ playSound("press");
1651
+ armHide();
1652
+ }
1653
+ function toPeek() {
1654
+ full = false; applyPeek();
1655
+ closeBubble();
1656
+ // 只在真的会缩回边缘时才播收回音 (停在屏幕中间时不缩, 也就不该有动静)
1657
+ if (st.side) playSound("release");
1658
+ if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
1659
+ }
1660
+ /** 停手 WHALE_IDLE_MS 后自动缩回; 每次互动都重新计时 */
1661
+ function armHide() {
1662
+ if (hideTimer) clearTimeout(hideTimer);
1663
+ hideTimer = setTimeout(function () { hideTimer = null; if (full) toPeek(); }, WHALE_IDLE_MS);
1664
+ }
1665
+
1666
+ // ---------- 吸附 ----------
1667
+ /**
1668
+ * 松手后的落位: 只有拖到左右两侧的「边缘带」里才吸附 + 探头,
1669
+ * 停在屏幕中间就整只留在原地 (side="" → applyPeek 不做位移)。
1670
+ */
1671
+ function settle() {
1672
+ var s = sizePx();
1673
+ var W = vw(), H = vh();
1674
+ if (!st.snapOn) {
1675
+ st.side = "";
1676
+ applyPos(); applyFlip(); applyPeek(); saveSoon();
1677
+ return;
1678
+ }
1679
+ var cx = st.left + s / 2;
1680
+ var zone = Math.max(s * 0.75, W * WHALE_EDGE_ZONE); // 边缘带宽度
1681
+ if (cx <= zone) { st.side = "left"; st.left = 0; }
1682
+ else if (cx >= W - zone) { st.side = "right"; st.left = W - s; }
1683
+ else { st.side = ""; } // 屏幕中间: 不缩
1684
+ // 纵向永远不缩, 只是别跑出屏幕
1685
+ st.top = Math.min(Math.max(st.top, 0), Math.max(0, H - s));
1686
+ applyPos(); applyFlip(); applyPeek(); saveSoon();
1687
+ }
1688
+
1689
+ // ---------- 拖拽 ----------
1690
+ function onDown(e) {
1691
+ if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
1692
+ drag = {
1693
+ id: e.pointerId, sx: e.clientX, sy: e.clientY,
1694
+ ox: st.left, oy: st.top, moved: false,
1695
+ // 起手时贴着哪一侧: 贴边状态下的纵向滑动算「沿边轨道滑」, 不展开
1696
+ rail: full ? "" : st.side, freed: false,
1697
+ };
1698
+ root.classList.add("dshadb-whale-dragging");
1699
+ root.classList.add("dshadb-whale-press");
1700
+ try { body.setPointerCapture(e.pointerId); } catch (err) {}
1701
+ playSound("press");
1702
+ }
1703
+ function onMove(e) {
1704
+ if (!drag || e.pointerId !== drag.id) return;
1705
+ var dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
1706
+ if (!drag.moved && dx * dx + dy * dy < 9) return;
1707
+ drag.moved = true;
1708
+ // 沿边纵向滑动: 横向没拉开就保持探头姿态, 只上下移动
1709
+ if (drag.rail && !drag.freed) {
1710
+ var breakOut = Math.abs(dx) > Math.max(28, sizePx() * WHALE_RAIL_BREAK);
1711
+ if (!breakOut) {
1712
+ st.top = drag.oy + dy;
1713
+ applyPos(); // 夹在屏幕内并写回 top
1714
+ applyPeek(); // 维持探头位移, 不展开
1715
+ e.preventDefault();
1716
+ return;
1717
+ }
1718
+ drag.freed = true; // 横向拉开了 → 转为自由拖拽
1719
+ }
1720
+ if (!full) {
1721
+ full = true; // 拖动时整只跟手, 不再半隐
1722
+ root.style.transform = "translate(0px, 0px)";
1723
+ closeBubble();
1724
+ }
1725
+ st.left = drag.ox + dx; st.top = drag.oy + dy;
1726
+ root.style.left = st.left + "px";
1727
+ root.style.top = st.top + "px";
1728
+ e.preventDefault();
1729
+ }
1730
+ function onUp(e) {
1731
+ if (!drag || e.pointerId !== drag.id) return;
1732
+ var moved = drag.moved;
1733
+ var railed = !!drag.rail && !drag.freed;
1734
+ drag = null;
1735
+ root.classList.remove("dshadb-whale-dragging");
1736
+ root.classList.remove("dshadb-whale-press");
1737
+ try { body.releasePointerCapture(e.pointerId); } catch (err) {}
1738
+ if (moved) {
1739
+ // 沿边滑完仍是探头状态: 只记住新高度, 不吸附动画也不展开
1740
+ if (railed) { saveSoon(); return; }
1741
+ settle(); armHide(); return;
1742
+ }
1743
+ // 没移动 = 点击: 弹全身 / 换台词, 但绝不立刻收回 —— 一律等停手 3 秒
1744
+ playSound("release");
1745
+ if (!full) { toFull(); return; }
1746
+ // 连点节流: 间隔太短只续命不换词, 避免气泡反复重建导致"看不到"
1747
+ var now = Date.now();
1748
+ if (now - lastLineAt < WHALE_LINE_MIN_MS) { keepBubble(); armHide(); return; }
1749
+ lastLineAt = now;
1750
+ openBubble();
1751
+ armHide();
1752
+ }
1753
+ body.addEventListener("pointerdown", onDown);
1754
+ body.addEventListener("pointermove", onMove);
1755
+ body.addEventListener("pointerup", onUp);
1756
+ body.addEventListener("pointercancel", onUp);
1757
+
1758
+ // ---------- 外部设置接口 ----------
1759
+ // 设置面板「大肥鱼」页改动后调用: 立刻应用到当前挂件并落盘
1760
+ function applyPatch(patch) {
1761
+ var soundTouched = false;
1762
+ for (var key in patch) {
1763
+ if (!Object.prototype.hasOwnProperty.call(patch, key)) continue;
1764
+ if (patch[key] === undefined) continue;
1765
+ st[key] = patch[key];
1766
+ if (key === "soundSet") soundTouched = true;
1767
+ if (key === "volume" || key === "soundOn") soundTouched = true;
1768
+ }
1769
+ if (soundTouched) {
1770
+ if (patch.soundSet !== undefined) reloadAudio();
1771
+ if (audio.press) audio.press.volume = st.volume;
1772
+ if (audio.release) audio.release.volume = st.volume;
1773
+ }
1774
+ if (patch.bubbleOn === false) closeBubble();
1775
+ if (patch.snapOn !== undefined) {
1776
+ if (st.snapOn) { settle(); return; }
1777
+ st.side = "";
1778
+ }
1779
+ applyAll();
1780
+ if (bubbleOpen) placeBubble();
1781
+ saveSoon();
1782
+ }
1783
+
1784
+ // ---------- 全局监听 ----------
1785
+ var onDocDown = function (e) {
1786
+ if (root.contains(e.target)) return;
1787
+ if (full && !drag) toPeek();
1788
+ };
1789
+ document.addEventListener("pointerdown", onDocDown);
1790
+ var onResize = function () {
1791
+ applyPos();
1792
+ if (st.snapOn && st.side) settle(); else applyPeek();
1793
+ if (bubbleOpen) placeBubble();
1794
+ };
1795
+ window.addEventListener("resize", onResize);
1796
+ var onVis = function () {
1797
+ if (document.hidden) { if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }
1798
+ else if (full) armHide();
1799
+ };
1800
+ document.addEventListener("visibilitychange", onVis);
1801
+
1802
+ whaleWidget = {
1803
+ root: root,
1804
+ applyPatch: applyPatch,
1805
+ getState: function () {
1806
+ var out = {};
1807
+ for (var k in st) out[k] = st[k];
1808
+ return out;
1809
+ },
1810
+ onContext: function () { /* 峰谷变化下次开泡时自动取用 */ },
1811
+ dispose: function () {
1812
+ body.removeEventListener("pointerdown", onDown);
1813
+ body.removeEventListener("pointermove", onMove);
1814
+ body.removeEventListener("pointerup", onUp);
1815
+ body.removeEventListener("pointercancel", onUp);
1816
+ document.removeEventListener("pointerdown", onDocDown);
1817
+ document.removeEventListener("visibilitychange", onVis);
1818
+ window.removeEventListener("resize", onResize);
1819
+ if (hideTimer) clearTimeout(hideTimer);
1820
+ if (bubbleTimer) clearTimeout(bubbleTimer);
1821
+ if (saveTimer) clearTimeout(saveTimer);
1822
+ if (root.parentNode) root.parentNode.removeChild(root);
1823
+ whaleWidget = null;
1824
+ },
1825
+ };
1826
+
1827
+ // ---------- 拉取已存设置 ----------
1828
+ applyAll();
1829
+ fetch("/api-dashboard/whale/settings", { cache: "no-store" })
1830
+ .then(function (r) { return r.json(); })
1831
+ .then(function (d) {
1832
+ if (!d || !d.ok || !d.settings) return;
1833
+ for (var key in WHALE_DEFAULTS) {
1834
+ if (d.settings[key] !== undefined && d.settings[key] !== null) st[key] = d.settings[key];
1835
+ }
1836
+ if (d.settings.left !== undefined) st.left = d.settings.left;
1837
+ if (d.settings.top !== undefined) st.top = d.settings.top;
1838
+ applyAll();
1839
+ })
1840
+ .catch(function () {});
1841
+ }
1842
+ function removeWhaleWidget() { if (whaleWidget) whaleWidget.dispose(); }
1843
+ /** 设置面板改动后推给活着的挂件 (未挂载时静默忽略, 值已由端点落盘) */
1844
+ function patchWhaleWidget(patch) {
1845
+ if (whaleWidget && whaleWidget.applyPatch) whaleWidget.applyPatch(patch);
1846
+ }
1847
+ //#endregion
1848
+
1185
1849
  //#region plugin
1186
1850
  const inject = ["slots", "locale", "modelDirectories"];
1187
1851
  function apply(ctx) {
@@ -1220,6 +1884,18 @@ window.__ModuleLoader__.load({
1220
1884
  const dashData = react.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
1221
1885
  const config = dashData.config;
1222
1886
 
1887
+ // v1.1.0: 大肥鱼互动挂件 — 设置里开启「收养大肥鱼」后挂载/卸载 (纯互动, 不含余额)
1888
+ const whaleOn = !!(config && config.whaleEnabled);
1889
+ react.useEffect(() => {
1890
+ if (!whaleOn) { removeWhaleWidget(); return; }
1891
+ ensureWhaleWidget();
1892
+ return removeWhaleWidget;
1893
+ }, [whaleOn]);
1894
+ // 峰谷时段变化同步给挂件 (台词里的「当前时间段」用它, 不涉及任何金额)
1895
+ react.useEffect(() => {
1896
+ updateWhaleContext({ isPeak: !!(config && config.isPeak) });
1897
+ }, [config && config.isPeak]);
1898
+
1223
1899
  const handleSelect = (id) => { setSelected(id); setSelectedId(id); };
1224
1900
 
1225
1901
  // v0.5.15: 按模型名切到对应平台的核心逻辑 (两条信号共用)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-api-dashboard",
3
- "version": "1.0.2",
3
+ "version": "1.1.2",
4
4
  "description": "多平台 API 余额/用量看板插件:官方真实品牌图标,三层 UI 直接在输入框下方显示当前选中平台余额与估算消耗,点开底部抽屉切换所有平台;支持国内外平台与自定义中转站自动探测余额(查不到标未开放);自定义安全/预警阈值,余额告警提醒;进入页面自动刷新",
5
5
  "keywords": [
6
6
  "dsh",
package/src/index.js CHANGED
@@ -441,6 +441,19 @@ export const Config = Schema.object({
441
441
  cacheMiss: Schema.number().min(0).default(1),
442
442
  output: Schema.number().min(0).default(2),
443
443
  }).default({}),
444
+ /** 收养大肥鱼: 屏幕侧边互动宠物挂件 (v1.1.0, 纯互动不含余额, 移植自 MeteorNOX/DeepSeek-Balance-Whale-Widget, MIT) */
445
+ whaleEnabled: Schema.boolean().default(false),
446
+ /** 大肥鱼挂件设置: 大小/音效/音量/气泡/峰谷文案/吸附/位置记忆 */
447
+ whaleSettings: Schema.object({
448
+ scale: Schema.number().min(0.6).max(2.5).default(1),
449
+ soundOn: Schema.boolean().default(true),
450
+ soundSet: Schema.string().default('duck'),
451
+ volume: Schema.number().min(0).max(1).default(0.5),
452
+ bubbleOn: Schema.boolean().default(true),
453
+ peakMode: Schema.string().default('default'),
454
+ snapOn: Schema.boolean().default(true),
455
+ peekRatio: Schema.number().min(0.15).max(0.9).default(0.5),
456
+ }).default({}),
444
457
  })
445
458
 
446
459
  // ============================================================
@@ -953,6 +966,13 @@ export function apply(ctx, config) {
953
966
  currency: persisted.currency ?? config.currency ?? 'CNY',
954
967
  safeThreshold: persisted.safeThreshold ?? config.safeThreshold ?? 50,
955
968
  warnThreshold: persisted.warnThreshold ?? config.warnThreshold ?? 10,
969
+ whaleEnabled: persisted.whaleEnabled ?? config.whaleEnabled ?? false,
970
+ whaleSettings: {
971
+ scale: 1, soundOn: true, soundSet: 'duck', volume: 0.5, bubbleOn: true,
972
+ peakMode: 'default', snapOn: true, peekRatio: 0.5, left: null, top: null, side: 'right',
973
+ ...(config.whaleSettings ?? {}),
974
+ ...(persisted.whaleSettings ?? {}),
975
+ },
956
976
  }
957
977
 
958
978
  const getConfig = () => runtimeConfig
@@ -1036,6 +1056,7 @@ export function apply(ctx, config) {
1036
1056
  currency: runtimeConfig.currency,
1037
1057
  isPeak: isPeakTime(),
1038
1058
  isWeekend: isWeekend(),
1059
+ whaleEnabled: !!runtimeConfig.whaleEnabled,
1039
1060
  },
1040
1061
  }
1041
1062
  // A3: 告警检测
@@ -1178,6 +1199,110 @@ export function apply(ctx, config) {
1178
1199
  },
1179
1200
  }), 'dsh-api-dashboard: icon route')
1180
1201
 
1202
+ // v1.1.0: 大肥鱼互动挂件资产 (移植自 MeteorNOX/DeepSeek-Balance-Whale-Widget, MIT License)
1203
+ const WHALE_ASSET_ROOT = join(SELF_ROOT, 'assets', 'whale')
1204
+ const whaleAsset = (name) => join(WHALE_ASSET_ROOT, name)
1205
+ let whaleImgCache = null
1206
+ webCtx.effect(() => webCtx.webServer.register({
1207
+ kind: 'exact', path: '/api-dashboard/whale/image.png',
1208
+ async handler(req, res) {
1209
+ if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
1210
+ try {
1211
+ if (!whaleImgCache) whaleImgCache = readFileSync(whaleAsset('DSniang1.png'))
1212
+ res.writeHead(200, {
1213
+ 'Content-Type': 'image/png',
1214
+ 'Cache-Control': 'public, max-age=86400',
1215
+ 'Content-Length': whaleImgCache.length,
1216
+ })
1217
+ res.end(whaleImgCache)
1218
+ } catch { res.writeHead(404); res.end() }
1219
+ },
1220
+ }), 'dsh-api-dashboard: whale image route')
1221
+ // rua.gif (随机台词动图)
1222
+ let whaleGifCache = null
1223
+ webCtx.effect(() => webCtx.webServer.register({
1224
+ kind: 'exact', path: '/api-dashboard/whale/rua.gif',
1225
+ async handler(req, res) {
1226
+ if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
1227
+ try {
1228
+ if (!whaleGifCache) whaleGifCache = readFileSync(whaleAsset('rua.gif'))
1229
+ res.writeHead(200, {
1230
+ 'Content-Type': 'image/gif',
1231
+ 'Cache-Control': 'public, max-age=86400',
1232
+ 'Content-Length': whaleGifCache.length,
1233
+ })
1234
+ res.end(whaleGifCache)
1235
+ } catch { res.writeHead(404); res.end() }
1236
+ },
1237
+ }), 'dsh-api-dashboard: whale gif route')
1238
+ // 音效 (每请求读盘 + no-store, 更换音频即生效; ?set=duck|fx1 选音效组)
1239
+ for (const kind of ['press', 'release']) {
1240
+ webCtx.effect(() => webCtx.webServer.register({
1241
+ kind: 'exact', path: `/api-dashboard/whale/sound/${kind}.mp3`,
1242
+ async handler(req, res) {
1243
+ if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
1244
+ try {
1245
+ const set = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('set') === 'fx1' ? 'fx1' : 'duck'
1246
+ const table = { duck: { press: 'Ya1.mp3', release: 'Ya2.mp3' }, fx1: { press: 'D1.mp3', release: 'D2.mp3' } }
1247
+ const data = readFileSync(whaleAsset(table[set][kind]))
1248
+ res.writeHead(200, {
1249
+ 'Content-Type': 'audio/mpeg',
1250
+ 'Cache-Control': 'no-store',
1251
+ 'Content-Length': data.length,
1252
+ })
1253
+ res.end(data)
1254
+ } catch { res.writeHead(404); res.end() }
1255
+ },
1256
+ }), `dsh-api-dashboard: whale sound ${kind} route`)
1257
+ }
1258
+
1259
+ // v1.1.0: 大肥鱼挂件设置 (大小/音效/音量/气泡/峰谷文案/吸附/位置) —— 独立轻量端点,
1260
+ // 与看板主配置分开, 滑块拖动等高频写入不触发余额刷新
1261
+ webCtx.effect(() => webCtx.webServer.register({
1262
+ kind: 'exact', path: '/api-dashboard/whale/settings',
1263
+ async handler(req, res) {
1264
+ if (req.method === 'GET') { sendJson(res, 200, { ok: true, settings: runtimeConfig.whaleSettings }); return }
1265
+ if (req.method === 'PUT' || req.method === 'POST') {
1266
+ try {
1267
+ let raw = ''
1268
+ for await (const chunk of req) { raw += chunk }
1269
+ const body = raw ? JSON.parse(raw) : {}
1270
+ const cur = runtimeConfig.whaleSettings
1271
+ const num = (v, lo, hi, dflt) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : dflt)
1272
+ runtimeConfig.whaleSettings = {
1273
+ scale: num(body.scale, 0.6, 2.5, cur.scale),
1274
+ soundOn: typeof body.soundOn === 'boolean' ? body.soundOn : cur.soundOn,
1275
+ soundSet: body.soundSet === 'fx1' ? 'fx1' : (body.soundSet === 'duck' ? 'duck' : cur.soundSet),
1276
+ volume: num(body.volume, 0, 1, cur.volume),
1277
+ bubbleOn: typeof body.bubbleOn === 'boolean' ? body.bubbleOn : cur.bubbleOn,
1278
+ peakMode: ['default', 'liangwen', 'qiangqiang'].includes(body.peakMode) ? body.peakMode : cur.peakMode,
1279
+ snapOn: typeof body.snapOn === 'boolean' ? body.snapOn : cur.snapOn,
1280
+ peekRatio: num(body.peekRatio, 0.15, 0.9, cur.peekRatio),
1281
+ left: typeof body.left === 'number' && Number.isFinite(body.left) ? body.left : cur.left,
1282
+ top: typeof body.top === 'number' && Number.isFinite(body.top) ? body.top : cur.top,
1283
+ // v1.1.0: 只保留左右吸附 (上下不再缩回); '' = 停在屏幕中间不缩
1284
+ side: ['left', 'right', ''].includes(body.side) ? body.side : cur.side,
1285
+ }
1286
+ savePersistedState({
1287
+ refreshIntervalMs: runtimeConfig.refreshIntervalMs,
1288
+ clientPollIntervalMs: runtimeConfig.clientPollIntervalMs,
1289
+ timeoutMs: runtimeConfig.timeoutMs,
1290
+ customRelays: runtimeConfig.customRelays,
1291
+ customModels: runtimeConfig.customModels,
1292
+ currency: runtimeConfig.currency,
1293
+ safeThreshold: runtimeConfig.safeThreshold,
1294
+ warnThreshold: runtimeConfig.warnThreshold,
1295
+ whaleEnabled: runtimeConfig.whaleEnabled,
1296
+ whaleSettings: runtimeConfig.whaleSettings,
1297
+ })
1298
+ sendJson(res, 200, { ok: true, settings: runtimeConfig.whaleSettings })
1299
+ } catch (err) { sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) }) }
1300
+ return
1301
+ }
1302
+ res.writeHead(405, { Allow: 'GET, PUT, POST' }); res.end()
1303
+ },
1304
+ }), 'dsh-api-dashboard: whale settings route')
1305
+
1181
1306
  webCtx.effect(() => webCtx.webServer.register({
1182
1307
  kind: 'exact', path: '/api-dashboard/config',
1183
1308
  async handler(req, res) {
@@ -1188,6 +1313,7 @@ export function apply(ctx, config) {
1188
1313
  customModels: runtimeConfig.customModels.map(m => ({ ...m, apiKey: m.apiKey ? '***' : '' })),
1189
1314
  presets: runtimeConfig.presets,
1190
1315
  refreshIntervalSec: Math.round(runtimeConfig.refreshIntervalMs / 1000),
1316
+ whaleEnabled: !!runtimeConfig.whaleEnabled,
1191
1317
  })
1192
1318
  return
1193
1319
  }
@@ -1226,6 +1352,8 @@ export function apply(ctx, config) {
1226
1352
  if (typeof body.safeThreshold === 'number' && body.safeThreshold >= 0) runtimeConfig.safeThreshold = body.safeThreshold
1227
1353
  if (typeof body.warnThreshold === 'number' && body.warnThreshold >= 0) runtimeConfig.warnThreshold = body.warnThreshold
1228
1354
  if (typeof body.currency === 'string' && body.currency.trim()) runtimeConfig.currency = body.currency.trim().toUpperCase()
1355
+ // v1.1.0: 收养大肥鱼开关
1356
+ if (typeof body.whaleEnabled === 'boolean') runtimeConfig.whaleEnabled = body.whaleEnabled
1229
1357
  // 持久化: 写入状态文件, 重启后恢复 (用户配置优先)
1230
1358
  savePersistedState({
1231
1359
  refreshIntervalMs: runtimeConfig.refreshIntervalMs,
@@ -1236,6 +1364,7 @@ export function apply(ctx, config) {
1236
1364
  currency: runtimeConfig.currency,
1237
1365
  safeThreshold: runtimeConfig.safeThreshold,
1238
1366
  warnThreshold: runtimeConfig.warnThreshold,
1367
+ whaleEnabled: runtimeConfig.whaleEnabled,
1239
1368
  })
1240
1369
  resetLoop(); await refreshAll()
1241
1370
  sendJson(res, 200, {
@@ -1243,6 +1372,7 @@ export function apply(ctx, config) {
1243
1372
  customRelays: runtimeConfig.customRelays.map(r => ({ ...r, apiKey: r.apiKey ? '***' : '' })),
1244
1373
  customModels: runtimeConfig.customModels.map(m => ({ ...m, apiKey: m.apiKey ? '***' : '' })),
1245
1374
  refreshIntervalSec: Math.round(runtimeConfig.refreshIntervalMs / 1000),
1375
+ whaleEnabled: !!runtimeConfig.whaleEnabled,
1246
1376
  })
1247
1377
  } catch (err) { sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) }) }
1248
1378
  return