<script setup lang="ts">
|
import type { FormInstance, TablePaginationConfig } from 'ant-design-vue';
|
import type { EnergyAlertRule, EnergyAlertLog, EnergyType } from '#/api/energy';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
import { Page } from '@vben/common-ui';
|
import { Alert, Button, Card, DatePicker, Form, FormItem, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, TabPane, Tag, message } from 'ant-design-vue';
|
import dayjs from 'dayjs';
|
import { checkEnergyAlerts, deleteEnergyAlertRule, getEnergyAlertLogs, getEnergyAlertRecipients, getEnergyAlertRules, saveEnergyAlertRule } from '#/api/energy';
|
import { energyInfo, energyOptions, numberText, productOptions } from '../shared';
|
|
defineOptions({ name: 'EnergyAlert' });
|
const tab = ref('rules');
|
const rules = ref<EnergyAlertRule[]>([]);
|
const logs = ref<EnergyAlertLog[]>([]);
|
const recipients = ref<{ label: string; value: number }[]>([]);
|
const loading = ref(false), error = ref(false), saving = ref(false), checking = ref(false), open = ref(false);
|
const rulePage = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
|
const logPage = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
|
const logFilters = reactive<{ date?: string; status?: string }>({});
|
const checkDate = ref(dayjs().format('YYYY-MM-DD'));
|
const initial = (): EnergyAlertRule => ({ name: '', energyType: 'WATER', metric: 'QUANTITY', threshold: 1, enabled: true, recipientIds: [], usageArea: '', productCategory: undefined });
|
const model = reactive<EnergyAlertRule>(initial());
|
const metricOptions = [
|
{ label: '每日用量', value: 'QUANTITY' }, { label: '每日费用', value: 'AMOUNT' },
|
{ label: '每月用量', value: 'MONTH_QUANTITY' }, { label: '每月费用', value: 'MONTH_AMOUNT' },
|
];
|
const metricLabel = (metric: string) => metricOptions.find(option => option.value === metric)?.label ?? metric;
|
const isAmount = (metric: string) => metric.endsWith('AMOUNT');
|
const formRef = ref<FormInstance>();
|
const unit = computed(() => isAmount(model.metric) ? '元' : energyInfo[model.energyType].unit);
|
const validation = {
|
name: [{ required: true, whitespace: true, message: '请输入规则名称' }],
|
threshold: [{ required: true, type: 'number' as const, min: 0.001, message: '阈值必须大于零' }],
|
recipientIds: [{ required: true, type: 'array' as const, min: 1, max: 50, message: '请选择 1 至 50 位接收人' }],
|
};
|
const ruleColumns = [
|
{ title: '规则名称', dataIndex: 'name', width: 180 }, { title: '能源', dataIndex: 'energyType', width: 80 },
|
{ title: '统计范围', key: 'scope', width: 200 }, { title: '统计指标 / 阈值', key: 'threshold', width: 190 },
|
{ title: '接收人', key: 'recipients', width: 180 }, { title: '状态', key: 'enabled', width: 80 },
|
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const },
|
];
|
const logColumns = [
|
{ title: '统计周期', key: 'period', width: 120 }, { title: '规则名称', dataIndex: 'ruleName', width: 160 },
|
{ title: '能源 / 范围', key: 'scope', width: 230 }, { title: '实际值 / 阈值', key: 'value', width: 210 },
|
{ title: '接收人', dataIndex: 'recipientName', width: 120 }, { title: '结果', key: 'status', width: 100 },
|
{ title: '失败原因', dataIndex: 'errorMessage', width: 300 },
|
];
|
let loadId = 0;
|
async function load() {
|
const id = ++loadId; loading.value = true; error.value = false;
|
try {
|
if (tab.value === 'rules') {
|
const result = await getEnergyAlertRules({ pageNo: rulePage.current, pageSize: rulePage.pageSize });
|
if (id === loadId) { rules.value = result.list; rulePage.total = result.total; }
|
} else {
|
const result = await getEnergyAlertLogs({ ...logFilters, pageNo: logPage.current, pageSize: logPage.pageSize });
|
if (id === loadId) { logs.value = result.list; logPage.total = result.total; }
|
}
|
} catch { if (id === loadId) error.value = true; }
|
finally { if (id === loadId) loading.value = false; }
|
}
|
function changePage(page: TablePaginationConfig) {
|
Object.assign(tab.value === 'rules' ? rulePage : logPage, { current: page.current ?? 1, pageSize: page.pageSize ?? 10 }); void load();
|
}
|
async function showForm(row?: EnergyAlertRule) {
|
try { recipients.value = (await getEnergyAlertRecipients()).map(user => ({ label: user.nickname, value: user.id })); }
|
catch { message.error('无法加载接收人,请重试'); return; }
|
Object.keys(model).forEach(key => delete (model as unknown as Record<string, unknown>)[key]);
|
Object.assign(model, row ? { ...row, recipientIds: [...row.recipientIds] } : initial());
|
open.value = true; formRef.value?.clearValidate();
|
}
|
async function save() {
|
await formRef.value?.validate(); saving.value = true;
|
try { await saveEnergyAlertRule({ ...model }); open.value = false; message.success('规则已保存,后续录入自动检查;历史日期可手动检查'); await load(); }
|
finally { saving.value = false; }
|
}
|
async function remove(id: number) { await deleteEnergyAlertRule(id); message.success('已删除,历史通知记录保留'); rulePage.current = 1; await load(); }
|
async function check() {
|
if (!checkDate.value || checkDate.value > dayjs().format('YYYY-MM-DD')) { message.warning('请选择今天或之前的日期'); return; }
|
checking.value = true;
|
try {
|
const result = await checkEnergyAlerts(checkDate.value);
|
message[result.failed ? 'warning' : 'success'](`检查完成:新发送 ${result.sent} 条,失败 ${result.failed} 条。已发送的通知不会重复发送。`);
|
tab.value = 'logs'; logFilters.date = checkDate.value; logPage.current = 1; await load();
|
} finally { checking.value = false; }
|
}
|
function recipientNames(ids: number[]) { return ids.map(id => recipients.value.find(user => user.value === id)?.label ?? `用户 ${id}`).join('、'); }
|
onMounted(async () => {
|
await load();
|
try { recipients.value = (await getEnergyAlertRecipients()).map(user => ({ label: user.nickname, value: user.id })); } catch { /* Rules remain viewable when recipient lookup fails. */ }
|
});
|
</script>
|
|
<template>
|
<Page title="能耗预警" description="配置每日或每月用量、费用阈值,超过阈值时向指定人员发送站内通知。">
|
<Alert class="mb-4" type="info" show-icon message="每日指标按天汇总,每月指标按自然月汇总本月已录入数据,超限即通知。同一规则、统计周期和接收人只成功通知一次。检查日期同时检查当天及所属月份。区域按名称精确匹配。" />
|
<Card>
|
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<Button v-access:code="['energy:alert:create']" type="primary" @click="showForm()">新增预警规则</Button>
|
<Space wrap><span>检查日期</span><DatePicker v-model:value="checkDate" value-format="YYYY-MM-DD" :allow-clear="false" /><Button v-access:code="['energy:alert:check']" :loading="checking" @click="check">检查预警 / 重试失败通知</Button></Space>
|
</div>
|
<Alert v-if="error" class="mb-4" type="error" show-icon message="加载失败,请点击刷新重试。" />
|
<Tabs v-model:active-key="tab" @change="load">
|
<TabPane key="rules" tab="预警规则">
|
<Table :columns="ruleColumns" :data-source="rules" row-key="id" :loading="loading" :pagination="rulePage" :scroll="{ x: 1100 }" :locale="{ emptyText: '暂无规则,请先设置阈值和通知接收人' }" @change="changePage">
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.dataIndex === 'energyType'">{{ energyInfo[record.energyType as EnergyType].name }}</template>
|
<template v-else-if="column.key === 'scope'">{{ record.usageArea || '全部区域' }} / {{ record.productCategory || '全部产品' }}</template>
|
<template v-else-if="column.key === 'threshold'">{{ metricLabel(record.metric) }} > {{ numberText(record.threshold) }} {{ isAmount(record.metric) ? '元' : energyInfo[record.energyType as EnergyType].unit }}</template>
|
<template v-else-if="column.key === 'recipients'">{{ recipientNames(record.recipientIds) }}</template>
|
<template v-else-if="column.key === 'enabled'"><Tag :color="record.enabled ? 'green' : 'default'">{{ record.enabled ? '启用' : '停用' }}</Tag></template>
|
<template v-else-if="column.key === 'action'"><Space :size="0"><Button v-access:code="['energy:alert:update']" type="link" @click="showForm(record as EnergyAlertRule)">编辑</Button><Popconfirm title="删除此规则?历史通知仍会保留。" @confirm="remove(record.id)"><Button v-access:code="['energy:alert:delete']" type="link" danger>删除</Button></Popconfirm></Space></template>
|
</template>
|
</Table>
|
</TabPane>
|
<TabPane key="logs" tab="通知记录">
|
<Space class="mb-4" wrap><DatePicker v-model:value="logFilters.date" value-format="YYYY-MM-DD" placeholder="统计日期" /><Select v-model:value="logFilters.status" :options="[{ label: '发送成功', value: 'SENT' }, { label: '发送失败', value: 'FAILED' }]" allow-clear placeholder="全部结果" style="width: 140px" /><Button @click="logPage.current = 1; load()">查询</Button></Space>
|
<Table :columns="logColumns" :data-source="logs" row-key="id" :loading="loading" :pagination="logPage" :scroll="{ x: 1340 }" :locale="{ emptyText: '暂无预警通知记录' }" @change="changePage">
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.key === 'period'">{{ record.periodType === 'MONTH' ? record.recordDate.slice(0, 7) : record.recordDate }}<div class="text-xs text-muted-foreground">{{ metricLabel(record.metric) }}</div></template>
|
<template v-else-if="column.key === 'scope'">{{ energyInfo[record.energyType as EnergyType].name }} / {{ record.scope }}</template>
|
<template v-else-if="column.key === 'value'">{{ numberText(record.actualValue) }} / {{ numberText(record.threshold) }} {{ isAmount(record.metric) ? '元' : energyInfo[record.energyType as EnergyType].unit }}</template>
|
<template v-else-if="column.key === 'status'"><Tag :color="record.status === 'SENT' ? 'green' : 'red'">{{ record.status === 'SENT' ? '发送成功' : '发送失败' }}</Tag></template>
|
</template>
|
</Table>
|
</TabPane>
|
<template #rightExtra><Button :loading="loading" @click="load">刷新</Button></template>
|
</Tabs>
|
</Card>
|
<Modal v-model:open="open" :title="model.id ? '编辑预警规则' : '新增预警规则'" :confirm-loading="saving" :mask-closable="false" :width="640" @ok="save">
|
<Form ref="formRef" :model="model" :rules="validation" layout="vertical" class="mt-4">
|
<FormItem label="规则名称" name="name"><Input v-model:value="model.name" :maxlength="100" placeholder="例如:燕窝生产区每日用水预警" /></FormItem>
|
<div class="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
|
<FormItem label="能源"><Select v-model:value="model.energyType" :options="energyOptions" /></FormItem>
|
<FormItem label="统计指标"><Select v-model:value="model.metric" :options="metricOptions" /></FormItem>
|
<FormItem label="使用区域(精确匹配,可留空)"><Input v-model:value="model.usageArea" :maxlength="100" placeholder="留空统计全部区域" /></FormItem>
|
<FormItem label="产品类别"><Select v-model:value="model.productCategory" :options="productOptions" allow-clear placeholder="全部产品" /></FormItem>
|
</div>
|
<FormItem :label="`${model.metric.startsWith('MONTH_') ? '每月' : '每日'}阈值(${unit},大于此值时通知)`" name="threshold"><InputNumber v-model:value="model.threshold" :min="0.001" :precision="3" style="width: 100%" /></FormItem>
|
<FormItem label="通知接收人" name="recipientIds"><Select v-model:value="model.recipientIds" mode="multiple" :options="recipients" option-filter-prop="label" placeholder="选择本站内用户" /></FormItem>
|
<FormItem label="启用规则"><Switch v-model:checked="model.enabled" /></FormItem>
|
</Form>
|
</Modal>
|
</Page>
|
</template>
|