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

801 lines
23 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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="t('EnergyOverview.filters.org')" prop="orgId">
<el-select
v-model="queryParams.orgId"
clearable
filterable
:placeholder="t('EnergyOverview.filters.orgPlaceholder')"
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="t('EnergyOverview.filters.energyType')" prop="energyTypeId">
<el-select
v-model="queryParams.energyTypeId"
:disabled="!hasEnergyTypes"
:placeholder="t('EnergyOverview.filters.energyTypePlaceholder')"
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="t('EnergyOverview.filters.timeRange')" 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="t('common.startTimeText')"
:end-placeholder="t('common.endTimeText')"
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" />
{{ t('common.query') }}
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" />
{{ t('common.reset') }}
</el-button>
<!-- <el-button type="success" plain @click="handleExport">
<Icon icon="ep:download" class="mr-5px" />
{{ t('action.export') }}
</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">
{{ t('EnergyOverview.panels.trend') }}
<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">
{{ t('EnergyOverview.panels.region') }}
<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">
{{ t('EnergyOverview.panels.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="t('EnergyOverview.table.rank')" 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="t('EnergyOverview.table.name')" prop="name" min-width="150" />
<el-table-column :label="t('EnergyOverview.table.region')" prop="region" width="110" />
<el-table-column
:label="`${t('EnergyOverview.table.usage')} (${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">
{{ t('EnergyOverview.panels.detail') }}
<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="t('EnergyOverview.table.meterName')" prop="name" min-width="170" />
<el-table-column :label="t('EnergyOverview.table.energyType')" prop="energyType" align="center" width="100" />
<el-table-column :label="t('EnergyOverview.table.region')" prop="region" align="center" width="120" />
<el-table-column
:label="`${t('EnergyOverview.table.energyUsage')} (${selectedEnergyUnit})`"
prop="value"
align="right"
width="150"
/>
<el-table-column :label="t('EnergyOverview.table.startTime')" prop="startTime" align="center" width="180" />
<el-table-column :label="t('EnergyOverview.table.endTime')" 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="t('EnergyOverview.empty')" :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 { t } = useI18n()
const message = useMessage()
const getTodayTimeRange = () => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const date = `${year}-${month}-${day}`
return [`${date} 00:00:00`, `${date} 23:59:59`]
}
const parseRangeTime = (value?: string) => {
if (!value) return undefined
const date = new Date(value.replace(/-/g, '/'))
return Number.isNaN(date.getTime()) ? undefined : date
}
const isSingleDayRange = (timeRange?: string[]) => {
if (!timeRange || timeRange.length !== 2) return false
const start = parseRangeTime(timeRange[0])
const end = parseRangeTime(timeRange[1])
if (!start || !end) return false
return (
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate()
)
}
const defaultTimeRange = getTodayTimeRange()
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 />${t('EnergyOverview.chart.usage')}${item?.data || 0} ${selectedEnergyUnit.value}`
}
},
legend: {
top: 4,
data: [t('EnergyOverview.chart.usage')]
},
grid: {
left: 24,
right: 24,
top: 46,
bottom: 32,
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
name: t('EnergyOverview.chart.time'),
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: t('EnergyOverview.chart.usage'),
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: t('EnergyOverview.chart.regionEnergy'),
type: 'pie',
radius: ['58%', '76%'],
center: ['34%', '50%'],
avoidLabelOverlap: true,
label: {
show: true,
position: 'center',
formatter: `${t('EnergyOverview.chart.totalUsage')}\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 buildFallbackTrendXAxis = () => {
const timeRange = queryParams.timeRange?.length === 2 ? queryParams.timeRange : defaultTimeRange
const [startTime, endTime] = timeRange
const start = new Date(startTime.replace(/-/g, '/'))
const end = new Date(endTime.replace(/-/g, '/'))
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start > end) {
return []
}
const sameDay =
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate()
const axis: string[] = []
if (sameDay) {
const current = new Date(start)
current.setMinutes(0, 0, 0)
const limit = new Date(end)
limit.setMinutes(0, 0, 0)
while (current <= limit) {
axis.push(`${String(current.getHours()).padStart(2, '0')}:00`)
current.setHours(current.getHours() + 1)
}
return axis
}
const current = new Date(start)
current.setHours(0, 0, 0, 0)
const limit = new Date(end)
limit.setHours(0, 0, 0, 0)
while (current <= limit) {
const year = current.getFullYear()
const month = String(current.getMonth() + 1).padStart(2, '0')
const day = String(current.getDate()).padStart(2, '0')
axis.push(`${year}-${month}-${day}`)
current.setDate(current.getDate() + 1)
}
return axis
}
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) => {
const fallbackXAxis = buildFallbackTrendXAxis()
trendXAxis.value = data.trendChart?.xAxis?.length ? data.trendChart.xAxis : fallbackXAxis
const responseSeries = (data.trendChart?.data || []).map((item) => Number(item) || 0)
trendSeries.value =
responseSeries.length > 0
? responseSeries
: new Array(trendXAxis.value.length).fill(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,
name: t('EnergyOverview.chart.time')
}
trendChartOptions.yAxis = {
...(trendChartOptions.yAxis as any),
name: selectedEnergyUnit.value
}
if (Array.isArray(trendChartOptions.legend?.data)) {
trendChartOptions.legend.data = [t('EnergyOverview.chart.usage')]
}
if (Array.isArray(trendChartOptions.series) && trendChartOptions.series[0]) {
;(trendChartOptions.series[0] as any).name = t('EnergyOverview.chart.usage')
;(trendChartOptions.series[0] as any).data = trendSeries.value
}
if (Array.isArray(regionPieOptions.series) && regionPieOptions.series[0]) {
;(regionPieOptions.series[0] as any).name = t('EnergyOverview.chart.regionEnergy')
;(regionPieOptions.series[0] as any).data = regionItems.value
;(regionPieOptions.series[0] as any).label = {
...(regionPieOptions.series[0] as any).label,
formatter: `${t('EnergyOverview.chart.totalUsage')}\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({ orgId: queryParams.orgId })
energyTypeOptions.value = normalizeEnergyTypeList(data)
} catch (error) {
energyTypeOptions.value = []
}
let defaultEnergyType = getDefaultEnergyType(energyTypeOptions.value)
try {
const deviceRes = await EnergyDeviceApi.getList({ orgId: queryParams.orgId })
const devices = ((deviceRes as any)?.data || deviceRes || []) as Array<{ deviceTypeId?: number }>
const deviceTypeIds = new Set(devices.map((item) => item.deviceTypeId).filter(Boolean))
defaultEnergyType =
energyTypeOptions.value.find((item) => deviceTypeIds.has(item.id)) || defaultEnergyType
} catch (error) {
// ignore and use the first energy type
}
defaultEnergyTypeId.value = defaultEnergyType?.id
queryParams.energyTypeId = defaultEnergyType?.id
}
const handleQuery = () => {
if (!isSingleDayRange(queryParams.timeRange)) {
message.warning(t('EnergyOverview.messages.singleDayOnly'))
return
}
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
queryParams.orgId = undefined
queryParams.energyTypeId = defaultEnergyTypeId.value
queryParams.timeRange = getTodayTimeRange()
handleQuery()
}
const handleExport = async () => {
if (!isSingleDayRange(queryParams.timeRange)) {
message.warning(t('EnergyOverview.messages.singleDayOnly'))
return
}
const timeRange = queryParams.timeRange?.length === 2 ? queryParams.timeRange : defaultTimeRange
await EnergyDeviceApi.exportQueryDataRecords({
orgId: queryParams.orgId,
startTime: timeRange[0],
endTime: timeRange[1]
})
message.success(t('EnergyOverview.exportSuccess'))
}
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()
}
})
watch(
() => queryParams.orgId,
async () => {
await getEnergyTypes()
queryParams.pageNo = 1
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>