422 lines
19 KiB
TypeScript
422 lines
19 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
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。
|
||
|
||
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>("");
|
||
const [members, setMembers] = useState<Member[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [err, setErr] = useState("");
|
||
|
||
// 建租户表单
|
||
const [tName, setTName] = useState("");
|
||
const [tSlug, setTSlug] = useState("");
|
||
const [tOwner, setTOwner] = useState("");
|
||
const [tPlan, setTPlan] = useState("free");
|
||
const [creating, setCreating] = useState(false);
|
||
|
||
// 加成员表单
|
||
const [mEmail, setMEmail] = useState("");
|
||
const [mRole, setMRole] = useState("member");
|
||
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) => {
|
||
try {
|
||
const rows = await listTenants();
|
||
setTenants(rows);
|
||
if (!keepSel || !rows.find((t) => t.id === selId)) setSelId(rows[0]?.id ?? "");
|
||
setErr("");
|
||
} catch (e) {
|
||
setErr((e as Error).message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
const loadMembers = async (id: string) => {
|
||
if (!id) {
|
||
setMembers([]);
|
||
return;
|
||
}
|
||
try {
|
||
setMembers(await listMembers(id));
|
||
} catch (e) {
|
||
setMErr((e as Error).message);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
void loadTenants();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
useEffect(() => {
|
||
void loadMembers(selId);
|
||
setMErr("");
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [selId]);
|
||
|
||
const onCreate = async () => {
|
||
if (!tName.trim() || !tSlug.trim()) return;
|
||
setCreating(true);
|
||
try {
|
||
await createTenant(tName.trim(), tSlug.trim(), tOwner.trim() || undefined, tPlan);
|
||
setTName("");
|
||
setTSlug("");
|
||
setTOwner("");
|
||
setTPlan("free");
|
||
await loadTenants();
|
||
} catch (e) {
|
||
setErr((e as Error).message);
|
||
} finally {
|
||
setCreating(false);
|
||
}
|
||
};
|
||
|
||
const onAdd = async () => {
|
||
if (!mEmail.trim() || !selId) return;
|
||
setAdding(true);
|
||
setMErr("");
|
||
try {
|
||
await addMember(selId, mEmail.trim(), mRole);
|
||
setMEmail("");
|
||
await Promise.all([loadMembers(selId), loadTenants()]);
|
||
} catch (e) {
|
||
setMErr((e as Error).message);
|
||
} finally {
|
||
setAdding(false);
|
||
}
|
||
};
|
||
const onRole = async (uid: string, role: string) => {
|
||
try {
|
||
await setMemberRole(selId, uid, role);
|
||
await loadMembers(selId);
|
||
} catch (e) {
|
||
setMErr((e as Error).message);
|
||
}
|
||
};
|
||
const onRemove = async (uid: string, email: string) => {
|
||
if (!window.confirm(`确定从「${selected?.name}」移除 ${email}?`)) return;
|
||
try {
|
||
await removeMember(selId, uid);
|
||
await Promise.all([loadMembers(selId), loadTenants()]);
|
||
} catch (e) {
|
||
setMErr((e as Error).message);
|
||
}
|
||
};
|
||
const onToggleShared = async (on: boolean) => {
|
||
try {
|
||
await setSharedBilling(selId, on);
|
||
await loadTenants();
|
||
} catch (e) {
|
||
setMErr((e as Error).message);
|
||
}
|
||
};
|
||
|
||
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">
|
||
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold text-gray-700">租户</h3>
|
||
<span className="text-[10px] text-gray-400">{tenants.length} 个 · tenant_id 全链路隔离</span>
|
||
</div>
|
||
<div className="max-h-[420px] space-y-2 overflow-y-auto pr-1">
|
||
{tenants.map((t) => (
|
||
<button
|
||
key={t.id}
|
||
onClick={() => setSelId(t.id)}
|
||
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 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>
|
||
<span className="tabular-nums text-gray-500">{credits(t.credit_balance_micro)} 积分</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||
<h3 className="mb-3 text-sm font-semibold text-gray-700">新建租户</h3>
|
||
<div className="space-y-3">
|
||
<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>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
{/* 右:选定租户的成员 */}
|
||
<div className="lg:col-span-2">
|
||
<section className="min-h-[400px] rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||
{!selected ? (
|
||
<div className="flex h-64 items-center justify-center text-xs text-gray-400">左侧选择一个租户查看成员</div>
|
||
) : (
|
||
<>
|
||
<div className="mb-4 flex items-center justify-between border-b pb-3">
|
||
<div>
|
||
<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>
|
||
|
||
<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>
|
||
|
||
{/* 状态控制 & 共享计费开关 */}
|
||
<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">
|
||
<input className="flex-1 rounded border bg-white px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="按邮箱加入已注册用户" value={mEmail} onChange={(e) => setMEmail(e.target.value)} />
|
||
<select className="rounded border bg-white px-2 py-1.5 text-xs text-gray-700 focus:border-violet-500 focus:outline-none" value={mRole} onChange={(e) => setMRole(e.target.value)}>
|
||
{ROLES.filter((r) => r !== "owner").map((r) => (
|
||
<option key={r} value={r}>{ROLE_CN[r]}</option>
|
||
))}
|
||
</select>
|
||
<button onClick={() => void onAdd()} disabled={adding || !mEmail.trim()} className="rounded bg-violet-600 px-3 py-1.5 text-xs text-white hover:bg-violet-700 disabled:opacity-40">
|
||
{adding ? "添加中…" : "加入"}
|
||
</button>
|
||
</div>
|
||
{mErr && <div className="mb-3 text-[11px] text-rose-600">{mErr}</div>}
|
||
|
||
{members.length === 0 ? (
|
||
<div className="flex h-40 items-center justify-center text-xs text-gray-400">暂无成员</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||
<th className="py-2 pr-3 font-medium">成员</th>
|
||
<th className="pr-3 font-medium">角色</th>
|
||
<th className="pr-3 font-medium">状态</th>
|
||
<th className="text-right font-medium">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{members.map((m) => {
|
||
const isOwner = m.role === "owner";
|
||
return (
|
||
<tr key={m.user_id} className="border-b border-gray-50 last:border-0">
|
||
<td className="py-2 pr-3">
|
||
<div className="font-medium text-gray-800">{m.name || m.email}</div>
|
||
<div className="font-mono text-[10px] text-gray-400">{m.email}</div>
|
||
</td>
|
||
<td className="pr-3">
|
||
{isOwner ? (
|
||
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">所有者</span>
|
||
) : (
|
||
<select value={m.role} onChange={(e) => void onRole(m.user_id, e.target.value)} className="rounded border bg-white px-1.5 py-0.5 text-xs text-gray-700 focus:border-violet-500 focus:outline-none">
|
||
{ROLES.filter((r) => r !== "owner").map((r) => (
|
||
<option key={r} value={r}>{ROLE_CN[r]}</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</td>
|
||
<td className="pr-3">
|
||
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-bold uppercase text-emerald-700">{m.status}</span>
|
||
</td>
|
||
<td className="text-right">
|
||
{isOwner ? (
|
||
<span className="text-[10px] text-gray-300">—</span>
|
||
) : (
|
||
<button onClick={() => void onRemove(m.user_id, m.email)} className="rounded border border-rose-100 px-2 py-1 text-[10px] text-rose-600 hover:bg-rose-50">移除</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|