Day 66 · 大屏视觉工程(上)— 布局系统与工业风主题 Token:从"能跑"到"能看"
同样的数据、同样的图表,好看的大屏和难看的大屏之间隔着一层系统性的视觉工程。今天建两层地基:布局系统(栅格、间距节奏、卡片容器)和主题 Token(把颜色/字号/圆角/阴影收敛成 CSS 变量体系)。这是阶段 0 CSS 知识(Grid、CSS 变量、BEM)在工业场景的总复习——当年练的每个基本功今天都有位置。
目录
- 一、工业大屏的视觉基因
- 二、主题 Token:视觉决策的单一来源
- 三、布局系统:栅格与间距节奏
- 四、卡片容器:工业化边框组件
- 五、顶部标题栏与底部指示器
- 六、屏 A(KPI 总览)布局落地
- 七、常见坑点
- 八、自测挑战
- 九、总结
一、工业大屏的视觉基因
1.1 为什么工业大屏长那个样子
深色底 → 监控室暗环境,深底护眼、让数据发光
青蓝色系 → 冷色调传递"精密、稳定"的工业气质
细边框发光 → 卡片边界在暗环境里的视觉锚点
大数字 → 三米外可读(Day 55 的字号纪律)
克制动效 → 数据是主角,装饰永远退后
1.2 反面教材清单(自查)
| 反面 | 为什么丑 |
|---|---|
| 每个卡片颜色不同 | 没有主次,视觉噪音 |
| 满屏都在闪 | 常驻动效超过 2 个,监控人员 10 分钟就疲劳 |
| 图表占位不齐 | 间距随意(8px/13px/20px 混用),显乱 |
| 纯白背景 | 监控室里刺眼,且发光色失效 |
二、主题 Token:视觉决策的单一来源
2.1 Token 分层
阶段 0 学过 CSS 变量,今天把它升级成有层级的 token 体系(设计系统的基础做法):
基础层(raw):颜色色值、字号原始值
↓
语义层(semantic):bg、text-primary、border、chart-1...
↓
组件层(component):card-bg、card-border、title-size...
2.2 落地:styles/tokens.css
/**
* 工业深色主题 Token(单一来源)
* 任何视觉调整只改这个文件——禁止在组件里写死色值
*/
:root {
/* ===== 基础层:色板 ===== */
--raw-bg-0: #080c14; /* 最深底 */
--raw-bg-1: #0d1421; /* 页面底 */
--raw-bg-2: #1a2740; /* 卡片底 */
--raw-line: #3a4a6a; /* 描边 */
--raw-text-0: #e0e8f0; /* 主文字 */
--raw-text-1: #8fa3c0; /* 次文字 */
--raw-text-2: #6a7a94; /* 弱文字(轴标签) */
--raw-blue: #00c6ff; /* 主色 */
--raw-green: #00ff88; /* 正常 */
--raw-yellow: #ffd666; /* 警告 */
--raw-red: #ff4d4f; /* 危险 */
/* ===== 语义层:用途命名 ===== */
--bg-page: var(--raw-bg-1);
--bg-card: rgba(26, 39, 64, 0.35);
--border-card: rgba(58, 74, 106, 0.4);
--border-glow: rgba(0, 198, 255, 0.35);
--text-title: var(--raw-text-0);
--text-body: var(--raw-text-1);
--text-dim: var(--raw-text-2);
--state-ok: var(--raw-green);
--state-warn: var(--raw-yellow);
--state-danger: var(--raw-red);
/* ===== 尺寸与节奏(4px 栅格)===== */
--gap-xs: 4px;
--gap-sm: 8px;
--gap-md: 16px;
--gap-lg: 24px;
--radius-card: 8px;
--radius-inner: 4px;
/* ===== 字号(大屏三米可读纪律,Day 55)===== */
--fs-kpi: 48px;
--fs-title: 18px;
--fs-axis: 12px;
--fs-dim: 11px;
/* ===== 字体:数字用等宽,跳动不移位 ===== */
--font-num: "DIN Alternate", "Roboto Mono", monospace;
--font-body: "PingFang SC", "Microsoft YaHei", sans-serif;
}
2.3 ECharts 主题同步
Day 55 的 industrial-dark.ts 里的色值改为读取同一份常量(CSS 变量 ECharts 读不到,TS 里导出一份并两边引用同一模块):
/**
* themes/palette.ts:TS 侧的 token 单一来源
* CSS(tokens.css)与 ECharts 主题(industrial-dark.ts)都从这里取值
*/
export const PALETTE = {
blue: "#00c6ff",
green: "#00ff88",
yellow: "#ffd666",
red: "#ff4d4f",
axisLabel: "#6a7a94",
splitLine: "rgba(106,122,148,0.15)",
} as const;
🎯 视觉单一来源(SSOT)的验收:想全局换主色(比如青色→琥珀色),只改一个文件、全屏(CSS + 图表 + Canvas)同步变化。改两个地方以上的视觉系统就是失败的。
三、布局系统:栅格与间距节奏
3.1 全屏框架
/**
* 大屏骨架:1920×1080 设计稿 + transform 缩放(Day 55 迁移)
* 内部布局:header / main(三屏轮播区)/ footer(指示器)
*/
#screen {
width: 1920px; height: 1080px;
position: relative;
transform-origin: left top;
background: var(--bg-page);
display: grid;
grid-template-rows: 72px 1fr 40px; /* 标题栏 / 内容 / 指示器 */
font-family: var(--font-body);
color: var(--text-body);
}
#screens-area { position: relative; } /* 三屏(absolute + opacity)的容器 */
3.2 屏内布局:统一栅格
三屏内容区都用同一套栅格(外边距 24、卡片间距 16、内边距 12):
/**
* 屏幕通用布局类:所有屏的内部结构都基于它
* BEM 命名(阶段 0 的规范复习)
*/
.screen-grid {
position: absolute; inset: 0;
padding: var(--gap-lg); /* 24px 外边距 */
display: grid;
gap: var(--gap-md); /* 16px 卡片间距 */
}
/* 屏 A:左 KPI 列 + 中俯视 + 右告警列 */
.screen-grid--overview {
grid-template-columns: 420px 1fr 420px;
grid-template-rows: 1fr 320px;
}
/* 屏 B:沿用第 9 周面板栅格(Day 56 的布局迁移)*/
.screen-grid--monitor {
grid-template-columns: 420px 1fr 420px;
grid-template-rows: 1fr 340px;
}
/* 屏 C:整屏拓扑 + 底部图例条 */
.screen-grid--topology {
grid-template-rows: 1fr 56px;
}
3.3 间距纪律
只允许出现 4 / 8 / 12 / 16 / 24 / 32 这几个间距值(4px 栅格)
❌ margin: 13px —— code review 时自我否决
四、卡片容器:工业化边框组件
4.1 组件化:TechCard
大屏上每个图表都住在一个"科技感卡片"里——做成组件,一处定义处处使用(阶段 0 组件化思想的落地):
/**
* 工业风卡片容器:标题 + 内容槽
* 结构:┌─ 切角边框 ─┐
* │ ▍标题 │
* │ [内容] │
* └───────────┘
*/
export function createTechCard(title: string): { root: HTMLElement; body: HTMLElement } {
const root = document.createElement("div");
root.className = "tech-card";
const header = document.createElement("div");
header.className = "tech-card__title";
// 标题前的竖条装饰(CSS 伪元素也可,这里用 span 便于加动效)
header.innerHTML = `<span class="tech-card__bar"></span>${title}`;
const body = document.createElement("div");
body.className = "tech-card__body";
root.append(header, body);
return { root, body };
}
4.2 卡片样式:切角 + 微光边框
/**
* 工业卡片:半透明底 + 1px 边框 + 四角切角
* 切角用 clip-path 实现(比 8 个绝对定位小方块干净)
*/
.tech-card {
background: var(--bg-card);
border: 1px solid var(--border-card);
border-radius: var(--radius-card);
clip-path: polygon(
12px 0, 100% 0, 100% calc(100% - 12px),
calc(100% - 12px) 100%, 0 100%, 0 12px
); /* 左上/右下切角:工业仪表的斜切语言 */
display: flex; flex-direction: column;
min-height: 0; /* grid 子项防溢出的关键 */
}
.tech-card__title {
display: flex; align-items: center; gap: var(--gap-sm);
padding: 10px var(--gap-md) 6px;
font-size: var(--fs-title); font-weight: 600;
color: var(--text-title);
}
.tech-card__bar {
width: 4px; height: 14px;
background: var(--raw-blue);
box-shadow: 0 0 8px var(--border-glow); /* 微发光:暗环境的锚点 */
}
.tech-card__body {
flex: 1; min-height: 0; /* 让图表容器拿到剩余高度 */
padding: var(--gap-sm) var(--gap-md) var(--gap-md);
position: relative;
}
🎯
min-height: 0是 grid/flex 布局里图表容器的生命线:不加它,内容默认最小高度会把卡片撑爆、图表溢出——大屏布局排查高频问题第一名。
五、顶部标题栏与底部指示器
5.1 标题栏
<header class="top-bar">
<div class="top-bar__deco top-bar__deco--l"></div> <!-- 左侧装饰线 -->
<h1 class="top-bar__title">智慧工厂生产监控中心</h1>
<div class="top-bar__meta">
<span class="conn-light" id="conn-light"></span>
<time id="clock">--:--:--</time>
</div>
<div class="top-bar__deco top-bar__deco--r"></div>
</header>
.top-bar {
display: flex; align-items: center; justify-content: center;
position: relative;
background: linear-gradient(180deg, rgba(26,39,64,0.6), transparent);
border-bottom: 1px solid var(--border-card);
}
.top-bar__title {
font-size: 26px; letter-spacing: 8px; /* 大标题字距拉开:大屏气质的关键一招 */
color: var(--text-title);
text-shadow: 0 0 12px var(--border-glow);
}
.top-bar__meta {
position: absolute; right: var(--gap-lg);
display: flex; align-items: center; gap: var(--gap-sm);
font-family: var(--font-num); /* 时间用等宽:秒针跳动不移位 */
color: var(--text-body);
}
/* 连接状态灯:Day 58 T3 组件的 token 化改造 */
.conn-light {
width: 10px; height: 10px; border-radius: 50%;
background: var(--state-ok);
box-shadow: 0 0 6px var(--state-ok);
}
.conn-light[data-state="reconnecting"] {
background: var(--state-warn); box-shadow: 0 0 6px var(--state-warn);
animation: blink 1s infinite;
}
.conn-light[data-state="closed"] {
background: var(--state-danger); box-shadow: 0 0 6px var(--state-danger);
}
5.2 底部指示器(轮播)
/**
* 轮播指示器:圆点 + 当前屏高亮 + 点击切换
* 与 ScreenRouter 联动(Day 64 的 onActive 事件驱动高亮)
*/
export function createIndicator(
screens: { id: string; label: string }[],
router: ScreenRouter
): HTMLElement {
const nav = document.createElement("nav");
nav.className = "screen-indicator";
const dots = screens.map((s) => {
const dot = document.createElement("button");
dot.className = "screen-indicator__dot";
dot.title = s.label;
dot.onclick = () => router.switchTo(s.id);
nav.appendChild(dot);
return dot;
});
router.onActive((id) => {
dots.forEach((d, i) => d.classList.toggle("is-active", screens[i].id === id));
});
return nav;
}
六、屏 A(KPI 总览)布局落地
今天把三屏中唯一"全新"的屏 A 布局做出来(B/C 明后天迁移):
/**
* 屏 A:KPI 总览
* 布局:左列 KPI×4 + 告警列表 / 中央厂区俯视占位 / 右列产量目标 + 质量分布
*/
export function createOverviewScreen(
el: HTMLElement,
store: ScreenStore
): ScreenModule {
// 左列
const kpiCard = createTechCard("关键指标");
const alarmCard = createTechCard("实时告警");
// 中央
const mapCard = createTechCard("厂区总览");
// 右列
const targetCard = createTechCard("产量目标达成");
const qualityCard = createTechCard("质量分布");
el.append(
wrap(kpiCard.root, "overview__kpi"), wrap(alarmCard.root, "overview__alarm"),
wrap(mapCard.root, "overview__map overview__map--wide"),
wrap(targetCard.root, "overview__target"), wrap(qualityCard.root, "overview__quality")
);
// KPI 翻牌器迁移(Day 56 的 createKpiCard 复用,样式改用 token)
const kpi = createKpiCard(kpiCard.body);
// 告警列表:订阅 store.onAlarms 渲染最新 8 条
let unsub: (() => void) | null = null;
return {
activate() {
unsub = store.subscribe({
onSnapshot: (s) => kpi.update(s.kpi),
onAlarms: (alarms) => renderAlarmList(alarmCard.body, alarms),
onConnState: (s) => setLight(s),
});
},
deactivate() { unsub?.(); },
};
}
中央"厂区俯视"今天用占位(渐变底 + 网格 + 中央工厂 SVG 轮廓),真正的厂区图属于阶段 3(3D/地图方向)——记入第 3 个月待办,占位也要好看(这才是大屏的诚实做法:占位 ≠ 烂尾)。
七、常见坑点
坑 1:色值散落各处
#00c6ff 在 CSS/TS/Canvas 里出现 20 次,改主色改到崩溃。修复:tokens.css + palette.ts 双源同步(2.3),搜索断言 hex 字面量只出现在这两个文件。
坑 2:图表容器高度为 0
grid 子项没加 min-height: 0 / flex 没加 min-height: 0,ECharts init 在 0 高度容器上。修复:卡片组件统一内置(4.2 的 .tech-card__body)。
坑 3:时间跳动导致布局抖动
14:00:01 和 14:00:11 宽度不同(非等宽字体),旁边的状态灯跟着晃。修复:时间/数字一律 --font-num 等宽字体。
坑 4:clip-path 切角吃掉边框发光
clip-path 会裁掉 box-shadow。修复:发光用卡片内层元素(如标题竖条)实现,或外层再包一个带同样 clip-path 的底色层模拟描边——本例选择"描边用 border、发光只在内部元素"的简化方案。
坑 5:三屏各自定义栅格
屏 A 用 420px 侧栏、屏 B 用 400px——切屏时左右卡片边界跳动。修复:三屏共享 --grid-side: 420px token。
八、自测挑战
T1 · Token 体系 + 屏 A 布局(60 分钟)
完成 tokens.css、palette.ts、TechCard、顶部栏、指示器,屏 A 五卡片布局 + KPI 数据接入。今天核心作业。
T2 · SSOT 验证(10 分钟)
把 --raw-blue 换成琥珀色(#ffb300),确认 CSS 卡片、状态灯、ECharts 图表同步变色后还原。
T3 · 双分辨率验收(20 分钟)
1920×1080 和 1366×768 下截图对比:卡片不破版、KPI 数字完整、标题不换行。破版处修栅格而非打补丁。
T4 · 告警列表组件(30 分钟)
屏 A 右侧告警列表:最新在前、danger 红边/warn 黄边、新告警滑入动画(一条 keyframes 即可)、空态显示"暂无告警"。它是明天动效纪律的第一个素材。
九、总结
| 层 | 产出 |
|---|---|
| Token | tokens.css + palette.ts 双源同步,SSOT 验收 |
| 布局 | 4px 栅格、统一侧栏宽、min-height:0 纪律 |
| 组件 | TechCard(切角+微光)、状态灯、指示器 |
| 屏 A | KPI + 告警 + 俯视占位 + 右列双卡 |
骨架有了皮肤。明天给皮肤注入生命:入场动效编排、数字滚动、扫描线氛围——以及同样重要的:动效的性能预算与克制纪律。