Files
yingqing/core/frontend/src/routes/admin/admin-billing.tsx
T
2026-08-19 17:44:56 +08:00

409 lines
19 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, 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 { SystemLoading } from "../../components/loading";
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);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
const LEDGER_TABS = [
{ key: "", label: "全部" },
{ key: "recharge", label: "充值" },
{ key: "charge", label: "扣费" },
{ key: "adjustment", label: "调额" },
{ key: "refund", label: "退款" }
];
const LEDGER_LABEL: Record<string, string> = {
recharge: "充值", reserve: "预扣", release: "释放", charge: "扣费", adjustment: "调额", refund: "退款"
};
function ledgerPill(t: string) {
const cls = ["recharge", "refund", "release"].includes(t) ? "ok" : t === "adjustment" ? "info" : "neutral";
return <span className={`pill ${cls}`}><span className="dot" />{LEDGER_LABEL[t] || t}</span>;
}
// 复用:加载团队下拉
function useTeams() {
const [teams, setTeams] = useState<AdminTeam[]>([]);
useEffect(() => {
adminApi.teams({ page_size: 200 }).then((r) => setTeams(r.results)).catch(() => {});
}, []);
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 }) {
const [ledgers, setLedgers] = useState<AdminLedger[]>([]);
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);
const teams = useTeams();
const load = useCallback(async () => {
setLoading(true);
try {
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 {
notify("error", "加载流水失败");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, page]);
useEffect(() => { void load(); }, [load]);
async function doAdjust() {
if (saving) return;
if (!form.team) { notify("error", "请选择团队"); return; }
if (!form.amount || Number(form.amount) === 0) { notify("error", "请输入非 0 金额"); return; }
setSaving(true);
try {
await adminApi.adjustCredit({ team: form.team, amount: form.amount, reason: form.reason });
notify("success", "已调额并入账");
setModalOpen(false);
setForm({ team: "", amount: "", reason: "" });
await load();
} catch (e) {
notify("error", e instanceof Error ? e.message : "调额失败");
} finally {
setSaving(false);
}
}
return (
<>
<div className="page-head">
<div>
<h1>计费审计</h1>
<div className="sub"><span className="mono">{count} 条流水</span> · 全局信用流水 · 手动调额</div>
</div>
<div className="actions">
<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); setPage(1); }}>{t.label}</button>
))}
</div>
</div>
{loading ? (
<SystemLoading title="正在加载计费数据" description="正在同步最新数据,请稍候。" icon="creditCard" />
) : ledgers.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3>暂无流水</h3></div>
) : (
<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>时间</th></tr></thead>
<tbody>
{ledgers.map((l) => (
<tr key={l.id}>
<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">{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>
)}
{modalOpen && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setModalOpen(false); }}>
<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"><Wallet size={16} /></div>
<div className="ti">手动调额</div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModalOpen(false)}><X size={14} /></button>
</div>
<div className="modal-b">
<p className="admin-modal-desc">为团队手动加 / 减额度(争议补偿)。金额可为负;调额后余额不得为负。会落一条调额流水。</p>
<div className="field">
<label className="field-label">团队 <span className="req">*</span></label>
<select className="select" value={form.team} onChange={(e) => setForm((f) => ({ ...f, team: e.target.value }))}>
<option value="">选择团队…</option>
{teams.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
<div className="field">
<label className="field-label">金额 <span className="field-hint">正=加,负=减</span></label>
<input className="input" type="text" placeholder="如 100 或 -50" value={form.amount} onChange={(e) => setForm((f) => ({ ...f, amount: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">备注</label>
<input className="input" type="text" placeholder="调额原因(可选)" value={form.reason} onChange={(e) => setForm((f) => ({ ...f, reason: e.target.value }))} />
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={() => setModalOpen(false)}>取消</button>
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void doAdjust()}>{saving ? "处理中…" : "确认调额"}</button>
</div>
</div>
</div>
)}
</>
);
}
// ─────────────────────────── 额度策略 ───────────────────────────
const EMPTY_POLICY = { id: "", team: "", monthly_limit: "", project_limit: "", per_task_limit: "", is_active: true };
export function AdminQuotaPage({ notify }: { notify: Notify }) {
const [policies, setPolicies] = useState<AdminQuotaPolicy[]>([]);
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<typeof EMPTY_POLICY>(EMPTY_POLICY);
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, 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]);
function openNew() { setEditing(EMPTY_POLICY); setModalOpen(true); }
function openEdit(p: AdminQuotaPolicy) {
setEditing({
id: p.id, team: p.team,
monthly_limit: p.monthly_limit ?? "", project_limit: p.project_limit ?? "", per_task_limit: p.per_task_limit ?? "",
is_active: p.is_active
});
setModalOpen(true);
}
async function save() {
if (saving) return;
if (!editing.team) { notify("error", "请选择团队"); return; }
setSaving(true);
const payload = {
monthly_limit: editing.monthly_limit === "" ? null : editing.monthly_limit,
project_limit: editing.project_limit === "" ? null : editing.project_limit,
per_task_limit: editing.per_task_limit === "" ? null : editing.per_task_limit,
is_active: editing.is_active
};
try {
if (editing.id) {
await adminApi.updateQuotaPolicy(editing.id, payload);
} else {
await adminApi.createQuotaPolicy({ team: editing.team, ...payload });
}
notify("success", "已保存额度策略");
setModalOpen(false);
await load();
} catch (e) {
notify("error", e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
}
async function del(p: AdminQuotaPolicy) {
try {
await adminApi.deleteQuotaPolicy(p.id);
notify("success", "已删除策略");
await load();
} catch {
notify("error", "删除失败");
}
}
const lim = (v: string | null) => (v == null ? <span className="muted">不限</span> : `${pts(v)} 积分`);
return (
<>
<div className="page-head">
<div>
<h1>额度策略</h1>
<div className="sub"><span className="mono">{count} 条</span> · 4 层额度(团队月度 / 项目 / 单任务)· 设了才拦,留空不限</div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" onClick={openNew}>+ 新建策略</button>
</div>
</div>
{loading ? (
<SystemLoading title="正在加载额度配置" description="正在同步最新数据,请稍候。" icon="gauge" />
) : policies.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3>暂无额度策略</h3><p>默认仅余额管控 · 点右上新建</p></div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
<thead><tr><th>团队</th><th>月度上限</th><th>项目上限</th><th>单任务上限</th><th>状态</th><th className="col-actions">操作</th></tr></thead>
<tbody>
{policies.map((p) => (
<tr key={p.id}>
<td>{p.team_name || <span className="muted">—</span>}</td>
<td className="num mono">{lim(p.monthly_limit)}</td>
<td className="num mono">{lim(p.project_limit)}</td>
<td className="num mono">{lim(p.per_task_limit)}</td>
<td>{p.is_active ? <span className="pill ok"><span className="dot" />启用</span> : <span className="pill neutral"><span className="dot" />停用</span>}</td>
<td className="col-actions">
<button className="btn btn-sm btn-ghost" type="button" onClick={() => openEdit(p)}>编辑</button>
<button className="btn btn-sm btn-ghost danger" type="button" onClick={() => del(p)}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
</div>
)}
{modalOpen && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setModalOpen(false); }}>
<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"><Gauge size={16} /></div>
<div className="ti">{editing.id ? "编辑额度策略" : "新建额度策略"}</div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModalOpen(false)}><X size={14} /></button>
</div>
<div className="modal-b">
<p className="admin-modal-desc">各上限留空 = 该维度不限。任一上限触发即拦截生成(在原余额管控之上叠加)。</p>
<div className="field">
<label className="field-label">团队 <span className="req">*</span></label>
<select className="select" value={editing.team} disabled={Boolean(editing.id)} onChange={(e) => setEditing((p) => ({ ...p, team: e.target.value }))}>
<option value="">选择团队…</option>
{teams.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
<div className="field-row">
<div className="field">
<label className="field-label">月度上限</label>
<input className="input" type="text" placeholder="留空不限" value={editing.monthly_limit} onChange={(e) => setEditing((p) => ({ ...p, monthly_limit: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">单任务上限</label>
<input className="input" type="text" placeholder="留空不限" value={editing.per_task_limit} onChange={(e) => setEditing((p) => ({ ...p, per_task_limit: e.target.value }))} />
</div>
</div>
<div className="field">
<label className="field-label">项目上限</label>
<input className="input" type="text" placeholder="留空不限" value={editing.project_limit} onChange={(e) => setEditing((p) => ({ ...p, project_limit: e.target.value }))} />
</div>
<label className="admin-switch-row">
<input type="checkbox" checked={editing.is_active} onChange={(e) => setEditing((p) => ({ ...p, is_active: e.target.checked }))} />
<span>启用此策略</span>
</label>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={() => setModalOpen(false)}>取消</button>
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void save()}>{saving ? "保存中…" : "保存"}</button>
</div>
</div>
</div>
)}
</>
);
}