a4d46f2b1e
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>
286 lines
8.9 KiB
TypeScript
286 lines
8.9 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|