Files
taiji-pda-v0/components/dashboard-layout.tsx
T

773 lines
30 KiB
TypeScript

"use client"
import type React from "react"
import { useState, useEffect } from "react"
import Link from "next/link"
import Image from "next/image"
import { usePathname } from "next/navigation"
import { TaijiAPIClient } from "@/lib/api-client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { useLanguage } from "@/contexts/language-context"
import { ThemeToggle } from "@/components/theme-toggle"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import {
LayoutDashboard,
Database,
GitBranch,
Boxes,
Network,
CreditCard,
ChevronLeft,
Bell,
Settings,
Activity,
Zap,
Globe,
LogOut,
User,
Key,
Copy,
Lock,
Mail,
BookOpen,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { getUser } from "@/lib/auth"
const navigationItems = [
{ nameKey: { zh: "概览", en: "Overview" }, href: "/", icon: LayoutDashboard },
{ nameKey: { zh: "服务网关", en: "Service Gateway" }, href: "/model-gateway", icon: GitBranch },
{ nameKey: { zh: "数据与工具", en: "Data & Tools" }, href: "/data-tools", icon: Database },
{ nameKey: { zh: "代理工厂", en: "Agent Factory" }, href: "/agent-factory", icon: Boxes },
{ nameKey: { zh: "编排中心", en: "Orchestration Hub" }, href: "/orchestration", icon: Network },
{ nameKey: { zh: "计费与资源", en: "Billing & Resources" }, href: "/billing", icon: CreditCard },
{ nameKey: { zh: "文档", en: "Documentation" }, href: "http://agnetdoc.taijiaicloud.com/", icon: BookOpen, external: true },
]
export function DashboardLayout({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false)
const pathname = usePathname()
const { language, setLanguage, t } = useLanguage()
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false)
const [showProfileDialog, setShowProfileDialog] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState(false)
// LiteLLM 密钥信息(从 /api/user/resources/info 获取)
const [litellmKeys, setLitellmKeys] = useState<Array<{
modelName: string
apiKey: string
apiBase: string
rpmLimit?: number
tpmLimit?: number
status?: string
}>>([])
const [litellmApiBase, setLitellmApiBase] = useState<string>("")
const [apiKeyLoading, setApiKeyLoading] = useState(false)
// 用户信息状态
const [userInfo, setUserInfo] = useState<{
username: string
name: string
email: string
company?: string
} | null>(null)
const [profileForm, setProfileForm] = useState({
username: "",
company: "",
})
const [profileLoading, setProfileLoading] = useState(false)
// 修改密码表单
const [passwordForm, setPasswordForm] = useState({
oldPassword: "",
newPassword: "",
confirmPassword: "",
})
const [passwordLoading, setPasswordLoading] = useState(false)
// API 延迟状态 - 从系统组件 mcp_server 获取
const [apiLatency, setApiLatency] = useState<number | null>(null)
// EU 余额状态
const [euBalance, setEuBalance] = useState<number | null>(null)
const [balanceLoading, setBalanceLoading] = useState(false)
useEffect(() => {
const loadApiLatency = async () => {
try {
const monitoringDashboard = await TaijiAPIClient.getMonitoringDashboard()
if (monitoringDashboard?.health?.services?.mcp_server?.latency !== undefined) {
setApiLatency(monitoringDashboard.health.services.mcp_server.latency)
}
} catch (error) {
console.error("Failed to load API latency:", error)
}
}
loadApiLatency()
}, [])
// 加载 EU 余额
useEffect(() => {
const loadBalance = async () => {
try {
setBalanceLoading(true)
const response = await TaijiAPIClient.getUserBalance()
if (response?.success && response?.data) {
setEuBalance(response.data.balance)
}
} catch (error) {
console.error("Failed to load balance:", error)
} finally {
setBalanceLoading(false)
}
}
loadBalance()
// 每30秒刷新一次余额
const balanceInterval = setInterval(loadBalance, 30000)
return () => clearInterval(balanceInterval)
}, [])
// 加载用户信息
useEffect(() => {
const loadUserInfo = () => {
const user = getUser()
if (user) {
setUserInfo({
username: user.username || user.email?.split("@")[0] || "",
name: user.name || user.full_name || user.email?.split("@")[0] || "",
email: user.email || "",
company: user.company || "",
})
setProfileForm({
username: user.username || user.email?.split("@")[0] || "",
company: user.company || "",
})
}
}
loadUserInfo()
}, [])
// 打开个人信息对话框时加载最新用户信息
useEffect(() => {
if (showProfileDialog) {
const loadUserInfo = async () => {
try {
const response = await TaijiAPIClient.getCurrentUser()
if (response?.success && response?.data) {
const user = response.data
setUserInfo({
username: user.username || user.email?.split("@")[0] || "",
name: user.name || user.full_name || user.email?.split("@")[0] || "",
email: user.email || "",
company: user.company || "",
})
setProfileForm({
username: user.username || user.email?.split("@")[0] || "",
company: user.company || "",
})
}
} catch (error: any) {
// 如果API不可用或失败,静默使用本地存储的信息
if (error.message === "PROFILE_API_NOT_AVAILABLE") {
// 接口未部署,使用本地数据
} else {
console.error("Failed to load user info:", error)
}
// 使用本地存储的信息
const user = getUser()
if (user) {
setUserInfo({
username: user.username || user.email?.split("@")[0] || "",
name: user.name || user.full_name || user.email?.split("@")[0] || "",
email: user.email || "",
company: user.company || "",
})
setProfileForm({
username: user.username || user.email?.split("@")[0] || "",
company: user.company || "",
})
}
}
}
loadUserInfo()
}
}, [showProfileDialog])
// 保存用户信息
const handleSaveProfile = async () => {
try {
setProfileLoading(true)
const response = await TaijiAPIClient.updateUserProfile({
username: profileForm.username,
company: profileForm.company || undefined,
})
if (response?.success) {
// 更新本地用户信息
const user = getUser()
if (user) {
user.username = profileForm.username
if (profileForm.company) {
user.company = profileForm.company
} else {
delete user.company
}
if (typeof window !== "undefined") {
localStorage.setItem("user", JSON.stringify(user))
}
// 更新显示的用户信息
if (userInfo) {
setUserInfo({
...userInfo,
username: profileForm.username,
company: profileForm.company || "",
})
}
}
setShowProfileDialog(false)
} else {
throw new Error(response?.message || "更新失败")
}
} catch (error: any) {
console.error("Failed to update profile:", error)
alert(error.message || t("更新失败,请重试", "Update failed, please try again"))
} finally {
setProfileLoading(false)
}
}
// 修改密码
const handleChangePassword = async () => {
// 验证表单
if (!passwordForm.oldPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
alert(t("请填写所有字段", "Please fill in all fields"))
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
alert(t("新密码和确认密码不匹配", "New password and confirm password do not match"))
return
}
if (passwordForm.newPassword.length < 8) {
alert(t("密码长度至少为8位", "Password must be at least 8 characters"))
return
}
if (passwordForm.oldPassword === passwordForm.newPassword) {
alert(t("新密码不能与旧密码相同", "New password cannot be the same as old password"))
return
}
try {
setPasswordLoading(true)
const response = await TaijiAPIClient.changePassword(
passwordForm.oldPassword,
passwordForm.newPassword
)
if (response?.success) {
alert(t("密码修改成功", "Password changed successfully"))
setPasswordForm({
oldPassword: "",
newPassword: "",
confirmPassword: "",
})
setShowChangePasswordDialog(false)
} else {
throw new Error(response?.message || t("修改失败", "Change failed"))
}
} catch (error: any) {
console.error("Failed to change password:", error)
alert(error.message || t("修改失败,请重试", "Change failed, please try again"))
} finally {
setPasswordLoading(false)
}
}
// 打开密钥管理对话框时加载密钥
useEffect(() => {
if (showApiKeyDialog) {
const loadApiKeys = async () => {
try {
setApiKeyLoading(true)
const response = await TaijiAPIClient.getUserResourcesInfo()
if (response?.success && response?.data) {
setLitellmKeys(response.data.litellmKeys || [])
setLitellmApiBase(response.data.litellmApiBase || "")
}
} catch (error) {
console.error("Failed to load API keys:", error)
} finally {
setApiKeyLoading(false)
}
}
loadApiKeys()
}
}, [showApiKeyDialog])
const copyToClipboard = (text: string) => {
if (typeof window !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(text)
}
}
return (
<div className="flex h-screen bg-background">
{/* Sidebar */}
<aside
className={cn(
"flex flex-col border-r border-border bg-card transition-all duration-300",
collapsed ? "w-16" : "w-64",
)}
>
{/* Logo */}
<div className="flex h-16 items-center justify-between border-b border-border px-4">
<Link href="/" className={cn("flex items-center gap-2", collapsed && "mx-auto")}>
<div className="flex h-8 w-8 items-center justify-center rounded-lg overflow-hidden shrink-0">
<Image
src="/logo.jpg"
alt="Taiji AI Platform Logo"
width={32}
height={32}
className="object-cover w-full h-full"
unoptimized
/>
</div>
{!collapsed && <span className="text-lg font-semibold">Taiji AI</span>}
</Link>
<Button
variant="ghost"
size="icon"
onClick={() => setCollapsed(!collapsed)}
className={cn("h-8 w-8", collapsed && "mx-auto")}
>
<ChevronLeft className={cn("h-4 w-4 transition-transform", collapsed && "rotate-180")} />
</Button>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 p-2 overflow-y-auto">
{navigationItems.map((item) => {
const isActive = pathname === item.href
const content = (
<div
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<item.icon className="h-5 w-5 shrink-0" />
{!collapsed && <span>{t(item.nameKey.zh, item.nameKey.en)}</span>}
</div>
)
if (item.external) {
return (
<a key={item.href} href={item.href} target="_blank" rel="noopener noreferrer">
{content}
</a>
)
}
return (
<Link key={item.href} href={item.href}>
{content}
</Link>
)
})}
</nav>
{/* Footer */}
<div className="border-t border-border p-2">
<Button
variant="ghost"
className={cn("w-full justify-start", collapsed && "justify-center px-0")}
onClick={() => setShowSettingsDialog(true)}
>
<Settings className="h-5 w-5 shrink-0" />
{!collapsed && <span className="ml-3">{t("设置", "Settings")}</span>}
</Button>
</div>
</aside>
{/* Main Content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Top Bar */}
<header className="flex h-16 items-center justify-between border-b border-border bg-card px-6">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-green-500" />
<span className="text-sm text-muted-foreground">{t("API 延迟", "API Latency")}: {apiLatency !== null ? `${apiLatency}ms` : "..."}</span>
</div>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm text-muted-foreground">{t("所有系统正常运行", "All Systems Operational")}</span>
</div>
</div>
<div className="flex items-center gap-3">
{/* Theme Toggle */}
<ThemeToggle />
{/* Language Switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<Globe className="h-4 w-4" />
<span className="hidden sm:inline">{language === "zh" ? "中文" : "English"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setLanguage("zh")}>中文</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("en")}>English</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Badge variant="outline" className="gap-2">
<Zap className="h-3 w-3" />
<span className="font-mono">
{balanceLoading ? "加载中..." : euBalance !== null ? `${euBalance} EU` : "-- EU"}
</span>
</Badge>
{/* Notification Bell */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-destructive" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<Card className="border-0 shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-4 w-4" />
{t("通知", "Notification")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t("如需购买请联系销售", "For purchasing, please contact sales")}
</p>
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-muted-foreground" />
<a
href="mailto:november@taijiaicloud.com"
className="text-primary hover:underline"
>
november@taijiaicloud.com
</a>
</div>
</div>
</CardContent>
</Card>
</DropdownMenuContent>
</DropdownMenu>
{/* User Menu with Logout */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-primary text-primary-foreground text-xs">
{(() => {
// 获取用户显示名称的首字母或前两个字符
const name = userInfo?.name || getUser()?.name || getUser()?.full_name || ""
if (name) {
// 如果是中文,取前两个字符;如果是英文,取首字母大写
const isChinese = /[\u4e00-\u9fa5]/.test(name)
if (isChinese) {
return name.substring(0, 2)
} else {
// 英文取首字母,支持多个单词
const words = name.trim().split(/\s+/)
if (words.length >= 2) {
// 多个单词取首字母
return (words[0][0] + words[1][0]).toUpperCase()
} else {
// 单个单词取前两个字符
return name.substring(0, 2).toUpperCase()
}
}
}
// 如果没有名称,尝试从邮箱获取
const email = userInfo?.email || getUser()?.email || ""
if (email) {
return email.substring(0, 2).toUpperCase()
}
// 默认值
return "U"
})()}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => setShowProfileDialog(true)}>
<User className="mr-2 h-4 w-4" />
{t("个人资料", "Profile")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowApiKeyDialog(true)}>
<Key className="mr-2 h-4 w-4" />
{t("密钥管理", "API Keys")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowSettingsDialog(true)}>
<Settings className="mr-2 h-4 w-4" />
{t("设置", "Settings")}
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/login" className="flex items-center">
<LogOut className="mr-2 h-4 w-4" />
{t("退出登录", "Sign Out")}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
{/* Page Content */}
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
<Dialog open={showApiKeyDialog} onOpenChange={setShowApiKeyDialog}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{t("密钥管理", "API Key Management")}</DialogTitle>
<DialogDescription>
{t("查看和管理您的API密钥", "View and manage your API keys")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{apiKeyLoading ? (
<div className="text-center py-4 text-muted-foreground">
{t("加载中...", "Loading...")}
</div>
) : litellmKeys.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
{t("暂无可用密钥", "No API keys available")}
</div>
) : (
litellmKeys.map((keyInfo, index) => (
<div key={index} className="space-y-3 p-4 rounded-lg border bg-muted/30">
{/* 模型名称 */}
{keyInfo.modelName && (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono">
{keyInfo.modelName}
</Badge>
{keyInfo.status && (
<Badge variant={keyInfo.status === "active" ? "default" : "outline"} className="text-xs">
{keyInfo.status === "active" ? t("激活", "Active") : keyInfo.status}
</Badge>
)}
</div>
)}
{/* API 密钥 */}
<div className="space-y-2">
<Label>{t("API密钥", "API Key")}</Label>
<div className="flex gap-2">
<Input value={keyInfo.apiKey} readOnly className="font-mono text-sm" type="password" />
<Button variant="outline" size="icon" onClick={() => copyToClipboard(keyInfo.apiKey)} title={t("复制", "Copy")}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t("请妥善保管您的API密钥。", "Keep your API key secure.")}
</p>
</div>
))
)}
</div>
<DialogFooter>
<Button onClick={() => setShowApiKeyDialog(false)}>{t("关闭", "Close")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Profile Dialog */}
<Dialog open={showProfileDialog} onOpenChange={setShowProfileDialog}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t("个人信息", "Profile Information")}</DialogTitle>
<DialogDescription>
{t("查看和编辑您的个人信息", "View and edit your profile information")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 用户名 - 可编辑 */}
<div className="space-y-2">
<Label htmlFor="username">{t("用户名", "Username")}</Label>
<Input
id="username"
value={profileForm.username}
onChange={(e) => setProfileForm({ ...profileForm, username: e.target.value })}
placeholder={t("请输入用户名", "Enter username")}
/>
<p className="text-xs text-muted-foreground">
{t("用户名用于登录和显示", "Username is used for login and display")}
</p>
</div>
{/* 邮箱 - 只读 */}
<div className="space-y-2">
<Label htmlFor="email">{t("邮箱", "Email")}</Label>
<Input
id="email"
type="email"
value={userInfo?.email || ""}
readOnly
disabled
tabIndex={-1}
className="bg-muted cursor-not-allowed select-none"
onSelect={(e) => e.preventDefault()}
onFocus={(e) => e.target.blur()}
/>
<p className="text-xs text-muted-foreground">
{t("邮箱不可修改", "Email cannot be modified")}
</p>
</div>
{/* 公司信息 - 可选,可编辑 */}
<div className="space-y-2">
<Label htmlFor="company">{t("公司信息", "Company")} ({t("可选", "Optional")})</Label>
<Input
id="company"
value={profileForm.company}
onChange={(e) => setProfileForm({ ...profileForm, company: e.target.value })}
placeholder={t("请输入公司名称", "Enter company name")}
/>
<p className="text-xs text-muted-foreground">
{t("公司信息为可选字段", "Company information is optional")}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowProfileDialog(false)}>
{t("取消", "Cancel")}
</Button>
<Button onClick={handleSaveProfile} disabled={profileLoading}>
{profileLoading ? t("保存中...", "Saving...") : t("保存", "Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Settings Dialog */}
<Dialog open={showSettingsDialog} onOpenChange={setShowSettingsDialog}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t("设置", "Settings")}</DialogTitle>
<DialogDescription>
{t("管理您的账户设置", "Manage your account settings")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Button
variant="outline"
className="w-full justify-start"
onClick={() => {
setShowSettingsDialog(false)
setShowChangePasswordDialog(true)
}}
>
<Lock className="mr-2 h-4 w-4" />
{t("修改密码", "Change Password")}
</Button>
</div>
<DialogFooter>
<Button onClick={() => setShowSettingsDialog(false)}>
{t("关闭", "Close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Change Password Dialog */}
<Dialog open={showChangePasswordDialog} onOpenChange={setShowChangePasswordDialog}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t("修改密码", "Change Password")}</DialogTitle>
<DialogDescription>
{t("请输入您的旧密码和新密码", "Please enter your old password and new password")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 旧密码 */}
<div className="space-y-2">
<Label htmlFor="oldPassword">{t("旧密码", "Old Password")}</Label>
<Input
id="oldPassword"
type="password"
value={passwordForm.oldPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, oldPassword: e.target.value })}
placeholder={t("请输入旧密码", "Enter old password")}
/>
</div>
{/* 新密码 */}
<div className="space-y-2">
<Label htmlFor="newPassword">{t("新密码", "New Password")}</Label>
<Input
id="newPassword"
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
placeholder={t("请输入新密码(至少8位)", "Enter new password (at least 8 characters)")}
/>
<p className="text-xs text-muted-foreground">
{t("密码长度至少为8位", "Password must be at least 8 characters")}
</p>
</div>
{/* 确认密码 */}
<div className="space-y-2">
<Label htmlFor="confirmPassword">{t("确认密码", "Confirm Password")}</Label>
<Input
id="confirmPassword"
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
placeholder={t("请再次输入新密码", "Enter new password again")}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
setShowChangePasswordDialog(false)
setPasswordForm({
oldPassword: "",
newPassword: "",
confirmPassword: "",
})
}}>
{t("取消", "Cancel")}
</Button>
<Button onClick={handleChangePassword} disabled={passwordLoading}>
{passwordLoading ? t("修改中...", "Changing...") : t("确认修改", "Confirm Change")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}