feat(admin): Phase 7 计费审计+4层额度策略 — 流水浏览/手动调额/额度策略CRUD+拦截
后端:adminpanel ledgers(全局流水+筛)+ adjust(手动调额,落 ADJUSTMENT)+ quota-policies CRUD; 额度拦截 _enforce_quota_policy(单任务/月度/项目)接入 reserve_credit,仅团队有 active 策略才生效(无策略零回归); adjust_credit helper。修真 bug:log_admin_action 加 savepoint + JSON 安全化(UUID/Decimal), 修复传 serializer.data 致审计报错污染外层事务 → TransactionManagementError。 前端:adminApi 计费/额度系列;计费审计页(流水表+手动调额弹窗)+ 额度策略页(CRUD 弹窗,团队下拉+三类上限+启用)。 测试:adminpanel 44 + billing + accounts = 70 单测过(调额±/超额拒/额度拦截 per_task+monthly/无策略零回归); 无头 e2e _admin-p7.mjs 6 断言过 + 0 console error(一次性团队,不动 demo 余额);tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ca1e50d32c
commit
39b4467258
@@ -3,6 +3,7 @@ import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import { CornerMarks, Decorations, ToastLike } from "../../components/app-shell";
|
||||
import type { Team, User } from "../../types";
|
||||
import type { NavigateFn } from "../route-config";
|
||||
import { AdminLedgersPage, AdminQuotaPage } from "./admin-billing";
|
||||
import { AdminInvitesPage } from "./admin-invites";
|
||||
import { AdminQualityPage } from "./admin-quality";
|
||||
import { AdminReviewsPage } from "./admin-reviews";
|
||||
@@ -186,6 +187,12 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe
|
||||
if (section.slug === "tasks") {
|
||||
return <AdminTasksPage notify={notify} />;
|
||||
}
|
||||
if (section.slug === "billing") {
|
||||
return <AdminLedgersPage notify={notify} />;
|
||||
}
|
||||
if (section.slug === "quota") {
|
||||
return <AdminQuotaPage notify={notify} />;
|
||||
}
|
||||
// 其余模块在各自阶段替换此占位为真实页面
|
||||
return <AdminPlaceholder section={section} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Gauge, Wallet, X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
|
||||
|
||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ─────────────────────────── 计费审计 ───────────────────────────
|
||||
|
||||
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 [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_size: 80 });
|
||||
setLedgers(res.results);
|
||||
setCount(res.count);
|
||||
} catch {
|
||||
notify("error", "加载流水失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab]);
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3>加载中…</h3><p>// fetching ledgers</p></div>
|
||||
) : ledgers.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3>暂无流水</h3><p>// no ledgers</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>备注</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">¥{l.amount}</td>
|
||||
<td className="num mono">¥{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>
|
||||
</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">手动调额<span>// credit adjust</span></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 load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminApi.quotaPolicies({ page_size: 100 });
|
||||
setPolicies(res.results);
|
||||
setCount(res.count);
|
||||
} catch {
|
||||
notify("error", "加载额度策略失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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> : `¥${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 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3>加载中…</h3><p>// fetching policies</p></div>
|
||||
) : 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>
|
||||
</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 ? "编辑额度策略" : "新建额度策略"}<span>// quota policy</span></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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user