first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
},
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AppError, type ErrorKind } from './AppError'
|
||||
export { normalizeError } from './normalizeError'
|
||||
@@ -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')
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { logger } from './logger'
|
||||
export { sanitize } from './sanitize'
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>}
|
||||
</>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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() },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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(),
|
||||
}),
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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' }),
|
||||
})
|
||||
@@ -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 }) })
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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 }),
|
||||
})
|
||||
@@ -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 }),
|
||||
})
|
||||
@@ -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 }),
|
||||
})
|
||||
@@ -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; } }
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user