1
0

refactor: 全面重构前端样式,消除内联 style 和硬编码色值,统一 TDesign 规范

- 重写 styles.css:CSS 变量化可用率色阶、状态色类、工具类、安全选择器
- 组件改造:StatusDot/StatusBar/TargetDetailDrawer/GroupHeader 等改用 CSS 类和 Typography
- color-threshold 移除 getLatencyColor 死代码,保留 getAvailabilityProgressColor 返回 CSS 变量
- target-table-columns 状态列和延迟列切换为 CSS 类
- 新增 css-utility-classes spec,更新 4 个 main specs(probe/card/table/drawer)
- README 和 config.yaml 写入前端样式开发规范
This commit is contained in:
2026-05-12 12:42:11 +08:00
parent 3e8d01715f
commit 9f2b906063
19 changed files with 332 additions and 198 deletions

View File

@@ -263,6 +263,23 @@ bun run verify
前端只通过 HTTP 调用后端API 路径为 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。
## 前端样式规范
前端基于 TDesign React 构建UI样式开发遵循以下优先级从高到低
1. **使用 TDesign 组件**:布局、间距、排版优先使用 TDesign 组件(如 Space、Divider、Typography
2. **使用 TDesign 组件 props**:通过组件的 props 参数控制外观(如 `theme``variant``size`
3. **使用 TDesign CSS tokens**:颜色、间距、字体等使用 `--td-*` CSS 变量(如 `--td-success-color``--td-comp-margin-xxl`
4. **在 styles.css 中定义 CSS 类**:无法通过上述方式满足的样式需求,集中定义在 `styles.css`
5. **自行开发组件**:仅在 TDesign 无法满足需求时自行开发
**红线**
- **严禁在组件中使用 `style` 属性内联调整样式**
- **严禁通过 CSS 覆盖 TDesign 组件内部类名**(如 `.t-tab-panel`),如需定制使用组件的 `className` prop
- **严禁使用 `!important`**
- 颜色统一使用 TDesign CSS tokens`--td-success-color``--td-error-color``--td-warning-color` 等),不使用硬编码色值
## 目标状态判定
单层判定模型,适用于 HTTP 和 Command 两种类型:

View File

@@ -11,6 +11,8 @@ context: |
- src/server目录下是基于bun实现的后端代码
- src/web目录下是基于vite、react、TDesign实现的前端代码
- 代码开发优先使用公共组件实现功能逻辑(优先级:官方库>主流三方库>项目公共工具>自行实现)
- 前端样式开发优先级TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
- 前端严禁组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
- 禁止创建git操作task
- 积极使用subagents精心设计并行任务节省上下文空间加速任务执行

View File

@@ -5,7 +5,7 @@
## Requirements
### Requirement: 分组卡片布局
Dashboard SHALL 按 group 字段将目标分组,每个分组包含带统计的分组标题和独立 TDesign PrimaryTable 表格。
Dashboard SHALL 按 group 字段将目标分组,每个分组包含带统计的分组标题和独立 TDesign PrimaryTable 表格。分组标题使用 TDesign Typography 组件渲染。
#### Scenario: 按分组展示目标
- **WHEN** 用户打开 Dashboard 页面
@@ -13,7 +13,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组包含带统计的
#### Scenario: 分组标题带统计标签
- **WHEN** 页面渲染某个分组
- **THEN** 分组标题 SHALL 使用 TDesign Space + Tag 组件显示分组名称和三个标签总数theme=primary, variant=light、正常数theme=success, variant=light、异常数theme=danger, variant=light标签仅显示数字
- **THEN** 分组标题 SHALL 使用 CSS flex 布局(`display:flex; align-items:center`显示分组名称和三个标签总数theme=primary, variant=light、正常数theme=success, variant=light、异常数theme=danger, variant=light标签仅显示数字。分组名称 SHALL 使用 TDesign Typography.Title 组件level="h4")渲染,不使用原生 h2 标签和内联 style。Typography.Title 默认 margin SHALL 通过 CSS 覆盖归零
#### Scenario: 分组统计标签提示
- **WHEN** 鼠标悬停在分组统计标签上
@@ -23,6 +23,10 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组包含带统计的
- **WHEN** 分组名称为 "default"
- **THEN** 分组标题 SHALL 显示 "默认分组"
#### Scenario: 分组标题间距
- **WHEN** 分组标题渲染
- **THEN** 标题与表格之间的间距 SHALL 通过 CSS 类控制,不使用内联 style 的 marginBottom
### Requirement: 响应式卡片网格
Dashboard SHALL 使用 TDesign PrimaryTable 展示每个分组的目标,表格宽度自适应容器。
@@ -70,7 +74,7 @@ Dashboard SHALL 使用 TDesign PrimaryTable 展示每个分组的目标,表格
- **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px
### Requirement: 卡片交互
表格行 SHALL 支持 hover 效果和点击打开 Drawer。
表格行 SHALL 支持 hover 效果和点击打开 Drawer。cursor 样式通过 CSS 类实现。
#### Scenario: 行 hover 效果
- **WHEN** 鼠标悬停在表格行上
@@ -79,3 +83,7 @@ Dashboard SHALL 使用 TDesign PrimaryTable 展示每个分组的目标,表格
#### Scenario: 行点击打开详情
- **WHEN** 用户点击某个目标表格行
- **THEN** 系统 SHALL 打开该目标的详情 Drawer
#### Scenario: 行 cursor 样式
- **WHEN** 表格渲染
- **THEN** PrimaryTable SHALL 通过 CSS 类 `.clickable-table` 设置 cursor: pointer不使用内联 style

View File

@@ -0,0 +1,69 @@
## Purpose
定义 styles.css 中集中管理的前端样式工具类和 CSS 自定义属性,供 TDesign 组件之外的自定义组件StatusDot、StatusBar 等)引用。
## Requirements
### Requirement: 状态色 CSS 类
styles.css SHALL 定义状态指示相关的 CSS 类,颜色使用 TDesign tokens。
#### Scenario: StatusDot 颜色类
- **WHEN** StatusDot 组件渲染
- **THEN** 组件 SHALL 使用 `.status-dot` 基础类 + `.status-dot--up`background: `--td-success-color`)或 `.status-dot--down`background: `--td-error-color`)修饰类,不使用内联 style
#### Scenario: StatusDot 发光阴影
- **WHEN** StatusDot 组件渲染
- **THEN** `.status-dot--up` SHALL 定义 `box-shadow` 使用 `--td-success-color``.status-dot--down` SHALL 定义 `box-shadow` 使用 `--td-error-color`
#### Scenario: StatusBar 色块类
- **WHEN** StatusBar 组件渲染色块
- **THEN** 组件 SHALL 使用 `.status-bar-block` 基础类 + `.status-bar-block--up`background: `--td-success-color`)、`.status-bar-block--down`background: `--td-error-color`)或 `.status-bar-block--empty`background: `--td-bg-color-component-disabled`)修饰类,不使用内联 style
### Requirement: 可用率色阶 CSS 变量
styles.css SHALL 定义 10 级可用率色阶 CSS 自定义属性,使用项目自定义色值。
#### Scenario: 色阶变量定义
- **WHEN** 可用率进度条渲染
- **THEN** 色阶 SHALL 通过 CSS 自定义属性 `--avail-0``--avail-9` 定义,值为项目自定义色值(`#d54941``#3dba60`
#### Scenario: 色阶渐变方向
- **WHEN** 色阶变量被引用
- **THEN** 色阶 SHALL 从红色0-30%经橙色30-60%过渡到绿色60-100%
### Requirement: 辅助工具类
styles.css SHALL 定义前端组件复用的工具类。
#### Scenario: 文本禁用色类
- **WHEN** 延迟列无数据需要显示占位符
- **THEN** 组件 SHALL 使用 `.text-disabled`color: `--td-text-color-disabled`),不使用内联 style
#### Scenario: 等宽数字类
- **WHEN** 数值需要等宽显示
- **THEN** 组件 SHALL 使用 `.tabular-nums`font-variant-numeric: tabular-nums
#### Scenario: 延迟色值类
- **WHEN** 延迟数值渲染
- **THEN** 组件 SHALL 使用 `.latency-ok`color: `--td-success-color`)、`.latency-warn`color: `--td-warning-color`)或 `.latency-error`color: `--td-error-color`)类,不使用内联 style
#### Scenario: 全宽布局类
- **WHEN** 组件需要占满父容器宽度
- **THEN** 组件 SHALL 使用 `.full-width`width: 100%),不使用内联 style
#### Scenario: 可点击表格类
- **WHEN** PrimaryTable 行支持点击交互
- **THEN** 表格 SHALL 使用 `.clickable-table`cursor: pointer不使用内联 style
#### Scenario: Tab 面板内边距类
- **WHEN** Drawer 内 Tabs 面板需要内边距
- **THEN** TabPanel SHALL 使用 `className="tab-panel-padded"` prop 传入类名,不通过入侵 TDesign 内部类名覆盖
### Requirement: 异常行背景类
styles.css SHALL 定义 DOWN 行的背景色,使用安全选择器且不使用 `!important`
#### Scenario: DOWN 行背景色
- **WHEN** 表格行标记为 DOWN 状态
- **THEN** 行 SHALL 通过 `.t-table tr.row-down` 选择器获得浅红色背景(引用 `--td-error-color-light` token不使用 `!important`
#### Scenario: DOWN 行 hover 状态
- **WHEN** 鼠标悬停在 DOWN 行上
- **THEN** 行背景 SHALL 通过 `.t-table--hoverable tbody tr.row-down:hover` 选择器显示 hover 状态色

View File

@@ -42,7 +42,7 @@ Dashboard SHALL 使用 TDesign Drawer 展示目标详情,包含时间范围筛
- **THEN** Drawer SHALL 关闭
### Requirement: 页面加载与错误状态
Dashboard SHALL 使用 TDesign 组件正确处理加载状态和 API 错误。
Dashboard SHALL 使用 TDesign 组件正确处理加载状态和 API 错误。页面标题使用 TDesign Typography 组件渲染。
#### Scenario: 首次加载
- **WHEN** 页面首次加载且数据尚未返回
@@ -55,3 +55,7 @@ Dashboard SHALL 使用 TDesign 组件正确处理加载状态和 API 错误。
#### Scenario: Drawer 内部加载状态
- **WHEN** Drawer 内趋势数据或历史记录正在加载
- **THEN** 概览面板的"趋势"区域 SHALL 显示 TDesign Skeleton 加载占位,记录表格 SHALL 显示 loading 状态
#### Scenario: 页面标题
- **WHEN** Dashboard 页面渲染
- **THEN** 页面标题 SHALL 使用 TDesign Typography.Title 组件level="h1")渲染"DiAL",副标题 SHALL 使用 Typography.Text 组件theme="secondary")渲染"统一拨测平台",不使用原生 h1/p 标签和内联 style

View File

@@ -5,7 +5,7 @@
## Requirements
### Requirement: 目标详情 Drawer
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示该目标的详细统计信息和检查记录。
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。
#### Scenario: 打开 Drawer
- **WHEN** 用户点击某个目标表格行
@@ -13,7 +13,7 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示
#### Scenario: Drawer 标题栏
- **WHEN** Drawer 渲染
- **THEN** 标题栏 SHALL 显示 StatusDot、目标名称和类型标签TDesign Tag以及内建关闭按钮
- **THEN** 标题栏 SHALL 使用 TDesign Space 组件align="center")布局,包含 StatusDot、目标名称TDesign Typography.Text strong和类型标签TDesign Tag以及内建关闭按钮。不使用内联 style 的 flex 布局
#### Scenario: 关闭 Drawer
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
@@ -31,6 +31,10 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示
- **WHEN** 用户从目标 A 切换到目标 B点击不同的表格行
- **THEN** Drawer SHALL 重置为概览 Tab使用 key={target.id} 确保组件状态不残留
#### Scenario: Drawer 内容区间距
- **WHEN** Drawer 内容渲染
- **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom
### Requirement: 时间范围选择器
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录两个面板的数据。时间选择器 SHALL 分两行显示:第一行为快捷按钮,第二行为日期时间范围选择器。
@@ -52,7 +56,7 @@ Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录
#### Scenario: DateRangePicker 全宽显示
- **WHEN** Drawer 渲染
- **THEN** DateRangePicker SHALL 占满时间选择区第二行的宽度(width: 100%
- **THEN** DateRangePicker SHALL 通过 CSS 类 `.full-width` 占满时间选择区第二行的宽度,不使用内联 style 的 width: 100%
#### Scenario: 默认时间范围
- **WHEN** Drawer 打开
@@ -63,22 +67,30 @@ Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录
- **THEN** 系统 SHALL 重新请求趋势数据和历史记录
### Requirement: Tabs 内容组织
Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。
Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。TabPanel 内边距通过 className prop 控制。
#### Scenario: Tab 标签
- **WHEN** Drawer 渲染
- **THEN** Tabs SHALL 显示两个标签:概览、记录
#### Scenario: Tab 面板内边距
- **WHEN** TabPanel 渲染
- **THEN** TabPanel SHALL 通过 `className` prop 传入自定义类名(`tab-panel-padded`)控制内边距,不通过入侵 TDesign 内部类名(`.t-tab-panel`)覆盖
### Requirement: 概览面板
概览 Tab SHALL 按区域展示目标统计摘要、趋势图、状态分布和基本信息,每个区域使用小标题分隔。
概览 Tab SHALL 按区域展示目标统计摘要、趋势图、状态分布和基本信息,每个区域使用 TDesign Divider 组件作为小标题分隔。
#### Scenario: 区域排列顺序
- **WHEN** 概览面板渲染
- **THEN** 面板 SHALL 按以下顺序展示区域:统计 → 趋势 → 状态分布 → 基本信息,每个区域前 SHALL 显示小标题
- **THEN** 面板 SHALL 按以下顺序展示区域:统计 → 趋势 → 状态分布 → 基本信息,每个区域前 SHALL 显示 TDesign Divideralign="left")作为小标题,不使用内联 style 的 h4 标签
#### Scenario: 区域间距
- **WHEN** 概览面板渲染
- **THEN** 各区域之间的间距 SHALL 通过 TDesign Space 组件direction="vertical")统一管理,不使用内联 style 的 margin
#### Scenario: 统计数值卡片
- **WHEN** 概览面板渲染
- **THEN** 面板 SHALL 在"统计"区域使用 TDesign Statistic 组件展示 4 个统计值总检查color=blue、正常color=green、异常color=red、可用率color=green, suffix="%"),使用 TDesign Row/Col 横向排列
- **THEN** 面板 SHALL 在"统计"区域使用 TDesign Statistic 组件展示 4 个统计值总检查color=blue、正常color=green、异常color=red、可用率color=green, suffix="%"),使用 TDesign Row/Col 横向排列。Row 的外层间距 SHALL 通过 TDesign Space 或 CSS 类控制,不使用内联 style
#### Scenario: 趋势折线图
- **WHEN** 概览面板渲染且趋势数据可用

View File

@@ -24,11 +24,11 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
- **THEN** 分组标题 SHALL 显示 "默认分组"
### Requirement: 表格列定义
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。
#### Scenario: 状态列
- **WHEN** 表格渲染
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染fixed="left"宽度 80px居中对齐支持筛选UP/DOWN/全部)
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60pxfixed="left"居中对齐支持筛选UP/DOWN/全部)。StatusDot SHALL 通过 CSS 类(`.status-dot--up` / `.status-dot--down`)控制颜色,不使用内联 style
#### Scenario: 名称列
- **WHEN** 表格渲染
@@ -40,15 +40,15 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
#### Scenario: 可用率列
- **WHEN** 表格渲染
- **THEN** 可用率列 SHALL 使用 TDesign Progress 组件theme=line, size=small渲染颜色按可用率数值每 10% 一档0-10% 最红(#d54941),每升高 10% 色阶偏移一档经过橙色区间90-100% 最绿(#3dba60label 显示百分比数值,支持排序(升序优先,最差排最前)
- **THEN** 可用率列 SHALL 使用 TDesign Progress 组件theme=line, size=small渲染颜色通过 CSS 自定义属性 `--avail-N`(基于项目自定义色值)控制,每 10% 一档label 显示百分比数值支持排序升序优先最差排最前。color-threshold 函数 SHALL 返回 CSS 自定义属性引用而非硬编码色值
#### Scenario: 最近状态列
- **WHEN** 表格渲染
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染 30 格采样色块,宽度 220px,色块使用 flex:1 自适应列宽
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染 30 格采样色块,宽度 220px。StatusBar SHALL 通过 CSS 类(`.status-bar-block--up` / `.status-bar-block--down` / `.status-bar-block--empty`)控制色块颜色,不使用内联 style
#### Scenario: 延迟列
- **WHEN** 表格渲染
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐颜色根据阈值变化≤100ms 使用 --td-success-color、100-500ms 使用 --td-warning-color、>500ms 使用 --td-error-color无数据显示"-"支持数值排序
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐颜色 SHALL 通过 CSS 类实现≤100ms 使用 `.latency-ok`、100-500ms 使用 `.latency-warn`、>500ms 使用 `.latency-error`。无数据 SHALL 使用 `.text-disabled`显示 "-"数值 SHALL 使用 `.tabular-nums` 类等宽显示。不使用内联 style
#### Scenario: 间隔列
- **WHEN** 表格渲染
@@ -62,11 +62,15 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
- **THEN** 每个分组表格 SHALL 默认按状态降序排列DOWN 目标排在同组最前面
### Requirement: DOWN 行视觉强化
表格中状态为 DOWN 的行 SHALL 具有视觉区分。
表格中状态为 DOWN 的行 SHALL 具有视觉区分,使用安全 CSS 选择器实现
#### Scenario: DOWN 行背景色
- **WHEN** 目标最近一次检查 matched=false
- **THEN** 该行 SHALL 使用浅红色背景(--td-error-color-light),与正常行形成视觉区分
- **THEN** 该行 SHALL 通过 `.t-table tr.row-down` CSS 选择器获得浅红色背景(`--td-error-color-light`),不使用 `!important`
#### Scenario: DOWN 行 hover 状态
- **WHEN** 鼠标悬停在 DOWN 行上
- **THEN** 行背景 SHALL 通过 `.t-table--hoverable tbody tr.row-down:hover` 选择器显示 hover 状态色,与正常行 hover 效果协调
### Requirement: 行点击交互
表格行 SHALL 支持点击打开目标详情 Drawer。

View File

@@ -1,4 +1,4 @@
import { Alert, Loading } from "tdesign-react";
import { Alert, Loading, Typography } from "tdesign-react";
import { useSummary, useTargets, useTargetDetail } from "./hooks/useTargetDetail";
import { SummaryCards } from "./components/SummaryCards";
import { TargetBoard } from "./components/TargetBoard";
@@ -26,8 +26,8 @@ export function App() {
return (
<main className="dashboard">
<header className="dashboard-header">
<h1>DiAL</h1>
<p className="dashboard-subtitle"></p>
<Typography.Title level="h1">DiAL</Typography.Title>
<Typography.Text theme="secondary"></Typography.Text>
</header>
{error && <Alert theme="error" message={`请求失败: ${error.message}`} closeBtn />}

View File

@@ -1,4 +1,4 @@
import { Space, Tag } from "tdesign-react";
import { Tag, Typography } from "tdesign-react";
interface GroupHeaderProps {
name: string;
@@ -11,8 +11,8 @@ export function GroupHeader({ name, total, up, down }: GroupHeaderProps) {
const displayName = name === "default" ? "默认分组" : name;
return (
<Space align="center" size={8} style={{ marginBottom: 12 }}>
<h2 style={{ margin: 0, fontSize: "1.1rem", fontWeight: 600 }}>{displayName}</h2>
<div className="group-header">
<Typography.Title level="h4">{displayName}</Typography.Title>
<Tag theme="primary" variant="light" title="总数">
{total}
</Tag>
@@ -22,6 +22,6 @@ export function GroupHeader({ name, total, up, down }: GroupHeaderProps) {
<Tag theme="danger" variant="light" title="异常">
{down}
</Tag>
</Space>
</div>
);
}

View File

@@ -10,14 +10,11 @@ export function StatusBar({ samples }: StatusBarProps) {
blocks.push(
<span
key={i}
className="status-bar-block"
style={{ background: sample.up ? "var(--td-success-color)" : "var(--td-error-color)" }}
className={`status-bar-block ${sample.up ? "status-bar-block--up" : "status-bar-block--down"}`}
/>,
);
} else {
blocks.push(
<span key={i} className="status-bar-block" style={{ background: "var(--td-bg-color-component-disabled)" }} />,
);
blocks.push(<span key={i} className="status-bar-block status-bar-block--empty" />);
}
}

View File

@@ -3,12 +3,5 @@ interface StatusDotProps {
}
export function StatusDot({ up }: StatusDotProps) {
const color = up ? "var(--td-success-color)" : "var(--td-error-color)";
const shadow = up ? "var(--td-success-color)" : "var(--td-error-color)";
return (
<span
className="status-dot"
style={{ background: color, boxShadow: `0 0 0 6px color-mix(in srgb, ${shadow} 14%, transparent)` }}
/>
);
return <span className={`status-dot ${up ? "status-dot--up" : "status-dot--down"}`} />;
}

View File

@@ -15,7 +15,7 @@ export function SummaryCards({ summary }: SummaryCardsProps) {
];
return (
<Row gutter={16} style={{ marginBottom: 32 }}>
<Row gutter={16} className="summary-cards-row">
{cards.map((card) => (
<Col key={card.label} span={4}>
<Card bordered>

View File

@@ -25,7 +25,7 @@ export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
});
return (
<Space direction="vertical" size={32} style={{ width: "100%" }}>
<Space direction="vertical" size={32} className="full-width">
{sortedGroups.map(([name, groupTargets]) => (
<TargetGroup key={name} name={name} targets={groupTargets} onTargetClick={onTargetClick} />
))}

View File

@@ -11,6 +11,9 @@ import {
Descriptions,
Skeleton,
PrimaryTable,
Divider,
Space,
Typography,
} from "tdesign-react";
import type { TabValue } from "tdesign-react";
import type { CheckResult, TargetStatus, TrendPoint, HistoryResponse } from "../../shared/api";
@@ -40,13 +43,6 @@ const TIME_SHORTCUTS = [
{ label: "7天", hours: 168, value: "7d" },
] as const;
const SECTION_TITLE_STYLE: React.CSSProperties = {
fontSize: 14,
fontWeight: 600,
color: "var(--td-text-color-primary)",
margin: "16px 0 8px",
};
const HISTORY_COLUMNS = [
{
colKey: "matched",
@@ -136,16 +132,16 @@ export function TargetDetailDrawer({
footer={false}
onClose={onClose}
header={
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Space align="center" size={8}>
<StatusDot up={!!isUp} />
<span style={{ fontWeight: 600 }}>{target.name}</span>
<Typography.Text strong>{target.name}</Typography.Text>
<Tag size="small" theme="primary" variant="light-outline">
{getTargetTypeDisplay(target.type)}
</Tag>
</div>
</Space>
}
>
<div style={{ marginBottom: 16 }}>
<Space direction="vertical" size={16} className="full-width">
<RadioGroup
theme="button"
variant="default-filled"
@@ -153,8 +149,6 @@ export function TargetDetailDrawer({
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
onChange={handleShortcut}
/>
</div>
<div style={{ marginBottom: 16 }}>
<DateRangePicker
mode="date"
enableTimePicker
@@ -162,16 +156,17 @@ export function TargetDetailDrawer({
valueType="YYYY-MM-DD HH:mm"
defaultTime={["00:00:00", "23:59:00"]}
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
style={{ width: "100%" }}
className="full-width"
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
onChange={handleDateRangeChange}
/>
</div>
</Space>
<Tabs className="drawer-tabs" value={activeTab} onChange={(val: TabValue) => setActiveTab(val)}>
<Tabs.TabPanel value="overview" label="概览">
<h4 style={{ ...SECTION_TITLE_STYLE, marginTop: 0 }}></h4>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Tabs value={activeTab} onChange={(val: TabValue) => setActiveTab(val)}>
<Tabs.TabPanel value="overview" label="概览" className="tab-panel-padded">
<Space direction="vertical" size={16} className="full-width">
<Divider align="left"></Divider>
<Row gutter={16}>
<Col span={3}>
<Statistic title="总检查" value={totalChecks} color="blue" />
</Col>
@@ -186,13 +181,13 @@ export function TargetDetailDrawer({
</Col>
</Row>
<h4 style={SECTION_TITLE_STYLE}></h4>
<Divider align="left"></Divider>
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} loading={false} />}
<h4 style={SECTION_TITLE_STYLE}></h4>
<Divider align="left"></Divider>
<StatusDonut up={upChecks} down={downChecks} />
<h4 style={SECTION_TITLE_STYLE}></h4>
<Divider align="left"></Divider>
<Descriptions
items={[
{ label: "目标地址", content: target.target },
@@ -204,9 +199,10 @@ export function TargetDetailDrawer({
{ label: "状态详情", content: target.latestCheck?.statusDetail ?? "-" },
]}
/>
</Space>
</Tabs.TabPanel>
<Tabs.TabPanel value="history" label="记录">
<Tabs.TabPanel value="history" label="记录" className="tab-panel-padded">
<PrimaryTable
columns={HISTORY_COLUMNS}
data={historyData.items}

View File

@@ -30,7 +30,7 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
const target = row as TargetStatus;
return target.latestCheck?.matched === false ? "row-down" : "";
}}
style={{ cursor: "pointer" }}
className="clickable-table"
/>
</div>
);

View File

@@ -1,24 +1,4 @@
const AVAILABILITY_COLORS = [
"#d54941", // 0-10%
"#d96241", // 10-20%
"#e37318", // 20-30%
"#e89318", // 30-40%
"#d9a818", // 40-50%
"#b8b020", // 50-60%
"#8dba30", // 60-70%
"#6dba3f", // 70-80%
"#4dba50", // 80-90%
"#3dba60", // 90-100%
];
export function getAvailabilityProgressColor(availability: number): string {
const index = Math.min(Math.floor(availability / 10), 9);
return AVAILABILITY_COLORS[index]!;
}
export function getLatencyColor(ms: number): string {
if (ms <= 100) return "var(--td-success-color)";
if (ms <= 500) return "var(--td-warning-color)";
return "var(--td-error-color)";
return `var(--avail-${index})`;
}

View File

@@ -4,15 +4,15 @@ import type { TargetStatus } from "../../shared/api";
import { StatusDot } from "../components/StatusDot";
import { StatusBar } from "../components/StatusBar";
import { getTargetTypeDisplay } from "./target-type-display";
import { getAvailabilityProgressColor, getLatencyColor } from "./color-threshold";
import { getAvailabilityProgressColor } from "./color-threshold";
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
import { statusFilter, typeFilter } from "./target-table-filters";
export const TARGET_TABLE_COLUMNS: PrimaryTableCol<TargetStatus>[] = [
{
colKey: "latestCheck.matched",
title: "状态",
width: 80,
title: "#",
width: 60,
fixed: "left",
align: "center",
filter: statusFilter,
@@ -72,9 +72,9 @@ export const TARGET_TABLE_COLUMNS: PrimaryTableCol<TargetStatus>[] = [
sortType: "all",
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
const ms = row.latestCheck?.durationMs;
if (ms === null || ms === undefined) return <span style={{ color: "var(--td-text-color-disabled)" }}>-</span>;
const color = getLatencyColor(ms);
return <span style={{ color, fontVariantNumeric: "tabular-nums" }}>{Math.round(ms)}ms</span>;
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
return <span className={`${colorClass} tabular-nums`}>{Math.round(ms)}ms</span>;
},
},
{

View File

@@ -1,22 +1,28 @@
:root {
--avail-0: #d54941;
--avail-1: #d96241;
--avail-2: #e37318;
--avail-3: #e89318;
--avail-4: #d9a818;
--avail-5: #b8b020;
--avail-6: #8dba30;
--avail-7: #6dba3f;
--avail-8: #4dba50;
--avail-9: #3dba60;
}
.dashboard {
padding: 32px 24px;
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
width: 100%;
}
.dashboard-header {
margin-bottom: 32px;
margin-bottom: var(--td-comp-margin-l);
}
.dashboard-header h1 {
margin: 0 0 4px;
font-size: 1.75rem;
letter-spacing: -0.03em;
}
.dashboard-subtitle {
.dashboard-header .t-typography {
margin: 0;
color: var(--td-text-color-secondary);
font-size: 0.9rem;
line-height: 1.3;
}
.status-dot {
@@ -27,6 +33,16 @@
flex-shrink: 0;
}
.status-dot--up {
background: var(--td-success-color);
box-shadow: 0 0 0 6px color-mix(in srgb, var(--td-success-color) 14%, transparent);
}
.status-dot--down {
background: var(--td-error-color);
box-shadow: 0 0 0 6px color-mix(in srgb, var(--td-error-color) 14%, transparent);
}
.status-bar {
display: flex;
gap: 2px;
@@ -41,6 +57,18 @@
border-radius: 2px;
}
.status-bar-block--up {
background: var(--td-success-color);
}
.status-bar-block--down {
background: var(--td-error-color);
}
.status-bar-block--empty {
background: var(--td-bg-color-component-disabled);
}
.status-donut {
position: relative;
display: flex;
@@ -71,10 +99,60 @@
font-size: 0.85rem;
}
.drawer-tabs .t-tab-panel {
.tab-panel-padded {
padding: 15px;
}
.row-down {
background: color-mix(in srgb, var(--td-error-color) 6%, transparent) !important;
.t-table tr.row-down {
background: color-mix(in srgb, var(--td-error-color) 6%, transparent);
}
.t-table--hoverable tbody tr.row-down:hover {
background: color-mix(in srgb, var(--td-error-color) 10%, transparent);
}
.text-disabled {
color: var(--td-text-color-disabled);
}
.tabular-nums {
font-variant-numeric: tabular-nums;
}
.latency-ok {
color: var(--td-success-color);
}
.latency-warn {
color: var(--td-warning-color);
}
.latency-error {
color: var(--td-error-color);
}
.full-width {
width: 100%;
}
.clickable-table {
cursor: pointer;
}
.group-header {
margin-bottom: var(--td-comp-margin-m);
display: flex;
align-items: center;
gap: 8px;
}
.group-header .t-typography {
margin: 0;
font-size: var(--td-font-size-title-medium);
font-weight: 600;
line-height: 1.5;
}
.summary-cards-row {
margin-bottom: var(--td-comp-margin-xl);
}

View File

@@ -1,95 +1,69 @@
import { describe, test, expect } from "bun:test";
import { getAvailabilityProgressColor, getLatencyColor } from "../../../src/web/constants/color-threshold";
import { getAvailabilityProgressColor } from "../../../src/web/constants/color-threshold";
describe("color-threshold", () => {
describe("getAvailabilityProgressColor", () => {
test("0-10% 返回第一档颜色", () => {
expect(getAvailabilityProgressColor(0)).toBe("#d54941");
expect(getAvailabilityProgressColor(5)).toBe("#d54941");
expect(getAvailabilityProgressColor(9.99)).toBe("#d54941");
test("0-10% 返回第一档 CSS 变量", () => {
expect(getAvailabilityProgressColor(0)).toBe("var(--avail-0)");
expect(getAvailabilityProgressColor(5)).toBe("var(--avail-0)");
expect(getAvailabilityProgressColor(9.99)).toBe("var(--avail-0)");
});
test("10-20% 返回第二档颜色", () => {
expect(getAvailabilityProgressColor(10)).toBe("#d96241");
expect(getAvailabilityProgressColor(15)).toBe("#d96241");
expect(getAvailabilityProgressColor(19.99)).toBe("#d96241");
test("10-20% 返回第二档 CSS 变量", () => {
expect(getAvailabilityProgressColor(10)).toBe("var(--avail-1)");
expect(getAvailabilityProgressColor(15)).toBe("var(--avail-1)");
expect(getAvailabilityProgressColor(19.99)).toBe("var(--avail-1)");
});
test("20-30% 返回第三档颜色", () => {
expect(getAvailabilityProgressColor(20)).toBe("#e37318");
expect(getAvailabilityProgressColor(25)).toBe("#e37318");
test("20-30% 返回第三档 CSS 变量", () => {
expect(getAvailabilityProgressColor(20)).toBe("var(--avail-2)");
expect(getAvailabilityProgressColor(25)).toBe("var(--avail-2)");
});
test("30-40% 返回第四档颜色", () => {
expect(getAvailabilityProgressColor(30)).toBe("#e89318");
expect(getAvailabilityProgressColor(35)).toBe("#e89318");
test("30-40% 返回第四档 CSS 变量", () => {
expect(getAvailabilityProgressColor(30)).toBe("var(--avail-3)");
expect(getAvailabilityProgressColor(35)).toBe("var(--avail-3)");
});
test("40-50% 返回第五档颜色", () => {
expect(getAvailabilityProgressColor(40)).toBe("#d9a818");
expect(getAvailabilityProgressColor(45)).toBe("#d9a818");
test("40-50% 返回第五档 CSS 变量", () => {
expect(getAvailabilityProgressColor(40)).toBe("var(--avail-4)");
expect(getAvailabilityProgressColor(45)).toBe("var(--avail-4)");
});
test("50-60% 返回第六档颜色", () => {
expect(getAvailabilityProgressColor(50)).toBe("#b8b020");
expect(getAvailabilityProgressColor(55)).toBe("#b8b020");
test("50-60% 返回第六档 CSS 变量", () => {
expect(getAvailabilityProgressColor(50)).toBe("var(--avail-5)");
expect(getAvailabilityProgressColor(55)).toBe("var(--avail-5)");
});
test("60-70% 返回第七档颜色", () => {
expect(getAvailabilityProgressColor(60)).toBe("#8dba30");
expect(getAvailabilityProgressColor(65)).toBe("#8dba30");
test("60-70% 返回第七档 CSS 变量", () => {
expect(getAvailabilityProgressColor(60)).toBe("var(--avail-6)");
expect(getAvailabilityProgressColor(65)).toBe("var(--avail-6)");
});
test("70-80% 返回第八档颜色", () => {
expect(getAvailabilityProgressColor(70)).toBe("#6dba3f");
expect(getAvailabilityProgressColor(75)).toBe("#6dba3f");
test("70-80% 返回第八档 CSS 变量", () => {
expect(getAvailabilityProgressColor(70)).toBe("var(--avail-7)");
expect(getAvailabilityProgressColor(75)).toBe("var(--avail-7)");
});
test("80-90% 返回第九档颜色", () => {
expect(getAvailabilityProgressColor(80)).toBe("#4dba50");
expect(getAvailabilityProgressColor(85)).toBe("#4dba50");
test("80-90% 返回第九档 CSS 变量", () => {
expect(getAvailabilityProgressColor(80)).toBe("var(--avail-8)");
expect(getAvailabilityProgressColor(85)).toBe("var(--avail-8)");
});
test("90-100% 返回第十档颜色", () => {
expect(getAvailabilityProgressColor(90)).toBe("#3dba60");
expect(getAvailabilityProgressColor(95)).toBe("#3dba60");
expect(getAvailabilityProgressColor(99.9)).toBe("#3dba60");
expect(getAvailabilityProgressColor(100)).toBe("#3dba60");
test("90-100% 返回第十档 CSS 变量", () => {
expect(getAvailabilityProgressColor(90)).toBe("var(--avail-9)");
expect(getAvailabilityProgressColor(95)).toBe("var(--avail-9)");
expect(getAvailabilityProgressColor(99.9)).toBe("var(--avail-9)");
expect(getAvailabilityProgressColor(100)).toBe("var(--avail-9)");
});
test("边界值", () => {
expect(getAvailabilityProgressColor(9.999)).toBe("#d54941");
expect(getAvailabilityProgressColor(10)).toBe("#d96241");
expect(getAvailabilityProgressColor(19.999)).toBe("#d96241");
expect(getAvailabilityProgressColor(20)).toBe("#e37318");
expect(getAvailabilityProgressColor(89.999)).toBe("#4dba50");
expect(getAvailabilityProgressColor(90)).toBe("#3dba60");
});
});
describe("getLatencyColor", () => {
test("<=100ms 返回 success 色", () => {
expect(getLatencyColor(0)).toBe("var(--td-success-color)");
expect(getLatencyColor(50)).toBe("var(--td-success-color)");
expect(getLatencyColor(100)).toBe("var(--td-success-color)");
});
test("100-500ms 返回 warning 色", () => {
expect(getLatencyColor(101)).toBe("var(--td-warning-color)");
expect(getLatencyColor(250)).toBe("var(--td-warning-color)");
expect(getLatencyColor(500)).toBe("var(--td-warning-color)");
});
test(">500ms 返回 error 色", () => {
expect(getLatencyColor(501)).toBe("var(--td-error-color)");
expect(getLatencyColor(1000)).toBe("var(--td-error-color)");
});
test("边界值", () => {
expect(getLatencyColor(100)).toBe("var(--td-success-color)");
expect(getLatencyColor(100.01)).toBe("var(--td-warning-color)");
expect(getLatencyColor(500)).toBe("var(--td-warning-color)");
expect(getLatencyColor(500.01)).toBe("var(--td-error-color)");
expect(getAvailabilityProgressColor(9.999)).toBe("var(--avail-0)");
expect(getAvailabilityProgressColor(10)).toBe("var(--avail-1)");
expect(getAvailabilityProgressColor(19.999)).toBe("var(--avail-1)");
expect(getAvailabilityProgressColor(20)).toBe("var(--avail-2)");
expect(getAvailabilityProgressColor(89.999)).toBe("var(--avail-8)");
expect(getAvailabilityProgressColor(90)).toBe("var(--avail-9)");
});
});
});