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

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