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:
sinohqb 2026-07-28 17:41:08 +08:00
parent 739d586aec
commit 9c564b575e
13 changed files with 626 additions and 231 deletions

View File

@ -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",

View File

@ -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": {

View File

@ -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"

View File

@ -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[]
} }

View File

@ -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

View 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])
}

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 260,
}}>
{[record.vendor_name, record.region].filter(Boolean).join(' · ') || record.endpoint_url}
</div>
</Tooltip>
</div>
),
},
{
// 模型名 + 协议
title: '模型 / 协议', key: 'model', width: 200,
render: (_: unknown, record: ModelConfig) => (
<div style={{ minWidth: 0 }}>
<div style={{
fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180,
}}>
{record.model_name || '-'}
</div>
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 3 }}> <div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 3 }}>
{[record.vendor_name, record.region].filter(Boolean).join(' · ')} {protocolOptions[record.provider]?.label || record.provider}
</div>
</div>
),
},
{
// 能力 + 输入/输出模态
title: '能力 / 模态', key: 'capability', width: 200,
render: (_: unknown, record: ModelConfig) => {
const modalityLine = (values: ModelModality[]) =>
values.map((v) => modalityLabels[v]).join('、')
return (
<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>
)
},
},
{
// 上下文/最大输出 + 特性
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>
)} )}
</div> </div>
), )
},
}, },
{ {
title: '协议', dataIndex: 'provider', key: 'provider', width: 150, // 状态 + 凭据 + 更新时间
render: (value: ModelProtocol) => <Tag>{protocolOptions[value]?.label || value}</Tag>, title: '状态', key: 'status', width: 150,
},
{
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>
<Space size={4} wrap> <Space size={4}>
<span style={{ color: '#8c8c8c', fontSize: 12, width: 28 }}></span> {record.enabled ? <Tag color="success" style={{ margin: 0 }}></Tag> : <Tag style={{ margin: 0 }}></Tag>}
{record.input_modalities.map((value) => ( {record.has_api_key
<Tag key={`input-${value}`} color={modalityColors[value]} style={{ margin: 0 }}> ? <Tag color="blue" style={{ margin: 0 }}></Tag>
{modalityLabels[value]} : <Tag style={{ margin: 0 }}></Tag>}
</Tag>
))}
</Space> </Space>
<Space size={4} wrap> <Tooltip title={`更新于 ${formatDateTime(record.updated_at)}`}>
<span style={{ color: '#8c8c8c', fontSize: 12, width: 28 }}></span> <div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 3 }}>
{record.output_modalities.map((value) => ( {formatDateTime(record.updated_at)}
<Tag key={`output-${value}`} color={modalityColors[value]} style={{ margin: 0 }}> </div>
{modalityLabels[value]} </Tooltip>
</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: 'actions', width: 110,
render: (_: unknown, record: ModelConfig) => {
const enabledFeatures = featureLabels.filter(([key]) => record[key])
return enabledFeatures.length ? (
<Space size={[4, 4]} wrap>
{enabledFeatures.map(([key, label]) => <Tag key={key} style={{ margin: 0 }}>{label}</Tag>)}
</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: '状态', dataIndex: 'enabled', key: 'enabled', width: 90,
render: (value: boolean) => value ? <Tag color="success"></Tag> : <Tag></Tag>,
},
{
title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 170,
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>

View File

@ -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 = () => {
setSelectedRunId('')
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, value: r.id,
label: `${r.id.slice(0, 8)}… | ${r.started_at ? formatDateTime(r.started_at) : ''}`, 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,13 +221,15 @@ 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,
}}> }}>
{/* 第一行:视图模式 + 场景筛选 + 重置/刷新 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Segmented <Segmented
value={viewMode} value={viewMode}
onChange={(v) => { setViewMode(v as ViewMode); setCompareResult(null) }} onChange={(v) => { setViewMode(v as ViewMode); setCompareResult(null) }}
@ -159,36 +238,63 @@ export default function ReportsPage() {
{ label: '对比报告', value: 'compare', icon: <DiffOutlined /> }, { label: '对比报告', value: 'compare', icon: <DiffOutlined /> },
]} ]}
/> />
<Select <Select
style={{ width: 340 }} 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>
{/* 第二行:报告选择 + 操作 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Select
style={{ flex: 1, minWidth: 320, maxWidth: 680 }}
placeholder={viewMode === 'compare' ? '选择报告 A' : '选择已完成的评测记录'} placeholder={viewMode === 'compare' ? '选择报告 A' : '选择已完成的评测记录'}
value={selectedRunId || undefined} value={selectedRunId || undefined}
onChange={handleView} onChange={(v) => (v ? handleView(v) : clearSelection())}
allowClear
showSearch showSearch
filterOption={(input, opt) => filterOption={optionFilter}
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())}
options={runSelectOptions} 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>
<Tooltip title={selectedRunId ? '仅可选择与报告 A 相同场景的记录' : '请先选择报告 A'}>
<Select <Select
style={{ width: 340 }} style={{ flex: 1, minWidth: 320, maxWidth: 680 }}
placeholder="选择报告 B" placeholder="选择报告 B(同场景)"
value={compareRunId || undefined} value={compareRunId || undefined}
onChange={setCompareRunId} onChange={(v) => { setCompareRunId(v ?? ''); if (!v) setCompareResult(null) }}
allowClear
showSearch showSearch
filterOption={(input, opt) => disabled={!selectedRunId}
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())} filterOption={optionFilter}
options={runSelectOptions.filter((o) => o.value !== selectedRunId)} options={compareOptions}
popupMatchSelectWidth={false}
notFoundContent={<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有同场景的其他评测记录" />}
/> />
</Tooltip>
<Button <Button
type="primary" type="primary"
icon={<DiffOutlined />} icon={<DiffOutlined />}
disabled={!selectedRunId || !compareRunId} disabled={!selectedRunId || !compareRunId}
onClick={handleCompare} onClick={handleCompare}
style={{ flexShrink: 0 }}
> >
</Button> </Button>
@ -196,7 +302,7 @@ export default function ReportsPage() {
)} )}
{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>
@ -205,11 +311,6 @@ export default function ReportsPage() {
</Button> </Button>
</Space> </Space>
)} )}
<div style={{ marginLeft: 'auto' }}>
<Tooltip title="刷新列表">
<Button size="middle" icon={<ReloadOutlined />} onClick={loadRuns} />
</Tooltip>
</div> </div>
</div> </div>

View File

@ -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(() => {

View File

@ -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,

View File

@ -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'
} }