【阶段1收官】day66-visual-layout-theme

作者:mario 发布时间: 2026-09-03 阅读量:6 评论数:0

Day 66 · 大屏视觉工程(上)— 布局系统与工业风主题 Token:从"能跑"到"能看"

同样的数据、同样的图表,好看的大屏和难看的大屏之间隔着一层系统性的视觉工程。今天建两层地基:布局系统(栅格、间距节奏、卡片容器)和主题 Token(把颜色/字号/圆角/阴影收敛成 CSS 变量体系)。这是阶段 0 CSS 知识(Grid、CSS 变量、BEM)在工业场景的总复习——当年练的每个基本功今天都有位置。


目录


一、工业大屏的视觉基因

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:0114: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 + 告警 + 俯视占位 + 右列双卡

骨架有了皮肤。明天给皮肤注入生命:入场动效编排、数字滚动、扫描线氛围——以及同样重要的:动效的性能预算与克制纪律

评论