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/dashboard/index.vue                  |  270 +++++++++++---------
 src/views/bi/decision/kpi-config/data.ts          |    9 
 src/views/bi/decision/trend-analysis/index.vue    |  232 ++++++++++++++++-
 src/api/bi/decision/kpi.ts                        |   42 +++
 src/api/bi/decision/forecast.ts                   |   30 ++
 src/views/bi/dashboard/data.ts                    |   21 +
 src/views/bi/decision/kpi-dashboard/index.vue     |    6 
 src/views/bi/decision/forecast-analysis/index.vue |  157 +++++++++++
 8 files changed, 611 insertions(+), 156 deletions(-)

diff --git a/src/api/bi/decision/forecast.ts b/src/api/bi/decision/forecast.ts
index 3a055ae..b9bc162 100644
--- a/src/api/bi/decision/forecast.ts
+++ b/src/api/bi/decision/forecast.ts
@@ -15,6 +15,23 @@
     dimension?: string;
     dimensionValue?: string;
   }
+
+  export type ForecastModel = 'SMA' | 'WMA' | 'LR' | 'SEASONAL' | 'YOY';
+
+  export interface GenerateParams {
+    model?: ForecastModel;
+    window?: number;
+    period?: number;
+  }
+
+  export interface GenerateKpiParams {
+    kpiCode: string;
+    model?: ForecastModel;
+    window?: number;
+    period?: number;
+    periods?: number;
+    intervalMinutes?: number;
+  }
 }
 
 export function getForecastList(forecastCode: string, params?: Record<string, unknown>) {
@@ -23,9 +40,18 @@
   });
 }
 
-export function generateForecast(forecastCode: string) {
+export function generateForecast(
+  forecastCode: string,
+  params?: DecisionForecastApi.GenerateParams,
+) {
   return requestClient.post('/bi/decision/forecast/generate', null, {
-    params: { forecastCode },
+    params: { forecastCode, ...params },
+  });
+}
+
+export function generateKpiForecast(params: DecisionForecastApi.GenerateKpiParams) {
+  return requestClient.post<number>('/bi/decision/forecast/generate-from-kpi', null, {
+    params,
   });
 }
 
diff --git a/src/api/bi/decision/kpi.ts b/src/api/bi/decision/kpi.ts
index c7fcfbf..7e55345 100644
--- a/src/api/bi/decision/kpi.ts
+++ b/src/api/bi/decision/kpi.ts
@@ -34,6 +34,35 @@
     status?: number;
     remark?: string;
   }
+
+  /** 瓒嬪娍蹇収鐐� */
+  export interface KpiTrendPoint {
+    time: string;
+    value: number;
+  }
+
+  /** 瓒嬪娍鏁版嵁 */
+  export interface KpiTrend {
+    kpiCode: string;
+    kpiName: string;
+    category: string;
+    unit: string;
+    periodType: string;
+    points: KpiTrendPoint[];
+  }
+
+  /** 鐜瘮鐮斿垽 */
+  export interface KpiCompare {
+    currentValue?: number;
+    currentTime?: string;
+    previousValue?: number;
+    previousTime?: string;
+    changeRate?: number | null;
+    avgValue?: number;
+    maxValue?: number;
+    minValue?: number;
+    periodCount: number;
+  }
 }
 
 export function getKpiOverview() {
@@ -52,6 +81,19 @@
   return requestClient.post<number>(`/bi/decision/kpi/refresh/${kpiCode}`);
 }
 
+export function getKpiTrend(
+  kpiCode: string,
+  params?: { beginTime?: string; endTime?: string; periodType?: string },
+) {
+  return requestClient.get<DecisionKpiApi.KpiTrend>(`/bi/decision/kpi/trend/${kpiCode}`, {
+    params,
+  });
+}
+
+export function getKpiCompare(kpiCode: string) {
+  return requestClient.get<DecisionKpiApi.KpiCompare>(`/bi/decision/kpi/compare/${kpiCode}`);
+}
+
 export function getKpiDefinitionPage(params: Record<string, unknown>) {
   return requestClient.get('/bi/decision/kpi-definition/page', { params });
 }
diff --git a/src/views/bi/dashboard/data.ts b/src/views/bi/dashboard/data.ts
index 49dc1f9..a57787b 100644
--- a/src/views/bi/dashboard/data.ts
+++ b/src/views/bi/dashboard/data.ts
@@ -10,6 +10,27 @@
 
 /** 浠〃鐩樺厓淇℃伅鏄犲皠 */
 export const DASHBOARD_META: Record<string, DashboardMeta> = {
+  'decision-overview': {
+    code: 'decision_overview',
+    title: '鍐崇瓥鎬昏',
+    subtitle: '鍏ㄥ巶鏍稿績 KPI 涓�瑙堜笌瓒嬪娍鐮斿垽澶у睆',
+    gradient: 'from-cyan-600 via-sky-600 to-blue-500',
+    icon: 'lucide:layout-dashboard',
+  },
+  energy: {
+    code: 'energy',
+    title: '鑳借�楃洃鎺�',
+    subtitle: '鐢ㄧ數钀ラ攢涓庤兘鑰楀疄鏃剁洃鎺уぇ灞�',
+    gradient: 'from-amber-500 via-orange-500 to-red-500',
+    icon: 'lucide:zap',
+  },
+  'sales-customer': {
+    code: 'sales_customer',
+    title: '钀ラ攢瀹㈡埛',
+    subtitle: '閿�鍞鍗曚笌瀹㈡埛缁忚惀鍒嗘瀽澶у睆',
+    gradient: 'from-emerald-600 via-green-600 to-teal-500',
+    icon: 'lucide:users',
+  },
   'purchase-sales': {
     code: 'purchase_sales',
     title: '閲囪喘 路 閿�鍞� 路 鍞悗',
diff --git a/src/views/bi/dashboard/index.vue b/src/views/bi/dashboard/index.vue
index cac84a6..0685f54 100644
--- a/src/views/bi/dashboard/index.vue
+++ b/src/views/bi/dashboard/index.vue
@@ -40,21 +40,40 @@
   }
 }
 
-// ======== 鍒嗙被 ========
-const numberCards = computed(() => charts.value.filter((c) => c.chartType === 'number'));
-const tableCharts = computed(() => charts.value.filter((c) => c.chartType === 'table'));
-const echartsCharts = computed(() => charts.value.filter((c) => !['number', 'table'].includes(c.chartType)));
+// ======== 12 鍒楁爡鏍艰嚜閫傚簲 ========
+const COMPACT_BREAKPOINT = 1180;
+const isCompact = ref(false);
+function updateCompact() {
+  isCompact.value = window.innerWidth < COMPACT_BREAKPOINT;
+}
 
-// ======== 鍥捐〃 Grid 鍒楄法搴� ========
-function isFullWidth(chart: BiDashboardApi.ChartItem): boolean {
-  const w = chart.position?.w;
-  if (w && w >= 24) return true;
+function isNumberCard(chart: BiDashboardApi.ChartItem): boolean {
+  return chart.chartType === 'number';
+}
+
+function isTableCard(chart: BiDashboardApi.ChartItem): boolean {
   return chart.chartType === 'table';
 }
 
-function getGridStyle(chart: BiDashboardApi.ChartItem): Record<string, string> {
-  if (isFullWidth(chart)) return { 'grid-column': '1 / -1' };
-  return {};
+function chartGridStyle(chart: BiDashboardApi.ChartItem): Record<string, string> {
+  const pos = chart.position;
+  if (isCompact.value) {
+    // 绐勫睆锛氭寚鏍囧崱鍗婂涓ゅ垪鎺掑竷锛屽浘琛�/琛ㄦ牸鍏ㄥ鍫嗗彔锛岄伩鍏嶆尋鍘�
+    return isNumberCard(chart) || chart.chartType === 'gauge'
+      ? { gridColumn: 'auto / span 6' }
+      : { gridColumn: '1 / -1' };
+  }
+  if (!pos) {
+    return { gridColumn: '1 / -1' };
+  }
+  const colStart = Math.max(1, (pos.x || 0) + 1);
+  const colSpan = Math.min(12, Math.max(1, pos.w || 12));
+  const rowStart = Math.max(1, (pos.y || 0) + 1);
+  const rowSpan = Math.max(1, pos.h || 1);
+  return {
+    gridColumn: `${colStart} / span ${colSpan}`,
+    gridRow: `${rowStart} / span ${rowSpan}`,
+  };
 }
 
 // ======== 鍥捐〃缂╂斁 ========
@@ -140,6 +159,9 @@
 
 // ======== 鐪嬫澘涓婚鑹� ========
 const dashboardAccentColors: Record<string, string> = {
+  'decision-overview': '#00E5FF',
+  energy: '#FFC107',
+  'sales-customer': '#00FF88',
   'purchase-sales': '#6366f1',
   'production-equipment': '#f59e0b',
   quality: '#f43f5e',
@@ -160,6 +182,8 @@
   if (name.includes('鍚堝悓') || name.includes('鍗忓悓')) return 'lucide:file-text';
   if (name.includes('璁㈠崟')) return 'lucide:clipboard-list';
   if (name.includes('搴撳瓨') || name.includes('浠撳簱')) return 'lucide:package';
+  if (name.includes('鑳借��') || name.includes('鐢甸噺') || name.includes('鍔熺巼') || name.includes('鐢ㄨ兘')) return 'lucide:zap';
+  if (name.includes('瀹㈡埛')) return 'lucide:users';
   return 'lucide:bar-chart-4';
 }
 
@@ -167,13 +191,17 @@
 function getKpiColor(i: number) { return kpiColors[i % kpiColors.length]; }
 
 watch(dashboardCode, () => loadData());
+watch(isCompact, () => setTimeout(resizeAllCharts, 120));
 onMounted(() => {
   document.addEventListener('fullscreenchange', onFullscreenChange);
+  window.addEventListener('resize', updateCompact);
+  updateCompact();
   setupResizeObserver();
   loadData();
 });
 onBeforeUnmount(() => {
   document.removeEventListener('fullscreenchange', onFullscreenChange);
+  window.removeEventListener('resize', updateCompact);
   resizeObserver?.disconnect();
 });
 </script>
@@ -227,87 +255,79 @@
       <div style="height: 400px" />
     </Spin>
 
-    <!-- ======== 浠〃鐩樺唴瀹� ======== -->
+    <!-- ======== 12 鍒楁爡鏍煎竷灞� ======== -->
     <template v-else>
-      <!-- KPI 鍗$墖琛� -->
-      <div v-if="numberCards.length > 0" class="kpi-row">
+      <div v-if="charts.length > 0" class="bi-grid">
         <div
-          v-for="(card, i) in numberCards"
-          :key="card.id"
-          class="kpi-card"
-          :style="{ '--kpi-color': getKpiColor(i), animationDelay: `${i * 0.06}s` }"
+          v-for="(chart, i) in charts"
+          :key="chart.id"
+          class="bi-cell"
+          :style="chartGridStyle(chart)"
         >
-          <div class="kpi-icon">
-            <IconifyIcon :icon="getKpiIcon(card.name)" />
+          <!-- 鏁板瓧鎸囨爣鍗� -->
+          <div
+            v-if="isNumberCard(chart)"
+            class="kpi-card"
+            :style="{ '--kpi-color': getKpiColor(i) }"
+          >
+            <div class="kpi-icon">
+              <IconifyIcon :icon="getKpiIcon(chart.name)" />
+            </div>
+            <div class="kpi-body">
+              <span class="kpi-label">{{ chart.name }}</span>
+              <span
+                class="kpi-value"
+                :ref="(el: unknown) => {
+                  if (el && chart.data?.[0]) {
+                    const v = extractNumberValue(chart.data[0] as Record<string, unknown>);
+                    maybeAnimate(el as HTMLElement, chart.id!, v);
+                  }
+                }"
+              >{{ getCardDisplayValue(chart) }}</span>
+            </div>
           </div>
-          <div class="kpi-body">
-            <span class="kpi-label">{{ card.name }}</span>
-            <span
-              class="kpi-value"
-              :ref="(el: unknown) => {
-                if (el && card.data?.[0]) {
-                  const v = extractNumberValue(card.data[0] as Record<string, unknown>);
-                  maybeAnimate(el as HTMLElement, card.id!, v);
-                }
-              }"
-            >{{ getCardDisplayValue(card) }}</span>
+
+          <!-- 鏁版嵁琛ㄦ牸 -->
+          <div v-else-if="isTableCard(chart)" class="table-card">
+            <div class="table-header">
+              <span class="table-header-dot" :style="{ background: accentColor, boxShadow: `0 0 6px ${accentColor}` }" />
+              <span class="table-header-title">{{ chart.name }}</span>
+              <span class="table-badge">瀹炴椂婊氬姩</span>
+            </div>
+            <div v-if="chart.data && chart.data.length > 0" class="table-wrap">
+              <table class="data-table">
+                <thead>
+                  <tr>
+                    <th
+                      v-for="key in Object.keys(chart.data[0] || {})"
+                      :key="key"
+                    >{{ key }}</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr
+                    v-for="(row, ri) in chart.data"
+                    :key="ri"
+                    :style="{ animationDelay: `${ri * 50}ms` }"
+                  >
+                    <td
+                      v-for="key in Object.keys(chart.data[0] || {})"
+                      :key="key"
+                    >{{ getCellValue(row, key) }}</td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+            <Empty v-else description="鏆傛棤鏁版嵁" />
           </div>
+
+          <!-- ECharts 鍥捐〃 -->
+          <ChartCard v-else :chart="chart" :accent-color="accentColor" />
         </div>
       </div>
-
-      <!-- 鍥捐〃 Grid -->
-      <div v-if="echartsCharts.length > 0" class="chart-row">
-        <div
-          v-for="(chart, i) in echartsCharts"
-          :key="chart.id"
-          :style="{ ...getGridStyle(chart), animationDelay: `${i * 0.08}s` }"
-        >
-          <ChartCard :chart="chart" :accent-color="accentColor" />
-        </div>
-      </div>
-
-      <!-- 鏁版嵁琛ㄦ牸 -->
-      <template v-if="tableCharts.length > 0">
-        <div
-          v-for="chart in tableCharts"
-          :key="chart.id"
-          class="table-card"
-        >
-          <div class="table-header">
-            <span class="table-header-dot" :style="{ background: accentColor, boxShadow: `0 0 6px ${accentColor}` }" />
-            <span class="table-header-title">{{ chart.name }}</span>
-            <span class="table-badge">瀹炴椂婊氬姩</span>
-          </div>
-          <div v-if="chart.data && chart.data.length > 0" class="table-wrap">
-            <table class="data-table">
-              <thead>
-                <tr>
-                  <th
-                    v-for="key in Object.keys(chart.data[0] || {})"
-                    :key="key"
-                  >{{ key }}</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr
-                  v-for="(row, ri) in chart.data"
-                  :key="ri"
-                  :style="{ animationDelay: `${ri * 50}ms` }"
-                >
-                  <td
-                    v-for="key in Object.keys(chart.data[0] || {})"
-                    :key="key"
-                  >{{ getCellValue(row, key) }}</td>
-                </tr>
-              </tbody>
-            </table>
-          </div>
-          <Empty v-else description="鏆傛棤鏁版嵁" />
-        </div>
-      </template>
 
       <!-- 绌虹姸鎬� -->
-      <div v-if="charts.length === 0" class="empty-state">
+      <div v-else class="empty-state">
         <IconifyIcon class="size-16 text-white/15" icon="lucide:bar-chart-4" />
         <p>鏆傛湭閰嶇疆鍥捐〃鏁版嵁</p>
         <p class="empty-sub">璇峰湪閰嶇疆绠$悊涓负褰撳墠浠〃鐩樻坊鍔犲浘琛�</p>
@@ -333,14 +353,17 @@
   --text-muted: rgba(145, 158, 185, 0.5);
 
   position: relative;
+  display: flex;
+  flex-direction: column;
   background:
     radial-gradient(ellipse 70% 50% at 50% 0%, #0d1f42 0%, #060e24 35%, #020817 100%);
   color: var(--text-primary);
-  padding: 16px 20px 24px;
+  padding: 16px 20px 20px;
   font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
   overflow-x: hidden;
   overflow-y: auto;
-  max-height: calc(100vh - 104px);
+  height: calc(100vh - 104px);
+  min-height: 320px;
 }
 
 /* 鑳屾櫙鍏夋檿 + 缃戞牸 */
@@ -375,6 +398,7 @@
 .dash-header {
   position: relative; z-index: 1;
   display: flex; align-items: center; gap: 16px;
+  flex-shrink: 0;
   padding-bottom: 14px; margin-bottom: 16px;
   border-bottom: 1px solid rgba(0,229,255,0.08);
 }
@@ -451,16 +475,35 @@
 }
 .dash-fs-btn:hover { border-color: rgba(255,255,255,0.2); color: #fff; background: rgba(255,255,255,0.04); }
 
-/* ======== KPI 琛� ======== */
-.kpi-row {
+/* ======== 12 鍒楁爡鏍� ======== */
+.bi-grid {
   position: relative; z-index: 1;
+  flex: 1;
+  min-height: 0;
   display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
-  gap: 12px; margin-bottom: 16px;
+  grid-template-columns: repeat(12, minmax(0, 1fr));
+  grid-auto-rows: minmax(36px, 1fr);
+  gap: 12px;
 }
 
+.bi-cell {
+  min-width: 0;
+  min-height: 0;
+  animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
+}
+
+.bi-cell > * {
+  height: 100%;
+}
+
+/* ======== KPI 鎸囨爣鍗� ======== */
 .kpi-card {
-  display: flex; align-items: center; gap: 12px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  text-align: center;
   padding: 14px 16px;
   background: var(--bg-card);
   backdrop-filter: blur(10px);
@@ -469,8 +512,6 @@
   border-radius: 10px;
   cursor: default;
   transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
-  animation: kpi-fade-up 0.55s cubic-bezier(0.4, 0, 0.2, 1) both;
-  min-width: 0;
   position: relative;
   overflow: hidden;
 }
@@ -491,8 +532,8 @@
 
 .kpi-icon {
   display: flex; align-items: center; justify-content: center;
-  width: 40px; height: 40px; flex-shrink: 0;
-  border-radius: 8px;
+  width: 42px; height: 42px; flex-shrink: 0;
+  border-radius: 10px;
   background: color-mix(in srgb, var(--kpi-color, var(--cyan)) 15%, transparent);
   color: var(--kpi-color, var(--cyan));
   font-size: 18px;
@@ -500,17 +541,18 @@
 }
 
 .kpi-body {
-  flex: 1; min-width: 0;
-  display: flex; flex-direction: column; gap: 4px;
+  display: flex; flex-direction: column; align-items: center; gap: 4px;
+  min-width: 0;
 }
 
 .kpi-label {
   font-size: 11px; color: var(--text-secondary); letter-spacing: 0.3px;
   white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+  max-width: 100%;
 }
 
 .kpi-value {
-  font-size: 24px; font-weight: 800; line-height: 1;
+  font-size: 28px; font-weight: 800; line-height: 1.1;
   font-variant-numeric: tabular-nums;
   color: var(--text-primary);
 }
@@ -520,32 +562,19 @@
   to { opacity: 1; transform: translateY(0); }
 }
 
-/* ======== 鍥捐〃琛� ======== */
-.chart-row {
-  position: relative; z-index: 1;
-  display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
-  gap: 12px;
-  margin-bottom: 16px;
-}
-
-.chart-row > div {
-  min-width: 0;
-  animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
-}
-
 /* ======== 琛ㄦ牸鍗$墖 ======== */
 .table-card {
-  position: relative; z-index: 1;
   background: var(--bg-card);
   backdrop-filter: blur(8px);
   -webkit-backdrop-filter: blur(8px);
   border: 1px solid var(--border);
   border-radius: 10px;
   padding: 14px;
-  margin-bottom: 16px;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
   transition: all 0.35s;
-  animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
+  position: relative;
 }
 .table-card::before {
   content: '';
@@ -582,7 +611,8 @@
 }
 
 .table-wrap {
-  overflow-x: auto; border-radius: 8px;
+  flex: 1;
+  overflow-x: auto; overflow-y: auto; border-radius: 8px;
   background: rgba(4, 14, 36, 0.55);
   backdrop-filter: blur(6px);
   -webkit-backdrop-filter: blur(6px);
@@ -643,8 +673,7 @@
 .is-fullscreen::after {
   position: fixed;
 }
-.is-fullscreen .kpi-row { gap: 16px; }
-.is-fullscreen .chart-row { gap: 16px; }
+.is-fullscreen .bi-grid { gap: 16px; }
 .is-fullscreen .dash-header { margin-bottom: 24px; }
 
 /* ======== 鍔犺浇鎬�/绌虹姸鎬� ======== */
@@ -657,9 +686,6 @@
   .bi-dashboard { padding: 10px; }
   .dash-header { flex-wrap: wrap; }
   .dash-header-divider { display: none; }
-  .kpi-row { grid-template-columns: 1fr 1fr; }
-  .chart-row { grid-template-columns: 1fr; }
-  .chart-row > div { grid-column: 1 / -1 !important; }
-  .kpi-value { font-size: 20px; }
+  .kpi-value { font-size: 24px; }
 }
-</style>
+</style>
\ No newline at end of file
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
diff --git a/src/views/bi/decision/kpi-config/data.ts b/src/views/bi/decision/kpi-config/data.ts
index c56b089..6e19b12 100644
--- a/src/views/bi/decision/kpi-config/data.ts
+++ b/src/views/bi/decision/kpi-config/data.ts
@@ -39,11 +39,12 @@
       componentProps: {
         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' },
         ],
         allowClear: true,
@@ -87,11 +88,12 @@
       component: 'Select',
       componentProps: {
         options: [
-          { 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' },
         ],
       },
@@ -145,10 +147,11 @@
 }
 
 export const CATEGORY_MAP: Record<string, string> = {
-  power_supply: '渚涚數閲�',
+  energy: '鑳借��',
   device_operation: '璁惧杩愯',
   production: '鐢熶骇',
   quality: '璐ㄩ噺',
   procurement: '閲囪喘',
+  sales: '钀ラ攢',
   safety: '瀹夊叏',
 };
diff --git a/src/views/bi/decision/kpi-dashboard/index.vue b/src/views/bi/decision/kpi-dashboard/index.vue
index a41563a..da68316 100644
--- a/src/views/bi/decision/kpi-dashboard/index.vue
+++ b/src/views/bi/decision/kpi-dashboard/index.vue
@@ -25,20 +25,22 @@
 }
 
 const CATEGORY_MAP: Record<string, string> = {
-  power_supply: '渚涚數閲�',
+  energy: '鑳借��',
   device_operation: '璁惧杩愯',
   production: '鐢熶骇',
   quality: '璐ㄩ噺',
   procurement: '閲囪喘',
+  sales: '钀ラ攢',
   safety: '瀹夊叏',
 };
 
 const CATEGORY_COLORS: Record<string, string> = {
-  power_supply: '#1677ff',
+  energy: '#f59e0b',
   device_operation: '#722ed1',
   production: '#13c2c2',
   quality: '#52c41a',
   procurement: '#fa8c16',
+  sales: '#1677ff',
   safety: '#ff4d4f',
 };
 
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