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 告警阈值修正并记录原因
This commit is contained in:
parent
739d586aec
commit
9c564b575e
4
frontend/web/package-lock.json
generated
4
frontend/web/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenteval-web",
|
"name": "agenteval-web",
|
||||||
"version": "0.3.0-dev",
|
"version": "0.4.0-dev",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenteval-web",
|
"name": "agenteval-web",
|
||||||
"version": "0.3.0-dev",
|
"version": "0.4.0-dev",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/charts": "^2.6.7",
|
"@ant-design/charts": "^2.6.7",
|
||||||
"@ant-design/icons": "^6.3.2",
|
"@ant-design/icons": "^6.3.2",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenteval-web",
|
"name": "agenteval-web",
|
||||||
"version": "0.3.0-dev",
|
"version": "0.4.0-dev",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { lazy, Suspense, useEffect } from 'react'
|
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Layout, Menu, Spin } from 'antd'
|
import { Layout, Menu, Spin, Button, Tooltip } from 'antd'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
@ -11,8 +11,11 @@ import {
|
|||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
FolderOpenOutlined,
|
FolderOpenOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import TabBar from './components/TabBar'
|
import TabBar from './components/TabBar'
|
||||||
|
import LoginPage from './pages/Login'
|
||||||
|
import { authApi, authToken, setOnUnauthorized } from './api'
|
||||||
import { useTabStore, type TabItem } from './stores/tabStore'
|
import { useTabStore, type TabItem } from './stores/tabStore'
|
||||||
import { colors } from './tokens'
|
import { colors } from './tokens'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
@ -68,10 +71,30 @@ const menuItems: MenuProps['items'] = routeConfigs.map((r) => ({
|
|||||||
label: r.name,
|
label: r.name,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
type AuthState = 'checking' | 'login' | 'ready'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { tabs, activeKey, openTab, setActiveTab } = useTabStore()
|
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(() => {
|
useEffect(() => {
|
||||||
const config = routeConfigs.find((r) => r.path === location.pathname)
|
const config = routeConfigs.find((r) => r.path === location.pathname)
|
||||||
@ -102,6 +125,18 @@ function App() {
|
|||||||
navigate(key)
|
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 (
|
return (
|
||||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
<Layout.Sider
|
<Layout.Sider
|
||||||
@ -120,7 +155,7 @@ function App() {
|
|||||||
height: 56,
|
height: 56,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '0 20px',
|
padding: '0 12px 0 20px',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||||
}}
|
}}
|
||||||
@ -128,6 +163,17 @@ function App() {
|
|||||||
<span style={{ color: '#fff', fontWeight: 700, fontSize: 16, letterSpacing: '0.3px' }}>
|
<span style={{ color: '#fff', fontWeight: 700, fontSize: 16, letterSpacing: '0.3px' }}>
|
||||||
AgentEvalTool
|
AgentEvalTool
|
||||||
</span>
|
</span>
|
||||||
|
{authRequired && (
|
||||||
|
<Tooltip title="退出登录" placement="right">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<LogoutOutlined />}
|
||||||
|
onClick={logout}
|
||||||
|
style={{ color: colors.siderText, marginLeft: 'auto' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Menu
|
<Menu
|
||||||
theme="dark"
|
theme="dark"
|
||||||
|
|||||||
@ -1,14 +1,38 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { message } from 'antd'
|
import { message } from 'antd'
|
||||||
|
|
||||||
|
const AUTH_TOKEN_KEY = 'agenteval_auth_token'
|
||||||
|
|
||||||
|
export const authToken = {
|
||||||
|
get: () => sessionStorage.getItem(AUTH_TOKEN_KEY),
|
||||||
|
set: (token: string) => sessionStorage.setItem(AUTH_TOKEN_KEY, token),
|
||||||
|
clear: () => sessionStorage.removeItem(AUTH_TOKEN_KEY),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registered by App to switch back to the login screen on 401. */
|
||||||
|
let onUnauthorized: (() => void) | null = null
|
||||||
|
export const setOnUnauthorized = (handler: (() => void) | null) => { onUnauthorized = handler }
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = authToken.get()
|
||||||
|
if (token) config.headers['X-Auth-Token'] = token
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
|
const isLoginCall = error.config?.url?.includes('/auth/login')
|
||||||
|
if (error.response?.status === 401 && !isLoginCall && onUnauthorized) {
|
||||||
|
authToken.clear()
|
||||||
|
onUnauthorized()
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
const msg = error.response?.data?.detail || error.message || '请求失败'
|
const msg = error.response?.data?.detail || error.message || '请求失败'
|
||||||
message.error(msg)
|
message.error(msg)
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
@ -17,6 +41,16 @@ api.interceptors.response.use(
|
|||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|
||||||
|
export interface AuthStatus {
|
||||||
|
auth_required: boolean
|
||||||
|
authenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
status: () => api.get<AuthStatus>('/auth/status'),
|
||||||
|
login: (password: string) => api.post<{ token: string }>('/auth/login', { password }),
|
||||||
|
}
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -100,21 +134,39 @@ export interface ModelConfigReference {
|
|||||||
purpose: string
|
purpose: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RunTrigger = 'manual' | 'ai_assistant' | 'cli'
|
||||||
|
|
||||||
export interface Run {
|
export interface Run {
|
||||||
id: string
|
id: string
|
||||||
target_id: string
|
target_id: string
|
||||||
scenario_id: string
|
scenario_id: string
|
||||||
status: string
|
status: string
|
||||||
|
triggered_by?: RunTrigger
|
||||||
|
scenario_name?: string | null
|
||||||
|
target_name?: string | null
|
||||||
started_at: string
|
started_at: string
|
||||||
completed_at: string | null
|
completed_at: string | null
|
||||||
summary: Record<string, unknown> | null
|
summary: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScenarioStat {
|
||||||
|
scenario_id: string
|
||||||
|
scenario_name: string
|
||||||
|
run_count: number
|
||||||
|
avg_pass_rate: number | null
|
||||||
|
last_run_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
targets_count: number
|
targets_count: number
|
||||||
scenarios_count: number
|
scenarios_count: number
|
||||||
runs_count: number
|
runs_count: number
|
||||||
|
model_configs_count: number
|
||||||
|
today_runs: number
|
||||||
|
running_count: number
|
||||||
overall_pass_rate: number | null
|
overall_pass_rate: number | null
|
||||||
|
trigger_breakdown: Partial<Record<RunTrigger, number>>
|
||||||
|
scenario_stats: ScenarioStat[]
|
||||||
recent_runs: Run[]
|
recent_runs: Run[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Empty, Input, Pagination, Progress, Segmented, Skeleton, Tooltip } from 'antd'
|
import { Empty, Input, Pagination, Progress, Segmented, Skeleton, Tag, Tooltip } from 'antd'
|
||||||
import { FileTextOutlined, SearchOutlined } from '@ant-design/icons'
|
import { FileTextOutlined, SearchOutlined } from '@ant-design/icons'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import type { Run, Scenario, Target } from '../api'
|
import type { Run, Scenario, Target } from '../api'
|
||||||
import type { RunProgress } from '../hooks/useRunSession'
|
import type { RunProgress } from '../hooks/useRunSession'
|
||||||
import { colors, statusColors, statusLabels } from '../tokens'
|
import { colors, statusColors, statusLabels, triggerColors, triggerLabels } from '../tokens'
|
||||||
import { elapsedStr, shortDateTime, toDate } from '../utils/date'
|
import { elapsedStr, shortDateTime, toDate } from '../utils/date'
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 10
|
||||||
@ -292,6 +292,14 @@ function RunRow({ r, selected, targetName, scenarioName, onSelect, onOpenReport,
|
|||||||
<span>{shortDateTime(r.started_at)}</span>
|
<span>{shortDateTime(r.started_at)}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{elapsedStr(r.started_at, r.completed_at)}</span>
|
<span>{elapsedStr(r.started_at, r.completed_at)}</span>
|
||||||
|
{r.triggered_by && r.triggered_by !== 'manual' && (
|
||||||
|
<Tag
|
||||||
|
color={triggerColors[r.triggered_by] ?? 'default'}
|
||||||
|
style={{ marginLeft: 2, marginRight: 0, fontSize: 10, lineHeight: '16px', padding: '0 4px' }}
|
||||||
|
>
|
||||||
|
{triggerLabels[r.triggered_by] ?? r.triggered_by}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
{onOpenReport && status === 'completed' && (
|
{onOpenReport && status === 'completed' && (
|
||||||
<Tooltip title="查看报告">
|
<Tooltip title="查看报告">
|
||||||
<FileTextOutlined
|
<FileTextOutlined
|
||||||
|
|||||||
23
frontend/web/src/hooks/useOnTabActive.ts
Normal file
23
frontend/web/src/hooks/useOnTabActive.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useTabStore } from '../stores/tabStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep-alive tabs never remount, so "load on mount" data goes stale.
|
||||||
|
* Fires `callback` every time the given tab path becomes active again
|
||||||
|
* (skips the initial mount, which pages already handle themselves).
|
||||||
|
*/
|
||||||
|
export function useOnTabActive(path: string, callback: () => void) {
|
||||||
|
const activeKey = useTabStore((s) => s.activeKey)
|
||||||
|
const first = useRef(true)
|
||||||
|
const cbRef = useRef(callback)
|
||||||
|
cbRef.current = callback
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeKey !== path) return
|
||||||
|
if (first.current) {
|
||||||
|
first.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cbRef.current()
|
||||||
|
}, [activeKey, path])
|
||||||
|
}
|
||||||
@ -1,56 +1,39 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Button, Col, Row, Table, Tag, Spin, Card, Tooltip } from 'antd'
|
import { Button, Card, Col, Empty, Progress, Row, Spin, Tag, Tooltip } from 'antd'
|
||||||
import { Line } from '@ant-design/charts'
|
import { Line } from '@ant-design/charts'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
AimOutlined,
|
AimOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
CheckCircleOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
SyncOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { runsApi, scenariosApi, targetsApi, type Run, type TrendPoint } from '../api'
|
import { statsApi, type DashboardStats, type Run, type TrendPoint } from '../api'
|
||||||
import PageWrapper from '../components/PageWrapper'
|
import PageWrapper from '../components/PageWrapper'
|
||||||
import StatCard from '../components/StatCard'
|
import StatCard from '../components/StatCard'
|
||||||
|
import { useOnTabActive } from '../hooks/useOnTabActive'
|
||||||
|
import { colors, statusColors, statusLabels, triggerColors, triggerLabels } from '../tokens'
|
||||||
import { formatDateTime } from '../utils/date'
|
import { formatDateTime } from '../utils/date'
|
||||||
|
import { passRateColor } from '../utils/colors'
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [counts, setCounts] = useState({ targets: 0, scenarios: 0, runs: 0 })
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
const [recentRuns, setRecentRuns] = useState<Run[]>([])
|
|
||||||
const [trend, setTrend] = useState<TrendPoint[]>([])
|
const [trend, setTrend] = useState<TrendPoint[]>([])
|
||||||
const [passRate, setPassRate] = useState<number | null>(null)
|
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [t, s, r] = await Promise.all([
|
const [d, t] = await Promise.all([statsApi.dashboard(), statsApi.trend(30)])
|
||||||
targetsApi.list(),
|
setStats(d.data)
|
||||||
scenariosApi.list(),
|
setTrend(t.data)
|
||||||
runsApi.list(),
|
|
||||||
])
|
|
||||||
const runs = r.data
|
|
||||||
setCounts({
|
|
||||||
targets: t.data.length,
|
|
||||||
scenarios: s.data.length,
|
|
||||||
runs: runs.length,
|
|
||||||
})
|
|
||||||
setRecentRuns(runs.slice(-10).reverse())
|
|
||||||
|
|
||||||
const completedRuns = runs.filter((run) => run.status === 'completed' && run.summary)
|
|
||||||
if (completedRuns.length > 0) {
|
|
||||||
const totalRate = completedRuns.reduce(
|
|
||||||
(sum, run) => sum + ((run.summary as any)?.pass_rate || 0),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
setPassRate(totalRate / completedRuns.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
const trendData: TrendPoint[] = completedRuns.map((run) => ({
|
|
||||||
date: run.started_at?.split('T')[0] || '',
|
|
||||||
pass_rate: ((run.summary as any)?.pass_rate || 0) * 100,
|
|
||||||
run_count: 1,
|
|
||||||
}))
|
|
||||||
setTrend(trendData)
|
|
||||||
} catch {
|
} catch {
|
||||||
// errors handled by interceptor
|
// errors handled by interceptor
|
||||||
} finally {
|
} finally {
|
||||||
@ -59,39 +42,25 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { loadData() }, [])
|
useEffect(() => { loadData() }, [])
|
||||||
|
useOnTabActive('/', loadData)
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
const passRate = stats?.overall_pass_rate ?? null
|
||||||
completed: 'success',
|
|
||||||
running: 'processing',
|
|
||||||
pending: 'default',
|
|
||||||
failed: 'error',
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: 'Run ID', dataIndex: 'id', key: 'id', ellipsis: true, width: 240 },
|
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100,
|
|
||||||
render: (s: string) => <Tag color={statusColor[s] || 'default'}>{s}</Tag>,
|
|
||||||
},
|
|
||||||
{ title: '开始时间', dataIndex: 'started_at', key: 'started_at', width: 180,
|
|
||||||
render: (t: string) => t ? formatDateTime(t) : '-',
|
|
||||||
},
|
|
||||||
{ title: '通过率', key: 'pass_rate', width: 100,
|
|
||||||
render: (_: any, record: Run) => {
|
|
||||||
const rate = (record.summary as any)?.pass_rate
|
|
||||||
return rate != null ? `${(rate * 100).toFixed(0)}%` : '-'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const trendConfig = {
|
const trendConfig = {
|
||||||
data: trend,
|
data: trend,
|
||||||
xField: 'date',
|
xField: 'date',
|
||||||
yField: 'pass_rate',
|
yField: 'pass_rate',
|
||||||
smooth: true,
|
smooth: true,
|
||||||
height: 200,
|
height: 240,
|
||||||
yAxis: { label: { formatter: (v: string) => `${v}%` }, min: 0, max: 100 },
|
yAxis: { label: { formatter: (v: string) => `${v}%` }, min: 0, max: 100 },
|
||||||
point: { size: 3 },
|
point: { size: 3 },
|
||||||
color: '#1677ff',
|
color: '#1677ff',
|
||||||
|
tooltip: {
|
||||||
|
formatter: (datum: TrendPoint) => ({
|
||||||
|
name: '通过率',
|
||||||
|
value: `${datum.pass_rate}%(${datum.run_count ?? '-'} 次执行)`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -106,58 +75,163 @@ export default function HomePage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* 内容区 — 可滚动 */}
|
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
|
||||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 16px 16px' }}>
|
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
|
{/* 指标卡行 */}
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={12} sm={8} xl={4}>
|
||||||
<StatCard icon={<AimOutlined />} color="blue" title="评测对象" value={counts.targets} />
|
<StatCard icon={<AimOutlined />} color="blue" title="评测对象" value={stats?.targets_count ?? 0} />
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={12} sm={8} xl={4}>
|
||||||
<StatCard icon={<FileTextOutlined />} color="green" title="评测场景" value={counts.scenarios} />
|
<StatCard icon={<FileTextOutlined />} color="green" title="评测场景" value={stats?.scenarios_count ?? 0} />
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={12} sm={8} xl={4}>
|
||||||
<StatCard icon={<PlayCircleOutlined />} color="orange" title="评测执行" value={counts.runs} />
|
<StatCard icon={<CloudServerOutlined />} color="purple" title="模型配置" value={stats?.model_configs_count ?? 0} />
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={12} sm={8} xl={4}>
|
||||||
|
<StatCard
|
||||||
|
icon={<PlayCircleOutlined />} color="orange" title="累计执行"
|
||||||
|
value={stats?.runs_count ?? 0}
|
||||||
|
suffix={stats?.today_runs ? ` / 今日 ${stats.today_runs}` : undefined}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8} xl={4}>
|
||||||
|
<StatCard
|
||||||
|
icon={<SyncOutlined spin={(stats?.running_count ?? 0) > 0} />}
|
||||||
|
color={(stats?.running_count ?? 0) > 0 ? 'blue' : 'green'}
|
||||||
|
title="运行中"
|
||||||
|
value={stats?.running_count ?? 0}
|
||||||
|
valueStyle={(stats?.running_count ?? 0) > 0 ? { color: '#1677ff' } : undefined}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8} xl={4}>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<CheckCircleOutlined />}
|
icon={<CheckCircleOutlined />}
|
||||||
color="purple"
|
color={passRate != null && passRate >= 0.8 ? 'green' : 'red'}
|
||||||
title="平均通过率"
|
title="平均通过率"
|
||||||
value={passRate != null ? passRate * 100 : 0}
|
value={passRate != null ? passRate * 100 : '-'}
|
||||||
suffix="%"
|
suffix={passRate != null ? '%' : undefined}
|
||||||
precision={1}
|
precision={1}
|
||||||
valueStyle={{ color: passRate != null && passRate >= 0.8 ? '#52c41a' : '#ff4d4f' }}
|
valueStyle={{ color: passRate != null ? passRateColor(passRate) : colors.textMuted }}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* 图表行 */}
|
||||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||||
<Col xs={24} lg={14}>
|
<Col xs={24} lg={14}>
|
||||||
<Card title="最近评测记录">
|
<Card title="通过率趋势(近 30 天)" size="small" styles={{ body: { minHeight: 260 } }}>
|
||||||
<Table
|
|
||||||
dataSource={recentRuns}
|
|
||||||
columns={columns}
|
|
||||||
rowKey="id"
|
|
||||||
size="small"
|
|
||||||
pagination={false}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} lg={10}>
|
|
||||||
<Card title="通过率趋势">
|
|
||||||
{trend.length > 0 ? (
|
{trend.length > 0 ? (
|
||||||
<Line {...trendConfig} />
|
<Line {...trendConfig} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ height: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#9ca3af' }}>
|
<Empty style={{ paddingTop: 60 }} description="暂无趋势数据" />
|
||||||
暂无趋势数据
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card title="场景表现" size="small" styles={{ body: { minHeight: 260, overflowY: 'auto', maxHeight: 300 } }}>
|
||||||
|
{stats?.scenario_stats.length ? stats.scenario_stats.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.scenario_id}
|
||||||
|
onClick={() => navigate('/reports')}
|
||||||
|
style={{ padding: '8px 4px', cursor: 'pointer', borderBottom: `1px solid ${colors.border}` }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500 }}>{s.scenario_name}</span>
|
||||||
|
<span style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||||
|
{s.run_count} 次 · {s.last_run_at ? formatDateTime(s.last_run_at) : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={s.avg_pass_rate != null ? Math.round(s.avg_pass_rate * 100) : 0}
|
||||||
|
size="small"
|
||||||
|
strokeColor={s.avg_pass_rate != null ? passRateColor(s.avg_pass_rate) : colors.textMuted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)) : <Empty style={{ paddingTop: 60 }} description="暂无已完成的评测" />}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 动态行 */}
|
||||||
|
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<Card title="最近评测记录" size="small">
|
||||||
|
{stats?.recent_runs.length ? stats.recent_runs.map((r) => (
|
||||||
|
<RecentRunRow key={r.id} run={r} onOpen={() => navigate(r.status === 'completed' ? `/reports?run=${r.id}` : '/runs')} />
|
||||||
|
)) : <Empty description="还没有评测记录" />}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card title="快捷操作" size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||||
|
<Button icon={<PlayCircleOutlined />} onClick={() => navigate('/runs')}>发起评测</Button>
|
||||||
|
<Button icon={<PlusOutlined />} onClick={() => navigate('/scenarios')}>新建场景</Button>
|
||||||
|
<Button icon={<RobotOutlined />} onClick={() => navigate('/openclaw')}>AI 助手</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card title="触发来源分布" size="small">
|
||||||
|
{stats && Object.keys(stats.trigger_breakdown).length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{(Object.entries(stats.trigger_breakdown) as Array<[string, number]>).map(([k, count]) => (
|
||||||
|
<div key={k} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Tag color={triggerColors[k] ?? 'default'} style={{ margin: 0, width: 72, textAlign: 'center' }}>
|
||||||
|
{triggerLabels[k] ?? k}
|
||||||
|
</Tag>
|
||||||
|
<Progress
|
||||||
|
percent={stats.runs_count ? Math.round((count / stats.runs_count) * 100) : 0}
|
||||||
|
size="small"
|
||||||
|
style={{ flex: 1, margin: 0 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: colors.textSecondary, width: 40, textAlign: 'right' }}>{count} 次</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Spin>
|
</Spin>
|
||||||
</div>
|
</div>
|
||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RecentRunRow({ run, onOpen }: { run: Run; onOpen: () => void }) {
|
||||||
|
const rate = (run.summary as Record<string, unknown>)?.pass_rate as number | undefined
|
||||||
|
const dotColor = statusColors[run.status] ?? colors.textMuted
|
||||||
|
const trigger = run.triggered_by ?? 'manual'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="run-row"
|
||||||
|
onClick={onOpen}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
padding: '8px 4px', cursor: 'pointer',
|
||||||
|
borderBottom: `1px solid ${colors.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: dotColor, flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 13 }}>
|
||||||
|
<span style={{ fontWeight: 500 }}>{run.scenario_name || run.scenario_id.slice(0, 8)}</span>
|
||||||
|
<span style={{ color: colors.textMuted }}> · {run.target_name || run.target_id.slice(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
{trigger !== 'manual' && (
|
||||||
|
<Tag color={triggerColors[trigger] ?? 'default'} style={{ margin: 0, fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
|
||||||
|
{triggerLabels[trigger] ?? trigger}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 12, color: dotColor, width: 48, flexShrink: 0 }}>{statusLabels[run.status] ?? run.status}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, fontWeight: 600, width: 44, textAlign: 'right', flexShrink: 0,
|
||||||
|
color: rate != null ? passRateColor(rate) : colors.textMuted,
|
||||||
|
}}>
|
||||||
|
{rate != null ? `${Math.round(rate * 100)}%` : '-'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: colors.textMuted, width: 130, textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
{run.started_at ? formatDateTime(run.started_at) : '-'}
|
||||||
|
</span>
|
||||||
|
<BarChartOutlined style={{ color: colors.textMuted }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
52
frontend/web/src/pages/Login.tsx
Normal file
52
frontend/web/src/pages/Login.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Button, Card, Form, Input, message } from 'antd'
|
||||||
|
import { LockOutlined } from '@ant-design/icons'
|
||||||
|
import { authApi, authToken } from '../api'
|
||||||
|
import { colors } from '../tokens'
|
||||||
|
|
||||||
|
export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const submit = async ({ password }: { password: string }) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await authApi.login(password)
|
||||||
|
authToken.set(res.data.token)
|
||||||
|
message.success('登录成功')
|
||||||
|
onSuccess()
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 axios 拦截器统一弹出(如"密码错误")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: `linear-gradient(160deg, ${colors.sider} 0%, #2e3250 100%)`,
|
||||||
|
}}>
|
||||||
|
<Card style={{ width: 360, boxShadow: '0 8px 32px rgba(0,0,0,0.35)' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, color: colors.text }}>AgentEvalTool</div>
|
||||||
|
<div style={{ fontSize: 13, color: colors.textSecondary, marginTop: 4 }}>
|
||||||
|
智能体质量评估平台
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Form onFinish={submit} autoComplete="off">
|
||||||
|
<Form.Item name="password" rules={[{ required: true, message: '请输入访问密码' }]}>
|
||||||
|
<Input.Password
|
||||||
|
size="large"
|
||||||
|
prefix={<LockOutlined style={{ color: colors.textMuted }} />}
|
||||||
|
placeholder="访问密码"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -33,13 +33,6 @@ const modalityLabels: Record<ModelModality, string> = {
|
|||||||
video: '视频',
|
video: '视频',
|
||||||
}
|
}
|
||||||
|
|
||||||
const modalityColors: Record<ModelModality, string> = {
|
|
||||||
text: 'default',
|
|
||||||
image: 'cyan',
|
|
||||||
audio: 'magenta',
|
|
||||||
video: 'purple',
|
|
||||||
}
|
|
||||||
|
|
||||||
const modalityOptions = Object.entries(modalityLabels).map(([value, label]) => ({ value, label }))
|
const modalityOptions = Object.entries(modalityLabels).map(([value, label]) => ({ value, label }))
|
||||||
|
|
||||||
const featureLabels: Array<[keyof Pick<
|
const featureLabels: Array<[keyof Pick<
|
||||||
@ -54,6 +47,13 @@ const featureLabels: Array<[keyof Pick<
|
|||||||
|
|
||||||
const formatTokens = (value: number | null) => value ? new Intl.NumberFormat('zh-CN').format(value) : '-'
|
const formatTokens = (value: number | null) => value ? new Intl.NumberFormat('zh-CN').format(value) : '-'
|
||||||
|
|
||||||
|
const compactTokens = (value: number | null) => {
|
||||||
|
if (!value) return '-'
|
||||||
|
if (value >= 1000000 && value % 100000 === 0) return `${value / 1000000}M`
|
||||||
|
if (value >= 1000) return `${Math.round(value / 1000)}K`
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
const protocolOptions: Record<ModelProtocol, {
|
const protocolOptions: Record<ModelProtocol, {
|
||||||
label: string
|
label: string
|
||||||
capabilities: ModelCapability[]
|
capabilities: ModelCapability[]
|
||||||
@ -207,7 +207,8 @@ export default function ModelConfigsPage() {
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '配置名称', dataIndex: 'name', key: 'name', width: 220,
|
// 配置名称 + 默认/厂商/区域 + Endpoint 摘要
|
||||||
|
title: '配置名称', dataIndex: 'name', key: 'name',
|
||||||
render: (value: string, record: ModelConfig) => (
|
render: (value: string, record: ModelConfig) => (
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<Space size={6}>
|
<Space size={6}>
|
||||||
@ -216,84 +217,96 @@ export default function ModelConfigsPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
{record.is_default && <Tag color="gold" style={{ margin: 0 }}>默认</Tag>}
|
{record.is_default && <Tag color="gold" style={{ margin: 0 }}>默认</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
{(record.vendor_name || record.region) && (
|
<Tooltip title={record.endpoint_url}>
|
||||||
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 3 }}>
|
<div style={{
|
||||||
{[record.vendor_name, record.region].filter(Boolean).join(' · ')}
|
color: '#8c8c8c', fontSize: 12, marginTop: 3,
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 260,
|
||||||
|
}}>
|
||||||
|
{[record.vendor_name, record.region].filter(Boolean).join(' · ') || record.endpoint_url}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '协议', dataIndex: 'provider', key: 'provider', width: 150,
|
// 模型名 + 协议
|
||||||
render: (value: ModelProtocol) => <Tag>{protocolOptions[value]?.label || value}</Tag>,
|
title: '模型 / 协议', key: 'model', width: 200,
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '能力', dataIndex: 'capability', key: 'capability', width: 110,
|
|
||||||
render: (value: ModelCapability) => <Tag color={capabilityColors[value]}>{capabilityLabels[value]}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '输入 / 输出', key: 'modalities', width: 230,
|
|
||||||
render: (_: unknown, record: ModelConfig) => (
|
render: (_: unknown, record: ModelConfig) => (
|
||||||
<Space direction="vertical" size={3}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<Space size={4} wrap>
|
<div style={{
|
||||||
<span style={{ color: '#8c8c8c', fontSize: 12, width: 28 }}>输入</span>
|
fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180,
|
||||||
{record.input_modalities.map((value) => (
|
}}>
|
||||||
<Tag key={`input-${value}`} color={modalityColors[value]} style={{ margin: 0 }}>
|
{record.model_name || '-'}
|
||||||
{modalityLabels[value]}
|
</div>
|
||||||
</Tag>
|
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 3 }}>
|
||||||
))}
|
{protocolOptions[record.provider]?.label || record.provider}
|
||||||
</Space>
|
</div>
|
||||||
<Space size={4} wrap>
|
|
||||||
<span style={{ color: '#8c8c8c', fontSize: 12, width: 28 }}>输出</span>
|
|
||||||
{record.output_modalities.map((value) => (
|
|
||||||
<Tag key={`output-${value}`} color={modalityColors[value]} style={{ margin: 0 }}>
|
|
||||||
{modalityLabels[value]}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '模型', dataIndex: 'model_name', key: 'model_name', width: 180, ellipsis: true,
|
|
||||||
render: (value: string | null) => value || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '规格', key: 'limits', width: 170,
|
|
||||||
render: (_: unknown, record: ModelConfig) => (
|
|
||||||
<div style={{ fontSize: 12, lineHeight: 1.7 }}>
|
|
||||||
<div><span style={{ color: '#8c8c8c' }}>上下文 </span>{formatTokens(record.context_window)}</div>
|
|
||||||
<div><span style={{ color: '#8c8c8c' }}>最大输出 </span>{formatTokens(record.max_output_tokens)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '特性', key: 'features', width: 210,
|
// 能力 + 输入/输出模态
|
||||||
|
title: '能力 / 模态', key: 'capability', width: 200,
|
||||||
render: (_: unknown, record: ModelConfig) => {
|
render: (_: unknown, record: ModelConfig) => {
|
||||||
const enabledFeatures = featureLabels.filter(([key]) => record[key])
|
const modalityLine = (values: ModelModality[]) =>
|
||||||
return enabledFeatures.length ? (
|
values.map((v) => modalityLabels[v]).join('、')
|
||||||
<Space size={[4, 4]} wrap>
|
return (
|
||||||
{enabledFeatures.map(([key, label]) => <Tag key={key} style={{ margin: 0 }}>{label}</Tag>)}
|
<Space size={6} align="start">
|
||||||
|
<Tag color={capabilityColors[record.capability]} style={{ margin: 0 }}>
|
||||||
|
{capabilityLabels[record.capability]}
|
||||||
|
</Tag>
|
||||||
|
<span style={{ color: '#8c8c8c', fontSize: 12, lineHeight: '22px' }}>
|
||||||
|
{modalityLine(record.input_modalities)} → {modalityLine(record.output_modalities)}
|
||||||
|
</span>
|
||||||
</Space>
|
</Space>
|
||||||
) : '-'
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: 'Endpoint', dataIndex: 'endpoint_url', key: 'endpoint_url', width: 260, ellipsis: true },
|
|
||||||
{
|
{
|
||||||
title: '凭据', dataIndex: 'has_api_key', key: 'has_api_key', width: 90,
|
// 上下文/最大输出 + 特性
|
||||||
render: (value: boolean) => value ? <Tag color="success">已配置</Tag> : <Tag>无</Tag>,
|
title: '规格 / 特性', key: 'spec', width: 210,
|
||||||
|
render: (_: unknown, record: ModelConfig) => {
|
||||||
|
const enabledFeatures = featureLabels.filter(([key]) => record[key])
|
||||||
|
return (
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
<Tooltip title={`上下文 ${formatTokens(record.context_window)} / 最大输出 ${formatTokens(record.max_output_tokens)}`}>
|
||||||
|
<span>
|
||||||
|
<span style={{ color: '#8c8c8c' }}>上下文 </span>{compactTokens(record.context_window)}
|
||||||
|
<span style={{ color: '#8c8c8c' }}> 输出 </span>{compactTokens(record.max_output_tokens)}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
{enabledFeatures.length > 0 && (
|
||||||
|
<div style={{ marginTop: 4, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{enabledFeatures.map(([key, label]) => (
|
||||||
|
<Tag key={key} style={{ margin: 0, fontSize: 11, lineHeight: '18px', padding: '0 5px' }}>{label}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态', dataIndex: 'enabled', key: 'enabled', width: 90,
|
// 状态 + 凭据 + 更新时间
|
||||||
render: (value: boolean) => value ? <Tag color="success">启用</Tag> : <Tag>停用</Tag>,
|
title: '状态', key: 'status', width: 150,
|
||||||
|
render: (_: unknown, record: ModelConfig) => (
|
||||||
|
<div>
|
||||||
|
<Space size={4}>
|
||||||
|
{record.enabled ? <Tag color="success" style={{ margin: 0 }}>启用</Tag> : <Tag style={{ margin: 0 }}>停用</Tag>}
|
||||||
|
{record.has_api_key
|
||||||
|
? <Tag color="blue" style={{ margin: 0 }}>有凭据</Tag>
|
||||||
|
: <Tag style={{ margin: 0 }}>无凭据</Tag>}
|
||||||
|
</Space>
|
||||||
|
<Tooltip title={`更新于 ${formatDateTime(record.updated_at)}`}>
|
||||||
|
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 3 }}>
|
||||||
|
{formatDateTime(record.updated_at)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 170,
|
title: '操作', key: 'actions', width: 110,
|
||||||
render: (value: string | null) => formatDateTime(value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作', key: 'actions', width: 145, fixed: 'right' as const,
|
|
||||||
render: (_: unknown, record: ModelConfig) => (
|
render: (_: unknown, record: ModelConfig) => (
|
||||||
<Space size={2}>
|
<Space size={2}>
|
||||||
<Tooltip title="连接测试">
|
<Tooltip title="连接测试">
|
||||||
@ -355,8 +368,9 @@ export default function ModelConfigsPage() {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{ pageSize: 15, showSizeChanger: true, showTotal: (total) => `共 ${total} 个` }}
|
pagination={{ pageSize: 15, showSizeChanger: true, showTotal: (total) => `共 ${total} 个` }}
|
||||||
scroll={{ x: 1800, y: 'calc(100vh - 200px)' }}
|
scroll={{ y: 'calc(100vh - 200px)' }}
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
|
tableLayout="auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Button, Card, Col, Collapse, Descriptions, Empty, Row, Segmented,
|
Button, Card, Col, Collapse, Descriptions, Empty, Row, Segmented,
|
||||||
Select, Space, Spin, Statistic, Table, Tag, Tooltip, Badge,
|
Select, Space, Spin, Statistic, Table, Tag, Tooltip, Badge, message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined, CloseCircleOutlined,
|
CheckCircleOutlined, CloseCircleOutlined, ClearOutlined,
|
||||||
DownloadOutlined, UserOutlined, RobotOutlined, ReloadOutlined,
|
DownloadOutlined, UserOutlined, RobotOutlined, ReloadOutlined,
|
||||||
DiffOutlined, FileMarkdownOutlined,
|
DiffOutlined, FileMarkdownOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { reportsApi, runsApi, type Run } from '../api'
|
import { reportsApi, runsApi, type Run } from '../api'
|
||||||
import { colors } from '../tokens'
|
import { colors, triggerColors, triggerLabels } from '../tokens'
|
||||||
import { formatDateTime } from '../utils/date'
|
import { formatDateTime } from '../utils/date'
|
||||||
|
import { useOnTabActive } from '../hooks/useOnTabActive'
|
||||||
|
|
||||||
interface TurnData {
|
interface TurnData {
|
||||||
sent_text: string
|
sent_text: string
|
||||||
@ -72,6 +73,7 @@ export default function ReportsPage() {
|
|||||||
const runQuery = searchParams.get('run') ?? ''
|
const runQuery = searchParams.get('run') ?? ''
|
||||||
|
|
||||||
const [runs, setRuns] = useState<Run[]>([])
|
const [runs, setRuns] = useState<Run[]>([])
|
||||||
|
const [scenarioFilter, setScenarioFilter] = useState<string>('')
|
||||||
const [selectedRunId, setSelectedRunId] = useState<string>('')
|
const [selectedRunId, setSelectedRunId] = useState<string>('')
|
||||||
const [compareRunId, setCompareRunId] = useState<string>('')
|
const [compareRunId, setCompareRunId] = useState<string>('')
|
||||||
const [report, setReport] = useState<Report | null>(null)
|
const [report, setReport] = useState<Report | null>(null)
|
||||||
@ -93,8 +95,8 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
const loadRuns = async () => {
|
const loadRuns = async () => {
|
||||||
const res = await runsApi.list()
|
const res = await runsApi.list()
|
||||||
const completed = res.data.filter((r) => r.status === 'completed').reverse()
|
// API 已按 started_at DESC 排序,最新的在前
|
||||||
setRuns(completed)
|
setRuns(res.data.filter((r) => r.status === 'completed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -104,6 +106,10 @@ export default function ReportsPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Keep-alive tabs never remount: refresh whenever this tab is re-activated
|
||||||
|
// so runs triggered elsewhere (e.g. AI assistant) show up.
|
||||||
|
useOnTabActive('/reports', loadRuns)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (runQuery && runQuery !== selectedRunId) {
|
if (runQuery && runQuery !== selectedRunId) {
|
||||||
loadReport(runQuery)
|
loadReport(runQuery)
|
||||||
@ -114,6 +120,10 @@ export default function ReportsPage() {
|
|||||||
const handleView = (runId: string) => {
|
const handleView = (runId: string) => {
|
||||||
setSearchParams({ run: runId })
|
setSearchParams({ run: runId })
|
||||||
loadReport(runId)
|
loadReport(runId)
|
||||||
|
// 对比报告要求同场景:A 变更后若 B 场景不同则清空
|
||||||
|
const a = runs.find((r) => r.id === runId)
|
||||||
|
const b = runs.find((r) => r.id === compareRunId)
|
||||||
|
if (a && b && a.scenario_id !== b.scenario_id) setCompareRunId('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCompare = async () => {
|
const handleCompare = async () => {
|
||||||
@ -122,15 +132,82 @@ export default function ReportsPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await reportsApi.compare(selectedRunId, compareRunId)
|
const res = await reportsApi.compare(selectedRunId, compareRunId)
|
||||||
setCompareResult(res.data as CompareResult)
|
setCompareResult(res.data as CompareResult)
|
||||||
|
} catch (e: any) {
|
||||||
|
const detail = e?.response?.data?.detail
|
||||||
|
if (detail) message.error(detail)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const runSelectOptions = runs.map((r) => ({
|
const clearSelection = () => {
|
||||||
value: r.id,
|
setSelectedRunId('')
|
||||||
label: `${r.id.slice(0, 8)}… | ${r.started_at ? formatDateTime(r.started_at) : ''}`,
|
setCompareRunId('')
|
||||||
}))
|
setReport(null)
|
||||||
|
setCompareResult(null)
|
||||||
|
setSearchParams({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setScenarioFilter('')
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景筛选项:从 run 列表聚合(含各场景 run 数量)
|
||||||
|
const scenarioOptions = useMemo(() => {
|
||||||
|
const map = new Map<string, { name: string; count: number }>()
|
||||||
|
for (const r of runs) {
|
||||||
|
const entry = map.get(r.scenario_id)
|
||||||
|
if (entry) entry.count += 1
|
||||||
|
else map.set(r.scenario_id, { name: r.scenario_name || r.scenario_id.slice(0, 8), count: 1 })
|
||||||
|
}
|
||||||
|
return [...map.entries()].map(([id, s]) => ({ value: id, label: `${s.name}(${s.count})` }))
|
||||||
|
}, [runs])
|
||||||
|
|
||||||
|
const filteredRuns = useMemo(
|
||||||
|
() => (scenarioFilter ? runs.filter((r) => r.scenario_id === scenarioFilter) : runs),
|
||||||
|
[runs, scenarioFilter],
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildOption = (r: Run) => {
|
||||||
|
const passRate = (r.summary as Record<string, unknown>)?.pass_rate as number | undefined
|
||||||
|
const pct = passRate != null ? `${Math.round(passRate * 100)}%` : '-'
|
||||||
|
const scenario = r.scenario_name || r.scenario_id.slice(0, 8)
|
||||||
|
const target = r.target_name || r.target_id.slice(0, 8)
|
||||||
|
const time = r.started_at ? formatDateTime(r.started_at) : ''
|
||||||
|
const trigger = r.triggered_by ?? 'manual'
|
||||||
|
return {
|
||||||
|
value: r.id,
|
||||||
|
searchText: `${scenario} ${target} ${time} ${r.id} ${triggerLabels[trigger] ?? trigger}`.toLowerCase(),
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis' }}>{scenario}</span>
|
||||||
|
<span style={{ color: colors.textMuted }}>· {target}</span>
|
||||||
|
<span style={{ color: colors.textMuted, fontSize: 12 }}>{time}</span>
|
||||||
|
<span style={{
|
||||||
|
color: passRate != null && passRate >= 0.8 ? '#3f8600' : '#cf1322',
|
||||||
|
fontSize: 12, fontWeight: 600,
|
||||||
|
}}>{pct}</span>
|
||||||
|
{trigger !== 'manual' && (
|
||||||
|
<Tag color={triggerColors[trigger] ?? 'default'} style={{ marginRight: 0, fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
|
||||||
|
{triggerLabels[trigger] ?? trigger}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSelectOptions = filteredRuns.map(buildOption)
|
||||||
|
|
||||||
|
// 报告 B 只能选与报告 A 同场景的 run
|
||||||
|
const selectedRun = runs.find((r) => r.id === selectedRunId)
|
||||||
|
const compareOptions = runs
|
||||||
|
.filter((r) => r.id !== selectedRunId && selectedRun && r.scenario_id === selectedRun.scenario_id)
|
||||||
|
.map(buildOption)
|
||||||
|
|
||||||
|
const optionFilter = (input: string, opt?: { searchText?: string }) =>
|
||||||
|
(opt?.searchText ?? '').includes(input.toLowerCase())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
@ -144,72 +221,96 @@ export default function ReportsPage() {
|
|||||||
<span style={{ fontSize: 13, color: colors.textSecondary }}>查看评测结果详情与统计分析</span>
|
<span style={{ fontSize: 13, color: colors.textSecondary }}>查看评测结果详情与统计分析</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 选择栏 */}
|
{/* 选择栏:两行布局,避免对比模式下控件换行错乱 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '8px 16px', flexShrink: 0,
|
padding: '8px 16px', flexShrink: 0,
|
||||||
borderBottom: `1px solid ${colors.border}`,
|
borderBottom: `1px solid ${colors.border}`,
|
||||||
background: colors.bgSubtle,
|
background: colors.bgSubtle,
|
||||||
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
|
display: 'flex', flexDirection: 'column', gap: 8,
|
||||||
}}>
|
}}>
|
||||||
<Segmented
|
{/* 第一行:视图模式 + 场景筛选 + 重置/刷新 */}
|
||||||
value={viewMode}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
onChange={(v) => { setViewMode(v as ViewMode); setCompareResult(null) }}
|
<Segmented
|
||||||
options={[
|
value={viewMode}
|
||||||
{ label: '单次报告', value: 'single' },
|
onChange={(v) => { setViewMode(v as ViewMode); setCompareResult(null) }}
|
||||||
{ label: '对比报告', value: 'compare', icon: <DiffOutlined /> },
|
options={[
|
||||||
]}
|
{ label: '单次报告', value: 'single' },
|
||||||
/>
|
{ label: '对比报告', value: 'compare', icon: <DiffOutlined /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
style={{ width: 320 }}
|
||||||
|
value={scenarioFilter || undefined}
|
||||||
|
placeholder={`全部场景(${runs.length})`}
|
||||||
|
allowClear
|
||||||
|
onChange={(v) => setScenarioFilter(v ?? '')}
|
||||||
|
options={scenarioOptions}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
/>
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
||||||
|
<Tooltip title="重置筛选与选择">
|
||||||
|
<Button size="middle" icon={<ClearOutlined />} onClick={resetFilters}>重置</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="刷新列表">
|
||||||
|
<Button size="middle" icon={<ReloadOutlined />} onClick={loadRuns} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Select
|
{/* 第二行:报告选择 + 操作 */}
|
||||||
style={{ width: 340 }}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
placeholder={viewMode === 'compare' ? '选择报告 A' : '选择已完成的评测记录'}
|
<Select
|
||||||
value={selectedRunId || undefined}
|
style={{ flex: 1, minWidth: 320, maxWidth: 680 }}
|
||||||
onChange={handleView}
|
placeholder={viewMode === 'compare' ? '选择报告 A' : '选择已完成的评测记录'}
|
||||||
showSearch
|
value={selectedRunId || undefined}
|
||||||
filterOption={(input, opt) =>
|
onChange={(v) => (v ? handleView(v) : clearSelection())}
|
||||||
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())}
|
allowClear
|
||||||
options={runSelectOptions}
|
showSearch
|
||||||
/>
|
filterOption={optionFilter}
|
||||||
|
options={runSelectOptions}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
/>
|
||||||
|
|
||||||
{viewMode === 'compare' && (
|
{viewMode === 'compare' && (
|
||||||
<>
|
<>
|
||||||
<span style={{ color: colors.textMuted, fontSize: 12 }}>vs</span>
|
<span style={{ color: colors.textMuted, fontSize: 12, flexShrink: 0 }}>vs</span>
|
||||||
<Select
|
<Tooltip title={selectedRunId ? '仅可选择与报告 A 相同场景的记录' : '请先选择报告 A'}>
|
||||||
style={{ width: 340 }}
|
<Select
|
||||||
placeholder="选择报告 B"
|
style={{ flex: 1, minWidth: 320, maxWidth: 680 }}
|
||||||
value={compareRunId || undefined}
|
placeholder="选择报告 B(同场景)"
|
||||||
onChange={setCompareRunId}
|
value={compareRunId || undefined}
|
||||||
showSearch
|
onChange={(v) => { setCompareRunId(v ?? ''); if (!v) setCompareResult(null) }}
|
||||||
filterOption={(input, opt) =>
|
allowClear
|
||||||
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())}
|
showSearch
|
||||||
options={runSelectOptions.filter((o) => o.value !== selectedRunId)}
|
disabled={!selectedRunId}
|
||||||
/>
|
filterOption={optionFilter}
|
||||||
<Button
|
options={compareOptions}
|
||||||
type="primary"
|
popupMatchSelectWidth={false}
|
||||||
icon={<DiffOutlined />}
|
notFoundContent={<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有同场景的其他评测记录" />}
|
||||||
disabled={!selectedRunId || !compareRunId}
|
/>
|
||||||
onClick={handleCompare}
|
</Tooltip>
|
||||||
>
|
<Button
|
||||||
对比
|
type="primary"
|
||||||
</Button>
|
icon={<DiffOutlined />}
|
||||||
</>
|
disabled={!selectedRunId || !compareRunId}
|
||||||
)}
|
onClick={handleCompare}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
对比
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{viewMode === 'single' && report && (
|
{viewMode === 'single' && report && (
|
||||||
<Space>
|
<Space style={{ flexShrink: 0 }}>
|
||||||
<Button icon={<DownloadOutlined />} onClick={() => window.open(`/api/reports/${selectedRunId}/html`, '_blank')}>
|
<Button icon={<DownloadOutlined />} onClick={() => window.open(`/api/reports/${selectedRunId}/html`, '_blank')}>
|
||||||
导出 HTML
|
导出 HTML
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<FileMarkdownOutlined />} onClick={() => window.open(reportsApi.markdownUrl(selectedRunId), '_blank')}>
|
<Button icon={<FileMarkdownOutlined />} onClick={() => window.open(reportsApi.markdownUrl(selectedRunId), '_blank')}>
|
||||||
导出 MD
|
导出 MD
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginLeft: 'auto' }}>
|
|
||||||
<Tooltip title="刷新列表">
|
|
||||||
<Button size="middle" icon={<ReloadOutlined />} onClick={loadRuns} />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import RuleOverview from '../components/RuleOverview'
|
|||||||
import CaseDetail from '../components/CaseDetail'
|
import CaseDetail from '../components/CaseDetail'
|
||||||
import { useRunSession } from '../hooks/useRunSession'
|
import { useRunSession } from '../hooks/useRunSession'
|
||||||
import { useTicker } from '../hooks/useTicker'
|
import { useTicker } from '../hooks/useTicker'
|
||||||
|
import { useOnTabActive } from '../hooks/useOnTabActive'
|
||||||
import { colors, statusColors, statusLabels } from '../tokens'
|
import { colors, statusColors, statusLabels } from '../tokens'
|
||||||
import { formatDateTime, elapsedStr } from '../utils/date'
|
import { formatDateTime, elapsedStr } from '../utils/date'
|
||||||
import { passRateColor } from '../utils/colors'
|
import { passRateColor } from '../utils/colors'
|
||||||
@ -52,6 +53,10 @@ export default function RunsPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadLists() }, [])
|
useEffect(() => { loadLists() }, [])
|
||||||
|
|
||||||
|
// Keep-alive tabs never remount: refresh the list whenever the tab is
|
||||||
|
// re-activated so runs triggered elsewhere (e.g. AI assistant) show up.
|
||||||
|
useOnTabActive('/runs', loadLists)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session.isLive) return
|
if (!session.isLive) return
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
|
|||||||
@ -43,6 +43,18 @@ export const statusLabels: Record<string, string> = {
|
|||||||
cancelled: '已取消',
|
cancelled: '已取消',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const triggerLabels: Record<string, string> = {
|
||||||
|
manual: '手动',
|
||||||
|
ai_assistant: 'AI 助手',
|
||||||
|
cli: 'CLI',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const triggerColors: Record<string, string> = {
|
||||||
|
manual: 'default',
|
||||||
|
ai_assistant: 'purple',
|
||||||
|
cli: 'blue',
|
||||||
|
}
|
||||||
|
|
||||||
export const spacing = {
|
export const spacing = {
|
||||||
xs: 4,
|
xs: 4,
|
||||||
sm: 8,
|
sm: 8,
|
||||||
|
|||||||
@ -22,6 +22,14 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
// Accepted oversized chunks (measured 2026-07-28):
|
||||||
|
// vendor ~2.12 MB min / ~650 kB gzip — react + @ant-design/charts
|
||||||
|
// (charts CANNOT be split out: forced separate chunk creates
|
||||||
|
// a circular reference and a runtime TDZ crash, and charts
|
||||||
|
// loads on the default Home route anyway)
|
||||||
|
// vendor-antd ~620 kB min / ~173 kB gzip
|
||||||
|
// Warning fires only above this limit, i.e. on a real size regression.
|
||||||
|
chunkSizeWarningLimit: 2200,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
@ -37,8 +45,8 @@ export default defineConfig({
|
|||||||
// NOTE: do NOT split @ant-design/charts into its own chunk — its deep
|
// NOTE: do NOT split @ant-design/charts into its own chunk — its deep
|
||||||
// @antv dependency graph creates a circular chunk reference that causes
|
// @antv dependency graph creates a circular chunk reference that causes
|
||||||
// a runtime TDZ crash ("Cannot access 'd' before initialization").
|
// a runtime TDZ crash ("Cannot access 'd' before initialization").
|
||||||
// charts is loaded on the Home page (default route) anyway, so a
|
// Returning undefined for @antv doesn't help either: rollup still
|
||||||
// separate chunk buys almost nothing.
|
// merges it into vendor (verified 2026-07-28).
|
||||||
if (id.includes('node_modules')) {
|
if (id.includes('node_modules')) {
|
||||||
return 'vendor'
|
return 'vendor'
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user