feat(dashboard): 最近请求缓存命中列 + 中文模式人民币计价 + 价格解析记忆化
- 最近请求表新增缓存命中率列(缓存读取/总输入) - useCurrency:中文界面费用按最新 USD→CNY 汇率显示 ¥,英文保持 $(open.er-api.com,缓存 12h,失败兜底 7.2) - match_price 记忆化缓存:O(记录数×5910) → O(模型种类×5910),扫描从秒级降到百毫秒级 - 仪表盘右上角显示加载耗时/数据量(get_summary 往返埋点);Rust 侧分段计时
This commit is contained in:
1 parent
de132ffb3a
commit
38a11666ac
9 files changed
+173
-28
No files matched your search
@@ -610,7 +610,26 @@ fn strip_context_suffix(model_name: &str) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Memoized price resolution. `match_price` is called once per usage record
|
||||||
|
/// during the scan, and the models.dev suffix scan is O(5910) per miss — with
|
||||||
|
/// thousands of records sharing a handful of model names, memoizing the result
|
||||||
|
/// turns the whole scan from O(records × 5910) into O(unique_models × 5910).
|
||||||
|
static MATCH_PRICE_MEMO: OnceLock<Mutex<HashMap<String, (String, f64, f64, f64, bool)>>> =
|
||||||
|
OnceLock::new();
|
||||||
|
|
||||||
|
/// Resolve a model's price, caching per-model results (hits and misses).
|
||||||
fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
|
fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
|
||||||
|
let key = model_name.to_ascii_lowercase();
|
||||||
|
let memo = MATCH_PRICE_MEMO.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
if let Some(hit) = memo.lock().unwrap().get(&key) {
|
||||||
|
return hit.clone();
|
||||||
|
}
|
||||||
|
let result = match_price_inner(model_name);
|
||||||
|
memo.lock().unwrap().insert(key, result.clone());
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn match_price_inner(model_name: &str) -> (String, f64, f64, f64, bool) {
|
||||||
let bare = match model_name.rsplit_once('/') {
|
let bare = match model_name.rsplit_once('/') {
|
||||||
Some((_, b)) => b,
|
Some((_, b)) => b,
|
||||||
None => model_name,
|
None => model_name,
|
||||||
@@ -1537,6 +1556,7 @@ pub fn get_prices() -> PricesResult {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh: Option<bool>) -> SummaryResult {
|
pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh: Option<bool>) -> SummaryResult {
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
let refresh = refresh.unwrap_or(false);
|
let refresh = refresh.unwrap_or(false);
|
||||||
let home = resolve_kimi_home(home_override);
|
let home = resolve_kimi_home(home_override);
|
||||||
let now_ms = std::time::SystemTime::now()
|
let now_ms = std::time::SystemTime::now()
|
||||||
@@ -1544,6 +1564,7 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
|
|||||||
let r = range.unwrap_or_else(|| "30d".into());
|
let r = range.unwrap_or_else(|| "30d".into());
|
||||||
|
|
||||||
let (records, meta) = scan_usage_cached(&home, refresh);
|
let (records, meta) = scan_usage_cached(&home, refresh);
|
||||||
|
let t1 = std::time::Instant::now();
|
||||||
let stats = aggregate(&records, &r, now_ms);
|
let stats = aggregate(&records, &r, now_ms);
|
||||||
let all_stats = aggregate(&records, "all", now_ms);
|
let all_stats = aggregate(&records, "all", now_ms);
|
||||||
let heatmap = build_heatmap(&records, now_ms);
|
let heatmap = build_heatmap(&records, now_ms);
|
||||||
@@ -1568,6 +1589,15 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
|
|||||||
name, provider: std::env::var("KIMI_MODEL_PROVIDER").ok(), model: std::env::var("KIMI_MODEL_ID").ok(),
|
name, provider: std::env::var("KIMI_MODEL_PROVIDER").ok(), model: std::env::var("KIMI_MODEL_ID").ok(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let t2 = std::time::Instant::now();
|
||||||
|
let scan_ms = t1.duration_since(t0).as_secs_f64() * 1000.0;
|
||||||
|
let aggregate_ms = t2.duration_since(t1).as_secs_f64() * 1000.0;
|
||||||
|
let total_ms = t2.duration_since(t0).as_secs_f64() * 1000.0;
|
||||||
|
eprintln!(
|
||||||
|
"[dashboard] get_summary range={} records={} scan={:.0}ms aggregate={:.0}ms total={:.0}ms",
|
||||||
|
r, records.len(), scan_ms, aggregate_ms, total_ms
|
||||||
|
);
|
||||||
|
|
||||||
SummaryResult {
|
SummaryResult {
|
||||||
home: home.to_string_lossy().to_string(),
|
home: home.to_string_lossy().to_string(),
|
||||||
valid: is_kimi_home(&home),
|
valid: is_kimi_home(&home),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
import { useCurrency } from "../../hooks/useCurrency";
|
||||||
|
import { fmtPct, fmtTokens } from "../../lib/dashboard-format";
|
||||||
import type { DailyRow } from "../../types/dashboard";
|
import type { DailyRow } from "../../types/dashboard";
|
||||||
import { DailyDetailModal } from "./DailyDetailModal";
|
import { DailyDetailModal } from "./DailyDetailModal";
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ function shortModel(model: string): string {
|
|||||||
|
|
||||||
export function DailyBars({ daily, dimension, names, unknownProviderLabel }: DailyBarsProps) {
|
export function DailyBars({ daily, dimension, names, unknownProviderLabel }: DailyBarsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { money } = useCurrency();
|
||||||
const [selected, setSelected] = useState<DailyRow | null>(null);
|
const [selected, setSelected] = useState<DailyRow | null>(null);
|
||||||
|
|
||||||
const colorMap = useMemo(() => {
|
const colorMap = useMemo(() => {
|
||||||
@@ -103,7 +105,7 @@ export function DailyBars({ daily, dimension, names, unknownProviderLabel }: Dai
|
|||||||
|
|
||||||
const tooltipLines = [
|
const tooltipLines = [
|
||||||
d.date,
|
d.date,
|
||||||
`${t("totalTokens")}: ${fmtTokens(d.totalTokens)} · ${fmtUsd(d.costUsd)} · ${t("colCacheHit")} ${fmtPct(d.cacheHitRate)}`,
|
`${t("totalTokens")}: ${fmtTokens(d.totalTokens)} · ${money(d.costUsd)} · ${t("colCacheHit")} ${fmtPct(d.cacheHitRate)}`,
|
||||||
...segments.map((s) => `${displayName(s.key)}: ${fmtTokens(s.tokens)}`),
|
...segments.map((s) => `${displayName(s.key)}: ${fmtTokens(s.tokens)}`),
|
||||||
"",
|
"",
|
||||||
t("doubleClickHint"),
|
t("doubleClickHint"),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
import { useCurrency } from "../../hooks/useCurrency";
|
||||||
|
import { fmtPct, fmtTokens } from "../../lib/dashboard-format";
|
||||||
import type { DailyRow } from "../../types/dashboard";
|
import type { DailyRow } from "../../types/dashboard";
|
||||||
import { modelColor } from "./DailyBars";
|
import { modelColor } from "./DailyBars";
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ function shortModel(model: string): string {
|
|||||||
|
|
||||||
export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabel, onClose }: DailyDetailModalProps) {
|
export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabel, onClose }: DailyDetailModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { money } = useCurrency();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -106,7 +108,7 @@ export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabe
|
|||||||
{/* Day summary chips */}
|
{/* Day summary chips */}
|
||||||
<div className="grid grid-cols-2 gap-2 px-5 py-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2 px-5 py-3 sm:grid-cols-4">
|
||||||
<SummaryChip label={t("totalTokens")} value={fmtTokens(day.totalTokens)} tone="default" />
|
<SummaryChip label={t("totalTokens")} value={fmtTokens(day.totalTokens)} tone="default" />
|
||||||
<SummaryChip label={t("cost")} value={fmtUsd(day.costUsd)} tone="orange" />
|
<SummaryChip label={t("cost")} value={money(day.costUsd)} tone="orange" />
|
||||||
<SummaryChip label={t("requests")} value={Math.round(day.requests).toLocaleString()} tone="blue" />
|
<SummaryChip label={t("requests")} value={Math.round(day.requests).toLocaleString()} tone="blue" />
|
||||||
<SummaryChip label={t("cacheHitRate")} value={fmtPct(day.cacheHitRate)} tone="green" />
|
<SummaryChip label={t("cacheHitRate")} value={fmtPct(day.cacheHitRate)} tone="green" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
|
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
|
||||||
|
import { useCurrency } from "../../hooks/useCurrency";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
import { fmtInt, fmtPct, fmtTime, fmtTokens } from "../../lib/dashboard-format";
|
||||||
import { DailyBars } from "./DailyBars";
|
import { DailyBars } from "./DailyBars";
|
||||||
import { Heatmap } from "./Heatmap";
|
import { Heatmap } from "./Heatmap";
|
||||||
import { TrendLineChart } from "./TrendLineChart";
|
import { TrendLineChart } from "./TrendLineChart";
|
||||||
|
|
||||||
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
|
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
|
||||||
|
|
||||||
|
/** Per-request cache hit rate: cached tokens / total input tokens. */
|
||||||
|
function cacheHitOf(r: { inputOther: number; inputCacheRead: number }): number {
|
||||||
|
const denom = r.inputOther + r.inputCacheRead;
|
||||||
|
return denom > 0 ? r.inputCacheRead / denom : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function Card({
|
function Card({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
@@ -44,7 +51,8 @@ function KpiCard({ label, value, sub }: { label: string; value: string; sub?: st
|
|||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { range, changeRange, data, loading, error, refresh } = useDashboard();
|
const { money } = useCurrency();
|
||||||
|
const { range, changeRange, data, loading, error, refresh, loadStats } = useDashboard();
|
||||||
const [showAllModels, setShowAllModels] = useState(false);
|
const [showAllModels, setShowAllModels] = useState(false);
|
||||||
const [recentPage, setRecentPage] = useState(1);
|
const [recentPage, setRecentPage] = useState(1);
|
||||||
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
|
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
|
||||||
@@ -120,28 +128,35 @@ export function DashboardPage() {
|
|||||||
{ label: t("kpiCacheCreation"), value: fmtTokens(totals.inputCacheCreation), sub: fmtInt(totals.inputCacheCreation) },
|
{ label: t("kpiCacheCreation"), value: fmtTokens(totals.inputCacheCreation), sub: fmtInt(totals.inputCacheCreation) },
|
||||||
{ label: t("kpiCacheHit"), value: fmtPct(totals.cacheHitRate) },
|
{ label: t("kpiCacheHit"), value: fmtPct(totals.cacheHitRate) },
|
||||||
{ label: t("totalTokens"), value: fmtTokens(totals.totalTokens), sub: fmtInt(totals.totalTokens) },
|
{ label: t("totalTokens"), value: fmtTokens(totals.totalTokens), sub: fmtInt(totals.totalTokens) },
|
||||||
{ label: t("kpiCost"), value: fmtUsd(totals.costUsd) },
|
{ label: t("kpiCost"), value: money(totals.costUsd) },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto p-4 space-y-4">
|
<div className="h-full overflow-auto p-4 space-y-4">
|
||||||
{/* Range tabs + refresh */}
|
{/* Range tabs + refresh */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center bg-input border border-border rounded p-0.5">
|
<div className="flex items-center gap-3">
|
||||||
{RANGES.map((r) => (
|
<div className="flex items-center bg-input border border-border rounded p-0.5">
|
||||||
<button
|
{RANGES.map((r) => (
|
||||||
key={r}
|
<button
|
||||||
type="button"
|
key={r}
|
||||||
onClick={() => changeRange(r)}
|
type="button"
|
||||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
onClick={() => changeRange(r)}
|
||||||
range === r
|
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||||
? "bg-blue-600 text-white"
|
range === r
|
||||||
: "text-content-muted hover:text-content-primary"
|
? "bg-blue-600 text-white"
|
||||||
}`}
|
: "text-content-muted hover:text-content-primary"
|
||||||
>
|
}`}
|
||||||
{RANGE_LABELS[r]}
|
>
|
||||||
</button>
|
{RANGE_LABELS[r]}
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{loadStats && !loading && (
|
||||||
|
<span className="hidden text-[10px] text-content-muted sm:inline">
|
||||||
|
{t("loadStats", { ms: loadStats.ms, kb: loadStats.kb })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -175,7 +190,7 @@ export function DashboardPage() {
|
|||||||
{fmtTokens(tot.totalTokens)}
|
{fmtTokens(tot.totalTokens)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[11px] text-content-muted">
|
<div className="mt-1 text-[11px] text-content-muted">
|
||||||
{fmtInt(tot.requests)} {t("unitTimes")} · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
|
{fmtInt(tot.requests)} {t("unitTimes")} · {money(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -305,7 +320,7 @@ export function DashboardPage() {
|
|||||||
<td className="py-2 pr-3 text-right text-content-muted">{fmtInt(m.requests)}</td>
|
<td className="py-2 pr-3 text-right text-content-muted">{fmtInt(m.requests)}</td>
|
||||||
<td className="py-2 pr-3 text-right text-content-muted">{fmtTokens(m.totalTokens)}</td>
|
<td className="py-2 pr-3 text-right text-content-muted">{fmtTokens(m.totalTokens)}</td>
|
||||||
<td className="py-2 pr-3 text-right text-content-muted">{fmtPct(m.cacheHitRate)}</td>
|
<td className="py-2 pr-3 text-right text-content-muted">{fmtPct(m.cacheHitRate)}</td>
|
||||||
<td className="py-2 text-right text-orange-600 dark:text-orange-400">{fmtUsd(m.costUsd)}</td>
|
<td className="py-2 text-right text-orange-600 dark:text-orange-400">{money(m.costUsd)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -346,6 +361,7 @@ export function DashboardPage() {
|
|||||||
<th className="pb-2 pr-4 font-normal text-right">{t("colInput")}</th>
|
<th className="pb-2 pr-4 font-normal text-right">{t("colInput")}</th>
|
||||||
<th className="pb-2 pr-4 font-normal text-right">{t("colOutput")}</th>
|
<th className="pb-2 pr-4 font-normal text-right">{t("colOutput")}</th>
|
||||||
<th className="pb-2 pr-4 font-normal text-right">{t("colCacheRead")}</th>
|
<th className="pb-2 pr-4 font-normal text-right">{t("colCacheRead")}</th>
|
||||||
|
<th className="pb-2 pr-4 font-normal text-right">{t("colCacheHit")}</th>
|
||||||
<th className="pb-2 font-normal text-right">{t("cost")}</th>
|
<th className="pb-2 font-normal text-right">{t("cost")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -359,7 +375,8 @@ export function DashboardPage() {
|
|||||||
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputOther)}</td>
|
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputOther)}</td>
|
||||||
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.output)}</td>
|
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.output)}</td>
|
||||||
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputCacheRead)}</td>
|
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputCacheRead)}</td>
|
||||||
<td className="py-1.5 text-right text-orange-600 dark:text-orange-400">{fmtUsd(r.costUsd)}</td>
|
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtPct(cacheHitOf(r))}</td>
|
||||||
|
<td className="py-1.5 text-right text-orange-600 dark:text-orange-400">{money(r.costUsd)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
import { useCurrency } from "../../hooks/useCurrency";
|
||||||
|
import { fmtPct, fmtTokens } from "../../lib/dashboard-format";
|
||||||
import type { HeatmapCell, HeatmapData } from "../../types/dashboard";
|
import type { HeatmapCell, HeatmapData } from "../../types/dashboard";
|
||||||
|
|
||||||
interface HeatmapProps {
|
interface HeatmapProps {
|
||||||
@@ -220,6 +221,7 @@ export function Heatmap({ heatmap }: HeatmapProps) {
|
|||||||
|
|
||||||
function HeatmapTooltip({ state }: { state: HoverState }) {
|
function HeatmapTooltip({ state }: { state: HoverState }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { money } = useCurrency();
|
||||||
const { cell, x, y } = state;
|
const { cell, x, y } = state;
|
||||||
// Position above the cursor with a small offset; flip below if too close to top.
|
// Position above the cursor with a small offset; flip below if too close to top.
|
||||||
const offset = 14;
|
const offset = 14;
|
||||||
@@ -239,7 +241,7 @@ function HeatmapTooltip({ state }: { state: HoverState }) {
|
|||||||
>
|
>
|
||||||
<div className="font-medium text-content-primary">{cell.date}</div>
|
<div className="font-medium text-content-primary">{cell.date}</div>
|
||||||
<div className="text-content-muted">
|
<div className="text-content-muted">
|
||||||
{fmtTokens(cell.totalTokens)} · {fmtUsd(cell.costUsd)} ·{" "}
|
{fmtTokens(cell.totalTokens)} · {money(cell.costUsd)} ·{" "}
|
||||||
{fmtInt(cell.requests)} {t("requests")}
|
{fmtInt(cell.requests)} {t("requests")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-content-muted">
|
<div className="text-content-muted">
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "../i18n";
|
||||||
|
import { fmtUsd } from "../lib/dashboard-format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* USD → CNY exchange rate for the dashboard.
|
||||||
|
* Chinese mode shows prices in CNY (latest available rate), English stays in
|
||||||
|
* USD. The rate is fetched once and cached in localStorage for 12h; any
|
||||||
|
* failure falls back to a conservative fixed rate so the UI never blocks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FALLBACK_RATE = 7.2;
|
||||||
|
const CACHE_KEY = "kimi-switch-cny-rate";
|
||||||
|
const CACHE_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
||||||
|
|
||||||
|
function readCachedRate(): number | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const { ts, rate } = JSON.parse(raw) as { ts: number; rate: number };
|
||||||
|
if (Date.now() - ts > CACHE_TTL || !(rate > 1)) return null;
|
||||||
|
return rate;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCachedRate(rate: number) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), rate }));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCurrency() {
|
||||||
|
const { lang } = useTranslation();
|
||||||
|
const [rate, setRate] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const cached = readCachedRate();
|
||||||
|
if (cached) {
|
||||||
|
setRate(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://open.er-api.com/v6/latest/USD");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as { rates?: { CNY?: number } };
|
||||||
|
const cny = Number(data.rates?.CNY);
|
||||||
|
if (cny > 1) {
|
||||||
|
if (!cancelled) setRate(cny);
|
||||||
|
writeCachedRate(cny);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* network blocked → fallback */
|
||||||
|
}
|
||||||
|
if (!cancelled) setRate(FALLBACK_RATE);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Format a USD cost for display: CNY in Chinese mode, USD otherwise. */
|
||||||
|
const money = useCallback(
|
||||||
|
(usd: number): string => {
|
||||||
|
if (lang !== "zh") return fmtUsd(usd);
|
||||||
|
const v = (Number(usd) || 0) * (rate ?? FALLBACK_RATE);
|
||||||
|
if (v === 0) return "¥0.00";
|
||||||
|
if (v < 0.1) return "¥" + v.toFixed(3);
|
||||||
|
if (v < 1) return "¥" + v.toFixed(2);
|
||||||
|
return "¥" + v.toFixed(2);
|
||||||
|
},
|
||||||
|
[lang, rate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { money, cnyRate: rate ?? FALLBACK_RATE };
|
||||||
|
}
|
||||||
@@ -22,16 +22,23 @@ export function useDashboard() {
|
|||||||
const [data, setData] = useState<SummaryResult | null>(null);
|
const [data, setData] = useState<SummaryResult | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
/** Round-trip timing for the last get_summary call (user-perceived lag). */
|
||||||
|
const [loadStats, setLoadStats] = useState<{ ms: number; kb: number } | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async (force = false) => {
|
const refresh = useCallback(async (force = false) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const start = performance.now();
|
||||||
try {
|
try {
|
||||||
const result = await invoke<SummaryResult>("get_summary", {
|
const result = await invoke<SummaryResult>("get_summary", {
|
||||||
range,
|
range,
|
||||||
refresh: force,
|
refresh: force,
|
||||||
});
|
});
|
||||||
setData(result);
|
setData(result);
|
||||||
|
setLoadStats({
|
||||||
|
ms: Math.round(performance.now() - start),
|
||||||
|
kb: Math.round(JSON.stringify(result).length / 1024),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
setError(msg);
|
setError(msg);
|
||||||
@@ -53,5 +60,5 @@ export function useDashboard() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { range, changeRange, data, loading, error, refresh };
|
return { range, changeRange, data, loading, error, refresh, loadStats };
|
||||||
}
|
}
|
||||||
@@ -197,6 +197,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
scanning: "Scanning…",
|
scanning: "Scanning…",
|
||||||
refresh: "Refresh",
|
refresh: "Refresh",
|
||||||
refreshing: "Refreshing…",
|
refreshing: "Refreshing…",
|
||||||
|
loadStats: "Loaded in {ms} ms · {kb} KB payload",
|
||||||
refreshed: "Refreshed",
|
refreshed: "Refreshed",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
confirmDeleteTitle: "Delete session forever?",
|
confirmDeleteTitle: "Delete session forever?",
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export const zhTranslations = {
|
|||||||
scanning: "扫描中…",
|
scanning: "扫描中…",
|
||||||
refresh: "刷新",
|
refresh: "刷新",
|
||||||
refreshing: "刷新中…",
|
refreshing: "刷新中…",
|
||||||
|
loadStats: "加载 {ms} ms · 数据 {kb} KB",
|
||||||
refreshed: "已刷新",
|
refreshed: "已刷新",
|
||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
confirmDeleteTitle: "永久删除会话?",
|
confirmDeleteTitle: "永久删除会话?",
|
||||||
|
|||||||
Reference in new issue
Block a user