feat(性能优化): 实现图表渲染按帧拆分以提升主线程响应

refactor(位移看板): 移除多指标切换逻辑并收敛为位移专属

docs(告警规则): 增加回差和持续秒数的详细说明与提示

perf(图表渲染): 引入renderInFrames工具实现渐进式渲染

fix(阈值配置): 移除告警级别字段避免与规则页重复维护

style(代码结构): 清理位移看板中残留的多指标相关代码

chore(类型定义): 更新DisplacementBoardQuery类型定义

test(组件测试): 移除平行坐标图相关测试用例
main
zch 2 months ago
parent d38576a1be
commit 0b1d64bc05

@ -133,9 +133,23 @@ export interface VibrationBoardQuery extends EmsQuery {
/**
*
* vibrationParam vibrationDisplacement
*
* <p> VibrationBoardQuery vibrationParam
* DisplacementBoardQueryBo
* vibrationParam </p>
*/
export interface DisplacementBoardQuery extends VibrationBoardQuery {}
export interface DisplacementBoardQuery extends EmsQuery {
monitorId?: EmsId | string;
monitorIds?: Array<EmsId> | string;
samplingInterval?: number | string;
highThreshold?: EmsDecimalValue;
warningThreshold?: EmsDecimalValue;
minContinuousSamples?: EmsId | string;
rapidRiseThreshold?: EmsDecimalValue;
stddevThreshold?: EmsDecimalValue;
beginRecordTime?: EmsDateValue;
endRecordTime?: EmsDateValue;
}
/**
*
@ -302,7 +316,19 @@ export interface EmsEntity extends BaseEntity {
alarmLower?: EmsDecimalValue;
recoverUpper?: EmsDecimalValue;
recoverLower?: EmsDecimalValue;
/**
* Hysteresis / 线/
* (alarmUpper - hysteresis) (alarmLower + hysteresis)
* alarmUpper = 100hysteresis = 5 100 95
* null 0
*/
hysteresis?: EmsDecimalValue;
/**
* Duration Seconds
* durationSec
* durationSec = 30 28 30 30
* null 0
*/
durationSec?: EmsId;
notifyGroupId?: EmsId;
remark?: string;
@ -676,7 +702,17 @@ export interface EmsRecordAlarmRuleVO extends EmsEntity {
alarmLower?: EmsDecimalValue;
recoverUpper?: EmsDecimalValue;
recoverLower?: EmsDecimalValue;
/**
* Hysteresis / alarmUpper / alarmLower 线/
* alarmUpper = 100hysteresis = 5 100 95
* null 0
*/
hysteresis?: EmsDecimalValue;
/**
* Duration Seconds
* durationSec = 30 30
* null 0
*/
durationSec?: EmsId;
alarmLevel?: string;
notifyGroupId?: EmsId;
@ -730,7 +766,17 @@ export interface MonitorMetricThresholdVO extends EmsEntity {
warnLower?: EmsDecimalValue;
alarmUpper?: EmsDecimalValue;
alarmLower?: EmsDecimalValue;
/**
* Hysteresis / 线/
* alarmUpper = 100hysteresis = 5 100 95
* null 0
*/
hysteresis?: EmsDecimalValue;
/**
* Duration Seconds
* durationSec = 30 30
* null 0
*/
durationSec?: EmsId;
alarmLevel?: string;
notifyGroupId?: EmsId;

@ -112,7 +112,8 @@
<el-table-column label="告警下限" align="center" prop="alarmLower" min-width="110" />
<el-table-column label="回差值" align="center" prop="hysteresis" min-width="100" />
<el-table-column label="持续触发秒数" align="center" prop="durationSec" min-width="120" />
<el-table-column label="告警级别" align="center" prop="alarmLevel" min-width="110" />
<!-- 告警级别暂时停用统一阈值页不再展示该字段避免和告警规则页形成重复维护 -->
<!-- <el-table-column label="告警级别" align="center" prop="alarmLevel" min-width="110" /> -->
<el-table-column label="启用状态" align="center" width="110">
<template #default="scope">
<dict-tag :options="sysNormalDisableOptions" :value="scope.row.isEnable" />
@ -186,17 +187,55 @@
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="回差值" prop="hysteresis">
<el-form-item prop="hysteresis">
<template #label>
<span class="field-label-with-hint">
回差值
<el-tooltip placement="top" effect="dark">
<template #content>
<div class="field-tooltip-content">
<p><strong>回差值Hysteresis / 死区</strong></p>
<p>在告警阈值附近设置的"缓冲区间"防止测量值在阈值线上抖动导致告警反复触发/恢复告警抖动</p>
<p>规则越界后只有回落到 (告警上限 回差) 以下 (告警下限 + 回差) 以上才判定为恢复</p>
<p>示例告警上限 = 100回差 = 5 100 触发告警只有值 95 才视为恢复</p>
<p>单位与被测量一致留空或 0 表示不启用回差保护</p>
</div>
</template>
<el-icon class="field-hint-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<el-input-number v-model="form.hysteresis" :precision="4" controls-position="right" style="width: 100%" />
<div class="field-hint">防止告警抖动如告警上限=100回差=5 95 才视为恢复</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="持续触发秒数" prop="durationSec">
<el-form-item prop="durationSec">
<template #label>
<span class="field-label-with-hint">
持续触发秒数
<el-tooltip placement="top" effect="dark">
<template #content>
<div class="field-tooltip-content">
<p><strong>持续触发秒数Duration Seconds</strong></p>
<p>测量值必须<strong>连续</strong>越界达到该秒数才会正式触发告警用于过滤瞬时尖峰造成的误报</p>
<p>规则越界计时累计 该秒数才落库并推送中途恢复则计时清零</p>
<p>示例持续秒数 = 30 连续 28 秒越界后回落不告警连续 30 秒越界在第 30 秒触发告警</p>
<p>单位留空或 0 表示不延迟单采样越界即触发</p>
</div>
</template>
<el-icon class="field-hint-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<el-input-number v-model="form.durationSec" :min="0" :step="1" controls-position="right" style="width: 100%" />
<div class="field-hint">过滤瞬时尖峰需连续越界该秒数才触发告警</div>
</el-form-item>
</el-col>
</el-row>
<!-- 告警级别暂时停用当前阈值配置只保留真正参与阈值治理的字段减少无效录入 -->
<!--
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="告警级别" prop="alarmLevel">
@ -204,6 +243,7 @@
</el-form-item>
</el-col>
</el-row>
-->
<el-form-item label="启用状态" prop="isEnable">
<el-radio-group v-model="form.isEnable">
@ -228,6 +268,7 @@
<script setup lang="ts">
import { getCurrentInstance } from 'vue';
import { QuestionFilled } from '@element-plus/icons-vue';
import { useDict } from '@/utils/dict';
import {
addMonitorMetricThreshold,
@ -334,8 +375,6 @@ const initFormData: MonitorMetricThresholdForm = {
hysteresis: undefined,
durationSec: undefined,
alarmLevel: undefined,
//
// notifyGroupId: undefined,
isEnable: '0',
remark: undefined
};
@ -354,8 +393,6 @@ const data = reactive<PageData<MonitorMetricThresholdForm, MonitorMetricThreshol
hysteresis: undefined,
durationSec: undefined,
alarmLevel: undefined,
//
// notifyGroupId: undefined,
isEnable: undefined,
params: {}
},
@ -719,6 +756,46 @@ onMounted(async () => {
background: linear-gradient(180deg, #f8fcfc 0%, #ffffff 100%);
}
.field-label-with-hint {
display: inline-flex;
align-items: center;
gap: 4px;
}
.field-hint-icon {
color: #14b8a6;
cursor: help;
font-size: 14px;
&:hover {
color: #0f766e;
}
}
.field-hint {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
margin-top: 4px;
}
.field-tooltip-content {
max-width: 320px;
line-height: 1.65;
p {
margin: 0 0 6px;
}
p:last-child {
margin-bottom: 0;
}
strong {
color: #fdd08a;
}
}
.main-cell {
line-height: 20px;
font-weight: 600;

@ -37,11 +37,6 @@
<el-option v-for="dict in dict.type.is_flag" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="通知组" prop="notifyGroupId">
<el-select v-model="queryParams.notifyGroupId" placeholder="请选择通知组" clearable filterable style="width: 220px">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item class="query-actions">
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery"></el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery"></el-button>
@ -58,14 +53,6 @@
<span class="panel-badge warm">措施联动</span>
</div>
<div class="notify-entry-bar">
<div class="notify-entry-text">通知组与通知成员的配置入口已经接回规则页保存时会同步回填通知对象摘要避免规则挂空通知链</div>
<div class="notify-entry-actions">
<el-button link type="primary" @click="openNotifyGroupPage"></el-button>
<el-button link type="primary" @click="openNotifyUserPage"></el-button>
</div>
</div>
<el-row :gutter="10" class="toolbar-row">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['ems/record:recordAlarmRule:add']"
@ -125,7 +112,8 @@
<el-table-column label="恢复下限" align="center" prop="recoverLower" v-if="columns[8].visible" />
<el-table-column label="回差" align="center" prop="hysteresis" v-if="columns[9].visible" />
<el-table-column label="持续秒数" align="center" prop="durationSec" v-if="columns[10].visible" />
<el-table-column label="告警级别" align="center" prop="alarmLevel" v-if="columns[11].visible" />
<!-- 告警级别暂时停用规则页不再暴露这个入口避免和统一阈值配置形成双口径 -->
<!-- <el-table-column label="告警级别" align="center" prop="alarmLevel" v-if="columns[11].visible" /> -->
<el-table-column label="启用状态" align="center" prop="isEnable" v-if="columns[12].visible">
<template #default="scope">
<dict-tag :options="dict.type.is_flag" :value="scope.row.isEnable" />
@ -216,31 +204,54 @@
<el-form-item label="恢复下限" prop="recoverLower">
<el-input-number v-model="form.recoverLower" placeholder="请输入恢复下限" :precision="4" />
</el-form-item>
<el-form-item label="回差" prop="hysteresis">
<el-form-item prop="hysteresis">
<template #label>
<span class="field-label-with-hint">
回差
<el-tooltip placement="top" effect="dark">
<template #content>
<div class="field-tooltip-content">
<p><strong>回差Hysteresis / 死区</strong></p>
<p>在告警阈值附近设置的"缓冲区间"防止测量值在阈值线上抖动导致告警反复触发/恢复告警抖动</p>
<p>规则越界后只有回落到 (告警上限 回差) 以下 (告警下限 + 回差) 以上才判定为恢复</p>
<p>示例告警上限 = 100回差 = 5 100 触发告警只有值 95 才视为恢复</p>
<p>单位与被测量一致留空或 0 表示不启用回差保护</p>
</div>
</template>
<el-icon class="field-hint-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<el-input-number v-model="form.hysteresis" placeholder="请输入回差" :precision="4" />
<div class="field-hint">防止告警抖动如告警上限=100回差=5 95 才视为恢复</div>
</el-form-item>
<el-form-item label="持续秒数" prop="durationSec">
<el-form-item prop="durationSec">
<template #label>
<span class="field-label-with-hint">
持续秒数
<el-tooltip placement="top" effect="dark">
<template #content>
<div class="field-tooltip-content">
<p><strong>持续秒数Duration Seconds</strong></p>
<p>测量值必须<strong>连续</strong>越界达到该秒数才会正式触发告警用于过滤瞬时尖峰造成的误报</p>
<p>规则越界计时累计 该秒数才落库并推送中途恢复则计时清零</p>
<p>示例持续秒数 = 30 连续 28 秒越界后回落不告警连续 30 秒越界在第 30 秒触发告警</p>
<p>单位留空或 0 表示不延迟单采样越界即触发</p>
</div>
</template>
<el-icon class="field-hint-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<el-input-number v-model="form.durationSec" placeholder="请输入持续秒数" :precision="0" />
<div class="field-hint">过滤瞬时尖峰需连续越界该秒数才触发告警</div>
</el-form-item>
<!-- 告警级别暂时停用先收敛到单一规则口径避免运维误以为这里还会参与实时判定 -->
<!--
<el-form-item label="告警级别" prop="alarmLevel">
<el-input v-model="form.alarmLevel" placeholder="请输入告警级别" />
</el-form-item>
<el-form-item label="通知组" prop="notifyGroupId">
<el-select
v-model="form.notifyGroupId"
placeholder="请选择通知组"
clearable
filterable
style="width: 100%"
@change="handleNotifyGroupChange"
>
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="通知对象摘要" prop="notifyUser">
<el-input v-model="form.notifyUser" type="textarea" :rows="2" placeholder="可手工补充通知对象;选择通知组后会自动回填成员摘要" />
<div class="field-tip">这里保留 notifyUser 摘要字段是为了兼容旧台账展示后端会以 notifyGroupId 作为通知配置的主校验入口</div>
</el-form-item>
-->
<el-form-item label="启用状态" prop="isEnable">
<el-radio-group v-model="form.isEnable">
<el-radio v-for="dict in dict.type.is_flag" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
@ -364,9 +375,8 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus';
import { QuestionFilled } from '@element-plus/icons-vue';
import { useDict } from '@/utils/dict';
import { listAlarmNotifyGroupAll } from '@/api/ems/base/alarmNotifyGroup';
import { listAlarmNotifyGroupUser } from '@/api/ems/base/alarmNotifyGroupUser';
import {
listRecordAlarmRule,
getRecordAlarmRule,
@ -376,8 +386,6 @@ import {
} from '@/api/ems/record/recordAlarmRule';
import { listBaseMonitorInfo } from '@/api/ems/base/baseMonitorInfo';
import { getEmsAlarmActionStepsByRuleId, batchSaveActionSteps } from '@/api/ems/base/emsAlarmActionStep';
import type { AlarmNotifyGroupVO } from '@/api/ems/base/alarmNotifyGroup/types';
import type { AlarmNotifyGroupUserVO } from '@/api/ems/base/alarmNotifyGroupUser/types';
import { getToken } from '@/utils/auth';
defineOptions({
@ -417,9 +425,6 @@ const createQueryParams = () => ({
durationSec: null,
alarmLevel: null,
isEnable: null,
notifyGroupId: null,
notifyUser: null,
nickName: null,
cause: null
});
@ -446,10 +451,7 @@ const createFormData = () => ({
hysteresis: null,
durationSec: null,
alarmLevel: null,
notifyGroupId: null,
isEnable: '0',
notifyUser: null,
nickName: null,
cause: null,
createBy: null,
createTime: null,
@ -479,8 +481,6 @@ const state = reactive({
open: false,
//
monitorList: [],
groupOptions: [] as AlarmNotifyGroupVO[],
groupUserMap: {} as Record<string, AlarmNotifyGroupUserVO[]>,
selectedMonitorType: null,
// monitorType
//
@ -592,7 +592,6 @@ const {
columns,
currentRuleObjId,
form,
groupOptions,
ids,
imagePreviewVisible,
loading,
@ -612,17 +611,6 @@ const {
uploadHeaders
} = toRefs(state);
const resolveNotifyUsers = (groupId?: string | number | null) => {
if (groupId === null || groupId === undefined || groupId === '') {
return '';
}
const users = state.groupUserMap[String(groupId)] || [];
return users
.map((item) => item.nickName || item.userName || item.phone || item.email)
.filter(Boolean)
.join('、');
};
//
const overviewStats = computed(() => {
const rules = recordAlarmRuleList.value;
@ -711,9 +699,6 @@ const handleUpdate = (row) => {
if (selectedMonitor) {
selectedMonitorType.value = selectedMonitor.monitorType;
}
if (form.value.notifyGroupId) {
handleNotifyGroupChange(form.value.notifyGroupId);
}
open.value = true;
title.value = '修改异常告警规则';
});
@ -737,14 +722,6 @@ const submitForm = () => {
form.value.triggerValue = form.value.alarmLower;
}
form.value.isEnable = form.value.isEnable ?? '0';
if (form.value.notifyGroupId) {
const notifySummary = resolveNotifyUsers(form.value.notifyGroupId);
if (!notifySummary && !form.value.notifyUser) {
ElMessage.warning('当前通知组没有可用成员,请先补充通知成员后再保存规则');
return;
}
form.value.notifyUser = form.value.notifyUser || notifySummary;
}
if (form.value.objId != null) {
updateRecordAlarmRule(form.value).then((response) => {
proxy?.$modal.msgSuccess('修改成功');
@ -792,49 +769,6 @@ const getMonitorList = () => {
});
};
const loadNotifyGroupOptions = async () => {
const response = await listAlarmNotifyGroupAll();
groupOptions.value = (((response as any).data ?? (response as any).rows ?? []) || []) as AlarmNotifyGroupVO[];
};
const loadNotifyGroupUsers = async () => {
const response = await listAlarmNotifyGroupUser({
pageNum: 1,
pageSize: 1000
} as any);
const users = (((response as any).rows ?? (response as any).data ?? []) || []) as AlarmNotifyGroupUserVO[];
const nextMap: Record<string, AlarmNotifyGroupUserVO[]> = {};
users.forEach((item) => {
const groupId = item.groupId;
if (groupId === null || groupId === undefined || groupId === '') {
return;
}
const key = String(groupId);
if (!nextMap[key]) {
nextMap[key] = [];
}
nextMap[key].push(item);
});
state.groupUserMap = nextMap;
};
const handleNotifyGroupChange = (groupId?: string | number | null) => {
if (!groupId) {
form.value.notifyUser = null;
return;
}
const notifySummary = resolveNotifyUsers(groupId);
form.value.notifyUser = notifySummary || form.value.notifyUser;
};
const openNotifyGroupPage = () => {
proxy?.$tab.openPage('/ems/base/alarmNotifyGroup', '通知组配置');
};
const openNotifyUserPage = () => {
proxy?.$tab.openPage('/ems/base/alarmNotifyGroupUser', '通知成员配置');
};
const handleMonitorChange = (monitorId) => {
// monitorIdmonitorCode
const selectedMonitor = monitorList.value.find((m) => m.monitorCode === monitorId);
@ -1067,8 +1001,6 @@ const getFullImageUrl = (relativePath) => {
onMounted(() => {
getList();
getMonitorList();
loadNotifyGroupOptions();
loadNotifyGroupUsers();
});
</script>
@ -1237,29 +1169,6 @@ onMounted(() => {
background: linear-gradient(90deg, rgba(59, 130, 246, 0.08), rgba(236, 72, 153, 0.08));
}
.notify-entry-bar {
align-items: center;
background: rgba(249, 239, 225, 0.7);
border: 1px solid rgba(190, 141, 103, 0.18);
border-radius: 18px;
display: flex;
gap: 12px;
justify-content: space-between;
margin-bottom: 12px;
padding: 12px 16px;
}
.notify-entry-text {
color: #6f5b51;
font-size: 13px;
line-height: 1.7;
}
.notify-entry-actions {
display: flex;
gap: 10px;
}
.data-table {
:deep(.el-table__header-wrapper th) {
background: #f5f7ff;
@ -1304,11 +1213,44 @@ onMounted(() => {
background: #fafbff;
}
.field-tip {
.field-label-with-hint {
display: inline-flex;
align-items: center;
gap: 4px;
}
.field-hint-icon {
color: #6366f1;
cursor: help;
font-size: 14px;
&:hover {
color: #4338ca;
}
}
.field-hint {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.6;
margin-top: 6px;
line-height: 1.5;
margin-top: 4px;
}
.field-tooltip-content {
max-width: 320px;
line-height: 1.65;
p {
margin: 0 0 6px;
}
p:last-child {
margin-bottom: 0;
}
strong {
color: #fdd08a;
}
}
.action-steps-container {
@ -1499,10 +1441,5 @@ onMounted(() => {
flex-direction: column;
align-items: flex-start;
}
.notify-entry-bar {
align-items: flex-start;
flex-direction: column;
}
}
</style>

@ -88,6 +88,7 @@ import type { TempBoardQuery, TempBoardAdvancedVO } from '@/api/ems/report/tempB
import HelpButton from '../components/HelpButton.vue';
import { ADVANCED_HELP } from '../components/helpContent';
import { useChartResize } from '../components/useChartResize';
import { renderInFrames } from '../components/renderInFrames';
defineOptions({ name: 'TempBoardAdvanced' });
@ -151,9 +152,12 @@ function lazyLoadChart(
fetchFn(queryForm)
.then((res) => {
//
//
// sankeyriver
// renderInFrames requestAnimationFrame renderFn
// 便线
if (queryId === currentQueryId) {
nextTick(() => renderFn(res.data));
nextTick(() => renderInFrames([() => renderFn(res.data)]));
}
})
.catch((err) => {

@ -97,6 +97,7 @@ import type { TempBoardQuery, TempBoardComparisonVO } from '@/api/ems/report/tem
import HelpButton from '../components/HelpButton.vue'
import { COMPARISON_HELP } from '../components/helpContent'
import { useChartResize } from '../components/useChartResize'
import { renderInFrames } from '../components/renderInFrames'
defineOptions({ name: 'TempBoardComparison' })
@ -164,45 +165,52 @@ async function handleQuery() {
])
peakList.value = peakRes.data
await nextTick()
renderBarH(avgRankChartRef, avgRes.data, 'avgTemp', '#f5a623', '#f7c948', '℃')
renderBarH(stabilityChartRef, stabRes.data, 'tempStddev', '#36cfc9', '#5ad8a6', 'σ')
renderBarH(fluctuationChartRef, fluctRes.data, 'tempRange', '#409eff', '#69c0ff', '℃')
// vs
const chart = getChart(dailyDiffChartRef)
if (chart && diffRes.data.length) {
chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
let html = `<b>${params[0].name}</b><br/>`
params.forEach((p: any) => { html += `${p.marker} ${p.seriesName}: <b>${p.value}℃</b><br/>` })
return html
}
},
legend: { data: ['今日', '昨日'], top: 0, textStyle: { color: '#666' } },
grid: { left: 120, right: 30, top: 40, bottom: 20 },
yAxis: { type: 'category', data: diffRes.data.map(d => d.monitorName || d.monitorId), axisLabel: { color: '#333' } },
xAxis: { type: 'value', name: '℃', axisLabel: { color: '#666' } },
series: [
{
name: '今日', type: 'bar',
data: diffRes.data.map(d => d.todayAvg),
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [{ offset: 0, color: '#409eff' }, { offset: 1, color: '#69c0ff' }]), borderRadius: [0, 4, 4, 0] },
barMaxWidth: 20
},
{
name: '昨日', type: 'bar',
data: diffRes.data.map(d => d.yesterdayAvg),
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [{ offset: 0, color: '#909399' }, { offset: 1, color: '#b1b3b8' }]), borderRadius: [0, 4, 4, 0] },
barMaxWidth: 20
}
]
})
}
// / / / vs 4
// 4 setOption 线 +
renderInFrames([
() => renderBarH(avgRankChartRef, avgRes.data, 'avgTemp', '#f5a623', '#f7c948', '℃'),
() => renderBarH(stabilityChartRef, stabRes.data, 'tempStddev', '#36cfc9', '#5ad8a6', 'σ'),
() => renderBarH(fluctuationChartRef, fluctRes.data, 'tempRange', '#409eff', '#69c0ff', '℃'),
() => renderDailyDiffChart(diffRes.data)
])
} finally { loading.value = false }
}
/** 今日 vs 昨日 分组柱图 */
function renderDailyDiffChart(data: TempBoardComparisonVO[]) {
const chart = getChart(dailyDiffChartRef)
if (!chart || !data.length) return
chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
let html = `<b>${params[0].name}</b><br/>`
params.forEach((p: any) => { html += `${p.marker} ${p.seriesName}: <b>${p.value}℃</b><br/>` })
return html
}
},
legend: { data: ['今日', '昨日'], top: 0, textStyle: { color: '#666' } },
grid: { left: 120, right: 30, top: 40, bottom: 20 },
yAxis: { type: 'category', data: data.map(d => d.monitorName || d.monitorId), axisLabel: { color: '#333' } },
xAxis: { type: 'value', name: '℃', axisLabel: { color: '#666' } },
series: [
{
name: '今日', type: 'bar',
data: data.map(d => d.todayAvg),
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [{ offset: 0, color: '#409eff' }, { offset: 1, color: '#69c0ff' }]), borderRadius: [0, 4, 4, 0] },
barMaxWidth: 20
},
{
name: '昨日', type: 'bar',
data: data.map(d => d.yesterdayAvg),
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [{ offset: 0, color: '#909399' }, { offset: 1, color: '#b1b3b8' }]), borderRadius: [0, 4, 4, 0] },
barMaxWidth: 20
}
]
})
}
onMounted(() => { initTimeRange(); handleQuery() })
</script>

@ -0,0 +1,63 @@
/**
* requestAnimationFrame
* setOption / chart.resize线
*
* 使
* - / KPI trenddistributioncomparisonoverview
* - ResizeObserver resize
* - "一帧画一个"
*
* error
*
* SSR / requestAnimationFrame
* "异步 + 有序 + 单任务"
*/
type Task = () => void;
const raf: (cb: FrameRequestCallback) => number =
typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
? window.requestAnimationFrame.bind(window)
: (cb: FrameRequestCallback) => setTimeout(() => cb(performance.now()), 16) as unknown as number;
/**
*
* @param tasks
*/
export function renderInFrames(tasks: Task[]): void {
if (!tasks || !tasks.length) return;
// 复制一份队列,允许外部在调用后继续变更原数组而不会污染这轮执行。
const queue = tasks.slice();
const step = () => {
const fn = queue.shift();
if (!fn) return;
try {
fn();
} catch (err) {
// 单帧失败不中断后续渲染,确保其他图表仍能按帧出图。
console.error('[renderInFrames] task failed:', err);
}
if (queue.length) raf(step);
};
raf(step);
}
/**
* rAF flush
* ResizeObserver
* flush
*/
export function createRafBatcher(flush: () => void) {
let scheduled = false;
return () => {
if (scheduled) return;
scheduled = true;
raf(() => {
scheduled = false;
try {
flush();
} catch (err) {
console.error('[createRafBatcher] flush failed:', err);
}
});
};
}

@ -1,8 +1,17 @@
import { onBeforeUnmount, onMounted, type Ref } from 'vue';
import * as echarts from 'echarts';
import { createRafBatcher, renderInFrames } from './renderInFrames';
/**
* ECharts + resize
*
*
* 1. ResizeObserver ~ 1
* resize线
* createRafBatcher observer flush
* 2. flush chart.resize()renderInFrames
* rAF
*
* @param chartRefs ref
* @returns chartInstances setOption
*/
@ -29,15 +38,25 @@ export function useChartResize(...chartRefs: Ref<HTMLElement | undefined>[]) {
chartInstances.clear();
}
/** 把当前所有图表的 resize 按帧拆开执行,一帧只 resize 一张。 */
function flushResize() {
const tasks: Array<() => void> = [];
chartInstances.forEach((chart) => {
if (!chart.isDisposed()) {
tasks.push(() => chart.resize());
}
});
renderInFrames(tasks);
}
/** rAF 合并器:同一帧内的多次 observer / window resize 只会触发一次 flush。 */
const scheduleResize = createRafBatcher(flushResize);
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
resizeObserver = new ResizeObserver(() => {
chartInstances.forEach((chart) => {
if (!chart.isDisposed()) {
chart.resize();
}
});
scheduleResize();
});
// 观察所有图表容器
chartRefs.forEach((r) => {
@ -46,19 +65,11 @@ export function useChartResize(...chartRefs: Ref<HTMLElement | undefined>[]) {
}
});
// 同时监听窗口 resize
window.addEventListener('resize', handleResize);
window.addEventListener('resize', scheduleResize);
});
function handleResize() {
chartInstances.forEach((chart) => {
if (!chart.isDisposed()) {
chart.resize();
}
});
}
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('resize', scheduleResize);
resizeObserver?.disconnect();
disposeAll();
});

@ -77,6 +77,7 @@ import type { TempBoardQuery, TempBoardDistributionVO } from '@/api/ems/report/t
import HelpButton from '../components/HelpButton.vue';
import { DISTRIBUTION_HELP } from '../components/helpContent';
import { useChartResize } from '../components/useChartResize';
import { renderInFrames } from '../components/renderInFrames';
defineOptions({ name: 'TempBoardDistribution' });
@ -128,10 +129,14 @@ async function handleQuery() {
getHourlyHeatmap(queryForm)
]);
await nextTick();
renderIntervalChart(intervalRes.data);
renderHistogramChart(histRes.data);
renderCalendarHeatmap(calRes.data);
renderHourlyHeatmap(hourRes.data);
// / / / 4 4
// setOption 线
renderInFrames([
() => renderIntervalChart(intervalRes.data),
() => renderHistogramChart(histRes.data),
() => renderCalendarHeatmap(calRes.data),
() => renderHourlyHeatmap(hourRes.data)
]);
} finally {
loading.value = false;
}

@ -119,6 +119,7 @@ import type { TempBoardQuery, TempBoardOverviewVO } from '@/api/ems/report/tempB
import HelpButton from '../components/HelpButton.vue';
import { OVERVIEW_HELP } from '../components/helpContent';
import { useChartResize } from '../components/useChartResize';
import { renderInFrames } from '../components/renderInFrames';
defineOptions({ name: 'TempBoardOverview' });
@ -181,66 +182,76 @@ function handleReset() {
handleQuery();
}
function renderTopNCharts() {
/** 高温 TopN 柱图 */
function renderHighTopNChart() {
const highChart = getChart(highTopNChartRef);
if (highChart && overview.value.highTempTopN) {
const d = overview.value.highTempTopN;
highChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => `${params[0].name}<br/>温度: <b style="color:#f56c6c">${params[0].value}℃</b>`
},
grid: { left: 120, right: 50, top: 10, bottom: 20 },
xAxis: { type: 'value', name: '℃', axisLabel: { color: '#666' } },
yAxis: { type: 'category', data: d.map((i) => i.monitorName || i.monitorId).reverse(), axisLabel: { color: '#333' } },
series: [
{
type: 'bar',
data: d.map((i) => i.temperature).reverse(),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#f56c6c' },
{ offset: 1, color: '#fab6b6' }
]),
borderRadius: [0, 4, 4, 0]
},
label: { show: true, position: 'right', formatter: '{c}℃', color: '#f56c6c', fontSize: 12 },
barMaxWidth: 24
}
]
});
}
if (!highChart || !overview.value.highTempTopN) return;
const d = overview.value.highTempTopN;
highChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => `${params[0].name}<br/>温度: <b style="color:#f56c6c">${params[0].value}℃</b>`
},
grid: { left: 120, right: 50, top: 10, bottom: 20 },
xAxis: { type: 'value', name: '℃', axisLabel: { color: '#666' } },
yAxis: { type: 'category', data: d.map((i) => i.monitorName || i.monitorId).reverse(), axisLabel: { color: '#333' } },
series: [
{
type: 'bar',
data: d.map((i) => i.temperature).reverse(),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#f56c6c' },
{ offset: 1, color: '#fab6b6' }
]),
borderRadius: [0, 4, 4, 0]
},
label: { show: true, position: 'right', formatter: '{c}℃', color: '#f56c6c', fontSize: 12 },
barMaxWidth: 24
}
]
});
}
/** 低温 TopN 柱图 */
function renderLowTopNChart() {
const lowChart = getChart(lowTopNChartRef);
if (lowChart && overview.value.lowTempTopN) {
const d = overview.value.lowTempTopN;
lowChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => `${params[0].name}<br/>温度: <b style="color:#409eff">${params[0].value}℃</b>`
},
grid: { left: 120, right: 50, top: 10, bottom: 20 },
xAxis: { type: 'value', name: '℃', axisLabel: { color: '#666' } },
yAxis: { type: 'category', data: d.map((i) => i.monitorName || i.monitorId).reverse(), axisLabel: { color: '#333' } },
series: [
{
type: 'bar',
data: d.map((i) => i.temperature).reverse(),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#409eff' },
{ offset: 1, color: '#a0cfff' }
]),
borderRadius: [0, 4, 4, 0]
},
label: { show: true, position: 'right', formatter: '{c}℃', color: '#409eff', fontSize: 12 },
barMaxWidth: 24
}
]
});
}
if (!lowChart || !overview.value.lowTempTopN) return;
const d = overview.value.lowTempTopN;
lowChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => `${params[0].name}<br/>温度: <b style="color:#409eff">${params[0].value}℃</b>`
},
grid: { left: 120, right: 50, top: 10, bottom: 20 },
xAxis: { type: 'value', name: '℃', axisLabel: { color: '#666' } },
yAxis: { type: 'category', data: d.map((i) => i.monitorName || i.monitorId).reverse(), axisLabel: { color: '#333' } },
series: [
{
type: 'bar',
data: d.map((i) => i.temperature).reverse(),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#409eff' },
{ offset: 1, color: '#a0cfff' }
]),
borderRadius: [0, 4, 4, 0]
},
label: { show: true, position: 'right', formatter: '{c}℃', color: '#409eff', fontSize: 12 },
barMaxWidth: 24
}
]
});
}
/**
* 总览 TopN 双图渲染两张柱图各占一帧避免一次性连调两次 setOption
* 把主线程钉死查询后用户能看到图表逐一渐进出现
*/
function renderTopNCharts() {
renderInFrames([renderHighTopNChart, renderLowTopNChart]);
}
onMounted(() => {

@ -20,6 +20,7 @@
### 4. 线程分离与主线程释放
* **Web Worker 解析数据**:如果后端依然传输了庞大的 JSON例如上万个点的原始数据利用 Vite 自带的 `?worker` 方便地将耗时的数据结构转换(如 Array 转换为 ECharts 需要的 Category/Value 结构)放入 Web Worker 线程处理,避免阻塞浏览器主 UI 线程导致假死。
* **多图 / 多条数据按帧拆分(`requestAnimationFrame`**:本看板所有**多条数据 / 多卡片图表**的渲染统一走 `components/renderInFrames.ts` 里的 `renderInFrames(tasks)` 工具,一帧只跑一个 `setOption`,避免一次查询后连续 `setOption` 4~5 次把主线程钉死、界面白屏。`ResizeObserver` / `window resize` 也通过同文件的 `createRafBatcher` 做 rAF 合并 + 拆帧 resize拖拽窗口时不会让多张图在同一帧集体重排。任何新加的图表卡片都必须沿用这个约定。
## 二、 后端接口与传输优化 (Spring Boot 3.5.12 + JDK 25/21/17)

@ -167,6 +167,7 @@ import type {
import HelpButton from '../components/HelpButton.vue';
import { TREND_HELP } from '../components/helpContent';
import { useChartResize } from '../components/useChartResize';
import { renderInFrames } from '../components/renderInFrames';
defineOptions({ name: 'TempBoardTrend' });
@ -457,19 +458,23 @@ async function handleQuery() {
peakValleyList.value = peakValleyRes.data;
await nextTick();
// / / / / 5
// 5 setOptionLTTB 线 200~400ms
//
const tasks: Array<() => void> = [];
if (queryForm.monitorId) {
renderLineChart(minuteChartRef, minuteRes.data);
tasks.push(() => renderLineChart(minuteChartRef, minuteRes.data));
// minute hourly
const hourlyData = needHourlyRequest ? hourlyRes.data : minuteRes.data;
renderLineChart(hourlyChartRef, hourlyData, { showBand: true });
renderChangeRateChart(changeRateRes.data);
tasks.push(() => renderLineChart(hourlyChartRef, hourlyData, { showBand: true }));
tasks.push(() => renderChangeRateChart(changeRateRes.data));
} else {
renderChartPlaceholder(minuteChartRef, '请选择测点后查看分钟趋势');
renderChartPlaceholder(hourlyChartRef, '请选择测点后查看小时趋势');
tasks.push(() => renderChartPlaceholder(minuteChartRef, '请选择测点后查看分钟趋势'));
tasks.push(() => renderChartPlaceholder(hourlyChartRef, '请选择测点后查看小时趋势'));
}
renderMultiCompareChart(multiCompareRes.data);
renderLineChart(dailyChartRef, dailyRes.data, { showBand: true });
tasks.push(() => renderMultiCompareChart(multiCompareRes.data));
tasks.push(() => renderLineChart(dailyChartRef, dailyRes.data, { showBand: true }));
renderInFrames(tasks);
} finally {
loading.value = false;
}

@ -4,10 +4,8 @@
:selection-label="queryForm.selectionLabel"
:time-range="daterangeRecordTime"
:sampling-interval="queryForm.samplingInterval"
:vibration-param="queryForm.vibrationParam"
@update:time-range="setTimeRange"
@update:sampling-interval="setSamplingInterval"
@update:vibration-param="setVibrationParam"
@query="handleQuery"
/>
@ -53,22 +51,22 @@
</template>
<script setup lang="ts">
import * as echarts from 'echarts';
import { getDisplacementAdvancedData as getVibrationAdvancedData, type VibrationAdvancedPageVO } from '@/api/ems/report/displacementBoard';
import { getDisplacementAdvancedData, type DisplacementAdvancedPageVO } from '@/api/ems/report/displacementBoard';
import HelpButton from '../components/HelpButton.vue';
import VibrationBoardFilter from '../components/VibrationBoardFilter.vue';
import VibrationBoardDeviceTree from '../components/VibrationBoardDeviceTree.vue';
import { ADVANCED_HELP } from '../components/helpContent';
import { useChartResize } from '../components/useChartResize';
import { useVibrationBoardQueryState } from '../components/useVibrationBoardQueryState';
import { toNumber, vibrationMetricOptions } from '../components/vibrationBoardShared';
import { toNumber } from '../components/vibrationBoardShared';
defineOptions({ name: 'VibrationBoardAdvanced' });
const sankeyChartRef = ref<HTMLElement>();
const treemapChartRef = ref<HTMLElement>();
const parallelChartRef = ref<HTMLElement>();
const { getChart } = useChartResize(sankeyChartRef, treemapChartRef, parallelChartRef);
// +
// parallelChartRef/
const { getChart } = useChartResize(sankeyChartRef, treemapChartRef);
const {
loading,
@ -81,16 +79,13 @@ const {
handleTreeNodeClick,
setTimeRange,
setSamplingInterval,
setVibrationParam,
buildQuery
} = useVibrationBoardQueryState();
const advancedData = ref<VibrationAdvancedPageVO>({
const advancedData = ref<DisplacementAdvancedPageVO>({
sankeyNodes: [],
sankeyLinks: [],
treemapItems: [],
parallelAxes: [],
parallelSeries: []
treemapItems: []
});
const riskBandColorMap: Record<string, string> = {
@ -124,12 +119,9 @@ const getNodeColor = (name?: string) => {
const renderCharts = () => {
const sankeyChart = getChart(sankeyChartRef);
const treemapChart = getChart(treemapChartRef);
const parallelChart = getChart(parallelChartRef);
const sankeyNodes = advancedData.value.sankeyNodes || [];
const sankeyLinks = advancedData.value.sankeyLinks || [];
const treemapItems = advancedData.value.treemapItems || [];
const parallelAxes = advancedData.value.parallelAxes || [];
const parallelSeries = advancedData.value.parallelSeries || [];
if (sankeyChart) {
if (!sankeyNodes.length || !sankeyLinks.length) {
@ -219,67 +211,17 @@ const renderCharts = () => {
}
}
if (parallelChart) {
if (!parallelAxes.length || !parallelSeries.length) {
parallelChart.clear();
return;
}
parallelChart.setOption(
{
color: [...vibrationMetricOptions.map((item) => item.color), '#8b5cf6', '#64748b'] as string[],
tooltip: {
trigger: 'item'
},
legend: {
type: 'scroll',
top: 8,
data: parallelSeries.map((item) => item.monitorName || item.monitorId || '--'),
textStyle: { color: '#475569' }
},
parallelAxis: parallelAxes.map((item) => ({
dim: toNumber(item.dim),
name: item.name,
max: Math.max(toNumber(item.max), 1),
nameLocation: 'end',
nameGap: 18,
axisLabel: { color: '#64748b' }
})),
parallel: {
left: 52,
right: 24,
top: 64,
bottom: 42,
parallelAxisDefault: {
type: 'value',
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisTick: { show: false },
splitLine: { lineStyle: { type: 'dashed', color: 'rgba(148, 163, 184, 0.3)' } }
}
},
series: parallelSeries.map((item) => ({
name: item.monitorName || item.monitorId || '--',
type: 'parallel',
smooth: 0.2,
lineStyle: { width: 1.6, opacity: 0.42 },
emphasis: { lineStyle: { width: 3, opacity: 0.95 } },
data: [(item.values || []).map((value) => toNumber(value))]
}))
},
true
);
}
//
};
const handleQuery = async () => {
loading.value = true;
try {
const { data } = await getVibrationAdvancedData(buildQuery());
const { data } = await getDisplacementAdvancedData(buildQuery());
advancedData.value = data || {
sankeyNodes: [],
sankeyLinks: [],
treemapItems: [],
parallelAxes: [],
parallelSeries: []
treemapItems: []
};
await nextTick();
renderCharts();

@ -4,10 +4,8 @@
:selection-label="queryForm.selectionLabel"
:time-range="daterangeRecordTime"
:sampling-interval="queryForm.samplingInterval"
:vibration-param="queryForm.vibrationParam"
@update:time-range="setTimeRange"
@update:sampling-interval="setSamplingInterval"
@update:vibration-param="handleMetricChange"
@query="handleQuery"
/>
@ -187,7 +185,7 @@
<script setup lang="ts">
import * as echarts from 'echarts';
import { getDisplacementAnomalyData as getVibrationAnomalyData, type VibrationAnomalyPageVO } from '@/api/ems/report/displacementBoard';
import { getDisplacementAnomalyData, type DisplacementAnomalyPageVO } from '@/api/ems/report/displacementBoard';
import HelpButton from '../components/HelpButton.vue';
import VibrationBoardFilter from '../components/VibrationBoardFilter.vue';
import VibrationBoardDeviceTree from '../components/VibrationBoardDeviceTree.vue';
@ -212,19 +210,17 @@ const {
handleTreeNodeClick,
setTimeRange,
setSamplingInterval,
setVibrationParam,
resetAnomalyThresholds,
buildQuery
} = useVibrationBoardQueryState();
const anomalyData = ref<VibrationAnomalyPageVO>({
const anomalyData = ref<DisplacementAnomalyPageVO>({
highEvents: [],
continuousEvents: [],
rapidRiseEvents: [],
jitterEvents: []
});
const applyThresholdDefaults = (data?: VibrationAnomalyPageVO) => {
const applyThresholdDefaults = (data?: DisplacementAnomalyPageVO) => {
if (!data) {
return;
}
@ -294,16 +290,10 @@ const renderEventChart = () => {
);
};
const handleMetricChange = (_value: string) => {
//
resetAnomalyThresholds();
setVibrationParam('vibrationDisplacement');
};
const handleQuery = async () => {
loading.value = true;
try {
const { data } = await getVibrationAnomalyData(buildQuery());
const { data } = await getDisplacementAnomalyData(buildQuery());
anomalyData.value = data || {
highEvents: [],
continuousEvents: [],

@ -4,10 +4,8 @@
:selection-label="queryForm.selectionLabel"
:time-range="daterangeRecordTime"
:sampling-interval="queryForm.samplingInterval"
:vibration-param="queryForm.vibrationParam"
@update:time-range="setTimeRange"
@update:sampling-interval="setSamplingInterval"
@update:vibration-param="setVibrationParam"
@query="handleQuery"
/>
@ -61,7 +59,7 @@
<script setup lang="ts">
import * as echarts from 'echarts';
import { getDisplacementComparisonData as getVibrationComparisonData, type VibrationComparisonPageVO } from '@/api/ems/report/displacementBoard';
import { getDisplacementComparisonData, type DisplacementComparisonPageVO } from '@/api/ems/report/displacementBoard';
import HelpButton from '../components/HelpButton.vue';
import VibrationBoardFilter from '../components/VibrationBoardFilter.vue';
import VibrationBoardDeviceTree from '../components/VibrationBoardDeviceTree.vue';
@ -87,13 +85,12 @@ const {
handleTreeNodeClick,
setTimeRange,
setSamplingInterval,
setVibrationParam,
buildQuery
} = useVibrationBoardQueryState();
const comparisonData = ref<VibrationComparisonPageVO>({ rankItems: [], scatterItems: [] });
const comparisonData = ref<DisplacementComparisonPageVO>({ rankItems: [], scatterItems: [] });
const metricColorMap = Object.fromEntries(vibrationMetricOptions.map((item) => [item.field, item.color]));
const activeMetric = computed(() => getVibrationMetricOption(comparisonData.value.metricField || queryForm.value.vibrationParam));
const activeMetric = computed(() => getVibrationMetricOption(comparisonData.value.metricField));
const renderCharts = () => {
const rankChart = getChart(rankChartRef);
@ -101,7 +98,7 @@ const renderCharts = () => {
const unit = comparisonData.value.unit || activeMetric.value.unit;
const rankItems = comparisonData.value.rankItems || [];
const scatterItems = comparisonData.value.scatterItems || [];
const currentMetricColor = metricColorMap[comparisonData.value.metricField || queryForm.value.vibrationParam] || activeMetric.value.color;
const currentMetricColor = metricColorMap[comparisonData.value.metricField || ''] || activeMetric.value.color;
if (rankChart) {
if (!rankItems.length) {
@ -248,7 +245,7 @@ const renderCharts = () => {
const handleQuery = async () => {
loading.value = true;
try {
const { data } = await getVibrationComparisonData(buildQuery());
const { data } = await getDisplacementComparisonData(buildQuery());
comparisonData.value = data || { rankItems: [], scatterItems: [] };
await nextTick();
//

@ -36,18 +36,21 @@
</template>
<script setup lang="ts">
/**
* 位移专属筛选组件
*
* <p>唯一指标固定为位移筛选器不再暴露 vibrationParamshowMetric 等多指标切换字段
* 也不再向父组件发 update:vibrationParam 事件避免残留通用设计让后续维护者误以为还能切指标</p>
*/
const props = defineProps<{
selectionLabel: string;
timeRange: string[];
samplingInterval: number;
vibrationParam?: string;
showMetric?: boolean;
}>();
const emit = defineEmits<{
'update:timeRange': [string[]];
'update:samplingInterval': [number];
'update:vibrationParam': [string];
query: [];
}>();

@ -1,14 +1,21 @@
import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo';
import type { VibrationBoardQuery } from '@/api/ems/types';
import type { DisplacementBoardQuery } from '@/api/ems/types';
import { createDefaultTimeRange } from './vibrationBoardShared';
type VibrationBoardQueryForm = {
compareScope: string;
/**
*
*
* <p> hook 沿 vibrationParamdefaultMetric
* compareScope /
* 1. vibrationDisplacement vibrationParam
* 2. monitorIds.length compareScope
* 3. buildQuery() BuildQueryOptions.includeVibrationParam </p>
*/
type DisplacementBoardQueryForm = {
monitorId: string;
monitorIds: string[];
selectionLabel: string;
samplingInterval: number;
vibrationParam: string;
highThreshold: number | undefined;
warningThreshold: number | undefined;
minContinuousSamples: number | undefined;
@ -16,17 +23,12 @@ type VibrationBoardQueryForm = {
stddevThreshold: number | undefined;
};
type BuildQueryOptions = {
includeVibrationParam?: boolean;
};
const createDefaultQueryForm = (defaultMetric = 'vibrationDisplacement'): VibrationBoardQueryForm => ({
compareScope: 'all',
const createDefaultQueryForm = (): DisplacementBoardQueryForm => ({
monitorId: '',
monitorIds: [],
selectionLabel: '全部振动设备',
// 默认选中全部位移设备,避免首屏空数据,也统一了“位移专属”的业务表述。
selectionLabel: '全部位移设备',
samplingInterval: 5,
vibrationParam: defaultMetric,
highThreshold: undefined,
warningThreshold: undefined,
minContinuousSamples: undefined,
@ -43,22 +45,18 @@ const sharedState = reactive({
queryForm: createDefaultQueryForm()
});
export function useVibrationBoardQueryState(defaultMetric = 'vibrationDisplacement') {
export function useVibrationBoardQueryState() {
const localState = reactive({
loading: false,
treeLoading: false,
treeProps: { label: 'label', children: 'children' }
});
// 仅在首次进入页面时应用默认主看指标,后续页面切换沿用用户最新选择。
if (!sharedState.queryForm.vibrationParam) {
sharedState.queryForm.vibrationParam = defaultMetric;
}
const { loading, treeLoading, treeProps } = toRefs(localState);
const { monitorTreeOptions, deviceDisplayMap, daterangeRecordTime, queryForm } = toRefs(sharedState);
const deviceCount = computed(() => queryForm.value.monitorIds?.length || (queryForm.value.monitorId ? 1 : 0));
const hasMultiDevice = computed(() => deviceCount.value > 1 || ['group', 'all'].includes(queryForm.value.compareScope));
// 位移专属看板只按“选中设备数量”判断单/多设备视图,不再沿用 compareScope 之类的多模式标记。
const hasMultiDevice = computed(() => deviceCount.value > 1);
const buildDeviceDisplayMap = (nodes: any[]): Record<string, string> =>
(nodes || []).reduce(
@ -73,17 +71,16 @@ export function useVibrationBoardQueryState(defaultMetric = 'vibrationDisplaceme
const getLeafNodes = (nodes: any[]): any[] =>
(nodes || []).flatMap((item) => (item.children?.length ? getLeafNodes(item.children) : item.code ? [item] : []));
const buildSelection = (nodes: any[], selectionLabel: string, compareScope?: string) => {
const buildSelection = (nodes: any[], selectionLabel: string) => {
const monitorIds = nodes.map((item) => item.code);
return {
compareScope: compareScope || (monitorIds.length > 1 ? 'group' : 'single'),
monitorId: monitorIds.length === 1 ? monitorIds[0] : '',
monitorIds,
selectionLabel
};
};
const applySelection = (selection: any) => {
const applySelection = (selection: { monitorId: string; monitorIds: string[]; selectionLabel: string }) => {
Object.assign(queryForm.value, selection);
};
@ -95,14 +92,8 @@ export function useVibrationBoardQueryState(defaultMetric = 'vibrationDisplaceme
queryForm.value.samplingInterval = value;
};
const setVibrationParam = (value: string) => {
// 位移专属看板不再允许切换指标,这里强制收敛为位移,避免旧页面残留调用把状态带偏。
queryForm.value.vibrationParam = 'vibrationDisplacement';
};
const resetAnomalyThresholds = () => {
// 主看指标切换后必须回到“未覆盖”状态,让后端按新指标重新下发默认阈值,
// 否则会把上一指标的阈值误当成当前指标的用户自定义值。
// 异常阈值应从后端拉取的位移默认值重新覆盖,这里复位是为了避免保留上一次手改值误伤下一轮查询。
queryForm.value.highThreshold = undefined;
queryForm.value.warningThreshold = undefined;
queryForm.value.minContinuousSamples = undefined;
@ -122,7 +113,7 @@ export function useVibrationBoardQueryState(defaultMetric = 'vibrationDisplaceme
deviceDisplayMap.value = buildDeviceDisplayMap(monitorTreeOptions.value);
sharedState.treeLoaded = true;
if (!queryForm.value.monitorIds.length && !queryForm.value.monitorId) {
applySelection(buildSelection(getLeafNodes(monitorTreeOptions.value), '全部振动设备', 'all'));
applySelection(buildSelection(getLeafNodes(monitorTreeOptions.value), '全部位移设备'));
}
} finally {
treeLoading.value = false;
@ -132,16 +123,14 @@ export function useVibrationBoardQueryState(defaultMetric = 'vibrationDisplaceme
const handleTreeNodeClick = (data: any) => {
if (!data) return;
if (data.children?.length) {
applySelection(buildSelection(getLeafNodes(data.children), `${data.label}设备组`, 'group'));
applySelection(buildSelection(getLeafNodes(data.children), `${data.label}设备组`));
return;
}
applySelection(buildSelection([data], data.label, 'single'));
applySelection(buildSelection([data], data.label));
};
const buildQuery = (options?: BuildQueryOptions): VibrationBoardQuery => ({
const buildQuery = (): DisplacementBoardQuery => ({
samplingInterval: queryForm.value.samplingInterval,
// 位移专属看板统一固定为 vibrationDisplacement前端不再暴露可切换口径。
vibrationParam: 'vibrationDisplacement',
beginRecordTime: daterangeRecordTime.value[0],
endRecordTime: daterangeRecordTime.value[1],
highThreshold: queryForm.value.highThreshold,
@ -172,7 +161,6 @@ export function useVibrationBoardQueryState(defaultMetric = 'vibrationDisplaceme
handleTreeNodeClick,
setTimeRange,
setSamplingInterval,
setVibrationParam,
resetAnomalyThresholds,
buildQuery,
getMonitorDisplayName

@ -4,10 +4,8 @@
:selection-label="queryForm.selectionLabel"
:time-range="daterangeRecordTime"
:sampling-interval="queryForm.samplingInterval"
:vibration-param="queryForm.vibrationParam"
@update:time-range="setTimeRange"
@update:sampling-interval="setSamplingInterval"
@update:vibration-param="setVibrationParam"
@query="handleQuery"
/>
@ -65,7 +63,7 @@
<div class="card-header">
<div class="card-title-group">
<div class="card-title">小时热力图</div>
<div class="card-subtitle">用于识别班次工况或生产节拍带来的固定高时段</div>
<div class="card-subtitle">用于识别班次工况或生产节拍带来的固定高位移时段</div>
</div>
<HelpButton title="小时热力图" :content="DISTRIBUTION_HELP" />
</div>
@ -79,7 +77,7 @@
<script setup lang="ts">
import * as echarts from 'echarts';
import { getDisplacementDistributionData as getVibrationDistributionData, type VibrationDistributionPageVO } from '@/api/ems/report/displacementBoard';
import { getDisplacementDistributionData, type DisplacementDistributionPageVO } from '@/api/ems/report/displacementBoard';
import HelpButton from '../components/HelpButton.vue';
import VibrationBoardFilter from '../components/VibrationBoardFilter.vue';
import VibrationBoardDeviceTree from '../components/VibrationBoardDeviceTree.vue';
@ -107,17 +105,16 @@ const {
handleTreeNodeClick,
setTimeRange,
setSamplingInterval,
setVibrationParam,
buildQuery
} = useVibrationBoardQueryState();
const distributionData = ref<VibrationDistributionPageVO>({
const distributionData = ref<DisplacementDistributionPageVO>({
intervalBuckets: [],
histogramBuckets: [],
calendarHeatmap: [],
hourlyHeatmap: []
});
const activeMetric = computed(() => getVibrationMetricOption(distributionData.value.metricField || queryForm.value.vibrationParam));
const activeMetric = computed(() => getVibrationMetricOption(distributionData.value.metricField));
const clearCharts = () => {
getChart(intervalChartRef)?.clear();
@ -316,7 +313,7 @@ const renderCharts = () => {
const handleQuery = async () => {
loading.value = true;
try {
const { data } = await getVibrationDistributionData(buildQuery());
const { data } = await getDisplacementDistributionData(buildQuery());
distributionData.value = data || {
intervalBuckets: [],
histogramBuckets: [],

@ -4,10 +4,8 @@
:selection-label="queryForm.selectionLabel"
:time-range="daterangeRecordTime"
:sampling-interval="queryForm.samplingInterval"
:vibration-param="queryForm.vibrationParam"
@update:time-range="setTimeRange"
@update:sampling-interval="setSamplingInterval"
@update:vibration-param="setVibrationParam"
@query="handleQuery"
/>
@ -69,7 +67,7 @@
<div class="card-title">仪表盘总览</div>
<div class="card-subtitle">围绕位移关键统计值做快速巡检适合先判断当前风险边界再决定是否深入排查</div>
</div>
<HelpButton title="振动总览" :content="OVERVIEW_HELP" />
<HelpButton title="位移总览" :content="OVERVIEW_HELP" />
</div>
</template>
<div ref="gaugeChartRef" class="chart-container-lg" />
@ -103,7 +101,7 @@
<script setup lang="ts">
import * as echarts from 'echarts';
import { getDisplacementOverviewData as getVibrationOverviewData, type VibrationOverviewPageVO } from '@/api/ems/report/displacementBoard';
import { getDisplacementOverviewData, type DisplacementOverviewPageVO } from '@/api/ems/report/displacementBoard';
import HelpButton from '../components/HelpButton.vue';
import VibrationBoardFilter from '../components/VibrationBoardFilter.vue';
import VibrationBoardDeviceTree from '../components/VibrationBoardDeviceTree.vue';
@ -137,13 +135,13 @@ const {
handleTreeNodeClick,
setTimeRange,
setSamplingInterval,
setVibrationParam,
buildQuery
} = useVibrationBoardQueryState();
const overviewData = ref<VibrationOverviewPageVO>({ metricCards: [], gaugeItems: [], deviceRanks: [] });
const overviewData = ref<DisplacementOverviewPageVO>({ metricCards: [], gaugeItems: [], deviceRanks: [] });
const metricColorMap = Object.fromEntries(vibrationMetricOptions.map((item) => [item.field, item.color]));
const activeMetric = computed(() => getVibrationMetricOption(overviewData.value.metricField || queryForm.value.vibrationParam));
// vibrationParam
const activeMetric = computed(() => getVibrationMetricOption(overviewData.value.metricField));
const buildGaugeCenters = (count: number) => {
if (count <= 1) {
@ -168,7 +166,7 @@ const renderCharts = () => {
const gaugeItems = overviewData.value.gaugeItems || [];
const deviceRanks = overviewData.value.deviceRanks || [];
const unit = overviewData.value.unit || activeMetric.value.unit;
const currentMetricColor = metricColorMap[overviewData.value.metricField || queryForm.value.vibrationParam] || activeMetric.value.color;
const currentMetricColor = metricColorMap[overviewData.value.metricField || ''] || activeMetric.value.color;
if (gaugeItems.length && gaugeChart) {
const gaugeCenters = buildGaugeCenters(gaugeItems.length);
@ -361,7 +359,7 @@ const renderCharts = () => {
const handleQuery = async () => {
loading.value = true;
try {
const { data } = await getVibrationOverviewData(buildQuery());
const { data } = await getDisplacementOverviewData(buildQuery());
overviewData.value = data || { metricCards: [], gaugeItems: [], deviceRanks: [] };
await nextTick();
// DOM

@ -4,10 +4,8 @@
:selection-label="queryForm.selectionLabel"
:time-range="daterangeRecordTime"
:sampling-interval="queryForm.samplingInterval"
:vibration-param="queryForm.vibrationParam"
@update:time-range="setTimeRange"
@update:sampling-interval="setSamplingInterval"
@update:vibration-param="setVibrationParam"
@query="handleQuery"
/>
@ -20,7 +18,7 @@
<template #header>
<div class="card-header">
<div class="card-title-group">
<div class="card-title">{{ trendData.multiDevice ? '设备对比趋势图' : '多指标趋势图' }}</div>
<div class="card-title">{{ trendData.multiDevice ? '设备对比趋势图' : '位移趋势图' }}</div>
<div class="card-subtitle">
{{
trendData.multiDevice
@ -54,7 +52,7 @@
<script setup lang="ts">
import * as echarts from 'echarts';
import { getDisplacementTrendData as getVibrationTrendData, type VibrationTrendPageVO } from '@/api/ems/report/displacementBoard';
import { getDisplacementTrendData, type DisplacementTrendPageVO } from '@/api/ems/report/displacementBoard';
import HelpButton from '../components/HelpButton.vue';
import VibrationBoardFilter from '../components/VibrationBoardFilter.vue';
import VibrationBoardDeviceTree from '../components/VibrationBoardDeviceTree.vue';
@ -80,13 +78,12 @@ const {
handleTreeNodeClick,
setTimeRange,
setSamplingInterval,
setVibrationParam,
buildQuery
} = useVibrationBoardQueryState();
const trendData = ref<VibrationTrendPageVO>({ series: [], hourlyItems: [] });
const trendData = ref<DisplacementTrendPageVO>({ series: [], hourlyItems: [] });
const metricColorMap = Object.fromEntries(vibrationMetricOptions.map((item) => [item.field, item.color]));
const activeMetric = computed(() => getVibrationMetricOption(trendData.value.metricField || queryForm.value.vibrationParam));
const activeMetric = computed(() => getVibrationMetricOption(trendData.value.metricField));
const renderCharts = () => {
const trendChart = getChart(trendChartRef);
@ -100,7 +97,7 @@ const renderCharts = () => {
} else {
trendChart.setOption(
{
color: series.map((item) => metricColorMap[item.field || queryForm.value.vibrationParam] || activeMetric.value.color),
color: series.map((item) => metricColorMap[item.field || ''] || activeMetric.value.color),
tooltip: {
trigger: 'axis',
formatter: (params: any[]) => {
@ -131,7 +128,7 @@ const renderCharts = () => {
axisLabel: { color: '#64748b' }
},
series: series.map((item) => {
const seriesColor = metricColorMap[item.field || queryForm.value.vibrationParam] || activeMetric.value.color;
const seriesColor = metricColorMap[item.field || ''] || activeMetric.value.color;
return {
name: item.name,
type: 'line',
@ -222,7 +219,7 @@ const renderCharts = () => {
const handleQuery = async () => {
loading.value = true;
try {
const { data } = await getVibrationTrendData(buildQuery());
const { data } = await getDisplacementTrendData(buildQuery());
trendData.value = data || { series: [], hourlyItems: [] };
await nextTick();
//

Loading…
Cancel
Save