feat(admin): add image quota management UI on memind_adm 5174
Add image-quota admin pages and API wiring for plan defaults, ledger, and per-user grants, plus local verify scripts and AGENTS.md note that this repo is the sole admin UI surface. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,7 +21,10 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
},
|
||||
{
|
||||
label: '计费',
|
||||
items: [{ to: '/billing', label: '计费中心', end: false }],
|
||||
items: [
|
||||
{ to: '/billing', label: '计费中心', end: false },
|
||||
{ to: '/image-quota', label: '图片额度' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '平台配置',
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
fetchImageQuotaConfig,
|
||||
fetchImageQuotaLedger,
|
||||
patchImageQuotaPlan,
|
||||
} from '../../api/client';
|
||||
import type { ImageQuotaLedgerEntry, PlanDefinition } from '../../types';
|
||||
import { Pagination } from '../../components/Pagination';
|
||||
import { formatTime } from '../utils/format';
|
||||
|
||||
type Tab = 'plans' | 'ledger';
|
||||
|
||||
function fmtQuota(value: number | null | undefined, unlimited = false) {
|
||||
if (unlimited || value == null) return '无限';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
admin_grant: '管理员充值',
|
||||
admin_adjust: '管理员调整',
|
||||
consume: '生图消费',
|
||||
period_reset: '周期重置',
|
||||
plan_change: '套餐变更',
|
||||
};
|
||||
|
||||
export function ImageQuotaPage() {
|
||||
const [tab, setTab] = useState<Tab>('plans');
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>图片生成额度</h2>
|
||||
<p className="muted">
|
||||
用户有剩余额度时才能调用 image_make 生图;套餐默认额度中 0 表示无限。用户级充值请在「用户管理 → 用户详情」中操作。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-card" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === 'plans' ? 'send-btn' : 'ghost-btn'}
|
||||
onClick={() => setTab('plans')}
|
||||
>
|
||||
套餐默认额度
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === 'ledger' ? 'send-btn' : 'ghost-btn'}
|
||||
onClick={() => setTab('ledger')}
|
||||
>
|
||||
额度流水
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'plans' ? <PlansTab /> : <LedgerTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlansTab() {
|
||||
const [plans, setPlans] = useState<PlanDefinition[]>([]);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busyPlan, setBusyPlan] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchImageQuotaConfig();
|
||||
setPlans(result.plans);
|
||||
setDrafts(Object.fromEntries(result.plans.map((plan) => [plan.planType, String(plan.periodImages)])));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const savePlan = async (planType: string) => {
|
||||
const raw = drafts[planType];
|
||||
const periodImages = Math.floor(Number(raw));
|
||||
if (!Number.isFinite(periodImages) || periodImages < 0) {
|
||||
setError('额度必须是非负整数');
|
||||
return;
|
||||
}
|
||||
setBusyPlan(planType);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
await patchImageQuotaPlan(planType, periodImages);
|
||||
setMessage(`已更新 ${planType} 默认图片额度`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setBusyPlan(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
|
||||
<section className="admin-card">
|
||||
{loading && plans.length === 0 ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>套餐</th>
|
||||
<th>标识</th>
|
||||
<th>月图片额度</th>
|
||||
<th>月 Token</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans.map((plan) => (
|
||||
<tr key={plan.planType}>
|
||||
<td>{plan.name}</td>
|
||||
<td>
|
||||
<code className="mono">{plan.planType}</code>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={drafts[plan.planType] ?? String(plan.periodImages)}
|
||||
onChange={(e) => setDrafts((prev) => ({ ...prev, [plan.planType]: e.target.value }))}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{plan.periodTokens === 0 ? '无限' : plan.periodTokens.toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={loading || busyPlan === plan.planType}
|
||||
onClick={() => void savePlan(plan.planType)}
|
||||
>
|
||||
{busyPlan === plan.planType ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{plans.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
暂无套餐配置
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerTab() {
|
||||
const [entries, setEntries] = useState<ImageQuotaLedgerEntry[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [userId, setUserId] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async (p = 1) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchImageQuotaLedger({
|
||||
page: p,
|
||||
pageSize: 30,
|
||||
userId: userId.trim() || undefined,
|
||||
});
|
||||
setEntries(result.entries);
|
||||
setTotal(result.total);
|
||||
setTotalPages(result.totalPages);
|
||||
setPage(p);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load(1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="admin-card">
|
||||
<form
|
||||
className="admin-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void load(1);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="按 userId 过滤"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="send-btn" disabled={loading}>
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
|
||||
<section className="admin-card">
|
||||
{loading && entries.length === 0 ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>用户</th>
|
||||
<th>变动</th>
|
||||
<th>剩余</th>
|
||||
<th>原因</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="muted">{formatTime(entry.createdAt)}</td>
|
||||
<td>
|
||||
<div>{entry.displayName || entry.username || '—'}</div>
|
||||
<code className="mono muted">{entry.userId}</code>
|
||||
</td>
|
||||
<td style={{ color: entry.delta >= 0 ? 'var(--ok)' : 'var(--danger)' }}>
|
||||
{entry.delta >= 0 ? `+${entry.delta}` : entry.delta}
|
||||
</td>
|
||||
<td>{fmtQuota(entry.balanceAfter)}</td>
|
||||
<td>{REASON_LABELS[entry.reason] ?? entry.reason}</td>
|
||||
<td className="muted">{entry.note || entry.refId || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
暂无流水
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={30}
|
||||
onChange={(p) => void load(p)}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { getAdminUser, rechargeUser, updateAdminUser } from '../../api/client';
|
||||
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, grantUserImageQuota } from '../../api/client';
|
||||
import { CapabilitySettings } from '../../components/CapabilitySettings';
|
||||
import { PolicySettings } from '../../components/PolicySettings';
|
||||
import { SkillSettings } from '../../components/SkillSettings';
|
||||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||||
import { formatYuan } from '../utils/format';
|
||||
import type { PortalUser } from '../../types';
|
||||
import type { ImageQuotaView, PortalUser } from '../../types';
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -23,6 +23,9 @@ export function UserDetailPage() {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [recharge, setRecharge] = useState({ amountYuan: '10', note: '' });
|
||||
const [spaceQuotaMb, setSpaceQuotaMb] = useState('5');
|
||||
const [imageQuota, setImageQuota] = useState<ImageQuotaView | null>(null);
|
||||
const [imageQuotaLoading, setImageQuotaLoading] = useState(false);
|
||||
const [imageGrant, setImageGrant] = useState({ delta: '', note: '' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.spaceQuotaBytes) return;
|
||||
@@ -57,6 +60,28 @@ export function UserDetailPage() {
|
||||
};
|
||||
}, [userId, users]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role !== 'user') {
|
||||
setImageQuota(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setImageQuotaLoading(true);
|
||||
void fetchUserImageQuota(user.id)
|
||||
.then((result) => {
|
||||
if (!cancelled) setImageQuota(result.quota);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setImageQuota(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setImageQuotaLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user?.id, user?.role]);
|
||||
|
||||
const handleRecharge = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
@@ -75,6 +100,33 @@ export function UserDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageGrant = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
setMessage(null);
|
||||
setLocalError(null);
|
||||
setError(null);
|
||||
const delta = Math.floor(Number(imageGrant.delta));
|
||||
if (!Number.isFinite(delta) || delta === 0) {
|
||||
setLocalError('请输入非零整数额度');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await grantUserImageQuota(user.id, delta, imageGrant.note.trim());
|
||||
setImageQuota(result.quota);
|
||||
setMessage('图片额度已更新');
|
||||
setImageGrant({ delta: '', note: '' });
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '图片额度调整失败');
|
||||
}
|
||||
};
|
||||
|
||||
const imageQuotaSummary = imageQuota
|
||||
? imageQuota.unlimited
|
||||
? '无限'
|
||||
: `剩余 ${imageQuota.remaining ?? 0} / 总计 ${imageQuota.total ?? 0}(套餐 ${imageQuota.limit} + 充值 ${imageQuota.bonus},已用 ${imageQuota.used})`
|
||||
: '';
|
||||
|
||||
if (!loading && !user && !error) {
|
||||
return <Navigate to="/users" replace />;
|
||||
}
|
||||
@@ -186,6 +238,32 @@ export function UserDetailPage() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<h2>图片生成额度</h2>
|
||||
{imageQuotaLoading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<p className="muted">{imageQuotaSummary || '暂无额度信息(用户可能尚无订阅)'}</p>
|
||||
)}
|
||||
<form className="admin-form" onSubmit={handleImageGrant}>
|
||||
<input
|
||||
placeholder="调整额度(张,正数充值、负数扣减)"
|
||||
type="number"
|
||||
step="1"
|
||||
value={imageGrant.delta}
|
||||
onChange={(e) => setImageGrant((s) => ({ ...s, delta: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
placeholder="备注"
|
||||
value={imageGrant.note}
|
||||
onChange={(e) => setImageGrant((s) => ({ ...s, note: e.target.value }))}
|
||||
/>
|
||||
<button type="submit" className="send-btn">
|
||||
调整额度
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<h2>调整空间</h2>
|
||||
<p className="muted">
|
||||
|
||||
Reference in New Issue
Block a user