- 登录页 + App 鉴权门 + X-Auth-Token 拦截器 + 菜单头部退出按钮 - 仪表盘重构:6 指标卡 / 趋势图 + 场景表现 / 最近记录 + 快捷操作 + 来源分布 - Reports 页重做:场景筛选、富选项下拉、allowClear、一键重置、同场景对比约束 - useOnTabActive:标签页激活自动刷新(根治 AI 助手评测记录"消失") - ModelConfigs 12 列合并为 6 列;来源 Tag;chunk 告警阈值修正并记录原因
231 lines
7.3 KiB
TypeScript
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
|