liu
2026-09-16 0af5e35af8338cc8a2e38c19ab79efbb9eff0b2c
src/views/bi/decision/forecast-analysis/index.vue
@@ -1,23 +1,47 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { DatePicker, Select, Spin } from 'ant-design-vue';
import { Button, DatePicker, InputNumber, message, Select, Spin } from 'ant-design-vue';
import dayjs from 'dayjs';
import { getForecastList } from '#/api/bi/decision/forecast';
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 = [
  { label: '电力运维', value: 4 },
  { label: '发电', value: 5 },
  { label: '检修', value: 6 },
];
async function loadData() {
@@ -31,28 +55,273 @@
  }
}
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({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 100,
  },
  wrapperClass: 'grid-cols-1',
  layout: 'vertical',
  schema: [
    {
      fieldName: 'forecastId',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'workOrderName',
      label: '工单名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入工单名称',
      },
    },
    {
      fieldName: 'workOrderType',
      label: '工单类型',
      component: 'Select',
      componentProps: {
        options: WORK_ORDER_TYPE_OPTIONS,
        placeholder: '请选择工单类型',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'requestDate',
      label: '需求日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择需求日期',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
    },
    {
      fieldName: 'quantity',
      label: '生产数量',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 2,
        placeholder: '请输入生产数量',
      },
      rules: 'required',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        rows: 3,
      },
    },
  ],
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    try {
      const values = await formApi.getValues<{
        forecastId: number;
        workOrderName?: string;
        workOrderType: number;
        requestDate?: string;
        quantity: number;
        remark?: string;
      }>();
      await generateForecastWorkOrder({
        forecastId: Number(values.forecastId),
        workOrderName: values.workOrderName,
        workOrderType: Number(values.workOrderType),
        requestDate: values.requestDate,
        quantity: Number(values.quantity ?? 1),
        remark: values.remark,
      });
      await modalApi.close();
      message.success('工单生成成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      return;
    }
    const data = modalApi.getData<{ item: DecisionForecastApi.ForecastItem }>();
    const item = data?.item;
    await formApi.resetForm();
    await formApi.setValues({
      forecastId: item?.id,
      workOrderType: 4,
      requestDate: item?.pointTime ?? dayjs().format('YYYY-MM-DD HH:mm:ss'),
      quantity: item?.forecastValue ?? 1,
      remark: `由预测(${item?.forecastName ?? forecastCode.value})自动生成`,
    });
  },
});
function handleGenerate(item: DecisionForecastApi.ForecastItem) {
  modalApi.setData({ item }).open();
}
onMounted(async () => {
  await Promise.all([loadData(), loadKpis()]);
});
</script>
<template>
  <Page :auto-content-height="true">
    <div class="p-4">
      <div class="mb-4 flex items-center gap-4">
        <h2 class="text-lg font-bold">负荷预测</h2>
    <Modal title="负荷预测生成生产工单" class="w-1/2">
      <Form class="mx-3" />
    </Modal>
    <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 v-for="item in forecastData" :key="item.id" class="rounded-lg border p-4">
          <div
            v-for="item in forecastData"
            :key="item.id"
            class="rounded-lg border p-4"
          >
            <div class="mb-2 flex items-center justify-between">
              <span class="font-medium">{{ item.forecastName }}</span>
              <span class="text-xs text-gray-400">{{ item.pointTime }}</span>
@@ -67,9 +336,22 @@
                置信区间: [{{ 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>
            <div class="mt-3">
              <a-button
                type="primary"
                ghost
                size="small"
                @click="handleGenerate(item)"
              >
                生成工单
              </a-button>
            </div>
          </div>
        </div>
      </Spin>