添加总览功能

This commit is contained in:
砂糖
2025-10-15 16:29:00 +08:00
parent b843776a7f
commit e59767375f
11 changed files with 688 additions and 533 deletions

View File

@@ -1,548 +1,340 @@
<template>
<div class="app-container ems-overview">
<div class="toolbar">
<div class="toolbar-left">
<el-button-group>
<el-button size="mini" :type="viewLevel===1?'primary':'default'" @click="setView(1)">1</el-button>
<el-button size="mini" :type="viewLevel===2?'primary':'default'" @click="setView(2)">2</el-button>
<el-button size="mini" :type="viewLevel===3?'primary':'default'" @click="setView(3)">3</el-button>
</el-button-group>
<el-input
size="mini"
v-model="query.keyword"
placeholder="据位/设备关键词"
clearable
class="q-input"
@keyup.enter.native="handleQuery"
/>
<el-button size="mini" type="primary" @click="handleQuery">查询</el-button>
<el-button size="mini" @click="resetQuery">重置查询</el-button>
</div>
<div class="toolbar-right">
<el-button size="mini" icon="el-icon-zoom-in" @click="zoom(1.1)"/>
<el-button size="mini" icon="el-icon-zoom-out" @click="zoom(0.9)"/>
<el-button size="mini" icon="el-icon-refresh" @click="resetView">重置视图</el-button>
<div class="container">
<div class="setting">
<el-checkbox-group v-model="setting">
<el-checkbox-button label="left">实时监控</el-checkbox-button>
<el-checkbox-button label="right">统计信息</el-checkbox-button>
<el-checkbox-button label="bottom">告警记录</el-checkbox-button>
</el-checkbox-group>
</div>
<div class="left" v-if="setting.includes('left')">
<Live />
</div>
<div class="right" v-if="setting.includes('right')">
<Statistic />
</div>
<div class="bottom" v-if="setting.includes('bottom')">
<Record />
</div>
<div class="points">
<!-- 标点渲染 -->
<div v-for="(point, index) in points" :key="index" class="point-marker"
:style="{ left: point.x + '%', top: point.y + '%' }"
:class="{ 'normal-status': point.status === 0, 'abnormal-status': point.status === 1 }"
@mouseenter="showTooltip(index)" @mouseleave="hideTooltip(index)">
<div
style="background-color: #00000060; display: flex; align-items: center; justify-content: center; gap: 4px; padding: 4px; color: #fff;">
<!-- 标点图标 -->
<div class="marker-icon">
<i class="fa" :class="point.status === 0 ? 'fa-check-circle' : 'fa-exclamation-circle'"></i>
</div>
<div class="point-name">{{ point.name }}</div>
</div>
<!-- 悬停提示框 -->
<div class="point-tooltip" :class="{ 'visible': activeTooltip === index }">
<div class="tooltip-header">
<h4>{{ point.name }}</h4>
<span class="status-badge" :class="point.status === 0 ? 'normal' : 'abnormal'">
{{ point.status === 0 ? '正常' : '异常' }}
</span>
</div>
<div class="tooltip-body" v-if="point.children && point.children.length > 0">
<table class="device-table">
<thead>
<tr>
<th>设备名称</th>
<th>设备IP</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="(device, devIdx) in point.children" :key="devIdx">
<td>{{ device.deviceName }}</td>
<td>{{ device.ipAddress || '无数据' }}</td>
<td>
<!-- <span class="device-status" :class="device.status === 0 ? 'normal' : 'abnormal'">
{{ device.status === 0 ? '正常' : '异常' }}
</span> -->
<dict-tag :options="dict.type.alarm_device_status" :value="device.status"/>
</td>
</tr>
</tbody>
</table>
</div>
<div class="tooltip-body" v-else>
<div class="no-device">暂无设备</div>
</div>
</div>
</div>
</div>
<div class="canvas-wrapper" ref="wrapper">
<svg :viewBox="viewBox" class="overview-svg">
<!-- 区域由矩形拼接组成 -->
<g
v-for="area in areas"
:key="area.id"
style="cursor:pointer;"
@click="selectArea(area, $event)"
>
<!-- 拼接矩形 -->
<g v-for="(shape, idx) in area.shapes" :key="area.id + '_s_' + idx">
<rect
:x="shape.x"
:y="shape.y"
:width="shape.w"
:height="shape.h"
:fill="area.id===selectedAreaId ? area.fillActive : area.fill"
:stroke="area.stroke"
:stroke-width="2"
:opacity="viewLevel===3 ? 0.8 : 1"
rx="4"
ry="4"
/>
</g>
<!-- 区域标题 -->
<text :x="area.labelPos.x" :y="area.labelPos.y" class="area-label">
{{ area.name }}
</text>
<!-- 点位格子/长方形 -->
<g v-if="viewLevel!==1 || area.id===selectedAreaId">
<g
v-for="pt in area.pointsInfo"
:key="pt.id"
@click.stop="openPoint(pt, area, $event)"
style="cursor:pointer;"
>
<rect
:x="pt.x-pt.w/2"
:y="pt.y-pt.h/2"
:width="pt.w"
:height="pt.h"
rx="4"
ry="4"
:fill="ptFill(pt)"
stroke="#444"
:stroke-width="selectedPoint && selectedPoint.id===pt.id ? 2 : 1"
:opacity="viewLevel===2 ? 0.9 : 0.8"
:class="{'pt-highlight': highlightedIds.has(pt.id)}"
/>
<text :x="pt.x" :y="pt.y-6" class="pt-title">{{ pt.name }}</text>
<text :x="pt.x" :y="pt.y+10" class="pt-sub">
{{ safeMetric(pt, 'metric1', 'label', '指标1') + '' + safeMetric(pt, 'metric1', 'value', '--') }}
</text>
<text :x="pt.x" :y="pt.y+24" class="pt-sub">
{{ safeMetric(pt, 'metric2', 'label', '指标2') + '' + safeMetric(pt, 'metric2', 'value', '--') }}
</text>
</g>
</g>
</g>
</svg>
<!-- 悬浮信息卡点击块/点位时显示 -->
<div
v-if="infoBox.show"
class="info-box"
:style="{ left: infoBox.x + 'px', top: infoBox.y + 'px' }"
>
<div class="info-title">{{ infoBox.title }}</div>
<div class="info-line" v-for="(line, i) in infoBox.lines" :key="i">{{ line }}</div>
</div>
</div>
<!-- 点位详情 -->
<el-dialog
title="点位详情"
:visible.sync="pointDialogOpen"
width="420px"
append-to-body
>
<div v-if="selectedPoint">
<p>点位名称{{ selectedPoint.name }}</p>
<p>所属区域{{ selectedArea ? selectedArea.name : '-' }}</p>
<p>坐标({{ selectedPoint.x }}, {{ selectedPoint.y }})</p>
<el-divider></el-divider>
<p>设备数据</p>
<ul class="point-ul">
<li>
{{ safeMetric(selectedPoint, 'metric1', 'label', '指标1') }}
{{ safeMetric(selectedPoint, 'metric1', 'value', '--') }}
</li>
<li>
{{ safeMetric(selectedPoint, 'metric2', 'label', '指标2') }}
{{ safeMetric(selectedPoint, 'metric2', 'value', '--') }}
</li>
</ul>
</div>
<span slot="footer" class="dialog-footer">
<el-button size="mini" type="primary" @click="pointDialogOpen=false"> </el-button>
</span>
</el-dialog>
</div>
</template>
<script>
// @ts-nocheck
import Live from './components/Live.vue'
import Record from './components/Record.vue'
import Statistic from './components/Statistic.vue'
import { getLeftWithDevices } from '@/api/ems/overview'
export default {
name: 'EmsOverview',
components: {
Live,
Record,
Statistic
},
dicts: ['alarm_device_status'],
data() {
return {
viewLevel: 1, // 1/2/3 切换视图
scale: 1,
viewBox: '0 0 1200 700',
selectedAreaId: null,
selectedPoint: null,
selectedArea: null,
pointDialogOpen: false,
// 查询
query: {
keyword: ''
},
highlightedIds: new Set(),
infoBox: {
show: false,
x: 0,
y: 0,
title: '',
lines: []
},
// 示例数据:使用由正方形/长方形拼接的区域形状
areas: [
{
id: 'A',
name: '冷藏区',
// 用多个长方形拼接
shapes: [
{ x: 40, y: 60, w: 560, h: 560 }, // 大矩形
{ x: 40, y: 620, w: 560, h: 10 } // 底部细条,强调边界(可选)
],
labelPos: { x: 60, y: 110 },
fill: '#4c6ef5',
fillActive: '#3b5bdb',
stroke: '#2b2d42',
// 点位
pointsInfo: [
{
id: 'A1', name: '点位-01', x: 250, y: 220, w: 110, h: 70,
deviceData: { metric1: { label: '温度', value: '4.2℃' }, metric2: { label: '状态', value: '正常' } }
},
{
id: 'A2', name: '点位-02', x: 520, y: 260, w: 110, h: 70,
deviceData: { metric1: { label: '温度', value: '5.1℃' }, metric2: { label: '状态', value: '正常' } }
},
{
id: 'A3', name: '点位-03', x: 420, y: 420, w: 110, h: 70,
deviceData: { metric1: { label: '温度', value: '7.8℃' }, metric2: { label: '状态', value: '告警' } }
}
],
// 库存示例(点击区域显示)
inventory: { title: '库存', count: 31890 }
},
{
id: 'B',
name: '冷冻区',
shapes: [
{ x: 620, y: 60, w: 520, h: 280 }, // 上部
{ x: 620, y: 350, w: 520, h: 270 } // 下部
],
labelPos: { x: 640, y: 110 },
fill: '#4ea8de',
fillActive: '#3a86ff',
stroke: '#1b263b',
pointsInfo: [
{
id: 'B1', name: '点位-11', x: 780, y: 220, w: 110, h: 70,
deviceData: { metric1: { label: '温度', value: '-12.3℃' }, metric2: { label: '状态', value: '正常' } }
},
{
id: 'B2', name: '点位-12', x: 980, y: 460, w: 110, h: 70,
deviceData: { metric1: { label: '温度', value: '-10.0℃' }, metric2: { label: '状态', value: '维护' } }
}
],
inventory: { title: '库存', count: 21890 }
},
// 分拣区:原来是不规则多边形,现改为由多个矩形拼接成“侧边栏”效果
{
id: 'C',
name: '分拣区',
shapes: [
{ x: 1160, y: 60, w: 20, h: 560 }, // 竖条边框
{ x: 1080, y: 60, w: 70, h: 130 },
{ x: 1080, y: 200, w: 70, h: 130 },
{ x: 1080, y: 340, w: 70, h: 130 },
{ x: 1080, y: 480, w: 70, h: 140 }
],
labelPos: { x: 1090, y: 90 },
fill: '#95d5b2',
fillActive: '#74c69d',
stroke: '#2d6a4f',
pointsInfo: [
{
id: 'C1', name: '能量棒', x: 1115, y: 130, w: 60, h: 60,
deviceData: { metric1: { label: '重量', value: '1000g' }, metric2: { label: '状态', value: '良好' } }
},
{
id: 'C2', name: '显卡', x: 1115, y: 270, w: 60, h: 60,
deviceData: { metric1: { label: '型号', value: '4001' }, metric2: { label: '状态', value: '正常' } }
},
{
id: 'C3', name: '特种钢', x: 1115, y: 410, w: 60, h: 60,
deviceData: { metric1: { label: '数量', value: '999个' }, metric2: { label: '状态', value: '正常' } }
},
{
id: 'C4', name: '能量棒', x: 1115, y: 560, w: 60, h: 60,
deviceData: { metric1: { label: '重量', value: '500g' }, metric2: { label: '状态', value: '正常' } }
}
],
inventory: { title: '库存', count: 4080 }
}
]
};
// 标点数据补充
setting: ['left', 'right'],
points: [],
activeTooltip: -1 // 控制提示框显示
}
},
created() {
this.getLeftWithDevices()
},
methods: {
setView(n) {
this.viewLevel = n;
showTooltip(index) {
this.activeTooltip = index
},
zoom(factor) {
this.scale = Math.max(0.5, Math.min(2.5, this.scale * factor));
const base = { w: 1200, h: 700 };
const w = base.w / this.scale;
const h = base.h / this.scale;
this.viewBox = `0 0 ${w} ${h}`;
},
resetView() {
this.scale = 1;
this.viewBox = '0 0 1200 700';
this.selectedAreaId = null;
this.selectedPoint = null;
this.selectedArea = null;
this.hideInfo();
this.highlightedIds = new Set();
hideTooltip() {
this.activeTooltip = -1
},
getLeftWithDevices() {
getLeftWithDevices().then(res => {
console.log(res.data);
// 安全读取设备指标
safeMetric(obj, mKey, fKey, defVal) {
if (!obj || !obj.deviceData) return defVal;
const m = obj.deviceData[mKey];
return m && m[fKey] != null ? m[fKey] : defVal;
},
// 1. 树结构转数组只保留叶子节点children为空数组的节点
const leafNodes = [];
// 点位填充色
ptFill(pt) {
const val = (this.safeMetric(pt, 'metric2', 'value', '') || '').toString();
if (val.includes('告警') || val.includes('异常')) return '#ff6b6b';
if (val.includes('维护')) return '#ffd166';
return '#8ecae6';
},
// 递归收集叶子节点的函数
const collectLeafs = (nodes) => {
if (!nodes || !Array.isArray(nodes)) return;
// 区域选择 + 信息卡显示(库存)
selectArea(area, evt) {
this.selectedAreaId = area.id;
this.selectedArea = area;
this.showInfo(evt, area.name, [
area.inventory?.title || '库存',
`库存数量:${this.formatNumber(area.inventory?.count ?? 0)}`
]);
},
// 打开点位(弹框 + 信息卡)
openPoint(pt, area, evt) {
this.selectedPoint = pt;
this.selectedArea = area;
this.pointDialogOpen = true;
const l1 = `${this.safeMetric(pt, 'metric1', 'label', '指标1')}${this.safeMetric(pt, 'metric1', 'value', '--')}`;
const l2 = `${this.safeMetric(pt, 'metric2', 'label', '指标2')}${this.safeMetric(pt, 'metric2', 'value', '--')}`;
this.showInfo(evt, pt.name, [l1, l2]);
},
// 悬浮信息卡
showInfo(evt, title, lines) {
const wrap = this.$refs.wrapper;
if (!wrap) return;
const rect = wrap.getBoundingClientRect();
const x = evt.clientX - rect.left + 12;
const y = evt.clientY - rect.top + 12;
this.infoBox = { show: true, x, y, title, lines };
},
hideInfo() {
this.infoBox.show = false;
},
// 查询(据位查询):优先走接口,失败回退本地过滤
async handleQuery() {
const kw = (this.query.keyword || '').trim();
this.highlightedIds = new Set();
if (!kw) {
this.hideInfo();
return;
}
let usedApi = false;
try {
// 存在封装的 API 则调用
if (this.$api && this.$api.ems && this.$api.ems.queryPoints) {
await this.queryPoints({ keyword: kw });
usedApi = true;
}
} catch (e) {
// 忽略接口错误,回退本地
usedApi = false;
}
// 高亮与信息卡(对当前 this.areas 数据集进行)
let firstHit = null;
this.areas.forEach(area => {
(area.pointsInfo || []).forEach(pt => {
const text = [
pt.name,
this.safeMetric(pt, 'metric1', 'label', ''),
this.safeMetric(pt, 'metric1', 'value', ''),
this.safeMetric(pt, 'metric2', 'label', ''),
this.safeMetric(pt, 'metric2', 'value', '')
].join(' ');
if (text.includes(kw)) {
this.highlightedIds.add(pt.id);
if (!firstHit) firstHit = { pt, area };
}
});
});
if (firstHit) {
this.selectedAreaId = firstHit.area.id;
this.selectedArea = firstHit.area;
this.selectedPoint = firstHit.pt;
const wrap = this.$refs.wrapper;
if (wrap) {
const rect = wrap.getBoundingClientRect();
const x = (firstHit.pt.x / (1200 / rect.width)) + 12;
const y = (firstHit.pt.y / (700 / rect.height)) + 12;
this.infoBox = {
show: true,
x: Math.min(rect.width - 220, Math.max(8, x)),
y: Math.min(rect.height - 140, Math.max(8, y)),
title: firstHit.pt.name,
lines: [
`${this.safeMetric(firstHit.pt, 'metric1', 'label', '指标1')}${this.safeMetric(firstHit.pt, 'metric1', 'value', '--')}`,
`${this.safeMetric(firstHit.pt, 'metric2', 'label', '指标2')}${this.safeMetric(firstHit.pt, 'metric2', 'value', '--')}`
]
};
}
} else {
this.$message.info(usedApi ? '接口无匹配结果' : '未查询到匹配点位/设备数据');
this.hideInfo();
}
},
resetQuery() {
this.query.keyword = '';
this.highlightedIds = new Set();
this.hideInfo();
},
// 对接接口:据位查询或区域点位拉取
queryPoints(params) {
if (!(this.$api && this.$api.ems && this.$api.ems.queryPoints)) {
return Promise.resolve();
}
return this.$api.ems.queryPoints(params).then(res => {
const data = (res && (res.data ?? res)) || {};
// 结构 A包含 areas
if (Array.isArray(data.areas)) {
// 将后端 areas 适配为前端结构,缺省字段从现有同 id 区域继承
const existMap = new Map(this.areas.map(a => [a.id, a]));
this.areas = data.areas.map(a => {
const old = existMap.get(a.id) || {};
return {
id: a.id,
name: a.name ?? old.name ?? '',
shapes: Array.isArray(a.shapes) ? a.shapes : (old.shapes || []),
labelPos: a.labelPos ?? old.labelPos ?? { x: 60, y: 110 },
fill: old.fill || '#4c6ef5',
fillActive: old.fillActive || '#3b5bdb',
stroke: old.stroke || '#2b2d42',
inventory: a.inventory ?? old.inventory ?? null,
pointsInfo: Array.isArray(a.points)
? a.points.map(p => ({
id: p.id, name: p.name,
x: p.x, y: p.y, w: p.w, h: p.h,
deviceData: p.deviceData || {}
}))
: (old.pointsInfo || [])
};
nodes.forEach(node => {
// 检查是否为叶子节点children存在且为空数组
if (node.children && Array.isArray(node.children) && node.children.length === 0) {
leafNodes.push(node);
} else {
// 递归处理子节点
collectLeafs(node.children);
}
});
return;
}
// 结构 B仅返回 points需含 areaId
if (Array.isArray(data.points)) {
const byArea = {};
data.points.forEach(p => {
const aid = p.areaId;
if (!aid) return;
(byArea[aid] = byArea[aid] || []).push({
id: p.id, name: p.name,
x: p.x, y: p.y, w: p.w, h: p.h,
deviceData: p.deviceData || {}
});
});
this.areas = this.areas.map(a => ({
...a,
pointsInfo: byArea[a.id] ? byArea[a.id] : (a.pointsInfo || [])
}));
// 可选data.areasInfo 覆盖库存
if (Array.isArray(data.areasInfo)) {
const infoMap = new Map(data.areasInfo.map(i => [i.id || i.areaId, i]));
this.areas = this.areas.map(a => ({
...a,
inventory: infoMap.get(a.id)?.inventory ?? a.inventory
}));
}
return;
}
// 未识别结构,保持不变
}).catch(err => {
this.$message.error('查询接口失败');
// 继续走本地回退
});
},
};
formatNumber(n) {
const str = (n ?? 0).toString();
return str.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
// 开始收集叶子节点
collectLeafs(res.data);
// 2. 转换数组结构
const transformedData = leafNodes.map(node => ({
name: node.path, // 将path转换为name
status: 0, // 状态设为0
children: node.devices, // children使用原来的devices字段
x: node.x, // 保留x字段
y: node.y // 保留y字段
// 如果有其他需要保留的字段,可以在这里继续添加
}));
// 这里可以使用转换后的数据,例如赋值给组件的状态
this.points = transformedData;
console.log('转换后的叶子节点数据:', transformedData);
}).catch(error => {
console.error('获取数据失败:', error);
});
}
}
};
}
</script>
<style scoped>
.ems-overview {
display: flex;
flex-direction: column;
<style>
.container {
width: 100%;
height: calc(100vh - 84px);
position: relative;
background: url('../../../assets/images/bird.png') no-repeat center center;
background-size: cover;
overflow: hidden;
}
.toolbar {
.setting {
position: absolute;
top: 10px;
left: 10px;
z-index: 100;
}
.left {
position: absolute;
top: 60px;
left: 10px;
width: 220px;
z-index: 10;
}
.right {
position: absolute;
top: 10px;
right: 10px;
width: 220px;
z-index: 10;
}
.bottom {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 10;
}
.points {
width: 100%;
height: 100%;
position: relative;
z-index: 5;
}
/* 标点样式 */
.point-marker {
position: absolute;
transform: translate(-50%, -50%);
cursor: pointer;
transition: all 0.3s ease;
}
.marker-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.normal-status .marker-icon {
background-color: #43a047;
/* 正常-绿色 */
}
.abnormal-status .marker-icon {
background-color: #e53935;
/* 异常-红色 */
animation: pulse 1.5s infinite;
}
/* 脉冲动画 */
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(229, 57, 53, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(229, 57, 53, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(229, 57, 53, 0);
}
}
.tooltip-body {
max-height: 200px;
overflow-y: auto;
}
/* 提示框样式 */
.point-tooltip {
position: absolute;
top: 30px;
left: 50%;
transform: translateX(-50%);
width: 320px;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 12px;
display: none;
z-index: 100;
}
.point-tooltip.visible {
display: block;
}
.tooltip-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.q-input {
width: 220px;
}
.toolbar-right {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.canvas-wrapper {
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #f8fafc;
height: 720px;
position: relative;
overflow: hidden;
}
.overview-svg {
width: 100%;
height: 100%;
}
.area-label {
fill: #ffffff;
font-size: 16px;
font-weight: 700;
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
}
.pt-title, .pt-sub {
fill: #ffffff;
font-size: 12px;
text-anchor: middle;
user-select: none;
pointer-events: none;
}
.pt-highlight {
stroke: #ffb703 !important;
stroke-width: 3 !important;
}
.info-box {
position: absolute;
min-width: 180px;
max-width: 240px;
background: #fff;
color: #111827;
box-shadow: 0 6px 20px rgba(0,0,0,0.18);
border-radius: 8px;
padding: 10px 12px;
z-index: 10;
pointer-events: none;
}
.info-title {
font-weight: 600;
margin-bottom: 6px;
}
.info-line {
font-size: 12px;
line-height: 18px;
}
.point-ul {
.tooltip-header h4 {
margin: 0;
padding-left: 16px;
font-size: 16px;
color: #333;
}
.status-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
color: white;
}
.status-badge.normal {
background-color: #43a047;
}
.status-badge.abnormal {
background-color: #e53935;
}
.device-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.device-table th {
padding: 6px 4px;
text-align: left;
background-color: #f5f5f5;
font-weight: 500;
color: #666;
}
.device-table td {
padding: 8px 4px;
border-bottom: 1px solid #f0f0f0;
}
.device-table tr:last-child td {
border-bottom: none;
}
.device-status {
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
}
.device-status.normal {
background-color: #e8f5e9;
color: #2e7d32;
}
.device-status.abnormal {
background-color: #ffebee;
color: #c62828;
}
</style>