| | |
| | | <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() { |
| | |
| | | } |
| | | } |
| | | |
| | | 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> |
| | |
| | | 置信区间: [{{ 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> |