f6bb02254e
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>
1953 lines
68 KiB
TypeScript
1953 lines
68 KiB
TypeScript
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>汇率(USD→CNY)</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('当前环境已锁定为仅读 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 (
|
||
<>
|
||
<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>
|
||
);
|
||
}
|