feat(bi): 新增决策总览仪表盘并优化预测分析功能
- 添加决策总览、能耗监控、营销客户三个新仪表盘及其元数据配置
- 重构仪表盘网格布局系统,实现12列栅格自适应布局
- 新增多种预测模型支持包括SMA、WMA、LR、SEASONAL、YOY
- 实现基于KPI历史数据的滚动预测功能
- 优化趋势分析页面,添加环比研判卡片和折线图展示
- 更新KPI分类配置,将供电量改为能耗,新增营销分类
- 优化图表响应式布局和移动端适配效果
| | |
| | | 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>) { |
| | |
| | | }); |
| | | } |
| | | |
| | | 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, |
| | | }); |
| | | } |
| | | |
| | |
| | | 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() { |
| | |
| | | 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 }); |
| | | } |
| | |
| | | |
| | | /** 仪表盘元信息映射 */ |
| | | 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: '采购 · 销售 · 售后', |
| | |
| | | } |
| | | } |
| | | |
| | | // ======== 分类 ======== |
| | | 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}`, |
| | | }; |
| | | } |
| | | |
| | | // ======== 图表缩放 ======== |
| | |
| | | |
| | | // ======== 看板主题色 ======== |
| | | const dashboardAccentColors: Record<string, string> = { |
| | | 'decision-overview': '#00E5FF', |
| | | energy: '#FFC107', |
| | | 'sales-customer': '#00FF88', |
| | | 'purchase-sales': '#6366f1', |
| | | 'production-equipment': '#f59e0b', |
| | | quality: '#f43f5e', |
| | |
| | | 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'; |
| | | } |
| | | |
| | |
| | | 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> |
| | |
| | | <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> |
| | |
| | | --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; |
| | | } |
| | | |
| | | /* 背景光晕 + 网格 */ |
| | |
| | | .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); |
| | | } |
| | |
| | | } |
| | | .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); |
| | |
| | | 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; |
| | | } |
| | |
| | | |
| | | .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; |
| | |
| | | } |
| | | |
| | | .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); |
| | | } |
| | |
| | | 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: ''; |
| | |
| | | } |
| | | |
| | | .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); |
| | |
| | | .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; } |
| | | |
| | | /* ======== 加载态/空状态 ======== */ |
| | |
| | | .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> |
| | |
| | | <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: '季节指数 SEASONAL', value: 'SEASONAL' }, |
| | | { label: '同比增长 YOY', value: 'YOY' }, |
| | | ]; |
| | | |
| | | const WORK_ORDER_TYPE_OPTIONS = [ |
| | |
| | | } |
| | | } |
| | | |
| | | 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({ |
| | |
| | | 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({ |
| | |
| | | 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> |
| | |
| | | <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 |
| | |
| | | 置信区间: [{{ 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> |
| | |
| | | </Spin> |
| | | </div> |
| | | </Page> |
| | | </template> |
| | | </template> |
| | |
| | | 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, |
| | |
| | | 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' }, |
| | | ], |
| | | }, |
| | |
| | | } |
| | | |
| | | export const CATEGORY_MAP: Record<string, string> = { |
| | | power_supply: '供电量', |
| | | energy: '能耗', |
| | | device_operation: '设备运行', |
| | | production: '生产', |
| | | quality: '质量', |
| | | procurement: '采购', |
| | | sales: '营销', |
| | | safety: '安全', |
| | | }; |
| | |
| | | } |
| | | |
| | | 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', |
| | | }; |
| | | |
| | |
| | | <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 }, |
| | |
| | | { 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="暂无历史快照数据,KPI 定义后从下一个整点开始聚合" /> |
| | | </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 |