Files
memind_adm/src/admin/pages/BillingPage.tsx
T
john f6bb02254e feat(billing): add formula A/B admin UI and subscription renew status
Expose dual metering formula editing with batch user assignment, show
auto-renew and pending-recharge states in subscriptions, and document
billing acceptance checks for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 08:45:43 +08:00

1953 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLDivElement>(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 (
<div ref={ref} className="user-combobox">
<div className="user-combobox-input-wrap" onClick={() => setOpen(true)}>
{selected && !open ? (
<span className="user-combobox-selected">
{selected.displayName} <span className="muted">@{selected.username}</span>
<span className="user-combobox-balance">¥{formatYuan(selected.balanceCents)}</span>
</span>
) : (
<input
className="user-combobox-input"
placeholder={selected ? `${selected.displayName} @${selected.username}` : placeholder}
value={query}
autoFocus={open}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
/>
)}
{value && (
<button type="button" className="user-combobox-clear" onClick={(e) => { e.stopPropagation(); clear(); }}>×</button>
)}
</div>
{open && (
<div className="user-combobox-dropdown">
{filtered.length === 0 ? (
<div className="user-combobox-empty"></div>
) : (
filtered.map((u) => (
<div key={u.id} className="user-combobox-option" onMouseDown={() => select(u)}>
<span className="user-combobox-name">{u.displayName}</span>
<span className="user-combobox-meta muted">@{u.username}</span>
<span className="user-combobox-bal">¥{formatYuan(u.balanceCents)}</span>
</div>
))
)}
</div>
)}
</div>
);
}
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<TabKey, string> = {
subscriptions: '/billing',
recharge: '/billing/recharge',
usage: '/billing/usage',
ledger: '/billing/ledger',
formula: '/billing/formula',
};
const SOURCE_LABELS: Record<string, string> = {
'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 (
<form className="admin-form" onSubmit={onSave}>
<p className="muted">
{formulaKey} · {SOURCE_LABELS[source] ?? source}
{updatedAt
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
: ''}
</p>
<label className="inline-check">
<input
type="checkbox"
checked={draft.useBackendCost}
disabled={envLocked}
onChange={(event) => onDraftChange({ ...draft, useBackendCost: event.target.checked })}
/>
<span>
<strong></strong>
<span className="muted"> USD </span>
</span>
</label>
<label>
<span>USDCNY</span>
<input
type="number"
min="0.01"
step="0.01"
disabled={envLocked}
value={draft.usdCnyRate}
onChange={(e) => patchNumber('usdCnyRate', e.target.value)}
/>
</label>
<label>
<span></span>
<input
type="number"
min="0.01"
step="0.01"
disabled={envLocked}
value={draft.marginMultiplier}
onChange={(e) => patchNumber('marginMultiplier', e.target.value)}
/>
</label>
<label>
<span></span>
<input
type="number"
min="1"
step="1"
disabled={envLocked}
value={draft.minBillCents}
onChange={(e) => patchNumber('minBillCents', e.target.value)}
/>
</label>
<h3>Token 退</h3>
<label>
<span> / 1k tokens</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked}
value={draft.inputCentsPer1k}
onChange={(e) => patchNumber('inputCentsPer1k', e.target.value)}
/>
</label>
<label>
<span> / 1k tokens</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked}
value={draft.outputCentsPer1k}
onChange={(e) => patchNumber('outputCentsPer1k', e.target.value)}
/>
</label>
<h3>Finish cost </h3>
<label className="inline-check">
<input
type="checkbox"
checked={draft.costEstimateFromTokens}
disabled={envLocked || !draft.useBackendCost}
onChange={(event) => onDraftChange({
...draft,
costEstimateFromTokens: event.target.checked,
})}
/>
<span>
<strong> Token </strong>
<span className="muted"> </span>
</span>
</label>
<label>
<span>USD / 1M</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked || !draft.useBackendCost}
value={draft.costEstimateInputUsdPer1M}
onChange={(e) => patchNumber('costEstimateInputUsdPer1M', e.target.value)}
/>
</label>
<label>
<span>USD / 1M</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked || !draft.useBackendCost}
value={draft.costEstimateOutputUsdPer1M}
onChange={(e) => patchNumber('costEstimateOutputUsdPer1M', e.target.value)}
/>
</label>
<p className="muted">
USD × {draft.usdCnyRate} × {draft.marginMultiplier}
</p>
<button type="submit" className="send-btn" disabled={saving || envLocked}>
{saving ? '保存中…' : `保存计量公式 ${formulaKey}`}
</button>
</form>
);
}
function FormulaAssignmentSection() {
const { users, reload, error, setError } = useAdminUsers();
const [selected, setSelected] = useState<string[]>([]);
const [formulaFilter, setFormulaFilter] = useState<'all' | BillingFormulaKey>('all');
const [search, setSearch] = useState('');
const [assigning, setAssigning] = useState(false);
const [message, setMessage] = useState<string | null>(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 (
<section className="admin-card">
<h2></h2>
<p className="muted"> A B使 A</p>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<div className="billing-toolbar">
<input
className="users-search-input"
placeholder="搜索用户名 / 显示名"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<select
className="billing-filter-select"
value={formulaFilter}
onChange={(event) => setFormulaFilter(event.target.value as 'all' | BillingFormulaKey)}
>
<option value="all"></option>
<option value="A"> A</option>
<option value="B"> B</option>
</select>
<button type="button" className="ghost-btn" onClick={() => void reload()} disabled={assigning}>
</button>
<button type="button" className="send-btn" disabled={!selected.length || assigning} onClick={() => void handleAssign('A')}>
A
</button>
<button type="button" className="send-btn" disabled={!selected.length || assigning} onClick={() => void handleAssign('B')}>
B
</button>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>
<input
type="checkbox"
checked={filteredUsers.length > 0 && filteredUsers.every((user) => selected.includes(user.id))}
onChange={toggleAll}
/>
</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id}>
<td>
<input
type="checkbox"
checked={selected.includes(user.id)}
onChange={() => toggleUser(user.id)}
/>
</td>
<td>
<span>{user.displayName}</span>
<span className="muted"> @{user.username}</span>
</td>
<td><strong>{user.billingFormula ?? 'A'}</strong></td>
<td className="billing-num">¥{formatYuan(user.balanceCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredUsers.length === 0 ? <p className="muted billing-empty"></p> : null}
</section>
);
}
function FormulaTab() {
const [activeFormula, setActiveFormula] = useState<BillingFormulaKey>('A');
const [drafts, setDrafts] = useState<Record<BillingFormulaKey, BillingAdminConfig>>({
A: DEFAULT_BILLING_FORMULA,
B: DEFAULT_BILLING_FORMULA,
});
const [meta, setMeta] = useState<Record<BillingFormulaKey, { source: string; updatedAt: number | null }>>({
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<string | null>(null);
const [message, setMessage] = useState<string | null>(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('当前环境已锁定为仅读 envH5_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 (
<>
<section className="admin-card">
<h2></h2>
<p className="muted">
A / B A B
</p>
{formulaText ? <p className="muted">{formulaText}</p> : null}
<div className="admin-tabs" role="tablist">
{FORMULA_TABS.map((key) => (
<button
key={key}
type="button"
role="tab"
aria-selected={activeFormula === key}
className={`admin-tab${activeFormula === key ? ' active' : ''}`}
onClick={() => setActiveFormula(key)}
>
{key}
</button>
))}
</div>
{loading ? <p className="muted"></p> : null}
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
{!loading ? (
<FormulaConfigForm
formulaKey={activeFormula}
draft={drafts[activeFormula]}
source={meta[activeFormula].source}
updatedAt={meta[activeFormula].updatedAt}
envLocked={envLocked}
saving={saving}
onDraftChange={(next) => setDrafts((prev) => ({ ...prev, [activeFormula]: next }))}
onSave={handleSave}
/>
) : null}
</section>
<FormulaAssignmentSection />
</>
);
}
const PAGE_SIZE = 20;
function RechargeTab() {
const { users, reload, error, setError } = useAdminUsers();
const [message, setMessage] = useState<string | null>(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 (
<section className="admin-card">
<h2></h2>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<form className="admin-form" onSubmit={handleRecharge}>
<UserCombobox
users={users.filter((u) => u.role === 'user')}
value={recharge.userId}
onChange={(id) => setRecharge((s) => ({ ...s, userId: id }))}
/>
<input placeholder="金额(元)" type="number" min="0.01" step="0.01" value={recharge.amountYuan}
onChange={(e) => setRecharge((s) => ({ ...s, amountYuan: e.target.value }))} />
<input placeholder="备注(可选)" value={recharge.note}
onChange={(e) => setRecharge((s) => ({ ...s, note: e.target.value }))} />
<button type="submit" className="send-btn" disabled={!recharge.userId || submitting}>
{submitting ? '处理中…' : '充值'}
</button>
</form>
</section>
);
}
function UsageRecordsTable({
result,
onPage,
}: {
result: PagedResult<UsageRecord>;
onPage: (page: number) => void;
}) {
const formatBillingSource = (row: UsageRecord) => {
if (row.billingSource === 'subscription') return '套餐额度';
return `¥${formatYuan(row.costCents)}`;
};
return (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}></th>
<th style={{ textAlign: 'right' }}></th>
</tr>
</thead>
<tbody>
{result.items.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>
<span>{row.displayName || row.username}</span>
<span className="muted"> @{row.username}</span>
</td>
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
<td className="billing-num">{formatBillingSource(row)}</td>
<td className="billing-num">
{row.billingSource === 'subscription' ? '—' : `¥${formatYuan(row.balanceAfterCents)}`}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={result.page}
totalPages={result.totalPages}
total={result.total}
pageSize={result.pageSize}
onChange={onPage}
/>
</>
);
}
function UsageTotalsGrid({ totals }: { totals: UsageTotals }) {
const totalTokens = totals.inputTokens + totals.outputTokens;
return (
<div className="admin-stat-grid usage-summary-grid">
<div className="admin-stat-card">
<div className="admin-stat-label"></div>
<div className="admin-stat-value">{totals.count.toLocaleString()}</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">Token </div>
<div className="admin-stat-value">{totalTokens.toLocaleString()}</div>
<div className="muted usage-summary-sub">
{totals.inputTokens.toLocaleString()} / {totals.outputTokens.toLocaleString()}
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label"></div>
<div className="admin-stat-value">¥{formatYuan(totals.costCents)}</div>
</div>
</div>
);
}
function UsageSummaryPanel({
summary,
rangeLabel,
allTimeLabel = '累计(不限区间)',
loading,
}: {
summary: UsageSummaryResult | null;
rangeLabel: string;
allTimeLabel?: string;
loading?: boolean;
}) {
if (loading && !summary) {
return <p className="muted usage-summary-loading"></p>;
}
if (!summary) return null;
return (
<div className="usage-summary-panel">
{summary.range && (
<section className="usage-summary-block">
<h4 className="usage-summary-title">{rangeLabel}</h4>
<UsageTotalsGrid totals={summary.range} />
</section>
)}
<section className="usage-summary-block">
<h4 className="usage-summary-title">{allTimeLabel}</h4>
<UsageTotalsGrid totals={summary.allTime} />
</section>
</div>
);
}
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 (
<div className="usage-chart-panel">
<div className="usage-chart-head">
<div>
<h3>{title}</h3>
{hint && <p className="muted usage-chart-hint">{hint}</p>}
</div>
<div className="usage-chart-controls">
<div className="usage-metric-checks" role="group" aria-label="图表指标">
{METRIC_OPTIONS.map(({ key, label }) => {
const checked = chartMetrics.includes(key);
const color = METRIC_CONFIG[key].color;
return (
<label key={key} className={`usage-metric-check${checked ? ' checked' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => onMetricsChange(toggleChartMetric(chartMetrics, key))}
/>
<span className="usage-metric-check-dot" style={{ background: color }} />
{label}
</label>
);
})}
</div>
<div className="usage-chart-type-toggle" role="group" aria-label="图表类型">
<button
type="button"
className={`usage-chart-type-btn${chartType === 'bar' ? ' active' : ''}`}
onClick={() => onChartTypeChange('bar')}
>
</button>
<button
type="button"
className={`usage-chart-type-btn${chartType === 'line' ? ' active' : ''}`}
onClick={() => onChartTypeChange('line')}
>
线
</button>
</div>
</div>
</div>
<UsageTrendChart
buckets={stats.buckets}
startAt={stats.startAt}
endAt={stats.endAt}
metrics={chartMetrics}
chartType={chartType}
/>
</div>
);
}
type UsageView = 'overview' | 'query';
function UsageOverviewPanel() {
const [result, setResult] = useState<PagedResult<UsageRecord> | null>(null);
const [stats, setStats] = useState<UsageStatsResult | null>(null);
const [summary, setSummary] = useState<UsageSummaryResult | null>(null);
const [loadingTable, setLoadingTable] = useState(false);
const [loadingChart, setLoadingChart] = useState(false);
const [loadingSummary, setLoadingSummary] = useState(false);
const [error, setError] = useState<string | null>(null);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [chartMetrics, setChartMetrics] = useState<UsageChartMetric[]>(DEFAULT_CHART_METRICS);
const [chartType, setChartType] = useState<UsageChartType>('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 && <p className="banner banner-error">{error}</p>}
<div className="usage-overview-filter">
<span className="muted usage-overview-chart-label"></span>
<div className="usage-date-range">
<input
type="date"
className="billing-filter-select usage-date-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<span className="muted usage-date-sep"></span>
<input
type="date"
className="billing-filter-select usage-date-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<div className="usage-preset-btns">
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(7)}>7</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(30)}>30</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(90)}>90</button>
<button
type="button"
className={`admin-btn-secondary usage-preset-btn${!hasDateFilter ? ' active' : ''}`}
onClick={clearDateFilter}
>
</button>
</div>
</div>
<UsageSummaryPanel
summary={summary}
rangeLabel={rangeSummaryLabel}
allTimeLabel="累计汇总(不限区间 · 全平台)"
loading={loadingSummary}
/>
{loadingChart && !stats && <p className="muted"></p>}
{!loadingChart && stats && (
<UsageChartPanel
stats={stats}
chartMetrics={chartMetrics}
chartType={chartType}
onMetricsChange={setChartMetrics}
onChartTypeChange={setChartType}
title="用量统计"
hint={chartHint}
/>
)}
{loadingTable && !result && <p className="muted"></p>}
{!loadingTable && result && (
result.items.length === 0 ? (
<p className="muted billing-empty"></p>
) : (
<UsageRecordsTable result={result} onPage={handlePage} />
)
)}
</>
);
}
function UsageQueryPanel() {
const { users } = useAdminUsers();
const defaultRange = presetDateRange(30);
const [result, setResult] = useState<PagedResult<UsageRecord> | null>(null);
const [stats, setStats] = useState<UsageStatsResult | null>(null);
const [summary, setSummary] = useState<UsageSummaryResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userId, setUserId] = useState('');
const [startDate, setStartDate] = useState(defaultRange.startDate);
const [endDate, setEndDate] = useState(defaultRange.endDate);
const [chartMetrics, setChartMetrics] = useState<UsageChartMetric[]>(DEFAULT_CHART_METRICS);
const [chartType, setChartType] = useState<UsageChartType>('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<UsageStatsResult | null> = Promise.resolve(null);
let summaryPromise: Promise<UsageSummaryResult | null> = 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 (
<>
<div className="billing-toolbar usage-toolbar usage-query-toolbar">
<UserCombobox
users={users}
value={userId}
onChange={setUserId}
placeholder="选择用户"
/>
<div className="usage-date-range">
<input
type="date"
className="billing-filter-select usage-date-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
disabled={!userId}
/>
<span className="muted usage-date-sep"></span>
<input
type="date"
className="billing-filter-select usage-date-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
disabled={!userId}
/>
</div>
<div className="usage-preset-btns">
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(7)}>7</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(30)}>30</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(90)}>90</button>
<button
type="button"
className={`admin-btn-secondary usage-preset-btn${userId && !hasDateFilter ? ' active' : ''}`}
disabled={!userId}
onClick={clearDateFilter}
>
</button>
</div>
</div>
{error && <p className="banner banner-error">{error}</p>}
{!userId && !loading && (
<p className="muted billing-empty"></p>
)}
{userId && loading && !result && !stats && <p className="muted"></p>}
{userId && selectedUser && (
<div className="usage-user-balance-bar">
<span className="usage-user-balance-name">
{selectedUser.displayName}
<span className="muted"> @{selectedUser.username}</span>
</span>
<span className="usage-user-balance-label muted"></span>
<span className="usage-user-balance-value">¥{formatYuan(selectedUser.balanceCents)}</span>
</div>
)}
{userId && (
<UsageSummaryPanel
summary={summary}
rangeLabel={rangeSummaryLabel}
allTimeLabel={allTimeSummaryLabel}
loading={loading && !summary}
/>
)}
{userId && !loading && stats && (
<UsageChartPanel
stats={stats}
chartMetrics={chartMetrics}
chartType={chartType}
onMetricsChange={setChartMetrics}
onChartTypeChange={setChartType}
title="用户用量趋势"
hint={chartHint}
/>
)}
{userId && !loading && result && (
result.items.length === 0 ? (
<p className="muted billing-empty">{hasDateFilter ? '该时间范围内暂无记录' : '该用户暂无用量记录'}</p>
) : (
<UsageRecordsTable result={result} onPage={handlePage} />
)
)}
</>
);
}
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 (
<section className="admin-card">
<div className="admin-card-head usage-tab-head">
<h2></h2>
<div className="usage-view-switch" role="tablist" aria-label="用量记录视图">
<button
type="button"
role="tab"
aria-selected={view === 'overview'}
className={`usage-view-btn${view === 'overview' ? ' active' : ''}`}
onClick={() => setView('overview')}
>
</button>
<button
type="button"
role="tab"
aria-selected={view === 'query'}
className={`usage-view-btn${view === 'query' ? ' active' : ''}`}
onClick={() => setView('query')}
>
</button>
</div>
</div>
<div className="usage-tab-body">
{view === 'overview' ? <UsageOverviewPanel /> : <UsageQueryPanel />}
</div>
</section>
);
}
function LedgerTab() {
const { users } = useAdminUsers();
const [result, setResult] = useState<PagedResult<LedgerEntry> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string, string> = { recharge: '充值', deduct: '扣费', refund: '退款', adjust: '调整' };
return (
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<div className="billing-toolbar">
<UserCombobox
users={users}
value={pendingUserId}
onChange={setPendingUserId}
placeholder="全部用户"
/>
<button type="button" className="send-btn billing-query-btn" onClick={handleQuery} disabled={loading}>
</button>
</div>
</div>
{error && <p className="banner banner-error">{error}</p>}
{!result && !loading && (
<p className="muted billing-empty"></p>
)}
{loading && <p className="muted"></p>}
{!loading && result && (
result.items.length === 0 ? (
<p className="muted billing-empty"></p>
) : (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}></th>
<th></th>
</tr>
</thead>
<tbody>
{result.items.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>
<span>{row.displayName || row.username}</span>
<span className="muted"> @{row.username}</span>
</td>
<td><span className={`ledger-type-tag ledger-type-${row.type}`}>{TYPE_LABEL[row.type] ?? row.type}</span></td>
<td className={`billing-num ${row.amountCents < 0 ? 'text-error' : 'text-income'}`}>
{row.amountCents >= 0 ? '+' : ''}¥{formatYuan(Math.abs(row.amountCents))}
</td>
<td className="billing-note">{row.note ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={result.page} totalPages={result.totalPages} total={result.total}
pageSize={result.pageSize} onChange={handlePage} />
</>
)
)}
</section>
);
}
const PLAN_TIER_LABEL: Record<string, string> = {
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<void>;
onClose: () => void;
}) {
const [form, setForm] = useState<PlanForm>(initial);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(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 (
<div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal-box" style={{ maxWidth: 520 }}>
<div className="modal-head">
<h3>{isNew ? '新建套餐' : `编辑套餐 · ${initial.name}`}</h3>
<button type="button" className="modal-close" onClick={onClose}>×</button>
</div>
{err && <p className="banner banner-error">{err}</p>}
<form className="admin-form plan-form" onSubmit={handleSubmit}>
{isNew && (
<label className="plan-form-row">
<span> <span className="muted">(plan_type)</span></span>
<input
placeholder="如 basic / pro_plus"
value={form.planType}
onChange={(e) => set('planType', e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
required
/>
</label>
)}
<label className="plan-form-row">
<span></span>
<input placeholder="如 专业版" value={form.name} onChange={(e) => set('name', e.target.value)} required />
</label>
<label className="plan-form-row">
<span></span>
<input type="number" min="0" step="1" value={form.priceCents}
onChange={(e) => set('priceCents', e.target.value)} />
<span className="plan-form-hint">¥{(Number(form.priceCents) / 100).toFixed(2)} / </span>
</label>
<label className="plan-form-row">
<span></span>
<input type="number" min="1" step="1" value={form.periodDays}
onChange={(e) => set('periodDays', e.target.value)} />
</label>
<label className="plan-form-row">
<span>Token </span>
<input type="number" min="0" step="10000" value={form.periodTokens}
onChange={(e) => set('periodTokens', e.target.value)} />
<span className="plan-form-hint">
{Number(form.periodTokens) === 0 ? '不限' : `${(Number(form.periodTokens) / 10000).toFixed(0)} 万 Token`}
</span>
</label>
<label className="plan-form-row">
<span></span>
<input type="number" min="0" step="1" value={form.periodImages}
onChange={(e) => set('periodImages', e.target.value)} />
<span className="plan-form-hint">
{Number(form.periodImages) === 0 ? '不限' : `${form.periodImages} 张/周期`}
</span>
</label>
<label className="plan-form-row">
<span></span>
<select className="admin-select" value={form.modelTier}
onChange={(e) => set('modelTier', e.target.value)}>
<option value="basic">basic</option>
<option value="standard">standard</option>
<option value="premium">premium</option>
</select>
</label>
<label className="plan-form-row">
<span></span>
<input type="number" min="0" max="2" step="0.01" value={form.overageRate}
onChange={(e) => set('overageRate', e.target.value)} />
<span className="plan-form-hint">1.0 = </span>
</label>
<label className="plan-form-row">
<span></span>
<input type="number" step="1" value={form.sortOrder}
onChange={(e) => set('sortOrder', e.target.value)} />
</label>
<label className="plan-form-row">
<span></span>
<input placeholder="套餐说明文字" value={form.description}
onChange={(e) => set('description', e.target.value)} />
</label>
<label className="plan-form-row plan-form-check">
<input type="checkbox" checked={form.isActive}
onChange={(e) => set('isActive', e.target.checked)} />
<span></span>
</label>
<div className="plan-form-actions">
<button type="button" className="admin-btn-secondary" onClick={onClose}></button>
<button type="submit" className="send-btn" disabled={saving}>
{saving ? '保存中…' : '保存套餐'}
</button>
</div>
</form>
</div>
</div>
);
}
function PlanCatalogSection({
plans,
onPlansChange,
}: {
plans: PlanDefinition[];
onPlansChange: () => void;
}) {
const [editing, setEditing] = useState<PlanForm | null>(null);
const [err, setErr] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(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 (
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" className="admin-btn-secondary" onClick={handleSyncProduction} disabled={syncingProd}>
{syncingProd ? '同步中…' : '同步生产后台'}
</button>
<button type="button" className="send-btn" onClick={openNew}>+ </button>
</div>
</div>
{err && <p className="banner banner-error">{err}</p>}
{msg && <p className="banner banner-info">{msg}</p>}
{plans.length === 0 ? (
<p className="muted"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th style={{ textAlign: 'right' }}></th>
<th style={{ textAlign: 'right' }}>Token </th>
<th style={{ textAlign: 'right' }}></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{plans.map((p) => (
<tr key={p.planType} style={{ opacity: p.isActive ? 1 : 0.5 }}>
<td>
<strong>{p.name}</strong>
<span className="muted"> ({p.planType})</span>
{p.description && <div className="plan-desc muted">{p.description}</div>}
</td>
<td className="billing-num">
{p.priceCents === 0 ? '免费' : `¥${formatYuan(p.priceCents)}/月`}
</td>
<td className="billing-num">
{p.periodTokens === 0 ? '不限' : `${(p.periodTokens / 10000).toFixed(0)}`}
</td>
<td className="billing-num">
{p.periodImages === 0 ? '不限' : `${p.periodImages}`}
</td>
<td>{PLAN_TIER_LABEL[p.modelTier] ?? p.modelTier}</td>
<td>{p.periodDays} </td>
<td>{p.overageRate === 1 ? '—' : `×${p.overageRate}`}</td>
<td>
<span className={`ledger-type-tag ledger-type-${p.isActive ? 'active' : 'cancelled'}`}>
{p.isActive ? '上架' : '下架'}
</span>
</td>
<td className="plan-actions">
<button type="button" className="admin-btn-secondary" onClick={() => openEdit(p)}></button>
{p.planType !== 'free' && (
<button type="button" className="admin-btn-danger"
disabled={deleting === p.planType}
onClick={() => void handleDelete(p)}>
{deleting === p.planType ? '删除中…' : '删除'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editing !== null && (
<PlanFormModal initial={editing} onSave={handleSave} onClose={closeModal} />
)}
</section>
);
}
function SubscriptionsTab() {
const { users, reload: reloadUsers } = useAdminUsers();
const [plans, setPlans] = useState<PlanDefinition[]>([]);
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<string | null>(null);
const [message, setMessage] = useState<string | null>(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<string | null>(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<string, string> = { active: '有效', expired: '已到期', cancelled: '已取消' };
const autoRenewLabel = (row: AdminSubscription) => {
if (!row.autoRenew) return '未开启';
if (row.status === 'active') return '已开启';
if (row.status === 'expired') return '待补扣';
return '—';
};
return (
<>
<PlanCatalogSection plans={plans} onPlansChange={loadPlans} />
{/* Grant subscription */}
<section className="admin-card">
<h2></h2>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<form className="admin-form" onSubmit={handleGrant}>
<UserCombobox
users={users.filter((u) => u.role === 'user')}
value={grant.userId}
onChange={(id) => setGrant((s) => ({ ...s, userId: id }))}
/>
<select
className="admin-select"
value={grant.planType}
onChange={(e) => setGrant((s) => ({ ...s, planType: e.target.value }))}
required
>
<option value=""></option>
{plans.map((p) => (
<option key={p.planType} value={p.planType}>
{p.name} {p.priceCents === 0 ? '免费' : `¥${formatYuan(p.priceCents)}/月`}
{!p.isActive ? ' [下架]' : ''}
</option>
))}
</select>
<input
placeholder="有效天数(留空用套餐默认)"
type="number"
min="1"
value={grant.durationDays}
onChange={(e) => setGrant((s) => ({ ...s, durationDays: e.target.value }))}
/>
<input
placeholder="备注(可选)"
value={grant.note}
onChange={(e) => setGrant((s) => ({ ...s, note: e.target.value }))}
/>
<button type="submit" className="send-btn" disabled={!grant.userId || !grant.planType || submitting}>
{submitting ? '处理中…' : '授予'}
</button>
</form>
</section>
{/* Subscription list */}
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<div className="billing-toolbar">
<UserCombobox
users={users}
value={pendingUserId}
onChange={setPendingUserId}
placeholder="全部用户"
/>
<select
className="admin-select billing-status-select"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
>
<option value=""></option>
<option value="active"></option>
<option value="expired"></option>
<option value="cancelled"></option>
</select>
<button
type="button"
className="send-btn billing-query-btn"
onClick={handleQuery}
disabled={loading}
>
</button>
</div>
</div>
{loading && <p className="muted"></p>}
{!loading && result && (
result.items.length === 0 ? (
<p className="muted billing-empty"></p>
) : (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}>Token </th>
<th style={{ textAlign: 'right' }}></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{result.items.map((row) => (
<tr key={row.id}>
<td>
<span>{row.displayName}</span>
<span className="muted"> @{row.username}</span>
</td>
<td><strong>{row.planType}</strong></td>
<td>
<span className={`ledger-type-tag ledger-type-${row.status}`}>
{STATUS_LABEL[row.status] ?? row.status}
</span>
</td>
<td>
<span className={row.autoRenew && row.status === 'expired' ? 'text-error' : undefined}>
{autoRenewLabel(row)}
</span>
</td>
<td className="billing-num">
{row.periodTokensUsed.toLocaleString()}
{row.periodTokensLimit > 0 && (
<span className="muted"> / {row.periodTokensLimit.toLocaleString()}</span>
)}
</td>
<td className="billing-num">
{row.periodImagesUsed}
{row.periodImagesLimit > 0 && (
<span className="muted"> / {row.periodImagesLimit}</span>
)}
</td>
<td className="billing-time">{formatTime(row.expiresAt)}</td>
<td className="billing-note">{row.note ?? '—'}</td>
<td>
{row.status === 'active' && (
<button
type="button"
className="admin-btn-danger"
disabled={cancelling === row.userId}
onClick={() => void handleCancel(row.userId, row.displayName)}
>
{cancelling === row.userId ? '取消中…' : '取消套餐'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={result.page}
totalPages={result.totalPages}
total={result.total}
pageSize={result.pageSize}
onChange={handlePage}
/>
</>
)
)}
</section>
</>
);
}
export function BillingPage() {
const location = useLocation();
const navigate = useNavigate();
const activeTab = tabFromPath(location.pathname);
return (
<div className="admin-page">
<div className="admin-page-head">
<h2></h2>
<p className="muted"></p>
</div>
<div className="admin-tabs" role="tablist">
{TABS.map((tab) => (
<button key={tab.key} type="button" role="tab" aria-selected={activeTab === tab.key}
className={`admin-tab${activeTab === tab.key ? ' active' : ''}`}
onClick={() => navigate(TAB_PATHS[tab.key])}>
{tab.label}
</button>
))}
</div>
<div className="billing-tab-content">
{activeTab === 'recharge' && <RechargeTab />}
{activeTab === 'usage' && <UsageTab />}
{activeTab === 'ledger' && <LedgerTab />}
{activeTab === 'subscriptions' && <SubscriptionsTab />}
{activeTab === 'formula' && <FormulaTab />}
</div>
</div>
);
}