AgentEvalTool/frontend/web/src/App.tsx
sinohqb 9c564b575e feat(frontend): v0.4 login page, dashboard rebuild, reports UX, keep-alive refresh
- 登录页 + App 鉴权门 + X-Auth-Token 拦截器 + 菜单头部退出按钮
- 仪表盘重构:6 指标卡 / 趋势图 + 场景表现 / 最近记录 + 快捷操作 + 来源分布
- Reports 页重做:场景筛选、富选项下拉、allowClear、一键重置、同场景对比约束
- useOnTabActive:标签页激活自动刷新(根治 AI 助手评测记录"消失")
- ModelConfigs 12 列合并为 6 列;来源 Tag;chunk 告警阈值修正并记录原因
2026-07-28 17:41:08 +08:00

231 lines
7.3 KiB
TypeScript

import { lazy, Suspense, useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { Layout, Menu, Spin, Button, Tooltip } from 'antd'
import type { MenuProps } from 'antd'
import {
DashboardOutlined,
AimOutlined,
FileTextOutlined,
PlayCircleOutlined,
BarChartOutlined,
RobotOutlined,
FolderOpenOutlined,
CloudServerOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import TabBar from './components/TabBar'
import LoginPage from './pages/Login'
import { authApi, authToken, setOnUnauthorized } from './api'
import { useTabStore, type TabItem } from './stores/tabStore'
import { colors } from './tokens'
import type { ReactNode } from 'react'
// Lazy-load pages to split heavy dependencies (@ant-design/charts, Monaco Editor)
// into separate chunks. The keep-alive tab pattern still works — each page is
// loaded on first access and then stays mounted.
const HomePage = lazy(() => import('./pages/Home'))
const TargetsPage = lazy(() => import('./pages/Targets'))
const ScenariosPage = lazy(() => import('./pages/Scenarios'))
const RunsPage = lazy(() => import('./pages/Runs'))
const ReportsPage = lazy(() => import('./pages/Reports'))
const OpenClawPage = lazy(() => import('./pages/OpenClaw'))
const FilesPage = lazy(() => import('./pages/Files'))
const ModelConfigsPage = lazy(() => import('./pages/ModelConfigs'))
function PageLoader({ children }: { children: ReactNode }) {
return (
<Suspense fallback={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin size="large" />
</div>
}>
{children}
</Suspense>
)
}
interface RouteConfig {
path: string
name: string
icon: ReactNode
component: () => ReactNode
}
const routeConfigs: RouteConfig[] = [
{ path: '/', name: '仪表盘', icon: <DashboardOutlined />, component: () => <PageLoader><HomePage /></PageLoader> },
{ path: '/targets', name: '评测对象', icon: <AimOutlined />, component: () => <PageLoader><TargetsPage /></PageLoader> },
{ path: '/models', name: '模型配置', icon: <CloudServerOutlined />, component: () => <PageLoader><ModelConfigsPage /></PageLoader> },
{ path: '/scenarios', name: '评测场景', icon: <FileTextOutlined />, component: () => <PageLoader><ScenariosPage /></PageLoader> },
{ path: '/runs', name: '评测执行', icon: <PlayCircleOutlined />, component: () => <PageLoader><RunsPage /></PageLoader> },
{ path: '/reports', name: '评测报告', icon: <BarChartOutlined />, component: () => <PageLoader><ReportsPage /></PageLoader> },
{ path: '/openclaw', name: 'AI 助手', icon: <RobotOutlined />, component: () => <PageLoader><OpenClawPage /></PageLoader> },
{ path: '/files', name: '原始文件', icon: <FolderOpenOutlined />, component: () => <PageLoader><FilesPage /></PageLoader> },
]
const componentMap: Record<string, () => ReactNode> = {}
routeConfigs.forEach((r) => { componentMap[r.path] = r.component })
const menuItems: MenuProps['items'] = routeConfigs.map((r) => ({
key: r.path,
icon: r.icon,
label: r.name,
}))
type AuthState = 'checking' | 'login' | 'ready'
function App() {
const location = useLocation()
const navigate = useNavigate()
const { tabs, activeKey, openTab, setActiveTab } = useTabStore()
const [authState, setAuthState] = useState<AuthState>('checking')
const [authRequired, setAuthRequired] = useState(false)
useEffect(() => {
setOnUnauthorized(() => setAuthState('login'))
authApi.status()
.then((res) => {
setAuthRequired(res.data.auth_required)
setAuthState(res.data.authenticated ? 'ready' : 'login')
})
.catch(() => setAuthState('ready')) // 后端不可达时不锁死界面,由具体请求报错
return () => setOnUnauthorized(null)
}, [])
const logout = () => {
authToken.clear()
setAuthState('login')
}
useEffect(() => {
const config = routeConfigs.find((r) => r.path === location.pathname)
if (config) {
const tab: TabItem = {
key: config.path,
title: config.name,
icon: config.icon,
closable: config.path !== '/',
}
openTab(tab)
} else {
setActiveTab('/')
navigate('/')
}
}, [location.pathname])
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
const config = routeConfigs.find((r) => r.path === key)
if (!config) return
const tab: TabItem = {
key: config.path,
title: config.name,
icon: config.icon,
closable: config.path !== '/',
}
openTab(tab)
navigate(key)
}
if (authState === 'checking') {
return (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
)
}
if (authState === 'login') {
return <LoginPage onSuccess={() => setAuthState('ready')} />
}
return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
<Layout.Sider
width={220}
style={{
background: colors.sider,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
margin: 0,
padding: 0,
}}
>
<div
style={{
height: 56,
display: 'flex',
alignItems: 'center',
padding: '0 12px 0 20px',
flexShrink: 0,
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}
>
<span style={{ color: '#fff', fontWeight: 700, fontSize: 16, letterSpacing: '0.3px' }}>
AgentEvalTool
</span>
{authRequired && (
<Tooltip title="退出登录" placement="right">
<Button
type="text"
size="small"
icon={<LogoutOutlined />}
onClick={logout}
style={{ color: colors.siderText, marginLeft: 'auto' }}
/>
</Tooltip>
)}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[activeKey]}
items={menuItems}
onClick={handleMenuClick}
style={{
background: 'transparent',
border: 'none',
flex: 1,
overflow: 'auto',
paddingTop: 8,
paddingBottom: 0,
}}
/>
</Layout.Sider>
<Layout style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<TabBar />
<Layout.Content
style={{
flex: 1,
minHeight: 0,
overflow: 'hidden',
background: colors.bgLayout,
position: 'relative',
}}
>
{tabs.map((tab) => {
const Component = componentMap[tab.key]
if (!Component) return null
const isFullBleed = tab.key === '/openclaw'
return (
<div
key={tab.key}
style={{
display: tab.key === activeKey ? 'block' : 'none',
...(isFullBleed
? { position: 'absolute', inset: 0 }
: { height: '100%', overflow: 'hidden' }),
}}
>
<Component />
</div>
)
})}
</Layout.Content>
</Layout>
</Layout>
)
}
export default App