feat(admin): upgrade admin console UI widgets, add plan & status management, and redesign system status dashboard
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user