1
0
Files
DiAL/openspec/changes/frontend-architecture-refactor/design.md
lanyuanxiaoyao 76b47006fe feat: 新增两个 OpenSpec 变更提案 — 前端架构重构与 HTTP Checker 质量加固
- frontend-architecture-refactor: 拆分 hooks/组件、类型筛选器动态化
- http-checker-quality-hardening: ReDoS 防护、failure 格式修正、测试补全
2026-05-13 18:40:08 +08:00

6.4 KiB
Raw Blame History

Context

当前前端代码约 970 行,功能完整但存在以下架构问题:

  1. hook 职责过重hooks/useTargetDetail.ts113 行)同时承载全局查询(useSummaryuseTargets、Drawer 状态管理、条件查询和通用 fetchJson 封装,文件名与实际职责不匹配
  2. 组件体积膨胀TargetDetailDrawer.tsx228 行)混合了时间选择逻辑、两个 Tab 的完整渲染、列定义和统计计算
  3. 类型维护重复target-type-display.tstarget-table-filters.ts 各自硬编码 checker 类型列表,新增 checker 需改两处前端文件
  4. 测试覆盖不足:仅 constants/ 下 4 个纯函数有测试,utils/time.ts 和组件内统计逻辑未覆盖
  5. 小问题StatusDonut 用数组索引做 key、StatusBar 硬编码 30 格、TrendChart 冗余 loading prop

后端 CheckerRegistry 已有 supportedTypes 属性,可直接暴露给前端。项目未上线,不需要向前兼容。

Goals / Non-Goals

Goals:

  • hook 按职责拆分,文件名匹配实际内容
  • TargetDetailDrawer 拆分为 3 个组件,每个 < 100 行
  • 类型筛选器由后端 meta API 驱动,新增 checker 前端零改动
  • 删除 type label 转换层,直接使用 type 原始文本
  • 补齐前端纯函数测试
  • 修复已知小问题

Non-Goals:

  • 不引入路由库或状态管理库
  • 不重构后端 API 结构
  • 不改变现有轮询策略和 QueryClient 配置
  • 不新增 CSS 文件(继续使用单一 styles.css

Decisions

Decision 1: hook 拆分策略

选择:按查询层级拆分为 use-queries.tsuse-target-detail.ts

  • use-queries.tsqueryKeysfetchJson(不导出)、useSummaryuseTargetsuseMeta
  • use-target-detail.ts:仅保留 Drawer 状态管理(selectedTargetId、时间范围、分页、openDrawer/closeDrawer和条件查询trend/history

理由全局查询summary/targets/meta是面板级别的与 Drawer 详情无关。拆分后各文件职责单一,命名自解释。fetchJson 仅被 query 层使用,留在 use-queries.ts 内部不导出。

备选方案

  • 仅重命名为 useQueries.ts — 解决命名问题但不解决职责混合
  • 每个 query 一个文件 — 过度拆分,增加文件数量但无实质收益

Decision 2: TargetDetailDrawer 拆分方式

选择:拆为 3 个组件 + 1 个常量文件

TargetDetailDrawer.tsx  ← Drawer 壳 + 时间选择 + Tab 切换
OverviewTab.tsx         ← 统计 + TrendChart + StatusDonut + Descriptions
HistoryTab.tsx          ← PrimaryTable + 分页
constants/history-table-columns.tsx  ← HISTORY_COLUMNS

理由:两个 Tab 的内容完全独立,拆分后各组件 < 100 行。HISTORY_COLUMNSTARGET_TABLE_COLUMNS 性质相同,应放在 constants/ 下保持一致。

统计计算逻辑(totalChecks/upChecks/downChecks)提取为 utils/stats.ts 纯函数,便于测试和 useMemo

Decision 3: Meta API 设计

选择GET /api/meta 返回 { checkerTypes: string[] }

// src/shared/api.ts
export interface MetaResponse {
  checkerTypes: string[];
}

理由

  • checkerRegistry.supportedTypes 直接获取,无需额外维护
  • 返回 string[] 而非 { key, label }[],因为决策是不做 label 转换
  • 端点命名为 /api/meta 而非 /api/types,为未来扩展预留空间(如版本号、功能开关等)
  • staleTime: Infinity,应用生命周期内只请求一次

备选方案

  • 从 targets 响应中动态提取 — 只能获取"当前有数据的类型",不能获取"系统支持的全部类型"
  • 在 health 端点中附带 — 语义不匹配health 应保持最小化

Decision 4: 类型展示策略

选择:删除 target-type-display.ts,所有展示位置直接使用 target.type 原始文本

影响位置

  • target-table-columns.tsx 类型列 cellrow.type 直接渲染
  • TargetDetailDrawer.tsx 标题栏 Tagtarget.type 直接渲染
  • typeFilter 列表:从 meta API 获取label 和 value 均为原始 type 文本

理由type 文本本身已足够清晰(httpcommandtcp),无需额外映射层。消除了前后端重复维护的问题。

Decision 5: 列定义动态化

选择TARGET_TABLE_COLUMNS 从静态常量改为工厂函数

export function createTargetTableColumns(checkerTypes: string[]): PrimaryTableCol<TargetStatus>[] {
  const typeFilter = {
    list: [
      { label: "全部", value: "" },
      ...checkerTypes.map(t => ({ label: t, value: t })),
    ],
    type: "single" as const,
  };
  // ... 返回列定义数组
}

数据流useMeta()checkerTypescreateTargetTableColumns(checkerTypes)TargetGroup columns prop

理由typeFilter 是唯一需要动态数据的部分,通过工厂函数注入参数,保持列定义的纯函数特性。statusFilter 保持静态UP/DOWN 是固定的)。

TargetGroup 新增 columns prop 接收动态列定义,TargetBoard 负责调用工厂函数并传递。

Decision 6: StatusBar maxSlots 参数化

选择:新增 maxSlots prop默认 30组件根据 prop 渲染格数

interface StatusBarProps {
  samples: Array<{ up: boolean }>;
  maxSlots?: number;
}

理由:消除硬编码魔数,使组件可复用。默认值 30 保持向后兼容。

Decision 7: TrendChart 移除 loading prop

选择:移除 loading prop组件只接收 data: TrendPoint[]

理由:调用方(OverviewTab)已用 Skeleton 处理 loading 状态,TrendChart 只在有数据时渲染。组件内部的 if (loading) 分支和 loading={false} 传参都是死代码。

Risks / Trade-offs

Risk Mitigation
列定义改为函数后,每次 checkerTypes 变化会重新创建列数组 useMemo 包裹 createTargetTableColumns 调用meta 数据 staleTime: Infinity 确保不会频繁变化
meta API 在 targets 之前未返回时,筛选器暂时为空 meta 请求极轻量(无 DB 查询),通常先于 targets 返回;即使晚到,筛选器会在数据到达后自动出现
拆分后组件间 props 传递增多 层级仅增加一层Drawer → Tabprops 类型明确,不会造成 prop drilling 问题

Open Questions

无。方案已在 explore 阶段与用户确认。