feat(billing): enforce image generation quota with admin API and user display
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:
john
2026-08-03 14:58:38 +08:00
parent 36d8e74784
commit 946d8756c8
14 changed files with 639 additions and 23 deletions
+75 -7
View File
@@ -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