[BI] 实现供水运营专题大屏功能

- 增强OperationDashboard.vue,支持WebSocket实时数据推送
- 新增WaterSupplySpecialScreen.vue供水专题大屏组件
- 后端集成WebSocket实时数据推送服务
- 新增BI RESTful API接口
- 支持ECharts图表可视化展示
- 实现实时报警、水质监测、营收分析等核心功能

Fixes Issue #38: [BI] 运营仪表盘 + 供水专题大屏

Co-authored-by: bot_dev1 <bot_dev1@xayunmei.com>
This commit is contained in:
2026-06-15 10:02:12 +08:00
parent 85df71fc28
commit 5524e1e2ae
10 changed files with 2566 additions and 2 deletions
+59
View File
@@ -0,0 +1,59 @@
import { createRouter, createWebHistory } from 'vue-router'
import OperationDashboard from '@/views/dashboard/OperationDashboard.vue'
import WaterSupplySpecialScreen from '@/views/dashboard/WaterSupplySpecialScreen.vue'
const routes = [
{
path: '/',
redirect: '/dashboard/operation'
},
{
path: '/dashboard',
redirect: '/dashboard/operation'
},
{
path: '/dashboard/operation',
name: 'OperationDashboard',
component: OperationDashboard,
meta: {
title: '供水运营总览',
requiresAuth: true
}
},
{
path: '/dashboard/water-supply',
name: 'WaterSupplySpecialScreen',
component: WaterSupplySpecialScreen,
meta: {
title: '供水专题大屏',
requiresAuth: true
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 全局路由守卫
router.beforeEach((to, from, next) => {
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - 供水管理系统` : '供水管理系统'
// 这里可以添加认证逻辑
if (to.meta.requiresAuth) {
// 检查用户是否已登录
const token = localStorage.getItem('token')
if (token) {
next()
} else {
// 重定向到登录页面
next('/login')
}
} else {
next()
}
})
export default router
+246
View File
@@ -0,0 +1,246 @@
import { ElMessage } from 'element-plus'
/**
* WebSocket服务类
*/
class WebSocketService {
constructor() {
this.ws = null
this.isConnected = false
this.reconnectAttempts = 0
this.maxReconnectAttempts = 5
this.reconnectInterval = 3000
this.messageHandlers = new Map()
this.heartbeatInterval = null
}
/**
* 连接WebSocket
* @param {string} url - WebSocket地址
* @param {Object} options - 连接选项
*/
connect(url = null, options = {}) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = url || `${protocol}//${window.location.host}/ws/water-data`
try {
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
console.log('WebSocket连接已建立')
this.isConnected = true
this.reconnectAttempts = 0
// 发送连接确认
this.send({
type: 'connection-status',
data: { status: 'connected' }
})
// 设置心跳检测
this.startHeartbeat()
// 连接成功回调
this.emit('connected', {})
ElMessage.success('实时数据连接已建立')
}
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
this.handleMessage(message)
} catch (error) {
console.error('WebSocket消息解析错误:', error)
}
}
this.ws.onclose = (event) => {
console.log('WebSocket连接已关闭:', event.code, event.reason)
this.isConnected = false
this.stopHeartbeat()
// 连接关闭回调
this.emit('disconnected', { code: event.code, reason: event.reason })
// 自动重连
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++
console.log(`尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
setTimeout(() => {
this.connect(wsUrl, options)
}, this.reconnectInterval)
} else {
ElMessage.error('实时数据连接失败,请刷新页面重试')
}
}
this.ws.onerror = (error) => {
console.error('WebSocket连接错误:', error)
this.emit('error', error)
}
} catch (error) {
console.error('WebSocket连接初始化失败:', error)
ElMessage.error('实时数据连接失败,将使用模拟数据')
}
}
/**
* 发送消息
* @param {Object} message - 消息对象
*/
send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message))
} else {
console.warn('WebSocket未连接,消息发送失败')
}
}
/**
* 处理接收到的消息
* @param {Object} message - 消息对象
*/
handleMessage(message) {
const { type, data } = message
// 调用对应的消息处理器
if (this.messageHandlers.has(type)) {
const handlers = this.messageHandlers.get(type)
handlers.forEach(handler => {
try {
handler(data)
} catch (error) {
console.error(`消息处理器执行错误 [${type}]:`, error)
}
})
}
// 通用消息事件
this.emit('message', { type, data })
// 更新实时数据
this.updateRealTimeData(type, data)
}
/**
* 更新实时数据
* @param {string} type - 数据类型
* @param {Object} data - 数据内容
*/
updateRealTimeData(type, data) {
// 将数据存储到全局状态
if (window.realTimeData) {
window.realTimeData[type] = {
data,
timestamp: new Date().toISOString()
}
}
}
/**
* 订阅数据通道
* @param {Array} channels - 数据通道列表
*/
subscribe(channels) {
this.send({
type: 'subscribe',
channels
})
}
/**
* 获取KPI数据
*/
getKPI() {
this.send({
type: 'get-kpi'
})
}
/**
* 设置消息处理器
* @param {string} type - 消息类型
* @param {Function} handler - 处理函数
*/
on(type, handler) {
if (!this.messageHandlers.has(type)) {
this.messageHandlers.set(type, [])
}
this.messageHandlers.get(type).push(handler)
}
/**
* 移除消息处理器
* @param {string} type - 消息类型
* @param {Function} handler - 处理函数
*/
off(type, handler) {
if (this.messageHandlers.has(type)) {
const handlers = this.messageHandlers.get(type)
const index = handlers.indexOf(handler)
if (index > -1) {
handlers.splice(index, 1)
}
}
}
/**
* 触发事件
* @param {string} event - 事件名称
* @param {Object} data - 事件数据
*/
emit(event, data) {
// 这里可以使用事件总线系统
if (window.eventBus) {
window.eventBus.emit(event, data)
}
}
/**
* 开始心跳检测
*/
startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.isConnected) {
this.send({ type: 'heartbeat', timestamp: Date.now() })
}
}, 30000) // 30秒发送一次心跳
}
/**
* 停止心跳检测
*/
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
this.heartbeatInterval = null
}
}
/**
* 断开连接
*/
disconnect() {
if (this.ws) {
this.ws.close()
this.ws = null
}
this.isConnected = false
this.stopHeartbeat()
this.messageHandlers.clear()
}
}
// 创建WebSocket服务实例
const websocketService = new WebSocketService()
// 初始化全局数据
if (typeof window !== 'undefined') {
window.websocketService = websocketService
window.realTimeData = {}
}
export default websocketService
@@ -140,6 +140,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import axios from 'axios'
import {
WaterFilled,
TrendCharts,
@@ -151,6 +152,7 @@ import {
Charging,
Timer
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
// 当前时间
const currentTime = ref('')
@@ -567,6 +569,222 @@ const handleResize = () => {
// 生命周期钩子
let timeInterval: number
let ws: WebSocket = null
let isWebSocketConnected = false
// WebSocket连接
const connectWebSocket = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}/ws/water-data`
try {
ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log('WebSocket连接已建立')
isWebSocketConnected = true
ElMessage.success('实时数据连接已建立')
// 订阅实时数据
ws.send(JSON.stringify({
type: 'subscribe',
channels: [
'supply-trend',
'water-quality',
'realtime-alarm',
'device-status',
'revenue-data',
'energy-data'
]
}))
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
handleWebSocketData(data)
} catch (error) {
console.error('WebSocket消息解析错误:', error)
}
}
ws.onclose = () => {
console.log('WebSocket连接已关闭')
isWebSocketConnected = false
ElMessage.warning('实时数据连接已断开,正在重连...')
// 3秒后重连
setTimeout(connectWebSocket, 3000)
}
ws.onerror = (error) => {
console.error('WebSocket连接错误:', error)
isWebSocketConnected = false
ElMessage.error('实时数据连接错误')
}
} catch (error) {
console.error('WebSocket连接初始化失败:', error)
ElMessage.error('实时数据连接失败,将使用模拟数据')
}
}
// 处理WebSocket数据
const handleWebSocketData = (data) => {
switch (data.type) {
case 'supply-trend':
updateSupplyTrendData(data.data)
break
case 'water-quality':
updateWaterQualityData(data.data)
break
case 'realtime-alarm':
updateRealtimeAlarms(data.data)
break
case 'device-status':
updateDeviceStatusData(data.data)
break
case 'revenue-data':
updateRevenueData(data.data)
break
case 'energy-data':
updateEnergyData(data.data)
break
case 'kpi-update':
updateKPIs(data.data)
break
}
}
// 更新KPI指标
const updateKPIs = (kpiData) => {
if (kpiData.supplyTotal) {
coreMetrics.value[0].value = kpiData.supplyTotal.toLocaleString()
}
if (kpiData.waterOutput) {
coreMetrics.value[1].value = kpiData.waterOutput.toLocaleString()
}
if (kpiData.productionLossRate !== undefined) {
coreMetrics.value[2].value = kpiData.productionLossRate.toFixed(1)
coreMetrics.value[2].change = kpiData.productionLossTrend > 0 ? '+' + kpiData.productionLossTrend.toFixed(1) + '%' : kpiData.productionLossTrend.toFixed(1) + '%'
coreMetrics.value[2].trend = kpiData.productionLossTrend > 0 ? 'trend-up' : 'trend-down'
}
if (kpiData.revenueAmount) {
coreMetrics.value[3].value = kpiData.revenueAmount.toLocaleString()
}
if (kpiData.waterQualityScore !== undefined) {
coreMetrics.value[4].value = kpiData.waterQualityScore.toFixed(1)
coreMetrics.value[4].change = kpiData.waterQualityTrend > 0 ? '+' + kpiData.waterQualityTrend.toFixed(1) : kpiData.waterQualityTrend.toFixed(1)
coreMetrics.value[4].trend = kpiData.waterQualityTrend > 0 ? 'trend-up' : 'trend-down'
}
if (kpiData.alarmCount !== undefined) {
coreMetrics.value[5].value = kpiData.alarmCount
coreMetrics.value[5].change = kpiData.alarmTrend > 0 ? '+' + kpiData.alarmTrend + '%' : kpiData.alarmTrend + '%'
coreMetrics.value[5].trend = kpiData.alarmTrend > 0 ? 'trend-up' : 'trend-down'
}
}
// 更新供水趋势数据
const updateSupplyTrendData = (data) => {
if (window.charts[0]) {
window.charts[0].setOption({
series: [
{
name: '进水',
data: data.inflow
},
{
name: '出水',
data: data.outflow
}
]
})
}
}
// 更新水质数据
const updateWaterQualityData = (data) => {
if (window.charts[1]) {
window.charts[1].setOption({
series: [{
value: data.currentValues
}]
})
}
}
// 更新实时报警
const updateRealtimeAlarms = (alarms) => {
if (alarms && alarms.length > 0) {
alarms.forEach(alarm => {
realTimeAlarms.value.unshift({
id: Date.now() + Math.random(),
time: alarm.time || new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
title: alarm.title,
location: alarm.location,
level: alarm.level || 'warning',
status: alarm.status || '处理中'
})
// 只保留最新的10条
if (realTimeAlarms.value.length > 10) {
realTimeAlarms.value = realTimeAlarms.value.slice(0, 10)
}
})
}
}
// 更新设备状态
const updateDeviceStatusData = (data) => {
if (window.charts[3]) {
window.charts[3].setOption({
series: [{
data: data
}]
})
}
}
// 更新营收数据
const updateRevenueData = (data) => {
if (window.charts[4]) {
window.charts[4].setOption({
series: [{
data: data.monthlyRevenue
}]
})
}
}
// 更新能耗数据
const updateEnergyData = (data) => {
if (window.charts[5]) {
window.charts[5].setOption({
series: [{
data: data.hourlyEnergy
}]
})
}
}
// API数据获取
const fetchStaticData = async () => {
try {
// 获取最新KPI指标
const kpiResponse = await axios.get('/api/bi/kpi/current')
if (kpiResponse.data.success) {
updateKPIs(kpiResponse.data.data)
}
// 获取历史趋势数据
const trendResponse = await axios.get('/api/bi/trends/last7days')
if (trendResponse.data.success) {
// 可以用来初始化或更新图表数据
}
} catch (error) {
console.error('获取静态数据失败:', error)
}
}
onMounted(() => {
updateTime()
@@ -575,6 +793,12 @@ onMounted(() => {
nextTick(() => {
initCharts()
window.addEventListener('resize', handleResize)
// 建立WebSocket连接
connectWebSocket()
// 获取静态数据作为备用
fetchStaticData()
})
})
@@ -586,6 +810,11 @@ onUnmounted(() => {
chart && chart.dispose()
})
}
// 关闭WebSocket连接
if (ws) {
ws.close()
}
})
</script>
File diff suppressed because it is too large Load Diff