<script lang="ts" setup>
|
import { computed, onMounted, ref } from 'vue';
|
|
import { Page, useVbenModal } from '@vben/common-ui';
|
|
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 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() {
|
loading.value = true;
|
try {
|
forecastData.value = await getForecastList(forecastCode.value);
|
} catch {
|
forecastData.value = [];
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
/** ========== 指定预测编码生成 ========== */
|
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">
|
<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 指标和预警规则后查看
|
</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 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>
|
</div>
|
<div class="mb-1 text-2xl font-bold">
|
{{ item.forecastValue ?? '-' }}
|
<span class="ml-1 text-sm font-normal text-gray-400">(预测)</span>
|
</div>
|
<div v-if="item.actualValue != null" class="text-sm text-gray-500">
|
实际值: {{ item.actualValue }}
|
<span v-if="item.lowerBound != null" class="ml-2">
|
置信区间: [{{ 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>
|
</div>
|
</Page>
|
</template>
|