李翔-大数据技术

Big data technology!

8-3 数据可视化 实验三:前端部署

八、实验三:前端部署


任务1:创建前端项目(Vue3 + Vite)

目标: 成功创建并运行 Vue3 + Vite 前端项目,浏览器访问 http://localhost:5173 看到 Vue 欢迎页。

重要前提

  1. 本机必须先安装 Node.js(自带 npm 包管理工具)

  2. Windows 下必须用管理员权限 CMD 创建项目(普通权限会导致 npm 写入缓存失败)

  3. 不要使用 IDEA 内置终端创建项目(默认普通权限)


步骤1:管理员身份打开 CMD,进入代码目录

建议和后端项目放在同级目录:

# 切换到 D 盘
D:
# 进入代码存放文件夹
cd D:\bigdata1\code

步骤2:检查 Node.js 环境

  • Node.js:让 JavaScript 能在电脑上运行(类似 Java 的 JDK)

  • npm:Node.js 自带的包管理工具(类似 Maven)

node -v
npm -v
  • ✅ 能输出版本号 = 环境正常

  • ❌ 提示不是内部命令 = 未安装 Node.js

Windows 安装 Node.js:下载地址 https://nodejs.org/en/download → 下载 LTS 版本 → 安装 → 重新打开 CMD 验证


步骤3:使用 Vite 创建 Vue3 项目

npm create vite@latest smart-screen-frontend -- --template vue


命令部分含义
npmNode.js 包管理工具
create vite@latest使用最新版 Vite 创建项目
smart-screen-frontend新建项目的文件夹名称
--template vue指定项目模板为 Vue


安装过程中依次选择:

  • Ok to proceed? (y)输入:y

  • Install with npm and start now?Yes(默认回车确认)

执行成功后,当前目录会生成 smart-screen-frontend 项目文件夹。


步骤4:进入项目并安装依赖

Ctrl+C 强制关闭已启动的前端项目,然后:

# 1. 进入前端项目目录
cd smart-screen-frontend

# 2. 安装项目依赖
# npm 根据 package.json 自动下载 Vite、Vue 等依赖到 node_modules
npm install

# 3. 安装通信库和图表库
# axios:前端发送 HTTP 请求
# echarts:绘制柱状图、折线图、饼图
npm i axios echarts

# 4. 启动前端项目
npm run dev

步骤5:验证前端是否运行成功

浏览器访问 http://localhost:5173,看到 Vue 页面 = 前端创建成功。

停止项目:CMD 中按 Ctrl + C 输入 y 回车关闭服务。


任务2:编写 Vue 项目,调用接口并画图

目标: 把接口数据变成图表。

大屏布局(左右三栏)


左栏(Left)中栏(Center)右栏(Right)
① KPI 关键指标(4 项)③ 实时温度趋势(双折线)⑤ 在线率仪表盘
② 风险等级占比(环形图)④ 历史 7 天温度(柱形图)⑥ Top 榜单(上下双表)



步骤1:在 IDEA 中打开前端项目

  1. 打开 IDEA → FileOpen

  2. 选中 D:/bigdata1/code/smart-screen-frontend 文件夹

  3. 选择 New Window 打开项目(避免和后端项目冲突)


步骤2:创建/覆盖核心文件

【网页入口】index.html

📁 /index.html

<!doctype html>
<html lang="en">
   <head>
       <meta charset="UTF-8" />
       <link rel="icon" type="image/svg+xml" href="/vite.svg" />
       <meta name="viewport" content="width=device-width, initial-scale=1.0" />
       <!-- 浏览器标签页标题 -->
       <title>智慧设备运行大屏</title>
   </head>
   <body>
       <!-- Vue 项目挂载点,所有页面会渲染到这个 div -->
       <div id="app"></div>
       <!-- 引入 Vue 入口文件 main.js -->
       <script type="module" src="/src/main.js"></script>
   </body>
</html>
【项目入口】main.js

📁 src/main.js

// 导入 Vue 创建应用方法
import { createApp } from "vue";
// 导入根组件
import App from "./App.vue";
// 导入全局大屏样式
import "./styles/screen.css";

// 创建 Vue 实例并挂载到 #app 节点
createApp(App).mount("#app");
【根组件】App.vue

📁 src/App.vue

<template>
 <!-- 渲染大屏核心组件 -->
 <Screen />
</template>

<script setup>
// 从 views 目录导入大屏页面组件
import Screen from "./views/screen.vue";
</script>
【全局样式】screen.css

📁 src/styles/screen.css(先创建 styles 文件夹)

作用: 大屏项目的全局样式,统一控制页面布局、颜色主题、组件样式。

/* =========================
   0) 全局基础设置
   (覆盖 Vite 脚手架自带的居中布局)
   ========================= */

/*
 * :root 等同于 <html> 元素,是整棵 DOM 树的根
 * 在这里定义字体和颜色,所有子元素都会自动继承,
 * 不需要在每个地方重复写
 */
:root{
    /* 优先用微软雅黑,找不到就依次试下一个,最后兜底用系统字体 */
    font-family: "Microsoft YaHei", system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
    line-height: 1.5;
    font-weight: 400;
    /* 近白色偏蓝,适合深色大屏 */
    color: rgba(232, 246, 255, 0.95);
    /* 深蓝近黑的背景底色 */
    background: #04101a;
    /* 以下三行:禁止浏览器自动合成字体粗细/斜体,改由系统渲染,文字更清晰锐利 */
    font-synthesis: none;
    text-rendering: optimizeLegibility;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

/*
 * 注意!Vite 新建项目时会自动给 body 加上 display:flex 并居中对齐
 * 大屏可视化需要铺满整个浏览器窗口,所以这三个元素都必须撑满高度
 * overflow:hidden:禁止出现页面级别的滚动条(各卡片内部可以有独立滚动)
 */
html, body, #app{
    height: 100%;
    margin: 0;
    padding: 0;
    overflow: hidden;
}

/*
 * 把 body 从 Vite 默认的 flex 居中改回普通块布局
 * min-width / min-height 设为 0 的原因:
 *   当元素是 flex/grid 的子项时,浏览器默认给它 min-height:auto,
 *   意思是"至少要有内容那么高",这会让它撑破父容器的高度
 *   强制设为 0 才能让父容器正常约束它的尺寸
 */
body{
    display: block;
    min-width: 0;
    min-height: 0;
}

/*
 * Vite 默认给 #app 加了 max-width:1280px 和 padding
 * 大屏分辨率通常是 1920×1080 甚至更高,
 * 如果不清掉 max-width,内容最多只能铺 1280px 宽,两侧会出现大片空白
 */
#app{
    width: 100%;
    height: 100%;
    max-width: none;
    padding: 0;
    margin: 0;
    text-align: left;
}

/* =========================
   1) 大屏背景 + 标题栏
   ========================= */

/*
 * 整个大屏页面的背景容器
 * 用三层渐变叠加营造科技感(CSS 中多个 background 从上到下叠加渲染):
 *   第一层:左上角一个圆形蓝色光晕(radial-gradient)
 *   第二层:右上角一个圆形青绿色光晕(radial-gradient)
 *   第三层:从深蓝到近黑的整体底色(linear-gradient)
 */
.screen-bg{
    height: 100%;
    background:
            radial-gradient(circle at 20% 20%, rgba(0, 180, 255, 0.25), transparent 40%),
            radial-gradient(circle at 80% 30%, rgba(0, 255, 180, 0.15), transparent 45%),
            linear-gradient(180deg, #061826 0%, #04101a 100%);
    color: rgba(232, 246, 255, 0.95);
}

/*
 * 顶部标题栏,固定 64px 高
 *
 * position:relative 是关键——
 *   左侧操作员姓名和右侧时钟都用了 position:absolute(绝对定位)
 *   绝对定位的元素会找"离自己最近的、有定位属性的祖先"作为坐标原点
 *   这里加了 relative,它们就会相对于标题栏来定位,
 *   而不是跑到整个页面的角落
 */
.screen-header{
    height: 64px;
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
    user-select: none;
    /* 底部细线,颜色极淡,只是做视觉分隔用 */
    border-bottom: 1px solid rgba(0, 200, 255, 0.12);
}

/*
 * 主标题的渐变发光文字效果,分两步实现:
 *
 * 第一步:把文字本身变透明,显示出下方的渐变背景色
 *   background:设置一个左右两端青色、中间近白的水平渐变
 *   -webkit-text-fill-color: transparent → 把文字填充色变透明
 *   background-clip: text → 把背景图像裁切成文字的形状
 *   这样文字就"显现"出了渐变色
 *
 * 第二步:加发光效果
 *   这里不能用 text-shadow,因为 text-shadow 只作用于"有颜色的文字"
 *   文字变透明后 text-shadow 就失效了
 *   改用 filter:drop-shadow,它会作用于所有"有像素"的区域,
 *   即使文字是透明的,也能正常发光
 */
.screen-title{
    font-size: 30px;
    font-weight: 800;
    letter-spacing: 8px;
    background: linear-gradient(
        90deg,
        rgba(0, 200, 255, 0.75)   0%,
        rgba(230, 250, 255, 1.00) 28%,
        rgba(230, 250, 255, 1.00) 72%,
        rgba(0, 200, 255, 0.75)  100%
    );
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-clip: text;
    filter:
        drop-shadow(0 0 10px rgba(0, 200, 255, 0.55))
        drop-shadow(0 0 26px rgba(0, 200, 255, 0.25));
}

/*
 * 左侧操作员信息块(绝对定位)
 * bottom:0 让它贴着标题栏的底边,
 * 和右侧时钟共享同一条视觉基线,看起来整齐对齐
 */
.screen-student{
    position: absolute;
    left: 14px;
    bottom: 0;
    user-select: none;
}

/* 操作员姓名/工号的文字样式:加大字距让字间隔更宽,有科技感 */
.screen-student-label{
    font-size: 13px;
    letter-spacing: 3px;
    color: rgba(0, 200, 255, 0.7);
    font-weight: 400;
    line-height: 1.3;
}

/*
 * 右侧实时时钟(绝对定位)
 * right:14px 与内容区的右侧内边距 14px 对齐,
 * 视觉上时钟正好悬在右列卡片的正上方
 * bottom:0 与左侧操作员信息共享同一基线
 */
.screen-clock{
    position: absolute;
    right: 14px;
    bottom: 0;
    text-align: right;
    line-height: 1.3;
    user-select: none;
}

/* 日期行:字号小、颜色淡,视觉权重低于下方的时间行,起辅助说明作用 */
.screen-clock-date{
    font-size: 11px;
    letter-spacing: 2px;
    color: rgba(0, 200, 255, 0.6);
    font-weight: 400;
}

/*
 * 时间行:大字加发光效果
 *
 * font-variant-numeric: tabular-nums 是防抖动的关键:
 *   默认情况下数字 0~9 的字符宽度不同(比如 "1" 比 "8" 窄)
 *   时间每秒更新一次,数字宽度变化会导致整行文字左右跳动
 *   tabular-nums 强制所有数字使用相同宽度(等宽字形),彻底消除抖动
 */
.screen-clock-time{
    font-size: 22px;
    font-weight: 700;
    letter-spacing: 4px;
    color: rgba(0, 200, 255, 0.92);
    text-shadow: 0 0 10px rgba(0, 200, 255, 0.6), 0 0 24px rgba(0, 200, 255, 0.25);
    font-variant-numeric: tabular-nums;
}

/*
 * 标题栏以下的内容区域
 *
 * height: calc(100% - 64px):
 *   用视口总高度减去标题栏高度 64px,让内容区恰好填满剩余空间
 *
 * min-height:0 是一个常见陷阱,必须加:
 *   .screen-wrap 是 flex 容器的子项
 *   flex 子项默认 min-height:auto(即"至少有内容那么高")
 *   这会导致内部的 grid 高度算不准,卡片可能被撑出屏幕之外
 *   设为 0 才能让高度被父容器正确限制住
 */
.screen-wrap{
    height: calc(100% - 64px);
    padding: 12px 14px 14px;
    box-sizing: border-box;
    min-height: 0;
}

/* =========================
   2) 三栏布局(左 / 中 / 右)
   ========================= */

/*
 * 整个内容区的三列网格容器
 *
 * 为什么用 fr 而不用百分比(如 26% 48% 26%)?
 *   百分比是基于容器总宽度计算的,不会自动扣除列间距(gap)
 *   三列 26%+48%+26% = 100%,再加上两个 12px 的 gap,总宽就超出了容器
 *   右侧列会被挤出去,右边框只露出 1px 甚至完全消失
 *   fr 单位会先扣掉所有 gap,再按比例分配剩余空间,所以不会超出
 *
 * minmax(0, 26fr) 的作用:
 *   最小宽度设为 0,防止列内某个子元素内容过宽时把整列撑大
 *
 * padding-right:1px 兜底:
 *   极少数分辨率下浏览器亚像素计算有误差,右边线仍可能差 1px,
 *   加这个边距彻底保险
 */
.grid{
    height: 100%;
    width: 100%;
    display: grid;
    grid-template-columns: minmax(0, 26fr) minmax(0, 48fr) minmax(0, 26fr);
    gap: 12px;
    min-height: 0;
    padding-right: 1px;
    box-sizing: border-box;
}

/*
 * 中间列内部的子网格:把空间分成上下两半(1fr 1fr = 各占 50%)
 * 这样中间列可以放两个等高的卡片,上下各占一半
 * min-height:0 原因同上,防止子卡片把列高度撑破
 */
.col-2{
    height: 100%;
    display: grid;
    grid-template-rows: 1fr 1fr;
    gap: 12px;
    min-height: 0;
}

/* =========================
   3) 卡片
   ========================= */

/*
 * 每个数据卡片的容器
 *
 * display:flex + flex-direction:column 是让图表/表格填满卡片的核心方案:
 *   纵向 flex 布局中,子元素可以通过 flex:1 抢占剩余高度
 *   标题行用 flex:0 固定高度,内容区用 flex:1 填满剩余空间
 *   如果不用 flex,内容区只能写死高度或用 height:100%,
 *   而嵌套布局中 height:100% 经常算不准
 *
 * min-height:0:卡片本身也是 grid 的子项,同样需要清掉默认限制
 */
.card{
    border: 1px solid rgba(0, 200, 255, 0.25);
    background: rgba(2, 18, 28, 0.55);
    /* inset 关键字让阴影向内扩散,产生微弱的内发光效果 */
    box-shadow: 0 0 18px rgba(0, 180, 255, 0.08) inset;
    border-radius: 10px;
    padding: 10px 10px 12px;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    min-height: 0;
}

/*
 * 卡片标题行
 * flex: 0 0 auto 的含义:
 *   第一个 0 = 不放大(不占用多余空间)
 *   第二个 0 = 不缩小(不被压缩)
 *   auto = 保持自身内容的自然高度
 * 这样标题永远只占它自己需要的高度,把剩余空间全部留给图表
 */
.card-title{
    display: flex;
    align-items: center;
    gap: 8px;
    font-weight: 800;
    color: rgba(180, 240, 255, 0.95);
    margin: 0 0 8px;
    font-size: 14px;
    flex: 0 0 auto;
}

/*
 * 标题左侧的装饰圆点
 * box-shadow 不写 inset,默认是向外扩散,产生发光效果
 */
.card-title .dot{
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: rgba(0, 200, 255, 0.9);
    box-shadow: 0 0 10px rgba(0, 200, 255, 0.9);
}

/*
 * 卡片内除标题以外的所有直接子元素(图表容器、表格容器、KPI 块等)
 * 用 :not(.card-title) 选中它们,统一赋予 flex:1 + min-height:0
 * 这样不管卡片里放什么内容,都能自动撑满标题行以下的剩余空间
 * 好处:不需要给每种内容分别写一遍相同的样式
 */
.card > :not(.card-title){
    flex: 1 1 auto;
    min-height: 0;
}

/*
 * ECharts 图表的容器元素
 *
 * height:100% !important 为什么要加 !important?
 *   ECharts 初始化时会在元素上写入内联样式(style="height:xxx px"),
 *   内联样式的优先级高于任何 class 样式,会把我们的 height:100% 覆盖掉
 *   加 !important 才能把优先级压回来,让图表跟随卡片高度自适应
 *
 * min-height:0 / min-width:0 防止图表内容把容器撑大
 */
.echart{
    flex: 1 1 auto;
    width: 100%;
    height: 100% !important;
    min-height: 0;
    min-width: 0;
}

/*
 * 卡片底部的小字说明文本
 * flex:0 0 auto 保持固定高度,不参与拉伸,不会挤压上方图表的空间
 */
.small{
    font-size: 12px;
    opacity: 0.9;
    margin-top: 6px;
    flex: 0 0 auto;
}

/* =========================
   4) KPI 指标块
   ========================= */

/*
 * KPI 区域:2 列等宽网格,用于放多个指标格
 * flex:1 1 auto 使整块 KPI 区域填满卡片标题以下的空间
 */
.kpi{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    flex: 1 1 auto;
    min-height: 0;
}

/*
 * 单个指标格
 * 用虚线(dashed)边框而非实线,视觉上更轻盈,
 * 不会和卡片外边框抢眼
 */
.kpi-item{
    padding: 10px;
    border: 1px dashed rgba(0, 200, 255, 0.25);
    border-radius: 10px;
    min-height: 0;
}

/* 指标标签(如"在线设备"):字号小、透明度低,视觉权重小,起说明作用 */
.kpi-label{ font-size: 12px; opacity: 0.85; }
/* 指标数值(如"128"):字号大、加粗,与标签形成明显的大小层级对比 */
.kpi-value{ font-size: 20px; font-weight: 900; margin-top: 6px; }

/* =========================
   5) 表格 + 滚动条
   ========================= */

/*
 * 表格的滚动包裹层
 * overflow:auto:内容超出时自动出现滚动条,不超出时不显示
 * 如果没有这一层,表格行数一多就会把整张卡片从下面撑高,
 * 破坏全局的固定高度布局
 */
.table-wrap{
    flex: 1 1 auto;
    min-height: 0;
    overflow: auto;
}

/*
 * 以下三条是自定义滚动条外观,只在 Chrome / Edge 生效
 * Firefox 不支持 ::-webkit-scrollbar,会用系统默认样式
 * (Firefox 可用标准属性 scrollbar-width 和 scrollbar-color 控制)
 */
/* 滚动条整体的宽度 */
.table-wrap::-webkit-scrollbar{
    width: 8px;
}
/* 滚动条的滑块(可拖动那块):半透明青色 */
.table-wrap::-webkit-scrollbar-thumb{
    background: rgba(0, 200, 255, 0.25);
    border-radius: 8px;
}
/* 滚动条的背景轨道:极淡白色,几乎不可见 */
.table-wrap::-webkit-scrollbar-track{
    background: rgba(255, 255, 255, 0.04);
}

/*
 * 基础表格
 * border-collapse:collapse 消除单元格之间的默认双边框间隙,
 * 让相邻单元格共用同一条边线,表格看起来更整洁
 */
.table{
    width: 100%;
    border-collapse: collapse;
    font-size: 12px;
}
/*
 * 每个单元格只加底部边线,不加上下左右四条线
 * 这样视觉上更简洁,减少视觉噪音
 */
.table th, .table td{
    border-bottom: 1px solid rgba(0, 200, 255, 0.15);
    padding: 8px 6px;
    text-align: left;
}
/* 表头透明度略低,与数据行形成轻微对比,但不需要加粗 */
.table th{ opacity: 0.9; }

/*
 * 胶囊状标签(用于显示状态等)
 * border-radius:999px 是常见技巧:
 *   设一个远大于元素高度的圆角值,不管内容多宽多窄都能保持两端半圆
 *   注意不能用 50%,50% 对矩形会变成椭圆,不是胶囊形
 */
.badge{
    display: inline-block;
    padding: 2px 8px;
    border-radius: 999px;
    font-size: 12px;
    border: 1px solid rgba(0, 200, 255, 0.25);
}

/*
 * 一张卡片里同时放两张表时的外层容器
 * flex-direction:column 让两张表上下排列
 * 两张表各自通过 flex:1 平分高度,并且各自独立滚动
 */
.two-table{
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
    gap: 2px;
}

/*
 * 每张表自己的滚动容器
 * flex:1 占据一半高度,overflow:auto 让它独立滚动,互不影响
 */
.table-box{
    flex: 1;
    min-height: 0;
    overflow: auto;
}

/*
 * 双表模式下必须压缩行高
 * 如果用正常行距,一张表的内容就能撑满整张卡片,第二张表就没有位置了
 */
.table-tight{
    font-size: 12px;
}

/* 缩小单元格内边距和行高,让每行占用更少的垂直空间 */
.table-tight th,
.table-tight td{
    padding: 2px 4px;
    line-height: 1.1;
}

/* =========================
   6) 风险环形图:旋转扫描光弧 + 中心数值
   ========================= */

/*
 * 圆环图的外层容器
 * 必须加 position:relative,
 * 因为内部的光弧(.pie-scan-ring)和中心数值(.pie-center)
 * 都用了 position:absolute,绝对定位元素的坐标原点就是这个容器
 */
.pie-wrap{
    position: relative;
}

/*
 * 旋转扫描光弧:纯 CSS 实现,叠在 ECharts 圆环正上方
 *
 * 尺寸和位置为什么是这些数值?
 *   ECharts 配置:radius:['60%','68%'],center:['50%','46%']
 *   → 光弧宽度设 74%(比外环直径 68% 略大,从视觉上包裹住环体)
 *   → top 设 46% 与 ECharts 圆心位置完全对齐,否则光弧会上下偏移
 *
 * 光弧怎么画的?用 conic-gradient(锥形渐变):
 *   从 0° 出发顺时针:
 *     0~18°:最亮(扫描光的"前锋")
 *     18~45°:渐暗(扫描尾迹)
 *     45~80°:继续淡出
 *     80~360°:完全透明(空白区域)
 *   整个元素旋转起来就像雷达扫描光
 *
 * 怎么只显示圆环那一圈?用 mask(遮罩)裁掉多余部分:
 *   中心 59% 以内 → 透明(对应 ECharts 圆环内圆内侧)
 *   61%~92%      → 不透明(对应 ECharts 圆环的环体部分)
 *   94% 以外     → 透明(对应 ECharts 圆环外圆外侧)
 *   -webkit-mask 和 mask 写两遍是为了兼容旧版 Chrome
 *
 * pointer-events:none:让鼠标事件穿透这个元素,不影响图表的 hover/click
 */
.pie-scan-ring{
    position: absolute;
    left: 50%;
    top: 46%;
    width: 74%;
    aspect-ratio: 1;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    pointer-events: none;
    z-index: 1;

    background: conic-gradient(
        rgba(0, 200, 255, 0.95)  0deg,
        rgba(0, 200, 255, 0.55) 18deg,
        rgba(0, 200, 255, 0.15) 45deg,
        transparent             80deg,
        transparent            360deg
    );

    -webkit-mask: radial-gradient(
        circle closest-side at center,
        transparent  59%,
        black        61%,
        black        92%,
        transparent  94%
    );
    mask: radial-gradient(
        circle closest-side at center,
        transparent  59%,
        black        61%,
        black        92%,
        transparent  94%
    );

    animation: pie-scan-spin 8s linear infinite;
}

/*
 * 光弧旋转动画
 * 注意:transform 里必须同时保留 translate 和 rotate
 *   如果只写 to { transform: rotate(360deg) },
 *   之前的 translate(-50%, -50%) 会被覆盖,元素会跑回左上角绕圈,
 *   而不是以圆心为轴原地旋转
 *   所以每一帧都要把 translate 带上
 */
@keyframes pie-scan-spin{
    to{ transform: translate(-50%, -50%) rotate(360deg); }
}

/*
 * 圆环中心的数值覆盖层(绝对定位,叠在图表正上方)
 * z-index:2 比光弧(z-index:1)高一层,确保数字始终显示在光弧上面
 * pointer-events:none 同光弧,不拦截鼠标事件
 */
.pie-center{
    position: absolute;
    left: 50%;
    top: 46%;
    transform: translate(-50%, -50%);
    text-align: center;
    pointer-events: none;
    z-index: 2;
    display: flex;
    flex-direction: column;
    align-items: center;
    line-height: 1.2;
    gap: 1px;
}

/*
 * 圆环中心的"预警次数"标签和"次"单位
 * 字号小、颜色淡,作为辅助说明,视觉权重低于中间的大数字
 */
.pie-center-label,
.pie-center-unit{
    font-size: 10px;
    letter-spacing: 1.5px;
    color: rgba(250, 173, 20, 0.65);
}

/*
 * 预警次数的主数值
 * 用橙黄色(#faad14)而非圆环的青色,形成颜色对比,视觉上一眼抓住
 * tabular-nums 同时钟,防止数字更新时字符宽度变化导致文字跳动
 */
.pie-center-value{
    font-size: 26px;
    font-weight: 800;
    letter-spacing: 1px;
    color: #faad14;
    text-shadow: 0 0 10px rgba(250, 173, 20, 0.65);
    font-variant-numeric: tabular-nums;
    line-height: 1;
}

/*
 * 有预警时,JS 会给 .pie-center 动态添加 .pie-center--alarm 类
 * 触发数值的脉冲发光动画,持续提醒操作员注意
 */
.pie-center--alarm .pie-center-value{
    animation: pie-alarm-pulse 2s ease-in-out infinite;
}

/*
 * 脉冲发光动画:在"微弱光晕"和"强烈光晕"之间来回切换
 * opacity 同步由 1 变到 0.75,配合光晕变化产生"告警灯闪烁"的视觉效果
 * ease-in-out 让动画两端慢、中间快,比匀速更自然
 */
@keyframes pie-alarm-pulse{
    0%, 100%{
        text-shadow: 0 0 10px rgba(250, 173, 20, 0.65);
        opacity: 1;
    }
    50%{
        text-shadow: 0 0 18px rgba(250, 173, 20, 1), 0 0 36px rgba(250, 173, 20, 0.4);
        opacity: 0.75;
    }
}

步骤3:配置开发代理(解决跨域)

问题: 前端运行在 5173,后端运行在 8080,端口不同会产生跨域。

方案: 配置 Vite 代理,让前端请求先发给 Vite,再由 Vite 转发到后端。

前端请求 /api/xxx
  ↓
Vite 代理接收
  ↓
转发到 http://localhost:8080/api/xxx
  ↓
后端返回数据 → Vite → 前端
【代理配置】vite.config.js

📁 vite.config.js(项目根目录)

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
 plugins: [vue()],
 server: {
   proxy: {
     // 所有以 /api 开头的请求转发到后端
     "/api": {
       // 后端服务地址(部署在 master 改为 http://master:8080)
       target: "http://localhost:8080",
       // 开启跨域处理
       changeOrigin: true,
     },
   },
 },
});

步骤4:创建图表共享颜色配置

【图表配色】chartColors.js

📁 src/config/chartColors.js

/**
 * 大屏图表统一颜色配置
 * 集中管理:改一个地方,全图表生效
 */

// 风险等级 → 主色(饼图扇区)
export const riskColorMap = {
  '正常': '#34d399',  // 薄荷绿
  '预警': '#f87171',  // 珊瑚红
}

// 风险等级 → 发光阴影色
export const riskShadowMap = {
  '正常': 'rgba(52, 211, 153, 0.45)',
  '预警': 'rgba(248, 113, 113, 0.45)',
}

/**
 * 根据在线率返回颜色(仪表盘用)
 * @param rate 在线率百分比(0~100)
 * @return { color: 主色, shadow: 发光色 }
 */
export function getStatusColor(rate) {
  // ≥80%:绿色(健康)
  if (rate >= 80) {
    return {
      color: '#34d399',
      shadow: 'rgba(52, 211, 153, 0.65)',
    }
  }
  // ≥50%:琥珀黄(警告)
  if (rate >= 50) {
    return {
      color: '#fbbf24',
      shadow: 'rgba(251, 191, 36, 0.65)',
    }
  }
  // <50%:珊瑚红(危险)
  return {
    color: '#f87171',
    shadow: 'rgba(248, 113, 113, 0.65)',
  }
}

步骤5:创建 6 个面板组件 + Screen 主页面

目录结构:

src/
 ├─ styles/
 │   └─ screen.css
 ├─ config/
 │   └─ chartColors.js
 └─ views/
     ├─ screen.vue
     └─ parts/
         ├─ LeftTopKpi.vue
         ├─ LeftBottomRiskPie.vue
         ├─ CenterTopTrend.vue
         ├─ CenterBottomHistory7d.vue
         ├─ RightTopGauge.vue
         └─ RightBottomTopTwoTables.vue
【大屏主页面】screen.vue

📁 src/views/screen.vue

作用: 大屏总控制台,负责请求后端数据、定时刷新、把数据分发给 6 个子组件。

<template>
 <div class="screen-bg">

   <!-- 顶部标题栏 -->
   <div class="screen-header">
     <!-- 操作员信息(左下) -->
     <div class="screen-student">
       <span class="screen-student-label">操作员:{{ studentName }}</span>
     </div>
     <span class="screen-title">智慧设备运行预测大屏</span>
     <!-- 右上时钟:日期 + 时间 -->
     <div class="screen-clock">
       <div class="screen-clock-date">{{ clockDate }}</div>
       <div class="screen-clock-time">{{ clockTime }}</div>
     </div>
   </div>

   <!-- 主内容区:三栏布局 -->
   <div class="screen-wrap">
     <div class="grid">
       <!-- 左栏:KPI + 风险饼图 -->
       <div class="col-2">
         <LeftTopKpi              :data="data" />
         <LeftBottomRiskPie       :data="data" />
       </div>
       <!-- 中栏:折线图 + 柱状图 -->
       <div class="col-2">
         <CenterTopTrend          :data="data" />
         <CenterBottomHistory7d   :data="data" />
       </div>
       <!-- 右栏:仪表盘 + Top 榜单 -->
       <div class="col-2">
         <RightTopGauge           :data="data" />
         <RightBottomTopTwoTables :data="data" />
       </div>
     </div>
   </div>
 </div>
</template>

<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import axios from 'axios'

// 引入 6 个子组件
import LeftTopKpi              from './parts/LeftTopKpi.vue'
import LeftBottomRiskPie       from './parts/LeftBottomRiskPie.vue'
import CenterTopTrend          from './parts/CenterTopTrend.vue'
import CenterBottomHistory7d   from './parts/CenterBottomHistory7d.vue'
import RightTopGauge           from './parts/RightTopGauge.vue'
import RightBottomTopTwoTables from './parts/RightBottomTopTwoTables.vue'

// 响应式变量
const data        = ref(null)   // 大屏核心数据
const clockDate   = ref('')     // 日期
const clockTime   = ref('')     // 时间
const studentName = ref('李翔') // 操作员姓名(教学用)

// 定时器
let dataTimer  = null  // 5 秒刷新数据
let clockTimer = null  // 1 秒更新时钟

// axios 实例(vite 代理已配置,baseURL 留空)
const http = axios.create({ baseURL: '', timeout: 10000 })

// 时钟函数
function updateClock() {
 const now  = new Date()
 const days = ['日', '一', '二', '三', '四', '五', '六']
 clockDate.value = now.toLocaleDateString('zh-CN', {
   year: 'numeric', month: '2-digit', day: '2-digit',
 }) + '  周' + days[now.getDay()]
 clockTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
}

/**
* 核心:并发请求 6 个接口,整理数据
* axios 响应:resp.data = 整个响应体({code, msg, data})
* 取业务数据:resp.data.data(两个 .data)
*/
async function fetchAllData() {
 // Promise.all:6 个请求并发,全部完成后才继续
 const [kpiResp, trendResp, alarmTopResp, history7dResp, riskPieResp, topRiskResp] =
   await Promise.all([
     http.get('/api/kpi/summary'),
     http.get('/api/kpi/tempTrend',   { params: { limit: 60 } }),
     http.get('/api/kpi/alarmTop',    { params: { limit: 3  } }),
     http.get('/api/history/temp7d'),
     http.get('/api/predict/riskPie'),
     http.get('/api/predict/topRisk', { params: { limit: 3  } }),
   ])

 // 解包业务数据,|| {} 兜底防止 null
 const kpi       = kpiResp.data.data        || {}
 const tempTrend = trendResp.data.data      || []
 const alarmTop  = alarmTopResp.data.data   || []
 const history7d = history7dResp.data.data  || []
 const riskPie   = riskPieResp.data.data    || []
 const topRisk   = topRiskResp.data.data    || []

 // 整理后存入 data,子组件自动重新渲染
 data.value = {
   // ① KPI(4 个数字卡片)
   kpi: {
     onlineCount:   kpi.onlineCount   ?? 0,
     alarmCount:    kpi.alarmCount    ?? 0,
     avgTemp:       Number((kpi.avgTemp ?? 0).toFixed(2)),
     highRiskCount: kpi.highRiskCount ?? 0,
     totalDevice:   kpi.totalDevice   ?? 50,
   },
   // ② 实时温度趋势
   tempTrend10s: tempTrend.map(item => ({
     ts:      item.ts,
     avgTemp: item.avgTemp,
     maxTemp: item.maxTemp,
   })),
   // ③ 风险分布
   riskPie: riskPie.map(item => ({
     name:  item.name,
     value: item.value,
   })),
   // ④ 近 7 天温度(去重 + 截短日期)
   history7d: deduplicateByDate(history7d)
     .map(item => ({
       dt:      (item.dt || '').slice(5),  // "2026-02-14" → "02-14"
       avgTemp: Number((item.avgTemp ?? 0).toFixed(2)),
     }))
     .slice(0, 7),
   // ⑤ 告警 Top3
   alarmTop: alarmTop.map(item => ({
     deviceId: item.deviceId,
     cnt:      item.cnt,
   })),
   // ⑥ 高风险 Top3
   topRisk: topRisk.map(item => ({
     deviceId:  item.deviceId,
     prob1:     item.prob1,
     eventTime: item.eventTime,
   })),
 }
}

// 同一天只保留第一条
function deduplicateByDate(arr) {
 const map = new Map()
 arr.forEach(item => {
   if (item.dt && !map.has(item.dt)) map.set(item.dt, item)
 })
 return Array.from(map.values())
}

// 加载数据(失败时保留上次数据,避免页面闪烁)
async function loadData() {
 try {
   await fetchAllData()
 } catch (err) {
   console.error('[大屏] 请求失败:', err.message)
 }
}

// 生命周期
onMounted(async () => {
 updateClock()                                // 立即显示时间
 clockTimer = setInterval(updateClock, 1000)  // 每秒更新
 await loadData()                             // 立即拉数据
 dataTimer  = setInterval(loadData, 5000)     // 每 5 秒刷新
})

onUnmounted(() => {
 // 必须清除定时器,防止内存泄漏
 clearInterval(clockTimer)
 clearInterval(dataTimer)
})
</script>

【左上-KPI 指标】LeftTopKpi.vue

📁 src/views/parts/LeftTopKpi.vue

作用: 显示 4 个 KPI 数字卡片(在线设备数、告警数量、平均温度、预测高危设备数)。

<template>
 <div class="card">
   <h3 class="card-title"><span class="dot"></span>① KPI 关键指标</h3>

   <div class="kpi">
     <!-- 在线设备数 -->
     <div class="kpi-item kpi-blue">
       <div class="kpi-label">在线设备数</div>
       <div class="kpi-value">{{ kpi.onlineCount }}</div>
       <div class="small">总设备:{{ kpi.totalDevice }}</div>
     </div>

     <!-- 告警数量 -->
     <div class="kpi-item kpi-orange">
       <div class="kpi-label">告警数量</div>
       <div class="kpi-value">{{ kpi.alarmCount }}</div>
       <div class="small">近10秒统计</div>
     </div>

     <!-- 平均温度 -->
     <div class="kpi-item kpi-yellow">
       <div class="kpi-label">平均温度(℃)</div>
       <div class="kpi-value">{{ kpi.avgTemp }}</div>
       <div class="small">实时窗口</div>
     </div>

     <!-- 预测高危设备数 -->
     <div class="kpi-item kpi-red">
       <div class="kpi-label">预测高危设备数</div>
       <div class="kpi-value">{{ kpi.highRiskCount }}</div>
       <div class="small">预测模块</div>
     </div>
   </div>
 </div>
</template>

<script setup>
import { computed } from 'vue'

// 接收父组件传来的数据
const props = defineProps({ data: Object })

/**
* 安全取出 kpi 对象,防止 props.data 为 null 时报错
* 每个字段都有默认值 0
*/
const kpi = computed(function () {
 let kpiObj = {}
 if (props.data && props.data.kpi) {
   kpiObj = props.data.kpi
 }
 return {
   onlineCount:   kpiObj.onlineCount   !== undefined ? kpiObj.onlineCount   : 0,
   alarmCount:    kpiObj.alarmCount    !== undefined ? kpiObj.alarmCount    : 0,
   avgTemp:       kpiObj.avgTemp       !== undefined ? kpiObj.avgTemp       : 0,
   highRiskCount: kpiObj.highRiskCount !== undefined ? kpiObj.highRiskCount : 0,
   totalDevice:   kpiObj.totalDevice   !== undefined ? kpiObj.totalDevice   : 0,
 }
})
</script>

<style scoped>
/* 每个 KPI 格的主题色 */
.kpi-blue   { border-color: rgba(56, 189, 248, 0.55); }
.kpi-blue   .kpi-value { color: #38bdf8; }

.kpi-orange { border-color: rgba(251, 146, 60, 0.55); }
.kpi-orange .kpi-value { color: #fb923c; }

.kpi-yellow { border-color: rgba(251, 191, 36, 0.55); }
.kpi-yellow .kpi-value { color: #fbbf24; }

.kpi-red    { border-color: rgba(248, 113, 113, 0.55); }
.kpi-red    .kpi-value { color: #f87171; }
</style>

【左下-风险饼图】LeftBottomRiskPie.vue

📁 src/views/parts/LeftBottomRiskPie.vue

作用: 风险等级环形图,中心显示预警次数,带旋转光弧和数字闪烁动画。

<template>
  <div>
    <h3>
      <span></span>
      ② 风险等级占比(实时·近10分钟)
    </h3>

    <!-- pie-wrap: 让图表、光弧、中心数字叠加显示 -->
    <div>
      <!-- ECharts 图表容器 -->
      <div ref="pieEl"></div>
      <!-- CSS 旋转扫描光弧(装饰效果) -->
      <div aria-hidden="true"></div>
      <!-- 圆环中心:预警次数 -->
      <div :class="{ 'pie-center--alarm': warnCount > 0 }">
        <span>预警</span>
        <span>{{ warnCount }}</span>
        <span>次</span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { riskColorMap, riskShadowMap } from '../../config/chartColors.js'

const props = defineProps({ data: Object })

// 安全取出饼图数据
const riskPieData = computed(function () {
  if (props.data && props.data.riskPie) {
    return props.data.riskPie
  }
  return []
})

// 从数据中提取"预警"次数(中心数字)
const warnCount = computed(function () {
  const warnItem = riskPieData.value.find(function (item) {
    return item.name === '预警'
  })
  if (warnItem) return warnItem.value
  return 0
})

const pieEl = ref(null)
let chart = null

/**
 * 绘制饼图
 * 给每项数据加颜色 → 调用 setOption 画图
 */
function render() {
  if (!chart) return

  // 给数据加颜色和发光效果
  const coloredData = []
  for (let i = 0; i < riskPieData.value.length; i++) {
    const item = riskPieData.value[i]
    coloredData.push({
      name: item.name,
      value: item.value,
      itemStyle: {
        color: riskColorMap[item.name],
        shadowBlur: 8,
        shadowColor: riskShadowMap[item.name],
      },
    })
  }

  chart.setOption({
    backgroundColor: 'transparent',
    tooltip: {
      trigger: 'item',
      backgroundColor: 'rgba(2,18,28,0.88)',
      borderColor: 'rgba(0,200,255,0.35)',
      borderWidth: 1,
      textStyle: { color: 'rgba(230,250,255,0.9)' },
    },
    legend: {
      top: 'bottom',
      textStyle: { color: 'rgba(0,200,255,0.8)' },
      itemStyle: { borderWidth: 0 },
    },
    series: [{
      type: 'pie',
      radius: ['45%', '68%'],   // 内外半径,形成环形
      center: ['50%', '46%'],
      label: {
        color: 'rgba(230,250,255,0.9)',
        formatter: '{b}\n{d}%',  // {b}=名称 {d}=百分比
        fontSize: 11,
      },
      labelLine: { lineStyle: { color: 'rgba(0,200,255,0.4)' } },
      emphasis: {
        scale: true,
        scaleSize: 5,
        itemStyle: { shadowBlur: 20, shadowColor: 'rgba(0,200,255,0.45)' },
      },
      data: coloredData,
    }],
  })
}

function onResize() {
  if (chart) chart.resize()
}

onMounted(function () {
  chart = echarts.init(pieEl.value)
  render()
  window.addEventListener('resize', onResize)
})

onUnmounted(function () {
  window.removeEventListener('resize', onResize)
  if (chart) chart.dispose()
})

// 监听父组件数据变化,自动重绘
watch(
  function () { return props.data },
  render,
  { deep: true }
)
</script>

【中上-温度折线图】CenterTopTrend.vue

📁 src/views/parts/CenterTopTrend.vue

作用: 双折线图,展示近 10 分钟的平均温度和最高温度。

<template>
 <div class="card">
   <h3 class="card-title"><span class="dot"></span>③ 实时温度趋势(10秒·近10分钟·平均/最高)</h3>
   <div class="card-body">
     <div ref="lineEl" class="echart"></div>
   </div>
 </div>
</template>

<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import * as echarts from 'echarts'

const props = defineProps({ data: Object })

// 安全取出折线图数据
const trendData = computed(function () {
 if (props.data && props.data.tempTrend10s) {
   return props.data.tempTrend10s
 }
 return []
})

const lineEl = ref(null)
let chart = null

/**
* 自定义鼠标悬浮提示框
* 显示:时间 + 各折线的名称和值
*/
function formatTooltip(params) {
 if (!params || params.length === 0) return ''

 // 取时间(所有行时间一样,取第一条)
 const time = params[0].axisValue
 const lines = [time]

 // 拼接每条折线的数据
 for (let i = 0; i < params.length; i++) {
   const p = params[i]
   lines.push(p.marker + ' ' + p.seriesName + ': ' + p.value)
 }

 return lines.join('<br/>')
}

/**
* X 轴时间标签:只显示时分秒
* "2026-02-16 14:30:30" → "14:30:30"
*/
function formatXLabel(val) {
 const s = String(val || '')
 if (s.length >= 19) {
   return s.slice(11, 19)
 }
 return s
}

function render() {
 if (!chart) return

 // 分别提取 X 轴时间、平均温度、最高温度
 const timeList = []
 const avgTempList = []
 const maxTempList = []
 for (let i = 0; i < trendData.value.length; i++) {
   const item = trendData.value[i]
   timeList.push(item.ts)
   avgTempList.push(Number(item.avgTemp).toFixed(2))
   maxTempList.push(item.maxTemp)
 }

 chart.setOption({
   tooltip: {
     trigger: 'axis',           // 按 X 轴触发(适合折线图)
     formatter: formatTooltip,
   },
   legend: { top: 6, textStyle: { color: 'rgba(230,250,255,0.9)' } },
   grid: { left: 40, right: 16, top: 34, bottom: 28 },
   xAxis: {
     type: 'category',
     data: timeList,
     axisLabel: {
       color: 'rgba(230,250,255,0.85)',
       formatter: formatXLabel,
     },
     axisLine: { lineStyle: { color: 'rgba(0,200,255,0.35)' } },
   },
   yAxis: {
     type: 'value',
     axisLabel: { color: 'rgba(230,250,255,0.85)' },
     splitLine: { lineStyle: { color: 'rgba(0,200,255,0.12)' } },
   },
   color: ['#fbbf24', '#38bdf8'],
   series: [
     {
       name: '最高温度',
       type: 'line',
       data: maxTempList,
       smooth: true,           // 平滑曲线
       symbolSize: 6,
       areaStyle: { opacity: 0.15 },  // 折线下方填充
     },
     {
       name: '平均温度',
       type: 'line',
       data: avgTempList,
       smooth: true,
       symbolSize: 6,
       areaStyle: { opacity: 0.15 },
     },
   ],
 }, true)  // true = 完整替换配置
}

function onResize() {
 if (chart) chart.resize()
}

onMounted(function () {
 chart = echarts.init(lineEl.value)
 render()
 window.addEventListener('resize', onResize)
})

onUnmounted(function () {
 window.removeEventListener('resize', onResize)
 if (chart) chart.dispose()
})

watch(function () { return props.data }, render, { deep: true })
</script>

【中下-历史柱状图】CenterBottomHistory7d.vue

📁 src/views/parts/CenterBottomHistory7d.vue

作用: 柱状图展示近 7 天平均温度,柱子三段渐变。

<template>
 <div class="card">
   <h3 class="card-title"><span class="dot"></span>④ 历史温度趋势(近7天柱形图)</h3>
   <div class="card-body">
     <div ref="barEl" class="echart"></div>
   </div>
 </div>
</template>

<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import * as echarts from 'echarts'

const props = defineProps({ data: Object })

const historyData = computed(function () {
 if (props.data && props.data.history7d) {
   return props.data.history7d
 }
 return []
})

const barEl = ref(null)
let chart = null

function render() {
 if (!chart) return

 // 提取日期和温度数据
 const dateList = []
 const tempList = []
 for (let i = 0; i < historyData.value.length; i++) {
   dateList.push(historyData.value[i].dt)
   tempList.push(historyData.value[i].avgTemp)
 }

 /**
  * 柱子三段渐变色(顶 → 中 → 底)
  * LinearGradient(0, 0, 0, 1) 表示从上到下的垂直渐变
  */
 const barGradient = new echarts.graphic.LinearGradient(0, 0, 0, 1, [
   { offset: 0,   color: 'rgba(125, 211, 252, 1.00)' },  // 顶部:亮天蓝
   { offset: 0.6, color: 'rgba(56,  189, 248, 0.88)' },  // 中部:天蓝
   { offset: 1,   color: 'rgba(3,   105, 161, 0.65)' },  // 底部:深蓝
 ])

 chart.setOption({
   tooltip: { trigger: 'axis' },
   grid: { left: 44, right: 16, top: 20, bottom: 36 },
   xAxis: {
     type: 'category',
     data: dateList,
     axisLabel: { color: 'rgba(140, 200, 255, 0.8)', fontSize: 11 },
     axisLine:  { lineStyle: { color: 'rgba(100, 180, 255, 0.3)' } },
     axisTick:  { show: false },
   },
   yAxis: {
     type: 'value',
     axisLabel: {
       color: 'rgba(140, 200, 255, 0.8)',
       fontSize: 11,
       formatter: '{value}°C',  // 刻度加 °C 单位
     },
     axisLine: { show: false },
     axisTick: { show: false },
     splitLine: {
       lineStyle: {
         color: 'rgba(100, 160, 255, 0.15)',
         type: 'dashed',  // 虚线
       },
     },
   },
   series: [{
     name: '平均温度',
     type: 'bar',
     barWidth: '45%',
     data: tempList,
     label: {
       show: true,
       position: 'top',
       formatter: '{c}°C',  // {c} = 数值
       color: 'rgba(180, 230, 255, 0.85)',
       fontSize: 10,
     },
     itemStyle: {
       borderRadius: [4, 4, 0, 0],  // 上方圆角
       color: barGradient,
     },
   }],
 })
}

function onResize() {
 if (chart) chart.resize()
}

onMounted(function () {
 chart = echarts.init(barEl.value)
 render()
 window.addEventListener('resize', onResize)
})

onUnmounted(function () {
 window.removeEventListener('resize', onResize)
 if (chart) chart.dispose()
})

watch(function () { return props.data }, render, { deep: true })
</script>

【右上-在线率仪表盘】RightTopGauge.vue

📁 src/views/parts/RightTopGauge.vue

作用: 仪表盘展示设备在线率,颜色根据在线率自动切换(绿/橙/红)。

<template>
  <div>
    <h3><span></span>⑤ 在线率仪表盘</h3>
    <div>
      <div ref="gaugeEl"></div>
    </div>
  </div>
</template>

<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { getStatusColor } from '../../config/chartColors.js'

const props = defineProps({ data: Object })

const gaugeEl = ref(null)
let chart = null

function render() {
  if (!chart) return

  // 安全取出在线数和总设备数
  let onlineCount = 0
  let totalDevice = 0
  if (props.data && props.data.kpi) {
    onlineCount = props.data.kpi.onlineCount || 0
    totalDevice = props.data.kpi.totalDevice || 0
  }

  // 计算在线率(百分比整数)
  let rate = 0
  if (totalDevice > 0) {
    rate = Math.round((onlineCount / totalDevice) * 100)
  }

  // 根据在线率获取颜色
  const colors = getStatusColor(rate)
  const mainColor   = colors.color
  const shadowColor = colors.shadow

  // 进度弧渐变色
  const progressGradient = {
    type: 'linear', x: 0, y: 0, x2: 1, y2: 0,
    colorStops: [
      { offset: 0, color: 'rgba(0,180,255,0.85)' },
      { offset: 1, color: mainColor },
    ],
  }

  chart.setOption({
    backgroundColor: 'transparent',
    series: [{
      type: 'gauge',
      startAngle: 210,
      endAngle: -30,
      radius: '82%',
      center: ['50%', '52%'],
      // 进度弧(有颜色部分)
      progress: {
        show: true,
        width: 12,
        itemStyle: { color: progressGradient, shadowBlur: 14, shadowColor: shadowColor },
      },
      // 背景弧(灰色部分)
      axisLine: {
        lineStyle: { width: 12, color: [[1, 'rgba(0,180,255,0.1)']] },
      },
      splitLine: { distance: -16, length: 10, lineStyle: { color: 'rgba(0,200,255,0.4)', width: 1.5 } },
      axisTick:  { distance: -10, length: 6,  lineStyle: { color: 'rgba(0,200,255,0.2)', width: 1   } },
      axisLabel: { color: 'rgba(0,200,255,0.65)', distance: 24, fontSize: 11 },
      // 指针
      pointer: {
        width: 4,
        length: '65%',
        itemStyle: { color: 'rgba(0,200,255,0.9)', shadowBlur: 8, shadowColor: 'rgba(0,200,255,0.8)' },
      },
      // 指针圆心
      anchor: {
        show: true,
        showAbove: true,
        size: 12,
        itemStyle: { color: mainColor, shadowBlur: 15, shadowColor: shadowColor },
      },
      title: { color: 'rgba(0,200,255,0.7)', fontSize: 12, offsetCenter: [0, '75%'] },
      // 中间大数字
      detail: {
        valueAnimation: true,
        color: mainColor,
        fontSize: 28,
        fontWeight: 'bold',
        formatter: '{value}%',
        offsetCenter: [0, '35%'],
        textShadowBlur: 8,
        textShadowColor: shadowColor,
      },
      data: [{ value: rate, name: '在线率' }],
    }],
  })
}

function onResize() {
  if (chart) chart.resize()
}

onMounted(function () {
  chart = echarts.init(gaugeEl.value)
  render()
  window.addEventListener('resize', onResize)
})

onUnmounted(function () {
  window.removeEventListener('resize', onResize)
  if (chart) chart.dispose()
})

watch(function () { return props.data }, render)
</script>

【右下-Top 榜单】RightBottomTopTwoTables.vue

📁 src/views/parts/RightBottomTopTwoTables.vue

作用: 双表格组件,上表告警 Top3,下表高风险 Top3,带金银铜排名徽章。

<template>
 <div class="card">
   <h3 class="card-title"><span class="dot"></span>⑥ 告警 & 风险 Top3</h3>

   <div class="two-table">

     <!-- ===== 上表:告警 Top3 ===== -->
     <div class="table-box">
       <div class="sub-title">
         <span class="dot"></span>告警 Top3(实时)
       </div>
       <table class="table table-tight">
         <thead>
           <tr>
             <th style="width:36px;">#</th>
             <th>设备</th>
             <th style="width:72px;">告警次数</th>
           </tr>
         </thead>
         <tbody>
           <!-- v-for 循环渲染:row 是当前行,idx 是下标 -->
           <tr v-for="(row, idx) in alarmTop3" :key="row.deviceId + idx">
             <!-- 排名徽章:1=金 2=银 3=铜 -->
             <td><span :class="getRankClass(idx)">{{ idx + 1 }}</span></td>
             <td>{{ row.deviceId }}</td>
             <!-- 告警次数:根据数量染色 -->
             <td><span :class="getAlarmClass(row.cnt)">{{ row.cnt }}</span></td>
           </tr>
         </tbody>
       </table>
     </div>

     <!-- ===== 下表:高风险 Top3 ===== -->
     <div class="table-box">
       <div class="sub-title">
         <span class="dot"></span>高风险 Top3(实时)
       </div>
       <table class="table table-tight">
         <thead>
           <tr>
             <th style="width:36px;">#</th>
             <th>设备</th>
             <th style="width:56px;">风险值</th>
             <th style="width:88px;">时间</th>
           </tr>
         </thead>
         <tbody>
           <tr
             v-for="(row, idx) in topRisk3"
             :key="row.deviceId + idx"
             :class="getRiskRowClass(row.prob1)"
           >
             <td><span :class="getRankClass(idx)">{{ idx + 1 }}</span></td>
             <td>{{ row.deviceId }}</td>
             <td :class="getRiskTextClass(row.prob1)">{{ formatProb(row.prob1) }}</td>
             <td class="td-time">{{ formatTime(row.eventTime) }}</td>
           </tr>
         </tbody>
       </table>
     </div>

   </div>
 </div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({ data: Object })

// 安全取出表格数据,最多取 3 条
const alarmTop3 = computed(function () {
 if (props.data && props.data.alarmTop) {
   return props.data.alarmTop.slice(0, 3)
 }
 return []
})

const topRisk3 = computed(function () {
 if (props.data && props.data.topRisk) {
   return props.data.topRisk.slice(0, 3)
 }
 return []
})

// 排名徽章 class
function getRankClass(idx) {
 if (idx === 0) return 'rank-gold'
 if (idx === 1) return 'rank-silver'
 if (idx === 2) return 'rank-bronze'
 return ''
}

// 告警次数颜色
function getAlarmClass(cnt) {
 const n = Number(cnt)
 if (n >= 10) return 'cnt-high'
 if (n >= 5)  return 'cnt-mid'
 return 'cnt-low'
}

// 风险等级判断(核心函数,集中管理判断逻辑)
function getRiskLevel(prob) {
 const n = Number(prob)
 if (n >= 0.7) return 'high'
 if (n >= 0.4) return 'mid'
 return 'low'
}

// 风险值文字颜色
function getRiskTextClass(prob) {
 const level = getRiskLevel(prob)
 if (level === 'high') return 'prob-high'
 if (level === 'mid')  return 'prob-mid'
 return ''
}

// 行背景色
function getRiskRowClass(prob) {
 const level = getRiskLevel(prob)
 if (level === 'high') return 'row-danger'
 if (level === 'mid')  return 'row-warn'
 return ''
}

// 风险值保留 2 位小数
function formatProb(v) {
 if (v === null || v === undefined) return '-'
 const n = Number(v)
 if (isNaN(n)) return '-'
 return n.toFixed(2)
}

// 时间截短:"2026-02-16 14:30:30" → "02-16 14:30"
function formatTime(s) {
 if (!s) return '-'
 return String(s).slice(5, 16)
}
</script>

<style scoped>
.table-box + .table-box { padding-top: 10px; }

.sub-title {
 display: flex;
 align-items: center;
 gap: 6px;
 font-size: 13px;
 font-weight: 700;
 color: rgba(180, 240, 255, 0.85);
 margin-bottom: 0;
}

/* 排名徽章 */
.rank-gold, .rank-silver, .rank-bronze {
 display: inline-flex;
 align-items: center;
 justify-content: center;
 width: 20px;
 height: 20px;
 border-radius: 50%;
 font-size: 11px;
 font-weight: bold;
}
.rank-gold   { color: #ffd700; background: rgba(255, 215,   0, 0.18); }
.rank-silver { color: #c0c0c0; background: rgba(192, 192, 192, 0.15); }
.rank-bronze { color: #cd7f32; background: rgba(205, 127,  50, 0.15); }

/* 告警次数颜色 */
.cnt-high { color: #f87171; font-weight: bold; }
.cnt-mid  { color: #fb923c; }
.cnt-low  { color: #34d399; }

/* 风险值文字颜色 */
.prob-high { color: #f87171; font-weight: bold; text-shadow: 0 0 6px rgba(248, 113, 113, 0.55); }
.prob-mid  { color: #fb923c; }

/* 行斑马纹 + 悬停效果 */
tbody tr:nth-child(even) { background: rgba(0, 200, 255, 0.03); }
tbody tr { transition: background 0.2s; }
tbody tr:hover { background: rgba(0, 200, 255, 0.09) !important; }

/* 高危行:淡红底色 */
.row-danger { background: rgba(248, 113, 113, 0.06) !important; }
/* 预警行:淡橙底色 */
.row-warn { background: rgba(251, 146, 60, 0.05) !important; }

/* 时间列:等宽数字防抖动 */
.td-time {
 font-variant-numeric: tabular-nums;
 color: rgba(180, 220, 255, 0.65);
 font-size: 11px;
}
</style>

任务3:启动大数据分析模块和后端

按顺序启动:

  1. 启动 Hadoop 集群

  2. 启动 Hive Metastore 服务

  3. 启动 ZooKeeper 和 Kafka

  4. 启动 Kafka 实时流数据采集

  5. 启动 Flink 实时分析:RtMetricsSqlJob

  6. 启动 Spark ML:DevicePredictStreamJob

  7. 在 IDEA 中启动 Spring Boot


任务4:本地开发环境-启动 Vue 前端

# 1. 使用 IDEA 打开 smart-screen-frontend
# 2. 在项目根目录打开终端
# 3. 启动前端开发服务器(默认 5173 端口)
npm run dev

浏览器打开 http://localhost:5173,应能看到:

  • ✅ 左上 KPI 4 项

  • ✅ 左下 风险环形图

  • ✅ 中上 实时双折线(avg/max,60 条数据,每 5 秒刷新)

  • ✅ 中下 历史 7 天柱状图

  • ✅ 右上 在线率仪表盘

  • ✅ 右下 告警 Top3 + 高风险 Top3 双表


任务5:前端常用排查顺序

F12 打开浏览器开发者工具,按以下顺序排查:

1. Console 控制台
  有红色报错 → 优先解决前端代码或跨域问题

2. Network 网络请求
  接口状态码不是 200 → 检查接口地址、后端服务或 Nginx 代理

3. 点开具体请求查看 Response
  没有数据 → 检查后端接口返回

4. 对照前端代码检查取值字段
  Response 有数据但页面不显示 → 检查字段取值是否写对

任务6:Nginx 部署(企业上线方式)

Nginx 的两件事:

  1. 托管前端 dist 静态文件(让别人通过浏览器访问大屏)

  2. 反向代理后端接口 /api(前端请求自动转发到 8080)

部署后效果:

  • 浏览器只与 Nginx 通信,不再直接访问后端 8080

  • 前后端同源(同 IP、同端口)→ 不跨域


6.1 安装并配置 Nginx(master 节点)

前置:更换 yum 源(CentOS 7 官方源已停止维护)

# ① 备份原 yum 源
mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.bak

# ② 下载阿里云 yum 源
curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo

# ③ 清空旧缓存并重建
yum clean all
yum makecache

# ④ 安装扩展仓库 EPEL
yum install -y epel-release

# ⑤ 查看当前启用的仓库
yum repolist

1)安装 Nginx

yum install -y nginx
nginx -v

2)编辑配置文件

vi /etc/nginx/nginx.conf

http {} 内的 server {} 中配置:

server {
    listen 80;        # 监听 80 端口(HTTP 默认端口)
    server_name _;    # _ 匹配任意域名

    # ① 前端静态文件根目录
    root /opt/apps/nginx/smart-screen;

    # ② 前端页面路由(解决 Vue 刷新 404 问题)
    # try_files:先找文件,再找目录,都没有就返回 index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # ③ 后端接口反向代理
    # 浏览器请求 /api/xxx → Nginx 转发到 8080
    location /api/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

3)配置检查与重新加载

# 检查语法
nginx -t

# 重新加载配置
systemctl reload nginx

看到 syntax is ok / test is successful 才能继续。

4)禁用 SELinux

报错: Spring Boot 正常运行,但通过 Nginx 访问出现 502 Bad Gateway

查看 Nginx 错误日志:sudo tail -f /var/log/nginx/error.log

报错示例:connect() to 127.0.0.1:8080 failed (13: Permission denied)

原因: SELinux 阻止 Nginx 访问 8080

# 方法1:临时关闭(重启失效)
sudo setenforce 0

# 方法2:永久禁用
# 编辑 /etc/selinux/config,修改:
SELINUX=disabled

# 查看状态
sestatus

# 重启系统
reboot

6.2 启动 Nginx

# 启动服务
systemctl start nginx

# 设置开机自启
systemctl enable nginx

# 查看状态
systemctl status nginx

浏览器访问 http://192.168.36.100 看到 Nginx 默认欢迎页 = 成功。


任务7:前端打包部署

7.1 前端项目打包

# 进入项目目录
d:
cd D:\bigdata1\code\smart-screen-frontend

# 执行打包命令
npm run build

打包成功后,会自动生成 dist/ 目录:

smart-screen-frontend/
├── dist/
│   ├── index.html
│   ├── assets/
│   │   ├── xxx.js
│   │   └── xxx.css
│   └── ...

7.2 上传前端打包文件到服务器

1)创建 Nginx 站点目录

mkdir -p /opt/apps/nginx/smart-screen/

2)上传 dist 目录中的所有文件

⚠️ 上传的是 dist/ 中的所有文件,而不是 dist 文件夹本身

将 smart-screen-frontend/dist/ 中的所有文件上传到
/opt/apps/nginx/smart-screen/

3)确认目录结构

/opt/apps/nginx/smart-screen/
├─ index.html
├─ assets/
│   ├─ *.js
│   └─ *.css
└─ vite.svg

4)检查 SELinux 状态

sestatus
# 状态:SELinux status: disabled

任务8:服务器部署模式访问前端

启动顺序:

启动环境:

第1步 启动 Hadoop 集群

# 1)在 master 节点执行,启动 HDFS:
start-dfs.sh

# 2)在 slave1 节点执行,启动 YARN:
start-yarn.sh

第2步 启动 ZooKeeper 和 Kafka

# 1)在 master / slave1 / slave2 都执行:
zkServer.sh start

# 2)在 master / slave1 / slave2 都执行:检查状态
zkServer.sh status

# 3)在 master / slave1 / slave2 都执行
kafka-server-start.sh -daemon /opt/apps/kafka/config/server.properties

第3步 启动 Hive Metastore 服务

# 新建 master 节点窗口,执行下面命令
nohup hive --service metastore &

第4步 启动Flink集群

# 启动 Flink 集群【启动 JobManager(作业管理器)和 TaskManager(任务管理器)】
start-cluster.sh

第5步 启动Kafka实时流数据采集

# 1)在 master 节点【新开窗口】
cd /opt/datas

# 2)执行Python 程序,实时数据写入Kafka:
python 01_device_status_sim.py | \
kafka-console-producer.sh \
--broker-list master:9092 \
--topic device_status_topic

第6步 启动Flink实时分析: RtMetricsSqlJob

# 集群模式提交 Flink 作业
flink run -c com.demo.flink.RtMetricsSqlJob \
/opt/jars/flink-rt-metrics-1.0.0.jar \
--jobmanager.memory.process.size 512m \
--taskmanager.memory.process.size 512m \
--taskmanager.numberOfTaskSlots 1 \
--parallelism 1

第7步 启动Spark ML: DevicePredictStreamJob

# 本地模式启动(调试用,在本地模式运行 Spark,并使用 1 个线程)
spark-submit \
--class com.demo.spark.DevicePredictStreamJob \
--master local[1] \
/opt/jars/spark-job-1.0.0.jar

第8步 在IDEA中启动Spring boot

# 新建 master 窗口,启动后端
java -jar /opt/jars/smart-screen-backend-1.0.0-SNAPSHOT.jar

第9步 浏览器访问前端页面

# 前端打包文件上传服务器,并由 Nginx 托管后,可以在浏览器中直接访问
http://192.168.36.100/


访问流程:

浏览器 → http://192.168.36.100/ → Nginx
                                   ├─ 静态资源:返回 index.html、JS、CSS
                                   └─ /api/ 请求:反向代理到 8080 后端

Nginx 的两个作用:

  1. 托管前端静态资源

  2. 将前端 /api/ 请求转发到后端

应能看到完整大屏:

  • ✅ 左上 KPI 4 项

  • ✅ 左下 风险环形图

  • ✅ 中上 实时双折线

  • ✅ 中下 历史 7 天柱状图

  • ✅ 右上 在线率仪表盘

  • ✅ 右下 告警 + 高风险双表


九、验收标准(学生提交)

建议提交 6 张截图 + 2 个结果

  1. 后端启动成功(8080)

  2. Postman/curl 调通接口(temp7d、kpi、topRisk)

  3. 前端开发页面能显示图表(5173)

  4. dist 打包成功

  5. Nginx 部署后 80 端口访问成功

  6. 大屏图表数据正常刷新(至少显示近 7 日数据)


十、实验总结

本实验完成了从后端开发 → 前端开发 → 服务器部署的完整流程:

  • 后端:Spring Boot 三层架构(Controller / Service / DAO),连接 MySQL 与 ClickHouse,提供 6 个 REST 接口

  • 前端:Vue3 + Vite + ECharts,6 个图表组件分栏展示,每 5 秒自动刷新

  • 部署:Nginx 反向代理 + 前端 dist 静态托管,实现前后端同源访问


发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

Powered By Z-BlogPHP 1.7.3

版权:李翔
备案/许可证编号为:新ICP备2024006115号-1