Files
sundynix-agentix/sundynix-admin/src/pages/TenantsPage.tsx
T

422 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}