You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

511 lines
15 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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