feat(admin): upgrade admin console UI widgets, add plan & status management, and redesign system status dashboard

This commit is contained in:
Blizzard
2026-07-18 22:18:12 +08:00
parent 197531ea33
commit 0d2929931f
16 changed files with 1979 additions and 503 deletions
+172 -12
View File
@@ -1,5 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { listTenants, createTenant, listMembers, addMember, setMemberRole, removeMember, setSharedBilling, type TenantRow, type Member } from "../api";
import {
listTenants,
createTenant,
listMembers,
addMember,
setMemberRole,
removeMember,
setSharedBilling,
setTenantPlan,
setTenantStatus,
type TenantRow,
type Member
} from "../api";
import { GrantCreditsModal } from "../components/GrantCreditsModal";
// 管理端「租户 & 用户」= 多成员租户管理(平台运维口径):租户目录 + 每租户成员增改删。
// 全真数据:GET/POST /admin/tenants、/admin/tenants/:id/members。
@@ -8,6 +21,9 @@ const ROLES = ["owner", "admin", "member", "viewer", "billing_admin"];
const ROLE_CN: Record<string, string> = { owner: "所有者", admin: "管理员", member: "成员", viewer: "只读", billing_admin: "计费管理" };
const credits = (m: number) => (m / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const PLANS = ["free", "pro", "enterprise"];
const PLAN_CN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
export function TenantsPage() {
const [tenants, setTenants] = useState<TenantRow[]>([]);
const [selId, setSelId] = useState<string>("");
@@ -19,6 +35,7 @@ export function TenantsPage() {
const [tName, setTName] = useState("");
const [tSlug, setTSlug] = useState("");
const [tOwner, setTOwner] = useState("");
const [tPlan, setTPlan] = useState("free");
const [creating, setCreating] = useState(false);
// 加成员表单
@@ -27,6 +44,11 @@ export function TenantsPage() {
const [adding, setAdding] = useState(false);
const [mErr, setMErr] = useState("");
// Modals state
const [editPlanTenant, setEditPlanTenant] = useState<TenantRow | null>(null);
const [grantTenant, setGrantTenant] = useState<TenantRow | null>(null);
const [statusChanging, setStatusChanging] = useState(false);
const selected = useMemo(() => tenants.find((t) => t.id === selId) ?? null, [tenants, selId]);
const loadTenants = async (keepSel = true) => {
@@ -67,10 +89,11 @@ export function TenantsPage() {
if (!tName.trim() || !tSlug.trim()) return;
setCreating(true);
try {
await createTenant(tName.trim(), tSlug.trim(), tOwner.trim() || undefined);
await createTenant(tName.trim(), tSlug.trim(), tOwner.trim() || undefined, tPlan);
setTName("");
setTSlug("");
setTOwner("");
setTPlan("free");
await loadTenants();
} catch (e) {
setErr((e as Error).message);
@@ -119,11 +142,89 @@ export function TenantsPage() {
}
};
const onPlanChange = async (plan: string) => {
if (!editPlanTenant) return;
try {
await setTenantPlan(editPlanTenant.id, plan);
setEditPlanTenant(null);
await loadTenants();
} catch (e) {
setErr(`修改 Plan 失败: ${(e as Error).message}`);
}
};
const onStatusToggle = async () => {
if (!selected) return;
const nextStatus = selected.status === "suspended" ? "active" : "suspended";
const actionText = nextStatus === "suspended" ? "暂停" : "恢复";
if (!window.confirm(`确定要 ${actionText} 租户「${selected.name}」吗?`)) return;
setStatusChanging(true);
try {
await setTenantStatus(selected.id, nextStatus);
await loadTenants();
} catch (e) {
setMErr(`${actionText}租户失败: ${(e as Error).message}`);
} finally {
setStatusChanging(false);
}
};
if (loading) return <div className="text-sm text-gray-400"></div>;
return (
<div className="space-y-4">
{err && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{err}</div>}
{/* 充值 Modal */}
<GrantCreditsModal
tenant={grantTenant}
onClose={() => setGrantTenant(null)}
onDone={() => void loadTenants()}
/>
{/* 修改方案 Modal */}
{editPlanTenant && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && setEditPlanTenant(null)}
>
<div className="relative mx-4 w-full max-w-sm overflow-hidden rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
<div>
<h3 className="text-base font-semibold text-gray-900"></h3>
<p className="mt-0.5 text-xs text-gray-400"> {editPlanTenant.name} </p>
</div>
<button onClick={() => setEditPlanTenant(null)} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
</div>
<div className="space-y-4 px-6 py-5">
<div className="space-y-2">
{PLANS.map((p) => (
<button
key={p}
onClick={() => void onPlanChange(p)}
className={`w-full text-left p-3 rounded-lg border flex items-center justify-between transition-all ${
editPlanTenant.plan === p
? "border-violet-500 bg-violet-50/50 font-semibold text-violet-700"
: "border-gray-100 hover:bg-gray-50 text-gray-700"
}`}
>
<span>{PLAN_CN[p]}</span>
<span className="text-[11px] uppercase text-gray-400">{p}</span>
</button>
))}
</div>
</div>
<div className="bg-gray-50 px-6 py-4 flex justify-end">
<button onClick={() => setEditPlanTenant(null)} className="rounded-lg border border-gray-200 bg-white px-4 py-2 text-xs text-gray-500 hover:bg-gray-50">
</button>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 左:租户目录 + 建租户 */}
<div className="space-y-6">
@@ -140,8 +241,22 @@ export function TenantsPage() {
className={`w-full rounded-lg border p-3 text-left transition-all ${selId === t.id ? "border-violet-500 bg-violet-50/40 shadow-sm" : "border-gray-100 hover:bg-gray-50/60"}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-800">{t.name}</span>
<span className={`rounded px-1.5 py-0.5 text-[9px] font-medium ${t.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}>{t.plan}</span>
<span className="text-xs font-bold text-gray-800 flex items-center gap-1.5">
{t.name}
{t.status === "suspended" && (
<span className="rounded bg-rose-100 text-rose-700 px-1 py-0.2 text-[8px] font-bold"></span>
)}
</span>
<span
onClick={(e) => {
e.stopPropagation();
setEditPlanTenant(t);
}}
title="点击修改 Plan"
className={`rounded px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:opacity-85 ${t.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}
>
{t.plan}
</span>
</div>
<div className="mt-2 flex items-center justify-between text-[10px] text-gray-400">
<span>{t.members} · <span className="font-mono">{t.slug}</span></span>
@@ -158,6 +273,20 @@ export function TenantsPage() {
<input className="w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="租户名称(如:华泰投行部)" value={tName} onChange={(e) => setTName(e.target.value)} />
<input className="w-full rounded border px-2 py-1.5 text-xs font-mono focus:border-violet-500 focus:outline-none" placeholder="唯一标识 slug(如 ht-ib" value={tSlug} onChange={(e) => setTSlug(e.target.value)} />
<input className="w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="owner 邮箱(可选,须已注册)" value={tOwner} onChange={(e) => setTOwner(e.target.value)} />
<label className="block text-xs text-gray-500 space-y-1">
<span></span>
<select
value={tPlan}
onChange={(e) => setTPlan(e.target.value)}
className="w-full rounded border bg-white px-2 py-1.5 text-xs text-gray-700 focus:border-violet-500 focus:outline-none"
>
{PLANS.map((p) => (
<option key={p} value={p}>{PLAN_CN[p]} ({p})</option>
))}
</select>
</label>
<button onClick={() => void onCreate()} disabled={creating || !tName.trim() || !tSlug.trim()} className="w-full rounded bg-violet-600 py-1.5 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-40">
{creating ? "创建中…" : "创建租户"}
</button>
@@ -174,18 +303,49 @@ export function TenantsPage() {
<>
<div className="mb-4 flex items-center justify-between border-b pb-3">
<div>
<h4 className="text-sm font-semibold text-gray-700">{selected.name} · </h4>
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-700">{selected.name} · </h4>
<span
onClick={() => setEditPlanTenant(selected)}
className={`rounded px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:opacity-85 ${selected.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}
>
{selected.plan}
</span>
</div>
<p className="text-[11px] text-gray-400"> · slug {selected.slug}</p>
</div>
<span className="tabular-nums text-xs text-gray-500">{credits(selected.credit_balance_micro)} </span>
<div className="flex items-center gap-2 text-right">
<span className="tabular-nums text-xs text-gray-500">{credits(selected.credit_balance_micro)} </span>
<button
onClick={() => setGrantTenant(selected)}
className="rounded border border-violet-200 px-2 py-0.5 text-[10px] text-violet-600 hover:bg-violet-50"
>
/
</button>
</div>
</div>
{/* 共享计费开关 */}
<label className="mb-3 flex cursor-pointer items-center gap-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
<input type="checkbox" checked={selected.shared_billing} onChange={(e) => void onToggleShared(e.target.checked)} className="h-4 w-4 accent-violet-600" />
<span className="font-medium text-gray-700"></span>
<span className="text-gray-400">owner </span>
</label>
{/* 状态控制 & 共享计费开关 */}
<div className="mb-3 flex flex-wrap items-center justify-between gap-3 p-3 rounded-lg border border-gray-100 bg-gray-50/50">
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-600">
<input type="checkbox" checked={selected.shared_billing} onChange={(e) => void onToggleShared(e.target.checked)} className="h-4 w-4 accent-violet-600" />
<span className="font-medium text-gray-700"></span>
<span className="text-gray-400"></span>
</label>
<button
onClick={() => void onStatusToggle()}
disabled={statusChanging}
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold shadow-sm transition-all ${
selected.status === "suspended"
? "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
: "border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100"
}`}
>
{selected.status === "suspended" ? "恢复租户账号" : "暂停租户账号"}
</button>
</div>
{/* 加成员 */}
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-violet-100 bg-violet-50/30 p-3">