feat: admin端UI
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import { useState } from "react";
|
||||
|
||||
// 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" }
|
||||
];
|
||||
|
||||
// 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" },
|
||||
];
|
||||
|
||||
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 [newTenantName, setNewTenantName] = useState("");
|
||||
const [newTenantCode, setNewTenantCode] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// 用户编辑/创建状态
|
||||
const [showAddUser, setShowAddUser] = useState(false);
|
||||
const [newUserName, setNewUserName] = useState("");
|
||||
const [newUserEmail, setNewUserEmail] = useState("");
|
||||
const [newUserBudget, setNewUserBudget] = useState(50000);
|
||||
|
||||
// 创建租户
|
||||
const handleCreateTenant = () => {
|
||||
if (!newTenantName || !newTenantCode) return;
|
||||
setIsCreating(true);
|
||||
|
||||
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 toggleUserStatus = (id: string) => {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, status: u.status === "active" ? "suspended" : "active" } : u))
|
||||
);
|
||||
};
|
||||
|
||||
// 生成新 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 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);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
<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 ? "正在开辟隔离数据空间..." : "开辟租户空间"}
|
||||
</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="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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user