balance-dsh 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -35,11 +35,22 @@ window.__ModuleLoader__.load({
35
35
  .balance-footer__error{color:var(--dsw-alias-state-error-primary)}
36
36
  .balance-topup{box-sizing:border-box;display:flex;align-items:center;justify-content:flex-start;gap:8px;width:calc(100% + 4px);min-width:0;height:42px;margin:0 -2px;padding:0 10px 0 8px;color:var(--dsw-alias-state-error-primary,#e5484d);font-size:14px;font-weight:600;line-height:22px;text-decoration:none;cursor:pointer;white-space:nowrap;overflow:hidden}
37
37
  .balance-topup:hover{text-decoration:underline}
38
- /* ── 收起态:悬浮 FAB(保持原样) ── */
39
- .balance-fab{box-sizing:border-box;position:fixed;left:68px;bottom:12px;z-index:40;height:32px;padding:0 14px;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-base));border:1px solid var(--dsw-alias-border-l2);border-radius:999px;cursor:pointer;font-size:12px;font-weight:600;line-height:1;text-decoration:none;white-space:nowrap;pointer-events:auto;box-shadow:0 4px 12px rgba(0,0,0,.10)}
40
- .balance-fab:hover{background:var(--dsw-alias-interactive-bg-hover)}
41
- .balance-fab-dark{color:#fff;background:var(--dsw-alias-state-error-primary,#e5484d);border-color:var(--dsw-alias-state-error-primary,#e5484d)}
42
- .balance-fab-dark:hover{background:#cf3a3f}
38
+ /* ── 收起态:圆形悬浮 FAB(组件里 portal 到 body,绕开收起态 footArea 的 display:none) ──
39
+ liquid glass:半透明底 + backdrop-filter 模糊下层界面 + 顶部高光内阴影 + 柔和外投影。
40
+ backdrop-filter 要求自身背景半透明才出效果,所以底色用 color-mix 掺透明,
41
+ 并保留主题变量(深/浅色主题都能跟着走)。
42
+ ⚠️ 必须显式写 corner-shape:round:DSH 主题包(dsh-client-ui-theme)里有一条
43
+ *,:before,:after{corner-shape:var(--dsw-corner-shape)},而 --dsw-corner-shape
44
+ 默认是 superellipse(1.5)——不覆盖的话 border-radius:50% 会被渲染成"圆角方形"
45
+ (squircle),根本不是圆(2026-09-28 用户反馈「很奇怪啊」,截图是 squircle)。
46
+ DSH 自己的 .pill 也是这么显式写 corner-shape:round 抵消的。 */
47
+ .balance-fab{box-sizing:border-box;position:fixed;left:44px;bottom:12px;z-index:9998;width:60px;height:60px;padding:0;border-radius:50%;corner-shape:round;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:-.01em;text-decoration:none;white-space:nowrap;overflow:hidden;cursor:pointer;pointer-events:auto;background:color-mix(in srgb,var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-base,#1b1b1c)) 58%,transparent);-webkit-backdrop-filter:blur(18px) saturate(180%);backdrop-filter:blur(18px) saturate(180%);border:1px solid color-mix(in srgb,var(--dsw-alias-label-primary) 14%,transparent);box-shadow:inset 0 1px 0 color-mix(in srgb,#fff 26%,transparent),inset 0 -1px 1px color-mix(in srgb,#fff 8%,transparent),0 6px 20px rgba(0,0,0,.28);transition:transform .18s var(--ds-ease-in-out,ease-out),background .18s var(--ds-ease-in-out,ease-out),box-shadow .18s var(--ds-ease-in-out,ease-out)}
48
+ .balance-fab:hover{transform:scale(1.06);background:color-mix(in srgb,var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-base,#1b1b1c)) 76%,transparent);box-shadow:inset 0 1px 0 color-mix(in srgb,#fff 34%,transparent),inset 0 -1px 1px color-mix(in srgb,#fff 10%,transparent),0 10px 26px rgba(0,0,0,.34)}
49
+ .balance-fab:active{transform:scale(.95)}
50
+ /* 金额偏长时(如 ¥1234.56)自动收一号字,免得 60px 圆里挤爆 */
51
+ .balance-fab--dense{font-size:11px;letter-spacing:-.03em}
52
+ .balance-fab-dark{color:#fff;background:color-mix(in srgb,#e5484d 62%,transparent);border-color:color-mix(in srgb,#fff 22%,transparent)}
53
+ .balance-fab-dark:hover{background:color-mix(in srgb,#e5484d 78%,transparent)}
43
54
  /* ── 消耗行 pill:规格逐条照抄 DSH 原生 StatsPills 的 .pill(_091hzG_pill)── */
44
55
  .balance-usage-wrap{min-width:0;display:inline-flex}
45
56
  .balance-usage-line{box-sizing:border-box;corner-shape:round;max-width:100%;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:calc(var(--dsh-content-font-size-secondary,13px) - 1px);font-variant-numeric:tabular-nums;line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));white-space:nowrap;background:0 0;border:none;border-radius:999px;align-items:center;gap:6px;padding:1px 8px;display:inline-flex;cursor:pointer;transition:background .15s var(--ds-ease-in-out,.2s)}
@@ -60,14 +71,8 @@ window.__ModuleLoader__.load({
60
71
  /* ── 跨会话统计浮层(余额元素的子级,向右/向上展开) ── */
61
72
  .balance-stats-anchor{position:relative;display:flex;min-width:0;overflow:visible}
62
73
  .balance-stats-anchor--wide{width:100%}
63
- .balance-stats-anchor--compact{position:fixed;left:68px;bottom:12px;z-index:10000}
74
+ .balance-stats-anchor--compact{position:fixed;left:44px;bottom:12px;z-index:9998}
64
75
  .balance-stats-anchor--compact .balance-fab{position:static}
65
- .balance-platform-layer{box-sizing:border-box;position:fixed;inset:0;z-index:2147483000;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base-platform,var(--dsw-alias-bg-base,#1b1b1c));animation:balance-stats-backdrop-in .16s var(--ds-ease-in-out,ease-out) both}
66
- .balance-platform-header{flex:none;display:flex;align-items:center;gap:8px;height:44px;padding:0 12px;border-bottom:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.2));-webkit-app-region:drag}
67
- .balance-platform-back{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary,inherit);font:inherit;font-size:13px;cursor:pointer;-webkit-app-region:no-drag;transition:background .15s var(--ds-ease-in-out,.2s)}
68
- .balance-platform-back svg{flex:none;display:block}
69
- .balance-platform-back:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
70
- .balance-platform-viewport{flex:1;min-height:0}
71
76
  .balance-stats-layer{box-sizing:border-box;position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.24));backdrop-filter:var(--dsw-mask-blur,blur(2px));-webkit-backdrop-filter:var(--dsw-mask-blur,blur(2px));animation:balance-stats-backdrop-in .16s var(--ds-ease-in-out,ease-out) both}
72
77
  .balance-stats-layer--closing{pointer-events:none;animation:balance-stats-backdrop-out .14s var(--ds-ease-in-out,ease-in) both}
73
78
  .balance-stats-panel{box-sizing:border-box;position:relative;z-index:10000;width:min(800px,100%);max-height:100%;overflow-x:hidden;overflow-y:auto;scrollbar-gutter:stable;padding:0 0 24px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));border:0;border-radius:24px;box-shadow:var(--dsw-elevation-prominent,0 14px 36px rgba(0,0,0,.24));transform-origin:center center;will-change:transform,opacity;contain:layout style;animation:balance-stats-panel-in .26s var(--ds-ease-in-out,ease-out) .04s both;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2,rgba(127,127,127,.35));--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2,rgba(127,127,127,.55))}
@@ -120,8 +125,13 @@ window.__ModuleLoader__.load({
120
125
  .balance-heatmap-cell--4{background:rgba(22,163,74,.95)}
121
126
  .balance-heatmap-cell--blank{visibility:hidden}
122
127
  .balance-stats-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:14px;color:var(--dsw-alias-label-secondary);font-size:11px}
123
- .balance-stats-link{padding:7px 12px;border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:8px;color:var(--dsw-alias-label-primary);text-decoration:none;background:var(--dsw-alias-bg-module-platform,rgba(127,127,127,.04))}
128
+ /* 底部操作区:官方用量 / 去充值走原生平台视口(带平台凭据、无需网页登录);
129
+ 更多账号信息对齐 DSH 原生账号卡片,开平台首页(外部窗口,原生视口只认 usage / top-up) */
130
+ .balance-stats-actions{display:flex;flex:none;align-items:center;gap:8px}
131
+ .balance-stats-link{padding:7px 12px;border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:8px;color:var(--dsw-alias-label-primary);text-decoration:none;white-space:nowrap;background:var(--dsw-alias-bg-module-platform,rgba(127,127,127,.04))}
124
132
  .balance-stats-link:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
133
+ .balance-stats-link--primary{border-color:transparent;color:#fff;background:var(--dsw-alias-state-business-primary,#4d6bfe)}
134
+ .balance-stats-link--primary:hover{background:var(--dsw-alias-state-business-primary-hover,#3f5ae0)}
125
135
  .balance-stats-group{box-sizing:border-box;margin-bottom:0;padding:11px;border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:10px;background:var(--dsw-alias-bg-module-platform,rgba(127,127,127,.04));animation:balance-stats-card-in .22s var(--ds-ease-in-out,ease-out) both}
126
136
  .balance-stats-group__title{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin:0 0 6px;color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600}
127
137
  .balance-stats-group__hint{color:var(--dsw-alias-label-secondary);font-weight:400;font-variant-numeric:tabular-nums}
@@ -142,6 +152,46 @@ window.__ModuleLoader__.load({
142
152
  :has(> :has(> [data-slot="sidebar.footer.action"])):has(.balance-footer, .balance-topup) > :has(> [data-slot="sidebar.settings"]){order:-1;flex:0 0 auto;width:32px;min-width:0}
143
153
  :has(> :has(> [data-slot="sidebar.footer.action"])):has(.balance-footer, .balance-topup) [data-slot="sidebar.settings"] button{width:32px;height:32px;margin:0;padding:0;justify-content:center;border-radius:50%;gap:0}
144
154
  :has(> :has(> [data-slot="sidebar.footer.action"])):has(.balance-footer, .balance-topup) [data-slot="sidebar.settings"] button span{display:none}
155
+ /* ── 插件配置卡片:DSH「插件」页 → balance-dsh 详情页(slot plugins.bundle.config) ──
156
+ 用插件自己的稳定类名(不依赖 DSH 的 CSS Modules hash);主题变量一律带回退值,
157
+ 变量缺失时仍然可读,不会变成透明文字。开关/按钮的真圆角要显式 corner-shape:round,
158
+ 否则会被 DSH 主题的全局 superellipse 画成方圆角。 */
159
+ .balance-settings{box-sizing:border-box;display:flex;flex-direction:column;width:100%;margin:0;padding:16px 18px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22));border-radius:var(--dsw-radius-lg,16px);background:var(--dsw-alias-bg-module-platform,transparent)}
160
+ .balance-settings__head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
161
+ .balance-settings__title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}
162
+ .balance-settings__version{font-size:12px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
163
+ .balance-settings__desc{margin:4px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
164
+ .balance-settings__group{margin:14px 0 0;padding:12px 0 0;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22))}
165
+ .balance-settings__group-title{margin:0 0 2px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary)}
166
+ .balance-settings__head-btn{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;box-sizing:border-box;padding:0;border:0;background:0 0;font:inherit;text-align:left;cursor:pointer;color:inherit}
167
+ .balance-settings__head-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px;border-radius:var(--dsw-radius-sm,6px)}
168
+ .balance-settings__head-text{flex:1;min-width:0}
169
+ .balance-settings__chevron{flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s ease}
170
+ .balance-settings[data-open="true"] .balance-settings__chevron{transform:rotate(180deg)}
171
+ .balance-settings__body{margin:12px 0 0;padding:12px 0 0;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22))}
172
+ .balance-settings__row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0}
173
+ .balance-settings__label{display:flex;flex-direction:column;gap:2px;min-width:0}
174
+ .balance-settings__label-text{font-size:13px;line-height:19px;color:var(--dsw-alias-label-primary)}
175
+ .balance-settings__hint{font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary)}
176
+ .balance-settings__control{display:flex;align-items:center;gap:6px;flex:none}
177
+ .balance-settings__unit{font-size:12px;color:var(--dsw-alias-label-tertiary)}
178
+ .balance-settings__input{box-sizing:border-box;width:92px;height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4,rgba(127,127,127,.35));border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-3,rgba(127,127,127,.1));color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;font-variant-numeric:tabular-nums;outline:none}
179
+ .balance-settings__input:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary,#4d6bfe)}
180
+ .balance-settings__input--wide{width:100%;min-width:0}
181
+ .balance-settings__toggle{position:relative;box-sizing:border-box;flex:none;width:36px;height:20px;padding:2px;border:none;border-radius:999px;corner-shape:round;background:var(--dsw-alias-border-l3,rgba(127,127,127,.35));cursor:pointer;transition:background .12s ease}
182
+ .balance-settings__toggle[aria-checked="true"]{background:var(--dsw-alias-brand-primary,#4d6bfe)}
183
+ .balance-settings__toggle::after{content:"";display:block;width:16px;height:16px;border-radius:50%;corner-shape:round;background:var(--dsw-alias-switch-thumb,#fff);transition:transform .12s ease}
184
+ .balance-settings__toggle[aria-checked="true"]::after{background:var(--dsw-alias-label-primary-foreground,#fff);transform:translateX(16px)}
185
+ .balance-settings__actions{display:flex;align-items:center;gap:8px;margin:14px 0 0;padding:12px 0 0;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22))}
186
+ .balance-settings__status{flex:1;min-width:0;font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary)}
187
+ .balance-settings__status--error{color:var(--dsw-alias-state-error-primary,#e5484d)}
188
+ .balance-settings__status--ok{color:var(--dsw-alias-state-success-primary,#22c55e)}
189
+ .balance-settings__btn{box-sizing:border-box;flex:none;padding:5px 14px;border:1px solid transparent;border-radius:var(--dsw-radius-md,8px);background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:1.5;cursor:pointer;transition:opacity .12s ease,color .12s ease}
190
+ .balance-settings__btn:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
191
+ .balance-settings__btn:disabled{opacity:.4;cursor:default}
192
+ .balance-settings__btn--primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
193
+ .balance-settings__btn--primary:hover:not(:disabled){opacity:.88;color:var(--dsw-alias-bg-layer-3)}
194
+ .balance-settings__summary{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
145
195
  `;
146
196
 
147
197
  const tagId = "balance/balance-widget.css";
@@ -200,25 +250,28 @@ window.__ModuleLoader__.load({
200
250
  }
201
251
 
202
252
  /** 分时段金额:优先按服务端已标注的各模型 cost 累加(byModel);
203
- * 无 byModel 时回退到旧的单模型逻辑:高峰桶按目录价 ×2、空闲桶按目录价(兼容子代理/旧数据)。 */
204
- function computeSplitCost(usage, p) {
253
+ * 无 byModel 时回退到旧的单模型逻辑:高峰桶按目录价 × 倍率、空闲桶按目录价(兼容子代理/旧数据)。
254
+ * ⚠️ p 必须是**目录价**(服务端 pricing 字段即目录价),倍率由 peakMultiplier 单独给,
255
+ * 否则会与这里的乘法重复翻倍(高峰变 ×4)。 */
256
+ function computeSplitCost(usage, p, multiplier = 2) {
205
257
  if (Array.isArray(usage.byModel) && usage.byModel.length > 0) {
206
258
  let total = 0;
207
259
  for (const b of usage.byModel) {
208
- total += typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : computeSingleSplitCost(b, p);
260
+ total += typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : computeSingleSplitCost(b, p, multiplier);
209
261
  }
210
262
  return total;
211
263
  }
212
- return computeSingleSplitCost(usage, p);
264
+ return computeSingleSplitCost(usage, p, multiplier);
213
265
  }
214
- function computeSingleSplitCost(usage, p) {
215
- const costOf = (u) =>
216
- ((u.inputTokens ?? 0) * p.inputCnyPerMillion +
266
+ function computeSingleSplitCost(usage, p, multiplier = 2) {
267
+ const costOf = (u, factor) =>
268
+ (((u.inputTokens ?? 0) * p.inputCnyPerMillion +
217
269
  (u.cacheReadTokens ?? 0) * p.cacheReadCnyPerMillion +
218
270
  (u.cacheWriteTokens ?? 0) * p.cacheWriteCnyPerMillion) /
219
271
  1_000_000 +
220
- ((u.outputTokens ?? 0) * p.outputCnyPerMillion) / 1_000_000;
221
- return costOf(usage.peak) * 2 + costOf(usage.offpeak);
272
+ ((u.outputTokens ?? 0) * p.outputCnyPerMillion) / 1_000_000) *
273
+ factor;
274
+ return costOf(usage.peak, multiplier) + costOf(usage.offpeak, 1);
222
275
  }
223
276
  function formatTokens(n) {
224
277
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
@@ -226,148 +279,56 @@ window.__ModuleLoader__.load({
226
279
  return String(n);
227
280
  }
228
281
 
229
- // ─── 打开 DeepSeek 平台页面(用量 / 充值) ─────────────────────────────
230
- // 三级策略,逐级回退:
231
- // 1) DSH 官方版的「原生平台视口」——preload 通过 contextBridge 暴露的 globalThis.dshPlatform
232
- // (接口:open(page, bounds) / setBounds(bounds) / close(),page 取 "usage" | "top-up")。
233
- // DSH 设置→账号与余额里的「查询用量」按钮就走它(dsh-client-ui-settings-account 的
234
- // PlatformOverlay + dshPlatform.open)。这个原生子窗口自带平台凭据(官方注释:
235
- // "the native child owns remote content and credentials"),所以**不需要网页登录**。
236
- // 注意:那个包的 platformPages 是包内私有 store,插件无法触发 DSH 自己的 host,
237
- // 因此这里自己画同款覆盖层并把视口坐标喂给 bridge。
238
- // 2) 应用内侧栏浏览器(dsh-client-ui-sidebar-browser)——旧版/无原生视口时用,
239
- // 按 DSH 自身 openExternalLink 的判定:sidebarRightTabs 注册了 "browser" 才可用。
240
- // 该视口**没有**平台凭据,打开 platform.deepseek.com 会要求网页登录。
241
- // 3) window.open 交给系统浏览器。
242
- // 全程 try/catch:DSH 内部机制无兼容承诺,任一级失败都要继续降级,不能拖垮插件。
243
- let pluginCtx = null;
282
+ // ─── 打开 DeepSeek 平台页面(用量 / 充值 / 账号信息) ───────────────────
283
+ // **一律交给系统默认浏览器**(window.open,DSH 的 Electron 主进程会转给 shell.openExternal)。
284
+ // 用户 2026-10-04 明确要求:不要在软件内开网页。
285
+ // 历史(已删除,别再恢复):曾实现三级策略——① DSH 官方版 preload 暴露的 globalThis.dshPlatform
286
+ // 原生平台视口(自带平台凭据、无需网页登录)② 应用内侧栏浏览器(无凭据,会要求登录)
287
+ // ③ 系统浏览器。原生视口那套覆盖层(openPlatformPage / closePlatformPage /
288
+ // .balance-platform-* 样式)与侧栏浏览器回退都已随本次改动删除。
244
289
  const USAGE_URL = "https://platform.deepseek.com/usage";
245
290
  const TOP_UP_URL = "https://platform.deepseek.com/top_up";
246
-
247
- /** 当前原生平台视口的覆盖层;null 表示未打开。 */
248
- let platformOverlay = null;
249
-
250
- /** 关闭原生平台视口覆盖层(幂等;bridge.close + 摘除 DOM + 解绑监听)。 */
251
- function closePlatformPage() {
252
- if (platformOverlay === null) return;
253
- const { layer, bridge, observer, onKey } = platformOverlay;
254
- platformOverlay = null;
255
- try {
256
- observer.disconnect();
257
- } catch {
258
- /* ignore */
259
- }
260
- try {
261
- window.removeEventListener("keydown", onKey, true);
262
- } catch {
263
- /* ignore */
264
- }
265
- try {
266
- const closing = bridge.close();
267
- if (closing && typeof closing.catch === "function") closing.catch(() => {});
268
- } catch {
269
- /* ignore */
270
- }
271
- try {
272
- layer.remove();
273
- } catch {
274
- /* ignore */
275
- }
276
- }
291
+ // 「更多账号信息」= DSH 原生账号卡片同款入口:它用的是 new URL("/", usageUrl).href,
292
+ // 即平台首页(不是某个子页)。原生那个 <a> 没有走原生平台视口(视口只认 usage / top-up),
293
+ // 是直接开外部窗口,所以这里也传 page = null。
294
+ const PLATFORM_HOME_URL = "https://platform.deepseek.com/";
277
295
 
278
296
  /**
279
- * 用原生平台视口打开官方平台页面。
280
- * @param page - "usage"(用量)或 "top-up"(充值)。
281
- * @returns 是否成功接管(false 时调用方继续降级)。
297
+ * 量出聊天输入卡片(composer card)的垂直中心(视口坐标)。
298
+ * 收起态圆形余额靠它做「垂直中心对齐聊天栏」,所以不写死像素。
299
+ * @returns 中心 y;量不到返回 null,调用方回退到 CSS 的固定 bottom。
282
300
  */
283
- function openPlatformPage(page) {
284
- const bridge = typeof globalThis === "undefined" ? void 0 : globalThis.dshPlatform;
285
- if (!bridge || typeof bridge.open !== "function") return false;
301
+ function measureComposerCenterY() {
302
+ if (typeof document === "undefined") return null;
286
303
  try {
287
- closePlatformPage();
288
- const layer = document.createElement("div");
289
- layer.className = "balance-platform-layer";
290
- const header = document.createElement("header");
291
- header.className = "balance-platform-header";
292
- // 与 DSH 的 PlatformOverlay 一致:让标题栏可拖动窗口
293
- header.setAttribute("data-window-drag", "");
294
- const back = document.createElement("button");
295
- back.type = "button";
296
- back.className = "balance-platform-back";
297
- // 与 DSH 的 PlatformOverlay 一致:chevron 图标 + 文案。DSH 用的是 primitives 的
298
- // IconChevronLeftOutlineRegular 组件;这里覆盖层是纯 DOM,自绘同款 SVG,
299
- // 免得为了一个图标把 React 渲染引进覆盖层。
300
- const chevron = document.createElementNS("http://www.w3.org/2000/svg", "svg");
301
- chevron.setAttribute("viewBox", "0 0 16 16");
302
- chevron.setAttribute("width", "16");
303
- chevron.setAttribute("height", "16");
304
- chevron.setAttribute("aria-hidden", "true");
305
- const chevronPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
306
- chevronPath.setAttribute("d", "M10 3.5 5.5 8 10 12.5");
307
- chevronPath.setAttribute("fill", "none");
308
- chevronPath.setAttribute("stroke", "currentColor");
309
- chevronPath.setAttribute("stroke-width", "1.6");
310
- chevronPath.setAttribute("stroke-linecap", "round");
311
- chevronPath.setAttribute("stroke-linejoin", "round");
312
- chevron.appendChild(chevronPath);
313
- back.appendChild(chevron);
314
- back.appendChild(document.createTextNode("返回 DeepSeek Harness"));
315
- back.addEventListener("click", closePlatformPage);
316
- header.appendChild(back);
317
- const viewport = document.createElement("div");
318
- viewport.className = "balance-platform-viewport";
319
- layer.appendChild(header);
320
- layer.appendChild(viewport);
321
- document.body.appendChild(layer);
322
-
323
- // 原生视口按 CSS 像素贴合这个占位元素(DSH 也是拿 getBoundingClientRect())
324
- const bounds = () => {
325
- const rect = viewport.getBoundingClientRect();
326
- return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
327
- };
328
- const observer = new ResizeObserver(() => {
329
- if (platformOverlay === null) return;
330
- try {
331
- const moved = bridge.setBounds(bounds());
332
- if (moved && typeof moved.catch === "function") moved.catch(() => {});
333
- } catch {
334
- /* ignore */
304
+ const host = document.querySelector('[data-slot="conversation.composer.bar"]');
305
+ if (host) {
306
+ const own = host.getBoundingClientRect();
307
+ if (own.height > 0) return own.top + own.height / 2;
308
+ // slot 出口容器常常是 display:contents(不生成盒子,rect 全 0),
309
+ // 那种情况取后代里面积最大的元素当卡片
310
+ let best = null;
311
+ for (const el of host.querySelectorAll("*")) {
312
+ const rect = el.getBoundingClientRect();
313
+ if (rect.width <= 0 || rect.height <= 0) continue;
314
+ if (best === null || rect.width * rect.height > best.width * best.height) best = rect;
335
315
  }
336
- });
337
- const onKey = (event) => {
338
- if (event.key === "Escape") closePlatformPage();
339
- };
340
- platformOverlay = { layer, bridge, observer, onKey };
341
- observer.observe(viewport);
342
- window.addEventListener("keydown", onKey, true);
343
- const opened = bridge.open(page, bounds());
344
- if (opened && typeof opened.catch === "function") opened.catch(() => closePlatformPage());
345
- return true;
346
- } catch {
347
- closePlatformPage();
348
- return false;
349
- }
350
- }
351
-
352
- /** 打开应用内嵌浏览器的标签页(无原生视口时的二级回退)。 */
353
- function openSidebarBrowser(url) {
354
- try {
355
- const get = pluginCtx && typeof pluginCtx.get === "function" ? pluginCtx.get.bind(pluginCtx) : null;
356
- if (get === null) return false;
357
- const right = get("sidebarRight");
358
- const tabs = get("sidebarRightTabs");
359
- const hasBrowser = tabs && typeof tabs.get === "function" && tabs.get("browser") !== undefined;
360
- if (right && typeof right.openTab === "function" && hasBrowser) {
361
- right.openTab("browser", { params: { url } });
362
- return true;
316
+ if (best) return best.top + best.height / 2;
317
+ }
318
+ // 再回退到输入元素本身(老版本 DSH 没有 data-slot 标记)
319
+ const editor = document.querySelector('textarea, [contenteditable="true"], [role="textbox"]');
320
+ if (editor) {
321
+ const rect = editor.getBoundingClientRect();
322
+ if (rect.height > 0) return rect.top + rect.height / 2;
363
323
  }
364
324
  } catch {
365
- // 侧栏浏览器不可用(旧版 DSH / 服务未就绪)
325
+ /* 结构变了就当量不到,交给 CSS 兜底 */
366
326
  }
367
- return false;
327
+ return null;
368
328
  }
369
329
 
370
- /** 最终回退:交给系统浏览器。 */
330
+ /** 打开外部网页:交给系统默认浏览器。DSH 的 Electron 主进程拦截 window.open
331
+ * 并转给 shell.openExternal,所以这里不会弹出应用内窗口。 */
371
332
  function openInSystemBrowser(url) {
372
333
  try {
373
334
  window.open(url, "_blank", "noopener,noreferrer");
@@ -376,10 +337,8 @@ window.__ModuleLoader__.load({
376
337
  }
377
338
  }
378
339
 
379
- /** 打开平台页面:原生视口 → 侧栏浏览器 → 系统浏览器。 */
340
+ /** 打开平台页面:一律走系统默认浏览器,不在应用内开原生视口或内嵌浏览器。 */
380
341
  function openPlatformOrUrl(page, url) {
381
- if (openPlatformPage(page)) return;
382
- if (openSidebarBrowser(url)) return;
383
342
  openInSystemBrowser(url);
384
343
  }
385
344
 
@@ -564,19 +523,105 @@ window.__ModuleLoader__.load({
564
523
  const onDuty = PEAK_WEEKDAYS.includes(weekday) || kind === "workday"; // 调休上班的周末按工作日算
565
524
  return onDuty && PEAK_WINDOWS.some((w) => hour >= w.start && hour < w.end) ? "peak" : "offpeak";
566
525
  }
567
- /** 按当前时段调整价格:高峰 ×2、空闲原价。注意:若服务端已配 config.peak:true
568
- * (强制高峰价翻倍),此处会再乘一次,两种方式不要同时使用。 */
569
- function periodAdjustedPricing(p) {
526
+ /** 按当前时段调整价格:高峰 × 倍率、空闲原价。
527
+ * ⚠️ p 必须是**目录价**(服务端 pricing 字段即目录价);倍率来自设置的 peakMultiplier,
528
+ * 若服务端传来的价已含倍率,这里会再乘一次造成金额虚高。 */
529
+ function periodAdjustedPricing(p, multiplier = 2) {
570
530
  if (currentPeriod() !== "peak") return p;
571
531
  return {
572
- inputCnyPerMillion: p.inputCnyPerMillion * 2,
573
- cacheReadCnyPerMillion: p.cacheReadCnyPerMillion * 2,
574
- cacheWriteCnyPerMillion: p.cacheWriteCnyPerMillion * 2,
575
- outputCnyPerMillion: p.outputCnyPerMillion * 2,
532
+ inputCnyPerMillion: p.inputCnyPerMillion * multiplier,
533
+ cacheReadCnyPerMillion: p.cacheReadCnyPerMillion * multiplier,
534
+ cacheWriteCnyPerMillion: p.cacheWriteCnyPerMillion * multiplier,
535
+ outputCnyPerMillion: p.outputCnyPerMillion * multiplier,
536
+ };
537
+ }
538
+
539
+ // ─── 插件设置:与 DSH「插件」页的配置卡片共用同一份 ────────────────────
540
+ // 设置真身在服务端的 <dshHome>/balance-dsh.json(见 lib/index.js),客户端只做
541
+ // 读缓存 + 写提交。默认值与服务端 DEFAULT_SETTINGS 逐字段保持一致:服务端暂时
542
+ // 不可用(例如服务端改动还没重启生效)时 UI 仍按默认值工作,不会白屏或报错。
543
+ const SETTINGS_DEFAULTS = {
544
+ refreshIntervalSec: 60,
545
+ lowBalanceThreshold: 10,
546
+ apiKey: "",
547
+ showBalanceRow: true,
548
+ showUsagePill: true,
549
+ showPeriodLabel: true,
550
+ showFab: true,
551
+ fabSize: 60,
552
+ fabLeft: 44,
553
+ statsSessionLimit: 200,
554
+ statsCacheSec: 300,
555
+ peakMultiplier: 2,
556
+ priceFlash: null,
557
+ pricePro: null,
558
+ };
559
+
560
+ let settingsCache = { value: { ...SETTINGS_DEFAULTS }, loaded: false, version: "" };
561
+ const settingsListeners = new Set();
562
+ let settingsInFlight = null;
563
+
564
+ function emitSettings() {
565
+ for (const listener of settingsListeners) listener();
566
+ }
567
+
568
+ /** 拉一次设置(并发自动合并);成功后写缓存并通知订阅者。 */
569
+ function fetchSettingsOnce(force) {
570
+ if (settingsInFlight !== null) return settingsInFlight;
571
+ if (!force && settingsCache.loaded) return Promise.resolve(settingsCache.value);
572
+ settingsInFlight = (async () => {
573
+ try {
574
+ const res = await fetch("/balance-api?config=1");
575
+ const data = await res.json();
576
+ if (data && data.ok === true && data.settings) {
577
+ settingsCache = {
578
+ value: { ...SETTINGS_DEFAULTS, ...data.settings },
579
+ loaded: true,
580
+ version: typeof data.version === "string" ? data.version : settingsCache.version,
581
+ };
582
+ }
583
+ } catch {
584
+ // 服务端不可用:保持默认值,不打扰用户
585
+ } finally {
586
+ settingsInFlight = null;
587
+ }
588
+ emitSettings();
589
+ return settingsCache.value;
590
+ })();
591
+ return settingsInFlight;
592
+ }
593
+
594
+ /** 提交设置补丁;成功后以服务端回传的完整设置为准(服务端会做校验与夹紧)。 */
595
+ async function saveSettings(patch) {
596
+ const res = await fetch("/balance-api?config=1", {
597
+ method: "POST",
598
+ headers: { "Content-Type": "application/json" },
599
+ body: JSON.stringify({ settings: { ...settingsCache.value, ...patch } }),
600
+ });
601
+ const data = await res.json();
602
+ if (!data || data.ok !== true) throw new Error((data && data.error) || "保存失败");
603
+ settingsCache = {
604
+ value: { ...SETTINGS_DEFAULTS, ...data.settings },
605
+ loaded: true,
606
+ version: typeof data.version === "string" ? data.version : settingsCache.version,
576
607
  };
608
+ emitSettings();
609
+ return settingsCache.value;
577
610
  }
578
611
 
579
- // ─── 余额数据:模块级共享缓存 + 60s 轮询 ──────────────────────────────
612
+ /** 订阅设置(首次挂载异步拉一次)。 */
613
+ function useSettings() {
614
+ const [, force] = react.useState(0);
615
+ react.useEffect(() => {
616
+ const listener = () => force((n) => n + 1);
617
+ settingsListeners.add(listener);
618
+ void fetchSettingsOnce(false);
619
+ return () => settingsListeners.delete(listener);
620
+ }, []);
621
+ return settingsCache.value;
622
+ }
623
+
624
+ // ─── 余额数据:模块级共享缓存 + 轮询(间隔取自 refreshIntervalSec) ────
580
625
  // 切换会话时 header 组件会重新挂载,若每个实例各自拉取会先消失再出现(闪烁);
581
626
  // 缓存全局存一份,任何实例挂载都直接读缓存,无需重新请求。
582
627
  let balanceCache = { status: "loading", balance: null, error: null, refreshing: false };
@@ -609,19 +654,38 @@ window.__ModuleLoader__.load({
609
654
  emitBalance();
610
655
  }
611
656
 
657
+ // 轮询间隔由设置 refreshIntervalSec 决定:间隔变了要重建定时器。
658
+ let balancePollIntervalSec = 0;
659
+
612
660
  function ensureBalancePolling() {
613
- if (balancePollTimer !== null) return;
661
+ const raw = Number(settingsCache.value.refreshIntervalSec);
662
+ const sec = Number.isFinite(raw) && raw > 0 ? raw : 60;
663
+ if (balancePollTimer !== null && balancePollIntervalSec === sec) return;
664
+ if (balancePollTimer !== null) {
665
+ clearInterval(balancePollTimer);
666
+ balancePollTimer = null;
667
+ }
668
+ balancePollIntervalSec = sec;
614
669
  fetchBalanceOnce();
615
- balancePollTimer = setInterval(fetchBalanceOnce, 60_000);
670
+ balancePollTimer = setInterval(fetchBalanceOnce, sec * 1000);
616
671
  }
617
672
 
618
673
  function useBalance() {
619
674
  const [, force] = react.useState(0);
620
675
  react.useEffect(() => {
621
- const listener = () => force((n) => n + 1);
676
+ // 余额变化 → 重渲染;设置变化 → 重渲染并按新间隔重建轮询
677
+ const listener = () => {
678
+ force((n) => n + 1);
679
+ ensureBalancePolling();
680
+ };
622
681
  balanceListeners.add(listener);
682
+ settingsListeners.add(listener);
683
+ void fetchSettingsOnce(false);
623
684
  ensureBalancePolling();
624
- return () => balanceListeners.delete(listener);
685
+ return () => {
686
+ balanceListeners.delete(listener);
687
+ settingsListeners.delete(listener);
688
+ };
625
689
  }, []);
626
690
  return balanceCache;
627
691
  }
@@ -994,10 +1058,27 @@ window.__ModuleLoader__.load({
994
1058
  (stats?.limited ? "(为保证性能仅取最近 200 个)" : "") +
995
1059
  ";按北京时间和实际模型峰谷价格估算,结果缓存 5 分钟。",
996
1060
  }),
997
- jsx.jsx("a", {
998
- className: "balance-stats-link",
999
- ...externalLink(USAGE_URL, "usage"),
1000
- children: "查看官方用量",
1061
+ jsx.jsxs("div", {
1062
+ className: "balance-stats-actions",
1063
+ children: [
1064
+ jsx.jsx("a", {
1065
+ className: "balance-stats-link",
1066
+ ...externalLink(USAGE_URL, "usage"),
1067
+ children: "查看官方用量",
1068
+ }),
1069
+ // 官方「去充值」走同一个原生平台视口(带平台凭据,不用网页登录)
1070
+ jsx.jsx("a", {
1071
+ className: "balance-stats-link balance-stats-link--primary",
1072
+ ...externalLink(TOP_UP_URL, "top-up"),
1073
+ children: "去充值",
1074
+ }),
1075
+ // 「更多账号信息」对齐 DSH 原生账号卡片:开平台首页(外部窗口)
1076
+ jsx.jsx("a", {
1077
+ className: "balance-stats-link",
1078
+ ...externalLink(PLATFORM_HOME_URL, null),
1079
+ children: "更多账号信息",
1080
+ }),
1081
+ ],
1001
1082
  }),
1002
1083
  ],
1003
1084
  }),
@@ -1055,6 +1136,17 @@ window.__ModuleLoader__.load({
1055
1136
  // ─── 余额显示:单击统计,双击 DeepSeek 用量页,右键刷新 ─────────────
1056
1137
  function BalanceFooter({ wide }) {
1057
1138
  const state = useBalance();
1139
+ const settings = useSettings();
1140
+ // 设置里的外观值:夹到安全范围,手改 JSON 写出畸形值也不会把 UI 搞坏
1141
+ const rawSize = Number(settings.fabSize);
1142
+ const fabSize = Number.isFinite(rawSize) ? Math.min(120, Math.max(32, rawSize)) : 60;
1143
+ const rawLeft = Number(settings.fabLeft);
1144
+ const fabLeft = Number.isFinite(rawLeft) ? Math.min(600, Math.max(0, rawLeft)) : 44;
1145
+ const rawThreshold = Number(settings.lowBalanceThreshold);
1146
+ const lowThreshold = Number.isFinite(rawThreshold) ? rawThreshold : 10;
1147
+ // 圆钮里字号随直径等比缩放(60px → 13px;长金额 → 11px)
1148
+ const fabFont = Math.max(9, Math.round((fabSize * 13) / 60));
1149
+ const fabFontDense = Math.max(8, Math.round((fabSize * 11) / 60));
1058
1150
  const [statsOpen, setStatsOpen] = react.useState(false);
1059
1151
  const [statsClosing, setStatsClosing] = react.useState(false);
1060
1152
  const clickTimer = react.useRef(null);
@@ -1066,6 +1158,45 @@ window.__ModuleLoader__.load({
1066
1158
  if (clickTimer.current !== null) clearTimeout(clickTimer.current);
1067
1159
  if (closeTimer.current !== null) clearTimeout(closeTimer.current);
1068
1160
  }, []);
1161
+ // 收起态:让圆形余额与聊天输入卡片(composer card)**垂直居中对齐**。
1162
+ // 卡片位置随窗口尺寸、输入行数变化,硬编码 bottom 必然错位,所以运行时测量。
1163
+ // DSH 的 slot 契约里 conversation.composer.bar = "Resident composer body"(就是那张卡片),
1164
+ // 而本插件的消耗行挂在 conversation.composer.dock(卡片下方的 ambient entries)。
1165
+ react.useEffect(() => {
1166
+ if (wide) return;
1167
+ const anchor = anchorRef.current;
1168
+ if (!anchor) return;
1169
+ const apply = () => {
1170
+ const centerY = measureComposerCenterY();
1171
+ if (centerY === null) {
1172
+ // 测不到(旧版 DSH / 卡片尚未渲染)→ 交回 CSS 里的 bottom:12px 兜底
1173
+ anchor.style.top = "";
1174
+ anchor.style.bottom = "";
1175
+ return;
1176
+ }
1177
+ // 必须同时清掉 bottom:fixed 元素同时有 top 和 bottom 会被拉伸
1178
+ anchor.style.bottom = "auto";
1179
+ anchor.style.top = Math.round(centerY - (anchor.offsetHeight || fabSize) / 2) + "px";
1180
+ };
1181
+ apply();
1182
+ window.addEventListener("resize", apply);
1183
+ let observer = null;
1184
+ try {
1185
+ const card = document.querySelector('[data-slot="conversation.composer.bar"]');
1186
+ if (typeof ResizeObserver === "function" && card) {
1187
+ observer = new ResizeObserver(apply);
1188
+ observer.observe(card);
1189
+ }
1190
+ } catch {
1191
+ /* 结构变了就只靠 resize 兜底 */
1192
+ }
1193
+ return () => {
1194
+ window.removeEventListener("resize", apply);
1195
+ if (observer) observer.disconnect();
1196
+ anchor.style.top = "";
1197
+ anchor.style.bottom = "";
1198
+ };
1199
+ }, [wide, fabSize]);
1069
1200
  const closeStats = react.useCallback(() => {
1070
1201
  if (!statsOpen || statsClosing) return;
1071
1202
  setStatsClosing(true);
@@ -1115,14 +1246,15 @@ window.__ModuleLoader__.load({
1115
1246
  onDoubleClick: openUsagePage,
1116
1247
  onContextMenu: onBalanceContextMenu,
1117
1248
  };
1118
- const withPanel = (node) => jsx.jsxs("div", {
1249
+ const withPanel = (node, hint, anchorStyle) => jsx.jsxs("div", {
1119
1250
  ref: anchorRef,
1120
1251
  className: "balance-stats-anchor balance-stats-anchor--" + (wide ? "wide" : "compact"),
1252
+ style: anchorStyle,
1121
1253
  children: [
1122
1254
  // 悬停提示走 DSH 主题化浮层:primitives.Tooltip 内部是 cloneElement(children, {ref}),
1123
1255
  // 不会插入额外 DOM 层,因此不破坏余额行在 flex 容器里的 42px 布局(浮层本身 portal 到 body)。
1124
1256
  jsx.jsx(primitives.Tooltip, {
1125
- label: "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
1257
+ label: hint || "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
1126
1258
  side: "top",
1127
1259
  delayMs: 400,
1128
1260
  children: node,
@@ -1142,25 +1274,44 @@ window.__ModuleLoader__.load({
1142
1274
  });
1143
1275
 
1144
1276
  if (!wide) {
1277
+ // 设置里关掉圆钮就整个不渲染(统计面板入口随之消失,符合「关掉显示」的预期)
1278
+ if (settings.showFab === false) return null;
1145
1279
  if (state.status === "error" || !state.balance) return null;
1146
1280
  const b = state.balance;
1281
+ // 尺寸 / 水平位置由设置决定,用内联样式覆盖 CSS 默认值(CSS 保留 60px/44px 兜底)
1282
+ const fabStyle = { width: fabSize + "px", height: fabSize + "px", left: fabLeft + "px" };
1283
+ const anchorStyle = { left: fabLeft + "px" };
1147
1284
  const total = Number.parseFloat(b.total_balance ?? "0");
1148
1285
  const depleted = b.is_available === false || !Number.isFinite(total) || total <= 0;
1149
- if (depleted) {
1150
- return jsx.jsx("a", {
1151
- className: "balance-fab balance-fab-dark",
1152
- ...externalLink(TOP_UP_URL, "top-up", { onContextMenu: onBalanceContextMenu }),
1153
- children: "去充值",
1154
- });
1155
- }
1156
- return withPanel(jsx.jsx("a", {
1157
- className: "balance-fab",
1158
- ...externalLink(USAGE_URL, "usage"),
1159
- ...interactions,
1160
- children: "¥" + b.total_balance,
1161
- }));
1286
+ // ⚠️ 必须 portal 到 body:收起态 DSH 把整个 footArea 设为 display:none
1287
+ // (`[data-windows-titlebar] .<hash>_collapsed .<hash>_footArea{display:none}`),
1288
+ // 而 `sidebar.footer.action` 就在 footArea 里。display:none 的祖先不会生成盒子,
1289
+ // 后代即使 position:fixed 也完全不渲染——组件确实被调用了(wide=false),但 DOM 不可见。
1290
+ // portal 到 body 后 position:fixed 才真正生效(2026-09-28 用户反馈「收起侧边栏余额不显示」)。
1291
+ // 圆形 FAB 放不下长文本:余额显示「¥<金额>」,余额耗尽时用「充值」而不是「去充值」
1292
+ const balanceText = "¥" + b.total_balance;
1293
+ // 两种情况都经 withPanel:收起态的定位落在它渲染的 .balance-stats-anchor--compact 上
1294
+ // (测量 effect 通过 anchorRef 设 top),裸 FAB 就没有 anchor 可量了。
1295
+ const fab = depleted
1296
+ ? withPanel(jsx.jsx("a", {
1297
+ className: "balance-fab balance-fab-dark",
1298
+ style: { ...fabStyle, fontSize: fabFont + "px" },
1299
+ ...externalLink(TOP_UP_URL, "top-up", { onContextMenu: onBalanceContextMenu }),
1300
+ children: "充值",
1301
+ }), "余额不足,单击前往充值", anchorStyle)
1302
+ : withPanel(jsx.jsx("a", {
1303
+ className: "balance-fab" + (balanceText.length > 6 ? " balance-fab--dense" : ""),
1304
+ style: { ...fabStyle, fontSize: (balanceText.length > 6 ? fabFontDense : fabFont) + "px" },
1305
+ ...externalLink(USAGE_URL, "usage"),
1306
+ ...interactions,
1307
+ children: balanceText,
1308
+ }), undefined, anchorStyle);
1309
+ return typeof document === "undefined" ? fab : reactDom.createPortal(fab, document.body);
1162
1310
  }
1163
1311
 
1312
+ // 展开态余额行:设置里关掉就不渲染(收起态圆钮由上面的 showFab 单独控制)
1313
+ if (settings.showBalanceRow === false) return null;
1314
+
1164
1315
  if (state.status === "error") {
1165
1316
  return withPanel(jsx.jsx("div", {
1166
1317
  className: "balance-footer",
@@ -1205,7 +1356,13 @@ window.__ModuleLoader__.load({
1205
1356
  }
1206
1357
 
1207
1358
  const period = currentPeriod();
1208
- const low = total > 0 && total < 10;
1359
+ const low = total > 0 && total < lowThreshold;
1360
+ // 时段标签可关:关掉后只留金额(「刷新中…」的反馈也一起隐藏,保持一行干净)
1361
+ const periodNode = settings.showPeriodLabel === false
1362
+ ? null
1363
+ : state.refreshing
1364
+ ? jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--refreshing", children: "刷新中…" })
1365
+ : jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--" + period, children: period === "peak" ? "高峰" : "空闲" });
1209
1366
  return withPanel(jsx.jsxs("div", {
1210
1367
  className: "balance-footer",
1211
1368
  ...interactions,
@@ -1216,10 +1373,8 @@ window.__ModuleLoader__.load({
1216
1373
  className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy"),
1217
1374
  children: [
1218
1375
  totalText,
1219
- " • ",
1220
- state.refreshing
1221
- ? jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--refreshing", children: "刷新中…" })
1222
- : jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--" + period, children: period === "peak" ? "高峰" : "空闲" }),
1376
+ periodNode === null ? null : " • ",
1377
+ periodNode,
1223
1378
  ],
1224
1379
  }),
1225
1380
  ],
@@ -1228,7 +1383,13 @@ window.__ModuleLoader__.load({
1228
1383
 
1229
1384
  // ─── 当前会话消耗:输入区 dock ──────────────────────────────────────────
1230
1385
  function UsageCostLine({ useProjection, sessionId }) {
1386
+ const settings = useSettings();
1231
1387
  const p = usePricing();
1388
+ // 高峰倍率来自设置(服务端 pricing 字段是目录价,倍率由这里单独乘)
1389
+ const peakMultiplier = (() => {
1390
+ const n = Number(settings.peakMultiplier);
1391
+ return Number.isFinite(n) && n > 0 ? n : 2;
1392
+ })();
1232
1393
  const model = useCurrentModel();
1233
1394
  const usage = typeof useProjection === "function" ? useProjection("tokenUsage") : undefined;
1234
1395
  const [splitUsage, setSplitUsage] = react.useState(null);
@@ -1318,13 +1479,13 @@ function UsageCostLine({ useProjection, sessionId }) {
1318
1479
  // 服务端分桶可能"非 null 但为空"(拿不到该会话事件),此时必须回退本地投影估算,否则金额会显示 ¥0.0000
1319
1480
  const splitUsable =
1320
1481
  split != null && ((Array.isArray(split.byModel) && split.byModel.length > 0) || (split.totalTokens ?? 0) > 0);
1321
- const cost = splitUsable ? computeSplitCost(split, p) : computeCost(usage, periodAdjustedPricing(p));
1482
+ const cost = splitUsable ? computeSplitCost(split, p, peakMultiplier) : computeCost(usage, periodAdjustedPricing(p, peakMultiplier));
1322
1483
  const peakTokens = splitUsable ? split.peak.inputTokens + split.peak.cacheReadTokens + split.peak.cacheWriteTokens + split.peak.outputTokens : 0;
1323
1484
  const offpeakTokens = splitUsable ? split.offpeak.inputTokens + split.offpeak.cacheReadTokens + split.offpeak.cacheWriteTokens + split.offpeak.outputTokens : 0;
1324
1485
 
1325
1486
  // 子代理消耗(与主会话分开,同样跨时段精确计费)
1326
1487
  const hasSubagent = splitReady && subagentUsage && subagentUsage.totalTokens > 0;
1327
- const subCost = hasSubagent ? computeSplitCost(subagentUsage, p) : 0;
1488
+ const subCost = hasSubagent ? computeSplitCost(subagentUsage, p, peakMultiplier) : 0;
1328
1489
  const subNewTokens = hasSubagent
1329
1490
  ? (subagentUsage.inputTokens ?? 0) + (subagentUsage.outputTokens ?? 0)
1330
1491
  : 0;
@@ -1417,6 +1578,9 @@ function UsageCostLine({ useProjection, sessionId }) {
1417
1578
  jsx.jsx("dl", { className: "balance-usage-panel__details", children: detailRows("c", costDetails) }),
1418
1579
  ];
1419
1580
 
1581
+ // 设置里关掉消耗行就整个不渲染(点开的明细浮层随之一并消失)
1582
+ if (settings.showUsagePill === false) return null;
1583
+
1420
1584
  return jsx.jsxs("div", {
1421
1585
  className: "balance-usage-wrap",
1422
1586
  children: [
@@ -1447,12 +1611,253 @@ function UsageCostLine({ useProjection, sessionId }) {
1447
1611
  });
1448
1612
  }
1449
1613
 
1614
+ // ─── 插件配置卡片:DSH「插件」页 → balance-dsh → 设置 ─────────────────────
1615
+ // slot 契约(dsh-client-ui-plugin-manager 的 plugins.bundle.config,keyed 类型):
1616
+ // key = bundle 的包名,本插件即 "balance-dsh"
1617
+ // ownerProps = { view: "summary" | "page", form?: ConfigPageForm }
1618
+ // view="summary" → 插件列表里的单行摘要;view="page" → 详情页完整表单
1619
+ // (渲染在 bundle 描述与行列表之间)
1620
+ // 刻意**不用** ownerProps.form:那要接入 DSH 的 settings schema(内部 API,
1621
+ // 无兼容承诺),配置值改走插件自己的 /balance-api?config=1(见 lib/index.js),
1622
+ // 所以这里忽略 form、自己读写。缺失 view / props 时按 page 渲染,不会崩。
1623
+ function BalanceSettingsCard(props) {
1624
+ const view = props && props.view;
1625
+ const settings = useSettings();
1626
+ const [draft, setDraft] = react.useState(null);
1627
+ const [saving, setSaving] = react.useState(false);
1628
+ const [status, setStatus] = react.useState({ kind: "", text: "" });
1629
+
1630
+ // 拿到设置后灌一次草稿;draft 已有内容就不再覆盖,避免打断正在输入的用户
1631
+ react.useEffect(() => {
1632
+ setDraft((current) => (current === null ? { ...settings } : current));
1633
+ }, [settings]);
1634
+
1635
+ const value = draft === null ? settings : draft;
1636
+ // 实时保存:改动后 debounce 落盘,避免连续输入时每敲一个键都发一次请求。
1637
+ // 用 ref 拿「最新值」,否则快速连续改两个字段时第二个 patch 会基于过期闭包丢改动。
1638
+ const valueRef = react.useRef(value);
1639
+ valueRef.current = value;
1640
+ const saveTimer = react.useRef(null);
1641
+ const pendingRef = react.useRef(null);
1642
+ react.useEffect(() => () => {
1643
+ if (saveTimer.current) clearTimeout(saveTimer.current);
1644
+ }, []);
1645
+
1646
+ const commit = async (next) => {
1647
+ setSaving(true);
1648
+ try {
1649
+ await saveSettings(next);
1650
+ setStatus({ kind: "ok", text: "已保存" });
1651
+ } catch (error) {
1652
+ setStatus({ kind: "error", text: error && error.message ? error.message : String(error) });
1653
+ } finally {
1654
+ setSaving(false);
1655
+ }
1656
+ };
1657
+ const queueSave = (next) => {
1658
+ pendingRef.current = next;
1659
+ if (saveTimer.current) clearTimeout(saveTimer.current);
1660
+ setStatus({ kind: "", text: "修改中…" });
1661
+ saveTimer.current = setTimeout(() => {
1662
+ saveTimer.current = null;
1663
+ const payload = pendingRef.current;
1664
+ pendingRef.current = null;
1665
+ if (payload) commit(payload);
1666
+ }, 350);
1667
+ };
1668
+ const patch = (key, next) => {
1669
+ const merged = { ...valueRef.current, [key]: next };
1670
+ setDraft(merged);
1671
+ queueSave(merged);
1672
+ };
1673
+ const onReset = () => {
1674
+ const next = { ...SETTINGS_DEFAULTS };
1675
+ setDraft(next);
1676
+ queueSave(next);
1677
+ };
1678
+
1679
+ // 插件列表里的单行摘要
1680
+ if (view === "summary") {
1681
+ const shown = [
1682
+ value.showBalanceRow !== false ? "余额行" : null,
1683
+ value.showUsagePill !== false ? "消耗行" : null,
1684
+ value.showFab !== false ? "圆钮 " + value.fabSize + "px" : null,
1685
+ ].filter(Boolean).join(" · ");
1686
+ return jsx.jsx("span", {
1687
+ className: "balance-settings__summary",
1688
+ children: "每 " + value.refreshIntervalSec + "s 刷新余额 · " + (shown === "" ? "界面全部隐藏" : shown),
1689
+ });
1690
+ }
1691
+
1692
+ const row = (key, label, hint, control) => jsx.jsxs("div", {
1693
+ className: "balance-settings__row",
1694
+ children: [
1695
+ jsx.jsxs("div", {
1696
+ className: "balance-settings__label",
1697
+ children: [
1698
+ jsx.jsx("span", { className: "balance-settings__label-text", children: label }),
1699
+ hint ? jsx.jsx("span", { className: "balance-settings__hint", children: hint }) : null,
1700
+ ],
1701
+ }),
1702
+ jsx.jsx("div", { className: "balance-settings__control", children: control }),
1703
+ ],
1704
+ }, key);
1705
+
1706
+ const toggle = (key, label, onClick) => jsx.jsx("button", {
1707
+ type: "button",
1708
+ role: "switch",
1709
+ "aria-checked": value[key] === true,
1710
+ "aria-label": label,
1711
+ className: "balance-settings__toggle",
1712
+ onClick: onClick || (() => patch(key, value[key] !== true)),
1713
+ });
1714
+
1715
+ const numberInput = (key, min, max) => jsx.jsx("input", {
1716
+ className: "balance-settings__input",
1717
+ type: "number",
1718
+ min,
1719
+ max,
1720
+ step: 1,
1721
+ value: value[key],
1722
+ onChange: (event) => patch(key, event.target.value),
1723
+ });
1724
+
1725
+ const numberRow = (key, label, hint, min, max, unit) =>
1726
+ row(key, label, hint, [
1727
+ numberInput(key, min, max),
1728
+ jsx.jsx("span", { className: "balance-settings__unit", children: unit, key: key + "-unit" }),
1729
+ ]);
1730
+
1731
+ const toggleRow = (key, label, hint) => row(key, label, hint, toggle(key, label));
1732
+
1733
+ // 整张设置卡片可折叠(默认收起):形式对齐 DSH 原生 DisclosureRow —— 一行
1734
+ // 标题 + 描述 + 右侧 chevron,展开时箭头翻转 180 度。
1735
+ const [openSettings, setOpenSettings] = react.useState(false);
1736
+ const chevron = jsx.jsx("svg", {
1737
+ className: "balance-settings__chevron",
1738
+ viewBox: "0 0 16 16",
1739
+ width: 14,
1740
+ height: 14,
1741
+ "aria-hidden": "true",
1742
+ children: jsx.jsx("path", {
1743
+ d: "M4 6.5l4 4 4-4",
1744
+ fill: "none",
1745
+ stroke: "currentColor",
1746
+ strokeWidth: 1.5,
1747
+ strokeLinecap: "round",
1748
+ strokeLinejoin: "round",
1749
+ }),
1750
+ });
1751
+ const group = (key, title, rows) => jsx.jsxs("div", {
1752
+ className: "balance-settings__group",
1753
+ children: [
1754
+ jsx.jsx("div", { className: "balance-settings__group-title", children: title }),
1755
+ ...rows,
1756
+ ],
1757
+ }, key);
1758
+
1759
+ // 自定义价格已从界面移除:价格本来就跟随官方内置价表(官方调价时更新插件即可),
1760
+ // 普通用户没有自己填的必要。服务端仍支持 settings.priceByModel 覆盖价,
1761
+ // 需要的人可以直接改 ~/.dsh/balance-dsh.json。
1762
+
1763
+ return jsx.jsxs("div", {
1764
+ className: "balance-settings",
1765
+ "data-open": openSettings ? "true" : "false",
1766
+ children: [
1767
+ jsx.jsxs("button", {
1768
+ type: "button",
1769
+ className: "balance-settings__head-btn",
1770
+ "aria-expanded": openSettings,
1771
+ onClick: () => setOpenSettings((prev) => prev !== true),
1772
+ children: [
1773
+ jsx.jsxs("div", {
1774
+ className: "balance-settings__head-text",
1775
+ children: [
1776
+ jsx.jsxs("div", {
1777
+ className: "balance-settings__head",
1778
+ children: [
1779
+ jsx.jsx("span", { className: "balance-settings__title", children: "余额与用量" }),
1780
+ settingsCache.version
1781
+ ? jsx.jsx("span", { className: "balance-settings__version", children: "v" + settingsCache.version })
1782
+ : null,
1783
+ ],
1784
+ }),
1785
+ jsx.jsx("p", {
1786
+ className: "balance-settings__desc",
1787
+ children: "余额刷新频率、界面显示开关、收起态圆钮外观、统计范围与计费口径。改动即时保存并生效;其中服务端相关项需重启 DSH。",
1788
+ }),
1789
+ ],
1790
+ }),
1791
+ chevron,
1792
+ ],
1793
+ }),
1794
+
1795
+ openSettings ? jsx.jsxs("div", {
1796
+ className: "balance-settings__body",
1797
+ children: [
1798
+ group("balance", "余额", [
1799
+ numberRow("refreshIntervalSec", "余额刷新间隔", "轮询间隔;对话产生新消耗时也会顺带刷新。", 5, 3600, "秒"),
1800
+ numberRow("lowBalanceThreshold", "低余额警示阈值", "余额低于该值时金额转为琥珀色。", 0, 100000, "元"),
1801
+ row("apiKey", "API Key", "官方账号未登录或读不到余额时的回退凭据;留空则只用官方账号。",
1802
+ jsx.jsx("input", {
1803
+ className: "balance-settings__input",
1804
+ style: { width: "280px" },
1805
+ type: "password",
1806
+ autoComplete: "off",
1807
+ placeholder: "sk-…(留空 = 不设置)",
1808
+ value: value.apiKey == null ? "" : value.apiKey,
1809
+ onChange: (event) => patch("apiKey", event.target.value),
1810
+ })),
1811
+ ]),
1812
+
1813
+ group("display", "界面显示", [
1814
+ toggleRow("showBalanceRow", "侧边栏余额行", "展开侧边栏时底部紧挨设置齿轮的那一行。"),
1815
+ toggleRow("showUsagePill", "会话消耗行", "输入框下方的「本会话消耗」胶囊(含点开的明细浮层)。"),
1816
+ toggleRow("showPeriodLabel", "高峰 / 空闲标签", "余额行里金额后面的时段提示。"),
1817
+ ]),
1818
+
1819
+ group("fab", "收起态圆钮", [
1820
+ toggleRow("showFab", "显示圆钮", "收起侧边栏时左下角的悬浮圆形余额。"),
1821
+ numberRow("fabSize", "直径", "圆钮大小;圆内字号按比例缩放。", 32, 120, "px"),
1822
+ numberRow("fabLeft", "距左缘", "数值越大越靠右。", 0, 600, "px"),
1823
+ ]),
1824
+
1825
+ group("stats", "用量统计", [
1826
+ numberRow("statsSessionLimit", "扫描会话数上限", "统计面板最多回看多少个历史会话(越大越慢)。", 10, 2000, "个"),
1827
+ numberRow("statsCacheSec", "统计缓存时长", "统计结果缓存多久;0 = 每次重新扫描。", 0, 3600, "秒"),
1828
+ ]),
1829
+
1830
+ group("billing", "计费", [
1831
+ numberRow("peakMultiplier", "高峰时段倍率", "官方为 2(高峰价 = 空闲价 × 倍率)。", 1, 10, "倍"),
1832
+ ]),
1833
+
1834
+ jsx.jsxs("div", {
1835
+ className: "balance-settings__actions",
1836
+ children: [
1837
+ jsx.jsx("span", {
1838
+ className: "balance-settings__status" +
1839
+ (status.kind === "error" ? " balance-settings__status--error" : status.kind === "ok" ? " balance-settings__status--ok" : ""),
1840
+ children: saving ? "保存中…" : status.text,
1841
+ }),
1842
+ jsx.jsx("button", {
1843
+ type: "button",
1844
+ className: "balance-settings__btn",
1845
+ onClick: onReset,
1846
+ disabled: saving,
1847
+ children: "恢复默认",
1848
+ }),
1849
+ ],
1850
+ }),
1851
+ ],
1852
+ }) : null,
1853
+ ],
1854
+ });
1855
+ }
1856
+
1450
1857
  // ─── 插件主体 ───────────────────────────────────────────────────────────
1451
1858
  const inject = ["slots", "sessions"];
1452
1859
 
1453
1860
  function apply(ctx) {
1454
- // 保存 ctx:用于探测官方版 DSH 的侧栏内嵌浏览器(ctx.sidebarRight / ctx.sidebarRightTabs)
1455
- pluginCtx = ctx;
1456
1861
  // 拿到 sessions 服务,用于解析当前会话使用的模型(按模型取价)
1457
1862
  sessionsFace = ctx.sessions;
1458
1863
  // 余额显示:展开态底部胶囊(order 20 排在市场 10 之后、紧挨设置);收起态悬浮 FAB
@@ -1478,6 +1883,22 @@ function UsageCostLine({ useProjection, sessionId }) {
1478
1883
  UsageCostLine,
1479
1884
  ),
1480
1885
  );
1886
+
1887
+ // DSH「插件」页 → 点 balance-dsh → 详情页里的配置卡片。
1888
+ // slot 契约:plugins.bundle.config 是 keyed 类型,key 必须是 **bundle 的包名**
1889
+ // (本插件 = "balance-dsh"),DSH 的插件管理页按这个 key 派发。
1890
+ // 该 slot 由 dsh-client-ui-plugin-manager 声明(页面没打开时不存在),但
1891
+ // slots.inject 是惰性的——slot 出现时才回调,所以这里直接注册即可。
1892
+ // 旧版 DSH 没有这个 slot 时 inject 静默无效,不影响其余功能。
1893
+ ctx.slots.inject("plugins.bundle.config", () =>
1894
+ ctx.slots.register(
1895
+ {
1896
+ name: "plugins.bundle.config",
1897
+ key: "balance-dsh",
1898
+ },
1899
+ BalanceSettingsCard,
1900
+ ),
1901
+ );
1481
1902
  }
1482
1903
 
1483
1904
  exports.apply = apply;