diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 7a046d7..4965919 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`); + } +} diff --git a/sundynix-admin/src/pages/TenantsPage.tsx b/sundynix-admin/src/pages/TenantsPage.tsx index 69a91a2..5e2bca4 100644 --- a/sundynix-admin/src/pages/TenantsPage.tsx +++ b/sundynix-admin/src/pages/TenantsPage.tsx @@ -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 = { 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([]); + const [selId, setSelId] = useState(""); + const [members, setMembers] = useState([]); + 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
加载租户中…
; return ( -
+
+ {err &&
{err}
}
- {/* 左侧租户目录 */} + {/* 左:租户目录 + 建租户 */}
-

租户与工作区

- 单关系实例 · 独立逻辑 Schema +

租户

+ {tenants.length} 个 · tenant_id 全链路隔离
- -
- {tenants.map((t) => { - const isSelected = selectedTenant.id === t.id; - return ( -
{ - 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" - }`} - > -
- {t.name} - -
-
- 代码: {t.code} - 用户数: {t.usersCount} -
+
+ {tenants.map((t) => ( + + ))}
- {/* 新增租户表单 */}
-

创建新租户空间

+

新建租户

- - -
- {/* 右侧选定租户的用户管理详情 */} -
-
-
-
-

{selectedTenant.name} · 成员列表

-

管理租户账号,配置 token 消费日限额与 API 密钥

-
- -
- - {/* 新建成员面板 */} - {showAddUser && ( -
-
新增团队成员
-
- - - -
-
- - -
-
- )} - - {users.length === 0 ? ( -
- 暂无成员。点击右上角“新增成员”进行配置。 -
+ {/* 右:选定租户的成员 */} +
+
+ {!selected ? ( +
左侧选择一个租户查看成员
) : ( -
- {users.map((u) => ( -
-
-
- {u.name} - {u.email} - - {u.status === "active" ? "正常" : "挂起"} - -
- - {/* Token 限额进度条 */} -
-
- 今日已用: {u.dailyUsed.toLocaleString()} - 限额: {u.dailyBudget.toLocaleString()} -
-
-
= 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)}%` }} - /> -
-
-
- - {/* API 密钥分配区 */} -
-
- - {u.apiKey.slice(0, 15)}... - - -
-
- - -
-
+ <> +
+
+

{selected.name} · 成员

+

成员共享该租户的积分池与用量口径 · slug {selected.slug}

- ))} -
+ {credits(selected.credit_balance_micro)} 积分 +
+ + {/* 加成员 */} +
+ setMEmail(e.target.value)} /> + + +
+ {mErr &&
{mErr}
} + + {members.length === 0 ? ( +
暂无成员
+ ) : ( +
+ + + + + + + + + + + {members.map((m) => { + const isOwner = m.role === "owner"; + return ( + + + + + + + ); + })} + +
成员角色状态操作
+
{m.name || m.email}
+
{m.email}
+
+ {isOwner ? ( + 所有者 + ) : ( + + )} + + {m.status} + + {isOwner ? ( + + ) : ( + + )} +
+
+ )} + )}