From c45b600d47aa86b3a152d9a2c430876d42618cb2 Mon Sep 17 00:00:00 2001 From: XinSpark Date: Mon, 19 Oct 2026 00:46:22 +0800 Subject: [PATCH 01/12] fix(ui): keep the model dropdown usable and unclipped Ticking one model collapsed the candidate list to that single entry: the query loop registered a model in `models` only after the selection filter, so the response advertised just the selected key. Register the candidate first, keep the aggregate filter where it was, and ship the composite `key` on each entry so the client stops re-deriving `provider/model`. That second half also fixes a silent blank report: with an empty provider the client built `/model`, which matched nothing, so total/daily/byModel all came back 0. The filter popovers were `position: absolute` inside the settings panel, which is `overflow: hidden`, so their right half was clipped. They now sit in a measured `position: fixed` panel that flips up when the space below is too tight, caps its height, and re-measures on scroll/resize. The model list declares `min-height: 0` so it shrinks instead of being cut, and the popover declares border-box so `width: 320px` really is 320px. --- src/client/index.js | 76 +++++++++++++++++++++++++++++++++++++++++++-- src/host/query.js | 9 ++++-- tests/query.test.js | 51 +++++++++++++++++++++++++++--- 3 files changed, 125 insertions(+), 11 deletions(-) diff --git a/src/client/index.js b/src/client/index.js index 0265c03..cae7f84 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -163,9 +163,15 @@ const CSS = ` box-shadow: var(--dsw-shadow-lv3); } .tsd-model-popover { + box-sizing: border-box; width: 320px; max-width: calc(100vw - 24px); } +/* 浮层高度由 useFixedPanel 的 maxHeight 决定:只有列表可伸缩,其余部分不压缩 */ +.tsd-model-popover > .tsd-popover-tools, +.tsd-model-popover > .tsd-search, +.tsd-model-popover > .tsd-popover-note, +.tsd-model-popover > .tsd-popover-empty { flex: none; } .tsd-range-popover { width: 300px; max-width: calc(100vw - 24px); @@ -273,6 +279,8 @@ const CSS = ` .tsd-model-list { display: flex; flex-direction: column; + flex: 1 1 auto; + min-height: 0; gap: 2px; max-height: 240px; overflow-y: auto; @@ -1468,6 +1476,58 @@ function useOutsideClick(open, ref, onClose) { }, [open, onClose, ref]) } +/* --------------------------------------------------------------------------- + * 通用 Hook:浮层定位(viewport 定位 + 空间不足上翻 + 高度封顶) + * + * 设置面板本身是 `overflow: hidden` 的裁剪容器,贴着过滤栏右端的 absolute + * 浮层会被整条裁掉(右侧的「清空」看不见)。CSS 无法感知祖先裁剪,只能量一次 + * 视口再定位;滚动或缩放时重算,避免浮层留在原地。 + * ------------------------------------------------------------------------- */ +const PANEL_MIN_ROOM = 240 // 下方空间小于它且上方更宽裕时上翻 +const PANEL_MIN_HEIGHT = 160 + +function useFixedPanel(anchorRef, open, width, gap = 6, margin = 12) { + const [style, setStyle] = React.useState(null) + React.useEffect(() => { + if (!open) { + setStyle(null) + return undefined + } + function measure() { + const el = anchorRef.current + if (!el) return + const r = el.getBoundingClientRect() + const boxW = Math.min(width, window.innerWidth - margin * 2) + const left = Math.max(margin, Math.min(r.left, window.innerWidth - margin - boxW)) + const below = window.innerHeight - r.bottom - gap - margin + const above = r.top - gap - margin + const flip = below < PANEL_MIN_ROOM && above > below + setStyle({ + position: 'fixed', + left, + width: boxW, + maxHeight: Math.max(PANEL_MIN_HEIGHT, flip ? above : below), + ...(flip + ? { bottom: window.innerHeight - r.top + gap } + : { top: r.bottom + gap }), + display: 'flex', + flexDirection: 'column', + zIndex: 40, + }) + } + measure() + window.addEventListener('resize', measure) + window.addEventListener('scroll', measure, true) + return () => { + window.removeEventListener('resize', measure) + window.removeEventListener('scroll', measure, true) + } + }, [open, anchorRef, width, gap, margin]) + if (!open) return null + // 首帧还没量到尺寸:先藏起来,否则会闪到左上角 + return style || { position: 'fixed', top: 0, left: 0, visibility: 'hidden' } +} + /* --------------------------------------------------------------------------- * Header:标题 + 副标题 + 刷新按钮 * ------------------------------------------------------------------------- */ @@ -1491,8 +1551,9 @@ function StatsHeader(props) { * 自定义日期范围浮层 * ------------------------------------------------------------------------- */ function CustomRangePopover(props) { - const { custom, onApply, onClose } = props + const { custom, onApply, onClose, anchorRef } = props const t = useT() + const panelStyle = useFixedPanel(anchorRef, true, 300) const [from, setFrom] = React.useState(custom.from) const [to, setTo] = React.useState(custom.to) const invalid = !from || !to || from > to @@ -1506,6 +1567,7 @@ function CustomRangePopover(props) { }, [onClose]) return h('div', { className: 'tsd-popover tsd-range-popover', + style: panelStyle, role: 'dialog', 'aria-label': t('customRangeAria'), }, @@ -1545,6 +1607,7 @@ function ModelDropdown(props) { const [open, setOpen] = React.useState(false) const [query, setQuery] = React.useState('') const anchorRef = React.useRef(null) + const panelStyle = useFixedPanel(anchorRef, open, 320) React.useEffect(() => { if (!open) return undefined @@ -1563,8 +1626,10 @@ function ModelDropdown(props) { }, [open]) // 只列出当前范围有数据的模型;已选但掉出候选列表的「补回」,保证可渲染、可清除 + // 候选键直接用宿主给好的 `key`(compositeModelKey 的结果):客户端不再自己拼 + // `provider + '/' + model`,provider 为空时那种拼法会得到 `/model` 而永远选不中。 const candidates = React.useMemo(() => { - const set = new Set((options || []).map((m) => m.provider + '/' + m.model)) + const set = new Set((options || []).map((m) => m.key || (m.provider ? m.provider + '/' + m.model : m.model))) ;(selected || []).forEach((m) => set.add(m)) const list = Array.from(set) list.sort() @@ -1589,6 +1654,7 @@ function ModelDropdown(props) { open ? h('div', { className: 'tsd-popover tsd-model-popover', + style: panelStyle, role: 'listbox', 'aria-label': t('modelFilterGroup'), 'aria-multiselectable': true, @@ -1638,6 +1704,7 @@ function FilterBar(props) { } = props const t = useT() const segRef = React.useRef(null) + const customBtnRef = React.useRef(null) useOutsideClick(customOpen, segRef, onCustomClose) const customLabel = fmtDateSlash(custom.from) + ' - ' + fmtDateSlash(custom.to) @@ -1658,6 +1725,7 @@ function FilterBar(props) { return h('button', { key: p.key, type: 'button', + ref: isCustom ? customBtnRef : undefined, className: 'tsd-seg-btn' + (active ? ' tsd-seg-active' : '') + (isCustom ? ' tsd-seg-custom' : ''), 'aria-pressed': active, title: isCustom && active ? customLabel : undefined, @@ -1665,7 +1733,9 @@ function FilterBar(props) { }, h('span', { className: 'tsd-seg-label' }, label)) })), - customOpen ? h(CustomRangePopover, { custom, onApply: onCustomApply, onClose: onCustomClose }) : null), + customOpen + ? h(CustomRangePopover, { custom, onApply: onCustomApply, onClose: onCustomClose, anchorRef: customBtnRef }) + : null), h(ModelDropdown, { options: modelOptions, selected: models, onChange: onModelsChange })) } diff --git a/src/host/query.js b/src/host/query.js index 1038103..b790ff0 100644 --- a/src/host/query.js +++ b/src/host/query.js @@ -57,7 +57,7 @@ export function query(state, args) { const dailyMap = new Map(); const modelMap = new Map(); const total = emptyBucket('total'); - const seenModels = new Map(); // composite key -> {provider, model} (first composite wins) + const seenModels = new Map(); // composite key -> {key, provider, model} (first composite wins) let unpricedCalls = 0; // Range-anchored day-step grid: blocks start at the range's first day. const dayAnchor = Number.isFinite(from) ? alignFloor(from, 'day') : null; @@ -65,11 +65,14 @@ export function query(state, args) { for (const r of state.index ? state.index.recordsInRange(from, to) : state.records) { if (r.time < from || r.time > to) continue; + // Candidate list first, selection filter second: the dropdown must offer + // every model that has data in the range, not only the ones already ticked + // (ticking one used to collapse the list to that single entry). + const modelKey = compositeModelKey(r); + if (!seenModels.has(modelKey)) seenModels.set(modelKey, { key: modelKey, provider: r.provider, model: r.model }); if (!modelMatches(r)) continue; const cost = state.prices.costOf(r); if (r.totalTokens > 0 && !state.prices.hasPrice(r)) unpricedCalls += 1; - const modelKey = compositeModelKey(r); - if (!seenModels.has(modelKey)) seenModels.set(modelKey, { provider: r.provider, model: r.model }); const dk = useStepGrid ? stepDayKey(r, dayAnchor, stepDays) : bucketKeyFor(r.time, granularity, stepDays); let db = dailyMap.get(dk); diff --git a/tests/query.test.js b/tests/query.test.js index 432b103..bfa0d9e 100644 --- a/tests/query.test.js +++ b/tests/query.test.js @@ -54,8 +54,8 @@ test('totals, daily and byModel over the 3-record fixture', () => { assert.deepEqual(res.byModel.map((b) => b.totalTokens), [3000, 2500]); assert.deepEqual(res.byModel.map((b) => b.cost), [0.007, 0.0035]); assert.deepEqual(res.models, [ - { provider: 'anthropic', model: 'claude' }, - { provider: 'openai', model: 'gpt' }, + { key: 'anthropic/claude', provider: 'anthropic', model: 'claude' }, + { key: 'openai/gpt', provider: 'openai', model: 'gpt' }, ]); assert.equal(res.unpricedCalls, 0); assert.deepEqual(res.backfill, { status: 'idle', total: 0, done: 0 }); @@ -132,9 +132,9 @@ test('byModel keeps providers distinct via composite keys when providers share a assert.equal(keys.some((k) => k === 'claude'), false); // no bare-model rows assert.equal(res.byModel.find((b) => b.key === 'acme/claude').calls, 1); assert.deepEqual(res.models, [ - { provider: 'anthropic', model: 'claude' }, - { provider: 'openai', model: 'gpt' }, - { provider: 'acme', model: 'claude' }, + { key: 'anthropic/claude', provider: 'anthropic', model: 'claude' }, + { key: 'openai/gpt', provider: 'openai', model: 'gpt' }, + { key: 'acme/claude', provider: 'acme', model: 'claude' }, ]); }); @@ -150,3 +150,44 @@ test('unpriced uses hasPrice: a priced-zero model is not unpriced, a missing pri assert.equal(res.total.calls, 5); assert.equal(res.byModel.find((b) => b.key === 'free-zero').cost, 0); // priced row, cost 0 }); + +test('models lists every in-range model even when a selection filters the aggregates', () => { + const state = makeState(); + const res = query(state, { + from: Date.UTC(2025, 0, 1), + to: Date.UTC(2025, 1, 1), + models: ['anthropic/claude'], + }); + // The dropdown is fed from `models`: it must not collapse to the selection. + assert.deepEqual(res.models.map((m) => m.key), ['anthropic/claude', 'openai/gpt']); + // Aggregates stay scoped to the selection. + assert.deepEqual(res.byModel.map((b) => b.key), ['anthropic/claude']); + assert.equal(res.total.calls, 2); + assert.deepEqual(res.daily.map((b) => b.key), ['2025-01-02', '2025-01-04']); +}); + +test('models entries carry the composite key, including for an empty provider', () => { + const state = makeState([ + { time: Date.UTC(2025, 0, 6, 9, 0), session: 's1', provider: '', model: 'solo', turn: 4, step: 0, inputTokens: 100, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, totalTokens: 100 }, + ]); + const res = query(state, { from: Date.UTC(2025, 0, 1), to: Date.UTC(2025, 1, 1) }); + assert.deepEqual(res.models, [{ key: 'solo', provider: '', model: 'solo' }]); + // Feeding that key back must select the record (a `provider + '/' + model` + // client-side key would produce '/solo' and silently match nothing). + const picked = query(state, { + from: Date.UTC(2025, 0, 1), + to: Date.UTC(2025, 1, 1), + models: ['solo'], + }); + assert.equal(picked.total.calls, 1); + assert.equal(picked.total.totalTokens, 100); +}); + +test('models stays inside the requested time range', () => { + const state = makeState([ + ...RECORDS, + { time: Date.UTC(2024, 11, 20, 9, 0), session: 's1', provider: 'acme', model: 'old', turn: 1, step: 0, inputTokens: 10, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, totalTokens: 10 }, + ]); + const res = query(state, { from: Date.UTC(2025, 0, 1), to: Date.UTC(2025, 1, 1) }); + assert.equal(res.models.some((m) => m.key === 'acme/old'), false); +}); From 2205a2e9f263e69f2fb22a59ad1eddff2db4901b Mon Sep 17 00:00:00 2001 From: XinSpark Date: Mon, 19 Oct 2026 00:47:56 +0800 Subject: [PATCH 02/12] feat(ui): add an "All" range and drop the fake 30-day limit The custom-range popover said "date only, up to 30 days", but nothing enforced it: the host only checks that the bounds are numbers, the date inputs carry no min/max, and the one real limit is MAX_BUCKETS (2000 output buckets, only reached while zero-filling). The hint was the only thing that looked like a limit. Add an "all" preset that starts at 0 and asks for present-only buckets (fill: false), so it never zero-fills 1970..today and never trips MAX_BUCKETS. Custom ranges keep the existing day-step collapse, which governs chart density rather than correctness; the hint now says so plainly. --- src/client/index.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/client/index.js b/src/client/index.js index cae7f84..77d1ce4 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -1133,6 +1133,7 @@ const ZH = { last7d: '近7天', last30d: '近30天', custom: '自定义', + all: '全部', allModels: '全部模型', selectedModels: '已选 {count} 个模型', selectAll: '全选', @@ -1145,7 +1146,7 @@ const ZH = { startTime: '开始时间', endTime: '结束时间', rangeInvalid: '开始时间不能晚于结束时间', - rangeHint: '支持选择日期,最长 30 天', + rangeHint: '支持选择任意日期范围,不限跨度', cancel: '取消', confirm: '确定', dateRangeGroup: '日期范围', @@ -1216,6 +1217,7 @@ const EN = { last7d: 'Last 7 days', last30d: 'Last 30 days', custom: 'Custom', + all: 'All', allModels: 'All models', selectedModels: '{count} models selected', selectAll: 'Select all', @@ -1228,7 +1230,7 @@ const EN = { startTime: 'Start', endTime: 'End', rangeInvalid: 'Start must not be later than end', - rangeHint: 'Date only, up to 30 days', + rangeHint: 'Any date range — no length limit', cancel: 'Cancel', confirm: 'OK', dateRangeGroup: 'Date range', @@ -1357,13 +1359,17 @@ function fmtDateSlash(s) { return m[1] + '/' + m[2] + '/' + m[3] } // 预设 / 自定义 → { from, to }(Unix 毫秒) -// 今日 = 当天 00:00 至今;近7/30天 = 含今天往前 N 个自然日 00:00 起;自定义 = 起日 00:00 ~ 止日 23:59:59.999 +// 今日 = 当天 00:00 至今;近7/30天 = 含今天往前 N 个自然日 00:00 起;全部 = 不限起点; +// 自定义 = 起日 00:00 ~ 止日 23:59:59.999 function rangeFor(preset, custom) { const now = new Date() const today = startOfDay(now) if (preset === 'today') return { from: today.getTime(), to: now.getTime() } if (preset === '7d') return { from: addDays(today, -6).getTime(), to: now.getTime() } if (preset === '30d') return { from: addDays(today, -29).getTime(), to: now.getTime() } + // 全量:0 是有限值,宿主的 fill 判定认它;调用方必须同时传 fill:false, + // 否则会去零填充 1970 年至今、撞上 MAX_BUCKETS 退化成只看有数据的桶。 + if (preset === 'all') return { from: 0, to: now.getTime() } const from = new Date(custom.from + 'T00:00:00').getTime() const to = new Date(custom.to + 'T23:59:59.999').getTime() if (!Number.isFinite(from) || !Number.isFinite(to)) { @@ -1712,6 +1718,7 @@ function FilterBar(props) { { key: 'today', label: t('today') }, { key: '7d', label: t('last7d') }, { key: '30d', label: t('last30d') }, + { key: 'all', label: t('all') }, { key: 'custom', label: t('custom') }, ] @@ -2351,7 +2358,8 @@ function StatsDashboard(props) { from: eff.from, to: eff.to, models: models.slice(), - fill: true, + // 「全部」不做零填充:真实边界是输出桶数(宿主 MAX_BUCKETS),不是天数。 + fill: range !== 'all', granularity: effGran, stepDays, heatmap: true, From 1c12e9c2fdf578c76cb90a337fc3189d2a49ac18 Mon Sep 17 00:00:00 2001 From: XinSpark Date: Mon, 19 Oct 2026 00:52:07 +0800 Subject: [PATCH 03/12] feat(usage): let the reference cost be shown in CNY or USD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `currency` was a bare display symbol with no conversion behind it, while the default table is DeepSeek's USD list price per 1M tokens — printed with a ¥ sign, so the visible CNY number sat about 7x below the real CNY list (0.27 / 1.10 / 0.07 USD is 2 / 8 / 0.5 CNY at 7:1). The table is now documented as USD and never rewritten: `query` accepts a currency, converts once at the output boundary (daily, byModel, total and heatmap, a single rounding at 1e-6 so the sums stay exact), and returns the currency it actually used. CNY/USD resolve through a fixed 7:1 table; unknown or empty values fall back to the configured currency instead of throwing. Client side, the reference-cost card carries a ¥/$ picker in its value row. The choice is remembered locally (a display preference, not plugin config) and triggers a re-query, because conversion happens host-side. Amounts below half a cent render "<0.01" instead of a misleading 0.00. --- src/client/index.js | 155 +++++++++++++++++++++++++++++++++++++++++-- src/host/query.js | 14 ++++ src/host/settings.js | 37 +++++++++++ tests/query.test.js | 40 +++++++++-- 4 files changed, 235 insertions(+), 11 deletions(-) diff --git a/src/client/index.js b/src/client/index.js index 77d1ce4..dae0ff7 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -309,6 +309,58 @@ const CSS = ` min-width: 0; } +/* ============ 币种切换(参考成本卡) ============ */ +.tsd-cur-anchor { + position: relative; + display: inline-flex; + margin-right: 6px; + vertical-align: baseline; +} +.tsd-cur-btn { + display: inline-flex; + align-items: center; + gap: 2px; + height: 24px; + padding: 0 4px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 15px; + font-weight: 600; + line-height: 1; + cursor: pointer; +} +.tsd-cur-btn:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} +.tsd-cur-popover { + box-sizing: border-box; + width: 150px; + padding: 6px; +} +.tsd-cur-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} +.tsd-cur-option:hover { background: var(--dsw-alias-interactive-bg-hover); } +.tsd-cur-option-active { background: var(--dsw-alias-interactive-bg-hover); } +.tsd-cur-symbol { font-family: var(--ds-font-family-code); } +.tsd-cur-name { color: var(--dsw-alias-label-secondary); font-size: 12px; } + /* ============ 自定义日期浮层 ============ */ .tsd-range-fields { display: flex; @@ -1129,6 +1181,9 @@ const ZH = { nav: '用量统计', subtitle: '查看 AI 模型的使用情况和成本统计', refresh: '刷新', + currencyHint: '参考成本币种(固定汇率 1 USD = 7 CNY)', + currencyCNY: '人民币', + currencyUSD: '美元', today: '今日', last7d: '近7天', last30d: '近30天', @@ -1213,6 +1268,9 @@ const EN = { nav: 'Usage Stats', subtitle: 'View AI model usage and cost statistics', refresh: 'Refresh', + currencyHint: 'Reference-cost currency (fixed rate: 1 USD = 7 CNY)', + currencyCNY: 'CNY', + currencyUSD: 'USD', today: 'Today', last7d: 'Last 7 days', last30d: 'Last 30 days', @@ -1320,7 +1378,11 @@ function formatInt(n) { } function formatCost(n, cur) { if (!Number.isFinite(n)) return '--' - return (cur || '¥') + n.toFixed(2) + const c = cur || '¥' + // Below half a cent there is nothing to show at 2 decimals; say so instead of + // printing a misleading 0.00 (a $0.03 CNY cost becomes $0.004). + if (n > 0 && n < 0.005) return c + '<0.01' + return c + n.toFixed(2) } function dayLabel(key) { const [, m, d] = String(key || '').split('-') @@ -1746,11 +1808,74 @@ function FilterBar(props) { h(ModelDropdown, { options: modelOptions, selected: models, onChange: onModelsChange })) } +/* --------------------------------------------------------------------------- + * 参考成本币种切换(人民币 / 美元,固定 1 USD = 7 CNY) + * + * 换算在宿主侧完成(价目表是美元价,绝不回写),所以这里只负责选择与持久化: + * 换币种即换查询,不重查数字不会变。 + * ------------------------------------------------------------------------- */ +const CURRENCY_SYMBOLS = { CNY: '¥', USD: '$' } +const CURRENCY_STORE_KEY = 'dsh-plugin-usage:display-currency' + +function readStoredCurrency() { + try { + return window.localStorage.getItem(CURRENCY_STORE_KEY) === 'USD' ? 'USD' : 'CNY' + } catch (e) { + return 'CNY' // 无痕模式 / 存储被禁:退回默认 + } +} +function storeCurrency(code) { + try { + window.localStorage.setItem(CURRENCY_STORE_KEY, code) + } catch (e) { + // 存不下就只影响下次打开,不影响本次显示 + } +} + +function CurrencyPicker(props) { + const { code, onChange } = props + const t = useT() + const [open, setOpen] = React.useState(false) + const ref = React.useRef(null) + const panelStyle = useFixedPanel(ref, open, 150) + useOutsideClick(open, ref, () => setOpen(false)) + const active = CURRENCY_SYMBOLS[code] ? code : 'CNY' + return h('span', { className: 'tsd-cur-anchor', ref }, + h('button', { + type: 'button', + className: 'tsd-cur-btn', + title: t('currencyHint'), + 'aria-label': t('currencyHint'), + 'aria-haspopup': 'listbox', + 'aria-expanded': open, + onClick: () => setOpen((v) => !v), + }, + h('span', { className: 'tsd-cur-symbol' }, CURRENCY_SYMBOLS[active]), + h(SvgIcon, { name: 'chevron', size: 11, className: 'tsd-chevron' + (open ? ' tsd-chevron-open' : '') })), + open + ? h('div', { + className: 'tsd-popover tsd-cur-popover', + style: panelStyle, + role: 'listbox', + 'aria-label': t('currencyHint'), + }, ['CNY', 'USD'].map((c) => h('button', { + key: c, + type: 'button', + role: 'option', + 'aria-selected': c === active, + className: 'tsd-cur-option' + (c === active ? ' tsd-cur-option-active' : ''), + onClick: () => { onChange(c); setOpen(false) }, + }, + h('span', { className: 'tsd-cur-symbol' }, CURRENCY_SYMBOLS[c]), + h('span', { className: 'tsd-cur-name' }, t(c === 'CNY' ? 'currencyCNY' : 'currencyUSD'))))) + : null) +} + /* --------------------------------------------------------------------------- * 汇总指标卡 ×5 * ------------------------------------------------------------------------- */ function SummaryCards(props) { - const { total, currency, unpricedCalls } = props + const { total, currency, unpricedCalls, currencyCode, onCurrencyChange } = props const tt = useT() const d = total || {} const inputTotal = (d.inputTokens || 0) + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0) @@ -1779,6 +1904,7 @@ function SummaryCards(props) { key: 'cost', icon: 'coins', label: tt('cost'), value: formatCost(d.cost, currency), title: formatCost(d.cost, currency), color: 'var(--dsw-static-green-500)', + prefix: h(CurrencyPicker, { code: currencyCode, onChange: onCurrencyChange }), }, ] return h('div', { className: 'tsd-cards' }, @@ -1788,7 +1914,7 @@ function SummaryCards(props) { h('span', { className: 'tsd-card-icon', style: { color: c.color } }, h(SvgIcon, { name: c.icon, size: 14 })), h('span', { className: 'tsd-card-label' }, c.label)), - h('div', { className: 'tsd-card-value', title: c.title }, c.value), + h('div', { className: 'tsd-card-value', title: c.title }, c.prefix || null, c.value), c.sub ? h('div', { className: 'tsd-card-sub' }, c.sub) : null, c.key === 'cost' && unpricedCalls > 0 ? h('div', { className: 'tsd-card-note' }, tt('unpricedNote', { count: formatInt(unpricedCalls) })) @@ -2328,6 +2454,9 @@ function StatsDashboard(props) { const [data, setData] = React.useState(null) const [error, setError] = React.useState(null) const [prices, setPrices] = React.useState({ prices: {}, currency: '¥' }) + // 展示币种:宿主按它换算成本,所以换币种必须重查;选择记在本地(展示偏好, + // 不写插件设置,免得每次取数都在改写用户配置)。 + const [displayCurrency, setDisplayCurrency] = React.useState(readStoredCurrency) // P1-6 backfill 轮询状态:queryKey 变化时重置,仅对同一查询串轮询 const queryKeyRef = React.useRef(null) const backfillSeenRef = React.useRef(false) @@ -2345,7 +2474,7 @@ function StatsDashboard(props) { React.useEffect(() => { let current = true let backfillTimer = null - const queryKey = eff.from + '|' + eff.to + '|' + models.join(',') + '|' + effGran + '|' + stepDays + const queryKey = eff.from + '|' + eff.to + '|' + models.join(',') + '|' + effGran + '|' + stepDays + '|' + displayCurrency if (queryKeyRef.current !== queryKey) { queryKeyRef.current = queryKey backfillSeenRef.current = false @@ -2363,6 +2492,7 @@ function StatsDashboard(props) { granularity: effGran, stepDays, heatmap: true, + currency: displayCurrency, }) .then((r) => { if (!current) return @@ -2390,7 +2520,7 @@ function StatsDashboard(props) { current = false if (backfillTimer !== null) clearTimeout(backfillTimer) } - }, [eff.from, eff.to, models, effGran, stepDays, retry]) + }, [eff.from, eff.to, models, effGran, stepDays, displayCurrency, retry]) // 价格表:进入页面拉一次 + 每次刷新同步。 // P0-1:优先官方 settings scope(usage.prices 命名空间),不可用时回退 RPC。 @@ -2441,6 +2571,15 @@ function StatsDashboard(props) { setRange('today') }, []) + const changeCurrency = React.useCallback((code) => { + storeCurrency(code) + setDisplayCurrency(code) + }, []) + // 符号以宿主回传的实际币种为准(切换后立刻重查,不会出现「换了标志没换数字」); + // 老宿主没有该字段时按本地选择兜底。 + const currencySymbol = (data && data.currency && data.currency.symbol) + || CURRENCY_SYMBOLS[displayCurrency] || prices.currency || '¥' + const busy = status === 'loading' || status === 'refreshing' const ready = data !== null const isError = status === 'error' @@ -2479,8 +2618,10 @@ function StatsDashboard(props) { ? h('div', { className: 'tsd-content', 'data-busy': busy ? 'true' : undefined }, h(SummaryCards, { total: data.total, - currency: prices.currency || '¥', + currency: currencySymbol, unpricedCalls: data.unpricedCalls || 0, + currencyCode: displayCurrency, + onCurrencyChange: changeCurrency, }), h(DailyChart, { daily: data.daily, @@ -2489,7 +2630,7 @@ function StatsDashboard(props) { granLabel, }), h(HeatmapCard, { days: data.heatmap || [] }), - h(ModelTable, { rows: data.byModel, currency: prices.currency || '¥' })) + h(ModelTable, { rows: data.byModel, currency: currencySymbol })) : null)) } diff --git a/src/host/query.js b/src/host/query.js index b790ff0..0128b58 100644 --- a/src/host/query.js +++ b/src/host/query.js @@ -7,6 +7,7 @@ import { dayKey, DAY_MS, bucketKeyFor, stepMs, alignFloor, stepDayKey, MAX_BUCKETS, } from './time.js'; +import { currencyOf } from './settings.js'; function emptyBucket(key) { return { @@ -151,12 +152,25 @@ export function query(state, args) { } } + // Display currency: the price table is denominated in USD and stays as it is + // (it is shared, polled, and never mutated per request). Conversion happens + // here, once, at the output boundary — including the heatmap and the totals, + // so no two views of the same data disagree. Six decimals keep float tails + // out of the sums. + const money = currencyOf(args.currency, state.prices.currency); + const toDisplay = (v) => (money.perUsd === 1 ? v : Math.round(v * money.perUsd * 1e6) / 1e6); + for (const b of daily) b.cost = toDisplay(b.cost); + for (const b of byModel) b.cost = toDisplay(b.cost); + total.cost = toDisplay(total.cost); + if (heatmap !== null) for (const b of heatmap) b.cost = toDisplay(b.cost); + return { daily, byModel, total, models, unpricedCalls, + currency: { code: money.code, symbol: money.symbol }, ...heatmap === null ? {} : { heatmap }, backfill: { status: state.backfill.status, total: state.backfill.total, done: state.backfill.done }, }; diff --git a/src/host/settings.js b/src/host/settings.js index 5ab968f..968f584 100644 --- a/src/host/settings.js +++ b/src/host/settings.js @@ -12,6 +12,43 @@ * The store below stays the in-memory view the aggregation code reads. */ export const DEFAULT_CURRENCY = '¥'; +/** + * Currency the price table is denominated in. + * + * DEFAULT_PRICES carries DeepSeek's USD list price per 1M tokens (0.27 input / + * 1.10 output / 0.07 cache-read for `deepseek-chat`, which is exactly 2 / 8 / + * 0.5 CNY at the fixed 7:1). The table is never rewritten at runtime: a display + * switch converts on the way out, so a query can never pollute the next one. + */ +export const PRICE_UNIT_CURRENCY = 'USD'; +/** Display currencies; `perUsd` is how many units of it one USD buys. */ +export const CURRENCIES = { + USD: { code: 'USD', symbol: '$', perUsd: 1 }, + CNY: { code: 'CNY', symbol: '¥', perUsd: 7 }, +}; + +function currencyCodeOf(value) { + const raw = typeof value === 'string' ? value.trim().toUpperCase() : ''; + if (raw === 'USD' || raw === '$' || raw === 'US$') return 'USD'; + if (raw === 'CNY' || raw === '¥' || raw === 'RMB' || raw === 'CN¥') return 'CNY'; + return ''; +} + +/** + * Resolve a configured currency (ISO code or the bare symbol the store has + * always held) to a CURRENCIES entry. Unknown values fall back to `fallback`, + * then to the default display currency — never throw on user input. + * + * @param {unknown} value + * @param {unknown} [fallback] + */ +export function currencyOf(value, fallback) { + const hit = CURRENCIES[currencyCodeOf(value)]; + if (hit) return hit; + const fb = CURRENCIES[currencyCodeOf(fallback)]; + if (fb) return fb; + return CURRENCIES[currencyCodeOf(DEFAULT_CURRENCY)] || CURRENCIES.CNY; +} export const DEFAULT_PRICES = { 'deepseek-chat': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 }, 'deepseek-v3': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 }, diff --git a/tests/query.test.js b/tests/query.test.js index bfa0d9e..d37b526 100644 --- a/tests/query.test.js +++ b/tests/query.test.js @@ -42,17 +42,17 @@ test('totals, daily and byModel over the 3-record fixture', () => { cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 5500, - cost: 0.0105, + cost: 0.0735, }); assert.deepEqual(res.daily.map((b) => b.key), ['2025-01-02', '2025-01-03', '2025-01-04']); assert.deepEqual(res.daily.map((b) => b.calls), [1, 1, 1]); assert.deepEqual(res.daily.map((b) => b.totalTokens), [1500, 3000, 1000]); - assert.deepEqual(res.daily.map((b) => b.cost), [0.002, 0.007, 0.0015]); + assert.deepEqual(res.daily.map((b) => b.cost), [0.014, 0.049, 0.0105]); // byModel rows are keyed by provider/model composite. assert.deepEqual(res.byModel.map((b) => b.key), ['openai/gpt', 'anthropic/claude']); // sorted by totalTokens desc assert.deepEqual(res.byModel.map((b) => b.calls), [1, 2]); assert.deepEqual(res.byModel.map((b) => b.totalTokens), [3000, 2500]); - assert.deepEqual(res.byModel.map((b) => b.cost), [0.007, 0.0035]); + assert.deepEqual(res.byModel.map((b) => b.cost), [0.049, 0.0245]); assert.deepEqual(res.models, [ { key: 'anthropic/claude', provider: 'anthropic', model: 'claude' }, { key: 'openai/gpt', provider: 'openai', model: 'gpt' }, @@ -108,7 +108,7 @@ test('heatmap is a zero-filled daily series for the current year (Date.now mocke assert.deepEqual(res.heatmap.map((b) => b.calls), [0, 1, 1, 1, 0, 0, 0, 0, 0, 0]); assert.equal(res.heatmap[1].inputTokens, 1000); assert.equal(res.heatmap[1].totalTokens, 1500); - assert.equal(res.heatmap[1].cost, 0.002); + assert.equal(res.heatmap[1].cost, 0.014); assert.equal(query(state, {}).heatmap, undefined); // omitted unless requested }); @@ -191,3 +191,35 @@ test('models stays inside the requested time range', () => { const res = query(state, { from: Date.UTC(2025, 0, 1), to: Date.UTC(2025, 1, 1) }); assert.equal(res.models.some((m) => m.key === 'acme/old'), false); }); + +test('currency converts the USD price table at the fixed 7:1 rate', () => { + const state = makeState(); + const args = { from: Date.UTC(2025, 0, 1), to: Date.UTC(2025, 1, 1) }; + const usd = query(state, { ...args, currency: 'USD' }); + assert.deepEqual(usd.currency, { code: 'USD', symbol: '$' }); + assert.equal(usd.total.cost, 0.0105); + const cny = query(state, { ...args, currency: 'CNY' }); + assert.deepEqual(cny.currency, { code: 'CNY', symbol: '¥' }); + assert.equal(cny.total.cost, 0.0735); + assert.deepEqual(cny.daily.map((b) => b.cost), [0.014, 0.049, 0.0105]); + // Conversion is display-only: token fields are untouched. + assert.equal(cny.total.totalTokens, usd.total.totalTokens); + assert.equal(cny.total.calls, usd.total.calls); +}); + +test('currency defaults to the configured value and unknown codes fall back', () => { + const state = makeState(); + assert.deepEqual(query(state, {}).currency, { code: 'CNY', symbol: '¥' }); + assert.deepEqual(query(state, { currency: 'EUR' }).currency, { code: 'CNY', symbol: '¥' }); + state.prices.setCurrency('$'); + assert.deepEqual(query(state, {}).currency, { code: 'USD', symbol: '$' }); +}); + +test('a currency query never rewrites the price table', () => { + const state = makeState(); + const args = { from: Date.UTC(2025, 0, 1), to: Date.UTC(2025, 1, 1) }; + query(state, { ...args, currency: 'CNY' }); + assert.equal(state.prices.priceOf({ provider: 'anthropic', model: 'claude' }).inputPerM, 1); + // A later USD query must still see the original USD numbers. + assert.equal(query(state, { ...args, currency: 'USD' }).total.cost, 0.0105); +}); From 8b9c590f6cc13e54816cd523765399d102d95134 Mon Sep 17 00:00:00 2001 From: XinSpark Date: Mon, 19 Oct 2026 00:53:45 +0800 Subject: [PATCH 04/12] feat(ui): align the refresh control and the cost wording with the shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh button was a 34px bordered tile wrapping a 24-grid, stroke-2 glyph, which read as a foreign control inside a settings panel. It is now a 28px transparent icon button (hover fills, no border; the shared :focus-visible group already covers it) using the DSH native refresh glyph on its 16-grid at stroke 1.5, and it disables itself with aria-busy while a refresh is in flight. `SvgIcon` now accepts an icon that carries its own viewBox, stroke width and path list, so both grid systems coexist; the string-based call sites are untouched. "成本" now reads "参考成本" ("Est. cost" in English) on the card and in the model table header, which is what the number actually is: a price-table estimate, not a billed amount. --- src/client/index.js | 51 +++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/src/client/index.js b/src/client/index.js index dae0ff7..834aae4 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -42,13 +42,13 @@ const CSS = ` display: inline-flex; align-items: center; justify-content: center; - width: 34px; - height: 34px; + width: 28px; + height: 28px; padding: 0; - border: 1px solid var(--dsw-alias-border-l2); - background: var(--dsw-alias-bg-layer-1); + border: 1px solid transparent; + background: transparent; color: var(--dsw-alias-label-secondary); - border-radius: 8px; + border-radius: 6px; cursor: pointer; font: inherit; } @@ -56,6 +56,14 @@ const CSS = ` color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } +.tsd-icon-btn:disabled { + cursor: default; + opacity: 0.6; +} +.tsd-icon-btn:disabled:hover { + color: var(--dsw-alias-label-secondary); + background: transparent; +} .tsd-spinning svg { animation: tsd-spin 0.9s linear infinite; } @@ -1211,7 +1219,7 @@ const ZH = { inputTokens: '输入 Token', outputTokens: '输出 Token', calls: '调用次数', - cost: '成本', + cost: '参考成本', approxIO: '≈ 输入+输出', unpricedNote: '{count} 次调用未定价', dailyTrend: '每日用量趋势', @@ -1242,7 +1250,7 @@ const ZH = { colInput: '输入 Token', colOutput: '输出 Token', colTotal: '总 Token', - colCost: '成本', + colCost: '参考成本', colShare: '占比', unpriced: '未定价', unpricedRowHint: '该模型未配置定价,按 0 计价', @@ -1298,7 +1306,7 @@ const EN = { inputTokens: 'Input Tokens', outputTokens: 'Output Tokens', calls: 'Calls', - cost: 'Cost', + cost: 'Est. cost', approxIO: '≈ input+output', unpricedNote: '{count} calls unpriced', dailyTrend: 'Daily usage trend', @@ -1329,7 +1337,7 @@ const EN = { colInput: 'Input Tokens', colOutput: 'Output Tokens', colTotal: 'Total Tokens', - colCost: 'Cost', + colCost: 'Est. cost', colShare: 'Share', unpriced: 'Unpriced', unpricedRowHint: 'No pricing configured; counted as 0', @@ -1508,7 +1516,16 @@ const ICON_PATHS = { up: 'M12 21V9m0 0l-4.5 4.5M12 9l4.5 4.5M4 3h16', activity: 'M2 12h4l2.5-8 5 16 2.5-8h6', coins: 'M3 9a2 2 0 012-2h14a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9zm0 5h18M8 5.5V4a2 2 0 012-2h4a2 2 0 012 2v1.5', - refresh: 'M21 12a9 9 0 11-2.64-6.36M21 4v6h-6', + // 刷新图标取自 DSH 原生图形(16 网格、stroke 1.5),与设置页其它图标同一观感; + // 其余图标仍是 24 网格(stroke 2),SvgIcon 两种定义都吃。 + refresh: { + viewBox: '0 0 16 16', + strokeWidth: 1.5, + paths: [ + 'M14.5001 8C14.5 9.28552 14.1188 10.5422 13.4045 11.611C12.6903 12.6799 11.6752 13.5129 10.4875 14.0049C9.29982 14.4968 7.99295 14.6255 6.73212 14.3747C5.4713 14.124 4.31314 13.505 3.4041 12.596C2.49514 11.687 1.87614 10.5288 1.62537 9.26798C1.37459 8.00716 1.50331 6.70028 1.99525 5.51261C2.48719 4.32494 3.32025 3.30981 4.3891 2.59557C5.45795 1.88134 6.71458 1.50008 8.0001 1.5C9.9001 1.5 11.7001 2.3 13.0001 3.6L14.5001 5.1', + 'M14.4999 1.5V5.1H10.8999', + ], + }, chevron: 'M6 9l6 6 6-6', inbox: 'M22 12h-6l-2 3h-4l-2-3H2m3.5-6.5L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.5-6.5A2 2 0 0016.76 5H7.24a2 2 0 00-1.74 1.5z', alert: 'M12 9v4m0 4h.01M10.3 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.7 3.86a2 2 0 00-3.4 0z', @@ -1516,18 +1533,24 @@ const ICON_PATHS = { } function SvgIcon(props) { const { name, size, className } = props + // 一个名字对应「一串 path + 自己的 viewBox/线宽」;字符串是 24 网格的简写, + // 调用方(31 处)无需关心差别。 + const spec = ICON_PATHS[name] || ICON_PATHS.activity + const icon = typeof spec === 'string' + ? { viewBox: '0 0 24 24', strokeWidth: 2, paths: [spec] } + : spec return h('svg', { className: className || 'tsd-icon', width: size || 16, height: size || 16, - viewBox: '0 0 24 24', + viewBox: icon.viewBox || '0 0 24 24', fill: 'none', stroke: 'currentColor', - strokeWidth: 2, + strokeWidth: icon.strokeWidth || 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, - }, h('path', { d: ICON_PATHS[name] || ICON_PATHS.activity })) + }, icon.paths.map((d, i) => h('path', { key: i, d }))) } /* --------------------------------------------------------------------------- @@ -1610,6 +1633,8 @@ function StatsHeader(props) { type: 'button', className: 'tsd-icon-btn' + (busy ? ' tsd-spinning' : ''), onClick: onRefresh, + disabled: busy, + 'aria-busy': busy || undefined, title: t('refresh'), 'aria-label': t('refresh'), }, h(SvgIcon, { name: 'refresh' }))) From 7b1598bc2f0c8e27c1628e48c82aa19593b756de Mon Sep 17 00:00:00 2001 From: XinSpark Date: Mon, 19 Oct 2026 00:55:31 +0800 Subject: [PATCH 05/12] feat(ui): show the model share in the KPI grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token share per model lived only in the summary table's ratio column, so at a glance there was no sense of which model dominates the selected range. Add it as a sixth cell of the same `.tsd-cards` grid instead of a new container: the grid is `repeat(auto-fit, minmax(150px, 1fr))` and only direct children take a cell. The cell reuses the host `byModel` rows the summary table already renders, so the two views cannot drift — top four models by total tokens plus an aggregated "Others" segment; hover/focus switches the caption, and each segment's `title` carries input/output/cost with the same unpriced rule as the table (tokens > 0 and no cost). Deliberately no floating tooltip: this card sits against the settings panel's overflow boundary, so the detail rides on the native title rather than re-introducing the clipping the popovers just got rid of. --- src/client/index.js | 175 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 3 deletions(-) diff --git a/src/client/index.js b/src/client/index.js index 834aae4..9e38177 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -369,6 +369,50 @@ const CSS = ` .tsd-cur-symbol { font-family: var(--ds-font-family-code); } .tsd-cur-name { color: var(--dsw-alias-label-secondary); font-size: 12px; } +/* ============ 模型占比(KPI 卡第 6 格) ============ */ +.tsd-card-share-body { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.tsd-share-track { + display: flex; + width: 100%; + height: 10px; + border-radius: 999px; + overflow: hidden; + background: var(--dsw-alias-bg-layer-2); +} +.tsd-share-seg { + height: 100%; + min-width: 2px; +} +.tsd-share-seg[data-active='true'] { filter: brightness(1.15); } +.tsd-share-seg:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} +.tsd-share-caption { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + min-width: 0; + font-size: 12px; + color: var(--dsw-alias-label-secondary); +} +.tsd-share-cap-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tsd-share-cap-pct { + flex: none; + color: var(--dsw-alias-label-primary); + font-variant-numeric: tabular-nums; +} + /* ============ 自定义日期浮层 ============ */ .tsd-range-fields { display: flex; @@ -1252,6 +1296,9 @@ const ZH = { colTotal: '总 Token', colCost: '参考成本', colShare: '占比', + modelShare: '模型占比', + shareOther: '其它', + shareHint: '按总 Token 占比,仅统计当前范围内的模型', unpriced: '未定价', unpricedRowHint: '该模型未配置定价,按 0 计价', noData: '暂无数据', @@ -1339,6 +1386,9 @@ const EN = { colTotal: 'Total Tokens', colCost: 'Est. cost', colShare: 'Share', + modelShare: 'Model share', + shareOther: 'Others', + shareHint: 'Share of total tokens, current range only', unpriced: 'Unpriced', unpricedRowHint: 'No pricing configured; counted as 0', noData: 'No data', @@ -1530,6 +1580,8 @@ const ICON_PATHS = { inbox: 'M22 12h-6l-2 3h-4l-2-3H2m3.5-6.5L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.5-6.5A2 2 0 0016.76 5H7.24a2 2 0 00-1.74 1.5z', alert: 'M12 9v4m0 4h.01M10.3 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.7 3.86a2 2 0 00-3.4 0z', search: 'M21 21l-4.35-4.35M17 10.5a6.5 6.5 0 11-13 0 6.5 6.5 0 0113 0z', + // 饼图(占比):外圈 + 两条半径,与卡片标题的「占比」语义一致 + share: 'M21 12a9 9 0 11-9-9v9h9z', } function SvgIcon(props) { const { name, size, className } = props @@ -1897,10 +1949,118 @@ function CurrencyPicker(props) { } /* --------------------------------------------------------------------------- - * 汇总指标卡 ×5 + * 模型占比(KPI 网格里的第 6 格) + * + * 数据直接吃宿主 `byModel` 的行(与「按模型明细」表同一份),不另算一遍。 + * 明细放在各段的原生 title 上而不是浮层:KPI 卡贴着设置面板的裁剪边界, + * 再叠一层浮层只会把之前修好的裁剪问题带回来。 + * ------------------------------------------------------------------------- */ +const SHARE_COLORS = [ + 'var(--dsw-static-deepseek-500)', + 'var(--dsw-static-green-500)', + 'var(--color-blue-500)', + 'var(--color-orange-500)', +] +const SHARE_TOP = 4 +const SHARE_REST_KEY = '__rest__' + +/** `provider/model` → 裸模型名;色带格位放不下全名,全名留给 title 与 aria。 */ +function shortModelName(key) { + const s = String(key == null ? '' : key) + const i = s.lastIndexOf('/') + return i === -1 ? s : s.slice(i + 1) +} +/** 宿主行是 `totalTokens`;兼容已映射成 `total` 的形状,空行也不能抛。 */ +function rowTotal(r) { + return r ? (r.totalTokens || r.total || 0) : 0 +} +/** 降序取前 4 段,其余并成「其它」;无数据时返回空段。 */ +function shareSegments(rows) { + const list = (rows || []).slice().sort((a, b) => rowTotal(b) - rowTotal(a)) + const total = list.reduce((s, r) => s + rowTotal(r), 0) + if (total <= 0) return { total: 0, segs: [] } + const numbers = (r) => ({ + tokens: rowTotal(r), + calls: r.calls || 0, + // 与明细表同一口径:输入含缓存读写 + input: (r.inputTokens || 0) + (r.cacheReadTokens || 0) + (r.cacheWriteTokens || 0), + output: r.outputTokens || 0, + cost: r.cost || 0, + }) + const segs = list.slice(0, SHARE_TOP).map((r, i) => Object.assign({ + key: String(r.key), + label: shortModelName(r.key), + pct: rowTotal(r) / total, + color: SHARE_COLORS[i % SHARE_COLORS.length], + }, numbers(r))) + const restRows = list.slice(SHARE_TOP) + const restTokens = restRows.reduce((s, r) => s + rowTotal(r), 0) + if (restTokens > 0) { + const agg = restRows.reduce((a, r) => { + const n = numbers(r) + return { + tokens: a.tokens + n.tokens, + calls: a.calls + n.calls, + input: a.input + n.input, + output: a.output + n.output, + cost: a.cost + n.cost, + } + }, { tokens: 0, calls: 0, input: 0, output: 0, cost: 0 }) + segs.push(Object.assign({ + key: SHARE_REST_KEY, + label: null, // 「其它」的文案由渲染层取词典 + pct: restTokens / total, + color: 'var(--dsw-alias-label-tertiary)', + }, agg)) + } + return { total, segs } +} + +function ModelShareBar(props) { + const { rows, currency } = props + const t = useT() + const [hoverKey, setHoverKey] = React.useState(null) + const occ = shareSegments(rows) + if (occ.total <= 0) return h('div', { className: 'tsd-card-value' }, t('noData')) + const find = (key) => occ.segs.filter((s) => s.key === key)[0] || null + const shown = (hoverKey && find(hoverKey)) || occ.segs[0] + const labelOf = (s) => (s.key === SHARE_REST_KEY ? t('shareOther') : s.label) + const fullOf = (s) => (s.key === SHARE_REST_KEY ? t('shareOther') : s.key) + const tipOf = (s) => [ + fullOf(s), + fmtPct(s.pct) + ' · ' + formatInt(s.tokens) + ' ' + t('totalTokens'), + t('legendInput') + ' ' + formatInt(s.input) + ' · ' + t('legendOutput') + ' ' + formatInt(s.output), + t('cost') + ' ' + (s.tokens > 0 && !(s.cost > 0) ? t('unpriced') : formatCost(s.cost, currency)), + ].join('\n') + return h('div', { className: 'tsd-card-share-body' }, + h('div', { + className: 'tsd-share-track', + onMouseLeave: () => setHoverKey(null), + }, + occ.segs.map((s) => h('div', { + key: s.key, + className: 'tsd-share-seg', + 'data-share-key': s.key, + 'data-active': shown && shown.key === s.key ? 'true' : undefined, + style: { width: s.pct * 100 + '%', background: s.color }, + role: 'img', + tabIndex: 0, + title: tipOf(s), + 'aria-label': fullOf(s) + ' · ' + fmtPct(s.pct) + ' · ' + formatInt(s.tokens) + ' ' + t('totalTokens'), + onMouseEnter: () => setHoverKey(s.key), + onFocus: () => setHoverKey(s.key), + onBlur: () => setHoverKey(null), + }))), + h('div', { className: 'tsd-share-caption' }, + h('span', { className: 'tsd-share-cap-name' }, labelOf(shown)), + h('span', { className: 'tsd-share-cap-pct' }, fmtPct(shown.pct)))) +} + +/* --------------------------------------------------------------------------- + * 汇总指标卡 ×6 * ------------------------------------------------------------------------- */ function SummaryCards(props) { - const { total, currency, unpricedCalls, currencyCode, onCurrencyChange } = props + const { total, currency, unpricedCalls, currencyCode, onCurrencyChange, byModel } = props const tt = useT() const d = total || {} const inputTotal = (d.inputTokens || 0) + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0) @@ -1931,6 +2091,12 @@ function SummaryCards(props) { color: 'var(--dsw-static-green-500)', prefix: h(CurrencyPicker, { code: currencyCode, onChange: onCurrencyChange }), }, + { + key: 'share', icon: 'share', label: tt('modelShare'), + title: tt('shareHint'), + color: 'var(--dsw-alias-brand-primary)', + content: h(ModelShareBar, { rows: byModel, currency }), + }, ] return h('div', { className: 'tsd-cards' }, cards.map((c) => @@ -1939,7 +2105,9 @@ function SummaryCards(props) { h('span', { className: 'tsd-card-icon', style: { color: c.color } }, h(SvgIcon, { name: c.icon, size: 14 })), h('span', { className: 'tsd-card-label' }, c.label)), - h('div', { className: 'tsd-card-value', title: c.title }, c.prefix || null, c.value), + c.content + ? c.content + : h('div', { className: 'tsd-card-value', title: c.title }, c.prefix || null, c.value), c.sub ? h('div', { className: 'tsd-card-sub' }, c.sub) : null, c.key === 'cost' && unpricedCalls > 0 ? h('div', { className: 'tsd-card-note' }, tt('unpricedNote', { count: formatInt(unpricedCalls) })) @@ -2647,6 +2815,7 @@ function StatsDashboard(props) { unpricedCalls: data.unpricedCalls || 0, currencyCode: displayCurrency, onCurrencyChange: changeCurrency, + byModel: data.byModel, }), h(DailyChart, { daily: data.daily, From 2d1eb9d410bb15f87aaad7aff114c8c65b60a3fa Mon Sep 17 00:00:00 2001 From: XinSpark Date: Mon, 19 Oct 2026 00:57:17 +0800 Subject: [PATCH 06/12] feat(ui): give the plugin its own artwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two places, one shape. `icon.svg` (three bars, vertical gradient) is declared in `package.json#icon` and shipped through `files[]`, so the plugin manager can render it on the plugin card; the settings nav row uses the same motif. The nav icon has no contract to go through: `settings.section` projects only id/order/label, and the settings shell resolves the glyph from a hardcoded id whitelist, so a third-party section always falls back to the gear. Registering under a first-party id would take over a built-in row instead, so this marks its own row by visible label and swaps the gear for a masked glyph — the same technique the other third-party plugins in this profile use. It never replaces the svg node (React throws on removeChild during unmount), degrades to the gear when the row or the DOM is missing, and is disposed with the fiber. The mask reads only the alpha channel, hence the flat single-colour glyph. Commented for deletion once the shell grows an icon field on the section contract. --- icon.svg | 11 +++++++ package.json | 2 ++ src/client/index.js | 77 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 icon.svg diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..d21fe90 --- /dev/null +++ b/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/package.json b/package.json index 14ad380..27439bf 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "url": "git+https://github.com/bennett-dsh/dsh-plugin-usage.git" }, "type": "module", + "icon": "./icon.svg", "main": "lib/host/index.js", "exports": { ".": { @@ -22,6 +23,7 @@ "files": [ "lib", "cordis.patch.yml", + "icon.svg", "image.png", "image-1.png" ], diff --git a/src/client/index.js b/src/client/index.js index 9e38177..34c95a5 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -2828,6 +2828,82 @@ function StatsDashboard(props) { : null)) } +/* --------------------------------------------------------------------------- + * 设置页导航图标 + * + * `settings.section` 只投影 id/order/label,导航图标由设置页按 id 白名单自己 + * 决定(dsh-client-ui-settings-general 的 navIcon),第三方没有插口。所以这里 + * 给「自己那一行」打一个属性标记,再用 CSS mask 把兜底的齿轮换成用量图标: + * 不替换节点、不碰 React 管的 svg(替换会让 React 卸载时抛 NotFoundError), + * 只加属性、只用 ::before 画字形。 + * + * 认行只能按可见文本:类名是 CSS Modules 哈希,账号行还会条件出现。 + * 官方一旦给 settings.section 加上 icon 字段,这个模块应整体删除。 + * ------------------------------------------------------------------------- */ +const NAV_ICON_MASK = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent( + '' + + '' + + '' + + '', +) +// mask 只读 alpha 通道,所以字形必须是纯色实心(icon.svg 的渐变与半透明在这里会丢) +const NAV_ICON_CSS = [ + '[data-tsd-nav] > svg { display: none; }', + '[data-tsd-nav]::before {', + " content: '';", + ' flex: none;', + ' width: 16px;', + ' height: 16px;', + ' background: currentColor;', + ' -webkit-mask-image: url("' + NAV_ICON_MASK + '");', + ' mask-image: url("' + NAV_ICON_MASK + '");', + ' -webkit-mask-size: 16px 16px;', + ' mask-size: 16px 16px;', + ' -webkit-mask-repeat: no-repeat;', + ' mask-repeat: no-repeat;', + '}', +].join('\n') + +/** 认不出行、拿不到 DOM,都只是退化成齿轮,绝不抛出。返回 disposer。 */ +function installSettingsNavIcon(label) { + if (typeof document === 'undefined' || typeof MutationObserver !== 'function') return null + let styleEl = null + try { + styleEl = document.createElement('style') + styleEl.setAttribute('data-plugin', 'dsh-plugin-usage') + styleEl.textContent = NAV_ICON_CSS + document.head.appendChild(styleEl) + } catch (e) { + console.warn('[usage] settings nav icon: styles unavailable') + return null + } + const mark = () => { + const rows = document.querySelectorAll('[role="dialog"] button') + const want = label() + for (let i = 0; i < rows.length; i++) { + const row = rows[i] + if (row.getAttribute('data-tsd-nav') !== null) continue + if (!row.querySelector('svg')) continue // 只标带图标的导航行,别误标普通按钮 + if ((row.textContent || '').trim() !== want) continue + row.setAttribute('data-tsd-nav', '') + } + } + let observer = null + try { + mark() + observer = new MutationObserver(() => { try { mark() } catch (e) {} }) + observer.observe(document.body, { childList: true, subtree: true }) + } catch (e) { + console.warn('[usage] settings nav icon: observer unavailable', e) + } + return () => { + if (observer) observer.disconnect() + if (styleEl) styleEl.remove() + const marked = document.querySelectorAll('[data-tsd-nav]') + for (let i = 0; i < marked.length; i++) marked[i].removeAttribute('data-tsd-nav') + } +} + /* --------------------------------------------------------------------------- * 注册:settings.section 页面 * ------------------------------------------------------------------------- */ @@ -2848,6 +2924,7 @@ function apply(ctx) { }, 'usage: styles') ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'usage: dictionaries') const t0 = ctx.locale ? ctx.locale.bind(NS) : fallbackT + ctx.effect(() => installSettingsNavIcon(() => t0('nav')), 'usage: nav-icon') // cordis ctx is a strict proxy: DIRECT property access on an un-injected // service throws ("cannot get property X without inject"), so a bare // `ctx.slots ?? ...` would throw and skip the fallback. Probe via the safe From eb694ea667c2cc23e037330d8aae1d4d568da09b Mon Sep 17 00:00:00 2001 From: XinSpark Date: Mon, 19 Oct 2026 00:58:37 +0800 Subject: [PATCH 07/12] chore(release): 0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the six dashboard changes and bump to a feature release. The default price table stays DeepSeek's USD list; it is now converted for display instead of being printed with a ¥ sign, which is why the visible CNY figures grow about 7x. Called out in the changelog so it does not later read as a regression. --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ README.md | 6 +++++- package.json | 2 +- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 804eea1..c0a6bc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,46 @@ All notable changes to this project are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.3.0] - 2026-10-19 + +设置页仪表盘的可用性与观感版本:修掉两处「看着正常其实不能用」的交互,补上 +区间、币种与模型占比,并把控件对齐到 shell 的原生观感。 + +### Fixed + +- **模型下拉勾选一个就塌缩(P1)**:宿主先按已选模型过滤、后登记候选,响应里的 + `models` 只剩已选中的那个。现在先登记候选再过滤;`models` 项直接带 `key` + (`compositeModelKey` 的结果),客户端不再自己拼 `provider/model` —— 顺带修掉 + provider 为空时拼出 `/model`、选中后所有行都不匹配、整页归零的静默空结果。 +- **模型/日期浮层被设置面板裁掉**:设置面板是 `overflow: hidden`,绝对定位的浮层 + 右半边被切。改用 `useFixedPanel`:按锚点实测位置 `position: fixed`、视口夹取、 + 下方空间不足时上翻。顺带补 `.tsd-model-popover` 缺失的 `box-sizing`。 + +### Added + +- **「全部」区间**:`rangeFor('all')` 取 `[0, now)` 并关掉 `fill`,真实边界是宿主的 + `MAX_BUCKETS` 而不是天数;同时删掉「最长 30 天」这句假限制(date input 本来就没有 + min/max)。 +- **模型占比卡**:KPI 网格第 6 格,按总 Token 显示前 4 个模型 + 「其它」,悬浮/聚焦 + 切换说明行,明细走各段 `title`(与明细表同一口径,含未定价判定)。 +- **参考成本币种切换**:¥ / $ 切换器在参考成本卡的值行最前,切换后重查;宿主 + `query` 收 `currency`、在输出边界按固定 7:1 折算并回传实际使用的 `{code, symbol}`; + 小于半分显示 `<0.01`。 +- **插件图标**:`package.json#icon` + `icon.svg`(同时进 `files[]`),插件卡片与设置页 + 导航行共用同一图形。 + +### Changed + +- **价目表基准明确为美元**:内置默认价(0.27 / 1.10 / 0.07 USD 每百万 Token)此前 + 直接配 `¥` 显示,比官方人民币价低约 7 倍;现在按 7:1 折算,显示数字随之变为官方 + 人民币价位。价目表与「未定价」判定本身不变。 +- **文案**:卡片与表头的「成本」改为「参考成本」(英文 `Est. cost`),它确实是按 + 价目表估算、不是账单。 +- **刷新按钮**:34px 描边方块改为 28px 透明图标按钮,图标换成 DSH 原生 16 网格 + stroke 1.5 的刷新字形;请求中 `disabled` + `aria-busy`。 +- **`SvgIcon`** 支持自带 `viewBox` / `strokeWidth` / 多段 `paths` 的图标定义,字符串 + 写法仍是 24 网格 stroke 2 的简写。 + ## [0.2.2] - 2026-10-18 按 DSH 0.2.x 官方 API 审计后的正确性版本:修复「UI 正常但数据恒为空」的数据管道, diff --git a/README.md b/README.md index 5e7ddfc..b160034 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ DeepSeek Harness 的 **Token 用量统计**插件:按天 / 自定义范围查 ## 功能 -- 今日 / 近 7 天 / 近 30 天 / 自定义范围,按小时·天聚合 +- 今日 / 近 7 天 / 近 30 天 / 全部 / 自定义范围(不限跨度),按小时·天聚合 - 模型筛选、按模型明细(请求数、输入/输出、缓存、成本、占比) +- KPI 网格里的「模型占比」卡:按总 Token 显示前 4 个模型与「其它」 +- 参考成本可在 ¥ / $ 之间切换(固定汇率 1 USD = 7 CNY,宿主侧折算) - 可配置的每百万 Token 价格表(内置 DeepSeek 常见模型默认价) - 年度热力图;深/浅色自适应,只用 DSH 主题变量,无第三方图表库 @@ -28,6 +30,7 @@ DeepSeek Harness 的 **Token 用量统计**插件:按天 / 自定义范围查 ## 配置 设置 → 插件 → `dsh-plugin-usage` 可改 `currency` 和 `prices`(每百万 Token 价格),写入 profile patch,重启保留。 +`currency` 是默认显示币种;仪表盘上参考成本卡里的 ¥ / $ 切换只影响当前界面(同浏览器记住选择)。 价格优先匹配 `provider/model`,其次裸 `model`;未配置的模型标为「未定价」,成本记 0。 @@ -39,6 +42,7 @@ DeepSeek Harness 的 **Token 用量统计**插件:按天 / 自定义范围查 - 计费对齐官方 `tokenUsage` 投影:同一 `(turn, step)` 替换;`llm/retry-started` 后的重试累加;`assistant/attempt` 的 stream 用量也计入 - fork 会话按 `inheritedEventCount` 跳过父会话前缀,不重复计费 - 费用 = Σ(各 Token 分桶 × 对应单价) / 1e6 +- 价目表以**美元 / 百万 Token** 为基准(与 DeepSeek 官方价一致);显示 `¥` 时按固定 7:1 折算,价目表本身不改写 ## 兼容性 diff --git a/package.json b/package.json index 27439bf..4e4dd58 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-plugin-usage", "description": "Token usage statistics for DeepSeek Harness — daily/custom-range views, model filters, call counts and cost, inspired by CC Switch statistics.", - "version": "0.2.2", + "version": "0.3.0", "license": "MIT", "repository": { "type": "git", From e668074c97cbae97aaeb54d59d5b9587cc87db53 Mon Sep 17 00:00:00 2001 From: XinSpark Date: Mon, 19 Oct 2026 01:14:56 +0800 Subject: [PATCH 08/12] feat(ui): move the currency switcher into the cost card head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version put the switcher inside the value row, and `formatCost` already prefixes the symbol, so the card rendered the symbol twice (`¥ ▾ ¥14.78`) while the 15px symbol + 11px chevron broke the numeric baseline. Split the two concerns: `formatCost` stays the only place that draws a symbol, and the switcher becomes a card-header action. - `.tsd-card-actions` (margin-left:auto) holds card-level controls; the trigger is a 20px transparent button with a hover fill, matching the refresh button in `StatsHeader`. - The trigger shows the currency *code* (`CNY`/`USD`) rather than the symbol: the symbol is already on the number, and `$` alone is ambiguous. Arrow keys change the value while closed, move the selection while open, Esc closes. - The menu reuses `.tsd-popover` + `useFixedPanel`, shows symbol + name + a check on the active row, and states the rate in `.tsd-popover-note` (`1 USD = 7 CNY`). --- src/client/index.js | 97 ++++++++++++++++++++++++++++++++------------- 1 file changed, 69 insertions(+), 28 deletions(-) diff --git a/src/client/index.js b/src/client/index.js index 34c95a5..5a6eec9 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -317,36 +317,47 @@ const CSS = ` min-width: 0; } -/* ============ 币种切换(参考成本卡) ============ */ +/* ============ 币种切换(参考成本卡头部动作) ============ */ +/* 卡片级动作放头部右侧,20px 高、透明底 hover 填充:与 StatsHeader 的刷新按钮、 + FilterBar 的下拉同一套观感。数值行保持纯数字,不再插控件。 */ +.tsd-card-actions { + display: flex; + align-items: center; + gap: 2px; + margin-left: auto; +} .tsd-cur-anchor { position: relative; display: inline-flex; - margin-right: 6px; - vertical-align: baseline; } .tsd-cur-btn { display: inline-flex; align-items: center; gap: 2px; - height: 24px; + height: 20px; padding: 0 4px; - border: 1px solid transparent; - border-radius: 6px; + border: none; + border-radius: 5px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; - font-size: 15px; - font-weight: 600; + font-size: 11px; + font-weight: 500; line-height: 1; + letter-spacing: 0.02em; cursor: pointer; } -.tsd-cur-btn:hover { +.tsd-cur-btn:hover, +.tsd-cur-btn-open { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } +.tsd-cur-code { + font-variant-numeric: tabular-nums; +} .tsd-cur-popover { box-sizing: border-box; - width: 150px; + width: 190px; padding: 6px; } .tsd-cur-option { @@ -366,8 +377,15 @@ const CSS = ` } .tsd-cur-option:hover { background: var(--dsw-alias-interactive-bg-hover); } .tsd-cur-option-active { background: var(--dsw-alias-interactive-bg-hover); } -.tsd-cur-symbol { font-family: var(--ds-font-family-code); } +.tsd-cur-symbol { + color: var(--dsw-alias-label-secondary); + font-family: var(--ds-font-family-code); +} .tsd-cur-name { color: var(--dsw-alias-label-secondary); font-size: 12px; } +.tsd-cur-check { + margin-left: auto; + color: var(--dsw-alias-brand-primary); +} /* ============ 模型占比(KPI 卡第 6 格) ============ */ .tsd-card-share-body { @@ -1582,6 +1600,7 @@ const ICON_PATHS = { search: 'M21 21l-4.35-4.35M17 10.5a6.5 6.5 0 11-13 0 6.5 6.5 0 0113 0z', // 饼图(占比):外圈 + 两条半径,与卡片标题的「占比」语义一致 share: 'M21 12a9 9 0 11-9-9v9h9z', + check: 'M20 6L9 17l-5-5', } function SvgIcon(props) { const { name, size, className } = props @@ -1914,37 +1933,58 @@ function CurrencyPicker(props) { const t = useT() const [open, setOpen] = React.useState(false) const ref = React.useRef(null) - const panelStyle = useFixedPanel(ref, open, 150) + const panelStyle = useFixedPanel(ref, open, 190) useOutsideClick(open, ref, () => setOpen(false)) const active = CURRENCY_SYMBOLS[code] ? code : 'CNY' + const options = ['CNY', 'USD'] + // 收起时 ↑↓ 直接换值(像原生 select),展开时在两项间移动;Esc 关菜单。 + const move = (step) => { + const i = options.indexOf(active) + onChange(options[(i + step + options.length) % options.length]) + } + const onKeyDown = (e) => { + if (e.key === 'Escape') { setOpen(false); return } + if (!open) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); move(e.key === 'ArrowDown' ? 1 : -1); return } + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(true) } + return + } + if (e.key === 'ArrowDown') { e.preventDefault(); move(1) } + else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) } + } return h('span', { className: 'tsd-cur-anchor', ref }, h('button', { type: 'button', - className: 'tsd-cur-btn', + className: 'tsd-cur-btn' + (open ? ' tsd-cur-btn-open' : ''), title: t('currencyHint'), 'aria-label': t('currencyHint'), 'aria-haspopup': 'listbox', 'aria-expanded': open, onClick: () => setOpen((v) => !v), + onKeyDown, }, - h('span', { className: 'tsd-cur-symbol' }, CURRENCY_SYMBOLS[active]), - h(SvgIcon, { name: 'chevron', size: 11, className: 'tsd-chevron' + (open ? ' tsd-chevron-open' : '') })), + h('span', { className: 'tsd-cur-code' }, active), + h(SvgIcon, { name: 'chevron', size: 10, className: 'tsd-chevron' + (open ? ' tsd-chevron-open' : '') })), open ? h('div', { className: 'tsd-popover tsd-cur-popover', style: panelStyle, role: 'listbox', 'aria-label': t('currencyHint'), - }, ['CNY', 'USD'].map((c) => h('button', { - key: c, - type: 'button', - role: 'option', - 'aria-selected': c === active, - className: 'tsd-cur-option' + (c === active ? ' tsd-cur-option-active' : ''), - onClick: () => { onChange(c); setOpen(false) }, - }, - h('span', { className: 'tsd-cur-symbol' }, CURRENCY_SYMBOLS[c]), - h('span', { className: 'tsd-cur-name' }, t(c === 'CNY' ? 'currencyCNY' : 'currencyUSD'))))) + onKeyDown, + }, + options.map((c) => h('button', { + key: c, + type: 'button', + role: 'option', + 'aria-selected': c === active, + className: 'tsd-cur-option' + (c === active ? ' tsd-cur-option-active' : ''), + onClick: () => { onChange(c); setOpen(false) }, + }, + h('span', { className: 'tsd-cur-symbol' }, CURRENCY_SYMBOLS[c]), + h('span', { className: 'tsd-cur-name' }, t(c === 'CNY' ? 'currencyCNY' : 'currencyUSD')), + c === active ? h(SvgIcon, { name: 'check', size: 12, className: 'tsd-cur-check' }) : null)), + h('div', { className: 'tsd-popover-note' }, t('currencyHint'))) : null) } @@ -2089,7 +2129,7 @@ function SummaryCards(props) { key: 'cost', icon: 'coins', label: tt('cost'), value: formatCost(d.cost, currency), title: formatCost(d.cost, currency), color: 'var(--dsw-static-green-500)', - prefix: h(CurrencyPicker, { code: currencyCode, onChange: onCurrencyChange }), + actions: h(CurrencyPicker, { code: currencyCode, onChange: onCurrencyChange }), }, { key: 'share', icon: 'share', label: tt('modelShare'), @@ -2104,10 +2144,11 @@ function SummaryCards(props) { h('div', { className: 'tsd-card-head' }, h('span', { className: 'tsd-card-icon', style: { color: c.color } }, h(SvgIcon, { name: c.icon, size: 14 })), - h('span', { className: 'tsd-card-label' }, c.label)), + h('span', { className: 'tsd-card-label' }, c.label), + c.actions ? h('span', { className: 'tsd-card-actions' }, c.actions) : null), c.content ? c.content - : h('div', { className: 'tsd-card-value', title: c.title }, c.prefix || null, c.value), + : h('div', { className: 'tsd-card-value', title: c.title }, c.value), c.sub ? h('div', { className: 'tsd-card-sub' }, c.sub) : null, c.key === 'cost' && unpricedCalls > 0 ? h('div', { className: 'tsd-card-note' }, tt('unpricedNote', { count: formatInt(unpricedCalls) })) From eae76cd127e194596e191080b05ca8594ee09387 Mon Sep 17 00:00:00 2001 From: XinSpark Date: Mon, 19 Oct 2026 01:17:00 +0800 Subject: [PATCH 09/12] feat(ui): give the model-share card the same shape as the other KPIs The card only had a head, a thick bar and one caption line, so it read as a chart wedged into the KPI grid instead of a KPI. Its detail text also went through a native `title`, whose black box covered the neighbouring card. - Layout now mirrors the other cards: head / value (largest segment's percentage, same 20px/700 as `21,536`) / sub (that segment's short model name, 12px ellipsis) / a 4px bar under both. - Hovering or focusing a segment swaps value and sub together, then restores the largest segment on leave. - Detail moved from `title` to the shared `.tsd-tooltip` drawn at fixed coordinates from `getBoundingClientRect` (the heatmap already does this), so the settings panel's `overflow` cannot clip it. - The "others" segment is a tinted brand colour (`color-mix`) rather than grey, which reads as disabled. --- src/client/index.js | 95 +++++++++++++++++++++++++-------------------- 1 file changed, 52 insertions(+), 43 deletions(-) diff --git a/src/client/index.js b/src/client/index.js index 5a6eec9..e01dab9 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -388,17 +388,28 @@ const CSS = ` } /* ============ 模型占比(KPI 卡第 6 格) ============ */ +/* 与其余 5 张卡同构:head / value(最大段百分比)/ sub(模型名)/ 色带。 + hover·focus 某段时 value 与 sub 一起换成该段;明细走 .tsd-tooltip(fixed), + 不用原生 title —— title 的黑框会盖住相邻卡片。 */ .tsd-card-share-body { display: flex; flex-direction: column; - gap: 6px; min-width: 0; } +.tsd-share-sub { + margin-top: 2px; + font-size: 12px; + color: var(--dsw-alias-label-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .tsd-share-track { display: flex; width: 100%; - height: 10px; - border-radius: 999px; + height: 4px; + margin-top: 8px; + border-radius: 2px; overflow: hidden; background: var(--dsw-alias-bg-layer-2); } @@ -406,30 +417,11 @@ const CSS = ` height: 100%; min-width: 2px; } -.tsd-share-seg[data-active='true'] { filter: brightness(1.15); } +.tsd-share-seg[data-active='true'] { filter: brightness(1.2); } .tsd-share-seg:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; } -.tsd-share-caption { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - min-width: 0; - font-size: 12px; - color: var(--dsw-alias-label-secondary); -} -.tsd-share-cap-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.tsd-share-cap-pct { - flex: none; - color: var(--dsw-alias-label-primary); - font-variant-numeric: tabular-nums; -} /* ============ 自定义日期浮层 ============ */ .tsd-range-fields { @@ -2050,7 +2042,7 @@ function shareSegments(rows) { key: SHARE_REST_KEY, label: null, // 「其它」的文案由渲染层取词典 pct: restTokens / total, - color: 'var(--dsw-alias-label-tertiary)', + color: 'color-mix(in srgb, var(--dsw-static-deepseek-500) 28%, var(--dsw-alias-bg-layer-3))', }, agg)) } return { total, segs } @@ -2059,24 +2051,44 @@ function shareSegments(rows) { function ModelShareBar(props) { const { rows, currency } = props const t = useT() - const [hoverKey, setHoverKey] = React.useState(null) + const [tip, setTip] = React.useState(null) // { key, left, top } in viewport coords const occ = shareSegments(rows) if (occ.total <= 0) return h('div', { className: 'tsd-card-value' }, t('noData')) const find = (key) => occ.segs.filter((s) => s.key === key)[0] || null - const shown = (hoverKey && find(hoverKey)) || occ.segs[0] + const shown = (tip && find(tip.key)) || occ.segs[0] const labelOf = (s) => (s.key === SHARE_REST_KEY ? t('shareOther') : s.label) const fullOf = (s) => (s.key === SHARE_REST_KEY ? t('shareOther') : s.key) - const tipOf = (s) => [ - fullOf(s), - fmtPct(s.pct) + ' · ' + formatInt(s.tokens) + ' ' + t('totalTokens'), - t('legendInput') + ' ' + formatInt(s.input) + ' · ' + t('legendOutput') + ' ' + formatInt(s.output), - t('cost') + ' ' + (s.tokens > 0 && !(s.cost > 0) ? t('unpriced') : formatCost(s.cost, currency)), - ].join('\n') + const costOf = (s) => (s.tokens > 0 && !(s.cost > 0) ? t('unpriced') : formatCost(s.cost, currency)) + // 悬停/聚焦:从段的视口矩形推开,fixed 定位不受设置面板 overflow 裁剪 + const showTip = (e, s) => { + const r = e.currentTarget.getBoundingClientRect() + setTip({ key: s.key, left: r.left + r.width / 2, top: r.top }) + } + const hideTip = () => setTip(null) + const tipBox = shown && tip + ? h('div', { + className: 'tsd-tooltip tsd-share-tip', + style: { position: 'fixed', left: tip.left + 'px', top: tip.top + 'px', transform: 'translate(-50%, -110%)' }, + }, + h('div', { className: 'tsd-tooltip-date' }, fullOf(shown)), + h('div', { className: 'tsd-tooltip-row' }, + h('span', { className: 'tsd-tooltip-dot', style: { background: shown.color } }), + h('span', null, t('totalTokens')), + h('span', { className: 'tsd-tooltip-val' }, formatInt(shown.tokens))), + h('div', { className: 'tsd-tooltip-row' }, + h('span', null, t('legendInput')), + h('span', { className: 'tsd-tooltip-val' }, formatInt(shown.input))), + h('div', { className: 'tsd-tooltip-row' }, + h('span', null, t('legendOutput')), + h('span', { className: 'tsd-tooltip-val' }, formatInt(shown.output))), + h('div', { className: 'tsd-tooltip-row' }, + h('span', null, t('cost')), + h('span', { className: 'tsd-tooltip-val' }, costOf(shown)))) + : null return h('div', { className: 'tsd-card-share-body' }, - h('div', { - className: 'tsd-share-track', - onMouseLeave: () => setHoverKey(null), - }, + h('div', { className: 'tsd-card-value', title: labelOf(shown) }, fmtPct(shown.pct)), + h('div', { className: 'tsd-share-sub' }, labelOf(shown)), + h('div', { className: 'tsd-share-track', onMouseLeave: hideTip }, occ.segs.map((s) => h('div', { key: s.key, className: 'tsd-share-seg', @@ -2085,15 +2097,12 @@ function ModelShareBar(props) { style: { width: s.pct * 100 + '%', background: s.color }, role: 'img', tabIndex: 0, - title: tipOf(s), 'aria-label': fullOf(s) + ' · ' + fmtPct(s.pct) + ' · ' + formatInt(s.tokens) + ' ' + t('totalTokens'), - onMouseEnter: () => setHoverKey(s.key), - onFocus: () => setHoverKey(s.key), - onBlur: () => setHoverKey(null), + onMouseEnter: (e) => showTip(e, s), + onFocus: (e) => showTip(e, s), + onBlur: hideTip, }))), - h('div', { className: 'tsd-share-caption' }, - h('span', { className: 'tsd-share-cap-name' }, labelOf(shown)), - h('span', { className: 'tsd-share-cap-pct' }, fmtPct(shown.pct)))) + tipBox) } /* --------------------------------------------------------------------------- From b070084da75c823469459522e18a9b4de92816ef Mon Sep 17 00:00:00 2001 From: XinSpark Date: Mon, 19 Oct 2026 01:19:55 +0800 Subject: [PATCH 10/12] feat(pkg): give the plugin page a localized title and description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin page showed the bare package name and the long English `description`, because `readPluginMeta` in `dsh-app-boot` falls back to `package.json#name` / `#description` when the package ships no `locale/.json`. Ship the dictionaries the loader already looks for: - `locale/en.json` and `locale/zh.json` with `{"meta":{title, description}}`, the shape `dictionariesOf` parses. - `"./locale/*.json"` in `exports` and `locale` in `files`, since the loader resolves `/locale/en.json` through the package resolver and the tarball must carry it. - Redraw `icon.svg` in the shipped art's idiom: transparent 36x36, opaque shapes on a diagonal `#67C7FE → #3F77D8` gradient. The old art stacked three 50/75/100% opacity bars, which read as washed out next to the official icons. --- icon.svg | 12 ++++++------ locale/en.json | 6 ++++++ locale/zh.json | 6 ++++++ package.json | 4 +++- 4 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 locale/en.json create mode 100644 locale/zh.json diff --git a/icon.svg b/icon.svg index d21fe90..2274fa0 100644 --- a/icon.svg +++ b/icon.svg @@ -1,11 +1,11 @@ - - - + + + - - - + + + diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..d90c9d1 --- /dev/null +++ b/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Usage Statistics", + "description": "Token, call and reference-cost statistics by day and by model, with model filters and a yearly heatmap." + } +} diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..3786265 --- /dev/null +++ b/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "用量统计", + "description": "按天、按模型查看 Token、调用次数与参考成本,支持模型筛选与年度热力图。" + } +} diff --git a/package.json b/package.json index 4e4dd58..7d9e4c0 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,14 @@ "default": "./lib/client/index.js" }, "./cordis.patch.yml": "./cordis.patch.yml", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "files": [ "lib", "cordis.patch.yml", "icon.svg", + "locale", "image.png", "image-1.png" ], From d2e2ecad727abe1903d9073a5376934fb09b2fa4 Mon Sep 17 00:00:00 2001 From: XinSpark Date: Mon, 19 Oct 2026 01:30:38 +0800 Subject: [PATCH 11/12] feat(ui): restore the inline currency switch and bar-over-caption share card Side-by-side, the row-style versions read better, so both go back to the shape the fork ships while keeping the fixes that came out of the remake. Currency switch: - Trigger returns to the value row and draws the symbol itself, so the symbol appears exactly once (`formatCost` no longer supplies one) and the control inherits the 20px/700 number's baseline instead of fighting it. - Keeps the keyboard handling (arrows/Esc/Enter) and the menu note that states the fixed 1 USD = 7 CNY rate. Model share: - Back to a 10px pill bar with an 11px tertiary caption underneath (name + percentage), which keeps the KPI numbers as the row's headlines. - Keeps the fixed-position tooltip: the native `title` it replaced was what painted a black box over the neighbouring card. - Drops the icon's invented pie path for the shipped two-stroke glyph, and drops the now-unused `shareHint` string. --- src/client/index.js | 162 +++++++++++++++++++++++++++----------------- 1 file changed, 98 insertions(+), 64 deletions(-) diff --git a/src/client/index.js b/src/client/index.js index e01dab9..2b731e4 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -317,57 +317,61 @@ const CSS = ` min-width: 0; } -/* ============ 币种切换(参考成本卡头部动作) ============ */ -/* 卡片级动作放头部右侧,20px 高、透明底 hover 填充:与 StatsHeader 的刷新按钮、 - FilterBar 的下拉同一套观感。数值行保持纯数字,不再插控件。 */ -.tsd-card-actions { - display: flex; - align-items: center; - gap: 2px; - margin-left: auto; -} +/* ============ 币种切换(参考成本卡数值行内) ============ */ +/* 数值行最前只放一个「符号 + 小箭头」的图标式触发器:符号本身就是读数的一部分, + 尺寸/字重/基线继承数值行,读起来像数字前缀而不是一个控件。 + 符号只在这里画一次,formatCost 不再重复带符号。 */ .tsd-cur-anchor { position: relative; display: inline-flex; } .tsd-cur-btn { display: inline-flex; - align-items: center; - gap: 2px; - height: 20px; - padding: 0 4px; + align-items: baseline; + gap: 3px; + margin: 0 2px 0 0; + padding: 0 2px; border: none; - border-radius: 5px; + border-radius: 6px; background: transparent; - color: var(--dsw-alias-label-secondary); + color: inherit; font: inherit; - font-size: 11px; - font-weight: 500; - line-height: 1; - letter-spacing: 0.02em; + line-height: inherit; + vertical-align: baseline; cursor: pointer; + transition: color 0.15s ease, background 0.15s ease; } .tsd-cur-btn:hover, .tsd-cur-btn-open { + color: var(--dsw-alias-brand-primary); background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); } -.tsd-cur-code { - font-variant-numeric: tabular-nums; +.tsd-cur-btn:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} +.tsd-cur-chevron { + flex: none; + align-self: center; + transition: transform 0.15s ease; } +.tsd-cur-btn-open .tsd-cur-chevron { transform: rotate(180deg); } .tsd-cur-popover { box-sizing: border-box; - width: 190px; + width: 180px; + max-width: calc(100vw - 24px); padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; } .tsd-cur-option { display: flex; align-items: center; gap: 8px; - width: 100%; - padding: 6px 8px; + padding: 7px 9px; border: none; - border-radius: 6px; + border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; @@ -376,51 +380,82 @@ const CSS = ` cursor: pointer; } .tsd-cur-option:hover { background: var(--dsw-alias-interactive-bg-hover); } -.tsd-cur-option-active { background: var(--dsw-alias-interactive-bg-hover); } +.tsd-cur-option:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} .tsd-cur-symbol { + flex: none; + width: 20px; + text-align: center; + font-weight: 600; +} +.tsd-cur-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: var(--dsw-alias-label-secondary); - font-family: var(--ds-font-family-code); + font-size: 12px; } -.tsd-cur-name { color: var(--dsw-alias-label-secondary); font-size: 12px; } +.tsd-cur-option-active { background: var(--dsw-alias-interactive-bg-hover); } .tsd-cur-check { margin-left: auto; color: var(--dsw-alias-brand-primary); } /* ============ 模型占比(KPI 卡第 6 格) ============ */ -/* 与其余 5 张卡同构:head / value(最大段百分比)/ sub(模型名)/ 色带。 - hover·focus 某段时 value 与 sub 一起换成该段;明细走 .tsd-tooltip(fixed), - 不用原生 title —— title 的黑框会盖住相邻卡片。 */ +/* 主体是占比条,下方模型名与百分比压成脚注(11px tertiary), + 与其它卡的大数字形成层级差,不抢 KPI 的读数位置。 */ .tsd-card-share-body { display: flex; flex-direction: column; + gap: 8px; min-width: 0; -} -.tsd-share-sub { - margin-top: 2px; - font-size: 12px; - color: var(--dsw-alias-label-tertiary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + /* 对齐 .tsd-card-value 的上边距,六张卡保持同一基线 */ + margin-top: 8px; } .tsd-share-track { display: flex; + align-items: stretch; width: 100%; - height: 4px; - margin-top: 8px; - border-radius: 2px; + height: 10px; + border-radius: 999px; overflow: hidden; - background: var(--dsw-alias-bg-layer-2); + background: var(--dsw-alias-interactive-bg-hover); } .tsd-share-seg { height: 100%; min-width: 2px; + outline: none; + opacity: 0.9; + transition: opacity 0.12s ease; } -.tsd-share-seg[data-active='true'] { filter: brightness(1.2); } -.tsd-share-seg:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); - outline-offset: 1px; +.tsd-share-seg:hover, +.tsd-share-seg:focus-visible, +.tsd-share-seg[data-active='true'] { opacity: 1; } +.tsd-share-caption { + display: flex; + align-items: baseline; + gap: 6px; + font-size: 11px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); +} +.tsd-share-cap-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tsd-share-cap-pct { + flex: none; + font-variant-numeric: tabular-nums; +} +.tsd-share-empty { + font-size: 14px; + font-weight: 400; + color: var(--dsw-alias-label-tertiary); } /* ============ 自定义日期浮层 ============ */ @@ -1308,7 +1343,6 @@ const ZH = { colShare: '占比', modelShare: '模型占比', shareOther: '其它', - shareHint: '按总 Token 占比,仅统计当前范围内的模型', unpriced: '未定价', unpricedRowHint: '该模型未配置定价,按 0 计价', noData: '暂无数据', @@ -1398,7 +1432,6 @@ const EN = { colShare: 'Share', modelShare: 'Model share', shareOther: 'Others', - shareHint: 'Share of total tokens, current range only', unpriced: 'Unpriced', unpricedRowHint: 'No pricing configured; counted as 0', noData: 'No data', @@ -1590,8 +1623,8 @@ const ICON_PATHS = { inbox: 'M22 12h-6l-2 3h-4l-2-3H2m3.5-6.5L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.5-6.5A2 2 0 0016.76 5H7.24a2 2 0 00-1.74 1.5z', alert: 'M12 9v4m0 4h.01M10.3 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.7 3.86a2 2 0 00-3.4 0z', search: 'M21 21l-4.35-4.35M17 10.5a6.5 6.5 0 11-13 0 6.5 6.5 0 0113 0z', - // 饼图(占比):外圈 + 两条半径,与卡片标题的「占比」语义一致 - share: 'M21 12a9 9 0 11-9-9v9h9z', + // 饼图(占比):外圈 + 楔形,与卡片标题的「占比」语义一致 + share: 'M21.21 15.89A10 10 0 118 2.83M22 12A10 10 0 0012 2v10z', check: 'M20 6L9 17l-5-5', } function SvgIcon(props) { @@ -1925,7 +1958,7 @@ function CurrencyPicker(props) { const t = useT() const [open, setOpen] = React.useState(false) const ref = React.useRef(null) - const panelStyle = useFixedPanel(ref, open, 190) + const panelStyle = useFixedPanel(ref, open, 180) useOutsideClick(open, ref, () => setOpen(false)) const active = CURRENCY_SYMBOLS[code] ? code : 'CNY' const options = ['CNY', 'USD'] @@ -1955,8 +1988,8 @@ function CurrencyPicker(props) { onClick: () => setOpen((v) => !v), onKeyDown, }, - h('span', { className: 'tsd-cur-code' }, active), - h(SvgIcon, { name: 'chevron', size: 10, className: 'tsd-chevron' + (open ? ' tsd-chevron-open' : '') })), + h('span', null, CURRENCY_SYMBOLS[active]), + h(SvgIcon, { name: 'chevron', size: 10, className: 'tsd-cur-chevron' })), open ? h('div', { className: 'tsd-popover tsd-cur-popover', @@ -2042,7 +2075,7 @@ function shareSegments(rows) { key: SHARE_REST_KEY, label: null, // 「其它」的文案由渲染层取词典 pct: restTokens / total, - color: 'color-mix(in srgb, var(--dsw-static-deepseek-500) 28%, var(--dsw-alias-bg-layer-3))', + color: 'var(--dsw-alias-label-secondary)', }, agg)) } return { total, segs } @@ -2053,7 +2086,7 @@ function ModelShareBar(props) { const t = useT() const [tip, setTip] = React.useState(null) // { key, left, top } in viewport coords const occ = shareSegments(rows) - if (occ.total <= 0) return h('div', { className: 'tsd-card-value' }, t('noData')) + if (occ.total <= 0) return h('div', { className: 'tsd-card-value tsd-share-empty' }, t('noData')) const find = (key) => occ.segs.filter((s) => s.key === key)[0] || null const shown = (tip && find(tip.key)) || occ.segs[0] const labelOf = (s) => (s.key === SHARE_REST_KEY ? t('shareOther') : s.label) @@ -2086,8 +2119,6 @@ function ModelShareBar(props) { h('span', { className: 'tsd-tooltip-val' }, costOf(shown)))) : null return h('div', { className: 'tsd-card-share-body' }, - h('div', { className: 'tsd-card-value', title: labelOf(shown) }, fmtPct(shown.pct)), - h('div', { className: 'tsd-share-sub' }, labelOf(shown)), h('div', { className: 'tsd-share-track', onMouseLeave: hideTip }, occ.segs.map((s) => h('div', { key: s.key, @@ -2102,6 +2133,11 @@ function ModelShareBar(props) { onFocus: (e) => showTip(e, s), onBlur: hideTip, }))), + // 条下只留一行低调脚注(当前悬浮的那段,否则最大的那段); + // 全名与明细都在悬浮卡片里,所以这里不挂 title。 + h('div', { className: 'tsd-share-caption' }, + h('span', { className: 'tsd-share-cap-name' }, labelOf(shown)), + h('span', { className: 'tsd-share-cap-pct' }, fmtPct(shown.pct))), tipBox) } @@ -2138,11 +2174,10 @@ function SummaryCards(props) { key: 'cost', icon: 'coins', label: tt('cost'), value: formatCost(d.cost, currency), title: formatCost(d.cost, currency), color: 'var(--dsw-static-green-500)', - actions: h(CurrencyPicker, { code: currencyCode, onChange: onCurrencyChange }), + prefix: h(CurrencyPicker, { code: currencyCode, onChange: onCurrencyChange }), }, { key: 'share', icon: 'share', label: tt('modelShare'), - title: tt('shareHint'), color: 'var(--dsw-alias-brand-primary)', content: h(ModelShareBar, { rows: byModel, currency }), }, @@ -2153,11 +2188,10 @@ function SummaryCards(props) { h('div', { className: 'tsd-card-head' }, h('span', { className: 'tsd-card-icon', style: { color: c.color } }, h(SvgIcon, { name: c.icon, size: 14 })), - h('span', { className: 'tsd-card-label' }, c.label), - c.actions ? h('span', { className: 'tsd-card-actions' }, c.actions) : null), + h('span', { className: 'tsd-card-label' }, c.label)), c.content ? c.content - : h('div', { className: 'tsd-card-value', title: c.title }, c.value), + : h('div', { className: 'tsd-card-value', title: c.title }, c.prefix || null, c.value), c.sub ? h('div', { className: 'tsd-card-sub' }, c.sub) : null, c.key === 'cost' && unpricedCalls > 0 ? h('div', { className: 'tsd-card-note' }, tt('unpricedNote', { count: formatInt(unpricedCalls) })) From fc1ef0a9e099b24315d921397b2cb8751e8b67d8 Mon Sep 17 00:00:00 2001 From: XinSpark Date: Mon, 19 Oct 2026 01:41:21 +0800 Subject: [PATCH 12/12] fix(ui): draw the cost symbol once, wrap the menu, use real theme colours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects from the first work-exp pass: - The cost card printed `¥ ¥14.78`: the switcher draws the symbol and `formatCost` still prefixed it. Split out `costDigits` so the card renders digits only and the symbol has exactly one owner. - The rate note ran out of the popover: the switcher lives inside `.tsd-card-value`, which sets `white-space: nowrap` for the number, and the popover inherited it. Reset to `normal` on the popover. - Two share segments rendered as grey and white: `--color-blue-500` and `--color-orange-500` are not DSH tokens, so those backgrounds fell back to nothing. Use only families the theme actually ships (deepseek / green / amber / red) and tint "others" with `--dsw-static-deepseek-300` instead of a neutral grey, which reads as disabled on a dark card. --- src/client/index.js | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/client/index.js b/src/client/index.js index 2b731e4..d81470a 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -364,6 +364,9 @@ const CSS = ` display: flex; flex-direction: column; gap: 2px; + /* 浮层挂在 .tsd-card-value 里,那边为了数值行写了 nowrap; + 浮层里的菜单项与说明必须恢复换行,否则长文案会横着溢出浮层。 */ + white-space: normal; } .tsd-cur-option { display: flex; @@ -1477,13 +1480,16 @@ function formatTokensShort(n) { function formatInt(n) { return Number.isFinite(n) ? Math.round(n).toLocaleString() : '--' } -function formatCost(n, cur) { +/** 只要数字部分:成本卡的符号由币种切换器自己画,再带一次就成了「¥ ¥14.78」。 */ +function costDigits(n) { if (!Number.isFinite(n)) return '--' - const c = cur || '¥' // Below half a cent there is nothing to show at 2 decimals; say so instead of // printing a misleading 0.00 (a $0.03 CNY cost becomes $0.004). - if (n > 0 && n < 0.005) return c + '<0.01' - return c + n.toFixed(2) + if (n > 0 && n < 0.005) return '<0.01' + return n.toFixed(2) +} +function formatCost(n, cur) { + return (cur || '¥') + costDigits(n) } function dayLabel(key) { const [, m, d] = String(key || '').split('-') @@ -2021,10 +2027,12 @@ function CurrencyPicker(props) { * 再叠一层浮层只会把之前修好的裁剪问题带回来。 * ------------------------------------------------------------------------- */ const SHARE_COLORS = [ + // 只用主题里真实存在、且彼此分得开的彩色族:深蓝、绿、琥珀、红。 + // 不用 neutral / 白 / 黑 —— 它们在深浅两套主题里一边会糊进背景,一边会被读成禁用态。 'var(--dsw-static-deepseek-500)', 'var(--dsw-static-green-500)', - 'var(--color-blue-500)', - 'var(--color-orange-500)', + 'var(--dsw-static-amber-500)', + 'var(--dsw-static-red-400)', ] const SHARE_TOP = 4 const SHARE_REST_KEY = '__rest__' @@ -2075,7 +2083,8 @@ function shareSegments(rows) { key: SHARE_REST_KEY, label: null, // 「其它」的文案由渲染层取词典 pct: restTokens / total, - color: 'var(--dsw-alias-label-secondary)', + // 「其它」用同色系的淡色(浅蓝),不用灰/白:那两种在深色卡片上会被读成禁用 + color: 'var(--dsw-static-deepseek-300)', }, agg)) } return { total, segs } @@ -2172,7 +2181,8 @@ function SummaryCards(props) { }, { key: 'cost', icon: 'coins', label: tt('cost'), - value: formatCost(d.cost, currency), title: formatCost(d.cost, currency), + // 符号由 prefix 的币种切换器画(它显示当前币种符号),这里只给数字 + value: costDigits(d.cost), title: formatCost(d.cost, currency), color: 'var(--dsw-static-green-500)', prefix: h(CurrencyPicker, { code: currencyCode, onChange: onCurrencyChange }), },