import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { assignBillingFormula, cancelUserSubscription, createSubscriptionPlan, deleteSubscriptionPlan, getAdminUsageStats, getAdminUsageSummary, getBillingConfig, grantUserSubscription, listAdminLedger, listAdminSubscriptions, listAdminUsage, listSubscriptionPlans, rechargeUser, syncSubscriptionPlansToProduction, updateBillingConfig, updateSubscriptionPlan, } from '../../api/client'; import type { PagedResult } from '../../api/client'; import type { AdminSubscription, AdminUserRow, BillingAdminConfig, BillingFormulaKey, LedgerEntry, PlanDefinition, UsageRecord, UsageStatsResult, UsageSummaryResult, UsageTotals, } from '../../types'; import { Pagination } from '../../components/Pagination'; import { dateRangeToUnix, DEFAULT_CHART_METRICS, METRIC_CONFIG, METRIC_OPTIONS, presetDateRange, toggleChartMetric, UsageTrendChart, type UsageChartMetric, type UsageChartType, } from '../../components/UsageTrendChart'; 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' | 'formula'; const TABS: { key: TabKey; label: string }[] = [ { key: 'subscriptions', label: '订阅记录' }, { key: 'recharge', label: '充值' }, { key: 'usage', label: '用量记录' }, { key: 'ledger', label: '资金流水' }, { key: 'formula', label: '计量公式' }, ]; const TAB_PATHS: Record = { subscriptions: '/billing', recharge: '/billing/recharge', usage: '/billing/usage', ledger: '/billing/ledger', formula: '/billing/formula', }; const SOURCE_LABELS: Record = { 'admin-db': '后台配置', env: '环境变量', 'env-override': '环境变量锁定', default: '默认值', }; function tabFromPath(pathname: string): TabKey { const suffix = pathname.replace(/^\/billing\/?/, ''); if (suffix === 'usage' || suffix.startsWith('usage/')) return 'usage'; if (suffix === 'recharge') return 'recharge'; if (suffix === 'ledger') return 'ledger'; if (suffix === 'formula') return 'formula'; if (suffix === 'subscriptions') return 'subscriptions'; return 'subscriptions'; } const DEFAULT_BILLING_FORMULA: BillingAdminConfig = { useBackendCost: true, usdCnyRate: 7.2, marginMultiplier: 1.2, inputCentsPer1k: 2, outputCentsPer1k: 6, minBillCents: 1, costEstimateFromTokens: true, costEstimateInputUsdPer1M: 0.27, costEstimateOutputUsdPer1M: 1.1, }; const FORMULA_TABS: BillingFormulaKey[] = ['A', 'B']; function FormulaConfigForm({ formulaKey, draft, source, updatedAt, envLocked, saving, onDraftChange, onSave, }: { formulaKey: BillingFormulaKey; draft: BillingAdminConfig; source: string; updatedAt: number | null; envLocked: boolean; saving: boolean; onDraftChange: (next: BillingAdminConfig) => void; onSave: (event: React.FormEvent) => void; }) { const patchNumber = (key: keyof BillingAdminConfig, value: string) => { const num = Number(value); onDraftChange({ ...draft, [key]: Number.isFinite(num) ? num : draft[key], }); }; return (

计量公式 {formulaKey} · 来源:{SOURCE_LABELS[source] ?? source} {updatedAt ? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}` : ''}

Token 回退单价(成本缺失时)

上游成本估算(Finish 无 cost 时)

预览:成本模式扣费 ≈ 上游 USD × {draft.usdCnyRate} × {draft.marginMultiplier}

); } function FormulaAssignmentSection() { const { users, reload, error, setError } = useAdminUsers(); const [selected, setSelected] = useState([]); const [formulaFilter, setFormulaFilter] = useState<'all' | BillingFormulaKey>('all'); const [search, setSearch] = useState(''); const [assigning, setAssigning] = useState(false); const [message, setMessage] = useState(null); const filteredUsers = users.filter((user) => { if (user.role !== 'user') return false; const formula = user.billingFormula ?? 'A'; if (formulaFilter !== 'all' && formula !== formulaFilter) return false; if (!search.trim()) return true; const q = search.toLowerCase(); return user.username.toLowerCase().includes(q) || user.displayName.toLowerCase().includes(q); }); const toggleUser = (userId: string) => { setSelected((prev) => ( prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId] )); }; const toggleAll = () => { const ids = filteredUsers.map((user) => user.id); const allSelected = ids.length > 0 && ids.every((id) => selected.includes(id)); setSelected((prev) => ( allSelected ? prev.filter((id) => !ids.includes(id)) : [...new Set([...prev, ...ids])] )); }; const handleAssign = async (formula: BillingFormulaKey) => { if (!selected.length) return; setAssigning(true); setMessage(null); setError(null); try { const result = await assignBillingFormula(selected, formula); setMessage(`已将 ${result.updated} 位用户分配到计量公式 ${formula}`); setSelected([]); await reload(); } catch (err) { setError(err instanceof Error ? err.message : '批量分配失败'); } finally { setAssigning(false); } }; return (

批量分配计量公式

勾选用户后分配到公式 A 或 B。未分配用户默认使用公式 A。

{error &&

{error}

} {message &&

{message}

}
setSearch(event.target.value)} />
{filteredUsers.map((user) => ( ))}
0 && filteredUsers.every((user) => selected.includes(user.id))} onChange={toggleAll} /> 用户 当前公式 余额
toggleUser(user.id)} /> {user.displayName} @{user.username} {user.billingFormula ?? 'A'} ¥{formatYuan(user.balanceCents)}
{filteredUsers.length === 0 ?

没有匹配的用户

: null}
); } function FormulaTab() { const [activeFormula, setActiveFormula] = useState('A'); const [drafts, setDrafts] = useState>({ A: DEFAULT_BILLING_FORMULA, B: DEFAULT_BILLING_FORMULA, }); const [meta, setMeta] = useState>({ A: { source: 'default', updatedAt: null }, B: { source: 'default', updatedAt: null }, }); const [formulaText, setFormulaText] = useState(''); const [envLocked, setEnvLocked] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [message, setMessage] = useState(null); const load = useCallback(async () => { setLoading(true); setError(null); try { const result = await getBillingConfig('A'); setDrafts({ A: result.formulas?.A ?? result.config, B: result.formulas?.B ?? result.config, }); setMeta({ A: { source: result.formulaMeta?.A?.source ?? result.source ?? 'default', updatedAt: result.formulaMeta?.A?.updatedAt ?? result.updatedAt ?? null, }, B: { source: result.formulaMeta?.B?.source ?? result.source ?? 'default', updatedAt: result.formulaMeta?.B?.updatedAt ?? null, }, }); setFormulaText(result.formula ?? ''); setEnvLocked(Boolean(result.envOverrideActive)); } catch (err) { setError(err instanceof Error ? err.message : '加载计量公式失败'); } finally { setLoading(false); } }, []); useEffect(() => { void load(); }, [load]); const handleSave = async (e: React.FormEvent) => { e.preventDefault(); const draft = drafts[activeFormula]; if (envLocked) { setError('当前环境已锁定为仅读 env(H5_BILLING_CONFIG_SOURCE=env),无法保存。'); return; } if (draft.marginMultiplier <= 0 || draft.usdCnyRate <= 0) { setError('汇率与毛利倍数必须大于 0。'); return; } if ( draft.useBackendCost && !window.confirm( `确认保存公式 ${activeFormula} 成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`, ) ) { return; } setSaving(true); setError(null); setMessage(null); try { const result = await updateBillingConfig(draft, activeFormula); setDrafts({ A: result.formulas?.A ?? drafts.A, B: result.formulas?.B ?? drafts.B, }); setMeta({ A: { source: result.formulaMeta?.A?.source ?? result.source ?? 'admin-db', updatedAt: result.formulaMeta?.A?.updatedAt ?? result.updatedAt ?? null, }, B: { source: result.formulaMeta?.B?.source ?? result.source ?? 'admin-db', updatedAt: result.formulaMeta?.B?.updatedAt ?? result.updatedAt ?? null, }, }); setFormulaText(result.formula ?? ''); setEnvLocked(Boolean(result.envOverrideActive)); setMessage(`计量公式 ${activeFormula} 已保存。Portal 扣费会在数秒内读取新配置,无需重启。`); } catch (err) { setError(err instanceof Error ? err.message : '保存失败'); } finally { setSaving(false); } }; return ( <>

计量公式

支持公式 A / B 两套独立配置。用户默认走公式 A,可在下方批量分配到 B。

{formulaText ?

{formulaText}

: null}
{FORMULA_TABS.map((key) => ( ))}
{loading ?

加载中…

: null} {error &&

{error}

} {message &&

{message}

} {!loading ? ( setDrafts((prev) => ({ ...prev, [activeFormula]: next }))} onSave={handleSave} /> ) : null}
); } 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 UsageRecordsTable({ result, onPage, }: { result: PagedResult; onPage: (page: number) => void; }) { const formatBillingSource = (row: UsageRecord) => { if (row.billingSource === 'subscription') return '套餐额度'; return `¥${formatYuan(row.costCents)}`; }; return ( <>
{result.items.map((row) => ( ))}
时间 用户 输入 Token 输出 Token 扣费 余额后
{formatTime(row.createdAt)} {row.displayName || row.username} @{row.username} {row.inputTokens.toLocaleString()} {row.outputTokens.toLocaleString()} {formatBillingSource(row)} {row.billingSource === 'subscription' ? '—' : `¥${formatYuan(row.balanceAfterCents)}`}
); } function UsageTotalsGrid({ totals }: { totals: UsageTotals }) { const totalTokens = totals.inputTokens + totals.outputTokens; return (
请求次数
{totals.count.toLocaleString()}
Token 总量
{totalTokens.toLocaleString()}
入 {totals.inputTokens.toLocaleString()} / 出 {totals.outputTokens.toLocaleString()}
消费金额
¥{formatYuan(totals.costCents)}
); } function UsageSummaryPanel({ summary, rangeLabel, allTimeLabel = '累计(不限区间)', loading, }: { summary: UsageSummaryResult | null; rangeLabel: string; allTimeLabel?: string; loading?: boolean; }) { if (loading && !summary) { return

汇总加载中…

; } if (!summary) return null; return (
{summary.range && (

{rangeLabel}

)}

{allTimeLabel}

); } function UsageChartPanel({ stats, chartMetrics, chartType, onMetricsChange, onChartTypeChange, title = '用量趋势', hint, }: { stats: UsageStatsResult; chartMetrics: UsageChartMetric[]; chartType: UsageChartType; onMetricsChange: (metrics: UsageChartMetric[]) => void; onChartTypeChange: (type: UsageChartType) => void; title?: string; hint?: string; }) { return (

{title}

{hint &&

{hint}

}
{METRIC_OPTIONS.map(({ key, label }) => { const checked = chartMetrics.includes(key); const color = METRIC_CONFIG[key].color; return ( ); })}
); } type UsageView = 'overview' | 'query'; function UsageOverviewPanel() { const [result, setResult] = useState | null>(null); const [stats, setStats] = useState(null); const [summary, setSummary] = useState(null); const [loadingTable, setLoadingTable] = useState(false); const [loadingChart, setLoadingChart] = useState(false); const [loadingSummary, setLoadingSummary] = useState(false); const [error, setError] = useState(null); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [chartMetrics, setChartMetrics] = useState(DEFAULT_CHART_METRICS); const [chartType, setChartType] = useState('bar'); const hasDateFilter = Boolean(startDate && endDate && startDate <= endDate); const loadTable = useCallback(async (page: number, start: string, end: string) => { if (start && end && start > end) { setError('请选择有效的时间范围'); return; } setLoadingTable(true); setError(null); try { const params: { page: number; pageSize: number; startAt?: number; endAt?: number } = { page, pageSize: PAGE_SIZE, }; if (start && end) { const range = dateRangeToUnix(start, end); params.startAt = range.startAt; params.endAt = range.endAt; } const data = await listAdminUsage(params); setResult(data); } catch (err) { setError(err instanceof Error ? err.message : '加载失败'); } finally { setLoadingTable(false); } }, []); const loadChart = useCallback(async (start: string, end: string) => { if (start && end && start > end) return; setLoadingChart(true); setError(null); try { const chartRange = start && end ? { startDate: start, endDate: end } : presetDateRange(30); const { startAt, endAt } = dateRangeToUnix(chartRange.startDate, chartRange.endDate); const statsData = await getAdminUsageStats({ startAt, endAt }); setStats(statsData); } catch (err) { setError(err instanceof Error ? err.message : '图表加载失败'); } finally { setLoadingChart(false); } }, []); const loadSummary = useCallback(async (start: string, end: string) => { if (start && end && start > end) return; setLoadingSummary(true); setError(null); try { const data = start && end ? await getAdminUsageSummary(dateRangeToUnix(start, end)) : await getAdminUsageSummary(); setSummary(data); } catch (err) { setError(err instanceof Error ? err.message : '汇总加载失败'); } finally { setLoadingSummary(false); } }, []); useEffect(() => { void loadTable(1, startDate, endDate); }, [startDate, endDate, loadTable]); useEffect(() => { void loadChart(startDate, endDate); }, [startDate, endDate, loadChart]); useEffect(() => { void loadSummary(startDate, endDate); }, [startDate, endDate, loadSummary]); const applyPreset = (days: number) => { const range = presetDateRange(days); setStartDate(range.startDate); setEndDate(range.endDate); }; const clearDateFilter = () => { setStartDate(''); setEndDate(''); }; const handlePage = (page: number) => void loadTable(page, startDate, endDate); const chartHint = hasDateFilter ? `${startDate} 至 ${endDate} 全平台用量(列表与图表同步筛选)` : '近 30 天全平台用量(列表展示全部记录;设置日期后列表与图表同步筛选)'; const rangeSummaryLabel = hasDateFilter ? `区间汇总(${startDate} 至 ${endDate})` : ''; return ( <> {error &&

{error}

}
日期区间
setStartDate(e.target.value)} /> setEndDate(e.target.value)} />
{loadingChart && !stats &&

图表加载中…

} {!loadingChart && stats && ( )} {loadingTable && !result &&

加载中…

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

暂无记录

) : ( ) )} ); } function UsageQueryPanel() { const { users } = useAdminUsers(); const defaultRange = presetDateRange(30); const [result, setResult] = useState | null>(null); const [stats, setStats] = useState(null); const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [userId, setUserId] = useState(''); const [startDate, setStartDate] = useState(defaultRange.startDate); const [endDate, setEndDate] = useState(defaultRange.endDate); const [chartMetrics, setChartMetrics] = useState(DEFAULT_CHART_METRICS); const [chartType, setChartType] = useState('bar'); const hasDateFilter = Boolean(startDate && endDate && startDate <= endDate); const load = useCallback(async (p: number, uid: string, start: string, end: string, withChart: boolean) => { if (!uid) return; if (start && end && start > end) { setError('请选择有效的时间范围'); return; } if ((start && !end) || (!start && end)) { setError('请完整选择起止日期'); return; } const filtered = Boolean(start && end); setLoading(true); setError(null); try { const listParams: { userId: string; page: number; pageSize: number; startAt?: number; endAt?: number } = { userId: uid, page: p, pageSize: PAGE_SIZE, }; let statsPromise: Promise = Promise.resolve(null); let summaryPromise: Promise = Promise.resolve(null); if (filtered) { const { startAt, endAt } = dateRangeToUnix(start, end); listParams.startAt = startAt; listParams.endAt = endAt; if (withChart) { statsPromise = getAdminUsageStats({ userId: uid, startAt, endAt }); summaryPromise = getAdminUsageSummary({ userId: uid, startAt, endAt }); } } else if (withChart) { const endAt = Math.floor(Date.now() / 1000); statsPromise = getAdminUsageStats({ userId: uid, startAt: 1, endAt }); summaryPromise = getAdminUsageSummary({ userId: uid }); } const [data, statsData, summaryData] = await Promise.all([ listAdminUsage(listParams), statsPromise, summaryPromise, ]); setResult(data); if (statsData) setStats(statsData); if (summaryData) setSummary(summaryData); } catch (err) { setError(err instanceof Error ? err.message : '加载失败'); } finally { setLoading(false); } }, []); useEffect(() => { if (!userId) { setResult(null); setStats(null); setSummary(null); setError(null); return; } void load(1, userId, startDate, endDate, true); }, [userId, startDate, endDate, load]); const applyPreset = (days: number) => { const range = presetDateRange(days); setStartDate(range.startDate); setEndDate(range.endDate); }; const clearDateFilter = () => { setStartDate(''); setEndDate(''); }; const handlePage = (p: number) => void load(p, userId, startDate, endDate, false); const selectedUser = users.find((u) => u.id === userId); const chartHint = hasDateFilter ? `${startDate} 至 ${endDate}${selectedUser ? ` · ${selectedUser.displayName}` : ''}` : `全部日期${selectedUser ? ` · ${selectedUser.displayName}` : ''}`; const rangeSummaryLabel = hasDateFilter ? `区间汇总(${startDate} 至 ${endDate}${selectedUser ? ` · ${selectedUser.displayName}` : ''})` : ''; const allTimeSummaryLabel = selectedUser ? `累计汇总(${selectedUser.displayName} · 全部日期)` : '累计汇总(全部日期)'; return ( <>
setStartDate(e.target.value)} disabled={!userId} /> setEndDate(e.target.value)} disabled={!userId} />
{error &&

{error}

} {!userId && !loading && (

请选择用户,将自动加载用量与趋势图

)} {userId && loading && !result && !stats &&

加载中…

} {userId && selectedUser && (
{selectedUser.displayName} @{selectedUser.username} 当前余额 ¥{formatYuan(selectedUser.balanceCents)}
)} {userId && ( )} {userId && !loading && stats && ( )} {userId && !loading && result && ( result.items.length === 0 ? (

{hasDateFilter ? '该时间范围内暂无记录' : '该用户暂无用量记录'}

) : ( ) )} ); } function UsageTab() { const location = useLocation(); const navigate = useNavigate(); const view: UsageView = location.pathname.endsWith('/query') ? 'query' : 'overview'; const setView = (next: UsageView) => { navigate(next === 'query' ? '/billing/usage/query' : '/billing/usage'); }; return (

用量记录

{view === 'overview' ? : }
); } 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.displayName || row.username} @{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: '已取消' }; const autoRenewLabel = (row: AdminSubscription) => { if (!row.autoRenew) return '未开启'; if (row.status === 'active') return '已开启'; if (row.status === 'expired') return '待补扣'; return '—'; }; 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} {autoRenewLabel(row)} {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 location = useLocation(); const navigate = useNavigate(); const activeTab = tabFromPath(location.pathname); return (

计费中心

充值、用量、资金流水与计量公式

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