【Vue3】day83-screen-a-migration

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

Day 83 · 屏 A 迁移 — KPI 总览页:把"开场白"搬进 Vue

生态件就绪(Router/Pinia/KeepAlive/Teleport),今天迁最后一块主内容:屏 A(KPI 总览页)——大屏的"开场白":四 KPI 翻牌矩阵 + 厂区俯视占位图 + 告警滚动条。它的迁移几乎全是装配活(零件都是 Week 11-12 造好的),真正的增量只有一件:告警滚动条的 CSS 动画与 v-for 的协作。今天结束时,三屏只剩屏 C(拓扑)留待 Week 13。


目录


一、屏 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>

两个值得记进笔记的技巧

  1. :key 骤变重播动画——key 换了 Vue 视为"新元素",CSS 动画自动重播。比 Transition 组件更轻,适合单元素轮换
  2. 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 战:三屏完整轮播联调 + 本周收官验收。

评论