refactor: 优化卡片显示一致性与可扩展性
- 统一 Summary Cards 和 Target Cards 宽度为 280px(CSS 变量控制) - 分组统计改为徽章展示(纯数字 + 颜色区分) - 目标名称添加 title 属性支持显示完整名称 - 建立类型映射系统,Command 显示为 CMD,支持扩展 - 移除移动端响应式代码,简化维护 - 新增 target-type-display 能力规格 - 更新 card-dashboard 和 target-detail-modal 规格
This commit is contained in:
@@ -28,6 +28,7 @@ src/
|
||||
api.ts 前后端共享 TypeScript 类型
|
||||
web/ Vite + React 前端 Dashboard
|
||||
components/ UI 组件(卡片、分组、模态框、状态条等)
|
||||
constants/ 常量定义(类型映射等)
|
||||
hooks/ 数据轮询和详情管理 hooks
|
||||
utils/ 前端工具函数
|
||||
scripts/ 开发、构建和 smoke test 脚本
|
||||
|
||||
@@ -11,9 +11,13 @@ Dashboard SHALL 按分组展示所有拨测目标,每个分组包含带统计
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 按分组展示目标卡片,"默认分组" 排在最上面,其余分组按 YAML 配置顺序排列
|
||||
|
||||
#### Scenario: 分组标题带统计
|
||||
#### Scenario: 分组标题带统计徽章
|
||||
- **WHEN** 页面渲染某个分组
|
||||
- **THEN** 分组标题 SHALL 显示分组名称、该分组内目标总数、正常数和异常数,格式为 `分组名 (N个, X UP / Y DOWN)`
|
||||
- **THEN** 分组标题 SHALL 显示分组名称和三个徽章:总数(蓝色)、正常数(绿色)、异常数(红色),徽章仅显示数字
|
||||
|
||||
#### Scenario: 分组统计徽章提示
|
||||
- **WHEN** 鼠标悬停在分组统计徽章上
|
||||
- **THEN** 徽章 SHALL 显示提示文字("总数"、"正常"、"异常")
|
||||
|
||||
#### Scenario: "default" 分组显示名称
|
||||
- **WHEN** 分组名称为 "default"
|
||||
@@ -28,7 +32,7 @@ Dashboard SHALL 使用固定宽度的卡片配合 Flexbox 流动布局,容器
|
||||
|
||||
#### Scenario: 卡片固定宽度
|
||||
- **WHEN** 页面渲染卡片(包括 Summary Cards 和 Target Cards)
|
||||
- **THEN** 每个卡片 SHALL 固定宽度 270px,使用 `flex-shrink: 0` 防止收缩
|
||||
- **THEN** 每个卡片 SHALL 固定宽度 280px,使用 CSS 变量 `--dashboard-card-width` 统一控制
|
||||
|
||||
#### Scenario: 流动式布局
|
||||
- **WHEN** 视口宽度变化
|
||||
@@ -47,7 +51,11 @@ Dashboard SHALL 使用固定宽度的卡片配合 Flexbox 流动布局,容器
|
||||
|
||||
#### Scenario: 卡片第一层内容
|
||||
- **WHEN** 卡片渲染
|
||||
- **THEN** 卡片第一层 SHALL 展示状态指示圆点(UP 绿色 / DOWN 红色)、目标名称和类型标签(HTTP / Command)
|
||||
- **THEN** 卡片第一层 SHALL 展示状态指示圆点(UP 绿色 / DOWN 红色)、目标名称和类型标签(HTTP / CMD)
|
||||
|
||||
#### Scenario: 卡片名称完整提示
|
||||
- **WHEN** 目标名称过长被截断显示
|
||||
- **THEN** 鼠标悬停在名称上 SHALL 通过浏览器原生 tooltip 显示完整名称
|
||||
|
||||
#### Scenario: 卡片状态指示圆点
|
||||
- **WHEN** 目标最近一次拨测 matched=true
|
||||
|
||||
@@ -74,3 +74,14 @@ Dashboard SHALL 在用户点击目标卡片后弹出模态框,展示该目标
|
||||
#### Scenario: 自上而下渲染
|
||||
- **WHEN** 模态框渲染
|
||||
- **THEN** 内容区域 SHALL 分为上下两部分,上方展示统计图表,下方展示检查结果列表和分页器
|
||||
|
||||
### Requirement: 模态框标题栏类型标签
|
||||
模态框标题栏 SHALL 显示目标类型标签,使用统一的类型显示映射系统。
|
||||
|
||||
#### Scenario: 类型标签显示
|
||||
- **WHEN** 模态框标题栏渲染
|
||||
- **THEN** 标题栏 SHALL 在目标名称旁显示类型标签(HTTP / CMD)
|
||||
|
||||
#### Scenario: 类型标签使用映射系统
|
||||
- **WHEN** 模态框渲染类型标签
|
||||
- **THEN** 类型标签 SHALL 使用统一的类型显示映射函数,不硬编码映射逻辑
|
||||
|
||||
42
openspec/specs/target-type-display/spec.md
Normal file
42
openspec/specs/target-type-display/spec.md
Normal file
@@ -0,0 +1,42 @@
|
||||
## Purpose
|
||||
|
||||
定义目标类型(Target Type)的前端显示名称映射系统,支持从后端类型标识符到前端展示名称的可扩展转换。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 类型显示名称映射
|
||||
系统 SHALL 提供目标类型到显示名称的映射,将后端类型标识符转换为前端展示的简短名称。
|
||||
|
||||
#### Scenario: HTTP 类型显示
|
||||
- **WHEN** 目标类型为 "http"
|
||||
- **THEN** 前端 SHALL 显示 "HTTP"
|
||||
|
||||
#### Scenario: Command 类型显示
|
||||
- **WHEN** 目标类型为 "command"
|
||||
- **THEN** 前端 SHALL 显示 "CMD"
|
||||
|
||||
#### Scenario: 未知类型处理
|
||||
- **WHEN** 目标类型不在映射表中
|
||||
- **THEN** 前端 SHALL 将类型名称转换为大写显示
|
||||
|
||||
### Requirement: 映射可扩展性
|
||||
类型映射系统 SHALL 支持后续新增类型,无需修改多处代码。
|
||||
|
||||
#### Scenario: 新增类型映射
|
||||
- **WHEN** 需要新增目标类型(如 "tcp"、"dns"、"grpc")
|
||||
- **THEN** 开发者 SHALL 仅需在映射常量中添加一条记录
|
||||
|
||||
#### Scenario: 映射单一数据源
|
||||
- **WHEN** 前端组件需要显示目标类型
|
||||
- **THEN** 组件 SHALL 调用统一的映射函数,不直接硬编码映射逻辑
|
||||
|
||||
### Requirement: 类型安全
|
||||
类型映射系统 SHALL 提供类型安全的访问方式。
|
||||
|
||||
#### Scenario: TypeScript 类型推导
|
||||
- **WHEN** 使用映射常量
|
||||
- **THEN** TypeScript SHALL 能够推导出正确的类型(使用 `as const`)
|
||||
|
||||
#### Scenario: 运行时安全
|
||||
- **WHEN** 传入无效类型
|
||||
- **THEN** 系统 SHALL 返回 fallback 值,不抛出异常
|
||||
@@ -11,9 +11,17 @@ export function GroupHeader({ name, total, up, down }: GroupHeaderProps) {
|
||||
return (
|
||||
<div className="group-header">
|
||||
<h2 className="group-title">{displayName}</h2>
|
||||
<span className="group-stats">
|
||||
({total}个, {up} UP / {down} DOWN)
|
||||
</span>
|
||||
<div className="group-stats">
|
||||
<span className="stat-badge stat-badge-total" title="总数">
|
||||
{total}
|
||||
</span>
|
||||
<span className="stat-badge stat-badge-up" title="正常">
|
||||
{up}
|
||||
</span>
|
||||
<span className="stat-badge stat-badge-down" title="异常">
|
||||
{down}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TargetStatus } from "../../shared/api";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { MiniSparkline } from "./MiniSparkline";
|
||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
||||
|
||||
interface TargetCardProps {
|
||||
target: TargetStatus;
|
||||
@@ -15,8 +16,10 @@ export function TargetCard({ target, onClick }: TargetCardProps) {
|
||||
<div className="target-card" onClick={onClick} role="button" tabIndex={0}>
|
||||
<div className="card-header">
|
||||
<StatusDot up={!!isUp} />
|
||||
<span className="card-name">{target.name}</span>
|
||||
<span className="card-type-badge">{target.type === "http" ? "HTTP" : "Command"}</span>
|
||||
<span className="card-name" title={target.name}>
|
||||
{target.name}
|
||||
</span>
|
||||
<span className="card-type-badge">{getTargetTypeDisplay(target.type)}</span>
|
||||
</div>
|
||||
<div className="card-status-bar">
|
||||
<StatusBar samples={target.recentSamples} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StatusDonut } from "./StatusDonut";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { TimeRangePicker } from "./TimeRangePicker";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
||||
|
||||
interface TargetDetailModalProps {
|
||||
target: TargetStatus;
|
||||
@@ -48,7 +49,7 @@ export function TargetDetailModal({
|
||||
<div className="modal-title-row">
|
||||
<StatusDot up={!!isUp} />
|
||||
<h3 className="modal-title">{target.name}</h3>
|
||||
<span className="card-type-badge">{target.type === "http" ? "HTTP" : "Command"}</span>
|
||||
<span className="card-type-badge">{getTargetTypeDisplay(target.type)}</span>
|
||||
</div>
|
||||
<button className="modal-close-btn" onClick={onClose}>
|
||||
×
|
||||
|
||||
10
src/web/constants/target-type-display.ts
Normal file
10
src/web/constants/target-type-display.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const TARGET_TYPE_DISPLAY = {
|
||||
http: "HTTP",
|
||||
command: "CMD",
|
||||
} as const;
|
||||
|
||||
export type TargetType = keyof typeof TARGET_TYPE_DISPLAY;
|
||||
|
||||
export function getTargetTypeDisplay(type: string): string {
|
||||
return TARGET_TYPE_DISPLAY[type as TargetType] || type.toUpperCase();
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
--dashboard-card-width: 280px;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -60,7 +61,7 @@ body {
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
width: 280px;
|
||||
width: var(--dashboard-card-width);
|
||||
flex-shrink: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.12);
|
||||
@@ -122,8 +123,33 @@ body {
|
||||
}
|
||||
|
||||
.group-stats {
|
||||
color: #61728a;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-badge-total {
|
||||
background: rgba(53, 109, 210, 0.1);
|
||||
color: #356dd2;
|
||||
}
|
||||
|
||||
.stat-badge-up {
|
||||
background: rgba(31, 191, 117, 0.1);
|
||||
color: #1fbf75;
|
||||
}
|
||||
|
||||
.stat-badge-down {
|
||||
background: rgba(229, 72, 77, 0.1);
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
@@ -133,7 +159,7 @@ body {
|
||||
}
|
||||
|
||||
.target-card {
|
||||
width: 270px;
|
||||
width: var(--dashboard-card-width);
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.12);
|
||||
@@ -579,23 +605,3 @@ body {
|
||||
color: #94a3b8;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.modal-charts {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.time-range-picker {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
39
tests/web/constants/target-type-display.test.ts
Normal file
39
tests/web/constants/target-type-display.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { TARGET_TYPE_DISPLAY, getTargetTypeDisplay } from "../../../src/web/constants/target-type-display";
|
||||
|
||||
describe("target-type-display", () => {
|
||||
describe("TARGET_TYPE_DISPLAY 常量", () => {
|
||||
test("定义了 http 类型映射", () => {
|
||||
expect(TARGET_TYPE_DISPLAY.http).toBe("HTTP");
|
||||
});
|
||||
|
||||
test("定义了 command 类型映射", () => {
|
||||
expect(TARGET_TYPE_DISPLAY.command).toBe("CMD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTargetTypeDisplay 函数", () => {
|
||||
test("http 类型返回 HTTP", () => {
|
||||
expect(getTargetTypeDisplay("http")).toBe("HTTP");
|
||||
});
|
||||
|
||||
test("command 类型返回 CMD", () => {
|
||||
expect(getTargetTypeDisplay("command")).toBe("CMD");
|
||||
});
|
||||
|
||||
test("未知类型返回大写形式", () => {
|
||||
expect(getTargetTypeDisplay("tcp")).toBe("TCP");
|
||||
expect(getTargetTypeDisplay("dns")).toBe("DNS");
|
||||
expect(getTargetTypeDisplay("grpc")).toBe("GRPC");
|
||||
});
|
||||
|
||||
test("空字符串返回空字符串", () => {
|
||||
expect(getTargetTypeDisplay("")).toBe("");
|
||||
});
|
||||
|
||||
test("已大写的类型保持大写", () => {
|
||||
expect(getTargetTypeDisplay("HTTP")).toBe("HTTP");
|
||||
expect(getTargetTypeDisplay("CMD")).toBe("CMD");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user