feat(ems): 添加产线信息显示和视图切换功能

refactor(dashboard): 移除过时的趋势分析组件

- 在仪表盘表格中添加产线信息显示
- 在设备管理页面增加卡片/表格视图切换功能
- 删除不再使用的趋势分析组件
This commit is contained in:
2026-04-30 13:06:14 +08:00
parent b1a997fde8
commit 9967d8be46
7 changed files with 784 additions and 1808 deletions

View File

@@ -1,125 +1,696 @@
<template>
<el-row>
<el-col :span="4">
<!-- <div style="padding: 10px;">
<el-select v-model="energyType" placeholder="请选择能源类型" @change="refresh">
<el-option v-for="item in energyTypeList" :key="item.energyTypeId" :label="item.name" :value="item.energyTypeId" />
</el-select>
</div> -->
<div class="ems-dashboard-container" v-loading="loading">
<!-- 顶部筛选区域 -->
<div class="filter-panel">
<el-form :inline="true" :model="queryParams" class="filter-form">
<el-form-item label="时间范围">
<time-range-picker
v-model="timeRangeParams"
start-key="recordStartDate"
end-key="recordEndDate"
:default-start-time="defaultStartTime"
:default-end-time="defaultEndTime"
@quick-select="loadData"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-refresh" @click="loadData">刷新</el-button>
</el-form-item>
</el-form>
</div>
<!-- 区域选择默认展开最高高度为60%的屏幕尺寸 -->
<el-tree @node-click="handleNodeClick" :data="locationList" :props="defaultProps" :default-expand-all="true" :style="{ height: 'calc(50vh - 50px)' }"></el-tree>
<!-- 统计卡片 -->
<div class="stat-cards">
<el-row :gutter="20">
<el-col :span="6" v-for="(stat, index) in statistics" :key="index">
<div class="stat-card" :class="'stat-' + index">
<div class="stat-icon">
<i :class="stat.icon"></i>
</div>
<div class="stat-content">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- 设备列表 -->
<!-- <ul v-if="locationId">
<li></li>
</ul>
<div v-else>
<el-empty description="请选择区域"></el-empty>
</div> -->
</el-col>
<!-- 图表区域 -->
<div class="charts-section">
<el-row :gutter="20">
<!-- 能源消耗趋势图 -->
<el-col :span="16">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">能源消耗趋势</span>
</div>
<div ref="trendChart" class="chart-container"></div>
</div>
</el-col>
<el-col :span="20" v-if="showRight">
<!-- <el-tabs v-model="activeTab" type="card">
<el-tab-pane label="环比概况" name="1"></el-tab-pane>
<el-tab-pane label="近期趋势" name="2"></el-tab-pane>
<el-tab-pane label="同比分析" name="3"></el-tab-pane>
<el-tab-pane label="环比分析" name="4"></el-tab-pane>
</el-tabs> -->
<el-tabs v-model="energyType" type="card" @tab-click="refresh">
<el-tab-pane v-for="item in energyTypeList" :key="item.energyTypeId" :label="item.name" :name="item.energyTypeId"></el-tab-pane>
</el-tabs>
<!-- 能源类型占比 -->
<el-col :span="8">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">能源类型占比</span>
</div>
<div ref="energyTypeChart" class="chart-container"></div>
</div>
</el-col>
</el-row>
<Overview ref="overview" :unit="energyUnit" :energyName="energyName" :energyType="energyType" :locationId="locationId" :deviceId="deviceId" />
<RecentTrend ref="recentTrend" :unit="energyUnit" :energyName="energyName" :energyType="energyType" :locationId="locationId" :deviceId="deviceId" />
<YearToYear ref="yearToYear" :unit="energyUnit" :energyName="energyName" :energyType="energyType" :locationId="locationId" :deviceId="deviceId" />
<MonthToMonth ref="monthToMonth" :unit="energyUnit" :energyName="energyName" :energyType="energyType" :locationId="locationId" :deviceId="deviceId" />
</el-col>
<el-col :span="20" v-else>
<el-empty description="请选择能源类型和区域"></el-empty>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<!-- 产线能耗对比 -->
<el-col :span="12">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">产线能耗对比</span>
</div>
<div ref="productionLineChart" class="chart-container"></div>
</div>
</el-col>
<!-- 设备能耗排行 -->
<el-col :span="12">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">设备能耗排行 TOP 10</span>
</div>
<div ref="deviceRankingChart" class="chart-container"></div>
</div>
</el-col>
</el-row>
<!-- 产线能源类型二维表 -->
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="24">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">产线能源类型二维表</span>
</div>
<div class="table-container">
<el-table :data="tableData" border style="width: 100%" :summary-method="getSummaries" show-summary>
<el-table-column prop="lineName" label="产线"></el-table-column>
<el-table-column v-for="type in energyTypeList" :key="type.energyTypeId"
:prop="'type_' + type.energyTypeId" :label="type.name">
<template slot-scope="scope">
{{ scope.row['type_' + type.energyTypeId] ? Number(scope.row['type_' + type.energyTypeId]).toFixed(2) : 0 }}
</template>
</el-table-column>
<el-table-column prop="total" label="合计" fixed="right" width="150">
<template slot-scope="scope">
{{ scope.row.total ? Number(scope.row.total).toFixed(2) : 0 }}
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-col>
</el-row>
</div>
</div>
</template>
<script>
import { listEnergyType } from "@/api/ems/energyType";
import { listLocation } from "@/api/ems/location";
import Overview from "./panels/Overview.vue";
import RecentTrend from "./panels/RecentTrends.vue";
import YearToYear from "./panels/YearOnYear.vue";
import MonthToMonth from "./panels/MonthToMonth.vue";
import { listEnergyType } from '@/api/ems/energyType'
import { listMeter } from '@/api/ems/meter'
import { listEnergyRecord } from '@/api/ems/energyRecord'
import dayjs from 'dayjs'
import * as echarts from 'echarts'
import TimeRangePicker from '@/views/wms/report/components/timeRangePicker.vue'
export default {
name: "Dashboard",
name: 'EmsDashboard',
dicts: ['sys_lines'],
components: {
Overview,
RecentTrend,
YearToYear,
MonthToMonth
TimeRangePicker
},
data() {
const now = dayjs()
return {
energyTypeList: [],
energyType: '',
locationId: '',
deviceId: '',
locationList: [
],
defaultProps: {
children: 'children',
label: 'name'
timeRangeParams: {
recordStartDate: now.startOf('month').format('YYYY-MM-DD HH:mm:ss'),
recordEndDate: now.endOf('month').format('YYYY-MM-DD HH:mm:ss')
},
activeTab: '1'
defaultStartTime: now.startOf('month').format('YYYY-MM-DD HH:mm:ss'),
defaultEndTime: now.endOf('month').format('YYYY-MM-DD HH:mm:ss'),
queryParams: {
energyTypeId: null,
productionLines: []
},
energyTypeList: [],
meterList: [],
energyRecords: [],
statistics: [],
tableData: [],
trendChartType: 'day',
trendChart: null,
energyTypeChart: null,
productionLineChart: null,
deviceRankingChart: null,
loading: false,
}
},
created() {
this.getEnergyTypeList();
this.getLocationList();
mounted() {
this.initCharts()
this.loadBasicData()
window.addEventListener('resize', this.handleResize)
},
computed: {
showRight() {
return this.energyType && (this.deviceId || this.locationId);
},
energyUnit() {
return this.energyTypeList.find(item => item.energyTypeId === this.energyType)?.unit;
},
energyName() {
return this.energyTypeList.find(item => item.energyTypeId === this.energyType)?.name;
}
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
if (this.trendChart) this.trendChart.dispose()
if (this.energyTypeChart) this.energyTypeChart.dispose()
if (this.productionLineChart) this.productionLineChart.dispose()
if (this.deviceRankingChart) this.deviceRankingChart.dispose()
},
methods: {
getEnergyTypeList() {
listEnergyType({ pageNum: 1, pageSize: 9999 }).then(response => {
this.energyTypeList = response.rows;
this.energyType = this.energyTypeList[0]?.energyTypeId;
});
initCharts() {
this.trendChart = echarts.init(this.$refs.trendChart)
this.energyTypeChart = echarts.init(this.$refs.energyTypeChart)
this.productionLineChart = echarts.init(this.$refs.productionLineChart)
this.deviceRankingChart = echarts.init(this.$refs.deviceRankingChart)
},
getLocationList() {
listLocation().then(response => {
this.locationList = this.handleTree(response.data, "locationId", "parentId");
});
handleResize() {
this.trendChart && this.trendChart.resize()
this.energyTypeChart && this.energyTypeChart.resize()
this.productionLineChart && this.productionLineChart.resize()
this.deviceRankingChart && this.deviceRankingChart.resize()
},
handleNodeClick(data) {
this.locationId = data.locationId;
this.deviceId = undefined;
this.refresh();
async loadBasicData() {
try {
this.loading = true;
const [energyTypeRes, meterRes] = await Promise.all([
listEnergyType({ pageSize: 999 }),
listMeter({ pageSize: 999, isTotalMeter: 0 })
])
this.energyTypeList = energyTypeRes.rows || []
this.meterList = meterRes.rows || []
await this.loadData()
} catch (error) {
console.error('加载基础数据失败', error)
}
},
refresh() {
if (this.$refs.overview) {
this.$refs.overview.refresh();
async loadData() {
try {
const query = {
recordStartDate: this.timeRangeParams.recordStartDate,
recordEndDate: this.timeRangeParams.recordEndDate
}
if (this.queryParams.energyTypeId) {
query.energyTypeId = this.queryParams.energyTypeId
}
this.loading = true;
const res = await listEnergyRecord(query)
this.energyRecords = res.rows || []
this.processData()
} catch (error) {
console.error('加载数据失败', error)
} finally {
this.loading = false;
}
if (this.$refs.recentTrend) {
this.$refs.recentTrend.refresh();
},
processData() {
const validMeterIds = new Set(this.meterList.map(m => m.meterId))
this.energyRecords = this.energyRecords.filter(record => validMeterIds.has(record.meterId))
this.calculateStatistics()
this.updateTrendChart()
this.updateEnergyTypeChart()
this.updateProductionLineChart()
this.updateDeviceRankingChart()
this.updateTableData()
},
calculateStatistics() {
const totalConsumption = this.energyRecords.reduce((sum, record) => sum + (Number(record.consumption) || 0), 0)
const avgDaily = this.getAvgDailyConsumption()
const maxConsumption = this.getMaxConsumption()
const deviceCount = new Set(this.energyRecords.map(r => r.meterId)).size
this.statistics = [
{
label: '总能耗',
value: Number(totalConsumption).toFixed(2),
icon: 'el-icon-data-line',
trend: 12.5
},
{
label: '日均能耗',
value: Number(avgDaily).toFixed(2),
icon: 'el-icon-data-analysis',
trend: -3.2
},
{
label: '最高能耗',
value: Number(maxConsumption).toFixed(2),
icon: 'el-icon-s-data',
trend: 5.1
},
{
label: '活跃设备',
value: deviceCount,
icon: 'el-icon-s-tools',
trend: 8.3
}
]
},
getAvgDailyConsumption() {
if (this.energyRecords.length === 0) return 0
const dateMap = new Map()
this.energyRecords.forEach(record => {
const date = record.recordDate
if (!dateMap.has(date)) {
dateMap.set(date, 0)
}
dateMap.set(date, dateMap.get(date) + (Number(record.consumption) || 0))
})
const total = Array.from(dateMap.values()).reduce((sum, val) => sum + val, 0)
return total / dateMap.size
},
getMaxConsumption() {
if (this.energyRecords.length === 0) return 0
return Math.max(...this.energyRecords.map(r => Number(r.consumption) || 0))
},
updateTrendChart() {
const dateFormat = this.trendChartType === 'day' ? 'YYYY-MM-DD' :
this.trendChartType === 'week' ? 'YYYY-WW' : 'YYYY-MM'
const groupedData = new Map()
this.energyRecords.forEach(record => {
const dateKey = dayjs(record.recordDate).format(dateFormat)
const energyType = this.getEnergyTypeName(record.energyId)
if (!groupedData.has(dateKey)) {
groupedData.set(dateKey, {})
}
if (!groupedData.get(dateKey)[energyType]) {
groupedData.get(dateKey)[energyType] = 0
}
groupedData.get(dateKey)[energyType] += Number(record.consumption) || 0
})
const dates = Array.from(groupedData.keys()).sort()
const energyTypes = Array.from(new Set(this.energyRecords.map(r => this.getEnergyTypeName(r.energyId))))
const colors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#00CED1']
const series = energyTypes.map((type, index) => ({
name: type,
type: 'line',
smooth: true,
data: dates.map(date => groupedData.get(date)[type] || 0),
itemStyle: { color: colors[index % colors.length] },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: colors[index % colors.length] + '80' },
{ offset: 1, color: colors[index % colors.length] + '10' }
])
}
}))
const option = {
tooltip: {
trigger: 'axis'
},
legend: {
data: energyTypes
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates
},
yAxis: {
type: 'value',
name: '能耗'
},
series
}
if (this.$refs.yearToYear) {
this.$refs.yearToYear.refresh();
this.trendChart.setOption(option)
},
updateEnergyTypeChart() {
const typeMap = new Map()
this.energyRecords.forEach(record => {
const typeName = this.getEnergyTypeName(record.energyId)
typeMap.set(typeName, (typeMap.get(typeName) || 0) + (Number(record.consumption) || 0))
})
const data = Array.from(typeMap.entries()).map(([name, value]) => ({ name, value }))
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
right: 10,
top: 'center'
},
series: [
{
name: '能源类型',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 20,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data
}
]
}
if (this.$refs.monthToMonth) {
this.$refs.monthToMonth.refresh();
this.energyTypeChart.setOption(option)
},
updateProductionLineChart() {
const lineMap = new Map()
this.energyRecords.forEach(record => {
const meter = this.meterList.find(m => m.meterId === record.meterId)
if (meter && meter.productionLine) {
const lines = Array.isArray(meter.productionLine) ? meter.productionLine : [meter.productionLine]
lines.forEach(lineId => {
const lineName = this.getLineName(lineId)
lineMap.set(lineName, (lineMap.get(lineName) || 0) + (Number(record.consumption) || 0))
})
}
})
const sortedLines = Array.from(lineMap.entries())
.sort((a, b) => b[1] - a[1])
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value',
name: '能耗'
},
yAxis: {
type: 'category',
data: sortedLines.map(item => item[0])
},
series: [
{
name: '能耗',
type: 'bar',
data: sortedLines.map(item => item[1]),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#409EFF' },
{ offset: 1, color: '#00CED1' }
]),
borderRadius: [0, 4, 4, 0]
}
}
]
}
this.productionLineChart.setOption(option)
},
updateDeviceRankingChart() {
const deviceMap = new Map()
this.energyRecords.forEach(record => {
const meter = this.meterList.find(m => m.meterId === record.meterId)
const deviceName = meter ? meter.meterCode : `设备${record.meterId}`
deviceMap.set(deviceName, (deviceMap.get(deviceName) || 0) + (Number(record.consumption) || 0))
})
const sortedDevices = Array.from(deviceMap.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value',
name: '能耗'
},
yAxis: {
type: 'category',
data: sortedDevices.map(item => item[0]).reverse()
},
series: [
{
name: '能耗',
type: 'bar',
data: sortedDevices.map(item => item[1]).reverse(),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#67C23A' },
{ offset: 1, color: '#95E67D' }
]),
borderRadius: [0, 4, 4, 0]
}
}
]
}
this.deviceRankingChart.setOption(option)
},
getEnergyTypeName(energyTypeId) {
const type = this.energyTypeList.find(t => t.energyTypeId === energyTypeId)
return type ? type.name : '未知'
},
getLineName(lineId) {
const line = this.dict.type.sys_lines?.find(l => l.value === lineId)
return line ? line.label : lineId
},
updateTableData() {
const lineEnergyMap = new Map()
this.energyRecords.forEach(record => {
const meter = this.meterList.find(m => m.meterId === record.meterId)
if (meter && meter.productionLine) {
const lines = Array.isArray(meter.productionLine) ? meter.productionLine : [meter.productionLine]
lines.forEach(lineId => {
const lineName = this.getLineName(lineId)
const energyTypeId = record.energyId
if (!lineEnergyMap.has(lineName)) {
lineEnergyMap.set(lineName, {})
}
const lineData = lineEnergyMap.get(lineName)
if (!lineData['type_' + energyTypeId]) {
lineData['type_' + energyTypeId] = 0
}
lineData['type_' + energyTypeId] += Number(record.consumption) || 0
})
}
})
this.tableData = Array.from(lineEnergyMap.entries()).map(([lineName, data]) => {
const row = { lineName, ...data }
let total = 0
this.energyTypeList.forEach(type => {
const val = data['type_' + type.energyTypeId] || 0
total += val
})
row.total = total
return row
})
},
getSummaries(param) {
const { columns, data } = param
const sums = []
columns.forEach((column, index) => {
if (index === 0) {
sums[index] = '合计'
return
}
if (column.property === 'total') {
const values = data.map(item => Number(item[column.property]) || 0)
sums[index] = values.reduce((prev, curr) => prev + curr, 0).toFixed(2)
} else if (column.property && column.property.startsWith('type_')) {
const values = data.map(item => Number(item[column.property]) || 0)
sums[index] = values.reduce((prev, curr) => prev + curr, 0).toFixed(2)
} else {
sums[index] = ''
}
})
return sums
}
}
}
</script>
<style lang="scss" scoped>
.ems-dashboard-container {
padding: 20px;
background: #f6f7fb;
min-height: calc(100vh - 100px);
.filter-panel {
background: #ffffff;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.filter-form {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 16px;
}
}
.stat-cards {
margin-bottom: 20px;
.stat-card {
background: #ffffff;
border-radius: 8px;
padding: 20px;
display: flex;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
}
&.stat-0 {
border-left: 4px solid #409EFF;
}
&.stat-1 {
border-left: 4px solid #67C23A;
}
&.stat-2 {
border-left: 4px solid #E6A23C;
}
&.stat-3 {
border-left: 4px solid #F56C6C;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
font-size: 28px;
.stat-0 & {
background: linear-gradient(135deg, #409EFF, #00CED1);
color: #ffffff;
}
.stat-1 & {
background: linear-gradient(135deg, #67C23A, #95E67D);
color: #ffffff;
}
.stat-2 & {
background: linear-gradient(135deg, #E6A23C, #F7D94C);
color: #ffffff;
}
.stat-3 & {
background: linear-gradient(135deg, #F56C6C, #FC9494);
color: #ffffff;
}
}
.stat-content {
flex: 1;
.stat-value {
font-size: 28px;
font-weight: bold;
color: #303133;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.stat-trend {
font-size: 12px;
color: #67C23A;
&:has(.el-icon-bottom) {
color: #F56C6C;
}
}
}
}
}
.charts-section {
.chart-card {
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.chart-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.chart-container {
width: 100%;
height: 350px;
}
.table-container {
width: 100%;
}
}
}
}
</script>
</style>

View File

@@ -1,608 +0,0 @@
<template>
<div class="month-on-month-analysis">
<!-- 筛选区域//年切换 + 单周期选择 -->
<div class="filter-section">
<el-row :gutter="20" align="middle">
<!-- 时间类型切换默认选中按周 -->
<el-col :span="6">
<el-select
v-model="timeType"
placeholder="请选择时间类型"
@change="handleTimeTypeChange"
clearable
>
<el-option label="按周" value="week"></el-option>
<el-option label="按月" value="month"></el-option>
<el-option label="按年" value="year"></el-option>
</el-select>
</el-col>
<!-- 日期选择器单周期选择非范围 -->
<el-col :span="12">
<!-- 1. 按周单周选择器 -->
<el-date-picker
v-if="timeType === 'week'"
v-model="dateRange"
type="date"
placeholder="选择周"
format="yyyy年第WW周"
value-format="yyyy-'W'WW"
picker-options="{ type: 'week' }"
:disabled-date="disableFutureDate"
></el-date-picker>
<!-- 2. 按月单月选择器 -->
<el-date-picker
v-else-if="timeType === 'month'"
v-model="dateRange"
type="date"
placeholder="选择月"
format="yyyy-MM"
value-format="yyyy-MM"
picker-options="{ type: 'month' }"
:disabled-date="disableFutureDate"
></el-date-picker>
<!-- 3. 按年单年选择器 -->
<el-date-picker
v-else-if="timeType === 'year'"
v-model="dateRange"
type="date"
placeholder="选择年"
format="yyyy"
value-format="yyyy"
picker-options="{ type: 'year' }"
:disabled-date="disableFutureDate"
></el-date-picker>
</el-col>
<!-- 查询/重置按钮无选择时禁用查询 -->
<el-col :span="6">
<el-button type="primary" @click="handleQuery" :disabled="!dateRange">查询</el-button>
<el-button type="text" @click="handleReset" style="margin-left: 10px;">重置</el-button>
</el-col>
</el-row>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-loading-spinner></el-loading-spinner>
<p>数据加载中...</p>
</div>
<!-- 环比指标卡区域保持原有展示逻辑 -->
<div v-else class="indicator-cards">
<el-row :gutter="20">
<!-- 当期值 -->
<el-col :span="6">
<div class="indicator-card">
<div class="card-header">
<span class="card-title">当期值</span>
<span class="card-period">{{ currentPeriodText }}</span>
</div>
<div class="card-value">{{ formatNumber(currentValue) }} {{ unit }}</div>
<div class="card-desc">当前{{ timeTypeMap[timeType] }}{{ indicatorName }}</div>
</div>
</el-col>
<!-- 上期值 -->
<el-col :span="6">
<div class="indicator-card">
<div class="card-header">
<span class="card-title">上期值</span>
<span class="card-period">{{ previousPeriodText }}</span>
</div>
<div class="card-value">{{ formatNumber(previousValue) }} {{ unit }}</div>
<div class="card-desc">上一个{{ timeTypeMap[timeType] }}{{ indicatorName }}</div>
</div>
</el-col>
<!-- 增加值 -->
<el-col :span="6">
<div class="indicator-card">
<div class="card-header">
<span class="card-title">增加值</span>
<span class="card-icon"><i class="el-icon-arrow-right"></i></span>
</div>
<div class="card-value" :class="increaseValue > 0 ? 'text-increase' : increaseValue < 0 ? 'text-decrease' : ''">
{{ formatNumber(increaseValue) }} {{ unit }}
</div>
<div class="card-desc">当期 - 上期</div>
</div>
</el-col>
<!-- 环比 -->
<el-col :span="6">
<div class="indicator-card">
<div class="card-header">
<span class="card-title">环比</span>
<span class="card-icon"><i class="el-icon-refresh"></i></span>
</div>
<div class="card-value" :class="monthOnMonth > 0 ? 'text-increase' : monthOnMonth < 0 ? 'text-decrease' : ''">
{{ monthOnMonth !== null ? (monthOnMonth * 100).toFixed(2) + '%' : '--' }}
</div>
<div class="card-desc">当期 - 上期/ 上期 × 100%</div>
</div>
</el-col>
</el-row>
</div>
</div>
</template>
<script>
export default {
name: 'CycleAnalysis',
props: {
// 数据单位kWh、Nm³、元
unit: {
type: String,
default: 'kWh'
},
// 指标名称(如:能耗、费用、产量)
indicatorName: {
type: String,
default: '能耗'
}
},
data() {
return {
// 时间类型默认按周week
timeType: 'week',
// 单周期选择值格式week→yyyy-Wwwmonth→yyyy-MMyear→yyyy
dateRange: '',
// 加载状态
loading: false,
// 核心数据
currentValue: null, // 当期值
previousValue: null, // 上期值
increaseValue: null, // 增加值(当期-上期)
monthOnMonth: null, // 环比((当期-上期)/上期)
// 周期文本描述2025年第23周、2025年06月
currentPeriodText: '',
previousPeriodText: '',
// 时间类型映射(用于文案显示)
timeTypeMap: {
week: '周',
month: '月',
year: '年'
}
}
},
mounted() {
// 初始化默认周期(当前周/月/年)+ 自动查询一次
this.initDateRange()
this.handleQuery()
},
methods: {
/**
* 初始化默认周期(当前时间类型的当前周期)
*/
initDateRange() {
const now = new Date()
const year = now.getFullYear()
let currentPeriod
switch (this.timeType) {
case 'week':
// 周格式yyyy-Www两位周数
const [currentWeek] = this.getWeekInfo(now)
currentPeriod = `${year}-W${this.padZero(currentWeek)}`
break
case 'month':
// 月格式yyyy-MM两位月份
const currentMonth = now.getMonth() + 1
currentPeriod = `${year}-${this.padZero(currentMonth)}`
break
case 'year':
// 年格式yyyy
currentPeriod = `${year}`
break
}
this.dateRange = currentPeriod
// 同步更新周期文本(当期+上期)
this.updatePeriodText()
},
/**
* 获取周信息(本年周数 + 周起始日期)
* @param {Date} date - 日期对象
* @returns {Array} [周数, 周起始日期Date]
*/
getWeekInfo(date) {
const firstDayOfYear = new Date(date.getFullYear(), 0, 1)
const pastDaysOfYear = (date - firstDayOfYear) / (24 * 60 * 60 * 1000)
// 周日为一周第一天,计算本年周数
const weekNumber = Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7)
// 计算当前周的起始日期(周日)
const weekStart = new Date(date)
weekStart.setDate(date.getDate() - date.getDay())
return [weekNumber, weekStart]
},
/**
* 计算上期周期(根据当前周期自动推导)
* @param {String} currentPeriod - 当前周期2025-W23、2025-06、2025
* @param {String} timeType - 时间类型week/month/year
* @returns {String} 上期周期
*/
getPreviousPeriod(currentPeriod, timeType) {
const [year, part] = currentPeriod.split(
timeType === 'week' ? '-W' : timeType === 'month' ? '-' : ''
)
let prevYear = parseInt(year)
let prevPart
switch (timeType) {
case 'week':
const currentWeek = parseInt(part)
// 若当前是第1周上期为上一年最后一周
if (currentWeek === 1) {
prevYear -= 1
prevPart = this.getLastWeekOfYear(prevYear)
} else {
prevPart = currentWeek - 1
}
return `${prevYear}-W${this.padZero(prevPart)}`
case 'month':
const currentMonth = parseInt(part)
// 若当前是1月上期为上一年12月
if (currentMonth === 1) {
prevYear -= 1
prevPart = 12
} else {
prevPart = currentMonth - 1
}
return `${prevYear}-${this.padZero(prevPart)}`
case 'year':
// 上期为上一年
return `${prevYear - 1}`
}
},
/**
* 获取某一年的最后一周(处理跨年周场景)
* @param {Number} year - 年份
* @returns {Number} 最后一周的周数
*/
getLastWeekOfYear(year) {
const lastDayOfYear = new Date(year, 11, 31) // 12月31日
const [lastWeek] = this.getWeekInfo(lastDayOfYear)
return lastWeek
},
/**
* 将周期转换为时间段startDate ~ endDate格式yyyy-MM-dd
* @param {String} period - 周期2025-W23、2025-06、2025
* @param {String} timeType - 时间类型week/month/year
* @returns {Object} { start: 开始日期, end: 结束日期 }
*/
getPeriodTimeRange(period, timeType) {
let startDate, endDate
const [year, part] = period.split(
timeType === 'week' ? '-W' : timeType === 'month' ? '-' : ''
)
const targetYear = parseInt(year)
switch (timeType) {
case 'week':
const week = parseInt(part)
// 计算该年第N周的起始日期周日
const firstDayOfYear = new Date(targetYear, 0, 1)
const firstWeekStart = new Date(firstDayOfYear)
firstWeekStart.setDate(firstDayOfYear.getDate() - firstDayOfYear.getDay())
// 第N周起始 = 第一周起始 + (N-1)*7天
startDate = new Date(firstWeekStart)
startDate.setDate(firstWeekStart.getDate() + (week - 1) * 7)
// 第N周结束 = 起始 + 6天周六
endDate = new Date(startDate)
endDate.setDate(startDate.getDate() + 6)
break
case 'month':
const month = parseInt(part) - 1 // 月份从0开始
startDate = new Date(targetYear, month, 1) // 当月1号
endDate = new Date(targetYear, month + 1, 0) // 当月最后一天
break
case 'year':
startDate = new Date(targetYear, 0, 1) // 1月1号
endDate = new Date(targetYear, 11, 31) // 12月31号
break
}
// 格式化为 yyyy-MM-dd
const format = (date) => date.toISOString().split('T')[0]
return { start: format(startDate), end: format(endDate) }
},
/**
* 更新周期文本描述(当期+上期)
*/
updatePeriodText() {
if (!this.dateRange) return
const currentPeriod = this.dateRange
const previousPeriod = this.getPreviousPeriod(currentPeriod, this.timeType)
switch (this.timeType) {
case 'week':
const [currWYear, currW] = currentPeriod.split('-W')
const [prevWYear, prevW] = previousPeriod.split('-W')
this.currentPeriodText = `${currWYear}年第${currW}`
this.previousPeriodText = `${prevWYear}年第${prevW}`
break
case 'month':
const [currMYear, currM] = currentPeriod.split('-')
const [prevMYear, prevM] = previousPeriod.split('-')
this.currentPeriodText = `${currMYear}${currM}`
this.previousPeriodText = `${prevMYear}${prevM}`
break
case 'year':
this.currentPeriodText = `${currentPeriod}`
this.previousPeriodText = `${previousPeriod}`
break
}
},
/**
* 时间类型切换:重置周期 + 自动查询
*/
handleTimeTypeChange() {
this.dateRange = ''
this.initDateRange()
this.resetData()
this.handleQuery()
},
/**
* 查询按钮:构建参数 + 调用接口 + 计算环比
*/
handleQuery() {
this.loading = true
// 1. 构建接口参数(含当期/上期时间段)
const apiParams = this.buildApiParams()
// 2. 调用接口实际项目替换为axios
this.mockApiRequest(apiParams)
.then(({ currentValue, previousValue }) => {
// 3. 保存原始数据保留2位小数
this.currentValue = Number(currentValue.toFixed(2))
this.previousValue = Number(previousValue.toFixed(2))
// 4. 计算增加值和环比
this.calculateIndicators()
// 5. 更新周期文本
this.updatePeriodText()
})
.catch(err => {
this.$message.error(`数据获取失败:${err.message}`)
this.resetData()
})
.finally(() => {
this.loading = false
})
},
/**
* 构建接口参数(核心:传递当期/上期时间段)
* @returns {Object} 接口请求参数
*/
buildApiParams() {
if (!this.dateRange) return {}
// 当前周期 + 上期周期
const currentPeriod = this.dateRange
const previousPeriod = this.getPreviousPeriod(currentPeriod, this.timeType)
// 转换为时间段start/end: yyyy-MM-dd
const currentTimeRange = this.getPeriodTimeRange(currentPeriod, this.timeType)
const previousTimeRange = this.getPeriodTimeRange(previousPeriod, this.timeType)
return {
indicatorName: this.indicatorName, // 指标名称(如:能耗)
timeType: this.timeType, // 时间类型week/month/year
currentRange: currentTimeRange, // 当期时间段
previousRange: previousTimeRange // 上期时间段
}
},
/**
* 模拟接口请求实际项目替换为axios.post/get
* @param {Object} params - 接口参数
* @returns {Promise} 包含当期/上期值的Promise
*/
mockApiRequest(params) {
return new Promise((resolve) => {
// 模拟网络延迟
setTimeout(() => {
// 根据时间类型生成合理范围的随机数据(以上期为基础)
let baseValue = 0
switch (params.timeType) {
case 'week':
baseValue = 7000 + Math.random() * 2000 // 周数据7000-9000
break
case 'month':
baseValue = 30000 + Math.random() * 5000 // 月数据30000-35000
break
case 'year':
baseValue = 365000 + Math.random() * 50000 // 年数据36.5万-41.5万
break
}
// 当期值 = 上期值 × 随机波动(-5% ~ +15%
const previousValue = baseValue
const fluctuation = -0.05 + Math.random() * 0.2 // 波动范围:-5% ~ +15%
const currentValue = previousValue * (1 + fluctuation)
resolve({ currentValue, previousValue })
}, 800)
})
},
/**
* 计算增加值和环比处理上期为0的异常场景
*/
calculateIndicators() {
// 增加值 = 当期 - 上期
this.increaseValue = this.currentValue - this.previousValue
// 环比 = (当期 - 上期) / 上期上期为0时特殊处理
if (this.previousValue === 0) {
this.monthOnMonth = this.currentValue > 0 ? 1 : 0 // 上期为0时当期有值则环比100%
} else {
this.monthOnMonth = this.increaseValue / this.previousValue
}
},
/**
* 重置:恢复默认周期 + 清空数据 + 自动查询
*/
handleReset() {
this.initDateRange()
this.resetData()
this.handleQuery()
},
/**
* 清空核心数据
*/
resetData() {
this.currentValue = null
this.previousValue = null
this.increaseValue = null
this.monthOnMonth = null
this.currentPeriodText = ''
this.previousPeriodText = ''
},
/**
* 数字补零(确保周数/月份为两位)
* @param {Number} num - 需补零的数字
* @returns {String} 补零后的字符串
*/
padZero(num) {
return num < 10 ? `0${num}` : `${num}`
},
/**
* 格式化数字(千分位分隔 + 保留2位小数
* @param {Number} num - 需格式化的数字
* @returns {String} 格式化后的字符串
*/
formatNumber(num) {
if (num === null || isNaN(num)) return '--'
return num.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
},
/**
* 禁用未来日期选择(无法选择未到的周/月/年)
* @param {Date} date - 待判断日期
* @returns {Boolean} 是否禁用
*/
disableFutureDate(date) {
return date > new Date()
},
refresh() {
this.handleQuery()
}
}
}
</script>
<style scoped>
.month-on-month-analysis {
padding: 20px;
background-color: #fff;
border-radius: 6px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.filter-section {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
}
.indicator-cards {
margin-top: 10px;
}
.indicator-card {
background-color: #fff;
border-radius: 6px;
padding: 18px;
height: 100%;
border: 1px solid #f0f0f0;
transition: all 0.3s ease;
}
.indicator-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.card-title {
font-size: 14px;
color: #666;
font-weight: 500;
}
.card-period {
font-size: 12px;
color: #999;
background-color: #f5f7fa;
padding: 2px 8px;
border-radius: 4px;
}
.card-icon {
color: #409eff;
font-size: 14px;
}
.card-value {
font-size: 26px;
font-weight: 600;
color: #333;
margin-bottom: 6px;
transition: color 0.3s ease;
}
.card-desc {
font-size: 12px;
color: #999;
}
/* 增加值/环比颜色:上升红、下降绿 */
.text-increase {
color: #f56c6c;
}
.text-decrease {
color: #67c23a;
}
/* 加载状态 */
.loading-container {
text-align: center;
padding: 60px 0;
color: #666;
}
.loading-container p {
margin-top: 12px;
font-size: 14px;
}
</style>

View File

@@ -1,320 +0,0 @@
<template>
<div>
<!-- 今日 vs 昨日 -->
<div class="trend-row">
<div class="data-card">
<div class="data-value">{{ data.today }}</div>
<div class="data-label">今日</div>
</div>
<div class="data-card">
<div class="data-value">{{ data.yesterday }}</div>
<div class="data-label">昨日</div>
</div>
<div class="trend-card" :class="todayTrendClass">
<div class="trend-indicator">
<i class="fas" :class="todayTrendIcon"></i>
</div>
<div class="trend-percent">{{ todayPercent }}</div>
<div class="trend-diff">{{ todayDiff }}</div>
</div>
</div>
<!-- 本月 vs 上月 -->
<div class="trend-row">
<div class="data-card">
<div class="data-value">{{ data.currentMonth }}</div>
<div class="data-label">本月</div>
</div>
<div class="data-card">
<div class="data-value">{{ data.lastMonth }}</div>
<div class="data-label">上月</div>
</div>
<div class="trend-card" :class="monthTrendClass">
<div class="trend-indicator">
<i class="fas" :class="monthTrendIcon"></i>
</div>
<div class="trend-percent">{{ monthPercent }}</div>
<div class="trend-diff">{{ monthDiff }}</div>
</div>
</div>
<!-- 本年 vs 去年 -->
<div class="trend-row">
<div class="data-card">
<div class="data-value">{{ data.currentYear }}</div>
<div class="data-label">本年</div>
</div>
<div class="data-card">
<div class="data-value">{{ data.lastYear }}</div>
<div class="data-label">去年</div>
</div>
<div class="trend-card" :class="yearTrendClass">
<div class="trend-indicator">
<i class="fas" :class="yearTrendIcon"></i>
</div>
<div class="trend-percent">{{ yearPercent }}</div>
<div class="trend-diff">{{ yearDiff }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "TrendComparison",
props: {
unit: {
type: String,
required: true
},
energyName: {
type: String,
required: true
},
energyType: {
type: String,
required: true
},
locationId: {
type: String,
required: false
},
deviceId: {
type: String,
required: false
}
},
data() {
return {
data: {} // 存储模拟数据
};
},
created() {
// 组件创建时获取模拟数据
this.data = this.fetchMockData();
},
methods: {
/**
* 模拟API请求生成六个数值
* 生成规则:
* - 今日随机100-500之间的数值
* - 昨日基于今日数值上下浮动20%
* - 本月随机5000-20000之间的数值
* - 上月基于本月数值上下浮动30%
* - 本年随机100000-500000之间的数值
* - 去年基于本年数值上下浮动40%
*/
fetchMockData() {
// 生成今日数据
const today = parseFloat((Math.random() * 400 + 100).toFixed(2));
// 昨日数据今日数据的80%-120%之间
const yesterday = parseFloat((today * (0.8 + Math.random() * 0.4)).toFixed(2));
// 本月数据
const currentMonth = parseFloat((Math.random() * 15000 + 5000).toFixed(2));
// 上月数据本月数据的70%-130%之间
const lastMonth = parseFloat((currentMonth * (0.7 + Math.random() * 0.6)).toFixed(2));
// 本年数据
const currentYear = parseFloat((Math.random() * 400000 + 100000).toFixed(2));
// 去年数据本年数据的60%-140%之间
const lastYear = parseFloat((currentYear * (0.6 + Math.random() * 0.8)).toFixed(2));
return {
today,
yesterday,
currentMonth,
lastMonth,
currentYear,
lastYear
};
},
/**
* 格式化数值显示,添加千位分隔符
* @param {Number} num 要格式化的数值
* @returns {String} 格式化后的字符串
*/
formatNumber(num) {
return num.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
},
// 对外暴露的刷新方法
refresh() {
this.data = this.fetchMockData();
}
},
computed: {
// ---- 今日 vs 昨日 计算 ----
todayDiff() {
const diff = this.data.today - this.data.yesterday;
return diff >= 0
? `+${this.formatNumber(diff)}`
: `${this.formatNumber(diff)}`;
},
todayPercent() {
if (this.data.yesterday === 0) return "0.00%";
const percent = ((this.data.today - this.data.yesterday) / this.data.yesterday) * 100;
return percent >= 0
? `+${percent.toFixed(2)}%`
: `${percent.toFixed(2)}%`;
},
todayTrendClass() {
return this.data.today > this.data.yesterday ? "trend-up" : "trend-down";
},
todayTrendIcon() {
return this.data.today > this.data.yesterday ? "fa-arrow-up" : "fa-arrow-down";
},
// ---- 本月 vs 上月 计算 ----
monthDiff() {
const diff = this.data.currentMonth - this.data.lastMonth;
return diff >= 0
? `+${this.formatNumber(diff)}`
: `${this.formatNumber(diff)}`;
},
monthPercent() {
if (this.data.lastMonth === 0) return "0.00%";
const percent = ((this.data.currentMonth - this.data.lastMonth) / this.data.lastMonth) * 100;
return percent >= 0
? `+${percent.toFixed(2)}%`
: `${percent.toFixed(2)}%`;
},
monthTrendClass() {
return this.data.currentMonth > this.data.lastMonth ? "trend-up" : "trend-down";
},
monthTrendIcon() {
return this.data.currentMonth > this.data.lastMonth ? "fa-arrow-up" : "fa-arrow-down";
},
// ---- 本年 vs 去年 计算 ----
yearDiff() {
const diff = this.data.currentYear - this.data.lastYear;
return diff >= 0
? `+${this.formatNumber(diff)}`
: `${this.formatNumber(diff)}`;
},
yearPercent() {
if (this.data.lastYear === 0) return "0.00%";
const percent = ((this.data.currentYear - this.data.lastYear) / this.data.lastYear) * 100;
return percent >= 0
? `+${percent.toFixed(2)}%`
: `${percent.toFixed(2)}%`;
},
yearTrendClass() {
return this.data.currentYear > this.data.lastYear ? "trend-up" : "trend-down";
},
yearTrendIcon() {
return this.data.currentYear > this.data.lastYear ? "fa-arrow-up" : "fa-arrow-down";
}
}
};
</script>
<style scoped>
.trend-container {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 800px;
margin: 20px auto;
padding: 20px;
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.trend-title {
color: #333333;
font-size: 18px;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.trend-row {
display: flex;
margin-bottom: 15px;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s;
}
.trend-row:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}
.data-card, .trend-card {
padding: 15px;
text-align: center;
}
.data-card {
flex: 1;
background-color: #f9fafb;
border-right: 1px solid #f0f0f0;
}
.trend-card {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 15px;
}
.data-value {
font-size: 22px;
font-weight: 600;
color: #111827;
margin-bottom: 5px;
}
.data-label {
font-size: 14px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.trend-indicator {
margin-bottom: 5px;
font-size: 16px;
}
.trend-percent {
font-size: 20px;
font-weight: 600;
margin-bottom: 3px;
}
.trend-diff {
font-size: 14px;
color: #4b5563;
}
/* 趋势样式 */
.trend-up {
background-color: rgba(16, 185, 129, 0.05);
}
.trend-up .trend-percent,
.trend-up .trend-indicator {
color: #10b981; /* 绿色表示上升 */
}
.trend-down {
background-color: rgba(239, 68, 68, 0.05);
}
.trend-down .trend-percent,
.trend-down .trend-indicator {
color: #ef4444; /* 红色表示下降 */
}
</style>

View File

@@ -1,382 +0,0 @@
<template>
<div class="trends-container">
<!-- 月度能耗图表区域 -->
<el-row class="chart-group">
<el-col :span="24">
<div class="chart-header">
<h3>月度能耗趋势</h3>
<div class="chart-type-btn-group">
<el-button :type="monthChartType === 'bar' ? 'primary' : 'default'" @click="toggleMonthChartType('bar')"
:disabled="!hasMonthData">
柱状图
</el-button>
<el-button :type="monthChartType === 'line' ? 'primary' : 'default'" @click="toggleMonthChartType('line')"
:disabled="!hasMonthData">
折线图
</el-button>
</div>
</div>
<div class="chart-wrapper">
<div ref="monthChart" class="chart-container"></div>
</div>
</el-col>
</el-row>
<!-- 日度能耗图表区域 -->
<el-row class="chart-group">
<el-col :span="24">
<div class="chart-header">
<h3>日度能耗趋势</h3>
<div class="chart-type-btn-group">
<el-button :type="dayChartType === 'bar' ? 'primary' : 'default'" @click="toggleDayChartType('bar')"
:disabled="!hasDayData">
柱状图
</el-button>
<el-button :type="dayChartType === 'line' ? 'primary' : 'default'" @click="toggleDayChartType('line')"
:disabled="!hasDayData">
折线图
</el-button>
</div>
</div>
<div class="chart-wrapper">
<div ref="dayChart" class="chart-container"></div>
</div>
</el-col>
</el-row>
</div>
</template>
<script>
import * as echarts from 'echarts'
import { getRecentEnergySummary } from '@/api/ems/dashboard/timer'
export default {
name: 'RecentTrends',
data() {
return {
monthData: [], // 原始月度数据
dayData: [], // 原始日度数据
completedMonthData: [], // 补全后的月度数据
completedDayData: [], // 补全后的日度数据
monthChartType: 'bar',
dayChartType: 'bar',
monthChart: null,
dayChart: null,
hasMonthData: false, // 标记是否有月度数据
hasDayData: false // 标记是否有日度数据
}
},
props: {
unit: {
type: String,
required: true
},
energyName: {
type: String,
required: true
},
energyType: {
type: String,
required: true
},
locationId: {
type: String,
required: false
},
deviceId: {
type: String,
required: false
}
},
mounted() {
const dayChartDOM = this.$refs.dayChart
console.log(dayChartDOM, this.$refs)
if (!dayChartDOM) return
this.dayChart = echarts.init(dayChartDOM)
const monthChartDOM = this.$refs.monthChart
if (!monthChartDOM) return
this.monthChart = echarts.init(monthChartDOM)
this.fetchMonthData()
this.fetchDayData()
window.addEventListener('resize', this.handleWindowResize)
},
beforeDestroy() {
this.destroyCharts()
window.removeEventListener('resize', this.handleWindowResize)
},
methods: {
// 销毁图表实例
destroyCharts() {
if (this.monthChart) {
this.monthChart.dispose()
this.monthChart = null
}
if (this.dayChart) {
this.dayChart.dispose()
this.dayChart = null
}
},
// 数据请求
fetchMonthData() {
const year = new Date().getFullYear()
getRecentEnergySummary({ year, energyType: this.energyType, locationId: this.locationId, deviceId: this.deviceId })
.then(res => {
this.monthData = res.data || []
console.log(this.monthData)
// 检查是否有有效数据
this.hasMonthData = Array.isArray(this.monthData) && this.monthData.length > 0
this.completedMonthData = this.completeMonthData(this.monthData)
this.initMonthChart()
})
},
fetchDayData() {
const date = new Date()
const month = date.getFullYear() + '-' + (date.getMonth() + 1).toString().padStart(2, '0')
getRecentEnergySummary({ month, energyType: this.energyType, locationId: this.locationId, deviceId: this.deviceId })
.then(res => {
this.dayData = res.data || []
console.log(this.dayData)
this.completedDayData = this.completeDayData(this.dayData)
this.initDayChart()
})
},
// 补全月度数据 - 固定显示12个月份一整年
completeMonthData(data) {
this.hasMonthData = Array.isArray(this.monthData) && this.monthData.length > 0
if (!this.hasMonthData) {
const result = []
for (let i = 0; i < 12; i++) {
result.push({ month: i + 1, totalConsumption: 0 })
}
return result
}
const result = []
for (let i = 0; i < 12; i++) {
// 查找data中month{year}-{month}中{month}为i+1的数据
const item = data.find(item => item.month?.split('-')[1].padStart(2, '0') == i + 1)
console.log(item, i + 1, data)
if (item) {
result.push(item)
} else {
result.push({ month: i + 1, totalConsumption: 0 })
}
}
return result
},
// 补全日度数据 - 按照当月实际天数补齐
completeDayData(data) {
this.hasDayData = Array.isArray(this.dayData) && this.dayData.length > 0
// 本月天数
const today = new Date()
const dayCount = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate()
if (!this.hasDayData) {
const result = []
for (let i = 0; i < dayCount; i++) {
result.push({ day: i + 1, totalConsumption: 0 })
}
return result
}
// 补全数据
const result = []
for (let i = 0; i < dayCount; i++) {
const item = data.find(item => item.day?.split('-')[2].padStart(2, '0') == i + 1)
if (item) {
result.push(item)
} else {
result.push({ day: i + 1, totalConsumption: 0 })
}
}
return result;
},
// 初始化月度图表
initMonthChart() {
if (!this.hasMonthData) return
console.log(this.completedMonthData)
const xData = this.completedMonthData.map(item => item.month)
const yData = this.completedMonthData.map(item => item.totalConsumption)
const option = {
tooltip: {
trigger: 'axis',
formatter: '{b}: {c} ' + this.unit
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: xData,
axisLabel: {
rotate: 15
}
},
yAxis: {
type: 'value',
name: '能耗 (' + this.unit + ')',
min: 0
},
series: [
{
name: '总能耗',
type: this.monthChartType,
data: yData,
itemStyle: {
color: this.monthChartType === 'bar' ? '#409EFF' : '#67C23A'
},
markPoint: {
data: [
{ type: 'max', name: '最大值', itemStyle: { color: '#F56C6C' } },
{ type: 'min', name: '最小值', itemStyle: { color: '#909399' } }
],
label: {
formatter: '{b}: {c} ' + this.unit
}
},
smooth: this.monthChartType === 'line'
}
]
}
console.log(option)
this.monthChart.setOption(option)
},
// 初始化日度图表
initDayChart() {
if (!this.hasDayData) return
const xData = this.completedDayData.map(item => item.day)
const yData = this.completedDayData.map(item => item.totalConsumption)
const option = {
tooltip: {
trigger: 'axis',
formatter: '{b}: {c} ' + this.unit
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: xData,
axisLabel: {
rotate: 15
}
},
yAxis: {
type: 'value',
name: '能耗 (' + this.unit + ')',
min: 0
},
series: [
{
name: '总能耗',
type: this.dayChartType,
data: yData,
itemStyle: {
color: this.dayChartType === 'bar' ? '#409EFF' : '#67C23A'
},
markPoint: {
data: [
{ type: 'max', name: '最大值', itemStyle: { color: '#F56C6C' } },
{ type: 'min', name: '最小值', itemStyle: { color: '#909399' } }
],
label: {
formatter: '{b}: {c} ' + this.unit
}
},
smooth: this.dayChartType === 'line'
}
]
}
this.dayChart.setOption(option)
},
// 切换图表类型
toggleMonthChartType(type) {
this.monthChartType = type
this.initMonthChart()
},
toggleDayChartType(type) {
this.dayChartType = type
this.initDayChart()
},
// 窗口大小调整
handleWindowResize() {
if (this.monthChart) this.monthChart.resize()
if (this.dayChart) this.dayChart.resize()
},
// 对外暴露的刷新方法
refresh() {
this.fetchMonthData()
this.fetchDayData()
}
}
}
</script>
<style scoped>
.trends-container {
padding: 20px;
}
.chart-group {
margin-bottom: 30px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.chart-type-btn-group button {
margin-left: 10px;
}
.chart-wrapper {
width: 100%;
height: 300px;
border: 1px solid #eee;
border-radius: 4px;
position: relative;
}
.chart-container {
width: 100%;
height: 100%;
padding: 10px;
}
.no-data {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -1,340 +0,0 @@
<template>
<div class="energy-analysis-container">
<!-- 年份选择与查询按钮区域增加年份格式验证 -->
<div class="query-section">
<el-date-picker v-model="year" type="year" value-format="yyyy" placeholder="选择年份" style="margin-right: 10px;"/>
<el-button type="primary" @click="handleQuery">查询</el-button>
</div>
<!-- 图表区域新增刷新按钮优化图例布局 -->
<div class="chart-section">
<el-row :gutter="10" align="middle" style="margin-bottom: 10px;">
<!-- 图例 -->
<el-col :span="4">
<div class="legend-item">
<span class="legend-dot" style="background-color: #409EFF;"></span>
<span>本期{{ year }}</span>
</div>
<div class="legend-item">
<span class="legend-dot" style="background-color: #C084FC;"></span>
<span>同期{{ Number(year) - 1 }}</span>
</div>
</el-col>
<!-- 图表类型切换 + 刷新按钮 -->
<el-col :span="20" class="chart-operation">
<el-button-group>
<el-button type="text" :class="{ active: chartType === 'bar' }" @click="chartType = 'bar'">柱状图</el-button>
<el-button type="text" :class="{ active: chartType === 'line' }" @click="chartType = 'line'">折线图</el-button>
<el-button type="text" @click="handleRefresh">
<i class="el-icon-refresh"></i> 刷新数据
</el-button>
</el-button-group>
</el-col>
</el-row>
<!-- ECharts 容器增加无数据提示 -->
<div ref="chartRef" class="echarts-box" />
</div>
<!-- 同比分析表格格式化数据显示 -->
<div class="table-section">
<el-table :data="tableData" border style="width: 100%" :cell-style="{ textAlign: 'center' }">
<el-table-column prop="periodTime" label="本期时间" align="center" />
<el-table-column :label="'本期能耗(' + unit + ')'" align="center">
<template #default="scope">
{{ scope.row.currentEnergy !== null ? scope.row.currentEnergy : '--' }}
</template>
</el-table-column>
<el-table-column :label="'同期能耗(' + unit + ')'" align="center">
<template #default="scope">
{{ scope.row.samePeriodEnergy !== null ? scope.row.samePeriodEnergy : '--' }}
</template>
</el-table-column>
<el-table-column label="同比(%)" align="center">
<template #default="scope">
<span :class="scope.row.yearOnYear !== null ? (scope.row.yearOnYear > 0 ? 'text-red' : 'text-green') : ''">
{{ scope.row.yearOnYear !== null ? (scope.row.yearOnYear * 100).toFixed(2) + '%' : '--' }}
</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'YearOnYearAnalysis',
props: {
energyType: { type: String, default: '' },
locationId: { type: String, default: '' },
deviceId: { type: String, default: '' },
unit: { type: String, default: 'Nm3' }, // 能耗单位默认Nm3
energyName: { type: String, default: '天然气' }
},
data() {
return {
year: new Date().getFullYear().toString(), // 默认当前年份
chartType: 'bar', // 默认柱状图
// 统一数据源:表格和图表共用此数据
tableData: Array.from({ length: 12 }, (_, i) => ({
periodTime: `${i + 1}`, // 月份1月-12月
currentEnergy: null, // 本期能耗(当前年份当月)
samePeriodEnergy: null, // 同期能耗(上一年当月)
yearOnYear: null // 同比((本期-同期)/同期)
})),
chartInstance: null // ECharts实例
}
},
mounted() {
this.initChart()
// 窗口大小变化监听
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
// 销毁ECharts实例避免内存泄漏
if (this.chartInstance) {
this.chartInstance.dispose()
}
window.removeEventListener('resize', this.handleResize)
},
watch: {
// 图表类型切换时重新绘制
chartType: {
handler: 'drawChart',
immediate: true
}
},
methods: {
// 1. 初始化ECharts实例
initChart() {
this.chartInstance = echarts.init(this.$refs.chartRef)
this.drawChart()
},
// 2. 绘制图表核心从tableData提取数据
drawChart() {
// 从统一数据源tableData提取图表所需数据
const xData = this.tableData.map(item => item.periodTime) // X轴月份
const hasValidData = this.tableData.some(item => item.currentEnergy !== null) // 是否有有效数据
// 处理数据null值转为0避免图表报错
const currentData = this.tableData.map(item => item.currentEnergy ?? 0)
const samePeriodData = this.tableData.map(item => item.samePeriodEnergy ?? 0)
const chartOption = {
// 提示框配置(显示同比信息)
tooltip: {
trigger: 'axis',
axisPointer: { type: this.chartType === 'bar' ? 'shadow' : 'line' },
formatter: (params) => {
const month = params[0].name
const currentItem = this.tableData.find(item => item.periodTime === month)
let tooltipHtml = `<div>${month}</div>`
params.forEach(param => {
const seriesName = param.seriesName
const value = param.value
const percent = currentItem.yearOnYear !== null
? `(同比:${(currentItem.yearOnYear * 100).toFixed(2)}%`
: ''
tooltipHtml += `<div>${seriesName}${value} ${this.unit} ${percent}</div>`
})
return tooltipHtml
}
},
// 图表网格(边距调整)
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
// X轴配置
xAxis: {
type: 'category',
data: xData,
axisLabel: { interval: 0 } // 强制显示所有月份标签
},
// Y轴配置显示能耗单位
yAxis: {
type: 'value',
name: `能耗(${this.unit})`,
nameTextStyle: { marginRight: 10 }
},
// 系列数据(本期+同期)
series: [
{
name: `本期(${this.year}年)`,
type: this.chartType,
data: hasValidData ? currentData : [], // 无数据时传空数组,触发无数据提示
itemStyle: { color: '#409EFF' },
...(this.chartType === 'bar' && { barBorderRadius: [4, 4, 0, 0] }) // 柱状图圆角
},
{
name: `同期(${Number(this.year) - 1}年)`,
type: this.chartType,
data: hasValidData ? samePeriodData : [],
itemStyle: { color: '#C084FC' },
...(this.chartType === 'bar' && { barBorderRadius: [4, 4, 0, 0] })
}
],
// 无数据提示配置
noDataLoadingOption: {
text: '暂无数据,请选择年份并点击查询',
textStyle: { fontSize: 16, color: '#999' },
position: 'center'
}
}
this.chartInstance.setOption(chartOption)
},
// 3. 处理查询核心调用模拟API同步更新tableData
handleQuery() {
// 校验年份必须是4位数字
console.log(this.year)
if (!/^\d{4}$/.test(this.year)) {
this.$message.warning('请输入有效的4位年份如2025')
return
}
// 模拟API请求实际项目替换为axios请求
this.mockGetEnergyData(this.year)
.then(apiData => {
// 同步更新统一数据源:表格和图表将自动使用新数据
this.tableData = apiData
// 重新绘制图表(确保图表使用新数据)
this.drawChart()
this.$message.success(`成功获取${this.year}年能耗数据`)
})
.catch(err => {
this.$message.error(`数据获取失败:${err.message}`)
})
},
// 4. 刷新数据(重新调用查询逻辑)
handleRefresh() {
this.handleQuery()
},
// 5. 窗口大小变化时调整图表尺寸
handleResize() {
this.chartInstance?.resize()
},
// 6. 模拟API根据年份返回能耗数据实际项目替换为后端接口
mockGetEnergyData(year) {
return new Promise((resolve) => {
// 模拟网络延迟800ms
setTimeout(() => {
const currentYear = Number(year)
const lastYear = currentYear - 1
// 生成12个月的模拟数据确保表格和图表数据一致
const mockData = Array.from({ length: 12 }, (_, index) => {
const month = index + 1
const periodTime = `${month}`
// 生成合理的随机能耗(根据年份区分基数,避免数据重复)
const currentEnergy = Math.floor(currentYear * 5 + Math.random() * 100) // 本期能耗
const samePeriodEnergy = Math.floor(lastYear * 5 + Math.random() * 100) // 同期能耗
// 计算同比处理同期为0的异常情况
const yearOnYear = samePeriodEnergy === 0
? (currentEnergy > 0 ? 1 : 0) // 同期为0时本期有值则同比100%
: (currentEnergy - samePeriodEnergy) / samePeriodEnergy
return {
periodTime,
currentEnergy,
samePeriodEnergy,
yearOnYear // 保留小数(表格显示时转为百分比)
}
})
resolve(mockData)
}, 800)
})
},
refresh() {
this.handleQuery()
}
}
}
</script>
<style scoped>
.energy-analysis-container {
padding: 20px;
background-color: #fff;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.query-section {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.chart-section {
margin-bottom: 20px;
}
.echarts-box {
width: 100%;
height: 400px;
border: 1px solid #e6e6e6;
border-radius: 4px;
}
/* 图例样式 */
.legend-item {
display: inline-block;
margin-right: 20px;
vertical-align: middle;
font-size: 14px;
color: #666;
}
.legend-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
}
/* 图表操作区(右对齐) */
.chart-operation {
text-align: right;
}
/* 图表类型按钮激活状态 */
.el-button-group .active {
color: #409EFF;
font-weight: 500;
}
/* 表格同比颜色(红色:上升,绿色:下降) */
.text-red {
color: #F56C6C;
}
.text-green {
color: #67C23A;
}
.table-section {
margin-top: 20px;
}
</style>

View File

@@ -22,6 +22,10 @@
<el-button icon="el-icon-download" @click="downloadTemplate">下载模板</el-button>
<el-button icon="el-icon-upload" @click="handleImport">导入设备</el-button>
<el-button icon="el-icon-search" @click="showSearch = !showSearch">{{ showSearch ? '隐藏' : '显示' }}搜索</el-button>
<el-button-group style="margin-left: auto;">
<el-button :type="viewType === 'card' ? 'primary' : ''" @click="viewType = 'card'">卡片视图</el-button>
<el-button :type="viewType === 'table' ? 'primary' : ''" @click="viewType = 'table'">表格视图</el-button>
</el-button-group>
<input
ref="importFile"
type="file"
@@ -57,68 +61,121 @@
</div>
</el-collapse-transition>
<!-- 设备卡片列表 -->
<!-- 设备卡片/表格列表 -->
<div class="devices-container" v-loading="loading">
<div v-if="meterList.length === 0" class="empty-state">
<div class="empty-icon">📦</div>
<div class="empty-text">暂无设备数据</div>
</div>
<el-row :gutter="20" class="device-cards" v-else>
<el-col
v-for="meter in meterList"
:key="meter.meterId"
:xs="24" :sm="12" :md="8" :lg="6"
>
<div class="device-card">
<!-- 卡片头部 -->
<div class="card-header">
<div class="header-left">
<div class="meter-code">{{ meter.meterCode }}</div>
<div class="energy-type">{{ getEnergyName(meter.energyTypeId) }}</div>
</div>
<el-tag
:type="getStatusType(meter.status)"
size="small"
>
{{ getStatusText(meter.status) }}
</el-tag>
</div>
<!-- 卡片主体 -->
<div class="card-body">
<div class="info-item">
<span class="label">型号</span>
<span class="value">{{ meter.model || '-' }}</span>
</div>
<div class="info-item">
<span class="label">制造商</span>
<span class="value">{{ meter.manufacturer || '-' }}</span>
</div>
<div class="info-item">
<span class="label">安装日期</span>
<span class="value">{{ meter.installDate || '-' }}</span>
</div>
<div class="info-item status-selector">
<el-radio-group
v-model="meter.status"
<!-- 卡片视图 -->
<template v-if="viewType === 'card'">
<div v-if="meterList.length === 0" class="empty-state">
<div class="empty-icon">📦</div>
<div class="empty-text">暂无设备数据</div>
</div>
<el-row :gutter="20" class="device-cards" v-else>
<el-col
v-for="meter in meterList"
:key="meter.meterId"
:xs="24" :sm="12" :md="8" :lg="6"
>
<div class="device-card">
<!-- 卡片头部 -->
<div class="card-header">
<div class="header-left">
<div class="meter-code">{{ meter.meterCode }}</div>
<div class="energy-type">{{ getEnergyName(meter.energyTypeId) }}[{{ meter.productionLine || '-' }}]</div>
</div>
<el-tag
:type="getStatusType(meter.status)"
size="small"
@input="handleStatusChange(meter)"
>
<el-radio-button :label="0">在用</el-radio-button>
<el-radio-button :label="1">停用</el-radio-button>
<el-radio-button :label="2">维护</el-radio-button>
</el-radio-group>
{{ getStatusText(meter.status) }}
</el-tag>
</div>
<!-- 卡片主体 -->
<div class="card-body">
<div class="info-item">
<span class="label">型号</span>
<span class="value">{{ meter.model || '-' }}</span>
</div>
<div class="info-item">
<span class="label">制造商</span>
<span class="value">{{ meter.manufacturer || '-' }}</span>
</div>
<div class="info-item">
<span class="label">安装日期</span>
<span class="value">{{ meter.installDate || '-' }}</span>
</div>
<div class="info-item status-selector">
<el-radio-group
v-model="meter.status"
size="small"
@input="handleStatusChange(meter)"
>
<el-radio-button :label="0">在用</el-radio-button>
<el-radio-button :label="1">停用</el-radio-button>
<el-radio-button :label="2">维护</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 卡片底部操作 -->
<div class="card-footer">
<el-button type="primary" size="small" @click="handleEditMeter(meter)">编辑</el-button>
<el-button type="danger" size="small" @click="handleDeleteMeter(meter)">删除</el-button>
</div>
</div>
<!-- 卡片底部操作 -->
<div class="card-footer">
<el-button type="primary" size="small" @click="handleEditMeter(meter)">编辑</el-button>
<el-button type="danger" size="small" @click="handleDeleteMeter(meter)">删除</el-button>
</div>
</div>
</el-col>
</el-row>
</el-col>
</el-row>
</template>
<!-- 表格视图 -->
<template v-else>
<el-table v-loading="loading" :data="meterList" border>
<el-table-column label="设备名称" align="center" prop="meterCode" />
<el-table-column label="能源类型" align="center" width="150">
<template slot-scope="scope">
{{ getEnergyName(scope.row.energyTypeId) }}
</template>
</el-table-column>
<el-table-column label="相关产线" align="center" prop="productionLine" />
<el-table-column label="型号" align="center" prop="model" />
<el-table-column label="制造商" align="center" prop="manufacturer" />
<el-table-column label="安装日期" align="center" prop="installDate" />
<el-table-column label="状态" align="center" width="150">
<template slot-scope="scope">
<el-tag :type="getStatusType(scope.row.status)" size="small">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态选择" align="center" width="220">
<template slot-scope="scope">
<el-radio-group
v-model="scope.row.status"
size="small"
@input="handleStatusChange(scope.row)"
>
<el-radio-button :label="0">在用</el-radio-button>
<el-radio-button :label="1">停用</el-radio-button>
<el-radio-button :label="2">维护</el-radio-button>
</el-radio-group>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="150">
<template slot-scope="scope">
<el-button
size="mini"
type="primary"
@click="handleEditMeter(scope.row)"
>编辑</el-button>
<el-button
size="mini"
type="danger"
@click="handleDeleteMeter(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
</div>
<!-- 新增/编辑设备对话框 -->
@@ -143,9 +200,8 @@
</el-form-item>
<el-form-item label="相关产线" prop="lineId">
<el-select v-model="meterForm.productionLine" placeholder="请选择相关产线" filterable clearable>
<el-option v-for="item in dict.type.sys_lines" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<muti-select v-model="meterForm.productionLine" :options="dict.type.sys_lines" placeholder="请选择相关产线" filterable clearable>
</muti-select>
</el-form-item>
<!-- 基本信息 -->
@@ -185,6 +241,7 @@ export default {
loading: false,
importLoading: false,
showSearch: false,
viewType: 'card', // 'card' or 'table'
meterList: [],
energyTypeList: [],
warehouseList: [],
@@ -694,8 +751,6 @@ export default {
flex: 1;
padding: 6px 0 !important;
font-size: 12px !important;
}
// 能源类型卡片选择

View File

@@ -42,7 +42,7 @@
<el-table :data="tableData" style="width: 100%" border :show-summary="configOptions.showSumRow" :summary-method="getSummaries">
<el-table-column prop="date" label="日期" width="120" />
<el-table-column v-for="meter in meters" :key="meter.meterId" :prop="`meter_${meter.meterId}`"
:label="meter.meterCode">
:label="meter.meterCode + '(' + (meter.productionLine || '暂无') + ')'">
<template slot-scope="scope">
<input v-model="scope.row[`meter_${meter.meterId}`]" class="nob"
@change="(e) => handleCellChange(scope.row, meter.meterId, e)" />