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/forecast-analysis/index.vue |  157 ++++++++++++++++++++++++++++++++++++++++++++++++----
 1 files changed, 145 insertions(+), 12 deletions(-)

diff --git a/src/views/bi/decision/forecast-analysis/index.vue b/src/views/bi/decision/forecast-analysis/index.vue
index 404c042..20eeb4b 100644
--- a/src/views/bi/decision/forecast-analysis/index.vue
+++ b/src/views/bi/decision/forecast-analysis/index.vue
@@ -1,27 +1,41 @@
 <script lang="ts" setup>
-import { onMounted, ref } from 'vue';
+import { computed, onMounted, ref } from 'vue';
 
 import { Page, useVbenModal } from '@vben/common-ui';
 
-import { DatePicker, message, Select, Spin } from 'ant-design-vue';
+import { Button, DatePicker, InputNumber, message, Select, Spin } from 'ant-design-vue';
 import dayjs from 'dayjs';
 
 import { useVbenForm } from '#/adapter/form';
 import {
+  generateForecast,
   generateForecastWorkOrder,
+  generateKpiForecast,
   getForecastList,
 } from '#/api/bi/decision/forecast';
+import type { DecisionForecastApi } from '#/api/bi/decision/forecast';
+import { getKpiOverview } from '#/api/bi/decision/kpi';
+import type { DecisionKpiApi } from '#/api/bi/decision/kpi';
 
 defineOptions({ name: 'DecisionForecastAnalysis' });
 
 const loading = ref(false);
-const forecastData = ref<any[]>([]);
+const generating = ref(false);
+const forecastData = ref<DecisionForecastApi.ForecastItem[]>([]);
 const forecastCode = ref('load_forecast_daily');
 
 const FORECAST_OPTIONS = [
   { label: '鏃ヨ礋鑽烽娴�', value: 'load_forecast_daily' },
   { label: '鍛ㄨ礋鑽烽娴�', value: 'load_forecast_weekly' },
   { label: '鏈堣礋鑽烽娴�', value: 'load_forecast_monthly' },
+];
+
+const MODEL_OPTIONS: Array<{ label: string; value: DecisionForecastApi.ForecastModel }> = [
+  { label: '绠�鍗曠Щ鍔ㄥ钩鍧� SMA', value: 'SMA' },
+  { label: '鍔犳潈绉诲姩骞冲潎 WMA', value: 'WMA' },
+  { label: '绾挎�у洖褰� LR', value: 'LR' },
+  { label: '瀛h妭鎸囨暟 SEASONAL', value: 'SEASONAL' },
+  { label: '鍚屾瘮澧為暱 YOY', value: 'YOY' },
 ];
 
 const WORK_ORDER_TYPE_OPTIONS = [
@@ -41,7 +55,74 @@
   }
 }
 
-onMounted(loadData);
+/** ========== 鎸囧畾棰勬祴缂栫爜鐢熸垚 ========== */
+const model = ref<DecisionForecastApi.ForecastModel>('SMA');
+
+async function handleGenerateForecast() {
+  generating.value = true;
+  try {
+    await generateForecast(forecastCode.value, { model: model.value });
+    message.success(`宸茬敓鎴� ${forecastCode.value} 棰勬祴`);
+    await loadData();
+  } catch {
+    message.error('鐢熸垚澶辫触锛岃妫�鏌ラ娴嬮厤缃�');
+  } finally {
+    generating.value = false;
+  }
+}
+
+/** ========== 鍩轰簬 KPI 鍘嗗彶婊氬姩棰勬祴 ========== */
+const kpiList = ref<DecisionKpiApi.KpiItem[]>([]);
+const selectedKpi = ref<string>('');
+const kpiModel = ref<DecisionForecastApi.ForecastModel>('SMA');
+const forecastPeriods = ref(30);
+const intervalMinutes = ref(360);
+
+const kpiOptions = computed(() =>
+  kpiList.value.map((k) => ({
+    label: `${k.name}${k.unit ? `锛�${k.unit}锛塦 : ''}`,
+    value: k.code,
+  })),
+);
+
+async function loadKpis() {
+  try {
+    const overview = await getKpiOverview();
+    kpiList.value = overview.kpis || [];
+    if (!selectedKpi.value && kpiList.value.length > 0) {
+      selectedKpi.value = kpiList.value[0].code;
+    }
+  } catch {
+    kpiList.value = [];
+  }
+}
+
+async function handleGenerateKpiForecast() {
+  if (!selectedKpi.value) {
+    message.warning('璇峰厛閫夋嫨 KPI');
+    return;
+  }
+  generating.value = true;
+  try {
+    const count = await generateKpiForecast({
+      kpiCode: selectedKpi.value,
+      model: kpiModel.value,
+      periods: forecastPeriods.value,
+      intervalMinutes: intervalMinutes.value,
+    });
+    message.success(`宸插熀浜� KPI 鐢熸垚 ${count} 鏉¢娴媊);
+    forecastCode.value = selectedKpi.value;
+    await loadData();
+  } catch (e) {
+    const msg =
+      typeof e === 'string'
+        ? e
+        : (e as Error | undefined)?.message || '鏆傛棤鍘嗗彶鏁版嵁锛屾棤娉曢娴�';
+    message.error(msg);
+  } finally {
+    generating.value = false;
+  }
+}
 
 /** ========== 鐢熸垚宸ュ崟 ========== */
 const [Form, formApi] = useVbenForm({
@@ -148,7 +229,7 @@
     if (!isOpen) {
       return;
     }
-    const data = modalApi.getData<{ item: any }>();
+    const data = modalApi.getData<{ item: DecisionForecastApi.ForecastItem }>();
     const item = data?.item;
     await formApi.resetForm();
     await formApi.setValues({
@@ -156,14 +237,18 @@
       workOrderType: 4,
       requestDate: item?.pointTime ?? dayjs().format('YYYY-MM-DD HH:mm:ss'),
       quantity: item?.forecastValue ?? 1,
-      remark: `鐢辫礋鑽烽娴嬶紙${item?.forecastName ?? forecastCode.value}锛夎嚜鍔ㄧ敓鎴恅,
+      remark: `鐢遍娴嬶紙${item?.forecastName ?? forecastCode.value}锛夎嚜鍔ㄧ敓鎴恅,
     });
   },
 });
 
-function handleGenerate(item: any) {
+function handleGenerate(item: DecisionForecastApi.ForecastItem) {
   modalApi.setData({ item }).open();
 }
+
+onMounted(async () => {
+  await Promise.all([loadData(), loadKpis()]);
+});
 </script>
 
 <template>
@@ -171,20 +256,65 @@
     <Modal title="璐熻嵎棰勬祴鐢熸垚鐢熶骇宸ュ崟" class="w-1/2">
       <Form class="mx-3" />
     </Modal>
-    <div class="p-4">
-      <div class="mb-4 flex items-center gap-4">
-        <h2 class="text-lg font-bold">璐熻嵎棰勬祴</h2>
+    <div class="p-4 space-y-4">
+      <!-- 澶撮儴锛氶娴嬬紪鐮� + 妯″瀷 -->
+      <div class="flex flex-wrap items-center gap-4">
+        <h2 class="text-lg font-bold">棰勬祴鍒嗘瀽</h2>
         <Select
           v-model:value="forecastCode"
           :options="FORECAST_OPTIONS"
           style="width: 180px"
           @change="loadData"
         />
+        <Select v-model:value="model" :options="MODEL_OPTIONS" style="width: 200px" />
+        <Button type="primary" :loading="generating" @click="handleGenerateForecast">
+          鐢熸垚棰勬祴
+        </Button>
+      </div>
+
+      <!-- KPI 婊氬姩棰勬祴 -->
+      <div class="rounded-lg border border-gray-200 bg-white p-4">
+        <div class="mb-3 text-sm font-medium">鍩轰簬 KPI 鍘嗗彶婊氬姩棰勬祴</div>
+        <div class="grid grid-cols-2 gap-3 lg:grid-cols-5">
+          <Select
+            v-model:value="selectedKpi"
+            :options="kpiOptions"
+            style="width: 100%"
+            placeholder="閫夋嫨 KPI"
+            show-search
+            option-filter-prop="label"
+          />
+          <Select v-model:value="kpiModel" :options="MODEL_OPTIONS" style="width: 100%" />
+          <div>
+            <InputNumber
+              v-model:value="forecastPeriods"
+              class="!w-full"
+              :min="1"
+              :max="90"
+              placeholder="棰勬祴鏈熸暟"
+            />
+          </div>
+          <div>
+            <InputNumber
+              v-model:value="intervalMinutes"
+              class="!w-full"
+              :min="60"
+              :step="60"
+              placeholder="闂撮殧鍒嗛挓"
+            />
+          </div>
+          <Button :loading="generating" @click="handleGenerateKpiForecast">
+            鐢熸垚 KPI 棰勬祴
+          </Button>
+        </div>
+        <div class="mt-2 text-xs text-gray-400">
+          浣跨敤 KPI 鍘嗗彶蹇収鎸夋墍閫夋ā鍨嬫粴鍔ㄩ娴嬶紝棰勬祴鏈熸暟榛樿 30銆佷笂闄� 90锛涢棿闅斿垎閽熼粯璁� 360锛�6 灏忔椂锛夈��
+        </div>
       </div>
 
       <Spin :spinning="loading">
         <div v-if="forecastData.length === 0" class="py-12 text-center text-gray-400">
-          鏆傛棤棰勬祴鏁版嵁锛岃鍏堥厤缃� KPI 鎸囨爣鍜岄璀﹁鍒欏悗鏌ョ湅
+          鏆傛棤棰勬祴鏁版嵁锛岃閰嶇疆 KPI 鎸囨爣鍜岄璀﹁鍒欏悗鏌ョ湅
         </div>
         <div v-else class="grid grid-cols-1 gap-4 lg:grid-cols-2">
           <div
@@ -206,6 +336,9 @@
                 缃俊鍖洪棿: [{{ item.lowerBound }} ~ {{ item.upperBound }}]
               </span>
             </div>
+            <div v-if="item.modelVersion" class="mt-1 text-xs text-gray-400">
+              妯″瀷: {{ item.modelVersion }}
+            </div>
             <div v-if="item.dimension" class="mt-1 text-xs text-gray-400">
               {{ item.dimension }}: {{ item.dimensionValue }}
             </div>
@@ -224,4 +357,4 @@
       </Spin>
     </div>
   </Page>
-</template>
+</template>
\ No newline at end of file

--
Gitblit v1.9.3