接线图角落弹框

This commit is contained in:
stt
2025-10-23 15:47:56 +08:00
parent f3252f8a15
commit b3154e894a
5 changed files with 6601 additions and 5035 deletions

858
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,8 +26,10 @@
"element-plus": "^2.7.5",
"exceljs": "v4.4.0",
"file-saver": "v2.0.5",
"grid-layout-plus": "^1.1.0",
"html2canvas": "^1.4.1",
"jquery": "^3.7.1",
"jsencrypt": "^3.3.2",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
@@ -38,20 +40,18 @@
"nprogress": "^0.2.0",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"qs": "^6.12.0",
"screenfull": "^6.0.2",
"splitpanes": "3.1.5",
"splitpanes": "^3.1.5",
"use-element-plus-theme": "^0.0.5",
"vue": "^3.3.11",
"vue-draggable-resizable": "3.0.0-beta.2",
"vue-draggable-resizable": "^3.0.0-beta.2",
"vue-i18n": "9.10.2",
"vue-router": "4",
"vue-types": "^5.1.1",
"vxe-table": "^4.5.17",
"vxe-table-plugin-export-xlsx": "^4.0.7",
"grid-layout-plus": "^1.1.0",
"vue-i18n": "9.10.2",
"qs": "^6.12.0",
"vue-types": "^5.1.1",
"web-storage-cache": "^1.1.1",
"jsencrypt": "^3.3.2",
"xe-utils": "^3.5.14"
},
"devDependencies": {
@@ -61,9 +61,9 @@
"@types/splitpanes": "2.2.6",
"@vitejs/plugin-vue": "^4.5.2",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"unplugin-auto-import": "^0.16.7",
"sass": "^1.69.5",
"typescript": "^5.2.2",
"unplugin-auto-import": "^0.16.7",
"vite": "^5.0.8",
"vue-tsc": "^1.8.25"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -0,0 +1,214 @@
import { ElMessage, EVENT_CODE } from "element-plus";
// 定义消息类型,用于类型检查
type MessageType = {
[key: string]: any;
};
export default class SocketService {
// 单例模式实例
private static instance: SocketService | null = null;
// 和服务端连接的socket对象
private ws: WebSocket | null = null;
// 存储回调函数
private callBackMapping: {
[key: string]: ((message: MessageType) => void) | null;
} = {};
// 标识是否连接成功
private connected: boolean = false;
// 记录重试的次数
private sendRetryCount: number = 0;
// 重新连接尝试的次数
private connectRetryCount: number = 0;
// Web Worker 实例
private work: Worker | null = null;
// 临时的 Blob URL
private workerBlobUrl: string | null = null;
// 上次活动时间戳
private lastActivityTime: number = 0;
// 最后一次收到心跳回复时间
private lastResponseHeartTime: number = Date.now();
// 重新连接延迟,单位毫秒
private reconnectDelay: number = 5000;
// 单例模式获取实例
public static get Instance(): SocketService {
if (!this.instance) {
this.instance = new SocketService();
}
return this.instance;
}
// 定义连接服务器的方法
public async connect(id: any) {
if (!window.WebSocket) {
console.log("您的浏览器不支持WebSocket");
return;
}
let response = null;
if (import.meta.env.VITE_NAME == "beijing") {
response = await fetch("/vue/");
} else if (import.meta.env.VITE_NAME == "wuxi") {
response = await fetch("");
}
// const response = await fetch("/vue/");
const mqttUrl = response.headers.get("X-Mqtt-Url");
console.log("🚀 ~ SocketService ~ connect ~ mqttUrl:", mqttUrl);
setTimeout(() => {
//"ws://10.156.193.182:18093/ws/screen" 北京
//"ws://192.168.1.130:19001/ws/askRealTimeData/" 无锡
const url = (mqttUrl || "ws://192.168.1.127:19001/ws/") + id;
// const url = (mqttUrl || "ws://192.168.1.63:18093/ws/screen") + id;
console.log("🚀 ~ SocketService ~ connect ~ url:", url);
this.ws = new WebSocket(url);
this.ws.onopen = () => this.handleOpen();
this.ws.onclose = () => this.handleClose();
this.ws.onerror = () => this.handleError();
this.ws.onmessage = (event) => this.handleMessage(event);
}, 0);
}
// 处理连接成功事件
private handleOpen(): void {
ElMessage.success("webSocket连接服务端成功了");
console.log("连接服务端成功了");
this.connected = true;
this.connectRetryCount = 0;
this.updateLastActivityTime();
this.startHeartbeat();
}
// 处理连接关闭事件
private handleClose(): void {
console.log("连接webSocket服务端关闭");
this.connected = false;
this.connectRetryCount++;
this.clearHeartbeat();
// 可根据需要添加重连逻辑
// setTimeout(() => this.connect(), 500 * this.connectRetryCount);
}
// 处理连接错误事件
private handleError(): void {
ElMessage.error("webSocket连接异常");
}
// 处理服务端发送过来的数据
private handleMessage(event: MessageEvent): void {
// console.log('🚀 ~ SocketService ~ handleMessage ~ event.data:', event.data)
if (event.data == "连接成功") {
this.sendHeartbeat();
} else if (event.data.length > 10) {
let message: MessageType;
try {
// console.log(
// 'Received message:', event.data)
message = JSON.parse(event.data);
this.callBackMapping["message"]!(message);
} catch (e) {
// console.error("消息解析失败", event.data, e);
return;
}
// console.log("🚀 ~ SocketService ~ handleMessage ~ message:", message)
// this.callBackMapping["message"]!(message);
} else {
// ElMessage.error(event.data);
}
}
// 启动心跳检测
private startHeartbeat(): void {
this.lastResponseHeartTime = Date.now();
const url = window.URL.createObjectURL(
new Blob([
"(function(e){setInterval(function(){this.postMessage(null)},30000)})()",
])
);
this.workerBlobUrl = url;
this.work = new Worker(url);
this.work.onmessage = (e) => this.handleWorkerMessage(e);
}
// 处理 Web Worker 消息
private handleWorkerMessage(e: MessageEvent): void {
// if (this.lastActivityTime - this.lastResponseHeartTime > 30000) {
// // 说明已经三轮心跳没收到回复了,关闭检测,提示用户。
// // ElMessage.error('业务主体模块发生未知异常,请尝试重新启动!')
// this.clearHeartbeat()
// return
// }
// console.log(123);
this.sendHeartbeat();
}
// 发送心跳消息
private sendHeartbeat(): void {
// console.log(new Date() + '进入心跳消息发送。。。。。。。。。。。。。')
if (this.ws) {
this.ws.send("alive");
this.updateLastActivityTime();
}
}
// 更新活动时间
private updateLastActivityTime(): void {
this.lastActivityTime = Date.now();
}
// 清除心跳检测
private clearHeartbeat(): void {
if (this.work) {
this.work.terminate();
this.work = null;
}
if (this.workerBlobUrl) {
window.URL.revokeObjectURL(this.workerBlobUrl);
this.workerBlobUrl = null;
}
}
// 回调函数的注册
public registerCallBack(
socketType: string,
callBack: (message: MessageType) => void
): void {
this.callBackMapping[socketType] = callBack;
}
// 取消某一个回调函数
public unRegisterCallBack(socketType: string): void {
this.callBackMapping[socketType] = null;
}
// 发送数据的方法
public send(data: any): void {
if (this.connected) {
this.sendRetryCount = 0;
try {
if (this.ws) {
this.ws.send(JSON.stringify(data));
}
} catch (e) {
if (this.ws) {
this.ws.send(data);
}
}
} else {
this.sendRetryCount++;
setTimeout(() => this.send(data), this.sendRetryCount * 500);
}
}
// 断开方法
public closeWs(): void {
if (this.connected && this.ws) {
this.ws.close();
}
console.log("执行WS关闭命令..");
}
}

View File

@@ -0,0 +1,498 @@
<template>
<div class="container">
<!-- 使用 v-for 遍历四个角落 -->
<div v-for="corner in corners" :key="corner.id" :class="['corner', corner.className]">
<div class="content">
<div class="title" style="display: flex">
<img src="@/assets/img/lightning.png" style="width: 25px; height: 25px; margin-right: 10px" />
<span>{{ corner.title }}</span>
</div>
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>A</th>
<th>B</th>
<th>C</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in corner.data" :key="index">
<td>{{ item.name }}</td>
<td>{{ item.a }}</td>
<td>{{ item.b }}</td>
<td>{{ item.c }}</td>
</tr>
</tbody>
</table>
<!-- <el-descriptions :column="1" size="small" label-width="70px" border>
<el-descriptions-item
v-for="(item, index) in corner.data"
:key="index"
:label="item.label"
></el-descriptions-item>
</el-descriptions> -->
</div>
<span class="close-btn" @click="closeCorner(corner.id)">
<Close />
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, reactive } from 'vue'
// import { clickImage } from "@/api/manage_wx";
import { Close } from '@element-plus/icons-vue'
import socketClient from '@/utils/webSocketClient'
//开始创建webSocket客户端
const dataSocket = reactive({
socketServe: socketClient.Instance
})
// 定义四个角落的数据
const corners = ref([
{
id: 'topLeft',
title: '左上',
className: 'top-left',
show: false,
data: [
{ name: '电压', a: 1098, b: 2080, c: 3006 },
{ name: '电流', a: 15, b: 25, c: 35 },
{ name: '电压畸变率', a: 12, b: 22, c: 32 },
{ name: '电流畸变率', a: 18, b: 28, c: 38 },
{ name: '电压偏值', a: 12, b: 22, c: 32 },
{ name: '电压不平衡', a: 18, b: 28, c: 38 }
] as any[],
elementId: '', // 记录该角落对应的元素ID
raceLists: [] as any[] // 为每个角落添加独立的跑马灯数据存储
},
{
id: 'topRight',
title: '右上',
className: 'top-right',
show: false,
data: [] as any[],
elementId: '',
raceLists: [] as any[] // 为每个角落添加独立的跑马灯数据存储
}
// {
// id: "bottomLeft",
// title: "左下",
// className: "bottom-left",
// show: false,
// data: [] as { label: string; value: string }[],
// elementId: "",
// },
// {
// id: "bottomRight",
// title: "右下",
// className: "bottom-right",
// show: false,
// data: [] as { label: string; value: string }[],
// elementId: "",
// },
])
interface DataTableItem {
name: string
a: number
b: number
c: number
}
const tableData = ref<DataTableItem[]>([
{ name: '电压', a: 1098, b: 2080, c: 3006 },
{ name: '电流', a: 15, b: 25, c: 35 },
{ name: '电压畸变率', a: 12, b: 22, c: 32 },
{ name: '电流畸变率', a: 18, b: 28, c: 38 },
{ name: '电压偏值', a: 12, b: 22, c: 32 },
{ name: '电压不平衡', a: 18, b: 28, c: 38 }
])
const steadyStateList = ref([])
const selectedId = ref('')
// 内部响应式数据
const eventList = ref<any[]>([])
// const handleClickImage = async (elementId: string) => {
// // 检查 elementId 是否有值,没有值则直接返回空数组
// if (!elementId) {
// eventList.value = [];
// return;
// }
// try {
// // 发送点击图片请求
// const res = await clickImage({ lineId: elementId });
// // 确保返回的数据是数组格式,并且过滤掉 null/undefined 元素
// let dataToStore: any[] = [];
// if (Array.isArray(res.data)) {
// dataToStore = res.data.filter(
// (item:any) => item !== null && item !== undefined
// );
// } else if (res.data && Array.isArray(res.data.records)) {
// dataToStore = res.data.records.filter(
// (item:any) => item !== null && item !== undefined
// );
// } else if (res.data) {
// // 如果是单个对象且不为 null
// if (res.data !== null && res.data !== undefined) {
// dataToStore = [res.data];
// }
// }
// eventList.value = dataToStore;
// } catch (error) {
// console.error("调用 clickImage 接口出错:", error);
// // 出错时设置为空数组,避免后续处理出错
// eventList.value = [];
// }
// };
// 记录显示顺序,用于循环替换
const displayOrder = ref<number[]>([])
// 更新指定角落数据的函数
const updateCornerData = (cornerIndex: number, dataItem: any, elementId: string) => {
// 更新标题为 objName
if (dataItem.objName) {
corners.value[cornerIndex].title = dataItem.objName
} else {
corners.value[cornerIndex].title = dataItem.stationName
}
// 格式化数据
corners.value[cornerIndex].data = [
{ label: '监测点', value: dataItem.lineName },
// {
// label: "暂降次数",
// value: dataItem.eventIds.length,
// },
{
label: '暂降次数',
value: ``
},
// { label: "稳态指标", value: "Ua:65.5 Ub:65.02 Uc:65.27 Uac:112.85 Uab:112.67 Ubc:112.85" },
{
label: '稳态指标',
value: ``
}
]
// 记录该角落对应的元素ID
corners.value[cornerIndex].elementId = elementId
corners.value[cornerIndex].show = true
}
// 显示下一个角落的函数
const showNextCorner = (elementId: string) => {
// 检查该元素ID是否已经显示过
const existingCornerIndex = corners.value.findIndex(corner => corner.elementId === elementId && corner.show)
if (existingCornerIndex !== -1) {
// 如果该元素已经显示过,不更新数据
return
}
// 确保 eventList.value 是数组并且过滤掉 null/undefined 元素
if (!Array.isArray(eventList.value)) {
console.warn('eventList.value 不是数组格式:', eventList.value)
return
}
// 过滤掉 null 和 undefined 元素,然后查找匹配项
const validItems = eventList.value.filter(item => item !== null && item !== undefined)
const dataItem = validItems.find(item => item.lineId === elementId)
// 如果没有找到匹配的数据项,则不更新数据
if (!dataItem) {
console.warn('未找到匹配的数据项:', elementId)
return
}
// 查找一个未显示的角落
const availableCornerIndex = corners.value.findIndex(corner => !corner.show)
if (availableCornerIndex !== -1) {
// 有空闲角落,显示在该角落
updateCornerData(availableCornerIndex, dataItem, elementId)
// 将事件数据存储到该角落
corners.value[availableCornerIndex].raceLists = dataItem.eventList || []
// 记录显示顺序
displayOrder.value.push(availableCornerIndex)
} else {
// 没有空闲角落,按顺序替换角落
// 获取需要替换的角落索引(循环替换)
const replaceIndex = displayOrder.value.shift() || 0
updateCornerData(replaceIndex, dataItem, elementId)
// 将事件数据存储到该角落
corners.value[replaceIndex].raceLists = dataItem.eventList || []
// 将替换的索引重新加入队列末尾
displayOrder.value.push(replaceIndex)
}
}
// 关闭指定角落的函数
const closeCorner = (id: string) => {
const cornerIndex = corners.value.findIndex(c => c.id === id)
if (cornerIndex !== -1) {
corners.value[cornerIndex].show = false
corners.value[cornerIndex].elementId = '' // 清空元素ID记录
// 从显示顺序中移除该角落索引
const orderIndex = displayOrder.value.indexOf(cornerIndex)
if (orderIndex !== -1) {
displayOrder.value.splice(orderIndex, 1)
}
}
send()
}
// 关闭所有角落的函数
const closeAllCorners = () => {
corners.value.forEach(corner => {
corner.show = false
corner.elementId = ''
})
displayOrder.value = []
}
// 组件挂载后初始化监听器
onMounted(() => {
init()
// 初始化时不显示任何内容
})
// 连接webSocket客户端
const init = () => {
if (!dataSocket.socketServe) {
console.error('WebSocket 客户端实例不存在')
return
}
dataSocket.socketServe.connect(new Date().getTime())
dataSocket.socketServe.registerCallBack('message', (res: any) => {
if (res.type == 1) {
//稳态指标数据
let steadyState = JSON.parse(res.message)
// console.log(steadyState, "8990hhhhh");
if (steadyState == null || steadyState.length == 0) return
steadyStateList.value = steadyState
corners.value.forEach((corner, index) => {
let str = ``
steadyState
.filter((item: any) => item.lineId == corner.elementId)
.forEach((item: any) => {
if (item.value == 3.1415926) {
str += `<div>${item.statisticalName}/</div>`
} else {
str += `<div>${item.statisticalName}${item.value}${item.unit}</div>`
}
})
corner.data.length > 0
? (corner.data[2].value = `<div style="max-height: 100px;overflow-y: auto;">${str} </div>`)
: ''
})
}
})
}
const time = ref(null)
// 推送消息
const send = () => {
dataSocket.socketServe.send({
pageId: selectedId.value,
lineIdList: corners.value.filter(item => item.show == true).map(corner => corner.elementId)
})
if (time.value) {
clearTimeout(time.value)
}
if (corners.value.filter(item => item.show == true).map(corner => corner.elementId).length == 0) return
time.value = setInterval(() => {
dataSocket.socketServe.send({
pageId: selectedId.value,
lineIdList: corners.value.filter(item => item.show == true).map(corner => corner.elementId)
})
}, 1000 * 60)
}
// 监听来自 iframe 的消息
window.addEventListener('message', async function (event) {
// 安全起见可以验证消息来源origin
// if (event.origin !== 'https://trusted-origin.com') return;
// 处理从 iframe 发送过来的消息
if (event.data.action === 'coreClick') {
const clickedElementId = event.data.coreId
selectedId.value = event.data.selectedId
// 调用接口获取最新数据
// await handleClickImage(clickedElementId);
// 根据接收到的元素LineId显示对应数据
await showNextCorner(clickedElementId)
await send()
}
})
// 页面卸载时清除定时器
onBeforeUnmount(() => {
clearTimeout(time.value)
dataSocket.socketServe?.closeWs()
})
</script>
<style>
/* 走马灯样式 - 支持逐条显示 */
.simple-marquee {
width: 230px;
height: 24px;
overflow: hidden;
position: relative;
white-space: nowrap;
}
.marquee-content {
display: inline-block;
padding-left: 100%;
/* animation: marquee-single 15s linear infinite; */
animation: marquee-single var(--marquee-duration, 25s) linear infinite;
line-height: 24px;
text-decoration: underline;
text-decoration-color: #4877f6;
}
@keyframes marquee-single {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
/* 鼠标悬停时暂停动画 */
.simple-marquee:hover .marquee-content {
animation-play-state: paused;
cursor: pointer;
}
</style>
<style scoped lang="scss">
.container {
position: relative;
}
.corner {
width: 180px;
/* height: 135px; */
background-color: #fff;
position: absolute;
color: #000;
/* font-weight: bold; */
/* 添加弹出动画 */
opacity: 0;
transform: scale(0.3);
transition: all 0.4s ease-out;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
/* 显示状态的样式 */
.corner:not([style*='display: none']):not([style*='display:none']) {
opacity: 1;
transform: scale(1);
}
.top-left {
top: 10px;
left: 10px;
}
.top-right {
top: 10px;
right: 10px;
}
.bottom-left {
top: 170px;
left: 10px;
}
.bottom-right {
top: 170px;
right: 10px;
}
.content {
width: 100%;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.title {
font-size: 16px;
// text-align: center;
padding: 5px;
/* color: #409eff; */
border-bottom: 1px solid #444;
background-color: #81d3f8;
border-radius: 8px 8px 0 0;
}
.data-item {
display: flex;
margin-bottom: 4px;
font-size: 12px;
}
.label {
/* font-weight: bold; */
width: 55px;
flex-shrink: 0;
}
.value {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 关闭按钮样式 */
.close-btn {
position: absolute;
top: 10px;
right: 10px;
width: 14px;
color: white;
font-size: 14px;
font-weight: bold;
cursor: pointer;
}
.indicator {
display: flex;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
border: 1px solid #000;
text-align: center;
font-size: 12px;
}
</style>