Files
memind/src/components/BalanceRing.tsx
T
john 946d8756c8
Memind CI / Test, build, and release guards (push) Failing after 3s
feat(billing): enforce image generation quota with admin API and user display
Add period_images_bonus and ledger tracking, gate image_make on remaining quota,
expose admin image-quota routes, show remaining image quota in the balance popover,
and document that admin UI must live in memind_adm (5174) not ops.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:58:38 +08:00

430 lines
16 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 type { ActiveSubscription } from '../types';
const RING_R = 16;
const CIRC = 2 * Math.PI * RING_R;
const POPOVER_WIDTH = 260;
const HEADER_POPOVER_OPEN_EVENT = 'tkmind:header-popover-open';
const HEADER_POPOVER_ID = 'balance-ring';
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 formatCallsApprox(tokens: number) {
const calls = Math.floor(tokens / 3000);
return `约 ${calls} 次`;
}
function formatImageQuota(sub: ActiveSubscription) {
const limit = sub.periodImagesLimit ?? 0;
const bonus = sub.periodImagesBonus ?? 0;
const used = sub.periodImagesUsed ?? 0;
if (limit === 0) return { unlimited: true, remaining: null, total: null, used };
const total = limit + bonus;
const remaining = Math.max(0, total - used);
return { unlimited: false, remaining, total, used };
}
type BalanceRingProps = {
balanceCents: number;
totalCreditCents?: number;
tokensUsed?: number;
subscription?: ActiveSubscription | null;
onRecharge: (force?: boolean) => void;
onSubscribe?: () => void;
};
const PLAN_LABELS: Record<string, string> = {
free: '免费版',
lite: '轻量版',
standard: '标准版',
pro: '专业版',
};
export function BalanceRing({
balanceCents,
totalCreditCents,
tokensUsed = 0,
subscription,
onRecharge,
onSubscribe,
}: BalanceRingProps) {
const [open, setOpen] = useState(false);
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
const wrapRef = useRef<HTMLDivElement>(null);
// Subscription quota mode: only paid plans change the ring display.
// Free plan runs silently in the background — balance ring stays primary.
const isPaidPlan = Boolean(subscription) && subscription!.planType !== 'free';
const hasSub = isPaidPlan;
const subUnlimited = hasSub && subscription!.periodTokensLimit === 0;
const subLimit = subscription?.periodTokensLimit ?? 0;
const subUsed = subscription?.periodTokensUsed ?? 0;
const subRemaining = Math.max(0, subLimit - subUsed);
const subPct = subLimit > 0 ? Math.round((subRemaining / subLimit) * 100) : 100;
const subLow = hasSub && !subUnlimited && subPct <= 15;
const subEmpty = hasSub && !subUnlimited && subRemaining <= 0;
const imageQuota = subscription ? formatImageQuota(subscription) : null;
const showImageQuota = Boolean(
subscription && (imageQuota?.unlimited || (imageQuota?.total ?? 0) > 0 || (subscription.periodImagesBonus ?? 0) > 0),
);
const imageLow = Boolean(
imageQuota && !imageQuota.unlimited && imageQuota.remaining !== null && imageQuota.total
&& imageQuota.remaining / imageQuota.total <= 0.15,
);
const imageEmpty = Boolean(
imageQuota && !imageQuota.unlimited && imageQuota.remaining === 0,
);
// Balance mode (used when no active subscription or overage).
const total = Math.max(totalCreditCents ?? balanceCents, balanceCents, 0);
const spent = Math.max(0, total - balanceCents);
const balancePct = 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 balanceLow = balanceCents > 0 && balanceCents <= 100;
const balanceEmpty = balanceCents <= 0;
// Ring display: subscription quota takes priority over balance.
let ringRemainLen: number;
let ringSpentLen: number;
let centerLabel: string;
let ariaLabel: string;
let low: boolean;
let empty: boolean;
if (hasSub && !subUnlimited) {
const usedLen = subLimit > 0 ? (subUsed / subLimit) * CIRC : 0;
ringRemainLen = subLimit > 0 ? (subRemaining / subLimit) * CIRC : CIRC;
ringSpentLen = usedLen;
centerLabel = subEmpty ? '0%' : `${subPct}%`;
ariaLabel = `本月额度,剩余 ${subPct}%`;
low = subLow;
empty = subEmpty;
} else if (hasSub && subUnlimited) {
ringRemainLen = CIRC;
ringSpentLen = 0;
centerLabel = '∞';
ariaLabel = '专业版,不限额度';
low = false;
empty = false;
} else {
ringRemainLen = remainLen;
ringSpentLen = spentLen;
centerLabel = total <= 0 ? '—' : balanceEmpty ? '0%' : `${balancePct}%`;
ariaLabel = total > 0 ? `账户余额,剩余 ${balancePct}%` : '账户余额';
low = balanceLow;
empty = balanceEmpty;
}
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(() => {
const handleOtherPopoverOpen = (event: Event) => {
const detail = (event as CustomEvent<{ id?: string }>).detail;
if (detail?.id && detail.id !== HEADER_POPOVER_ID) {
setOpen(false);
}
};
window.addEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
return () => window.removeEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
}, []);
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]);
const periodEndDate = subscription
? new Date(subscription.periodEnd).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
: null;
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) => {
const next = !value;
if (next) {
window.dispatchEvent(
new CustomEvent(HEADER_POPOVER_OPEN_EVENT, {
detail: { id: HEADER_POPOVER_ID },
}),
);
}
return next;
});
}}
>
<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={`${ringSpentLen} ${CIRC}`}
strokeDashoffset={0}
/>
<circle
className="balance-ring-remain"
cx="20"
cy="20"
r={RING_R}
strokeDasharray={`${ringRemainLen} ${CIRC}`}
strokeDashoffset={-ringSpentLen}
/>
</svg>
<span className="balance-ring-center">{centerLabel}</span>
</button>
<div className="balance-popover" role="dialog" aria-label="账户额度" style={popoverStyle}>
{hasSub ? (
<>
<h4>
{PLAN_LABELS[subscription!.planType] ?? subscription!.planType}
{subscription!.planType !== 'free' && (
<span className="balance-plan-badge">订阅中</span>
)}
</h4>
{subUnlimited ? (
<div className="balance-popover-row">
<span className="balance-popover-label balance-popover-label-muted">本月额度</span>
<span>不限量</span>
</div>
) : (
<>
<div className="balance-popover-row">
<span className="balance-popover-label">
<span className="balance-dot balance-dot-remain" aria-hidden="true" />
剩余额度
</span>
<strong>{formatCallsApprox(subRemaining)}</strong>
</div>
<div className="balance-popover-row">
<span className="balance-popover-label">
<span className="balance-dot balance-dot-spent" aria-hidden="true" />
已使用
</span>
<strong>{formatCallsApprox(subUsed)}</strong>
</div>
<div className="balance-popover-bar" aria-hidden="true">
<div
className="balance-popover-bar-spent"
style={{ width: `${subLimit > 0 ? (subUsed / subLimit) * 100 : 0}%` }}
/>
<div
className="balance-popover-bar-remain"
style={{ width: `${subLimit > 0 ? (subRemaining / subLimit) * 100 : 0}%` }}
/>
</div>
</>
)}
{periodEndDate && (
<p className="balance-popover-hint">周期截止 {periodEndDate}</p>
)}
<div className="balance-popover-section">
<h5>余额账户</h5>
<div className="balance-popover-row">
<span className="balance-popover-label balance-popover-label-muted">可用余额</span>
<span>{formatYuan(balanceCents)}</span>
</div>
{subscription!.overageRate < 1 && (
<p className="balance-popover-hint">
超出套餐后按 {Math.round(subscription!.overageRate * 10)} 折从余额扣费
</p>
)}
</div>
{(subEmpty || subLow) && (
<p className="balance-popover-warning">
{subEmpty ? '本月额度已用完' : '额度即将耗尽'}
{balanceCents > 0 ? ',将从余额扣费' : ',请充值继续使用'}
</p>
)}
</>
) : (
<>
<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>
{(balanceLow || balanceEmpty) && (
<p className="balance-popover-warning">余额不足,请充值后继续</p>
)}
{subscription?.planType === 'free' && subscription.periodTokensLimit > 0 && (
<p className="balance-popover-hint">
每月赠送 {formatCallsApprox(Math.max(0, subscription.periodTokensLimit - subscription.periodTokensUsed))} 免费额度,余额用于超出部分
</p>
)}
</>
)}
<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>
{showImageQuota && imageQuota ? (
<div className="balance-popover-section">
<h5>图片生成</h5>
{imageQuota.unlimited ? (
<div className="balance-popover-row">
<span className="balance-popover-label balance-popover-label-muted">本月额度</span>
<span>不限量</span>
</div>
) : (
<>
<div className="balance-popover-row">
<span className="balance-popover-label">
<span className="balance-dot balance-dot-remain" aria-hidden="true" />
剩余张数
</span>
<strong>{imageQuota.remaining ?? 0} 张</strong>
</div>
<div className="balance-popover-row">
<span className="balance-popover-label">
<span className="balance-dot balance-dot-spent" aria-hidden="true" />
已使用
</span>
<strong>{imageQuota.used} 张</strong>
</div>
{(subscription?.periodImagesBonus ?? 0) > 0 ? (
<p className="balance-popover-hint">
含套餐 {subscription!.periodImagesLimit ?? 0} 张 + 额外充值 {subscription!.periodImagesBonus} 张
</p>
) : null}
<div className="balance-popover-bar" aria-hidden="true">
<div
className="balance-popover-bar-spent"
style={{
width: `${imageQuota.total ? (imageQuota.used / imageQuota.total) * 100 : 0}%`,
}}
/>
<div
className="balance-popover-bar-remain"
style={{
width: `${imageQuota.total ? ((imageQuota.remaining ?? 0) / imageQuota.total) * 100 : 0}%`,
}}
/>
</div>
</>
)}
{(imageEmpty || imageLow) && (
<p className="balance-popover-warning">
{imageEmpty ? '本月图片额度已用完,暂无法 AI 生图' : '图片额度即将耗尽'}
</p>
)}
</div>
) : null}
<div style={{ display: 'flex', gap: 8 }}>
{onSubscribe && (
<button
type="button"
className="balance-popover-cta"
onClick={() => {
setOpen(false);
onSubscribe();
}}
style={{ flex: 1 }}
>
{hasSub && subscription!.planType !== 'free' ? '管理套餐' : '升级套餐'}
</button>
)}
<button
type="button"
className="balance-popover-cta"
onClick={() => {
setOpen(false);
onRecharge(empty);
}}
style={onSubscribe ? { flex: 1 } : {}}
>
去充值
</button>
</div>
</div>
</div>
);
}