2 天以前 dc1d067c566bbde1c7170186960a8bd27f210a47
feat(bi): 新增决策总览仪表盘并优化预测分析功能

- 添加决策总览、能耗监控、营销客户三个新仪表盘及其元数据配置
- 重构仪表盘网格布局系统,实现12列栅格自适应布局
- 新增多种预测模型支持包括SMA、WMA、LR、SEASONAL、YOY
- 实现基于KPI历史数据的滚动预测功能
- 优化趋势分析页面,添加环比研判卡片和折线图展示
- 更新KPI分类配置,将供电量改为能耗,新增营销分类
- 优化图表响应式布局和移动端适配效果
已修改8个文件
767 ■■■■ 文件已修改
src/api/bi/decision/forecast.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bi/decision/kpi.ts 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/data.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/index.vue 270 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/decision/forecast-analysis/index.vue 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/decision/kpi-config/data.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/decision/kpi-dashboard/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/decision/trend-analysis/index.vue 232 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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,
  });
}
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 });
}
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: '采购 · 销售 · 售后',
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>
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: '季节指数 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>
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: '安全',
};
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',
};
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="暂无历史快照数据,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