dsh-consumption-badge 1.2.1 → 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.
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 {
@@ -203,10 +454,10 @@ window.__ModuleLoader__.load({
203
454
 
204
455
  /* ================= 消耗弹窗背景样式(与个性化插件联动) ================= */
205
456
  const MODAL_STYLE_KEY = 'dsh-consumption:modal-style'
206
- const BUILD_TAG = 'dcb-1.2.1'
457
+ const BUILD_TAG = 'dcb-1.2.2'
207
458
  // 本地版本:本包是零构建的手写客户端包,无法 import package.json,因此把版本号作为常量维护,
208
459
  // 并由 check.mjs 守卫与 package.json 严格一致(发布前不一致直接失败)。
209
- const PLUGIN_VERSION = '1.2.1'
460
+ const PLUGIN_VERSION = '1.2.2'
210
461
  const PKG_NAME = 'dsh-consumption-badge'
211
462
  const UPDATE_REGISTRY_URL = 'https://registry.npmjs.org/' + PKG_NAME + '/latest'
212
463
  const UPDATE_COMMAND = 'dsh plugin --profile web update ' + PKG_NAME
@@ -219,6 +470,7 @@ window.__ModuleLoader__.load({
219
470
  // opacity:背景图片自身的不透明度(0–100,与个性化插件其它背景栏同口径);
220
471
  // 旧版的 scrim(压暗遮罩)在读取时折算成 100 - scrim,老配置不会丢。
221
472
  const DEFAULT_MODAL_STYLE = { mode: 'default', color: '#17181c', image: '', imageKey: '', opacity: 85, glass: false, blur: 22 }
473
+ // label 是词典 key(中文原文),渲染时统一走 tt(mode.label),所以这里不能直接显示。
222
474
  const MODAL_MODES = [
223
475
  { value: 'default', label: '默认' },
224
476
  { value: 'glass', label: '毛玻璃' },
@@ -521,7 +773,7 @@ window.__ModuleLoader__.load({
521
773
  return latest !== '' ? latest : null
522
774
  })
523
775
  .catch(function (err) {
524
- console.warn('[consumption-badge] 更新检查失败(静默):' + (err && err.message ? err.message : String(err)))
776
+ console.warn('[consumption-badge] update check failed (silent): ' + (err && err.message ? err.message : String(err)))
525
777
  return null
526
778
  })
527
779
  if (controller === null) return request
@@ -580,6 +832,11 @@ window.__ModuleLoader__.load({
580
832
  turns: [],
581
833
  turnsState: 'idle',
582
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: '',
583
840
  account: null,
584
841
  accountState: 'idle',
585
842
  integrations: null,
@@ -595,6 +852,9 @@ window.__ModuleLoader__.load({
595
852
  balance: null,
596
853
  balanceState: 'idle',
597
854
  balanceError: '',
855
+ balanceErrorKind: '',
856
+ balanceErrorCode: '',
857
+ balanceErrorFallback: '',
598
858
  balanceAt: 0,
599
859
  }
600
860
  const listeners = new Set()
@@ -663,13 +923,26 @@ window.__ModuleLoader__.load({
663
923
  }
664
924
  state.turns = restoreTurns(sessionId, normalized)
665
925
  state.turnsState = res && res.ok === false ? 'error' : 'ready'
666
- 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
+ }
667
937
  persistTurns(sessionId, normalized)
668
938
  emit()
669
939
  })
670
940
  .catch(function (e) {
671
941
  state.turnsState = 'error'
672
- state.turnsError = '会话消耗读取失败:' + msgOf(e)
942
+ state.turnsErrorKind = 'read'
943
+ state.turnsErrorCode = ''
944
+ state.turnsError = msgOf(e)
945
+ state.turnsErrorFallback = ''
673
946
  emit()
674
947
  })
675
948
  .then(function () { turnsInFlight = null })
@@ -684,17 +957,35 @@ window.__ModuleLoader__.load({
684
957
  balanceInFlight = Promise.resolve()
685
958
  .then(function () { return fetchJson(API + '/balance' + (force ? '?force=1' : '')) })
686
959
  .then(function (res) {
687
- const value = res && typeof res === 'object' ? res : { ok: false, error: '空响应' }
960
+ const value = res && typeof res === 'object' ? res : { ok: false }
688
961
  state.balance = value
689
962
  state.balanceAt = Date.now()
690
963
  state.balanceState = 'ready'
691
- 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
+ }
692
980
  emit()
693
981
  })
694
982
  .catch(function (e) {
695
983
  state.balance = null
696
984
  state.balanceState = 'error'
697
- state.balanceError = '余额获取失败:' + msgOf(e)
985
+ state.balanceErrorKind = 'fetch'
986
+ state.balanceErrorCode = ''
987
+ state.balanceError = msgOf(e)
988
+ state.balanceErrorFallback = ''
698
989
  emit()
699
990
  })
700
991
  .then(function () { balanceInFlight = null })
@@ -767,10 +1058,10 @@ window.__ModuleLoader__.load({
767
1058
  const key = 'modal-bg-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
768
1059
  return idbPutImage(key, file).then(function (stored) {
769
1060
  if (stored !== true) {
770
- console.warn('[consumption-badge] IndexedDB 写入失败,退回 dataURL')
1061
+ console.warn('[consumption-badge] IndexedDB write failed, falling back to a data URL')
771
1062
  return readAsDataUrl(file).then(function (dataUrl) {
772
1063
  if (!dataUrl) {
773
- state.modalImageStatus = 'error: IndexedDB 与 FileReader 都不可用'
1064
+ state.modalImageStatus = 'error: ' + tt('IndexedDB 与 FileReader 都不可用')
774
1065
  emit()
775
1066
  return
776
1067
  }
@@ -785,7 +1076,7 @@ window.__ModuleLoader__.load({
785
1076
  if (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function' && blob) {
786
1077
  setResolvedImageUrl(URL.createObjectURL(blob))
787
1078
  } else if (blob === null) {
788
- console.warn('[consumption-badge] 图片已入库但取回失败', key)
1079
+ console.warn('[consumption-badge] image stored but could not be read back', key)
789
1080
  }
790
1081
  if (prevKey && prevKey !== key) idbDeleteImage(prevKey)
791
1082
  state.modalImageStatus = 'saved'
@@ -793,7 +1084,7 @@ window.__ModuleLoader__.load({
793
1084
  })
794
1085
  }).catch(function (err) {
795
1086
  const text = err && err.message ? err.message : String(err)
796
- console.error('[consumption-badge] 保存图片失败:' + text)
1087
+ console.error('[consumption-badge] failed to save the image: ' + text)
797
1088
  state.modalImageStatus = 'error: ' + text
798
1089
  emit()
799
1090
  })
@@ -931,7 +1222,7 @@ window.__ModuleLoader__.load({
931
1222
  const holder = React.useState(-1)
932
1223
  const hover = holder[0]
933
1224
  const setHover = holder[1]
934
- if (!series.length) return h('div', { className: 'dcb-empty' }, '本会话还没有可统计的轮次')
1225
+ if (!series.length) return h('div', { className: 'dcb-empty' }, tt('本会话还没有可统计的轮次'))
935
1226
  const innerW = CHART_W - CHART_PAD.l - CHART_PAD.r
936
1227
  const innerH = CHART_H - CHART_PAD.t - CHART_PAD.b
937
1228
  let maxToken = 1
@@ -998,18 +1289,18 @@ window.__ModuleLoader__.load({
998
1289
  h('div', { className: 'dcb-tooltip-title' },
999
1290
  '#' + String(hovered.turn),
1000
1291
  h('span', { className: 'dcb-tooltip-model' }, hovered.model || DEFAULT_MODEL),
1001
- h('span', { className: hovered.peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, hovered.peak ? '峰时' : '谷时')),
1002
- h('div', { className: 'dcb-tooltip-row' }, h('span', null, '输入命中'), h('b', null, fmtTokens(hovered.hit))),
1003
- h('div', { className: 'dcb-tooltip-row' }, h('span', null, '输入未命中'), h('b', null, fmtTokens(hovered.miss))),
1004
- h('div', { className: 'dcb-tooltip-row' }, h('span', null, '输出'), h('b', null, fmtTokens(hovered.out))),
1005
- h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, '本轮合计'), h('b', null, fmtTokens(hovered.tokens))),
1006
- 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))))
1007
1298
  : null
1008
1299
 
1009
1300
  return h('div', { className: 'dcb-chart-wrap' },
1010
1301
  h('div', { className: 'dcb-chart-legend' },
1011
- h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot' }), '每轮 Token'),
1012
- 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('每轮估算金额'))),
1013
1304
  h('div', { className: 'dcb-chart-body' },
1014
1305
  h('svg', {
1015
1306
  className: 'dcb-chart-svg',
@@ -1031,6 +1322,24 @@ window.__ModuleLoader__.load({
1031
1322
 
1032
1323
  /* components: one store per plugin instance */
1033
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
+ }
1034
1343
  // Assigned by the overlay's callback ref, so Esc can reach a focused node without useRef.
1035
1344
  let panelRootEl = null
1036
1345
 
@@ -1084,7 +1393,7 @@ window.__ModuleLoader__.load({
1084
1393
  return h('button', {
1085
1394
  type: 'button',
1086
1395
  className: 'dcb-pill',
1087
- title: '本会话消耗(点击查看逐轮明细)',
1396
+ title: tt('本会话消耗(点击查看逐轮明细)'),
1088
1397
  onClick: function (event) {
1089
1398
  try {
1090
1399
  event.preventDefault()
@@ -1098,7 +1407,7 @@ window.__ModuleLoader__.load({
1098
1407
  h('span', { className: 'dcb-pill-sep' }, '|'),
1099
1408
  h('span', null, pending ? '…' : '≈¥' + fmtYuan(cost)),
1100
1409
  h('span', { className: 'dcb-pill-sep' }, '|'),
1101
- 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 ? '峰' : '谷')))
1102
1411
  }
1103
1412
 
1104
1413
  function ConsumptionPanel() {
@@ -1138,27 +1447,28 @@ window.__ModuleLoader__.load({
1138
1447
  const totalCost = costOf(aggregated, price.table, peak)
1139
1448
  const balance = state.balance
1140
1449
  const loading = state.balanceState === 'loading'
1450
+ const balanceErr = balanceErrorText()
1141
1451
  let balanceValue = '—'
1142
- let balanceHint = state.balanceError || '打开面板时请求一次'
1452
+ let balanceHint = balanceErr || tt('打开面板时请求一次')
1143
1453
  let balanceClass = 'dcb-card-value'
1144
1454
  if (loading && !balance) {
1145
- balanceValue = '读取中…'
1455
+ balanceValue = tt('读取中…')
1146
1456
  balanceHint = 'GET /user/balance'
1147
1457
  } else if (balance && balance.ok === false) {
1148
- balanceValue = '获取失败'
1149
- balanceHint = state.balanceError || '余额接口不可用'
1458
+ balanceValue = tt('获取失败')
1459
+ balanceHint = balanceErr || tt('余额接口不可用')
1150
1460
  balanceClass = 'dcb-card-value dcb-bad'
1151
1461
  } else if (balance && balance.available === false) {
1152
- balanceValue = '余额不足'
1153
- balanceHint = '请前往 DeepSeek 开放平台充值'
1462
+ balanceValue = tt('余额不足')
1463
+ balanceHint = tt('请前往 DeepSeek 开放平台充值')
1154
1464
  balanceClass = 'dcb-card-value dcb-bad'
1155
1465
  } else if (balance) {
1156
1466
  const symbol = balance.currency && balance.currency !== 'CNY' ? String(balance.currency) + ' ' : '¥'
1157
1467
  balanceValue = symbol + fmtYuan(balance.total)
1158
1468
  const parts = []
1159
- if (typeof balance.granted === 'number') parts.push('赠送 ¥' + fmtYuan(balance.granted))
1160
- if (typeof balance.toppedUp === 'number') parts.push('充值 ¥' + fmtYuan(balance.toppedUp))
1161
- 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 账户余额')
1162
1472
  }
1163
1473
 
1164
1474
  function close() {
@@ -1186,27 +1496,27 @@ window.__ModuleLoader__.load({
1186
1496
  h('div', {
1187
1497
  className: surface.className,
1188
1498
  role: 'dialog',
1189
- 'aria-label': '本会话消耗',
1499
+ 'aria-label': tt('本会话消耗'),
1190
1500
  style: surface.style,
1191
1501
  },
1192
1502
  h('div', { className: 'dcb-head' },
1193
- h('div', { className: 'dcb-head-title' }, '⛽ 本会话消耗'),
1503
+ h('div', { className: 'dcb-head-title' }, tt('⛽ 本会话消耗')),
1194
1504
  h('div', { className: 'dcb-head-actions' },
1195
- h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.ensureBalance(true) } }, '刷新余额'),
1196
- 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 关闭') }, '✕'))),
1197
1507
  h('div', { className: 'dcb-cards' },
1198
- h(Card, { label: '累计 Token', value: fmtTokens(tokens), hint: '命中 ' + fmtTokens(aggregated.hit) + ' · 未命中 ' + fmtTokens(aggregated.miss) + ' · 输出 ' + fmtTokens(aggregated.out) }),
1199
- h(Card, { label: '估算金额', value: '≈¥' + fmtYuan(totalCost), hint: (price.known ? price.id : price.id + '(按 flash 估算)') + (peak ? ' · 峰时 ×2' : ' · 谷时') }),
1200
- 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') }),
1201
1511
  h('div', { className: 'dcb-card' },
1202
- h('div', { className: 'dcb-card-label' }, '账户余额'),
1512
+ h('div', { className: 'dcb-card-label' }, tt('账户余额')),
1203
1513
  h('div', { className: balanceClass }, balanceValue),
1204
1514
  h('div', { className: 'dcb-card-hint' }, balanceHint))),
1205
- h('div', { className: 'dcb-section-title' }, '逐轮消耗趋势'),
1515
+ h('div', { className: 'dcb-section-title' }, tt('逐轮消耗趋势')),
1206
1516
  h(UsageChart, { series: series }),
1207
1517
  h('div', { className: 'dcb-foot' },
1208
- state.turnsError ? h('span', { className: 'dcb-foot-warn' }, state.turnsError + ' ') : null,
1209
- '金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考;数据源为本会话日志,刷新后自动恢复。')))
1518
+ state.turnsError ? h('span', { className: 'dcb-foot-warn' }, turnsErrorText() + ' ') : null,
1519
+ tt('金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考;数据源为本会话日志,刷新后自动恢复。'))))
1210
1520
  }
1211
1521
 
1212
1522
  /* ===================== settings section: history page ===================== */
@@ -1624,12 +1934,12 @@ window.__ModuleLoader__.load({
1624
1934
  ? h('div', { className: 'dcb-tooltip', style: { left: String((xAt(hover) / HIST_W) * 100) + '%' } },
1625
1935
  h('div', { className: 'dcb-tooltip-title' },
1626
1936
  hovered.label,
1627
- h('span', { className: 'dcb-tooltip-model' }, hovered.records + ' 轮')),
1628
- h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, '当日合计'), h('b', null, '≈¥' + fmtYuan(hovered.cost))),
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))),
1629
1939
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, 'Token'), h('b', null, fmtTokens(hovered.tokens))),
1630
- h('div', { className: 'dcb-tooltip-row' }, h('span', null, '峰时'), h('b', null, '≈¥' + fmtYuan(hovered.peakCost) + ' / ' + fmtTokens(hovered.peakTokens))),
1631
- h('div', { className: 'dcb-tooltip-row' }, h('span', null, '谷时'), h('b', null, '≈¥' + fmtYuan(hovered.valleyCost) + ' / ' + fmtTokens(hovered.valleyTokens))),
1632
- h('div', { className: 'dcb-tooltip-row' }, h('span', null, '模型'), h('b', null, hovered.models.length ? hovered.models.join(', ') : '—')))
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(', ') : '—')))
1633
1943
  : null
1634
1944
 
1635
1945
  return h('div', { className: 'dcb-chart-body' },
@@ -1779,69 +2089,70 @@ window.__ModuleLoader__.load({
1779
2089
  const nowPeak = isPeakAt(Date.now())
1780
2090
  const balance = st.balance
1781
2091
  const balanceLoading = st.balanceState === 'loading'
2092
+ const balanceErr = balanceErrorText()
1782
2093
  let balanceValue = '—'
1783
- let balanceHint = st.balanceError || '打开设置页时请求一次'
2094
+ let balanceHint = balanceErr || tt('打开设置页时请求一次')
1784
2095
  let balanceClass = 'dcb-card-value'
1785
2096
  if (balanceLoading && !balance) {
1786
- balanceValue = '读取中…'
2097
+ balanceValue = tt('读取中…')
1787
2098
  balanceHint = 'GET /user/balance'
1788
2099
  } else if (balance && balance.ok === false) {
1789
- balanceValue = '获取失败'
1790
- balanceHint = st.balanceError || '余额接口不可用'
2100
+ balanceValue = tt('获取失败')
2101
+ balanceHint = balanceErr || tt('余额接口不可用')
1791
2102
  balanceClass = 'dcb-card-value dcb-bad'
1792
2103
  } else if (balance && balance.available === false) {
1793
- balanceValue = '余额不足'
1794
- balanceHint = '请前往 DeepSeek 开放平台充值'
2104
+ balanceValue = tt('余额不足')
2105
+ balanceHint = tt('请前往 DeepSeek 开放平台充值')
1795
2106
  balanceClass = 'dcb-card-value dcb-bad'
1796
2107
  } else if (balance) {
1797
2108
  const symbol = balance.currency && balance.currency !== 'CNY' ? String(balance.currency) + ' ' : '¥'
1798
2109
  balanceValue = symbol + fmtYuan(balance.total)
1799
2110
  const parts = []
1800
- if (typeof balance.granted === 'number') parts.push('赠送 ¥' + fmtYuan(balance.granted))
1801
- if (typeof balance.toppedUp === 'number') parts.push('充值 ¥' + fmtYuan(balance.toppedUp))
1802
- balanceHint = parts.length ? parts.join(' · ') : 'DeepSeek 账户余额'
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 账户余额')
1803
2114
  }
1804
2115
 
1805
2116
  const index = buildWorkspaceIndex(wsSig)
1806
2117
  const points = stats ? stats.points : []
1807
2118
  const hasData = !!stats && stats.count > 0
1808
2119
  const rangeOptions = [
1809
- { id: 'today', label: '今天' },
1810
- { id: 'week', label: '近一周' },
1811
- { id: 'month', label: '近一月' },
1812
- { id: 'year', label: '近一年' },
2120
+ { id: 'today', label: tt('今天') },
2121
+ { id: 'week', label: tt('近一周') },
2122
+ { id: 'month', label: tt('近一月') },
2123
+ { id: 'year', label: tt('近一年') },
1813
2124
  ]
1814
2125
 
1815
2126
  return h('div', { className: 'dcb-set' },
1816
2127
  h('div', { className: 'dcb-set-head' },
1817
- h('div', { className: 'dcb-set-title' }, '消耗统计'),
1818
- h('div', { className: 'dcb-set-note' }, '本地估算,非官方账单')),
2128
+ h('div', { className: 'dcb-set-title' }, tt('消耗统计')),
2129
+ h('div', { className: 'dcb-set-note' }, tt('本地估算,非官方账单'))),
1819
2130
  // 顶部四卡:累计(全部历史,不受下面筛选影响)+ 当前时段 + 账户余额
1820
2131
  h('div', { className: 'dcb-cards dcb-cards-set' },
1821
2132
  h(Card, {
1822
- label: '累计 Token',
2133
+ label: tt('累计 Token'),
1823
2134
  value: allStats ? fmtTokens(allStats.totalTokens) : '—',
1824
2135
  hint: allStats
1825
- ? ('命中 ' + fmtTokens(allStats.totalHit) + ' · 未命中 ' + fmtTokens(allStats.totalMiss) + ' · 输出 ' + fmtTokens(allStats.totalOut))
1826
- : '全部历史 · 含所有工作区',
2136
+ ? tt('命中 {hit} · 未命中 {miss} · 输出 {out}', { hit: fmtTokens(allStats.totalHit), miss: fmtTokens(allStats.totalMiss), out: fmtTokens(allStats.totalOut) })
2137
+ : tt('全部历史 · 含所有工作区'),
1827
2138
  }),
1828
2139
  h(Card, {
1829
- label: '估算金额',
2140
+ label: tt('估算金额'),
1830
2141
  value: allStats ? '≈¥' + fmtYuan(allStats.totalCost) : '—',
1831
- hint: (accountPrice.known ? accountPrice.id : accountPrice.id + '(按 flash 估算)') + ' · 全部历史逐轮计价',
2142
+ hint: (accountPrice.known ? accountPrice.id : tt('{id}(按 flash 估算)', { id: accountPrice.id })) + tt(' · 全部历史逐轮计价'),
1832
2143
  }),
1833
2144
  h(Card, {
1834
- label: '当前时段',
1835
- value: nowPeak ? '高峰' : '空闲',
1836
- hint: '北京时间 工作日 09:00-12:00 / 14:00-18:00',
2145
+ label: tt('当前时段'),
2146
+ value: tt(nowPeak ? '高峰' : '空闲'),
2147
+ hint: tt('北京时间 工作日 09:00-12:00 / 14:00-18:00'),
1837
2148
  }),
1838
2149
  h('div', { className: 'dcb-card' },
1839
- h('div', { className: 'dcb-card-label' }, '账户余额'),
2150
+ h('div', { className: 'dcb-card-label' }, tt('账户余额')),
1840
2151
  h('div', { className: balanceClass }, balanceValue),
1841
2152
  h('div', { className: 'dcb-card-hint' }, balanceHint))),
1842
2153
  h('div', { className: 'dcb-set-controls' },
1843
2154
  h('div', { className: 'dcb-field' },
1844
- h('label', { className: 'dcb-field-label', htmlFor: 'dcb-range-select' }, '时间范围'),
2155
+ h('label', { className: 'dcb-field-label', htmlFor: 'dcb-range-select' }, tt('时间范围')),
1845
2156
  h('select', {
1846
2157
  id: 'dcb-range-select',
1847
2158
  className: 'dcb-select',
@@ -1853,20 +2164,20 @@ window.__ModuleLoader__.load({
1853
2164
  },
1854
2165
  },
1855
2166
  rangeOptions.map(function (opt) { return h('option', { key: opt.id, value: opt.id }, opt.label) }),
1856
- (calFrom || calTo) ? h('option', { key: 'custom', value: 'custom' }, '自定义区间') : null)),
2167
+ (calFrom || calTo) ? h('option', { key: 'custom', value: 'custom' }, tt('自定义区间')) : null)),
1857
2168
  h('div', { className: 'dcb-field' },
1858
- h('label', { className: 'dcb-field-label', htmlFor: 'dcb-workspace-select' }, '工作区'),
2169
+ h('label', { className: 'dcb-field-label', htmlFor: 'dcb-workspace-select' }, tt('工作区')),
1859
2170
  h('select', {
1860
2171
  id: 'dcb-workspace-select',
1861
2172
  className: 'dcb-select',
1862
2173
  value: wid,
1863
2174
  onChange: function (event) { setWid(event.target.value) },
1864
2175
  },
1865
- h('option', { key: 'all', value: '' }, '全部工作区'),
2176
+ h('option', { key: 'all', value: '' }, tt('全部工作区')),
1866
2177
  index.list.map(function (it) { return h('option', { key: it.id, value: it.id }, it.title) }),
1867
- h('option', { key: 'none', value: UNKNOWN_WORKSPACE }, '未归属工作区'))),
2178
+ h('option', { key: 'none', value: UNKNOWN_WORKSPACE }, tt('未归属工作区')))),
1868
2179
  h('div', { className: 'dcb-field' },
1869
- h('span', { className: 'dcb-field-label' }, '日历选择(覆盖时间范围)'),
2180
+ h('span', { className: 'dcb-field-label' }, tt('日历选择(覆盖时间范围)')),
1870
2181
  h('div', { className: 'dcb-range' },
1871
2182
  h('input', {
1872
2183
  type: 'date',
@@ -1882,36 +2193,36 @@ window.__ModuleLoader__.load({
1882
2193
  onChange: function (event) { setCalTo(event.target.value) },
1883
2194
  }),
1884
2195
  (calFrom || calTo)
1885
- ? h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setCalFrom(''); setCalTo('') } }, '清除')
2196
+ ? h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setCalFrom(''); setCalTo('') } }, tt('清除'))
1886
2197
  : null)),
1887
2198
  h('div', { className: 'dcb-field' },
1888
- h('span', { className: 'dcb-field-label' }, '数据'),
1889
- h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setReloadTick(function (v) { return v + 1 }) } }, '刷新'))),
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('刷新')))),
1890
2201
  h('div', { className: 'dcb-set-body' },
1891
2202
  h('div', { className: 'dcb-hist-legend' },
1892
- h('span', { className: 'dcb-tog dcb-tog-cost is-on' }, h('i', null), '估算金额(右轴)'),
2203
+ h('span', { className: 'dcb-tog dcb-tog-cost is-on' }, h('i', null), tt('估算金额(右轴)')),
1893
2204
  h('button', {
1894
2205
  type: 'button',
1895
2206
  className: showTokens ? 'dcb-tog is-on' : 'dcb-tog',
1896
2207
  onClick: function () { setShowTokens(function (v) { return v === true ? false : true }) },
1897
- }, h('i', null), 'Token(左轴,默认隐藏)')),
2208
+ }, h('i', null), tt('Token(左轴,默认隐藏)'))),
1898
2209
  hasData
1899
2210
  ? h(HistoryChart, { points: points, showTokens: showTokens })
1900
- : h('div', { className: 'dcb-hist-empty' }, '该范围内暂无消耗记录')),
2211
+ : h('div', { className: 'dcb-hist-empty' }, tt('该范围内暂无消耗记录'))),
1901
2212
  hasData
1902
2213
  ? h('div', { className: 'dcb-sum' },
1903
- h('span', null, '总消耗 ', h('b', null, '≈¥' + fmtYuan(stats.totalCost))),
1904
- h('span', null, '总 Token ', h('b', null, fmtTokens(stats.totalTokens))),
1905
- h('span', null, '记录 ', h('b', null, String(stats.count)), ' 条'),
1906
- h('span', null, '峰时 ', h('b', null, '≈¥' + fmtYuan(stats.peakCost))),
1907
- h('span', null, '谷时 ', h('b', null, '≈¥' + fmtYuan(stats.valleyCost))),
1908
- h('span', null, '粒度 ', h('b', null, stats.granularity === 'month' ? '按月' : (stats.granularity === 'hour' ? '按小时' : '按日'))))
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' ? '按小时' : '按日')))))
1909
2220
  : null,
1910
2221
  h('div', { className: 'dcb-hist-aside' },
1911
- '数据源:本机逐轮记录(localStorage ' + LS_PREFIX + '<sessionId>:<turn>' + ');按北京时间聚合'
1912
- + '(今天与单日区间逐小时,多日逐日,近一年逐月)'
1913
- + (stats && stats.via === 'worker' ? ';聚合已在 Web Worker 中完成' : '')
1914
- + '。金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考。'))
2222
+ tt('数据源:本机逐轮记录(localStorage {prefix}<sessionId>:<turn>);按北京时间聚合(今天与单日区间逐小时,多日逐日,近一年逐月){worker}。金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考。', {
2223
+ prefix: LS_PREFIX,
2224
+ worker: stats && stats.via === 'worker' ? tt(';聚合已在 Web Worker 中完成') : '',
2225
+ })))
1915
2226
  }
1916
2227
 
1917
2228
  /* ============ 与 dsh-plugin-personalization 联动:个性化页内新增「消耗弹窗」栏 ============ */
@@ -1943,6 +2254,11 @@ window.__ModuleLoader__.load({
1943
2254
  React.useEffect(function () {
1944
2255
  return store.subscribe(function () { setTick(function (v) { return v + 1 }) })
1945
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
+ }, [])
1946
2262
  const cfg = store.state.modalStyle
1947
2263
 
1948
2264
  function patch(next) {
@@ -1978,23 +2294,23 @@ window.__ModuleLoader__.load({
1978
2294
  const rows = []
1979
2295
  rows.push(h('div', { className: 'pp-field', key: 'mode' },
1980
2296
  h('div', { className: 'pp-fieldLabelWrap' },
1981
- h('span', { className: 'pp-fieldLabel' }, '背景'),
1982
- h('span', { className: 'pp-fieldHint' }, '点击对话里的 ⛽ 胶囊弹出的消耗面板')),
2297
+ h('span', { className: 'pp-fieldLabel' }, tt('背景')),
2298
+ h('span', { className: 'pp-fieldHint' }, tt('点击对话里的 ⛽ 胶囊弹出的消耗面板'))),
1983
2299
  h('div', { className: 'pp-fieldControl' },
1984
- h('div', { className: 'pp-modes', role: 'radiogroup', 'aria-label': '消耗弹窗背景' },
2300
+ h('div', { className: 'pp-modes', role: 'radiogroup', 'aria-label': tt('消耗弹窗背景') },
1985
2301
  MODAL_MODES.map(function (mode) {
1986
2302
  return h(ModalPill, {
1987
2303
  key: mode.value,
1988
2304
  active: cfg.mode === mode.value,
1989
2305
  onClick: function () { patch({ mode: mode.value }) },
1990
- }, mode.label)
2306
+ }, tt(mode.label))
1991
2307
  })))))
1992
2308
 
1993
2309
  if (cfg.mode === 'solid') {
1994
2310
  rows.push(h('div', { className: 'pp-field', key: 'solid' },
1995
2311
  h('div', { className: 'pp-fieldLabelWrap' },
1996
- h('span', { className: 'pp-fieldLabel' }, '纯色'),
1997
- h('span', { className: 'pp-fieldHint' }, '弹窗底色;文字颜色会按明暗自动切换')),
2312
+ h('span', { className: 'pp-fieldLabel' }, tt('纯色')),
2313
+ h('span', { className: 'pp-fieldHint' }, tt('弹窗底色;文字颜色会按明暗自动切换'))),
1998
2314
  h('div', { className: 'pp-fieldControl' },
1999
2315
  h('div', { className: 'pp-colorRow' },
2000
2316
  h('input', {
@@ -2021,17 +2337,17 @@ window.__ModuleLoader__.load({
2021
2337
  const previewSrc = localImage ? store.state.modalImageUrl : cfg.image
2022
2338
  const status = store.state.modalImageStatus
2023
2339
  let statusText = ''
2024
- if (status === 'saving') statusText = '正在保存…'
2025
- else if (status === 'saved') statusText = '已保存到本机'
2340
+ if (status === 'saving') statusText = tt('正在保存…')
2341
+ else if (status === 'saved') statusText = tt('已保存到本机')
2026
2342
  else if (typeof status === 'string' && status.indexOf('error:') === 0) statusText = status
2027
- else if (localImage && !store.state.modalImageUrl) statusText = '取回失败,请重新选择'
2343
+ else if (localImage && !store.state.modalImageUrl) statusText = tt('取回失败,请重新选择')
2028
2344
  const summary = localImage
2029
- ? ('当前:本地图片' + (statusText ? '(' + statusText + ')' : '(已保存到本机)'))
2030
- : (urlImage ? '当前:图片 URL' : '未设置图片(此时按默认背景显示)')
2345
+ ? (statusText ? tt('当前:本地图片({status})', { status: statusText }) : tt('当前:本地图片(已保存到本机)'))
2346
+ : (urlImage ? tt('当前:图片 URL') : tt('未设置图片(此时按默认背景显示)'))
2031
2347
  rows.push(h('div', { className: 'pp-field', key: 'image' },
2032
2348
  h('div', { className: 'pp-fieldLabelWrap' },
2033
- h('span', { className: 'pp-fieldLabel' }, '图片'),
2034
- h('span', { className: 'pp-fieldHint' }, summary + ';可选 URL 或本地图片,不限大小')),
2349
+ h('span', { className: 'pp-fieldLabel' }, tt('图片')),
2350
+ h('span', { className: 'pp-fieldHint' }, summary + tt(';可选 URL 或本地图片,不限大小'))),
2035
2351
  h('div', { className: 'pp-fieldControl' },
2036
2352
  h('div', { className: 'pp-urlRow' },
2037
2353
  // 原生 file 输入隐藏起来:它在保存后必然显示"未选择文件",容易误判成没存上。
@@ -2049,13 +2365,13 @@ window.__ModuleLoader__.load({
2049
2365
  const el = fileRef.current
2050
2366
  if (el && typeof el.click === 'function') el.click()
2051
2367
  },
2052
- }, hasImage ? '更换图片…' : '选择图片…'),
2368
+ }, hasImage ? tt('更换图片…') : tt('选择图片…')),
2053
2369
  h('button', {
2054
2370
  type: 'button',
2055
2371
  className: 'pp-blockReset',
2056
2372
  disabled: !hasImage,
2057
2373
  onClick: clearImage,
2058
- }, '清除'),
2374
+ }, tt('清除')),
2059
2375
  previewSrc
2060
2376
  ? h('img', { className: 'pp-thumb', src: previewSrc, alt: '' })
2061
2377
  : null,
@@ -2063,14 +2379,14 @@ window.__ModuleLoader__.load({
2063
2379
  type: 'text',
2064
2380
  className: 'dcb-date',
2065
2381
  style: { flex: '1 1 160px', minWidth: '0' },
2066
- placeholder: localImage ? '已使用本地图片,输入 URL 可替换' : 'https://…',
2382
+ placeholder: localImage ? tt('已使用本地图片,输入 URL 可替换') : 'https://…',
2067
2383
  value: localImage ? '' : cfg.image,
2068
2384
  onChange: function (event) { patch({ image: event.target.value, imageKey: '' }) },
2069
2385
  })))))
2070
2386
  rows.push(h('div', { className: 'pp-field', key: 'opacity' },
2071
2387
  h('div', { className: 'pp-fieldLabelWrap' },
2072
- h('span', { className: 'pp-fieldLabel' }, '不透明度'),
2073
- h('span', { className: 'pp-fieldHint' }, '背景图片自身的透明度(0% 完全不可见,100% 完整显示)')),
2388
+ h('span', { className: 'pp-fieldLabel' }, tt('不透明度')),
2389
+ h('span', { className: 'pp-fieldHint' }, tt('背景图片自身的透明度(0% 完全不可见,100% 完整显示)'))),
2074
2390
  h('div', { className: 'pp-fieldControl' },
2075
2391
  h('div', { className: 'pp-opacityRow' },
2076
2392
  h('input', {
@@ -2079,7 +2395,7 @@ window.__ModuleLoader__.load({
2079
2395
  min: '0',
2080
2396
  max: '100',
2081
2397
  step: '1',
2082
- 'aria-label': '不透明度',
2398
+ 'aria-label': tt('不透明度'),
2083
2399
  value: String(typeof cfg.opacity === 'number' ? cfg.opacity : DEFAULT_MODAL_STYLE.opacity),
2084
2400
  onChange: function (event) { patch({ opacity: Number(event.target.value) }) },
2085
2401
  }),
@@ -2091,9 +2407,9 @@ window.__ModuleLoader__.load({
2091
2407
  if (cfg.mode === 'image' || cfg.mode === 'solid') {
2092
2408
  rows.push(h('div', { className: 'pp-field', key: 'glass' },
2093
2409
  h('div', { className: 'pp-fieldLabelWrap' },
2094
- h('span', { className: 'pp-fieldLabel' }, '毛玻璃'),
2410
+ h('span', { className: 'pp-fieldLabel' }, tt('毛玻璃')),
2095
2411
  h('span', { className: 'pp-fieldHint' },
2096
- cfg.mode === 'image' ? '把背景图片磨砂化(对图片做真模糊)' : '底色半透明并模糊背后的页面')),
2412
+ tt(cfg.mode === 'image' ? '把背景图片磨砂化(对图片做真模糊)' : '底色半透明并模糊背后的页面'))),
2097
2413
  h('div', { className: 'pp-fieldControl' },
2098
2414
  h('button', {
2099
2415
  type: 'button',
@@ -2108,8 +2424,8 @@ window.__ModuleLoader__.load({
2108
2424
  if (cfg.mode === 'glass' || cfg.glass === true) {
2109
2425
  rows.push(h('div', { className: 'pp-field', key: 'blur' },
2110
2426
  h('div', { className: 'pp-fieldLabelWrap' },
2111
- h('span', { className: 'pp-fieldLabel' }, '毛玻璃强度'),
2112
- h('span', { className: 'pp-fieldHint' }, '模糊半径(0–48px),遮罩层按 45% 跟随')),
2427
+ h('span', { className: 'pp-fieldLabel' }, tt('毛玻璃强度')),
2428
+ h('span', { className: 'pp-fieldHint' }, tt('模糊半径(0–48px)'))),
2113
2429
  h('div', { className: 'pp-fieldControl' },
2114
2430
  h('div', { className: 'pp-opacityRow' },
2115
2431
  h('input', {
@@ -2126,8 +2442,8 @@ window.__ModuleLoader__.load({
2126
2442
  return h('section', { className: 'pp-block', 'data-section': 'dcb-consumption', 'data-dcb-build': BUILD_TAG },
2127
2443
  h('header', { className: 'pp-blockHead' },
2128
2444
  h('div', { className: 'pp-blockHeadMain' },
2129
- h('h3', { className: 'pp-blockTitle' }, '消耗弹窗'),
2130
- h('p', { className: 'pp-blockDesc' }, '消耗徽章弹层的背景样式;不影响对话区与侧边栏背景。' + BUILD_TAG)),
2445
+ h('h3', { className: 'pp-blockTitle' }, tt('消耗弹窗')),
2446
+ h('p', { className: 'pp-blockDesc' }, tt('消耗徽章弹层的背景样式;不影响对话区与侧边栏背景。{tag}', { tag: BUILD_TAG }))),
2131
2447
  h('button', {
2132
2448
  type: 'button',
2133
2449
  className: 'pp-blockReset',
@@ -2140,7 +2456,7 @@ window.__ModuleLoader__.load({
2140
2456
  } catch (e) { /* ignore */ }
2141
2457
  }
2142
2458
  },
2143
- }, '重置本栏')),
2459
+ }, tt('重置本栏'))),
2144
2460
  h('div', { className: 'pp-blockBody' }, rows))
2145
2461
  }
2146
2462
 
@@ -2173,7 +2489,7 @@ window.__ModuleLoader__.load({
2173
2489
  root = reactDom.createRoot(container)
2174
2490
  root.render(h(ConsumptionModalSettings, null))
2175
2491
  } else {
2176
- container.textContent = '消耗弹窗设置需要 react-dom,当前环境不可用。'
2492
+ container.textContent = tt('消耗弹窗设置需要 react-dom,当前环境不可用。')
2177
2493
  }
2178
2494
  return container
2179
2495
  }
@@ -2189,7 +2505,7 @@ window.__ModuleLoader__.load({
2189
2505
  if (node.parentNode !== page) page.appendChild(node)
2190
2506
  if (!announced) {
2191
2507
  announced = true
2192
- console.log('[consumption-badge] 检测到 dsh-plugin-personalization:已在其设置页注入「消耗弹窗」栏')
2508
+ console.log('[consumption-badge] dsh-plugin-personalization detected: injected the usage popup section into its settings page')
2193
2509
  }
2194
2510
  }
2195
2511
 
@@ -2259,43 +2575,43 @@ window.__ModuleLoader__.load({
2259
2575
  function copyCommand() {
2260
2576
  const clipboard = typeof navigator !== 'undefined' && navigator !== null ? navigator.clipboard : null
2261
2577
  if (clipboard === null || clipboard === undefined || typeof clipboard.writeText !== 'function') {
2262
- console.warn('[consumption-badge] 剪贴板不可用,请手动复制命令')
2578
+ console.warn('[consumption-badge] clipboard unavailable, copy the command manually')
2263
2579
  return
2264
2580
  }
2265
2581
  try {
2266
2582
  const pending = clipboard.writeText(UPDATE_COMMAND)
2267
2583
  if (pending && typeof pending.then === 'function') {
2268
2584
  pending.then(function () { setCopied(true) }, function () {
2269
- console.warn('[consumption-badge] 剪贴板写入失败')
2585
+ console.warn('[consumption-badge] clipboard write failed')
2270
2586
  })
2271
2587
  return
2272
2588
  }
2273
2589
  setCopied(true)
2274
2590
  } catch (e) {
2275
- console.warn('[consumption-badge] 剪贴板写入抛错')
2591
+ console.warn('[consumption-badge] clipboard write threw')
2276
2592
  }
2277
2593
  }
2278
2594
 
2279
2595
  return h('div', { className: 'dcb-notice', role: 'status', 'aria-live': 'polite' },
2280
2596
  h('div', { className: 'dcb-notice-head' },
2281
- h('span', { className: 'dcb-notice-title' }, '消耗徽章有新版本'),
2597
+ h('span', { className: 'dcb-notice-title' }, tt('消耗徽章有新版本')),
2282
2598
  h('span', { className: 'dcb-notice-versions' },
2283
- '当前 v' + String(info.currentVersion) + ' → 最新 v' + String(info.latestVersion)),
2599
+ tt('当前 v{current} → 最新 v{latest}', { current: String(info.currentVersion), latest: String(info.latestVersion) })),
2284
2600
  h('button', {
2285
2601
  type: 'button',
2286
2602
  className: 'dcb-notice-x',
2287
- title: '稍后提醒',
2603
+ title: tt('稍后提醒'),
2288
2604
  onClick: function () { store.dismissUpdate('later') },
2289
2605
  }, '✕')),
2290
2606
  h('div', { className: 'dcb-notice-cmd', title: UPDATE_COMMAND }, UPDATE_COMMAND),
2291
2607
  h('div', { className: 'dcb-notice-actions' },
2292
2608
  h('button', { type: 'button', className: 'dcb-btn dcb-btn-primary', onClick: copyCommand },
2293
- copied ? '已复制' : '复制更新命令'),
2294
- h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.dismissUpdate('ignore') } }, '忽略此版本'),
2295
- h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.dismissUpdate('later') } }, '稍后提醒'),
2296
- h('span', { className: 'dcb-notice-hint' }, '更新后需重启 dsh 进程')),
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 进程'))),
2297
2613
  h('div', { className: 'dcb-notice-foot' },
2298
- '版本来自 npm registry(24h 缓存);「忽略此版本」会记住,直到出现更高版本。'))
2614
+ tt('版本来自 npm registry(24h 缓存);「忽略此版本」会记住,直到出现更高版本。')))
2299
2615
  }
2300
2616
 
2301
2617
  /* styles + registration */
@@ -2429,9 +2745,15 @@ window.__ModuleLoader__.load({
2429
2745
 
2430
2746
  exports.apply = function apply(ctx) {
2431
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('消耗') }
2432
2754
  ctx.slots.inject('conversation.chat.assistant-actions', function () {
2433
2755
  const dispose = ctx.slots.register(
2434
- { name: 'conversation.chat.assistant-actions', id: 'consumption', order: 20, label: '消耗' },
2756
+ { name: 'conversation.chat.assistant-actions', id: 'consumption', order: 20, label: labelThunk },
2435
2757
  function (props) { return h(ConsumptionBadge, props) },
2436
2758
  )
2437
2759
  return function () { dispose() }
@@ -2446,7 +2768,7 @@ window.__ModuleLoader__.load({
2446
2768
  // 设置页整页:settings.section(不是 settings.plugin.item 的卡片位)。
2447
2769
  ctx.slots.inject('settings.section', function () {
2448
2770
  const dispose = ctx.slots.register(
2449
- { name: 'settings.section', id: 'balance-dsh-consumption', order: 26, label: '消耗' },
2771
+ { name: 'settings.section', id: 'balance-dsh-consumption', order: 26, label: labelThunk },
2450
2772
  function (props) { return h(ConsumptionSettings, props) },
2451
2773
  )
2452
2774
  return function () { dispose() }