云
5 天以前 1b57bc924e68db3873bf24deee91a75fa014ea96
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
<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 { Alert, Button, Card, DatePicker, Empty, Form, FormItem, Input, RadioGroup, Select, Space, Spin, Table, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { 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 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 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,
    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) }],
  });
}
async function renderCharts() {
  if (!hasRecords.value || !data.value) return;
  await renderTrend();
  if (Number(data.value.totalAmount) > 0) await shareChart.renderEcharts({
    animation: false,
    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));
  const areaLabels = [...totals].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([label]) => label).reverse();
  await areaChart.renderEcharts({
    animation: false,
    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><Button type="primary" :loading="loading" @click="load">分析</Button><Button @click="reset">重置</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>
          </div>
          <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>