- POST /ai/chat:多轮对话(messages 透传)+ 页面上下文注入 —— 前端带当前路径, 后端自动附加该数据源近 15 分钟轻量日志摘要(总量/Top3 应用/告警命中), 追问「这个报错什么原因」有数据依据;脱敏/每日配额/Pro 闸门与整页分析共用 - AiAssistant 浮球+浮窗挂 Layout:仅 admin+Pro 渲染;消息气泡、Enter 发送、 上下文随路由自动采集;免费版不显示
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
import {
|
||
memo,
|
||
useState,
|
||
useEffect,
|
||
useCallback,
|
||
Suspense,
|
||
} from 'react'
|
||
import { Link, useLocation, useNavigate } from 'react-router'
|
||
import {
|
||
LogOut,
|
||
PanelLeftClose,
|
||
PanelLeftOpen,
|
||
ChevronRight,
|
||
Activity,
|
||
Menu,
|
||
Sun,
|
||
Moon,
|
||
} from 'lucide-react'
|
||
import { useTheme } from 'next-themes'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { cn } from '@/lib/utils'
|
||
import { useIsMobile } from '@/hooks/use-mobile'
|
||
import { menuItems, findRouteMeta, findGroupKey } from '@/router/routes'
|
||
import { useLicense } from '@/hooks/useLicense'
|
||
import { AiAssistant } from '@/components/AiAssistant'
|
||
import { Lock } from 'lucide-react'
|
||
import { GroupTabs } from './GroupTabs'
|
||
import { getUser, clearAuth } from '@/lib/auth-storage'
|
||
import { isThemeAuto, setThemeAuto } from '@/lib/theme-schedule'
|
||
import { useScheduledTheme } from '@/hooks/useScheduledTheme'
|
||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||
import { Skeleton } from '@/components/ui/skeleton'
|
||
|
||
// 侧栏折叠状态持久化 key(命名空间见开发标准 08 章)
|
||
const LAYOUT_COLLAPSED_KEY = 'lokistack.layout.collapsed'
|
||
|
||
// 侧栏导航 — 桌面 aside 与移动端抽屉共用;菜单数据来自路由配置单源派生
|
||
function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
|
||
const location = useLocation()
|
||
const { t } = useTranslation()
|
||
const isAdmin = getUser()?.role === 'admin'
|
||
const { pro } = useLicense()
|
||
// adminOnly 项(用户管理)仅管理员可见;后端 AdminAuth 兜底
|
||
const visibleItems = menuItems.filter((item) => !item.adminOnly || isAdmin)
|
||
return (
|
||
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto" aria-label={t('layout.nav')}>
|
||
{visibleItems.map((item) => {
|
||
// 分组入口:当前路由属于该分组时高亮(进入 /hosts 也要让「资源接入」亮)
|
||
const currentGroup = findGroupKey(location.pathname)
|
||
const isActive = item.groupKey
|
||
? currentGroup === item.groupKey
|
||
: location.pathname === item.path
|
||
return (
|
||
<Link
|
||
key={item.path}
|
||
to={item.path}
|
||
onClick={onNavigate}
|
||
aria-label={t(item.label)}
|
||
className={cn(
|
||
'relative flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
|
||
isActive
|
||
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold before:absolute before:left-0 before:h-5 before:w-0.5 before:rounded-full before:bg-sidebar-accent-foreground/60'
|
||
: 'text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground'
|
||
)}
|
||
title={!collapsed ? t(item.label) : undefined}
|
||
>
|
||
{item.icon && <item.icon className="w-5 h-5 flex-shrink-0" />}
|
||
{!collapsed && <span className="text-sm font-medium">{t(item.label)}</span>}
|
||
{item.proOnly && !pro && <Lock className="w-3 h-3 text-sidebar-foreground/50 flex-shrink-0" aria-label="Pro" />}
|
||
{!collapsed && isActive && (
|
||
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
|
||
)}
|
||
</Link>
|
||
)
|
||
})}
|
||
</nav>
|
||
)
|
||
}
|
||
|
||
// 侧栏 Logo 区
|
||
function SidebarLogo({ collapsed }: { collapsed: boolean }) {
|
||
return (
|
||
<div className="h-16 flex items-center px-4 border-b border-sidebar-border flex-shrink-0">
|
||
{collapsed ? (
|
||
<div className="w-8 h-8 rounded-lg border border-sidebar-primary/30 bg-sidebar-primary/15 flex items-center justify-center mx-auto">
|
||
<Activity className="w-5 h-5 text-sidebar-primary" />
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-8 h-8 rounded-lg border border-sidebar-primary/30 bg-sidebar-primary/15 flex items-center justify-center">
|
||
<Activity className="w-5 h-5 text-sidebar-primary" />
|
||
</div>
|
||
<span className="font-bold text-xl tracking-tight">LokiStack</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 主题二态切换:浅色 ↔ 深色(key: lokistack.theme.mode;resolvedTheme 兼容"跟随系统/定时"偏好)
|
||
// 定时模式开启时点击 = 关闭定时并切到相反主题(用户手动覆盖调度)。
|
||
function ThemeToggle() {
|
||
const { resolvedTheme, setTheme } = useTheme()
|
||
const { t } = useTranslation()
|
||
const isDark = resolvedTheme === 'dark'
|
||
const label = isDark ? t('layout.toLight') : t('layout.toDark')
|
||
return (
|
||
<button
|
||
onClick={() => {
|
||
if (isThemeAuto()) {
|
||
setThemeAuto(false)
|
||
setTheme(isDark ? 'light' : 'dark')
|
||
return
|
||
}
|
||
setTheme(isDark ? 'light' : 'dark')
|
||
}}
|
||
aria-label={label}
|
||
title={label}
|
||
className="p-2 rounded-lg text-muted-foreground hover:bg-muted"
|
||
>
|
||
{isDark ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// 页面级加载骨架(挂在 main 内,切页时布局不闪)
|
||
function PageSkeleton() {
|
||
const { t } = useTranslation()
|
||
return (
|
||
<div className="space-y-6" aria-busy="true" aria-label={t('layout.loading')}>
|
||
<div className="space-y-2">
|
||
<Skeleton className="h-7 w-48" />
|
||
<Skeleton className="h-4 w-72" />
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{[1, 2, 3].map((i) => (
|
||
<Skeleton key={i} className="h-40 rounded-xl" />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Layout 组件 — 布局骨架,业务页只挂在内容区
|
||
* - 菜单/顶栏标题来自路由 meta 单源(router/routes.tsx)
|
||
* - 桌面端折叠状态持久化;移动端(<768px)侧栏为抽屉 + 遮罩
|
||
* - 页面懒加载的 Suspense 边界在 main 内部
|
||
*/
|
||
const Layout = memo(function Layout({ children }: { children: React.ReactNode }) {
|
||
const isMobile = useIsMobile()
|
||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(LAYOUT_COLLAPSED_KEY) === '1')
|
||
const [mobileOpen, setMobileOpen] = useState(false)
|
||
const location = useLocation()
|
||
const navigate = useNavigate()
|
||
const { t } = useTranslation()
|
||
const user = getUser()
|
||
|
||
// 主题定时调度(「定时」模式开启时每分钟按时区解析浅/深色)
|
||
useScheduledTheme()
|
||
|
||
// 顶栏标题来自路由 meta
|
||
// meta.title 为 i18n key(common 命名空间),展示前翻译
|
||
const pageTitleKey = findRouteMeta(location.pathname)?.title
|
||
const pageTitle = pageTitleKey ? t(pageTitleKey) : 'LokiStack'
|
||
|
||
// 折叠状态持久化(仅桌面端语义)
|
||
useEffect(() => {
|
||
localStorage.setItem(LAYOUT_COLLAPSED_KEY, collapsed ? '1' : '0')
|
||
}, [collapsed])
|
||
|
||
const toggleSidebar = useCallback(() => {
|
||
setCollapsed((prev) => !prev)
|
||
}, [])
|
||
|
||
const handleLogout = useCallback(() => {
|
||
clearAuth()
|
||
navigate('/login', { replace: true })
|
||
}, [navigate])
|
||
|
||
return (
|
||
<div className="flex h-screen bg-background">
|
||
{/* 桌面侧栏 */}
|
||
{!isMobile && (
|
||
<aside
|
||
className={cn(
|
||
'bg-sidebar text-sidebar-accent-foreground transition-all duration-300 flex flex-col flex-shrink-0',
|
||
collapsed ? 'w-16' : 'w-64'
|
||
)}
|
||
>
|
||
<SidebarLogo collapsed={collapsed} />
|
||
<SidebarNav collapsed={collapsed} />
|
||
<div className="p-2 border-t border-sidebar-border flex-shrink-0">
|
||
<button
|
||
onClick={toggleSidebar}
|
||
aria-label={collapsed ? t('layout.expand') : t('layout.collapse')}
|
||
title={collapsed ? t('layout.expand') : t('layout.collapse')}
|
||
className="flex items-center justify-center w-full py-2.5 rounded-lg text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||
>
|
||
{collapsed ? (
|
||
<PanelLeftOpen className="w-5 h-5" />
|
||
) : (
|
||
<PanelLeftClose className="w-5 h-5" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
)}
|
||
|
||
{/* 移动端抽屉侧栏 */}
|
||
{isMobile && (
|
||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||
<SheetContent side="left" className="bg-sidebar text-sidebar-accent-foreground border-none p-0 w-72">
|
||
<SheetHeader className="sr-only">
|
||
<SheetTitle className="text-sidebar-accent-foreground">{t('layout.sheetTitle')}</SheetTitle>
|
||
</SheetHeader>
|
||
<div className="flex flex-col h-full">
|
||
<SidebarLogo collapsed={false} />
|
||
<SidebarNav collapsed={false} onNavigate={() => setMobileOpen(false)} />
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
)}
|
||
|
||
{/* Main Content */}
|
||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||
{/* Top Bar */}
|
||
<header className="h-16 bg-card border-b border-border flex items-center justify-between px-4 md:px-6 flex-shrink-0 gap-3">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
{isMobile && (
|
||
<button
|
||
onClick={() => setMobileOpen(true)}
|
||
aria-label={t('layout.openNav')}
|
||
className="p-2 rounded-lg text-muted-foreground hover:bg-muted"
|
||
>
|
||
<Menu className="w-5 h-5" />
|
||
</button>
|
||
)}
|
||
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">
|
||
{pageTitle}
|
||
</h1>
|
||
</div>
|
||
<div className="flex items-center gap-4 flex-shrink-0">
|
||
<ThemeToggle />
|
||
{user && (
|
||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||
<span>{user.username}</span>
|
||
<button
|
||
onClick={handleLogout}
|
||
title={t('layout.logout')}
|
||
aria-label={t('layout.logout')}
|
||
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-all"
|
||
>
|
||
<LogOut className="w-4 h-4" />
|
||
<span className="hidden md:inline">{t('layout.logoutShort')}</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
{/* 分组 tab(资源接入 / 日志服务 / 日志 / 系统管理);不属于分组时不渲染 */}
|
||
<GroupTabs />
|
||
|
||
{/* Page Content — 页面级 Suspense 边界,懒加载时不闪布局 */}
|
||
<main className="flex-1 overflow-auto p-4 md:p-6">
|
||
<Suspense fallback={<PageSkeleton />}>{children}</Suspense>
|
||
</main>
|
||
<AiAssistant />
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
|
||
export default Layout |