951 lines
35 KiB
TypeScript
951 lines
35 KiB
TypeScript
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>
|
||
);
|
||
}
|