4 Commits
prod ... master

Author SHA1 Message Date
b91d073b8d feat(web-antd): 实现电表数据实时推送功能
- 新增 WebSocket 服务,用于接收实时推送的电表数据
2025-09-01 18:09:56 +08:00
ee6aa163d9 refactor(web-antd): 优化电表信息卡片的样式和内容 2025-08-31 16:16:19 +08:00
55e57eb219 feat(property): 添加水电气表当前读数功能
- 新增 currentReading 函数获取水电气表当前读数和状态
- 更新 floor-tree 组件,增加 isMeter 属性控制是否显示电表数据
- 重构 electricitySituation 页面,展示电表当前读数和状态信息
- 优化 waterSituation 页面,引入 floor-tree 组件
2025-08-31 12:48:27 +08:00
f951aeb520 refactor(web-antd): 重构电力情况页面 2025-08-31 00:40:02 +08:00
10 changed files with 502 additions and 539 deletions

View File

@@ -20,9 +20,6 @@ jobs:
- name: 安装pnpm - name: 安装pnpm
run: npm i pnpm -g run: npm i pnpm -g
- name: 配置镜像
run: git config --global url."https://".insteadOf git://
- name: 安装依赖 - name: 安装依赖
run: | run: |
git config --global url."https://".insteadOf git:// git config --global url."https://".insteadOf git://

View File

@@ -20,6 +20,8 @@ VITE_GLOB_RSA_PUBLIC_KEY=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj6
VITE_GLOB_RSA_PRIVATE_KEY=MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE= VITE_GLOB_RSA_PRIVATE_KEY=MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE=
# 客户端id # 客户端id
VITE_GLOB_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e VITE_GLOB_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e
# 开启WEBSOCKET
VITE_APP_WEBSOCKET=true
# 开启SSE # 开启SSE
VITE_GLOB_SSE_ENABLE=true VITE_GLOB_SSE_ENABLE=true

View File

@@ -26,6 +26,8 @@ VITE_GLOB_RSA_PUBLIC_KEY=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj6
VITE_GLOB_RSA_PRIVATE_KEY=MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE= VITE_GLOB_RSA_PRIVATE_KEY=MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE=
# 客户端id # 客户端id
VITE_GLOB_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e VITE_GLOB_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e
# 开启WEBSOCKET
VITE_APP_WEBSOCKET=true
# 开启SSE # 开启SSE
VITE_GLOB_SSE_ENABLE=true VITE_GLOB_SSE_ENABLE=true

View File

@@ -54,7 +54,7 @@
"echarts-gl": "^2.0.9", "echarts-gl": "^2.0.9",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"mpegts.js": "1.7.3", "mpegts.js": "^1.8.0",
"pinia": "catalog:", "pinia": "catalog:",
"tinymce": "^7.3.0", "tinymce": "^7.3.0",
"unplugin-vue-components": "^0.27.3", "unplugin-vue-components": "^0.27.3",

View File

@@ -1,6 +1,6 @@
import type { MeterInfoVO, MeterInfoForm, MeterInfoQuery } from './model' import type { MeterInfoVO, MeterInfoForm, MeterInfoQuery } from './model'
import type { ID, IDS, PageResult, TreeNode } from '#/api/common'; import type { ID, IDS, PageResult, TreeNode } from '#/api/common'
import { commonExport } from '#/api/helper' import { commonExport } from '#/api/helper'
import { requestClient } from '#/api/request' import { requestClient } from '#/api/request'
@@ -64,6 +64,13 @@ export function meterInfoRemove(id: ID | IDS) {
* @param level * @param level
* @returns 水电气树 * @returns 水电气树
*/ */
export function queryTree(meterType: number | string) { export function queryTree(params?: any) {
return requestClient.get<TreeNode<Number>[]>(`/property/meterInfo/tree/${meterType}`) return requestClient.get<TreeNode<Number>[]>(`/property/meterInfo/tree`, { params })
}
/**
* 获取水/电/气表当前读数/状态
*/
export function currentReading(params?: any) {
return requestClient.get<void>(`/property/meterInfo/currentReading`, { params })
} }

View File

@@ -0,0 +1,162 @@
import { useAccessStore } from '@vben/stores';
interface WebSocketCallbacks {
onOpen?: (event: Event) => void;
onMessage?: (event: MessageEvent) => void;
onError?: (error: Event) => void;
onClose?: (event: CloseEvent) => void;
}
export default class WebSocketService {
private webSocket: WebSocket | null = null;
private webSocketURL: string = '';
// 默认回调函数
private onOpenCallback: (event: Event) => void;
private onMessageCallback: (event: MessageEvent) => void;
private onErrorCallback: (error: Event) => void;
private onCloseCallback: (event: CloseEvent) => void;
constructor(callbacks?: WebSocketCallbacks) {
// 设置回调函数,使用自定义回调或默认回调
this.onOpenCallback = callbacks?.onOpen || this.defaultOnOpen;
this.onMessageCallback = callbacks?.onMessage || this.defaultOnMessage;
this.onErrorCallback = callbacks?.onError || this.defaultOnError;
this.onCloseCallback = callbacks?.onClose || this.defaultOnClose;
}
// 初始化WebSocket连接
initWebSocket(webSocketURL: string): void {
this.webSocketURL = webSocketURL;
try {
this.webSocket = new WebSocket(webSocketURL);
this.webSocket.onopen = this.onOpenCallback;
this.webSocket.onmessage = this.onMessageCallback;
this.webSocket.onerror = this.onErrorCallback;
this.webSocket.onclose = this.onCloseCallback;
} catch (error) {
console.error('Failed to initialize WebSocket:', error);
}
}
// 设置onOpen回调并更新WebSocket事件处理器
setOnOpenCallback(callback: (event: Event) => void): void {
this.onOpenCallback = callback;
if (this.webSocket) {
this.webSocket.onopen = this.onOpenCallback;
}
}
// 设置onMessage回调并更新WebSocket事件处理器
setOnMessageCallback(callback: (event: MessageEvent) => void): void {
this.onMessageCallback = callback;
if (this.webSocket) {
this.webSocket.onmessage = this.onMessageCallback;
}
}
// 设置onError回调并更新WebSocket事件处理器
setOnErrorCallback(callback: (error: Event) => void): void {
this.onErrorCallback = callback;
if (this.webSocket) {
this.webSocket.onerror = this.onErrorCallback;
}
}
// 设置onClose回调并更新WebSocket事件处理器
setOnCloseCallback(callback: (event: CloseEvent) => void): void {
this.onCloseCallback = callback;
if (this.webSocket) {
this.webSocket.onclose = this.onCloseCallback;
}
}
// 默认连接建立成功的回调
private defaultOnOpen(event: Event): void {
console.log('WebSocket连接建立成功', event);
}
// 默认收到服务器消息的回调
private defaultOnMessage(event: MessageEvent): void {
console.log('收到服务器消息', event.data);
// 通常这里会解析数据并更新页面
// const data = JSON.parse(event.data);
// 根据消息类型处理不同业务...
}
// 默认发生错误的回调
private defaultOnError(error: Event): void {
console.error('WebSocket连接错误', error);
}
// 默认连接关闭的回调
private defaultOnClose(event: CloseEvent): void {
console.log('WebSocket连接关闭', event);
}
// 关闭连接
close(): void {
if (this.webSocket) {
this.webSocket.close();
this.webSocket = null;
}
}
// 发送消息
sendMessage(message: string | object): void {
if (this.webSocket && this.webSocket.readyState === WebSocket.OPEN) {
const dataToSend = typeof message === 'string' ? message : JSON.stringify(message);
this.webSocket.send(dataToSend);
} else {
console.error('WebSocket连接未就绪');
}
}
// 获取当前WebSocket状态
getReadyState(): number | null {
return this.webSocket ? this.webSocket.readyState : null;
}
// 获取WebSocket URL
getWebSocketURL(): string {
return this.webSocketURL;
}
// 重新连接
reconnect(): void {
if (this.webSocketURL) {
this.close();
this.initWebSocket(this.webSocketURL);
}
}
}
// 创建一个可导出的WebSocket实例
let globalWebSocketService: WebSocketService | null = null;
/**
* 初始化WebSocket连接的可导出方法
* @param url WebSocket服务器地址
* @param callbacks 回调函数对象(可选)
* @returns WebSocketService实例
*/
export function initWebSocket(callbacks?: WebSocketCallbacks): void {
if (!globalWebSocketService) {
globalWebSocketService = new WebSocketService(callbacks);
}
const accessStore = useAccessStore();
const clinetId = import.meta.env.VITE_GLOB_APP_CLIENT_ID;
const api = import.meta.env.VITE_GLOB_API_URL;
const host = window.location.host;
const url = `ws://${host}${api}/resource/websocket?clientid=${clinetId}&Authorization=Bearer ${accessStore.accessToken}`;
globalWebSocketService.initWebSocket(url);
}
/**
* 获取全局WebSocket服务实例
* @returns WebSocketService实例或null
*/
export function getWebSocketService(): WebSocketService | null {
return globalWebSocketService;
}

View File

@@ -1,30 +1,31 @@
import { createApp, watchEffect } from 'vue'; import { createApp, watchEffect } from 'vue'
import { registerAccessDirective } from '@vben/access'; import { registerAccessDirective } from '@vben/access'
import { registerLoadingDirective } from '@vben/common-ui/es/loading'; import { registerLoadingDirective } from '@vben/common-ui/es/loading'
import { preferences } from '@vben/preferences'; import { preferences } from '@vben/preferences'
import { initStores } from '@vben/stores'; import { initStores } from '@vben/stores'
import '@vben/styles'; import '@vben/styles'
import '@vben/styles/antd'; import '@vben/styles/antd'
import { useTitle } from '@vueuse/core'; import { useTitle } from '@vueuse/core'
import { setupGlobalComponent } from '#/components/global'; import { setupGlobalComponent } from '#/components/global'
import { $t, setupI18n } from '#/locales'; import { $t, setupI18n } from '#/locales'
import { initComponentAdapter } from './adapter/component'; import { initComponentAdapter } from './adapter/component'
import { initSetupVbenForm } from './adapter/form'; import { initSetupVbenForm } from './adapter/form'
import App from './app.vue'; import App from './app.vue'
import { router } from './router'; import { router } from './router'
import { initWebSocket } from '#/api/websocket'
async function bootstrap(namespace: string) { async function bootstrap(namespace: string) {
// 初始化组件适配器 // 初始化组件适配器
await initComponentAdapter(); await initComponentAdapter()
// 初始化表单组件 // 初始化表单组件
await initSetupVbenForm(); await initSetupVbenForm()
// // 设置弹窗的默认配置 // // 设置弹窗的默认配置
// setDefaultModalProps({ // setDefaultModalProps({
@@ -35,49 +36,53 @@ async function bootstrap(namespace: string) {
// zIndex: 1020, // zIndex: 1020,
// }); // });
const app = createApp(App);
const app = createApp(App)
// 全局组件 // 全局组件
setupGlobalComponent(app); setupGlobalComponent(app)
// 注册v-loading指令 // 注册v-loading指令
registerLoadingDirective(app, { registerLoadingDirective(app, {
loading: 'loading', // 在这里可以自定义指令名称也可以明确提供false表示不注册这个指令 loading: 'loading', // 在这里可以自定义指令名称也可以明确提供false表示不注册这个指令
spinning: 'spinning', spinning: 'spinning',
}); })
// 国际化 i18n 配置 // 国际化 i18n 配置
await setupI18n(app); await setupI18n(app)
// 配置 pinia-tore // 配置 pinia-tore
await initStores(app, { namespace }); await initStores(app, { namespace })
// 初始化WebSocket
initWebSocket()
// 安装权限指令 // 安装权限指令
registerAccessDirective(app); registerAccessDirective(app)
// 初始化 tippy // 初始化 tippy
const { initTippy } = await import('@vben/common-ui/es/tippy'); const { initTippy } = await import('@vben/common-ui/es/tippy')
initTippy(app); initTippy(app)
// 配置路由及路由守卫 // 配置路由及路由守卫
app.use(router); app.use(router)
// 配置Motion插件 // 配置Motion插件
const { MotionPlugin } = await import('@vben/plugins/motion'); const { MotionPlugin } = await import('@vben/plugins/motion')
app.use(MotionPlugin); app.use(MotionPlugin)
// 动态更新标题 // 动态更新标题
watchEffect(() => { watchEffect(() => {
if (preferences.app.dynamicTitle) { if (preferences.app.dynamicTitle) {
const routeTitle = router.currentRoute.value.meta?.title; const routeTitle = router.currentRoute.value.meta?.title
const pageTitle = const pageTitle =
(routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name; (routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name
useTitle(pageTitle); useTitle(pageTitle)
} }
}); })
app.mount('#app'); app.mount('#app')
} }
export { bootstrap }; export { bootstrap }

View File

@@ -1,14 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PropType } from "vue"; import type { PropType } from 'vue';
import { onMounted, ref } from "vue"; import { onMounted, ref } from 'vue';
import { handleNode } from "@vben/utils"; import { handleNode } from '@vben/utils';
import { Empty, Skeleton, Tree } from "ant-design-vue"; import { Empty, Skeleton, Tree } from 'ant-design-vue';
import { queryTree } from "#/api/property/energyManagement/meterInfo"; import { queryTree } from '#/api/property/energyManagement/meterInfo';
import type { TreeNode } from "#/api/common"; import type { TreeNode } from '#/api/common';
defineOptions({ inheritAttrs: false }); defineOptions({ inheritAttrs: false });
withDefaults(defineProps<{ showSearch?: boolean }>(), { showSearch: true }); const props = defineProps({
isMeter: {
type: Boolean,
default: true,
},
});
const emit = defineEmits<{ const emit = defineEmits<{
/** /**
@@ -23,13 +28,13 @@ const emit = defineEmits<{
select: [selectedKeys: string[], info: any]; select: [selectedKeys: string[], info: any];
}>(); }>();
const selectTreeId = defineModel("selectTreeId", { const selectTreeId = defineModel('selectTreeId', {
type: Array as PropType<string[]>, type: Array as PropType<string[]>,
}); });
const searchValue = defineModel("searchValue", { const searchValue = defineModel('searchValue', {
type: String, type: String,
default: "", default: '',
}); });
const treeArray = ref<TreeNode[]>([]); const treeArray = ref<TreeNode[]>([]);
@@ -38,8 +43,8 @@ const showTreeSkeleton = ref<boolean>(true);
async function loadTree() { async function loadTree() {
showTreeSkeleton.value = true; showTreeSkeleton.value = true;
searchValue.value = ""; searchValue.value = '';
const ret = await queryTree(1); const ret = await queryTree({ meterType: 1, isMeter: props.isMeter });
handleNode(ret, 3); handleNode(ret, 3);
treeArray.value = ret; treeArray.value = ret;
showTreeSkeleton.value = false; showTreeSkeleton.value = false;
@@ -62,33 +67,46 @@ onMounted(loadTree);
<template> <template>
<div :class="$attrs.class"> <div :class="$attrs.class">
<Skeleton :loading="showTreeSkeleton" <Skeleton
:paragraph="{ rows: 8 }" :loading="showTreeSkeleton"
active :paragraph="{ rows: 8 }"
class="p-[8px] flex-1 min-h-0"> active
<div class="bg-background flex h-full flex-col overflow-y-auto rounded-lg"> class="min-h-0 flex-1 p-[8px]"
>
<div
class="bg-background flex h-full flex-col overflow-y-auto rounded-lg"
>
<div class="h-full overflow-x-hidden px-[8px]"> <div class="h-full overflow-x-hidden px-[8px]">
<Tree v-bind="$attrs" <Tree
v-if="treeArray.length > 0" v-bind="$attrs"
v-model:selected-keys="selectTreeId" v-if="treeArray.length > 0"
:show-line="{ showLeafIcon: false }" v-model:selected-keys="selectTreeId"
:tree-data="treeArray" :show-line="{ showLeafIcon: false }"
:virtual="false" :tree-data="treeArray"
default-expand-all :virtual="false"
@select="(selectedKeys, info) => $emit('select', selectedKeys, info)"> default-expand-all
@select="
(selectedKeys, info) => $emit('select', selectedKeys, info)
"
>
<template #title="{ label }"> <template #title="{ label }">
<span v-if="label.indexOf(searchValue) > -1"> <span v-if="label.indexOf(searchValue) > -1">
{{ label.substring(0, label.indexOf(searchValue)) }} {{ label.substring(0, label.indexOf(searchValue)) }}
<span style="color: #f50">{{ searchValue }}</span> <span style="color: #f50">{{ searchValue }}</span>
{{ label.substring(label.indexOf(searchValue) + searchValue.length) }} {{
label.substring(
label.indexOf(searchValue) + searchValue.length,
)
}}
</span> </span>
<span v-else>{{ label }}</span> <span v-else>{{ label }}</span>
</template> </template>
</Tree> </Tree>
<div v-else <div v-else class="mt-5">
class="mt-5"> <Empty
<Empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据" /> description="暂无数据"
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,516 +1,286 @@
<script setup lang="ts"> <script setup lang="ts">
import { RadioGroup, RadioButton, message } from 'ant-design-vue'; import { useAccessStore } from '@vben/stores';
import { Page } from '@vben/common-ui'; import { BackTop, message, Spin } from 'ant-design-vue';
import { Page, VbenCountToAnimator } from '@vben/common-ui';
import { ref, onMounted, onBeforeUnmount, reactive } from 'vue'; import { ref, onMounted, onBeforeUnmount, reactive } from 'vue';
import * as echarts from 'echarts';
import type { ECharts, EChartsOption } from 'echarts';
import FloorTree from '../components/floor-tree.vue'; import FloorTree from '../components/floor-tree.vue';
import dayjs from 'dayjs'; import { getWebSocketService } from '#/api/websocket';
import { meterRecordTrend } from '#/api/property/energyManagement/meterRecord'; import { currentReading } from '#/api/property/energyManagement/meterInfo';
// 左边楼层用 const ws = getWebSocketService();
const selectFloorId = ref<string[]>([]);
const chainData = reactive({ if (ws) {
todayEnergy: '231.78', // 使用setOnMessageCallback方法设置消息回调
yesterdaySamePeriodEnergy: '269.56', ws.setOnMessageCallback((event: MessageEvent) => {
dayTrendPercentage: '-14.02%', // 解析数据并更新UI
dayTrendValue: '-37.78', try {
currentMonthEnergy: '18758.39', const data = JSON.parse(event.data);
lastMonthSamePeriodEnergy: '--', if (data.type === 'meter') {
monthTrendPercentage: '--', if (typeof data.data === 'undefined') {
monthTrendValue: '--', message.warn('当前楼层暂无电表!');
currentYearEnergy: '18758.39', }
lastYearSamePeriodEnergy: '--', readingData.value = data.data;
yearTrendPercentage: '--', readingTime.value = data.readingTime;
yearTrendValue: '--', }
}); } catch (e) {
console.error('Error parsing data:');
const peakData = reactive({
todayPeakPower: '2961.08',
todayPeakTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
yesterdayPeakPower: '2993.89',
yesterdayPeakTime: dayjs().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss'),
});
const energyTrendTime = ref('1');
// 能耗趋势图表容器
const energyTrendChart = ref<HTMLElement | null>(null);
const energyTrendInstance = ref<ECharts | null>(null);
const energyTrendOption: EChartsOption = {
tooltip: {
trigger: 'item',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'category',
data: [],
name: '时间',
},
yAxis: {
type: 'value',
name: 'kW.h',
},
series: [
{
data: [],
type: 'bar',
markPoint: {
data: [
{ type: 'max', name: 'Max' },
{ type: 'min', name: 'Min' },
],
},
},
],
};
const initEnergyTrendChart = () => {
if (energyTrendChart.value) {
// 销毁旧实例
energyTrendInstance.value?.dispose();
// 创建新实例
energyTrendInstance.value = echarts.init(energyTrendChart.value);
// 设置配置项
energyTrendInstance.value.setOption(energyTrendOption);
// buildingEnergyTrendData('1');
// 可选:添加窗口大小变化监听
const resizeHandler = () => {
energyTrendInstance.value?.resize();
};
window.addEventListener('resize', resizeHandler);
// 在组件卸载前移除监听
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeHandler);
});
}
};
function buildingEnergyTrendData(val: string) {
if (trendData.value.hour == null) {
message.warning('请先选择楼层或电表!');
return;
}
const now = new Date();
let timeArr = [];
let valArr = [];
let name = '时间';
if (val == '1') {
const hour = now.getHours();
for (let i = 0; i < hour; i++) {
timeArr.push(i + ':00');
} }
valArr = trendData.value.hour.today.data; readingLoading.value = false;
} else if (val == '2') { });
const day = now.getDate();
for (let i = 1; i < day; i++) {
timeArr.push(i);
}
name = '日期';
valArr = trendData.value.day.nowMonth.data;
} else {
const month = now.getMonth() + 1;
for (let i = 1; i < month + 1; i++) {
timeArr.push(i);
}
name = '月份';
valArr = trendData.value.month.nowYear.data;
}
if (energyTrendInstance.value) { // 如果需要,也可以设置错误回调
energyTrendInstance.value.setOption({ ws.setOnErrorCallback((error: any) => {
xAxis: { data: timeArr, name }, console.log('Error in WebSocket:');
series: [{ data: valArr }], currentReading({ meterType: 0, floorId: 0 });
}); readingLoading.value = false;
} });
} }
//日用电率
const powerCurveChart = ref<HTMLElement | null>(null);
const powerCurveInstance = ref<ECharts | null>(null);
const powerCurveOption: EChartsOption = {
tooltip: {
trigger: 'item',
axisPointer: {
type: 'cross',
crossStyle: {
color: '#999',
},
},
},
toolbox: {
feature: {
magicType: { show: true, type: ['line', 'bar'] },
restore: { show: true },
},
},
legend: {
data: ['今日', '昨日'],
},
xAxis: [
{
type: 'category',
data: [
'0:00',
'1:00',
'2:00',
'3:00',
'4:00',
'5:00',
'6:00',
'7:00',
'8:00',
'9:00',
'10:00',
'11:00',
'12:00',
'13:00',
'14:00',
'15:00',
'16:00',
'17:00',
'18:00',
'19:00',
'20:00',
'21:00',
'22:00',
'23:00',
],
axisPointer: {
type: 'shadow',
},
name: '时',
},
],
yAxis: [
{
type: 'value',
name: 'kW',
},
],
series: [
{
name: '昨日',
type: 'line',
smooth: true,
data: [],
markPoint: {
data: [{ type: 'max' }, { type: 'min' }],
},
itemStyle: { color: '#cbb0e3' }, // 数据点颜色
lineStyle: { color: '#cbb0e3' }, // 线条颜色
},
],
};
const initPowerCurveChart = () => {
if (powerCurveChart.value) {
// 销毁旧实例
powerCurveInstance.value?.dispose();
// 创建新实例
powerCurveInstance.value = echarts.init(powerCurveChart.value);
// 设置配置项
powerCurveInstance.value.setOption(powerCurveOption);
// 可选:添加窗口大小变化监听
const resizeHandler = () => {
powerCurveInstance.value?.resize();
};
window.addEventListener('resize', resizeHandler);
// 在组件卸载前移除监听
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeHandler);
});
}
};
// 组件挂载后初始化图表
onMounted(() => {
initEnergyTrendChart();
initPowerCurveChart();
});
// 组件卸载前销毁图表实例
onBeforeUnmount(() => { onBeforeUnmount(() => {
energyTrendInstance.value?.dispose(); currentReading({ meterType: 0, floorId: 0 });
powerCurveInstance.value?.dispose();
}); });
const trendData = ref<any>({}); const readingData = ref<any>({});
const readingTime = ref('');
let readingLoading = ref(false);
async function handleSelectFloor(selectedKeys, info) { async function handleSelectFloor(selectedKeys, info) {
const now = new Date(); if (typeof selectedKeys[0] === 'undefined') {
// 获取年、月、日 return;
const year = now.getFullYear(); }
// 月份从0开始所以要+1并格式化为两位数 if (ws.webSocket.readyState !== 1) {
const month = String(now.getMonth() + 1).padStart(2, '0'); message.warn('websocket未连接请刷新页面重试');
// 日期格式化为两位数 return;
const day = String(now.getDate()).padStart(2, '0'); }
readingLoading.value = true;
let data = { await currentReading({
day: year + '-' + month + '-' + day,
month: year + '-' + month,
year: year,
meterType: 1, meterType: 1,
meterId: null, floorId: selectedKeys[0],
floorId: null, });
}; }
if (info.node.level == 3) { function targetFn() {
data.floorId = selectedKeys[0]; return document.getElementById('right-panel');
} else {
data.meterId = selectedKeys[0];
}
const trend = await meterRecordTrend(data);
trendData.value = trend;
// 趋势曲线
let timeArr = [];
let valArr = [];
let name = '时间';
if (energyTrendTime.value == '1') {
const hour = now.getHours();
for (let i = 0; i < hour; i++) {
timeArr.push(i + ':00');
}
valArr = trend.hour.today.data;
} else if (energyTrendTime.value == '2') {
const day = now.getDate();
for (let i = 1; i < day; i++) {
timeArr.push(i);
}
name = '日期';
valArr = trend.day.nowMonth.data;
} else {
const month = now.getMonth() + 1;
for (let i = 1; i < month + 1; i++) {
timeArr.push(i);
}
name = '月份';
valArr = trend.month.nowYear.data;
}
if (energyTrendInstance.value) {
energyTrendInstance.value.setOption({
xAxis: { data: timeArr, name },
series: [{ data: valArr }],
});
}
if (powerCurveInstance.value) {
powerCurveInstance.value.setOption({
series: [
{
name: '今日',
type: 'line',
smooth: true,
data: trend.hour.today.data,
markPoint: {
data: [{ type: 'max' }, { type: 'min' }],
},
},
{
name: '昨日',
type: 'line',
smooth: true,
data: trend.hour.yesterday.data,
markPoint: {
data: [{ type: 'max' }, { type: 'min' }],
},
},
],
});
}
} }
</script> </script>
<template> <template>
<Page :auto-content-height="true"> <Page :auto-content-height="true">
<div class="flex h-full gap-[8px]"> <div class="flex h-full gap-[8px]">
<FloorTree class="w-[260px]" @select="handleSelectFloor"></FloorTree> <FloorTree
<div class="flex-1 overflow-hidden"> :isMeter="false"
<div class="row"> class="w-[260px]"
<div class="energy-trend-container"> @select="handleSelectFloor"
<div class="energy-trend-top"> ></FloorTree>
<div class="section-header"> <div class="flex-1" id="right-panel">
<div class="header-title">能耗趋势</div> <Spin :spinning="readingLoading" size="large" style="height: 100px">
<div class="cards-container">
<div v-for="item in readingData" class="meterInfo-card">
<h2>
{{ item.meterName }}
</h2>
<div class="meterInfo-reading">
<p>
<VbenCountToAnimator
:end-val="item.initReading"
:decimals="2"
prefix=""
/>
</p>
<div></div>
</div>
<div class="meterInfo-list">
<ul>
<li>
<span>采集时间</span>
<span>{{ readingTime }}</span>
</li>
<li>
<span>设备位置</span>
<span>{{ item.installLocation }}</span>
</li>
</ul>
</div>
<div class="button-get-plan">
<a
v-if="item.communicationState !== 0"
style="background: #6bb1e3"
>
<span>在线</span>
</a>
<a v-else style="background: orange">
<span>离线</span>
</a>
</div> </div>
<RadioGroup
v-model:value="energyTrendTime"
button-style="solid"
size="small"
@change="buildingEnergyTrendData(energyTrendTime)"
>
<RadioButton value="1">当日</RadioButton>
<RadioButton value="2">当月</RadioButton>
<RadioButton value="3">当年</RadioButton>
</RadioGroup>
</div> </div>
<div class="chart-placeholder" ref="energyTrendChart"></div>
</div> </div>
</div> </Spin>
<div class="row">
<div class="power-curve-container">
<div class="section-header">
<div class="header-title">平均电功率曲线</div>
</div>
<div class="power-chart" ref="powerCurveChart"></div>
</div>
<!-- <div class="power-peak-container">
<div class="section-header">
<div class="header-title">电功率峰值</div>
</div>
<div class="peak-item">
<p class="value">{{ peakData.todayPeakPower }}</p>
<p class="time">{{ peakData.todayPeakTime }}</p>
<div class="bottom-text">当日(kW)</div>
</div>
<div class="peak-item">
<p class="value">{{ peakData.yesterdayPeakPower }}</p>
<p class="time">{{ peakData.yesterdayPeakTime }}</p>
<div class="bottom-text">昨日(kW)</div>
</div>
</div> -->
</div>
</div> </div>
<BackTop class="back-to-top" :target="targetFn"></BackTop>
</div> </div>
</Page> </Page>
</template> </template>
<style scoped> <style scoped>
.row { /* 右侧内容区域样式 */
display: flex; .flex-1 {
gap: 1rem;
width: 100%;
}
.comparison-section-container {
flex: 2;
}
.energy-trend-container {
flex: 3;
}
.power-curve-container {
margin-top: 10px;
flex: 5;
}
.power-peak-container {
margin-top: 10px;
flex: 1; flex: 1;
overflow-y: auto; /* 添加垂直滚动条 */
padding: 10px;
height: 100%; /* 确保高度占满父容器 */
box-sizing: border-box; /* 包括padding在高度计算中 */
} }
.energy-trend-top { /* 卡片容器样式 */
.cards-container {
display: flex; display: flex;
justify-content: space-between; flex-wrap: wrap;
justify-content: flex-start;
gap: 20px; /* 使用gap替代margin控制间距 */
} }
.section-header { .meterInfo-card {
border-left: 4px solid #3671e8;
margin-bottom: 15px;
padding-left: 16px;
}
.header-title {
font-size: 16px;
color: #333;
}
.comparison-section-container,
.energy-trend-container,
.power-curve-container,
.power-peak-container {
background: #fff; background: #fff;
width: 15rem;
padding-left: 2rem;
padding-right: 2rem;
padding-top: 10px;
padding-bottom: 20px;
border-radius: 10px;
border-bottom: 4px solid #87ceeb;
box-shadow: 0 6px 30px rgba(173, 216, 230, 0.3);
font-family: 'Poppins', sans-serif;
height: 270px;
margin: 0 5px 20px 5px;
}
.meterInfo-card h2 {
margin-bottom: 15px;
font-size: 27px;
color: #5faee3;
font-weight: 600;
}
.meterInfo-card h2 span {
display: block;
margin-top: -4px;
color: #7eb8da;
font-size: 12px;
font-weight: 400;
}
.meterInfo-reading {
position: relative;
background: #f0f8ff;
width: 14.46rem;
margin-left: -0.65rem;
padding: 0.2rem 1.2rem;
border-radius: 5px 0 0 5px;
}
.meterInfo-reading p {
margin: 0;
padding-top: 0.4rem;
display: flex;
font-size: 1.9rem;
font-weight: 500;
color: #4a8cbb;
}
.meterInfo-reading p:before {
content: '';
margin-right: 5px;
font-size: 15px;
font-weight: 300;
}
.meterInfo-reading p:after {
content: '/ KW.H';
margin-left: 5px;
font-size: 15px;
font-weight: 300;
}
.meterInfo-reading div {
position: absolute;
bottom: -23px;
right: 0px;
width: 0;
height: 0;
border-top: 13px solid #87ceeb;
border-bottom: 10px solid transparent;
border-right: 13px solid transparent;
z-index: 1;
}
.meterInfo-list {
margin-top: 16px;
}
.meterInfo-list ul {
padding: 0;
font-size: 14px;
}
.meterInfo-list ul li {
color: #5a7d9a;
list-style: none;
margin-bottom: 0.2rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.meterInfo-list ul li svg {
width: 0.9rem;
fill: currentColor;
}
.meterInfo-list ul li span {
font-weight: 300;
white-space: nowrap;
}
.button-get-plan {
display: flex;
justify-content: center;
margin-top: 1.2rem;
}
.button-get-plan a {
display: flex;
justify-content: center;
align-items: center;
color: #fff;
padding: 10px 15px;
border-radius: 5px; border-radius: 5px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); text-decoration: none;
padding: 1rem; font-size: 0.8rem;
letter-spacing: 0.05rem;
font-weight: 500;
transition: all 0.3s ease;
} }
.comparison-grid { .button-get-plan a:hover {
display: grid; transform: translateY(-3%);
grid-template-columns: repeat(3, 1fr); box-shadow: 0 3px 10px rgba(123, 180, 220, 0.6); /* 浅蓝色阴影 */
gap: 5px; background: #5fa0d0; /* 悬停时略深的蓝色 */
} }
.comparison-item { .button-get-plan .svg-rocket {
padding: 5px 10px; margin-right: 10px;
border: 1px solid #e0e0e0; width: 0.9rem;
text-align: center; fill: currentColor;
} }
.item-value { .back-to-top {
font-size: 22px; width: 50px;
color: #333; height: 50px;
margin-bottom: 10px;
} }
.item-title { /* 返回顶部按钮 */
font-size: 12px; .back-to-top:hover {
color: #666; background-color: #6bb1e3;
} transform: translateY(-5px);
.item-top {
font-size: 16px;
margin-bottom: 10px;
}
.item-percent {
color: #7fb926;
margin-bottom: 5px;
border-bottom: 1px solid #666;
}
.item-unit {
font-size: 12px;
color: #999;
margin-left: 3px;
}
.chart-placeholder {
height: 36vh;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.power-chart {
height: 38vh;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.peak-item {
padding: 15px 0 0 0;
border: 1px solid #3671e8;
text-align: center;
margin-bottom: 1rem;
.bottom-text {
background-color: #3671e8;
color: white;
line-height: 40px;
}
}
.peak-item .value {
font-size: 22px;
color: #333;
}
.peak-item .time {
font-size: 12px;
color: #999;
margin: 5px 0;
} }
</style> </style>

View File

@@ -81,7 +81,7 @@ import { Page } from '@vben/common-ui'
import { ref, onMounted, onBeforeUnmount, reactive } from 'vue' import { ref, onMounted, onBeforeUnmount, reactive } from 'vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts' import type { ECharts, EChartsOption } from 'echarts'
import FloorTree from "../../electricEnergy/components/floor-tree.vue" import FloorTree from "../components/floor-tree.vue"
const chainData = reactive({ const chainData = reactive({
currentMonthEnergy: '9', currentMonthEnergy: '9',