import { useEffect, useRef, useState, type CSSProperties } from 'react'; import { getMyUsage } from '../api/client'; import type { UsageRecord } from '../types'; const RING_R = 16; const CIRC = 2 * Math.PI * RING_R; const POPOVER_WIDTH = 260; const RECENT_USAGE_LIMIT = 5; function formatYuan(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } function formatTokenCount(tokens: number) { if (tokens >= 100_000) { return `${(tokens / 10_000).toFixed(1).replace(/\.0$/, '')} 万`; } if (tokens >= 10_000) { return `${(tokens / 10_000).toFixed(2).replace(/\.?0+$/, '')} 万`; } if (tokens >= 1_000) { return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, '')}k`; } return tokens.toLocaleString('zh-CN'); } function formatTokenCompact(tokens: number) { if (tokens >= 1_000) { return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, '')}k`; } return String(tokens); } function formatUsageTime(ts: number) { const date = new Date(ts); const now = new Date(); const isToday = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(); const time = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); if (isToday) return `今天 ${time}`; return date.toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } type BalanceRingProps = { balanceCents: number; totalCreditCents?: number; tokensUsed?: number; onRecharge: (force?: boolean) => void; }; export function BalanceRing({ balanceCents, totalCreditCents, tokensUsed = 0, onRecharge, }: BalanceRingProps) { const [open, setOpen] = useState(false); const [popoverStyle, setPopoverStyle] = useState({}); const [usageRecords, setUsageRecords] = useState(null); const [usageLoading, setUsageLoading] = useState(false); const [usageError, setUsageError] = useState(null); const wrapRef = useRef(null); const total = Math.max(totalCreditCents ?? balanceCents, balanceCents, 0); const spent = Math.max(0, total - balanceCents); const pct = total > 0 ? Math.round((balanceCents / total) * 100) : 0; const spentLen = total > 0 ? (spent / total) * CIRC : 0; const remainLen = total > 0 ? (balanceCents / total) * CIRC : 0; const spentPct = total > 0 ? (spent / total) * 100 : 0; const remainPct = total > 0 ? (balanceCents / total) * 100 : 0; const low = balanceCents > 0 && balanceCents <= 100; const empty = balanceCents <= 0; const showRecentUsage = low || empty; useEffect(() => { if (!open) return; const handleClick = (event: MouseEvent) => { if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) { setOpen(false); } }; document.addEventListener('click', handleClick); return () => document.removeEventListener('click', handleClick); }, [open]); useEffect(() => { if (!open) return; const updatePosition = () => { const anchor = wrapRef.current; if (!anchor) return; const rect = anchor.getBoundingClientRect(); const width = Math.min(POPOVER_WIDTH, window.innerWidth - 24); let left = rect.left + rect.width / 2 - width / 2; left = Math.max(12, Math.min(left, window.innerWidth - width - 12)); setPopoverStyle({ position: 'fixed', top: rect.bottom + 10, left, width, }); }; updatePosition(); window.addEventListener('resize', updatePosition); window.addEventListener('scroll', updatePosition, true); return () => { window.removeEventListener('resize', updatePosition); window.removeEventListener('scroll', updatePosition, true); }; }, [open]); useEffect(() => { if (!open) return; let cancelled = false; setUsageLoading(true); setUsageError(null); void getMyUsage() .then((records) => { if (!cancelled) setUsageRecords(records); }) .catch((err) => { if (!cancelled) { setUsageError(err instanceof Error ? err.message : '加载失败'); } }) .finally(() => { if (!cancelled) setUsageLoading(false); }); return () => { cancelled = true; }; }, [open, tokensUsed, balanceCents]); useEffect(() => { if (open) return; setUsageRecords(null); setUsageError(null); setUsageLoading(false); }, [open]); const recentUsage = (usageRecords ?? []).slice(0, RECENT_USAGE_LIMIT); const centerLabel = total <= 0 ? '—' : empty ? '0%' : `${pct}%`; const ariaLabel = total > 0 ? `账户额度,剩余 ${pct}%` : '账户额度'; return (

账户额度

{formatYuan(balanceCents)}
{formatYuan(spent)}
累计充值 {formatYuan(total)}
); }