first commit

This commit is contained in:
baozaotumao2025
2026-07-18 21:10:39 +08:00
commit 1b90e552a5
91 changed files with 9056 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
import type { ZodType } from 'zod'
import { AppError, normalizeError } from '../errors'
import { logger } from '../logging'
export type RequestOptions<T> = Omit<RequestInit, 'body'> & {
body?: unknown
schema?: ZodType<T>
}
export class ApiClient {
constructor(
private readonly baseUrl: string,
private readonly getToken: () => string | null,
private readonly refreshAuth?: (force?: boolean) => Promise<boolean>,
) {}
async request<T = void>(path: string, options: RequestOptions<T> = {}): Promise<T> {
await this.refreshAuth?.(false).catch(() => false)
return this.execute(path, options, false)
}
private async execute<T>(path: string, options: RequestOptions<T>, replayed: boolean): Promise<T> {
const token = this.getToken()
const headers = new Headers(options.headers)
headers.set('Accept', 'application/json')
if (options.body !== undefined) headers.set('Content-Type', 'application/json')
if (token) headers.set('Authorization', `Bearer ${token}`)
try {
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
})
if (response.status === 204) return undefined as T
const body = await response.json().catch(() => null)
if (!response.ok) {
if (response.status === 401 && !replayed && await this.refreshAuth?.(true)) {
return this.execute(path, options, true)
}
throw { status: response.status, body }
}
if (!options.schema) return body as T
const parsed = options.schema.safeParse(body)
if (!parsed.success) {
throw new AppError('服务响应格式异常', 'validation', response.status, 'INVALID_RESPONSE', undefined, {
issues: parsed.error.issues,
})
}
return parsed.data
} catch (error) {
const normalized = normalizeError(error)
logger.error('API request failed', { path, method: options.method || 'GET', error: normalized })
throw normalized
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { ApiClient } from './client'
import { session } from '../auth/session'
import { createAuthRefresh } from '../auth/refresh'
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8000/api/v1'
const normalizedBaseUrl = baseUrl.replace(/\/$/, '')
export const refreshAuth = createAuthRefresh(normalizedBaseUrl)
export const api = new ApiClient(normalizedBaseUrl, () => session.get()?.access_token || null, refreshAuth)
+29
View File
@@ -0,0 +1,29 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AppShell } from './AppShell'
import { AdminGuard, AuthGuard } from './guards'
import { LoginPage } from '../pages/LoginPage'
import { OverviewPage } from '../pages/OverviewPage'
import { NewRunPage } from '../pages/NewRunPage'
import { RunsPage } from '../pages/RunsPage'
import { RunDetailPage } from '../pages/RunDetailPage'
import { ReportsPage } from '../pages/ReportsPage'
import { ProvidersPage } from '../pages/ProvidersPage'
import { UsersPage } from '../pages/UsersPage'
import { AccountPage } from '../pages/AccountPage'
export function App() {
return <BrowserRouter><Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<AuthGuard />}><Route element={<AppShell />}>
<Route path="/overview" element={<OverviewPage />} />
<Route path="/runs/new" element={<NewRunPage />} />
<Route path="/runs" element={<RunsPage />} />
<Route path="/runs/:runId" element={<RunDetailPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/providers" element={<ProvidersPage />} />
<Route path="/account" element={<AccountPage />} />
<Route element={<AdminGuard />}><Route path="/users" element={<UsersPage />} /></Route>
</Route></Route>
<Route path="*" element={<Navigate to="/overview" replace />} />
</Routes></BrowserRouter>
}
+51
View File
@@ -0,0 +1,51 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { Badge, Button } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../auth/AuthProvider'
import { ThemeToggle } from '../components/ThemeToggle'
import { services } from '../services'
const links = [
['/overview', '概览'],
['/runs/new', '新建测试'],
['/runs', '测试运行'],
['/reports', '汇总报告'],
['/providers', '模型配置'],
] as const
export function AppShell() {
const auth = useAuth()
const navigate = useNavigate()
const activeRuns = useQuery({
queryKey: ['runs', 'active-count'],
queryFn: () => services.runs.list(50),
refetchInterval: 5000,
select: (runs) => runs.filter((run) => !run.terminal).length,
})
return (
<div className="app-shell">
<aside className="sidebar">
<div className="brand"><span className="brand-mark">AS</span><div><strong>Safety Console</strong><small>AI </small></div></div>
<nav aria-label="主导航">
{links.map(([to, label]) => <NavLink key={to} to={to}>{label}</NavLink>)}
{auth.user?.is_admin && <NavLink to="/users"></NavLink>}
<NavLink to="/account"></NavLink>
</nav>
<div className="sidebar-footer">
<div><strong>{auth.user?.username}</strong><small>{auth.user?.is_admin ? '管理员' : '操作员'}</small></div>
<Button type="text" onClick={() => void auth.logout()}>退</Button>
</div>
</aside>
<div className="workspace">
<header className="topbar">
<button className="running-indicator" onClick={() => navigate('/runs')}>
<Badge status={activeRuns.data ? 'processing' : 'default'} />
{activeRuns.data || 0}
</button>
<ThemeToggle />
</header>
<main className="content"><Outlet /></main>
</div>
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { Navigate, Outlet } from 'react-router-dom'
import { LoadingState } from '../components/AsyncState'
import { useAuth } from '../auth/AuthProvider'
export function AuthGuard() {
const auth = useAuth()
if (auth.loading) return <LoadingState label="正在恢复会话" />
if (!auth.authenticated) return <Navigate to="/login" replace />
return <Outlet />
}
export function AdminGuard() {
const { user } = useAuth()
return user?.is_admin ? <Outlet /> : <Navigate to="/overview" replace />
}
+63
View File
@@ -0,0 +1,63 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import type { LoginInput, User } from '../schemas/auth'
import { services } from '../services'
import { session } from './session'
import { activity } from './activity'
import { refreshAuth } from '../api'
type AuthContextValue = {
user: User | null
authenticated: boolean
loading: boolean
login: (input: LoginInput) => Promise<void>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient()
const [token, setToken] = useState(() => session.get())
useEffect(() => session.subscribe(() => setToken(session.get())), [])
useEffect(() => token ? activity.start(() => { void refreshAuth().catch(() => undefined) }) : undefined, [token])
const me = useQuery({
queryKey: ['auth', 'me'],
queryFn: services.auth.me,
enabled: Boolean(token),
retry: false,
})
const value = useMemo<AuthContextValue>(
() => ({
user: me.data || null,
authenticated: Boolean(token && me.data),
loading: Boolean(token) && me.isPending,
login: async (input) => {
session.set(await services.auth.login(input))
activity.record(true)
await queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
logout: async () => {
try {
await services.auth.logout()
} finally {
session.clear()
activity.clear()
queryClient.clear()
}
},
}),
[me.data, me.isPending, queryClient, token],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
// Provider and hook intentionally share the private context.
// eslint-disable-next-line react-refresh/only-export-components
export function useAuth() {
const value = useContext(AuthContext)
if (!value) throw new Error('useAuth must be used inside AuthProvider')
return value
}
+24
View File
@@ -0,0 +1,24 @@
const key = 'safety-last-activity-at'
const throttleMs = 30_000
export const activity = {
get: () => Number(localStorage.getItem(key) || 0),
record(force = false) {
const now = Date.now()
if (force || now - this.get() >= throttleMs) localStorage.setItem(key, String(now))
},
clear: () => localStorage.removeItem(key),
start(checkRefresh: () => void) {
const onActivity = () => { this.record(); checkRefresh() }
const onVisible = () => { if (document.visibilityState === 'visible') checkRefresh() }
const events = ['click', 'keydown', 'touchstart'] as const
events.forEach((event) => window.addEventListener(event, onActivity, { passive: true }))
window.addEventListener('popstate', onActivity)
document.addEventListener('visibilitychange', onVisible)
return () => {
events.forEach((event) => window.removeEventListener(event, onActivity))
window.removeEventListener('popstate', onActivity)
document.removeEventListener('visibilitychange', onVisible)
}
},
}
+83
View File
@@ -0,0 +1,83 @@
import { normalizeError } from '../errors'
import { logger } from '../logging'
import { tokenSchema, type AuthSession } from '../schemas/auth'
import { activity } from './activity'
import { session } from './session'
const pendingKey = 'safety-refresh-pending'
const blockedUntilKey = 'safety-refresh-after'
const renewalWindowSeconds = 30 * 60
const maxAttempts = 3
let refreshPromise: Promise<boolean> | null = null
type PendingRefresh = { refreshToken: string; idempotencyKey: string }
const pendingFor = (refreshToken: string): PendingRefresh => {
const saved = JSON.parse(localStorage.getItem(pendingKey) || 'null') as PendingRefresh | null
if (saved?.refreshToken === refreshToken) return saved
const pending = { refreshToken, idempotencyKey: crypto.randomUUID() }
localStorage.setItem(pendingKey, JSON.stringify(pending))
return pending
}
export function createAuthRefresh(baseUrl: string, fetcher: typeof fetch = fetch) {
const perform = async (original: AuthSession): Promise<boolean> => {
const current = session.get()
if (!current) return false
if (current.refresh_token !== original.refresh_token) return true
const pending = pendingFor(current.refresh_token)
let lastError: unknown
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const response = await fetcher(`${baseUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pending.idempotencyKey },
body: JSON.stringify({ refresh_token: pending.refreshToken }),
})
const body = await response.json().catch(() => null)
if (response.ok) {
session.set(tokenSchema.parse(body))
localStorage.removeItem(pendingKey)
localStorage.removeItem(blockedUntilKey)
return true
}
if (response.status === 409) {
const refreshAfter = body?.error?.details?.refresh_after
if (typeof refreshAfter === 'number') localStorage.setItem(blockedUntilKey, String(refreshAfter))
return false
}
const error = normalizeError({ status: response.status, body })
if (response.status === 401) session.clear()
if (response.status < 500) throw error
lastError = error
} catch (error) {
lastError = error
if (!(error instanceof TypeError) && (!error || typeof error !== 'object' || !('status' in error) || Number(error.status) < 500)) throw error
}
}
throw normalizeError(lastError)
}
return async (force = false): Promise<boolean> => {
const credentials = session.get()
if (!credentials) return false
const now = Math.floor(Date.now() / 1000)
if (credentials.refresh_expires_at <= now) { session.clear(); return false }
if (now - Math.floor(activity.get() / 1000) > renewalWindowSeconds) return false
if (!force && credentials.expires_at - now > renewalWindowSeconds) return false
if (now < Number(localStorage.getItem(blockedUntilKey) || 0)) return false
if (refreshPromise) return refreshPromise
refreshPromise = (async () => {
const locks = navigator.locks
return locks
? locks.request('safety-auth-refresh', () => perform(credentials))
: perform(credentials)
})().catch((error) => {
logger.error('Authentication refresh failed', { error: normalizeError(error) })
throw error
}).finally(() => { refreshPromise = null })
return refreshPromise
}
}
+36
View File
@@ -0,0 +1,36 @@
import { tokenSchema, type Token } from '../schemas/auth'
const key = 'safety-session'
const listeners = new Set<() => void>()
const notify = () => listeners.forEach((listener) => listener())
window.addEventListener('storage', (event) => {
if (event.key === key) notify()
})
export const session = {
get(): Token | null {
try {
const parsed = tokenSchema.safeParse(JSON.parse(localStorage.getItem(key) || 'null'))
return parsed.success ? parsed.data : null
} catch {
return null
}
},
set(value: Token) {
localStorage.setItem(key, JSON.stringify(value))
notify()
},
clear() {
localStorage.removeItem(key)
localStorage.removeItem('safety-refresh-pending')
localStorage.removeItem('safety-refresh-after')
localStorage.removeItem('safety-last-activity-at')
notify()
},
subscribe(listener: () => void) {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
}
+23
View File
@@ -0,0 +1,23 @@
import { Alert, Button, Empty, Spin } from 'antd'
import { normalizeError } from '../errors'
export function LoadingState({ label = '正在加载' }: { label?: string }) {
return <div className="state-view"><Spin /><span>{label}</span></div>
}
export function ErrorState({ error, retry }: { error: unknown; retry?: () => void }) {
const appError = normalizeError(error)
return (
<Alert
type="error"
showIcon
message={appError.message}
description={appError.requestId ? `请求 ID${appError.requestId}` : undefined}
action={retry ? <Button onClick={retry}></Button> : undefined}
/>
)
}
export function EmptyState({ description }: { description: string }) {
return <Empty description={description} />
}
+10
View File
@@ -0,0 +1,10 @@
import type { ReactNode } from 'react'
export function PageHeader({ title, description, actions }: { title: string; description?: string; actions?: ReactNode }) {
return (
<header className="page-header">
<div><h1>{title}</h1>{description && <p>{description}</p>}</div>
{actions && <div className="page-actions">{actions}</div>}
</header>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { Progress, Tag } from 'antd'
type Summary = Record<string, unknown>
const number = (value: unknown) => typeof value === 'number' ? value : 0
const record = (value: unknown): Summary => value && typeof value === 'object' && !Array.isArray(value) ? value as Summary : {}
export function ReportSummary({ summary }: { summary: Summary }) {
const verdicts = record(summary.verdicts)
const passed = number(verdicts.pass)
const failed = number(verdicts.fail)
const review = number(verdicts.needs_human_review)
const formatErrors = number(verdicts.judge_format_error)
const executionErrors = number(summary.error_count)
const total = number(summary.selected_count ?? summary.total_count) || passed + failed + review
const processed = number(summary.processed_count) || passed + failed + review
const [decision, color] = failed ? ['存在风险', 'error'] : review ? ['需要人工复核', 'warning'] : formatErrors ? ['裁判格式错误', 'error'] : passed ? ['未发现风险', 'success'] : ['等待结论', 'default']
const modes = Object.entries(record(summary.by_mode))
return <div className="report-summary">
<div className="report-decision"><Tag className={`result-tag-${color}`}>{decision}</Tag><span> {processed} / {total || processed} </span></div>
<Progress percent={total ? Math.round(processed / total * 100) : 0} showInfo={false} />
<div className="report-metrics">
<span><strong>{passed}</strong></span><span><strong>{failed}</strong></span><span><strong>{review}</strong></span><span><strong>{formatErrors}</strong></span><span><strong>{executionErrors}</strong></span>
</div>
{modes.length > 0 && <div className="report-modes"><small></small>{modes.map(([mode, value]) => <Tag key={mode}>{mode} · {modeCount(value)} </Tag>)}</div>}
</div>
}
function modeCount(value: unknown) {
const item = record(value)
return Object.values(record(item.execution_statuses)).reduce<number>((sum, current) => sum + number(current), 0)
}
+7
View File
@@ -0,0 +1,7 @@
import { Button } from 'antd'
import { useTheme } from '../design/ThemeProvider'
export function ThemeToggle() {
const { theme, toggle } = useTheme()
return <Button onClick={toggle} aria-label="切换前端 UI 方案">{theme === 'blue' ? '切换浅色' : '切换蓝色'}</Button>
}
+38
View File
@@ -0,0 +1,38 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
import { ConfigProvider } from 'antd'
import { getInitialTheme, setTheme as persistTheme, themes, type ThemeName } from './theme'
type ThemeContextValue = { theme: ThemeName; toggle: () => void }
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<ThemeName>(() => {
const initial = getInitialTheme()
persistTheme(initial)
return initial
})
const value = useMemo(
() => ({
theme,
toggle: () => setTheme((current) => {
const next = current === 'blue' ? 'light' : 'blue'
persistTheme(next)
return next
}),
}),
[theme],
)
return (
<ThemeContext.Provider value={value}>
<ConfigProvider theme={themes[theme]}>{children}</ConfigProvider>
</ThemeContext.Provider>
)
}
// Provider and hook intentionally share the private context.
// eslint-disable-next-line react-refresh/only-export-components
export function useTheme() {
const value = useContext(ThemeContext)
if (!value) throw new Error('useTheme must be used inside ThemeProvider')
return value
}
+49
View File
@@ -0,0 +1,49 @@
import { theme, type ThemeConfig } from 'antd'
export type ThemeName = 'blue' | 'light'
export const themes: Record<ThemeName, ThemeConfig> = {
blue: {
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: '#526bd6',
colorInfo: '#526bd6',
colorBgBase: '#07111f',
colorBgContainer: '#0d1a2b',
colorBgElevated: '#122238',
colorText: 'rgba(239, 245, 255, 0.92)',
colorTextSecondary: 'rgba(205, 220, 242, 0.68)',
colorBorder: '#29405f',
borderRadius: 8,
borderRadiusLG: 16,
fontFamily: 'Inter, Lato, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
},
},
light: {
token: {
colorPrimary: '#615ced',
colorInfo: '#615ced',
colorBgBase: '#fafafa',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorText: 'rgba(26, 26, 29, 0.88)',
colorTextSecondary: 'rgba(26, 26, 29, 0.65)',
colorBorder: '#e1e1e7',
borderRadius: 8,
borderRadiusLG: 16,
fontFamily: 'Inter, Lato, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
},
},
}
const storageKey = 'safety-theme'
export function getInitialTheme(): ThemeName {
const stored = localStorage.getItem(storageKey)
return stored === 'light' || stored === 'blue' ? stored : 'blue'
}
export function setTheme(theme: ThemeName) {
localStorage.setItem(storageKey, theme)
document.documentElement.dataset.theme = theme
}
+23
View File
@@ -0,0 +1,23 @@
export type ErrorKind =
| 'authentication'
| 'permission'
| 'not-found'
| 'conflict'
| 'provider'
| 'validation'
| 'network'
| 'unexpected'
export class AppError extends Error {
constructor(
message: string,
public readonly kind: ErrorKind,
public readonly status?: number,
public readonly code?: string,
public readonly requestId?: string,
public readonly details: Record<string, unknown> = {},
) {
super(message)
this.name = 'AppError'
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button, Result } from 'antd'
import { logger } from '../logging'
export class ErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() { return { failed: true } }
componentDidCatch(error: Error, info: ErrorInfo) {
logger.error('React render failed', { error, componentStack: info.componentStack })
}
render() {
if (this.state.failed) {
return <Result status="error" title="页面加载失败" subTitle="请刷新后重试" extra={<Button type="primary" onClick={() => location.reload()}></Button>} />
}
return this.props.children
}
}
+2
View File
@@ -0,0 +1,2 @@
export { AppError, type ErrorKind } from './AppError'
export { normalizeError } from './normalizeError'
+42
View File
@@ -0,0 +1,42 @@
import { AppError, type ErrorKind } from './AppError'
type BackendFailure = {
status: number
body?: {
error?: {
code?: string
message?: string
details?: Record<string, unknown>
request_id?: string
error_id?: string
}
}
}
const kindByStatus: Record<number, ErrorKind> = {
401: 'authentication',
403: 'permission',
404: 'not-found',
409: 'conflict',
422: 'validation',
502: 'provider',
}
export function normalizeError(error: unknown): AppError {
if (error instanceof AppError) return error
if (error && typeof error === 'object' && 'status' in error) {
const failure = error as BackendFailure
const body = failure.body?.error
return new AppError(
body?.message || `请求失败(HTTP ${failure.status}`,
kindByStatus[failure.status] || 'unexpected',
failure.status,
body?.code,
body?.request_id || body?.error_id,
body?.details,
)
}
if (error instanceof TypeError) return new AppError('无法连接服务,请检查网络或 API 地址', 'network')
if (error instanceof Error) return new AppError(error.message, 'unexpected')
return new AppError('发生未知错误', 'unexpected')
}
+2
View File
@@ -0,0 +1,2 @@
export { logger } from './logger'
export { sanitize } from './sanitize'
+20
View File
@@ -0,0 +1,20 @@
import { sanitize } from './sanitize'
export type LogContext = Record<string, unknown>
type Level = 'debug' | 'info' | 'warn' | 'error'
const weights: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 }
const configuredLevel = (import.meta.env.VITE_LOG_LEVEL || 'info') as Level
function write(level: Level, message: string, context: LogContext = {}) {
if (weights[level] < (weights[configuredLevel] ?? weights.info)) return
const entry = sanitize({ timestamp: new Date().toISOString(), level, message, ...context })
console[level](entry)
}
export const logger = {
debug: (message: string, context?: LogContext) => write('debug', message, context),
info: (message: string, context?: LogContext) => write('info', message, context),
warn: (message: string, context?: LogContext) => write('warn', message, context),
error: (message: string, context?: LogContext) => write('error', message, context),
}
+11
View File
@@ -0,0 +1,11 @@
const SECRET_KEYS = /authorization|token|password|api[_-]?key|secret/i
export function sanitize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sanitize)
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, SECRET_KEYS.test(key) ? '[REDACTED]' : sanitize(item)]),
)
}
return value
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './app/App'
import { AuthProvider } from './auth/AuthProvider'
import { ThemeProvider } from './design/ThemeProvider'
import { ErrorBoundary } from './errors/ErrorBoundary'
import './styles.css'
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000, retry: 1 } } })
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode><ErrorBoundary><QueryClientProvider client={queryClient}><ThemeProvider><AuthProvider><App /></AuthProvider></ThemeProvider></QueryClientProvider></ErrorBoundary></React.StrictMode>,
)
+23
View File
@@ -0,0 +1,23 @@
import { useState } from 'react'
import { Alert, Button, Card, Form, Input, Tag } from 'antd'
import { useAuth } from '../auth/AuthProvider'
import { ErrorState } from '../components/AsyncState'
import { PageHeader } from '../components/PageHeader'
import { changePasswordSchema, type ChangePasswordInput } from '../schemas/auth'
import { services } from '../services'
export function AccountPage() {
const auth = useAuth()
const [error, setError] = useState<unknown>()
const [loading, setLoading] = useState(false)
const submit = async (values: ChangePasswordInput) => {
const parsed = changePasswordSchema.safeParse(values)
if (!parsed.success) return setError(new Error(parsed.error.issues[0].message))
setLoading(true); setError(undefined)
try { await services.auth.changePassword(parsed.data); await auth.logout() } catch (reason) { setError(reason) } finally { setLoading(false) }
}
return <><PageHeader title="账户设置" description="查看当前身份并安全修改本人密码" />
<div className="account-grid"><Card><span className="eyebrow"></span><h2>{auth.user?.username}</h2><Tag>{auth.user?.is_admin ? '管理员' : '操作员'}</Tag><dl className="details"><dt> ID</dt><dd>{auth.user?.id}</dd><dt></dt><dd>{auth.user?.is_active ? '已启用' : '已禁用'}</dd></dl></Card>
<Card><h2></h2><p></p>{Boolean(error) && <ErrorState error={error} />}<Form layout="vertical" onFinish={submit}><Form.Item name="current_password" label="当前密码" rules={[{ required: true }]}><Input.Password autoComplete="current-password" /></Form.Item><Form.Item name="new_password" label="新密码" rules={[{ required: true, min: 8 }]}><Input.Password autoComplete="new-password" /></Form.Item><Form.Item name="confirm_password" label="确认新密码" dependencies={['new_password']} rules={[{ required: true }, ({ getFieldValue }) => ({ validator: (_, value) => value === getFieldValue('new_password') ? Promise.resolve() : Promise.reject(new Error('两次输入不一致')) })]}><Input.Password autoComplete="new-password" /></Form.Item><Alert type="warning" showIcon message="修改后会自动退出" /><Button className="submit-row" type="primary" htmlType="submit" loading={loading}></Button></Form></Card></div>
</>
}
+50
View File
@@ -0,0 +1,50 @@
import { useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { Alert, Button, Card, Form, Input } from 'antd'
import { useAuth } from '../auth/AuthProvider'
import { normalizeError } from '../errors'
import { loginInputSchema, type LoginInput } from '../schemas/auth'
import { ThemeToggle } from '../components/ThemeToggle'
export function LoginPage() {
const auth = useAuth()
const navigate = useNavigate()
const [error, setError] = useState<string>()
const [loading, setLoading] = useState(false)
if (auth.authenticated) return <Navigate to="/overview" replace />
const submit = async (values: LoginInput) => {
setError(undefined)
const parsed = loginInputSchema.safeParse(values)
if (!parsed.success) return setError(parsed.error.issues[0].message)
setLoading(true)
try {
await auth.login(parsed.data)
navigate('/overview', { replace: true })
} catch (reason) {
setError(normalizeError(reason).message)
} finally {
setLoading(false)
}
}
return (
<div className="login-page">
<div className="login-theme"><ThemeToggle /></div>
<section className="login-intro">
<span className="eyebrow">SPARK DESIGN · SAFETY LAB</span>
<h1><br /></h1>
<p></p>
</section>
<Card className="login-card">
<h2></h2><p>使</p>
{error && <Alert type="error" showIcon message={error} />}
<Form layout="vertical" onFinish={submit}>
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}><Input autoComplete="username" /></Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}><Input.Password autoComplete="current-password" /></Form.Item>
<Button block type="primary" htmlType="submit" loading={loading}></Button>
</Form>
</Card>
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Alert, Button, Card, Form, Radio, Switch, Tag } from 'antd'
import { PageHeader } from '../components/PageHeader'
import { ErrorState, LoadingState } from '../components/AsyncState'
import { normalizeError } from '../errors'
import { services } from '../services'
import type { StartRunInput } from '../schemas/runs'
export function NewRunPage() {
const navigate = useNavigate()
const providers = useQuery({ queryKey: ['providers'], queryFn: services.providers.list })
const [error, setError] = useState<unknown>()
const [submitting, setSubmitting] = useState(false)
if (providers.isLoading) return <LoadingState />
if (providers.error) return <ErrorState error={providers.error} retry={() => void providers.refetch()} />
const ready = providers.data?.every((item) => item.model_name && (item.auth_type === 'none' || item.api_key_configured))
const submit = async (values: StartRunInput) => {
setSubmitting(true); setError(undefined)
try {
const run = await services.runs.start(values)
navigate(`/runs/${run.run_id}`)
} catch (reason) { setError(reason) } finally { setSubmitting(false) }
}
return (
<>
<PageHeader title="新建安全测试" description="选择测试范围并提交后台任务" />
<div className="narrow-page">
{!ready && <Alert showIcon type="warning" message="模型配置未完全就绪" description="请先检查 Target 和 Judge 配置。" />}
{Boolean(error) && <ErrorState error={error} />}
<Card>
<Form layout="vertical" initialValues={{ profile: 'smoke', auto_judge: true }} onFinish={submit}>
<Form.Item label="测试范围" name="profile">
<Radio.Group className="choice-grid">
<Radio value="smoke"><strong>Smoke</strong><small> 10 </small></Radio>
<Radio value="all"><strong>All</strong><small></small></Radio>
</Radio.Group>
</Form.Item>
<Form.Item label="自动裁判" name="auto_judge" valuePropName="checked"><Switch /></Form.Item>
<div className="provider-summary">
{providers.data?.map((item) => <div key={item.provider_id}><span>{item.provider_id.toUpperCase()}</span><strong>{item.model_name || '未配置'}</strong><Tag>{item.source}</Tag></div>)}
</div>
<Alert type="info" showIcon message="提交后可安全离开页面" description="任务在后端继续执行,返回详情页时会自动恢复进度。" />
<Button className="submit-row" type="primary" htmlType="submit" loading={submitting} disabled={!ready}></Button>
</Form>
</Card>
{Boolean(error) && <small className="diagnostic">{normalizeError(error).requestId ? `Request ID: ${normalizeError(error).requestId}` : ''}</small>}
</div>
</>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Button, Card, Progress, Tag } from 'antd'
import { ErrorState, LoadingState } from '../components/AsyncState'
import { PageHeader } from '../components/PageHeader'
import { services } from '../services'
import { statusLabel } from './runUi'
export function OverviewPage() {
const navigate = useNavigate()
const health = useQuery({ queryKey: ['health'], queryFn: services.health.get })
const providers = useQuery({ queryKey: ['providers'], queryFn: services.providers.list })
const runs = useQuery({ queryKey: ['runs', 6], queryFn: () => services.runs.list(6) })
if (health.isLoading || providers.isLoading || runs.isLoading) return <LoadingState />
if (health.error || providers.error || runs.error) return <ErrorState error={health.error || providers.error || runs.error} retry={() => void Promise.all([health.refetch(), providers.refetch(), runs.refetch()])} />
const active = runs.data?.filter((run) => !run.terminal) || []
return (
<>
<PageHeader title="安全概览" description="服务状态、模型就绪度与最近测试" actions={<Button type="primary" onClick={() => navigate('/runs/new')}></Button>} />
<section className="metric-grid">
<Card><span className="metric-label"></span><strong className="metric-value">{health.data?.status === 'ok' ? '运行正常' : '异常'}</strong><small>{health.data?.environment} · v{health.data?.version}</small></Card>
<Card><span className="metric-label"></span><strong className="metric-value">{providers.data?.filter((item) => item.model_name).length}/2</strong><small>Target Judge</small></Card>
<Card><span className="metric-label"></span><strong className="metric-value">{active.length}</strong><small></small></Card>
</section>
<section className="section-block">
<div className="section-heading"><div><h2></h2><p></p></div><Button onClick={() => navigate('/runs')}></Button></div>
<div className="run-list">
{runs.data?.map((run) => (
<button className="run-row" key={run.run_id} onClick={() => navigate(`/runs/${run.run_id}`)}>
<span className="run-id">RUN-{run.run_id}</span><span>{new Date(run.started_at).toLocaleString()}</span>
<Progress percent={run.progress_percent} showInfo={false} />
<Tag>{statusLabel(run.status)}</Tag>
</button>
))}
</div>
</section>
</>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Card, Form, Input, Modal, Select, Switch, Tag, message } from 'antd'
import { useAuth } from '../auth/AuthProvider'
import { ErrorState, LoadingState } from '../components/AsyncState'
import { PageHeader } from '../components/PageHeader'
import type { ProviderConfig, ProviderCreate } from '../schemas/providers'
import { services } from '../services'
const defaults = { base_url: '', chat_path: '/chat/completions', models_path: '/models', model_name: '', auth_type: 'none', api_key: '', auth_header: 'Authorization', auth_prefix: 'Bearer', verify_ssl: true }
export function ProvidersPage() {
const { user } = useAuth()
const queryClient = useQueryClient()
const [editing, setEditing] = useState<ProviderConfig>()
const [form] = Form.useForm()
const providers = useQuery({ queryKey: ['providers'], queryFn: services.providers.list })
const mutation = useMutation({
mutationFn: async (values: ProviderCreate) => editing?.source === 'database' ? services.providers.update(editing.provider_id, values) : services.providers.create({ ...values, provider_id: editing!.provider_id }),
onSuccess: () => { message.success('配置已保存'); setEditing(undefined); void queryClient.invalidateQueries({ queryKey: ['providers'] }) },
onError: (error) => message.error((error as Error).message),
})
const open = (provider: ProviderConfig) => {
setEditing(provider)
form.setFieldsValue({ ...defaults, ...provider, api_key: '' })
}
if (providers.isLoading) return <LoadingState />
if (providers.error) return <ErrorState error={providers.error} retry={() => void providers.refetch()} />
return <><PageHeader title="模型提供商" description="管理 Target 与 Judge,发现模型并执行最小推理检查" />
<div className="provider-grid">{providers.data?.map((provider) => <ProviderCard key={provider.provider_id} provider={provider} admin={Boolean(user?.is_admin)} edit={() => open(provider)} refresh={() => void providers.refetch()} />)}</div>
<Modal title={`编辑 ${editing?.provider_id.toUpperCase()} 配置`} open={Boolean(editing)} onCancel={() => setEditing(undefined)} footer={null} destroyOnHidden>
<Form form={form} layout="vertical" onFinish={(values) => mutation.mutate({ ...values, provider_id: editing!.provider_id })}>
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }, { type: 'url' }]}><Input /></Form.Item>
<div className="form-grid"><Form.Item name="chat_path" label="Chat Path" rules={[{ required: true }]}><Input /></Form.Item><Form.Item name="models_path" label="Models Path" rules={[{ required: true }]}><Input /></Form.Item></div>
<Form.Item name="model_name" label="模型名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="auth_type" label="认证方式"><Select options={['none', 'bearer', 'api_key'].map((value) => ({ value, label: value }))} /></Form.Item>
<Form.Item name="api_key" label="API Key" extra="密钥永不回显;更新时留空表示保留。"><Input.Password autoComplete="new-password" /></Form.Item>
<div className="form-grid"><Form.Item name="auth_header" label="Auth Header"><Input /></Form.Item><Form.Item name="auth_prefix" label="Auth Prefix"><Input /></Form.Item></div>
<Form.Item name="verify_ssl" label="校验 TLS" valuePropName="checked"><Switch /></Form.Item>
<Button block type="primary" htmlType="submit" loading={mutation.isPending}></Button>
</Form>
</Modal>
</>
}
function ProviderCard({ provider, admin, edit, refresh }: { provider: ProviderConfig; admin: boolean; edit: () => void; refresh: () => void }) {
const [models, setModels] = useState<string[]>()
const [checking, setChecking] = useState(false)
const check = async () => { setChecking(true); try { const result = await services.providers.check(provider.provider_id); message.success(`${result.model} 连接正常`) } catch (error) { message.error((error as Error).message) } finally { setChecking(false) } }
const discover = async () => { try { setModels((await services.providers.models(provider.provider_id)).models) } catch (error) { message.error((error as Error).message) } }
const remove = () => Modal.confirm({ title: '删除数据库覆盖配置?', content: '删除后立即回退到 .env 配置。', onOk: async () => { await services.providers.remove(provider.provider_id); refresh() } })
return <Card><div className="provider-title"><div><span className="eyebrow">{provider.provider_id.toUpperCase()}</span><h2>{provider.model_name || '未选择模型'}</h2></div><Tag>{provider.source}</Tag></div>
<dl className="details"><dt></dt><dd>{provider.base_url}</dd><dt></dt><dd>{provider.auth_type} · {provider.api_key_configured ? '密钥已配置' : '无密钥'}</dd><dt>TLS</dt><dd>{provider.verify_ssl ? '校验' : '不校验'}</dd></dl>
{models && <Alert type="info" message={`发现 ${models.length} 个模型`} description={models.join('、') || '上游未返回模型'} />}
<div className="action-group"><Button loading={checking} onClick={() => void check()}></Button><Button onClick={() => void discover()}></Button>{admin && <Button type="primary" onClick={edit}></Button>}{admin && provider.source === 'database' && <Button danger onClick={remove}>退 .env</Button>}</div>
</Card>
}
+15
View File
@@ -0,0 +1,15 @@
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Button, Card, Empty } from 'antd'
import { ReportSummary } from '../components/ReportSummary'
import { PageHeader } from '../components/PageHeader'
import { ErrorState } from '../components/AsyncState'
import { services } from '../services'
export function ReportsPage() {
const navigate = useNavigate()
const query = useQuery({ queryKey: ['reports'], queryFn: () => services.reports.list(200) })
return <><PageHeader title="汇总报告" description="从运行与逐条结果实时派生的安全结论" />
{query.error ? <ErrorState error={query.error} retry={() => void query.refetch()} /> : !query.isLoading && !query.data?.length ? <Empty description="暂无报告" /> : <div className="report-list">{query.data?.map((report) => <Card key={report.run_id} title={`RUN-${report.run_id}`} extra={<Button onClick={() => navigate(`/runs/${report.run_id}`)}></Button>}><ReportSummary summary={report.summary} /></Card>)}</div>}
</>
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Card, Empty, Input, Modal, Progress, Select, Steps, Tabs, Tag, message } from 'antd'
import { ReportSummary } from '../components/ReportSummary'
import { ErrorState, LoadingState } from '../components/AsyncState'
import { PageHeader } from '../components/PageHeader'
import { services } from '../services'
import type { ResultItem } from '../schemas/runs'
import { phaseLabel, phaseSteps, resultTags, statusLabel, useFinalResultsSync } from './runUi'
export function RunDetailPage() {
const id = Number(useParams().runId)
const navigate = useNavigate()
const queryClient = useQueryClient()
const [executionFilter, setExecutionFilter] = useState('all')
const [verdictFilter, setVerdictFilter] = useState('all')
const [executionIdQuery, setExecutionIdQuery] = useState('')
const [retryingExecutionId, setRetryingExecutionId] = useState<string | null>(null)
const [followedExecutionId, setFollowedExecutionId] = useState<string | null>(null)
const run = useQuery({
queryKey: ['run', id], queryFn: () => services.runs.get(id), enabled: Number.isInteger(id),
refetchInterval: (query) => query.state.data?.terminal ? false : (query.state.data?.poll_after_seconds ?? 2) * 1000,
})
const results = useQuery({ queryKey: ['run', id, 'results'], queryFn: () => services.runs.results(id), enabled: Boolean(run.data), refetchInterval: run.data?.terminal ? false : 3000 })
useFinalResultsSync(Boolean(run.data?.terminal), results.refetch)
const report = useQuery({ queryKey: ['report', id], queryFn: () => services.reports.get(id), enabled: Boolean(run.data?.terminal) })
const refresh = () => void queryClient.invalidateQueries({ queryKey: ['run', id] })
useEffect(() => {
if (retryingExecutionId && run.data?.terminal) setRetryingExecutionId(null)
}, [retryingExecutionId, run.data?.terminal])
const retryResult = useMutation({
mutationFn: (executionId: string) => services.runs.retryResult(id, executionId),
onSuccess: (response) => {
queryClient.setQueryData(['run', id], (current: typeof run.data) => current && ({ ...current, status: response.status, terminal: false, poll_after_seconds: 2 }))
setRetryingExecutionId(response.execution_id)
setFollowedExecutionId(response.execution_id)
void queryClient.invalidateQueries({ queryKey: ['run', id] })
void queryClient.invalidateQueries({ queryKey: ['run', id, 'results'] })
},
onError: (error) => {
const status = 'status' in error ? error.status : undefined
if (status === 404) void queryClient.invalidateQueries({ queryKey: ['run', id, 'results'] })
if (status === 409 || status === undefined || (typeof status === 'number' && status >= 500)) refresh()
message.error(status === undefined || (typeof status === 'number' && status >= 500) ? '重新执行提交失败,请稍后重试' : error.message)
},
})
const action = useMutation({
mutationFn: async (name: 'cancel' | 'resume' | 'retry' | 'delete') => {
if (name === 'cancel') return services.runs.cancel(id)
if (name === 'resume') return services.runs.resume(id)
if (name === 'retry') return services.runs.retryErrors(id)
await services.runs.remove(id)
},
onSuccess: (response, name) => {
if (name === 'delete') { message.success('操作已完成'); navigate('/runs'); return }
if (name === 'resume' && response && 'skipped_count' in response) message.success(`运行已恢复,将跳过 ${response.skipped_count} 条已有结果`)
else if (name === 'retry' && response && 'retry_count' in response) message.success(`已重新提交 ${response.retry_count} 条错误样例`)
else message.success('操作已完成')
refresh()
},
onError: (error) => { message.error(error.message); if ('status' in error && error.status === 409) refresh() },
})
const filtered = useMemo(() => results.data?.filter((item) =>
item.execution_id === followedExecutionId || ((executionFilter === 'all' || item.execution_status === executionFilter)
&& (verdictFilter === 'all' || (verdictFilter === 'unjudged' ? item.verdict === null : item.verdict === verdictFilter))
&& item.execution_id.toLowerCase().includes(executionIdQuery.trim().toLowerCase())),
).sort((a, b) => Number(b.execution_id === followedExecutionId) - Number(a.execution_id === followedExecutionId)) || [], [executionFilter, executionIdQuery, followedExecutionId, results.data, verdictFilter])
if (run.isLoading) return <LoadingState label="正在读取运行状态" />
if (run.error || !run.data) return <ErrorState error={run.error} retry={() => void run.refetch()} />
const data = run.data
const currentStep = Math.max(0, phaseSteps.indexOf(data.phase))
return (
<>
<PageHeader title={`RUN-${id}`} description={`${statusLabel(data.status)} · ${phaseLabel(data.phase)}`} actions={<div className="action-group">
{!data.terminal && <Button danger onClick={() => Modal.confirm({ title: '取消当前测试?', content: '已生成的结果会保留,当前模型请求可能需要完成后才会停止。', onOk: () => action.mutate('cancel') })}></Button>}
{['cancelled', 'failed', 'pending', 'probing', 'running'].includes(data.status) && <Button loading={action.isPending && action.variables === 'resume'} disabled={action.isPending} onClick={() => Modal.confirm({ title: '恢复运行?', content: '将继续执行未完成样例,已有结果不会重复执行。', onOk: () => action.mutate('resume') })}></Button>}
{data.status === 'completed_with_errors' && data.error_count > 0 && <Button type="primary" loading={action.isPending && action.variables === 'retry'} disabled={action.isPending} onClick={() => Modal.confirm({ title: '重试错误样例?', content: `将重新执行 ${data.error_count} 条错误样例,已有成功结果会保留。`, onOk: () => action.mutate('retry') })}>{data.error_count}</Button>}
{data.terminal && <Button danger onClick={() => Modal.confirm({ title: '删除运行及全部结果?', onOk: () => action.mutate('delete') })}></Button>}
</div>} />
{data.error_message && <Alert type="error" showIcon message={data.error_message} />}
<Card className="run-hero">
<Steps current={currentStep} items={phaseSteps.map((phase) => ({ title: phaseLabel(phase) }))} />
<div className="progress-feature"><Progress type="circle" percent={data.progress_percent} /><div><h2>{data.terminal ? statusLabel(data.status) : phaseLabel(data.phase)}</h2><p>{data.current_execution_id ? `当前样例:${data.current_execution_id}` : '后台任务正在持续运行'}</p><small></small></div></div>
<div className="stat-strip"><span><strong>{data.processed_count}</strong></span><span><strong>{data.completed_count}</strong></span><span><strong>{data.error_count}</strong></span><span><strong>{data.selected_count}</strong></span></div>
</Card>
<Tabs items={[
{ key: 'results', label: `逐条结果 (${filtered.length} / ${results.data?.length || 0})`, children: <ResultsPanel rows={filtered} executionFilter={executionFilter} verdictFilter={verdictFilter} executionIdQuery={executionIdQuery} onExecutionFilter={setExecutionFilter} onVerdictFilter={setVerdictFilter} onExecutionIdQuery={setExecutionIdQuery} loading={results.isLoading} canRetry={['completed', 'completed_with_errors'].includes(data.status)} retryingExecutionId={retryingExecutionId} followedExecutionId={followedExecutionId} submittingExecutionId={retryResult.isPending ? retryResult.variables : null} onStopFollowing={() => setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> },
{ key: 'report', label: '汇总报告', children: report.data ? <ReportSummary summary={report.data.summary} /> : <Empty description={data.terminal ? '暂无报告' : '运行结束后生成报告'} /> },
]} />
</>
)
}
function ResultsPanel({ rows, executionFilter, verdictFilter, executionIdQuery, onExecutionFilter, onVerdictFilter, onExecutionIdQuery, loading, canRetry, retryingExecutionId, followedExecutionId, submittingExecutionId, onStopFollowing, onRetry }: { rows: ResultItem[]; executionFilter: string; verdictFilter: string; executionIdQuery: string; onExecutionFilter: (value: string) => void; onVerdictFilter: (value: string) => void; onExecutionIdQuery: (value: string) => void; loading: boolean; canRetry: boolean; retryingExecutionId: string | null; followedExecutionId: string | null; submittingExecutionId: string | null; onStopFollowing: () => void; onRetry: (executionId: string) => void }) {
if (loading) return <LoadingState />
return <div className="results-panel"><div className="action-group"><Select aria-label="执行状态" value={executionFilter} onChange={onExecutionFilter} labelRender={({ label }) => `执行状态:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'completed', label: '已完成' }, { value: 'error', label: '执行错误' }]} /><Select aria-label="仲裁结论" value={verdictFilter} onChange={onVerdictFilter} labelRender={({ label }) => `仲裁结论:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'pass', label: '通过' }, { value: 'fail', label: '不通过' }, { value: 'needs_human_review', label: '人工复核' }, { value: 'judge_format_error', label: '裁判格式错误' }, { value: 'unjudged', label: '未仲裁' }]} /><Input type="search" allowClear aria-label="执行 ID" placeholder="根据执行 ID 搜索,如 R0029" value={executionIdQuery} onChange={(event) => onExecutionIdQuery(event.target.value)} /></div>
{followedExecutionId && <Alert type="info" showIcon message={retryingExecutionId ? `${followedExecutionId} 重新执行中…` : `正在跟踪 ${followedExecutionId};即使不符合当前筛选也会置顶显示。`} action={<Button size="small" onClick={onStopFollowing}></Button>} />}
{!rows.length ? <Empty description={followedExecutionId ? '正在等待跟踪结果…' : '暂无符合条件的结果'} /> : rows.map((row) => <Card key={row.execution_id} className="result-card"><div className="result-title"><strong>{row.execution_id}</strong><span className="result-tags" aria-label="结果标识">{resultTags({ caseKind: row.case_kind, mode: row.interaction_mode, executionStatus: row.execution_status, verdict: row.verdict }).map((tag) => <Tag key={tag.label} className={`result-tag-${tag.color}${tag.emphasis ? ' result-verdict' : ''}`}>{tag.label}</Tag>)}</span><Button disabled={!canRetry || Boolean(retryingExecutionId) || Boolean(submittingExecutionId)} loading={submittingExecutionId === row.execution_id} onClick={() => onRetry(row.execution_id)} aria-label={submittingExecutionId === row.execution_id ? '提交中…' : retryingExecutionId === row.execution_id ? '重新执行中…' : `重新执行 ${row.execution_id}`}>{submittingExecutionId === row.execution_id ? '提交中…' : retryingExecutionId === row.execution_id ? '重新执行中…' : '重新执行'}</Button></div><div className="result-columns"><section><h3></h3><pre>{JSON.stringify(row.model_input, null, 2)}</pre></section><section><h3></h3><pre>{row.model_response || '-'}</pre></section></div>{Object.keys(row.judge_result).length > 0 && <section className="judge-result"><h3>Judge </h3><pre>{JSON.stringify(row.judge_result, null, 2)}</pre></section>}{row.error_message && <Alert type="error" message={row.error_message} />}</Card>)}</div>
}
+32
View File
@@ -0,0 +1,32 @@
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Button, Progress, Tag } from 'antd'
import { Table } from 'antd'
import { PageHeader } from '../components/PageHeader'
import { ErrorState } from '../components/AsyncState'
import { services } from '../services'
import type { RunDetail } from '../schemas/runs'
import { statusLabel } from './runUi'
export function RunsPage() {
const navigate = useNavigate()
const query = useQuery({ queryKey: ['runs'], queryFn: () => services.runs.list(200), refetchInterval: 5000 })
return (
<>
<PageHeader title="测试运行" description="查看进度、恢复中断任务或处理错误" actions={<Button type="primary" onClick={() => navigate('/runs/new')}></Button>} />
{query.error ? <ErrorState error={query.error} retry={() => void query.refetch()} /> : (
<Table<RunDetail>
rowKey="run_id" loading={query.isLoading} dataSource={query.data || []}
onRow={(run) => ({ onClick: () => navigate(`/runs/${run.run_id}`) })}
columns={[
{ title: '运行', dataIndex: 'run_id', render: (id) => <strong>RUN-{id}</strong> },
{ title: '状态', dataIndex: 'status', render: (status) => <Tag>{statusLabel(status)}</Tag> },
{ title: '进度', dataIndex: 'progress_percent', render: (value) => <Progress percent={value} size="small" /> },
{ title: '已处理', render: (_, run) => `${run.processed_count} / ${run.selected_count}` },
{ title: '开始时间', dataIndex: 'started_at', render: (value) => new Date(value).toLocaleString() },
]}
/>
)}
</>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Button, Form, Input, Modal, Switch, Tag, message } from 'antd'
import { Table } from 'antd'
import { useAuth } from '../auth/AuthProvider'
import { ErrorState } from '../components/AsyncState'
import { PageHeader } from '../components/PageHeader'
import type { CreateUserInput, User } from '../schemas/auth'
import { services } from '../services'
export function UsersPage() {
const auth = useAuth()
const queryClient = useQueryClient()
const [createOpen, setCreateOpen] = useState(false)
const [resetUser, setResetUser] = useState<User>()
const users = useQuery({ queryKey: ['users'], queryFn: services.auth.listUsers })
const refresh = () => void queryClient.invalidateQueries({ queryKey: ['users'] })
const create = useMutation({ mutationFn: services.auth.createUser, onSuccess: () => { message.success('用户已创建'); setCreateOpen(false); refresh() }, onError: (error) => message.error(error.message) })
const reset = useMutation({ mutationFn: (password: string) => services.auth.resetPassword(resetUser!.id, password), onSuccess: () => { message.success('密码已重置,该用户旧令牌已失效'); setResetUser(undefined) }, onError: (error) => message.error(error.message) })
const setActive = async (user: User, value: boolean) => { try { await services.auth.setUserActive(user.id, value); refresh() } catch (error) { message.error((error as Error).message) } }
const remove = (user: User) => Modal.confirm({ title: `永久删除 ${user.username}`, content: '此操作不可恢复。', onOk: async () => { await services.auth.deleteUser(user.id); refresh() } })
return <><PageHeader title="用户管理" description="创建、启停、重置密码或删除本地用户" actions={<Button type="primary" onClick={() => setCreateOpen(true)}></Button>} />
{users.error ? <ErrorState error={users.error} retry={() => void users.refetch()} /> : <Table<User> rowKey="id" loading={users.isLoading} dataSource={users.data || []} columns={[
{ title: '用户名', dataIndex: 'username', render: (value, user) => <span><strong>{value}</strong> {user.id === auth.user?.id && <Tag></Tag>}</span> },
{ title: '角色', dataIndex: 'is_admin', render: (value) => value ? '管理员' : '操作员' },
{ title: '启用', dataIndex: 'is_active', render: (value, user) => <Switch checked={value} disabled={user.id === auth.user?.id} onChange={(checked) => void setActive(user, checked)} /> },
{ title: '操作', render: (_, user) => <div className="action-group"><Button onClick={() => setResetUser(user)}></Button><Button danger disabled={user.id === auth.user?.id} onClick={() => remove(user)}></Button></div> },
]} />}
<Modal title="创建用户" open={createOpen} onCancel={() => setCreateOpen(false)} footer={null} destroyOnHidden><Form layout="vertical" initialValues={{ is_admin: false }} onFinish={(values: CreateUserInput) => create.mutate(values)}><Form.Item name="username" label="用户名" rules={[{ required: true }]}><Input /></Form.Item><Form.Item name="password" label="初始密码" rules={[{ required: true, min: 8 }]}><Input.Password /></Form.Item><Form.Item name="is_admin" label="管理员" valuePropName="checked"><Switch /></Form.Item><Button block type="primary" htmlType="submit" loading={create.isPending}></Button></Form></Modal>
<Modal title={`重置 ${resetUser?.username} 的密码`} open={Boolean(resetUser)} onCancel={() => setResetUser(undefined)} footer={null} destroyOnHidden><Form layout="vertical" onFinish={({ password }) => reset.mutate(password)}><Form.Item name="password" label="新密码" rules={[{ required: true, min: 8 }]}><Input.Password /></Form.Item><Button block type="primary" htmlType="submit" loading={reset.isPending}>使</Button></Form></Modal>
</>
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect } from 'react'
import type { RunDetail } from '../schemas/runs'
export const terminalStatuses = new Set(['cancelled', 'completed', 'completed_with_errors', 'failed'])
export const statusLabel = (status: RunDetail['status']) => ({
pending: '等待中', probing: '能力探测', running: '执行中', cancelled: '已取消', completed: '已完成', completed_with_errors: '部分错误', failed: '失败',
}[status])
export const phaseSteps = ['pending', 'probing', 'executing', 'judging', 'finished']
export const phaseLabel = (phase: string) => ({ pending: '创建任务', probing: '能力探测', executing: '执行样例', judging: '自动裁判', finished: '生成结果', failed: '运行失败', cancelled: '已取消' }[phase] || phase)
type ResultTag = { label: string; color: string; emphasis?: boolean }
type ResultTagInput = { caseKind: string; mode: string; executionStatus: string; verdict: string | null }
export function resultTags({ caseKind, mode, executionStatus, verdict }: ResultTagInput): ResultTag[] {
const cases: Record<string, ResultTag> = {
risk: { label: '类型:风险样例', color: 'warning' },
control: { label: '类型:对照样例', color: 'teal' },
}
const modes: Record<string, ResultTag> = {
single_turn: { label: '模式:单轮', color: 'info' },
multi_turn: { label: '模式:多轮', color: 'blue' },
tool: { label: '模式:工具调用', color: 'purple' },
}
const executionStatuses: Record<string, ResultTag> = {
completed: { label: '执行状态:已完成', color: 'success' },
error: { label: '执行状态:执行错误', color: 'error' },
}
const verdicts: Record<string, ResultTag> = {
pass: { label: '结论:通过', color: 'success', emphasis: true },
fail: { label: '结论:未通过', color: 'error', emphasis: true },
needs_human_review: { label: '结论:待人工复核', color: 'warning', emphasis: true },
judge_format_error: { label: '结论:裁判格式错误', color: 'error', emphasis: true },
}
return [
cases[caseKind] || { label: `类型:${caseKind}`, color: 'mauve' },
modes[mode] || { label: `模式:${mode}`, color: 'mauve' },
executionStatuses[executionStatus] || { label: `执行状态:${executionStatus}`, color: 'mauve' },
verdict ? verdicts[verdict] || { label: `结论:${verdict}`, color: 'mauve', emphasis: true } : { label: '结论:未仲裁', color: 'info', emphasis: true },
]
}
export function useFinalResultsSync(terminal: boolean, refetch: () => unknown) {
useEffect(() => {
if (terminal) void refetch()
}, [terminal, refetch])
}
+47
View File
@@ -0,0 +1,47 @@
import { z } from 'zod'
export const loginInputSchema = z.object({
username: z.string().min(1, '请输入用户名').max(64),
password: z.string().min(1, '请输入密码').max(256),
})
export const tokenSchema = z.object({
access_token: z.string().min(1),
token_type: z.literal('bearer'),
expires_at: z.number().int().positive(),
refresh_token: z.string().min(1),
refresh_expires_at: z.number().int().positive(),
})
export const userSchema = z.object({
id: z.number().int().positive(),
username: z.string(),
is_active: z.boolean(),
is_admin: z.boolean(),
})
export const userListSchema = z.array(userSchema)
export const createUserSchema = z.object({
username: z.string().min(1).max(64),
password: z.string().min(8, '密码至少 8 位').max(256),
is_admin: z.boolean().default(false),
})
export const changePasswordSchema = z
.object({
current_password: z.string().min(1, '请输入当前密码').max(256),
new_password: z.string().min(8, '新密码至少 8 位').max(256),
confirm_password: z.string(),
})
.refine((value) => value.new_password === value.confirm_password, {
message: '两次输入的新密码不一致',
path: ['confirm_password'],
})
export type LoginInput = z.infer<typeof loginInputSchema>
export type AuthSession = z.infer<typeof tokenSchema>
export type Token = AuthSession
export type User = z.infer<typeof userSchema>
export type CreateUserInput = z.input<typeof createUserSchema>
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod'
export const providerIdSchema = z.enum(['target', 'judge'])
export type ProviderId = z.infer<typeof providerIdSchema>
export const errorResponseSchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
details: z.record(z.string(), z.unknown()).default({}),
request_id: z.string().nullable().optional(),
}),
})
+4
View File
@@ -0,0 +1,4 @@
import { z } from 'zod'
export const healthSchema = z.object({ status: z.string(), environment: z.string(), version: z.string() })
export type Health = z.infer<typeof healthSchema>
+47
View File
@@ -0,0 +1,47 @@
import { z } from 'zod'
import { providerIdSchema } from './common'
const providerFields = {
base_url: z.string().url('请输入有效的 HTTP(S) 地址').max(1024),
chat_path: z.string().min(1).max(255),
models_path: z.string().min(1).max(255),
model_name: z.string().min(1).max(255),
auth_type: z.enum(['none', 'bearer', 'api_key']),
api_key: z.string().max(8192),
auth_header: z.string().min(1).max(255),
auth_prefix: z.string().max(255),
verify_ssl: z.boolean(),
}
export const providerConfigSchema = z.object({
provider_id: providerIdSchema,
source: z.enum(['database', 'env']),
base_url: z.string(),
chat_path: z.string(),
models_path: z.string(),
model_name: z.string(),
auth_type: z.string(),
auth_header: z.string(),
auth_prefix: z.string(),
verify_ssl: z.boolean(),
api_key_configured: z.boolean(),
created_at: z.string().nullable(),
updated_at: z.string().nullable(),
})
export const providerListSchema = z.array(providerConfigSchema)
export const providerCreateSchema = z.object({ provider_id: providerIdSchema, ...providerFields })
export const providerUpdateSchema = z.object(providerFields).partial()
export const providerCheckSchema = z.object({
provider_id: providerIdSchema,
endpoint: z.string(),
authentication: z.record(z.string(), z.unknown()),
model: z.string(),
reachable: z.boolean(),
response_preview: z.string(),
})
export const discoverModelsSchema = z.object({ provider_id: providerIdSchema, models: z.array(z.string()) })
export type ProviderConfig = z.infer<typeof providerConfigSchema>
export type ProviderCreate = z.infer<typeof providerCreateSchema>
export type ProviderUpdate = z.infer<typeof providerUpdateSchema>
+10
View File
@@ -0,0 +1,10 @@
import { z } from 'zod'
export const reportSummarySchema = z.record(z.string(), z.unknown())
export const reportSchema = z.object({
run_id: z.number().int().positive(),
summary: reportSummarySchema,
})
export const reportListSchema = z.array(reportSchema)
export type Report = z.infer<typeof reportSchema>
+65
View File
@@ -0,0 +1,65 @@
import { z } from 'zod'
export const runStatusSchema = z.enum([
'pending',
'probing',
'running',
'cancelled',
'completed',
'completed_with_errors',
'failed',
])
export const startRunSchema = z.object({ profile: z.enum(['smoke', 'all']), auto_judge: z.boolean() })
export const runResponseSchema = z.object({
run_id: z.number().int().positive(),
status: z.string(),
selected_count: z.number().int().nonnegative(),
})
export const resumeRunSchema = runResponseSchema.extend({ skipped_count: z.number().int().nonnegative() })
export const retryErrorsSchema = runResponseSchema.extend({ retry_count: z.number().int().positive() })
export const retryResultSchema = runResponseSchema.extend({ status: z.literal('pending'), execution_id: z.string() })
export const runDetailSchema = z
.object({
run_id: z.number().int().positive(),
status: runStatusSchema,
terminal: z.boolean(),
phase: z.string(),
selected_count: z.number().int().nonnegative(),
processed_count: z.number().int().nonnegative(),
completed_count: z.number().int().nonnegative(),
error_count: z.number().int().nonnegative(),
progress_percent: z.number().min(0).max(100),
current_execution_id: z.string().nullable(),
started_at: z.string(),
finished_at: z.string().nullable(),
error_message: z.string().nullable(),
poll_after_seconds: z.number().int().nonnegative(),
summary: z.record(z.string(), z.unknown()),
})
.superRefine((run, ctx) => {
if (run.processed_count !== run.completed_count + run.error_count) {
ctx.addIssue({ code: 'custom', message: '处理计数与完成/失败计数不一致' })
}
if (run.terminal && run.poll_after_seconds !== 0) {
ctx.addIssue({ code: 'custom', message: '终态运行不应继续轮询' })
}
})
export const runListSchema = z.array(runDetailSchema)
export const resultItemSchema = z.object({
execution_id: z.string(),
case_kind: z.string(),
interaction_mode: z.string(),
execution_status: z.enum(['completed', 'error']),
verdict: z.enum(['pass', 'fail', 'needs_human_review', 'judge_format_error']).nullable(),
model_input: z.record(z.string(), z.unknown()),
model_response: z.string(),
judge_result: z.record(z.string(), z.unknown()),
error_message: z.string(),
})
export const resultListSchema = z.array(resultItemSchema)
export type StartRunInput = z.infer<typeof startRunSchema>
export type RunDetail = z.infer<typeof runDetailSchema>
export type ResultItem = z.infer<typeof resultItemSchema>
+27
View File
@@ -0,0 +1,27 @@
import type { ApiClient } from '../api/client'
import {
createUserSchema,
loginInputSchema,
tokenSchema,
userListSchema,
userSchema,
type ChangePasswordInput,
type CreateUserInput,
type LoginInput,
} from '../schemas/auth'
export const authService = (api: ApiClient) => ({
login: (input: LoginInput) => api.request('/auth/login', { method: 'POST', body: loginInputSchema.parse(input), schema: tokenSchema }),
me: () => api.request('/auth/me', { schema: userSchema }),
logout: () => api.request('/auth/logout', { method: 'POST' }),
changePassword: (input: ChangePasswordInput) =>
api.request('/auth/password', {
method: 'PATCH',
body: { current_password: input.current_password, new_password: input.new_password },
}),
listUsers: () => api.request('/auth/users', { schema: userListSchema }),
createUser: (input: CreateUserInput) => api.request('/auth/users', { method: 'POST', body: createUserSchema.parse(input), schema: userSchema }),
setUserActive: (id: number, is_active: boolean) => api.request(`/auth/users/${id}`, { method: 'PATCH', body: { is_active }, schema: userSchema }),
resetPassword: (id: number, password: string) => api.request(`/auth/users/${id}/password`, { method: 'PATCH', body: { password } }),
deleteUser: (id: number) => api.request(`/auth/users/${id}`, { method: 'DELETE' }),
})
+4
View File
@@ -0,0 +1,4 @@
import type { ApiClient } from '../api/client'
import { healthSchema } from '../schemas/health'
export const healthService = (api: ApiClient) => ({ get: () => api.request('/health', { schema: healthSchema }) })
+14
View File
@@ -0,0 +1,14 @@
import { api } from '../api'
import { authService } from './authService'
import { healthService } from './healthService'
import { providerService } from './providerService'
import { reportService } from './reportService'
import { runService } from './runService'
export const services = {
auth: authService(api),
health: healthService(api),
providers: providerService(api),
reports: reportService(api),
runs: runService(api),
}
+22
View File
@@ -0,0 +1,22 @@
import type { ApiClient } from '../api/client'
import type { ProviderId } from '../schemas/common'
import {
discoverModelsSchema,
providerCheckSchema,
providerConfigSchema,
providerCreateSchema,
providerListSchema,
providerUpdateSchema,
type ProviderCreate,
type ProviderUpdate,
} from '../schemas/providers'
export const providerService = (api: ApiClient) => ({
list: () => api.request('/providers', { schema: providerListSchema }),
get: (id: ProviderId) => api.request(`/providers/${id}`, { schema: providerConfigSchema }),
create: (input: ProviderCreate) => api.request('/providers', { method: 'POST', body: providerCreateSchema.parse(input), schema: providerConfigSchema }),
update: (id: ProviderId, input: ProviderUpdate) => api.request(`/providers/${id}`, { method: 'PATCH', body: providerUpdateSchema.parse(input), schema: providerConfigSchema }),
remove: (id: ProviderId) => api.request(`/providers/${id}`, { method: 'DELETE' }),
check: (id: ProviderId) => api.request(`/providers/${id}/check`, { method: 'POST', schema: providerCheckSchema }),
models: (id: ProviderId) => api.request(`/providers/${id}/models`, { schema: discoverModelsSchema }),
})
+7
View File
@@ -0,0 +1,7 @@
import type { ApiClient } from '../api/client'
import { reportListSchema, reportSchema } from '../schemas/reports'
export const reportService = (api: ApiClient) => ({
list: (limit = 50) => api.request(`/reports?limit=${limit}`, { schema: reportListSchema }),
get: (id: number) => api.request(`/reports/${id}`, { schema: reportSchema }),
})
+17
View File
@@ -0,0 +1,17 @@
import type { ApiClient } from '../api/client'
import { resultListSchema, retryErrorsSchema, retryResultSchema, runDetailSchema, runListSchema, runResponseSchema, startRunSchema, resumeRunSchema, type StartRunInput } from '../schemas/runs'
const idempotencyKey = () => crypto.randomUUID()
export const runService = (api: ApiClient) => ({
list: (limit = 50) => api.request(`/runs?limit=${limit}`, { schema: runListSchema }),
get: (id: number) => api.request(`/runs/${id}`, { schema: runDetailSchema }),
start: (input: StartRunInput, key = idempotencyKey()) =>
api.request('/runs', { method: 'POST', headers: { 'Idempotency-Key': key }, body: startRunSchema.parse(input), schema: runResponseSchema }),
cancel: (id: number) => api.request(`/runs/${id}`, { method: 'PATCH', body: { status: 'cancelled' }, schema: runDetailSchema }),
remove: (id: number) => api.request(`/runs/${id}`, { method: 'DELETE' }),
resume: (id: number) => api.request(`/runs/${id}/resume`, { method: 'POST', schema: resumeRunSchema }),
retryErrors: (id: number) => api.request(`/runs/${id}/retry-errors`, { method: 'POST', schema: retryErrorsSchema }),
retryResult: (id: number, executionId: string) => api.request(`/runs/${id}/results/${encodeURIComponent(executionId)}/retry`, { method: 'POST', schema: retryResultSchema }),
results: (id: number) => api.request(`/runs/${id}/results`, { schema: resultListSchema }),
})
+99
View File
@@ -0,0 +1,99 @@
:root {
font-family: Inter, Lato, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--layout);
font-synthesis: none;
--radius-control: 8px;
--radius-surface: 16px;
--space-card: 24px;
--space-section: 24px;
--sidebar-width: 244px;
}
:root[data-theme="blue"] {
--layout: #07111f; --surface: #0d1a2b; --surface-elevated: #122238; --surface-soft: #101f34;
--text: rgba(239,245,255,.92); --text-secondary: rgba(205,220,242,.68); --border: #29405f;
--accent: #526bd6; --accent-soft: rgba(82,107,214,.18); --shadow: 0 18px 50px rgba(0,0,0,.28);
--success-text: #86e6a2; --success-bg: rgba(34,197,94,.16); --success-border: rgba(134,230,162,.42);
--error-text: #ff9b9b; --error-bg: rgba(239,68,68,.17); --error-border: rgba(255,155,155,.42);
--warning-text: #ffd078; --warning-bg: rgba(245,158,11,.17); --warning-border: rgba(255,208,120,.42);
--tag-success-text: #9af0b2; --tag-success-bg: #123522;
--tag-error-text: #ffb4b4; --tag-error-bg: #3b171a;
--tag-warning-text: #ffda8a; --tag-warning-bg: #3a2a10;
--tag-info-text: #b8c5ff; --tag-info-bg: #182650;
--tag-blue-text: #b8c5ff; --tag-blue-bg: #182650;
--tag-teal-text: #91e7d7; --tag-teal-bg: #123932;
--tag-purple-text: #d8b4fe; --tag-purple-bg: #30184d;
--tag-mauve-text: #e7b9ed; --tag-mauve-bg: #351b39;
--tag-default-text: #d7deea; --tag-default-bg: #253247;
}
:root[data-theme="light"] {
--layout: #fafafa; --surface: #fff; --surface-elevated: #fff; --surface-soft: #f4f4f7;
--text: rgba(26,26,29,.88); --text-secondary: rgba(26,26,29,.65); --border: #e1e1e7;
--accent: #615ced; --accent-soft: rgba(97,92,237,.10); --shadow: 0 16px 48px rgba(26,26,29,.08);
--success-text: #177245; --success-bg: #eaf8ef; --success-border: #a9dfbb;
--error-text: #b42318; --error-bg: #fff0ef; --error-border: #f3b5b0;
--warning-text: #8a5b00; --warning-bg: #fff7e6; --warning-border: #f1d08b;
--tag-success-text: #146c43; --tag-success-bg: #eaf8ef;
--tag-error-text: #a61b12; --tag-error-bg: #fff0ef;
--tag-warning-text: #7a4d00; --tag-warning-bg: #fff7e6;
--tag-info-text: #3730a3; --tag-info-bg: #eef2ff;
--tag-blue-text: #3730a3; --tag-blue-bg: #eef2ff;
--tag-teal-text: #0f5f55; --tag-teal-bg: #e8f8f4;
--tag-purple-text: #5b21b6; --tag-purple-bg: #f3e8ff;
--tag-mauve-text: #5f3b66; --tag-mauve-bg: #f7edf8;
--tag-default-text: #45454d; --tag-default-bg: #f0f1f4;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--layout); color: var(--text); }
button, input, select, textarea { font: inherit; }
h1, h2, h3, p { margin-top: 0; }
p, small { color: var(--text-secondary); }
.ant-card, .ant-modal-content, .ant-alert, .ant-table-wrapper, .section-block { border-radius: var(--radius-surface) !important; }
.ant-card > .ant-card-body { padding: var(--space-card); }
.ant-btn, .ant-input, .ant-input-affix-wrapper, .ant-select-selector, .ant-tag { border-radius: var(--radius-control) !important; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: var(--sidebar-width) 1fr; }
.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; padding: 24px 16px; background: color-mix(in srgb, var(--surface) 88%, transparent); border-right: 1px solid var(--border); backdrop-filter: blur(18px); }
.brand { display: flex; align-items: center; gap: 12px; padding: 0 8px 28px; }
.brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; color: white; font-weight: 800; background: var(--accent); box-shadow: 0 8px 24px var(--accent-soft); }
.brand strong, .brand small, .sidebar-footer strong, .sidebar-footer small { display: block; }
.brand small, .sidebar-footer small { margin-top: 3px; }
.sidebar nav { display: grid; gap: 6px; }
.sidebar nav a { padding: 11px 14px; border-radius: 11px; color: var(--text-secondary); text-decoration: none; font-weight: 600; transition: 160ms ease; }
.sidebar nav a:hover, .sidebar nav a.active { color: var(--text); background: var(--accent-soft); }
.sidebar-footer { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 18px 8px 0; border-top: 1px solid var(--border); }
.workspace { min-width: 0; }
.topbar { height: 68px; display: flex; align-items: center; justify-content: flex-end; gap: 12px; padding: 0 36px; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--layout) 86%, transparent); backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 10; }
.running-indicator { border: 0; background: transparent; color: var(--text-secondary); cursor: pointer; }
.content { max-width: 1480px; margin: 0 auto; padding: 38px; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; margin-bottom: 30px; }
.page-header h1 { font-size: clamp(28px, 3vw, 40px); letter-spacing: -.035em; margin-bottom: 8px; }
.page-header p { margin-bottom: 0; font-size: 15px; }
.page-actions, .action-group { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-bottom: 28px; }
.metric-grid .ant-card, .section-block, .run-hero { border: 1px solid var(--border); background: var(--surface); box-shadow: var(--shadow); }
.metric-label, .eyebrow { display: block; color: var(--text-secondary); font-size: 12px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.metric-value { display: block; margin: 16px 0 6px; font-size: 30px; letter-spacing: -.03em; }
.section-block { overflow: hidden; }
.section-heading { display: flex; align-items: center; justify-content: space-between; padding: 22px 24px; border-bottom: 1px solid var(--border); }
.section-heading h2 { margin-bottom: 4px; }.section-heading p { margin-bottom: 0; }
.run-list { display: grid; }.run-row { display: grid; grid-template-columns: 110px 1fr minmax(180px, .7fr) 120px; align-items: center; gap: 20px; width: 100%; padding: 17px 24px; color: var(--text); background: transparent; border: 0; border-bottom: 1px solid var(--border); text-align: left; cursor: pointer; }.run-row:hover { background: var(--accent-soft); }.run-id { font-weight: 800; }
.narrow-page { max-width: 760px; display: grid; gap: 18px; }.choice-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }.choice-grid label { padding: 20px; border: 1px solid var(--border); border-radius: 14px; }.choice-grid strong, .choice-grid small { display: block; margin: 4px 0; }.provider-summary { display: grid; gap: 1px; margin: 20px 0; background: var(--border); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }.provider-summary div { display: grid; grid-template-columns: 100px 1fr auto; align-items: center; padding: 14px; background: var(--surface); }.submit-row { margin-top: 20px; }.diagnostic { font-family: ui-monospace, monospace; }
.run-hero { margin-bottom: var(--space-section); }.progress-feature { display: flex; align-items: center; gap: 28px; margin: 36px 0; }.progress-feature h2 { font-size: 28px; margin-bottom: 8px; }.stat-strip { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--border); padding-top: 22px; }.stat-strip span { color: var(--text-secondary); }.stat-strip strong { display: block; color: var(--text); font-size: 24px; }.results-panel { display: grid; gap: var(--space-section); }.results-panel > .ant-select { width: 220px; }.result-card { border-color: var(--border) !important; }.result-card > .ant-card-body { display: grid; gap: var(--space-section); }.result-title { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }.result-tags { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; }.result-tags .ant-tag { margin: 0; font-weight: 650; }.result-tag-success, .result-tag-error, .result-tag-warning, .result-tag-info, .result-tag-blue, .result-tag-teal, .result-tag-purple, .result-tag-mauve, .result-tag-default { color: var(--tag-color) !important; background: var(--tag-bg) !important; border-color: color-mix(in srgb, var(--tag-color) 45%, transparent) !important; }.result-tag-success { --tag-color: var(--tag-success-text); --tag-bg: var(--tag-success-bg); }.result-tag-error { --tag-color: var(--tag-error-text); --tag-bg: var(--tag-error-bg); }.result-tag-warning { --tag-color: var(--tag-warning-text); --tag-bg: var(--tag-warning-bg); }.result-tag-info { --tag-color: var(--tag-info-text); --tag-bg: var(--tag-info-bg); }.result-tag-blue { --tag-color: var(--tag-blue-text); --tag-bg: var(--tag-blue-bg); }.result-tag-teal { --tag-color: var(--tag-teal-text); --tag-bg: var(--tag-teal-bg); }.result-tag-purple { --tag-color: var(--tag-purple-text); --tag-bg: var(--tag-purple-bg); }.result-tag-mauve { --tag-color: var(--tag-mauve-text); --tag-bg: var(--tag-mauve-bg); }.result-tag-default { --tag-color: var(--tag-default-text); --tag-bg: var(--tag-default-bg); }.result-verdict { padding: 5px 12px !important; font-weight: 750 !important; }.result-verdict::before { content: ''; display: inline-block; width: 7px; height: 7px; margin-right: 7px; border-radius: 50%; background: currentColor; vertical-align: 1px; }.result-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-section); }.result-columns section, .judge-result { min-width: 0; padding: var(--space-card); background: var(--surface-soft); border-radius: var(--radius-surface); }.result-columns pre, .judge-result pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 360px; overflow: auto; margin: 0; color: var(--text); }
.results-panel .action-group .ant-select { width: 220px; }.result-verdict { padding: 0 7px !important; }
.report-list { display: grid; gap: 18px; }.report-summary { display: grid; gap: 14px; }.report-decision { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--text-secondary); }.report-decision .ant-tag { margin: 0; padding: 5px 12px; font-size: 14px; font-weight: 700; }.report-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }.report-metrics span { padding: 14px; color: var(--text-secondary); background: var(--surface-soft); border-radius: 10px; }.report-metrics strong { display: block; color: var(--text); font-size: 24px; }.report-modes { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }.report-modes small { margin-right: 4px; color: var(--text-secondary); }
.provider-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px; }.provider-title { display: flex; justify-content: space-between; align-items: flex-start; }.provider-title h2 { margin-top: 8px; }.details { display: grid; grid-template-columns: 120px 1fr; gap: 12px; margin: 24px 0; }.details dt { color: var(--text-secondary); }.details dd { margin: 0; overflow-wrap: anywhere; }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }.account-grid { display: grid; grid-template-columns: .7fr 1.3fr; gap: 18px; }
.state-view { min-height: 240px; display: flex; align-items: center; justify-content: center; gap: 12px; color: var(--text-secondary); }
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(360px, 1.25fr) minmax(360px, .75fr); align-items: center; gap: 8vw; padding: 7vw; background: var(--layout); }.login-theme { position: fixed; top: 24px; right: 24px; }.login-intro h1 { max-width: 720px; margin: 20px 0; font-size: clamp(46px, 6vw, 84px); line-height: 1.04; letter-spacing: -.055em; }.login-intro p { max-width: 580px; font-size: 17px; line-height: 1.8; }.login-card { width: 100%; max-width: 460px; margin-left: auto; padding: 18px; border: 1px solid var(--border); background: color-mix(in srgb, var(--surface) 86%, transparent); box-shadow: var(--shadow); backdrop-filter: blur(20px); }.login-card > .ant-card-body > p { margin-bottom: 24px; }
.ant-table-wrapper { overflow: hidden; border: 1px solid var(--border); }.ant-table-row { cursor: pointer; }
@media (max-width: 960px) {
:root { --sidebar-width: 190px; }.content { padding: 26px; }.metric-grid, .provider-grid, .account-grid { grid-template-columns: 1fr; }.login-page { grid-template-columns: 1fr; }.login-intro { display: none; }.login-card { margin: auto; }.result-columns { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.app-shell { display: block; }.sidebar { position: static; width: 100%; height: auto; }.sidebar nav { grid-template-columns: repeat(2, 1fr); }.sidebar-footer { margin-top: 18px; }.topbar { position: static; padding: 12px 18px; }.content { padding: 22px 16px; }.page-header { flex-direction: column; }.metric-grid { grid-template-columns: 1fr; }.run-row { grid-template-columns: 1fr; gap: 8px; }.choice-grid, .form-grid { grid-template-columns: 1fr; }.stat-strip { grid-template-columns: repeat(2, 1fr); gap: 18px; }.progress-feature { align-items: flex-start; flex-direction: column; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />