feat: revamp web hub — global connection status, array config editor, log streaming
- Global connection badge in app header (persistent dashboard SSE via ConnectionProvider) - LogProvider keeps log data and SSE stream alive across page switches (no reload spinner) - Config drawer: password reveal toggle, array-type row editor for list fields - Plugin management: optimistic updates, race-free loading, reset all group overrides - Logs: load-earlier pagination; backend tail-read optimization (before/end offsets) - web_auth: Web password changes persist (sync_admin seeding only when .env has explicit key) - Dashboard: collect in thread pool + 1s result cache - ErrorBoundary + local Suspense: plugin chunk loading stays inside content area - UI polish: table row hover, mobile header/breadcrumb fixes, wider config drawer - config_standard: array type inference for list fields; array env skip - random_jm_code / steam_info: array schema migration; README web frontend note Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,32 @@
|
||||
import { useLayoutEffect, useEffect, useRef, useState, Suspense } from 'react'
|
||||
import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Avatar, Button, Separator } from '@heroui/react'
|
||||
import { House, Rocket, Gear, FileText } from '@gravity-ui/icons'
|
||||
import { Avatar, Button, Separator, Spinner } from '@heroui/react'
|
||||
import { House, Rocket, Gear, FileText, Bars } from '@gravity-ui/icons'
|
||||
import { useAuth } from '../auth/auth'
|
||||
import { hubPlugins } from '../api/client'
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary'
|
||||
import { useConnection } from '../connection'
|
||||
|
||||
interface NavItem { id: string; label: string; description: string; icon: any }
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ id: 'home', label: '首页', description: '主机与服务状态', icon: House },
|
||||
{ id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket },
|
||||
{ id: 'logs', label: '查看日志', description: 'bot 运行日志实时查看', icon: FileText },
|
||||
{ id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear },
|
||||
]
|
||||
|
||||
// 插件名映射做模块级缓存:布局多次挂载(路由切换)时不必重复拉取
|
||||
let webNamesCache: Record<string, string> | null = null
|
||||
|
||||
export function AppLayout() {
|
||||
const { token, username, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// 全局连接状态(顶栏徽章):由常驻 dashboard SSE 驱动
|
||||
const { status, lastUpdate } = useConnection()
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'home', label: '首页', description: '主机与服务状态', icon: House },
|
||||
{ id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket },
|
||||
{ id: 'logs', label: '查看日志', description: 'bot 运行日志实时查看', icon: FileText },
|
||||
{ id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear },
|
||||
]
|
||||
const navItems = NAV_ITEMS
|
||||
const path = location.pathname.replace(/\/+$/, '') || '/'
|
||||
const isHome = path === '/hub'
|
||||
const isSettings = path.startsWith('/hub/settings')
|
||||
@@ -26,11 +37,62 @@ export function AppLayout() {
|
||||
const activeId = isPlugins ? 'plugins' : isLogs ? 'logs' : isSettings ? 'settings' : 'home'
|
||||
const pageMeta = navItems.find(it => it.id === activeId) || navItems[0]
|
||||
|
||||
if (!token) return <Navigate to="/hub/login" replace />
|
||||
// 插件子页:从 /hub/<插件id>/... 提取插件 id,并用注册表名字做顶栏面包屑
|
||||
const pluginId = path.split('/')[2] || ''
|
||||
const [webNames, setWebNames] = useState<Record<string, string>>({})
|
||||
useEffect(() => {
|
||||
if (webNamesCache) { setWebNames(webNamesCache); return }
|
||||
hubPlugins().then(d => {
|
||||
const m: Record<string, string> = {}
|
||||
for (const p of (d && d.plugins) || []) m[p.id] = p.name
|
||||
webNamesCache = m
|
||||
setWebNames(m)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
const subName = isPluginSub ? (webNames[pluginId] || pluginId) : ''
|
||||
|
||||
const navMain = navItems.filter(it => it.id !== 'settings')
|
||||
const navBottom = navItems.filter(it => it.id === 'settings')
|
||||
const activeMainId = navMain.find(it => it.id === activeId)?.id ?? null
|
||||
|
||||
// 移动端侧栏抽屉:默认收起,点击汉堡按钮展开;切换路由后自动收起
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
useEffect(() => { setSidebarOpen(false) }, [path])
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSidebarOpen(false) }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
// 滑动指示条:在主导航区中根据高亮项位置平滑移动
|
||||
const [indicator, setIndicator] = useState<{ top: number; height: number } | null>(null)
|
||||
const mainNavRef = useRef<HTMLElement>(null)
|
||||
|
||||
const recalcIndicator = () => {
|
||||
const nav = mainNavRef.current
|
||||
if (!nav || !activeMainId) { setIndicator(null); return }
|
||||
const el = nav.querySelector<HTMLElement>('[data-nav-id="' + activeMainId + '"]')
|
||||
if (!el) { setIndicator(null); return }
|
||||
const navRect = nav.getBoundingClientRect()
|
||||
const itemRect = el.getBoundingClientRect()
|
||||
setIndicator({ top: itemRect.top - navRect.top, height: itemRect.height })
|
||||
}
|
||||
|
||||
useLayoutEffect(() => { recalcIndicator() }, [activeMainId, path])
|
||||
useEffect(() => {
|
||||
// 窗口尺寸变化(如移动端调转)时重算指示条位置
|
||||
window.addEventListener('resize', recalcIndicator)
|
||||
return () => window.removeEventListener('resize', recalcIndicator)
|
||||
}, [activeMainId, path])
|
||||
|
||||
if (!token) return <Navigate to="/hub/login" replace state={{ from: location.pathname + location.search }} />
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-900">
|
||||
<aside className="flex h-full w-60 shrink-0 flex-col overflow-hidden bg-white border-r border-slate-200">
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-30 bg-slate-900/40 backdrop-blur-sm md:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
<aside className={'fixed inset-y-0 left-0 z-40 flex h-full w-60 shrink-0 flex-col overflow-hidden bg-white border-r border-slate-200 transition-transform duration-300 ease-out md:static md:translate-x-0 ' + (sidebarOpen ? 'translate-x-0' : '-translate-x-full')}>
|
||||
{/* 品牌头 */}
|
||||
<div className="flex h-16 shrink-0 items-center px-4">
|
||||
<div className="grid size-9 place-items-center rounded-xl bg-indigo-100 text-indigo-600"><Gear className="size-5" /></div>
|
||||
@@ -38,29 +100,36 @@ export function AppLayout() {
|
||||
<div className="truncate text-sm font-bold">HeXi 管理系统</div>
|
||||
<div className="truncate text-xs text-slate-400">统一管理台</div>
|
||||
</div>
|
||||
<Button size="sm" variant="tertiary" className="md:hidden" onPress={() => setSidebarOpen(false)} aria-label="关闭菜单">✕</Button>
|
||||
</div>
|
||||
|
||||
{/* 导航 */}
|
||||
<nav className="flex-1 min-h-0 space-y-1 overflow-y-auto px-2 py-2">
|
||||
{navItems.filter(it => it.id !== 'settings').map(it => {
|
||||
{/* 导航(主区,含滑动指示条) */}
|
||||
<nav ref={mainNavRef} className="relative flex-1 min-h-0 space-y-1 overflow-y-auto px-2 py-2">
|
||||
{indicator && (
|
||||
<span
|
||||
className="pointer-events-none absolute left-2 w-0.5 rounded-full bg-indigo-500 transition-all duration-300 ease-out"
|
||||
style={{ top: indicator.top + (indicator.height - 20) / 2, height: 20 }}
|
||||
/>
|
||||
)}
|
||||
{navMain.map(it => {
|
||||
const to = it.id === 'home' ? '/hub' : '/hub/' + it.id
|
||||
return (
|
||||
<NavLink
|
||||
key={it.id}
|
||||
to={to}
|
||||
end={it.id === 'home'}
|
||||
data-nav-id={it.id}
|
||||
className={({ isActive }) => {
|
||||
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors cursor-pointer'
|
||||
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors duration-200 cursor-pointer'
|
||||
return base + (isActive ? ' bg-indigo-50 text-indigo-700' : ' text-slate-600 hover:bg-slate-100 hover:text-slate-800')
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-indigo-500" />}
|
||||
<span className="relative z-10 grid w-11 shrink-0 place-items-center"><it.icon className="size-4" /></span>
|
||||
<span className={'relative z-10 grid w-11 shrink-0 place-items-center transition-transform duration-200 ' + (isActive ? 'scale-110' : 'group-hover:scale-105')}><it.icon className="size-4" /></span>
|
||||
<span className="relative z-10 flex min-w-0 flex-1 flex-col items-start overflow-hidden pr-3">
|
||||
<span className="w-full truncate leading-tight">{it.label}</span>
|
||||
<span className="w-full truncate text-xs font-normal text-slate-400">{it.description}</span>
|
||||
<span className={'w-full truncate text-xs font-normal transition-colors duration-200 ' + (isActive ? 'text-indigo-400' : 'text-slate-400')}>{it.description}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -69,23 +138,23 @@ export function AppLayout() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 底部设置 */}
|
||||
{/* 底部设置(单条目时用缩放指示条) */}
|
||||
<nav className="shrink-0 space-y-1 border-t border-slate-100 px-2 py-2">
|
||||
{navItems.filter(it => it.id === 'settings').map(it => {
|
||||
{navBottom.map(it => {
|
||||
const to = '/hub/' + it.id
|
||||
return (
|
||||
<NavLink
|
||||
key={it.id}
|
||||
to={to}
|
||||
className={({ isActive }) => {
|
||||
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors cursor-pointer'
|
||||
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors duration-200 cursor-pointer'
|
||||
return base + (isActive ? ' bg-indigo-50 text-indigo-700' : ' text-slate-600 hover:bg-slate-100 hover:text-slate-800')
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-indigo-500" />}
|
||||
<span className="relative z-10 grid w-11 shrink-0 place-items-center"><it.icon className="size-4" /></span>
|
||||
<span className={'absolute left-0 top-1/2 h-5 w-0.5 origin-center -translate-y-1/2 rounded-full bg-indigo-500 transition-all duration-300 ' + (isActive ? 'scale-y-100 opacity-100' : 'scale-y-0 opacity-0')} />
|
||||
<span className={'relative z-10 grid w-11 shrink-0 place-items-center transition-transform duration-200 ' + (isActive ? 'scale-110' : 'group-hover:scale-105')}><it.icon className="size-4" /></span>
|
||||
<span className="relative z-10 flex min-w-0 flex-1 flex-col items-start overflow-hidden pr-3">
|
||||
<span className="w-full truncate leading-tight">{it.label}</span>
|
||||
<span className="w-full truncate text-xs font-normal text-slate-400">{it.description}</span>
|
||||
@@ -99,23 +168,53 @@ export function AppLayout() {
|
||||
</aside>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3 md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-2 md:gap-3">
|
||||
<Button size="sm" variant="tertiary" className="shrink-0 md:hidden" onPress={() => setSidebarOpen(v => !v)} aria-label="打开菜单">
|
||||
<Bars className="size-5" />
|
||||
</Button>
|
||||
{isPluginSub && (
|
||||
<Button size="sm" variant="tertiary" onPress={() => navigate('/hub/plugins')}>← 返回插件管理</Button>
|
||||
<Button size="sm" variant="tertiary" className="max-w-24 shrink-0 truncate" onPress={() => navigate('/hub/plugins')}>← 返回</Button>
|
||||
)}
|
||||
{isPluginSub ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-sm text-slate-400">插件管理</span>
|
||||
<span className="shrink-0 text-sm text-slate-300">›</span>
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-slate-800">{subName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<pageMeta.icon className="hidden size-4 shrink-0 text-slate-500 sm:block" />
|
||||
<span className="truncate text-sm font-semibold text-slate-800">{pageMeta.label}</span>
|
||||
</>
|
||||
)}
|
||||
<pageMeta.icon className="size-4 text-slate-500" />
|
||||
<span className="text-sm font-semibold text-slate-800">{pageMeta.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex shrink-0 items-center gap-2 md:gap-3">
|
||||
<span
|
||||
title={lastUpdate ? '最后更新 ' + new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false }) : '正在连接后端…'}
|
||||
className={'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors duration-300 ' + (status === '实时' ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : status === '重连中' ? 'border-amber-200 bg-amber-50 text-amber-600' : 'border-slate-200 bg-slate-50 text-slate-400')}
|
||||
>
|
||||
<span className={'size-1.5 rounded-full ' + (status === '实时' ? 'bg-emerald-500' : status === '重连中' ? 'animate-pulse bg-amber-500' : 'bg-slate-300')} />
|
||||
{status}
|
||||
{lastUpdate && <span className="hidden text-[10px] opacity-70 sm:inline">{new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false })}</span>}
|
||||
</span>
|
||||
<Avatar size="sm"><Avatar.Fallback>{(username || 'U').charAt(0).toUpperCase()}</Avatar.Fallback></Avatar>
|
||||
<span className="text-sm text-slate-600">{username || '未登录'}</span>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<span className="hidden text-sm text-slate-600 sm:inline">{username || '未登录'}</span>
|
||||
<Separator orientation="vertical" className="hidden h-5 sm:block" />
|
||||
<Button size="sm" variant="tertiary" onPress={() => { logout(); navigate('/hub/login') }}>退出</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto p-6"><Outlet /></main>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
|
||||
<div key={path} className="hexi-page-enter">
|
||||
{/* 插件懒加载只替换内容区;子页面异常也不拖垮整个布局 */}
|
||||
<ErrorBoundary compact>
|
||||
<Suspense fallback={<div className="flex justify-center py-10"><Spinner /></div>}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user