任务1:创建前端项目(Vue3 + Vite)
目标: 成功创建并运行 Vue3 + Vite 前端项目,浏览器访问 http://localhost:5173 看到 Vue 欢迎页。
重要前提
本机必须先安装 Node.js(自带 npm 包管理工具)
Windows 下必须用管理员权限 CMD 创建项目(普通权限会导致 npm 写入缓存失败)
不要使用 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
| 命令部分 | 含义 |
|---|---|
npm | Node.js 包管理工具 |
create vite@latest | 使用最新版 Vite 创建项目 |
smart-screen-frontend | 新建项目的文件夹名称 |
--template vue | 指定项目模板为 Vue |
安装过程中依次选择:
Ok to proceed? (y)→ 输入:yInstall 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 中打开前端项目
打开 IDEA →
File→Open选中
D:/bigdata1/code/smart-screen-frontend文件夹选择 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">操作员:</span>
</div>
<span class="screen-title">智慧设备运行预测大屏</span>
<!-- 右上时钟:日期 + 时间 -->
<div class="screen-clock">
<div class="screen-clock-date"></div>
<div class="screen-clock-time"></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"></div>
<div class="small">总设备:</div>
</div>
<!-- 告警数量 -->
<div class="kpi-item kpi-orange">
<div class="kpi-label">告警数量</div>
<div class="kpi-value"></div>
<div class="small">近10秒统计</div>
</div>
<!-- 平均温度 -->
<div class="kpi-item kpi-yellow">
<div class="kpi-label">平均温度(℃)</div>
<div class="kpi-value"></div>
<div class="small">实时窗口</div>
</div>
<!-- 预测高危设备数 -->
<div class="kpi-item kpi-red">
<div class="kpi-label">预测高危设备数</div>
<div class="kpi-value"></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)"></span></td>
<td></td>
<!-- 告警次数:根据数量染色 -->
<td><span :class="getAlarmClass(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)"></span></td>
<td></td>
<td :class="getRiskTextClass(row.prob1)"></td>
<td class="td-time"></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:启动大数据分析模块和后端
按顺序启动:
启动 Hadoop 集群
启动 Hive Metastore 服务
启动 ZooKeeper 和 Kafka
启动 Kafka 实时流数据采集
启动 Flink 实时分析:
RtMetricsSqlJob启动 Spark ML:
DevicePredictStreamJob在 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 的两件事:
托管前端 dist 静态文件(让别人通过浏览器访问大屏)
反向代理后端接口
/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 的两个作用:
托管前端静态资源
将前端
/api/请求转发到后端
应能看到完整大屏:
✅ 左上 KPI 4 项
✅ 左下 风险环形图
✅ 中上 实时双折线
✅ 中下 历史 7 天柱状图
✅ 右上 在线率仪表盘
✅ 右下 告警 + 高风险双表
九、验收标准(学生提交)
建议提交 6 张截图 + 2 个结果:
后端启动成功(8080)
Postman/curl 调通接口(temp7d、kpi、topRisk)
前端开发页面能显示图表(5173)
dist 打包成功
Nginx 部署后 80 端口访问成功
大屏图表数据正常刷新(至少显示近 7 日数据)
十、实验总结
本实验完成了从后端开发 → 前端开发 → 服务器部署的完整流程:
后端:Spring Boot 三层架构(Controller / Service / DAO),连接 MySQL 与 ClickHouse,提供 6 个 REST 接口
前端:Vue3 + Vite + ECharts,6 个图表组件分栏展示,每 5 秒自动刷新
部署