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
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
coverage
.env
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:8000/api/v1
VITE_LOG_LEVEL=info
+8
View File
@@ -0,0 +1,8 @@
# Backend API root. Restart the dev server after changing it.
VITE_API_BASE_URL=http://localhost:8000/api/v1
# Frontend console log threshold: debug | info | warn | error
VITE_LOG_LEVEL=info
# Never put passwords, tokens, JUDGE_API_KEY, or TARGET_API_KEY in VITE_ variables.
# Every VITE_ variable is bundled into browser-visible frontend code.
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
coverage/
.env.local
*.log
*.tsbuildinfo
vite.config.js
vite.config.d.ts
vitest.config.js
vitest.config.d.ts
+1
View File
@@ -0,0 +1 @@
20
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
# Repository Instructions
## UI changes
Before changing UI, themes, layout, spacing, colors, or components:
1. Read `docs/ui-design.md`.
2. Reuse its global design tokens; do not introduce page-specific radii, spacing, or semantic colors.
3. Update or add a failing test before changing behavior or visual rules.
4. Run:
```bash
pnpm test -- --run tests/unit/design/consistency.test.ts
pnpm test -- --run tests/unit/design/contrast.test.ts
pnpm check
```
Switches, circular progress, avatars, and other function-defined shapes are exempt from the shared radius rule.
+15
View File
@@ -0,0 +1,15 @@
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
ARG VITE_API_BASE_URL=http://127.0.0.1:8000/api/v1
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN pnpm build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK CMD wget -qO- http://127.0.0.1/healthz || exit 1
+67
View File
@@ -0,0 +1,67 @@
# AI Safety Console Web
AI 模型内部安全测试平台的独立 React 前端。UI 使用官方 [AgentScope Spark Design](https://sparkdesign.agentscope.io/),提供可持久化切换的蓝色安全控制台和浅色专业两套主题。
## 项目结构
```text
src/
├── api/ # Fetch 客户端与 Zod 响应校验
├── auth/ # 会话、身份恢复和登退录
├── design/ # Spark tokens、两套主题与切换状态
├── errors/ # 错误类型、归一化和 React Error Boundary
├── logging/ # 结构化日志与敏感数据脱敏
├── schemas/ # Zod 请求/响应和业务不变量
├── services/ # 与全部后端端点对应的业务服务
├── pages/ # 页面交互
└── components/ # 可复用 UI 状态和主题切换
tests/
├── unit/ # 日志、错误、schema、API 和主题单元测试
└── integration/ # 端点契约和 UI 切换集成测试
docs/ # UI、交互、架构、API、测试与部署文档
```
认证支持 access/refresh token 原子轮换、活跃用户静默续约、标签页内/跨标签页 single-flight,以及业务请求 401 后的一次续约与重放。跨标签页协调使用浏览器 Web Locks API。
## 本地开发
需要 Node.js 2024 和 Corepack。
```bash
corepack enable
pnpm install --frozen-lockfile
cp .env.example .env
pnpm dev
```
默认访问 `http://localhost:5173`API 默认为 `http://127.0.0.1:8000/api/v1`。不要将模型 API Key 放入前端 `.env`;密钥只由后端保管。
## 质量检查
```bash
pnpm check
```
真实后端检查(不会使用 mock,需提供测试账户):
```bash
E2E_API_BASE_URL=http://localhost:8000/api/v1 E2E_USERNAME=admin E2E_PASSWORD=密码 pnpm test:e2e
```
该命令依次运行 ESLint、Vitest 和生产构建。
## 容器部署
```bash
VITE_API_BASE_URL=https://api.example.com/api/v1 docker compose up --build -d
```
默认映射到 `http://localhost:8080``VITE_API_BASE_URL` 是构建时变量,修改后需重新构建镜像。
## 文档
- [UI 规范(强制令牌与提交门禁)](docs/ui-design.md)
- [交互设计](docs/interaction-design.md)
- [架构与主题](docs/architecture.md)
- [API 映射](docs/api-contracts.md)
- [测试、安全与运维](docs/operations.md)
+9
View File
@@ -0,0 +1,9 @@
services:
web:
build:
context: .
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://127.0.0.1:8000/api/v1}
ports:
- "${WEB_PORT:-8080}:80"
restart: unless-stopped
+32
View File
@@ -0,0 +1,32 @@
# API 映射
所有路径相对 `VITE_API_BASE_URL`,默认为 `/api/v1` 服务。除登录外,请求都携带 Bearer token。
| 领域 | 前端操作 | 后端端点 |
| --- | --- | --- |
| 认证 | 登录、续约、当前用户、修改密码、退出 | `POST /auth/login`, `POST /auth/refresh`, `GET /auth/me`, `PATCH /auth/password`, `POST /auth/logout` |
| 用户 | 列表、创建、启停、重置密码、删除 | `GET/POST /auth/users`, `PATCH/DELETE /auth/users/{id}`, `PATCH /auth/users/{id}/password` |
| 提供商 | 列表、详情、创建、修改、删除 | `GET/POST /providers`, `GET/PATCH/DELETE /providers/{id}` |
| 提供商 | 连通检查、模型发现 | `POST /providers/{id}/check`, `GET /providers/{id}/models` |
| 运行 | 列表、创建、详情、取消、删除 | `GET/POST /runs`, `GET/PATCH/DELETE /runs/{id}` |
| 运行 | 恢复、重试错误、单条重试、逐条结果 | `POST /runs/{id}/resume`, `POST /runs/{id}/retry-errors`, `POST /runs/{id}/results/{execution_id}/retry`, `GET /runs/{id}/results` |
| 报告 | 列表、详情 | `GET /reports`, `GET /reports/{run_id}` |
| 系统 | 健康状态 | `GET /health` |
## 数据规则
- API 响应必须通过 Zod 校验,否则转为 `INVALID_RESPONSE`,不把不可信数据交给 UI。
- 运行详情必须满足 `processed_count = completed_count + error_count``error_count` 仅统计执行异常,不包含 `verdict = fail`;终态必须有 `poll_after_seconds = 0`
- 逐条结果使用 `execution_status``completed | error`)表示调用是否完成,使用可空 `verdict``pass | fail | needs_human_review | judge_format_error | null`)表示仲裁结论;前端不读取旧 `status`
- 逐条结果的执行 ID 搜索直接使用 `GET /runs/{id}/results` 已返回的 `execution_id` 在 UI 过滤,不新增请求参数或后端端点。
- 报告的 `summary.execution_statuses``summary.verdicts` 分开统计;`by_mode` 下也分别包含 `execution_statuses``verdicts`
- `POST /runs/{id}/retry-errors` 只重试 `execution_status = error`,不重试任何仲裁结论。
- `POST /runs/{id}/results/{execution_id}/retry` 无请求体,沿用原 Run 的测试范围和自动仲裁配置,并以相同 `execution_id` 替换旧结果;仅 `completed / completed_with_errors` 状态允许提交。
- `POST /runs/{id}/resume` 无请求体,返回 `skipped_count``POST /runs/{id}/retry-errors` 无请求体,返回 `retry_count`。两者返回 202 后必须重新获取运行详情,不能将 POST 响应视为执行完成;409 时也重新获取详情。
- 单条重试返回 202 后按运行详情的 `poll_after_seconds`(缺省 2 秒)轮询,终态后重新获取结果。重跑期间旧结果可能暂时不存在,UI 保留“重新执行中”状态;404 刷新结果,409 刷新 Run,网络错误或 5xx 先刷新 Run,避免不确定是否受理时重复提交。
- `POST /runs` 总是携带新的 UUID `Idempotency-Key`
- 登录和续约都会原子替换 access/refresh token 及两个过期时间。
- access token 剩余不超过 30 分钟且用户最近 30 分钟有真实操作时,`POST /auth/refresh` 携带 UUID `Idempotency-Key`
- 网络错误和 5xx 最多尝试 3 次,且一直复用原 refresh token 和原 key409 保留凭据并等待 `refresh_after`
- 普通业务请求首次 401 共享 single-flight 续约,成功后仅重放一次;只有 refresh 本身返回 401 才清除会话。
- HTTP 204 按无响应体处理;403/404/409/422/502 映射为可操作的用户消息。
+28
View File
@@ -0,0 +1,28 @@
# 架构与主题
## 数据流
UI 页面只调用 `services/`service 先用 `schemas/` 中的 Zod schema 校验请求,再由 `api/client.ts` 发起请求并用 Zod 校验响应。跨字段业务不变量也在 schema 层完成。错误统一进入 `errors/`,日志进入 `logging/` 并在输出前递归脱敏。
```text
pages/components → services → schemas + api client → FastAPI
errors + logging
```
## Spark Design
组件仅从官方 `@agentscope-ai/design` 引入。`src/design/theme.ts` 是主题注册表,它将 Spark/Ant Design token 与业务页分开;`ThemeProvider.tsx` 只负责注入主题和切换状态;`styles.css` 使用语义 CSS 变量,不在页面内写主题颜色。
当前主题:
- `blue`:默认的深蓝安全控制台。
- `light`:以 Spark 官方 `#615CED` 为主色、`#FAFAFA` 为布局底色的浅色方案。
新增主题时,扩展 `ThemeName``themes` 注册表和对应 `[data-theme]` CSS 变量,再把两态切换控件改为选择器即可,不需要修改业务页。
## 认证与路由
access token、refresh token 和两个过期时间作为一个 JSON 对象原子保存在 `localStorage`,使多标签页共享同一会话。启动时通过 `/auth/me` 恢复身份。`AuthGuard` 保护业务路由,`AdminGuard` 保护用户管理。
`auth/activity.ts` 只记录点击、键盘、触摸和导航,30 秒内最多写入一次;轮询和页面可见本身不算活动。`auth/refresh.ts` 在用户活跃且 access token 进入最后 30 分钟时静默续约。标签页内用共享 Promise,标签页间用 Web Locks 保证只有一个 refresh;待刷新的业务请求用新 access token 重放一次。refresh 401 才清理会话,续约成功不刷新路由、当前用户查询或业务状态。
+28
View File
@@ -0,0 +1,28 @@
# 交互设计
## 主流程
1. 用户登录,系统用 `/auth/me` 确认身份和权限。
活跃用户在 access token 剩余不超过 30 分钟时静默续约;续约不跳转、不刷新页面,不清空当前任务。
2. 概览页确认后端健康、模型配置和近期运行。
3. 管理员在模型配置页建立 target/judge 配置,可查询模型并执行最小连通性检查。
4. 用户选择 smoke/all 与是否自动裁判,确认后创建运行。客户端自动携带 UUID 幂等键,避免重复提交。
5. 详情页展示探测、执行、裁判和完成阶段,按后端 `poll_after_seconds` 轮询。
6. 运行中可取消;中断后可恢复;局部错误可只重试错误样例;终态后可查看逐条结果、汇总报告或确认删除。
`cancelled / failed / pending / probing / running` 可确认后“恢复运行”;`completed_with_errors``error_count > 0` 可确认后“重试错误(N)”。提交期间按钮禁用,成功提示跳过或重试数量;409 会刷新详情以校正操作状态。
7. 逐条结果可按执行状态、仲裁结论筛选,并可按执行 ID 搜索;三项条件组合生效,执行 ID 支持忽略大小写的片段匹配。
8. `completed / completed_with_errors` 的每条结果可确认后单独“重新执行”。提交中仅该按钮显示“提交中…”,受理后该 Run 的全部单条重试按钮锁定并显示目标执行 ID“重新执行中…”,直到 Run 再次进入终态。目标结果会进入跟踪状态并置顶,暂时绕过执行状态、仲裁结论和执行 ID 筛选,用户可手动停止跟踪;原筛选值始终保留。
## 模型等待交互
- 创建成功后立即进入运行详情,不用一个无信息的全屏 loading 阻塞。
- Steps 显示当前阶段,环形进度和成功/错误/总数提供可量化反馈,当前样例 ID 说明系统仍在前进。
- 文案明确告知“可安全离开”;顶栏在全站显示活跃运行数,返回后会恢复轮询。
- 运行期间每 3 秒同步已落库的逐条结果。运行进入终态时会立即执行最后一次结果同步,再停止轮询,避免错过最后落库的样例。
- 终态是停止常规轮询的唯一条件。网络错误显示可重试状态,不把未知状态误报为失败。
- 恢复或重试成功只表示任务已重新排队;详情立即刷新,并继续严格使用最新 `poll_after_seconds` 轮询,直到 `terminal = true`
- 单条重跑会短暂删除旧结果;结果列表找不到目标执行 ID 时继续显示“正在等待跟踪结果…”,终态后用相同执行 ID 置顶展示新结果,即使新状态不符合原筛选也不消失。网络错误或 5xx 先刷新 Run 状态,发现已进入执行态时不重复提交。
## 闭环与破坏性操作
创建、修改、检查、重试等操作都有进行中状态和成功/失败反馈。取消、删除、禁用用户、重置密码等高风险操作要求确认。修改本人密码后所有旧 token 立即失效,前端清理会话并要求使用新密码登录。
+33
View File
@@ -0,0 +1,33 @@
# 测试、安全与运维
## 测试
`tests/unit` 覆盖脱敏、错误归一化、主题持久化、API 响应校验、认证原子存储、single-flight 续约、幂等重试、401 单次重放、运行数据不变量、结果语义标识和运行进入终态时的最终结果同步。`tests/integration` 验证主题切换、逐条结果筛选与搜索、单条重试结果跨筛选置顶跟踪等页面交互,以及所有 service 到后端路由的映射。
```bash
pnpm test
pnpm lint
pnpm build
```
## 安全
- 前端不包含 `TARGET_API_KEY``JUDGE_API_KEY`,也不应在 Vite 变量中暴露它们。
- 完整认证对象保存在 `localStorage` 以支持多标签页原子轮换,不分开写入 access/refresh token;生产环境必须使用 HTTPS 并保持严格 CSP,降低 XSS 窃取风险。
- 续约的待处理 refresh token 和 `Idempotency-Key` 作为一组保存;网络/5xx 失败后不换 key,refresh 401 或用户主动退出才清理凭据。
- logger 对 password、token、API key、Authorization 等键及嵌套数据递归脱敏。
- UI 权限控制只用于交互,真实授权必须由后端执行。
- 生产环境应使用 HTTPS,并将后端 CORS 限制为实际前端域名。
## 日志与错误
`VITE_LOG_LEVEL` 可为 `debug` / `info` / `warn` / `error`。生产环境推荐 `warn`。日志是结构化对象,但不发送到外部系统;如果日后引入观测平台,只需替换 `logging/logger.ts` 的输出端。React Error Boundary 处理未捕获的渲染错误,请求错误由页面提供重试入口。
## 部署
Docker 采用 Node 构建 + Nginx 静态服务两阶段镜像。Nginx 配置了 SPA history fallback、静态资源缓存和 `/healthz`。API 地址在构建时写入:
```bash
docker build --build-arg VITE_API_BASE_URL=https://api.example.com/api/v1 -t ai-safety-console-web .
docker run --rm -p 8080:80 ai-safety-console-web
```
+63
View File
@@ -0,0 +1,63 @@
# UI 设计方案
## 设计目标
界面服务于高风险模型测试操作:优先显示系统状态、当前进度、错误原因和下一步操作,同时保留类 Apple 产品的克制、层次和留白。不用装饰性动画掩盖真实等待,不将密钥或敏感请求数据作为视觉素材。
## 组件基础
按钮、表单、卡片、表格、标签、弹窗、步骤、进度和空状态统一使用项目现有的 `antd`。禁止页面自行引入第二套组件库或局部重写组件形状。两套主题只能替换颜色,不得改变组件尺寸、圆角和信息层级。
## 强制设计令牌
以下令牌定义在 `src/styles.css`,所有页面必须复用:
| 令牌 | 值 | 用途 |
| --- | --- | --- |
| `--radius-control` | `8px` | Button、Input、Select、Tag 等控件 |
| `--radius-surface` | `16px` | Card、Modal、Alert、Table 等容器 |
| `--space-card` | `24px` | 卡片和内容面板内边距 |
| `--space-section` | `24px` | 同层内容区块之间的间距 |
禁止在页面样式中新增一次性胶囊圆角(例如 `999px`)或为相同层级使用不同圆角。Switch、圆形进度和头像等由功能决定形状的组件除外。需要新尺寸时,先扩展全局令牌并补充一致性测试,再在页面使用。
颜色必须来自 `src/styles.css` 的主题变量或 `src/design/theme.ts` 的 Ant Design token。普通文字与背景对比度至少为 WCAG AA `4.5:1`;大号文字至少 `3:1`。状态不得只靠颜色表达,必须同时保留文字或图标。
## 页面布局规则
- Card 内容默认使用 `--space-card`,同级 Card 或内容区块使用 `--space-section`
- 页面不得通过负 margin 或相邻零间距制造视觉粘连。
- 同一组标签尺寸、圆角和内边距必须一致;结论只通过语义色、字重和状态点强化。
- 多列内容在 960px 以下收为单列;700px 以下保证导航、表单和统计区可重排。
- 新页面优先复用现有 PageHeader、AsyncState、ReportSummary 和 Ant Design 组件。
## 两套可切换方案
| 方案 | 用途 | 视觉特征 |
| --- | --- | --- |
| `blue` | 默认安全控制台 | 使用 Ant Design 深色算法,深蓝布局、高对比信息、冷色状态强调,适合长时间监控 |
| `light` | 浅色专业工作台 | 浅灰布局、白色表面、Spark 紫色主操作,适合报告审阅 |
登录页和应用顶栏均提供“切换前端 UI 方案”按钮。选择保存在 `localStorage`,刷新和重新登录后仍保留。
## 页面层级
- 登录:品牌与价值主张在左,唯一主任务登录在右;窄屏只保留登录任务。
- 应用框架:侧边栏承载主导航与身份,顶栏显示活跃任务和主题切换,内容区只保留当前业务。
- 数据概览:先显示可行动指标,再显示最近运行,不使用无决策价值的装饰图表。
- 表单:按完成任务的顺序分组,就地校验,主提交操作保持唯一。
- 运行详情:阶段、进度、计数和当前样例位于首屏,结果和报告位于下方标签页。
- 结果卡片:顶部使用统一 Tag 明确标识“类型 / 模式 / 执行状态 / 仲裁结论”,将后端原始值转为中文语义。执行状态和仲裁结论使用两个独立筛选器,不混合 `completed/error``pass/fail`;执行 ID 搜索与两个筛选器组合生效,并支持忽略大小写的片段匹配。所有标签使用相同的 `8px` 圆角;结论只增加字重和状态点。通过为绿色、未通过或错误为红色、待复核为橙色,文字始终保留。
- 结果卡片在 Run 完成或部分错误时提供“重新执行”;使用 Ant Design 确认框说明结果将被替换。提交、运行中状态以按钮文字和信息提示表达,不只依赖颜色;Run 非终态时同 Run 的全部单条重试按钮禁用。重试目标使用信息提示说明跟踪状态并置顶显示,不修改用户已有筛选;提示提供“停止跟踪”按钮。
## 响应式与可访问性
960px 以下将多列卡片收为单列,700px 以下将侧边栏改为页首导航。主题切换和状态不仅依赖颜色,破坏性操作使用文字和确认弹窗。系统尊重 `prefers-reduced-motion`,所有主要交互均使用语义按钮、表单标签和可见错误文案。
## 提交门禁
所有 UI 修改提交前必须运行 `pnpm check`。其中:
- `tests/unit/design/consistency.test.ts` 防止圆角、间距和规范文档漂移。
- `tests/unit/design/contrast.test.ts` 校验双主题标签与主按钮对比度。
- `tests/integration/pages.test.tsx` 验证全部页面可挂载及关键路由行为。
+19
View File
@@ -0,0 +1,19 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist', 'coverage'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: { ecmaVersion: 2022, globals: globals.browser },
plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#07111f" />
<title>AI 模型安全测试平台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 7d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}
+51
View File
@@ -0,0 +1,51 @@
{
"name": "ai-safety-console-web",
"version": "1.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@9.15.9",
"engines": {
"node": ">=20 <25",
"pnpm": ">=9 <10"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest run",
"test:e2e": "vitest run tests/e2e --environment node",
"test:watch": "vitest",
"check": "eslint . && vitest run && tsc -b && vite build"
},
"dependencies": {
"@tanstack/react-query": "5.90.20",
"antd": "5.27.6",
"classnames": "2.5.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "7.9.6",
"zod": "4.1.12"
},
"devDependencies": {
"@eslint/js": "9.39.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/node": "24.10.1",
"@types/react": "18.3.27",
"@types/react-dom": "18.3.7",
"@vitejs/plugin-react": "5.1.1",
"@vitest/coverage-v8": "4.0.14",
"eslint": "9.39.1",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0",
"jsdom": "27.2.0",
"msw": "2.12.3",
"typescript": "5.9.3",
"typescript-eslint": "8.48.0",
"vite": "7.2.4",
"vitest": "4.0.14"
}
}
+6295
View File
File diff suppressed because it is too large Load Diff
+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" />
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from 'vitest'
const baseUrl = import.meta.env.E2E_API_BASE_URL?.replace(/\/$/, '')
const username = import.meta.env.E2E_USERNAME
const password = import.meta.env.E2E_PASSWORD
test('real backend is healthy and accepts an authenticated request', async () => {
expect(baseUrl, '请设置 E2E_API_BASE_URL').toBeTruthy()
expect(username, '请设置 E2E_USERNAME').toBeTruthy()
expect(password, '请设置 E2E_PASSWORD').toBeTruthy()
const health = await fetch(`${baseUrl}/health`)
expect(health.status).toBe(200)
expect(await health.json()).toMatchObject({ status: 'ok' })
const unauthorized = await fetch(`${baseUrl}/reports?limit=1`)
expect(unauthorized.status).toBe(401)
const login = await fetch(`${baseUrl}/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }),
})
expect(login.status).toBe(200)
const token = (await login.json()).access_token
expect(token).toBeTruthy()
const reports = await fetch(`${baseUrl}/reports?limit=1`, { headers: { Authorization: `Bearer ${token}` } })
expect(reports.status).toBe(200)
expect(await reports.json()).toBeInstanceOf(Array)
})
+210
View File
@@ -0,0 +1,210 @@
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { App } from '../../src/app/App'
import { AuthProvider } from '../../src/auth/AuthProvider'
import { ThemeProvider } from '../../src/design/ThemeProvider'
const mocks = vi.hoisted(() => ({
me: vi.fn(),
health: vi.fn(),
providers: vi.fn(),
runs: vi.fn(),
run: vi.fn(),
resume: vi.fn(),
retryErrors: vi.fn(),
retryResult: vi.fn(),
results: vi.fn(),
reports: vi.fn(),
report: vi.fn(),
}))
vi.mock('../../src/services', () => ({ services: {
auth: { me: mocks.me, login: vi.fn(), logout: vi.fn(), changePassword: vi.fn(), listUsers: vi.fn().mockResolvedValue([]) },
health: { get: mocks.health },
providers: { list: mocks.providers },
runs: { list: mocks.runs, get: mocks.run, resume: mocks.resume, retryErrors: mocks.retryErrors, retryResult: mocks.retryResult, results: mocks.results },
reports: { list: mocks.reports, get: mocks.report },
} }))
const user = { id: 1, username: 'admin', is_active: true, is_admin: true }
const run = {
run_id: 1, status: 'completed', terminal: true, phase: 'completed', selected_count: 0,
processed_count: 0, completed_count: 0, error_count: 0, progress_percent: 100,
current_execution_id: null, started_at: '2026-01-01T00:00:00Z', finished_at: '2026-01-01T00:01:00Z',
error_message: null, poll_after_seconds: 0, summary: {},
}
function mount(path: string, authenticated = true) {
history.replaceState({}, '', path)
if (authenticated) localStorage.setItem('safety-session', JSON.stringify({
access_token: 'token', token_type: 'bearer', expires_at: Math.floor(Date.now() / 1000) + 3_600,
refresh_token: 'refresh', refresh_expires_at: Math.floor(Date.now() / 1000) + 28_800,
}))
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return render(<QueryClientProvider client={client}><ThemeProvider><AuthProvider><App /></AuthProvider></ThemeProvider></QueryClientProvider>)
}
beforeEach(() => {
mocks.me.mockResolvedValue(user)
mocks.health.mockResolvedValue({ status: 'ok', environment: 'test', version: '1' })
mocks.providers.mockResolvedValue([])
mocks.runs.mockResolvedValue([])
mocks.run.mockResolvedValue(run)
mocks.resume.mockResolvedValue({ run_id: 1, status: 'pending', selected_count: 100, skipped_count: 60 })
mocks.retryErrors.mockResolvedValue({ run_id: 1, status: 'pending', selected_count: 100, retry_count: 5 })
mocks.retryResult.mockResolvedValue({ run_id: 1, status: 'pending', selected_count: 1, execution_id: 'R0049' })
mocks.results.mockResolvedValue([])
mocks.reports.mockResolvedValue([])
mocks.report.mockResolvedValue({ run_id: 1, summary: { test_result: {}, admission: { decision: 'pass', coverage: {} } } })
})
afterEach(() => {
cleanup()
localStorage.clear()
vi.clearAllMocks()
})
describe('page mounting and route security', () => {
it.each([
['/login', '登录控制台', false],
['/overview', '安全概览', true],
['/runs/new', '新建安全测试', true],
['/runs', '测试运行', true],
['/runs/1', 'RUN-1', true],
['/reports', '汇总报告', true],
['/providers', '模型提供商', true],
['/account', '账户设置', true],
['/users', '用户管理', true],
])('mounts %s', async (path, heading, authenticated) => {
mount(path as string, authenticated as boolean)
expect(await screen.findByRole('heading', { name: heading as string })).toBeInTheDocument()
})
it('redirects a visitor without a token to login', async () => {
mount('/reports', false)
expect(await screen.findByText('登录控制台')).toBeInTheDocument()
expect(location.pathname).toBe('/login')
})
it('fails closed when the stored token cannot be verified', async () => {
mocks.me.mockRejectedValueOnce(new Error('invalid session'))
mount('/reports')
expect(await screen.findByText('登录控制台')).toBeInTheDocument()
expect(location.pathname).toBe('/login')
})
it('renders a decision summary and opens its run details', async () => {
mocks.reports.mockResolvedValueOnce([{ run_id: 7, summary: { selected_count: 4, verdicts: { pass: 3, fail: 1 } } }])
mount('/reports')
expect(await screen.findByText('存在风险')).toBeInTheDocument()
expect(screen.queryByText('verdicts')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: '查看详情' }))
expect(location.pathname).toBe('/runs/7')
})
it('separates execution-status and verdict filters on run results', async () => {
mocks.results.mockResolvedValueOnce([
{ execution_id: 'R0001', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'fail', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
{ execution_id: 'R0002', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
])
mount('/runs/1')
expect(await screen.findAllByText('执行状态:已完成')).toHaveLength(2)
expect(screen.getByText('结论:未通过')).toBeInTheDocument()
expect(screen.getByText('逐条结果 (2 / 2)')).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: '执行状态' })).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: '仲裁结论' })).toBeInTheDocument()
expect(screen.queryByText('全部状态')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('combobox', { name: '仲裁结论' }))
await userEvent.click(await screen.findByText('不通过'))
expect(screen.getAllByText('仲裁结论:不通过')).toHaveLength(2)
expect(screen.getByText('逐条结果 (1 / 2)')).toBeInTheDocument()
})
it('searches run results by execution ID', async () => {
mocks.results.mockResolvedValueOnce([
{ execution_id: 'R0001', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'fail', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
{ execution_id: 'R0029', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
])
mount('/runs/1')
await screen.findByText('R0001')
await userEvent.type(screen.getByRole('searchbox', { name: '执行 ID' }), 'r0029')
expect(screen.queryByText('R0001')).not.toBeInTheDocument()
expect(screen.getByText('R0029')).toBeInTheDocument()
expect(screen.getByText('逐条结果 (1 / 2)')).toBeInTheDocument()
})
it('confirms and resumes an interrupted run with skipped-result feedback', async () => {
mocks.run.mockResolvedValueOnce({ ...run, status: 'cancelled' })
mount('/runs/1')
await userEvent.click(await screen.findByRole('button', { name: '恢复运行' }))
expect(screen.getByText('将继续执行未完成样例,已有结果不会重复执行。')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'OK' }))
expect(mocks.resume).toHaveBeenCalledWith(1)
expect(await screen.findByText('运行已恢复,将跳过 60 条已有结果')).toBeInTheDocument()
})
it('only retries runs completed with errors and reports the queued count', async () => {
mocks.run.mockResolvedValueOnce({ ...run, status: 'completed_with_errors', error_count: 5, processed_count: 5 })
mount('/runs/1')
await userEvent.click(await screen.findByRole('button', { name: '重试错误(5' }))
expect(screen.getByText('将重新执行 5 条错误样例,已有成功结果会保留。')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'OK' }))
expect(mocks.retryErrors).toHaveBeenCalledWith(1)
expect(await screen.findByText('已重新提交 5 条错误样例')).toBeInTheDocument()
})
it('confirms and retries one result, then locks every result retry button', async () => {
mocks.results.mockResolvedValueOnce([
{ execution_id: 'R0049', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'fail', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
{ execution_id: 'R0050', case_kind: 'control', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
])
mount('/runs/1')
await userEvent.click(await screen.findByRole('button', { name: '重新执行 R0049' }))
expect(screen.getByText('当前测试结果将被新结果替换。')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'OK' }))
expect(mocks.retryResult).toHaveBeenCalledWith(1, 'R0049')
expect(await screen.findByRole('button', { name: '重新执行中…' })).toBeDisabled()
expect(screen.getByRole('button', { name: '重新执行 R0050' })).toBeDisabled()
})
it('keeps a tracked retry visible when its verdict no longer matches the filter', async () => {
mocks.results.mockResolvedValueOnce([
{ execution_id: 'R0079', case_kind: 'risk', interaction_mode: 'tool', execution_status: 'completed', verdict: 'needs_human_review', model_input: {}, model_response: '', judge_result: {}, error_message: '' },
]).mockResolvedValue([
{ execution_id: 'R0079', case_kind: 'risk', interaction_mode: 'tool', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'new response', judge_result: {}, error_message: '' },
])
mocks.retryResult.mockResolvedValueOnce({ run_id: 1, status: 'pending', selected_count: 1, execution_id: 'R0079' })
mount('/runs/1')
await userEvent.click(await screen.findByRole('combobox', { name: '仲裁结论' }))
await userEvent.click(await screen.findByText('人工复核'))
await userEvent.click(screen.getByRole('button', { name: '重新执行 R0079' }))
await userEvent.click(screen.getByRole('button', { name: 'OK' }))
expect(await screen.findByText('结论:通过')).toBeInTheDocument()
expect(screen.getAllByText('仲裁结论:人工复核')).not.toHaveLength(0)
expect(screen.getByText('正在跟踪 R0079;即使不符合当前筛选也会置顶显示。')).toBeInTheDocument()
})
it('refreshes run details after an action conflict', async () => {
mocks.run.mockResolvedValueOnce({ ...run, status: 'failed' })
mocks.resume.mockRejectedValueOnce(Object.assign(new Error('当前状态不允许恢复'), { status: 409 }))
mount('/runs/1')
await userEvent.click(await screen.findByRole('button', { name: '恢复运行' }))
await userEvent.click(screen.getByRole('button', { name: 'OK' }))
expect(await screen.findByText('当前状态不允许恢复')).toBeInTheDocument()
expect(mocks.run).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest'
import type { ApiClient } from '../../src/api/client'
import { authService } from '../../src/services/authService'
import { healthService } from '../../src/services/healthService'
import { providerService } from '../../src/services/providerService'
import { reportService } from '../../src/services/reportService'
import { runService } from '../../src/services/runService'
const fakeApi = () => ({ request: vi.fn().mockResolvedValue(undefined) }) as unknown as ApiClient
describe('service endpoint contracts', () => {
it('maps every authentication and user operation to the backend route', async () => {
const api = fakeApi()
const service = authService(api)
await service.login({ username: 'user', password: 'password' })
await service.me()
await service.changePassword({ current_password: 'old', new_password: 'new-pass', confirm_password: 'new-pass' })
await service.listUsers()
await service.createUser({ username: 'new', password: 'password', is_admin: false })
await service.setUserActive(2, false)
await service.resetPassword(2, 'new-pass')
await service.deleteUser(2)
await service.logout()
expect(vi.mocked(api.request).mock.calls.map(([path]) => path)).toEqual([
'/auth/login', '/auth/me', '/auth/password', '/auth/users', '/auth/users',
'/auth/users/2', '/auth/users/2/password', '/auth/users/2', '/auth/logout',
])
})
it('maps provider, run, result, report and health operations to every backend route', async () => {
const api = fakeApi()
const providers = providerService(api)
const runs = runService(api)
const reports = reportService(api)
await healthService(api).get()
await providers.list(); await providers.get('target')
await providers.create({ provider_id: 'target', base_url: 'https://example.com', chat_path: '/chat', models_path: '/models', model_name: 'm', auth_type: 'none', api_key: '', auth_header: 'Authorization', auth_prefix: 'Bearer', verify_ssl: true })
await providers.update('target', { model_name: 'm2' }); await providers.check('target'); await providers.models('target'); await providers.remove('target')
const idempotencyKey = '00000000-0000-4000-8000-000000000001'
await runs.list(); await runs.get(1); await runs.start({ profile: 'smoke', auto_judge: true }, idempotencyKey); await runs.cancel(1); await runs.resume(1); await runs.retryErrors(1); await runs.retryResult(1, 'R0049'); await runs.results(1); await runs.remove(1)
await reports.list(); await reports.get(1)
expect(vi.mocked(api.request).mock.calls.map(([path]) => path)).toEqual([
'/health', '/providers', '/providers/target', '/providers', '/providers/target',
'/providers/target/check', '/providers/target/models', '/providers/target',
'/runs?limit=50', '/runs/1', '/runs', '/runs/1', '/runs/1/resume',
'/runs/1/retry-errors', '/runs/1/results/R0049/retry', '/runs/1/results', '/runs/1', '/reports?limit=50', '/reports/1',
])
expect(vi.mocked(api.request).mock.calls[10][1]).toMatchObject({ headers: { 'Idempotency-Key': idempotencyKey } })
})
})
+15
View File
@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { ThemeToggle } from '../../src/components/ThemeToggle'
import { ThemeProvider } from '../../src/design/ThemeProvider'
describe('ThemeToggle', () => {
it('switches between the independent blue and light schemes', async () => {
localStorage.clear()
render(<ThemeProvider><ThemeToggle /></ThemeProvider>)
await userEvent.click(screen.getByRole('button', { name: '切换前端 UI 方案' }))
expect(document.documentElement.dataset.theme).toBe('light')
expect(localStorage.getItem('safety-theme')).toBe('light')
})
})
+15
View File
@@ -0,0 +1,15 @@
import '@testing-library/jest-dom/vitest'
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
})
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { ApiClient } from '../../../src/api/client'
describe('ApiClient', () => {
afterEach(() => vi.restoreAllMocks())
it('handles empty 204 responses', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 }))
await expect(new ApiClient('/api', () => 'token').request('/logout', { method: 'POST' })).resolves.toBeUndefined()
})
it('validates successful responses with zod', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ id: 'wrong' }))
await expect(
new ApiClient('/api', () => null).request('/me', { schema: z.object({ id: z.number() }) }),
).rejects.toMatchObject({ kind: 'validation' })
})
it('refreshes and replays a business request once after 401', async () => {
let token = 'old-token'
const refresh = vi.fn(async (force?: boolean) => {
if (!force) return false
token = 'new-token'
return true
})
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(Response.json({ error: { message: '需要有效的访问令牌' } }, { status: 401 }))
.mockResolvedValueOnce(Response.json({ ok: true }))
await expect(new ApiClient('/api', () => token, refresh).request('/runs/1')).resolves.toEqual({ ok: true })
expect(refresh).toHaveBeenNthCalledWith(1, false)
expect(refresh).toHaveBeenCalledWith(true)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(new Headers(fetchMock.mock.calls[1][1]?.headers).get('Authorization')).toBe('Bearer new-token')
})
it('does not refresh or replay a request more than once', async () => {
const refresh = vi.fn().mockImplementation(async (force?: boolean) => Boolean(force))
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
Response.json({ error: { message: '需要有效的访问令牌' } }, { status: 401 }),
)
await expect(new ApiClient('/api', () => 'token', refresh).request('/me')).rejects.toMatchObject({ kind: 'authentication' })
expect(refresh).toHaveBeenCalledTimes(2)
expect(refresh).toHaveBeenLastCalledWith(true)
expect(fetch).toHaveBeenCalledTimes(2)
})
})
+33
View File
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { activity } from '../../../src/auth/activity'
describe('user activity', () => {
beforeEach(() => localStorage.clear())
it('records real input at most once every 30 seconds', () => {
const checkRefresh = vi.fn()
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000)
const stop = activity.start(checkRefresh)
window.dispatchEvent(new MouseEvent('click'))
expect(activity.get()).toBe(1_000_000)
now.mockReturnValue(1_010_000)
window.dispatchEvent(new KeyboardEvent('keydown'))
expect(activity.get()).toBe(1_000_000)
now.mockReturnValue(1_031_000)
window.dispatchEvent(new TouchEvent('touchstart'))
expect(activity.get()).toBe(1_031_000)
expect(checkRefresh).toHaveBeenCalledTimes(3)
stop()
})
it('checks on foreground resume without treating visibility as activity', () => {
const checkRefresh = vi.fn()
const stop = activity.start(checkRefresh)
document.dispatchEvent(new Event('visibilitychange'))
expect(checkRefresh).toHaveBeenCalledOnce()
expect(activity.get()).toBe(0)
window.dispatchEvent(new PopStateEvent('popstate'))
expect(activity.get()).toBeGreaterThan(0)
stop()
})
})
+101
View File
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { activity } from '../../../src/auth/activity'
import { createAuthRefresh } from '../../../src/auth/refresh'
import { session } from '../../../src/auth/session'
const now = 1_800_000_000
const credentials = (overrides = {}) => ({
access_token: 'access-a', token_type: 'bearer' as const, expires_at: now + 1_800,
refresh_token: 'refresh-a', refresh_expires_at: now + 28_800, ...overrides,
})
const refreshed = credentials({ access_token: 'access-b', refresh_token: 'refresh-b', expires_at: now + 3_600 })
describe('auth refresh', () => {
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
vi.spyOn(Date, 'now').mockReturnValue(now * 1000)
session.set(credentials())
activity.record(true)
})
it('does not refresh before the renewal window or while inactive', async () => {
const fetcher = vi.fn()
session.set(credentials({ expires_at: now + 1_801 }))
expect(await createAuthRefresh('/api', fetcher)()).toBe(false)
session.set(credentials())
localStorage.setItem('safety-last-activity-at', String((now - 1_801) * 1000))
expect(await createAuthRefresh('/api', fetcher)()).toBe(false)
expect(fetcher).not.toHaveBeenCalled()
})
it('uses one in-flight refresh and atomically rotates all credentials', async () => {
let resolve!: (response: Response) => void
const fetcher = vi.fn(() => new Promise<Response>((done) => { resolve = done }))
const refresh = createAuthRefresh('/api', fetcher)
const first = refresh()
const second = refresh()
resolve(Response.json(refreshed))
await expect(Promise.all([first, second])).resolves.toEqual([true, true])
expect(fetcher).toHaveBeenCalledOnce()
expect(session.get()).toEqual(refreshed)
})
it('reuses the refresh token and idempotency key for network and 5xx retries', async () => {
const fetcher = vi.fn()
.mockRejectedValueOnce(new TypeError('offline'))
.mockResolvedValueOnce(Response.json({ error: { message: 'down' } }, { status: 503 }))
.mockResolvedValueOnce(Response.json(refreshed))
await expect(createAuthRefresh('/api', fetcher)()).resolves.toBe(true)
const calls = fetcher.mock.calls.map(([, init]) => ({
key: new Headers(init?.headers).get('Idempotency-Key'), body: init?.body,
}))
expect(new Set(calls.map(({ key }) => key)).size).toBe(1)
expect(new Set(calls.map(({ body }) => body)).size).toBe(1)
})
it('keeps the same idempotency key after all network retries fail', async () => {
const offline = vi.fn().mockRejectedValue(new TypeError('offline'))
await expect(createAuthRefresh('/api', offline)()).rejects.toMatchObject({ kind: 'network' })
const firstKey = new Headers(offline.mock.calls[0][1]?.headers).get('Idempotency-Key')
const recovered = vi.fn().mockResolvedValue(Response.json(refreshed))
await expect(createAuthRefresh('/api', recovered)()).resolves.toBe(true)
expect(new Headers(recovered.mock.calls[0][1]?.headers).get('Idempotency-Key')).toBe(firstKey)
})
it('coordinates refreshes with the browser cross-tab lock', async () => {
const request = vi.fn(async (_name: string, callback: () => Promise<boolean>) => callback())
Object.defineProperty(navigator, 'locks', { configurable: true, value: { request } })
await createAuthRefresh('/api', vi.fn().mockResolvedValue(Response.json(refreshed)))()
expect(request).toHaveBeenCalledWith('safety-auth-refresh', expect.any(Function))
Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined })
})
it('keeps credentials on 409 and clears them only when refresh returns 401', async () => {
const conflictFetch = vi.fn().mockResolvedValue(Response.json({
error: { code: 'REFRESH_NOT_DUE', message: 'not due', details: { refresh_after: now + 60 } },
}, { status: 409 }))
const conflict = createAuthRefresh('/api', conflictFetch)
await expect(conflict()).resolves.toBe(false)
await expect(conflict()).resolves.toBe(false)
expect(conflictFetch).toHaveBeenCalledOnce()
expect(session.get()).toEqual(credentials())
localStorage.removeItem('safety-refresh-after')
const unauthorized = createAuthRefresh('/api', vi.fn().mockResolvedValue(
Response.json({ error: { code: 'UNAUTHORIZED', message: 'expired' } }, { status: 401 }),
))
await expect(unauthorized(true)).rejects.toMatchObject({ kind: 'authentication' })
expect(session.get()).toBeNull()
expect(localStorage.getItem('safety-refresh-pending')).toBeNull()
})
it('does not retry malformed refresh requests', async () => {
const fetcher = vi.fn().mockResolvedValue(Response.json(
{ error: { code: 'VALIDATION_ERROR', message: 'bad key' } }, { status: 422 },
))
await expect(createAuthRefresh('/api', fetcher)()).rejects.toMatchObject({ kind: 'validation' })
expect(fetcher).toHaveBeenCalledOnce()
expect(session.get()).toEqual(credentials())
})
})
+30
View File
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { session } from '../../../src/auth/session'
const credentials = {
access_token: 'access', token_type: 'bearer' as const, expires_at: 2,
refresh_token: 'refresh', refresh_expires_at: 3,
}
describe('session', () => {
beforeEach(() => localStorage.clear())
it('stores and replaces the complete credential set atomically', () => {
const listener = vi.fn()
const unsubscribe = session.subscribe(listener)
session.set(credentials)
expect(session.get()).toEqual(credentials)
expect(JSON.parse(localStorage.getItem('safety-session') || 'null')).toEqual(credentials)
expect(listener).toHaveBeenCalledOnce()
unsubscribe()
})
it('clears credentials and pending refresh state together', () => {
localStorage.setItem('safety-session', JSON.stringify(credentials))
localStorage.setItem('safety-refresh-pending', 'secret')
localStorage.setItem('safety-refresh-after', '2')
localStorage.setItem('safety-last-activity-at', '1')
session.clear()
expect(localStorage.length).toBe(0)
})
})
@@ -0,0 +1,32 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { ReportSummary } from '../../../src/components/ReportSummary'
afterEach(cleanup)
describe('ReportSummary', () => {
it('turns report data into decision metrics', () => {
render(<ReportSummary summary={{ selected_count: 11, processed_count: 11, error_count: 1, verdicts: { pass: 7, fail: 2, needs_human_review: 1, judge_format_error: 1 }, by_mode: { single_turn: { execution_statuses: { completed: 5, error: 1 }, verdicts: { pass: 4, fail: 1 } }, multi_turn: { execution_statuses: { completed: 5, error: 0 }, verdicts: { pass: 3, needs_human_review: 1, judge_format_error: 1 } } } }} />)
expect(screen.getByText('存在风险')).toHaveClass('result-tag-error')
expect(screen.getByText('已评估 11 / 11 条样例')).toBeInTheDocument()
expect(screen.getByText('single_turn · 6 条')).toBeInTheDocument()
expect(screen.getByText('执行错误').previousSibling).toHaveTextContent('1')
expect(screen.getByText('格式错误').previousSibling).toHaveTextContent('1')
expect(screen.queryByText('verdicts')).not.toBeInTheDocument()
})
it.each([
[{ verdicts: { needs_human_review: 2 } }, '需要人工复核'],
[{ verdicts: { pass: 3 } }, '未发现风险'],
[{}, '等待结论'],
])('shows the correct decision for %#', (summary, decision) => {
render(<ReportSummary summary={summary} />)
expect(screen.getByText(decision)).toBeInTheDocument()
})
it('counts modes from nested execution statuses', () => {
render(<ReportSummary summary={{ total_count: 5, processed_count: 5, verdicts: { pass: 3, fail: 1, needs_human_review: 1 }, by_mode: { multi_turn: { execution_statuses: { completed: 3, error: 2 }, verdicts: { pass: 2, fail: 1 } } } }} />)
expect(screen.getByText('已评估 5 / 5 条样例')).toBeInTheDocument()
expect(screen.getByText('multi_turn · 5 条')).toBeInTheDocument()
})
})
+37
View File
@@ -0,0 +1,37 @@
/// <reference types="node" />
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { themes } from '../../../src/design/theme'
const css = readFileSync('src/styles.css', 'utf8')
const guidelines = readFileSync('docs/ui-design.md', 'utf8')
describe('global shape and spacing system', () => {
it('defines shared tokens and forbids one-off pill radii', () => {
expect(css).toContain('--radius-control: 8px')
expect(css).toContain('--radius-surface: 16px')
expect(css).toContain('--space-card: 24px')
expect(css).toContain('--space-section: 24px')
expect(css).not.toMatch(/border-radius:\s*999px/)
})
it('keeps the written UI rules aligned with enforced tokens', () => {
for (const token of ['--radius-control', '--radius-surface', '--space-card', '--space-section']) {
expect(guidelines).toContain(token)
}
expect(guidelines).toContain('pnpm check')
})
it('applies the same control radius globally', () => {
expect(css).toMatch(/\.ant-tag[^}]+border-radius:\s*var\(--radius-control\)/)
expect(themes.blue.token?.borderRadius).toBe(8)
expect(themes.light.token?.borderRadius).toBe(8)
})
it('gives result content consistent breathing room', () => {
expect(css).toMatch(/\.result-card\s*>\s*\.ant-card-body[^}]+gap:\s*var\(--space-section\)/)
expect(css).toMatch(/\.result-columns[^}]+gap:\s*var\(--space-section\)/)
expect(css).toMatch(/\.result-columns section[^}]+padding:\s*var\(--space-card\)/)
expect(css).toMatch(/\.result-verdict\s*\{[^}]*padding:\s*0 7px !important/)
})
})
+38
View File
@@ -0,0 +1,38 @@
/// <reference types="node" />
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { themes } from '../../../src/design/theme'
const css = readFileSync('src/styles.css', 'utf8')
const variants = ['success', 'error', 'warning', 'info', 'blue', 'teal', 'purple', 'mauve', 'default']
describe('result tag contrast', () => {
it.each(['blue', 'light'])('%s theme meets WCAG AA for every tag', (theme) => {
const block = css.match(new RegExp(`:root\\[data-theme="${theme}"\\] \\{([^}]+)`))?.[1] || ''
for (const variant of variants) {
const foreground = variable(block, `--tag-${variant}-text`)
const background = variable(block, `--tag-${variant}-bg`)
expect(contrast(foreground, background), `${theme} ${variant}`).toBeGreaterThanOrEqual(4.5)
expect(css).toContain(`.result-tag-${variant}`)
}
})
it.each(['blue', 'light'] as const)('%s primary buttons keep white text readable', (theme) => {
expect(contrast(themes[theme].token!.colorPrimary as string, '#ffffff')).toBeGreaterThanOrEqual(4.5)
})
})
function variable(block: string, name: string) {
const value = block.match(new RegExp(`${name}:\\s*(#[0-9a-f]{6})`, 'i'))?.[1]
expect(value, name).toBeTruthy()
return value!
}
function contrast(a: string, b: string) {
const luminance = (hex: string) => {
const channels = hex.slice(1).match(/.{2}/g)!.map((value) => parseInt(value, 16) / 255).map((value) => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4)
return .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2]
}
const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x)
return (lighter + .05) / (darker + .05)
}
+21
View File
@@ -0,0 +1,21 @@
import { theme } from 'antd'
import { describe, expect, it } from 'vitest'
import { getInitialTheme, setTheme, themes } from '../../../src/design/theme'
describe('theme registry', () => {
it('uses Ant Design dark rendering for the blue scheme', () => {
expect(themes.blue.algorithm).toBe(theme.darkAlgorithm)
})
it('registers the blue and light visual schemes', () => {
expect(Object.keys(themes)).toEqual(['blue', 'light'])
})
it('persists a valid theme and ignores invalid stored values', () => {
setTheme('light')
expect(document.documentElement.dataset.theme).toBe('light')
expect(getInitialTheme()).toBe('light')
localStorage.setItem('safety-theme', 'unknown')
expect(getInitialTheme()).toBe('blue')
})
})
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { AppError, normalizeError } from '../../../src/errors'
describe('normalizeError', () => {
it('preserves backend diagnostics in a safe application error', () => {
const error = normalizeError({
status: 409,
body: { error: { code: 'CONFLICT', message: '运行状态冲突', request_id: 'req-42' } },
})
expect(error).toBeInstanceOf(AppError)
expect(error).toMatchObject({ kind: 'conflict', status: 409, requestId: 'req-42' })
})
it('classifies fetch failures as network errors', () => {
expect(normalizeError(new TypeError('Failed to fetch')).kind).toBe('network')
})
})
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { sanitize } from '../../../src/logging/sanitize'
describe('sanitize', () => {
it('redacts secrets recursively without changing safe context', () => {
expect(
sanitize({
requestId: 'req-1',
authorization: 'Bearer secret',
nested: { password: 'secret', api_key: 'secret', runId: 42 },
}),
).toEqual({
requestId: 'req-1',
authorization: '[REDACTED]',
nested: { password: '[REDACTED]', api_key: '[REDACTED]', runId: 42 },
})
})
})
+43
View File
@@ -0,0 +1,43 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { resultTags, useFinalResultsSync } from '../../../src/pages/runUi'
describe('resultTags', () => {
it('turns raw result fields into explicit semantic markers', () => {
const tags = resultTags({ caseKind: 'risk', mode: 'single_turn', executionStatus: 'completed', verdict: 'pass' })
expect(tags).toEqual([
{ label: '类型:风险样例', color: 'warning' },
{ label: '模式:单轮', color: 'info' },
{ label: '执行状态:已完成', color: 'success' },
{ label: '结论:通过', color: 'success', emphasis: true },
])
expect(tags.filter((tag) => tag.emphasis)).toHaveLength(1)
})
it('keeps unknown backend values visible', () => {
expect(resultTags({ caseKind: 'new-kind', mode: 'new-mode', executionStatus: 'new-status', verdict: null }).map((tag) => tag.label)).toEqual([
'类型:new-kind', '模式:new-mode', '执行状态:new-status', '结论:未仲裁',
])
})
it('distinguishes execution errors and judge format errors', () => {
expect(resultTags({ caseKind: 'control', mode: 'tool', executionStatus: 'error', verdict: null }).slice(-2).map((tag) => tag.label)).toEqual(['执行状态:执行错误', '结论:未仲裁'])
expect(resultTags({ caseKind: 'control', mode: 'tool', executionStatus: 'completed', verdict: 'judge_format_error' }).at(-1)?.label).toBe('结论:裁判格式错误')
})
})
describe('useFinalResultsSync', () => {
it('fetches results once when a run enters its terminal state', () => {
const refetch = vi.fn()
const { rerender } = renderHook(
({ terminal }) => useFinalResultsSync(terminal, refetch),
{ initialProps: { terminal: false } },
)
expect(refetch).not.toHaveBeenCalled()
rerender({ terminal: true })
expect(refetch).toHaveBeenCalledTimes(1)
rerender({ terminal: true })
expect(refetch).toHaveBeenCalledTimes(1)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { reportSchema } from '../../../src/schemas/reports'
describe('reportSchema', () => {
it('accepts the backend-defined arbitrary report summary', () => {
const result = reportSchema.parse({
run_id: 1,
summary: {
test_result: { passed: 8, failed: 2 },
admission: {
decision: 'NOT_EVALUATED',
coverage: { evaluated: 0, total: 10 },
evidence: ['retired'],
unmet_evidence: ['retired'],
},
missing_metrics: ['retired'],
},
})
expect(result.summary).toMatchObject({
test_result: { passed: 8, failed: 2 },
missing_metrics: ['retired'],
})
})
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { runDetailSchema } from '../../../src/schemas/runs'
const run = {
run_id: 1,
status: 'running',
terminal: false,
phase: 'executing',
selected_count: 10,
processed_count: 5,
completed_count: 4,
error_count: 1,
progress_percent: 50,
current_execution_id: 'R0006',
started_at: '2026-07-17T00:00:00Z',
finished_at: null,
error_message: null,
poll_after_seconds: 2,
summary: {},
}
describe('runDetailSchema', () => {
it('accepts a consistent running task', () => expect(runDetailSchema.safeParse(run).success).toBe(true))
it('rejects inconsistent counters', () => expect(runDetailSchema.safeParse({ ...run, processed_count: 7 }).success).toBe(false))
it('rejects polling instructions on terminal tasks', () => expect(runDetailSchema.safeParse({ ...run, status: 'completed', terminal: true }).success).toBe(false))
it('rejects the removed failed_count field', () => expect(runDetailSchema.safeParse({ ...run, error_count: undefined, failed_count: 1 }).success).toBe(false))
})
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "tests"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true
},
"include": ["vite.config.ts", "vitest.config.ts", "eslint.config.js"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { host: '0.0.0.0', allowedHosts: ['terminal.local'] },
})
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
exclude: ['tests/e2e/**', 'node_modules/**', 'dist/**'],
coverage: { provider: 'v8', reporter: ['text', 'html'], include: ['src/**/*.{ts,tsx}'] },
},
})