import { useCallback, useEffect, useRef, useState } from 'react'; import { cancelUserSubscription, createSubscriptionPlan, deleteSubscriptionPlan, grantUserSubscription, listAdminLedger, listAdminSubscriptions, listAdminUsage, listSubscriptionPlans, rechargeUser, syncSubscriptionPlansToProduction, updateSubscriptionPlan, } from '../../api/client'; import type { PagedResult } from '../../api/client'; import type { AdminSubscription, AdminUserRow, LedgerEntry, PlanDefinition, UsageRecord } from '../../types'; import { Pagination } from '../../components/Pagination'; import { useAdminUsers } from '../hooks/useAdminUsers'; import { formatTime, formatYuan } from '../utils/format'; function UserCombobox({ users, value, onChange, placeholder = '搜索用户名 / 显示名称', }: { users: AdminUserRow[]; value: string; onChange: (id: string) => void; placeholder?: string; }) { const [query, setQuery] = useState(''); const [open, setOpen] = useState(false); const ref = useRef(null); const selected = users.find((u) => u.id === value); const filtered = query.trim() ? users.filter((u) => { const q = query.toLowerCase(); return u.username.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q); }) : users; useEffect(() => { const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, []); const select = (u: AdminUserRow) => { onChange(u.id); setQuery(''); setOpen(false); }; const clear = () => { onChange(''); setQuery(''); }; return (
setOpen(true)}> {selected && !open ? ( {selected.displayName} @{selected.username} ¥{formatYuan(selected.balanceCents)} ) : ( { setQuery(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} /> )} {value && ( )}
{open && (
{filtered.length === 0 ? (
无匹配用户
) : ( filtered.map((u) => (
select(u)}> {u.displayName} @{u.username} ¥{formatYuan(u.balanceCents)}
)) )}
)}
); } type TabKey = 'recharge' | 'usage' | 'ledger' | 'subscriptions'; const TABS: { key: TabKey; label: string }[] = [ { key: 'subscriptions', label: '订阅记录' }, { key: 'recharge', label: '充值' }, { key: 'usage', label: '用量记录' }, { key: 'ledger', label: '资金流水' }, ]; const PAGE_SIZE = 20; function RechargeTab() { const { users, reload, error, setError } = useAdminUsers(); const [message, setMessage] = useState(null); const [recharge, setRecharge] = useState({ userId: '', amountYuan: '10', note: '' }); const [submitting, setSubmitting] = useState(false); const handleRecharge = async (e: React.FormEvent) => { e.preventDefault(); if (!recharge.userId) return; setMessage(null); setError(null); setSubmitting(true); try { const amountCents = Math.round(Number(recharge.amountYuan) * 100); await rechargeUser(recharge.userId, amountCents, recharge.note || undefined); setMessage('充值成功'); setRecharge((s) => ({ ...s, userId: '', note: '' })); await reload(); } catch (err) { setError(err instanceof Error ? err.message : '充值失败'); } finally { setSubmitting(false); } }; return (

充值

{error &&

{error}

} {message &&

{message}

}
u.role === 'user')} value={recharge.userId} onChange={(id) => setRecharge((s) => ({ ...s, userId: id }))} /> setRecharge((s) => ({ ...s, amountYuan: e.target.value }))} /> setRecharge((s) => ({ ...s, note: e.target.value }))} />
); } function UsageTab() { const { users } = useAdminUsers(); const [result, setResult] = useState | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [filterUserId, setFilterUserId] = useState(''); const [pendingUserId, setPendingUserId] = useState(''); const load = useCallback(async (p: number, userId: string) => { setLoading(true); setError(null); try { const data = await listAdminUsage({ userId: userId || undefined, page: p, pageSize: PAGE_SIZE }); setResult(data); setFilterUserId(userId); } catch (err) { setError(err instanceof Error ? err.message : '加载失败'); } finally { setLoading(false); } }, []); useEffect(() => { void load(1, ''); }, [load]); const handleQuery = () => void load(1, pendingUserId); const handlePage = (p: number) => void load(p, filterUserId); return (

用量记录

{error &&

{error}

} {!result && !loading && (

选择筛选条件后点击查询

)} {loading &&

加载中…

} {!loading && result && ( result.items.length === 0 ? (

暂无记录

) : ( <>
{result.items.map((row) => ( ))}
时间 用户 输入 Token 输出 Token 扣费 余额后
{formatTime(row.createdAt)} @{row.username} {row.inputTokens.toLocaleString()} {row.outputTokens.toLocaleString()} ¥{formatYuan(row.costCents)} ¥{formatYuan(row.balanceAfterCents)}
) )}
); } function LedgerTab() { const { users } = useAdminUsers(); const [result, setResult] = useState | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [filterUserId, setFilterUserId] = useState(''); const [pendingUserId, setPendingUserId] = useState(''); const load = useCallback(async (p: number, userId: string) => { setLoading(true); setError(null); try { const data = await listAdminLedger({ userId: userId || undefined, page: p, pageSize: PAGE_SIZE }); setResult(data); setFilterUserId(userId); } catch (err) { setError(err instanceof Error ? err.message : '加载失败'); } finally { setLoading(false); } }, []); useEffect(() => { void load(1, ''); }, [load]); const handleQuery = () => void load(1, pendingUserId); const handlePage = (p: number) => void load(p, filterUserId); const TYPE_LABEL: Record = { recharge: '充值', deduct: '扣费', refund: '退款', adjust: '调整' }; return (

资金流水

{error &&

{error}

} {!result && !loading && (

选择筛选条件后点击查询

)} {loading &&

加载中…

} {!loading && result && ( result.items.length === 0 ? (

暂无记录

) : ( <>
{result.items.map((row) => ( ))}
时间 用户 类型 金额 备注
{formatTime(row.createdAt)} @{row.username} {TYPE_LABEL[row.type] ?? row.type} {row.amountCents >= 0 ? '+' : ''}¥{formatYuan(Math.abs(row.amountCents))} {row.note ?? '—'}
) )}
); } const PLAN_TIER_LABEL: Record = { basic: '基础', standard: '标准', premium: '旗舰', }; const EMPTY_PLAN_FORM = { planType: '', name: '', priceCents: '0', periodDays: '30', periodTokens: '0', periodImages: '0', modelTier: 'basic', overageRate: '1.00', sortOrder: '0', isActive: true, description: '', }; type PlanForm = typeof EMPTY_PLAN_FORM; function planToForm(p: PlanDefinition): PlanForm { return { planType: p.planType, name: p.name, priceCents: String(p.priceCents), periodDays: String(p.periodDays), periodTokens: String(p.periodTokens), periodImages: String(p.periodImages), modelTier: p.modelTier, overageRate: String(p.overageRate), sortOrder: String(p.sortOrder), isActive: p.isActive, description: p.description ?? '', }; } function PlanFormModal({ initial, onSave, onClose, }: { initial: PlanForm; onSave: (form: PlanForm) => Promise; onClose: () => void; }) { const [form, setForm] = useState(initial); const [saving, setSaving] = useState(false); const [err, setErr] = useState(null); const isNew = !initial.planType; const set = (key: keyof PlanForm, val: string | boolean) => setForm((s) => ({ ...s, [key]: val })); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); setErr(null); try { await onSave(form); } catch (ex) { setErr(ex instanceof Error ? ex.message : '保存失败'); } finally { setSaving(false); } }; return (
e.target === e.currentTarget && onClose()}>

{isNew ? '新建套餐' : `编辑套餐 · ${initial.name}`}

{err &&

{err}

}
{isNew && ( )}
); } function PlanCatalogSection({ plans, onPlansChange, }: { plans: PlanDefinition[]; onPlansChange: () => void; }) { const [editing, setEditing] = useState(null); const [err, setErr] = useState(null); const [msg, setMsg] = useState(null); const [deleting, setDeleting] = useState(null); const [syncingProd, setSyncingProd] = useState(false); const openNew = () => setEditing({ ...EMPTY_PLAN_FORM }); const openEdit = (p: PlanDefinition) => setEditing(planToForm(p)); const closeModal = () => setEditing(null); const handleSave = async (form: PlanForm) => { const data = { name: form.name, priceCents: Number(form.priceCents), periodDays: Number(form.periodDays), periodTokens: Number(form.periodTokens), periodImages: Number(form.periodImages), modelTier: form.modelTier, overageRate: Number(form.overageRate), sortOrder: Number(form.sortOrder), isActive: form.isActive, description: form.description || null, }; const isNew = !editing?.planType || editing.planType !== form.planType; if (isNew) { const result = await createSubscriptionPlan(form.planType, data); setMsg( result.sync?.ok === false ? `已创建套餐「${form.name}」,但生产后台同步失败:${result.sync.message}` : result.sync?.message ? `已创建套餐「${form.name}」,${result.sync.message}` : `已创建套餐「${form.name}」`, ); } else { const result = await updateSubscriptionPlan(form.planType, data); setMsg( result.sync?.ok === false ? `已更新套餐「${form.name}」,但生产后台同步失败:${result.sync.message}` : result.sync?.message ? `已更新套餐「${form.name}」,${result.sync.message}` : `已更新套餐「${form.name}」`, ); } closeModal(); onPlansChange(); }; const handleDelete = async (p: PlanDefinition) => { if (!window.confirm(`确认删除套餐「${p.name}」?已购用户不受影响,但新用户无法选择此套餐。`)) return; setDeleting(p.planType); setErr(null); try { const result = await deleteSubscriptionPlan(p.planType); setMsg( result.sync?.ok === false ? `已删除套餐「${p.name}」,但生产后台同步失败:${result.sync.message}` : result.sync?.message ? `已删除套餐「${p.name}」,${result.sync.message}` : `已删除套餐「${p.name}」`, ); onPlansChange(); } catch (ex) { setErr(ex instanceof Error ? ex.message : '删除失败'); } finally { setDeleting(null); } }; const handleSyncProduction = async () => { setSyncingProd(true); setErr(null); try { const result = await syncSubscriptionPlansToProduction(); if (result.ok) setMsg(result.message); else setErr(result.message); } catch (ex) { setErr(ex instanceof Error ? ex.message : '同步生产后台失败'); } finally { setSyncingProd(false); } }; return (

套餐目录

{err &&

{err}

} {msg &&

{msg}

} {plans.length === 0 ? (

暂无套餐,点击「新建套餐」添加。

) : (
{plans.map((p) => ( ))}
套餐 价格 Token 配额 图片配额 模型层级 天数 超量费率 状态
{p.name} ({p.planType}) {p.description &&
{p.description}
}
{p.priceCents === 0 ? '免费' : `¥${formatYuan(p.priceCents)}/月`} {p.periodTokens === 0 ? '不限' : `${(p.periodTokens / 10000).toFixed(0)} 万`} {p.periodImages === 0 ? '不限' : `${p.periodImages} 张`} {PLAN_TIER_LABEL[p.modelTier] ?? p.modelTier} {p.periodDays} 天 {p.overageRate === 1 ? '—' : `×${p.overageRate}`} {p.isActive ? '上架' : '下架'} {p.planType !== 'free' && ( )}
)} {editing !== null && ( )}
); } function SubscriptionsTab() { const { users, reload: reloadUsers } = useAdminUsers(); const [plans, setPlans] = useState([]); const [result, setResult] = useState<{ items: AdminSubscription[]; total: number; page: number; totalPages: number; pageSize: number; } | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [message, setMessage] = useState(null); const [filterUserId, setFilterUserId] = useState(''); const [pendingUserId, setPendingUserId] = useState(''); const [filterStatus, setFilterStatus] = useState('active'); const [grant, setGrant] = useState({ userId: '', planType: '', durationDays: '', note: '' }); const [submitting, setSubmitting] = useState(false); const [cancelling, setCancelling] = useState(null); const loadPlans = useCallback(async () => { try { const p = await listSubscriptionPlans(); setPlans(p); } catch { /* ignore */ } }, []); useEffect(() => { void loadPlans(); }, [loadPlans]); const load = useCallback(async (p: number, userId: string, status: string) => { setLoading(true); setError(null); try { const data = await listAdminSubscriptions({ userId: userId || undefined, status: status || undefined, page: p, pageSize: 20, }); setResult(data); setFilterUserId(userId); setFilterStatus(status); } catch (err) { setError(err instanceof Error ? err.message : '加载失败'); } finally { setLoading(false); } }, []); useEffect(() => { void load(1, '', 'active'); }, [load]); const handleQuery = () => void load(1, pendingUserId, filterStatus); const handlePage = (p: number) => void load(p, filterUserId, filterStatus); const activePlans = plans.filter((p) => p.isActive && p.priceCents > 0); const handleGrant = async (e: React.FormEvent) => { e.preventDefault(); if (!grant.userId || !grant.planType) return; setSubmitting(true); setError(null); setMessage(null); try { const days = grant.durationDays ? Number(grant.durationDays) : undefined; await grantUserSubscription(grant.userId, grant.planType, days, grant.note || undefined); const planName = plans.find((p) => p.planType === grant.planType)?.name ?? grant.planType; const user = users.find((u) => u.id === grant.userId); setMessage(`已为 ${user?.displayName ?? grant.userId} 授予 ${planName} 套餐`); setGrant((s) => ({ ...s, userId: '', note: '' })); await reloadUsers(); void load(1, filterUserId, filterStatus); } catch (err) { setError(err instanceof Error ? err.message : '授予失败'); } finally { setSubmitting(false); } }; const handleCancel = async (userId: string, displayName: string) => { if (!window.confirm(`确认取消 ${displayName} 的套餐?`)) return; setCancelling(userId); setError(null); setMessage(null); try { await cancelUserSubscription(userId); setMessage(`已取消 ${displayName} 的套餐`); void load(1, filterUserId, filterStatus); } catch (err) { setError(err instanceof Error ? err.message : '取消失败'); } finally { setCancelling(null); } }; const STATUS_LABEL: Record = { active: '有效', expired: '已到期', cancelled: '已取消' }; return ( <> {/* Grant subscription */}

授予套餐

{error &&

{error}

} {message &&

{message}

}
u.role === 'user')} value={grant.userId} onChange={(id) => setGrant((s) => ({ ...s, userId: id }))} /> setGrant((s) => ({ ...s, durationDays: e.target.value }))} /> setGrant((s) => ({ ...s, note: e.target.value }))} />
{/* Subscription list */}

订阅记录

{loading &&

加载中…

} {!loading && result && ( result.items.length === 0 ? (

暂无记录

) : ( <>
{result.items.map((row) => ( ))}
用户 套餐 状态 Token 用量 图片用量 到期时间 备注
{row.displayName} @{row.username} {row.planType} {STATUS_LABEL[row.status] ?? row.status} {row.periodTokensUsed.toLocaleString()} {row.periodTokensLimit > 0 && ( / {row.periodTokensLimit.toLocaleString()} )} {row.periodImagesUsed} {row.periodImagesLimit > 0 && ( / {row.periodImagesLimit} )} {formatTime(row.expiresAt)} {row.note ?? '—'} {row.status === 'active' && ( )}
) )}
); } export function BillingPage() { const [activeTab, setActiveTab] = useState('subscriptions'); return (

计费中心

充值、用量与资金流水

{TABS.map((tab) => ( ))}
{activeTab === 'recharge' && } {activeTab === 'usage' && } {activeTab === 'ledger' && } {activeTab === 'subscriptions' && }
); }