feat(billing): 计费商业化重构(积分制)+ 团队差异化调价
统一计价引擎 apps/billing/pricing.py:1积分=¥0.1,平台成本(¥)与用户价(积分)双记账, 视频按火山真实 usage.total_tokens 结算(true-up,终结¥1/段倒贴)+ 首发×1.5毛利系数。 Team.price_multiplier 差异化调价(jimeng同款):挂牌价×系数两步HALF_UP取整,视频类 按下单时的价格/汇率快照结算(中途改配不影响在途任务)。 BillingConfig 单例(汇率/毛利系数/预留buffer,admin可调即刻生效)。存量数据 ×10 rescale 迁移(RunPython+atomic,MySQL 迁移不可中断重跑)。开户赠送归零(DEFAULT_TRIAL_CREDITS=0, 商业决策)。audit_billing 加 I9(卖亏审计)。271 条测试 + tsc/build 全绿。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Gauge, Wallet, X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { Pager } from "../../components/pager";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
|
||||
import { pts } from "../stage-config";
|
||||
|
||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
@@ -37,6 +41,70 @@ function useTeams() {
|
||||
return teams;
|
||||
}
|
||||
|
||||
// ─────────────────────────── 平台计费配置 ───────────────────────────
|
||||
|
||||
// 积分汇率 / 视频毛利系数 / 预留 buffer(与后端计价引擎同源,改动即刻生效于下一次估价/结算)
|
||||
function BillingConfigCard({ notify }: { notify: Notify }) {
|
||||
const [form, setForm] = useState({ points_per_yuan: "", video_margin_multiplier: "", video_reserve_buffer: "" });
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await adminApi.billingConfig();
|
||||
setForm({
|
||||
points_per_yuan: cfg.points_per_yuan,
|
||||
video_margin_multiplier: cfg.video_margin_multiplier,
|
||||
video_reserve_buffer: cfg.video_reserve_buffer
|
||||
});
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
notify("error", "计费配置加载失败");
|
||||
}
|
||||
}, [notify]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.updateBillingConfig(form);
|
||||
notify("success", "计费配置已更新,即刻生效");
|
||||
} catch (e) {
|
||||
notify("error", e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!loaded) return null;
|
||||
return (
|
||||
<div className="card-hard admin-billing-config" style={{ padding: "18px 20px", marginBottom: 20 }}>
|
||||
<div className="section-h" style={{ marginBottom: 12 }}>
|
||||
<h2 style={{ fontSize: 16 }}>平台计费配置</h2>
|
||||
<span className="more">[ /billing-config ]</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "flex-end", flexWrap: "wrap" }}>
|
||||
<div className="field" style={{ width: 160 }}>
|
||||
<label className="field-label">积分汇率(积分/¥)</label>
|
||||
<input className="input" type="number" step="0.01" value={form.points_per_yuan} onChange={(e) => setForm((f) => ({ ...f, points_per_yuan: e.target.value }))} />
|
||||
<div className="field-hint">1 元兑换的积分数</div>
|
||||
</div>
|
||||
<div className="field" style={{ width: 160 }}>
|
||||
<label className="field-label">视频毛利系数</label>
|
||||
<input className="input" type="number" step="0.01" value={form.video_margin_multiplier} onChange={(e) => setForm((f) => ({ ...f, video_margin_multiplier: e.target.value }))} />
|
||||
<div className="field-hint">用户价 = 火山成本 × 系数</div>
|
||||
</div>
|
||||
<div className="field" style={{ width: 160 }}>
|
||||
<label className="field-label">视频预留 buffer</label>
|
||||
<input className="input" type="number" step="0.01" value={form.video_reserve_buffer} onChange={(e) => setForm((f) => ({ ...f, video_reserve_buffer: e.target.value }))} />
|
||||
<div className="field-hint">预留 = 预估积分 × buffer</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void save()}>{saving ? "保存中…" : "保存配置"}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────── 计费审计 ───────────────────────────
|
||||
|
||||
export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
||||
@@ -44,6 +112,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
||||
const [count, setCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form, setForm] = useState({ team: "", amount: "", reason: "" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -52,7 +121,8 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminApi.ledgers({ ledger_type: tab || undefined, page_size: 80 });
|
||||
const res = await adminApi.ledgers({ ledger_type: tab || undefined, page, page_size: PAGE_SIZE });
|
||||
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
|
||||
setLedgers(res.results);
|
||||
setCount(res.count);
|
||||
} catch {
|
||||
@@ -61,7 +131,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab]);
|
||||
}, [tab, page]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
@@ -94,11 +164,12 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
||||
<button className="btn btn-primary" type="button" onClick={() => setModalOpen(true)}>+ 手动调额</button>
|
||||
</div>
|
||||
</div>
|
||||
<BillingConfigCard notify={notify} />
|
||||
|
||||
<div className="admin-toolbar">
|
||||
<div className="tabs-sub">
|
||||
{LEDGER_TABS.map((t) => (
|
||||
<button key={t.key || "all"} type="button" className={`tab-sub${tab === t.key ? " active" : ""}`} onClick={() => setTab(t.key)}>{t.label}</button>
|
||||
<button key={t.key || "all"} type="button" className={`tab-sub${tab === t.key ? " active" : ""}`} onClick={() => { setTab(t.key); setPage(1); }}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,14 +188,15 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
||||
<td>{l.team_name || <span className="muted">—</span>}</td>
|
||||
<td>{l.username || <span className="muted">—</span>}</td>
|
||||
<td>{ledgerPill(l.ledger_type)}</td>
|
||||
<td className="num mono">¥{l.amount}</td>
|
||||
<td className="num mono">¥{l.balance_after}</td>
|
||||
<td className="num mono">{pts(l.amount)} 积分</td>
|
||||
<td className="num mono">{pts(l.balance_after)} 积分</td>
|
||||
<td>{l.reason || <span className="muted">—</span>}</td>
|
||||
<td className="mono col-time">{fmtDate(l.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -179,19 +251,22 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const teams = useTeams();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminApi.quotaPolicies({ page_size: 100 });
|
||||
const res = await adminApi.quotaPolicies({ page, page_size: PAGE_SIZE });
|
||||
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
|
||||
setPolicies(res.results);
|
||||
setCount(res.count);
|
||||
} catch {
|
||||
if (page > 1) { setPage(1); return; }
|
||||
notify("error", "加载额度策略失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
@@ -241,7 +316,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
|
||||
}
|
||||
}
|
||||
|
||||
const lim = (v: string | null) => (v == null ? <span className="muted">不限</span> : `¥${v}`);
|
||||
const lim = (v: string | null) => (v == null ? <span className="muted">不限</span> : `${pts(v)} 积分`);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -279,6 +354,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Building, KeyRound, X } from "lucide-react";
|
||||
import { Building, KeyRound, Percent, X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { Pager } from "../../components/pager";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import type { AdminTeam, AdminTeamDetail, AdminUser } from "../../types";
|
||||
import { pts } from "../stage-config";
|
||||
|
||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||
|
||||
@@ -19,6 +21,8 @@ function statusPill(active: boolean) {
|
||||
: <span className="pill err"><span className="dot" />停用</span>;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "active", label: "启用" },
|
||||
@@ -33,22 +37,36 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [detail, setDetail] = useState<AdminTeamDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
// 差异化调价:行级「定价」弹窗(系数 0.10~10.00,<1 折扣 / >1 加价,静默生效)
|
||||
const [pricingTarget, setPricingTarget] = useState<AdminTeam | null>(null);
|
||||
const [pricingValue, setPricingValue] = useState("1.00");
|
||||
const [pricingSaving, setPricingSaving] = useState(false);
|
||||
// 卖亏告警阈值 = 1/毛利系数(系数×毛利<1 即视频卖低于成本):跟随计费配置,不硬编码 0.67(review 确认)
|
||||
const [videoMargin, setVideoMargin] = useState(1.5);
|
||||
useEffect(() => {
|
||||
void adminApi.billingConfig().then((cfg) => setVideoMargin(Number(cfg.video_margin_multiplier) || 1.5)).catch(() => undefined);
|
||||
}, []);
|
||||
const lossThreshold = 1 / videoMargin;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminApi.teams({ status: statusFilter || undefined, search: search.trim() || undefined, page_size: 100 });
|
||||
const res = await adminApi.teams({ status: statusFilter || undefined, search: search.trim() || undefined, page, page_size: PAGE_SIZE });
|
||||
// 删完当前页最后一条(或筛选变化)导致本页悬空 → 回退一页
|
||||
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
|
||||
setTeams(res.results);
|
||||
setCount(res.count);
|
||||
} catch {
|
||||
if (page > 1) { setPage(1); return; } // 页码越界(后端 404)→ 回第一页
|
||||
notify("error", "加载团队失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [statusFilter, search]);
|
||||
}, [statusFilter, search, page]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
@@ -74,6 +92,23 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function savePricing() {
|
||||
if (!pricingTarget || pricingSaving) return;
|
||||
const v = Number(pricingValue);
|
||||
if (!Number.isFinite(v) || v < 0.1 || v > 10) { notify("error", "系数需在 0.10 ~ 10.00 之间"); return; }
|
||||
setPricingSaving(true);
|
||||
try {
|
||||
await adminApi.setTeamPricing(pricingTarget.id, pricingValue);
|
||||
notify("success", `「${pricingTarget.name}」价格系数已设为 ×${Number(pricingValue).toFixed(2)}`);
|
||||
setPricingTarget(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
notify("error", e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setPricingSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
@@ -86,10 +121,10 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
<div className="admin-toolbar">
|
||||
<div className="tabs-sub">
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => setStatusFilter(t.key)}>{t.label}</button>
|
||||
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => { setStatusFilter(t.key); setPage(1); }}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<input className="input admin-search" type="text" placeholder="搜索团队名…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<input className="input admin-search" type="text" placeholder="搜索团队名…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -100,7 +135,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
<div className="admin-table-wrap">
|
||||
<table className="t admin-table">
|
||||
<thead>
|
||||
<tr><th>团队名</th><th>超管</th><th>成员</th><th>余额</th><th>状态</th><th>创建时间</th><th className="col-actions">操作</th></tr>
|
||||
<tr><th>团队名</th><th>超管</th><th>成员</th><th>余额</th><th>价格系数</th><th>状态</th><th>创建时间</th><th className="col-actions">操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{teams.map((t) => (
|
||||
@@ -108,10 +143,12 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
<td>{t.name}</td>
|
||||
<td>{t.owner_username || <span className="muted">—</span>}</td>
|
||||
<td className="num">{t.member_count}</td>
|
||||
<td className="num mono">¥{t.balance}</td>
|
||||
<td className="num mono">{pts(t.balance)} 积分</td>
|
||||
<td className="num mono">{Number(t.price_multiplier) === 1 ? <span className="muted">标准价</span> : `×${Number(t.price_multiplier).toFixed(2)}`}</td>
|
||||
<td>{statusPill(t.status === "active")}</td>
|
||||
<td className="mono col-time">{fmtDate(t.created_at)}</td>
|
||||
<td className="col-actions">
|
||||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => { setPricingTarget(t); setPricingValue(Number(t.price_multiplier).toFixed(2)); }}>定价</button>
|
||||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => openDetail(t.id)}>详情</button>
|
||||
<button className={`btn btn-sm btn-ghost${t.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggle(t)}>
|
||||
{t.status === "active" ? "停用" : "启用"}
|
||||
@@ -121,6 +158,32 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pricingTarget && (
|
||||
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setPricingTarget(null); }}>
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-label="团队定价">
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><Percent size={16} /></div>
|
||||
<div className="ti">团队定价<span>// {pricingTarget.name}</span></div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setPricingTarget(null)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
<p className="admin-modal-desc">最终积分价 = 标准挂牌价 × 系数(全部计费类型统一生效,对团队静默)。<1 为折扣,>1 为加价;视频在途任务按下单快照不受影响。</p>
|
||||
<div className="field">
|
||||
<label className="field-label">价格系数 <span className="lbl-note">(0.10 ~ 10.00,1.00 = 标准价)</span></label>
|
||||
<input className="input num-input" type="number" step="0.05" min="0.1" max="10" value={pricingValue} onChange={(e) => setPricingValue(e.target.value)} />
|
||||
{Number(pricingValue) < lossThreshold && <div className="field-hint">// ⚠ 低于 ×{lossThreshold.toFixed(2)} 时视频将卖低于平台成本(毛利 ×{videoMargin} 被抵消),I9 审计会告警</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-f">
|
||||
<button className="btn" type="button" onClick={() => setPricingTarget(null)}>取消</button>
|
||||
<button className="btn btn-primary" type="button" disabled={pricingSaving} onClick={() => void savePricing()}>{pricingSaving ? "保存中…" : "保存"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -141,7 +204,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
<div className="admin-detail-meta">
|
||||
<div><span className="k">超管</span><span className="v">{detail.owner_username || "—"}</span></div>
|
||||
<div><span className="k">成员数</span><span className="v">{detail.member_count}</span></div>
|
||||
<div><span className="k">余额</span><span className="v mono">¥{detail.balance}</span></div>
|
||||
<div><span className="k">余额</span><span className="v mono">{pts(detail.balance)} 积分</span></div>
|
||||
<div><span className="k">状态</span><span className="v">{statusPill(detail.status === "active")}</span></div>
|
||||
</div>
|
||||
<div className="admin-detail-subhead">成员 · {detail.members.length}</div>
|
||||
@@ -153,7 +216,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
<td>{m.username}</td>
|
||||
<td>{m.role}</td>
|
||||
<td>{statusPill(m.user_status === "active")}</td>
|
||||
<td className="num mono">¥{m.monthly_credit_limit}</td>
|
||||
<td className="num mono">{pts(m.monthly_credit_limit)} 积分</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -179,6 +242,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pwdTarget, setPwdTarget] = useState<AdminUser | null>(null);
|
||||
const [pwd, setPwd] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -186,16 +250,18 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminApi.users({ status: statusFilter || undefined, search: search.trim() || undefined, page_size: 100 });
|
||||
const res = await adminApi.users({ status: statusFilter || undefined, search: search.trim() || undefined, page, page_size: PAGE_SIZE });
|
||||
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
|
||||
setUsers(res.results);
|
||||
setCount(res.count);
|
||||
} catch {
|
||||
if (page > 1) { setPage(1); return; }
|
||||
notify("error", "加载用户失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [statusFilter, search]);
|
||||
}, [statusFilter, search, page]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
@@ -238,10 +304,10 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
<div className="admin-toolbar">
|
||||
<div className="tabs-sub">
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => setStatusFilter(t.key)}>{t.label}</button>
|
||||
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => { setStatusFilter(t.key); setPage(1); }}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<input className="input admin-search" type="text" placeholder="搜索用户名…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<input className="input admin-search" type="text" placeholder="搜索用户名…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -284,6 +350,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user