!764 针对 iothome 页面上次codereview的优化

Merge pull request !764 from alwayssuper/feature/iot
pull/790/head
芋道源码 2025-06-14 07:00:25 +00:00 committed by Gitee
commit 108782ba80
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
8 changed files with 775 additions and 457 deletions

View File

@ -1397,42 +1397,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.0':
resolution: {integrity: sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==, tarball: https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.0':
resolution: {integrity: sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==, tarball: https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.0':
resolution: {integrity: sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==, tarball: https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.0':
resolution: {integrity: sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==, tarball: https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.0':
resolution: {integrity: sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==, tarball: https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.0':
resolution: {integrity: sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==, tarball: https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz}
@ -1536,55 +1530,46 @@ packages:
resolution: {integrity: sha512-9OwUnK/xKw6DyRlgx8UizeqRFOfi9mf5TYCw1uolDaJSbUmBxP85DE6T4ouCMoN6pXw8ZoTeZCSEfSaYo+/s1w==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.27.4.tgz}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.27.4':
resolution: {integrity: sha512-Vgdo4fpuphS9V24WOV+KwkCVJ72u7idTgQaBoLRD0UxBAWTF9GWurJO9YD9yh00BzbkhpeXtm6na+MvJU7Z73A==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.27.4.tgz}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.27.4':
resolution: {integrity: sha512-pleyNgyd1kkBkw2kOqlBx+0atfIIkkExOTiifoODo6qKDSpnc6WzUY5RhHdmTdIJXBdSnh6JknnYTtmQyobrVg==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.27.4.tgz}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.27.4':
resolution: {integrity: sha512-caluiUXvUuVyCHr5DxL8ohaaFFzPGmgmMvwmqAITMpV/Q+tPoaHZ/PWa3t8B2WyoRcIIuu1hkaW5KkeTDNSnMA==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.27.4.tgz}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-powerpc64le-gnu@4.27.4':
resolution: {integrity: sha512-FScrpHrO60hARyHh7s1zHE97u0KlT/RECzCKAdmI+LEoC1eDh/RDji9JgFqyO+wPDb86Oa/sXkily1+oi4FzJQ==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.27.4.tgz}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.27.4':
resolution: {integrity: sha512-qyyprhyGb7+RBfMPeww9FlHwKkCXdKHeGgSqmIXw9VSUtvyFZ6WZRtnxgbuz76FK7LyoN8t/eINRbPUcvXB5fw==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.27.4.tgz}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.27.4':
resolution: {integrity: sha512-PFz+y2kb6tbh7m3A7nA9++eInGcDVZUACulf/KzDtovvdTizHpZaJty7Gp0lFwSQcrnebHOqxF1MaKZd7psVRg==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.27.4.tgz}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.27.4':
resolution: {integrity: sha512-Ni8mMtfo+o/G7DVtweXXV/Ol2TFf63KYjTtoZ5f078AUgJTmaIJnj4JFU7TK/9SVWTaSJGxPi5zMDgK4w+Ez7Q==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.27.4.tgz}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.27.4':
resolution: {integrity: sha512-5AeeAF1PB9TUzD+3cROzFTnAJAcVUGLuR8ng0E0WXGkYhp6RD6L+6szYVX+64Rs0r72019KHZS1ka1q+zU/wUw==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.27.4.tgz}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.27.4':
resolution: {integrity: sha512-yOpVsA4K5qVwu2CaS3hHxluWIK5HQTjNV4tWjQXluMiiiu4pJj4BN98CvxohNCpcjMeTXk/ZMJBRbgRg8HBB6A==, tarball: https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.27.4.tgz}
@ -1630,28 +1615,24 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.9.3':
resolution: {integrity: sha512-tzVH480RY6RbMl/QRgh5HK3zn1ZTFsThuxDGo6Iuk1MdwIbdFYUY034heWUTI4u3Db97ArKh0hNL0xhO3+PZdg==, tarball: https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.9.3.tgz}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/core-linux-x64-gnu@1.9.3':
resolution: {integrity: sha512-ivXXBRDXDc9k4cdv10R21ccBmGebVOwKXT/UdH1PhxUn9m/h8erAWjz5pcELwjiMf27WokqPgaWVfaclDbgE+w==, tarball: https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.9.3.tgz}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.9.3':
resolution: {integrity: sha512-ILsGMgfnOz1HwdDz+ZgEuomIwkP1PHT6maigZxaCIuC6OPEhKE8uYna22uU63XvYcLQvZYDzpR3ms47WQPuNEg==, tarball: https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.9.3.tgz}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.9.3':
resolution: {integrity: sha512-e+XmltDVIHieUnNJHtspn6B+PCcFOMYXNJB1GqoCcyinkEIQNwC8KtWgMqUucUbEWJkPc35NHy9k8aCXRmw9Kg==, tarball: https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.9.3.tgz}

View File

@ -16,10 +16,16 @@ export interface IotStatisticsSummaryRespVO {
productCategoryDeviceCounts: Record<string, number>
}
/** 时间戳-数值的键值对类型 */
interface TimeValueItem {
[key: string]: number
}
/** IoT 消息统计数据类型 */
export interface IotStatisticsDeviceMessageSummaryRespVO {
upstreamCounts: Record<number, number>
downstreamCounts: Record<number, number>
statType: number
upstreamCounts: TimeValueItem[]
downstreamCounts: TimeValueItem[]
}
// IoT 数据统计 API

View File

@ -330,3 +330,30 @@ export function getDateRange(
dayjs(endDate).endOf('d').format('YYYY-MM-DD HH:mm:ss')
]
}
/**
*
* @param hours
* @returns
*/
export function getHoursAgo(hours: number): number {
return dayjs().subtract(hours, 'hour').valueOf()
}
/**
*
* @param range '8h' | '24h' | '7d'
* @returns
*/
export function getTimeRangeStart(range: '8h' | '24h' | '7d'): number {
switch (range) {
case '8h':
return getHoursAgo(8)
case '24h':
return getHoursAgo(24)
case '7d':
return dayjs().subtract(7, 'day').valueOf()
default:
return dayjs().valueOf()
}
}

View File

@ -0,0 +1,50 @@
<template>
<el-card class="stat-card" shadow="never" :loading="loading">
<div class="flex flex-col">
<div class="flex justify-between items-center mb-1">
<span class="text-gray-500 text-base font-medium">{{ title }}</span>
<Icon :icon="icon" :class="`text-[32px] ${iconColor}`" />
</div>
<span class="text-3xl font-bold text-gray-700">
<span v-if="value === -1">--</span>
<span v-else>{{ value }}</span>
</span>
<el-divider class="my-2" />
<div class="flex justify-between items-center text-gray-400 text-sm">
<span>今日新增</span>
<span class="text-green-500" v-if="todayCount !== -1">+{{ todayCount }}</span>
<span v-else>--</span>
</div>
</div>
</el-card>
</template>
<script lang="ts" setup>
import { propTypes } from '@/utils/propTypes'
/** 统计卡片组件 */
defineOptions({ name: 'ComparisonCard' })
const props = defineProps({
title: propTypes.string.def('').isRequired,
value: propTypes.number.def(0).isRequired,
todayCount: propTypes.number.def(0).isRequired,
icon: propTypes.string.def('').isRequired,
iconColor: propTypes.string.def(''),
loading: {
type: Boolean,
default: false
}
})
</script>
<style lang="scss" scoped>
.stat-card {
transition: all 0.3s;
&:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgb(0 0 0 / 8%);
}
}
</style>

View File

@ -0,0 +1,127 @@
<template>
<el-card class="chart-card" shadow="never" :loading="loading">
<template #header>
<div class="flex items-center">
<span class="text-base font-medium text-gray-600">设备数量统计</span>
</div>
</template>
<div v-if="loading && !hasData" class="h-[240px] flex justify-center items-center">
<el-empty description="加载中..." />
</div>
<div v-else-if="!hasData" class="h-[240px] flex justify-center items-center">
<el-empty description="暂无数据" />
</div>
<div v-else ref="deviceCountChartRef" class="h-[240px]"></div>
</el-card>
</template>
<script lang="ts" setup>
import * as echarts from 'echarts/core'
import { PieChart } from 'echarts/charts'
import { CanvasRenderer } from 'echarts/renderers'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import { LabelLayout } from 'echarts/features'
import { IotStatisticsSummaryRespVO } from '@/api/iot/statistics'
import type { PropType } from 'vue'
/** 设备数量统计卡片 */
defineOptions({ name: 'DeviceCountCard' })
const props = defineProps({
statsData: {
type: Object as PropType<IotStatisticsSummaryRespVO>,
required: true
},
loading: {
type: Boolean,
default: false
}
})
const deviceCountChartRef = ref()
//
const hasData = computed(() => {
if (!props.statsData) return false
const categories = Object.entries(props.statsData.productCategoryDeviceCounts || {})
return categories.length > 0 && props.statsData.deviceCount !== -1
})
//
const initChart = () => {
//
if (!hasData.value) return
// DOM
if (!deviceCountChartRef.value) {
console.warn('图表DOM元素不存在')
return
}
echarts.use([TooltipComponent, LegendComponent, PieChart, CanvasRenderer, LabelLayout])
try {
const chart = echarts.init(deviceCountChartRef.value)
chart.setOption({
tooltip: {
trigger: 'item'
},
legend: {
top: '5%',
right: '10%',
align: 'left',
orient: 'vertical',
icon: 'circle'
},
series: [
{
name: 'Access From',
type: 'pie',
radius: ['50%', '80%'],
avoidLabelOverlap: false,
center: ['30%', '50%'],
label: {
show: false,
position: 'outside'
},
emphasis: {
label: {
show: true,
fontSize: 20,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: Object.entries(props.statsData.productCategoryDeviceCounts).map(([name, value]) => ({
name,
value
}))
}
]
})
return chart
} catch (error) {
console.error('初始化图表失败:', error)
return null
}
}
//
watch(() => props.statsData, () => {
// 使 nextTick DOM
nextTick(() => {
initChart()
})
}, { deep: true })
//
onMounted(() => {
// 使 nextTick DOM
nextTick(() => {
initChart()
})
})
</script>

View File

@ -0,0 +1,162 @@
<template>
<el-card class="chart-card" shadow="never" :loading="loading">
<template #header>
<div class="flex items-center">
<span class="text-base font-medium text-gray-600">设备状态统计</span>
</div>
</template>
<div v-if="loading && !hasData" class="h-[240px] flex justify-center items-center">
<el-empty description="加载中..." />
</div>
<div v-else-if="!hasData" class="h-[240px] flex justify-center items-center">
<el-empty description="暂无数据" />
</div>
<el-row v-else class="h-[240px]">
<el-col :span="8" class="flex flex-col items-center">
<div ref="deviceOnlineCountChartRef" class="h-[160px] w-full"></div>
<div class="text-center mt-2">
<span class="text-sm text-gray-600">在线设备</span>
</div>
</el-col>
<el-col :span="8" class="flex flex-col items-center">
<div ref="deviceOfflineChartRef" class="h-[160px] w-full"></div>
<div class="text-center mt-2">
<span class="text-sm text-gray-600">离线设备</span>
</div>
</el-col>
<el-col :span="8" class="flex flex-col items-center">
<div ref="deviceActiveChartRef" class="h-[160px] w-full"></div>
<div class="text-center mt-2">
<span class="text-sm text-gray-600">待激活设备</span>
</div>
</el-col>
</el-row>
</el-card>
</template>
<script lang="ts" setup>
import * as echarts from 'echarts/core'
import { GaugeChart } from 'echarts/charts'
import { CanvasRenderer } from 'echarts/renderers'
import { IotStatisticsSummaryRespVO } from '@/api/iot/statistics'
import type { PropType } from 'vue'
/** 设备状态统计卡片 */
defineOptions({ name: 'DeviceStateCountCard' })
const props = defineProps({
statsData: {
type: Object as PropType<IotStatisticsSummaryRespVO>,
required: true
},
loading: {
type: Boolean,
default: false
}
})
const deviceOnlineCountChartRef = ref()
const deviceOfflineChartRef = ref()
const deviceActiveChartRef = ref()
//
const hasData = computed(() => {
if (!props.statsData) return false
return props.statsData.deviceCount !== -1
})
//
const initGaugeChart = (el: any, value: number, color: string) => {
// DOM
if (!el) {
console.warn('图表DOM元素不存在')
return
}
echarts.use([GaugeChart, CanvasRenderer])
try {
const chart = echarts.init(el)
chart.setOption({
series: [
{
type: 'gauge',
startAngle: 360,
endAngle: 0,
min: 0,
max: props.statsData.deviceCount || 100, // 使
progress: {
show: true,
width: 12,
itemStyle: {
color: color
}
},
axisLine: {
lineStyle: {
width: 12,
color: [[1, '#E5E7EB']]
}
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
pointer: { show: false },
anchor: { show: false },
title: { show: false },
detail: {
valueAnimation: true,
fontSize: 24,
fontWeight: 'bold',
fontFamily: 'Inter, sans-serif',
color: color,
offsetCenter: [0, '0'],
formatter: (value: number) => {
return `${value}`
}
},
data: [{ value: value }]
}
]
})
return chart
} catch (error) {
console.error('初始化图表失败:', error)
return null
}
}
//
const initCharts = () => {
//
if (!hasData.value) return
// 使 nextTick DOM
nextTick(() => {
// 线
if (deviceOnlineCountChartRef.value) {
initGaugeChart(deviceOnlineCountChartRef.value, props.statsData.deviceOnlineCount, '#0d9')
}
// 线
if (deviceOfflineChartRef.value) {
initGaugeChart(deviceOfflineChartRef.value, props.statsData.deviceOfflineCount, '#f50')
}
//
if (deviceActiveChartRef.value) {
initGaugeChart(deviceActiveChartRef.value, props.statsData.deviceInactiveCount, '#05b')
}
})
}
//
watch(() => props.statsData, () => {
initCharts()
}, { deep: true })
//
onMounted(() => {
initCharts()
})
</script>

View File

@ -0,0 +1,325 @@
<template>
<el-card class="chart-card" shadow="never" :loading="loading">
<template #header>
<div class="flex items-center justify-between">
<span class="text-base font-medium text-gray-600">
上下行消息量统计
<span class="text-sm text-gray-400 ml-2">
{{ props.messageStats.statType === 1 ? '(按天)' : '(按小时)' }}
</span>
</span>
<div class="flex items-center space-x-2">
<el-radio-group v-model="timeRange" @change="handleTimeRangeChange">
<el-radio-button label="8h">最近8小时</el-radio-button>
<el-radio-button label="24h">最近24小时</el-radio-button>
<el-radio-button label="7d">近一周</el-radio-button>
</el-radio-group>
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
:default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
@change="handleDateRangeChange"
/>
</div>
</div>
</template>
<div v-if="loading && !hasData" class="h-[300px] flex justify-center items-center">
<el-empty description="加载中..." />
</div>
<div v-else-if="!hasData" class="h-[300px] flex justify-center items-center">
<el-empty description="暂无数据" />
</div>
<div v-else ref="messageChartRef" class="h-[300px]"></div>
</el-card>
</template>
<script lang="ts" setup>
import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { CanvasRenderer } from 'echarts/renderers'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { UniversalTransition } from 'echarts/features'
import { IotStatisticsDeviceMessageSummaryRespVO } from '@/api/iot/statistics'
import { formatDate, getTimeRangeStart } from '@/utils/formatTime'
import type { PropType } from 'vue'
import dayjs from 'dayjs'
/** 消息趋势统计卡片 */
defineOptions({ name: 'MessageTrendCard' })
const props = defineProps({
messageStats: {
type: Object as PropType<IotStatisticsDeviceMessageSummaryRespVO>,
required: true
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['timeRangeChange'])
const timeRange = ref('7d')
const dateRange = ref<any>(null)
const messageChartRef = ref()
//
const hasData = computed(() => {
if (!props.messageStats) return false
const upstreamCounts = Array.isArray(props.messageStats.upstreamCounts)
? props.messageStats.upstreamCounts
: []
const downstreamCounts = Array.isArray(props.messageStats.downstreamCounts)
? props.messageStats.downstreamCounts
: []
return upstreamCounts.length > 0 || downstreamCounts.length > 0
})
// TODO @super dayjs 1h24h7d utils/formatTime.ts
//
const handleTimeRangeChange = (range: string) => {
const now = dayjs().valueOf()
const startTime = getTimeRangeStart(range as '8h' | '24h' | '7d')
dateRange.value = null
emit('timeRangeChange', { startTime, endTime: now })
}
//
const handleDateRangeChange = (value: [Date, Date] | null) => {
if (value) {
timeRange.value = ''
emit('timeRangeChange', {
startTime: value[0].getTime(),
endTime: value[1].getTime()
})
}
}
//
const initChart = () => {
echarts.use([
LineChart,
CanvasRenderer,
GridComponent,
LegendComponent,
TooltipComponent,
UniversalTransition
])
//
if (!hasData.value) return
// DOM
if (!messageChartRef.value) {
console.warn('图表DOM元素不存在')
return
}
//
const upstreamCounts = Array.isArray(props.messageStats.upstreamCounts)
? props.messageStats.upstreamCounts
: Object.entries(props.messageStats.upstreamCounts || {}).map(([key, value]) => ({ [key]: value }))
const downstreamCounts = Array.isArray(props.messageStats.downstreamCounts)
? props.messageStats.downstreamCounts
: Object.entries(props.messageStats.downstreamCounts || {}).map(([key, value]) => ({ [key]: value }))
//
let timestamps: number[] = []
try {
//
if (Array.isArray(upstreamCounts) && upstreamCounts.length > 0) {
timestamps = Array.from(
new Set([
...upstreamCounts.map(item => Number(Object.keys(item)[0])),
...downstreamCounts.map(item => Number(Object.keys(item)[0]))
])
).sort((a, b) => a - b)
} else {
//
const upKeys = Object.keys(props.messageStats.upstreamCounts || {}).map(Number)
const downKeys = Object.keys(props.messageStats.downstreamCounts || {}).map(Number)
timestamps = Array.from(new Set([...upKeys, ...downKeys])).sort((a, b) => a - b)
}
} catch (error) {
console.error('提取时间戳出错:', error)
timestamps = []
}
console.log('时间戳:', timestamps)
// - statType
const xdata = timestamps.map((ts) => {
// statType
if (props.messageStats.statType === 1) {
// - 使 YYYY-MM-DD
return formatDate(dayjs(ts).toDate(), 'YYYY-MM-DD')
} else {
// - 使 YYYY-MM-DD HH:mm
return formatDate(dayjs(ts).toDate(), 'YYYY-MM-DD HH:mm')
}
})
let upData: number[] = []
let downData: number[] = []
try {
//
if (Array.isArray(upstreamCounts) && upstreamCounts.length > 0) {
upData = timestamps.map((ts) => {
const item = upstreamCounts.find(count =>
Number(Object.keys(count)[0]) === ts
)
return item ? Number(Object.values(item)[0]) : 0
})
downData = timestamps.map((ts) => {
const item = downstreamCounts.find(count =>
Number(Object.keys(count)[0]) === ts
)
return item ? Number(Object.values(item)[0]) : 0
})
} else {
//
const upstreamObj = props.messageStats.upstreamCounts || {}
const downstreamObj = props.messageStats.downstreamCounts || {}
upData = timestamps.map((ts) => Number(upstreamObj[ts as keyof typeof upstreamObj] || 0))
downData = timestamps.map((ts) => Number(downstreamObj[ts as keyof typeof downstreamObj] || 0))
}
} catch (error) {
console.error('提取数据出错:', error)
upData = []
downData = []
}
//
try {
const chart = echarts.init(messageChartRef.value)
chart.setOption({
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderColor: '#E5E7EB',
textStyle: {
color: '#374151'
}
},
legend: {
data: ['上行消息量', '下行消息量'],
textStyle: {
color: '#374151',
fontWeight: 500
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: xdata,
axisLine: {
lineStyle: {
color: '#E5E7EB'
}
},
axisLabel: {
color: '#6B7280'
}
},
yAxis: {
type: 'value',
axisLine: {
lineStyle: {
color: '#E5E7EB'
}
},
axisLabel: {
color: '#6B7280'
},
splitLine: {
lineStyle: {
color: '#F3F4F6'
}
}
},
series: [
{
name: '上行消息量',
type: 'line',
smooth: true,
data: upData,
itemStyle: {
color: '#3B82F6'
},
lineStyle: {
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(59, 130, 246, 0.2)' },
{ offset: 1, color: 'rgba(59, 130, 246, 0)' }
])
}
},
{
name: '下行消息量',
type: 'line',
smooth: true,
data: downData,
itemStyle: {
color: '#10B981'
},
lineStyle: {
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(16, 185, 129, 0.2)' },
{ offset: 1, color: 'rgba(16, 185, 129, 0)' }
])
}
}
]
})
return chart
} catch (error) {
console.error('初始化图表失败:', error)
return null
}
}
//
watch(
() => props.messageStats,
() => {
// 使 nextTick DOM
nextTick(() => {
initChart()
})
},
{ deep: true }
)
//
onMounted(() => {
// 使 nextTick DOM
nextTick(() => {
initChart()
})
})
</script>

View File

@ -2,145 +2,65 @@
<!-- 第一行统计卡片行 -->
<el-row :gutter="16" class="mb-4">
<el-col :span="6">
<el-card class="stat-card" shadow="never">
<div class="flex flex-col">
<div class="flex justify-between items-center mb-1">
<span class="text-gray-500 text-base font-medium">分类数量</span>
<Icon icon="ep:menu" class="text-[32px] text-blue-400" />
</div>
<span class="text-3xl font-bold text-gray-700">
{{ statsData.productCategoryCount }}
</span>
<el-divider class="my-2" />
<div class="flex justify-between items-center text-gray-400 text-sm">
<span>今日新增</span>
<span class="text-green-500">+{{ statsData.productCategoryTodayCount }}</span>
</div>
</div>
</el-card>
<ComparisonCard
title="分类数量"
:value="statsData.productCategoryCount"
:todayCount="statsData.productCategoryTodayCount"
icon="ep:menu"
iconColor="text-blue-400"
:loading="loading"
/>
</el-col>
<el-col :span="6">
<el-card class="stat-card" shadow="never">
<div class="flex flex-col">
<div class="flex justify-between items-center mb-1">
<span class="text-gray-500 text-base font-medium">产品数量</span>
<Icon icon="ep:box" class="text-[32px] text-orange-400" />
</div>
<span class="text-3xl font-bold text-gray-700">{{ statsData.productCount }}</span>
<el-divider class="my-2" />
<div class="flex justify-between items-center text-gray-400 text-sm">
<span>今日新增</span>
<span class="text-green-500">+{{ statsData.productTodayCount }}</span>
</div>
</div>
</el-card>
<ComparisonCard
title="产品数量"
:value="statsData.productCount"
:todayCount="statsData.productTodayCount"
icon="ep:box"
iconColor="text-orange-400"
:loading="loading"
/>
</el-col>
<el-col :span="6">
<el-card class="stat-card" shadow="never">
<div class="flex flex-col">
<div class="flex justify-between items-center mb-1">
<span class="text-gray-500 text-base font-medium">设备数量</span>
<Icon icon="ep:cpu" class="text-[32px] text-purple-400" />
</div>
<span class="text-3xl font-bold text-gray-700">{{ statsData.deviceCount }}</span>
<el-divider class="my-2" />
<div class="flex justify-between items-center text-gray-400 text-sm">
<span>今日新增</span>
<span class="text-green-500">+{{ statsData.deviceTodayCount }}</span>
</div>
</div>
</el-card>
<ComparisonCard
title="设备数量"
:value="statsData.deviceCount"
:todayCount="statsData.deviceTodayCount"
icon="ep:cpu"
iconColor="text-purple-400"
:loading="loading"
/>
</el-col>
<el-col :span="6">
<el-card class="stat-card" shadow="never">
<div class="flex flex-col">
<div class="flex justify-between items-center mb-1">
<span class="text-gray-500 text-base font-medium">设备消息数</span>
<Icon icon="ep:message" class="text-[32px] text-teal-400" />
</div>
<span class="text-3xl font-bold text-gray-700">
{{ statsData.deviceMessageCount }}
</span>
<el-divider class="my-2" />
<div class="flex justify-between items-center text-gray-400 text-sm">
<span>今日新增</span>
<span class="text-green-500">+{{ statsData.deviceMessageTodayCount }}</span>
</div>
</div>
</el-card>
<ComparisonCard
title="设备消息数"
:value="statsData.deviceMessageCount"
:todayCount="statsData.deviceMessageTodayCount"
icon="ep:message"
iconColor="text-teal-400"
:loading="loading"
/>
</el-col>
</el-row>
<!-- 第二行图表行 -->
<el-row :gutter="16" class="mb-4">
<el-col :span="12">
<el-card class="chart-card" shadow="never">
<template #header>
<div class="flex items-center">
<span class="text-base font-medium text-gray-600">设备数量统计</span>
</div>
</template>
<div ref="deviceCountChartRef" class="h-[240px]"></div>
</el-card>
<DeviceCountCard :statsData="statsData" :loading="loading" />
</el-col>
<el-col :span="12">
<el-card class="chart-card" shadow="never">
<template #header>
<div class="flex items-center">
<span class="text-base font-medium text-gray-600">设备状态统计</span>
</div>
</template>
<el-row class="h-[240px]">
<el-col :span="8" class="flex flex-col items-center">
<div ref="deviceOnlineCountChartRef" class="h-[160px] w-full"></div>
<div class="text-center mt-2">
<span class="text-sm text-gray-600">在线设备</span>
</div>
</el-col>
<el-col :span="8" class="flex flex-col items-center">
<div ref="deviceOfflineChartRef" class="h-[160px] w-full"></div>
<div class="text-center mt-2">
<span class="text-sm text-gray-600">离线设备</span>
</div>
</el-col>
<el-col :span="8" class="flex flex-col items-center">
<div ref="deviceActiveChartRef" class="h-[160px] w-full"></div>
<div class="text-center mt-2">
<span class="text-sm text-gray-600">待激活设备</span>
</div>
</el-col>
</el-row>
</el-card>
<DeviceStateCountCard :statsData="statsData" :loading="loading" />
</el-col>
</el-row>
<!-- 第三行消息统计行 -->
<el-row>
<el-col :span="24">
<el-card class="chart-card" shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-base font-medium text-gray-600">上下行消息量统计</span>
<div class="flex items-center space-x-2">
<el-radio-group v-model="timeRange" @change="handleTimeRangeChange">
<el-radio-button label="1h">最近1小时</el-radio-button>
<el-radio-button label="24h">最近24小时</el-radio-button>
<el-radio-button label="7d">近一周</el-radio-button>
</el-radio-group>
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
:default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
@change="handleDateRangeChange"
/>
</div>
</div>
</template>
<div ref="deviceMessageCountChartRef" class="h-[300px]"></div>
</el-card>
<MessageTrendCard
:messageStats="messageStats"
@time-range-change="handleTimeRangeChange"
:loading="loading"
/>
</el-col>
</el-row>
@ -148,356 +68,76 @@
</template>
<script setup lang="ts" name="Index">
import * as echarts from 'echarts/core'
import {
GridComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent
} from 'echarts/components'
import { GaugeChart, LineChart, PieChart } from 'echarts/charts'
import { LabelLayout, UniversalTransition } from 'echarts/features'
import { CanvasRenderer } from 'echarts/renderers'
import {
IotStatisticsDeviceMessageSummaryRespVO,
IotStatisticsSummaryRespVO,
ProductCategoryApi
} from '@/api/iot/statistics'
import { formatDate } from '@/utils/formatTime'
// TODO @super /Users/yunai/Java/yudao-ui-admin-vue3/src/views/mall/home/index.vue
import { getHoursAgo } from '@/utils/formatTime'
import ComparisonCard from './components/ComparisonCard.vue'
import DeviceCountCard from './components/DeviceCountCard.vue'
import DeviceStateCountCard from './components/DeviceStateCountCard.vue'
import MessageTrendCard from './components/MessageTrendCard.vue'
/** IoT 首页 */
defineOptions({ name: 'IoTHome' })
// TODO @super使 Echart yudao-ui-admin-vue3/src/views/mall/home/components/TradeTrendCard.vue
echarts.use([
TooltipComponent,
LegendComponent,
PieChart,
CanvasRenderer,
LabelLayout,
TitleComponent,
ToolboxComponent,
GridComponent,
LineChart,
UniversalTransition,
GaugeChart
])
const timeRange = ref('7d') //
const dateRange = ref<[Date, Date] | null>(null)
const queryParams = reactive({
startTime: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7
startTime: getHoursAgo( 7 * 24 ), // 7
endTime: Date.now() //
})
const deviceCountChartRef = ref() //
const deviceOnlineCountChartRef = ref() // 线
const deviceOfflineChartRef = ref() // 线
const deviceActiveChartRef = ref() //
const deviceMessageCountChartRef = ref() //
//
// TODO @super -1 cursor
const statsData = ref<IotStatisticsSummaryRespVO>({
productCategoryCount: 0,
productCount: 0,
deviceCount: 0,
deviceMessageCount: 0,
productCategoryTodayCount: 0,
productTodayCount: 0,
deviceTodayCount: 0,
deviceMessageTodayCount: 0,
deviceOnlineCount: 0,
deviceOfflineCount: 0,
deviceInactiveCount: 0,
productCategoryCount: -1,
productCount: -1,
deviceCount: -1,
deviceMessageCount: -1,
productCategoryTodayCount: -1,
productTodayCount: -1,
deviceTodayCount: -1,
deviceMessageTodayCount: -1,
deviceOnlineCount: -1,
deviceOfflineCount: -1,
deviceInactiveCount: -1,
productCategoryDeviceCounts: {}
})
//
const messageStats = ref<IotStatisticsDeviceMessageSummaryRespVO>({
upstreamCounts: {},
downstreamCounts: {}
statType: 0,
upstreamCounts: [],
downstreamCounts: []
})
/** 处理快捷时间范围选择 */
const handleTimeRangeChange = (timeRange: string) => {
const now = Date.now()
let startTime: number
//
const loading = ref(true)
// TODO @super dayjs 1h24h7d utils/formatTime.ts
switch (timeRange) {
case '1h':
startTime = now - 60 * 60 * 1000
break
case '24h':
startTime = now - 24 * 60 * 60 * 1000
break
case '7d':
startTime = now - 7 * 24 * 60 * 60 * 1000
break
default:
return
}
//
dateRange.value = null
//
queryParams.startTime = startTime
queryParams.endTime = now
//
/** 处理时间范围变化 */
const handleTimeRangeChange = (params: { startTime: number; endTime: number }) => {
queryParams.startTime = params.startTime
queryParams.endTime = params.endTime
getStats()
}
/** 处理自定义日期范围选择 */
const handleDateRangeChange = (value: [Date, Date] | null) => {
if (value) {
//
timeRange.value = ''
//
queryParams.startTime = value[0].getTime()
queryParams.endTime = value[1].getTime()
//
getStats()
}
}
/** 获取统计数据 */
const getStats = async () => {
//
statsData.value = await ProductCategoryApi.getIotStatisticsSummary()
//
messageStats.value = await ProductCategoryApi.getIotStatisticsDeviceMessageSummary(queryParams)
//
initCharts()
}
/** 初始化图表 */
const initCharts = () => {
//
echarts.init(deviceCountChartRef.value).setOption({
tooltip: {
trigger: 'item'
},
legend: {
top: '5%',
right: '10%',
align: 'left',
orient: 'vertical',
icon: 'circle'
},
series: [
{
name: 'Access From',
type: 'pie',
radius: ['50%', '80%'],
avoidLabelOverlap: false,
center: ['30%', '50%'],
label: {
show: false,
position: 'outside'
},
emphasis: {
label: {
show: true,
fontSize: 20,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: Object.entries(statsData.value.productCategoryDeviceCounts).map(([name, value]) => ({
name,
value
}))
}
]
})
// 线
initGaugeChart(deviceOnlineCountChartRef.value, statsData.value.deviceOnlineCount, '#0d9')
// 线
initGaugeChart(deviceOfflineChartRef.value, statsData.value.deviceOfflineCount, '#f50')
//
initGaugeChart(deviceActiveChartRef.value, statsData.value.deviceInactiveCount, '#05b')
//
initMessageChart()
}
/** 初始化仪表盘图表 */
const initGaugeChart = (el: any, value: number, color: string) => {
echarts.init(el).setOption({
series: [
{
type: 'gauge',
startAngle: 360,
endAngle: 0,
min: 0,
max: statsData.value.deviceCount || 100, // 使
progress: {
show: true,
width: 12,
itemStyle: {
color: color
}
},
axisLine: {
lineStyle: {
width: 12,
color: [[1, '#E5E7EB']]
}
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
pointer: { show: false },
anchor: { show: false },
title: { show: false },
detail: {
valueAnimation: true,
fontSize: 24,
fontWeight: 'bold',
fontFamily: 'Inter, sans-serif',
color: color,
offsetCenter: [0, '0'],
formatter: (value: number) => {
return `${value}`
}
},
data: [{ value: value }]
}
]
})
}
/** 初始化消息统计图表 */
const initMessageChart = () => {
//
// TODO @super idea
const timestamps = Array.from(
new Set([
...messageStats.value.upstreamCounts.map((item) => Number(Object.keys(item)[0])),
...messageStats.value.downstreamCounts.map((item) => Number(Object.keys(item)[0]))
])
).sort((a, b) => a - b) //
//
const xdata = timestamps.map((ts) => formatDate(ts, 'YYYY-MM-DD HH:mm'))
const upData = timestamps.map((ts) => {
const item = messageStats.value.upstreamCounts.find(
(count) => Number(Object.keys(count)[0]) === ts
)
return item ? Object.values(item)[0] : 0
})
const downData = timestamps.map((ts) => {
const item = messageStats.value.downstreamCounts.find(
(count) => Number(Object.keys(count)[0]) === ts
)
return item ? Object.values(item)[0] : 0
})
//
echarts.init(deviceMessageCountChartRef.value).setOption({
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderColor: '#E5E7EB',
textStyle: {
color: '#374151'
}
},
legend: {
data: ['上行消息量', '下行消息量'],
textStyle: {
color: '#374151',
fontWeight: 500
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: xdata,
axisLine: {
lineStyle: {
color: '#E5E7EB'
}
},
axisLabel: {
color: '#6B7280'
}
},
yAxis: {
type: 'value',
axisLine: {
lineStyle: {
color: '#E5E7EB'
}
},
axisLabel: {
color: '#6B7280'
},
splitLine: {
lineStyle: {
color: '#F3F4F6'
}
}
},
series: [
{
name: '上行消息量',
type: 'line',
smooth: true, // 线
data: upData,
itemStyle: {
color: '#3B82F6'
},
lineStyle: {
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(59, 130, 246, 0.2)' },
{ offset: 1, color: 'rgba(59, 130, 246, 0)' }
])
}
},
{
name: '下行消息量',
type: 'line',
smooth: true, // 线
data: downData,
itemStyle: {
color: '#10B981'
},
lineStyle: {
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(16, 185, 129, 0.2)' },
{ offset: 1, color: 'rgba(16, 185, 129, 0)' }
])
}
}
]
})
loading.value = true
try {
//
statsData.value = await ProductCategoryApi.getIotStatisticsSummary()
//
messageStats.value = await ProductCategoryApi.getIotStatisticsDeviceMessageSummary(queryParams)
} catch (error) {
console.error('获取统计数据出错:', error)
} finally {
loading.value = false
}
}
/** 初始化 */