dsh-consumption-badge 1.2.2 → 1.3.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
@@ -20,9 +20,63 @@ window.__ModuleLoader__.load({
20
20
 
21
21
  const API = '/api/consumption'
22
22
  const CSS_ID = 'dsh-consumption-badge'
23
- const PRICES = {
24
- 'deepseek-flash': { hit: 0.02, miss: 1, out: 4 },
25
- 'deepseek-v4-pro': { hit: 0.15, miss: 4.5, out: 13.5 },
23
+
24
+ /**
25
+ * 主流大模型价目表(**统一折算成人民币 / 1M tokens**,仅供估算)。
26
+ * - `hit` = 输入命中缓存、`miss` = 输入未命中、`out` = 输出;
27
+ * - 生成顺序:每家厂商**当前版本在前、legacy 在后**;`legacy: true` 只用于承接历史日志里的旧 id;
28
+ * - 非人民币原生价一律按官方 **USD 价 × 7.1** 折算(表里已经是折好的人民币,改价直接改这张表);
29
+ * - `peakPricing: true` 的模型才有 DeepSeek 那种「峰时 ×2」(其它厂商不区分峰谷);
30
+ * - `match` 是大小写无关的子串匹配,并且**最长命中优先**,所以 `gpt-5.6-terra-pro`、
31
+ * `claude-sonnet-4-5`、`deepseek-v4.1-flash-1017` 这类带后缀/日期的 id 都能落到正确家族。
32
+ *
33
+ * 当前版本(2026-11 口径):DeepSeek V4.1 Flash / V4 Pro(0813) / V3.2、GPT-5.6 Sol/Terra/Luna、
34
+ * Claude Opus·Sonnet·Haiku 5.5、Gemini 3.8 Flash / 3.1 Pro / 3 Flash、Qwen3.8 Max/Flash、
35
+ * GLM-5.3 / 5.1、Kimi K3.1 Pro / K3、Grok 4.7、Llama 4 Maverick、Mistral Large 4。
36
+ */
37
+ const MODELS = [
38
+ // 官方:MODEL deepseek-flash → MODEL VERSION DeepSeek-V4.1-Flash-1017(旧 id deepseek-v4-flash / -vision-exp 仍可调用并按 Flash 计费)
39
+ { id: 'deepseek-flash', provider: 'DeepSeek', label: 'DeepSeek V4.1 Flash', version: 'DeepSeek-V4.1-Flash-1017', legacy: false, match: ['deepseek-v4.1-flash', 'deepseek-v4-flash', 'deepseek-flash', 'deepseek-chat'], price: { hit: 0.02, miss: 1, out: 4 }, peakPricing: true, color: '#4d6bfe' },
40
+ // 官方:MODEL deepseek-v4-pro → MODEL VERSION DeepSeek-V4-Pro-0813
41
+ { id: 'deepseek-v4-pro', provider: 'DeepSeek', label: 'DeepSeek V4 Pro', version: 'DeepSeek-V4-Pro-0813', legacy: false, match: ['deepseek-v4-pro-0813', 'deepseek-v4-pro-0423', 'deepseek-v4-pro', 'deepseek-reasoner', 'deepseek-r1'], price: { hit: 0.15, miss: 4.5, out: 13.5 }, peakPricing: true, color: '#8b5cf6' },
42
+ // 老一代:只在历史日志里出现,按第三方零售价折算,仅作兜底估算
43
+ { id: 'deepseek-v3.2', provider: 'DeepSeek', label: 'DeepSeek V3.2 (legacy)', version: 'DeepSeek-V3.2', legacy: true, match: ['deepseek-v3.2', 'deepseek-v3'], price: { hit: 0.923, miss: 1.846, out: 2.698 }, peakPricing: true, color: '#818cf8' },
44
+ { id: 'gpt-5.6-sol', provider: 'OpenAI', label: 'GPT-5.6 Sol', legacy: false, match: ['gpt-5.6-sol'], price: { hit: 2.84, miss: 28.4, out: 142 }, peakPricing: false, color: '#0f766e' },
45
+ { id: 'gpt-5.6-terra', provider: 'OpenAI', label: 'GPT-5.6 Terra', legacy: false, match: ['gpt-5.6-terra'], price: { hit: 1.42, miss: 14.2, out: 85.2 }, peakPricing: false, color: '#10a37f' },
46
+ { id: 'gpt-5.6-luna', provider: 'OpenAI', label: 'GPT-5.6 Luna', legacy: false, match: ['gpt-5.6-luna'], price: { hit: 0.142, miss: 1.42, out: 8.52 }, peakPricing: false, color: '#34d399' },
47
+ { id: 'o3', provider: 'OpenAI', label: 'o3 / o3-pro', legacy: false, match: ['o3', 'o1', 'o4-mini'], price: { hit: 3.55, miss: 14.2, out: 56.8 }, peakPricing: false, color: '#059669' },
48
+ { id: 'gpt-4o', provider: 'OpenAI', label: 'GPT-4o (legacy)', legacy: true, match: ['gpt-4o'], price: { hit: 8.88, miss: 17.75, out: 71 }, peakPricing: false, color: '#22c55e' },
49
+ { id: 'gpt-4o-mini', provider: 'OpenAI', label: 'GPT-4o mini (legacy)', legacy: true, match: ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4-mini'], price: { hit: 0.53, miss: 1.07, out: 4.26 }, peakPricing: false, color: '#86efac' },
50
+ { id: 'claude-opus-5.5', provider: 'Anthropic', label: 'Claude Opus 5.5', legacy: false, match: ['claude-opus-5', 'claude-opus'], price: { hit: 1.42, miss: 28.4, out: 142 }, peakPricing: false, color: '#c2410c' },
51
+ { id: 'claude-sonnet-5.5', provider: 'Anthropic', label: 'Claude Sonnet 5.5', legacy: false, match: ['claude-sonnet-5', 'claude-sonnet'], price: { hit: 0.71, miss: 14.2, out: 71 }, peakPricing: false, color: '#d97757' },
52
+ { id: 'claude-haiku-5.5', provider: 'Anthropic', label: 'Claude Haiku 5.5', legacy: false, match: ['claude-haiku-5', 'claude-haiku'], price: { hit: 0.071, miss: 0.71, out: 3.55 }, peakPricing: false, color: '#fb923c' },
53
+ { id: 'claude-sonnet-4.6', provider: 'Anthropic', label: 'Claude Sonnet 4.6 (legacy)', legacy: true, match: ['claude-sonnet-4'], price: { hit: 2.13, miss: 21.3, out: 106.5 }, peakPricing: false, color: '#f59e0b' },
54
+ { id: 'claude-opus-4', provider: 'Anthropic', label: 'Claude Opus 4.x (legacy)', legacy: true, match: ['claude-opus-4', 'opus-4', 'opus-3'], price: { hit: 3.55, miss: 35.5, out: 177.5 }, peakPricing: false, color: '#b45309' },
55
+ { id: 'gemini-3.8-flash', provider: 'Google', label: 'Gemini 3.8 Flash', legacy: false, match: ['gemini-3.8-flash', 'gemini-3.7-flash', 'gemini-3.6-flash'], price: { hit: 0.5858, miss: 5.8575, out: 29.2875 }, peakPricing: false, color: '#0ea5e9' },
56
+ { id: 'gemini-3.1-pro', provider: 'Google', label: 'Gemini 3.1 Pro', legacy: false, match: ['gemini-3.1-pro', 'gemini-3-pro', 'gemini-pro'], price: { hit: 1.42, miss: 14.2, out: 85.2 }, peakPricing: false, color: '#4285f4' },
57
+ { id: 'gemini-3-flash', provider: 'Google', label: 'Gemini 3 Flash', legacy: false, match: ['gemini-3-flash', 'gemini-flash'], price: { hit: 0.355, miss: 3.55, out: 21.3 }, peakPricing: false, color: '#38bdf8' },
58
+ { id: 'gemini-2.5-flash', provider: 'Google', label: 'Gemini 2.5 Flash (legacy)', legacy: true, match: ['gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5'], price: { hit: 0.213, miss: 2.13, out: 17.75 }, peakPricing: false, color: '#7dd3fc' },
59
+ { id: 'qwen3.8-max', provider: 'Alibaba', label: 'Qwen3.8 Max', legacy: false, match: ['qwen3.8-max', 'qwen3-max', 'qwen-max', 'qwen-max-latest'], price: { hit: 1.775, miss: 14.2, out: 42.6 }, peakPricing: false, color: '#615ced' },
60
+ { id: 'qwen3.8-flash', provider: 'Alibaba', label: 'Qwen3.8 Flash', legacy: false, match: ['qwen3.8-flash', 'qwen3.7-flash', 'qwen-flash'], price: { hit: 0.1136, miss: 1.065, out: 3.337 }, peakPricing: false, color: '#a78bfa' },
61
+ { id: 'qwen3.7-plus', provider: 'Alibaba', label: 'Qwen3.7 Plus (legacy)', legacy: true, match: ['qwen3.7-plus', 'qwen-plus', 'qwen-turbo', 'qwen3'], price: { hit: 0.4544, miss: 2.272, out: 9.088 }, peakPricing: false, color: '#c4b5fd' },
62
+ { id: 'glm-5.3', provider: 'Zhipu', label: 'GLM-5.3', legacy: false, match: ['glm-5.3', 'glm-5'], price: { hit: 0.5396, miss: 0.568, out: 25.56 }, peakPricing: false, color: '#0d9488' },
63
+ { id: 'glm-5.1', provider: 'Zhipu', label: 'GLM-5.1', legacy: false, match: ['glm-5.1'], price: { hit: 1.846, miss: 9.94, out: 31.24 }, peakPricing: false, color: '#2dd4bf' },
64
+ { id: 'glm-4.6', provider: 'Zhipu', label: 'GLM-4.6 (legacy)', legacy: true, match: ['glm-4.7', 'glm-4.6', 'glm-4-plus', 'glm-4.5', 'glm-4-air'], price: { hit: 0.71, miss: 3.55, out: 14.2 }, peakPricing: false, color: '#99f6e4' },
65
+ { id: 'kimi-k3.1-pro', provider: 'Moonshot', label: 'Kimi K3.1 Pro', legacy: false, match: ['kimi-k3.1-pro'], price: { hit: 1.42, miss: 14.2, out: 71 }, peakPricing: false, color: '#db2777' },
66
+ { id: 'kimi-k3', provider: 'Moonshot', label: 'Kimi K3', legacy: false, match: ['kimi-k3', 'kimi'], price: { hit: 1.2922, miss: 1.3632, out: 106.5 }, peakPricing: false, color: '#f472b6' },
67
+ { id: 'kimi-k2.5', provider: 'Moonshot', label: 'Kimi K2.5 (legacy)', legacy: true, match: ['kimi-k2', 'moonshot-v1'], price: { hit: 1.065, miss: 3.55, out: 17.75 }, peakPricing: false, color: '#fbcfe8' },
68
+ { id: 'grok-4.7', provider: 'xAI', label: 'Grok 4.7', legacy: false, match: ['grok-4.7', 'grok-4.6', 'grok-4.5', 'grok-4'], price: { hit: 3.55, miss: 14.2, out: 42.6 }, peakPricing: false, color: '#64748b' },
69
+ { id: 'grok-4.3', provider: 'xAI', label: 'Grok 4.3 (legacy)', legacy: true, match: ['grok-4.3', 'grok-4.20', 'grok-3', 'grok-2'], price: { hit: 1.42, miss: 8.875, out: 17.75 }, peakPricing: false, color: '#94a3b8' },
70
+ { id: 'llama-4-maverick', provider: 'Meta', label: 'Llama 4 Maverick', legacy: false, match: ['llama-4', 'llama-3', 'llama3'], price: { hit: 0.355, miss: 1.3312, out: 4.6328 }, peakPricing: false, color: '#06b6d4' },
71
+ { id: 'mistral-large-4', provider: 'Mistral', label: 'Mistral Large 4', legacy: false, match: ['mistral-large-4', 'mistral-large', 'mistral-medium', 'mistral'], price: { hit: 0.497, miss: 4.97, out: 14.91 }, peakPricing: false, color: '#f97316' },
72
+ ]
73
+ /** 兼容旧版本:按 id 建索引;`PRICES[id]` 额外带 `peak` 标记,Worker 里自包含的聚合函数直接读它。 */
74
+ const PRICES = {}
75
+ const MODEL_BY_ID = {}
76
+ for (let mi = 0; mi < MODELS.length; mi += 1) {
77
+ const entry = MODELS[mi]
78
+ PRICES[entry.id] = { hit: entry.price.hit, miss: entry.price.miss, out: entry.price.out, peak: entry.peakPricing === true }
79
+ MODEL_BY_ID[entry.id] = entry
26
80
  }
27
81
  const DEFAULT_MODEL = 'deepseek-flash'
28
82
  const PEAK_MULTIPLIER = 2
@@ -65,18 +119,82 @@ window.__ModuleLoader__.load({
65
119
  }
66
120
  return false
67
121
  }
68
- // Unknown model ids still price as flash (or pro for pro-like ids) and are flagged.
69
- function priceFor(model) {
70
- const id = typeof model === 'string' ? model : ''
71
- if (PRICES[id]) return { id: id, table: PRICES[id], known: true }
72
- if (id.toLowerCase().indexOf('pro') >= 0) return { id: id, table: PRICES['deepseek-v4-pro'], known: false }
73
- return { id: id || DEFAULT_MODEL, table: PRICES[DEFAULT_MODEL], known: false }
122
+ /**
123
+ * 把任意模型 id 归到价目表里的一个家族。
124
+ * 先精确命中 id,再按 `match` 子串大小写无关匹配;都不中就用兜底模型(并标 `known:false`,
125
+ * UI 会把金额标成估算)。`pro`/`large`/`opus` 这类更强的名字在没有精确信息时会落到更贵的一档。
126
+ */
127
+ function resolveModel(model, fallbackId) {
128
+ const raw = typeof model === 'string' ? model.trim() : ''
129
+ const lower = raw.toLowerCase()
130
+ const fallback = typeof fallbackId === 'string' && MODEL_BY_ID[fallbackId] ? MODEL_BY_ID[fallbackId] : MODEL_BY_ID[DEFAULT_MODEL]
131
+ if (raw !== '') {
132
+ if (MODEL_BY_ID[raw]) return { id: raw, entry: MODEL_BY_ID[raw], known: true, raw: raw }
133
+ // 取**最长命中**的家族:`gpt-4o-mini-2026` 必须落到 gpt-4o-mini,而不是被 gpt-4o 抢走。
134
+ let best = null
135
+ let bestLen = 0
136
+ for (let i = 0; i < MODELS.length; i += 1) {
137
+ const entry = MODELS[i]
138
+ for (let k = 0; k < entry.match.length; k += 1) {
139
+ const needle = entry.match[k]
140
+ if (needle.length > bestLen && lower.indexOf(needle) >= 0) {
141
+ best = entry
142
+ bestLen = needle.length
143
+ }
144
+ }
145
+ }
146
+ if (best !== null) return { id: best.id, entry: best, known: true, raw: raw }
147
+ if (lower.indexOf('pro') >= 0 || lower.indexOf('opus') >= 0 || lower.indexOf('max') >= 0) {
148
+ return { id: 'deepseek-v4-pro', entry: MODEL_BY_ID['deepseek-v4-pro'], known: false, raw: raw }
149
+ }
150
+ }
151
+ return { id: fallback.id, entry: fallback, known: raw === '', raw: raw }
74
152
  }
153
+ /**
154
+ * 模型的可解释文案:`DeepSeek V4.1 Flash(DeepSeek-V4.1-Flash-1017 · 日志 id deepseek-flash)`
155
+ * 先显示价目表族名,再显示官方 MODEL VERSION 和日志里真实的 id,避免「明明用 V4.1 却显示 V3」这种误会。
156
+ */
157
+ function modelDetail(model) {
158
+ const info = priceFor(model)
159
+ const bits = []
160
+ if (info.version) bits.push(info.version)
161
+ const raw = String(info.raw || '')
162
+ if (raw && raw !== info.id) bits.push(tt('日志 id {id}', { id: raw }))
163
+ else if (raw) bits.push(tt('日志 id {id}', { id: raw }))
164
+ return bits.length > 0 ? info.label + '(' + bits.join(' · ') + ')' : info.label
165
+ }
166
+ function modelColor(model) {
167
+ return resolveModel(model).entry.color
168
+ }
169
+ function modelLabel(model) {
170
+ const r = resolveModel(model)
171
+ return r.known || r.raw === '' ? r.entry.label : r.raw
172
+ }
173
+ /** 计价入口:返回价目表条目(含 `peak` 标记)+ 该模型的展示信息。 */
174
+ function priceFor(model, fallbackId) {
175
+ const r = resolveModel(model, fallbackId)
176
+ return {
177
+ id: r.id,
178
+ table: PRICES[r.id],
179
+ known: r.known,
180
+ peakPricing: r.entry.peakPricing === true,
181
+ label: r.entry.label,
182
+ version: r.entry.version || '',
183
+ provider: r.entry.provider,
184
+ color: r.entry.color,
185
+ raw: r.raw,
186
+ }
187
+ }
188
+ /** 按模型计价:只有标了 `peak` 的模型(DeepSeek)才乘峰时系数,其它厂商不区分峰谷。 */
75
189
  function costOf(bucket, table, peak) {
76
- const k = peak ? PEAK_MULTIPLIER : 1
190
+ const k = peak && table && table.peak === true ? PEAK_MULTIPLIER : 1
77
191
  const cost = (bucket.hit / 1e6) * table.hit + (bucket.miss / 1e6) * table.miss + (bucket.out / 1e6) * table.out
78
192
  return cost * k
79
193
  }
194
+ /** 按「记录自己的模型」计价(多模型混跑时逐条算,不混价)。 */
195
+ function costOfModel(bucket, model, peak, fallbackId) {
196
+ return costOf(bucket, priceFor(model, fallbackId).table, peak)
197
+ }
80
198
  function fmtTokens(v) {
81
199
  const n = Math.max(0, Math.round(Number(v) || 0))
82
200
  if (n < 1000) return String(n)
@@ -95,6 +213,177 @@ window.__ModuleLoader__.load({
95
213
  if (n < 100) return n.toFixed(2)
96
214
  return n.toFixed(1)
97
215
  }
216
+
217
+ /* ===================== 显示币种(报价表始终是人民币) ===================== */
218
+ const LS_CURRENCY = 'dsh-consumption:currency'
219
+ const RATES_TTL_MS = 6 * 60 * 60 * 1000
220
+ /** 可选币种 + 符号;顺序与 Host 的 CURRENCY_CODES 一致(CNY 永远是第一项)。 */
221
+ const CURRENCIES = [
222
+ { code: 'CNY', symbol: '¥' },
223
+ { code: 'USD', symbol: '$' },
224
+ { code: 'EUR', symbol: '€' },
225
+ { code: 'GBP', symbol: '£' },
226
+ { code: 'JPY', symbol: 'JP¥' },
227
+ { code: 'HKD', symbol: 'HK$' },
228
+ { code: 'TWD', symbol: 'NT$' },
229
+ { code: 'KRW', symbol: '₩' },
230
+ { code: 'SGD', symbol: 'S$' },
231
+ { code: 'AUD', symbol: 'A$' },
232
+ { code: 'CAD', symbol: 'C$' },
233
+ { code: 'CHF', symbol: 'CHF ' },
234
+ { code: 'INR', symbol: '₹' },
235
+ { code: 'RUB', symbol: '₽' },
236
+ { code: 'BRL', symbol: 'R$' },
237
+ { code: 'THB', symbol: '฿' },
238
+ { code: 'MXN', symbol: 'MX$' },
239
+ ]
240
+
241
+ function currencyMeta(code) {
242
+ for (let i = 0; i < CURRENCIES.length; i += 1) {
243
+ if (CURRENCIES[i].code === code) return CURRENCIES[i]
244
+ }
245
+ return null
246
+ }
247
+ function symbolOf(code) {
248
+ const meta = currencyMeta(code)
249
+ return meta !== null ? meta.symbol : String(code) + ' '
250
+ }
251
+ /** 币种全名:优先用 Intl.DisplayNames(跟随系统语言),取不到就用代码。 */
252
+ function currencyName(code) {
253
+ try {
254
+ if (typeof Intl === 'object' && typeof Intl.DisplayNames === 'function') {
255
+ const names = new Intl.DisplayNames([localeTag()], { type: 'currency' })
256
+ const name = names.of(code)
257
+ if (typeof name === 'string' && name && name !== code) return name
258
+ }
259
+ } catch (e) { /* 回落代码 */ }
260
+ return code
261
+ }
262
+ /** 当前偏好币种(读不到/非法一律人民币)。 */
263
+ function readCurrency() {
264
+ const raw = lsGet(LS_CURRENCY)
265
+ return currencyMeta(raw) !== null ? raw : 'CNY'
266
+ }
267
+ const LS_MODEL_FILTER = 'dsh-consumption:model-filter'
268
+ /** 当前「模型」下拉的选择('' = 全部模型)。非法值一律当全部。 */
269
+ function readModelFilter() {
270
+ const raw = lsGet(LS_MODEL_FILTER)
271
+ return typeof raw === 'string' && MODEL_BY_ID[raw] ? raw : ''
272
+ }
273
+ /**
274
+ * 把人民币金额换算成当前偏好币种。
275
+ * @returns {{value: number, code: string, rate: number, converted: boolean}}
276
+ * `converted: false` 表示汇率还没拿到或该币种没有汇率 → 保持人民币。
277
+ */
278
+ function convertCny(value) {
279
+ const n = Number(value) || 0
280
+ // `store` 是模块级常量,渲染期一定已初始化;仍然兜一层,避免任何早期调用撞 TDZ。
281
+ let st = null
282
+ try {
283
+ st = store.state
284
+ } catch (e) {
285
+ st = null
286
+ }
287
+ const code = st !== null && typeof st.currency === 'string' ? st.currency : 'CNY'
288
+ if (code === 'CNY') return { value: n, code: 'CNY', rate: 1, converted: false }
289
+ const payload = st !== null ? st.rates : null
290
+ const table = payload && payload.rates && typeof payload.rates === 'object' ? payload.rates : null
291
+ const rate = table !== null && typeof table[code] === 'number' ? table[code] : null
292
+ if (rate === null || !(rate > 0)) return { value: n, code: 'CNY', rate: 1, converted: false }
293
+ return { value: n * rate, code: code, rate: rate, converted: true }
294
+ }
295
+ /** 金额(按偏好币种换算 + 前缀符号)。 */
296
+ function money(value) {
297
+ const r = convertCny(value)
298
+ return symbolOf(r.code) + fmtYuan(r.value)
299
+ }
300
+ /** 估算金额前缀 `≈`。 */
301
+ function moneyApprox(value) {
302
+ return '≈' + money(value)
303
+ }
304
+ /** 账户余额:原生币种是 CNY 就走换算,其它币种按原样显示。 */
305
+ function moneyBalance(amount, nativeCode) {
306
+ const src = typeof nativeCode === 'string' && nativeCode ? nativeCode : 'CNY'
307
+ if (src !== 'CNY') return symbolOf(src) + fmtYuan(Number(amount) || 0)
308
+ return money(amount)
309
+ }
310
+ /**
311
+ * 浏览器直连的汇率源(两个都带 `access-control-allow-origin: *`)。
312
+ * 用来兜住「Host 半边还没重启、/rates 404」以及 Host 侧出网被挡的情况。
313
+ */
314
+ const RATE_ENDPOINTS = [
315
+ { url: 'https://open.er-api.com/v6/latest/CNY', source: 'open.er-api.com' },
316
+ { url: 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/cny.json', source: 'currency-api' },
317
+ ]
318
+
319
+ /** 把两种公开汇率的响应归一成 `{ok, base, rates, source, fetchedAt}`。 */
320
+ function parseRatesPayload(json, source) {
321
+ if (!json || typeof json !== 'object') return null
322
+ const raw = json.rates && typeof json.rates === 'object' ? json.rates : (json.cny && typeof json.cny === 'object' ? json.cny : null)
323
+ if (raw === null) return null
324
+ const rates = {}
325
+ let found = 0
326
+ for (let i = 0; i < CURRENCIES.length; i += 1) {
327
+ const code = CURRENCIES[i].code
328
+ const v = typeof raw[code] === 'number' ? raw[code] : raw[code.toLowerCase()]
329
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
330
+ rates[code] = v
331
+ found += 1
332
+ }
333
+ }
334
+ if (found === 0) return null
335
+ const stamp = typeof json.time_last_update_unix === 'number'
336
+ ? json.time_last_update_unix * 1000
337
+ : (typeof json.date === 'string' ? Date.parse(json.date) : 0)
338
+ return {
339
+ ok: true,
340
+ base: 'CNY',
341
+ rates: rates,
342
+ source: source,
343
+ fetchedAt: Number.isFinite(stamp) && stamp > 0 ? stamp : 0,
344
+ stale: false,
345
+ via: 'browser',
346
+ }
347
+ }
348
+
349
+ /** 依次尝试浏览器直连的汇率源;全失败返回 null。 */
350
+ function fetchRatesDirect() {
351
+ if (typeof fetch !== 'function') return Promise.resolve(null)
352
+ let chain = Promise.resolve(null)
353
+ for (let i = 0; i < RATE_ENDPOINTS.length; i += 1) {
354
+ const endpoint = RATE_ENDPOINTS[i]
355
+ chain = chain.then(function (found) {
356
+ if (found !== null) return found
357
+ return fetch(endpoint.url, { headers: { accept: 'application/json' } })
358
+ .then(function (res) {
359
+ if (!res || res.ok === false) return null
360
+ return res.json()
361
+ })
362
+ .then(function (json) { return parseRatesPayload(json, endpoint.source) })
363
+ .catch(function () { return null })
364
+ })
365
+ }
366
+ return chain
367
+ }
368
+ /** 把模型列表渲染成按厂商分组的 <optgroup>(设置页与弹层共用)。 */
369
+ function modelOptionNodes(list) {
370
+ const order = []
371
+ const byProvider = {}
372
+ for (let i = 0; i < list.length; i += 1) {
373
+ const m = list[i]
374
+ if (!byProvider[m.provider]) {
375
+ byProvider[m.provider] = []
376
+ order.push(m.provider)
377
+ }
378
+ byProvider[m.provider].push(m)
379
+ }
380
+ return order.map(function (provider) {
381
+ return h('optgroup', { key: provider, label: provider },
382
+ byProvider[provider].map(function (m) {
383
+ return h('option', { key: m.id, value: m.id }, m.label)
384
+ }))
385
+ })
386
+ }
98
387
  function bucketOfTurns(turns) {
99
388
  const out = { hit: 0, miss: 0, out: 0 }
100
389
  for (let i = 0; i < turns.length; i += 1) {
@@ -138,6 +427,25 @@ window.__ModuleLoader__.load({
138
427
  '账户余额': 'Account balance',
139
428
  '逐轮消耗趋势': 'Per-turn usage trend',
140
429
  '命中 {hit} · 未命中 {miss} · 输出 {out}': 'hit {hit} · miss {miss} · output {out}',
430
+ '本轮消耗(点击查看本会话明细)': 'This turn’s usage (click for session details)',
431
+ '货币': 'Currency',
432
+ '模型': 'Model',
433
+ '全部模型': 'All models',
434
+ '按模型筛选;同时作为计价兜底': 'Filter by model; also the pricing fallback',
435
+ '仅 {model}': 'only {model}',
436
+ '日志 id {id}': 'log id {id}',
437
+ '{count} 个模型各自计价': '{count} models priced separately',
438
+ '按 {model} 约还可跑 {tokens} tokens': 'about {tokens} tokens left at {model} prices',
439
+ '金额按各模型官方价估算(DeepSeek 区分峰谷 ×1/×2,其它厂商按 USD 价 ×7.1 折人民币),仅供参考;数据源为本会话日志,刷新后自动恢复。': 'Costs are estimated from each model’s list price (DeepSeek applies peak/off-peak ×1/×2; other vendors are converted from USD at 7.1), for reference only; the source is this session log, restored automatically after a refresh.',
440
+ '金额按人民币报价折算显示': 'Amounts are priced in CNY and converted for display',
441
+ '当前以人民币显示(可切换成其它币种)': 'Showing CNY — pick another currency to convert',
442
+ '1 CNY = {rate} {code}': '1 CNY = {rate} {code}',
443
+ '汇率来源 {source}': 'rates from {source}',
444
+ '内置兜底汇率': 'built-in fallback',
445
+ '联网失败时使用内置汇率': 'built-in rates used because the lookup failed',
446
+ '正在获取汇率…': 'Fetching exchange rates…',
447
+ '汇率获取失败,暂时按人民币显示:{msg}': 'Could not fetch exchange rates, showing CNY: {msg}',
448
+ '余额 = {amount}': 'Balance = {amount}',
141
449
  '{id}(按 flash 估算)': '{id} (priced as flash)',
142
450
  ' · 峰时 ×2': ' · peak ×2',
143
451
  ' · 谷时': ' · off-peak ×1',
@@ -168,8 +476,8 @@ window.__ModuleLoader__.load({
168
476
  '余额接口不可用': 'Balance API unavailable',
169
477
  '余额不足': 'Insufficient balance',
170
478
  '请前往 DeepSeek 开放平台充值': 'Top up at the DeepSeek open platform',
171
- '赠送 ¥{v}': 'granted ¥{v}',
172
- '充值 ¥{v}': 'topped up ¥{v}',
479
+ '赠送 {amount}': 'granted {amount}',
480
+ '充值 {amount}': 'topped up {amount}',
173
481
  'DeepSeek 账户余额': 'DeepSeek account balance',
174
482
  '空响应': 'Empty response',
175
483
  '余额获取失败': 'Balance request failed',
@@ -282,6 +590,15 @@ window.__ModuleLoader__.load({
282
590
  * Bind the shell's locale service when the composition provides one.
283
591
  * Optional dependency on purpose: without the service every string falls
284
592
  * back to its Chinese key, so the plugin still works standalone.
593
+ *
594
+ * The dictionary namespace is `consumption-badge`. A **hot reload inside an
595
+ * open page** mounts a second fiber while the previous one's registration may
596
+ * still be alive (the shell re-imports the bundle); the locale service rejects
597
+ * a duplicate `(ns, locale)` pair, and the old dictionary would then stay bound
598
+ * forever — new keys added in a later build would render as their raw Chinese
599
+ * key. So: on a duplicate we register under a suffixed namespace instead of
600
+ * giving up, and we hand the disposer to `ctx.effect` so a clean unload frees
601
+ * the plain namespace again.
285
602
  * @param ctx client cordis context.
286
603
  */
287
604
  function attachLocale(ctx) {
@@ -296,15 +613,29 @@ window.__ModuleLoader__.load({
296
613
  service = null
297
614
  }
298
615
  if (service === null || typeof service.register !== 'function' || typeof service.bind !== 'function') return
616
+ // Only the `en` side is registered: the key already is the `zh` copy, and
617
+ // the locale service resolves a miss to the key itself.
618
+ let ns = LOCALE_NS
619
+ let dispose = null
299
620
  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)
621
+ dispose = service.register(ns, 'en', DICT_EN)
303
622
  } catch (e) {
304
- // Already registered (hot re-mount of the same fiber) — binding is enough.
623
+ ns = LOCALE_NS + '#' + BUILD_TAG + '-' + Math.random().toString(36).slice(2, 7)
624
+ try {
625
+ dispose = service.register(ns, 'en', DICT_EN)
626
+ console.warn('[consumption-badge] previous dictionary still registered; bound a private locale namespace ' + ns)
627
+ } catch (e2) {
628
+ console.warn('[consumption-badge] locale dictionary registration failed: ' + msgOf(e2))
629
+ return
630
+ }
305
631
  }
306
- boundTranslate = service.bind(LOCALE_NS)
632
+ boundTranslate = service.bind(ns)
307
633
  localeService = service
634
+ if (typeof dispose === 'function' && ctx && typeof ctx.effect === 'function') {
635
+ try {
636
+ ctx.effect(function () { return dispose })
637
+ } catch (e) { /* effects unavailable (tests): the dictionary simply stays */ }
638
+ }
308
639
  }
309
640
 
310
641
  /**
@@ -340,6 +671,20 @@ window.__ModuleLoader__.load({
340
671
  }
341
672
  }
342
673
 
674
+ /** Active locale id ('zh' when the service is absent). */
675
+ function activeLocaleId() {
676
+ try {
677
+ const snapshot = localeService !== null && typeof localeService.getLocale === 'function' ? localeService.getLocale() : null
678
+ if (snapshot && typeof snapshot.active === 'string' && snapshot.active) return snapshot.active
679
+ } catch (e) { /* fall through */ }
680
+ return 'zh'
681
+ }
682
+
683
+ /** BCP-47 tag for Intl: only zh/en are shipped. */
684
+ function localeTag() {
685
+ return activeLocaleId() === 'en' ? 'en' : 'zh-CN'
686
+ }
687
+
343
688
  /* Host error codes (lib/index.js) → the Chinese key tt() translates. */
344
689
  const HOST_ERROR_KEYS = {
345
690
  'no-credentials': 'credentials 服务不可用',
@@ -454,10 +799,10 @@ window.__ModuleLoader__.load({
454
799
 
455
800
  /* ================= 消耗弹窗背景样式(与个性化插件联动) ================= */
456
801
  const MODAL_STYLE_KEY = 'dsh-consumption:modal-style'
457
- const BUILD_TAG = 'dcb-1.2.2'
802
+ const BUILD_TAG = 'dcb-1.3.2'
458
803
  // 本地版本:本包是零构建的手写客户端包,无法 import package.json,因此把版本号作为常量维护,
459
804
  // 并由 check.mjs 守卫与 package.json 严格一致(发布前不一致直接失败)。
460
- const PLUGIN_VERSION = '1.2.2'
805
+ const PLUGIN_VERSION = '1.3.2'
461
806
  const PKG_NAME = 'dsh-consumption-badge'
462
807
  const UPDATE_REGISTRY_URL = 'https://registry.npmjs.org/' + PKG_NAME + '/latest'
463
808
  const UPDATE_COMMAND = 'dsh plugin --profile web update ' + PKG_NAME
@@ -830,6 +1175,11 @@ window.__ModuleLoader__.load({
830
1175
  version: 0,
831
1176
  focusPending: false,
832
1177
  turns: [],
1178
+ // 逐条 assistant 消息的用量(messageId → 记录):只用来把胶囊定位到「哪一轮」。
1179
+ messages: [],
1180
+ messageById: new Map(),
1181
+ // turn 号 → /turns 里的折叠记录(胶囊显示的是这一轮的合计)。
1182
+ turnByNumber: new Map(),
833
1183
  turnsState: 'idle',
834
1184
  turnsError: '',
835
1185
  // Error copy is stored language-neutral: the kind names the sentence and
@@ -841,6 +1191,13 @@ window.__ModuleLoader__.load({
841
1191
  accountState: 'idle',
842
1192
  integrations: null,
843
1193
  modalStyle: readModalStyle(),
1194
+ // 显示币种(本地偏好)+ 汇率(Host 路由,6h 缓存):金额展示都走 money()。
1195
+ currency: readCurrency(),
1196
+ // 模型筛选 / 计价兜底('' = 全部模型,不筛选;同时作为查不到模型时的计价基准)。
1197
+ modelFilter: readModelFilter(),
1198
+ rates: null,
1199
+ ratesState: 'idle',
1200
+ ratesError: '',
844
1201
  // 已解析的弹窗背景图地址:URL 直接用,本地图片是 IndexedDB 里的 blob: 地址。
845
1202
  modalImageUrl: '',
846
1203
  // 图片保存过程的可观测状态:'' | 'saving' | 'saved' | 以 'error:' 开头的失败原因
@@ -856,11 +1213,13 @@ window.__ModuleLoader__.load({
856
1213
  balanceErrorCode: '',
857
1214
  balanceErrorFallback: '',
858
1215
  balanceAt: 0,
1216
+ ratesAt: 0,
859
1217
  }
860
1218
  const listeners = new Set()
861
1219
  let turnsInFlight = null
862
1220
  let accountInFlight = null
863
1221
  let balanceInFlight = null
1222
+ let ratesInFlight = null
864
1223
 
865
1224
  function emit() {
866
1225
  state.version += 1
@@ -899,6 +1258,9 @@ window.__ModuleLoader__.load({
899
1258
  if (state.sessionId !== sessionId) {
900
1259
  state.sessionId = sessionId
901
1260
  state.turns = []
1261
+ state.messages = []
1262
+ state.messageById = new Map()
1263
+ state.turnByNumber = new Map()
902
1264
  state.turnsState = 'idle'
903
1265
  state.turnsError = ''
904
1266
  }
@@ -921,7 +1283,33 @@ window.__ModuleLoader__.load({
921
1283
  cached: false,
922
1284
  })
923
1285
  }
1286
+ // 逐条消息:仅当 Host 回了 messages(新增字段)时才覆盖,否则保持空表 ⇒ 徽章回落本会话口径。
1287
+ const rawMessages = res && Array.isArray(res.messages) ? res.messages : []
1288
+ const messages = []
1289
+ const byId = new Map()
1290
+ for (let i = 0; i < rawMessages.length; i += 1) {
1291
+ const m = rawMessages[i]
1292
+ if (!m || typeof m !== 'object') continue
1293
+ const rec = {
1294
+ id: typeof m.id === 'string' ? m.id : '',
1295
+ turn: num(m.turn),
1296
+ step: num(m.step),
1297
+ hit: num(m.hit),
1298
+ miss: num(m.miss),
1299
+ out: num(m.out),
1300
+ time: num(m.time),
1301
+ model: typeof m.model === 'string' ? m.model : '',
1302
+ }
1303
+ messages.push(rec)
1304
+ if (rec.id !== '') byId.set(rec.id, rec)
1305
+ }
1306
+ state.messages = messages
1307
+ state.messageById = byId
924
1308
  state.turns = restoreTurns(sessionId, normalized)
1309
+ // 索引必须和 state.turns 用同一份数据(restoreTurns 会补上历史窗口外的轮次)。
1310
+ const turnByNumber = new Map()
1311
+ for (let i = 0; i < state.turns.length; i += 1) turnByNumber.set(state.turns[i].turn, state.turns[i])
1312
+ state.turnByNumber = turnByNumber
925
1313
  state.turnsState = res && res.ok === false ? 'error' : 'ready'
926
1314
  if (res && res.ok === false) {
927
1315
  state.turnsErrorKind = 'host'
@@ -1008,6 +1396,74 @@ window.__ModuleLoader__.load({
1008
1396
  function bump() {
1009
1397
  emit()
1010
1398
  }
1399
+ /**
1400
+ * 取汇率(Host 端 6h 缓存;`force` 强制刷新 Host 缓存)。
1401
+ * 只要当前币种不是人民币就需要它,取不到时金额回落为人民币显示。
1402
+ */
1403
+ function ensureRates(force) {
1404
+ const now = Date.now()
1405
+ const fresh = state.rates && state.rates.ok && now - (state.ratesAt || 0) < RATES_TTL_MS
1406
+ if (!force && fresh) return Promise.resolve(state.rates)
1407
+ if (ratesInFlight) return ratesInFlight
1408
+ state.ratesState = 'loading'
1409
+ emit()
1410
+ ratesInFlight = Promise.resolve()
1411
+ .then(function () { return fetchJson(API + '/rates' + (force ? '?force=1' : '')) })
1412
+ .catch(function () { return null })
1413
+ .then(function (res) {
1414
+ if (res && typeof res === 'object' && res.ok !== false && res.rates && typeof res.rates === 'object') {
1415
+ return Object.assign({ via: 'host' }, res)
1416
+ }
1417
+ // Host 路由还没就绪(例如还没重启)或出网被挡:浏览器直连公开汇率源。
1418
+ return fetchRatesDirect()
1419
+ })
1420
+ .then(function (res) {
1421
+ if (res && typeof res === 'object' && res.ok !== false && res.rates && typeof res.rates === 'object') {
1422
+ state.rates = res
1423
+ state.ratesAt = Date.now()
1424
+ state.ratesState = 'ready'
1425
+ state.ratesError = ''
1426
+ } else {
1427
+ state.rates = null
1428
+ state.ratesState = 'error'
1429
+ state.ratesError = res && typeof res.error === 'string' ? res.error : 'rates unavailable'
1430
+ }
1431
+ emit()
1432
+ })
1433
+ .catch(function (e) {
1434
+ state.rates = null
1435
+ state.ratesState = 'error'
1436
+ state.ratesError = msgOf(e)
1437
+ emit()
1438
+ })
1439
+ .then(function () { ratesInFlight = null })
1440
+ return ratesInFlight
1441
+ }
1442
+ /** 切换显示币种:落本地偏好 → 需要汇率时顺手取一次 → 通知所有订阅者(即时重绘)。 */
1443
+ function setCurrency(code) {
1444
+ const next = currencyMeta(code) !== null ? code : 'CNY'
1445
+ if (state.currency === next) return
1446
+ state.currency = next
1447
+ lsSet(LS_CURRENCY, next)
1448
+ emit()
1449
+ if (next !== 'CNY') ensureRates(false)
1450
+ }
1451
+ /** 强制刷新汇率(设置页的「刷新汇率」按钮)。 */
1452
+ function refreshRates() {
1453
+ state.rates = null
1454
+ return ensureRates(true)
1455
+ }
1456
+ /**
1457
+ * 切换「模型」下拉:'' = 全部模型(不筛选);
1458
+ * 选了具体模型时,它同时是**统计筛选条件**和**计价兜底模型**。
1459
+ */
1460
+ function setModelFilter(id) {
1461
+ const next = typeof id === 'string' && MODEL_BY_ID[id] ? id : ''
1462
+ if (state.modelFilter === next) return
1463
+ state.modelFilter = next
1464
+ lsSet(LS_MODEL_FILTER, next)
1465
+ emit()
1466
+ }
1011
1467
  /** 更新弹窗背景配置:合并 → 落本地偏好 → 通知所有订阅者(弹层即时重绘)。 */
1012
1468
  function setModalStyle(patch) {
1013
1469
  const prev = state.modalStyle
@@ -1167,6 +1623,10 @@ window.__ModuleLoader__.load({
1167
1623
  ensureAccount: ensureAccount,
1168
1624
  ensureTurns: ensureTurns,
1169
1625
  ensureBalance: ensureBalance,
1626
+ ensureRates: ensureRates,
1627
+ setCurrency: setCurrency,
1628
+ refreshRates: refreshRates,
1629
+ setModelFilter: setModelFilter,
1170
1630
  openPanel: openPanel,
1171
1631
  closePanel: closePanel,
1172
1632
  setModalStyle: setModalStyle,
@@ -1199,7 +1659,8 @@ window.__ModuleLoader__.load({
1199
1659
  const bucket = { hit: num(t.hit), miss: num(t.miss), out: num(t.out) }
1200
1660
  const at = t.time > 0 ? t.time : nowMs
1201
1661
  const peak = isPeakAt(at)
1202
- const price = priceFor(t.model || modelFallback)
1662
+ // 每轮按**它自己用的模型**计价(多模型混跑时不会串价)。
1663
+ const price = priceFor(t.model, modelFallback)
1203
1664
  series.push({
1204
1665
  turn: t.turn,
1205
1666
  hit: bucket.hit,
@@ -1208,7 +1669,12 @@ window.__ModuleLoader__.load({
1208
1669
  tokens: bucket.hit + bucket.miss + bucket.out,
1209
1670
  cost: costOf(bucket, price.table, peak),
1210
1671
  peak: peak,
1672
+ peakPricing: price.peakPricing,
1211
1673
  model: price.id,
1674
+ rawModel: price.raw,
1675
+ version: price.version,
1676
+ label: price.label,
1677
+ color: price.color,
1212
1678
  known: price.known,
1213
1679
  cached: !!t.cached,
1214
1680
  time: at,
@@ -1241,10 +1707,8 @@ window.__ModuleLoader__.load({
1241
1707
  function yCost(v) { return CHART_PAD.t + innerH - (v / topCost) * innerH }
1242
1708
 
1243
1709
  const tokenPts = []
1244
- const costPts = []
1245
1710
  for (let i = 0; i < n; i += 1) {
1246
1711
  tokenPts.push(xAt(i).toFixed(2) + ',' + yToken(series[i].tokens).toFixed(2))
1247
- costPts.push(xAt(i).toFixed(2) + ',' + yCost(series[i].cost).toFixed(2))
1248
1712
  }
1249
1713
  const grid = []
1250
1714
  const leftLabels = []
@@ -1254,7 +1718,7 @@ window.__ModuleLoader__.load({
1254
1718
  const y = CHART_PAD.t + innerH * ratio
1255
1719
  grid.push(h('line', { key: 'g' + g, x1: CHART_PAD.l, x2: CHART_PAD.l + innerW, y1: y, y2: y, className: 'dcb-grid-line' }))
1256
1720
  leftLabels.push(h('text', { key: 'lt' + g, x: CHART_PAD.l - 8, y: y + 3.5, textAnchor: 'end', className: 'dcb-axis-label' }, fmtTokens(topToken * (1 - ratio))))
1257
- rightLabels.push(h('text', { key: 'rt' + g, x: CHART_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' }, '¥' + fmtYuan(topCost * (1 - ratio))))
1721
+ rightLabels.push(h('text', { key: 'rt' + g, x: CHART_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' }, money(topCost * (1 - ratio))))
1258
1722
  }
1259
1723
  const step = n <= 8 ? 1 : Math.ceil(n / 8)
1260
1724
  const xLabels = []
@@ -1262,11 +1726,49 @@ window.__ModuleLoader__.load({
1262
1726
  if (i % step !== 0 && i !== n - 1) continue
1263
1727
  xLabels.push(h('text', { key: 'x' + i, x: xAt(i), y: CHART_H - 6, textAnchor: 'middle', className: 'dcb-axis-label' }, '#' + String(series[i].turn)))
1264
1728
  }
1265
- const dots = []
1729
+ // 每个模型一条金额折线(各自颜色);该模型没有数据的轮次断开,不做插值连线。
1730
+ const byModel = {}
1731
+ const modelOrder = []
1266
1732
  for (let i = 0; i < n; i += 1) {
1267
- const active = hover === i
1268
- dots.push(h('circle', { key: 'dt' + i, cx: xAt(i), cy: yToken(series[i].tokens), r: active ? 4 : 2.2, className: 'dcb-dot' }))
1269
- dots.push(h('circle', { key: 'dc' + i, cx: xAt(i), cy: yCost(series[i].cost), r: active ? 4 : 2.2, className: 'dcb-dot dcb-dot-cost' }))
1733
+ const key = series[i].model || DEFAULT_MODEL
1734
+ if (byModel[key] === undefined) {
1735
+ byModel[key] = []
1736
+ modelOrder.push(key)
1737
+ }
1738
+ byModel[key].push(i)
1739
+ }
1740
+ const modelLines = []
1741
+ const dots = []
1742
+ for (let m = 0; m < modelOrder.length; m += 1) {
1743
+ const key = modelOrder[m]
1744
+ const idxs = byModel[key]
1745
+ const color = series[idxs[0]].color || '#4d6bfe'
1746
+ let d = ''
1747
+ let run = false
1748
+ for (let k = 0; k < n; k += 1) {
1749
+ const idx = byModel[key].indexOf(k)
1750
+ if (idx < 0) {
1751
+ run = false
1752
+ continue
1753
+ }
1754
+ const x = xAt(k)
1755
+ const y = yCost(series[k].cost)
1756
+ d += (run ? 'L' : 'M') + x.toFixed(2) + ' ' + y.toFixed(2) + ' '
1757
+ run = true
1758
+ }
1759
+ modelLines.push(h('path', {
1760
+ key: 'ml' + key,
1761
+ d: d.trim(),
1762
+ className: 'dcb-line dcb-line-cost',
1763
+ stroke: color,
1764
+ fill: 'none',
1765
+ }))
1766
+ for (let k = 0; k < idxs.length; k += 1) {
1767
+ const i2 = idxs[k]
1768
+ const active = hover === i2
1769
+ dots.push(h('circle', { key: 'dt' + i2, cx: xAt(i2), cy: yToken(series[i2].tokens), r: active ? 4 : 2.2, className: 'dcb-dot' }))
1770
+ dots.push(h('circle', { key: 'dc' + i2, cx: xAt(i2), cy: yCost(series[i2].cost), r: active ? 4 : 2.2, className: 'dcb-dot dcb-dot-cost', fill: color }))
1771
+ }
1270
1772
  }
1271
1773
  const hovered = hover >= 0 && hover < n ? series[hover] : null
1272
1774
  const crosshair = hovered
@@ -1288,19 +1790,30 @@ window.__ModuleLoader__.load({
1288
1790
  ? h('div', { className: 'dcb-tooltip', style: { left: String((xAt(hover) / CHART_W) * 100) + '%' } },
1289
1791
  h('div', { className: 'dcb-tooltip-title' },
1290
1792
  '#' + String(hovered.turn),
1291
- h('span', { className: 'dcb-tooltip-model' }, hovered.model || DEFAULT_MODEL),
1292
- h('span', { className: hovered.peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(hovered.peak ? '峰时' : '谷时'))),
1793
+ h('span', { className: 'dcb-tooltip-model', title: modelDetail(hovered.rawModel || hovered.model) },
1794
+ h('i', { className: 'dcb-model-dot', style: { background: hovered.color } }),
1795
+ (hovered.label || hovered.model || DEFAULT_MODEL)
1796
+ + (hovered.version ? ' · ' + hovered.version : '')
1797
+ + (hovered.rawModel && hovered.rawModel !== hovered.model ? ' · ' + hovered.rawModel : '')),
1798
+ h('span', { className: hovered.peak && hovered.peakPricing !== false ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(hovered.peak ? '峰时' : '谷时'))),
1293
1799
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输入命中')), h('b', null, fmtTokens(hovered.hit))),
1294
1800
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输入未命中')), h('b', null, fmtTokens(hovered.miss))),
1295
1801
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输出')), h('b', null, fmtTokens(hovered.out))),
1296
1802
  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))))
1803
+ h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('估算金额')), h('b', null, moneyApprox(hovered.cost))))
1298
1804
  : null
1299
1805
 
1300
1806
  return h('div', { className: 'dcb-chart-wrap' },
1301
1807
  h('div', { className: 'dcb-chart-legend' },
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('每轮估算金额'))),
1808
+ h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot dcb-dot-token' }), tt('每轮 Token')),
1809
+ modelOrder.length > 1
1810
+ ? modelOrder.map(function (key) {
1811
+ const first = series[byModel[key][0]]
1812
+ return h('span', { key: 'lg' + key, className: 'dcb-legend-item' },
1813
+ h('i', { className: 'dcb-legend-dot', style: { background: first.color } }),
1814
+ first.label || key)
1815
+ })
1816
+ : h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot dcb-dot-cost' }), tt('每轮估算金额'))),
1304
1817
  h('div', { className: 'dcb-chart-body' },
1305
1818
  h('svg', {
1306
1819
  className: 'dcb-chart-svg',
@@ -1311,8 +1824,8 @@ window.__ModuleLoader__.load({
1311
1824
  },
1312
1825
  h('g', null, grid),
1313
1826
  crosshair,
1314
- h('polyline', { className: 'dcb-line', points: tokenPts.join(' ') }),
1315
- h('polyline', { className: 'dcb-line dcb-line-cost', points: costPts.join(' ') }),
1827
+ h('polyline', { className: 'dcb-line dcb-line-token', points: tokenPts.join(' ') }),
1828
+ h('g', null, modelLines),
1316
1829
  h('g', null, dots),
1317
1830
  h('g', null, leftLabels),
1318
1831
  h('g', null, rightLabels),
@@ -1357,6 +1870,26 @@ window.__ModuleLoader__.load({
1357
1870
  props.hint ? h('div', { className: 'dcb-card-hint' }, props.hint) : null)
1358
1871
  }
1359
1872
 
1873
+ /**
1874
+ * 从 DOM 里读出「自己在第几轮」:外壳把 assistant-actions 这个槽位渲染在
1875
+ * `<div data-turn-tail="<turn>">` 里面。这是给「Host 还没重启、没回 messageId→turn 映射」
1876
+ * 时用的兜底通道 —— 有 Host 映射时优先用 Host 的。
1877
+ * @param el 胶囊自身的 DOM 节点。
1878
+ * @returns 轮次号,取不到返回 undefined。
1879
+ */
1880
+ function turnFromElement(el) {
1881
+ if (!el || typeof el.closest !== 'function') return undefined
1882
+ let node = null
1883
+ try {
1884
+ node = el.closest('[data-turn-tail]')
1885
+ } catch (e) {
1886
+ node = null
1887
+ }
1888
+ if (!node || typeof node.getAttribute !== 'function') return undefined
1889
+ const n = Number(node.getAttribute('data-turn-tail'))
1890
+ return Number.isFinite(n) && n > 0 ? n : undefined
1891
+ }
1892
+
1360
1893
  function ConsumptionBadge(props) {
1361
1894
  const state = store.state
1362
1895
  const tick = React.useState(0)
@@ -1365,35 +1898,88 @@ window.__ModuleLoader__.load({
1365
1898
  return store.subscribe(function () { setTick(function (v) { return v + 1 }) })
1366
1899
  }, [])
1367
1900
  const sessionId = props && typeof props.sessionId === 'string' ? props.sessionId : ''
1901
+ // 槽位 owner 传的是这条 finalized assistant 消息的身份;用它把自己的用量挑出来。
1902
+ const messageId = props && typeof props.messageId === 'string' ? props.messageId : ''
1903
+ const rootRef = React.useRef(null)
1904
+ const domTurnState = React.useState(undefined)
1905
+ const domTurn = domTurnState[0]
1906
+ const setDomTurn = domTurnState[1]
1907
+ React.useEffect(function () {
1908
+ // 每次渲染后都对一遍(槽位可能在 DOM 变动后被重新挂载)。
1909
+ const found = turnFromElement(rootRef.current)
1910
+ if (found !== undefined && found !== domTurn) setDomTurn(found)
1911
+ })
1368
1912
  const projection = props && typeof props.useProjection === 'function' ? props.useProjection('tokenUsage') : undefined
1369
1913
  React.useEffect(function () {
1370
1914
  store.ensureAccount()
1371
1915
  store.ensureTurns(sessionId)
1916
+ // 胶囊里也要显示余额:走同一份 60s 缓存,不额外压测接口。
1917
+ store.ensureBalance(false)
1918
+ // 非人民币显示时才需要汇率(Host 端 6h 缓存)。
1919
+ if (store.state.currency !== 'CNY') store.ensureRates(false)
1372
1920
  }, [sessionId, projection])
1373
1921
  React.useEffect(function () {
1374
- return everyTick(60000, function () { store.bump() })
1922
+ return everyTick(60000, function () {
1923
+ store.bump()
1924
+ store.ensureBalance(false)
1925
+ })
1375
1926
  }, [])
1376
1927
 
1928
+ // 槽位是按「轮」渲染的(TurnTailNodeView 用该轮的 finalNode.messageId 调 useSlots),
1929
+ // 所以这里要的是**这一轮**的合计,而不是某一步、更不是整个会话:
1930
+ // messageId → turn 号(Host 回映射)→ 该 turn 的折叠记录(/turns 本来就有)。
1931
+ const mapped = messageId !== '' ? state.messageById.get(messageId) : undefined
1932
+ // 优先 Host 的 messageId→turn;Host 旧版本没回 messages 时退回 DOM 上的轮次号。
1933
+ const turnNo = mapped !== undefined ? mapped.turn : domTurn
1934
+ const ownTurn = typeof turnNo === 'number' ? state.turnByNumber.get(turnNo) : undefined
1377
1935
  const live = projection && typeof projection === 'object'
1378
- const bucket = live
1379
- ? {
1380
- hit: num(projection.cacheReadTokens),
1381
- miss: num(projection.uncachedInputTokens) + num(projection.cacheWriteTokens),
1382
- out: num(projection.outputTokens),
1383
- }
1384
- : bucketOfTurns(state.turns)
1385
- // Without the live projection the first paint has no numbers yet: show a
1386
- // placeholder instead of a misleading zero.
1387
- const pending = !live && state.turns.length === 0 && state.turnsState !== 'ready'
1388
- const model = state.account && typeof state.account.model === 'string' && state.account.model ? state.account.model : DEFAULT_MODEL
1389
- const peak = isPeakAt(Date.now())
1390
- const tokens = bucket.hit + bucket.miss + bucket.out
1391
- const cost = costOf(bucket, priceFor(model).table, peak)
1936
+ // 三级口径:本轮合计 → 本会话(投影 / 折叠后的逐轮)→ 还没有数据。
1937
+ let bucket = null
1938
+ let scope = 'turn'
1939
+ let bucketModel = ''
1940
+ let bucketTime = 0
1941
+ if (ownTurn !== undefined) {
1942
+ bucket = { hit: ownTurn.hit, miss: ownTurn.miss, out: ownTurn.out }
1943
+ bucketModel = ownTurn.model
1944
+ bucketTime = ownTurn.time
1945
+ } else if (live) {
1946
+ scope = 'session'
1947
+ bucket = {
1948
+ hit: num(projection.cacheReadTokens),
1949
+ miss: num(projection.uncachedInputTokens) + num(projection.cacheWriteTokens),
1950
+ out: num(projection.outputTokens),
1951
+ }
1952
+ } else if (state.turns.length > 0) {
1953
+ scope = 'session'
1954
+ bucket = bucketOfTurns(state.turns)
1955
+ }
1956
+ // 还没有任何数字时显示占位符,而不是误导性的 0。
1957
+ const pending = bucket === null && state.turnsState !== 'ready'
1958
+ // 计价兜底模型:会话日志里查不到模型时用它(设置页下拉选的那个 → 账号当前模型 → 默认)。
1959
+ const model = bucketModel
1960
+ || state.modelFilter
1961
+ || (state.account && typeof state.account.model === 'string' && state.account.model ? state.account.model : DEFAULT_MODEL)
1962
+ const at = bucketTime > 0 ? bucketTime : Date.now()
1963
+ const peak = isPeakAt(at)
1964
+ const tokens = bucket === null ? 0 : bucket.hit + bucket.miss + bucket.out
1965
+ const cost = bucket === null ? 0 : costOf(bucket, priceFor(bucketModel, state.modelFilter).table, peak)
1966
+
1967
+ // 账户余额:成功 → ¥金额;读取中 → …;失败/未配置 → —(原因放 title,不挤占胶囊宽度)。
1968
+ const balance = state.balance
1969
+ let balanceValue = '…'
1970
+ if (balance && balance.ok === true && typeof balance.total === 'number') {
1971
+ balanceValue = moneyBalance(balance.total, balance.currency)
1972
+ } else if (state.balanceState === 'ready' || state.balanceState === 'error') {
1973
+ balanceValue = '—'
1974
+ }
1975
+ const balanceTitle = balanceErrorText() || tt('账户余额')
1392
1976
 
1393
1977
  return h('button', {
1394
1978
  type: 'button',
1395
1979
  className: 'dcb-pill',
1396
- title: tt('本会话消耗(点击查看逐轮明细)'),
1980
+ ref: function (el) { rootRef.current = el || null },
1981
+ title: (scope === 'turn' ? tt('本轮消耗(点击查看本会话明细)') : tt('本会话消耗(点击查看逐轮明细)'))
1982
+ + ' · ' + balanceTitle + ': ' + balanceValue,
1397
1983
  onClick: function (event) {
1398
1984
  try {
1399
1985
  event.preventDefault()
@@ -1405,9 +1991,11 @@ window.__ModuleLoader__.load({
1405
1991
  h('span', { className: 'dcb-pill-glyph' }, '⛽'),
1406
1992
  h('span', null, pending ? '…' : fmtTokens(tokens)),
1407
1993
  h('span', { className: 'dcb-pill-sep' }, '|'),
1408
- h('span', null, pending ? '…' : '≈¥' + fmtYuan(cost)),
1994
+ h('span', null, pending ? '…' : moneyApprox(cost)),
1995
+ h('span', { className: 'dcb-pill-sep' }, '|'),
1996
+ h('span', { className: peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(peak ? '峰' : '谷')),
1409
1997
  h('span', { className: 'dcb-pill-sep' }, '|'),
1410
- h('span', { className: peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(peak ? '峰' : '谷')))
1998
+ h('span', { className: 'dcb-pill-balance' }, tt('余额 = {amount}', { amount: balanceValue })))
1411
1999
  }
1412
2000
 
1413
2001
  function ConsumptionPanel() {
@@ -1432,19 +2020,33 @@ window.__ModuleLoader__.load({
1432
2020
  if (!open) return undefined
1433
2021
  store.ensureTurns(state.sessionId)
1434
2022
  store.ensureBalance(false)
2023
+ if (store.state.currency !== 'CNY') store.ensureRates(false)
1435
2024
  return everyTick(60000, function () { store.ensureBalance(false) })
1436
2025
  }, [open, state.sessionId])
1437
2026
  if (!open) return null
1438
2027
 
1439
2028
  const now = Date.now()
1440
2029
  const surface = modalSurface(state.modalStyle, state.modalImageUrl)
1441
- const model = state.account && typeof state.account.model === 'string' && state.account.model ? state.account.model : DEFAULT_MODEL
2030
+ const accountModel = state.account && typeof state.account.model === 'string' && state.account.model ? state.account.model : DEFAULT_MODEL
2031
+ // 面板里的「模型」下拉 = 计价兜底模型(日志查不到模型时用它)+ 图例口径。
2032
+ const model = state.modelFilter || accountModel
1442
2033
  const price = priceFor(model)
1443
2034
  const peak = isPeakAt(now)
1444
- const series = buildSeries(state.turns, model, now)
1445
- const aggregated = bucketOfTurns(state.turns)
2035
+ const allSeries = buildSeries(state.turns, model, now)
2036
+ // 只看选中模型时,卡片/图表都按该模型过滤(选「全部模型」时不过滤)。
2037
+ const series = state.modelFilter ? allSeries.filter(function (s) { return s.model === state.modelFilter }) : allSeries
2038
+ const aggregated = { hit: 0, miss: 0, out: 0 }
2039
+ let totalCost = 0
2040
+ for (let i = 0; i < series.length; i += 1) {
2041
+ aggregated.hit += series[i].hit
2042
+ aggregated.miss += series[i].miss
2043
+ aggregated.out += series[i].out
2044
+ totalCost += series[i].cost
2045
+ }
1446
2046
  const tokens = aggregated.hit + aggregated.miss + aggregated.out
1447
- const totalCost = costOf(aggregated, price.table, peak)
2047
+ const modelSet = {}
2048
+ for (let i = 0; i < allSeries.length; i += 1) modelSet[allSeries[i].model] = allSeries[i]
2049
+ const modelsUsed = Object.keys(modelSet).map(function (k) { return modelSet[k] })
1448
2050
  const balance = state.balance
1449
2051
  const loading = state.balanceState === 'loading'
1450
2052
  const balanceErr = balanceErrorText()
@@ -1463,12 +2065,18 @@ window.__ModuleLoader__.load({
1463
2065
  balanceHint = tt('请前往 DeepSeek 开放平台充值')
1464
2066
  balanceClass = 'dcb-card-value dcb-bad'
1465
2067
  } else if (balance) {
1466
- const symbol = balance.currency && balance.currency !== 'CNY' ? String(balance.currency) + ' ' : '¥'
1467
- balanceValue = symbol + fmtYuan(balance.total)
2068
+ balanceValue = moneyBalance(balance.total, balance.currency)
1468
2069
  const parts = []
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) }))
2070
+ if (typeof balance.granted === 'number') parts.push(tt('赠送 {amount}', { amount: moneyBalance(balance.granted, balance.currency) }))
2071
+ if (typeof balance.toppedUp === 'number') parts.push(tt('充值 {amount}', { amount: moneyBalance(balance.toppedUp, balance.currency) }))
1471
2072
  balanceHint = parts.length ? parts.join(' · ') : tt('DeepSeek 账户余额')
2073
+ // 选了模型时,把余额换算成「按该模型还能跑多少 token」——这就是余额显示与模型的联动。
2074
+ const perMillion = price.table.hit * 0.5 + price.table.miss * 0.3 + price.table.out * 0.2
2075
+ if (typeof balance.total === 'number' && perMillion > 0 && balance.currency === 'CNY') {
2076
+ const affordable = (balance.total / perMillion) * 1e6
2077
+ balanceHint = (parts.length ? parts.join(' · ') + ' · ' : '')
2078
+ + tt('按 {model} 约还可跑 {tokens} tokens', { model: price.label, tokens: fmtTokens(affordable) })
2079
+ }
1472
2080
  }
1473
2081
 
1474
2082
  function close() {
@@ -1502,11 +2110,31 @@ window.__ModuleLoader__.load({
1502
2110
  h('div', { className: 'dcb-head' },
1503
2111
  h('div', { className: 'dcb-head-title' }, tt('⛽ 本会话消耗')),
1504
2112
  h('div', { className: 'dcb-head-actions' },
2113
+ // 模型下拉:只看某个模型,同时作为「查不到模型时」的计价基准。
2114
+ h('select', {
2115
+ className: 'dcb-select dcb-select-inline',
2116
+ 'aria-label': tt('模型'),
2117
+ title: tt('按模型筛选;同时作为计价兜底'),
2118
+ value: state.modelFilter,
2119
+ onChange: function (event) { store.setModelFilter(event.target.value) },
2120
+ },
2121
+ h('option', { key: 'all', value: '' }, tt('全部模型')),
2122
+ modelOptionNodes(modelsUsed.length > 0
2123
+ ? modelsUsed.map(function (m) { return MODEL_BY_ID[m.model] || { id: m.model, label: m.label, provider: '—' } })
2124
+ : MODELS)),
1505
2125
  h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.ensureBalance(true) } }, tt('刷新余额')),
1506
2126
  h('button', { type: 'button', className: 'dcb-btn dcb-btn-close', onClick: close, title: tt('Esc 关闭') }, '✕'))),
1507
2127
  h('div', { className: 'dcb-cards' },
1508
2128
  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' : ' · 谷时') }),
2129
+ h(Card, {
2130
+ label: tt('估算金额'),
2131
+ value: moneyApprox(totalCost),
2132
+ hint: (state.modelFilter
2133
+ ? tt('仅 {model}', { model: modelDetail(state.modelFilter) })
2134
+ : tt('{count} 个模型各自计价', { count: String(modelsUsed.length || 1) })
2135
+ + (modelsUsed.length === 1 ? ' · ' + modelDetail(modelsUsed[0].model) : ''))
2136
+ + (price.peakPricing ? tt(peak ? ' · 峰时 ×2' : ' · 谷时') : ''),
2137
+ }),
1510
2138
  h(Card, { label: tt('当前时段'), value: tt(peak ? '高峰' : '空闲'), hint: tt('北京时间 工作日 09:00-12:00 / 14:00-18:00') }),
1511
2139
  h('div', { className: 'dcb-card' },
1512
2140
  h('div', { className: 'dcb-card-label' }, tt('账户余额')),
@@ -1516,7 +2144,7 @@ window.__ModuleLoader__.load({
1516
2144
  h(UsageChart, { series: series }),
1517
2145
  h('div', { className: 'dcb-foot' },
1518
2146
  state.turnsError ? h('span', { className: 'dcb-foot-warn' }, turnsErrorText() + ' ') : null,
1519
- tt('金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考;数据源为本会话日志,刷新后自动恢复。'))))
2147
+ tt('金额按各模型官方价估算(DeepSeek 区分峰谷 ×1/×2,其它厂商按 USD 价 ×7.1 折人民币),仅供参考;数据源为本会话日志,刷新后自动恢复。'))))
1520
2148
  }
1521
2149
 
1522
2150
  /* ===================== settings section: history page ===================== */
@@ -1631,9 +2259,11 @@ window.__ModuleLoader__.load({
1631
2259
  }
1632
2260
 
1633
2261
  /**
1634
- * 纯聚合:时间过滤 → 工作区过滤 → 按小时/日/月分桶(空桶补 0)→ 峰谷与金额拆分。
2262
+ * 纯聚合:时间过滤 → 工作区/模型过滤 → 按小时/日/月分桶(空桶补 0)→ 峰谷与金额拆分 →
2263
+ * 每个桶再按模型拆一份 `byModel`(折线图一个模型一条线就靠它)。
1635
2264
  * 刻意自包含(只读 input,不引用闭包变量),所以能直接 String() 进 Web Worker。
1636
- * @param input {records, from, to, wid, granularity: 'hour'|'day'|'month', prices, holidays, windows}
2265
+ * @param input {records, from, to, wid, model, granularity: 'hour'|'day'|'month', prices, holidays, windows}
2266
+ * `model` 非空时只统计该模型;`prices[id]` 里的 `peak: true` 表示该模型参与峰谷计时。
1637
2267
  */
1638
2268
  function computeConsumptionStats(input) {
1639
2269
  const records = input.records || []
@@ -1643,6 +2273,7 @@ window.__ModuleLoader__.load({
1643
2273
  const from = typeof input.from === 'number' ? input.from : 0
1644
2274
  const to = typeof input.to === 'number' ? input.to : 0
1645
2275
  const wid = typeof input.wid === 'string' ? input.wid : ''
2276
+ const modelFilter = typeof input.model === 'string' ? input.model : ''
1646
2277
  const granularity = input.granularity === 'month' ? 'month' : (input.granularity === 'hour' ? 'hour' : 'day')
1647
2278
  // noFill:只要合计不要时间轴(设置页顶部的“累计”卡片用),跳过分桶补齐。
1648
2279
  const noFill = input.noFill === true
@@ -1674,7 +2305,10 @@ window.__ModuleLoader__.load({
1674
2305
  function priceOf(model) {
1675
2306
  const id = typeof model === 'string' ? model : ''
1676
2307
  if (prices[id]) return prices[id]
1677
- if (id.toLowerCase().indexOf('pro') >= 0) return prices['deepseek-v4-pro'] || prices['deepseek-flash']
2308
+ // 兼容历史 id:pro/opus/max 归到更贵的一档,其余按 flash。
2309
+ if (id.toLowerCase().indexOf('pro') >= 0 || id.toLowerCase().indexOf('opus') >= 0 || id.toLowerCase().indexOf('max') >= 0) {
2310
+ return prices['deepseek-v4-pro'] || prices['deepseek-flash']
2311
+ }
1678
2312
  return prices['deepseek-flash']
1679
2313
  }
1680
2314
  function keyOf(ms) {
@@ -1702,14 +2336,17 @@ window.__ModuleLoader__.load({
1702
2336
  const produced = n(r.out)
1703
2337
  const tokens = hit + miss + produced
1704
2338
  if (tokens === 0) continue
2339
+ const rowModel = typeof r.model === 'string' && r.model ? r.model : ''
2340
+ if (modelFilter && rowModel !== modelFilter) continue
1705
2341
  const peak = isPeak(t)
1706
- const table = priceOf(r.model)
1707
- const k = peak ? 2 : 1
2342
+ const table = priceOf(rowModel)
2343
+ // 只有 `peak: true` 的模型(DeepSeek)才乘峰时系数。
2344
+ const k = peak && table.peak === true ? 2 : 1
1708
2345
  const cost = ((hit / 1e6) * table.hit + (miss / 1e6) * table.miss + (produced / 1e6) * table.out) * k
1709
2346
  const key = keyOf(t)
1710
2347
  let b = buckets[key]
1711
2348
  if (!b) {
1712
- b = { key: key, tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, models: [], records: 0 }
2349
+ b = { key: key, tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, models: [], records: 0, byModel: {} }
1713
2350
  buckets[key] = b
1714
2351
  }
1715
2352
  b.tokens += tokens
@@ -1733,13 +2370,22 @@ window.__ModuleLoader__.load({
1733
2370
  }
1734
2371
  const model = typeof r.model === 'string' && r.model ? r.model : 'unknown'
1735
2372
  if (b.models.indexOf(model) < 0) b.models.push(model)
2373
+ // 桶内按模型拆分:cost/tokens 给折线图每个模型一条线用。
2374
+ let slot = b.byModel[model]
2375
+ if (!slot) {
2376
+ slot = { cost: 0, tokens: 0, records: 0 }
2377
+ b.byModel[model] = slot
2378
+ }
2379
+ slot.cost += cost
2380
+ slot.tokens += tokens
2381
+ slot.records += 1
1736
2382
  count += 1
1737
2383
  }
1738
2384
 
1739
2385
  const points = []
1740
2386
  function push(key, ms) {
1741
2387
  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 }
2388
+ const point = b || { key: key, tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, models: [], records: 0, byModel: {} }
1743
2389
  if (granularity === 'month') point.label = key
1744
2390
  else if (granularity === 'hour') point.label = key.slice(11) + ':00'
1745
2391
  else point.label = key.slice(5)
@@ -1876,10 +2522,8 @@ window.__ModuleLoader__.load({
1876
2522
  function yToken(v) { return HIST_PAD.t + innerH - (v / topToken) * innerH }
1877
2523
  function yCost(v) { return HIST_PAD.t + innerH - (v / topCost) * innerH }
1878
2524
 
1879
- const costPts = []
1880
2525
  const tokenPts = []
1881
2526
  for (let i = 0; i < n; i += 1) {
1882
- costPts.push(xAt(i).toFixed(2) + ',' + yCost(points[i].cost).toFixed(2))
1883
2527
  tokenPts.push(xAt(i).toFixed(2) + ',' + yToken(points[i].tokens).toFixed(2))
1884
2528
  }
1885
2529
 
@@ -1890,7 +2534,7 @@ window.__ModuleLoader__.load({
1890
2534
  const ratio = g / 4
1891
2535
  const y = HIST_PAD.t + innerH * ratio
1892
2536
  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))))
2537
+ rightLabels.push(h('text', { key: 'hr' + g, x: HIST_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' }, money(topCost * (1 - ratio))))
1894
2538
  if (showTokens) {
1895
2539
  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
2540
  }
@@ -1904,13 +2548,47 @@ window.__ModuleLoader__.load({
1904
2548
  }
1905
2549
 
1906
2550
  const dots = []
2551
+ // 每个模型一条金额折线(各自颜色):该桶里没有这个模型就断线。
2552
+ const modelUsage = {}
2553
+ const modelOrder = []
1907
2554
  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' }))
2555
+ const byModel = points[i].byModel || {}
2556
+ for (const key in byModel) {
2557
+ if (!Object.prototype.hasOwnProperty.call(byModel, key)) continue
2558
+ if (modelUsage[key] === undefined) {
2559
+ modelUsage[key] = []
2560
+ modelOrder.push(key)
2561
+ }
2562
+ modelUsage[key].push(i)
2563
+ }
2564
+ }
2565
+ const modelLines = []
2566
+ for (let m = 0; m < modelOrder.length; m += 1) {
2567
+ const key = modelOrder[m]
2568
+ const rows = modelUsage[key]
2569
+ const color = modelColor(key)
2570
+ let d = ''
2571
+ for (let k = 0; k < rows.length; k += 1) {
2572
+ const idx = rows[k]
2573
+ const seg = points[idx].byModel[key]
2574
+ d += (k === 0 || rows[k - 1] !== idx - 1 ? 'M' : 'L') + xAt(idx).toFixed(2) + ' ' + yCost(seg.cost).toFixed(2) + ' '
2575
+ }
2576
+ modelLines.push(h('path', { key: 'hml' + key, d: d.trim(), className: 'dcb-line dcb-line-cost', stroke: color, fill: 'none' }))
2577
+ for (let k = 0; k < rows.length; k += 1) {
2578
+ const idx = rows[k]
2579
+ dots.push(h('circle', {
2580
+ key: 'hc' + key + idx,
2581
+ cx: xAt(idx),
2582
+ cy: yCost(points[idx].byModel[key].cost),
2583
+ r: hover === idx ? 4 : 2.2,
2584
+ className: 'dcb-dot dcb-dot-cost',
2585
+ fill: color,
2586
+ }))
1911
2587
  }
2588
+ }
2589
+ for (let i = 0; i < n; i += 1) {
1912
2590
  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' }))
2591
+ dots.push(h('circle', { key: 'ht' + i, cx: xAt(i), cy: yToken(points[i].tokens), r: hover === i ? 4 : 2.2, className: 'dcb-dot' }))
1914
2592
  }
1915
2593
  }
1916
2594
 
@@ -1935,11 +2613,13 @@ window.__ModuleLoader__.load({
1935
2613
  h('div', { className: 'dcb-tooltip-title' },
1936
2614
  hovered.label,
1937
2615
  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))),
2616
+ h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('当日合计')), h('b', null, moneyApprox(hovered.cost))),
1939
2617
  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(', ') : '—')))
2618
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('峰时')), h('b', null, moneyApprox(hovered.peakCost) + ' / ' + fmtTokens(hovered.peakTokens))),
2619
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('谷时')), h('b', null, moneyApprox(hovered.valleyCost) + ' / ' + fmtTokens(hovered.valleyTokens))),
2620
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('模型')), h('b', null, hovered.models.length
2621
+ ? hovered.models.map(function (m) { return modelLabel(m) }).join(', ')
2622
+ : '—')))
1943
2623
  : null
1944
2624
 
1945
2625
  return h('div', { className: 'dcb-chart-body' },
@@ -1952,8 +2632,8 @@ window.__ModuleLoader__.load({
1952
2632
  },
1953
2633
  h('g', null, grid),
1954
2634
  crosshair,
1955
- showTokens ? h('polyline', { className: 'dcb-line', points: tokenPts.join(' ') }) : null,
1956
- h('polyline', { className: 'dcb-line dcb-line-cost', points: costPts.join(' ') }),
2635
+ showTokens ? h('polyline', { className: 'dcb-line dcb-line-token', points: tokenPts.join(' ') }) : null,
2636
+ h('g', null, modelLines),
1957
2637
  h('g', null, dots),
1958
2638
  h('g', null, leftLabels),
1959
2639
  h('g', null, rightLabels),
@@ -1990,6 +2670,8 @@ window.__ModuleLoader__.load({
1990
2670
  const s8 = React.useState(null)
1991
2671
  const allStats = s8[0]
1992
2672
  const setAllStats = s8[1]
2673
+ // 「模型」下拉的状态放在 store 里(弹层与设置页共享同一个选择)。
2674
+ const modelFilter = store.state.modelFilter
1993
2675
  const s9 = React.useState(0)
1994
2676
  const setStoreTick = s9[1]
1995
2677
 
@@ -2002,6 +2684,10 @@ window.__ModuleLoader__.load({
2002
2684
  store.ensureBalance(false)
2003
2685
  return everyTick(60000, function () { store.ensureBalance(false) })
2004
2686
  }, [])
2687
+ // 非人民币显示时才需要汇率(Host 端 6h 缓存,切币种时 setCurrency 已经会取一次)。
2688
+ React.useEffect(function () {
2689
+ if (store.state.currency !== 'CNY') store.ensureRates(false)
2690
+ }, [])
2005
2691
 
2006
2692
  // 工作区签名:id + 标题 + 归属会话(WorkspaceView.sessionIds 即归属账本)。
2007
2693
  const wsSig = props && typeof props.useWorkspaces === 'function'
@@ -2036,6 +2722,7 @@ window.__ModuleLoader__.load({
2036
2722
  from: range.from,
2037
2723
  to: range.to,
2038
2724
  wid: wid,
2725
+ model: modelFilter,
2039
2726
  granularity: range.granularity,
2040
2727
  prices: PRICES,
2041
2728
  holidays: CN_FIXED_HOLIDAYS,
@@ -2051,7 +2738,7 @@ window.__ModuleLoader__.load({
2051
2738
  setStats(fallback)
2052
2739
  })
2053
2740
  return undefined
2054
- }, [records, rangeKey, wid, calFrom, calTo, wsSig])
2741
+ }, [records, rangeKey, wid, calFrom, calTo, wsSig, modelFilter])
2055
2742
 
2056
2743
  // 顶部“累计”卡片:全部历史合计(不受筛选影响),只算合计不铺时间轴。
2057
2744
  React.useEffect(function () {
@@ -2084,8 +2771,32 @@ window.__ModuleLoader__.load({
2084
2771
  }, [records, wsSig])
2085
2772
 
2086
2773
  const st = store.state
2774
+ // 显示币种 + 汇率提示(报价表恒为人民币,这里只是展示换算)。
2775
+ const currency = typeof st.currency === 'string' ? st.currency : 'CNY'
2776
+ const rates = st.rates
2777
+ const rateValue = rates && rates.rates && typeof rates.rates[currency] === 'number' ? rates.rates[currency] : null
2778
+ let rateNote = tt('金额按人民币报价折算显示')
2779
+ if (currency === 'CNY') {
2780
+ rateNote = tt('当前以人民币显示(可切换成其它币种)')
2781
+ } else if (rateValue !== null) {
2782
+ const when = rates && rates.fetchedAt ? new Date(rates.fetchedAt) : null
2783
+ const stamp = when !== null && !Number.isNaN(when.getTime())
2784
+ ? when.toLocaleString(localeTag())
2785
+ : ''
2786
+ rateNote = tt('1 CNY = {rate} {code}', { rate: String(Number(rateValue.toFixed(6))), code: currency })
2787
+ + ' · ' + tt('汇率来源 {source}', { source: rates.stale === true ? tt('内置兜底汇率') : String(rates.source || '') })
2788
+ + (stamp ? ' · ' + stamp : '')
2789
+ + (rates.stale === true ? ' · ' + tt('联网失败时使用内置汇率') : '')
2790
+ } else if (st.ratesState === 'loading') {
2791
+ rateNote = tt('正在获取汇率…')
2792
+ } else if (st.ratesState === 'error') {
2793
+ rateNote = tt('汇率获取失败,暂时按人民币显示:{msg}', { msg: String(st.ratesError || '') })
2794
+ } else {
2795
+ rateNote = tt('金额按人民币报价折算显示')
2796
+ }
2087
2797
  const accountModel = st.account && typeof st.account.model === 'string' && st.account.model ? st.account.model : DEFAULT_MODEL
2088
- const accountPrice = priceFor(accountModel)
2798
+ // 「模型」下拉选了具体模型时,它同时是计价兜底(日志里查不到模型时用它)。
2799
+ const accountPrice = priceFor(modelFilter || accountModel)
2089
2800
  const nowPeak = isPeakAt(Date.now())
2090
2801
  const balance = st.balance
2091
2802
  const balanceLoading = st.balanceState === 'loading'
@@ -2105,17 +2816,25 @@ window.__ModuleLoader__.load({
2105
2816
  balanceHint = tt('请前往 DeepSeek 开放平台充值')
2106
2817
  balanceClass = 'dcb-card-value dcb-bad'
2107
2818
  } else if (balance) {
2108
- const symbol = balance.currency && balance.currency !== 'CNY' ? String(balance.currency) + ' ' : '¥'
2109
- balanceValue = symbol + fmtYuan(balance.total)
2819
+ balanceValue = moneyBalance(balance.total, balance.currency)
2110
2820
  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) }))
2821
+ if (typeof balance.granted === 'number') parts.push(tt('赠送 {amount}', { amount: moneyBalance(balance.granted, balance.currency) }))
2822
+ if (typeof balance.toppedUp === 'number') parts.push(tt('充值 {amount}', { amount: moneyBalance(balance.toppedUp, balance.currency) }))
2113
2823
  balanceHint = parts.length ? parts.join(' · ') : tt('DeepSeek 账户余额')
2114
2824
  }
2115
2825
 
2116
2826
  const index = buildWorkspaceIndex(wsSig)
2117
2827
  const points = stats ? stats.points : []
2118
2828
  const hasData = !!stats && stats.count > 0
2829
+ // 图例:这一区间里出现过的模型(按聚合结果的 byModel 收集,颜色与折线一致)。
2830
+ const legendModels = []
2831
+ for (let pi = 0; pi < points.length; pi += 1) {
2832
+ const byModel = points[pi].byModel || {}
2833
+ for (const key in byModel) {
2834
+ if (!Object.prototype.hasOwnProperty.call(byModel, key)) continue
2835
+ if (legendModels.indexOf(key) < 0) legendModels.push(key)
2836
+ }
2837
+ }
2119
2838
  const rangeOptions = [
2120
2839
  { id: 'today', label: tt('今天') },
2121
2840
  { id: 'week', label: tt('近一周') },
@@ -2138,8 +2857,8 @@ window.__ModuleLoader__.load({
2138
2857
  }),
2139
2858
  h(Card, {
2140
2859
  label: tt('估算金额'),
2141
- value: allStats ? '≈¥' + fmtYuan(allStats.totalCost) : '—',
2142
- hint: (accountPrice.known ? accountPrice.id : tt('{id}(按 flash 估算)', { id: accountPrice.id })) + tt(' · 全部历史逐轮计价'),
2860
+ value: allStats ? moneyApprox(allStats.totalCost) : '—',
2861
+ hint: (accountPrice.known ? modelDetail(modelFilter || accountModel) : tt('{id}(按 flash 估算)', { id: accountPrice.id })) + tt(' · 全部历史逐轮计价'),
2143
2862
  }),
2144
2863
  h(Card, {
2145
2864
  label: tt('当前时段'),
@@ -2197,10 +2916,41 @@ window.__ModuleLoader__.load({
2197
2916
  : null)),
2198
2917
  h('div', { className: 'dcb-field' },
2199
2918
  h('span', { className: 'dcb-field-label' }, tt('数据')),
2200
- h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setReloadTick(function (v) { return v + 1 }) } }, tt('刷新')))),
2919
+ h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setReloadTick(function (v) { return v + 1 }) } }, tt('刷新'))),
2920
+ // 模型筛选:只看某个模型的消耗;同时作为「日志查不到模型」时的计价基准。
2921
+ h('div', { className: 'dcb-field' },
2922
+ h('label', { className: 'dcb-field-label', htmlFor: 'dcb-model-select' }, tt('模型')),
2923
+ h('select', {
2924
+ id: 'dcb-model-select',
2925
+ className: 'dcb-select',
2926
+ value: modelFilter,
2927
+ onChange: function (event) { store.setModelFilter(event.target.value) },
2928
+ },
2929
+ h('option', { key: 'all', value: '' }, tt('全部模型')),
2930
+ modelOptionNodes(MODELS))),
2931
+ // 显示币种:报价表永远是人民币,这里只决定展示时换算成什么货币。
2932
+ h('div', { className: 'dcb-field' },
2933
+ h('label', { className: 'dcb-field-label', htmlFor: 'dcb-currency-select' }, tt('货币')),
2934
+ h('select', {
2935
+ id: 'dcb-currency-select',
2936
+ className: 'dcb-select',
2937
+ value: currency,
2938
+ onChange: function (event) { store.setCurrency(event.target.value) },
2939
+ },
2940
+ CURRENCIES.map(function (c) {
2941
+ return h('option', { key: c.code, value: c.code }, c.code + ' ' + c.symbol.trim() + ' · ' + currencyName(c.code))
2942
+ })))),
2943
+ h('div', { className: 'dcb-set-note dcb-rate-note' }, rateNote),
2201
2944
  h('div', { className: 'dcb-set-body' },
2202
2945
  h('div', { className: 'dcb-hist-legend' },
2203
- h('span', { className: 'dcb-tog dcb-tog-cost is-on' }, h('i', null), tt('估算金额(右轴)')),
2946
+ // 每个模型一枚颜色 chip(与折线同色),右侧是 Token 轴开关。
2947
+ legendModels.length > 0
2948
+ ? legendModels.map(function (key) {
2949
+ return h('span', { key: 'lg' + key, className: 'dcb-legend-chip', title: modelDetail(key) },
2950
+ h('i', { className: 'dcb-legend-dot', style: { background: modelColor(key) } }),
2951
+ modelLabel(key))
2952
+ })
2953
+ : h('span', { className: 'dcb-tog dcb-tog-cost is-on' }, h('i', null), tt('估算金额(右轴)')),
2204
2954
  h('button', {
2205
2955
  type: 'button',
2206
2956
  className: showTokens ? 'dcb-tog is-on' : 'dcb-tog',
@@ -2211,11 +2961,11 @@ window.__ModuleLoader__.load({
2211
2961
  : h('div', { className: 'dcb-hist-empty' }, tt('该范围内暂无消耗记录'))),
2212
2962
  hasData
2213
2963
  ? h('div', { className: 'dcb-sum' },
2214
- h('span', null, tt('总消耗 '), h('b', null, '≈¥' + fmtYuan(stats.totalCost))),
2964
+ h('span', null, tt('总消耗 '), h('b', null, moneyApprox(stats.totalCost))),
2215
2965
  h('span', null, tt('总 Token '), h('b', null, fmtTokens(stats.totalTokens))),
2216
2966
  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))),
2967
+ h('span', null, tt('峰时 '), h('b', null, moneyApprox(stats.peakCost))),
2968
+ h('span', null, tt('谷时 '), h('b', null, moneyApprox(stats.valleyCost))),
2219
2969
  h('span', null, tt('粒度 '), h('b', null, tt(stats.granularity === 'month' ? '按月' : (stats.granularity === 'hour' ? '按小时' : '按日')))))
2220
2970
  : null,
2221
2971
  h('div', { className: 'dcb-hist-aside' },
@@ -2620,6 +3370,7 @@ window.__ModuleLoader__.load({
2620
3370
  .dcb-pill:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}
2621
3371
  .dcb-pill-glyph{font-size:11px;line-height:1}
2622
3372
  .dcb-pill-sep{opacity:.35}
3373
+ .dcb-pill-balance{font-variant-numeric:tabular-nums;max-width:160px;overflow:hidden;text-overflow:ellipsis}
2623
3374
  .dcb-tag{font-weight:600}
2624
3375
  .dcb-tag.is-peak{color:var(--dsw-alias-state-error-primary)}
2625
3376
  .dcb-tag.is-valley{color:var(--dsw-alias-state-success-primary)}
@@ -2660,6 +3411,14 @@ window.__ModuleLoader__.load({
2660
3411
  .dcb-legend-item{display:inline-flex;align-items:center;gap:6px}
2661
3412
  .dcb-legend-dot{display:inline-block;width:14px;height:3px;border-radius:2px;background:var(--dsw-alias-brand-primary)}
2662
3413
  .dcb-legend-dot.dcb-dot-cost{background:var(--dsw-alias-state-success-primary)}
3414
+ /* 图例里的模型 chip(与折线同色) */
3415
+ .dcb-legend-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}
3416
+ .dcb-legend-chip .dcb-legend-dot{width:9px;height:9px;border-radius:50%}
3417
+ /* 弹层标题栏里的模型下拉 */
3418
+ .dcb-select-inline{height:22px;font-size:11px;min-width:118px;max-width:190px;padding:0 6px;border-radius:6px}
3419
+ .dcb-select-inline:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
3420
+ /* tooltip 里的模型色点 */
3421
+ .dcb-model-dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px;vertical-align:middle}
2663
3422
  .dcb-chart-body{position:relative}
2664
3423
  .dcb-chart-svg{display:block;width:100%;height:216px}
2665
3424
  .dcb-grid-line{stroke:var(--dsw-alias-border-l1);stroke-width:1;opacity:.7;vector-effect:non-scaling-stroke}
@@ -2690,6 +3449,10 @@ window.__ModuleLoader__.load({
2690
3449
  .dcb-field-label{font-size:11px;color:var(--dsw-alias-label-secondary)}
2691
3450
  .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
3451
  .dcb-select{min-width:132px;cursor:pointer}
3452
+ /* 币种下拉要放「代码 + 符号 + 全名」,给它多一点宽度 */
3453
+ #dcb-currency-select{min-width:196px;max-width:300px}
3454
+ /* 汇率说明行会显示汇率 / 来源 / 时间戳,允许换行 */
3455
+ .dcb-rate-note{white-space:normal;line-height:16px;margin:-6px 0 12px}
2693
3456
  .dcb-date{min-width:126px}
2694
3457
  .dcb-select:focus,.dcb-date:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
2695
3458
  .dcb-range{display:flex;align-items:center;gap:6px}