From dc1d067c566bbde1c7170186960a8bd27f210a47 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期三, 09 九月 2026 13:45:58 +0800
Subject: [PATCH] feat(bi): 新增决策总览仪表盘并优化预测分析功能
---
src/views/bi/decision/trend-analysis/index.vue | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 files changed, 217 insertions(+), 15 deletions(-)
diff --git a/src/views/bi/decision/trend-analysis/index.vue b/src/views/bi/decision/trend-analysis/index.vue
index 7919f94..1764297 100644
--- a/src/views/bi/decision/trend-analysis/index.vue
+++ b/src/views/bi/decision/trend-analysis/index.vue
@@ -1,27 +1,56 @@
<script lang="ts" setup>
-import { onMounted, ref } from 'vue';
+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 { Select, Spin, Table } from 'ant-design-vue';
+import { Empty, Select, Spin, Table } from 'ant-design-vue';
+import dayjs from 'dayjs';
-import { getKpiOverview } from '#/api/bi/decision/kpi';
+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 trendData = ref<any[]>([]);
+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: 'power_supply' },
+ { 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 },
@@ -31,43 +60,216 @@
{ title: '棰勮鐘舵��', dataIndex: 'alertStatus', key: 'alertStatus', width: 100 },
];
-async function loadData() {
+async function loadKpis() {
loading.value = true;
try {
const overview = await getKpiOverview();
- let list = overview.kpis || [];
- if (selectedCategory.value) {
- list = list.filter((k) => k.category === selectedCategory.value);
+ kpiList.value = overview.kpis || [];
+ if (!selectedKpi.value && kpiOptions.value.length > 0) {
+ selectedKpi.value = kpiOptions.value[0].value;
}
- trendData.value = list;
} catch {
- trendData.value = [];
+ kpiList.value = [];
} finally {
loading.value = false;
}
}
-onMounted(loadData);
+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 flex items-center gap-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
- @change="loadData"
/>
+ <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="鏆傛棤鍘嗗彶蹇収鏁版嵁锛孠PI 瀹氫箟鍚庝粠涓嬩竴涓暣鐐瑰紑濮嬭仛鍚�" />
</div>
<Spin :spinning="loading">
<Table
:columns="columns"
- :data-source="trendData"
+ :data-source="kpiList.filter((k) =>
+ selectedCategory ? k.category === selectedCategory : true,
+ )"
:pagination="{ pageSize: 20 }"
row-key="code"
bordered
--
Gitblit v1.9.3