云
4 天以前 a241b66efcc41e973290a6722fb7a4d79b833ea1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
<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) }} &gt; {{ 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>