feat(usage): show usage amounts in a chosen currency

Add a Settings tab to the usage page that replaces the Pricing tab: an
amount display section on top (currency + hand-entered exchange rate) and
the existing pricing configuration below.

- Display currencies: USD, CNY, EUR, JPY, TWD, TRY; USD stays the default.
  Rates are entered manually; untouched preset rates are not written to
  settings.json so later preset updates still reach those users.
- Costs stay stored in USD; conversion only happens at display time.
  Converted amounts carry a "≈" prefix and hovers add the USD value.
- Chart data is converted before plotting so axis ticks stay round.
- Yen drops two decimals, and usage amounts now group thousands.
- The sidebar "Today" amount drops decimals from 100 up so it no longer
  squeezes the nav label.
- Settings are device-local (`usageDisplay` in ~/.cc-switch/settings.json).
This commit is contained in:
Jason committed 2026-10-16 19:29:23 +08:00
1 parent 85ae0c7ba2
commit 4e20fad5dc
29 files changed
+688 -61

No files matched your search

+40
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
@@ -359,6 +360,19 @@ pub struct CodexLegacyOfficialHistoryBucketMigration {
pub codex_config_dir: Option<String>,
}
/// 用量金额的显示方式。只影响显示:库里的费用一律是美元,按 `exchange_rates`
/// 换算后显示;值的合法性(认不认得币种、汇率是否为正数)由前端兜底。
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct UsageDisplaySettings {
/// 显示币种("USD" / "CNY"),缺省为 USD
#[serde(default, skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
/// 1 美元折多少目标币种,按币种存:换回 USD 再换回来,填过的汇率还在
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub exchange_rates: BTreeMap<String, f64>,
}
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
@@ -400,6 +414,9 @@ pub struct AppSettings {
pub usage_confirmed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_dashboard_refresh_interval_ms: Option<u32>,
/// 用量金额的显示币种与汇率(只影响显示)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_display: Option<UsageDisplaySettings>,
/// 会话用量自动扫描开关(默认开启=自动模式)。关闭后停止后台定时扫描
/// 各客户端会话日志,仅在用户点击"立即同步"时手动扫描;只管扫描时机,
/// 代理接管记账与启动费用回填(不读会话文件)不受此开关影响。
@@ -598,6 +615,7 @@ impl Default for AppSettings {
proxy_confirmed: None,
usage_confirmed: None,
usage_dashboard_refresh_interval_ms: None,
usage_display: None,
session_auto_sync_enabled: true,
enable_failover_toggle: false,
show_profile_switcher: false,
@@ -1376,6 +1394,28 @@ mod tests {
assert!(saved.get("codexStackClassicSubagents").is_none());
}
#[test]
fn usage_display_round_trips_and_stays_out_of_old_files() {
let old: AppSettings = serde_json::from_value(serde_json::json!({})).expect("old settings");
assert!(old.usage_display.is_none());
assert!(serde_json::to_value(&old)
.expect("serialize")
.get("usageDisplay")
.is_none());
let saved: AppSettings = serde_json::from_value(serde_json::json!({
"usageDisplay": { "currency": "CNY", "exchangeRates": { "CNY": 6.8 } }
}))
.expect("usage display");
let display = saved.usage_display.as_ref().expect("usage display");
assert_eq!(display.currency.as_deref(), Some("CNY"));
assert_eq!(display.exchange_rates.get("CNY"), Some(&6.8));
assert_eq!(
serde_json::to_value(&saved).expect("serialize")["usageDisplay"],
serde_json::json!({ "currency": "CNY", "exchangeRates": { "CNY": 6.8 } })
);
}
#[test]
fn visible_apps_old_settings_default_claude_desktop_visible() {
let visible: VisibleApps = serde_json::from_value(serde_json::json!({
+2 -2
View File
@@ -207,7 +207,7 @@ function MainDirectory({
appsUpdateAvailable = false,
}: DirectoryProps) {
const { t } = useTranslation();
const { money } = useMoneyFormat();
const { moneyShort } = useMoneyFormat();
const { hasUpdate } = useUpdate();
const { appStatus, todayCost, authNeedsAttention } = useSidebarStatus();
const apps = APP_IDS.filter((app) => visibleApps[app]);
@@ -215,7 +215,7 @@ function MainDirectory({
const todayLabel =
todayCost > 0
? t("nav.todayCost", { cost: money(todayCost, 2) })
? t("nav.todayCost", { cost: moneyShort(todayCost) })
: undefined;
const globals: {
+2 -2
View File
@@ -30,7 +30,7 @@ export function ModelStatsTable({
refreshIntervalMs,
}: ModelStatsTableProps) {
const { t, i18n } = useTranslation();
const { money } = useMoneyFormat();
const { money, moneyTitle } = useMoneyFormat();
const locale = getLocaleFromLanguage(getResolvedLang(i18n));
const { data: stats, isLoading } = useModelStats(
range,
@@ -98,7 +98,7 @@ export function ModelStatsTable({
</td>
<td
className={cn(usageTable.td, "font-medium")}
title={`${money(stat.totalCost, 6)} · ${t("usage.avgCost")} ${money(stat.avgCostPerRequest, 4)}`}
title={`${moneyTitle(stat.totalCost, 6)} · ${t("usage.avgCost")} ${money(stat.avgCostPerRequest, 4)}`}
>
{money(stat.totalCost, 2)}
</td>
+2 -2
View File
@@ -31,7 +31,7 @@ export function ProviderStatsTable({
refreshIntervalMs,
}: ProviderStatsTableProps) {
const { t, i18n } = useTranslation();
const { money } = useMoneyFormat();
const { money, moneyTitle } = useMoneyFormat();
const locale = getLocaleFromLanguage(getResolvedLang(i18n));
const { data: stats, isLoading } = useProviderStats(
range,
@@ -105,7 +105,7 @@ export function ProviderStatsTable({
</td>
<td
className={cn(usageTable.td, "font-medium")}
title={money(stat.totalCost, 6)}
title={moneyTitle(stat.totalCost, 6)}
>
{money(stat.totalCost, 2)}
</td>
+7 -3
View File
@@ -85,7 +85,7 @@ function DetailSection({ title, rows }: { title: string; rows: DetailRow[] }) {
function useDetailSections(request: RequestLog) {
const { t, i18n } = useTranslation();
const { money } = useMoneyFormat();
const { money, moneyTitle } = useMoneyFormat();
const usd = (value: string) => money(value, 4);
const locale = getLocaleFromLanguage(getResolvedLang(i18n));
const freshInput = getFreshInputTokens(request);
@@ -218,7 +218,7 @@ function useDetailSections(request: RequestLog) {
value: unpriced ? t("usage.unpriced") : usd(request.totalCostUsd),
strong: !unpriced,
muted: unpriced,
title: unpriced ? undefined : money(request.totalCostUsd, 6),
title: unpriced ? undefined : moneyTitle(request.totalCostUsd, 6),
});
let performance: DetailRow[];
@@ -317,6 +317,7 @@ function useDetailSections(request: RequestLog) {
function RequestDetailBody({ request }: { request: RequestLog }) {
const { t } = useTranslation();
const { currency } = useMoneyFormat();
const sections = useDetailSections(request);
return (
<div className="flex flex-col gap-4">
@@ -325,7 +326,10 @@ function RequestDetailBody({ request }: { request: RequestLog }) {
title={t("usage.detail.tokensTitle")}
rows={sections.tokens}
/>
<DetailSection title={t("usage.detail.costTitle")} rows={sections.cost} />
<DetailSection
title={t("usage.detail.costTitle", { currency })}
rows={sections.cost}
/>
<DetailSection
title={t("usage.detail.performance")}
rows={sections.performance}
+9 -4
View File
@@ -112,7 +112,7 @@ export function RequestLogTable({
onOpenDetail,
}: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const { money } = useMoneyFormat();
const { money, usdHint } = useMoneyFormat();
const [page, setPage] = useState(0);
const pageSize = 20;
@@ -300,9 +300,14 @@ export function RequestLogTable({
unpriced && "font-normal text-fg-3",
)}
title={
multiplier != null && multiplier !== 1
? `${t("usage.costMultiplier")} ×${multiplier.toFixed(2)}`
: undefined
[
unpriced ? undefined : usdHint(log.totalCostUsd),
multiplier != null && multiplier !== 1
? `${t("usage.costMultiplier")} ×${multiplier.toFixed(2)}`
: undefined,
]
.filter(Boolean)
.join(" · ") || undefined
}
>
{unpriced ? t("usage.unpriced") : money(log.totalCostUsd, 4)}
+33 -5
View File
@@ -51,6 +51,8 @@ import { ModelStatsTable } from "./ModelStatsTable";
import { PricingConfigPanel } from "./PricingConfigPanel";
import { RequestDetailPanel } from "./RequestDetailPanel";
import { UsageDataSourcesSheet } from "./UsageDataSourcesSheet";
import { UsageDisplaySection } from "./UsageDisplaySection";
import type { UsageDisplaySettings } from "@/types";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import { UsageHeatmap } from "./UsageHeatmap";
import { fmtInt, formatRelativeTime, getLocaleFromLanguage } from "./format";
@@ -71,8 +73,9 @@ const normalizeRefreshInterval = (value: number | undefined) =>
const STATUS_CODE_OPTIONS = [200, 400, 401, 429, 500] as const;
type UsageTab = "logs" | "providers" | "models" | "pricing";
const TABS: UsageTab[] = ["logs", "providers", "models", "pricing"];
// 「设置」放显示币种和模型定价(原「定价」页签并进来)
type UsageTab = "logs" | "providers" | "models" | "settings";
const TABS: UsageTab[] = ["logs", "providers", "models", "settings"];
/**
* 手动「立即同步」的时间,离开页面再回来也还在。后端也会记下最近一次扫描(后台定时和
@@ -146,6 +149,11 @@ interface UsageDashboardProps {
initialAppType?: AppTypeFilter;
/** 「数据来源」里的「修改记录请求用量」:打开设置 → 本地路由 */
onOpenRoutingSettings?: () => void;
/** 金额显示币种与汇率(设备级设置) */
usageDisplay?: UsageDisplaySettings;
onUsageDisplayChange?: (
next: UsageDisplaySettings,
) => Promise<boolean> | boolean | void;
}
/**
@@ -159,6 +167,8 @@ export function UsageDashboard({
onSessionAutoSyncEnabledChange,
initialAppType = "all",
onOpenRoutingSettings,
usageDisplay,
onUsageDisplayChange,
}: UsageDashboardProps = {}) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
@@ -636,7 +646,7 @@ export function UsageDashboard({
logs: t("usage.requestLogs"),
providers: t("usage.tabs.providers"),
models: t("usage.tabs.models"),
pricing: t("usage.tabs.pricing"),
settings: t("usage.tabs.settings"),
};
const statusLabel =
statusCode == null
@@ -735,7 +745,25 @@ export function UsageDashboard({
refreshIntervalMs={0}
/>
)}
{tab === "pricing" && <PricingConfigPanel />}
{tab === "settings" && (
<div className="flex flex-col gap-5 pt-3">
{onUsageDisplayChange && (
<UsageDisplaySection
value={usageDisplay}
onChange={onUsageDisplayChange}
/>
)}
<section aria-labelledby="usage-pricing-title">
<h3
id="usage-pricing-title"
className="m-0 text-strong font-semibold text-fg-1"
>
{t("usage.tabs.pricing")}
</h3>
<PricingConfigPanel />
</section>
</div>
)}
</div>
</section>
);
@@ -779,7 +807,7 @@ export function UsageDashboard({
variant="quiet"
size="regular"
onClick={() => {
setTab("pricing");
setTab("settings");
setPricingWhileEmpty(true);
}}
>
@@ -0,0 +1,170 @@
import { useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { UsageDisplaySettings } from "@/types";
import {
DISPLAY_CURRENCIES,
getResolvedLang,
isValidExchangeRate,
resolveMoneyDisplay,
type DisplayCurrency,
} from "./format";
interface UsageDisplaySectionProps {
value: UsageDisplaySettings | undefined;
onChange: (next: UsageDisplaySettings) => Promise<boolean> | boolean | void;
}
/** 「美元」「日元」:按界面语言取币种名,取不到就只写代码 */
function currencyName(code: DisplayCurrency, lang: string): string | undefined {
try {
const name = new Intl.DisplayNames([lang], { type: "currency" }).of(code);
return name && name !== code ? name : undefined;
} catch {
return undefined;
}
}
/** 用量「设置」页签顶部:选显示币种、手填汇率。只改显示,库里照旧存美元。 */
export function UsageDisplaySection({
value,
onChange,
}: UsageDisplaySectionProps) {
const { t, i18n } = useTranslation();
const lang = getResolvedLang(i18n);
const rateId = useId();
const savedCurrency = resolveMoneyDisplay(value).currency;
// 保存要等设置重新读回来才生效,先按用户刚选的显示,免得下拉闪回旧值
const [currency, setCurrency] = useState<DisplayCurrency>(savedCurrency);
useEffect(() => setCurrency(savedCurrency), [savedCurrency]);
// 选中币种的汇率(填过的,或预置值);切币种时输入框跟着换
const rate = resolveMoneyDisplay({ ...value, currency }).rate;
const [rateDraft, setRateDraft] = useState(String(rate));
const [rateInvalid, setRateInvalid] = useState(false);
useEffect(() => {
setRateDraft(String(rate));
setRateInvalid(false);
}, [currency, rate]);
const save = (next: Partial<UsageDisplaySettings>) =>
onChange({ ...value, ...next });
const changeCurrency = async (next: DisplayCurrency) => {
setCurrency(next);
// 保存失败(useSettings 已提示)就退回原来的币种
if ((await save({ currency: next })) === false) {
setCurrency(savedCurrency);
}
};
const commitRate = () => {
if (currency === "USD") return;
const parsed = Number(rateDraft.trim());
if (!isValidExchangeRate(parsed)) {
setRateInvalid(true);
return;
}
setRateInvalid(false);
// 没改就不写:没动过的预置汇率不落进设置文件,以后调整预置值还能跟上
if (parsed === rate) return;
void save({
exchangeRates: { ...value?.exchangeRates, [currency]: parsed },
});
};
const optionLabel = (code: DisplayCurrency) => {
const name = currencyName(code, lang);
return name ? `${code} · ${name}` : code;
};
return (
<section
aria-labelledby={`${rateId}-title`}
className="flex flex-col gap-2"
>
<h3
id={`${rateId}-title`}
className="m-0 text-strong font-semibold text-fg-1"
>
{t("usage.display.title")}
</h3>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-panel bg-subtle px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="text-body text-fg-2">
{t("usage.display.currency")}
</span>
<Select
value={currency}
onValueChange={(next) => {
// Radix Select 偶尔回报空串,忽略
const picked = DISPLAY_CURRENCIES.find((c) => c === next);
if (picked && picked !== currency) void changeCurrency(picked);
}}
>
<SelectTrigger
className="h-7 w-48 text-body"
aria-label={t("usage.display.currency")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DISPLAY_CURRENCIES.map((code) => (
<SelectItem key={code} value={code}>
{optionLabel(code)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currency !== "USD" && (
<div className="flex items-center gap-2 text-body tabular-nums text-fg-2">
<label htmlFor={rateId} className="shrink-0">
{t("usage.display.rate")}
</label>
<span className="shrink-0">1 USD =</span>
<Input
id={rateId}
inputMode="decimal"
className="h-7 w-24 tabular-nums"
value={rateDraft}
aria-invalid={rateInvalid}
onChange={(event) => {
setRateDraft(event.target.value);
setRateInvalid(false);
}}
onBlur={commitRate}
onKeyDown={(event) => {
if (event.key === "Enter") commitRate();
}}
/>
<span className="shrink-0">{currency}</span>
</div>
)}
</div>
{currency !== "USD" && (
<p
className={
rateInvalid
? "m-0 text-caption text-danger-text"
: "m-0 text-caption text-fg-3"
}
>
{rateInvalid
? t("usage.display.rateInvalid")
: // 两句各自带句号:中日文直接相连,其他语言隔一个空格
[t("usage.display.rateHint"), t("usage.display.approxNote")].join(
/^(zh|ja)/i.test(lang) ? "" : " ",
)}
</p>
)}
</section>
);
}
+2 -2
View File
@@ -158,7 +158,7 @@ export function UsageHero({
compact = false,
}: UsageHeroProps) {
const { t, i18n } = useTranslation();
const { money } = useMoneyFormat();
const { money, moneyTitle } = useMoneyFormat();
const locale = getLocaleFromLanguage(getResolvedLang(i18n));
const [moreOpen, setMoreOpen] = useState(false);
@@ -218,7 +218,7 @@ export function UsageHero({
value={
placeholder ?? (totalCost == null ? "--" : money(totalCost, 2))
}
title={totalCost == null ? undefined : money(totalCost, 6)}
title={totalCost == null ? undefined : moneyTitle(totalCost, 6)}
/>
<MetricCard
label={t("usage.totalRequests")}
+2
View File
@@ -39,6 +39,8 @@ export function UsagePage({
}
initialAppType={initialAppType}
onOpenRoutingSettings={onOpenRoutingSettings}
usageDisplay={settings?.usageDisplay}
onUsageDisplayChange={(usageDisplay) => save({ usageDisplay })}
/>
);
}
+8 -2
View File
@@ -175,7 +175,7 @@ export function UsageTrendChart({
refreshIntervalMs,
}: UsageTrendChartProps) {
const { t, i18n } = useTranslation();
const { money, moneyTick } = useMoneyFormat();
const { money, moneyTick, rate } = useMoneyFormat();
const [metric, setMetric] = useState<TrendMetric>("tokens");
const { startDate, endDate } = resolveUsageRange(range);
const { data: trends, isLoading } = useUsageTrends(
@@ -320,7 +320,13 @@ export function UsageTrendChart({
cursor={{ fill: "var(--bg-subtle)" }}
/>
<Bar
dataKey={metric}
// 成本先换算成显示币种再画,Y 轴才会按这个币种挑整齐的刻度
dataKey={
metric === "cost"
? (point: UsageTrendChartPoint) =>
point.cost == null ? null : point.cost * rate
: metric
}
name={metricLabel}
fill="var(--chart-1)"
radius={[3, 3, 0, 0]}
+86 -11
View File
@@ -1,3 +1,4 @@
import type { UsageDisplaySettings } from "@/types";
export function parseFiniteNumber(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
@@ -22,7 +23,26 @@ export function fmtInt(
}
/** 用量金额的显示币种。库里一律存美元,只在显示时按 `rate`(1 美元折多少)换算。 */
export type DisplayCurrency = "USD";
/** 界面语言各自的币种(en→USD、zh→CNY、ja→JPY、zh-TW→TWD、tr→TRY),外加欧元 */
export const DISPLAY_CURRENCIES = [
"USD",
"CNY",
"EUR",
"JPY",
"TWD",
"TRY",
] as const;
export type DisplayCurrency = (typeof DISPLAY_CURRENCIES)[number];
/**
* 用户没填汇率时的起点,按 2026-07~09 的报价取整;界面上写明要手动填,不联网取。
* 改这里只影响没填过的用户(填过的值存在设置文件里)。
*/
export const DEFAULT_EXCHANGE_RATES: Record<
Exclude<DisplayCurrency, "USD">,
number
> = { CNY: 6.8, EUR: 0.87, JPY: 158, TWD: 32, TRY: 48 };
export interface MoneyDisplay {
currency: DisplayCurrency;
@@ -31,9 +51,51 @@ export interface MoneyDisplay {
export const USD_DISPLAY: MoneyDisplay = { currency: "USD", rate: 1 };
const CURRENCY_SYMBOL: Record<DisplayCurrency, string> = { USD: "$" };
// 人民币和日元都写 ¥,两国各自就是这么写的;选的是哪种在设置里看得到
const CURRENCY_SYMBOL: Record<DisplayCurrency, string> = {
USD: "$",
CNY: "¥",
EUR: "€",
JPY: "¥",
TWD: "NT$",
TRY: "₺",
};
/** 固定小数位:`$1.23`。组件里用 `useMoneyFormat()`,别直接传 display。 */
/** 日元面额大(1 美元约 158 日元),同一处少显示两位小数:$0.0012 → ≈¥0.19 */
const FEWER_DIGITS: Partial<Record<DisplayCurrency, number>> = { JPY: 2 };
/** 整数部分加千位分隔:换成日元、里拉后动辄七位数 */
function groupThousands(fixed: string): string {
const [whole, frac] = fixed.split(".");
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return frac == null ? grouped : `${grouped}.${frac}`;
}
export function isValidExchangeRate(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
/** 把设置文件里的值收成能用的显示方式:不认得的币种回落 USD,汇率不合法用预置值。 */
export function resolveMoneyDisplay(
settings: UsageDisplaySettings | undefined,
): MoneyDisplay {
const currency =
DISPLAY_CURRENCIES.find((c) => c === settings?.currency) ?? "USD";
if (currency === "USD") return USD_DISPLAY;
const saved = settings?.exchangeRates?.[currency];
return {
currency,
rate: isValidExchangeRate(saved) ? saved : DEFAULT_EXCHANGE_RATES[currency],
};
}
/** 换算出来的数只是按手填汇率估的,前面加「≈」;0 不加。 */
function moneyPrefix(display: MoneyDisplay, value: number): string {
const approx = display.currency !== "USD" && value !== 0 ? "≈" : "";
return `${approx}${CURRENCY_SYMBOL[display.currency]}`;
}
/** 固定小数位:`$1.23` / `≈¥8.36`。组件里用 `useMoneyFormat()`,别直接传 display。 */
export function formatMoney(
usd: unknown,
digits: number,
@@ -42,21 +104,34 @@ export function formatMoney(
): string {
const num = parseFiniteNumber(usd);
if (num == null) return fallback;
return `${CURRENCY_SYMBOL[display.currency]}${(num * display.rate).toFixed(digits)}`;
const value = num * display.rate;
const shown = Math.max(0, digits - (FEWER_DIGITS[display.currency] ?? 0));
return `${moneyPrefix(display, value)}${groupThousands(value.toFixed(shown))}`;
}
/** 按大小取位数并去掉末尾的 0:`$0.0081` / `$0.012` / `$1.25` */
export function formatMoneyCompact(usd: number, display: MoneyDisplay): string {
const value = usd * display.rate;
const symbol = CURRENCY_SYMBOL[display.currency];
if (value < 0.01) return `${symbol}${Number(value.toFixed(4))}`;
if (value < 1) return `${symbol}${Number(value.toFixed(3))}`;
return `${symbol}${value.toFixed(2)}`;
const prefix = moneyPrefix(display, value);
if (value < 0.01) return `${prefix}${Number(value.toFixed(4))}`;
if (value < 1) return `${prefix}${Number(value.toFixed(3))}`;
return `${prefix}${groupThousands(value.toFixed(2))}`;
}
/** 坐标轴刻度:刻度值本身已经是整齐的数,不再定小数位 */
export function formatMoneyTick(usd: unknown, display: MoneyDisplay): string {
return `${CURRENCY_SYMBOL[display.currency]}${(parseFiniteNumber(usd) ?? 0) * display.rate}`;
/** 侧栏这类窄处:100 以上不带小数(`≈¥280`),免得把旁边的标签挤成省略号。 */
export function formatMoneyShort(usd: unknown, display: MoneyDisplay): string {
const num = parseFiniteNumber(usd);
if (num == null) return "--";
return formatMoney(num, Math.abs(num * display.rate) >= 100 ? 0 : 2, display);
}
/**
* 坐标轴刻度:画图前就把数据换算成显示币种(刻度才会是 ¥50 这种整数),
* 所以这里拿到的已是显示币种的值,只加符号;不定小数位,也不加「≈」。
*/
export function formatMoneyTick(value: unknown, display: MoneyDisplay): string {
const num = parseFiniteNumber(value) ?? 0;
return `${CURRENCY_SYMBOL[display.currency]}${groupThousands(String(num))}`;
}
/** 输出少于这个数的请求(工具调用这类)不算速度:0.1 秒回 15 个 token 会算出离谱的数。 */
+24 -7
View File
@@ -2,24 +2,41 @@ import { useMemo } from "react";
import {
formatMoney,
formatMoneyCompact,
formatMoneyShort,
formatMoneyTick,
resolveMoneyDisplay,
USD_DISPLAY,
} from "@/components/usage/format";
import { useSettingsQuery } from "@/lib/query";
/**
* 用量金额的统一出口:用量面板、侧栏「今日」、会话阅读页的费用都经这里显示。
* 定价(按美元录入)和额度 / 余额(供应商自己的币种)不走这里。
*/
export function useMoneyFormat() {
const display = USD_DISPLAY;
const { data: settings } = useSettingsQuery();
const { currency, rate } = resolveMoneyDisplay(settings?.usageDisplay);
return useMemo(
() => ({
return useMemo(() => {
const display = { currency, rate };
const converted = currency !== "USD";
return {
currency,
/** 1 美元折多少显示币种;画图要先把数据换算好,刻度才整齐 */
rate,
money: (usd: unknown, digits: number, fallback?: string) =>
formatMoney(usd, digits, display, fallback),
moneyCompact: (usd: number) => formatMoneyCompact(usd, display),
moneyTick: (usd: unknown) => formatMoneyTick(usd, display),
}),
[display],
);
moneyShort: (usd: unknown) => formatMoneyShort(usd, display),
moneyTick: (value: unknown) => formatMoneyTick(value, display),
/** 换算显示时悬停给出美元原值;按美元显示时没有,调用方别挂 title */
usdHint: (usd: unknown, digits: number = 6) =>
converted ? formatMoney(usd, digits, USD_DISPLAY) : undefined,
/** 本来就有悬停提示的地方:换算时在后面补上美元原值 */
moneyTitle: (usd: unknown, digits: number) =>
converted
? `${formatMoney(usd, digits, display)} (${formatMoney(usd, digits, USD_DISPLAY)})`
: formatMoney(usd, digits, display),
};
}, [currency, rate]);
}
+11 -2
View File
@@ -2374,6 +2374,14 @@
"seconds": "{{seconds}}s"
},
"dataSources": "Data sources",
"display": {
"title": "Amount display",
"currency": "Currency",
"rate": "Exchange rate",
"rateHint": "Entered by hand and used only for display; costs are still recorded in USD.",
"rateInvalid": "Enter a number greater than 0",
"approxNote": "Converted amounts are marked with ≈. Hover to see the USD value."
},
"providerFilter": {
"label": "Provider",
"all": "All providers",
@@ -2412,7 +2420,8 @@
"label": "Details",
"providers": "Providers",
"models": "Models",
"pricing": "Pricing"
"pricing": "Pricing",
"settings": "Settings"
},
"sortedByRequests": "Sorted by requests",
"app": "App",
@@ -2440,7 +2449,7 @@
},
"detail": {
"tokensTitle": "Token usage",
"costTitle": "Cost breakdown (USD)",
"costTitle": "Cost breakdown ({{currency}})",
"performance": "Performance",
"source": "Source",
"sourceProxy": "Routing service · status {{code}}",
+11 -2
View File
@@ -2374,6 +2374,14 @@
"seconds": "{{seconds}} 秒"
},
"dataSources": "データソース",
"display": {
"title": "金額の表示",
"currency": "通貨",
"rate": "為替レート",
"rateHint": "手動で入力し、表示の換算にのみ使います。費用は引き続き米ドルで記録されます。",
"rateInvalid": "0 より大きい数値を入力してください",
"approxNote": "換算した金額には「≈」が付きます。ホバーで米ドルの値を確認できます。"
},
"providerFilter": {
"label": "プロバイダー",
"all": "すべてのプロバイダー",
@@ -2412,7 +2420,8 @@
"label": "明細",
"providers": "プロバイダー",
"models": "モデル",
"pricing": "料金"
"pricing": "料金",
"settings": "設定"
},
"sortedByRequests": "リクエスト数順",
"app": "アプリ",
@@ -2440,7 +2449,7 @@
},
"detail": {
"tokensTitle": "トークン使用量",
"costTitle": "コスト内訳(USD)",
"costTitle": "コスト内訳({{currency}})",
"performance": "パフォーマンス",
"source": "ソース",
"sourceProxy": "ルーティングサービス · ステータス {{code}}",
+3 -2
View File
@@ -2364,7 +2364,8 @@
"label": "Ayrıntılar",
"providers": "Sağlayıcılar",
"models": "Modeller",
"pricing": "Fiyatlandırma"
"pricing": "Fiyatlandırma",
"settings": "Ayarlar"
},
"sortedByRequests": "İsteklere göre sıralandı",
"app": "Uygulama",
@@ -2386,7 +2387,7 @@
},
"detail": {
"tokensTitle": "Token kullanımı",
"costTitle": "Maliyet dökümü (USD)",
"costTitle": "Maliyet dökümü ({{currency}})",
"performance": "Performans",
"source": "Kaynak",
"sourceProxy": "Yönlendirme hizmeti · durumu {{code}}",
+11 -2
View File
@@ -2375,6 +2375,14 @@
"seconds": "{{seconds}} 秒"
},
"dataSources": "資料來源",
"display": {
"title": "金額顯示",
"currency": "幣別",
"rate": "匯率",
"rateHint": "手動填寫,只用於換算顯示;費用仍以美元記錄。",
"rateInvalid": "請輸入大於 0 的數字",
"approxNote": "換算後的金額前面帶「≈」,滑鼠移上去可看美元原值。"
},
"providerFilter": {
"label": "供應商",
"all": "全部供應商",
@@ -2413,7 +2421,8 @@
"label": "明細",
"providers": "供應商",
"models": "模型",
"pricing": "定價"
"pricing": "定價",
"settings": "設定"
},
"sortedByRequests": "依請求數排序",
"app": "應用程式",
@@ -2441,7 +2450,7 @@
},
"detail": {
"tokensTitle": "Tokens 用量",
"costTitle": "成本明細(USD)",
"costTitle": "成本明細({{currency}})",
"performance": "效能",
"source": "來源",
"sourceProxy": "路由服務 · 狀態碼 {{code}}",
+11 -2
View File
@@ -2374,6 +2374,14 @@
"seconds": "{{seconds}} 秒"
},
"dataSources": "数据来源",
"display": {
"title": "金额显示",
"currency": "币种",
"rate": "汇率",
"rateHint": "手动填写,只用于换算显示;费用仍按美元记录。",
"rateInvalid": "请输入大于 0 的数字",
"approxNote": "换算后的金额前面带「≈」,悬停可看美元原值。"
},
"providerFilter": {
"label": "供应商",
"all": "全部供应商",
@@ -2412,7 +2420,8 @@
"label": "明细",
"providers": "供应商",
"models": "模型",
"pricing": "定价"
"pricing": "定价",
"settings": "设置"
},
"sortedByRequests": "按请求数排序",
"app": "应用",
@@ -2440,7 +2449,7 @@
},
"detail": {
"tokensTitle": "Tokens 用量",
"costTitle": "成本明细(USD)",
"costTitle": "成本明细({{currency}})",
"performance": "性能",
"source": "来源",
"sourceProxy": "路由服务 · 状态码 {{code}}",
+10
View File
@@ -369,6 +369,14 @@ export interface RemoteSnapshotInfo {
remotePath: string;
}
/** 用量金额的显示方式;对应 Rust `UsageDisplaySettings`。值由前端校验兜底。 */
export interface UsageDisplaySettings {
/** "USD" / "CNY",缺省为 USD */
currency?: string;
/** 1 美元折多少目标币种,按币种存 */
exchangeRates?: Record<string, number>;
}
// 应用设置类型(用于设置对话框与 Tauri API)
// 存储在本地 ~/.cc-switch/settings.json,不随数据库同步
export interface Settings {
@@ -396,6 +404,8 @@ export interface Settings {
// User has confirmed the usage query first-run notice
usageConfirmed?: boolean;
usageDashboardRefreshIntervalMs?: number;
// 用量金额的显示币种与汇率(只影响显示,库里一律存美元)
usageDisplay?: UsageDisplaySettings;
// 会话用量自动扫描开关(默认开启=自动模式;关闭后仅手动同步时扫描会话日志,代理记账不受影响)
sessionAutoSyncEnabled?: boolean;
// Whether to show the failover toggle independently on the main page
+2 -1
View File
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import { renderWithQueryClient as render } from "../utils/testQueryClient";
import { describe, expect, it, vi } from "vitest";
import { ProviderStatsTable } from "@/components/usage/ProviderStatsTable";
import {
+2 -1
View File
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, screen } from "@testing-library/react";
import { renderWithQueryClient as render } from "../utils/testQueryClient";
import { describe, expect, it, vi } from "vitest";
import { RequestDetailPanel } from "@/components/usage/RequestDetailPanel";
import type { RequestLog } from "@/types/usage";
+2 -1
View File
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithQueryClient as render } from "../utils/testQueryClient";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
RequestLogTable,
@@ -259,7 +259,7 @@ describe("UsageDashboard (smoke)", () => {
await user.click(screen.getByRole("tab", { name: "usage.tabs.models" }));
expect(await screen.findByText("kimi-k2.6")).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "usage.tabs.pricing" }));
await user.click(screen.getByRole("tab", { name: "usage.tabs.settings" }));
expect(await screen.findByText("DeepSeek V4 Pro")).toBeInTheDocument();
expect(screen.getByText("$1.32")).toBeInTheDocument();
expect(screen.getByText("$0.044")).toBeInTheDocument();
+29 -5
View File
@@ -461,13 +461,37 @@ describe("UsageDashboard", () => {
),
);
await user.click(screen.getByRole("tab", { name: "usage.tabs.pricing" }));
await user.click(screen.getByRole("tab", { name: "usage.tabs.settings" }));
expect(screen.getByTestId("pricing-config-panel")).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "usage.tabs.pricing" }),
screen.getByRole("tab", { name: "usage.tabs.settings" }),
).toHaveAttribute("aria-selected", "true");
});
it("puts the amount display above pricing in the settings tab", async () => {
const user = userEvent.setup();
renderDashboard({
usageDisplay: { currency: "CNY" },
onUsageDisplayChange: vi.fn(),
});
// 头部不再有单独的显示按钮
expect(
screen.queryByRole("button", { name: "usage.display.title" }),
).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "usage.tabs.settings" }));
const display = screen.getByRole("heading", {
name: "usage.display.title",
});
const pricing = screen.getByRole("heading", { name: "usage.tabs.pricing" });
expect(
display.compareDocumentPosition(pricing) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(screen.getByRole("textbox")).toHaveValue("6.8");
expect(screen.getByTestId("pricing-config-panel")).toBeInTheDocument();
});
it("wires the tabs to the tabpanel and supports arrow / Home / End keys", async () => {
const user = userEvent.setup();
renderDashboard();
@@ -494,13 +518,13 @@ describe("UsageDashboard", () => {
await user.keyboard("{End}");
expect(
screen.getByRole("tab", { name: "usage.tabs.pricing" }),
screen.getByRole("tab", { name: "usage.tabs.settings" }),
).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(logsTab).toHaveAttribute("aria-selected", "true");
await user.keyboard("{ArrowLeft}");
expect(
screen.getByRole("tab", { name: "usage.tabs.pricing" }),
screen.getByRole("tab", { name: "usage.tabs.settings" }),
).toHaveAttribute("aria-selected", "true");
await user.keyboard("{Home}");
expect(logsTab).toHaveFocus();
@@ -548,7 +572,7 @@ describe("UsageDashboard", () => {
await screen.findByTestId("pricing-config-panel"),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "usage.tabs.pricing" }),
screen.getByRole("tab", { name: "usage.tabs.settings" }),
).toHaveAttribute("aria-selected", "true");
// 空库里没有可看的概览,不画全是 0 的卡
expect(screen.queryByTestId("usage-hero")).not.toBeInTheDocument();
@@ -0,0 +1,98 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { UsageDisplaySection } from "@/components/usage/UsageDisplaySection";
const pickCurrency = async (
user: ReturnType<typeof userEvent.setup>,
code: string,
) => {
await user.click(
screen.getByRole("combobox", { name: "usage.display.currency" }),
);
await user.click(
await screen.findByRole("option", { name: new RegExp(`^${code}`) }),
);
};
describe("UsageDisplaySection", () => {
// jsdom 没有 scrollIntoView,下拉打开时会调
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});
it("offers every display currency and hides the rate while showing USD", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<UsageDisplaySection value={undefined} onChange={onChange} />);
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
await user.click(
screen.getByRole("combobox", { name: "usage.display.currency" }),
);
const options = await screen.findAllByRole("option");
expect(options.map((o) => o.textContent?.slice(0, 3))).toEqual([
"USD",
"CNY",
"EUR",
"JPY",
"TWD",
"TRY",
]);
await user.keyboard("{Escape}");
await pickCurrency(user, "JPY");
expect(onChange).toHaveBeenCalledWith({ currency: "JPY" });
// 预置汇率:用户没填过时从这里起步
expect(screen.getByRole("textbox")).toHaveValue("158");
});
it("rejects a bad rate and keeps the rates of other currencies", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<UsageDisplaySection
value={{ currency: "CNY", exchangeRates: { CNY: 7, EUR: 0.9 } }}
onChange={onChange}
/>,
);
const input = screen.getByRole("textbox");
expect(input).toHaveValue("7");
await user.clear(input);
await user.type(input, "0{Enter}");
expect(screen.getByText("usage.display.rateInvalid")).toBeInTheDocument();
expect(input).toHaveAttribute("aria-invalid", "true");
expect(onChange).not.toHaveBeenCalled();
await user.clear(input);
await user.type(input, "7.25{Enter}");
expect(onChange).toHaveBeenCalledWith({
currency: "CNY",
exchangeRates: { CNY: 7.25, EUR: 0.9 },
});
});
it("switches the rate box to the rate saved for the picked currency", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<UsageDisplaySection
value={{ currency: "CNY", exchangeRates: { CNY: 7, EUR: 0.9 } }}
onChange={onChange}
/>,
);
await pickCurrency(user, "EUR");
expect(screen.getByRole("textbox")).toHaveValue("0.9");
});
it("does not write the preset rate when it was left unchanged", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<UsageDisplaySection value={{ currency: "TWD" }} onChange={onChange} />,
);
await user.type(screen.getByRole("textbox"), "{Enter}");
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import { renderWithQueryClient as render } from "../utils/testQueryClient";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { UsageTrendChart } from "@/components/usage/UsageTrendChart";
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import { renderWithQueryClient as render } from "../../../utils/testQueryClient";
import { describe, expect, it } from "vitest";
import { getAgentReaderStyle } from "@/components/sessions/reader/agentStyles";
import { transcriptToMarkdown } from "@/components/sessions/reader/exportMarkdown";
+67
View File
@@ -6,11 +6,13 @@ import {
formatFirstTokenSeconds,
formatMoney,
formatMoneyCompact,
formatMoneyShort,
formatMoneyTick,
formatOutputTokensPerSecond,
formatTokensCompact,
formatTokensPerSecond,
formatTokensShort,
resolveMoneyDisplay,
getAggregateTokensPerSecond,
getEstimatedTokensPerSecond,
getOutputTokensPerSecond,
@@ -254,3 +256,68 @@ describe("usage money formatting", () => {
expect(formatMoneyTick(undefined, USD_DISPLAY)).toBe("$0");
});
});
describe("display currency", () => {
it("falls back to USD and the preset rate for unusable settings", () => {
expect(resolveMoneyDisplay(undefined)).toEqual(USD_DISPLAY);
expect(resolveMoneyDisplay({ currency: "GBP" })).toEqual(USD_DISPLAY);
// 汇率只在显示非美元时生效
expect(
resolveMoneyDisplay({ currency: "USD", exchangeRates: { CNY: 7 } }),
).toEqual(USD_DISPLAY);
expect(resolveMoneyDisplay({ currency: "CNY" })).toEqual({
currency: "CNY",
rate: 6.8,
});
for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(
resolveMoneyDisplay({ currency: "CNY", exchangeRates: { CNY: bad } })
.rate,
).toBe(6.8);
}
expect(
resolveMoneyDisplay({ currency: "CNY", exchangeRates: { CNY: 7.25 } }),
).toEqual({ currency: "CNY", rate: 7.25 });
});
it("marks converted amounts with ≈ but leaves zero and axis ticks plain", () => {
const cny = { currency: "CNY" as const, rate: 7 };
expect(formatMoney(1.5, 2, cny)).toBe("≈¥10.50");
expect(formatMoney(0, 2, cny)).toBe("¥0.00");
expect(formatMoney(null, 2, cny)).toBe("--");
expect(formatMoneyCompact(0.001, cny)).toBe("≈¥0.007");
expect(formatMoneyCompact(0.0015, cny)).toBe("≈¥0.011");
// 刻度值画图前已换算,只加符号
expect(formatMoneyTick(50, cny)).toBe("¥50");
});
it("writes each currency with its own symbol and groups thousands", () => {
expect(formatMoney(30470.55, 2, USD_DISPLAY)).toBe("$30,470.55");
expect(formatMoney(-1234, 0, USD_DISPLAY)).toBe("$-1,234");
expect(formatMoney(2, 2, { currency: "EUR", rate: 0.87 })).toBe("≈€1.74");
expect(formatMoney(2, 2, { currency: "TWD", rate: 32 })).toBe("≈NT$64.00");
expect(formatMoney(100, 2, { currency: "TRY", rate: 48 })).toBe(
"≈₺4,800.00",
);
expect(formatMoneyTick(20000, { currency: "JPY", rate: 158 })).toBe(
"¥20,000",
);
});
it("shows yen with two fewer decimals", () => {
const jpy = { currency: "JPY" as const, rate: 158 };
expect(formatMoney(0.0012, 4, jpy)).toBe("≈¥0.19");
expect(formatMoney(30470.55, 2, jpy)).toBe("≈¥4,814,347");
expect(formatMoney(1.5, 1, jpy)).toBe("≈¥237");
expect(formatMoneyShort(0.5, jpy)).toBe("≈¥79");
});
it("drops the decimals in narrow spots once the amount reaches 100", () => {
const cny = { currency: "CNY" as const, rate: 6.8 };
expect(formatMoneyShort(41.2, USD_DISPLAY)).toBe("$41.20");
expect(formatMoneyShort(123.456, USD_DISPLAY)).toBe("$123");
expect(formatMoneyShort(41.2, cny)).toBe("≈¥280");
expect(formatMoneyShort(14.5, cny)).toBe("≈¥98.60");
expect(formatMoneyShort(undefined, cny)).toBe("--");
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useMoneyFormat } from "@/hooks/useMoneyFormat";
import type { Settings } from "@/types";
import { createTestQueryClient } from "../utils/testQueryClient";
const renderWithSettings = (settings: Partial<Settings>) => {
const client = createTestQueryClient();
client.setQueryData(["settings"], settings);
return renderHook(() => useMoneyFormat(), {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
}).result.current;
};
describe("useMoneyFormat", () => {
it("shows USD with no extra hover text by default", () => {
const fmt = renderWithSettings({});
expect(fmt.currency).toBe("USD");
expect(fmt.money(1.5, 2)).toBe("$1.50");
expect(fmt.usdHint(1.5)).toBeUndefined();
expect(fmt.moneyTitle(1.5, 6)).toBe("$1.500000");
});
it("converts with the saved rate and keeps the USD value for hover", () => {
const fmt = renderWithSettings({
usageDisplay: { currency: "CNY", exchangeRates: { CNY: 7 } },
});
expect(fmt.currency).toBe("CNY");
expect(fmt.rate).toBe(7);
expect(fmt.money(1.5, 2)).toBe("≈¥10.50");
expect(fmt.moneyShort(41.2)).toBe("≈¥288");
expect(fmt.usdHint(1.5)).toBe("$1.500000");
expect(fmt.moneyTitle(1.5, 6)).toBe("≈¥10.500000 ($1.500000)");
});
});