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 +++++++++++++++++++++++++++++------------------------
 1 files changed, 148 insertions(+), 122 deletions(-)

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

--
Gitblit v1.9.3