409 lines
19 KiB
TypeScript
409 lines
19 KiB
TypeScript
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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|