mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
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:
1 parent
85ae0c7ba2
commit
4e20fad5dc
29 files changed
+688
-61
No files matched your search
@@ -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!({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -39,6 +39,8 @@ export function UsagePage({
|
||||
}
|
||||
initialAppType={initialAppType}
|
||||
onOpenRoutingSettings={onOpenRoutingSettings}
|
||||
usageDisplay={settings?.usageDisplay}
|
||||
onUsageDisplayChange={(usageDisplay) => save({ usageDisplay })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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]}
|
||||
|
||||
@@ -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 会算出离谱的数。 */
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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("--");
|
||||
});
|
||||
});
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user