balance-dsh 1.2.2 → 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,24 +35,44 @@ 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}
43
- .balance-usage-line{box-sizing:border-box;display:flex;justify-content:center;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
44
- .balance-usage-line strong{color:var(--dsw-alias-label-primary);font-weight:600}
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)}
54
+ /* ── 消耗行 pill:规格逐条照抄 DSH 原生 StatsPills 的 .pill(_091hzG_pill)── */
55
+ .balance-usage-wrap{min-width:0;display:inline-flex}
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)}
57
+ .balance-usage-line svg{flex:none;width:14px;height:14px}
58
+ .balance-usage-line:hover,.balance-usage-line[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-secondary)}
59
+ .balance-usage-label{text-overflow:ellipsis;min-width:0;overflow:hidden}
60
+ /* ── 消耗明细浮层:规格照抄 DSH 原生「会话统计」面板(.HS2XBG_panel/.title/.titleRule/.details)── */
61
+ .balance-usage-panel{box-sizing:border-box;position:fixed;z-index:1100;border:0;border-radius:var(--dsw-radius-lg,12px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-module-platform,#242526));width:max-content;min-width:min(300px,100vw - 24px);max-width:min(440px,100vw - 24px);backdrop-filter:var(--dsw-menu-backdrop-filter,none);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent,0 8px 24px rgba(0,0,0,.32));color:var(--dsw-alias-label-secondary);cursor:default;padding:16px;font-size:12px;line-height:18px;animation:balance-stats-panel-in .12s var(--ds-ease-in-out,ease-out) both}
62
+ .balance-usage-panel__title{display:flex;justify-content:space-between;gap:16px;margin-bottom:8px;color:var(--dsw-alias-label-primary);font-weight:500}
63
+ .balance-usage-panel__title--second{margin-top:16px}
64
+ .balance-usage-panel__rule{border-top:.5px solid var(--dsw-alias-border-l2);margin-bottom:10px}
65
+ .balance-usage-panel__value{font-variant-numeric:tabular-nums}
66
+ .balance-usage-panel__label{display:inline-flex;align-items:center;gap:6px;min-width:0}
67
+ .balance-usage-panel__label svg{flex:none;width:14px;height:14px}
68
+ .balance-usage-panel__details{display:grid;grid-template-columns:minmax(76px,auto) minmax(0,1fr);gap:6px 16px;margin:0;color:var(--dsw-alias-label-tertiary)}
69
+ .balance-usage-panel__details dt,.balance-usage-panel__details dd{min-width:0;margin:0}
70
+ .balance-usage-panel__details dd{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;text-align:right}
45
71
  /* ── 跨会话统计浮层(余额元素的子级,向右/向上展开) ── */
46
72
  .balance-stats-anchor{position:relative;display:flex;min-width:0;overflow:visible}
47
73
  .balance-stats-anchor--wide{width:100%}
48
- .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}
49
75
  .balance-stats-anchor--compact .balance-fab{position:static}
50
- .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}
51
- .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}
52
- .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)}
53
- .balance-platform-back svg{flex:none;display:block}
54
- .balance-platform-back:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
55
- .balance-platform-viewport{flex:1;min-height:0}
56
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}
57
77
  .balance-stats-layer--closing{pointer-events:none;animation:balance-stats-backdrop-out .14s var(--ds-ease-in-out,ease-in) both}
58
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))}
@@ -105,8 +125,13 @@ window.__ModuleLoader__.load({
105
125
  .balance-heatmap-cell--4{background:rgba(22,163,74,.95)}
106
126
  .balance-heatmap-cell--blank{visibility:hidden}
107
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}
108
- .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))}
109
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)}
110
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}
111
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}
112
137
  .balance-stats-group__hint{color:var(--dsw-alias-label-secondary);font-weight:400;font-variant-numeric:tabular-nums}
@@ -127,6 +152,46 @@ window.__ModuleLoader__.load({
127
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}
128
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}
129
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}
130
195
  `;
131
196
 
132
197
  const tagId = "balance/balance-widget.css";
@@ -185,25 +250,28 @@ window.__ModuleLoader__.load({
185
250
  }
186
251
 
187
252
  /** 分时段金额:优先按服务端已标注的各模型 cost 累加(byModel);
188
- * 无 byModel 时回退到旧的单模型逻辑:高峰桶按目录价 ×2、空闲桶按目录价(兼容子代理/旧数据)。 */
189
- function computeSplitCost(usage, p) {
253
+ * 无 byModel 时回退到旧的单模型逻辑:高峰桶按目录价 × 倍率、空闲桶按目录价(兼容子代理/旧数据)。
254
+ * ⚠️ p 必须是**目录价**(服务端 pricing 字段即目录价),倍率由 peakMultiplier 单独给,
255
+ * 否则会与这里的乘法重复翻倍(高峰变 ×4)。 */
256
+ function computeSplitCost(usage, p, multiplier = 2) {
190
257
  if (Array.isArray(usage.byModel) && usage.byModel.length > 0) {
191
258
  let total = 0;
192
259
  for (const b of usage.byModel) {
193
- 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);
194
261
  }
195
262
  return total;
196
263
  }
197
- return computeSingleSplitCost(usage, p);
264
+ return computeSingleSplitCost(usage, p, multiplier);
198
265
  }
199
- function computeSingleSplitCost(usage, p) {
200
- const costOf = (u) =>
201
- ((u.inputTokens ?? 0) * p.inputCnyPerMillion +
266
+ function computeSingleSplitCost(usage, p, multiplier = 2) {
267
+ const costOf = (u, factor) =>
268
+ (((u.inputTokens ?? 0) * p.inputCnyPerMillion +
202
269
  (u.cacheReadTokens ?? 0) * p.cacheReadCnyPerMillion +
203
270
  (u.cacheWriteTokens ?? 0) * p.cacheWriteCnyPerMillion) /
204
271
  1_000_000 +
205
- ((u.outputTokens ?? 0) * p.outputCnyPerMillion) / 1_000_000;
206
- 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);
207
275
  }
208
276
  function formatTokens(n) {
209
277
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
@@ -211,148 +279,56 @@ window.__ModuleLoader__.load({
211
279
  return String(n);
212
280
  }
213
281
 
214
- // ─── 打开 DeepSeek 平台页面(用量 / 充值) ─────────────────────────────
215
- // 三级策略,逐级回退:
216
- // 1) DSH 官方版的「原生平台视口」——preload 通过 contextBridge 暴露的 globalThis.dshPlatform
217
- // (接口:open(page, bounds) / setBounds(bounds) / close(),page 取 "usage" | "top-up")。
218
- // DSH 设置→账号与余额里的「查询用量」按钮就走它(dsh-client-ui-settings-account 的
219
- // PlatformOverlay + dshPlatform.open)。这个原生子窗口自带平台凭据(官方注释:
220
- // "the native child owns remote content and credentials"),所以**不需要网页登录**。
221
- // 注意:那个包的 platformPages 是包内私有 store,插件无法触发 DSH 自己的 host,
222
- // 因此这里自己画同款覆盖层并把视口坐标喂给 bridge。
223
- // 2) 应用内侧栏浏览器(dsh-client-ui-sidebar-browser)——旧版/无原生视口时用,
224
- // 按 DSH 自身 openExternalLink 的判定:sidebarRightTabs 注册了 "browser" 才可用。
225
- // 该视口**没有**平台凭据,打开 platform.deepseek.com 会要求网页登录。
226
- // 3) window.open 交给系统浏览器。
227
- // 全程 try/catch:DSH 内部机制无兼容承诺,任一级失败都要继续降级,不能拖垮插件。
228
- 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-* 样式)与侧栏浏览器回退都已随本次改动删除。
229
289
  const USAGE_URL = "https://platform.deepseek.com/usage";
230
290
  const TOP_UP_URL = "https://platform.deepseek.com/top_up";
231
-
232
- /** 当前原生平台视口的覆盖层;null 表示未打开。 */
233
- let platformOverlay = null;
234
-
235
- /** 关闭原生平台视口覆盖层(幂等;bridge.close + 摘除 DOM + 解绑监听)。 */
236
- function closePlatformPage() {
237
- if (platformOverlay === null) return;
238
- const { layer, bridge, observer, onKey } = platformOverlay;
239
- platformOverlay = null;
240
- try {
241
- observer.disconnect();
242
- } catch {
243
- /* ignore */
244
- }
245
- try {
246
- window.removeEventListener("keydown", onKey, true);
247
- } catch {
248
- /* ignore */
249
- }
250
- try {
251
- const closing = bridge.close();
252
- if (closing && typeof closing.catch === "function") closing.catch(() => {});
253
- } catch {
254
- /* ignore */
255
- }
256
- try {
257
- layer.remove();
258
- } catch {
259
- /* ignore */
260
- }
261
- }
291
+ // 「更多账号信息」= DSH 原生账号卡片同款入口:它用的是 new URL("/", usageUrl).href,
292
+ // 即平台首页(不是某个子页)。原生那个 <a> 没有走原生平台视口(视口只认 usage / top-up),
293
+ // 是直接开外部窗口,所以这里也传 page = null。
294
+ const PLATFORM_HOME_URL = "https://platform.deepseek.com/";
262
295
 
263
296
  /**
264
- * 用原生平台视口打开官方平台页面。
265
- * @param page - "usage"(用量)或 "top-up"(充值)。
266
- * @returns 是否成功接管(false 时调用方继续降级)。
297
+ * 量出聊天输入卡片(composer card)的垂直中心(视口坐标)。
298
+ * 收起态圆形余额靠它做「垂直中心对齐聊天栏」,所以不写死像素。
299
+ * @returns 中心 y;量不到返回 null,调用方回退到 CSS 的固定 bottom。
267
300
  */
268
- function openPlatformPage(page) {
269
- const bridge = typeof globalThis === "undefined" ? void 0 : globalThis.dshPlatform;
270
- if (!bridge || typeof bridge.open !== "function") return false;
301
+ function measureComposerCenterY() {
302
+ if (typeof document === "undefined") return null;
271
303
  try {
272
- closePlatformPage();
273
- const layer = document.createElement("div");
274
- layer.className = "balance-platform-layer";
275
- const header = document.createElement("header");
276
- header.className = "balance-platform-header";
277
- // 与 DSH 的 PlatformOverlay 一致:让标题栏可拖动窗口
278
- header.setAttribute("data-window-drag", "");
279
- const back = document.createElement("button");
280
- back.type = "button";
281
- back.className = "balance-platform-back";
282
- // 与 DSH 的 PlatformOverlay 一致:chevron 图标 + 文案。DSH 用的是 primitives 的
283
- // IconChevronLeftOutlineRegular 组件;这里覆盖层是纯 DOM,自绘同款 SVG,
284
- // 免得为了一个图标把 React 渲染引进覆盖层。
285
- const chevron = document.createElementNS("http://www.w3.org/2000/svg", "svg");
286
- chevron.setAttribute("viewBox", "0 0 16 16");
287
- chevron.setAttribute("width", "16");
288
- chevron.setAttribute("height", "16");
289
- chevron.setAttribute("aria-hidden", "true");
290
- const chevronPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
291
- chevronPath.setAttribute("d", "M10 3.5 5.5 8 10 12.5");
292
- chevronPath.setAttribute("fill", "none");
293
- chevronPath.setAttribute("stroke", "currentColor");
294
- chevronPath.setAttribute("stroke-width", "1.6");
295
- chevronPath.setAttribute("stroke-linecap", "round");
296
- chevronPath.setAttribute("stroke-linejoin", "round");
297
- chevron.appendChild(chevronPath);
298
- back.appendChild(chevron);
299
- back.appendChild(document.createTextNode("返回 DeepSeek Harness"));
300
- back.addEventListener("click", closePlatformPage);
301
- header.appendChild(back);
302
- const viewport = document.createElement("div");
303
- viewport.className = "balance-platform-viewport";
304
- layer.appendChild(header);
305
- layer.appendChild(viewport);
306
- document.body.appendChild(layer);
307
-
308
- // 原生视口按 CSS 像素贴合这个占位元素(DSH 也是拿 getBoundingClientRect())
309
- const bounds = () => {
310
- const rect = viewport.getBoundingClientRect();
311
- return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
312
- };
313
- const observer = new ResizeObserver(() => {
314
- if (platformOverlay === null) return;
315
- try {
316
- const moved = bridge.setBounds(bounds());
317
- if (moved && typeof moved.catch === "function") moved.catch(() => {});
318
- } catch {
319
- /* 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;
320
315
  }
321
- });
322
- const onKey = (event) => {
323
- if (event.key === "Escape") closePlatformPage();
324
- };
325
- platformOverlay = { layer, bridge, observer, onKey };
326
- observer.observe(viewport);
327
- window.addEventListener("keydown", onKey, true);
328
- const opened = bridge.open(page, bounds());
329
- if (opened && typeof opened.catch === "function") opened.catch(() => closePlatformPage());
330
- return true;
331
- } catch {
332
- closePlatformPage();
333
- return false;
334
- }
335
- }
336
-
337
- /** 打开应用内嵌浏览器的标签页(无原生视口时的二级回退)。 */
338
- function openSidebarBrowser(url) {
339
- try {
340
- const get = pluginCtx && typeof pluginCtx.get === "function" ? pluginCtx.get.bind(pluginCtx) : null;
341
- if (get === null) return false;
342
- const right = get("sidebarRight");
343
- const tabs = get("sidebarRightTabs");
344
- const hasBrowser = tabs && typeof tabs.get === "function" && tabs.get("browser") !== undefined;
345
- if (right && typeof right.openTab === "function" && hasBrowser) {
346
- right.openTab("browser", { params: { url } });
347
- 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;
348
323
  }
349
324
  } catch {
350
- // 侧栏浏览器不可用(旧版 DSH / 服务未就绪)
325
+ /* 结构变了就当量不到,交给 CSS 兜底 */
351
326
  }
352
- return false;
327
+ return null;
353
328
  }
354
329
 
355
- /** 最终回退:交给系统浏览器。 */
330
+ /** 打开外部网页:交给系统默认浏览器。DSH 的 Electron 主进程拦截 window.open
331
+ * 并转给 shell.openExternal,所以这里不会弹出应用内窗口。 */
356
332
  function openInSystemBrowser(url) {
357
333
  try {
358
334
  window.open(url, "_blank", "noopener,noreferrer");
@@ -361,10 +337,8 @@ window.__ModuleLoader__.load({
361
337
  }
362
338
  }
363
339
 
364
- /** 打开平台页面:原生视口 → 侧栏浏览器 → 系统浏览器。 */
340
+ /** 打开平台页面:一律走系统默认浏览器,不在应用内开原生视口或内嵌浏览器。 */
365
341
  function openPlatformOrUrl(page, url) {
366
- if (openPlatformPage(page)) return;
367
- if (openSidebarBrowser(url)) return;
368
342
  openInSystemBrowser(url);
369
343
  }
370
344
 
@@ -549,19 +523,105 @@ window.__ModuleLoader__.load({
549
523
  const onDuty = PEAK_WEEKDAYS.includes(weekday) || kind === "workday"; // 调休上班的周末按工作日算
550
524
  return onDuty && PEAK_WINDOWS.some((w) => hour >= w.start && hour < w.end) ? "peak" : "offpeak";
551
525
  }
552
- /** 按当前时段调整价格:高峰 ×2、空闲原价。注意:若服务端已配 config.peak:true
553
- * (强制高峰价翻倍),此处会再乘一次,两种方式不要同时使用。 */
554
- function periodAdjustedPricing(p) {
526
+ /** 按当前时段调整价格:高峰 × 倍率、空闲原价。
527
+ * ⚠️ p 必须是**目录价**(服务端 pricing 字段即目录价);倍率来自设置的 peakMultiplier,
528
+ * 若服务端传来的价已含倍率,这里会再乘一次造成金额虚高。 */
529
+ function periodAdjustedPricing(p, multiplier = 2) {
555
530
  if (currentPeriod() !== "peak") return p;
556
531
  return {
557
- inputCnyPerMillion: p.inputCnyPerMillion * 2,
558
- cacheReadCnyPerMillion: p.cacheReadCnyPerMillion * 2,
559
- cacheWriteCnyPerMillion: p.cacheWriteCnyPerMillion * 2,
560
- 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,
561
607
  };
608
+ emitSettings();
609
+ return settingsCache.value;
562
610
  }
563
611
 
564
- // ─── 余额数据:模块级共享缓存 + 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) ────
565
625
  // 切换会话时 header 组件会重新挂载,若每个实例各自拉取会先消失再出现(闪烁);
566
626
  // 缓存全局存一份,任何实例挂载都直接读缓存,无需重新请求。
567
627
  let balanceCache = { status: "loading", balance: null, error: null, refreshing: false };
@@ -594,19 +654,38 @@ window.__ModuleLoader__.load({
594
654
  emitBalance();
595
655
  }
596
656
 
657
+ // 轮询间隔由设置 refreshIntervalSec 决定:间隔变了要重建定时器。
658
+ let balancePollIntervalSec = 0;
659
+
597
660
  function ensureBalancePolling() {
598
- 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;
599
669
  fetchBalanceOnce();
600
- balancePollTimer = setInterval(fetchBalanceOnce, 60_000);
670
+ balancePollTimer = setInterval(fetchBalanceOnce, sec * 1000);
601
671
  }
602
672
 
603
673
  function useBalance() {
604
674
  const [, force] = react.useState(0);
605
675
  react.useEffect(() => {
606
- const listener = () => force((n) => n + 1);
676
+ // 余额变化 → 重渲染;设置变化 → 重渲染并按新间隔重建轮询
677
+ const listener = () => {
678
+ force((n) => n + 1);
679
+ ensureBalancePolling();
680
+ };
607
681
  balanceListeners.add(listener);
682
+ settingsListeners.add(listener);
683
+ void fetchSettingsOnce(false);
608
684
  ensureBalancePolling();
609
- return () => balanceListeners.delete(listener);
685
+ return () => {
686
+ balanceListeners.delete(listener);
687
+ settingsListeners.delete(listener);
688
+ };
610
689
  }, []);
611
690
  return balanceCache;
612
691
  }
@@ -979,10 +1058,27 @@ window.__ModuleLoader__.load({
979
1058
  (stats?.limited ? "(为保证性能仅取最近 200 个)" : "") +
980
1059
  ";按北京时间和实际模型峰谷价格估算,结果缓存 5 分钟。",
981
1060
  }),
982
- jsx.jsx("a", {
983
- className: "balance-stats-link",
984
- ...externalLink(USAGE_URL, "usage"),
985
- 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
+ ],
986
1082
  }),
987
1083
  ],
988
1084
  }),
@@ -1040,6 +1136,17 @@ window.__ModuleLoader__.load({
1040
1136
  // ─── 余额显示:单击统计,双击 DeepSeek 用量页,右键刷新 ─────────────
1041
1137
  function BalanceFooter({ wide }) {
1042
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));
1043
1150
  const [statsOpen, setStatsOpen] = react.useState(false);
1044
1151
  const [statsClosing, setStatsClosing] = react.useState(false);
1045
1152
  const clickTimer = react.useRef(null);
@@ -1051,6 +1158,45 @@ window.__ModuleLoader__.load({
1051
1158
  if (clickTimer.current !== null) clearTimeout(clickTimer.current);
1052
1159
  if (closeTimer.current !== null) clearTimeout(closeTimer.current);
1053
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]);
1054
1200
  const closeStats = react.useCallback(() => {
1055
1201
  if (!statsOpen || statsClosing) return;
1056
1202
  setStatsClosing(true);
@@ -1100,14 +1246,15 @@ window.__ModuleLoader__.load({
1100
1246
  onDoubleClick: openUsagePage,
1101
1247
  onContextMenu: onBalanceContextMenu,
1102
1248
  };
1103
- const withPanel = (node) => jsx.jsxs("div", {
1249
+ const withPanel = (node, hint, anchorStyle) => jsx.jsxs("div", {
1104
1250
  ref: anchorRef,
1105
1251
  className: "balance-stats-anchor balance-stats-anchor--" + (wide ? "wide" : "compact"),
1252
+ style: anchorStyle,
1106
1253
  children: [
1107
1254
  // 悬停提示走 DSH 主题化浮层:primitives.Tooltip 内部是 cloneElement(children, {ref}),
1108
1255
  // 不会插入额外 DOM 层,因此不破坏余额行在 flex 容器里的 42px 布局(浮层本身 portal 到 body)。
1109
1256
  jsx.jsx(primitives.Tooltip, {
1110
- label: "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
1257
+ label: hint || "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
1111
1258
  side: "top",
1112
1259
  delayMs: 400,
1113
1260
  children: node,
@@ -1127,25 +1274,44 @@ window.__ModuleLoader__.load({
1127
1274
  });
1128
1275
 
1129
1276
  if (!wide) {
1277
+ // 设置里关掉圆钮就整个不渲染(统计面板入口随之消失,符合「关掉显示」的预期)
1278
+ if (settings.showFab === false) return null;
1130
1279
  if (state.status === "error" || !state.balance) return null;
1131
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" };
1132
1284
  const total = Number.parseFloat(b.total_balance ?? "0");
1133
1285
  const depleted = b.is_available === false || !Number.isFinite(total) || total <= 0;
1134
- if (depleted) {
1135
- return jsx.jsx("a", {
1136
- className: "balance-fab balance-fab-dark",
1137
- ...externalLink(TOP_UP_URL, "top-up", { onContextMenu: onBalanceContextMenu }),
1138
- children: "去充值",
1139
- });
1140
- }
1141
- return withPanel(jsx.jsx("a", {
1142
- className: "balance-fab",
1143
- ...externalLink(USAGE_URL, "usage"),
1144
- ...interactions,
1145
- children: "¥" + b.total_balance,
1146
- }));
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);
1147
1310
  }
1148
1311
 
1312
+ // 展开态余额行:设置里关掉就不渲染(收起态圆钮由上面的 showFab 单独控制)
1313
+ if (settings.showBalanceRow === false) return null;
1314
+
1149
1315
  if (state.status === "error") {
1150
1316
  return withPanel(jsx.jsx("div", {
1151
1317
  className: "balance-footer",
@@ -1190,7 +1356,13 @@ window.__ModuleLoader__.load({
1190
1356
  }
1191
1357
 
1192
1358
  const period = currentPeriod();
1193
- 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" ? "高峰" : "空闲" });
1194
1366
  return withPanel(jsx.jsxs("div", {
1195
1367
  className: "balance-footer",
1196
1368
  ...interactions,
@@ -1201,10 +1373,8 @@ window.__ModuleLoader__.load({
1201
1373
  className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy"),
1202
1374
  children: [
1203
1375
  totalText,
1204
- " • ",
1205
- state.refreshing
1206
- ? jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--refreshing", children: "刷新中…" })
1207
- : jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--" + period, children: period === "peak" ? "高峰" : "空闲" }),
1376
+ periodNode === null ? null : " • ",
1377
+ periodNode,
1208
1378
  ],
1209
1379
  }),
1210
1380
  ],
@@ -1213,12 +1383,47 @@ window.__ModuleLoader__.load({
1213
1383
 
1214
1384
  // ─── 当前会话消耗:输入区 dock ──────────────────────────────────────────
1215
1385
  function UsageCostLine({ useProjection, sessionId }) {
1386
+ const settings = useSettings();
1216
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
+ })();
1217
1393
  const model = useCurrentModel();
1218
1394
  const usage = typeof useProjection === "function" ? useProjection("tokenUsage") : undefined;
1219
1395
  const [splitUsage, setSplitUsage] = react.useState(null);
1220
1396
  const [subagentUsage, setSubagentUsage] = react.useState(null);
1221
1397
  const [splitReady, setSplitReady] = react.useState(false);
1398
+ // 消耗明细面板:**点击展开**(对齐 DSH 原生「会话统计」的交互——点开切换 + 选中态 + portal 浮层)
1399
+ const [detailsOpen, setDetailsOpen] = react.useState(false);
1400
+ const detailsAnchorRef = react.useRef(null);
1401
+ const detailsPanelRef = react.useRef(null);
1402
+ // 定位与"点外部关闭"复用 DSH primitives 的 hook,行为/视觉都与原生浮层一致。
1403
+ // 运行时探测(DSH 内部 API 无兼容承诺):hook 不存在时退化为无定位(面板仍渲染),不拖垮消耗行。
1404
+ // 注意这里的条件调用是安全的——primitives 是模块级常量,同一组件生命周期内判定结果恒定。
1405
+ const anchoredPosition =
1406
+ typeof primitives.useAnchoredPosition === "function"
1407
+ ? primitives.useAnchoredPosition({
1408
+ open: detailsOpen,
1409
+ anchorRef: detailsAnchorRef,
1410
+ panelRef: detailsPanelRef,
1411
+ side: "top",
1412
+ gap: 6,
1413
+ margin: 8,
1414
+ })
1415
+ : null;
1416
+ if (typeof primitives.useDismissOnOutsidePointer === "function") {
1417
+ primitives.useDismissOnOutsidePointer(detailsAnchorRef, detailsOpen, setDetailsOpen, detailsPanelRef);
1418
+ }
1419
+ react.useEffect(() => {
1420
+ if (!detailsOpen) return;
1421
+ const onKeyDown = (event) => {
1422
+ if (event.key === "Escape") setDetailsOpen(false);
1423
+ };
1424
+ document.addEventListener("keydown", onKeyDown);
1425
+ return () => document.removeEventListener("keydown", onKeyDown);
1426
+ }, [detailsOpen]);
1222
1427
 
1223
1428
  // 会话 token 总量(未缓存输入 + 缓存读/写 + 输出);为 0 时代表还没有消耗。
1224
1429
  const usageKey = usage
@@ -1274,67 +1479,385 @@ function UsageCostLine({ useProjection, sessionId }) {
1274
1479
  // 服务端分桶可能"非 null 但为空"(拿不到该会话事件),此时必须回退本地投影估算,否则金额会显示 ¥0.0000
1275
1480
  const splitUsable =
1276
1481
  split != null && ((Array.isArray(split.byModel) && split.byModel.length > 0) || (split.totalTokens ?? 0) > 0);
1277
- const cost = splitUsable ? computeSplitCost(split, p) : computeCost(usage, periodAdjustedPricing(p));
1482
+ const cost = splitUsable ? computeSplitCost(split, p, peakMultiplier) : computeCost(usage, periodAdjustedPricing(p, peakMultiplier));
1278
1483
  const peakTokens = splitUsable ? split.peak.inputTokens + split.peak.cacheReadTokens + split.peak.cacheWriteTokens + split.peak.outputTokens : 0;
1279
1484
  const offpeakTokens = splitUsable ? split.offpeak.inputTokens + split.offpeak.cacheReadTokens + split.offpeak.cacheWriteTokens + split.offpeak.outputTokens : 0;
1280
1485
 
1281
1486
  // 子代理消耗(与主会话分开,同样跨时段精确计费)
1282
1487
  const hasSubagent = splitReady && subagentUsage && subagentUsage.totalTokens > 0;
1283
- const subCost = hasSubagent ? computeSplitCost(subagentUsage, p) : 0;
1488
+ const subCost = hasSubagent ? computeSplitCost(subagentUsage, p, peakMultiplier) : 0;
1284
1489
  const subNewTokens = hasSubagent
1285
1490
  ? (subagentUsage.inputTokens ?? 0) + (subagentUsage.outputTokens ?? 0)
1286
1491
  : 0;
1287
1492
  const subCount = hasSubagent ? (subagentUsage.sessions ?? 0) : 0;
1288
1493
 
1289
- const detailLines = [
1290
- "模型:" + (model || "未知(按 deepseek-v4-flash 价估算)"),
1291
- "新增:" + formatTokens(newTokens) + " tokens(未缓存输入 " + formatTokens(uncachedInput) + " + 输出 " + formatTokens(output) + ")",
1292
- "计费:" + formatTokens(billedTotal) + " tokens(含缓存重读 " + formatTokens(cacheRead) + "、缓存写入 " + formatTokens(cacheWrite) + ")",
1494
+ // 缓存命中率口径与 DSH 相同:缓存读取 / (缓存读取 + 未缓存输入)
1495
+ const cacheHitBase = cacheRead + uncachedInput;
1496
+ const cacheHitPercent = cacheHitBase > 0 ? Math.round((cacheRead / cacheHitBase) * 100) : 0;
1497
+
1498
+ // pill = 图标 + 文字标签 + 金额:DSH 那颗 pill 已经在显示「{tok} · 缓存命中 {%}」,
1499
+ // 所以这里不再重复那些数字,但**保留文字说明**(用户要求「最早之前的文字」)。
1500
+ // 图标与 DSH 那颗保持一致(都用 IconDatabaseOutlineRegular)——换别的图标会「不搭配」
1501
+ // (2026-09-28 反馈「图标换了 不搭配」,仪表版被否掉)。
1502
+ // 图标缺失(DSH 改 API)时不渲染,文字照常显示,不影响点击。
1503
+ const children = [
1504
+ typeof primitives.IconDatabaseOutlineRegular === "function"
1505
+ ? jsx.jsx(primitives.IconDatabaseOutlineRegular, { key: "icon" })
1506
+ : null,
1507
+ jsx.jsx("span", { key: "label", className: "balance-usage-label", children: "本会话消耗" }),
1508
+ jsx.jsx("span", { key: "cost", className: "balance-usage-label", children: "≈ ¥" + cost.toFixed(4) }),
1509
+ ];
1510
+
1511
+ // ── 点击展开的明细浮层(结构与 DSH 原生「会话统计」一致:标题 + 分隔线 + dl 两列网格)──
1512
+ const usageDetails = [
1513
+ ["缓存命中", cacheHitPercent + "%"],
1514
+ ["未缓存输入", formatTokens(uncachedInput) + " tok"],
1515
+ ["缓存读取", formatTokens(cacheRead) + " tok"],
1516
+ ];
1517
+ if (cacheWrite > 0) usageDetails.push(["缓存写入", formatTokens(cacheWrite) + " tok"]);
1518
+ usageDetails.push(["输出", formatTokens(output) + " tok"]);
1519
+
1520
+ const costDetails = [
1521
+ ["新增 tokens", formatTokens(newTokens) + " tok"],
1522
+ ["计费 tokens", formatTokens(billedTotal) + " tok"],
1523
+ ["模型", model || "未知(按 flash 价估算)"],
1293
1524
  ];
1294
1525
  if (splitUsable) {
1295
- detailLines.push(
1296
- "时段拆分:高峰 " + formatTokens(peakTokens) + " tokens + 空闲 " + formatTokens(offpeakTokens) + " tokens",
1297
- "估算金额:¥" + cost.toFixed(4) + "(高峰桶按目录价×2、空闲桶按目录价)",
1526
+ costDetails.push(
1527
+ ["高峰 tokens", formatTokens(peakTokens) + " tok"],
1528
+ ["空闲 tokens", formatTokens(offpeakTokens) + " tok"],
1298
1529
  );
1299
- } else {
1300
- detailLines.push("估算金额:¥" + cost.toFixed(4) + "(按当前时段价估算)");
1301
1530
  }
1302
1531
  if (hasSubagent) {
1303
- const subPeak = subagentUsage.peak.inputTokens + subagentUsage.peak.cacheReadTokens + subagentUsage.peak.cacheWriteTokens + subagentUsage.peak.outputTokens;
1304
- const subOff = subagentUsage.offpeak.inputTokens + subagentUsage.offpeak.cacheReadTokens + subagentUsage.offpeak.cacheWriteTokens + subagentUsage.offpeak.outputTokens;
1305
- detailLines.push(
1306
- "子代理消耗:新增 " + formatTokens(subNewTokens) + " tokens、计费 " + formatTokens(subagentUsage.totalTokens) + " tokens(" + subCount + " 个子代理)",
1307
- "子代理金额:¥" + subCost.toFixed(4) + "(高峰桶×2、空闲桶按目录价)",
1532
+ costDetails.push(
1533
+ ["子代理", subCount + " 个"],
1534
+ ["子代理 tokens", "+" + formatTokens(subNewTokens) + " / 计费 " + formatTokens(subagentUsage.totalTokens)],
1308
1535
  );
1309
1536
  }
1310
- const children = [
1311
- jsx.jsx("span", { children: "本会话消耗" }),
1312
- jsx.jsx("span", { children: jsx.jsx("strong", { children: formatTokens(newTokens) + " tokens" }) }),
1537
+
1538
+ const detailRows = (prefix, rows) =>
1539
+ rows.flatMap(([label, value], index) => [
1540
+ jsx.jsx("dt", { key: prefix + index + "t", children: label }),
1541
+ jsx.jsx("dd", { key: prefix + index + "d", children: value }),
1542
+ ]);
1543
+
1544
+ const panelChildren = [
1545
+ jsx.jsxs("div", {
1546
+ className: "balance-usage-panel__title",
1547
+ children: [
1548
+ jsx.jsxs("span", {
1549
+ className: "balance-usage-panel__label",
1550
+ children: [
1551
+ typeof primitives.IconDatabaseOutlineRegular === "function"
1552
+ ? jsx.jsx(primitives.IconDatabaseOutlineRegular, {})
1553
+ : null,
1554
+ "Token 用量",
1555
+ ],
1556
+ }),
1557
+ jsx.jsx("span", { className: "balance-usage-panel__value", children: formatTokens(billedTotal) + " tok" }),
1558
+ ],
1559
+ }),
1560
+ jsx.jsx("div", { className: "balance-usage-panel__rule" }),
1561
+ jsx.jsx("dl", { className: "balance-usage-panel__details", children: detailRows("u", usageDetails) }),
1562
+ jsx.jsxs("div", {
1563
+ className: "balance-usage-panel__title balance-usage-panel__title--second",
1564
+ children: [
1565
+ jsx.jsxs("span", {
1566
+ className: "balance-usage-panel__label",
1567
+ children: [
1568
+ typeof primitives.IconGaugeOutlineRegular === "function"
1569
+ ? jsx.jsx(primitives.IconGaugeOutlineRegular, {})
1570
+ : null,
1571
+ "本会话金额",
1572
+ ],
1573
+ }),
1574
+ jsx.jsx("span", { className: "balance-usage-panel__value", children: "≈ ¥" + cost.toFixed(4) }),
1575
+ ],
1576
+ }),
1577
+ jsx.jsx("div", { className: "balance-usage-panel__rule" }),
1578
+ jsx.jsx("dl", { className: "balance-usage-panel__details", children: detailRows("c", costDetails) }),
1313
1579
  ];
1314
- if (billedTotal > newTokens) {
1315
- children.push(jsx.jsx("span", { children: "计费 " + formatTokens(billedTotal) }));
1316
- }
1317
- children.push(jsx.jsxs("span", { children: ["≈ ", jsx.jsx("strong", { children: "¥" + cost.toFixed(4) })] }));
1318
- if (hasSubagent) {
1319
- children.push(jsx.jsx("span", { children: "子代理 +" + formatTokens(subNewTokens) + " ≈ ¥" + subCost.toFixed(4) }));
1580
+
1581
+ // 设置里关掉消耗行就整个不渲染(点开的明细浮层随之一并消失)
1582
+ if (settings.showUsagePill === false) return null;
1583
+
1584
+ return jsx.jsxs("div", {
1585
+ className: "balance-usage-wrap",
1586
+ children: [
1587
+ jsx.jsx("button", {
1588
+ type: "button",
1589
+ ref: detailsAnchorRef,
1590
+ className: "balance-usage-line",
1591
+ "aria-haspopup": "dialog",
1592
+ "aria-expanded": detailsOpen,
1593
+ "aria-label": "本会话 Token 用量与金额明细",
1594
+ onClick: () => setDetailsOpen((value) => !value),
1595
+ children,
1596
+ }),
1597
+ detailsOpen
1598
+ ? reactDom.createPortal(
1599
+ jsx.jsx("div", {
1600
+ ref: detailsPanelRef,
1601
+ className: "balance-usage-panel",
1602
+ role: "dialog",
1603
+ "aria-label": "本会话 Token 用量",
1604
+ style: anchoredPosition ?? void 0,
1605
+ children: panelChildren,
1606
+ }),
1607
+ document.body,
1608
+ )
1609
+ : null,
1610
+ ],
1611
+ });
1612
+ }
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
+ });
1320
1690
  }
1321
- return jsx.jsx(primitives.Tooltip, {
1322
- label: detailLines.join("\n"),
1323
- side: "top",
1324
- delayMs: 300,
1325
- children: jsx.jsxs("div", {
1326
- className: "balance-usage-line",
1327
- children,
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",
1328
1749
  }),
1329
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
+ });
1330
1855
  }
1331
1856
 
1332
1857
  // ─── 插件主体 ───────────────────────────────────────────────────────────
1333
1858
  const inject = ["slots", "sessions"];
1334
1859
 
1335
1860
  function apply(ctx) {
1336
- // 保存 ctx:用于探测官方版 DSH 的侧栏内嵌浏览器(ctx.sidebarRight / ctx.sidebarRightTabs)
1337
- pluginCtx = ctx;
1338
1861
  // 拿到 sessions 服务,用于解析当前会话使用的模型(按模型取价)
1339
1862
  sessionsFace = ctx.sessions;
1340
1863
  // 余额显示:展开态底部胶囊(order 20 排在市场 10 之后、紧挨设置);收起态悬浮 FAB
@@ -1360,6 +1883,22 @@ function UsageCostLine({ useProjection, sessionId }) {
1360
1883
  UsageCostLine,
1361
1884
  ),
1362
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
+ );
1363
1902
  }
1364
1903
 
1365
1904
  exports.apply = apply;