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",
|
||||
"version": "0.3.0-dev",
|
||||
"version": "0.4.0-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenteval-web",
|
||||
"version": "0.3.0-dev",
|
||||
"version": "0.4.0-dev",
|
||||
"dependencies": {
|
||||
"@ant-design/charts": "^2.6.7",
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenteval-web",
|
||||
"version": "0.3.0-dev",
|
||||
"version": "0.4.0-dev",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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 { Layout, Menu, Spin } from 'antd'
|
||||
import { Layout, Menu, Spin, Button, Tooltip } from 'antd'
|
||||
import type { MenuProps } from 'antd'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
@ -11,8 +11,11 @@ import {
|
||||
RobotOutlined,
|
||||
FolderOpenOutlined,
|
||||
CloudServerOutlined,
|
||||
LogoutOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import TabBar from './components/TabBar'
|
||||
import LoginPage from './pages/Login'
|
||||
import { authApi, authToken, setOnUnauthorized } from './api'
|
||||
import { useTabStore, type TabItem } from './stores/tabStore'
|
||||
import { colors } from './tokens'
|
||||
import type { ReactNode } from 'react'
|
||||
@ -68,10 +71,30 @@ const menuItems: MenuProps['items'] = routeConfigs.map((r) => ({
|
||||
label: r.name,
|
||||
}))
|
||||
|
||||
type AuthState = 'checking' | 'login' | 'ready'
|
||||
|
||||
function App() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { tabs, activeKey, openTab, setActiveTab } = useTabStore()
|
||||
const [authState, setAuthState] = useState<AuthState>('checking')
|
||||
const [authRequired, setAuthRequired] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setOnUnauthorized(() => setAuthState('login'))
|
||||
authApi.status()
|
||||
.then((res) => {
|
||||
setAuthRequired(res.data.auth_required)
|
||||
setAuthState(res.data.authenticated ? 'ready' : 'login')
|
||||
})
|
||||
.catch(() => setAuthState('ready')) // 后端不可达时不锁死界面,由具体请求报错
|
||||
return () => setOnUnauthorized(null)
|
||||
}, [])
|
||||
|
||||
const logout = () => {
|
||||
authToken.clear()
|
||||
setAuthState('login')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const config = routeConfigs.find((r) => r.path === location.pathname)
|
||||
@ -102,6 +125,18 @@ function App() {
|
||||
navigate(key)
|
||||
}
|
||||
|
||||
if (authState === 'checking') {
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (authState === 'login') {
|
||||
return <LoginPage onSuccess={() => setAuthState('ready')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||
<Layout.Sider
|
||||
@ -120,7 +155,7 @@ function App() {
|
||||
height: 56,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 20px',
|
||||
padding: '0 12px 0 20px',
|
||||
flexShrink: 0,
|
||||
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' }}>
|
||||
AgentEvalTool
|
||||
</span>
|
||||
{authRequired && (
|
||||
<Tooltip title="退出登录" placement="right">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={logout}
|
||||
style={{ color: colors.siderText, marginLeft: 'auto' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
|
||||
@ -1,14 +1,38 @@
|
||||
import axios from 'axios'
|
||||
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({
|
||||
baseURL: '/api',
|
||||
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(
|
||||
(response) => response,
|
||||
(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 || '请求失败'
|
||||
message.error(msg)
|
||||
return Promise.reject(error)
|
||||
@ -17,6 +41,16 @@ api.interceptors.response.use(
|
||||
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
@ -100,21 +134,39 @@ export interface ModelConfigReference {
|
||||
purpose: string
|
||||
}
|
||||
|
||||
export type RunTrigger = 'manual' | 'ai_assistant' | 'cli'
|
||||
|
||||
export interface Run {
|
||||
id: string
|
||||
target_id: string
|
||||
scenario_id: string
|
||||
status: string
|
||||
triggered_by?: RunTrigger
|
||||
scenario_name?: string | null
|
||||
target_name?: string | null
|
||||
started_at: string
|
||||
completed_at: string | 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 {
|
||||
targets_count: number
|
||||
scenarios_count: number
|
||||
runs_count: number
|
||||
model_configs_count: number
|
||||
today_runs: number
|
||||
running_count: number
|
||||
overall_pass_rate: number | null
|
||||
trigger_breakdown: Partial<Record<RunTrigger, number>>
|
||||
scenario_stats: ScenarioStat[]
|
||||
recent_runs: Run[]
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
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 dayjs from 'dayjs'
|
||||
import type { Run, Scenario, Target } from '../api'
|
||||
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'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
@ -292,6 +292,14 @@ function RunRow({ r, selected, targetName, scenarioName, onSelect, onOpenReport,
|
||||
<span>{shortDateTime(r.started_at)}</span>
|
||||
<span>·</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' && (
|
||||
<Tooltip title="查看报告">
|
||||
<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 { 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 { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
AimOutlined,
|
||||
BarChartOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloudServerOutlined,
|
||||
FileTextOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
RobotOutlined,
|
||||
SyncOutlined,
|
||||
} 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 StatCard from '../components/StatCard'
|
||||
import { useOnTabActive } from '../hooks/useOnTabActive'
|
||||
import { colors, statusColors, statusLabels, triggerColors, triggerLabels } from '../tokens'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
import { passRateColor } from '../utils/colors'
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [counts, setCounts] = useState({ targets: 0, scenarios: 0, runs: 0 })
|
||||
const [recentRuns, setRecentRuns] = useState<Run[]>([])
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [trend, setTrend] = useState<TrendPoint[]>([])
|
||||
const [passRate, setPassRate] = useState<number | null>(null)
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [t, s, r] = await Promise.all([
|
||||
targetsApi.list(),
|
||||
scenariosApi.list(),
|
||||
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)
|
||||
const [d, t] = await Promise.all([statsApi.dashboard(), statsApi.trend(30)])
|
||||
setStats(d.data)
|
||||
setTrend(t.data)
|
||||
} catch {
|
||||
// errors handled by interceptor
|
||||
} finally {
|
||||
@ -59,39 +42,25 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
useOnTabActive('/', loadData)
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
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 passRate = stats?.overall_pass_rate ?? null
|
||||
|
||||
const trendConfig = {
|
||||
data: trend,
|
||||
xField: 'date',
|
||||
yField: 'pass_rate',
|
||||
smooth: true,
|
||||
height: 200,
|
||||
height: 240,
|
||||
yAxis: { label: { formatter: (v: string) => `${v}%` }, min: 0, max: 100 },
|
||||
point: { size: 3 },
|
||||
color: '#1677ff',
|
||||
tooltip: {
|
||||
formatter: (datum: TrendPoint) => ({
|
||||
name: '通过率',
|
||||
value: `${datum.pass_rate}%(${datum.run_count ?? '-'} 次执行)`,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
@ -106,58 +75,163 @@ export default function HomePage() {
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{/* 内容区 — 可滚动 */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 16px 16px' }}>
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
|
||||
<Spin spinning={loading}>
|
||||
{/* 指标卡行 */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatCard icon={<AimOutlined />} color="blue" title="评测对象" value={counts.targets} />
|
||||
<Col xs={12} sm={8} xl={4}>
|
||||
<StatCard icon={<AimOutlined />} color="blue" title="评测对象" value={stats?.targets_count ?? 0} />
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatCard icon={<FileTextOutlined />} color="green" title="评测场景" value={counts.scenarios} />
|
||||
<Col xs={12} sm={8} xl={4}>
|
||||
<StatCard icon={<FileTextOutlined />} color="green" title="评测场景" value={stats?.scenarios_count ?? 0} />
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatCard icon={<PlayCircleOutlined />} color="orange" title="评测执行" value={counts.runs} />
|
||||
<Col xs={12} sm={8} xl={4}>
|
||||
<StatCard icon={<CloudServerOutlined />} color="purple" title="模型配置" value={stats?.model_configs_count ?? 0} />
|
||||
</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
|
||||
icon={<CheckCircleOutlined />}
|
||||
color="purple"
|
||||
color={passRate != null && passRate >= 0.8 ? 'green' : 'red'}
|
||||
title="平均通过率"
|
||||
value={passRate != null ? passRate * 100 : 0}
|
||||
suffix="%"
|
||||
value={passRate != null ? passRate * 100 : '-'}
|
||||
suffix={passRate != null ? '%' : undefined}
|
||||
precision={1}
|
||||
valueStyle={{ color: passRate != null && passRate >= 0.8 ? '#52c41a' : '#ff4d4f' }}
|
||||
valueStyle={{ color: passRate != null ? passRateColor(passRate) : colors.textMuted }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 图表行 */}
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card title="最近评测记录">
|
||||
<Table
|
||||
dataSource={recentRuns}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card title="通过率趋势">
|
||||
<Card title="通过率趋势(近 30 天)" size="small" styles={{ body: { minHeight: 260 } }}>
|
||||
{trend.length > 0 ? (
|
||||
<Line {...trendConfig} />
|
||||
) : (
|
||||
<div style={{ height: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#9ca3af' }}>
|
||||
暂无趋势数据
|
||||
</div>
|
||||
<Empty style={{ paddingTop: 60 }} description="暂无趋势数据" />
|
||||
)}
|
||||
</Card>
|
||||
</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>
|
||||
</Spin>
|
||||
</div>
|
||||
</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: '视频',
|
||||
}
|
||||
|
||||
const modalityColors: Record<ModelModality, string> = {
|
||||
text: 'default',
|
||||
image: 'cyan',
|
||||
audio: 'magenta',
|
||||
video: 'purple',
|
||||
}
|
||||
|
||||
const modalityOptions = Object.entries(modalityLabels).map(([value, label]) => ({ value, label }))
|
||||
|
||||
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 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, {
|
||||
label: string
|
||||
capabilities: ModelCapability[]
|
||||
@ -207,7 +207,8 @@ export default function ModelConfigsPage() {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '配置名称', dataIndex: 'name', key: 'name', width: 220,
|
||||
// 配置名称 + 默认/厂商/区域 + Endpoint 摘要
|
||||
title: '配置名称', dataIndex: 'name', key: 'name',
|
||||
render: (value: string, record: ModelConfig) => (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Space size={6}>
|
||||
@ -216,84 +217,96 @@ export default function ModelConfigsPage() {
|
||||
</Tooltip>
|
||||
{record.is_default && <Tag color="gold" style={{ margin: 0 }}>默认</Tag>}
|
||||
</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 }}>
|
||||
{[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>
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '协议', dataIndex: 'provider', key: 'provider', width: 150,
|
||||
render: (value: ModelProtocol) => <Tag>{protocolOptions[value]?.label || value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '能力', dataIndex: 'capability', key: 'capability', width: 110,
|
||||
render: (value: ModelCapability) => <Tag color={capabilityColors[value]}>{capabilityLabels[value]}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '输入 / 输出', key: 'modalities', width: 230,
|
||||
// 状态 + 凭据 + 更新时间
|
||||
title: '状态', key: 'status', width: 150,
|
||||
render: (_: unknown, record: ModelConfig) => (
|
||||
<Space direction="vertical" size={3}>
|
||||
<Space size={4} wrap>
|
||||
<span style={{ color: '#8c8c8c', fontSize: 12, width: 28 }}>输入</span>
|
||||
{record.input_modalities.map((value) => (
|
||||
<Tag key={`input-${value}`} color={modalityColors[value]} style={{ margin: 0 }}>
|
||||
{modalityLabels[value]}
|
||||
</Tag>
|
||||
))}
|
||||
<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>
|
||||
<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>
|
||||
<Tooltip title={`更新于 ${formatDateTime(record.updated_at)}`}>
|
||||
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 3 }}>
|
||||
{formatDateTime(record.updated_at)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '特性', key: 'features', width: 210,
|
||||
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,
|
||||
title: '操作', key: 'actions', width: 110,
|
||||
render: (_: unknown, record: ModelConfig) => (
|
||||
<Space size={2}>
|
||||
<Tooltip title="连接测试">
|
||||
@ -355,8 +368,9 @@ export default function ModelConfigsPage() {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 15, showSizeChanger: true, showTotal: (total) => `共 ${total} 个` }}
|
||||
scroll={{ x: 1800, y: 'calc(100vh - 200px)' }}
|
||||
scroll={{ y: 'calc(100vh - 200px)' }}
|
||||
style={{ height: '100%' }}
|
||||
tableLayout="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
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'
|
||||
import {
|
||||
CheckCircleOutlined, CloseCircleOutlined,
|
||||
CheckCircleOutlined, CloseCircleOutlined, ClearOutlined,
|
||||
DownloadOutlined, UserOutlined, RobotOutlined, ReloadOutlined,
|
||||
DiffOutlined, FileMarkdownOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { reportsApi, runsApi, type Run } from '../api'
|
||||
import { colors } from '../tokens'
|
||||
import { colors, triggerColors, triggerLabels } from '../tokens'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
import { useOnTabActive } from '../hooks/useOnTabActive'
|
||||
|
||||
interface TurnData {
|
||||
sent_text: string
|
||||
@ -72,6 +73,7 @@ export default function ReportsPage() {
|
||||
const runQuery = searchParams.get('run') ?? ''
|
||||
|
||||
const [runs, setRuns] = useState<Run[]>([])
|
||||
const [scenarioFilter, setScenarioFilter] = useState<string>('')
|
||||
const [selectedRunId, setSelectedRunId] = useState<string>('')
|
||||
const [compareRunId, setCompareRunId] = useState<string>('')
|
||||
const [report, setReport] = useState<Report | null>(null)
|
||||
@ -93,8 +95,8 @@ export default function ReportsPage() {
|
||||
|
||||
const loadRuns = async () => {
|
||||
const res = await runsApi.list()
|
||||
const completed = res.data.filter((r) => r.status === 'completed').reverse()
|
||||
setRuns(completed)
|
||||
// API 已按 started_at DESC 排序,最新的在前
|
||||
setRuns(res.data.filter((r) => r.status === 'completed'))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -104,6 +106,10 @@ export default function ReportsPage() {
|
||||
// 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(() => {
|
||||
if (runQuery && runQuery !== selectedRunId) {
|
||||
loadReport(runQuery)
|
||||
@ -114,6 +120,10 @@ export default function ReportsPage() {
|
||||
const handleView = (runId: string) => {
|
||||
setSearchParams({ run: 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 () => {
|
||||
@ -122,15 +132,82 @@ export default function ReportsPage() {
|
||||
try {
|
||||
const res = await reportsApi.compare(selectedRunId, compareRunId)
|
||||
setCompareResult(res.data as CompareResult)
|
||||
} catch (e: any) {
|
||||
const detail = e?.response?.data?.detail
|
||||
if (detail) message.error(detail)
|
||||
} finally {
|
||||
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,
|
||||
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 (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 选择栏 */}
|
||||
{/* 选择栏:两行布局,避免对比模式下控件换行错乱 */}
|
||||
<div style={{
|
||||
padding: '8px 16px', flexShrink: 0,
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
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
|
||||
value={viewMode}
|
||||
onChange={(v) => { setViewMode(v as ViewMode); setCompareResult(null) }}
|
||||
@ -159,36 +238,63 @@ export default function ReportsPage() {
|
||||
{ label: '对比报告', value: 'compare', icon: <DiffOutlined /> },
|
||||
]}
|
||||
/>
|
||||
|
||||
<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' : '选择已完成的评测记录'}
|
||||
value={selectedRunId || undefined}
|
||||
onChange={handleView}
|
||||
onChange={(v) => (v ? handleView(v) : clearSelection())}
|
||||
allowClear
|
||||
showSearch
|
||||
filterOption={(input, opt) =>
|
||||
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())}
|
||||
filterOption={optionFilter}
|
||||
options={runSelectOptions}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
|
||||
{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
|
||||
style={{ width: 340 }}
|
||||
placeholder="选择报告 B"
|
||||
style={{ flex: 1, minWidth: 320, maxWidth: 680 }}
|
||||
placeholder="选择报告 B(同场景)"
|
||||
value={compareRunId || undefined}
|
||||
onChange={setCompareRunId}
|
||||
onChange={(v) => { setCompareRunId(v ?? ''); if (!v) setCompareResult(null) }}
|
||||
allowClear
|
||||
showSearch
|
||||
filterOption={(input, opt) =>
|
||||
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())}
|
||||
options={runSelectOptions.filter((o) => o.value !== selectedRunId)}
|
||||
disabled={!selectedRunId}
|
||||
filterOption={optionFilter}
|
||||
options={compareOptions}
|
||||
popupMatchSelectWidth={false}
|
||||
notFoundContent={<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有同场景的其他评测记录" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DiffOutlined />}
|
||||
disabled={!selectedRunId || !compareRunId}
|
||||
onClick={handleCompare}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
对比
|
||||
</Button>
|
||||
@ -196,7 +302,7 @@ export default function ReportsPage() {
|
||||
)}
|
||||
|
||||
{viewMode === 'single' && report && (
|
||||
<Space>
|
||||
<Space style={{ flexShrink: 0 }}>
|
||||
<Button icon={<DownloadOutlined />} onClick={() => window.open(`/api/reports/${selectedRunId}/html`, '_blank')}>
|
||||
导出 HTML
|
||||
</Button>
|
||||
@ -205,11 +311,6 @@ export default function ReportsPage() {
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Tooltip title="刷新列表">
|
||||
<Button size="middle" icon={<ReloadOutlined />} onClick={loadRuns} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import RuleOverview from '../components/RuleOverview'
|
||||
import CaseDetail from '../components/CaseDetail'
|
||||
import { useRunSession } from '../hooks/useRunSession'
|
||||
import { useTicker } from '../hooks/useTicker'
|
||||
import { useOnTabActive } from '../hooks/useOnTabActive'
|
||||
import { colors, statusColors, statusLabels } from '../tokens'
|
||||
import { formatDateTime, elapsedStr } from '../utils/date'
|
||||
import { passRateColor } from '../utils/colors'
|
||||
@ -52,6 +53,10 @@ export default function RunsPage() {
|
||||
|
||||
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(() => {
|
||||
if (!session.isLive) return
|
||||
const id = window.setInterval(() => {
|
||||
|
||||
@ -43,6 +43,18 @@ export const statusLabels: Record<string, string> = {
|
||||
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 = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
|
||||
@ -22,6 +22,14 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
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: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
@ -37,8 +45,8 @@ export default defineConfig({
|
||||
// NOTE: do NOT split @ant-design/charts into its own chunk — its deep
|
||||
// @antv dependency graph creates a circular chunk reference that causes
|
||||
// a runtime TDZ crash ("Cannot access 'd' before initialization").
|
||||
// charts is loaded on the Home page (default route) anyway, so a
|
||||
// separate chunk buys almost nothing.
|
||||
// Returning undefined for @antv doesn't help either: rollup still
|
||||
// merges it into vendor (verified 2026-07-28).
|
||||
if (id.includes('node_modules')) {
|
||||
return 'vendor'
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user