云
2 天以前 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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
<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>