feat(billing): enforce image generation quota with admin API and user display
Memind CI / Test, build, and release guards (push) Failing after 3s
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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
import type { ActiveSubscription } from '../types';
|
||||
|
||||
const RING_R = 16;
|
||||
const CIRC = 2 * Math.PI * RING_R;
|
||||
@@ -28,13 +29,15 @@ function formatCallsApprox(tokens: number) {
|
||||
return `约 ${calls} 次`;
|
||||
}
|
||||
|
||||
type ActiveSubscription = {
|
||||
planType: string;
|
||||
periodTokensLimit: number;
|
||||
periodTokensUsed: number;
|
||||
periodEnd: number;
|
||||
overageRate: number;
|
||||
};
|
||||
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;
|
||||
@@ -76,6 +79,18 @@ export function BalanceRing({
|
||||
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);
|
||||
@@ -329,6 +344,59 @@ export function BalanceRing({
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user