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>
);
}