Vue3 + Element Plus版本iot前端迁移到vben版本

This commit is contained in:
Administrator
2025-10-07 19:58:59 +08:00
parent e4707ef380
commit 877a03df4a
124 changed files with 20425 additions and 168 deletions

View File

@@ -0,0 +1,72 @@
<template>
<Card class="stat-card" :loading="loading">
<div class="flex flex-col h-full">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col flex-1">
<span class="text-gray-500 text-sm font-medium mb-2">{{ title }}</span>
<span class="text-3xl font-bold text-gray-800">
<span v-if="value === -1">--</span>
<CountTo v-else :end-val="value" :duration="1000" />
</span>
</div>
<div :class="`text-4xl ${iconColor}`">
<IconComponent />
</div>
</div>
<div class="mt-auto pt-3 border-t border-gray-100">
<div class="flex justify-between items-center text-sm">
<span class="text-gray-400">今日新增</span>
<span v-if="todayCount === -1" class="text-gray-400">--</span>
<span v-else class="text-green-500 font-medium">+{{ todayCount }}</span>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { Card } from 'ant-design-vue';
import { CountTo } from '@vben/common-ui';
import { createIconifyIcon } from '@vben/icons';
import { computed } from 'vue';
defineOptions({ name: 'ComparisonCard' });
const props = defineProps<{
title: string;
value: number;
todayCount: number;
icon: string;
iconColor?: string;
loading?: boolean;
}>();
const iconMap: Record<string, any> = {
'menu': createIconifyIcon('ant-design:appstore-outlined'),
'box': createIconifyIcon('ant-design:box-plot-outlined'),
'cpu': createIconifyIcon('ant-design:cluster-outlined'),
'message': createIconifyIcon('ant-design:message-outlined'),
};
const IconComponent = computed(() => iconMap[props.icon] || iconMap.menu);
</script>
<style scoped>
.stat-card {
height: 160px;
transition: all 0.3s ease;
cursor: pointer;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
.stat-card :deep(.ant-card-body) {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<Card title="设备数量统计" :loading="loading" class="chart-card">
<div v-if="loading && !hasData" class="h-[300px] flex justify-center items-center">
<Empty description="加载中..." />
</div>
<div v-else-if="!hasData" class="h-[300px] flex justify-center items-center">
<Empty description="暂无数据" />
</div>
<div v-else>
<EchartsUI ref="deviceCountChartRef" class="h-[400px] w-full" />
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue';
import { Card, Empty } from 'ant-design-vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import type { IotStatisticsApi } from '#/api/iot/statistics';
defineOptions({ name: 'DeviceCountCard' });
const props = defineProps<{
statsData: IotStatisticsApi.StatisticsSummary;
loading?: boolean;
}>();
const deviceCountChartRef = ref();
const { renderEcharts } = useEcharts(deviceCountChartRef);
/** 是否有数据 */
const hasData = computed(() => {
if (!props.statsData) return false;
const categories = Object.entries(props.statsData.productCategoryDeviceCounts || {});
return categories.length > 0 && props.statsData.deviceCount !== 0;
});
/** 初始化图表 */
const initChart = () => {
if (!hasData.value) return;
nextTick(() => {
const data = Object.entries(props.statsData.productCategoryDeviceCounts).map(
([name, value]) => ({ name, value })
);
renderEcharts({
tooltip: {
trigger: 'item',
formatter: '{b}: {c} 个 ({d}%)',
},
legend: {
type: 'scroll',
orient: 'horizontal',
bottom: '10px',
left: 'center',
icon: 'circle',
itemWidth: 10,
itemHeight: 10,
itemGap: 12,
textStyle: {
fontSize: 12,
},
pageButtonPosition: 'end',
pageIconSize: 12,
pageTextStyle: {
fontSize: 12,
},
pageFormatter: '{current}/{total}',
},
series: [
{
name: '设备数量',
type: 'pie',
radius: ['35%', '55%'],
center: ['50%', '40%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 8,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold',
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
labelLine: {
show: false,
},
data: data,
},
],
});
});
};
/** 监听数据变化 */
watch(
() => props.statsData,
() => {
initChart();
},
{ deep: true }
);
/** 组件挂载时初始化图表 */
onMounted(() => {
initChart();
});
</script>
<style scoped>
.chart-card {
height: 100%;
}
.chart-card :deep(.ant-card-body) {
padding: 20px;
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<Card title="设备状态统计" :loading="loading" class="chart-card">
<div v-if="loading && !hasData" class="h-[300px] flex justify-center items-center">
<Empty description="加载中..." />
</div>
<div v-else-if="!hasData" class="h-[300px] flex justify-center items-center">
<Empty description="暂无数据" />
</div>
<Row v-else class="h-[280px]">
<Col :span="8" class="flex items-center justify-center">
<EchartsUI ref="deviceOnlineChartRef" class="h-[250px] w-full" />
</Col>
<Col :span="8" class="flex items-center justify-center">
<EchartsUI ref="deviceOfflineChartRef" class="h-[250px] w-full" />
</Col>
<Col :span="8" class="flex items-center justify-center">
<EchartsUI ref="deviceInactiveChartRef" class="h-[250px] w-full" />
</Col>
</Row>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue';
import { Card, Empty, Row, Col } from 'ant-design-vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import type { IotStatisticsApi } from '#/api/iot/statistics';
defineOptions({ name: 'DeviceStateCountCard' });
const props = defineProps<{
statsData: IotStatisticsApi.StatisticsSummary;
loading?: boolean;
}>();
const deviceOnlineChartRef = ref();
const deviceOfflineChartRef = ref();
const deviceInactiveChartRef = ref();
const { renderEcharts: renderOnlineChart } = useEcharts(deviceOnlineChartRef);
const { renderEcharts: renderOfflineChart } = useEcharts(deviceOfflineChartRef);
const { renderEcharts: renderInactiveChart } = useEcharts(deviceInactiveChartRef);
/** 是否有数据 */
const hasData = computed(() => {
if (!props.statsData) return false;
return props.statsData.deviceCount !== 0;
});
/** 获取仪表盘配置 */
const getGaugeOption = (value: number, color: string, title: string): any => {
return {
series: [
{
type: 'gauge',
startAngle: 225,
endAngle: -45,
min: 0,
max: props.statsData.deviceCount || 100,
center: ['50%', '50%'],
radius: '80%',
progress: {
show: true,
width: 12,
itemStyle: {
color: color,
},
},
axisLine: {
lineStyle: {
width: 12,
color: [[1, '#E5E7EB']] as [number, string][],
},
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
pointer: { show: false },
title: {
show: true,
offsetCenter: [0, '80%'],
fontSize: 14,
color: '#666',
},
detail: {
valueAnimation: true,
fontSize: 32,
fontWeight: 'bold',
color: color,
offsetCenter: [0, '10%'],
formatter: (val: number) => `${val}`,
},
data: [{ value: value, name: title }],
},
],
};
};
/** 初始化图表 */
const initCharts = () => {
if (!hasData.value) return;
nextTick(() => {
// 在线设备
renderOnlineChart(
getGaugeOption(props.statsData.deviceOnlineCount, '#52c41a', '在线设备')
);
// 离线设备
renderOfflineChart(
getGaugeOption(props.statsData.deviceOfflineCount, '#ff4d4f', '离线设备')
);
// 待激活设备
renderInactiveChart(
getGaugeOption(props.statsData.deviceInactiveCount, '#1890ff', '待激活设备')
);
});
};
/** 监听数据变化 */
watch(
() => props.statsData,
() => {
initCharts();
},
{ deep: true }
);
/** 组件挂载时初始化图表 */
onMounted(() => {
initCharts();
});
</script>
<style scoped>
.chart-card {
height: 100%;
}
.chart-card :deep(.ant-card-body) {
padding: 20px;
}
</style>

View File

@@ -0,0 +1,245 @@
<template>
<Card class="chart-card" :loading="loading">
<template #title>
<div class="flex items-center justify-between flex-wrap gap-2">
<span class="text-base font-medium">上下行消息量统计</span>
<Space :size="8">
<Button
:type="activeTimeRange === '1h' ? 'primary' : 'default'"
size="small"
@click="setTimeRange('1h')"
>
最近1小时
</Button>
<Button
:type="activeTimeRange === '24h' ? 'primary' : 'default'"
size="small"
@click="setTimeRange('24h')"
>
最近24小时
</Button>
<Button
:type="activeTimeRange === '7d' ? 'primary' : 'default'"
size="small"
@click="setTimeRange('7d')"
>
近一周
</Button>
<RangePicker
v-model:value="dateRange"
format="YYYY-MM-DD"
:placeholder="['开始时间', '结束时间']"
@change="handleDateChange"
size="small"
style="width: 240px"
/>
</Space>
</div>
</template>
<div v-if="loading" class="h-[350px] flex justify-center items-center">
<Empty description="加载中..." />
</div>
<div v-else-if="!hasData" class="h-[350px] flex justify-center items-center">
<Empty description="暂无数据" />
</div>
<div v-else>
<EchartsUI ref="messageChartRef" class="h-[350px] w-full" />
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue';
import { Card, Empty, Space, DatePicker, Button } from 'ant-design-vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import {
StatisticsApi,
type IotStatisticsDeviceMessageSummaryByDateRespVO,
type IotStatisticsDeviceMessageReqVO,
} from '#/api/iot/statistics';
const { RangePicker } = DatePicker;
defineOptions({ name: 'MessageTrendCard' });
const messageChartRef = ref();
const { renderEcharts } = useEcharts(messageChartRef);
const loading = ref(false);
const messageData = ref<IotStatisticsDeviceMessageSummaryByDateRespVO[]>([]);
const activeTimeRange = ref('7d'); // 当前选中的时间范围
const dateRange = ref<[Dayjs, Dayjs] | undefined>(undefined);
const queryParams = reactive<IotStatisticsDeviceMessageReqVO>({
interval: 1, // 按天
times: [],
});
// 是否有数据
const hasData = computed(() => {
return messageData.value && messageData.value.length > 0;
});
// 设置时间范围
const setTimeRange = (range: string) => {
activeTimeRange.value = range;
dateRange.value = undefined; // 清空自定义时间选择
let start: Dayjs;
let end = dayjs();
switch (range) {
case '1h':
start = dayjs().subtract(1, 'hour');
queryParams.interval = 1; // 按分钟
break;
case '24h':
start = dayjs().subtract(24, 'hour');
queryParams.interval = 1; // 按小时
break;
case '7d':
start = dayjs().subtract(7, 'day');
queryParams.interval = 1; // 按天
break;
default:
start = dayjs().subtract(7, 'day');
queryParams.interval = 1;
}
queryParams.times = [
start.format('YYYY-MM-DD HH:mm:ss'),
end.format('YYYY-MM-DD HH:mm:ss'),
];
fetchMessageData();
};
// 处理自定义日期选择
const handleDateChange = () => {
if (dateRange.value && dateRange.value.length === 2) {
activeTimeRange.value = ''; // 清空快捷选择
queryParams.interval = 1; // 按天
queryParams.times = [
dateRange.value[0].startOf('day').format('YYYY-MM-DD HH:mm:ss'),
dateRange.value[1].endOf('day').format('YYYY-MM-DD HH:mm:ss'),
];
fetchMessageData();
}
};
// 获取消息统计数据
const fetchMessageData = async () => {
if (!queryParams.times || queryParams.times.length !== 2) return;
loading.value = true;
try {
messageData.value = await StatisticsApi.getDeviceMessageSummaryByDate(queryParams);
await nextTick();
initChart();
} catch (error) {
console.error('获取消息统计数据失败:', error);
messageData.value = [];
} finally {
loading.value = false;
}
};
// 初始化图表
const initChart = () => {
if (!hasData.value) return;
const times = messageData.value.map((item) => item.time);
const upstreamData = messageData.value.map((item) => item.upstreamCount);
const downstreamData = messageData.value.map((item) => item.downstreamCount);
renderEcharts({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985',
},
},
},
legend: {
data: ['上行消息', '下行消息'],
top: '5%',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true,
},
xAxis: [
{
type: 'category',
boundaryGap: false,
data: times,
},
],
yAxis: [
{
type: 'value',
name: '消息数量',
},
],
series: [
{
name: '上行消息',
type: 'line',
smooth: true,
areaStyle: {
opacity: 0.3,
},
emphasis: {
focus: 'series',
},
data: upstreamData,
itemStyle: {
color: '#1890ff',
},
},
{
name: '下行消息',
type: 'line',
smooth: true,
areaStyle: {
opacity: 0.3,
},
emphasis: {
focus: 'series',
},
data: downstreamData,
itemStyle: {
color: '#52c41a',
},
},
],
});
};
// 组件挂载时查询数据
onMounted(() => {
setTimeRange('7d'); // 默认显示近一周数据
});
</script>
<style scoped>
.chart-card {
height: 100%;
}
.chart-card :deep(.ant-card-body) {
padding: 20px;
}
.chart-card :deep(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
}
</style>