refactor(ui): baseline FormDrawer component and IntelligentEvals dogfood (ui-consistency ticket 01)

Extract the standard form drawer (width 640, footer actions, destroyOnClose)
as FormDrawer; reuse existing usePolling hook. IntelligentEvals becomes the
first consumer — create drawer and both 5s pollings switched with zero
behaviour change. Includes ui-consistency spec, tickets, and ADR-0005.
This commit is contained in:
sinohqb 2026-08-05 15:34:31 +08:00
parent 6cc2efafb6
commit d4c254e121
14 changed files with 241 additions and 29 deletions

View File

@ -0,0 +1,14 @@
# 01 — 基准组件沉淀 + IntelligentEvals 切换
**What to build:** 抽取两个共享件并让基准页成为第一个消费者。
- `components/FormDrawer.tsx`:标准表单 Drawerwidth 640 / footer 取消+主按钮 / destroyOnClose / submitting 态)
- `hooks/usePolling.ts`活跃态静默轮询5s组件卸载/条件关闭自动清理)
**Blocked by:** 无
**Acceptance criteria:**
- [ ] FormDrawer 与 usePolling 创建完成props/hook 签名简洁
- [ ] IntelligentEvals 的创建 Drawer 改用 FormDrawer行为不变
- [ ] IntelligentEvals 详情页轮询与 EvalDetail 会话轮询改用 usePolling行为不变
- [ ] `tsc --noEmit` 通过
- [ ] t480 走查创建评估、详情轮询、P0 全流程体验无回归

View File

@ -0,0 +1,13 @@
# 02 — Targets 页迁移
**What to build:** 评测对象页对齐交互标准PageWrapper 骨架复核、创建/编辑表单统一 FormDrawer、删除 Popconfirm 复核、空态 Empty、分页 20 行阈值。
**Blocked by:** 01
**Acceptance criteria:**
- [ ] 表单容器为 FormDrawer创建与编辑统一
- [ ] 删除有 Popconfirm 确认,连通测试按钮行为不变
- [ ] 列表空态 Empty、加载用 Table loading、20 行分页阈值
- [ ] 无 `window.confirm`、无绕过 tokens 的硬编码色值(迁移可见部分)
- [ ] `tsc --noEmit` 通过
- [ ] t480 走查:增删改查 + 连通测试 golden path

View File

@ -0,0 +1,14 @@
# 03 — Files 页迁移(检查点前最后一票)
**What to build:** 原始文件页对齐交互标准;**重点修正:删除分类/文件补 Popconfirm 确认**(现状无确认)。
**Blocked by:** 01
**Acceptance criteria:**
- [ ] 删除分类、删除文件均有 Popconfirm 确认(行为修正,记录在提交信息)
- [ ] 上传/下载交互保持不变,外壳对齐 PageWrapper 标准
- [ ] 空态 Empty无分类/无文件时)
- [ ] `tsc --noEmit` 通过
- [ ] t480 走查:分类树 CRUD + 上传下载 golden path
**检查点**:本票完成后暂停,评审 FormDrawer/usePolling API 与三页走查结果,用户确认后再继续 0408。

View File

@ -0,0 +1,12 @@
# 04 — ModelConfigs 页迁移
**What to build:** 模型配置页对齐交互标准:表单统一 FormDrawer、删除 Popconfirm、空态、分页阈值能力/模态约束的校验逻辑保持不变。
**Blocked by:** 03检查点通过后
**Acceptance criteria:**
- [ ] 创建/编辑表单统一 FormDrawer
- [ ] 删除有 Popconfirm启用/停用开关行为不变
- [ ] 空态 Empty、20 行分页阈值
- [ ] `tsc --noEmit` 通过
- [ ] t480 走查:模型配置 CRUD + 能力约束校验 golden path

View File

@ -0,0 +1,13 @@
# 05 — Scenarios 页迁移
**What to build:** 评测场景页对齐交互标准。Monaco 编辑器与版本对比等功能保持不变,只统一外壳与基础模式。
**Blocked by:** 04
**Acceptance criteria:**
- [ ] 创建/编辑入口统一(表单 Drawer 或保留 Monaco 全屏编辑——以现有主模式为准,但容器样式对齐标准)
- [ ] 删除有 Popconfirm模板相关交互不变
- [ ] 空态 Empty、20 行分页阈值
- [ ] 现状 Modal 与 Drawer 混用处收敛(保留 Monaco 场景所需的 Modal
- [ ] `tsc --noEmit` 通过
- [ ] t480 走查:场景 CRUD + 编辑 + 校验 golden path

View File

@ -0,0 +1,12 @@
# 06 — Runs 页迁移
**What to build:** 评测执行页对齐交互标准。**护栏**「导航内容分离」hacknav-only-tabs 隐藏 content-holder、内容 div flex 控高必须保留只统一外壳PageWrapper/标题区/加载态/空态)。
**Blocked by:** 05
**Acceptance criteria:**
- [ ] WebSocket 实时会话、取消、重跑等功能零回归
- [ ] 导航内容分离 hack 保留且有注释说明原因
- [ ] 空态、加载态对齐标准危险操作取消Popconfirm
- [ ] `tsc --noEmit` 通过
- [ ] t480 走查run 列表 + 详情 + 实时进度 + 取消 golden path

View File

@ -0,0 +1,12 @@
# 07 — Reports 页迁移
**What to build:** 评测报告页对齐交互标准。图表与对比区保持不变,只统一外壳与基础模式。
**Blocked by:** 06
**Acceptance criteria:**
- [ ] 报告选择/切换、图表渲染零回归
- [ ] PageWrapper 外壳、空态、加载态对齐标准
- [ ] 分页 20 行阈值
- [ ] `tsc --noEmit` 通过
- [ ] t480 走查:报告页各区块 golden path

View File

@ -0,0 +1,13 @@
# 08 — Campaigns 页迁移(最后硬骨头)
**What to build:** 评估活动页1094 行)对齐交互标准。时间线、探索区、分析区等独特交互不做模式化改造,只统一页面外壳与基础模式(表单 Drawer、删除确认、空态、反馈
**Blocked by:** 07
**Acceptance criteria:**
- [ ] 创建/编辑活动表单统一 FormDrawer
- [ ] 删除/取消活动 Popconfirm探索派发等独特交互不变
- [ ] 空态 Empty、20 行分页阈值
- [ ] 文件体量显著下降(重复模式收敛到共享件),不强制目标行数
- [ ] `tsc --noEmit` 通过
- [ ] t480 走查:活动 CRUD + 时间线 + 探索区 + 报告 golden path

View File

@ -0,0 +1,12 @@
# 09 — 全量回归与收口
**What to build:** 9 页全部迁移后的最终验收与文档收口。
**Blocked by:** 08
**Acceptance criteria:**
- [ ] 全部 9 页 t480 逐页走查一遍golden path + keep-alive 切 tab 状态不丢)
- [ ] 基准页IntelligentEvals重走 P0 全流程确认无回归
- [ ] `tsc --noEmit` + 后端 pytest 全绿
- [ ] AGENTS.md 前端章节更新(新增交互标准说明、清理过时描述)
- [ ] 本 spec 与票据验收项勾选归档

View File

@ -0,0 +1,45 @@
# UI 一致性重构 — Specgrill 共识沉淀)
日期2026-08-05 状态:已确认,开始实施
## 背景
管理后台 9 个业务页面约 4000 行交互模式不一致Runs/Reports 不用 PageWrapper、表单容器混用 Drawer/Modal、Files 删除无确认、空态/加载态/反馈方式各页不同。智能评估 v1.0 交付后,其页面模式成为事实上的最新标准。
## 决策清单grill 结论)
| # | 决策 | 结论 |
|---|------|------|
| 1 | 重构目标 | 交互层为主、视觉层顺带、信息架构不动 |
| 2 | 一致性基准 | 以智能评估页为标准,其他页面向它对齐 |
| 3 | 实施策略 | 先沉淀共享组件,再逐页迁移;每页一票一提交一验证 |
| 4 | 范围 | 9 个业务页面;排除 Login、OpenClawAI 助手嵌入应用) |
| 5 | 行为边界 | 交互对齐优先:不一致处按标准修正,不增删业务功能、不动 API |
| 6 | 迁移顺序 | IntelligentEvals → Targets → Files → ModelConfigs → Scenarios → Runs → Reports → Campaigns |
| 7 | 交互标准 | 见下表 |
| 8 | 验收 | 每票 tsc + t480 走查 + keep-alive 不回归;前 3 票后组件稳定检查点;基准页重走 P0 全流程;最终全量走查 |
## 交互标准(唯一基准)
| 模式 | 标准 |
|---|---|
| 页面骨架 | `PageWrapper inline fullHeight`,标题 + 描述 + extra 区(刷新按钮 + 主操作按钮) |
| 列表 | Table + `rowKey="id"`;空态用 `Empty`(带文案);加载用 Table 自带 loading |
| 分页 | 超过 20 行才分页,否则 `pagination={false}` |
| 表单 | Drawer 宽 640、`layout="vertical"`、底部 footer取消/主按钮)、`destroyOnClose`、提交前本地校验 |
| 详情 | 页面内部视图切换(不加新路由),顶部「返回 + 标题 + 状态 Tag + 动作按钮」 |
| 危险操作 | 统一 `Popconfirm`(含删除、取消等不可逆动作),禁用 `window.confirm` 和无确认删除 |
| 反馈 | 错误统一由 axios 拦截器弹 `message.error`;成功由动作函数显式 `message.success` |
| 轮询 | 活跃态外部会变化的状态5 秒静默轮询 |
## 实施风险与护栏
1. **Runs/Reports 的「导航内容分离」**是绕过 AntD CSS-in-JS 冲突的 hack见 AGENTS.md 陷阱 3迁移时必须保留其行为只统一外壳。
2. **Campaigns 的独特交互**(时间线、探索区、分析区)不做模式化改造,只统一页面外壳与基础模式。
3. **keep-alive 架构不动**:所有页面仍按顶级路径挂载,详情仍是页面内部状态。
4. **只抽取 ≥2 个页面会用的共享件**,防止过度抽象。第一版只抽 `FormDrawer`(表单 Drawer 标准件)与 `usePolling`(活跃态轮询 hook
5. 每票据:一个页面、一次提交、一次 t480 部署走查。
## 票据规划
`.scratch/ui-consistency/issues/`。依赖链01 → 02/03检查点→ 0408 → 09。

View File

@ -0,0 +1,23 @@
# ADR-0005: 管理后台 UI 一致性重构以智能评估页为基准
## 状态
已接受2026-08-05
## 背景
管理后台 9 个业务页面交互模式不一致(表单容器 Drawer/Modal 混用、空态/加载态/删除确认各页不同、Runs/Reports 绕过 PageWrapper。智能评估 v1.0 的页面模式是最新打磨并被用户验证过的。
## 决策
- 以智能评估页模式为唯一基准,逐页迁移对齐(先沉淀共享件 FormDrawer/usePolling再按 IntelligentEvals → Targets → Files → ModelConfigs → Scenarios → Runs → Reports → Campaigns 顺序)。
- 重构边界:只统一交互层,不增删业务功能、不动 API、不动信息架构与 keep-alive 路由。
- 交互标准PageWrapper 骨架、Table+Empty、20 行分页阈值、Drawer 640 表单、内部视图切换详情、Popconfirm 危险操作、message 反馈、活跃态 5s 轮询。
## 后果
- 正面:用户操作肌肉记忆可跨页面复用;新页面有明确模式可循;共享件减少重复代码。
- 负面迁移期间每页需人工走查验收Runs/Reports 的特殊 hack 需小心保留。
- 不做:视觉设计系统重做、导航重构、自动化视觉回归。
详细 spec 见 `.scratch/ui-consistency/spec.md`

View File

@ -0,0 +1,36 @@
import { Button, Drawer, Space } from 'antd'
import type { ReactNode } from 'react'
interface FormDrawerProps {
title: string
open: boolean
onClose: () => void
onSubmit: () => void
submitting?: boolean
okText?: string
width?: number
children: ReactNode
}
/** 标准表单抽屉:宽 640、底部取消/主按钮、destroyOnClose。 */
export default function FormDrawer({
title, open, onClose, onSubmit, submitting = false, okText = '确定', width = 640, children,
}: FormDrawerProps) {
return (
<Drawer
title={title}
open={open}
onClose={onClose}
width={width}
destroyOnClose
footer={
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button onClick={onClose}></Button>
<Button type="primary" loading={submitting} onClick={onSubmit}>{okText}</Button>
</Space>
}
>
{children}
</Drawer>
)
}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
Alert, Button, Card, Descriptions, Empty, Input, Modal, Popconfirm, Space, Spin, Tag, message,
} from 'antd'
@ -6,6 +6,7 @@ import { ArrowLeftOutlined, FileTextOutlined, StopOutlined } from '@ant-design/i
import { intelligentEvalsApi, type IntelligentEval, type IntelligentEvalSession } from '../../api'
import { colors } from '../../tokens'
import { formatDateTime, shortDateTime } from '../../utils/date'
import { usePolling } from '../../hooks/usePolling'
import { EVAL_STATUS, SESSION_STATUS } from './status'
const sectionCard: React.CSSProperties = { marginBottom: 16 }
@ -75,17 +76,18 @@ export default function EvalDetail({ ev, targetName, onBack, onOpenReport, onCha
const meta = EVAL_STATUS[ev.status] ?? { label: ev.status, color: 'default' }
const showSessions = ev.status === 'executing' || ev.status === 'completed'
const loadSessions = useCallback(() => {
intelligentEvalsApi.listSessions(ev.id)
.then((res) => setSessions(res.data.sessions))
.catch(() => undefined)
}, [ev.id])
useEffect(() => {
if (!showSessions) { setSessions(null); return }
let cancelled = false
const load = () => intelligentEvalsApi.listSessions(ev.id)
.then((res) => { if (!cancelled) setSessions(res.data.sessions) })
.catch(() => undefined)
void load()
if (ev.status !== 'executing') return () => { cancelled = true }
const timer = setInterval(load, 5000)
return () => { cancelled = true; clearInterval(timer) }
}, [ev.id, ev.status, showSessions])
loadSessions()
}, [showSessions, loadSessions])
usePolling(loadSessions, 5000, showSessions && ev.status === 'executing')
const runAction = async (fn: () => Promise<unknown>, okMsg: string) => {
setBusy(true)

View File

@ -1,13 +1,15 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import {
Button, Drawer, Empty, Form, Input, InputNumber, Select, Space, Table, Tag, message,
Button, Empty, Form, Input, InputNumber, Select, Space, Table, Tag, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'
import FormDrawer from '../components/FormDrawer'
import PageWrapper from '../components/PageWrapper'
import EvalDetail from '../components/intelligent_eval/EvalDetail'
import EvalReport from '../components/intelligent_eval/EvalReport'
import { EVAL_STATUS } from '../components/intelligent_eval/status'
import { usePolling } from '../hooks/usePolling'
import { useResource } from '../hooks/useResource'
import {
intelligentEvalsApi, targetsApi,
@ -53,11 +55,7 @@ export default function IntelligentEvalsPage() {
const pollActive = view === 'detail' && selected != null
&& (selected.status === 'planning' || selected.status === 'executing' || selected.status === 'pending_approval')
useEffect(() => {
if (!pollActive) return
const timer = setInterval(() => void reloadSelected(true), 5000)
return () => clearInterval(timer)
}, [pollActive, reloadSelected])
usePolling(() => void reloadSelected(true), 5000, pollActive)
const targetName = (id: string) =>
targets?.find((t) => t.id === id)?.name ?? id.slice(0, 8)
@ -189,20 +187,13 @@ export default function IntelligentEvalsPage() {
/>
</div>
<Drawer
<FormDrawer
title="新建智能评估"
open={createOpen}
onClose={() => setCreateOpen(false)}
width={640}
destroyOnClose
footer={
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button onClick={() => setCreateOpen(false)}></Button>
<Button type="primary" loading={submitting} onClick={submitCreate}>
</Button>
</Space>
}
onSubmit={submitCreate}
submitting={submitting}
okText="创建并开始规划"
>
<Form
form={form}
@ -268,7 +259,7 @@ export default function IntelligentEvalsPage() {
OpenClaw OpenClaw
</div>
</Form>
</Drawer>
</FormDrawer>
</PageWrapper>
)
}