feat: layout components (Sidebar, Header, MobileNav, TabBar, AppLayout) with routing

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
enzo
2026-07-22 16:33:42 +08:00
co-authored by Claude
parent bd3d0147f5
commit 7441546804
18 changed files with 589 additions and 3 deletions
+53 -3
View File
@@ -1,7 +1,57 @@
export default function App() { import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Skeleton from './components/ui/Skeleton'
import AppLayout from './components/layout/AppLayout'
import LoginPage from './pages/LoginPage'
import NotFoundPage from './pages/NotFoundPage'
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const GatewayPage = lazy(() => import('./pages/GatewayPage'))
const HealthPage = lazy(() => import('./pages/HealthPage'))
const KanbanPage = lazy(() => import('./pages/KanbanPage'))
const CronPage = lazy(() => import('./pages/CronPage'))
const KeysPage = lazy(() => import('./pages/KeysPage'))
const EventArchivePage = lazy(() => import('./pages/EventArchivePage'))
const UserManagementPage = lazy(() => import('./pages/UserManagementPage'))
const AuditLogPage = lazy(() => import('./pages/AuditLogPage'))
function Loading() {
return ( return (
<div className="min-h-screen flex items-center justify-center" style={{background:'var(--bg-root)', color:'var(--text)'}}> <div className="p-5">
<h1 className="text-2xl font-bold">Hive Manage v3</h1> <Skeleton variant="card" count={2} />
</div> </div>
) )
} }
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/404" element={<NotFoundPage />} />
<Route
path="/"
element={
<AppLayout>
<Suspense fallback={<Loading />}>
<Routes>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="gateway" element={<GatewayPage />} />
<Route path="health" element={<HealthPage />} />
<Route path="kanban" element={<KanbanPage />} />
<Route path="cron" element={<CronPage />} />
<Route path="keys" element={<KeysPage />} />
<Route path="events" element={<EventArchivePage />} />
<Route path="users" element={<UserManagementPage />} />
<Route path="audit" element={<AuditLogPage />} />
</Routes>
</Suspense>
</AppLayout>
}
/>
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
</BrowserRouter>
)
}
@@ -0,0 +1,138 @@
import { useState, type ReactNode } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTheme } from '../../hooks/useTheme'
import Sidebar from './Sidebar'
import Header from './Header'
import MobileNav from './MobileNav'
import TabBar from './TabBar'
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(() => {
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches
}
return false
})
if (typeof window !== 'undefined') {
const mql = window.matchMedia(query)
mql.addEventListener('change', (e) => setMatches(e.matches))
}
return matches
}
export interface AppLayoutProps {
children: ReactNode
breadcrumb?: string
onlineCount?: number
runningCount?: number
onSearch?: (query: string) => any
}
export default function AppLayout({
children,
breadcrumb,
onlineCount = 0,
runningCount = 0,
onSearch,
}: AppLayoutProps) {
const { theme, toggle } = useTheme()
const navigate = useNavigate()
const location = useLocation()
const isMobile = useMediaQuery('(max-width: 767px)')
const isTablet = useMediaQuery('(min-width: 768px) and (max-width: 1023px)')
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const handleNavigate = (path: string) => {
navigate(path)
}
const sidebarWidth = isTablet ? 180 : 220
if (isMobile) {
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--bg-root)' }}>
{/* Mobile header */}
<header
className="flex items-center justify-between px-4 shrink-0"
style={{
height: 'var(--header-h)',
background: 'var(--bg-nav)',
borderBottom: '1px solid var(--border)',
}}
>
<div className="flex items-center gap-2">
<button
onClick={() => setMobileNavOpen(true)}
className="cursor-pointer bg-transparent border-none text-[20px] p-1"
style={{ color: 'var(--text)' }}
>
☰
</button>
<span style={{ color: 'var(--honey)', fontWeight: 700, fontSize: 14 }}>知微蜂巢</span>
</div>
<button
onClick={toggle}
className="cursor-pointer bg-transparent border-none text-[18px] leading-none p-1"
title={theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
</header>
{/* Mobile content */}
<main className="flex-1 overflow-y-auto p-3 pb-[64px]">{children}</main>
{/* Mobile tab bar */}
<TabBar activePath={location.pathname} />
{/* Mobile nav drawer */}
<MobileNav
open={mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
activePath={location.pathname}
onNavigate={handleNavigate}
/>
</div>
)
}
return (
<div
className="min-h-screen"
style={{
display: 'grid',
gridTemplateColumns: `${sidebarWidth}px 1fr`,
gridTemplateRows: 'var(--header-h) 1fr',
gridTemplateAreas: '"sidebar header" "sidebar main"',
background: 'var(--bg-root)',
}}
>
{/* Sidebar */}
<div style={{ gridArea: 'sidebar' }}>
<Sidebar activePath={location.pathname} onNavigate={handleNavigate} />
</div>
{/* Header */}
<div style={{ gridArea: 'header' }}>
<Header
breadcrumb={breadcrumb}
onlineCount={onlineCount}
runningCount={runningCount}
onSearch={onSearch}
theme={theme}
onToggleTheme={toggle}
/>
</div>
{/* Main */}
<main
className="overflow-y-auto p-5"
style={{ gridArea: 'main', background: 'var(--bg-root)' }}
>
{children}
</main>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { useNavigate } from 'react-router-dom'
import Badge from '../ui/Badge'
import SearchBox from '../ui/SearchBox'
export interface HeaderProps {
breadcrumb?: string
onlineCount?: number
runningCount?: number
onSearch?: (query: string) => any
theme: string
onToggleTheme: () => void
}
export default function Header({
breadcrumb,
onlineCount = 0,
runningCount = 0,
onSearch,
theme,
onToggleTheme,
}: HeaderProps) {
const navigate = useNavigate()
return (
<header
className="flex items-center gap-3 px-4 shrink-0"
style={{
height: 'var(--header-h)',
background: 'var(--bg-nav)',
borderBottom: '1px solid var(--border)',
}}
>
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-[13px] min-w-0" style={{ color: 'var(--text-dim)' }}>
{breadcrumb && (
<>
<span
className="cursor-pointer hover:text-[var(--text)] transition-colors"
onClick={() => navigate('/dashboard')}
>
首页
</span>
<span style={{ color: 'var(--text-muted)' }}>/</span>
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{breadcrumb}</span>
</>
)}
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Search */}
{onSearch && (
<SearchBox onSearch={onSearch} placeholder="搜索功能、任务、日志..." />
)}
{/* Status badges */}
<Badge variant="success">在线 {onlineCount}</Badge>
<Badge variant="honey">运行中 {runningCount}</Badge>
{/* Theme toggle */}
<button
onClick={onToggleTheme}
className="cursor-pointer bg-transparent border-none text-[18px] leading-none p-1 rounded hover:bg-[var(--bg-hover)] transition-colors"
title={theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
{/* User avatar */}
<div
className="w-[30px] h-[30px] rounded-full flex items-center justify-center text-[13px] font-semibold cursor-pointer shrink-0"
style={{ background: 'var(--honey-glow)', color: 'var(--honey)' }}
title="管理员"
>
A
</div>
</header>
)
}
@@ -0,0 +1,75 @@
import { useEffect, useCallback, useState } from 'react'
import { createPortal } from 'react-dom'
import Sidebar from './Sidebar'
export interface MobileNavProps {
open: boolean
onClose: () => void
activePath: string
onNavigate: (path: string) => void
}
export default function MobileNav({ open, onClose, activePath, onNavigate }: MobileNavProps) {
const [mounted, setMounted] = useState(false)
const [visible, setVisible] = useState(false)
useEffect(() => {
if (open) {
setMounted(true)
requestAnimationFrame(() => setVisible(true))
} else {
setVisible(false)
const timer = setTimeout(() => setMounted(false), 250)
return () => clearTimeout(timer)
}
}, [open])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
},
[onClose],
)
useEffect(() => {
if (open) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [open, handleKeyDown])
const handleNavigate = (path: string) => {
onNavigate(path)
onClose()
}
if (!mounted) return null
return createPortal(
<div className="fixed inset-0 z-[9999]">
{/* Overlay */}
<div
className={`absolute inset-0 transition-opacity duration-250 ${
visible ? 'opacity-100' : 'opacity-0'
}`}
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={onClose}
/>
{/* Drawer */}
<div
className={`absolute top-0 left-0 h-full transition-transform duration-[250ms] ease-in-out`}
style={{
width: 280,
transform: visible ? 'translateX(0)' : 'translateX(-100%)',
}}
>
<Sidebar activePath={activePath} onNavigate={handleNavigate} />
</div>
</div>,
document.body,
)
}
+123
View File
@@ -0,0 +1,123 @@
import { type ReactNode } from 'react'
import Badge from '../ui/Badge'
interface NavItem {
label: string
icon: string
path: string
badge?: number
}
interface NavSection {
title: string
items: NavItem[]
}
const sections: NavSection[] = [
{
title: '蜂群态势',
items: [
{ label: '蜂群总览', icon: '📊', path: '/dashboard' },
{ label: 'Gateway', icon: '📡', path: '/gateway' },
{ label: '健康分', icon: '🏥', path: '/health' },
{ label: '工蜂列表', icon: '🤖', path: '/workers' },
],
},
{
title: '任务编排',
items: [
{ label: '看板追踪', icon: '📋', path: '/kanban' },
{ label: 'Cron', icon: '⏰', path: '/cron' },
{ label: '事件管理', icon: '🔔', path: '/events' },
],
},
{
title: '通信频道',
items: [{ label: '频道配置', icon: '📶', path: '/channels' }],
},
{
title: '运维工具',
items: [
{ label: '密钥', icon: '🔑', path: '/keys' },
{ label: '文件', icon: '📁', path: '/files' },
{ label: '终端', icon: '💻', path: '/terminal' },
{ label: '日志', icon: '📜', path: '/logs' },
{ label: 'MCP接入', icon: '🔌', path: '/mcp-in' },
{ label: 'MCP对外', icon: '📤', path: '/mcp-out' },
],
},
{
title: '知识库管理',
items: [{ label: '事件档案', icon: '📚', path: '/archive' }],
},
{
title: '系统',
items: [{ label: '设置', icon: '⚙', path: '/settings' }],
},
]
export interface SidebarProps {
activePath: string
onNavigate: (path: string) => void
}
export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
return (
<nav className="flex flex-col h-full overflow-y-auto" style={{ background: 'var(--bg-nav)', borderRight: '1px solid var(--border)' }}>
{/* Logo */}
<div
className="flex items-center gap-2 shrink-0 cursor-pointer px-5"
style={{ height: 'var(--header-h)' }}
onClick={() => onNavigate('/dashboard')}
>
<span style={{ fontSize: 20 }}>🐝</span>
<span style={{ color: 'var(--honey)', fontWeight: 700, fontSize: 15, letterSpacing: 0.3 }}>
知微蜂巢
</span>
</div>
{/* Nav sections */}
<div className="flex-1 overflow-y-auto px-3 pb-4">
{sections.map(section => (
<div key={section.title} className="mt-4">
<div
className="px-2 mb-1 text-[11px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--text-muted)' }}
>
{section.title}
</div>
{section.items.map(item => {
const isActive = activePath === item.path
return (
<div
key={item.path}
className="flex items-center justify-between rounded-[var(--radius-sm)] cursor-pointer px-2 py-[7px] text-[13px] transition-colors duration-100"
style={{
color: isActive ? 'var(--honey)' : 'var(--text-dim)',
fontWeight: isActive ? 600 : 400,
background: isActive ? 'var(--honey-glow)' : undefined,
}}
onMouseEnter={e => {
if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'
}}
onMouseLeave={e => {
if (!isActive) e.currentTarget.style.background = 'transparent'
}}
onClick={() => onNavigate(item.path)}
>
<span>
<span style={{ marginRight: 8 }}>{item.icon}</span>
{item.label}
</span>
{item.badge != null && item.badge > 0 && (
<Badge variant="danger">{item.badge}</Badge>
)}
</div>
)
})}
</div>
))}
</div>
</nav>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { useNavigate } from 'react-router-dom'
const tabs = [
{ label: '总览', icon: '📊', path: '/dashboard' },
{ label: 'Gateway', icon: '📡', path: '/gateway' },
{ label: 'Kanban', icon: '📋', path: '/kanban' },
{ label: 'Cron', icon: '⏰', path: '/cron' },
]
export interface TabBarProps {
activePath: string
}
export default function TabBar({ activePath }: TabBarProps) {
const navigate = useNavigate()
return (
<div
className="fixed bottom-0 left-0 right-0 flex items-center z-50"
style={{
height: 56,
background: 'var(--bg-nav)',
borderTop: '1px solid var(--border)',
}}
>
{tabs.map(tab => {
const isActive = activePath === tab.path
return (
<button
key={tab.path}
onClick={() => navigate(tab.path)}
className="flex-1 flex flex-col items-center justify-center gap-[2px] cursor-pointer bg-transparent border-none transition-colors duration-100"
style={{
color: isActive ? 'var(--honey)' : 'var(--text-dim)',
fontWeight: isActive ? 600 : 400,
}}
>
<span style={{ fontSize: 20 }}>{tab.icon}</span>
<span style={{ fontSize: 11 }}>{tab.label}</span>
</button>
)
})}
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react'
type Theme = 'dark' | 'light' | 'system'
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('hive-theme') as Theme) || 'system'
})
useEffect(() => {
const root = document.documentElement
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
} else {
root.setAttribute('data-theme', theme)
}
localStorage.setItem('hive-theme', theme)
}, [theme])
const toggle = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'))
return { theme, setTheme, toggle }
}
+3
View File
@@ -0,0 +1,3 @@
export default function AuditLogPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>审计日志</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>操作审计记录</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function CronPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Cron</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Cron 任务管理</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function DashboardPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Dashboard 仪表板</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function EventArchivePage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>事件档案</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>事件归档查询</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function GatewayPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Gateway 管理</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function HealthPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>系统健康评分</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function KanbanPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>看板追踪</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Kanban 任务看板</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function KeysPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>密钥管理</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>API 密钥管理</p></div>
}
+15
View File
@@ -0,0 +1,15 @@
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-root)' }}>
<div
className="p-8 rounded-[var(--radius)] w-[360px]"
style={{ background: 'var(--bg-card)', boxShadow: 'var(--shadow-lg)' }}
>
<h1 className="text-center mb-6" style={{ color: 'var(--honey)', fontSize: 20, fontWeight: 700 }}>
🐝 知微蜂巢
</h1>
<p className="text-center" style={{ color: 'var(--text-dim)', fontSize: 13 }}>登录页面(待实现)</p>
</div>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-root)' }}>
<div className="text-center">
<h1 style={{ color: 'var(--text-muted)', fontSize: 48, fontWeight: 800 }}>404</h1>
<p style={{ color: 'var(--text-dim)', marginTop: 8 }}>页面不存在</p>
</div>
</div>
)
}
@@ -0,0 +1,3 @@
export default function UserManagementPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>用户管理</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>用户管理与权限</p></div>
}