Files
memind_adm/src/admin/pages/BillingPage.tsx
T
2026-06-30 20:26:33 +08:00

951 lines
35 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 {
cancelUserSubscription,
createSubscriptionPlan,
deleteSubscriptionPlan,
grantUserSubscription,
listAdminLedger,
listAdminSubscriptions,
listAdminUsage,
listSubscriptionPlans,
rechargeUser,
syncSubscriptionPlansToProduction,
updateSubscriptionPlan,
} from '../../api/client';
import type { PagedResult } from '../../api/client';
import type { AdminSubscription, AdminUserRow, LedgerEntry, PlanDefinition, UsageRecord } from '../../types';
import { Pagination } from '../../components/Pagination';
import { useAdminUsers } from '../hooks/useAdminUsers';
import { formatTime, formatYuan } from '../utils/format';
function UserCombobox({
users,
value,
onChange,
placeholder = '搜索用户名 / 显示名称',
}: {
users: AdminUserRow[];
value: string;
onChange: (id: string) => void;
placeholder?: string;
}) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const ref = useRef<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';
const TABS: { key: TabKey; label: string }[] = [
{ key: 'subscriptions', label: '订阅记录' },
{ key: 'recharge', label: '充值' },
{ key: 'usage', label: '用量记录' },
{ key: 'ledger', label: '资金流水' },
];
const PAGE_SIZE = 20;
function RechargeTab() {
const { users, reload, error, setError } = useAdminUsers();
const [message, setMessage] = useState<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 UsageTab() {
const { users } = useAdminUsers();
const [result, setResult] = useState<PagedResult<UsageRecord> | 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 listAdminUsage({ userId: userId || undefined, page: p, pageSize: PAGE_SIZE });
setResult(data);
setFilterUserId(userId);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(1, ''); }, [load]);
const handleQuery = () => void load(1, pendingUserId);
const handlePage = (p: number) => void load(p, filterUserId);
return (
<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 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>@{row.username}</td>
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={result.page} totalPages={result.totalPages} total={result.total}
pageSize={result.pageSize} onChange={handlePage} />
</>
)
)}
</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>@{row.username}</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: '已取消' };
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 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 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 [activeTab, setActiveTab] = useState<TabKey>('subscriptions');
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={() => setActiveTab(tab.key)}>
{tab.label}
</button>
))}
</div>
<div className="billing-tab-content">
{activeTab === 'recharge' && <RechargeTab />}
{activeTab === 'usage' && <UsageTab />}
{activeTab === 'ledger' && <LedgerTab />}
{activeTab === 'subscriptions' && <SubscriptionsTab />}
</div>
</div>
);
}