<template>
|
<div class="app-container">
|
<el-card>
|
<template #header>
|
<div class="card-header">
|
<span>能耗统计分析</span>
|
<span class="desc">周期累计、时段拆分、趋势对比与负荷分析</span>
|
</div>
|
</template>
|
|
<el-form :inline="true" class="search-form">
|
<el-form-item label="统计维度">
|
<el-radio-group v-model="queryForm.dimension" @change="handleDimensionChange">
|
<el-radio-button value="day">日</el-radio-button>
|
<el-radio-button value="week">周</el-radio-button>
|
<el-radio-button value="month">月</el-radio-button>
|
<el-radio-button value="quarter">季</el-radio-button>
|
<el-radio-button value="year">年</el-radio-button>
|
</el-radio-group>
|
</el-form-item>
|
<el-form-item label="时间范围" class="time-range-item">
|
<el-date-picker
|
v-if="queryForm.dimension === 'day' || queryForm.dimension === 'week'"
|
v-model="dayRange"
|
type="daterange"
|
range-separator="至"
|
value-format="YYYY-MM-DD"
|
:shortcuts="dayShortcuts"
|
/>
|
<el-date-picker
|
v-else-if="queryForm.dimension === 'month'"
|
v-model="monthRange"
|
type="monthrange"
|
range-separator="至"
|
value-format="YYYY-MM"
|
/>
|
<el-date-picker
|
v-else-if="queryForm.dimension === 'quarter'"
|
v-model="quarterRange"
|
type="daterange"
|
range-separator="至"
|
value-format="YYYY-MM-DD"
|
/>
|
<el-date-picker
|
v-else
|
v-model="yearRange"
|
type="yearrange"
|
range-separator="至"
|
value-format="YYYY"
|
/>
|
</el-form-item>
|
<el-form-item label="趋势粒度">
|
<el-radio-group v-model="queryForm.trendGranularity" size="small" @change="handleQuery">
|
<el-radio-button value="hour">小时</el-radio-button>
|
<el-radio-button value="day">日</el-radio-button>
|
<el-radio-button value="week">周</el-radio-button>
|
<el-radio-button value="month">月</el-radio-button>
|
</el-radio-group>
|
</el-form-item>
|
<el-form-item>
|
<el-button type="primary" :loading="loading" @click="handleQuery">查询</el-button>
|
<el-button @click="handleExport">导出</el-button>
|
</el-form-item>
|
</el-form>
|
|
<!-- 一、基础用量统计 -->
|
<div class="section-title">基础用量统计</div>
|
<el-row :gutter="12" class="summary-row">
|
<el-col :span="4">
|
<div class="summary-card total">
|
<div class="label">周期累计电量</div>
|
<div class="value">{{ formatKwh(analytics.totalConsumption) }} <span>kWh</span></div>
|
</div>
|
</el-col>
|
<el-col :span="5">
|
<div class="summary-card">
|
<div class="label">小时平均用电量</div>
|
<div class="value">{{ formatKwh(analytics.avgConsumption) }} <span>kWh</span></div>
|
</div>
|
</el-col>
|
<el-col :span="5">
|
<div class="summary-card">
|
<div class="label">小时最大用电量</div>
|
<div class="value">{{ formatKwh(analytics.maxConsumption) }} <span>kWh</span></div>
|
</div>
|
</el-col>
|
<el-col :span="5">
|
<div class="summary-card">
|
<div class="label">小时最小用电量</div>
|
<div class="value">{{ formatKwh(analytics.minConsumption) }} <span>kWh</span></div>
|
</div>
|
</el-col>
|
<el-col :span="5">
|
<div class="summary-card load">
|
<div class="label">负荷率</div>
|
<div class="value">{{ formatKwh(analytics.loadRate, 1) }} <span>%</span></div>
|
<div class="hint">平均÷最大×100</div>
|
</div>
|
</el-col>
|
</el-row>
|
|
<!-- 二、同比环比 -->
|
<div class="section-title">同比 / 环比分析</div>
|
<el-row :gutter="16" class="compare-row">
|
<el-col :span="12">
|
<div class="compare-card">
|
<div class="compare-label">{{ analytics.chainComparison?.label || "环比上期" }}</div>
|
<div class="compare-body">
|
<div>
|
<span class="sub">上期</span>
|
<strong>{{ formatKwh(analytics.chainComparison?.compareTotal) }} kWh</strong>
|
</div>
|
<div>
|
<span class="sub">本期</span>
|
<strong>{{ formatKwh(analytics.chainComparison?.currentTotal) }} kWh</strong>
|
</div>
|
<div :class="deltaClass(analytics.chainComparison?.delta)">
|
{{ formatDelta(analytics.chainComparison) }}
|
</div>
|
</div>
|
</div>
|
</el-col>
|
<el-col :span="12">
|
<div class="compare-card">
|
<div class="compare-label">{{ analytics.yoyComparison?.label || "同比去年同期" }}</div>
|
<div class="compare-body">
|
<div>
|
<span class="sub">去年同期</span>
|
<strong>{{ formatKwh(analytics.yoyComparison?.compareTotal) }} kWh</strong>
|
</div>
|
<div>
|
<span class="sub">本期</span>
|
<strong>{{ formatKwh(analytics.yoyComparison?.currentTotal) }} kWh</strong>
|
</div>
|
<div :class="deltaClass(analytics.yoyComparison?.delta)">
|
{{ formatDelta(analytics.yoyComparison) }}
|
</div>
|
</div>
|
</div>
|
</el-col>
|
</el-row>
|
|
<!-- 三、趋势与拆分 -->
|
<div class="section-title">趋势与时段分析</div>
|
<el-row :gutter="16">
|
<el-col :span="14">
|
<div class="chart-panel">
|
<div class="chart-toolbar">
|
<span>用电趋势({{ trendGranularityLabel }})</span>
|
<el-radio-group
|
v-if="!isSingleDay"
|
v-model="chartType"
|
size="small"
|
@change="renderAllCharts"
|
>
|
<el-radio-button value="line">折线</el-radio-button>
|
<el-radio-button value="bar">柱状</el-radio-button>
|
</el-radio-group>
|
</div>
|
<div ref="trendChartRef" class="chart-container" />
|
</div>
|
</el-col>
|
<el-col :span="10">
|
<div class="chart-panel">
|
<div class="chart-toolbar"><span>时段拆分(峰平谷)</span></div>
|
<div ref="periodChartRef" class="chart-container short" />
|
</div>
|
</el-col>
|
</el-row>
|
|
<el-row :gutter="16" class="sub-chart-row">
|
<el-col :span="8">
|
<div class="chart-panel">
|
<div class="chart-toolbar"><span>班次用电对比</span></div>
|
<div ref="shiftChartRef" class="chart-container short" />
|
</div>
|
</el-col>
|
<el-col :span="8">
|
<div class="chart-panel">
|
<div class="chart-toolbar"><span>工作日 / 休息日</span></div>
|
<div ref="dayTypeChartRef" class="chart-container short" />
|
</div>
|
</el-col>
|
<el-col :span="8">
|
<div class="split-table-panel">
|
<div class="chart-toolbar"><span>拆分占比明细</span></div>
|
<el-table :data="splitTableRows" size="small" border max-height="280">
|
<el-table-column prop="category" label="类别" width="80" />
|
<el-table-column prop="name" label="项" min-width="80" />
|
<el-table-column label="电量(kWh)" width="100">
|
<template #default="{ row }">{{ formatKwh(row.consumption) }}</template>
|
</el-table-column>
|
<el-table-column label="占比" width="70">
|
<template #default="{ row }">{{ formatKwh(row.ratio, 1) }}%</template>
|
</el-table-column>
|
</el-table>
|
</div>
|
</el-col>
|
</el-row>
|
|
<div class="detail-title">用电明细</div>
|
<el-table v-loading="loading" :data="detailRecords" border stripe max-height="360">
|
<el-table-column label="时间" min-width="150">
|
<template #default="{ row }">
|
{{ parseTimeKey(row.timeKey, queryForm.dimension) }}
|
</template>
|
</el-table-column>
|
<el-table-column label="电表" min-width="160" show-overflow-tooltip>
|
<template #default="{ row }">
|
<div class="meter-cell">
|
<span class="meter-name">{{ row.meterName || row.address || "-" }}</span>
|
<span v-if="row.meterId" class="meter-id">ID: {{ row.meterId }}</span>
|
</div>
|
</template>
|
</el-table-column>
|
<el-table-column label="总电量(kWh)" width="120">
|
<template #default="{ row }">{{ formatKwh(row.totalConsumption) }}</template>
|
</el-table-column>
|
<el-table-column v-if="hasPeriodData" label="峰(kWh)" width="90">
|
<template #default="{ row }">{{ formatKwh(row.peakConsumption) }}</template>
|
</el-table-column>
|
<el-table-column v-if="hasPeriodData" label="平(kWh)" width="90">
|
<template #default="{ row }">{{ formatKwh(row.flatConsumption) }}</template>
|
</el-table-column>
|
<el-table-column v-if="hasPeriodData" label="谷(kWh)" width="90">
|
<template #default="{ row }">{{ formatKwh(row.valleyConsumption) }}</template>
|
</el-table-column>
|
<el-table-column label="操作" width="80" fixed="right">
|
<template #default="{ row }">
|
<el-button type="primary" link @click="showDetail(row)">详情</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
</el-card>
|
|
<el-dialog v-model="detailVisible" title="用电明细详情" width="520px" destroy-on-close>
|
<el-descriptions v-if="detailRow" :column="1" border size="small">
|
<el-descriptions-item label="时间">
|
{{ parseTimeKey(detailRow.timeKey, queryForm.dimension) }}
|
</el-descriptions-item>
|
<el-descriptions-item label="电表名称">{{ detailRow.meterName || "-" }}</el-descriptions-item>
|
<el-descriptions-item label="电表ID">{{ detailRow.meterId ?? "-" }}</el-descriptions-item>
|
<el-descriptions-item label="表地址">{{ detailRow.address || "-" }}</el-descriptions-item>
|
<el-descriptions-item label="总电量(kWh)">{{ formatKwh(detailRow.totalConsumption) }}</el-descriptions-item>
|
<el-descriptions-item label="开始时间">{{ detailRow.startTime || "-" }}</el-descriptions-item>
|
<el-descriptions-item label="结束时间">{{ detailRow.endTime || "-" }}</el-descriptions-item>
|
</el-descriptions>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup>
|
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
import { ElMessageBox } from "element-plus";
|
import * as echarts from "echarts";
|
import {
|
analyticsStatisticEle,
|
formatDayPicker,
|
formatMonthTime,
|
getYesterdayDayPicker,
|
parseTimeKey,
|
} from "@/api/energyManagement/statisticEle.js";
|
|
const { proxy } = getCurrentInstance();
|
|
const loading = ref(false);
|
const trendChartRef = ref(null);
|
const periodChartRef = ref(null);
|
const shiftChartRef = ref(null);
|
const dayTypeChartRef = ref(null);
|
const chartInstances = {};
|
|
const queryForm = reactive({ dimension: "day", trendGranularity: "hour" });
|
const chartType = ref("line");
|
const analytics = ref({});
|
const detailRecords = ref([]);
|
const detailVisible = ref(false);
|
const detailRow = ref(null);
|
|
const dayRange = ref([]);
|
const monthRange = ref([]);
|
const quarterRange = ref([]);
|
const yearRange = ref([]);
|
|
const isSingleDay = computed(() => {
|
if (!["day", "week"].includes(queryForm.dimension) || !dayRange.value?.length) return false;
|
return dayRange.value[0] === dayRange.value[1];
|
});
|
|
const trendGranularityLabel = computed(() => {
|
const map = { hour: "小时", day: "日", week: "周", month: "月", year: "年" };
|
return map[analytics.value.trendGranularity || queryForm.trendGranularity] || "日";
|
});
|
|
const hasPeriodData = computed(() =>
|
(analytics.value.periodSplits || []).length > 0
|
|| detailRecords.value.some((row) =>
|
hasPeriodValue(row, "peakConsumption")
|
|| hasPeriodValue(row, "flatConsumption")
|
|| hasPeriodValue(row, "valleyConsumption")
|
)
|
);
|
|
const splitTableRows = computed(() => {
|
const rows = [];
|
const push = (category, list) => {
|
(list || []).forEach((item) => rows.push({ category, ...item }));
|
};
|
push("峰平谷", analytics.value.periodSplits);
|
push("班次", analytics.value.shiftSplits);
|
push("日类型", analytics.value.dayTypeSplits);
|
return rows;
|
});
|
|
const dayShortcuts = [
|
{
|
text: "昨日",
|
value: () => {
|
const d = new Date();
|
d.setDate(d.getDate() - 1);
|
return [d, d];
|
},
|
},
|
{
|
text: "近7天",
|
value: () => {
|
const end = new Date();
|
const start = new Date(end.getTime() - 6 * 86400000);
|
return [start, end];
|
},
|
},
|
{
|
text: "近30天",
|
value: () => {
|
const end = new Date();
|
const start = new Date(end.getTime() - 29 * 86400000);
|
return [start, end];
|
},
|
},
|
];
|
|
const PIE_COLORS = ["#409EFF", "#67C23A", "#E6A23C", "#F56C6C", "#909399"];
|
|
function hasPeriodValue(row, field) {
|
const n = Number(row?.[field]);
|
return Number.isFinite(n) && n !== 0;
|
}
|
|
function formatKwh(value, digits = 2) {
|
const n = Number(value);
|
if (!Number.isFinite(n)) return (0).toFixed(digits);
|
return n.toFixed(digits);
|
}
|
|
function formatDelta(comp) {
|
if (!comp) return "-";
|
const sign = comp.delta >= 0 ? "+" : "";
|
return `${sign}${formatKwh(comp.delta)} kWh (${sign}${formatKwh(comp.changeRate, 1)}%)`;
|
}
|
|
function deltaClass(delta) {
|
if (delta > 0) return "delta up";
|
if (delta < 0) return "delta down";
|
return "delta";
|
}
|
|
function initDefaultRange() {
|
const yesterday = getYesterdayDayPicker();
|
dayRange.value = [yesterday, yesterday];
|
const now = new Date();
|
const weekAgo = new Date(now.getTime() - 7 * 86400000);
|
quarterRange.value = [formatDayPicker(weekAgo), formatDayPicker(now)];
|
monthRange.value = [
|
formatMonthTime(now).replace(/(\d{4})(\d{2})/, "$1-$2"),
|
formatMonthTime(now).replace(/(\d{4})(\d{2})/, "$1-$2"),
|
];
|
yearRange.value = [String(now.getFullYear()), String(now.getFullYear())];
|
}
|
|
function buildTimeParams() {
|
const dim = queryForm.dimension;
|
if (dim === "day" || dim === "week") {
|
return {
|
startTime: dayRange.value[0].replace(/-/g, ""),
|
endTime: dayRange.value[1].replace(/-/g, ""),
|
};
|
}
|
if (dim === "month") {
|
return {
|
startTime: monthRange.value[0].replace(/-/g, ""),
|
endTime: monthRange.value[1].replace(/-/g, ""),
|
};
|
}
|
if (dim === "quarter") {
|
return {
|
startTime: quarterRange.value[0].replace(/-/g, ""),
|
endTime: quarterRange.value[1].replace(/-/g, ""),
|
};
|
}
|
return { startTime: yearRange.value[0], endTime: yearRange.value[1] };
|
}
|
|
function syncTrendGranularity() {
|
if (isSingleDay.value) {
|
queryForm.trendGranularity = "hour";
|
chartType.value = "line";
|
return;
|
}
|
if (queryForm.trendGranularity === "hour") {
|
queryForm.trendGranularity = "day";
|
}
|
chartType.value = "bar";
|
}
|
|
async function handleQuery() {
|
syncTrendGranularity();
|
loading.value = true;
|
try {
|
const params = {
|
dimension: queryForm.dimension,
|
trendGranularity: queryForm.trendGranularity,
|
...buildTimeParams(),
|
};
|
const res = await analyticsStatisticEle(params);
|
analytics.value = res.data || {};
|
detailRecords.value = res.data?.records || [];
|
await nextTick();
|
renderAllCharts();
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
function getChart(key, refEl) {
|
if (!refEl) return null;
|
if (!chartInstances[key]) {
|
chartInstances[key] = echarts.init(refEl);
|
}
|
return chartInstances[key];
|
}
|
|
function renderTrendChart() {
|
const chart = getChart("trend", trendChartRef.value);
|
if (!chart) return;
|
const gran = analytics.value.trendGranularity || queryForm.trendGranularity;
|
const records = analytics.value.trendRecords || analytics.value.chartRecords || [];
|
const labels = records.map((item) => parseTimeKey(item.timeKey, gran));
|
const values = records.map((item) => Number(formatKwh(item.totalConsumption)));
|
const type = isSingleDay.value ? "line" : chartType.value;
|
chart.setOption({
|
tooltip: { trigger: "axis" },
|
grid: { left: 50, right: 20, top: 30, bottom: 50 },
|
xAxis: { type: "category", data: labels, axisLabel: { rotate: gran === "hour" ? 45 : 30, fontSize: 11 } },
|
yAxis: { type: "value", name: "kWh" },
|
series: [{
|
name: "用电量",
|
type,
|
data: values,
|
smooth: type === "line",
|
areaStyle: type === "line" ? { opacity: 0.1 } : undefined,
|
itemStyle: { color: "#409EFF" },
|
barMaxWidth: 36,
|
}],
|
}, true);
|
}
|
|
function renderPieChart(key, refEl, items, title) {
|
const chart = getChart(key, refEl);
|
if (!chart) return;
|
const data = (items || []).map((item, i) => ({
|
name: item.name,
|
value: Number(formatKwh(item.consumption)),
|
itemStyle: { color: PIE_COLORS[i % PIE_COLORS.length] },
|
}));
|
chart.setOption({
|
title: data.length ? undefined : { text: "暂无数据", left: "center", top: "center", textStyle: { color: "#909399", fontSize: 13 } },
|
tooltip: { trigger: "item", formatter: "{b}: {c} kWh ({d}%)" },
|
legend: { bottom: 0, type: "scroll" },
|
series: [{
|
name: title,
|
type: "pie",
|
radius: ["40%", "65%"],
|
center: ["50%", "45%"],
|
data,
|
label: { formatter: "{b}\n{d}%" },
|
}],
|
}, true);
|
}
|
|
function renderBarChart(key, refEl, items, title) {
|
const chart = getChart(key, refEl);
|
if (!chart) return;
|
const list = items || [];
|
chart.setOption({
|
title: list.length ? undefined : { text: "暂无数据", left: "center", top: "center", textStyle: { color: "#909399", fontSize: 13 } },
|
tooltip: { trigger: "axis" },
|
grid: { left: 50, right: 16, top: 20, bottom: 30 },
|
xAxis: { type: "category", data: list.map((i) => i.name) },
|
yAxis: { type: "value", name: "kWh" },
|
series: [{
|
name: title,
|
type: "bar",
|
data: list.map((i) => Number(formatKwh(i.consumption))),
|
itemStyle: { color: "#409EFF" },
|
barMaxWidth: 40,
|
}],
|
}, true);
|
}
|
|
function renderAllCharts() {
|
renderTrendChart();
|
renderPieChart("period", periodChartRef.value, analytics.value.periodSplits, "峰平谷");
|
renderBarChart("shift", shiftChartRef.value, analytics.value.shiftSplits, "班次");
|
renderPieChart("dayType", dayTypeChartRef.value, analytics.value.dayTypeSplits, "日类型");
|
}
|
|
function handleDimensionChange() {
|
syncTrendGranularity();
|
handleQuery();
|
}
|
|
function showDetail(row) {
|
detailRow.value = row;
|
detailVisible.value = true;
|
}
|
|
function handleExport() {
|
ElMessageBox.confirm("确认导出当前统计报表?", "导出", { type: "warning" })
|
.then(() => {
|
proxy.download("/statisticEle/export", {
|
dimension: queryForm.dimension,
|
...buildTimeParams(),
|
}, `能耗统计_${queryForm.dimension}.xlsx`);
|
})
|
.catch(() => {});
|
}
|
|
function handleResize() {
|
Object.values(chartInstances).forEach((c) => c?.resize());
|
}
|
|
watch(isSingleDay, () => {
|
syncTrendGranularity();
|
renderAllCharts();
|
});
|
|
onMounted(() => {
|
initDefaultRange();
|
handleQuery();
|
window.addEventListener("resize", handleResize);
|
});
|
|
onBeforeUnmount(() => {
|
window.removeEventListener("resize", handleResize);
|
Object.values(chartInstances).forEach((c) => c?.dispose());
|
});
|
</script>
|
|
<style scoped>
|
.card-header { display: flex; align-items: center; gap: 12px; }
|
.card-header .desc { font-size: 13px; color: #909399; }
|
.search-form { margin-bottom: 12px; }
|
.time-range-item { margin-right: 0; }
|
.section-title {
|
font-weight: 600;
|
font-size: 14px;
|
margin: 16px 0 10px;
|
padding-left: 8px;
|
border-left: 3px solid #409eff;
|
}
|
.summary-row { margin-bottom: 8px; }
|
.summary-card {
|
background: #f5f7fa;
|
border-radius: 8px;
|
padding: 16px 12px;
|
text-align: center;
|
min-height: 96px;
|
}
|
.summary-card.total { background: linear-gradient(135deg, #409eff22, #409eff11); }
|
.summary-card.load { background: linear-gradient(135deg, #67c23a22, #67c23a11); }
|
.summary-card .label { font-size: 12px; color: #909399; margin-bottom: 6px; }
|
.summary-card .value { font-size: 22px; font-weight: 600; }
|
.summary-card .value span { font-size: 12px; font-weight: 400; color: #909399; }
|
.summary-card .hint { font-size: 11px; color: #c0c4cc; margin-top: 4px; }
|
.compare-row { margin-bottom: 8px; }
|
.compare-card {
|
background: #fafafa;
|
border: 1px solid #ebeef5;
|
border-radius: 8px;
|
padding: 14px 16px;
|
}
|
.compare-label { font-weight: 500; margin-bottom: 10px; }
|
.compare-body { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
.compare-body .sub { display: block; font-size: 12px; color: #909399; margin-bottom: 2px; }
|
.delta { font-weight: 600; font-size: 13px; }
|
.delta.up { color: #f56c6c; }
|
.delta.down { color: #67c23a; }
|
.chart-panel, .split-table-panel {
|
border: 1px solid #ebeef5;
|
border-radius: 8px;
|
padding: 12px;
|
margin-bottom: 12px;
|
}
|
.sub-chart-row { margin-top: 0; }
|
.chart-toolbar {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 8px;
|
font-weight: 500;
|
font-size: 13px;
|
}
|
.chart-container { width: 100%; height: 320px; }
|
.chart-container.short { height: 280px; }
|
.detail-title { font-weight: 500; margin: 8px 0 10px; }
|
.meter-cell { display: flex; flex-direction: column; gap: 2px; }
|
.meter-name { font-size: 13px; }
|
.meter-id { font-size: 12px; color: #909399; }
|
</style>
|