feat: admin端UI

This commit is contained in:
Blizzard
2026-06-27 12:06:29 +08:00
parent d2662a1f37
commit 2d9695e940
9 changed files with 2117 additions and 85 deletions
+315
View File
@@ -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>
);
}