8d2f55771c
Wrap preview mode in ChatProvider so useChat works, and drop recharge ledger from the balance dropdown to keep it compact. Co-authored-by: Cursor <cursoragent@cursor.com>
262 lines
8.6 KiB
TypeScript
262 lines
8.6 KiB
TypeScript
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<CSSProperties>({});
|
||
const [usageRecords, setUsageRecords] = useState<UsageRecord[] | null>(null);
|
||
const [usageLoading, setUsageLoading] = useState(false);
|
||
const [usageError, setUsageError] = useState<string | null>(null);
|
||
const wrapRef = useRef<HTMLDivElement>(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 (
|
||
<div className={`balance-popover-wrap${open ? ' open' : ''}`} ref={wrapRef}>
|
||
<button
|
||
type="button"
|
||
className={`balance-ring-btn${low ? ' low' : ''}${empty ? ' empty' : ''}`}
|
||
aria-label={ariaLabel}
|
||
aria-expanded={open}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setOpen((value) => !value);
|
||
}}
|
||
>
|
||
<svg className="balance-ring-svg" viewBox="0 0 40 40" aria-hidden="true">
|
||
<circle className="balance-ring-track" cx="20" cy="20" r={RING_R} />
|
||
<circle
|
||
className="balance-ring-spent"
|
||
cx="20"
|
||
cy="20"
|
||
r={RING_R}
|
||
strokeDasharray={`${spentLen} ${CIRC}`}
|
||
strokeDashoffset={0}
|
||
/>
|
||
<circle
|
||
className="balance-ring-remain"
|
||
cx="20"
|
||
cy="20"
|
||
r={RING_R}
|
||
strokeDasharray={`${remainLen} ${CIRC}`}
|
||
strokeDashoffset={-spentLen}
|
||
/>
|
||
</svg>
|
||
<span className="balance-ring-center">{centerLabel}</span>
|
||
</button>
|
||
|
||
<div className="balance-popover" role="dialog" aria-label="账户额度" style={popoverStyle}>
|
||
<h4>账户额度</h4>
|
||
<div className="balance-popover-row">
|
||
<span className="balance-popover-label">
|
||
<span className="balance-dot balance-dot-remain" aria-hidden="true" />
|
||
剩余
|
||
</span>
|
||
<strong>{formatYuan(balanceCents)}</strong>
|
||
</div>
|
||
<div className="balance-popover-row">
|
||
<span className="balance-popover-label">
|
||
<span className="balance-dot balance-dot-spent" aria-hidden="true" />
|
||
已消耗
|
||
</span>
|
||
<strong>{formatYuan(spent)}</strong>
|
||
</div>
|
||
<div className="balance-popover-row">
|
||
<span className="balance-popover-label balance-popover-label-muted">累计充值</span>
|
||
<span>{formatYuan(total)}</span>
|
||
</div>
|
||
<div className="balance-popover-bar" aria-hidden="true">
|
||
<div className="balance-popover-bar-spent" style={{ width: `${spentPct}%` }} />
|
||
<div className="balance-popover-bar-remain" style={{ width: `${remainPct}%` }} />
|
||
</div>
|
||
|
||
<div className="balance-popover-section">
|
||
<h5>AI 消耗</h5>
|
||
<div className="balance-popover-row">
|
||
<span className="balance-popover-label balance-popover-label-muted">累计 Token</span>
|
||
<span>{formatTokenCount(tokensUsed)}</span>
|
||
</div>
|
||
<p className="balance-popover-hint">按人民币结算,每次对话按实际用量扣费</p>
|
||
</div>
|
||
|
||
<div
|
||
className={`balance-popover-section balance-popover-usage${showRecentUsage ? ' expanded' : ''}`}
|
||
>
|
||
<h5>近期扣费</h5>
|
||
{usageLoading ? (
|
||
<p className="balance-popover-muted">加载中…</p>
|
||
) : usageError ? (
|
||
<p className="balance-popover-muted">{usageError}</p>
|
||
) : recentUsage.length === 0 ? (
|
||
<p className="balance-popover-muted">暂无扣费记录</p>
|
||
) : (
|
||
<ul className="balance-usage-list">
|
||
{recentUsage.map((row) => (
|
||
<li key={row.id} className="balance-usage-item">
|
||
<span className="balance-usage-time">{formatUsageTime(row.createdAt)}</span>
|
||
<span className="balance-usage-tokens">
|
||
输入 {formatTokenCompact(row.inputTokens)} / 输出{' '}
|
||
{formatTokenCompact(row.outputTokens)}
|
||
</span>
|
||
<strong className="balance-usage-cost">{formatYuan(row.costCents)}</strong>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="balance-popover-cta"
|
||
onClick={() => {
|
||
setOpen(false);
|
||
onRecharge(empty);
|
||
}}
|
||
>
|
||
去充值
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|