feat(admin): 「租户 & 用户」页接真实成员管理(删掉 mock)
此前整页是硬编码 mock(假租户/假用户/假 API key/假日预算)。重写为真数据: - 左:租户目录(listTenants:名称/slug/plan/成员数/积分余额)+ 建租户表单 (名称/slug/可选 owner 邮箱)。 - 右:选定租户成员表(listMembers)——按邮箱加成员+选角色、行内改角色、移除; owner 受保护(不可改角色/移除)。api 加 listTenants/createTenant/listMembers/ addMember/setMemberRole/removeMember。 live 验证(preview):14 个真实租户带成员数/余额;UI 加 demoB→Dexter「2 成员」、 成员表 owner 保护 + 成员可改角色/移除、移除后回 1 成员,全过;tsc + 41 测试全过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -438,3 +438,65 @@ export async function adminUsage(params?: { tenant?: string; from?: string; to?:
|
||||
const d = (await res.json()) as UsageReport;
|
||||
return { ...d, trend: d.trend ?? [], tenants: d.tenants ?? [] }; // Go 空切片可能序列化为 null
|
||||
}
|
||||
|
||||
// —— 多成员租户:租户目录 + 成员管理 ——
|
||||
export interface TenantRow {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
plan: string;
|
||||
status: string;
|
||||
credit_balance_micro: number;
|
||||
members: number;
|
||||
}
|
||||
export interface Member {
|
||||
user_id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string; // owner / admin / member / viewer / billing_admin
|
||||
status: string;
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
export async function listTenants(): Promise<TenantRow[]> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants`, { headers: authHeaders() }));
|
||||
if (!res.ok) throw new Error(`list tenants failed: ${res.status}`);
|
||||
return ((await res.json()) as { tenants?: TenantRow[] }).tenants ?? [];
|
||||
}
|
||||
|
||||
export async function createTenant(name: string, slug: string, ownerEmail?: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "" }) }));
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string; warn?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `create tenant failed: ${res.status}`);
|
||||
if (d.warn) throw new Error(d.warn); // 租户已建但指定 owner 失败 → 当提示
|
||||
}
|
||||
|
||||
export async function listMembers(tenantId: string): Promise<Member[]> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members`, { headers: authHeaders() }));
|
||||
if (!res.ok) throw new Error(`list members failed: ${res.status}`);
|
||||
return ((await res.json()) as { members?: Member[] }).members ?? [];
|
||||
}
|
||||
|
||||
export async function addMember(tenantId: string, email: string, role: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ email, role }) }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `add member failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setMemberRole(tenantId: string, uid: string, role: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members/${uid}`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ role }) }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `set role failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeMember(tenantId: string, uid: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members/${uid}`, { method: "DELETE", headers: authHeaders() }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `remove member failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,311 +1,242 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { listTenants, createTenant, listMembers, addMember, setMemberRole, removeMember, type TenantRow, type Member } from "../api";
|
||||
|
||||
// Mock 租户数据
|
||||
const INITIAL_TENANTS = [
|
||||
{ id: "tenant_1", name: "法研智能科技 (Acme Law)", code: "acme-law", dbStatus: "provisioned", usersCount: 3, createdAt: "2026-03-12" },
|
||||
{ id: "tenant_2", name: "极客智造 (Beta Tech)", code: "beta-tech", dbStatus: "provisioned", usersCount: 8, createdAt: "2026-04-01" },
|
||||
{ id: "tenant_3", name: "信诚医疗集团 (Medi Trust)", code: "medi-trust", dbStatus: "provisioned", usersCount: 5, createdAt: "2026-05-15" },
|
||||
{ id: "tenant_4", name: "华泰证券研究部 (HT Securities)", code: "ht-sec", dbStatus: "pending", usersCount: 0, createdAt: "2026-06-25" }
|
||||
];
|
||||
// 管理端「租户 & 用户」= 多成员租户管理(平台运维口径):租户目录 + 每租户成员增改删。
|
||||
// 全真数据:GET/POST /admin/tenants、/admin/tenants/:id/members。
|
||||
|
||||
// Mock 用户数据(属于 tenant_2 的用户)
|
||||
const INITIAL_USERS = [
|
||||
{ id: "usr_101", name: "Alice (管理员)", email: "alice@beta-tech.com", dailyBudget: 150000, dailyUsed: 34200, status: "active", apiKey: "sk_sdx_live_7c3aed06b6e4dd9a" },
|
||||
{ id: "usr_102", name: "Bob", email: "bob@beta-tech.com", dailyBudget: 50000, dailyUsed: 49500, status: "active", apiKey: "sk_sdx_live_8bf926fed2662a1a" },
|
||||
{ id: "usr_103", name: "David (已挂起)", email: "david@beta-tech.com", dailyBudget: 100000, dailyUsed: 0, status: "suspended", apiKey: "sk_sdx_live_594be6ad7cc7f5fa" },
|
||||
];
|
||||
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 });
|
||||
|
||||
export function TenantsPage() {
|
||||
const [tenants, setTenants] = useState(INITIAL_TENANTS);
|
||||
const [selectedTenant, setSelectedTenant] = useState(INITIAL_TENANTS[1]); // 默认选 Beta Tech
|
||||
const [users, setUsers] = useState(INITIAL_USERS);
|
||||
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 [newTenantName, setNewTenantName] = useState("");
|
||||
const [newTenantCode, setNewTenantCode] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
// 建租户表单
|
||||
const [tName, setTName] = useState("");
|
||||
const [tSlug, setTSlug] = useState("");
|
||||
const [tOwner, setTOwner] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// 用户编辑/创建状态
|
||||
const [showAddUser, setShowAddUser] = useState(false);
|
||||
const [newUserName, setNewUserName] = useState("");
|
||||
const [newUserEmail, setNewUserEmail] = useState("");
|
||||
const [newUserBudget, setNewUserBudget] = useState(50000);
|
||||
// 加成员表单
|
||||
const [mEmail, setMEmail] = useState("");
|
||||
const [mRole, setMRole] = useState("member");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [mErr, setMErr] = useState("");
|
||||
|
||||
// 创建租户
|
||||
const handleCreateTenant = () => {
|
||||
if (!newTenantName || !newTenantCode) return;
|
||||
setIsCreating(true);
|
||||
const selected = useMemo(() => tenants.find((t) => t.id === selId) ?? null, [tenants, selId]);
|
||||
|
||||
setTimeout(() => {
|
||||
const newT = {
|
||||
id: "tenant_" + (tenants.length + 1),
|
||||
name: newTenantName,
|
||||
code: newTenantCode.toLowerCase().replace(/\s+/g, "-"),
|
||||
dbStatus: "provisioned",
|
||||
usersCount: 0,
|
||||
createdAt: new Date().toISOString().split("T")[0]
|
||||
};
|
||||
setTenants((prev) => [...prev, newT]);
|
||||
setSelectedTenant(newT);
|
||||
setUsers([]);
|
||||
setNewTenantName("");
|
||||
setNewTenantCode("");
|
||||
setIsCreating(false);
|
||||
}, 1200); // 模拟隔离数据库开辟延迟
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换用户冻结状态
|
||||
const toggleUserStatus = (id: string) => {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, status: u.status === "active" ? "suspended" : "active" } : u))
|
||||
);
|
||||
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);
|
||||
setTName("");
|
||||
setTSlug("");
|
||||
setTOwner("");
|
||||
await loadTenants();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 生成新 API Key
|
||||
const regenerateApiKey = (id: string) => {
|
||||
const chars = "abcdef0123456789";
|
||||
let randStr = "";
|
||||
for (let i = 0; i < 16; i++) randStr += chars[Math.floor(Math.random() * chars.length)];
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, apiKey: `sk_sdx_live_${randStr}` } : u))
|
||||
);
|
||||
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 handleAddUser = () => {
|
||||
if (!newUserName || !newUserEmail) return;
|
||||
const chars = "abcdef0123456789";
|
||||
let randStr = "";
|
||||
for (let i = 0; i < 16; i++) randStr += chars[Math.floor(Math.random() * chars.length)];
|
||||
|
||||
const newUser = {
|
||||
id: "usr_" + (100 + users.length + 1),
|
||||
name: newUserName,
|
||||
email: newUserEmail,
|
||||
dailyBudget: Number(newUserBudget) || 50000,
|
||||
dailyUsed: 0,
|
||||
status: "active",
|
||||
apiKey: `sk_sdx_live_${randStr}`
|
||||
};
|
||||
|
||||
setUsers((prev) => [...prev, newUser]);
|
||||
setTenants((prev) =>
|
||||
prev.map((t) => (t.id === selectedTenant.id ? { ...t, usersCount: t.usersCount + 1 } : t))
|
||||
);
|
||||
setSelectedTenant((t) => ({ ...t, usersCount: t.usersCount + 1 }));
|
||||
setNewUserName("");
|
||||
setNewUserEmail("");
|
||||
setShowAddUser(false);
|
||||
};
|
||||
if (loading) return <div className="text-sm text-gray-400">加载租户中…</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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>}
|
||||
<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">单关系实例 · 独立逻辑 Schema</span>
|
||||
<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="space-y-2 max-h-96 overflow-y-auto pr-1">
|
||||
{tenants.map((t) => {
|
||||
const isSelected = selectedTenant.id === t.id;
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
onClick={() => {
|
||||
setSelectedTenant(t);
|
||||
if (t.id === "tenant_2") setUsers(INITIAL_USERS);
|
||||
else setUsers([]); // mock 其余为空
|
||||
}}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
isSelected
|
||||
? "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={`h-2 w-2 rounded-full ${t.dbStatus === "provisioned" ? "bg-emerald-500" : "bg-amber-400"}`} />
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-[10px] text-gray-400">
|
||||
<span>代码: <span className="font-mono">{t.code}</span></span>
|
||||
<span>用户数: {t.usersCount}</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">{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>
|
||||
</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="text-sm font-semibold text-gray-700 mb-3">创建新租户空间</h3>
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-700">新建租户</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="block text-xs text-gray-500">
|
||||
租户企业名称
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none"
|
||||
placeholder="如:华泰证券投行部"
|
||||
value={newTenantName}
|
||||
onChange={(e) => setNewTenantName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-gray-500">
|
||||
租户标识域 (Unique Domain Code)
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 w-full rounded border px-2 py-1.5 text-xs font-mono focus:border-violet-500 focus:outline-none"
|
||||
placeholder="如:ht-sec"
|
||||
value={newTenantCode}
|
||||
onChange={(e) => setNewTenantCode(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={handleCreateTenant}
|
||||
disabled={isCreating || !newTenantName || !newTenantCode}
|
||||
className="w-full rounded bg-violet-600 py-1.5 text-xs text-white hover:bg-violet-700 font-semibold disabled:opacity-40"
|
||||
>
|
||||
{isCreating ? "正在开辟隔离数据空间..." : "开辟租户空间"}
|
||||
<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)} />
|
||||
<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="space-y-6 lg:col-span-2">
|
||||
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm min-h-[400px]">
|
||||
<div className="mb-4 flex items-center justify-between border-b pb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">{selectedTenant.name} · 成员列表</h4>
|
||||
<p className="text-[11px] text-gray-400">管理租户账号,配置 token 消费日限额与 API 密钥</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddUser(true)}
|
||||
className="rounded bg-violet-600 px-3 py-1.5 text-xs text-white hover:bg-violet-700"
|
||||
>
|
||||
新增成员
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 新建成员面板 */}
|
||||
{showAddUser && (
|
||||
<div className="mb-4 rounded-lg border border-violet-100 bg-violet-50/20 p-4 animate-fadeIn">
|
||||
<h5 className="text-xs font-bold text-gray-800 mb-3">新增团队成员</h5>
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<label className="text-xs text-gray-500">
|
||||
姓名
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 w-full rounded border bg-white px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none"
|
||||
placeholder="张三"
|
||||
value={newUserName}
|
||||
onChange={(e) => setNewUserName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs text-gray-500">
|
||||
邮箱
|
||||
<input
|
||||
type="email"
|
||||
className="mt-1 w-full rounded border bg-white px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none"
|
||||
placeholder="zhangsan@company.com"
|
||||
value={newUserEmail}
|
||||
onChange={(e) => setNewUserEmail(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="col-span-2 text-xs text-gray-500">
|
||||
每日 Token 消费限额 (配额日预算)
|
||||
<input
|
||||
type="number"
|
||||
className="mt-1 w-full rounded border bg-white px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none"
|
||||
value={newUserBudget}
|
||||
onChange={(e) => setNewUserBudget(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleAddUser} className="rounded bg-violet-600 px-3 py-1.5 text-xs text-white">确定添加</button>
|
||||
<button onClick={() => setShowAddUser(false)} className="rounded border bg-white px-3 py-1.5 text-xs text-gray-500">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{users.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-xs text-gray-400">
|
||||
暂无成员。点击右上角“新增成员”进行配置。
|
||||
</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="space-y-4">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="rounded-lg border border-gray-50 bg-gray-50/20 p-4 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-gray-800">{u.name}</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono">{u.email}</span>
|
||||
<span className={`rounded-full px-1.5 py-0.2 text-[8px] font-bold uppercase ${
|
||||
u.status === "active" ? "bg-emerald-100 text-emerald-800" : "bg-rose-100 text-rose-800"
|
||||
}`}>
|
||||
{u.status === "active" ? "正常" : "挂起"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Token 限额进度条 */}
|
||||
<div className="mt-3 w-64">
|
||||
<div className="flex justify-between text-[9px] text-gray-400 mb-0.5">
|
||||
<span>今日已用: {u.dailyUsed.toLocaleString()}</span>
|
||||
<span>限额: {u.dailyBudget.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
u.dailyUsed / u.dailyBudget >= 0.9 ? "bg-rose-500" :
|
||||
u.dailyUsed / u.dailyBudget >= 0.7 ? "bg-amber-400" : "bg-violet-600"
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, (u.dailyUsed / u.dailyBudget) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API 密钥分配区 */}
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-gray-400 font-mono bg-white border rounded px-2 py-0.5" title={u.apiKey}>
|
||||
{u.apiKey.slice(0, 15)}...
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(u.apiKey);
|
||||
alert("已复制 API 密钥到剪贴板!");
|
||||
}}
|
||||
className="text-[10px] text-violet-600 hover:underline"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => regenerateApiKey(u.id)}
|
||||
className="rounded border bg-white px-2 py-1 text-[10px] text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
重置密钥
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleUserStatus(u.id)}
|
||||
className={`rounded border px-2 py-1 text-[10px] ${
|
||||
u.status === "active" ? "border-rose-100 text-rose-600 hover:bg-rose-50" : "border-emerald-100 text-emerald-600 hover:bg-emerald-50"
|
||||
}`}
|
||||
>
|
||||
{u.status === "active" ? "挂起账户" : "恢复账户"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<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>
|
||||
<p className="text-[11px] text-gray-400">成员共享该租户的积分池与用量口径 · slug {selected.slug}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="tabular-nums text-xs text-gray-500">{credits(selected.credit_balance_micro)} 积分</span>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user