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