946d8756c8
Memind CI / Test, build, and release guards (push) Failing after 3s
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>
430 lines
16 KiB
TypeScript
430 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|