Day 83 · 屏 A 迁移 — KPI 总览页:把"开场白"搬进 Vue
生态件就绪(Router/Pinia/KeepAlive/Teleport),今天迁最后一块主内容:屏 A(KPI 总览页)——大屏的"开场白":四 KPI 翻牌矩阵 + 厂区俯视占位图 + 告警滚动条。它的迁移几乎全是装配活(零件都是 Week 11-12 造好的),真正的增量只有一件:告警滚动条的 CSS 动画与 v-for 的协作。今天结束时,三屏只剩屏 C(拓扑)留待 Week 13。
目录
- 一、屏 A 的组成盘点
- 二、ScreenA.vue 完整实现
- 三、告警滚动条:v-for 与 CSS 动画的协作
- 四、厂区俯视占位:分层留白的正确姿势
- 五、KPI 矩阵:storeToRefs 与 useRollingNumber 装配
- 六、常见坑点
- 七、自测挑战
- 八、总结
一、屏 A 的组成盘点
| 区块 | 原生版出处 | Vue 版零件 | 增量工作 |
|---|---|---|---|
| 四 KPI 翻牌 | Day 67 + Day 70 | KpiTile(useRollingNumber 内聚) | 装配 |
| 厂区俯视占位图 | Day 70 静态 SVG | 同一 SVG 资产迁移 | 复制 |
| 告警滚动条 | Day 63 告警条 + Day 70 改造 | AlarmTicker 组件 | 新写(第三节) |
| 布局 grid | Day 66 | 同一份 CSS | 复制 |
| 数据 | useScreenStore(已退役) | dashboard store(Day 80-81) | 装配 |
对照 Day 77 屏 B 的迁移成本:那周要从 VChart 开始造零件;今天全是装配——基础设施投资的边际回报开始显现。
二、ScreenA.vue 完整实现
<!-- src/screens/ScreenA.vue -->
<script setup lang="ts">
import { computed, onActivated } from "vue";
import { storeToRefs } from "pinia";
import { useDashboardStore } from "../stores/dashboard";
import TechCard from "../components/TechCard.vue";
import KpiTile from "../components/KpiTile.vue";
import AlarmTicker from "../components/AlarmTicker.vue";
defineOptions({ name: "ScreenA" }); // KeepAlive include 匹配(Day 82 坑 2)
const store = useDashboardStore();
const { latest } = storeToRefs(store);
// KPI 派生(getters 已有 avgTemp,屏级再补两个展示派生)
const kpis = computed(() => [
{ label: "今日产量", value: latest.value?.kpi.output ?? 0, decimals: 0 },
{ label: "平均温度", value: store.avgTemp, decimals: 1 },
{ label: "在线设备", value: latest.value?.devices.filter((d) => d.state !== "offline").length ?? 0, decimals: 0 },
{ label: "活跃告警", value: store.activeAlarms.length, decimals: 0 },
]);
// 激活协议:屏 A 无图表,只需轻校准(Day 82 纪律)
onActivated(() => {
// 翻牌器在数据驱动下自愈,无需干预——留空也写上钩子,未来加内容有位置
});
</script>
<template>
<section class="screen screen-a">
<!-- 布局:上 KPI 带 / 中 厂区图 / 下 告警条(Day 66 grid 原样) -->
<div class="overview-grid">
<div class="overview__kpis">
<TechCard title="关键指标总览">
<div class="kpi-matrix">
<!-- v-for + key:KPI 数固定但语义化 key 更稳(设备/指标 id) -->
<KpiTile
v-for="k in kpis"
:key="k.label"
:label="k.label"
:value="k.value"
:decimals="k.decimals"
/>
</div>
</TechCard>
</div>
<div class="overview__plant">
<TechCard title="厂区总览">
<img src="../assets/plant-topo.svg" alt="厂区俯视图" class="plant-img" />
</TechCard>
</div>
<div class="overview__ticker">
<AlarmTicker />
</div>
</div>
</section>
</template>
三、告警滚动条:v-for 与 CSS 动画的协作
3.1 需求回顾(Day 70 版)
单行滚动条:告警逐条横向滚入展示,danger 级红色。原生实现是"定时 shift 数组 + 改 DOM"。
3.2 Vue 版的思路转变
❌ 思路:定时器驱 DOM(把原生逻辑原样搬来)
✅ 思路:状态(当前显示第几条)驱动,DOM 是映射
<!-- src/components/AlarmTicker.vue -->
<script setup lang="ts">
/**
* 告警滚动条:每 4 秒轮换一条活跃告警
* 设计:一个 index ref 驱动当前显示;数据变化时 index 自动回卷
* (状态驱动——没有任何定时器直接操作 DOM)
*/
import { computed, onDeactivated, onMounted, onUnmounted, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { useDashboardStore } from "../stores/dashboard";
const store = useDashboardStore();
const { activeAlarms } = storeToRefs(store);
const index = ref(0);
let timer = 0;
// 当前告警:越界自动回卷(告警被 ack 后数组变短的容错——Day 74 key 纪律的姊妹坑)
const current = computed(() =>
activeAlarms.value.length > 0
? activeAlarms.value[index.value % activeAlarms.value.length]
: null
);
const start = (): void => {
timer = window.setInterval(() => {
index.value++;
}, 4000);
};
const stop = (): void => clearInterval(timer);
// 生命周期成对 + 激活协议(KeepAlive 下失活停表——省 CPU 纪律)
onMounted(start);
onUnmounted(stop);
onDeactivated(stop);
onActivated(start);
// 数据源清空时重置(从 0 数起,避免 % 0 的 NaN)
watch(() => activeAlarms.value.length, (len) => {
if (len === 0) index.value = 0;
});
</script>
<template>
<div class="ticker">
<span v-if="current" class="ticker__item" :data-level="current.level" :key="current.id">
<!-- :key 换人即重播动画:Transition 之外更轻的"key 骤变重渲"技巧 -->
{{ fmtTime(current.ts) }} · {{ current.text }}
</span>
<span v-else class="ticker__empty">所有系统运行正常</span>
</div>
</template>
<style scoped>
.ticker { overflow: hidden; height: 32px; }
.ticker__item {
display: inline-block; white-space: nowrap;
animation: ticker-in 0.5s ease; /* 每条滚入一次 */
color: var(--text-body); font-size: var(--fs-axis);
}
.ticker__item[data-level="danger"] { color: var(--state-danger); }
.ticker__item[data-level="warn"] { color: var(--state-warn); }
@keyframes ticker-in {
from { transform: translateX(40px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
两个值得记进笔记的技巧:
:key骤变重播动画——key 换了 Vue 视为"新元素",CSS 动画自动重播。比 Transition 组件更轻,适合单元素轮换index % length回卷——数组消费端的标准防御,告警被 ack/截断后 index 不会越界
四、厂区俯视占位:分层留白的正确姿势
厂区俯视图(plant-topo.svg)本周仍是静态占位(Week 13 屏 C 迁移时评估是否与拓扑页合并能力)。迁移纪律:
✅ SVG 文件原样拷贝到 src/assets/
✅ <img> 引用(Vite 自动处理资产路径与 hash)
❌ 不"顺手"加点击热区、不加设备点位动画——范围冻结(Week 10 铁律的延续)
记待办:厂区图与屏 C 拓扑的数据联动(图上分区点击 → 跳拓扑页对应设备)——Week 14 或阶段 2 后期再议。
五、KPI 矩阵:storeToRefs 与 useRollingNumber 装配
KpiTile 内部(Week 11 简化版升级为正式版):
<!-- src/components/KpiTile.vue(正式版) -->
<script setup lang="ts">
import { computed, toRef } from "vue";
import { useRollingNumber } from "../composables/useRollingNumber";
const props = defineProps<{
label: string;
value: number;
decimals?: number;
}>();
// toRef:把 props 的某个字段变成 ref——喂给组合式函数(Day 75 坑 3 的正面示范)
const valueRef = toRef(props, "value");
const display = useRollingNumber(valueRef, {
duration: 800,
decimals: props.decimals ?? 0,
});
</script>
<template>
<div class="kpi-tile">
<span class="kpi-tile__label">{{ label }}</span>
<span class="kpi-tile__value">{{ display }}</span>
</div>
</template>
装配链全景(值得作为"组合式架构"的教学样本):
WS 链路(服务层) → store.setSnapshot(actions)
→ latest(state) → kpis computed(屏级派生)
→ KpiTile props → toRef → useRollingNumber watch
→ display ref → 模板插值
数据从服务器到像素,每一跳都有名字、可断点、可测试——对照原生版 Day 63 手动 update 链,这条链路就是"状态驱动"的具象。
六、常见坑点
坑 1:滚动条 index 越界(% 0)
告警清空的瞬间 index % 0 = NaN,模板渲染 undefined。第三节的 watch + 回卷已防御;自己写类似组件时记住:凡对动态数组取模,先问空数组。
坑 2:onActivated 里重复 start 定时器
onMounted 已 start,onActivated 再 start → 双倍速滚动。纪律:start 前必 stop(或像第三节那样 start/stop 成对出现在对称钩子里)。
坑 3:KpiTile 的 useRollingNumber 在 props 更新前就跑了
首帧 value=0 → 滚到 0;数据到达 value=85 → 滚到 85。两次滚动(首帧 0 的滚动是浪费但无害)。若要消除:KpiTile 在 value 首次非零前不渲染(v-if 上移到屏级)——权衡观感(先出 0 再滚起来也是大屏惯例),记笔记即可。
坑 4:SVG 资产路径大小写
Windows 不区分大小写、构建服务器(Linux)区分——Plant-Topo.svg vs plant-topo.svg 本地跑通、build 后 404。规范:资产文件名全小写。
坑 5:屏 A 的告警滚动条与屏 B 的告警列表重复订阅
两个组件各调 useDashboardStore——不是问题(Pinia 是全局唯一状态,useXxxStore() 返回同一实例,无重复连接)。这与 Week 11 的 useScreenStore 镜像冗余本质不同——Pinia 版根本没有"订阅"概念,只有读状态。把这个认知差写进对照表。
七、自测挑战
T1 · 屏 A 完整迁移(80 分钟)
完成第二至五节全部区块,对照原生版录屏:KPI 数值/翻牌节奏/告警滚速/空状态文案逐项一致。
T2 · 滚动条压力测试(20 分钟)
Devtools 每 500ms push 一条告警持续 1 分钟:滚动条稳定轮换、无 NaN、无卡顿;ack 掉一半后 index 回卷正确。
T3 · 激活协议验证(20 分钟)
轮播三圈:屏 A 失活时滚动条停走(Console 打点验证)、切回续走且数据是最新的(store 活水常开的收益)。
八、总结
| 环节 | 要点 |
|---|---|
| 装配 | 零件全备,增量只有 AlarmTicker |
| 状态驱动 | 定时器只改 index,DOM 是映射 |
| key 骤变重播 | 单元素轮换动画的轻量技巧 |
| 激活协议 | start/stop 在对称钩子成对 |
| 认知升级 | Pinia 无"订阅"概念——读状态即订阅 |
三屏只剩屏 C。明天 BOSS 战:三屏完整轮播联调 + 本周收官验收。