feat: 新增物联网报表模块及实时告警处理组件

新增多个物联网报表页面,包括趋势总览、质量诊断、设备对比等报表视图
添加实时告警处理弹窗组件,支持告警展示、SOP步骤查看和处理操作
实现计量平衡、能效分析和仪表工况等高级报表功能
引入新的图表组件和工具函数,优化报表数据展示效果
重构告警规则表单,增强通知组选择器的可读性
main
zch 1 month ago
parent 08f83fb91e
commit 099a61b80e

@ -0,0 +1,482 @@
<template>
<el-dialog
v-model="dialogVisible"
width="720px"
:show-close="true"
:close-on-click-modal="false"
:before-close="handleDialogClose"
destroy-on-close
class="realtime-alarm-dialog"
>
<template #header>
<div class="dialog-header">
<div>
<div class="dialog-kicker">REALTIME ALARM</div>
<div class="dialog-title">{{ currentAlarm?.alarmTitle || '实时告警待处置' }}</div>
</div>
<div class="dialog-badges">
<el-tag type="danger" effect="dark">实时告警</el-tag>
<el-tag type="warning" effect="plain">待处理 {{ pendingCount }}</el-tag>
</div>
</div>
</template>
<template v-if="currentAlarm">
<el-descriptions :column="2" border size="small" class="alarm-descriptions">
<el-descriptions-item label="点位">{{ currentAlarm.monitorName || currentAlarm.monitorId || '-' }}</el-descriptions-item>
<el-descriptions-item label="记录时间">{{ formatDateTime(currentAlarm.collectTime) }}</el-descriptions-item>
<el-descriptions-item label="异常原因">{{ currentAlarm.cause || '-' }}</el-descriptions-item>
<el-descriptions-item label="告警级别">{{ currentAlarm.alarmLevel || '-' }}</el-descriptions-item>
<el-descriptions-item label="实际值">{{ formatValue(currentAlarm.actualValue ?? currentAlarm.alarmData) }}</el-descriptions-item>
<el-descriptions-item label="阈值">{{ formatValue(currentAlarm.thresholdValue) }}</el-descriptions-item>
<el-descriptions-item :span="2" label="告警内容">{{ currentAlarm.alarmContent || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="sop-header">
<div>
<div class="sop-title">命中 SOP</div>
<div class="sop-tip"> `monitorId + cause` 实时命中处置步骤避免值班人员还要二次检索</div>
</div>
<el-button text type="primary" :loading="sopLoading" @click="loadRealtimeActionSteps(currentAlarm)"> SOP</el-button>
</div>
<el-skeleton :loading="sopLoading" animated>
<template #template>
<div class="skeleton-row" v-for="index in 2" :key="index">
<el-skeleton-item variant="p" style="width: 100%; height: 72px" />
</div>
</template>
<template #default>
<el-empty v-if="!actionSteps.length" description="当前告警未命中处置步骤,请补充规则措施配置" />
<div v-else class="sop-list">
<article v-for="step in actionSteps" :key="step.objId || step.stepSequence" class="sop-card">
<div class="sop-sequence">步骤 {{ step.stepSequence || '-' }}</div>
<div class="sop-desc">{{ step.description || '-' }}</div>
<div v-if="step.remark" class="sop-remark">{{ step.remark }}</div>
<div v-if="step.stepImages?.length" class="sop-image-list">
<button
v-for="image in step.stepImages"
:key="image.objId || image.imageSequence"
type="button"
class="sop-image-card"
@click="previewImage(image.imageUrl)"
>
<img :src="resolveImageUrl(image.imageUrl)" :alt="image.description || 'SOP 参考图'" />
<span>{{ image.description || `图片 ${image.imageSequence || ''}` }}</span>
</button>
</div>
</article>
</div>
</template>
</el-skeleton>
</template>
<template #footer>
<div class="dialog-footer">
<el-button @click="deferCurrentAlarm"></el-button>
<el-button @click="returnToHome"></el-button>
<el-button type="primary" :loading="handling" @click="confirmCurrentAlarm"></el-button>
</div>
</template>
</el-dialog>
<transition name="fade-slide">
<div v-if="deferredQueue.length" class="deferred-float">
<div class="deferred-title">稍后处理队列</div>
<div class="deferred-count">{{ deferredQueue.length }} 条待继续处理</div>
<el-button size="small" type="primary" @click="resumeDeferredAlarm"></el-button>
</div>
</transition>
<el-dialog v-model="imagePreviewVisible" title="SOP 参考图" width="760px" append-to-body>
<div class="image-preview-container">
<img :src="previewImageUrl" alt="SOP 参考图预览" />
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus';
import { parseTime } from '@/utils/ruoyi';
import { getEmsAlarmActionStepsByAlarmInfo } from '@/api/ems/base/emsAlarmActionStep';
import { handleExceptions } from '@/api/ems/record/recordAlarmData';
import { useAlarmRealtimeBus } from '@/utils/alarmRealtime';
import type { AlarmHandleResultVO, EmsAlarmActionStepVO, EmsRecordAlarmDataVO } from '@/api/ems/types';
const router = useRouter();
const dialogVisible = ref(false);
const handling = ref(false);
const sopLoading = ref(false);
const currentAlarm = ref<EmsRecordAlarmDataVO | null>(null);
const currentQueueSource = ref<'live' | 'deferred' | null>(null);
const liveQueue = ref<EmsRecordAlarmDataVO[]>([]);
const deferredQueue = ref<EmsRecordAlarmDataVO[]>([]);
const actionSteps = ref<EmsAlarmActionStepVO[]>([]);
const imagePreviewVisible = ref(false);
const previewImageUrl = ref('');
const handledKeys = reactive(new Set<string>());
const pendingCount = computed(() => liveQueue.value.length + deferredQueue.value.length + (currentAlarm.value ? 1 : 0));
const buildAlarmKey = (alarm?: EmsRecordAlarmDataVO | null) => {
if (!alarm) {
return '';
}
return String(alarm.objId ?? `${alarm.monitorId || ''}_${alarm.cause || ''}_${alarm.collectTime || ''}`);
};
const formatDateTime = (value?: string | Date | number | null) => (value ? parseTime(value, '{y}-{m}-{d} {h}:{i}:{s}') : '-');
const formatValue = (value?: string | number | null) => (value === null || value === undefined || value === '' ? '-' : value);
const resolveImageUrl = (imageUrl?: string | null) => {
if (!imageUrl) {
return '';
}
if (/^https?:\/\//.test(imageUrl)) {
return imageUrl;
}
return `${import.meta.env.VITE_APP_BASE_API || ''}${imageUrl}`;
};
const previewImage = (imageUrl?: string | null) => {
if (!imageUrl) {
return;
}
previewImageUrl.value = resolveImageUrl(imageUrl);
imagePreviewVisible.value = true;
};
const loadRealtimeActionSteps = async (alarm: EmsRecordAlarmDataVO) => {
if (!alarm.monitorId || !alarm.cause) {
actionSteps.value = [];
return;
}
sopLoading.value = true;
try {
const response = await getEmsAlarmActionStepsByAlarmInfo(alarm.monitorId, alarm.cause);
actionSteps.value = ((response as any).data ?? []) as EmsAlarmActionStepVO[];
} catch (error) {
actionSteps.value = [];
ElMessage.warning('SOP 加载失败,请稍后重试');
} finally {
sopLoading.value = false;
}
};
const openAlarmFromQueue = async (queueSource: 'live' | 'deferred') => {
if (currentAlarm.value) {
return;
}
const targetQueue = queueSource === 'live' ? liveQueue.value : deferredQueue.value;
const nextAlarm = targetQueue.shift();
if (!nextAlarm) {
return;
}
currentAlarm.value = nextAlarm;
currentQueueSource.value = queueSource;
dialogVisible.value = true;
await loadRealtimeActionSteps(nextAlarm);
};
const openNextLiveAlarm = async () => {
if (!currentAlarm.value && liveQueue.value.length) {
await openAlarmFromQueue('live');
}
};
const enqueueRealtimeAlarms = async (alarms: EmsRecordAlarmDataVO[]) => {
const newAlarms = alarms.filter((alarm) => {
const alarmKey = buildAlarmKey(alarm);
if (!alarmKey || handledKeys.has(alarmKey) || String(alarm.alarmStatus) === '0') {
return false;
}
const isDuplicate =
buildAlarmKey(currentAlarm.value) === alarmKey ||
liveQueue.value.some((item) => buildAlarmKey(item) === alarmKey) ||
deferredQueue.value.some((item) => buildAlarmKey(item) === alarmKey);
return !isDuplicate;
});
if (!newAlarms.length) {
return;
}
liveQueue.value.push(...newAlarms);
await openNextLiveAlarm();
};
const consumeHandleResult = (result?: AlarmHandleResultVO | null) => {
const updatedCount = Number(result?.updatedCount ?? 0);
const alreadyHandledCount = Number(result?.alreadyHandledCount ?? 0);
if (updatedCount > 0 || alreadyHandledCount > 0) {
return true;
}
return false;
};
const clearCurrentAlarm = async () => {
if (currentAlarm.value) {
handledKeys.add(buildAlarmKey(currentAlarm.value));
}
currentAlarm.value = null;
currentQueueSource.value = null;
actionSteps.value = [];
dialogVisible.value = false;
await openNextLiveAlarm();
};
const confirmCurrentAlarm = async () => {
if (!currentAlarm.value?.objId) {
return;
}
handling.value = true;
try {
const response = await handleExceptions(currentAlarm.value.objId);
const success = consumeHandleResult((response as any).data ?? null);
if (!success) {
ElMessage.warning((response as any).msg || '告警状态未更新,请刷新后重试');
return;
}
ElMessage.success('告警状态已回写为已处理');
await clearCurrentAlarm();
} finally {
handling.value = false;
}
};
const deferCurrentAlarm = async () => {
if (!currentAlarm.value) {
dialogVisible.value = false;
return;
}
//
deferredQueue.value.push(currentAlarm.value);
currentAlarm.value = null;
currentQueueSource.value = null;
actionSteps.value = [];
dialogVisible.value = false;
await openNextLiveAlarm();
};
const returnToHome = async () => {
await router.push('/index');
await deferCurrentAlarm();
};
const resumeDeferredAlarm = async () => {
if (currentAlarm.value) {
return;
}
await openAlarmFromQueue('deferred');
};
const handleDialogClose = async (done: () => void) => {
await deferCurrentAlarm();
done();
};
let stopBus: (() => void) | undefined;
onMounted(() => {
stopBus = useAlarmRealtimeBus().on((event) => {
void enqueueRealtimeAlarms(event.alarms || []);
});
});
onUnmounted(() => {
stopBus?.();
});
</script>
<style scoped>
.dialog-header {
align-items: center;
display: flex;
justify-content: space-between;
gap: 12px;
}
.dialog-kicker {
color: var(--el-text-color-secondary);
font-size: 12px;
letter-spacing: 0.16em;
}
.dialog-title {
color: var(--el-text-color-primary);
font-size: 22px;
font-weight: 700;
margin-top: 6px;
}
.dialog-badges {
display: flex;
gap: 8px;
}
.alarm-descriptions {
margin-bottom: 20px;
}
.sop-header {
align-items: center;
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.sop-title {
font-size: 16px;
font-weight: 600;
}
.sop-tip {
color: var(--el-text-color-secondary);
font-size: 13px;
margin-top: 4px;
}
.skeleton-row + .skeleton-row {
margin-top: 12px;
}
.sop-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.sop-card {
background: linear-gradient(135deg, rgba(250, 244, 235, 0.9), rgba(255, 249, 245, 0.95));
border: 1px solid rgba(194, 154, 132, 0.22);
border-radius: 16px;
padding: 16px;
}
.sop-sequence {
color: #9a5f44;
font-size: 12px;
letter-spacing: 0.12em;
}
.sop-desc {
color: #43261c;
font-size: 15px;
font-weight: 600;
line-height: 1.7;
margin-top: 8px;
}
.sop-remark {
color: #7b665c;
font-size: 13px;
line-height: 1.7;
margin-top: 8px;
}
.sop-image-list {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
margin-top: 12px;
}
.sop-image-card {
align-items: stretch;
background: #fff;
border: 1px solid rgba(194, 154, 132, 0.24);
border-radius: 14px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
text-align: left;
}
.sop-image-card img {
border-radius: 10px;
height: 110px;
object-fit: cover;
width: 100%;
}
.sop-image-card span {
color: #5d463f;
font-size: 12px;
line-height: 1.5;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.deferred-float {
background: rgba(68, 30, 22, 0.96);
border-radius: 18px;
bottom: 24px;
box-shadow: 0 18px 40px rgba(33, 16, 12, 0.22);
color: #fff6ef;
padding: 16px 18px;
position: fixed;
right: 24px;
width: 240px;
z-index: 2100;
}
.deferred-title {
font-size: 14px;
font-weight: 600;
}
.deferred-count {
color: rgba(255, 244, 235, 0.8);
font-size: 12px;
line-height: 1.7;
margin: 8px 0 12px;
}
.image-preview-container {
align-items: center;
display: flex;
justify-content: center;
min-height: 320px;
}
.image-preview-container img {
max-height: 70vh;
max-width: 100%;
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.2s ease;
}
.fade-slide-enter-from,
.fade-slide-leave-to {
opacity: 0;
transform: translateY(8px);
}
@media (max-width: 768px) {
.dialog-header {
align-items: flex-start;
flex-direction: column;
}
.dialog-badges {
flex-wrap: wrap;
}
.deferred-float {
bottom: 16px;
left: 16px;
right: 16px;
width: auto;
}
}
</style>

@ -0,0 +1,88 @@
import type { EmsRecordAlarmDataVO } from '@/api/ems/types';
export const EMS_REALTIME_ALARM_EVENT = 'ems:alarm:realtime';
export const EMS_REALTIME_ALARM_PAYLOAD_TYPE = 'ems_alarm_realtime';
export interface EmsRealtimeAlarmEnvelope {
eventType: typeof EMS_REALTIME_ALARM_PAYLOAD_TYPE;
channel?: 'SSE' | 'WEBSOCKET' | 'UNKNOWN';
source?: string;
generatedAt?: string;
alarms: EmsRecordAlarmDataVO[];
}
interface LegacyRealtimeAlarmEnvelope {
eventType?: string;
source?: string;
eventTime?: string;
payload?: {
records?: EmsRecordAlarmDataVO[];
};
}
export interface ParsedAlarmMessage {
envelope: EmsRealtimeAlarmEnvelope | null;
displayMessage: string;
}
export const useAlarmRealtimeBus = () => useEventBus<EmsRealtimeAlarmEnvelope>(EMS_REALTIME_ALARM_EVENT);
const isRealtimeAlarmEnvelope = (value: unknown): value is EmsRealtimeAlarmEnvelope => {
if (!value || typeof value !== 'object') {
return false;
}
const envelope = value as EmsRealtimeAlarmEnvelope;
return envelope.eventType === EMS_REALTIME_ALARM_PAYLOAD_TYPE && Array.isArray(envelope.alarms);
};
const normalizeLegacyEnvelope = (value: unknown): EmsRealtimeAlarmEnvelope | null => {
if (!value || typeof value !== 'object') {
return null;
}
const legacyEnvelope = value as LegacyRealtimeAlarmEnvelope;
const records = legacyEnvelope.payload?.records;
if (legacyEnvelope.eventType !== 'EMS_REALTIME_ALARM_BATCH' || !Array.isArray(records)) {
return null;
}
return {
eventType: EMS_REALTIME_ALARM_PAYLOAD_TYPE,
source: legacyEnvelope.source,
generatedAt: legacyEnvelope.eventTime,
alarms: records
};
};
export const parseAlarmRealtimeMessage = (rawMessage: unknown): ParsedAlarmMessage => {
const fallbackMessage = typeof rawMessage === 'string' ? rawMessage : JSON.stringify(rawMessage ?? '');
if (typeof rawMessage !== 'string') {
return {
envelope: null,
displayMessage: fallbackMessage
};
}
try {
const parsed = JSON.parse(rawMessage);
const envelope = isRealtimeAlarmEnvelope(parsed) ? parsed : normalizeLegacyEnvelope(parsed);
if (!envelope) {
return {
envelope: null,
displayMessage: fallbackMessage
};
}
const firstAlarm = envelope.alarms[0];
const alarmCount = envelope.alarms.length;
const displayMessage =
alarmCount > 1
? `收到 ${alarmCount} 条实时告警,已进入待处置队列`
: `收到实时告警:${firstAlarm?.alarmTitle || firstAlarm?.monitorName || firstAlarm?.monitorId || '未命名告警'}`;
return {
envelope,
displayMessage
};
} catch {
return {
envelope: null,
displayMessage: fallbackMessage
};
}
};

@ -226,7 +226,14 @@
<el-input v-model="form.alarmLevel" placeholder="请输入告警级别" /> <el-input v-model="form.alarmLevel" placeholder="请输入告警级别" />
</el-form-item> </el-form-item>
<el-form-item label="通知组" prop="notifyGroupId"> <el-form-item label="通知组" prop="notifyGroupId">
<el-select v-model="form.notifyGroupId" placeholder="请选择通知组" clearable filterable style="width: 100%" @change="handleNotifyGroupChange"> <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-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>

@ -0,0 +1,99 @@
<template>
<section class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Alarm Trend</p>
<h3>告警趋势</h3>
</div>
<el-tag size="small" effect="plain"> {{ trend.length }} 个时间桶</el-tag>
</div>
<Chart class="chart-host" :chart-option="chartOption" />
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import Chart from '@/components/Charts/Chart.vue';
import type { AlertTrendPointVO } from '@/api/ems/types';
defineOptions({
name: 'AlarmTrendPanel'
});
const props = defineProps<{
trend: AlertTrendPointVO[];
}>();
const chartOption = computed(() => {
return {
color: ['#ef4444'],
tooltip: {
trigger: 'axis'
},
grid: {
top: 20,
left: '8%',
right: '4%',
bottom: 36
},
xAxis: {
type: 'category',
data: props.trend.map((item) => item.timeBucket)
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
type: 'dashed'
}
}
},
series: [
{
type: 'line',
smooth: true,
areaStyle: {
opacity: 0.18
},
data: props.trend.map((item) => item.alarmCount)
}
]
};
});
</script>
<style scoped>
.glass-panel {
padding: 18px 20px;
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.panel-eyebrow {
margin: 0 0 6px;
color: #2563eb;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.panel-head h3 {
margin: 0;
}
.chart-host {
width: 100%;
height: 320px;
}
</style>

@ -0,0 +1,127 @@
<template>
<section class="glass-panel score-board">
<div>
<p class="panel-eyebrow">Efficiency Score</p>
<h3>{{ templateName || '运行能效评分' }}</h3>
<p class="score-desc">{{ analysisConclusion || '评分结果仅用于工况横向比较。' }}</p>
</div>
<div class="score-ring">
<div class="score-label">综合评分</div>
<div class="score-value">{{ formatNumber(overallScore) }}</div>
<div class="score-foot">100 分制</div>
</div>
<div class="score-items">
<div v-for="item in metricScores.slice(0, 4)" :key="item.metricCode" class="score-item">
<span>{{ item.metricName }}</span>
<strong>{{ formatNumber(item.score) }}</strong>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { EfficiencyMetricScoreVO } from '@/api/ems/types';
defineOptions({
name: 'EfficiencyScoreBoard'
});
defineProps<{
overallScore?: number | string;
templateName?: string;
analysisConclusion?: string;
metricScores: EfficiencyMetricScoreVO[];
}>();
const formatNumber = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '--';
}
const numberValue = Number(value);
return Number.isNaN(numberValue) ? '--' : numberValue.toFixed(2);
};
</script>
<style scoped>
.glass-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) 180px minmax(220px, 280px);
gap: 18px;
align-items: center;
padding: 20px;
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.panel-eyebrow {
margin: 0 0 6px;
color: #2563eb;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.glass-panel h3 {
margin: 0;
}
.score-desc {
margin: 10px 0 0;
color: #64748b;
line-height: 1.7;
}
.score-ring {
display: grid;
place-items: center;
min-height: 180px;
border-radius: 50%;
background: radial-gradient(circle, rgba(37, 99, 235, 0.1) 0%, rgba(15, 118, 110, 0.16) 100%);
}
.score-label,
.score-foot {
color: #64748b;
font-size: 13px;
}
.score-value {
margin-top: 8px;
font-size: 44px;
font-weight: 700;
color: #0f172a;
}
.score-items {
display: grid;
gap: 12px;
}
.score-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.96));
}
.score-item span {
color: #475569;
}
.score-item strong {
color: #0f172a;
}
@media (max-width: 992px) {
.glass-panel {
grid-template-columns: 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,104 @@
<template>
<section class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Balance Circuits</p>
<h3>回路差异明细</h3>
</div>
<el-tag size="small" effect="plain"> {{ circuits.length }} 条回路</el-tag>
</div>
<el-empty v-if="!circuits.length" description="当前没有可展示的回路结果" />
<el-table v-else :data="circuits" stripe>
<el-table-column prop="monitorName" label="回路节点" min-width="180" />
<el-table-column prop="upstreamValue" label="上游值" min-width="120">
<template #default="{ row }">{{ formatNumber(row.upstreamValue) }}</template>
</el-table-column>
<el-table-column prop="downstreamTotal" label="下游汇总" min-width="120">
<template #default="{ row }">{{ formatNumber(row.downstreamTotal) }}</template>
</el-table-column>
<el-table-column prop="diffValue" label="差异值" min-width="120">
<template #default="{ row }">{{ formatNumber(row.diffValue) }}</template>
</el-table-column>
<el-table-column prop="diffRate" label="差异率" min-width="120">
<template #default="{ row }">{{ formatPercent(row.diffRate) }}</template>
</el-table-column>
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
<el-tag :type="row.abnormal ? 'danger' : 'success'" size="small">
{{ row.abnormal ? '超阈值' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="$emit('locate', row)">工况复盘</el-button>
</template>
</el-table-column>
</el-table>
</section>
</template>
<script setup lang="ts">
import type { MeterBalanceCircuitVO } from '@/api/ems/types';
defineOptions({
name: 'MeterBalanceTopology'
});
defineProps<{
circuits: MeterBalanceCircuitVO[];
}>();
defineEmits<{
(event: 'locate', row: MeterBalanceCircuitVO): void;
}>();
const formatNumber = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '--';
}
const numberValue = Number(value);
return Number.isNaN(numberValue) ? '--' : numberValue.toFixed(2);
};
const formatPercent = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '--';
}
const numberValue = Number(value);
return Number.isNaN(numberValue) ? '--' : `${(numberValue * 100).toFixed(2)}%`;
};
</script>
<style scoped>
.glass-panel {
padding: 18px 20px;
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.panel-eyebrow {
margin: 0 0 6px;
color: #2563eb;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.panel-head h3 {
margin: 0;
}
</style>

@ -0,0 +1,346 @@
<template>
<div class="app-container report-page">
<el-row :gutter="18">
<el-col :xl="5" :lg="6" :md="7" :sm="24" :xs="24">
<section class="glass-panel tree-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Scope Tree</p>
<h3>评分范围树</h3>
</div>
</div>
<el-tree
v-loading="treeLoading"
:data="treeData"
node-key="id"
:props="{ label: 'label', children: 'children' }"
default-expand-all
highlight-current
@node-click="handleNodeClick"
/>
</section>
</el-col>
<el-col :xl="19" :lg="18" :md="17" :sm="24" :xs="24">
<section class="hero-panel">
<div>
<p class="panel-eyebrow hero-eyebrow">Efficiency Analysis</p>
<h2>综合运行能效分析</h2>
<p class="hero-desc">当前评分严格定义为运行工况评分只用于设备横向比较与状态诊断不表示财务结算能效</p>
</div>
<el-tag effect="dark">{{ selectionLabel || '未选择范围' }}</el-tag>
</section>
<section class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Query</p>
<h3>评分筛选</h3>
</div>
<el-button type="primary" @click="handleQuery"></el-button>
</div>
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="分析范围">
<el-input :model-value="selectionLabel || ''" readonly style="width: 280px" />
</el-form-item>
<el-form-item label="记录时间">
<el-date-picker
v-model="timeRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="-"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 340px"
/>
</el-form-item>
<el-form-item label="指标">
<el-select
v-model="queryForm.metricCodes"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="自动识别有效指标"
style="width: 320px"
>
<el-option v-for="item in metricOptions" :key="item.metricCode" :label="item.metricName" :value="item.metricCode" />
</el-select>
</el-form-item>
</el-form>
</section>
<EfficiencyScoreBoard
v-if="reportData && reportData.hasData !== false"
:overall-score="reportData.overallScore"
:template-name="reportData.templateName"
:analysis-conclusion="reportData.analysisConclusion"
:metric-scores="reportData.metricScores || []"
/>
<section v-loading="loading" class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Ranking</p>
<h3>设备评分排行</h3>
</div>
</div>
<el-empty v-if="!reportData || reportData.hasData === false" :description="reportData?.emptyReason || '当前条件下无可分析数据'" />
<Chart v-else class="chart-host" :chart-option="chartOption" />
</section>
<section v-if="reportData?.metricScores?.length" class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Metrics</p>
<h3>指标评分明细</h3>
</div>
</div>
<el-table :data="reportData.metricScores" stripe>
<el-table-column prop="metricName" label="指标" min-width="140" />
<el-table-column prop="averageValue" label="均值" min-width="100">
<template #default="{ row }">{{ formatNumber(row.averageValue) }}</template>
</el-table-column>
<el-table-column prop="peakValue" label="峰值" min-width="100">
<template #default="{ row }">{{ formatNumber(row.peakValue) }}</template>
</el-table-column>
<el-table-column prop="stabilityScore" label="稳定性" min-width="100">
<template #default="{ row }">{{ formatNumber(row.stabilityScore) }}</template>
</el-table-column>
<el-table-column prop="fluctuationScore" label="波动性" min-width="100">
<template #default="{ row }">{{ formatNumber(row.fluctuationScore) }}</template>
</el-table-column>
<el-table-column prop="thresholdScore" label="越界得分" min-width="100">
<template #default="{ row }">{{ formatNumber(row.thresholdScore) }}</template>
</el-table-column>
<el-table-column prop="score" label="综合得分" min-width="100">
<template #default="{ row }">{{ formatNumber(row.score) }}</template>
</el-table-column>
</el-table>
</section>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import type { EChartsOption } from 'echarts';
import { ElMessage } from 'element-plus';
import Chart from '@/components/Charts/Chart.vue';
import EfficiencyScoreBoard from '../components/EfficiencyScoreBoard.vue';
import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo';
import { getEfficiencyAnalysisReport } from '@/api/ems/report/iotAdvancedReport';
import type { EfficiencyAnalysisVO, EmsTreeNode, IotMetricOptionVO } from '@/api/ems/types';
import { parseTime } from '@/utils/ruoyi';
defineOptions({
name: 'ComprehensiveEnergyEfficiencyReport'
});
const treeLoading = ref(false);
const loading = ref(false);
const treeData = ref<EmsTreeNode[]>([]);
const selectionLabel = ref('');
const reportData = ref<EfficiencyAnalysisVO>();
const createDefaultRange = () => {
const now = new Date();
const start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
return [parseTime(start, '{y}-{m}-{d} {h}:{i}:{s}'), parseTime(now, '{y}-{m}-{d} {h}:{i}:{s}')];
};
const timeRange = ref<string[]>(createDefaultRange());
const queryForm = reactive({
monitorIds: [] as string[],
metricCodes: [] as string[]
});
const metricOptions = computed<IotMetricOptionVO[]>(() =>
(reportData.value?.metricScores || []).map((item) => ({
metricCode: item.metricCode,
metricName: item.metricName,
unit: item.unit
}))
);
const chartOption = computed<EChartsOption>(() => {
const deviceScores = reportData.value?.deviceScores || [];
return {
color: ['#2563eb'],
tooltip: {
trigger: 'axis'
},
grid: {
top: 20,
left: '14%',
right: '6%',
bottom: 36
},
xAxis: {
type: 'value',
max: 100
},
yAxis: {
type: 'category',
data: deviceScores.map((item) => item.monitorName)
},
series: [
{
type: 'bar',
data: deviceScores.map((item) => Number(item.totalScore || 0)),
itemStyle: {
borderRadius: [0, 6, 6, 0]
}
}
]
};
});
const getLeafNodes = (nodes: EmsTreeNode[]): EmsTreeNode[] => {
let result: EmsTreeNode[] = [];
(nodes || []).forEach((item) => {
if (item.children && item.children.length > 0) {
result = result.concat(getLeafNodes(item.children));
return;
}
if (item.code) {
result.push(item);
}
});
return result;
};
const loadTree = async () => {
treeLoading.value = true;
try {
const response = await getMonitorInfoTree({});
treeData.value = response.data || [];
} finally {
treeLoading.value = false;
}
};
const handleNodeClick = (node: EmsTreeNode) => {
const targetNodes = node.children && node.children.length > 0 ? getLeafNodes([node]) : [node];
queryForm.monitorIds = targetNodes.map((item) => String(item.code || '')).filter((item) => item);
selectionLabel.value = String(node.label || '当前节点');
};
const handleQuery = async () => {
if (!queryForm.monitorIds.length) {
ElMessage.warning('请先选择评分范围');
return;
}
if (!timeRange.value || timeRange.value.length !== 2) {
ElMessage.warning('请选择完整的记录时间范围');
return;
}
loading.value = true;
try {
const { data } = await getEfficiencyAnalysisReport({
monitorIds: queryForm.monitorIds,
metricCodes: queryForm.metricCodes,
beginRecordTime: timeRange.value[0],
endRecordTime: timeRange.value[1]
});
reportData.value = data || { hasData: false, emptyReason: '接口未返回有效数据' };
} finally {
loading.value = false;
}
};
const formatNumber = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '--';
}
const numberValue = Number(value);
return Number.isNaN(numberValue) ? '--' : numberValue.toFixed(2);
};
onMounted(async () => {
await loadTree();
});
</script>
<style scoped>
.report-page {
min-height: calc(100vh - 84px);
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.14), transparent 24%),
radial-gradient(circle at top left, rgba(20, 184, 166, 0.1), transparent 28%), linear-gradient(180deg, #f4f8ff 0%, #f8fafc 100%);
}
.glass-panel,
.hero-panel {
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.tree-panel,
.glass-panel {
padding: 18px 20px;
margin-bottom: 18px;
}
.hero-panel {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 24px;
margin-bottom: 18px;
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(37, 99, 235, 0.92)),
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0));
color: #ffffff;
}
.hero-eyebrow {
color: rgba(255, 255, 255, 0.72);
}
.hero-panel h2,
.panel-head h3 {
margin: 0;
}
.hero-desc {
margin: 14px 0 0;
max-width: 760px;
line-height: 1.75;
color: rgba(255, 255, 255, 0.84);
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.panel-eyebrow {
margin: 0 0 6px;
color: #2563eb;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.chart-host {
width: 100%;
height: 320px;
}
@media (max-width: 992px) {
.hero-panel {
display: block;
}
}
</style>

@ -0,0 +1,318 @@
<template>
<div class="app-container report-page">
<el-row :gutter="18">
<el-col :xl="5" :lg="6" :md="7" :sm="24" :xs="24">
<section class="glass-panel tree-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Alert Scope</p>
<h3>统计范围树</h3>
</div>
</div>
<el-tree
v-loading="treeLoading"
:data="treeData"
node-key="id"
:props="{ label: 'label', children: 'children' }"
default-expand-all
highlight-current
@node-click="handleNodeClick"
/>
</section>
</el-col>
<el-col :xl="19" :lg="18" :md="17" :sm="24" :xs="24">
<section class="hero-panel">
<div>
<p class="panel-eyebrow hero-eyebrow">Alarm Report</p>
<h2>用能异常报警报表</h2>
<p class="hero-desc">这里严格区分当前无数据当前 0 告警只有时间范围内存在分表数据时页面才会把 0 告警解释为真正未命中规则</p>
</div>
<el-tag effect="dark">{{ selectionLabel || '未选择范围' }}</el-tag>
</section>
<section class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Query</p>
<h3>报警筛选</h3>
</div>
<el-button type="primary" @click="handleQuery"></el-button>
</div>
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="统计范围">
<el-input :model-value="selectionLabel || ''" readonly style="width: 260px" />
</el-form-item>
<el-form-item label="记录时间">
<el-date-picker
v-model="timeRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="-"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 340px"
/>
</el-form-item>
<el-form-item label="处理状态">
<el-select v-model="queryForm.alarmStatus" clearable style="width: 120px">
<el-option label="未处理" :value="1" />
<el-option label="已处理" :value="0" />
</el-select>
</el-form-item>
</el-form>
</section>
<section v-loading="loading" class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Summary</p>
<h3>报警概览</h3>
</div>
</div>
<el-empty v-if="!reportData || reportData.hasData === false" :description="reportData?.emptyReason || '当前条件下无可分析数据'" />
<template v-else>
<div class="summary-grid">
<article class="summary-card">
<span>告警总数</span>
<strong>{{ reportData.summary?.totalCount || 0 }}</strong>
</article>
<article class="summary-card">
<span>未处理</span>
<strong>{{ reportData.summary?.unhandledCount || 0 }}</strong>
</article>
<article class="summary-card">
<span>已处理</span>
<strong>{{ reportData.summary?.handledCount || 0 }}</strong>
</article>
</div>
<AlarmTrendPanel :trend="reportData.trend || []" />
</template>
</section>
<section v-if="reportData?.ranking?.length" class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Ranking</p>
<h3>异常排行</h3>
</div>
</div>
<el-table :data="reportData.ranking" stripe>
<el-table-column prop="monitorName" label="点位" min-width="180" />
<el-table-column prop="alarmCount" label="告警数" min-width="100" />
<el-table-column prop="unhandledCount" label="未处理" min-width="100" />
</el-table>
</section>
<section v-if="reportData?.records?.length" class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Records</p>
<h3>最近报警记录</h3>
</div>
</div>
<el-table :data="reportData.records" stripe>
<el-table-column prop="monitorName" label="点位" min-width="160" />
<el-table-column prop="alarmTitle" label="告警标题" min-width="220" />
<el-table-column prop="actualValue" label="实际值" min-width="100" />
<el-table-column prop="alarmLevel" label="级别" min-width="100" />
<el-table-column prop="collectTime" label="记录时间" min-width="180" />
</el-table>
</section>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import AlarmTrendPanel from '../components/AlarmTrendPanel.vue';
import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo';
import { getEnergyAbnormalAlertReport } from '@/api/ems/report/iotAdvancedReport';
import type { EmsTreeNode, EnergyAbnormalAlertReportVO } from '@/api/ems/types';
import { parseTime } from '@/utils/ruoyi';
defineOptions({
name: 'EnergyAbnormalAlertReport'
});
const treeLoading = ref(false);
const loading = ref(false);
const treeData = ref<EmsTreeNode[]>([]);
const selectionLabel = ref('');
const reportData = ref<EnergyAbnormalAlertReportVO>();
const createDefaultRange = () => {
const now = new Date();
const start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return [parseTime(start, '{y}-{m}-{d} {h}:{i}:{s}'), parseTime(now, '{y}-{m}-{d} {h}:{i}:{s}')];
};
const timeRange = ref<string[]>(createDefaultRange());
const queryForm = reactive({
monitorIds: [] as string[],
alarmStatus: undefined as number | undefined
});
const getLeafNodes = (nodes: EmsTreeNode[]): EmsTreeNode[] => {
let result: EmsTreeNode[] = [];
(nodes || []).forEach((item) => {
if (item.children && item.children.length > 0) {
result = result.concat(getLeafNodes(item.children));
return;
}
if (item.code) {
result.push(item);
}
});
return result;
};
const loadTree = async () => {
treeLoading.value = true;
try {
const response = await getMonitorInfoTree({});
treeData.value = response.data || [];
} finally {
treeLoading.value = false;
}
};
const handleNodeClick = (node: EmsTreeNode) => {
const targetNodes = node.children && node.children.length > 0 ? getLeafNodes([node]) : [node];
queryForm.monitorIds = targetNodes.map((item) => String(item.code || '')).filter((item) => item);
selectionLabel.value = String(node.label || '当前节点');
};
const handleQuery = async () => {
if (!queryForm.monitorIds.length) {
ElMessage.warning('请先选择统计范围');
return;
}
if (!timeRange.value || timeRange.value.length !== 2) {
ElMessage.warning('请选择完整的记录时间范围');
return;
}
loading.value = true;
try {
const { data } = await getEnergyAbnormalAlertReport({
monitorIds: queryForm.monitorIds,
alarmStatus: queryForm.alarmStatus,
beginRecordTime: timeRange.value[0],
endRecordTime: timeRange.value[1]
});
reportData.value = data || { hasData: false, emptyReason: '接口未返回有效数据' };
} finally {
loading.value = false;
}
};
onMounted(async () => {
await loadTree();
});
</script>
<style scoped>
.report-page {
min-height: calc(100vh - 84px);
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.14), transparent 24%),
radial-gradient(circle at top left, rgba(20, 184, 166, 0.1), transparent 28%), linear-gradient(180deg, #f4f8ff 0%, #f8fafc 100%);
}
.glass-panel,
.hero-panel,
.summary-card {
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.tree-panel,
.glass-panel {
padding: 18px 20px;
margin-bottom: 18px;
}
.hero-panel {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 24px;
margin-bottom: 18px;
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(37, 99, 235, 0.92)),
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0));
color: #ffffff;
}
.hero-eyebrow {
color: rgba(255, 255, 255, 0.72);
}
.hero-panel h2,
.panel-head h3 {
margin: 0;
}
.hero-desc {
margin: 14px 0 0;
max-width: 760px;
line-height: 1.75;
color: rgba(255, 255, 255, 0.84);
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.panel-eyebrow {
margin: 0 0 6px;
color: #2563eb;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 18px;
}
.summary-card {
padding: 18px;
}
.summary-card span {
color: #64748b;
font-size: 13px;
}
.summary-card strong {
display: block;
margin-top: 12px;
font-size: 28px;
color: #0f172a;
}
@media (max-width: 992px) {
.hero-panel {
display: block;
}
}
</style>

@ -0,0 +1,510 @@
<template>
<div class="app-container report-page">
<el-row :gutter="18">
<el-col :xl="5" :lg="6" :md="7" :sm="24" :xs="24">
<section class="glass-panel tree-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Monitor Tree</p>
<h3>分析点位树</h3>
</div>
</div>
<el-input v-model="treeKeyword" placeholder="输入点位名称筛选" clearable class="tree-search" />
<el-tree
ref="treeRef"
v-loading="treeLoading"
:data="treeData"
node-key="id"
:props="{ label: 'label', children: 'children' }"
default-expand-all
highlight-current
:filter-node-method="filterTree"
@node-click="handleNodeClick"
/>
</section>
</el-col>
<el-col :xl="19" :lg="18" :md="17" :sm="24" :xs="24">
<section class="hero-panel">
<div>
<p class="panel-eyebrow hero-eyebrow">Instrument Condition</p>
<h2>仪表工况分析</h2>
<p class="hero-desc">
所有统计值都直接来自 `record_iotenv_instant_YYYYMMDD` 分表真实记录这里支持秒//时三种时间粒度并严格区分页面支持当前无数据
</p>
</div>
<div class="hero-tags">
<el-tag effect="dark">{{ selectionLabel || '未选择点位' }}</el-tag>
<el-tag effect="plain">{{ queryForm.samplingGranularity }}</el-tag>
<el-tag effect="plain">{{ queryForm.samplingInterval }} 间隔</el-tag>
</div>
</section>
<section class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Query</p>
<h3>分析筛选</h3>
</div>
<el-button type="primary" @click="handleQuery"></el-button>
</div>
<el-form :inline="true" :model="queryForm" size="small" class="query-form">
<el-form-item label="分析范围">
<el-input :model-value="selectionLabel || ''" readonly style="width: 280px" />
</el-form-item>
<el-form-item label="记录时间">
<el-date-picker
v-model="timeRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="-"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 340px"
/>
</el-form-item>
<el-form-item label="粒度">
<el-select v-model="queryForm.samplingGranularity" style="width: 120px">
<el-option label="秒级" value="SECOND" />
<el-option label="分钟级" value="MINUTE" />
<el-option label="小时级" value="HOUR" />
</el-select>
</el-form-item>
<el-form-item label="间隔">
<el-input-number v-model="queryForm.samplingInterval" :min="1" :max="3600" controls-position="right" />
</el-form-item>
<el-form-item label="指标">
<el-select
v-model="queryForm.metricCodes"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="自动识别全部有效指标"
style="width: 320px"
>
<el-option v-for="item in metricOptions" :key="item.metricCode" :label="item.metricName" :value="item.metricCode" />
</el-select>
</el-form-item>
</el-form>
</section>
<div v-if="reportData?.summaryCards?.length" class="summary-grid">
<article v-for="item in reportData.summaryCards" :key="item.metricCode" class="summary-card">
<div class="summary-title">{{ item.metricName }}</div>
<div class="summary-value">
{{ formatNumber(item.latest) }}<span>{{ item.unit }}</span>
</div>
<div class="summary-foot">均值 {{ formatNumber(item.avg) }}{{ item.unit }}</div>
<div class="summary-foot">峰值 {{ formatNumber(item.max) }}{{ item.unit }}</div>
</article>
</div>
<section v-loading="loading" class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Trend</p>
<h3>工况曲线</h3>
</div>
<el-tag size="small" effect="plain">{{ chartSeries.length }} 条序列</el-tag>
</div>
<el-empty v-if="!reportData || reportData.hasData === false" :description="reportData?.emptyReason || '当前条件下无可分析数据'" />
<Chart v-else class="chart-host" :chart-option="chartOption" />
</section>
<section v-if="reportData?.seriesList?.length" class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Series</p>
<h3>序列明细</h3>
</div>
</div>
<el-table :data="seriesTableRows" stripe>
<el-table-column prop="monitorName" label="点位" min-width="180" />
<el-table-column prop="metricName" label="指标" min-width="120" />
<el-table-column prop="sampleCount" label="采样点数" min-width="100" />
<el-table-column prop="firstTime" label="首点时间" min-width="180" />
<el-table-column prop="lastTime" label="末点时间" min-width="180" />
</el-table>
</section>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import type { EChartsOption } from 'echarts';
import { useRoute } from 'vue-router';
import Chart from '@/components/Charts/Chart.vue';
import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo';
import { getInstrumentConditionReport } from '@/api/ems/report/iotAdvancedReport';
import type { EmsTreeNode, InstrumentConditionReportVO, InstrumentConditionSeriesVO, IotMetricOptionVO } from '@/api/ems/types';
import { parseTime } from '@/utils/ruoyi';
defineOptions({
name: 'InstrumentConditionReport'
});
const treeRef = ref();
const route = useRoute();
const treeLoading = ref(false);
const loading = ref(false);
const treeKeyword = ref('');
const treeData = ref<EmsTreeNode[]>([]);
const selectionLabel = ref('');
const reportData = ref<InstrumentConditionReportVO>();
const createDefaultRange = () => {
const now = new Date();
const start = new Date(now.getTime() - 2 * 60 * 60 * 1000);
return [parseTime(start, '{y}-{m}-{d} {h}:{i}:{s}'), parseTime(now, '{y}-{m}-{d} {h}:{i}:{s}')];
};
const timeRange = ref<string[]>(createDefaultRange());
const queryForm = reactive({
monitorIds: [] as string[],
samplingGranularity: 'MINUTE',
samplingInterval: 5,
metricCodes: [] as string[]
});
const metricOptions = computed<IotMetricOptionVO[]>(() => reportData.value?.availableMetrics || []);
const activeSeries = computed(() => {
const source = reportData.value?.seriesList || [];
if (!queryForm.metricCodes.length) {
return source;
}
return source.filter((item) => queryForm.metricCodes.includes(String(item.metricCode || '')));
});
const chartSeries = computed(() => activeSeries.value);
const chartOption = computed<EChartsOption>(() => {
const timeAxis = Array.from(
new Set(chartSeries.value.flatMap((item) => (item.points || []).map((point) => String(point.time || ''))).filter((item) => item))
).sort();
return {
tooltip: {
trigger: 'axis'
},
legend: {
top: 8,
data: chartSeries.value.map((item) => buildSeriesName(item))
},
grid: {
top: 60,
left: '7%',
right: '4%',
bottom: 48
},
dataZoom: [
{
type: 'slider',
height: 16
}
],
xAxis: {
type: 'category',
boundaryGap: false,
data: timeAxis
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
type: 'dashed'
}
}
},
series: chartSeries.value.map((item) => {
const pointMap = (item.points || []).reduce(
(accumulator, point) => {
accumulator[String(point.time || '')] = Number(point.value || 0);
return accumulator;
},
{} as Record<string, number>
);
return {
name: buildSeriesName(item),
type: 'line',
smooth: true,
connectNulls: false,
showSymbol: false,
data: timeAxis.map((time) => (Object.prototype.hasOwnProperty.call(pointMap, time) ? pointMap[time] : null))
};
})
};
});
const seriesTableRows = computed(() => {
return chartSeries.value.map((item) => ({
monitorName: item.monitorName,
metricName: item.metricName,
sampleCount: item.points?.length || 0,
firstTime: item.points?.[0]?.time || '--',
lastTime: item.points?.[item.points.length - 1]?.time || '--'
}));
});
const loadTree = async () => {
treeLoading.value = true;
try {
const response = await getMonitorInfoTree({});
treeData.value = response.data || [];
} finally {
treeLoading.value = false;
}
};
const getLeafNodes = (nodes: EmsTreeNode[]): EmsTreeNode[] => {
let result: EmsTreeNode[] = [];
(nodes || []).forEach((item) => {
if (item.children && item.children.length > 0) {
result = result.concat(getLeafNodes(item.children));
return;
}
if (item.code) {
result.push(item);
}
});
return result;
};
const handleNodeClick = (node: EmsTreeNode) => {
const targetNodes = node.children && node.children.length > 0 ? getLeafNodes([node]) : [node];
queryForm.monitorIds = targetNodes.map((item) => String(item.code || '')).filter((item) => item);
selectionLabel.value = String(node.label || '当前节点');
};
const handleQuery = async () => {
if (!queryForm.monitorIds.length) {
ElMessage.warning('请先选择分析点位');
return;
}
if (!timeRange.value || timeRange.value.length !== 2) {
ElMessage.warning('请选择完整的记录时间范围');
return;
}
loading.value = true;
try {
const { data } = await getInstrumentConditionReport({
monitorIds: queryForm.monitorIds,
metricCodes: queryForm.metricCodes,
samplingGranularity: queryForm.samplingGranularity,
samplingInterval: queryForm.samplingInterval,
beginRecordTime: timeRange.value[0],
endRecordTime: timeRange.value[1]
} as any);
reportData.value = data || { hasData: false, emptyReason: '接口未返回有效数据' };
} finally {
loading.value = false;
}
};
const buildSeriesName = (item: InstrumentConditionSeriesVO) => {
if (queryForm.monitorIds.length <= 1) {
return String(item.metricName || item.metricCode || '未命名指标');
}
return `${item.monitorName}-${item.metricName}`;
};
const filterTree = (value: string, data: EmsTreeNode) => {
if (!value) {
return true;
}
return String(data.label || '').includes(value);
};
const formatNumber = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '--';
}
const numberValue = Number(value);
return Number.isNaN(numberValue) ? '--' : numberValue.toFixed(2);
};
watch(treeKeyword, (value) => {
treeRef.value?.filter(value);
});
watch(
metricOptions,
(value) => {
if (!value.length) {
queryForm.metricCodes = [];
return;
}
queryForm.metricCodes = queryForm.metricCodes.filter((item) => value.some((metric) => metric.metricCode === item));
},
{
immediate: true
}
);
onMounted(async () => {
await loadTree();
await nextTick();
const routeMonitorIds = String(route.query.monitorIds || '')
.split(',')
.map((item) => item.trim())
.filter((item) => item);
if (routeMonitorIds.length) {
queryForm.monitorIds = routeMonitorIds;
selectionLabel.value = String(route.query.selectionLabel || '带参复盘范围');
}
const beginRecordTime = String(route.query.beginRecordTime || '');
const endRecordTime = String(route.query.endRecordTime || '');
if (beginRecordTime && endRecordTime) {
timeRange.value = [beginRecordTime, endRecordTime];
}
const metricCodes = String(route.query.metricCodes || '')
.split(',')
.map((item) => item.trim())
.filter((item) => item);
if (metricCodes.length) {
queryForm.metricCodes = metricCodes;
}
if (queryForm.monitorIds.length) {
await handleQuery();
}
});
</script>
<style scoped>
.report-page {
min-height: calc(100vh - 84px);
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.14), transparent 24%),
radial-gradient(circle at top left, rgba(20, 184, 166, 0.1), transparent 28%), linear-gradient(180deg, #f4f8ff 0%, #f8fafc 100%);
}
.glass-panel,
.hero-panel,
.summary-card {
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.tree-panel,
.glass-panel {
padding: 18px 20px;
margin-bottom: 18px;
}
.tree-search {
margin-bottom: 12px;
}
.hero-panel {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 24px;
margin-bottom: 18px;
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(37, 99, 235, 0.92)),
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0));
color: #ffffff;
}
.hero-eyebrow {
color: rgba(255, 255, 255, 0.72);
}
.hero-panel h2,
.panel-head h3 {
margin: 0;
}
.hero-desc {
margin: 14px 0 0;
max-width: 760px;
line-height: 1.75;
color: rgba(255, 255, 255, 0.84);
}
.hero-tags,
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.hero-tags {
flex-wrap: wrap;
align-content: flex-start;
}
.panel-head {
margin-bottom: 14px;
}
.panel-eyebrow {
margin: 0 0 6px;
color: #2563eb;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 18px;
}
.summary-card {
padding: 18px;
}
.summary-title {
color: #64748b;
font-size: 13px;
}
.summary-value {
margin-top: 12px;
font-size: 28px;
font-weight: 700;
color: #0f172a;
}
.summary-value span {
margin-left: 6px;
font-size: 14px;
color: #64748b;
}
.summary-foot {
margin-top: 8px;
color: #64748b;
font-size: 12px;
}
.chart-host {
width: 100%;
height: 420px;
}
@media (max-width: 992px) {
.hero-panel {
display: block;
}
.hero-tags {
margin-top: 12px;
}
}
</style>

@ -0,0 +1,15 @@
<template>
<IotReportWorkbench
mode="device"
page-title="设备对比报表"
page-subtitle="聚焦设备组之间的均值、峰值、采样量和走势对比,适合快速识别高波动、高风险和需要重点巡检的设备。"
/>
</template>
<script setup lang="ts">
import IotReportWorkbench from '../components/IotReportWorkbench.vue';
defineOptions({
name: 'IotDeviceCompareReport'
});
</script>

@ -0,0 +1,15 @@
<template>
<IotReportWorkbench
mode="quality"
page-title="质量诊断报表"
page-subtitle="聚焦覆盖率、状态流向、漏斗筛选与小时均值,适合识别数据完整性、异常集中时段和风险分层情况。"
/>
</template>
<script setup lang="ts">
import IotReportWorkbench from '../components/IotReportWorkbench.vue';
defineOptions({
name: 'IotQualityReport'
});
</script>

@ -0,0 +1,15 @@
<template>
<IotReportWorkbench
mode="trend"
page-title="趋势总览报表"
page-subtitle="聚焦设备与关键指标的时间趋势,适合查看温度、湿度、噪声、照度、浓度等物联数据的主线变化。"
/>
</template>
<script setup lang="ts">
import IotReportWorkbench from '../components/IotReportWorkbench.vue';
defineOptions({
name: 'IotTrendReport'
});
</script>

@ -0,0 +1,385 @@
<template>
<div class="app-container report-page">
<el-row :gutter="18">
<el-col :xl="5" :lg="6" :md="7" :sm="24" :xs="24">
<section class="glass-panel tree-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Balance Root</p>
<h3>回路根节点</h3>
</div>
</div>
<el-tree
v-loading="treeLoading"
:data="treeData"
node-key="id"
:props="{ label: 'label', children: 'children' }"
default-expand-all
highlight-current
@node-click="handleNodeClick"
/>
</section>
</el-col>
<el-col :xl="19" :lg="18" :md="17" :sm="24" :xs="24">
<section class="hero-panel">
<div>
<p class="panel-eyebrow hero-eyebrow">Meter Balance</p>
<h2>计量平衡报表</h2>
<p class="hero-desc">当前页面只对同口径指标做平衡分析若指标不是累计/流量类页面表达的是工况不平衡而不是实际损耗金额</p>
</div>
<el-tag effect="dark">{{ selectedRootName || '未选择根节点' }}</el-tag>
</section>
<section class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Query</p>
<h3>平衡筛选</h3>
</div>
<el-button type="primary" @click="handleQuery"></el-button>
</div>
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="根节点">
<el-input :model-value="selectedRootName || ''" readonly style="width: 260px" />
</el-form-item>
<el-form-item label="指标">
<el-select v-model="queryForm.metricCode" style="width: 180px">
<el-option v-for="item in metricOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="聚合">
<el-select v-model="queryForm.aggregationType" style="width: 120px">
<el-option label="最新值" value="LAST" />
<el-option label="平均值" value="AVG" />
<el-option label="汇总值" value="SUM" />
</el-select>
</el-form-item>
<el-form-item label="告警阈值">
<el-input-number v-model="queryForm.imbalanceThreshold" :min="0" :max="1" :step="0.01" :precision="2" controls-position="right" />
</el-form-item>
<el-form-item label="记录时间">
<el-date-picker
v-model="timeRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="-"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 340px"
/>
</el-form-item>
</el-form>
</section>
<section v-loading="loading" class="glass-panel">
<div class="panel-head">
<div>
<p class="panel-eyebrow">Overview</p>
<h3>平衡概览</h3>
</div>
</div>
<el-empty v-if="!reportData || reportData.hasData === false" :description="reportData?.emptyReason || '当前条件下无可分析数据'" />
<template v-else>
<div class="summary-grid">
<article class="summary-card">
<span>上游值</span>
<strong>{{ formatNumber(reportData.upstreamValue) }}</strong>
</article>
<article class="summary-card">
<span>下游汇总</span>
<strong>{{ formatNumber(reportData.downstreamTotal) }}</strong>
</article>
<article class="summary-card">
<span>差异值</span>
<strong>{{ formatNumber(reportData.diffValue) }}</strong>
</article>
<article class="summary-card">
<span>差异率</span>
<strong>{{ formatPercent(reportData.diffRate) }}</strong>
</article>
</div>
<el-alert :title="reportData.helpText || ''" type="info" :closable="false" show-icon class="page-alert" />
<Chart class="chart-host" :chart-option="chartOption" />
</template>
</section>
<MeterBalanceTopology :circuits="reportData?.circuits || []" @locate="handleLocateCircuit" />
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import type { EChartsOption } from 'echarts';
import { useRouter } from 'vue-router';
import Chart from '@/components/Charts/Chart.vue';
import MeterBalanceTopology from '../components/MeterBalanceTopology.vue';
import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo';
import { getMeterBalanceReport } from '@/api/ems/report/iotAdvancedReport';
import type { EmsTreeNode, MeterBalanceCircuitVO, MeterBalanceReportVO } from '@/api/ems/types';
import { parseTime } from '@/utils/ruoyi';
defineOptions({
name: 'MeterBalanceReport'
});
const router = useRouter();
const treeLoading = ref(false);
const loading = ref(false);
const treeData = ref<EmsTreeNode[]>([]);
const selectedRootName = ref('');
const reportData = ref<MeterBalanceReportVO>();
const metricOptions = [
{ label: '温度', value: 'temperature' },
{ label: '湿度', value: 'humidity' },
{ label: '照度', value: 'illuminance' },
{ label: '噪声', value: 'noise' },
{ label: '浓度', value: 'concentration' },
{ label: '振动速度', value: 'vibrationSpeed' },
{ label: '振动位移', value: 'vibrationDisplacement' },
{ label: '振动加速度', value: 'vibrationAcceleration' },
{ label: '振动温度', value: 'vibrationTemp' }
];
const createDefaultRange = () => {
const now = new Date();
const start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
return [parseTime(start, '{y}-{m}-{d} {h}:{i}:{s}'), parseTime(now, '{y}-{m}-{d} {h}:{i}:{s}')];
};
const timeRange = ref<string[]>(createDefaultRange());
const queryForm = reactive({
rootMonitorId: '',
metricCode: 'temperature',
aggregationType: 'AVG',
imbalanceThreshold: 0.1
});
const chartOption = computed<EChartsOption>(() => {
const circuits = reportData.value?.circuits || [];
return {
color: ['#2563eb'],
tooltip: {
trigger: 'axis'
},
grid: {
top: 20,
left: '16%',
right: '6%',
bottom: 36
},
xAxis: {
type: 'value',
axisLabel: {
formatter: (value: number) => `${(value * 100).toFixed(0)}%`
}
},
yAxis: {
type: 'category',
data: circuits.map((item) => item.monitorName)
},
series: [
{
type: 'bar',
data: circuits.map((item) => Number(item.diffRate || 0)),
itemStyle: {
color: (params: any) => (circuits[params.dataIndex]?.abnormal ? '#ef4444' : '#22c55e'),
borderRadius: [0, 6, 6, 0]
}
}
]
};
});
const loadTree = async () => {
treeLoading.value = true;
try {
const response = await getMonitorInfoTree({});
treeData.value = response.data || [];
} finally {
treeLoading.value = false;
}
};
const handleNodeClick = (node: EmsTreeNode) => {
queryForm.rootMonitorId = String(node.code || '');
selectedRootName.value = String(node.label || '');
if (node.metricCode) {
queryForm.metricCode = String(node.metricCode);
}
};
const handleQuery = async () => {
if (!queryForm.rootMonitorId) {
ElMessage.warning('请先选择平衡分析根节点');
return;
}
if (!timeRange.value || timeRange.value.length !== 2) {
ElMessage.warning('请选择完整的记录时间范围');
return;
}
loading.value = true;
try {
const { data } = await getMeterBalanceReport({
rootMonitorId: queryForm.rootMonitorId,
metricCode: queryForm.metricCode,
aggregationType: queryForm.aggregationType,
imbalanceThreshold: queryForm.imbalanceThreshold,
beginRecordTime: timeRange.value[0],
endRecordTime: timeRange.value[1]
});
reportData.value = data || { hasData: false, emptyReason: '接口未返回有效数据' };
} finally {
loading.value = false;
}
};
const handleLocateCircuit = (row: MeterBalanceCircuitVO) => {
router.push({
path: '/ems/report/instrument-condition-report',
query: {
monitorIds: row.monitorIds?.join(','),
beginRecordTime: timeRange.value[0],
endRecordTime: timeRange.value[1],
metricCodes: reportData.value?.metricCode
}
});
};
const formatNumber = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '--';
}
const numberValue = Number(value);
return Number.isNaN(numberValue) ? '--' : numberValue.toFixed(2);
};
const formatPercent = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '--';
}
const numberValue = Number(value);
return Number.isNaN(numberValue) ? '--' : `${(numberValue * 100).toFixed(2)}%`;
};
onMounted(async () => {
await loadTree();
});
</script>
<style scoped>
.report-page {
min-height: calc(100vh - 84px);
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.14), transparent 24%),
radial-gradient(circle at top left, rgba(20, 184, 166, 0.1), transparent 28%), linear-gradient(180deg, #f4f8ff 0%, #f8fafc 100%);
}
.glass-panel,
.hero-panel,
.summary-card {
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.tree-panel,
.glass-panel {
padding: 18px 20px;
margin-bottom: 18px;
}
.hero-panel {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 24px;
margin-bottom: 18px;
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(37, 99, 235, 0.92)),
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0));
color: #ffffff;
}
.hero-eyebrow {
color: rgba(255, 255, 255, 0.72);
}
.hero-panel h2,
.panel-head h3 {
margin: 0;
}
.hero-desc {
margin: 14px 0 0;
max-width: 760px;
line-height: 1.75;
color: rgba(255, 255, 255, 0.84);
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.panel-eyebrow {
margin: 0 0 6px;
color: #2563eb;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 18px;
}
.summary-card {
padding: 18px;
}
.summary-card span {
color: #64748b;
font-size: 13px;
}
.summary-card strong {
display: block;
margin-top: 12px;
font-size: 28px;
color: #0f172a;
}
.chart-host {
width: 100%;
height: 320px;
}
.page-alert {
margin-bottom: 16px;
}
@media (max-width: 992px) {
.hero-panel {
display: block;
}
}
</style>

@ -0,0 +1,988 @@
<template>
<div class="app-container realtime-iot-page">
<el-row :gutter="20">
<el-col :xl="5" :lg="6" :md="7" :sm="24" :xs="24">
<section class="glass-panel tree-panel">
<div class="panel-heading">
<div>
<p class="panel-eyebrow">Live Device Navigator</p>
<h3>实时设备树</h3>
</div>
<el-tag effect="plain" size="small">{{ monitorInfoOptions.length }} 个节点</el-tag>
</div>
<el-input
v-model="workUnitName"
class="tree-search"
placeholder="请输入计量设备名称"
clearable
size="small"
prefix-icon="el-icon-search"
/>
<div v-loading="treeLoading" class="tree-wrapper">
<el-tree
ref="treeRef"
:data="monitorInfoOptions"
:props="monitorProps"
:expand-on-click-node="false"
:filter-node-method="filterNode"
node-key="id"
default-expand-all
highlight-current
@node-click="handleNodeClick"
/>
</div>
</section>
</el-col>
<el-col :xl="19" :lg="18" :md="17" :sm="24" :xs="24">
<section class="hero-panel">
<div class="hero-main">
<div class="hero-copy">
<p class="hero-eyebrow">Realtime Curve Command Center</p>
<h2>环境监测实时曲线</h2>
<p class="hero-desc">
实时页专注增量接收窗口裁剪与运行态感知轮询和 WebSocket 共用同一条曲线追加管道既能先落地也能为后续流式升级留足扩展空间
</p>
</div>
<div class="hero-tags">
<el-tag :type="statusTagType" effect="dark">{{ statusText }}</el-tag>
<el-tag effect="plain">{{ sourceModeLabel }}</el-tag>
<el-tag effect="plain">窗口 {{ realtimeConfig.windowSize }} </el-tag>
<el-tag effect="plain">间隔 {{ realtimeConfig.pollingIntervalSec }} </el-tag>
</div>
</div>
<div class="hero-metrics">
<div class="metric-card">
<span class="metric-label">当前设备</span>
<strong class="metric-value">{{ selectedMonitorName || '未选择' }}</strong>
<p class="metric-hint">{{ monitorProfileText }}</p>
</div>
<div class="metric-card">
<span class="metric-label">窗口点数</span>
<strong class="metric-value">{{ realtimeRecords.length }}</strong>
<p class="metric-hint">仅保留最近 N 个点防止浏览器长期运行后膨胀</p>
</div>
<div class="metric-card">
<span class="metric-label">最新记录时间</span>
<strong class="metric-value metric-time">{{ lastRecordTime || '--' }}</strong>
<p class="metric-hint">实时曲线右侧最新落点时间</p>
</div>
<div class="metric-card">
<span class="metric-label">最近刷新</span>
<strong class="metric-value metric-time">{{ lastRefreshTime || '--' }}</strong>
<p class="metric-hint">{{ refreshHintText }}</p>
</div>
</div>
</section>
<section class="glass-panel control-panel">
<div class="panel-heading compact">
<div>
<p class="panel-eyebrow">Runtime Control</p>
<h3>实时控制台</h3>
</div>
<div class="panel-actions">
<el-button type="primary" size="small" @click="handleStart" :disabled="!selectedMonitorId">启动监测</el-button>
<el-button size="small" @click="handleStop"></el-button>
<el-button type="success" plain size="small" @click="handleApplyConfig" :disabled="!selectedMonitorId">应用配置</el-button>
<el-button type="warning" plain size="small" @click="handleManualRefresh" :disabled="!selectedMonitorId">立即刷新</el-button>
</div>
</div>
<el-form label-width="108px" size="small" class="control-form">
<div class="control-grid">
<!--
这里先注释页面上的数据源模式切换当前阶段统一走代码内配置默认 API 轮询
后续后端 WebSocket 稳定后只需要恢复此配置区即可重新开放切换能力
-->
<!--
<el-form-item label="数据源模式">
<el-radio-group v-model="realtimeConfig.sourceMode">
<el-radio-button label="polling">API轮询</el-radio-button>
<el-radio-button label="websocket">WebSocket</el-radio-button>
</el-radio-group>
</el-form-item>
-->
<el-form-item label="轮询间隔">
<el-input-number v-model="realtimeConfig.pollingIntervalSec" :min="1" :max="300" controls-position="right" />
<span class="field-suffix">/</span>
</el-form-item>
<el-form-item label="窗口大小">
<el-input-number v-model="realtimeConfig.windowSize" :min="10" :max="2000" controls-position="right" />
<span class="field-suffix"></span>
</el-form-item>
<el-form-item label="设备名称">
<el-input :model-value="selectedMonitorName || ''" readonly />
</el-form-item>
<!--
这里先注释页面上的 WebSocket 地址输入框避免业务侧误以为当前环境已经开放前端自助切换
地址仍保留在代码配置中后续联调通过后可直接恢复
-->
<!--
<el-form-item label="WebSocket地址" class="control-grid-full">
<el-input
v-model="realtimeConfig.wsUrl"
placeholder="例如ws://127.0.0.1:8080/ems/ws/iot/realtime"
clearable
/>
</el-form-item>
-->
</div>
</el-form>
<el-alert
v-if="realtimeConfig.sourceMode === 'websocket'"
type="warning"
:closable="false"
show-icon
title="当前前端已预留 WebSocket 接入结构,只要后端提供实时地址和消息体,就能直接切换到流式模式。"
/>
<el-alert
v-else
type="info"
:closable="false"
show-icon
title="轮询模式会自动按最近记录时间增量补数,重复数据会在前端先去重再进入实时窗口。"
/>
<p class="runtime-note">{{ runtimeMessage }}</p>
</section>
<section v-loading="chartLoading" class="glass-panel chart-panel">
<div class="panel-heading compact">
<div>
<p class="panel-eyebrow">Realtime Canvas</p>
<h3>动态曲线区</h3>
</div>
<div class="chart-status">
<el-tag size="small" effect="plain">{{ selectedMonitorId || '未选设备' }}</el-tag>
<el-tag size="small" :type="statusTagType" effect="plain">{{ statusText }}</el-tag>
<el-tag size="small" effect="plain">指标 {{ chartPanels.length }} </el-tag>
</div>
</div>
<div v-if="!selectedMonitorId" class="empty-block">
<el-empty description="请先在左侧树中选择具体设备节点,再启动实时监测" />
</div>
<div v-else-if="!realtimeRecords.length" class="empty-block">
<el-empty description="实时引擎已准备好,等待首批数据进入窗口" />
</div>
<div v-else-if="!chartPanels.length" class="empty-block">
<el-empty description="已收到实时数据,但未识别到可绘制曲线的字段,请确认后端返回值结构" />
</div>
<template v-else>
<div class="chart-summary-bar">
<span>设备{{ selectedMonitorName }}</span>
<span>模式{{ sourceModeLabel }}</span>
<span>窗口最近 {{ realtimeConfig.windowSize }} </span>
<span>最后游标{{ lastRecordTime || '--' }}</span>
</div>
<div class="chart-grid" :class="chartGridClass">
<article v-for="panel in chartPanels" :key="panel.key" class="chart-card">
<div class="chart-card-head">
<div>
<h4>{{ panel.label }}</h4>
<p>{{ panel.metric.unit ? `${panel.metric.label}(${panel.metric.unit})` : panel.metric.label }}</p>
</div>
<el-tag size="small" effect="plain">{{ panel.valueCount }} 个有效点</el-tag>
</div>
<Chart :ref="setChartRef(panel.key)" class="chart-canvas" :chart-option="panel.option" />
</article>
</div>
</template>
</section>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import Chart from '@/components/Charts/Chart.vue';
import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo';
import { getRecordIotenvInstantList } from '@/api/ems/record/recordIotenvInstant';
import { getToken } from '@/utils/auth';
import { parseTime } from '@/utils/ruoyi';
import type { RecordIotenvInstantVO } from '@/api/ems/types';
import {
buildIotCurveOption,
buildMetricHintsByNode,
connectCurveCharts,
extractRealtimeIotRecords,
mergeRealtimeWindow,
normalizeIotCurveRecord,
readMetricValue,
resolveIotCurveMetrics,
type CurveChartExpose,
type IotCurveMetricDefinition,
type NormalizedIotCurveRecord
} from '../iotCurveShared';
defineOptions({
name: 'realtimeIOTCurve',
dicts: ['collect_type']
});
type RealtimeSourceMode = 'polling' | 'websocket';
type RealtimeEngineStatus = 'idle' | 'running' | 'connecting' | 'stopped' | 'error';
interface ChartPanel {
key: string;
label: string;
metric: IotCurveMetricDefinition;
valueCount: number;
option: Record<string, unknown>;
}
const treeRef = ref<ElTreeInstance>();
const chartRefMap = shallowRef<Record<string, CurveChartExpose | null>>({});
const disconnectChartGroup = ref<() => void>(() => undefined);
const createDefaultWsUrl = () => {
const socketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${socketProtocol}//${window.location.host}/ems/ws/iot/realtime`;
};
const monitorProps = {
children: 'children',
label: 'label'
};
const treeLoading = ref(false);
const chartLoading = ref(false);
const requestPending = ref(false);
const monitorInfoOptions = ref<any[]>([]);
const workUnitName = ref('');
const selectedMonitorId = ref('');
const selectedMonitorName = ref('');
const selectedMonitorNode = ref<Record<string, unknown> | null>(null);
const realtimeRecords = ref<NormalizedIotCurveRecord[]>([]);
const lastRecordTime = ref('');
const lastRefreshTime = ref('');
const engineStatus = ref<RealtimeEngineStatus>('idle');
const isRealtimeRunning = ref(false);
const runtimeMessage = ref('待选择设备后启动监测');
const realtimeConfig = reactive({
sourceMode: 'polling' as RealtimeSourceMode,
pollingIntervalSec: 5,
windowSize: 120,
wsUrl: createDefaultWsUrl()
});
let pollingTimer: ReturnType<typeof setInterval> | null = null;
let socketController: ReturnType<typeof useWebSocket> | null = null;
const metricHints = computed(() => buildMetricHintsByNode(selectedMonitorNode.value));
const chartPanels = computed<ChartPanel[]>(() => {
const metrics = resolveIotCurveMetrics(realtimeRecords.value, metricHints.value);
return metrics.map((metric) => {
const valueCount = realtimeRecords.value.filter((item) => readMetricValue(item, metric) !== null).length;
return {
key: metric.key,
label: metric.label,
metric,
valueCount,
option: buildIotCurveOption({
metric,
monitorName: selectedMonitorName.value || '设备',
records: realtimeRecords.value,
premium: true,
subtitle: `${sourceModeLabel.value} | 最近 ${realtimeConfig.windowSize}`
})
};
});
});
const sourceModeLabel = computed(() => (realtimeConfig.sourceMode === 'polling' ? 'API轮询' : 'WebSocket'));
const monitorProfileText = computed(() => {
const pointType = String(selectedMonitorNode.value?.pointType || selectedMonitorNode.value?.metricName || '').trim();
const unitName = String(selectedMonitorNode.value?.unitName || '').trim();
if (pointType && unitName) {
return `${pointType} / ${unitName}`;
}
if (pointType) {
return pointType;
}
return '指标将根据实时数据字段自动识别';
});
const statusText = computed(() => {
const statusMap: Record<RealtimeEngineStatus, string> = {
idle: '待机',
running: realtimeConfig.sourceMode === 'polling' ? '轮询中' : '流式接收中',
connecting: '连接中',
stopped: '已暂停',
error: '异常'
};
return statusMap[engineStatus.value];
});
const statusTagType = computed(() => {
const tagMap: Record<RealtimeEngineStatus, 'info' | 'success' | 'warning' | 'danger'> = {
idle: 'info',
running: 'success',
connecting: 'warning',
stopped: 'info',
error: 'danger'
};
return tagMap[engineStatus.value];
});
const refreshHintText = computed(() => {
return realtimeConfig.sourceMode === 'polling' ? '轮询结果入窗时间' : '流式消息最近更新时间';
});
const chartGridClass = computed(() => {
return chartPanels.value.length <= 1 ? 'chart-grid-single' : 'chart-grid-multi';
});
const formatDateTime = (value: Date | number) => {
return parseTime(value, '{y}-{m}-{d} {h}:{i}:{s}');
};
const setChartRef = (key: string) => {
return (instance: any) => {
if (instance) {
chartRefMap.value[key] = instance as CurveChartExpose;
return;
}
delete chartRefMap.value[key];
};
};
const filterNode = (value, data) => {
if (!value) {
return true;
}
return String(data.label || '').includes(value);
};
const clearPollingTimer = () => {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
};
const closeSocketConnection = () => {
if (socketController) {
socketController.close();
socketController = null;
}
};
const stopRealtimeEngine = (status: RealtimeEngineStatus = 'stopped', message = '实时监测已暂停') => {
clearPollingTimer();
closeSocketConnection();
isRealtimeRunning.value = false;
engineStatus.value = status;
runtimeMessage.value = message;
};
const renderChartLinkage = () => {
disconnectChartGroup.value();
if (chartPanels.value.length < 2) {
return;
}
void nextTick(() => {
disconnectChartGroup.value();
disconnectChartGroup.value = connectCurveCharts(
chartPanels.value.map((item) => chartRefMap.value[item.key]),
'realtime-iot-chart-group'
);
});
};
const matchSelectedMonitor = (record: RecordIotenvInstantVO) => {
const currentMonitorId = String(selectedMonitorId.value || '');
const candidateIds = [record.monitorId, record.monitorCode]
.filter((item) => item !== null && item !== undefined)
.map((item) => String(item));
return candidateIds.length === 0 || candidateIds.includes(currentMonitorId);
};
const appendRealtimeRecords = (records: RecordIotenvInstantVO[], sourceLabel: string) => {
const filteredRecords = records.filter((item) => matchSelectedMonitor(item));
if (!filteredRecords.length) {
return;
}
// WebSocket
realtimeRecords.value = mergeRealtimeWindow(realtimeRecords.value, filteredRecords, realtimeConfig.windowSize);
lastRecordTime.value = realtimeRecords.value.at(-1)?.__timeKey || '';
lastRefreshTime.value = formatDateTime(new Date());
engineStatus.value = 'running';
runtimeMessage.value = `${sourceLabel} 已写入 ${filteredRecords.length} 条增量数据`;
renderChartLinkage();
};
const buildPollingQuery = () => {
const endTime = new Date();
const baselineSeconds = Math.max(realtimeConfig.windowSize * realtimeConfig.pollingIntervalSec, 300);
const beginTime = lastRecordTime.value ? new Date(lastRecordTime.value) : new Date(endTime.getTime() - baselineSeconds * 1000);
return {
monitorId: selectedMonitorId.value,
monitorIds: [],
samplingInterval: 1,
beginRecordTime: formatDateTime(beginTime),
endRecordTime: formatDateTime(endTime),
params: {}
};
};
const fetchRealtimeData = async (sourceLabel: string) => {
if (!selectedMonitorId.value || requestPending.value) {
return;
}
chartLoading.value = realtimeRecords.value.length === 0;
requestPending.value = true;
try {
const { data = [] } = await getRecordIotenvInstantList(buildPollingQuery());
appendRealtimeRecords(data, sourceLabel);
if (data.length === 0) {
lastRefreshTime.value = formatDateTime(new Date());
runtimeMessage.value = `${sourceLabel} 未获取到新数据`;
}
} catch (error) {
engineStatus.value = 'error';
runtimeMessage.value = `${sourceLabel} 拉取失败`;
ElMessage.error(`${sourceModeLabel.value} 获取实时数据失败,请检查接口或连接配置`);
console.error(error);
} finally {
requestPending.value = false;
chartLoading.value = false;
}
};
const resolveSocketUrl = () => {
if (!realtimeConfig.wsUrl) {
return '';
}
try {
const socketOrigin = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const baseUrl = `${socketOrigin}${window.location.host}`;
const url = new URL(realtimeConfig.wsUrl, baseUrl);
const token = getToken();
if (token) {
url.searchParams.set('Authorization', `Bearer ${token}`);
}
if (import.meta.env.VITE_APP_CLIENT_ID) {
url.searchParams.set('clientid', import.meta.env.VITE_APP_CLIENT_ID);
}
if (selectedMonitorId.value) {
// monitorId
url.searchParams.set('monitorId', selectedMonitorId.value);
}
return url.toString();
} catch (error) {
console.error(error);
return '';
}
};
const startPollingMode = async () => {
clearPollingTimer();
engineStatus.value = 'running';
runtimeMessage.value = '轮询引擎已启动';
await fetchRealtimeData('轮询初始化');
pollingTimer = setInterval(() => {
void fetchRealtimeData('轮询增量');
}, realtimeConfig.pollingIntervalSec * 1000);
};
const startWebSocketMode = async () => {
const socketUrl = resolveSocketUrl();
if (!socketUrl) {
engineStatus.value = 'error';
runtimeMessage.value = 'WebSocket 地址无效';
isRealtimeRunning.value = false;
ElMessage.warning('请先配置有效的 WebSocket 地址后再启动');
return;
}
closeSocketConnection();
engineStatus.value = 'connecting';
runtimeMessage.value = '等待 WebSocket 建立连接';
//
await fetchRealtimeData('初始化补数');
socketController = useWebSocket(socketUrl, {
autoReconnect: {
retries: 3,
delay: 2000
},
heartbeat: {
message: JSON.stringify({ type: 'ping' }),
interval: 10000,
pongTimeout: 5000
},
onConnected() {
engineStatus.value = 'running';
runtimeMessage.value = 'WebSocket 已连接,等待流式数据';
lastRefreshTime.value = formatDateTime(new Date());
},
onDisconnected() {
if (isRealtimeRunning.value) {
engineStatus.value = 'stopped';
runtimeMessage.value = 'WebSocket 已断开';
}
},
onError() {
engineStatus.value = 'error';
runtimeMessage.value = 'WebSocket 连接异常';
},
onMessage(_, event) {
if (typeof event.data !== 'string' || event.data.includes('ping')) {
return;
}
try {
const payload = JSON.parse(event.data);
appendRealtimeRecords(extractRealtimeIotRecords(payload), 'WebSocket');
} catch (error) {
console.warn('WebSocket 消息解析失败', error);
}
}
});
};
const startRealtimeEngine = async () => {
if (!selectedMonitorId.value) {
ElMessage.warning('请先选择具体设备节点');
return;
}
stopRealtimeEngine('idle', '准备重新启动实时监测');
isRealtimeRunning.value = true;
if (realtimeConfig.sourceMode === 'polling') {
await startPollingMode();
return;
}
await startWebSocketMode();
};
const resetRealtimeWindow = () => {
realtimeRecords.value = [];
lastRecordTime.value = '';
lastRefreshTime.value = '';
};
const handleNodeClick = async (data) => {
if (data.children && data.children.length > 0) {
ElMessage.info('实时曲线仅支持选择具体设备节点;父节点保留给后续多设备订阅扩展');
return;
}
selectedMonitorId.value = String(data.code || '');
selectedMonitorName.value = String(data.label || '');
selectedMonitorNode.value = data;
resetRealtimeWindow();
await startRealtimeEngine();
};
const handleStart = async () => {
await startRealtimeEngine();
};
const handleStop = () => {
stopRealtimeEngine('stopped', '已手动暂停实时监测');
};
const handleApplyConfig = async () => {
realtimeRecords.value = realtimeRecords.value.slice(-Math.max(1, realtimeConfig.windowSize));
renderChartLinkage();
if (selectedMonitorId.value && isRealtimeRunning.value) {
await startRealtimeEngine();
return;
}
ElMessage.success('实时配置已生效');
};
const handleManualRefresh = async () => {
await fetchRealtimeData('手动刷新');
};
const getTreeselect = async () => {
treeLoading.value = true;
try {
// /
const response = await getMonitorInfoTree({});
monitorInfoOptions.value = response.data || [];
} finally {
treeLoading.value = false;
}
};
watch(workUnitName, (value) => {
treeRef.value?.filter(value);
});
watch(chartPanels, () => {
renderChartLinkage();
});
watch(
() => realtimeConfig.windowSize,
(value) => {
realtimeRecords.value = realtimeRecords.value.slice(-Math.max(1, Number(value) || 1));
renderChartLinkage();
}
);
onMounted(async () => {
await getTreeselect();
});
onDeactivated(() => {
stopRealtimeEngine('stopped', '页面失活时已释放实时资源');
});
onBeforeUnmount(() => {
disconnectChartGroup.value();
stopRealtimeEngine('stopped', '页面卸载时已释放实时资源');
});
</script>
<style scoped>
.realtime-iot-page {
--page-bg-start: #eef4ff;
--page-bg-end: #f8fbff;
--panel-border: rgba(148, 163, 184, 0.22);
--panel-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
--hero-shadow: 0 30px 76px rgba(37, 99, 235, 0.2);
--text-primary: #0f172a;
--text-secondary: #64748b;
--accent: #2563eb;
min-height: calc(100vh - 84px);
background:
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 28%),
radial-gradient(circle at bottom left, rgba(20, 184, 166, 0.12), transparent 24%),
linear-gradient(180deg, var(--page-bg-start), var(--page-bg-end));
}
.glass-panel,
.hero-panel {
border: 1px solid var(--panel-border);
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
box-shadow: var(--panel-shadow);
}
.tree-panel,
.control-panel,
.chart-panel {
padding: 20px;
}
.hero-panel {
padding: 24px;
margin-bottom: 20px;
background:
linear-gradient(135deg, rgba(37, 99, 235, 0.98), rgba(14, 165, 233, 0.92)),
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0));
color: #ffffff;
box-shadow: var(--hero-shadow);
position: relative;
overflow: hidden;
}
.hero-panel::before,
.hero-panel::after {
content: '';
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
}
.hero-panel::before {
width: 220px;
height: 220px;
inset: -90px auto auto -70px;
}
.hero-panel::after {
width: 260px;
height: 260px;
inset: auto -70px -110px auto;
}
.panel-heading,
.hero-main,
.hero-metrics,
.control-grid,
.chart-grid,
.chart-summary-bar {
display: grid;
}
.panel-heading {
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
margin-bottom: 18px;
}
.panel-heading.compact {
margin-bottom: 16px;
}
.panel-eyebrow,
.hero-eyebrow {
margin: 0 0 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.panel-eyebrow {
color: var(--accent);
}
.panel-heading h3,
.hero-copy h2 {
margin: 0;
}
.hero-main {
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: start;
}
.hero-copy h2 {
font-size: 30px;
line-height: 1.18;
}
.hero-eyebrow {
color: rgba(255, 255, 255, 0.74);
}
.hero-desc {
max-width: 780px;
margin: 14px 0 0;
color: rgba(255, 255, 255, 0.84);
line-height: 1.75;
}
.hero-tags,
.panel-actions,
.chart-status {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.hero-metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-top: 24px;
}
.metric-card {
padding: 16px 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.metric-label {
display: block;
font-size: 12px;
color: rgba(255, 255, 255, 0.72);
}
.metric-value {
display: block;
margin-top: 10px;
font-size: 22px;
font-weight: 700;
line-height: 1.25;
}
.metric-time {
font-size: 16px;
}
.metric-hint {
margin: 10px 0 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.78);
}
.tree-search {
margin-bottom: 16px;
}
.tree-wrapper {
min-height: 640px;
max-height: 72vh;
overflow: auto;
padding: 12px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.88));
}
.control-panel,
.chart-panel {
margin-bottom: 20px;
}
.control-form {
margin-bottom: 12px;
}
.control-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 18px;
}
.control-grid-full {
grid-column: 1 / -1;
}
.field-suffix {
margin-left: 8px;
color: var(--text-secondary);
}
.runtime-note {
margin: 12px 2px 0;
color: var(--text-secondary);
font-size: 13px;
}
.chart-summary-bar {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 16px;
background: linear-gradient(90deg, rgba(37, 99, 235, 0.08), rgba(14, 165, 233, 0.05));
color: var(--text-secondary);
}
.chart-grid {
gap: 16px;
}
.chart-grid-single {
grid-template-columns: 1fr;
}
.chart-grid-multi {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chart-card {
padding: 16px;
border-radius: 22px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96)),
linear-gradient(135deg, rgba(37, 99, 235, 0.06), rgba(14, 165, 233, 0.05));
border: 1px solid rgba(226, 232, 240, 0.9);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.8),
0 18px 48px rgba(37, 99, 235, 0.06);
}
.chart-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.chart-card-head h4 {
margin: 0;
color: var(--text-primary);
font-size: 16px;
}
.chart-card-head p {
margin: 6px 0 0;
color: var(--text-secondary);
font-size: 12px;
}
.chart-canvas {
width: 100%;
height: 48vh;
min-height: 380px;
border-radius: 18px;
}
.empty-block {
min-height: 56vh;
display: flex;
align-items: center;
justify-content: center;
}
@media (max-width: 1400px) {
.hero-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 992px) {
.hero-main,
.control-grid,
.chart-grid-multi {
grid-template-columns: 1fr;
}
.hero-tags,
.panel-actions,
.chart-status {
justify-content: flex-start;
}
.chart-canvas {
height: 42vh;
min-height: 320px;
}
}
@media (max-width: 768px) {
.hero-panel,
.tree-panel,
.control-panel,
.chart-panel {
padding: 16px;
border-radius: 20px;
}
.hero-copy h2 {
font-size: 24px;
}
.hero-metrics {
grid-template-columns: 1fr;
}
.tree-wrapper {
min-height: 360px;
}
}
</style>
Loading…
Cancel
Save