Initial commit: TaskPool React panel
- React frontend with route-level code splitting - Backend rebranded from Baihu to TaskPool - DB brand migration script and local compatibility
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { Outlet, useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
FileCode,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
LogOut,
|
||||
Menu,
|
||||
Moon,
|
||||
Network,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
Sun,
|
||||
Terminal,
|
||||
Variable,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from '@/context/ThemeContext'
|
||||
import { api } from '@/api'
|
||||
import { usePublicSite, useSentence } from '@/api/hooks'
|
||||
import { NodeSwitcher } from '@/components/business/NodeSwitcher'
|
||||
import { SystemNotice } from '@/components/business/SystemNotice'
|
||||
import { ToastHost } from '@/components/business/ToastHost'
|
||||
import { resetAuthCache } from '@/lib/auth'
|
||||
import { eventBus } from '@/lib/event-bus'
|
||||
import { Button, cn } from '@/components/ui'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/', icon: LayoutDashboard, label: '数据仪表', exact: true },
|
||||
{ path: '/tasks', icon: ListTodo, label: '定时任务', exact: true },
|
||||
{ path: '/agents', icon: Server, label: '远程执行', exact: true },
|
||||
{ path: '/editor', icon: FileCode, label: '脚本编辑', exact: false },
|
||||
{ path: '/history', icon: ScrollText, label: '执行历史', exact: true },
|
||||
{ path: '/environments', icon: Variable, label: '变量机密', exact: true },
|
||||
{ path: '/languages', icon: Globe, label: '语言依赖', exact: false },
|
||||
{ path: '/terminal', icon: Terminal, label: '终端命令', exact: true },
|
||||
{ path: '/notify', icon: Bell, label: '消息推送', exact: true },
|
||||
{ path: '/logs', icon: KeyRound, label: '运行日志', exact: true },
|
||||
{ path: '/monitor', icon: Activity, label: '系统监控', exact: true },
|
||||
{ path: '/interconnect', icon: Network, label: '互联管理', exact: true },
|
||||
{ path: '/settings', icon: Settings, label: '系统设置', exact: true },
|
||||
] as const
|
||||
|
||||
function isActive(pathname: string, path: string, exact: boolean) {
|
||||
if (exact) return pathname === path
|
||||
return pathname === path || pathname.startsWith(path + '/')
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { data: site } = usePublicSite()
|
||||
const { data: sentenceData } = useSentence()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
const title = site?.title || '任务池'
|
||||
const sentence = sentenceData?.sentence || '欢迎使用任务池'
|
||||
|
||||
useEffect(() => {
|
||||
document.title = title
|
||||
const icon = site?.icon
|
||||
if (icon) {
|
||||
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.href = icon
|
||||
}
|
||||
}, [title, site?.icon])
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 1024)
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setMenuOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
eventBus.init()
|
||||
}, [])
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api.auth.logout()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resetAuthCache()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
||||
{isMobile && menuOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-[1px]" onClick={() => setMenuOpen(false)} />
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 flex w-[200px] shrink-0 flex-col border-r border-[var(--border)] bg-[var(--bg-secondary)] transition-transform duration-200 lg:static lg:translate-x-0',
|
||||
isMobile && !menuOpen ? '-translate-x-full' : 'translate-x-0',
|
||||
)}
|
||||
>
|
||||
<div className="relative flex h-14 items-center gap-2.5 border-b border-[var(--border)] px-4">
|
||||
<div className="ms-on-accent flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md bg-[var(--bg-accent)] text-[13px] font-bold text-[var(--text-on-accent)]">
|
||||
T
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-semibold tracking-tight">{title}</div>
|
||||
<div className="truncate text-[10px] text-[var(--text-muted)]">TaskPool</div>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<button
|
||||
className="rounded-md p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<X size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2.5 py-3">
|
||||
{NAV_ITEMS.map(({ path, icon: Icon, label, exact }) => {
|
||||
const active = isActive(location.pathname, path, exact)
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
onClick={() => navigate({ to: path })}
|
||||
className={cn(
|
||||
'group flex h-9 w-full items-center gap-2.5 rounded-md px-2.5 text-[13px] transition-[background,color,border-color] duration-150',
|
||||
active
|
||||
? 'border border-[var(--border-hover)] bg-[var(--bg-tertiary)] font-medium text-[var(--text-primary)]'
|
||||
: 'border border-transparent text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={15}
|
||||
strokeWidth={1.5}
|
||||
className={cn(active ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)] group-hover:text-[var(--text-primary)]')}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
{active ? <span className="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--bg-accent)]" /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="space-y-1 border-t border-[var(--border)] p-2.5">
|
||||
<Button variant="ghost" className="h-9 w-full justify-start gap-2.5 px-2.5 text-[13px]" onClick={logout}>
|
||||
<LogOut size={15} strokeWidth={1.5} />
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--bg-secondary)_92%,transparent)] px-4 backdrop-blur-md">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{isMobile && (
|
||||
<button
|
||||
className="rounded-md p-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
>
|
||||
<Menu size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
<p className="truncate text-[13px] text-[var(--text-muted)]" title={sentence}>
|
||||
{sentence}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NodeSwitcher />
|
||||
<SystemNotice />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'light' ? '深色模式' : '浅色模式'}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{theme === 'light' ? <Moon size={15} strokeWidth={1.5} /> : <Sun size={15} strokeWidth={1.5} />}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="ms-page flex-1 overflow-y-auto p-4 lg:p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<ToastHost />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user