You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
besure_web/src/views/mes/energyOverview/index.vue

684 lines
18 KiB
Vue

<template>
<div class="energy-overview-page">
<ContentWrap>
<el-form
ref="queryFormRef"
class="-mb-15px energy-overview-filter"
:model="queryParams"
:inline="true"
label-width="auto"
>
<el-form-item label="所属区域" prop="orgId">
<el-select
v-model="queryParams.orgId"
clearable
filterable
placeholder="请选择所属区域"
class="!w-220px"
>
<el-option v-for="item in orgOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="能源类型" prop="energyTypeId">
<el-select
v-model="queryParams.energyTypeId"
:disabled="!hasEnergyTypes"
placeholder="请选择能源类型"
class="!w-220px"
>
<el-option v-for="item in energyTypeOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="时间范围" prop="timeRange">
<el-date-picker
v-model="queryParams.timeRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator="~"
:default-time="[new Date('2000-01-01 00:00:00'), new Date('2000-01-01 23:59:59')]"
class="!w-420px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" />
查询
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" />
重置
</el-button>
<el-button type="success" plain @click="handleExport">
<Icon icon="ep:download" class="mr-5px" />
导出
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<template v-if="hasEnergyTypes">
<div class="metric-grid">
<div v-for="item in metricCards" :key="item.key" class="metric-card">
<div class="metric-icon" :class="item.theme">
<Icon :icon="item.icon" />
</div>
<div class="metric-content">
<div class="metric-label">{{ item.label }}</div>
<div class="metric-value">
{{ item.value }}
<span v-if="item.unit" class="metric-unit">{{ item.unit }}</span>
</div>
<div class="metric-sub">
<span>{{ item.subLabel }}</span>
<span v-if="item.change" class="metric-change" :class="{ down: item.down }">
<Icon :icon="item.down ? 'ep:bottom' : 'ep:top'" />
{{ item.change }}
</span>
<span v-else>{{ item.subValue }}</span>
</div>
</div>
</div>
</div>
<el-row :gutter="12" class="chart-row">
<el-col :xl="12" :lg="12" :md="24" :sm="24" :xs="24">
<ContentWrap class="overview-panel">
<div class="panel-header">
<div class="panel-title">
能源用量趋势
<Icon icon="ep:info-filled" class="panel-info" />
</div>
</div>
<Echart :height="300" :options="trendChartOptions" />
</ContentWrap>
</el-col>
<el-col :xl="12" :lg="12" :md="24" :sm="24" :xs="24">
<ContentWrap class="overview-panel">
<div class="panel-header">
<div class="panel-title">
区域能耗占比
<Icon icon="ep:info-filled" class="panel-info" />
</div>
</div>
<Echart :height="300" :options="regionPieOptions" />
</ContentWrap>
</el-col>
</el-row>
<el-row :gutter="12" class="detail-row">
<el-col :xl="7" :lg="8" :md="24" :sm="24" :xs="24">
<ContentWrap class="overview-panel">
<div class="panel-header">
<div class="panel-title">
能耗排行 TOP5
<Icon icon="ep:info-filled" class="panel-info" />
</div>
</div>
<el-table :data="rankList" :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="排名" align="center" width="70">
<template #default="scope">
<span class="rank-index" :class="`rank-${scope.$index + 1}`">{{ scope.$index + 1 }}</span>
</template>
</el-table-column>
<el-table-column label="名称" prop="name" min-width="150" />
<el-table-column label="所属区域" prop="region" width="110" />
<el-table-column :label="`用量 (${selectedEnergyUnit})`" prop="value" align="right" width="120" />
</el-table>
</ContentWrap>
</el-col>
<el-col :xl="17" :lg="16" :md="24" :sm="24" :xs="24">
<ContentWrap class="overview-panel">
<div class="panel-header">
<div class="panel-title">
能源明细表
<Icon icon="ep:info-filled" class="panel-info" />
</div>
</div>
<el-table v-loading="loading" :data="pageDetailList" :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="表名称" prop="name" min-width="170" />
<el-table-column label="能源类型" prop="energyType" align="center" width="100" />
<el-table-column label="所属区域" prop="region" align="center" width="120" />
<el-table-column :label="`能源用量 (${selectedEnergyUnit})`" prop="value" align="right" width="150" />
<el-table-column label="开始时间" prop="startTime" align="center" width="180" />
<el-table-column label="结束时间" prop="endTime" align="center" width="180" />
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
</el-col>
</el-row>
</template>
<ContentWrap v-else class="empty-panel">
<el-empty description="请先配置能源类型" :image-size="120" />
</ContentWrap>
</div>
</template>
<script setup lang="ts">
import { EChartsOption } from 'echarts'
import { Echart } from '@/components/Echart'
import { EnergyTypeApi, EnergyTypeVO } from '@/api/mes/energytype'
import {
EnergyDeviceApi,
EnergyOverviewDetailVO,
EnergyOverviewMetricVO,
EnergyOverviewRankVO,
EnergyOverviewRespVO
} from '@/api/mes/energydevice'
import {OrganizationApi, OrganizationVO} from "@/api/mes/organization";
defineOptions({ name: 'EnergyOverview' })
interface MetricCard {
key: string
label: string
value: string
unit: string
icon: string
theme: string
subLabel: string
change?: string
down?: boolean
subValue?: string
}
type DetailRow = EnergyOverviewDetailVO
const message = useMessage()
const defaultTimeRange = ['2026-05-04 00:00:00', '2026-05-05 23:59:59']
const orgOptions = ref<{ label: string; value: string | number; raw?: OrganizationVO }[]>([])
const loading = ref(false)
const queryFormRef = ref()
const energyTypeOptions = ref<EnergyTypeVO[]>([])
const defaultEnergyTypeId = ref<number>()
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
orgId: undefined as number | undefined,
energyTypeId: undefined as number | undefined,
timeRange: [...defaultTimeRange]
})
const metricCards = ref<MetricCard[]>([])
const rankList = ref<EnergyOverviewRankVO[]>([])
const pageDetailList = ref<DetailRow[]>([])
const total = ref(0)
const hasEnergyTypes = computed(() => energyTypeOptions.value.length > 0)
const selectedEnergyType = computed(() =>
energyTypeOptions.value.find((item) => item.id === queryParams.energyTypeId)
)
const selectedEnergyUnit = computed(() => selectedEnergyType.value?.unit || 'kWh')
const trendXAxis = ref<string[]>([])
const trendSeries = ref<number[]>([])
const regionItems = ref<{ name: string; value: number; percent: string }[]>([])
const regionTotal = ref('0')
const trendChartOptions = reactive<EChartsOption>({
color: ['#2f7df6'],
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = params?.[0]
return `${item?.axisValue || ''}<br />用量:${item?.data || 0} ${selectedEnergyUnit.value}`
}
},
legend: {
top: 4,
data: ['用量']
},
grid: {
left: 24,
right: 24,
top: 46,
bottom: 32,
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
name: '时间',
nameLocation: 'middle',
nameGap: 28,
data: trendXAxis.value,
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisLabel: { color: '#606266' }
},
yAxis: {
type: 'value',
name: selectedEnergyUnit.value,
min: 0,
axisLabel: { color: '#606266' },
splitLine: { lineStyle: { color: '#e8edf5' } }
},
series: [
{
name: '用量',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: { width: 3 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(47, 125, 246, 0.28)' },
{ offset: 1, color: 'rgba(47, 125, 246, 0.04)' }
]
}
},
data: trendSeries.value
}
]
})
const regionPieOptions = reactive<EChartsOption>({
color: ['#2f7df6', '#52c41a', '#faad14', '#13c2c2', '#fa8c16'],
tooltip: {
trigger: 'item',
formatter: `{b}<br />{c} ${selectedEnergyUnit.value} ({d}%)`
},
legend: {
orient: 'vertical',
right: 24,
top: 'center',
itemWidth: 12,
itemHeight: 12,
formatter: (name: string) => {
const item = regionItems.value.find((row) => row.name === name)
const value = item?.value ?? 0
const percent = item?.percent ?? '0.00'
return `${name} ${value.toLocaleString()} ${selectedEnergyUnit.value} (${percent}%)`
}
},
series: [
{
name: '区域能耗',
type: 'pie',
radius: ['58%', '76%'],
center: ['34%', '50%'],
avoidLabelOverlap: true,
label: {
show: true,
position: 'center',
formatter: `总用量\n{total|${regionTotal.value} ${selectedEnergyUnit.value}}`,
color: '#303133',
rich: {
total: {
fontSize: 20,
lineHeight: 34,
fontWeight: 700,
color: '#17233d'
}
}
},
labelLine: { show: false },
data: regionItems.value
}
]
})
const metricThemeMap: Record<string, { icon: string; theme: string }> = {
total: { icon: 'ep:lightning', theme: 'blue' },
deviceCount: { icon: 'ep:cpu', theme: 'green' },
topDevice: { icon: 'ep:histogram', theme: 'purple' },
topRegion: { icon: 'ep:pie-chart', theme: 'cyan' },
range: { icon: 'ep:calendar', theme: 'orange' }
}
const normalizeMetricCards = (metrics: EnergyOverviewMetricVO[]) => {
metricCards.value = metrics.map((item) => ({
key: item.key,
label: item.label,
value: item.value,
unit: item.unit || '',
icon: metricThemeMap[item.key]?.icon || 'ep:data-line',
theme: metricThemeMap[item.key]?.theme || 'blue',
subLabel: item.subLabel || '',
subValue: item.subValue || '',
change: item.change,
down: item.down
}))
}
const updateCharts = (data: EnergyOverviewRespVO) => {
trendXAxis.value = data.trendChart?.xAxis || []
trendSeries.value = (data.trendChart?.data || []).map((item) => Number(item) || 0)
regionItems.value = (data.regionChart?.items || []).map((item) => ({
name: item.name,
value: Number(item.value) || 0,
percent: item.percent
}))
regionTotal.value = data.regionChart?.totalValue || '0'
trendChartOptions.xAxis = {
...(trendChartOptions.xAxis as any),
data: trendXAxis.value
}
trendChartOptions.yAxis = {
...(trendChartOptions.yAxis as any),
name: selectedEnergyUnit.value
}
if (Array.isArray(trendChartOptions.series) && trendChartOptions.series[0]) {
;(trendChartOptions.series[0] as any).data = trendSeries.value
}
if (Array.isArray(regionPieOptions.series) && regionPieOptions.series[0]) {
;(regionPieOptions.series[0] as any).data = regionItems.value
;(regionPieOptions.series[0] as any).label = {
...(regionPieOptions.series[0] as any).label,
formatter: `总用量\n{total|${regionTotal.value} ${selectedEnergyUnit.value}}`
}
}
}
const getList = async () => {
if (!hasEnergyTypes.value) {
metricCards.value = []
rankList.value = []
pageDetailList.value = []
total.value = 0
return
}
loading.value = true
try {
const timeRange = queryParams.timeRange?.length === 2 ? queryParams.timeRange : defaultTimeRange
const res = await EnergyDeviceApi.queryOverviewData({
orgId: queryParams.orgId,
energyTypeId: queryParams.energyTypeId,
startTime: timeRange[0],
endTime: timeRange[1],
pageNo: queryParams.pageNo,
pageSize: queryParams.pageSize
})
const data = ((res as any)?.data || res) as EnergyOverviewRespVO
normalizeMetricCards(data.metrics || [])
rankList.value = data.rankList || []
pageDetailList.value = data.detailList || []
total.value = data.total || 0
updateCharts(data)
} finally {
loading.value = false
}
}
const normalizeEnergyTypeList = (data: unknown): EnergyTypeVO[] => {
const maybeData = data as { data?: EnergyTypeVO[] }
if (Array.isArray(data)) {
return data as EnergyTypeVO[]
}
if (Array.isArray(maybeData?.data)) {
return maybeData.data
}
return []
}
const getDefaultEnergyType = (list: EnergyTypeVO[]) => list[0]
const getEnergyTypes = async () => {
try {
const data = await EnergyTypeApi.getEnergyTypeList()
energyTypeOptions.value = normalizeEnergyTypeList(data)
} catch (error) {
energyTypeOptions.value = []
}
const defaultEnergyType = getDefaultEnergyType(energyTypeOptions.value)
defaultEnergyTypeId.value = defaultEnergyType?.id
queryParams.energyTypeId = defaultEnergyType?.id
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
queryParams.orgId = undefined
queryParams.energyTypeId = defaultEnergyTypeId.value
queryParams.timeRange = [...defaultTimeRange]
handleQuery()
}
const handleExport = async () => {
const timeRange = queryParams.timeRange?.length === 2 ? queryParams.timeRange : defaultTimeRange
await EnergyDeviceApi.exportQueryDataRecords({
orgId: queryParams.orgId,
startTime: timeRange[0],
endTime: timeRange[1]
})
message.success('导出已发起')
}
const loadOrgOptions = async () => {
if (orgOptions.value.length) return
const data: any = await OrganizationApi.getOrganizationList({})
const rows = (Array.isArray(data) ? data : data?.list ?? data?.data ?? []) as OrganizationVO[]
orgOptions.value = rows
.filter((r) => r?.id !== undefined && r?.name)
.map((r) => ({ label: r.name, value: r.id, raw: r }))
}
onMounted(async () => {
await loadOrgOptions()
await getEnergyTypes()
if (hasEnergyTypes.value) {
getList()
}
})
</script>
<style scoped lang="scss">
.energy-overview-page {
.energy-overview-filter {
:deep(.el-form-item) {
margin-bottom: 15px;
}
}
}
.metric-grid {
display: grid;
grid-template-columns: repeat(5, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.metric-card {
display: flex;
min-height: 118px;
padding: 24px 22px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
box-shadow: 0 1px 2px rgb(0 0 0 / 3%);
align-items: center;
gap: 18px;
}
.metric-icon {
display: flex;
width: 52px;
height: 52px;
font-size: 28px;
color: #fff;
border-radius: 50%;
align-items: center;
justify-content: center;
flex: 0 0 auto;
&.blue {
background: linear-gradient(135deg, #60a5fa, #2563eb);
}
&.green {
background: linear-gradient(135deg, #7ad957, #30b83f);
}
&.purple {
background: linear-gradient(135deg, #a78bfa, #7c3aed);
}
&.orange {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
}
&.cyan {
background: linear-gradient(135deg, #2dd4bf, #14b8a6);
}
}
.metric-content {
min-width: 0;
}
.metric-label {
margin-bottom: 8px;
font-size: 14px;
color: var(--el-text-color-regular);
}
.metric-value {
overflow: hidden;
font-size: 26px;
font-weight: 700;
line-height: 1.25;
color: var(--el-text-color-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-unit {
margin-left: 4px;
font-size: 13px;
font-weight: 600;
}
.metric-sub {
display: flex;
min-height: 22px;
margin-top: 8px;
font-size: 13px;
color: var(--el-text-color-regular);
align-items: center;
gap: 5px;
}
.metric-change {
display: inline-flex;
color: var(--el-color-danger);
align-items: center;
gap: 2px;
&.down {
color: var(--el-color-success);
}
}
.chart-row,
.detail-row {
margin-bottom: 12px;
}
.overview-panel {
margin-bottom: 0;
}
.empty-panel {
display: flex;
min-height: 520px;
margin-bottom: 0;
align-items: center;
justify-content: center;
}
.panel-header {
display: flex;
height: 32px;
margin-bottom: 8px;
align-items: center;
justify-content: space-between;
}
.panel-title {
display: inline-flex;
font-size: 16px;
font-weight: 700;
color: var(--el-text-color-primary);
align-items: center;
gap: 6px;
}
.panel-info {
font-size: 15px;
color: var(--el-color-primary-light-3);
}
.rank-index {
display: inline-flex;
width: 22px;
height: 22px;
font-size: 12px;
font-weight: 700;
color: #606266;
background: #eef1f6;
border-radius: 50%;
align-items: center;
justify-content: center;
&.rank-1 {
color: #fff;
background: #f5a400;
}
&.rank-2 {
color: #fff;
background: #aab2bf;
}
&.rank-3 {
color: #fff;
background: #d99458;
}
}
@media (max-width: 1400px) {
.metric-grid {
grid-template-columns: repeat(3, minmax(180px, 1fr));
}
}
@media (max-width: 900px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(160px, 1fr));
}
}
@media (max-width: 640px) {
.metric-grid {
grid-template-columns: 1fr;
}
.metric-card {
padding: 18px;
}
}
</style>