diff --git a/src/api/ems/types.ts b/src/api/ems/types.ts
index 6a8c169..7e5323b 100644
--- a/src/api/ems/types.ts
+++ b/src/api/ems/types.ts
@@ -133,9 +133,23 @@ export interface VibrationBoardQuery extends EmsQuery {
/**
* 位移专属看板查询参数。
- * 当前仍兼容 vibrationParam 字段,但业务上只允许 vibrationDisplacement。
+ *
+ *
不再继承 VibrationBoardQuery,也不再携带 vibrationParam 字段:
+ * 位移看板是单指标专属链路,后端 DisplacementBoardQueryBo 不接受指标切换,
+ * 前端再继续传 vibrationParam 只会让“位移专属”语义出现歧义。
*/
-export interface DisplacementBoardQuery extends VibrationBoardQuery {}
+export interface DisplacementBoardQuery extends EmsQuery {
+ monitorId?: EmsId | string;
+ monitorIds?: Array | 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 = 100,hysteresis = 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 = 100,hysteresis = 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 = 100,hysteresis = 5 → 值 ≥ 100 触发告警,只有值 ≤ 95 才视为恢复。
+ * 单位:与被测量同单位;为 null 或 0 表示不启用回差保护。
+ */
hysteresis?: EmsDecimalValue;
+ /**
+ * 持续触发秒数(Duration Seconds):测量值必须连续越界达到该秒数才会触发告警,用于过滤瞬时尖峰造成的误报。
+ * 示例:durationSec = 30 → 连续 30 秒越界才触发告警;中途恢复则计时清零。
+ * 单位:秒;为 null 或 0 表示不延迟,单采样越界即触发。
+ */
durationSec?: EmsId;
alarmLevel?: string;
notifyGroupId?: EmsId;
diff --git a/src/views/ems/base/monitorMetricThreshold/index.vue b/src/views/ems/base/monitorMetricThreshold/index.vue
index a102663..09e3fd4 100644
--- a/src/views/ems/base/monitorMetricThreshold/index.vue
+++ b/src/views/ems/base/monitorMetricThreshold/index.vue
@@ -112,7 +112,8 @@
-
+
+
@@ -186,17 +187,55 @@
-
+
+
+
+ 回差值
+
+
+
+
回差值(Hysteresis / 死区)
+
在告警阈值附近设置的"缓冲区间",防止测量值在阈值线上抖动导致告警反复触发/恢复(告警抖动)。
+
规则:越界后只有回落到 (告警上限 − 回差) 以下,或 (告警下限 + 回差) 以上,才判定为恢复。
+
示例:告警上限 = 100,回差 = 5 → 值 ≥ 100 触发告警,只有值 ≤ 95 才视为恢复。
+
单位与被测量一致;留空或 0 表示不启用回差保护。
+
+
+
+
+
+
+ 防止告警抖动,如告警上限=100、回差=5 → 值≤95 才视为恢复
-
+
+
+
+ 持续触发秒数
+
+
+
+
持续触发秒数(Duration Seconds)
+
测量值必须连续越界达到该秒数,才会正式触发告警,用于过滤瞬时尖峰造成的误报。
+
规则:越界计时累计 ≥ 该秒数才落库并推送;中途恢复则计时清零。
+
示例:持续秒数 = 30 → 连续 28 秒越界后回落不告警;连续 30 秒越界在第 30 秒触发告警。
+
单位:秒;留空或 0 表示不延迟,单采样越界即触发。
+
+
+
+
+
+
+ 过滤瞬时尖峰,需连续越界≥该秒数才触发告警
+
+
@@ -228,6 +268,7 @@
@@ -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;
- }
}
diff --git a/src/views/ems/report/tempBoard/advanced/index.vue b/src/views/ems/report/tempBoard/advanced/index.vue
index d20f011..3c352f5 100644
--- a/src/views/ems/report/tempBoard/advanced/index.vue
+++ b/src/views/ems/report/tempBoard/advanced/index.vue
@@ -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) => {
- // 只处理最新请求的结果
+ // 只处理最新请求的结果。
+ // 初次进入页面时顶部 sankey、river 两个观察器经常在同一帧命中,
+ // 这里用 renderInFrames(底层 requestAnimationFrame)包一层 renderFn,
+ // 保证即便多个懒加载同帧触发,也只会一帧画一个图,主线程不会连续几百毫秒被阻塞。
if (queryId === currentQueryId) {
- nextTick(() => renderFn(res.data));
+ nextTick(() => renderInFrames([() => renderFn(res.data)]));
}
})
.catch((err) => {
diff --git a/src/views/ems/report/tempBoard/comparison/index.vue b/src/views/ems/report/tempBoard/comparison/index.vue
index 9b1dea6..b6edc50 100644
--- a/src/views/ems/report/tempBoard/comparison/index.vue
+++ b/src/views/ems/report/tempBoard/comparison/index.vue
@@ -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 = `${params[0].name}
`
- params.forEach((p: any) => { html += `${p.marker} ${p.seriesName}: ${p.value}℃
` })
- 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 = `${params[0].name}
`
+ params.forEach((p: any) => { html += `${p.marker} ${p.seriesName}: ${p.value}℃
` })
+ 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() })
diff --git a/src/views/ems/report/tempBoard/components/renderInFrames.ts b/src/views/ems/report/tempBoard/components/renderInFrames.ts
new file mode 100644
index 0000000..7041649
--- /dev/null
+++ b/src/views/ems/report/tempBoard/components/renderInFrames.ts
@@ -0,0 +1,63 @@
+/**
+ * 把一组同步渲染任务按帧(requestAnimationFrame)依次执行,
+ * 每帧只跑一个 setOption / chart.resize,避免一次性把主线程钉死上百毫秒。
+ *
+ * 使用场景:
+ * - 多测点 / 多 KPI 同时出图的看板(trend、distribution、comparison、overview);
+ * - 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);
+ }
+ });
+ };
+}
diff --git a/src/views/ems/report/tempBoard/components/useChartResize.ts b/src/views/ems/report/tempBoard/components/useChartResize.ts
index d916e20..c87719b 100644
--- a/src/views/ems/report/tempBoard/components/useChartResize.ts
+++ b/src/views/ems/report/tempBoard/components/useChartResize.ts
@@ -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[]) {
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[]) {
}
});
// 同时监听窗口 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();
});
diff --git a/src/views/ems/report/tempBoard/distribution/index.vue b/src/views/ems/report/tempBoard/distribution/index.vue
index 1e459c8..b1bb1b5 100644
--- a/src/views/ems/report/tempBoard/distribution/index.vue
+++ b/src/views/ems/report/tempBoard/distribution/index.vue
@@ -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;
}
diff --git a/src/views/ems/report/tempBoard/overview/index.vue b/src/views/ems/report/tempBoard/overview/index.vue
index b4d069a..3456985 100644
--- a/src/views/ems/report/tempBoard/overview/index.vue
+++ b/src/views/ems/report/tempBoard/overview/index.vue
@@ -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}
温度: ${params[0].value}℃`
- },
- 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}
温度: ${params[0].value}℃`
+ },
+ 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}
温度: ${params[0].value}℃`
- },
- 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}
温度: ${params[0].value}℃`
+ },
+ 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(() => {
diff --git a/src/views/ems/report/tempBoard/performance-optimization.md b/src/views/ems/report/tempBoard/performance-optimization.md
index 629b761..f2bad01 100644
--- a/src/views/ems/report/tempBoard/performance-optimization.md
+++ b/src/views/ems/report/tempBoard/performance-optimization.md
@@ -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)
diff --git a/src/views/ems/report/tempBoard/trend/index.vue b/src/views/ems/report/tempBoard/trend/index.vue
index 528b34c..59027dc 100644
--- a/src/views/ems/report/tempBoard/trend/index.vue
+++ b/src/views/ems/report/tempBoard/trend/index.vue
@@ -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 次 setOption,LTTB 后仍可能把主线程钉死 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;
}
diff --git a/src/views/ems/report/vibrationBoard/advanced/index.vue b/src/views/ems/report/vibrationBoard/advanced/index.vue
index f86749e..3adf54e 100644
--- a/src/views/ems/report/vibrationBoard/advanced/index.vue
+++ b/src/views/ems/report/vibrationBoard/advanced/index.vue
@@ -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 @@