Files
memind/src/components/BalanceRing.tsx
T
john 8d2f55771c Fix MindSpace preview crash and simplify balance ring popover.
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>
2026-06-16 15:55:20 +08:00

262 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}