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