|
|
<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>
|