dsh-consumption-badge 1.1.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +110 -11
  2. package/lib/client.js +2079 -40
  3. package/lib/index.js +48 -15
  4. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -107,12 +107,263 @@ window.__ModuleLoader__.load({
107
107
 
108
108
  // 同源取数:Host 半边的只读路由,失败一律转成 store 里的错误文案。
109
109
  async function fetchJson(path) {
110
- if (typeof fetch !== 'function') throw new Error('fetch 不可用')
110
+ if (typeof fetch !== 'function') throw new Error(tt('fetch 不可用'))
111
111
  const res = await fetch(path, { headers: { accept: 'application/json' }, credentials: 'same-origin' })
112
112
  if (!res.ok) throw new Error('HTTP ' + String(res.status))
113
113
  return await res.json()
114
114
  }
115
115
 
116
+ /* ============ i18n: follow the shell locale (zh / en) ============ */
117
+ // Design: the Chinese copy IS the dictionary key. Under `zh` no dictionary is
118
+ // needed (the key is the text); under `en` DICT_EN supplies the translation.
119
+ // Anything missing from DICT_EN falls back to the key, so a gap can only show
120
+ // Chinese — never an empty string.
121
+ //
122
+ // No manual re-render plumbing for slot-mounted UI: dsh-client-web-react's
123
+ // SlotOutlet subscribes to the locale face revision (useLocaleRevision), so
124
+ // every outlet re-renders on a language switch and these module-level calls
125
+ // pick up the new locale. Only the personalization page's own React root
126
+ // (rendered outside the slot system) subscribes explicitly.
127
+ const LOCALE_NS = 'consumption-badge'
128
+ const DICT_EN = {
129
+ /* badge + panel */
130
+ '本会话消耗(点击查看逐轮明细)': 'Session usage (click for per-turn details)',
131
+ '本会话消耗': 'Session usage',
132
+ '⛽ 本会话消耗': '⛽ Session usage',
133
+ '刷新余额': 'Refresh balance',
134
+ 'Esc 关闭': 'Esc to close',
135
+ '累计 Token': 'Total tokens',
136
+ '估算金额': 'Estimated cost',
137
+ '当前时段': 'Current window',
138
+ '账户余额': 'Account balance',
139
+ '逐轮消耗趋势': 'Per-turn usage trend',
140
+ '命中 {hit} · 未命中 {miss} · 输出 {out}': 'hit {hit} · miss {miss} · output {out}',
141
+ '{id}(按 flash 估算)': '{id} (priced as flash)',
142
+ ' · 峰时 ×2': ' · peak ×2',
143
+ ' · 谷时': ' · off-peak ×1',
144
+ '高峰': 'Peak',
145
+ '空闲': 'Off-peak',
146
+ '峰': 'Peak',
147
+ '谷': 'Off-peak',
148
+ '峰时': 'Peak',
149
+ '谷时': 'Off-peak',
150
+ '北京时间 工作日 09:00-12:00 / 14:00-18:00': 'Beijing time, weekdays 09:00-12:00 / 14:00-18:00',
151
+ '金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考;数据源为本会话日志,刷新后自动恢复。': 'Costs are estimated from DeepSeek list prices (off-peak/peak ×1/×2) and are for reference only; the source is this session log, restored automatically after a refresh.',
152
+ /* chart tooltips */
153
+ '本会话还没有可统计的轮次': 'No turns to chart in this session yet',
154
+ '输入命中': 'Cache hit',
155
+ '输入未命中': 'Cache miss',
156
+ '输出': 'Output',
157
+ '本轮合计': 'Turn total',
158
+ '每轮 Token': 'Tokens per turn',
159
+ '每轮估算金额': 'Cost per turn',
160
+ '{n} 轮': '{n} turns',
161
+ '当日合计': 'Total',
162
+ '模型': 'Model',
163
+ /* balance + error copy */
164
+ '打开面板时请求一次': 'Fetched once per panel open',
165
+ '打开设置页时请求一次': 'Fetched once per settings open',
166
+ '读取中…': 'Loading…',
167
+ '获取失败': 'Failed',
168
+ '余额接口不可用': 'Balance API unavailable',
169
+ '余额不足': 'Insufficient balance',
170
+ '请前往 DeepSeek 开放平台充值': 'Top up at the DeepSeek open platform',
171
+ '赠送 ¥{v}': 'granted ¥{v}',
172
+ '充值 ¥{v}': 'topped up ¥{v}',
173
+ 'DeepSeek 账户余额': 'DeepSeek account balance',
174
+ '空响应': 'Empty response',
175
+ '余额获取失败': 'Balance request failed',
176
+ '余额获取失败:{msg}': 'Balance request failed: {msg}',
177
+ '会话消耗读取失败:{msg}': 'Failed to read session usage: {msg}',
178
+ 'fetch 不可用': 'fetch is not available',
179
+ /* host error codes (lib/index.js) → zh copy; DICT_EN supplies the English */
180
+ 'credentials 服务不可用': 'The credentials service is unavailable.',
181
+ '读取 API Key 失败:{detail}': 'Failed to read the API key: {detail}',
182
+ '未配置 {detail}': 'No API key configured ({detail}).',
183
+ 'subprocess 服务不可用': 'The subprocess service is unavailable.',
184
+ 'curl 没有输出:{detail}': 'curl produced no output: {detail}',
185
+ 'curl 启动失败:{detail}': 'curl could not be started: {detail}',
186
+ '响应解析失败:{detail}': 'Could not parse the response: {detail}',
187
+ '余额接口请求失败': 'The balance request failed.',
188
+ '缺少 sessionId': 'Missing sessionId.',
189
+ '会话日志不可用': 'The session log is unavailable.',
190
+ /* settings page */
191
+ '消耗统计': 'Usage statistics',
192
+ '本地估算,非官方账单': 'Local estimate, not an official bill',
193
+ '今天': 'Today',
194
+ '近一周': 'Last 7 days',
195
+ '近一月': 'Last 30 days',
196
+ '近一年': 'Last 12 months',
197
+ '全部历史 · 含所有工作区': 'All history · every workspace',
198
+ ' · 全部历史逐轮计价': ' · per-turn pricing over all history',
199
+ '时间范围': 'Time range',
200
+ '自定义区间': 'Custom range',
201
+ '工作区': 'Workspace',
202
+ '全部工作区': 'All workspaces',
203
+ '未归属工作区': 'Unassigned',
204
+ '日历选择(覆盖时间范围)': 'Calendar (overrides the range)',
205
+ '清除': 'Clear',
206
+ '数据': 'Data',
207
+ '刷新': 'Refresh',
208
+ '估算金额(右轴)': 'Estimated cost (right axis)',
209
+ 'Token(左轴,默认隐藏)': 'Tokens (left axis, hidden by default)',
210
+ '该范围内暂无消耗记录': 'No usage recorded in this range',
211
+ '总消耗 ': 'Total cost ',
212
+ '总 Token ': 'Total tokens ',
213
+ '记录 ': 'Records ',
214
+ ' 条': '',
215
+ '峰时 ': 'Peak ',
216
+ '谷时 ': 'Off-peak ',
217
+ '粒度 ': 'Bucket ',
218
+ '按月': 'monthly',
219
+ '按日': 'daily',
220
+ '按小时': 'hourly',
221
+ '数据源:本机逐轮记录(localStorage {prefix}<sessionId>:<turn>);按北京时间聚合(今天与单日区间逐小时,多日逐日,近一年逐月){worker}。金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考。': 'Source: local per-turn records (localStorage {prefix}<sessionId>:<turn>), aggregated in Beijing time (hourly for today and single-day ranges, daily for multi-day ranges, monthly for the last 12 months){worker}. Costs are estimated from DeepSeek list prices (off-peak/peak ×1/×2) and are for reference only.',
222
+ ';聚合已在 Web Worker 中完成': '; aggregation ran in a Web Worker',
223
+ /* personalization page block */
224
+ '消耗弹窗': 'Usage popup',
225
+ '背景': 'Background',
226
+ '点击对话里的 ⛽ 胶囊弹出的消耗面板': 'The usage panel opened by the ⛽ pill in the chat',
227
+ '消耗弹窗背景': 'Usage popup background',
228
+ '默认': 'Default',
229
+ '毛玻璃': 'Glass',
230
+ '自定义背景': 'Custom image',
231
+ '纯色': 'Solid color',
232
+ '弹窗底色;文字颜色会按明暗自动切换': 'Popup base color; text color adapts automatically to lightness',
233
+ '正在保存…': 'Saving…',
234
+ '已保存到本机': 'Saved on this device',
235
+ '取回失败,请重新选择': 'Could not read it back — please pick again',
236
+ '当前:本地图片({status})': 'Using: local image ({status})',
237
+ '当前:本地图片(已保存到本机)': 'Using: local image (saved on this device)',
238
+ '当前:图片 URL': 'Using: image URL',
239
+ '未设置图片(此时按默认背景显示)': 'No image set (falls back to the default background)',
240
+ '图片': 'Image',
241
+ ';可选 URL 或本地图片,不限大小': '; a URL or a local image, any size',
242
+ '更换图片…': 'Change image…',
243
+ '选择图片…': 'Choose image…',
244
+ '已使用本地图片,输入 URL 可替换': 'Local image in use — enter a URL to replace it',
245
+ '不透明度': 'Opacity',
246
+ '背景图片自身的透明度(0% 完全不可见,100% 完整显示)': 'Opacity of the background image itself (0% invisible, 100% fully visible)',
247
+ '把背景图片磨砂化(对图片做真模糊)': 'Frost the background image (a real blur on the image)',
248
+ '底色半透明并模糊背后的页面': 'Translucent base that blurs the page behind it',
249
+ '毛玻璃强度': 'Glass strength',
250
+ '模糊半径(0–48px)': 'Blur radius (0–48px)',
251
+ '消耗徽章弹层的背景样式;不影响对话区与侧边栏背景。{tag}': 'Background style of the badge popup; it does not affect the chat or sidebar background. {tag}',
252
+ '重置本栏': 'Reset this section',
253
+ '消耗弹窗设置需要 react-dom,当前环境不可用。': 'Popup settings need react-dom, which is not available here.',
254
+ 'error: IndexedDB 与 FileReader 都不可用': 'error: IndexedDB and FileReader are both unavailable',
255
+ 'IndexedDB 与 FileReader 都不可用': 'IndexedDB and FileReader are both unavailable',
256
+ /* update notice */
257
+ '消耗徽章有新版本': 'A new version of the usage badge is available',
258
+ '当前 v{current} → 最新 v{latest}': 'current v{current} → latest v{latest}',
259
+ '复制更新命令': 'Copy update command',
260
+ '已复制': 'Copied',
261
+ '忽略此版本': 'Ignore this version',
262
+ '稍后提醒': 'Remind me later',
263
+ '更新后需重启 dsh 进程': 'Restart the dsh process after updating',
264
+ '版本来自 npm registry(24h 缓存);「忽略此版本」会记住,直到出现更高版本。': 'Versions come from the npm registry (24h cache); “Ignore this version” is remembered until a higher version appears.',
265
+ /* slot labels */
266
+ '消耗': 'Usage',
267
+ }
268
+
269
+ let boundTranslate = null
270
+ let localeService = null
271
+ let appCtx = null
272
+
273
+ /** Fill `{name}` placeholders (same template syntax as the locale service). */
274
+ function applyParams(template, params) {
275
+ if (!params) return String(template)
276
+ return String(template).replace(/\{(\w+)\}/g, function (all, name) {
277
+ return Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : all
278
+ })
279
+ }
280
+
281
+ /**
282
+ * Bind the shell's locale service when the composition provides one.
283
+ * Optional dependency on purpose: without the service every string falls
284
+ * back to its Chinese key, so the plugin still works standalone.
285
+ * @param ctx client cordis context.
286
+ */
287
+ function attachLocale(ctx) {
288
+ if (localeService !== null) return
289
+ let service = null
290
+ try {
291
+ // ctx.get(name, strict = true) throws when the service is missing: this is an
292
+ // optional dependency, so read it non-strictly. The try/catch also covers
293
+ // contexts without a get() (older shells, test doubles).
294
+ if (ctx && typeof ctx.get === 'function') service = ctx.get('locale', false) || null
295
+ } catch (e) {
296
+ service = null
297
+ }
298
+ if (service === null || typeof service.register !== 'function' || typeof service.bind !== 'function') return
299
+ try {
300
+ // Only the `en` side is registered: the key already is the `zh` copy, and
301
+ // the locale service resolves a miss to the key itself.
302
+ service.register(LOCALE_NS, 'en', DICT_EN)
303
+ } catch (e) {
304
+ // Already registered (hot re-mount of the same fiber) — binding is enough.
305
+ }
306
+ boundTranslate = service.bind(LOCALE_NS)
307
+ localeService = service
308
+ }
309
+
310
+ /**
311
+ * Late-binding guard: the locale service may be provided after this plugin's
312
+ * apply() ran (boot order differs between compositions), so the first render
313
+ * retries the bind. Costs one ctx.get per tt() call while unbound.
314
+ */
315
+ function ensureLocaleBound() {
316
+ if (localeService !== null || appCtx === null) return
317
+ attachLocale(appCtx)
318
+ }
319
+
320
+ /** Translate a key with the active locale; falls back to the key itself. */
321
+ function tt(key, params) {
322
+ if (localeService === null) ensureLocaleBound()
323
+ if (boundTranslate !== null) {
324
+ try {
325
+ return boundTranslate(key, params)
326
+ } catch (e) { /* fall through to the key */ }
327
+ }
328
+ return applyParams(key, params)
329
+ }
330
+
331
+ /** Subscribe to locale switches (only the self-built React root needs this). */
332
+ function subscribeLocale(fn) {
333
+ const service = localeService
334
+ if (service === null || typeof service.subscribe !== 'function') return function () {}
335
+ try {
336
+ const off = service.subscribe(fn)
337
+ return typeof off === 'function' ? off : function () {}
338
+ } catch (e) {
339
+ return function () {}
340
+ }
341
+ }
342
+
343
+ /* Host error codes (lib/index.js) → the Chinese key tt() translates. */
344
+ const HOST_ERROR_KEYS = {
345
+ 'no-credentials': 'credentials 服务不可用',
346
+ 'key-read-failed': '读取 API Key 失败:{detail}',
347
+ 'no-key': '未配置 {detail}',
348
+ 'no-subprocess': 'subprocess 服务不可用',
349
+ 'curl-empty': 'curl 没有输出:{detail}',
350
+ 'spawn-failed': 'curl 启动失败:{detail}',
351
+ 'bad-response': '响应解析失败:{detail}',
352
+ 'balance-request-failed': '余额接口请求失败',
353
+ 'missing-session-id': '缺少 sessionId',
354
+ 'log-unavailable': '会话日志不可用',
355
+ }
356
+
357
+ /**
358
+ * Render a host error payload in the active locale. Unknown codes fall back
359
+ * to the host's own (English) message so nothing is ever swallowed.
360
+ */
361
+ function hostErrorText(code, detail, fallback) {
362
+ const key = typeof code === 'string' ? HOST_ERROR_KEYS[code] : undefined
363
+ if (key === undefined) return typeof fallback === 'string' ? fallback : ''
364
+ return tt(key, { detail: typeof detail === 'string' ? detail : '' })
365
+ }
366
+
116
367
  /* local persistence: one record per <sessionId>:<turn> */
117
368
  function lsGet(key) {
118
369
  try {
@@ -201,6 +452,376 @@ window.__ModuleLoader__.load({
201
452
  return merged
202
453
  }
203
454
 
455
+ /* ================= 消耗弹窗背景样式(与个性化插件联动) ================= */
456
+ const MODAL_STYLE_KEY = 'dsh-consumption:modal-style'
457
+ const BUILD_TAG = 'dcb-1.2.2'
458
+ // 本地版本:本包是零构建的手写客户端包,无法 import package.json,因此把版本号作为常量维护,
459
+ // 并由 check.mjs 守卫与 package.json 严格一致(发布前不一致直接失败)。
460
+ const PLUGIN_VERSION = '1.2.2'
461
+ const PKG_NAME = 'dsh-consumption-badge'
462
+ const UPDATE_REGISTRY_URL = 'https://registry.npmjs.org/' + PKG_NAME + '/latest'
463
+ const UPDATE_COMMAND = 'dsh plugin --profile web update ' + PKG_NAME
464
+ // 与 dsh-plugin-personalization 同口径:registry 结果缓存 24h;
465
+ // 「忽略此版本」持久化;「稍后提醒」只关闭本次会话(不写忽略键,下次启动仍会检测)。
466
+ const LS_UPDATE_CHECK = 'dsh-consumption:update-check'
467
+ const LS_IGNORED_UPDATE = 'dsh-consumption:ignored-update-version'
468
+ const UPDATE_CHECK_TTL = 24 * 60 * 60 * 1000
469
+ const UPDATE_FETCH_TIMEOUT = 5000
470
+ // opacity:背景图片自身的不透明度(0–100,与个性化插件其它背景栏同口径);
471
+ // 旧版的 scrim(压暗遮罩)在读取时折算成 100 - scrim,老配置不会丢。
472
+ const DEFAULT_MODAL_STYLE = { mode: 'default', color: '#17181c', image: '', imageKey: '', opacity: 85, glass: false, blur: 22 }
473
+ // label 是词典 key(中文原文),渲染时统一走 tt(mode.label),所以这里不能直接显示。
474
+ const MODAL_MODES = [
475
+ { value: 'default', label: '默认' },
476
+ { value: 'glass', label: '毛玻璃' },
477
+ { value: 'image', label: '自定义背景' },
478
+ { value: 'solid', label: '纯色' },
479
+ ]
480
+
481
+ /* ---- 弹窗背景图片的本地存储:IndexedDB 存 Blob,localStorage 只留一个 key ---- */
482
+ const IMG_DB_NAME = 'dsh-consumption-assets'
483
+ const IMG_STORE = 'images'
484
+ let imgDbPromise = null
485
+
486
+ /** 打开(或首次创建)图片库;环境不支持 IndexedDB 时返回 null。 */
487
+ function imageDb() {
488
+ if (typeof indexedDB === 'undefined' || indexedDB === null) return Promise.resolve(null)
489
+ if (imgDbPromise !== null) return imgDbPromise
490
+ imgDbPromise = new Promise(function (resolve) {
491
+ let req = null
492
+ try {
493
+ req = indexedDB.open(IMG_DB_NAME, 1)
494
+ } catch (e) {
495
+ resolve(null)
496
+ return
497
+ }
498
+ req.onupgradeneeded = function () {
499
+ try {
500
+ req.result.createObjectStore(IMG_STORE)
501
+ } catch (e) { /* 已存在 */ }
502
+ }
503
+ req.onsuccess = function () { resolve(req.result) }
504
+ req.onerror = function () { resolve(null) }
505
+ req.onblocked = function () { resolve(null) }
506
+ })
507
+ return imgDbPromise
508
+ }
509
+
510
+ /** 写入一张图片(File/Blob 直接入库存,不做 base64,因此没有大小上限)。 */
511
+ function idbPutImage(key, blob) {
512
+ return imageDb().then(function (db) {
513
+ if (db === null) return false
514
+ return new Promise(function (resolve) {
515
+ try {
516
+ const tx = db.transaction(IMG_STORE, 'readwrite')
517
+ tx.objectStore(IMG_STORE).put(blob, key)
518
+ tx.oncomplete = function () { resolve(true) }
519
+ tx.onerror = function () { resolve(false) }
520
+ tx.onabort = function () { resolve(false) }
521
+ } catch (e) {
522
+ resolve(false)
523
+ }
524
+ })
525
+ })
526
+ }
527
+
528
+ /** 读回一张图片。 */
529
+ function idbGetImage(key) {
530
+ return imageDb().then(function (db) {
531
+ if (db === null || !key) return null
532
+ return new Promise(function (resolve) {
533
+ try {
534
+ const tx = db.transaction(IMG_STORE, 'readonly')
535
+ const req = tx.objectStore(IMG_STORE).get(key)
536
+ req.onsuccess = function () { resolve(req.result === undefined ? null : req.result) }
537
+ req.onerror = function () { resolve(null) }
538
+ } catch (e) {
539
+ resolve(null)
540
+ }
541
+ })
542
+ })
543
+ }
544
+
545
+ /** 删除一张图片。 */
546
+ function idbDeleteImage(key) {
547
+ if (!key) return Promise.resolve()
548
+ return imageDb().then(function (db) {
549
+ if (db === null) return
550
+ try {
551
+ db.transaction(IMG_STORE, 'readwrite').objectStore(IMG_STORE).delete(key)
552
+ } catch (e) { /* ignore */ }
553
+ })
554
+ }
555
+
556
+ /** 读弹窗背景配置(本地偏好,非会话数据),损坏则回默认。 */
557
+ function readModalStyle() {
558
+ const out = {
559
+ mode: DEFAULT_MODAL_STYLE.mode,
560
+ color: DEFAULT_MODAL_STYLE.color,
561
+ image: DEFAULT_MODAL_STYLE.image,
562
+ imageKey: DEFAULT_MODAL_STYLE.imageKey,
563
+ opacity: DEFAULT_MODAL_STYLE.opacity,
564
+ blur: DEFAULT_MODAL_STYLE.blur,
565
+ glass: DEFAULT_MODAL_STYLE.glass,
566
+ }
567
+ const raw = lsGet(MODAL_STYLE_KEY)
568
+ if (!raw) return out
569
+ try {
570
+ const rec = JSON.parse(raw)
571
+ if (!rec || typeof rec !== 'object') return out
572
+ if (rec.mode === 'default' || rec.mode === 'glass' || rec.mode === 'image' || rec.mode === 'solid') out.mode = rec.mode
573
+ if (typeof rec.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(rec.color)) out.color = rec.color
574
+ if (typeof rec.image === 'string') out.image = rec.image
575
+ if (typeof rec.imageKey === 'string') out.imageKey = rec.imageKey
576
+ if (typeof rec.opacity === 'number' && rec.opacity >= 0 && rec.opacity <= 100) {
577
+ out.opacity = rec.opacity
578
+ } else if (typeof rec.scrim === 'number' && rec.scrim >= 0 && rec.scrim <= 100) {
579
+ // 旧配置:压暗 62% ⇒ 图片不透明度 38%
580
+ out.opacity = Math.max(0, Math.min(100, 100 - rec.scrim))
581
+ }
582
+ if (typeof rec.blur === 'number' && rec.blur >= 0 && rec.blur <= 48) out.blur = rec.blur
583
+ if (rec.glass === true) out.glass = true
584
+ } catch (e) { /* 忽略损坏配置 */ }
585
+ return out
586
+ }
587
+
588
+ /**
589
+ * 浅/深底上的墨色调色板:只作用于弹窗子树(覆盖主题 token 的局部值)。
590
+ * 连 `--dsw-alias-bg-overlay` 一起覆盖,否则 tooltip 会用主题的浅色底配我们强制出来的浅色字 = 看不见。
591
+ */
592
+ function inkPalette(dark) {
593
+ if (dark) {
594
+ return {
595
+ '--dsw-alias-bg-base': 'rgba(12,14,18,.58)',
596
+ '--dsw-alias-bg-layer-1': 'rgba(12,14,18,.55)',
597
+ '--dsw-alias-bg-layer-2': 'rgba(255,255,255,.12)',
598
+ '--dsw-alias-bg-overlay': 'rgba(16,18,24,.95)',
599
+ '--dsw-alias-label-primary': 'rgba(255,255,255,.96)',
600
+ '--dsw-alias-label-secondary': 'rgba(255,255,255,.74)',
601
+ '--dsw-alias-border-l1': 'rgba(255,255,255,.16)',
602
+ '--dsw-alias-border-l2': 'rgba(255,255,255,.28)',
603
+ }
604
+ }
605
+ return {
606
+ '--dsw-alias-bg-base': 'rgba(255,255,255,.6)',
607
+ '--dsw-alias-bg-layer-1': 'rgba(255,255,255,.66)',
608
+ '--dsw-alias-bg-layer-2': 'rgba(0,0,0,.06)',
609
+ '--dsw-alias-bg-overlay': 'rgba(255,255,255,.97)',
610
+ '--dsw-alias-label-primary': 'rgba(0,0,0,.88)',
611
+ '--dsw-alias-label-secondary': 'rgba(0,0,0,.62)',
612
+ '--dsw-alias-border-l1': 'rgba(0,0,0,.12)',
613
+ '--dsw-alias-border-l2': 'rgba(0,0,0,.22)',
614
+ }
615
+ }
616
+
617
+ /** 十六进制颜色的相对亮度(判断该配深色字还是浅色字)。 */
618
+ function hexLuma(hex) {
619
+ const v = parseInt(String(hex).slice(1), 16)
620
+ const r = (v >> 16) & 255
621
+ const g = (v >> 8) & 255
622
+ const b = v & 255
623
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
624
+ }
625
+
626
+ /**
627
+ * 由配置算出弹窗的 class + 自定义属性。真正的观感全部交给 CSS 变量 + backdrop-filter:
628
+ * - 毛玻璃:不加压暗底色,靠 `backdrop-filter` 模糊背后的页面(与个性化插件的设置面板同款做法);
629
+ * - 自定义背景:图片走伪元素 `--dcb-bg-layer`,因此可以单独对图片做模糊;
630
+ * - 纯色:只给 `--dcb-solid`,由 CSS 决定透明度(开毛玻璃时半透明 + 模糊)。
631
+ * @param style 偏好配置。
632
+ * @param imageUrl 已解析好的图片地址(URL 或 IndexedDB 里取出来的 blob: 地址)。
633
+ */
634
+ function modalSurface(style, imageUrl) {
635
+ const cfg = style || DEFAULT_MODAL_STYLE
636
+ const src = typeof imageUrl === 'string' && imageUrl !== '' ? imageUrl : (typeof cfg.image === 'string' ? cfg.image : '')
637
+ const hasImage = cfg.mode === 'image' && !!(cfg.imageKey || src)
638
+ const mode = cfg.mode === 'glass' ? 'glass' : (cfg.mode === 'solid' ? 'solid' : (hasImage ? 'image' : 'default'))
639
+ const glassed = cfg.mode === 'glass' || cfg.glass === true
640
+ const classes = ['dcb-modal']
641
+ if (mode === 'glass') classes.push('is-glass')
642
+ if (mode === 'solid') classes.push('has-solid')
643
+ if (mode === 'image') classes.push('has-image')
644
+ // 毛玻璃只对「有底」的三种形态有意义;图片被清除后回落默认外观,不再挂玻璃。
645
+ if (glassed && mode !== 'glass' && mode !== 'default') classes.push('is-glass')
646
+ const inline = {}
647
+ if (glassed) {
648
+ inline['--dcb-glass-blur'] = (typeof cfg.blur === 'number' ? cfg.blur : DEFAULT_MODAL_STYLE.blur) + 'px'
649
+ }
650
+ if (mode === 'solid') {
651
+ const dark = hexLuma(cfg.color) < 0.5
652
+ Object.assign(inline, inkPalette(dark))
653
+ inline['--dcb-solid'] = cfg.color
654
+ } else if (mode === 'image') {
655
+ // 与个性化插件其它背景栏同口径:图片自身带 opacity(真半透明,不与底色混成发白),
656
+ // 底下垫的是主题面板色,所以透明度越低越"淡",而不是越"暗"。
657
+ inline['--dcb-bg-image'] = 'url("' + String(src).replace(/"/g, '%22') + '")'
658
+ const alpha = Math.min(1, Math.max(0, (typeof cfg.opacity === 'number' ? cfg.opacity : DEFAULT_MODAL_STYLE.opacity) / 100))
659
+ inline['--dcb-bg-opacity'] = String(alpha)
660
+ }
661
+ // 遮罩层永远不参与毛玻璃:模糊只发生在弹窗本体(`.dcb-modal.is-glass` 的 backdrop-filter)。
662
+ return {
663
+ className: classes.join(' '),
664
+ style: inline,
665
+ glass: glassed || mode === 'glass',
666
+ }
667
+ }
668
+
669
+ /* ===================== 更新检测(对齐个性化插件的口径) ===================== */
670
+
671
+ /** 解析版本号:`1.2.3-rc.1` → { numbers:[1,2,3], pre:['rc','1'] }。 */
672
+ function parseVersionText(value) {
673
+ const text = typeof value === 'string' ? value.trim().replace(/^v/i, '') : ''
674
+ const dash = text.indexOf('-')
675
+ const core = dash >= 0 ? text.slice(0, dash) : text
676
+ const pre = dash >= 0 ? text.slice(dash + 1) : ''
677
+ const numbers = [0, 0, 0]
678
+ const parts = core.split('.')
679
+ for (let i = 0; i < 3; i += 1) {
680
+ const n = parseInt(parts[i] === undefined ? '0' : parts[i], 10)
681
+ numbers[i] = Number.isFinite(n) ? n : 0
682
+ }
683
+ return { numbers: numbers, pre: pre === '' ? [] : pre.split('.') }
684
+ }
685
+
686
+ /** 预发布段比较:数字段按数值、字母段按字典序,缺段者更高(1.0.0 > 1.0.0-rc.1)。 */
687
+ function comparePrerelease(a, b) {
688
+ if (a.length === 0 && b.length === 0) return 0
689
+ if (a.length === 0) return 1
690
+ if (b.length === 0) return -1
691
+ const len = Math.max(a.length, b.length)
692
+ for (let i = 0; i < len; i += 1) {
693
+ if (a[i] === undefined) return -1
694
+ if (b[i] === undefined) return 1
695
+ const an = Number(a[i])
696
+ const bn = Number(b[i])
697
+ const aNum = Number.isFinite(an) && String(an) === String(a[i])
698
+ const bNum = Number.isFinite(bn) && String(bn) === String(b[i])
699
+ if (aNum && bNum) {
700
+ if (an !== bn) return an < bn ? -1 : 1
701
+ } else if (a[i] !== b[i]) {
702
+ return a[i] < b[i] ? -1 : 1
703
+ }
704
+ }
705
+ return 0
706
+ }
707
+
708
+ /** 语义化版本比较:a<b 返回 -1,相等 0,a>b 返回 1。 */
709
+ function compareVersions(local, latest) {
710
+ const a = parseVersionText(local)
711
+ const b = parseVersionText(latest)
712
+ for (let i = 0; i < 3; i += 1) {
713
+ if (a.numbers[i] !== b.numbers[i]) return a.numbers[i] < b.numbers[i] ? -1 : 1
714
+ }
715
+ return comparePrerelease(a.pre, b.pre)
716
+ }
717
+
718
+ /** 读 registry 结果缓存 `{latestVersion, checkedAt}`;结构非法一律视为无缓存。 */
719
+ function readUpdateCache() {
720
+ const raw = lsGet(LS_UPDATE_CHECK)
721
+ if (typeof raw !== 'string' || raw === '') return null
722
+ try {
723
+ const parsed = JSON.parse(raw)
724
+ if (!parsed || typeof parsed !== 'object') return null
725
+ const latest = typeof parsed.latestVersion === 'string' ? parsed.latestVersion : ''
726
+ const checkedAt = Number(parsed.checkedAt)
727
+ if (latest === '' || !Number.isFinite(checkedAt)) return null
728
+ return { latestVersion: latest, checkedAt: checkedAt }
729
+ } catch (e) {
730
+ return null
731
+ }
732
+ }
733
+
734
+ function writeUpdateCache(info) {
735
+ if (!info || typeof info !== 'object') return
736
+ lsSet(LS_UPDATE_CHECK, JSON.stringify({ latestVersion: String(info.latestVersion), checkedAt: Number(info.checkedAt) }))
737
+ }
738
+
739
+ function readIgnoredVersion() {
740
+ const raw = lsGet(LS_IGNORED_UPDATE)
741
+ return typeof raw === 'string' && raw !== '' ? raw : null
742
+ }
743
+
744
+ function writeIgnoredVersion(version) {
745
+ if (version === null || version === undefined || version === '') return
746
+ lsSet(LS_IGNORED_UPDATE, String(version))
747
+ }
748
+
749
+ /**
750
+ * 请求 npm registry 的最新版本(5s 超时、静默失败)。
751
+ * 浏览器直连 registry 可行:它的 GET 响应带 `access-control-allow-origin: *`。
752
+ * @returns 版本号字符串,任何异常返回 null。
753
+ */
754
+ function fetchLatestVersion() {
755
+ if (typeof fetch !== 'function') return Promise.resolve(null)
756
+ const options = { headers: { Accept: 'application/vnd.npm.install-v1+json' } }
757
+ let controller = null
758
+ if (typeof AbortController === 'function') {
759
+ try {
760
+ controller = new AbortController()
761
+ options.signal = controller.signal
762
+ } catch (e) {
763
+ controller = null
764
+ }
765
+ }
766
+ const request = fetch(UPDATE_REGISTRY_URL, options)
767
+ .then(function (response) {
768
+ if (!response || response.ok === false) throw new Error('registry status ' + (response && response.status))
769
+ return response.json()
770
+ })
771
+ .then(function (data) {
772
+ const latest = data && typeof data.version === 'string' ? data.version.trim() : ''
773
+ return latest !== '' ? latest : null
774
+ })
775
+ .catch(function (err) {
776
+ console.warn('[consumption-badge] update check failed (silent): ' + (err && err.message ? err.message : String(err)))
777
+ return null
778
+ })
779
+ if (controller === null) return request
780
+ return new Promise(function (resolve) {
781
+ const timer = setTimeout(function () {
782
+ try {
783
+ controller.abort()
784
+ } catch (e) { /* ignore */ }
785
+ resolve(null)
786
+ }, UPDATE_FETCH_TIMEOUT)
787
+ request.then(function (value) {
788
+ clearTimeout(timer)
789
+ resolve(value)
790
+ })
791
+ })
792
+ }
793
+
794
+ /**
795
+ * 主流程:缓存命中(< TTL)直接用缓存比较,过期/缺失才请求 registry。
796
+ * 任何失败都返回 hasUpdate:false,绝不 reject —— 调用方无需 try/catch。
797
+ * @returns Promise<{hasUpdate, latestVersion, currentVersion, fromCache}>
798
+ */
799
+ function checkForUpdate() {
800
+ const current = PLUGIN_VERSION
801
+ const ignored = readIgnoredVersion()
802
+ const cached = readUpdateCache()
803
+ const fresh = cached !== null && Date.now() - cached.checkedAt < UPDATE_CHECK_TTL
804
+ const base = fresh
805
+ ? Promise.resolve(cached.latestVersion)
806
+ : fetchLatestVersion().then(function (latest) {
807
+ if (latest === null) return cached !== null ? cached.latestVersion : null
808
+ writeUpdateCache({ latestVersion: latest, checkedAt: Date.now() })
809
+ return latest
810
+ })
811
+ return base.then(function (latest) {
812
+ if (latest === null || latest === undefined) {
813
+ return { hasUpdate: false, latestVersion: null, currentVersion: current, fromCache: fresh, ignored: false }
814
+ }
815
+ return {
816
+ hasUpdate: compareVersions(current, latest) < 0 && latest !== ignored,
817
+ latestVersion: latest,
818
+ currentVersion: current,
819
+ fromCache: fresh,
820
+ ignored: latest === ignored,
821
+ }
822
+ })
823
+ }
824
+
204
825
  /* shared store: every pill and the single overlay panel read this */
205
826
  function createStore() {
206
827
  const state = {
@@ -211,11 +832,29 @@ window.__ModuleLoader__.load({
211
832
  turns: [],
212
833
  turnsState: 'idle',
213
834
  turnsError: '',
835
+ // Error copy is stored language-neutral: the kind names the sentence and
836
+ // the raw detail rides turnsError, so a locale switch re-renders the text.
837
+ turnsErrorKind: '',
838
+ turnsErrorCode: '',
839
+ turnsErrorFallback: '',
214
840
  account: null,
215
841
  accountState: 'idle',
842
+ integrations: null,
843
+ modalStyle: readModalStyle(),
844
+ // 已解析的弹窗背景图地址:URL 直接用,本地图片是 IndexedDB 里的 blob: 地址。
845
+ modalImageUrl: '',
846
+ // 图片保存过程的可观测状态:'' | 'saving' | 'saved' | 以 'error:' 开头的失败原因
847
+ modalImageStatus: '',
848
+ // 更新提示弹窗:'idle' | 'checking' | 'ready';laterDismissed 只在本次会话有效
849
+ updateState: 'idle',
850
+ updateInfo: null,
851
+ updateDismissed: false,
216
852
  balance: null,
217
853
  balanceState: 'idle',
218
854
  balanceError: '',
855
+ balanceErrorKind: '',
856
+ balanceErrorCode: '',
857
+ balanceErrorFallback: '',
219
858
  balanceAt: 0,
220
859
  }
221
860
  const listeners = new Set()
@@ -243,6 +882,7 @@ window.__ModuleLoader__.load({
243
882
  .then(function () { return fetchJson(API + '/account') })
244
883
  .then(function (res) {
245
884
  if (res && typeof res === 'object') state.account = res
885
+ if (res && res.integrations && typeof res.integrations === 'object') state.integrations = res.integrations
246
886
  state.accountState = 'ready'
247
887
  emit()
248
888
  })
@@ -283,13 +923,26 @@ window.__ModuleLoader__.load({
283
923
  }
284
924
  state.turns = restoreTurns(sessionId, normalized)
285
925
  state.turnsState = res && res.ok === false ? 'error' : 'ready'
286
- state.turnsError = res && typeof res.error === 'string' ? res.error : ''
926
+ if (res && res.ok === false) {
927
+ state.turnsErrorKind = 'host'
928
+ state.turnsErrorCode = typeof res.code === 'string' ? res.code : ''
929
+ state.turnsError = typeof res.detail === 'string' ? res.detail : ''
930
+ state.turnsErrorFallback = typeof res.error === 'string' ? res.error : ''
931
+ } else {
932
+ state.turnsErrorKind = ''
933
+ state.turnsErrorCode = ''
934
+ state.turnsError = ''
935
+ state.turnsErrorFallback = ''
936
+ }
287
937
  persistTurns(sessionId, normalized)
288
938
  emit()
289
939
  })
290
940
  .catch(function (e) {
291
941
  state.turnsState = 'error'
292
- state.turnsError = '会话消耗读取失败:' + msgOf(e)
942
+ state.turnsErrorKind = 'read'
943
+ state.turnsErrorCode = ''
944
+ state.turnsError = msgOf(e)
945
+ state.turnsErrorFallback = ''
293
946
  emit()
294
947
  })
295
948
  .then(function () { turnsInFlight = null })
@@ -304,17 +957,35 @@ window.__ModuleLoader__.load({
304
957
  balanceInFlight = Promise.resolve()
305
958
  .then(function () { return fetchJson(API + '/balance' + (force ? '?force=1' : '')) })
306
959
  .then(function (res) {
307
- const value = res && typeof res === 'object' ? res : { ok: false, error: '空响应' }
960
+ const value = res && typeof res === 'object' ? res : { ok: false }
308
961
  state.balance = value
309
962
  state.balanceAt = Date.now()
310
963
  state.balanceState = 'ready'
311
- state.balanceError = value.ok ? '' : (typeof value.error === 'string' ? value.error : '余额获取失败')
964
+ if (!res || typeof res !== 'object') {
965
+ state.balanceErrorKind = 'empty'
966
+ state.balanceError = ''
967
+ state.balanceErrorCode = ''
968
+ state.balanceErrorFallback = ''
969
+ } else if (value.ok) {
970
+ state.balanceErrorKind = ''
971
+ state.balanceError = ''
972
+ state.balanceErrorCode = ''
973
+ state.balanceErrorFallback = ''
974
+ } else {
975
+ state.balanceErrorKind = 'host'
976
+ state.balanceErrorCode = typeof value.code === 'string' ? value.code : ''
977
+ state.balanceError = typeof value.detail === 'string' ? value.detail : ''
978
+ state.balanceErrorFallback = typeof value.error === 'string' ? value.error : ''
979
+ }
312
980
  emit()
313
981
  })
314
982
  .catch(function (e) {
315
983
  state.balance = null
316
984
  state.balanceState = 'error'
317
- state.balanceError = '余额获取失败:' + msgOf(e)
985
+ state.balanceErrorKind = 'fetch'
986
+ state.balanceErrorCode = ''
987
+ state.balanceError = msgOf(e)
988
+ state.balanceErrorFallback = ''
318
989
  emit()
319
990
  })
320
991
  .then(function () { balanceInFlight = null })
@@ -337,6 +1008,159 @@ window.__ModuleLoader__.load({
337
1008
  function bump() {
338
1009
  emit()
339
1010
  }
1011
+ /** 更新弹窗背景配置:合并 → 落本地偏好 → 通知所有订阅者(弹层即时重绘)。 */
1012
+ function setModalStyle(patch) {
1013
+ const prev = state.modalStyle
1014
+ const next = {
1015
+ mode: prev.mode,
1016
+ color: prev.color,
1017
+ image: typeof prev.image === 'string' ? prev.image : '',
1018
+ imageKey: typeof prev.imageKey === 'string' ? prev.imageKey : '',
1019
+ opacity: typeof prev.opacity === 'number' ? prev.opacity : DEFAULT_MODAL_STYLE.opacity,
1020
+ blur: typeof prev.blur === 'number' ? prev.blur : DEFAULT_MODAL_STYLE.blur,
1021
+ glass: prev.glass === true,
1022
+ }
1023
+ if (patch && typeof patch === 'object') {
1024
+ if (patch.mode === 'default' || patch.mode === 'glass' || patch.mode === 'image' || patch.mode === 'solid') next.mode = patch.mode
1025
+ if (typeof patch.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(patch.color)) next.color = patch.color
1026
+ if (typeof patch.image === 'string') next.image = patch.image
1027
+ if (typeof patch.imageKey === 'string') next.imageKey = patch.imageKey
1028
+ if (typeof patch.opacity === 'number' && patch.opacity >= 0 && patch.opacity <= 100) next.opacity = patch.opacity
1029
+ if (typeof patch.blur === 'number' && patch.blur >= 0 && patch.blur <= 48) next.blur = patch.blur
1030
+ if (typeof patch.glass === 'boolean') next.glass = patch.glass
1031
+ }
1032
+ state.modalStyle = next
1033
+ lsSet(MODAL_STYLE_KEY, JSON.stringify(next))
1034
+ emit()
1035
+ }
1036
+
1037
+ /** 切换 blob: 地址时先回收旧的,避免内存泄漏。 */
1038
+ function setResolvedImageUrl(url) {
1039
+ const prev = state.modalImageUrl
1040
+ state.modalImageUrl = url
1041
+ if (prev && prev.indexOf('blob:') === 0 && prev !== url && typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') {
1042
+ try {
1043
+ URL.revokeObjectURL(prev)
1044
+ } catch (e) { /* ignore */ }
1045
+ }
1046
+ }
1047
+
1048
+ /**
1049
+ * 存一张本地图片:Blob 进 IndexedDB(不做 base64、无大小上限),偏好里只记 key。
1050
+ * 任何一步失败都会写进 `modalImageStatus` 并在控制台留一行带标记的日志,
1051
+ * 这样界面上能直接看到原因,不再是"静默失败 + 显示未设置"。
1052
+ * @param file 用户选择的 File。
1053
+ */
1054
+ function setModalImage(file) {
1055
+ if (!file) return Promise.resolve()
1056
+ state.modalImageStatus = 'saving'
1057
+ emit()
1058
+ const key = 'modal-bg-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
1059
+ return idbPutImage(key, file).then(function (stored) {
1060
+ if (stored !== true) {
1061
+ console.warn('[consumption-badge] IndexedDB write failed, falling back to a data URL')
1062
+ return readAsDataUrl(file).then(function (dataUrl) {
1063
+ if (!dataUrl) {
1064
+ state.modalImageStatus = 'error: ' + tt('IndexedDB 与 FileReader 都不可用')
1065
+ emit()
1066
+ return
1067
+ }
1068
+ setModalStyle({ mode: 'image', image: dataUrl, imageKey: '' })
1069
+ state.modalImageStatus = 'saved'
1070
+ emit()
1071
+ })
1072
+ }
1073
+ return idbGetImage(key).then(function (blob) {
1074
+ const prevKey = state.modalStyle.imageKey
1075
+ setModalStyle({ mode: 'image', image: '', imageKey: key })
1076
+ if (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function' && blob) {
1077
+ setResolvedImageUrl(URL.createObjectURL(blob))
1078
+ } else if (blob === null) {
1079
+ console.warn('[consumption-badge] image stored but could not be read back', key)
1080
+ }
1081
+ if (prevKey && prevKey !== key) idbDeleteImage(prevKey)
1082
+ state.modalImageStatus = 'saved'
1083
+ emit()
1084
+ })
1085
+ }).catch(function (err) {
1086
+ const text = err && err.message ? err.message : String(err)
1087
+ console.error('[consumption-badge] failed to save the image: ' + text)
1088
+ state.modalImageStatus = 'error: ' + text
1089
+ emit()
1090
+ })
1091
+ }
1092
+
1093
+ /** FileReader 兜底:把文件读成 dataURL(无大小限制,能存多少算多少)。 */
1094
+ function readAsDataUrl(file) {
1095
+ if (typeof FileReader !== 'function') return Promise.resolve('')
1096
+ return new Promise(function (resolve) {
1097
+ try {
1098
+ const reader = new FileReader()
1099
+ reader.onload = function () { resolve(typeof reader.result === 'string' ? reader.result : '') }
1100
+ reader.onerror = function () { resolve('') }
1101
+ reader.readAsDataURL(file)
1102
+ } catch (e) {
1103
+ resolve('')
1104
+ }
1105
+ })
1106
+ }
1107
+
1108
+ /** 清除本地图片:库里的记录、blob: 地址、偏好键一起清。 */
1109
+ function clearModalImage() {
1110
+ const prevKey = state.modalStyle.imageKey
1111
+ setResolvedImageUrl('')
1112
+ setModalStyle({ image: '', imageKey: '' })
1113
+ state.modalImageStatus = ''
1114
+ emit()
1115
+ return idbDeleteImage(prevKey)
1116
+ }
1117
+
1118
+ /** 启动时把已保存的本地图片取回来(生成新的 blob: 地址)。 */
1119
+ function hydrateModalImage() {
1120
+ const key = state.modalStyle.imageKey
1121
+ if (!key) return Promise.resolve()
1122
+ return idbGetImage(key).then(function (blob) {
1123
+ if (!blob) return
1124
+ if (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function') {
1125
+ setResolvedImageUrl(URL.createObjectURL(blob))
1126
+ emit()
1127
+ }
1128
+ })
1129
+ }
1130
+
1131
+ /** 启动后异步检查更新(绝不阻塞首屏);失败静默。 */
1132
+ function checkUpdates() {
1133
+ if (state.updateState === 'checking') return Promise.resolve(state.updateInfo)
1134
+ state.updateState = 'checking'
1135
+ emit()
1136
+ return checkForUpdate()
1137
+ .then(function (info) {
1138
+ state.updateInfo = info
1139
+ state.updateState = 'ready'
1140
+ emit()
1141
+ return info
1142
+ })
1143
+ .catch(function () {
1144
+ state.updateState = 'ready'
1145
+ state.updateInfo = null
1146
+ emit()
1147
+ return null
1148
+ })
1149
+ }
1150
+
1151
+ /**
1152
+ * 关闭更新提示。
1153
+ * @param kind 'later' 只关本次会话;'ignore' 永久忽略该版本(写忽略键,与个性化插件同口径)。
1154
+ */
1155
+ function dismissUpdate(kind) {
1156
+ if (kind === 'ignore' && state.updateInfo && state.updateInfo.latestVersion) {
1157
+ writeIgnoredVersion(state.updateInfo.latestVersion)
1158
+ if (state.updateInfo) state.updateInfo.hasUpdate = false
1159
+ }
1160
+ state.updateDismissed = true
1161
+ emit()
1162
+ }
1163
+
340
1164
  return {
341
1165
  state: state,
342
1166
  subscribe: subscribe,
@@ -345,6 +1169,12 @@ window.__ModuleLoader__.load({
345
1169
  ensureBalance: ensureBalance,
346
1170
  openPanel: openPanel,
347
1171
  closePanel: closePanel,
1172
+ setModalStyle: setModalStyle,
1173
+ setModalImage: setModalImage,
1174
+ clearModalImage: clearModalImage,
1175
+ hydrateModalImage: hydrateModalImage,
1176
+ checkUpdates: checkUpdates,
1177
+ dismissUpdate: dismissUpdate,
348
1178
  bump: bump,
349
1179
  }
350
1180
  }
@@ -392,7 +1222,7 @@ window.__ModuleLoader__.load({
392
1222
  const holder = React.useState(-1)
393
1223
  const hover = holder[0]
394
1224
  const setHover = holder[1]
395
- if (!series.length) return h('div', { className: 'dcb-empty' }, '本会话还没有可统计的轮次')
1225
+ if (!series.length) return h('div', { className: 'dcb-empty' }, tt('本会话还没有可统计的轮次'))
396
1226
  const innerW = CHART_W - CHART_PAD.l - CHART_PAD.r
397
1227
  const innerH = CHART_H - CHART_PAD.t - CHART_PAD.b
398
1228
  let maxToken = 1
@@ -459,18 +1289,18 @@ window.__ModuleLoader__.load({
459
1289
  h('div', { className: 'dcb-tooltip-title' },
460
1290
  '#' + String(hovered.turn),
461
1291
  h('span', { className: 'dcb-tooltip-model' }, hovered.model || DEFAULT_MODEL),
462
- h('span', { className: hovered.peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, hovered.peak ? '峰时' : '谷时')),
463
- h('div', { className: 'dcb-tooltip-row' }, h('span', null, '输入命中'), h('b', null, fmtTokens(hovered.hit))),
464
- h('div', { className: 'dcb-tooltip-row' }, h('span', null, '输入未命中'), h('b', null, fmtTokens(hovered.miss))),
465
- h('div', { className: 'dcb-tooltip-row' }, h('span', null, '输出'), h('b', null, fmtTokens(hovered.out))),
466
- h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, '本轮合计'), h('b', null, fmtTokens(hovered.tokens))),
467
- h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, '估算金额'), h('b', null, '≈¥' + fmtYuan(hovered.cost))))
1292
+ h('span', { className: hovered.peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(hovered.peak ? '峰时' : '谷时'))),
1293
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输入命中')), h('b', null, fmtTokens(hovered.hit))),
1294
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输入未命中')), h('b', null, fmtTokens(hovered.miss))),
1295
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输出')), h('b', null, fmtTokens(hovered.out))),
1296
+ h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('本轮合计')), h('b', null, fmtTokens(hovered.tokens))),
1297
+ h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('估算金额')), h('b', null, '≈¥' + fmtYuan(hovered.cost))))
468
1298
  : null
469
1299
 
470
1300
  return h('div', { className: 'dcb-chart-wrap' },
471
1301
  h('div', { className: 'dcb-chart-legend' },
472
- h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot' }), '每轮 Token'),
473
- h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot dcb-dot-cost' }), '每轮估算金额')),
1302
+ h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot' }), tt('每轮 Token')),
1303
+ h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot dcb-dot-cost' }), tt('每轮估算金额'))),
474
1304
  h('div', { className: 'dcb-chart-body' },
475
1305
  h('svg', {
476
1306
  className: 'dcb-chart-svg',
@@ -492,6 +1322,24 @@ window.__ModuleLoader__.load({
492
1322
 
493
1323
  /* components: one store per plugin instance */
494
1324
  const store = createStore()
1325
+
1326
+ /* Error copy is composed at render time (not at fetch time) so it follows the locale. */
1327
+ function turnsErrorText() {
1328
+ const st = store.state
1329
+ if (st.turnsErrorKind === 'host') return hostErrorText(st.turnsErrorCode, st.turnsError, st.turnsErrorFallback)
1330
+ if (st.turnsErrorKind === 'read') return tt('会话消耗读取失败:{msg}', { msg: st.turnsError })
1331
+ return st.turnsError
1332
+ }
1333
+ function balanceErrorText() {
1334
+ const st = store.state
1335
+ if (st.balanceErrorKind === 'empty') return tt('空响应')
1336
+ if (st.balanceErrorKind === 'host') {
1337
+ const text = hostErrorText(st.balanceErrorCode, st.balanceError, st.balanceErrorFallback)
1338
+ return text === '' ? tt('余额获取失败') : text
1339
+ }
1340
+ if (st.balanceErrorKind === 'fetch') return tt('余额获取失败:{msg}', { msg: st.balanceError })
1341
+ return st.balanceError
1342
+ }
495
1343
  // Assigned by the overlay's callback ref, so Esc can reach a focused node without useRef.
496
1344
  let panelRootEl = null
497
1345
 
@@ -545,7 +1393,7 @@ window.__ModuleLoader__.load({
545
1393
  return h('button', {
546
1394
  type: 'button',
547
1395
  className: 'dcb-pill',
548
- title: '本会话消耗(点击查看逐轮明细)',
1396
+ title: tt('本会话消耗(点击查看逐轮明细)'),
549
1397
  onClick: function (event) {
550
1398
  try {
551
1399
  event.preventDefault()
@@ -559,7 +1407,7 @@ window.__ModuleLoader__.load({
559
1407
  h('span', { className: 'dcb-pill-sep' }, '|'),
560
1408
  h('span', null, pending ? '…' : '≈¥' + fmtYuan(cost)),
561
1409
  h('span', { className: 'dcb-pill-sep' }, '|'),
562
- h('span', { className: peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, peak ? '峰' : '谷'))
1410
+ h('span', { className: peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(peak ? '峰' : '谷')))
563
1411
  }
564
1412
 
565
1413
  function ConsumptionPanel() {
@@ -589,6 +1437,7 @@ window.__ModuleLoader__.load({
589
1437
  if (!open) return null
590
1438
 
591
1439
  const now = Date.now()
1440
+ const surface = modalSurface(state.modalStyle, state.modalImageUrl)
592
1441
  const model = state.account && typeof state.account.model === 'string' && state.account.model ? state.account.model : DEFAULT_MODEL
593
1442
  const price = priceFor(model)
594
1443
  const peak = isPeakAt(now)
@@ -598,27 +1447,28 @@ window.__ModuleLoader__.load({
598
1447
  const totalCost = costOf(aggregated, price.table, peak)
599
1448
  const balance = state.balance
600
1449
  const loading = state.balanceState === 'loading'
1450
+ const balanceErr = balanceErrorText()
601
1451
  let balanceValue = '—'
602
- let balanceHint = state.balanceError || '打开面板时请求一次'
1452
+ let balanceHint = balanceErr || tt('打开面板时请求一次')
603
1453
  let balanceClass = 'dcb-card-value'
604
1454
  if (loading && !balance) {
605
- balanceValue = '读取中…'
1455
+ balanceValue = tt('读取中…')
606
1456
  balanceHint = 'GET /user/balance'
607
1457
  } else if (balance && balance.ok === false) {
608
- balanceValue = '获取失败'
609
- balanceHint = state.balanceError || '余额接口不可用'
1458
+ balanceValue = tt('获取失败')
1459
+ balanceHint = balanceErr || tt('余额接口不可用')
610
1460
  balanceClass = 'dcb-card-value dcb-bad'
611
1461
  } else if (balance && balance.available === false) {
612
- balanceValue = '余额不足'
613
- balanceHint = '请前往 DeepSeek 开放平台充值'
1462
+ balanceValue = tt('余额不足')
1463
+ balanceHint = tt('请前往 DeepSeek 开放平台充值')
614
1464
  balanceClass = 'dcb-card-value dcb-bad'
615
1465
  } else if (balance) {
616
1466
  const symbol = balance.currency && balance.currency !== 'CNY' ? String(balance.currency) + ' ' : '¥'
617
1467
  balanceValue = symbol + fmtYuan(balance.total)
618
1468
  const parts = []
619
- if (typeof balance.granted === 'number') parts.push('赠送 ¥' + fmtYuan(balance.granted))
620
- if (typeof balance.toppedUp === 'number') parts.push('充值 ¥' + fmtYuan(balance.toppedUp))
621
- balanceHint = parts.length ? parts.join(' · ') : 'DeepSeek 账户余额'
1469
+ if (typeof balance.granted === 'number') parts.push(tt('赠送 ¥{v}', { v: fmtYuan(balance.granted) }))
1470
+ if (typeof balance.toppedUp === 'number') parts.push(tt('充值 ¥{v}', { v: fmtYuan(balance.toppedUp) }))
1471
+ balanceHint = parts.length ? parts.join(' · ') : tt('DeepSeek 账户余额')
622
1472
  }
623
1473
 
624
1474
  function close() {
@@ -643,27 +1493,1128 @@ window.__ModuleLoader__.load({
643
1493
  if (event && event.target === event.currentTarget) close()
644
1494
  },
645
1495
  },
646
- h('div', { className: 'dcb-modal', role: 'dialog', 'aria-label': '本会话消耗' },
1496
+ h('div', {
1497
+ className: surface.className,
1498
+ role: 'dialog',
1499
+ 'aria-label': tt('本会话消耗'),
1500
+ style: surface.style,
1501
+ },
647
1502
  h('div', { className: 'dcb-head' },
648
- h('div', { className: 'dcb-head-title' }, '⛽ 本会话消耗'),
1503
+ h('div', { className: 'dcb-head-title' }, tt('⛽ 本会话消耗')),
649
1504
  h('div', { className: 'dcb-head-actions' },
650
- h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.ensureBalance(true) } }, '刷新余额'),
651
- h('button', { type: 'button', className: 'dcb-btn dcb-btn-close', onClick: close, title: 'Esc 关闭' }, '✕'))),
1505
+ h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.ensureBalance(true) } }, tt('刷新余额')),
1506
+ h('button', { type: 'button', className: 'dcb-btn dcb-btn-close', onClick: close, title: tt('Esc 关闭') }, '✕'))),
652
1507
  h('div', { className: 'dcb-cards' },
653
- h(Card, { label: '累计 Token', value: fmtTokens(tokens), hint: '命中 ' + fmtTokens(aggregated.hit) + ' · 未命中 ' + fmtTokens(aggregated.miss) + ' · 输出 ' + fmtTokens(aggregated.out) }),
654
- h(Card, { label: '估算金额', value: '≈¥' + fmtYuan(totalCost), hint: (price.known ? price.id : price.id + '(按 flash 估算)') + (peak ? ' · 峰时 ×2' : ' · 谷时') }),
655
- h(Card, { label: '当前时段', value: peak ? '高峰' : '空闲', hint: '北京时间 工作日 09:00-12:00 / 14:00-18:00' }),
1508
+ h(Card, { label: tt('累计 Token'), value: fmtTokens(tokens), hint: tt('命中 {hit} · 未命中 {miss} · 输出 {out}', { hit: fmtTokens(aggregated.hit), miss: fmtTokens(aggregated.miss), out: fmtTokens(aggregated.out) }) }),
1509
+ h(Card, { label: tt('估算金额'), value: '≈¥' + fmtYuan(totalCost), hint: (price.known ? price.id : tt('{id}(按 flash 估算)', { id: price.id })) + tt(peak ? ' · 峰时 ×2' : ' · 谷时') }),
1510
+ h(Card, { label: tt('当前时段'), value: tt(peak ? '高峰' : '空闲'), hint: tt('北京时间 工作日 09:00-12:00 / 14:00-18:00') }),
656
1511
  h('div', { className: 'dcb-card' },
657
- h('div', { className: 'dcb-card-label' }, '账户余额'),
1512
+ h('div', { className: 'dcb-card-label' }, tt('账户余额')),
658
1513
  h('div', { className: balanceClass }, balanceValue),
659
1514
  h('div', { className: 'dcb-card-hint' }, balanceHint))),
660
- h('div', { className: 'dcb-section-title' }, '逐轮消耗趋势'),
1515
+ h('div', { className: 'dcb-section-title' }, tt('逐轮消耗趋势')),
661
1516
  h(UsageChart, { series: series }),
662
1517
  h('div', { className: 'dcb-foot' },
663
- state.turnsError ? h('span', { className: 'dcb-foot-warn' }, state.turnsError + ' ') : null,
664
- '金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考;数据源为本会话日志,刷新后自动恢复。')))
1518
+ state.turnsError ? h('span', { className: 'dcb-foot-warn' }, turnsErrorText() + ' ') : null,
1519
+ tt('金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考;数据源为本会话日志,刷新后自动恢复。'))))
1520
+ }
1521
+
1522
+ /* ===================== settings section: history page ===================== */
1523
+
1524
+ // 逐轮记录超过这个条数就改在 Web Worker 里聚合(Worker 不可用时退回主线程)。
1525
+ const WORKER_THRESHOLD = 5000
1526
+ // 工作区下拉里的“未归属”哨兵:没有 session→workspace 映射的记录(例如会话已删除)。
1527
+ const UNKNOWN_WORKSPACE = '__none__'
1528
+ let aggSeq = 0
1529
+ let aggSeqAll = 0
1530
+
1531
+ /**
1532
+ * 读出全部逐轮记录。键结构沿用徽章既有存储:`dsh-consumption:<sessionId>:<turn>`,
1533
+ * 值 {hit, miss, out, time, model}。不新增任何存储。
1534
+ * @returns 记录数组(损坏/空记录跳过)。
1535
+ */
1536
+ function readAllRecords() {
1537
+ const out = []
1538
+ const keys = lsKeys(LS_PREFIX)
1539
+ for (let i = 0; i < keys.length; i += 1) {
1540
+ const key = keys[i]
1541
+ const rest = key.slice(LS_PREFIX.length)
1542
+ const sep = rest.lastIndexOf(':')
1543
+ if (sep <= 0) continue
1544
+ const sid = rest.slice(0, sep)
1545
+ const turn = Number(rest.slice(sep + 1))
1546
+ const raw = lsGet(key)
1547
+ if (!raw) continue
1548
+ try {
1549
+ const rec = JSON.parse(raw)
1550
+ if (!rec || typeof rec !== 'object') continue
1551
+ const hit = num(rec.hit)
1552
+ const miss = num(rec.miss)
1553
+ const produced = num(rec.out)
1554
+ if (hit + miss + produced === 0) continue
1555
+ out.push({
1556
+ sid: sid,
1557
+ turn: Number.isFinite(turn) ? turn : 0,
1558
+ time: typeof rec.time === 'number' ? rec.time : 0,
1559
+ model: typeof rec.model === 'string' ? rec.model : '',
1560
+ hit: hit,
1561
+ miss: miss,
1562
+ out: produced,
1563
+ })
1564
+ } catch (e) { /* 跳过损坏记录 */ }
1565
+ }
1566
+ return out
1567
+ }
1568
+
1569
+ /**
1570
+ * 解析 useWorkspaces 的签名串:既得到下拉选项,也得到**精确**的 session→workspace 映射
1571
+ * (WorkspaceView.sessionIds 就是归属账本,无需按路径猜)。
1572
+ * @param sig 由 useWorkspaces 选择器拼出的 `wid\u0001title\u0001sid,sid` 以 \u0002 连接。
1573
+ */
1574
+ function buildWorkspaceIndex(sig) {
1575
+ const list = []
1576
+ const bySession = {}
1577
+ if (!sig) return { list: list, bySession: bySession }
1578
+ const groups = String(sig).split('\u0002')
1579
+ for (let i = 0; i < groups.length; i += 1) {
1580
+ const parts = groups[i].split('\u0001')
1581
+ if (parts.length < 3) continue
1582
+ const wid = parts[0]
1583
+ if (!wid) continue
1584
+ list.push({ id: wid, title: parts[1] || wid })
1585
+ const ids = parts[2] ? parts[2].split(',') : []
1586
+ for (let j = 0; j < ids.length; j += 1) {
1587
+ if (ids[j]) bySession[ids[j]] = wid
1588
+ }
1589
+ }
1590
+ return { list: list, bySession: bySession }
1591
+ }
1592
+
1593
+ /** 北京时间当日 00:00 的毫秒值。 */
1594
+ function bjDayStart(ms) {
1595
+ const t = new Date(ms + 8 * 3600 * 1000)
1596
+ return Date.UTC(t.getUTCFullYear(), t.getUTCMonth(), t.getUTCDate()) - 8 * 3600 * 1000
1597
+ }
1598
+
1599
+ /** 北京时间某月 1 日 00:00 的毫秒值(monthDelta 可为负)。 */
1600
+ function bjMonthStart(ms, monthDelta) {
1601
+ const t = new Date(ms + 8 * 3600 * 1000)
1602
+ return Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + (monthDelta || 0), 1) - 8 * 3600 * 1000
665
1603
  }
666
1604
 
1605
+ /** `<input type="date">` 的值 → 北京时间当日 00:00 / 23:59:59.999。 */
1606
+ function parseDateInput(value, endOfDay) {
1607
+ if (!value) return 0
1608
+ const ms = Date.parse(String(value) + (endOfDay ? 'T23:59:59.999+08:00' : 'T00:00:00+08:00'))
1609
+ return Number.isFinite(ms) ? ms : 0
1610
+ }
1611
+
1612
+ /**
1613
+ * 时间范围解析。日历区间优先(覆盖下拉预设),否则按预设取北京时间自然日:
1614
+ * 今天=当日 00:00→现在(**按小时**);近一周=含今天在内 7 天;近一月=30 天;近一年=12 个自然月。
1615
+ */
1616
+ function resolveRange(rangeKey, calFrom, calTo, now) {
1617
+ if (calFrom || calTo) {
1618
+ const a = parseDateInput(calFrom, false)
1619
+ const b = parseDateInput(calTo, true)
1620
+ const lo = Math.min(a > 0 ? a : 0, b > 0 ? b : now)
1621
+ const hi = Math.max(a > 0 ? a : 0, b > 0 ? b : now)
1622
+ const days = Math.round((hi - lo) / 86400000) + 1
1623
+ // 单日区间(含“今天”)按小时看更有意义,多日再退回按日、跨度大按月。
1624
+ if (hi - lo <= 36 * 3600000) return { from: lo, to: hi, granularity: 'hour' }
1625
+ return { from: lo, to: hi, granularity: days > 92 ? 'month' : 'day' }
1626
+ }
1627
+ if (rangeKey === 'today') return { from: bjDayStart(now), to: now, granularity: 'hour' }
1628
+ if (rangeKey === 'month') return { from: bjDayStart(now - 29 * 86400000), to: now, granularity: 'day' }
1629
+ if (rangeKey === 'year') return { from: bjMonthStart(now, -11), to: now, granularity: 'month' }
1630
+ return { from: bjDayStart(now - 6 * 86400000), to: now, granularity: 'day' }
1631
+ }
1632
+
1633
+ /**
1634
+ * 纯聚合:时间过滤 → 工作区过滤 → 按小时/日/月分桶(空桶补 0)→ 峰谷与金额拆分。
1635
+ * 刻意自包含(只读 input,不引用闭包变量),所以能直接 String() 进 Web Worker。
1636
+ * @param input {records, from, to, wid, granularity: 'hour'|'day'|'month', prices, holidays, windows}
1637
+ */
1638
+ function computeConsumptionStats(input) {
1639
+ const records = input.records || []
1640
+ const prices = input.prices || {}
1641
+ const holidays = input.holidays || {}
1642
+ const windows = input.windows || []
1643
+ const from = typeof input.from === 'number' ? input.from : 0
1644
+ const to = typeof input.to === 'number' ? input.to : 0
1645
+ const wid = typeof input.wid === 'string' ? input.wid : ''
1646
+ const granularity = input.granularity === 'month' ? 'month' : (input.granularity === 'hour' ? 'hour' : 'day')
1647
+ // noFill:只要合计不要时间轴(设置页顶部的“累计”卡片用),跳过分桶补齐。
1648
+ const noFill = input.noFill === true
1649
+ const OFF = 8 * 3600 * 1000
1650
+
1651
+ function n(v) { return typeof v === 'number' && isFinite(v) && v > 0 ? v : 0 }
1652
+ function p2(v) { return v < 10 ? '0' + String(v) : String(v) }
1653
+ function clock(ms) {
1654
+ const t = new Date(ms + OFF)
1655
+ return {
1656
+ y: t.getUTCFullYear(),
1657
+ m: t.getUTCMonth() + 1,
1658
+ d: t.getUTCDate(),
1659
+ h: t.getUTCHours(),
1660
+ dow: t.getUTCDay(),
1661
+ minutes: t.getUTCHours() * 60 + t.getUTCMinutes(),
1662
+ mmdd: p2(t.getUTCMonth() + 1) + '-' + p2(t.getUTCDate()),
1663
+ }
1664
+ }
1665
+ function isPeak(ms) {
1666
+ const c = clock(ms)
1667
+ if (c.dow === 0 || c.dow === 6) return false
1668
+ if (holidays[c.mmdd]) return false
1669
+ for (let i = 0; i < windows.length; i += 1) {
1670
+ if (c.minutes >= windows[i][0] && c.minutes < windows[i][1]) return true
1671
+ }
1672
+ return false
1673
+ }
1674
+ function priceOf(model) {
1675
+ const id = typeof model === 'string' ? model : ''
1676
+ if (prices[id]) return prices[id]
1677
+ if (id.toLowerCase().indexOf('pro') >= 0) return prices['deepseek-v4-pro'] || prices['deepseek-flash']
1678
+ return prices['deepseek-flash']
1679
+ }
1680
+ function keyOf(ms) {
1681
+ const c = clock(ms)
1682
+ if (granularity === 'month') return c.y + '-' + p2(c.m)
1683
+ if (granularity === 'hour') return c.y + '-' + p2(c.m) + '-' + p2(c.d) + ' ' + p2(c.h)
1684
+ return c.y + '-' + p2(c.m) + '-' + p2(c.d)
1685
+ }
1686
+
1687
+ const buckets = {}
1688
+ const totals = { tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, hit: 0, miss: 0, out: 0 }
1689
+ let count = 0
1690
+ let unknownWorkspace = false
1691
+ for (let i = 0; i < records.length; i += 1) {
1692
+ const r = records[i]
1693
+ if (!r) continue
1694
+ const t = typeof r.time === 'number' ? r.time : 0
1695
+ if (!(t >= from && t <= to)) continue
1696
+ const rowWid = typeof r.wid === 'string' ? r.wid : ''
1697
+ if (wid) {
1698
+ if (wid === '__none__') { if (rowWid) continue; unknownWorkspace = true } else if (rowWid !== wid) continue
1699
+ }
1700
+ const hit = n(r.hit)
1701
+ const miss = n(r.miss)
1702
+ const produced = n(r.out)
1703
+ const tokens = hit + miss + produced
1704
+ if (tokens === 0) continue
1705
+ const peak = isPeak(t)
1706
+ const table = priceOf(r.model)
1707
+ const k = peak ? 2 : 1
1708
+ const cost = ((hit / 1e6) * table.hit + (miss / 1e6) * table.miss + (produced / 1e6) * table.out) * k
1709
+ const key = keyOf(t)
1710
+ let b = buckets[key]
1711
+ if (!b) {
1712
+ b = { key: key, tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, models: [], records: 0 }
1713
+ buckets[key] = b
1714
+ }
1715
+ b.tokens += tokens
1716
+ b.cost += cost
1717
+ b.records += 1
1718
+ totals.tokens += tokens
1719
+ totals.cost += cost
1720
+ totals.hit += hit
1721
+ totals.miss += miss
1722
+ totals.out += produced
1723
+ if (peak) {
1724
+ b.peakTokens += tokens
1725
+ b.peakCost += cost
1726
+ totals.peakTokens += tokens
1727
+ totals.peakCost += cost
1728
+ } else {
1729
+ b.valleyTokens += tokens
1730
+ b.valleyCost += cost
1731
+ totals.valleyTokens += tokens
1732
+ totals.valleyCost += cost
1733
+ }
1734
+ const model = typeof r.model === 'string' && r.model ? r.model : 'unknown'
1735
+ if (b.models.indexOf(model) < 0) b.models.push(model)
1736
+ count += 1
1737
+ }
1738
+
1739
+ const points = []
1740
+ function push(key, ms) {
1741
+ const b = buckets[key]
1742
+ const point = b || { key: key, tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, models: [], records: 0 }
1743
+ if (granularity === 'month') point.label = key
1744
+ else if (granularity === 'hour') point.label = key.slice(11) + ':00'
1745
+ else point.label = key.slice(5)
1746
+ point.stamp = ms
1747
+ points.push(point)
1748
+ }
1749
+ if (noFill) {
1750
+ // 合计已经在记录循环里累加完,这里不再铺时间轴。
1751
+ } else if (granularity === 'month') {
1752
+ const a = clock(from)
1753
+ const b = clock(to)
1754
+ let y = a.y
1755
+ let m = a.m
1756
+ let guard = 0
1757
+ while ((y < b.y || (y === b.y && m <= b.m)) && guard < 400) {
1758
+ push(y + '-' + p2(m), Date.UTC(y, m - 1, 1) - OFF)
1759
+ m += 1
1760
+ if (m > 12) {
1761
+ m = 1
1762
+ y += 1
1763
+ }
1764
+ guard += 1
1765
+ }
1766
+ } else if (granularity === 'hour') {
1767
+ let guard = 0
1768
+ for (let t = from; t <= to && guard < 48; t += 3600000) {
1769
+ push(keyOf(t), t)
1770
+ guard += 1
1771
+ }
1772
+ } else {
1773
+ let guard = 0
1774
+ for (let t = from; t <= to && guard < 800; t += 86400000) {
1775
+ push(keyOf(t), t)
1776
+ guard += 1
1777
+ }
1778
+ }
1779
+
1780
+ return {
1781
+ points: points,
1782
+ count: count,
1783
+ totalTokens: totals.tokens,
1784
+ totalCost: totals.cost,
1785
+ totalHit: totals.hit,
1786
+ totalMiss: totals.miss,
1787
+ totalOut: totals.out,
1788
+ peakTokens: totals.peakTokens,
1789
+ peakCost: totals.peakCost,
1790
+ valleyTokens: totals.valleyTokens,
1791
+ valleyCost: totals.valleyCost,
1792
+ granularity: granularity,
1793
+ from: from,
1794
+ to: to,
1795
+ hasUnknownWorkspace: unknownWorkspace,
1796
+ }
1797
+ }
1798
+
1799
+ /**
1800
+ * 记录多时在 Web Worker 里聚合,避免阻塞 UI;Worker 不可用(CSP/浏览器限制)时退回主线程。
1801
+ * @param input computeConsumptionStats 的入参。
1802
+ */
1803
+ function aggregateAsync(input) {
1804
+ const many = (input.records || []).length > WORKER_THRESHOLD
1805
+ const canWorker = typeof Worker === 'function' && typeof Blob === 'function'
1806
+ && typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
1807
+ if (many && canWorker) {
1808
+ return new Promise(function (resolve, reject) {
1809
+ let url = ''
1810
+ let worker = null
1811
+ try {
1812
+ const src = 'self.onmessage=function(e){try{self.postMessage((' + String(computeConsumptionStats) + ')(e.data))}catch(err){self.postMessage({workerError:String(err&&err.message||err)})}}'
1813
+ url = URL.createObjectURL(new Blob([src], { type: 'application/javascript' }))
1814
+ worker = new Worker(url)
1815
+ } catch (e) {
1816
+ reject(e)
1817
+ return
1818
+ }
1819
+ function cleanup() {
1820
+ try {
1821
+ worker.terminate()
1822
+ } catch (e) { /* 已结束 */ }
1823
+ try {
1824
+ URL.revokeObjectURL(url)
1825
+ } catch (e) { /* 已释放 */ }
1826
+ }
1827
+ worker.onmessage = function (event) {
1828
+ cleanup()
1829
+ const payload = event && event.data
1830
+ if (!payload || payload.workerError) {
1831
+ reject(new Error(payload && payload.workerError ? payload.workerError : 'worker failed'))
1832
+ return
1833
+ }
1834
+ payload.via = 'worker'
1835
+ resolve(payload)
1836
+ }
1837
+ worker.onerror = function () {
1838
+ cleanup()
1839
+ reject(new Error('worker failed'))
1840
+ }
1841
+ worker.postMessage(input)
1842
+ })
1843
+ }
1844
+ return Promise.resolve().then(function () {
1845
+ const out = computeConsumptionStats(input)
1846
+ out.via = 'main'
1847
+ return out
1848
+ })
1849
+ }
1850
+
1851
+ /* ---- history chart: dual axis, cost by default, tokens toggle ---- */
1852
+ const HIST_W = 760
1853
+ const HIST_H = 280
1854
+ const HIST_PAD = { l: 56, r: 58, t: 16, b: 26 }
1855
+
1856
+ function HistoryChart(props) {
1857
+ const points = props.points || []
1858
+ const showTokens = props.showTokens === true
1859
+ const holder = React.useState(-1)
1860
+ const hover = holder[0]
1861
+ const setHover = holder[1]
1862
+ const innerW = HIST_W - HIST_PAD.l - HIST_PAD.r
1863
+ const innerH = HIST_H - HIST_PAD.t - HIST_PAD.b
1864
+ let maxToken = 1
1865
+ let maxCost = 0
1866
+ for (let i = 0; i < points.length; i += 1) {
1867
+ if (points[i].tokens > maxToken) maxToken = points[i].tokens
1868
+ if (points[i].cost > maxCost) maxCost = points[i].cost
1869
+ }
1870
+ const topToken = niceMax(maxToken)
1871
+ const topCost = niceMax(maxCost > 0 ? maxCost : 1e-6)
1872
+ const n = points.length
1873
+ function xAt(i) {
1874
+ return n <= 1 ? HIST_PAD.l + innerW / 2 : HIST_PAD.l + (i / (n - 1)) * innerW
1875
+ }
1876
+ function yToken(v) { return HIST_PAD.t + innerH - (v / topToken) * innerH }
1877
+ function yCost(v) { return HIST_PAD.t + innerH - (v / topCost) * innerH }
1878
+
1879
+ const costPts = []
1880
+ const tokenPts = []
1881
+ for (let i = 0; i < n; i += 1) {
1882
+ costPts.push(xAt(i).toFixed(2) + ',' + yCost(points[i].cost).toFixed(2))
1883
+ tokenPts.push(xAt(i).toFixed(2) + ',' + yToken(points[i].tokens).toFixed(2))
1884
+ }
1885
+
1886
+ const grid = []
1887
+ const rightLabels = []
1888
+ const leftLabels = []
1889
+ for (let g = 0; g <= 4; g += 1) {
1890
+ const ratio = g / 4
1891
+ const y = HIST_PAD.t + innerH * ratio
1892
+ grid.push(h('line', { key: 'hg' + g, x1: HIST_PAD.l, x2: HIST_PAD.l + innerW, y1: y, y2: y, className: 'dcb-grid-line' }))
1893
+ rightLabels.push(h('text', { key: 'hr' + g, x: HIST_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' }, '¥' + fmtYuan(topCost * (1 - ratio))))
1894
+ if (showTokens) {
1895
+ leftLabels.push(h('text', { key: 'hl' + g, x: HIST_PAD.l - 8, y: y + 3.5, textAnchor: 'end', className: 'dcb-axis-label' }, fmtTokens(topToken * (1 - ratio))))
1896
+ }
1897
+ }
1898
+
1899
+ const step = Math.max(1, Math.ceil(n / 10))
1900
+ const xLabels = []
1901
+ for (let i = 0; i < n; i += 1) {
1902
+ if (i % step !== 0 && i !== n - 1) continue
1903
+ xLabels.push(h('text', { key: 'hx' + i, x: xAt(i), y: HIST_H - 8, textAnchor: 'middle', className: 'dcb-axis-label' }, points[i].label))
1904
+ }
1905
+
1906
+ const dots = []
1907
+ for (let i = 0; i < n; i += 1) {
1908
+ const active = hover === i
1909
+ if (points[i].records > 0) {
1910
+ dots.push(h('circle', { key: 'hc' + i, cx: xAt(i), cy: yCost(points[i].cost), r: active ? 4 : 2.2, className: 'dcb-dot dcb-dot-cost' }))
1911
+ }
1912
+ if (showTokens && points[i].records > 0) {
1913
+ dots.push(h('circle', { key: 'ht' + i, cx: xAt(i), cy: yToken(points[i].tokens), r: active ? 4 : 2.2, className: 'dcb-dot' }))
1914
+ }
1915
+ }
1916
+
1917
+ const hovered = hover >= 0 && hover < n ? points[hover] : null
1918
+ const crosshair = hovered
1919
+ ? h('line', { x1: xAt(hover), x2: xAt(hover), y1: HIST_PAD.t, y2: HIST_PAD.t + innerH, className: 'dcb-hover-line' })
1920
+ : null
1921
+
1922
+ function onMove(event) {
1923
+ try {
1924
+ const rect = event.currentTarget.getBoundingClientRect()
1925
+ if (!rect || rect.width <= 0) return
1926
+ const px = ((event.clientX - rect.left) / rect.width) * HIST_W
1927
+ const within = Math.min(1, Math.max(0, (px - HIST_PAD.l) / innerW))
1928
+ const idx = n <= 1 ? 0 : Math.round(within * (n - 1))
1929
+ if (idx !== hover) setHover(idx)
1930
+ } catch (e) { /* 忽略异常坐标 */ }
1931
+ }
1932
+
1933
+ const tooltip = hovered
1934
+ ? h('div', { className: 'dcb-tooltip', style: { left: String((xAt(hover) / HIST_W) * 100) + '%' } },
1935
+ h('div', { className: 'dcb-tooltip-title' },
1936
+ hovered.label,
1937
+ h('span', { className: 'dcb-tooltip-model' }, tt('{n} 轮', { n: hovered.records }))),
1938
+ h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('当日合计')), h('b', null, '≈¥' + fmtYuan(hovered.cost))),
1939
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, 'Token'), h('b', null, fmtTokens(hovered.tokens))),
1940
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('峰时')), h('b', null, '≈¥' + fmtYuan(hovered.peakCost) + ' / ' + fmtTokens(hovered.peakTokens))),
1941
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('谷时')), h('b', null, '≈¥' + fmtYuan(hovered.valleyCost) + ' / ' + fmtTokens(hovered.valleyTokens))),
1942
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('模型')), h('b', null, hovered.models.length ? hovered.models.join(', ') : '—')))
1943
+ : null
1944
+
1945
+ return h('div', { className: 'dcb-chart-body' },
1946
+ h('svg', {
1947
+ className: 'dcb-hist-svg',
1948
+ viewBox: '0 0 ' + HIST_W + ' ' + HIST_H,
1949
+ preserveAspectRatio: 'none',
1950
+ onMouseMove: onMove,
1951
+ onMouseLeave: function () { setHover(-1) },
1952
+ },
1953
+ h('g', null, grid),
1954
+ crosshair,
1955
+ showTokens ? h('polyline', { className: 'dcb-line', points: tokenPts.join(' ') }) : null,
1956
+ h('polyline', { className: 'dcb-line dcb-line-cost', points: costPts.join(' ') }),
1957
+ h('g', null, dots),
1958
+ h('g', null, leftLabels),
1959
+ h('g', null, rightLabels),
1960
+ h('g', null, xLabels)),
1961
+ tooltip)
1962
+ }
1963
+
1964
+ /** 设置页「消耗」整页:三个筛选 + 折线 + 汇总。筛选状态不持久化,每次打开回到“近一周 / 全部工作区”。 */
1965
+ function ConsumptionSettings(props) {
1966
+ const s0 = React.useState('week')
1967
+ const rangeKey = s0[0]
1968
+ const setRangeKey = s0[1]
1969
+ const s1 = React.useState('')
1970
+ const wid = s1[0]
1971
+ const setWid = s1[1]
1972
+ const s2 = React.useState('')
1973
+ const calFrom = s2[0]
1974
+ const setCalFrom = s2[1]
1975
+ const s3 = React.useState('')
1976
+ const calTo = s3[0]
1977
+ const setCalTo = s3[1]
1978
+ const s4 = React.useState(null)
1979
+ const records = s4[0]
1980
+ const setRecords = s4[1]
1981
+ const s5 = React.useState(null)
1982
+ const stats = s5[0]
1983
+ const setStats = s5[1]
1984
+ const s6 = React.useState(0)
1985
+ const reloadTick = s6[0]
1986
+ const setReloadTick = s6[1]
1987
+ const s7 = React.useState(false)
1988
+ const showTokens = s7[0]
1989
+ const setShowTokens = s7[1]
1990
+ const s8 = React.useState(null)
1991
+ const allStats = s8[0]
1992
+ const setAllStats = s8[1]
1993
+ const s9 = React.useState(0)
1994
+ const setStoreTick = s9[1]
1995
+
1996
+ // 账户/余额复用徽章共用的 store(同一份 60s 缓存,不额外发请求)。
1997
+ React.useEffect(function () {
1998
+ return store.subscribe(function () { setStoreTick(function (v) { return v + 1 }) })
1999
+ }, [])
2000
+ React.useEffect(function () {
2001
+ store.ensureAccount()
2002
+ store.ensureBalance(false)
2003
+ return everyTick(60000, function () { store.ensureBalance(false) })
2004
+ }, [])
2005
+
2006
+ // 工作区签名:id + 标题 + 归属会话(WorkspaceView.sessionIds 即归属账本)。
2007
+ const wsSig = props && typeof props.useWorkspaces === 'function'
2008
+ ? props.useWorkspaces(function (w) {
2009
+ const items = w && w.items ? w.items : []
2010
+ const out = []
2011
+ for (let i = 0; i < items.length; i += 1) {
2012
+ const it = items[i]
2013
+ if (!it) continue
2014
+ out.push(String(it.workspaceId || '') + '\u0001' + String(it.title || '') + '\u0001' + (it.sessionIds || []).join(','))
2015
+ }
2016
+ return out.join('\u0002')
2017
+ })
2018
+ : ''
2019
+
2020
+ React.useEffect(function () {
2021
+ setRecords(readAllRecords())
2022
+ }, [reloadTick])
2023
+
2024
+ React.useEffect(function () {
2025
+ if (!records) return undefined
2026
+ const now = Date.now()
2027
+ const range = resolveRange(rangeKey, calFrom, calTo, now)
2028
+ const index = buildWorkspaceIndex(wsSig)
2029
+ const rows = []
2030
+ for (let i = 0; i < records.length; i += 1) {
2031
+ const r = records[i]
2032
+ rows.push({ time: r.time, hit: r.hit, miss: r.miss, out: r.out, model: r.model, wid: index.bySession[r.sid] || '' })
2033
+ }
2034
+ const input = {
2035
+ records: rows,
2036
+ from: range.from,
2037
+ to: range.to,
2038
+ wid: wid,
2039
+ granularity: range.granularity,
2040
+ prices: PRICES,
2041
+ holidays: CN_FIXED_HOLIDAYS,
2042
+ windows: PEAK_WINDOWS,
2043
+ }
2044
+ const seq = ++aggSeq
2045
+ aggregateAsync(input).then(function (result) {
2046
+ if (seq === aggSeq) setStats(result)
2047
+ }).catch(function () {
2048
+ if (seq !== aggSeq) return
2049
+ const fallback = computeConsumptionStats(input)
2050
+ fallback.via = 'main'
2051
+ setStats(fallback)
2052
+ })
2053
+ return undefined
2054
+ }, [records, rangeKey, wid, calFrom, calTo, wsSig])
2055
+
2056
+ // 顶部“累计”卡片:全部历史合计(不受筛选影响),只算合计不铺时间轴。
2057
+ React.useEffect(function () {
2058
+ if (!records) return undefined
2059
+ const index = buildWorkspaceIndex(wsSig)
2060
+ const rows = []
2061
+ for (let i = 0; i < records.length; i += 1) {
2062
+ const r = records[i]
2063
+ rows.push({ time: r.time, hit: r.hit, miss: r.miss, out: r.out, model: r.model, wid: index.bySession[r.sid] || '' })
2064
+ }
2065
+ const input = {
2066
+ records: rows,
2067
+ from: 0,
2068
+ to: Date.now(),
2069
+ wid: '',
2070
+ granularity: 'day',
2071
+ noFill: true,
2072
+ prices: PRICES,
2073
+ holidays: CN_FIXED_HOLIDAYS,
2074
+ windows: PEAK_WINDOWS,
2075
+ }
2076
+ const seq = ++aggSeqAll
2077
+ aggregateAsync(input).then(function (result) {
2078
+ if (seq === aggSeqAll) setAllStats(result)
2079
+ }).catch(function () {
2080
+ if (seq !== aggSeqAll) return
2081
+ setAllStats(computeConsumptionStats(input))
2082
+ })
2083
+ return undefined
2084
+ }, [records, wsSig])
2085
+
2086
+ const st = store.state
2087
+ const accountModel = st.account && typeof st.account.model === 'string' && st.account.model ? st.account.model : DEFAULT_MODEL
2088
+ const accountPrice = priceFor(accountModel)
2089
+ const nowPeak = isPeakAt(Date.now())
2090
+ const balance = st.balance
2091
+ const balanceLoading = st.balanceState === 'loading'
2092
+ const balanceErr = balanceErrorText()
2093
+ let balanceValue = '—'
2094
+ let balanceHint = balanceErr || tt('打开设置页时请求一次')
2095
+ let balanceClass = 'dcb-card-value'
2096
+ if (balanceLoading && !balance) {
2097
+ balanceValue = tt('读取中…')
2098
+ balanceHint = 'GET /user/balance'
2099
+ } else if (balance && balance.ok === false) {
2100
+ balanceValue = tt('获取失败')
2101
+ balanceHint = balanceErr || tt('余额接口不可用')
2102
+ balanceClass = 'dcb-card-value dcb-bad'
2103
+ } else if (balance && balance.available === false) {
2104
+ balanceValue = tt('余额不足')
2105
+ balanceHint = tt('请前往 DeepSeek 开放平台充值')
2106
+ balanceClass = 'dcb-card-value dcb-bad'
2107
+ } else if (balance) {
2108
+ const symbol = balance.currency && balance.currency !== 'CNY' ? String(balance.currency) + ' ' : '¥'
2109
+ balanceValue = symbol + fmtYuan(balance.total)
2110
+ const parts = []
2111
+ if (typeof balance.granted === 'number') parts.push(tt('赠送 ¥{v}', { v: fmtYuan(balance.granted) }))
2112
+ if (typeof balance.toppedUp === 'number') parts.push(tt('充值 ¥{v}', { v: fmtYuan(balance.toppedUp) }))
2113
+ balanceHint = parts.length ? parts.join(' · ') : tt('DeepSeek 账户余额')
2114
+ }
2115
+
2116
+ const index = buildWorkspaceIndex(wsSig)
2117
+ const points = stats ? stats.points : []
2118
+ const hasData = !!stats && stats.count > 0
2119
+ const rangeOptions = [
2120
+ { id: 'today', label: tt('今天') },
2121
+ { id: 'week', label: tt('近一周') },
2122
+ { id: 'month', label: tt('近一月') },
2123
+ { id: 'year', label: tt('近一年') },
2124
+ ]
2125
+
2126
+ return h('div', { className: 'dcb-set' },
2127
+ h('div', { className: 'dcb-set-head' },
2128
+ h('div', { className: 'dcb-set-title' }, tt('消耗统计')),
2129
+ h('div', { className: 'dcb-set-note' }, tt('本地估算,非官方账单'))),
2130
+ // 顶部四卡:累计(全部历史,不受下面筛选影响)+ 当前时段 + 账户余额
2131
+ h('div', { className: 'dcb-cards dcb-cards-set' },
2132
+ h(Card, {
2133
+ label: tt('累计 Token'),
2134
+ value: allStats ? fmtTokens(allStats.totalTokens) : '—',
2135
+ hint: allStats
2136
+ ? tt('命中 {hit} · 未命中 {miss} · 输出 {out}', { hit: fmtTokens(allStats.totalHit), miss: fmtTokens(allStats.totalMiss), out: fmtTokens(allStats.totalOut) })
2137
+ : tt('全部历史 · 含所有工作区'),
2138
+ }),
2139
+ h(Card, {
2140
+ label: tt('估算金额'),
2141
+ value: allStats ? '≈¥' + fmtYuan(allStats.totalCost) : '—',
2142
+ hint: (accountPrice.known ? accountPrice.id : tt('{id}(按 flash 估算)', { id: accountPrice.id })) + tt(' · 全部历史逐轮计价'),
2143
+ }),
2144
+ h(Card, {
2145
+ label: tt('当前时段'),
2146
+ value: tt(nowPeak ? '高峰' : '空闲'),
2147
+ hint: tt('北京时间 工作日 09:00-12:00 / 14:00-18:00'),
2148
+ }),
2149
+ h('div', { className: 'dcb-card' },
2150
+ h('div', { className: 'dcb-card-label' }, tt('账户余额')),
2151
+ h('div', { className: balanceClass }, balanceValue),
2152
+ h('div', { className: 'dcb-card-hint' }, balanceHint))),
2153
+ h('div', { className: 'dcb-set-controls' },
2154
+ h('div', { className: 'dcb-field' },
2155
+ h('label', { className: 'dcb-field-label', htmlFor: 'dcb-range-select' }, tt('时间范围')),
2156
+ h('select', {
2157
+ id: 'dcb-range-select',
2158
+ className: 'dcb-select',
2159
+ value: calFrom || calTo ? 'custom' : rangeKey,
2160
+ onChange: function (event) {
2161
+ setRangeKey(event.target.value)
2162
+ setCalFrom('')
2163
+ setCalTo('')
2164
+ },
2165
+ },
2166
+ rangeOptions.map(function (opt) { return h('option', { key: opt.id, value: opt.id }, opt.label) }),
2167
+ (calFrom || calTo) ? h('option', { key: 'custom', value: 'custom' }, tt('自定义区间')) : null)),
2168
+ h('div', { className: 'dcb-field' },
2169
+ h('label', { className: 'dcb-field-label', htmlFor: 'dcb-workspace-select' }, tt('工作区')),
2170
+ h('select', {
2171
+ id: 'dcb-workspace-select',
2172
+ className: 'dcb-select',
2173
+ value: wid,
2174
+ onChange: function (event) { setWid(event.target.value) },
2175
+ },
2176
+ h('option', { key: 'all', value: '' }, tt('全部工作区')),
2177
+ index.list.map(function (it) { return h('option', { key: it.id, value: it.id }, it.title) }),
2178
+ h('option', { key: 'none', value: UNKNOWN_WORKSPACE }, tt('未归属工作区')))),
2179
+ h('div', { className: 'dcb-field' },
2180
+ h('span', { className: 'dcb-field-label' }, tt('日历选择(覆盖时间范围)')),
2181
+ h('div', { className: 'dcb-range' },
2182
+ h('input', {
2183
+ type: 'date',
2184
+ className: 'dcb-date',
2185
+ value: calFrom,
2186
+ onChange: function (event) { setCalFrom(event.target.value) },
2187
+ }),
2188
+ h('span', { className: 'dcb-range-sep' }, '~'),
2189
+ h('input', {
2190
+ type: 'date',
2191
+ className: 'dcb-date',
2192
+ value: calTo,
2193
+ onChange: function (event) { setCalTo(event.target.value) },
2194
+ }),
2195
+ (calFrom || calTo)
2196
+ ? h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setCalFrom(''); setCalTo('') } }, tt('清除'))
2197
+ : null)),
2198
+ h('div', { className: 'dcb-field' },
2199
+ h('span', { className: 'dcb-field-label' }, tt('数据')),
2200
+ h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setReloadTick(function (v) { return v + 1 }) } }, tt('刷新')))),
2201
+ h('div', { className: 'dcb-set-body' },
2202
+ h('div', { className: 'dcb-hist-legend' },
2203
+ h('span', { className: 'dcb-tog dcb-tog-cost is-on' }, h('i', null), tt('估算金额(右轴)')),
2204
+ h('button', {
2205
+ type: 'button',
2206
+ className: showTokens ? 'dcb-tog is-on' : 'dcb-tog',
2207
+ onClick: function () { setShowTokens(function (v) { return v === true ? false : true }) },
2208
+ }, h('i', null), tt('Token(左轴,默认隐藏)'))),
2209
+ hasData
2210
+ ? h(HistoryChart, { points: points, showTokens: showTokens })
2211
+ : h('div', { className: 'dcb-hist-empty' }, tt('该范围内暂无消耗记录'))),
2212
+ hasData
2213
+ ? h('div', { className: 'dcb-sum' },
2214
+ h('span', null, tt('总消耗 '), h('b', null, '≈¥' + fmtYuan(stats.totalCost))),
2215
+ h('span', null, tt('总 Token '), h('b', null, fmtTokens(stats.totalTokens))),
2216
+ h('span', null, tt('记录 '), h('b', null, String(stats.count)), tt(' 条')),
2217
+ h('span', null, tt('峰时 '), h('b', null, '≈¥' + fmtYuan(stats.peakCost))),
2218
+ h('span', null, tt('谷时 '), h('b', null, '≈¥' + fmtYuan(stats.valleyCost))),
2219
+ h('span', null, tt('粒度 '), h('b', null, tt(stats.granularity === 'month' ? '按月' : (stats.granularity === 'hour' ? '按小时' : '按日')))))
2220
+ : null,
2221
+ h('div', { className: 'dcb-hist-aside' },
2222
+ tt('数据源:本机逐轮记录(localStorage {prefix}<sessionId>:<turn>);按北京时间聚合(今天与单日区间逐小时,多日逐日,近一年逐月){worker}。金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考。', {
2223
+ prefix: LS_PREFIX,
2224
+ worker: stats && stats.via === 'worker' ? tt(';聚合已在 Web Worker 中完成') : '',
2225
+ })))
2226
+ }
2227
+
2228
+ /* ============ 与 dsh-plugin-personalization 联动:个性化页内新增「消耗弹窗」栏 ============ */
2229
+ // 取官方 UI 组件(与个性化页其他选项同款外观);取不到时退回普通元素。
2230
+ let dcbPrimitives = null
2231
+ try {
2232
+ dcbPrimitives = require('@deepseek-ai/dsh-client-ui-primitives')
2233
+ } catch (e) {
2234
+ dcbPrimitives = null
2235
+ }
2236
+
2237
+ function ModalPill(props) {
2238
+ const Pill = dcbPrimitives && dcbPrimitives.Pill ? dcbPrimitives.Pill : null
2239
+ if (typeof Pill === 'function') {
2240
+ return h(Pill, { active: props.active, onClick: props.onClick }, props.children)
2241
+ }
2242
+ return h('button', {
2243
+ type: 'button',
2244
+ className: props.active ? 'dcb-tog is-on' : 'dcb-tog',
2245
+ onClick: props.onClick,
2246
+ }, props.children)
2247
+ }
2248
+
2249
+ /** 注入到「个性化」页里的「消耗弹窗」栏目:排版沿用 pp-block / pp-field / pp-modes。 */
2250
+ function ConsumptionModalSettings() {
2251
+ const tick = React.useState(0)
2252
+ const setTick = tick[1]
2253
+ const fileRef = React.useRef(null)
2254
+ React.useEffect(function () {
2255
+ return store.subscribe(function () { setTick(function (v) { return v + 1 }) })
2256
+ }, [])
2257
+ // This block renders in its own React root outside the slot system, so it
2258
+ // subscribes to the locale face itself (slot outlets do that for us).
2259
+ React.useEffect(function () {
2260
+ return subscribeLocale(function () { setTick(function (v) { return v + 1 }) })
2261
+ }, [])
2262
+ const cfg = store.state.modalStyle
2263
+
2264
+ function patch(next) {
2265
+ store.setModalStyle(next)
2266
+ }
2267
+
2268
+ /** 清除图片:库里的图片、数据、URL 输入、文件选择框一起清(否则选同一个文件不会再触发 change)。 */
2269
+ function clearImage() {
2270
+ const input = fileRef.current
2271
+ if (input) {
2272
+ try {
2273
+ input.value = ''
2274
+ } catch (e) { /* ignore */ }
2275
+ }
2276
+ store.clearModalImage()
2277
+ }
2278
+
2279
+ /** 选图:Blob 直接进 IndexedDB,不做 base64、不设大小上限。 */
2280
+ function onPickFile(event) {
2281
+ const input = event && event.target ? event.target : null
2282
+ const file = input && input.files && input.files.length > 0 ? input.files[0] : null
2283
+ if (!file) return
2284
+ store.setModalImage(file).then(function () {
2285
+ const el = fileRef.current
2286
+ if (el) {
2287
+ try {
2288
+ el.value = ''
2289
+ } catch (e) { /* ignore */ }
2290
+ }
2291
+ })
2292
+ }
2293
+
2294
+ const rows = []
2295
+ rows.push(h('div', { className: 'pp-field', key: 'mode' },
2296
+ h('div', { className: 'pp-fieldLabelWrap' },
2297
+ h('span', { className: 'pp-fieldLabel' }, tt('背景')),
2298
+ h('span', { className: 'pp-fieldHint' }, tt('点击对话里的 ⛽ 胶囊弹出的消耗面板'))),
2299
+ h('div', { className: 'pp-fieldControl' },
2300
+ h('div', { className: 'pp-modes', role: 'radiogroup', 'aria-label': tt('消耗弹窗背景') },
2301
+ MODAL_MODES.map(function (mode) {
2302
+ return h(ModalPill, {
2303
+ key: mode.value,
2304
+ active: cfg.mode === mode.value,
2305
+ onClick: function () { patch({ mode: mode.value }) },
2306
+ }, tt(mode.label))
2307
+ })))))
2308
+
2309
+ if (cfg.mode === 'solid') {
2310
+ rows.push(h('div', { className: 'pp-field', key: 'solid' },
2311
+ h('div', { className: 'pp-fieldLabelWrap' },
2312
+ h('span', { className: 'pp-fieldLabel' }, tt('纯色')),
2313
+ h('span', { className: 'pp-fieldHint' }, tt('弹窗底色;文字颜色会按明暗自动切换'))),
2314
+ h('div', { className: 'pp-fieldControl' },
2315
+ h('div', { className: 'pp-colorRow' },
2316
+ h('input', {
2317
+ type: 'color',
2318
+ className: 'pp-colorSwatch',
2319
+ value: cfg.color,
2320
+ onChange: function (event) { patch({ color: event.target.value }) },
2321
+ }),
2322
+ h('input', {
2323
+ type: 'text',
2324
+ className: 'dcb-date',
2325
+ style: { minWidth: '96px' },
2326
+ value: cfg.color,
2327
+ onChange: function (event) { patch({ color: event.target.value }) },
2328
+ })))))
2329
+ }
2330
+
2331
+ if (cfg.mode === 'image') {
2332
+ // 只要「有 key」或「内存里已有 blob: 地址」都算已选:避免持久化失败时界面谎报未设置。
2333
+ const localImage = (typeof cfg.imageKey === 'string' && cfg.imageKey !== '')
2334
+ || String(store.state.modalImageUrl).indexOf('blob:') === 0
2335
+ const urlImage = typeof cfg.image === 'string' && cfg.image !== ''
2336
+ const hasImage = localImage || urlImage
2337
+ const previewSrc = localImage ? store.state.modalImageUrl : cfg.image
2338
+ const status = store.state.modalImageStatus
2339
+ let statusText = ''
2340
+ if (status === 'saving') statusText = tt('正在保存…')
2341
+ else if (status === 'saved') statusText = tt('已保存到本机')
2342
+ else if (typeof status === 'string' && status.indexOf('error:') === 0) statusText = status
2343
+ else if (localImage && !store.state.modalImageUrl) statusText = tt('取回失败,请重新选择')
2344
+ const summary = localImage
2345
+ ? (statusText ? tt('当前:本地图片({status})', { status: statusText }) : tt('当前:本地图片(已保存到本机)'))
2346
+ : (urlImage ? tt('当前:图片 URL') : tt('未设置图片(此时按默认背景显示)'))
2347
+ rows.push(h('div', { className: 'pp-field', key: 'image' },
2348
+ h('div', { className: 'pp-fieldLabelWrap' },
2349
+ h('span', { className: 'pp-fieldLabel' }, tt('图片')),
2350
+ h('span', { className: 'pp-fieldHint' }, summary + tt(';可选 URL 或本地图片,不限大小'))),
2351
+ h('div', { className: 'pp-fieldControl' },
2352
+ h('div', { className: 'pp-urlRow' },
2353
+ // 原生 file 输入隐藏起来:它在保存后必然显示"未选择文件",容易误判成没存上。
2354
+ h('input', {
2355
+ ref: fileRef,
2356
+ type: 'file',
2357
+ accept: 'image/*',
2358
+ style: { display: 'none' },
2359
+ onChange: onPickFile,
2360
+ }),
2361
+ h('button', {
2362
+ type: 'button',
2363
+ className: 'pp-blockReset',
2364
+ onClick: function () {
2365
+ const el = fileRef.current
2366
+ if (el && typeof el.click === 'function') el.click()
2367
+ },
2368
+ }, hasImage ? tt('更换图片…') : tt('选择图片…')),
2369
+ h('button', {
2370
+ type: 'button',
2371
+ className: 'pp-blockReset',
2372
+ disabled: !hasImage,
2373
+ onClick: clearImage,
2374
+ }, tt('清除')),
2375
+ previewSrc
2376
+ ? h('img', { className: 'pp-thumb', src: previewSrc, alt: '' })
2377
+ : null,
2378
+ h('input', {
2379
+ type: 'text',
2380
+ className: 'dcb-date',
2381
+ style: { flex: '1 1 160px', minWidth: '0' },
2382
+ placeholder: localImage ? tt('已使用本地图片,输入 URL 可替换') : 'https://…',
2383
+ value: localImage ? '' : cfg.image,
2384
+ onChange: function (event) { patch({ image: event.target.value, imageKey: '' }) },
2385
+ })))))
2386
+ rows.push(h('div', { className: 'pp-field', key: 'opacity' },
2387
+ h('div', { className: 'pp-fieldLabelWrap' },
2388
+ h('span', { className: 'pp-fieldLabel' }, tt('不透明度')),
2389
+ h('span', { className: 'pp-fieldHint' }, tt('背景图片自身的透明度(0% 完全不可见,100% 完整显示)'))),
2390
+ h('div', { className: 'pp-fieldControl' },
2391
+ h('div', { className: 'pp-opacityRow' },
2392
+ h('input', {
2393
+ className: 'pp-range',
2394
+ type: 'range',
2395
+ min: '0',
2396
+ max: '100',
2397
+ step: '1',
2398
+ 'aria-label': tt('不透明度'),
2399
+ value: String(typeof cfg.opacity === 'number' ? cfg.opacity : DEFAULT_MODAL_STYLE.opacity),
2400
+ onChange: function (event) { patch({ opacity: Number(event.target.value) }) },
2401
+ }),
2402
+ h('span', { className: 'pp-opacityValue' },
2403
+ String(typeof cfg.opacity === 'number' ? cfg.opacity : DEFAULT_MODAL_STYLE.opacity) + '%')))))
2404
+ }
2405
+
2406
+ // 毛玻璃开关 + 强度:自定义背景 / 纯色 都能叠加(「毛玻璃」选项本身已隐含开启)。
2407
+ if (cfg.mode === 'image' || cfg.mode === 'solid') {
2408
+ rows.push(h('div', { className: 'pp-field', key: 'glass' },
2409
+ h('div', { className: 'pp-fieldLabelWrap' },
2410
+ h('span', { className: 'pp-fieldLabel' }, tt('毛玻璃')),
2411
+ h('span', { className: 'pp-fieldHint' },
2412
+ tt(cfg.mode === 'image' ? '把背景图片磨砂化(对图片做真模糊)' : '底色半透明并模糊背后的页面'))),
2413
+ h('div', { className: 'pp-fieldControl' },
2414
+ h('button', {
2415
+ type: 'button',
2416
+ role: 'switch',
2417
+ 'aria-checked': cfg.glass === true ? 'true' : 'false',
2418
+ className: cfg.glass === true ? 'pp-switch pp-switchOn' : 'pp-switch',
2419
+ onClick: function () { patch({ glass: cfg.glass !== true }) },
2420
+ }))))
2421
+ }
2422
+
2423
+ // 毛玻璃强度:只要当前处于玻璃态(选了「毛玻璃」或打开了开关)就露出滑杆。
2424
+ if (cfg.mode === 'glass' || cfg.glass === true) {
2425
+ rows.push(h('div', { className: 'pp-field', key: 'blur' },
2426
+ h('div', { className: 'pp-fieldLabelWrap' },
2427
+ h('span', { className: 'pp-fieldLabel' }, tt('毛玻璃强度')),
2428
+ h('span', { className: 'pp-fieldHint' }, tt('模糊半径(0–48px)'))),
2429
+ h('div', { className: 'pp-fieldControl' },
2430
+ h('div', { className: 'pp-opacityRow' },
2431
+ h('input', {
2432
+ type: 'range',
2433
+ min: '0',
2434
+ max: '48',
2435
+ step: '1',
2436
+ value: String(typeof cfg.blur === 'number' ? cfg.blur : DEFAULT_MODAL_STYLE.blur),
2437
+ onChange: function (event) { patch({ blur: Number(event.target.value) }) },
2438
+ }),
2439
+ h('span', { className: 'pp-fieldHint' }, String(typeof cfg.blur === 'number' ? cfg.blur : DEFAULT_MODAL_STYLE.blur) + 'px')))))
2440
+ }
2441
+
2442
+ return h('section', { className: 'pp-block', 'data-section': 'dcb-consumption', 'data-dcb-build': BUILD_TAG },
2443
+ h('header', { className: 'pp-blockHead' },
2444
+ h('div', { className: 'pp-blockHeadMain' },
2445
+ h('h3', { className: 'pp-blockTitle' }, tt('消耗弹窗')),
2446
+ h('p', { className: 'pp-blockDesc' }, tt('消耗徽章弹层的背景样式;不影响对话区与侧边栏背景。{tag}', { tag: BUILD_TAG }))),
2447
+ h('button', {
2448
+ type: 'button',
2449
+ className: 'pp-blockReset',
2450
+ onClick: function () {
2451
+ store.setModalStyle(DEFAULT_MODAL_STYLE)
2452
+ const input = fileRef.current
2453
+ if (input) {
2454
+ try {
2455
+ input.value = ''
2456
+ } catch (e) { /* ignore */ }
2457
+ }
2458
+ },
2459
+ }, tt('重置本栏'))),
2460
+ h('div', { className: 'pp-blockBody' }, rows))
2461
+ }
2462
+
2463
+ /**
2464
+ * 把「消耗弹窗」栏注入个性化页。个性化插件没有暴露子插槽,因此:
2465
+ * - 只在页面上真的出现 `section.pp-block`(即个性化页已挂载)时才注入,等价于“同时安装”的运行时探测;
2466
+ * - 自建容器 + 自建 React root,个性化页重渲染把我的节点摘掉时,把**同一个容器**重新挂回去(状态不丢)。
2467
+ */
2468
+ function startPersonalizationBridge() {
2469
+ if (typeof document === 'undefined' || typeof MutationObserver !== 'function') {
2470
+ return function () {}
2471
+ }
2472
+ let container = null
2473
+ let root = null
2474
+ let scheduled = false
2475
+ let stopped = false
2476
+ let announced = false
2477
+ let reactDom = null
2478
+ try {
2479
+ reactDom = require('react-dom/client')
2480
+ } catch (e) {
2481
+ reactDom = null
2482
+ }
2483
+
2484
+ function ensureContainer() {
2485
+ if (container) return container
2486
+ container = document.createElement('div')
2487
+ container.setAttribute('data-dcb-personalization', '1')
2488
+ if (reactDom !== null && typeof reactDom.createRoot === 'function') {
2489
+ root = reactDom.createRoot(container)
2490
+ root.render(h(ConsumptionModalSettings, null))
2491
+ } else {
2492
+ container.textContent = tt('消耗弹窗设置需要 react-dom,当前环境不可用。')
2493
+ }
2494
+ return container
2495
+ }
2496
+
2497
+ function attach() {
2498
+ if (stopped) return
2499
+ // 快路径:已经挂在文档里就不用再找页面(避免每次 DOM 变动都查一次 selector)。
2500
+ if (container && container.isConnected) return
2501
+ const anchor = document.querySelector('section.pp-block')
2502
+ if (!anchor || !anchor.parentElement) return
2503
+ const page = anchor.parentElement
2504
+ const node = ensureContainer()
2505
+ if (node.parentNode !== page) page.appendChild(node)
2506
+ if (!announced) {
2507
+ announced = true
2508
+ console.log('[consumption-badge] dsh-plugin-personalization detected: injected the usage popup section into its settings page')
2509
+ }
2510
+ }
2511
+
2512
+ function schedule() {
2513
+ if (stopped || scheduled) return
2514
+ scheduled = true
2515
+ setTimeout(function () {
2516
+ scheduled = false
2517
+ try {
2518
+ attach()
2519
+ } catch (e) { /* 注入失败不影响主功能 */ }
2520
+ }, 0)
2521
+ }
2522
+
2523
+ const observer = new MutationObserver(schedule)
2524
+ observer.observe(document.body, { childList: true, subtree: true })
2525
+ schedule()
2526
+
2527
+ return function () {
2528
+ stopped = true
2529
+ try {
2530
+ observer.disconnect()
2531
+ } catch (e) { /* 已断开 */ }
2532
+ if (root !== null && typeof root.unmount === 'function') {
2533
+ try {
2534
+ root.unmount()
2535
+ } catch (e) { /* 已卸载 */ }
2536
+ }
2537
+ if (container && container.parentNode) {
2538
+ try {
2539
+ container.parentNode.removeChild(container)
2540
+ } catch (e) { /* 已移除 */ }
2541
+ }
2542
+ container = null
2543
+ root = null
2544
+ }
2545
+ }
2546
+
2547
+ /* ============== 更新提示弹窗(仿 dsh-plugin-personalization 的 UpdateNotice) ============== */
2548
+ /**
2549
+ * 右下角浮层弹窗:只在「本地版本 < 最新版本」且未被忽略、未在本次会话点过「稍后提醒」时出现。
2550
+ * 结构照抄个性化插件的提示条(标题 / 版本行 / 命令框 / 复制·忽略·稍后提醒),但不依赖它的样式表,
2551
+ * 自带 dcb-* 样式,因此没有装个性化插件也能显示。
2552
+ */
2553
+ function UpdatePopup() {
2554
+ const state = store.state
2555
+ const tick = React.useState(0)
2556
+ const setTick = tick[1]
2557
+ const copiedState = React.useState(false)
2558
+ const copied = copiedState[0]
2559
+ const setCopied = copiedState[1]
2560
+ React.useEffect(function () {
2561
+ return store.subscribe(function () { setTick(function (v) { return v + 1 }) })
2562
+ }, [])
2563
+ React.useEffect(function () {
2564
+ if (copied !== true) return undefined
2565
+ return everyTick(2000, function () { setCopied(false) })
2566
+ }, [copied])
2567
+
2568
+ const info = state.updateInfo
2569
+ const visible = state.updateState === 'ready'
2570
+ && state.updateDismissed !== true
2571
+ && !!info
2572
+ && info.hasUpdate === true
2573
+ if (!visible) return null
2574
+
2575
+ function copyCommand() {
2576
+ const clipboard = typeof navigator !== 'undefined' && navigator !== null ? navigator.clipboard : null
2577
+ if (clipboard === null || clipboard === undefined || typeof clipboard.writeText !== 'function') {
2578
+ console.warn('[consumption-badge] clipboard unavailable, copy the command manually')
2579
+ return
2580
+ }
2581
+ try {
2582
+ const pending = clipboard.writeText(UPDATE_COMMAND)
2583
+ if (pending && typeof pending.then === 'function') {
2584
+ pending.then(function () { setCopied(true) }, function () {
2585
+ console.warn('[consumption-badge] clipboard write failed')
2586
+ })
2587
+ return
2588
+ }
2589
+ setCopied(true)
2590
+ } catch (e) {
2591
+ console.warn('[consumption-badge] clipboard write threw')
2592
+ }
2593
+ }
2594
+
2595
+ return h('div', { className: 'dcb-notice', role: 'status', 'aria-live': 'polite' },
2596
+ h('div', { className: 'dcb-notice-head' },
2597
+ h('span', { className: 'dcb-notice-title' }, tt('消耗徽章有新版本')),
2598
+ h('span', { className: 'dcb-notice-versions' },
2599
+ tt('当前 v{current} → 最新 v{latest}', { current: String(info.currentVersion), latest: String(info.latestVersion) })),
2600
+ h('button', {
2601
+ type: 'button',
2602
+ className: 'dcb-notice-x',
2603
+ title: tt('稍后提醒'),
2604
+ onClick: function () { store.dismissUpdate('later') },
2605
+ }, '✕')),
2606
+ h('div', { className: 'dcb-notice-cmd', title: UPDATE_COMMAND }, UPDATE_COMMAND),
2607
+ h('div', { className: 'dcb-notice-actions' },
2608
+ h('button', { type: 'button', className: 'dcb-btn dcb-btn-primary', onClick: copyCommand },
2609
+ copied ? tt('已复制') : tt('复制更新命令')),
2610
+ h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.dismissUpdate('ignore') } }, tt('忽略此版本')),
2611
+ h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.dismissUpdate('later') } }, tt('稍后提醒')),
2612
+ h('span', { className: 'dcb-notice-hint' }, tt('更新后需重启 dsh 进程'))),
2613
+ h('div', { className: 'dcb-notice-foot' },
2614
+ tt('版本来自 npm registry(24h 缓存);「忽略此版本」会记住,直到出现更高版本。')))
2615
+ }
2616
+
2617
+ /* styles + registration */
667
2618
  const CSS = `
668
2619
  .dcb-pill{display:inline-flex;align-items:center;gap:5px;height:20px;padding:0 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1;font-variant-numeric:tabular-nums;cursor:pointer;white-space:nowrap;transition:border-color .15s,color .15s}
669
2620
  .dcb-pill:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}
@@ -672,8 +2623,25 @@ window.__ModuleLoader__.load({
672
2623
  .dcb-tag{font-weight:600}
673
2624
  .dcb-tag.is-peak{color:var(--dsw-alias-state-error-primary)}
674
2625
  .dcb-tag.is-valley{color:var(--dsw-alias-state-success-primary)}
675
- .dcb-overlay{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;pointer-events:auto;background:rgba(12,14,18,.42);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);animation:dcb-fade .16s ease-out;outline:none}
676
- .dcb-modal{width:min(780px,94vw);max-height:88vh;overflow:auto;border-radius:16px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);box-shadow:0 24px 60px rgba(0,0,0,.3);padding:18px 20px 14px;animation:dcb-pop .18s ease-out}
2626
+ /* 遮罩层只做压暗/淡纱,永远不做毛玻璃(周围不模糊)。注意:不写任何 backdrop-filter。 */
2627
+ .dcb-overlay{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;pointer-events:auto;background:color-mix(in srgb,var(--dsw-alias-bg-base) 46%,transparent);animation:dcb-fade .16s ease-out;outline:none}
2628
+ .dcb-modal{width:min(780px,94vw);max-height:88vh;overflow:auto;border-radius:16px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);box-shadow:0 24px 60px rgba(0,0,0,.3);padding:18px 20px 14px;animation:dcb-pop .18s ease-out;position:relative;isolation:isolate}
2629
+ /* 毛玻璃:不压暗,纯 backdrop-filter(与 dsh-plugin-personalization 的设置面板同款做法) */
2630
+ .dcb-modal.is-glass{background:color-mix(in srgb,var(--dsw-alias-bg-overlay) 18%,transparent);-webkit-backdrop-filter:blur(var(--dcb-glass-blur,22px)) saturate(1.6);backdrop-filter:blur(var(--dcb-glass-blur,22px)) saturate(1.6);border-color:color-mix(in srgb,var(--dsw-alias-border-l2) 70%,transparent);box-shadow:0 24px 60px rgba(0,0,0,.26),inset 0 1px 0 rgba(255,255,255,.12)}
2631
+ /* 纯色:底色只给变量,透明度由是否毛玻璃决定 */
2632
+ .dcb-modal.has-solid{background:var(--dcb-solid,#17181c)}
2633
+ .dcb-modal.has-solid.is-glass{background:color-mix(in srgb,var(--dcb-solid,#17181c) 52%,transparent);-webkit-backdrop-filter:blur(calc(var(--dcb-glass-blur,22px) * 0.8)) saturate(1.3);backdrop-filter:blur(calc(var(--dcb-glass-blur,22px) * 0.8)) saturate(1.3)}
2634
+ /* 自定义背景:图片放在伪元素上,opacity 作用在图片自身(真半透明,不与底色混成发白);
2635
+ 底下垫主题面板色,所以不透明度越低越淡,而不是越暗;开毛玻璃时对图片本身做模糊。 */
2636
+ .dcb-modal.has-image{background:var(--dsw-alias-bg-overlay)}
2637
+ .dcb-modal.has-image::before{content:'';position:absolute;inset:0;z-index:-1;border-radius:inherit;background-image:var(--dcb-bg-image);background-size:cover;background-position:center;background-repeat:no-repeat;opacity:var(--dcb-bg-opacity,.85)}
2638
+ .dcb-modal.has-image.is-glass{background:color-mix(in srgb,var(--dsw-alias-bg-overlay) 18%,transparent)}
2639
+ .dcb-modal.has-image.is-glass::before{-webkit-filter:blur(var(--dcb-glass-blur,22px)) saturate(1.15);filter:blur(var(--dcb-glass-blur,22px)) saturate(1.15);transform:scale(1.06)}
2640
+ @supports not (background: color-mix(in srgb, red 50%, transparent)){
2641
+ .dcb-overlay{background:rgba(18,20,26,.42)}
2642
+ .dcb-modal.is-glass{background:rgba(127,127,127,.16)}
2643
+ .dcb-modal.has-solid.is-glass{background:rgba(127,127,127,.18)}
2644
+ }
677
2645
  .dcb-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
678
2646
  .dcb-head-title{font-size:14px;font-weight:600;letter-spacing:.2px}
679
2647
  .dcb-head-actions{display:flex;gap:8px}
@@ -712,6 +2680,47 @@ window.__ModuleLoader__.load({
712
2680
  .dcb-foot-warn{color:var(--dsw-alias-state-error-primary)}
713
2681
  @keyframes dcb-fade{from{opacity:0}to{opacity:1}}
714
2682
  @keyframes dcb-pop{from{opacity:0;transform:translateY(6px) scale(.99)}to{opacity:1;transform:none}}
2683
+ /* ---- settings section: history page ---- */
2684
+ .dcb-set{padding:2px 2px 18px;color:var(--dsw-alias-label-primary)}
2685
+ .dcb-set-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:14px}
2686
+ .dcb-set-title{font-size:15px;font-weight:600;letter-spacing:.2px}
2687
+ .dcb-set-note{font-size:10.5px;color:var(--dsw-alias-label-secondary);opacity:.9;white-space:nowrap}
2688
+ .dcb-set-controls{display:flex;flex-wrap:wrap;gap:10px 12px;align-items:flex-end;margin-bottom:14px}
2689
+ .dcb-field{display:flex;flex-direction:column;gap:5px;min-width:0}
2690
+ .dcb-field-label{font-size:11px;color:var(--dsw-alias-label-secondary)}
2691
+ .dcb-select,.dcb-date{height:28px;padding:0 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;min-width:0}
2692
+ .dcb-select{min-width:132px;cursor:pointer}
2693
+ .dcb-date{min-width:126px}
2694
+ .dcb-select:focus,.dcb-date:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
2695
+ .dcb-range{display:flex;align-items:center;gap:6px}
2696
+ .dcb-range-sep{color:var(--dsw-alias-label-secondary);font-size:12px}
2697
+ .dcb-set-body{border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);padding:10px 12px 8px}
2698
+ .dcb-hist-svg{display:block;width:100%;height:280px}
2699
+ .dcb-hist-legend{display:flex;gap:14px;font-size:11px;color:var(--dsw-alias-label-secondary);margin-bottom:6px;flex-wrap:wrap}
2700
+ .dcb-tog{display:inline-flex;align-items:center;gap:6px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:11px;cursor:pointer;line-height:16px}
2701
+ .dcb-tog.is-on{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}
2702
+ .dcb-tog i{display:inline-block;width:12px;height:3px;border-radius:2px;background:currentColor;opacity:.55}
2703
+ .dcb-tog.is-on i{opacity:1;background:var(--dsw-alias-brand-primary)}
2704
+ .dcb-tog.dcb-tog-cost.is-on i{background:var(--dsw-alias-state-success-primary)}
2705
+ .dcb-sum{margin-top:12px;display:flex;gap:18px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary)}
2706
+ .dcb-sum b{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;font-weight:600}
2707
+ .dcb-cards-set{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-bottom:14px}
2708
+ /* ---- 更新提示弹窗(右下角浮层;结构对齐个性化插件的 pp-notice 提示条)---- */
2709
+ .dcb-notice{position:fixed;right:18px;bottom:18px;z-index:70;width:min(420px,calc(100vw - 32px));display:flex;flex-direction:column;gap:10px;padding:12px 14px;border-radius:var(--pp-radius-lg,12px);border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);box-shadow:0 18px 44px rgba(0,0,0,.24);pointer-events:auto;animation:dcb-pop .18s ease-out}
2710
+ .dcb-notice-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;min-width:0}
2711
+ .dcb-notice-title{font-size:14px;line-height:22px;font-weight:600}
2712
+ .dcb-notice-versions{font-size:12px;line-height:20px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
2713
+ .dcb-notice-x{margin-left:auto;flex:none;width:22px;height:22px;padding:0;border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1;cursor:pointer;border-radius:6px}
2714
+ .dcb-notice-x:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
2715
+ .dcb-notice-cmd{min-width:0;padding:8px 10px;border-radius:8px;border:1px dashed var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11.5px;line-height:18px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
2716
+ .dcb-notice-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
2717
+ .dcb-notice-hint{flex:1 1 160px;min-width:0;font-size:11px;line-height:18px;color:var(--dsw-alias-label-secondary)}
2718
+ .dcb-notice-foot{font-size:11px;line-height:17px;color:var(--dsw-alias-label-secondary);opacity:.85}
2719
+ .dcb-btn-primary{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}
2720
+ .dcb-set .dcb-card{padding:9px 11px}
2721
+ .dcb-set .dcb-card-value{font-size:17px}
2722
+ .dcb-hist-empty{padding:56px 0;text-align:center;font-size:12.5px;color:var(--dsw-alias-label-secondary)}
2723
+ .dcb-hist-aside{font-size:10.5px;color:var(--dsw-alias-label-secondary);margin-top:6px;opacity:.85}
715
2724
  `
716
2725
 
717
2726
  /** 样式标签:覆盖式写入(外壳热重挂插件时旧标签可能还在),随插件卸载摘除。 */
@@ -736,9 +2745,15 @@ window.__ModuleLoader__.load({
736
2745
 
737
2746
  exports.apply = function apply(ctx) {
738
2747
  insertCss(ctx)
2748
+ // Bind the shell locale first: every render below reads through tt().
2749
+ appCtx = ctx
2750
+ attachLocale(ctx)
2751
+ // Slot labels are thunks so the settings nav / action row re-read the copy
2752
+ // after a language switch (the slot system resolves labels at read time).
2753
+ const labelThunk = function () { return tt('消耗') }
739
2754
  ctx.slots.inject('conversation.chat.assistant-actions', function () {
740
2755
  const dispose = ctx.slots.register(
741
- { name: 'conversation.chat.assistant-actions', id: 'consumption', order: 20, label: '消耗' },
2756
+ { name: 'conversation.chat.assistant-actions', id: 'consumption', order: 20, label: labelThunk },
742
2757
  function (props) { return h(ConsumptionBadge, props) },
743
2758
  )
744
2759
  return function () { dispose() }
@@ -750,6 +2765,30 @@ window.__ModuleLoader__.load({
750
2765
  )
751
2766
  return function () { dispose() }
752
2767
  })
2768
+ // 设置页整页:settings.section(不是 settings.plugin.item 的卡片位)。
2769
+ ctx.slots.inject('settings.section', function () {
2770
+ const dispose = ctx.slots.register(
2771
+ { name: 'settings.section', id: 'balance-dsh-consumption', order: 26, label: labelThunk },
2772
+ function (props) { return h(ConsumptionSettings, props) },
2773
+ )
2774
+ return function () { dispose() }
2775
+ })
2776
+ // 与 dsh-plugin-personalization 联动:在它的设置页注入「消耗弹窗」背景控制。
2777
+ ctx.effect(function () { return startPersonalizationBridge() })
2778
+ // 把已存进 IndexedDB 的背景图取回来(生成新的 blob: 地址)。
2779
+ store.hydrateModalImage()
2780
+ // 更新提示弹窗:启动后延迟异步检查,绝不阻塞首屏;显示在右下角浮层,不遮挡任何控件。
2781
+ ctx.slots.inject('shell.overlay', function () {
2782
+ const dispose = ctx.slots.register(
2783
+ { name: 'shell.overlay', id: 'consumption-update-notice', order: 50 },
2784
+ function () { return h(UpdatePopup, null) },
2785
+ )
2786
+ return function () { dispose() }
2787
+ })
2788
+ ctx.effect(function () {
2789
+ const timer = setTimeout(function () { store.checkUpdates() }, 2500)
2790
+ return function () { clearTimeout(timer) }
2791
+ })
753
2792
  }
754
2793
 
755
2794
  return module.exports