<script setup lang="ts">
|
import type { EchartsUIType } from '@vben/plugins/echarts';
|
import type { EnergyAnalysis, EnergyFilters, EnergyType } from '#/api/energy';
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
import { Page } from '@vben/common-ui';
|
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
import { downloadFileFromBlobPart } from '@vben/utils';
|
import { Alert, Button, Card, DatePicker, Empty, Form, FormItem, Input, RadioGroup, Select, Space, Spin, Table, message } from 'ant-design-vue';
|
import dayjs from 'dayjs';
|
import { exportEnergyAnalysis, getEnergyAnalysis } from '#/api/energy';
|
import { energyInfo, energyOptions, numberText, productOptions } from '../shared';
|
|
defineOptions({ name: 'EnergyAnalysis' });
|
const defaults = () => ({ startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD'), groupBy: 'DAY' as 'DAY' | 'MONTH' });
|
const filters = reactive<EnergyFilters & ReturnType<typeof defaults>>(defaults());
|
const applied = ref({ ...filters });
|
const data = ref<EnergyAnalysis>();
|
const loading = ref(false);
|
const failed = ref(false);
|
const exporting = ref(false);
|
const selectedEnergy = ref<EnergyType>('WATER');
|
const trendRef = ref<EchartsUIType>();
|
const shareRef = ref<EchartsUIType>();
|
const areaRef = ref<EchartsUIType>();
|
const trendChart = useEcharts(trendRef);
|
const shareChart = useEcharts(shareRef);
|
const areaChart = useEcharts(areaRef);
|
const costRef = ref<EchartsUIType>();
|
const areaShareRef = ref<EchartsUIType>();
|
const usageRef = ref<EchartsUIType>();
|
const costChart = useEcharts(costRef);
|
const areaShareChart = useEcharts(areaShareRef);
|
const usageChart = useEcharts(usageRef);
|
const toolbox = (name: string) => ({ right: 8, feature: { saveAsImage: { title: '下载 PNG 图片', name, pixelRatio: 2 } } });
|
async function exportReport() {
|
exporting.value = true;
|
try { downloadFileFromBlobPart({ fileName: '能耗分析.xlsx', source: await exportEnergyAnalysis({ ...applied.value }) }); }
|
finally { exporting.value = false; }
|
}
|
const hasRecords = computed(() => (data.value?.recordCount ?? 0) > 0);
|
const summaryColumns = [
|
{ title: '能源', dataIndex: 'energyType' },
|
{ title: '总用量', dataIndex: 'quantity', align: 'right' as const },
|
{ title: '费用(元)', dataIndex: 'amount', align: 'right' as const },
|
{ title: '费用占比', key: 'share', align: 'right' as const },
|
{ title: '录入条数', dataIndex: 'recordCount', align: 'right' as const },
|
];
|
const detailColumns = [
|
{ title: '日期 / 月份', dataIndex: 'label' },
|
{ title: '能源', dataIndex: 'energyType' },
|
{ title: '用量', dataIndex: 'quantity', align: 'right' as const },
|
{ title: '费用(元)', dataIndex: 'amount', align: 'right' as const },
|
{ title: '录入条数', dataIndex: 'recordCount', align: 'right' as const },
|
];
|
let requestId = 0;
|
async function load() {
|
if (!filters.startDate || !filters.endDate || filters.startDate > filters.endDate) {
|
message.warning('请选择有效的开始和结束日期'); return;
|
}
|
if (dayjs(filters.endDate).isAfter(dayjs(filters.startDate).add(2, 'year'))) {
|
message.warning('每次分析请选择两年以内的日期范围'); return;
|
}
|
const id = ++requestId;
|
const params = { ...filters };
|
loading.value = true;
|
failed.value = false;
|
try {
|
const result = await getEnergyAnalysis(params);
|
if (id !== requestId) return;
|
data.value = result;
|
applied.value = params;
|
selectedEnergy.value = params.energyType ?? 'WATER';
|
await nextTick();
|
await renderCharts();
|
} catch { if (id === requestId) { data.value = undefined; failed.value = true; } }
|
finally { if (id === requestId) loading.value = false; }
|
}
|
function reset() {
|
Object.keys(filters).forEach(key => delete (filters as Record<string, unknown>)[key]);
|
Object.assign(filters, defaults());
|
void load();
|
}
|
async function renderTrend() {
|
if (!hasRecords.value || !data.value) return;
|
const info = energyInfo[selectedEnergy.value];
|
const month = applied.value.groupBy === 'MONTH';
|
const periods: string[] = [];
|
let cursor = dayjs(applied.value.startDate).startOf(month ? 'month' : 'day');
|
const end = dayjs(applied.value.endDate);
|
while (!cursor.isAfter(end)) {
|
periods.push(cursor.format(month ? 'YYYY-MM' : 'YYYY-MM-DD'));
|
cursor = cursor.add(1, month ? 'month' : 'day');
|
}
|
const values = new Map(data.value.trend.filter(row => row.energyType === selectedEnergy.value).map(row => [row.label, Number(row.quantity)]));
|
await trendChart.renderEcharts({
|
animation: false,
|
toolbox: toolbox(`${info.name}用量趋势`),
|
tooltip: { trigger: 'axis', confine: true },
|
grid: { left: 20, right: 24, top: 40, bottom: 70, containLabel: true },
|
xAxis: { type: 'category', data: periods },
|
yAxis: { type: 'value', name: `${info.name}用量(${info.unit})` },
|
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 12 }],
|
series: [{ name: `${info.name}(${info.unit})`, type: 'line', showSymbol: periods.length < 40, connectNulls: false, itemStyle: { color: info.color }, data: periods.map(period => values.get(period) ?? null) }],
|
});
|
const usage = data.value.areas.filter(row => row.energyType === selectedEnergy.value).sort((a, b) => Number(b.quantity) - Number(a.quantity)).slice(0, 10).reverse();
|
await usageChart.renderEcharts({
|
animation: false, toolbox: toolbox(`${info.name}区域用量`),
|
tooltip: { trigger: 'axis', confine: true, axisPointer: { type: 'shadow' } },
|
grid: { left: 16, right: 36, top: 40, bottom: 24, containLabel: true },
|
xAxis: { type: 'value', name: info.unit },
|
yAxis: { type: 'category', data: usage.map(row => row.label), axisLabel: { width: 100, overflow: 'truncate' } },
|
series: [{ name: `${info.name}(${info.unit})`, type: 'bar', itemStyle: { color: info.color }, data: usage.map(row => Number(row.quantity)) }],
|
});
|
await costChart.renderEcharts({
|
animation: false, toolbox: toolbox('能源费用趋势'),
|
tooltip: { trigger: 'axis', confine: true }, legend: { bottom: 0 },
|
grid: { left: 16, right: 24, top: 40, bottom: 70, containLabel: true },
|
xAxis: { type: 'category', data: periods }, yAxis: { type: 'value', name: '费用(元)' },
|
dataZoom: [{ type: 'inside' }],
|
series: energyOptions.map(option => {
|
const costs = new Map(data.value!.trend.filter(row => row.energyType === option.value).map(row => [row.label, Number(row.amount)]));
|
return { name: option.label, type: 'line' as const, showSymbol: periods.length < 40, connectNulls: false,
|
itemStyle: { color: energyInfo[option.value as EnergyType].color }, data: periods.map(period => costs.get(period) ?? null) };
|
}),
|
});
|
}
|
async function renderCharts() {
|
if (!hasRecords.value || !data.value) return;
|
await renderTrend();
|
if (Number(data.value.totalAmount) > 0) await shareChart.renderEcharts({
|
animation: false,
|
toolbox: toolbox('能源费用占比'),
|
tooltip: { trigger: 'item', confine: true, formatter: '{b}:{c} 元({d}%)' },
|
legend: { bottom: 0 },
|
series: [{ type: 'pie', radius: '65%', center: ['50%', '45%'], label: { formatter: '{b}\n{d}%' }, data: data.value.summary.filter(row => Number(row.amount) > 0).map(row => ({ name: energyInfo[row.energyType].name, value: Number(row.amount), itemStyle: { color: energyInfo[row.energyType].color } })) }],
|
});
|
const totals = new Map<string, number>();
|
for (const row of data.value.areas) totals.set(row.label, (totals.get(row.label) ?? 0) + Number(row.amount));
|
if (Number(data.value.totalAmount) > 0) {
|
const sorted = [...totals].filter(([, amount]) => amount > 0).sort((a, b) => b[1] - a[1]);
|
const shares = sorted.slice(0, 10).map(([name, value]) => ({ name, value }));
|
const others = sorted.slice(10).reduce((sum, [, amount]) => sum + amount, 0);
|
if (others > 0) shares.push({ name: '其他区域合计', value: others });
|
await areaShareChart.renderEcharts({
|
animation: false, toolbox: toolbox('区域费用占比'),
|
tooltip: { trigger: 'item', confine: true, formatter: '{b}:{c} 元({d}%)' },
|
legend: { type: 'scroll', bottom: 0 },
|
series: [{ type: 'pie', radius: ['35%', '60%'], center: ['50%', '44%'], label: { formatter: '{b}\n{d}%', width: 85, overflow: 'truncate' }, data: shares }],
|
});
|
}
|
const areaLabels = [...totals].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([label]) => label).reverse();
|
await areaChart.renderEcharts({
|
animation: false,
|
toolbox: toolbox('区域费用分布'),
|
tooltip: { trigger: 'axis', confine: true, axisPointer: { type: 'shadow' } },
|
legend: { bottom: 0 },
|
grid: { left: 16, right: 24, top: 30, bottom: 45, containLabel: true },
|
xAxis: { type: 'value', name: '元' },
|
yAxis: { type: 'category', data: areaLabels, axisLabel: { width: 110, overflow: 'truncate' } },
|
series: energyOptions.map(option => ({
|
name: option.label, type: 'bar' as const, stack: 'amount', itemStyle: { color: energyInfo[option.value as EnergyType].color },
|
data: areaLabels.map(label => data.value!.areas.filter(row => row.label === label && row.energyType === option.value).reduce((sum, row) => sum + Number(row.amount), 0)),
|
})),
|
});
|
}
|
watch(selectedEnergy, () => { void renderTrend(); });
|
onMounted(() => load());
|
</script>
|
|
<template>
|
<Page title="能耗数据分析" description="基于手动录入的台账,查看用量趋势及费用分布。">
|
<Card class="mb-4">
|
<Form :model="filters" layout="inline" class="analysis-filters" @finish="load">
|
<FormItem label="开始日期"><DatePicker v-model:value="filters.startDate" value-format="YYYY-MM-DD" :allow-clear="false" /></FormItem>
|
<FormItem label="结束日期"><DatePicker v-model:value="filters.endDate" value-format="YYYY-MM-DD" :allow-clear="false" /></FormItem>
|
<FormItem label="汇总"><Select v-model:value="filters.groupBy" :options="[{ label: '按日', value: 'DAY' }, { label: '按月', value: 'MONTH' }]" style="width: 100px" /></FormItem>
|
<FormItem label="能源"><Select v-model:value="filters.energyType" :options="energyOptions" allow-clear placeholder="全部能源" style="width: 130px" /></FormItem>
|
<FormItem label="区域"><Input v-model:value="filters.usageArea" placeholder="区域关键词" allow-clear style="width: 150px" /></FormItem>
|
<FormItem label="产品"><Select v-model:value="filters.productCategory" :options="productOptions" allow-clear placeholder="全部类别" style="width: 150px" /></FormItem>
|
<FormItem><Space wrap><Button type="primary" :loading="loading" @click="load">分析</Button><Button @click="reset">重置</Button><Button v-access:code="['energy:analysis:export']" :disabled="!data || loading" :loading="exporting" @click="exportReport">导出分析报表</Button></Space></FormItem>
|
</Form>
|
</Card>
|
<Alert v-if="failed" type="error" show-icon message="能耗分析加载失败,请点击分析重试。" class="mb-4" />
|
<Spin :spinning="loading">
|
<template v-if="data">
|
<Card class="mb-4" title="能源汇总">
|
<template #extra>{{ applied.startDate }} 至 {{ applied.endDate }}</template>
|
<div class="mb-4 flex flex-wrap gap-x-6 gap-y-2">
|
<span>总费用:<strong>{{ numberText(data.totalAmount, 2) }} 元</strong></span><span>已录入 {{ data.recordCount }} 条</span>
|
</div>
|
<Table :columns="summaryColumns" :data-source="data.summary" row-key="energyType" :pagination="false" :scroll="{ x: 600 }" size="small">
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.dataIndex === 'energyType'">{{ energyInfo[record.energyType as EnergyType].name }}</template>
|
<template v-else-if="column.dataIndex === 'quantity'">{{ numberText(record.quantity) }} {{ energyInfo[record.energyType as EnergyType].unit }}</template>
|
<template v-else-if="column.dataIndex === 'amount'">{{ numberText(record.amount, 2) }}</template>
|
<template v-else-if="column.key === 'share'">{{ Number(data.totalAmount) > 0 ? numberText(Number(record.amount) / Number(data.totalAmount) * 100, 2) + '%' : '—' }}</template>
|
</template>
|
</Table>
|
<p class="mb-0 mt-3 text-sm text-muted-foreground">水、电、蒸汽按各自单位统计。未录入日期在趋势中留空;单价为 0 的记录仍计入用量。</p>
|
</Card>
|
<Card v-if="!hasRecords"><Empty description="所选范围暂无能耗记录,请调整筛选条件或先录入能耗信息。" /></Card>
|
<template v-else>
|
<Card class="mb-4" title="用量趋势">
|
<template #extra><RadioGroup v-model:value="selectedEnergy" :options="energyOptions" option-type="button" size="small" /></template>
|
<EchartsUI ref="trendRef" class="h-[340px] w-full" />
|
</Card>
|
<div class="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<Card title="能源费用占比"><EchartsUI v-if="Number(data.totalAmount) > 0" ref="shareRef" class="h-[330px] w-full" /><Empty v-else description="尚未录入费用,填写单价后显示费用占比。" /></Card>
|
<Card title="区域费用分布(前 10)"><EchartsUI ref="areaRef" class="h-[330px] w-full" /></Card>
|
<Card title="能源费用趋势"><EchartsUI ref="costRef" class="h-[330px] w-full" /></Card>
|
<Card title="区域费用占比"><EchartsUI v-if="Number(data.totalAmount) > 0" ref="areaShareRef" class="h-[330px] w-full" /><Empty v-else description="尚无费用数据" /></Card>
|
<Card class="lg:col-span-2" :title="`${energyInfo[selectedEnergy].name} · 区域用量对比(前 10)`"><EchartsUI ref="usageRef" class="h-[330px] w-full" /></Card>
|
</div>
|
<p class="text-sm text-muted-foreground">图表右上角可下载 PNG;分析报表包含统计口径、能源汇总、日期趋势、区域汇总四个工作表,使用最近一次分析的筛选条件。</p>
|
<Card title="趋势明细">
|
<Table :columns="detailColumns" :data-source="data.trend" :row-key="row => `${row.label}-${row.energyType}`" :scroll="{ x: 600 }" :pagination="{ pageSize: 10, showSizeChanger: true }" size="small">
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.dataIndex === 'energyType'">{{ energyInfo[record.energyType as EnergyType].name }}</template>
|
<template v-else-if="column.dataIndex === 'quantity'">{{ numberText(record.quantity) }} {{ energyInfo[record.energyType as EnergyType].unit }}</template>
|
<template v-else-if="column.dataIndex === 'amount'">{{ numberText(record.amount, 2) }}</template>
|
</template>
|
</Table>
|
</Card>
|
</template>
|
</template>
|
</Spin>
|
</Page>
|
</template>
|
|
<style scoped>
|
.analysis-filters { gap: 8px 0; }
|
</style>
|