<script lang="ts" setup>
|
import type { EChartsOption, EchartsUIType } from '@vben/plugins/echarts';
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import { Page } from '@vben/common-ui';
|
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
|
import { Empty, Select, Spin, Table } from 'ant-design-vue';
|
import dayjs from 'dayjs';
|
|
import {
|
getKpiCompare,
|
getKpiOverview,
|
getKpiTrend,
|
} from '#/api/bi/decision/kpi';
|
import type { DecisionKpiApi } from '#/api/bi/decision/kpi';
|
|
defineOptions({ name: 'DecisionTrendAnalysis' });
|
|
const loading = ref(false);
|
const chartLoading = ref(false);
|
const kpiList = ref<DecisionKpiApi.KpiItem[]>([]);
|
const selectedCategory = ref<string>('');
|
const selectedKpi = ref<string>('');
|
const trend = ref<DecisionKpiApi.KpiTrend | null>(null);
|
const compare = ref<DecisionKpiApi.KpiCompare | null>(null);
|
|
const CATEGORY_OPTIONS = [
|
{ label: '全部', value: '' },
|
{ label: '能耗', value: 'energy' },
|
{ label: '设备运行', value: 'device_operation' },
|
{ label: '生产', value: 'production' },
|
{ label: '质量', value: 'quality' },
|
{ label: '采购', value: 'procurement' },
|
{ label: '营销', value: 'sales' },
|
{ label: '安全', value: 'safety' },
|
];
|
|
const kpiOptions = computed(() =>
|
kpiList.value
|
.filter((k) =>
|
selectedCategory.value ? k.category === selectedCategory.value : true,
|
)
|
.map((k) => ({
|
label: `${k.name}${k.unit ? `(${k.unit})` : ''}`,
|
value: k.code,
|
})),
|
);
|
|
const selectedKpiUnit = computed(
|
() => kpiList.value.find((k) => k.code === selectedKpi.value)?.unit ?? '',
|
);
|
|
const columns = [
|
{ title: 'KPI名称', dataIndex: 'name', key: 'name', width: 150 },
|
{ title: '分类', dataIndex: 'category', key: 'category', width: 100 },
|
{ title: '当前值', dataIndex: 'value', key: 'value', width: 120 },
|
{ title: '单位', dataIndex: 'unit', key: 'unit', width: 80 },
|
{ title: '预警状态', dataIndex: 'alertStatus', key: 'alertStatus', width: 100 },
|
];
|
|
async function loadKpis() {
|
loading.value = true;
|
try {
|
const overview = await getKpiOverview();
|
kpiList.value = overview.kpis || [];
|
if (!selectedKpi.value && kpiOptions.value.length > 0) {
|
selectedKpi.value = kpiOptions.value[0].value;
|
}
|
} catch {
|
kpiList.value = [];
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
const chartRef = ref<EchartsUIType>();
|
const { renderEcharts } = useEcharts(chartRef);
|
|
function formatTime(time: string): string {
|
return dayjs(time).format('MM-DD HH:mm');
|
}
|
|
function buildChartOptions(t: DecisionKpiApi.KpiTrend): EChartsOption {
|
const labels = t.points.map((p) => formatTime(p.time));
|
const values = t.points.map((p) => Number(p.value) || 0);
|
return {
|
backgroundColor: 'transparent',
|
grid: { bottom: 36, left: 12, right: 24, top: 28, containLabel: true },
|
tooltip: {
|
trigger: 'axis',
|
backgroundColor: 'rgba(255,255,255,0.96)',
|
borderColor: '#e5e7eb',
|
textStyle: { color: '#1f2937', fontSize: 12 },
|
axisPointer: { type: 'cross' },
|
},
|
xAxis: {
|
type: 'category',
|
boundaryGap: false,
|
data: labels,
|
axisLabel: { color: '#6b7280', fontSize: 11 },
|
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
axisTick: { show: false },
|
},
|
yAxis: {
|
type: 'value',
|
name: t.unit || '',
|
nameTextStyle: { color: '#9ca3af', fontSize: 11 },
|
axisLabel: { color: '#6b7280', fontSize: 11 },
|
splitLine: { lineStyle: { color: '#f0f1f3', type: 'dashed' } },
|
},
|
series: [
|
{
|
name: t.kpiName,
|
type: 'line',
|
data: values,
|
smooth: true,
|
symbol: 'circle',
|
symbolSize: 4,
|
lineStyle: { width: 2, color: '#1677ff' },
|
itemStyle: { color: '#1677ff' },
|
areaStyle: {
|
color: {
|
type: 'linear',
|
x: 0, y: 0, x2: 0, y2: 1,
|
colorStops: [
|
{ offset: 0, color: 'rgba(22,119,255,0.18)' },
|
{ offset: 1, color: 'rgba(22,119,255,0.01)' },
|
],
|
},
|
},
|
},
|
],
|
};
|
}
|
|
async function loadTrendDetail() {
|
if (!selectedKpi.value) {
|
trend.value = null;
|
compare.value = null;
|
return;
|
}
|
chartLoading.value = true;
|
try {
|
const [t, c] = await Promise.all([
|
getKpiTrend(selectedKpi.value),
|
getKpiCompare(selectedKpi.value),
|
]);
|
trend.value = t;
|
compare.value = c;
|
await renderEcharts(buildChartOptions(t));
|
} catch {
|
trend.value = null;
|
compare.value = null;
|
} finally {
|
chartLoading.value = false;
|
}
|
}
|
|
function formatCompareValue(v: number | undefined): string {
|
if (v == null || Number.isNaN(v)) return '--';
|
return Number.isInteger(v) ? v.toLocaleString() : Number(v).toFixed(2);
|
}
|
|
const changeRateText = computed(() => {
|
const rate = compare.value?.changeRate;
|
if (rate == null || Number.isNaN(rate)) return '--';
|
const up = rate >= 0;
|
return `${up ? '↑' : '↓'} ${Math.abs(rate).toFixed(2)}%`;
|
});
|
|
const changeRateColor = computed(() => {
|
const rate = compare.value?.changeRate;
|
if (rate == null || Number.isNaN(rate)) return '#6b7280';
|
return rate >= 0 ? '#22c55e' : '#ef4444';
|
});
|
|
watch(selectedCategory, () => {
|
const first = kpiOptions.value[0];
|
selectedKpi.value = first?.value ?? '';
|
});
|
|
watch(selectedKpi, loadTrendDetail);
|
|
onMounted(loadKpis);
|
onBeforeUnmount(() => {
|
chartRef.value = undefined;
|
});
|
</script>
|
|
<template>
|
<Page :auto-content-height="true">
|
<div class="p-4">
|
<div class="mb-4 grid grid-cols-1 gap-3 lg:grid-cols-[auto_auto_1fr]">
|
<h2 class="text-lg font-bold">趋势分析</h2>
|
<Select
|
v-model:value="selectedCategory"
|
:options="CATEGORY_OPTIONS"
|
style="width: 160px"
|
allow-clear
|
/>
|
<Select
|
v-model:value="selectedKpi"
|
:options="kpiOptions"
|
style="width: 280px"
|
placeholder="请选择 KPI"
|
show-search
|
option-filter-prop="label"
|
/>
|
</div>
|
|
<!-- 环比研判卡片 -->
|
<Spin :spinning="chartLoading">
|
<div v-if="selectedKpi" class="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
<div class="rounded-lg border border-gray-200 p-4">
|
<div class="text-xs text-gray-500">当前值</div>
|
<div class="mt-1 text-2xl font-bold text-gray-800">
|
{{ formatCompareValue(compare?.currentValue) }}
|
<span class="ml-1 text-sm font-normal text-gray-400">{{ selectedKpiUnit }}</span>
|
</div>
|
<div v-if="compare?.currentTime" class="mt-1 text-xs text-gray-400">
|
{{ formatTime(compare.currentTime) }}
|
</div>
|
</div>
|
<div class="rounded-lg border border-gray-200 p-4">
|
<div class="text-xs text-gray-500">环比变化率</div>
|
<div class="mt-1 text-2xl font-bold" :style="{ color: changeRateColor }">
|
{{ changeRateText }}
|
</div>
|
<div v-if="compare?.previousTime" class="mt-1 text-xs text-gray-400">
|
上期 {{ formatTime(compare.previousTime) }}
|
</div>
|
</div>
|
<div class="rounded-lg border border-gray-200 p-4">
|
<div class="text-xs text-gray-500">近24期均值</div>
|
<div class="mt-1 text-2xl font-bold text-gray-800">
|
{{ formatCompareValue(compare?.avgValue) }}
|
<span class="ml-1 text-sm font-normal text-gray-400">{{ selectedKpiUnit }}</span>
|
</div>
|
<div class="mt-1 text-xs text-gray-400">已统计 {{ compare?.periodCount ?? 0 }} 期</div>
|
</div>
|
<div class="rounded-lg border border-gray-200 p-4">
|
<div class="text-xs text-gray-500">峰值 / 波谷</div>
|
<div class="mt-1 text-2xl font-bold text-gray-800">
|
{{ formatCompareValue(compare?.maxValue) }}
|
<span class="text-sm font-normal text-gray-400">/</span>
|
{{ formatCompareValue(compare?.minValue) }}
|
<span class="ml-1 text-sm font-normal text-gray-400">{{ selectedKpiUnit }}</span>
|
</div>
|
</div>
|
</div>
|
</Spin>
|
|
<!-- 趋势折线图 -->
|
<div class="mb-4 rounded-lg border border-gray-200 bg-white p-4">
|
<div class="mb-2 flex items-center justify-between">
|
<span class="font-medium">{{ trend?.kpiName ?? selectedKpi }}</span>
|
<span v-if="trend" class="text-xs text-gray-400">
|
{{ trend.periodType }} 周期 · 共 {{ trend.points.length }} 个快照点
|
</span>
|
</div>
|
<EchartsUI v-if="trend && trend.points.length > 0" ref="chartRef" class="!h-72 w-full" />
|
<Empty v-else description="暂无历史快照数据,KPI 定义后从下一个整点开始聚合" />
|
</div>
|
|
<Spin :spinning="loading">
|
<Table
|
:columns="columns"
|
:data-source="kpiList.filter((k) =>
|
selectedCategory ? k.category === selectedCategory : true,
|
)"
|
:pagination="{ pageSize: 20 }"
|
row-key="code"
|
bordered
|
size="middle"
|
>
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.key === 'alertStatus'">
|
<span v-if="record.alertStatus === 'critical'" class="text-red-500">严重</span>
|
<span v-else-if="record.alertStatus === 'warn'" class="text-orange-500">警告</span>
|
<span v-else class="text-green-500">正常</span>
|
</template>
|
</template>
|
</Table>
|
</Spin>
|
</div>
|
</Page>
|
</template>
|