!269 refactor:【antd】【iot】重构首页统计组件,优化图表配置和数据加载逻辑,移除未使用的比较卡片组件

Merge pull request !269 from haohaoMT/dev
This commit is contained in:
芋道源码
2025-11-23 10:43:57 +00:00
committed by Gitee
11 changed files with 441 additions and 548 deletions

View File

@@ -1,39 +1,74 @@
/** 设备数量饼图配置 */
// TODO @haohao貌似没用到
export function getDeviceCountChartOptions(
productCategoryDeviceCounts: Record<string, number>,
/**
* 消息趋势图表配置
*/
export function getMessageTrendChartOptions(
times: string[],
upstreamData: number[],
downstreamData: number[],
): any {
const data = Object.entries(productCategoryDeviceCounts).map(
([name, value]) => ({ name, value }),
);
return {
tooltip: {
trigger: 'item',
formatter: '{b}: {c} 个 ({d}%)',
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985',
},
},
},
legend: {
data: ['上行消息', '下行消息'],
top: '5%',
right: '10%',
orient: 'vertical',
},
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: 'pie',
radius: ['50%', '80%'],
center: ['30%', '50%'],
data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
name: '上行消息',
type: 'line',
smooth: true,
areaStyle: {
opacity: 0.3,
},
label: {
show: true,
formatter: '{b}: {c}',
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',
},
},
],
@@ -41,9 +76,9 @@ export function getDeviceCountChartOptions(
}
/**
* 仪表盘图表配置
* 设备状态仪表盘图表配置
*/
export function getGaugeChartOptions(
export function getDeviceStateGaugeChartOptions(
value: number,
max: number,
color: string,
@@ -53,12 +88,12 @@ export function getGaugeChartOptions(
series: [
{
type: 'gauge',
startAngle: 180,
endAngle: 0,
startAngle: 225,
endAngle: -45,
min: 0,
max,
center: ['50%', '70%'],
radius: '120%',
center: ['50%', '50%'],
radius: '80%',
progress: {
show: true,
width: 12,
@@ -69,29 +104,95 @@ export function getGaugeChartOptions(
axisLine: {
lineStyle: {
width: 12,
color: [[1, '#E5E7EB']],
color: [[1, '#E5E7EB']] as [number, string][],
},
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
pointer: { show: false },
detail: {
valueAnimation: true,
fontSize: 24,
fontWeight: 'bold',
color,
offsetCenter: [0, '-20%'],
formatter: '{value}',
},
title: {
show: true,
offsetCenter: [0, '20%'],
offsetCenter: [0, '80%'],
fontSize: 14,
color: '#666',
},
detail: {
valueAnimation: true,
fontSize: 32,
fontWeight: 'bold',
color,
offsetCenter: [0, '10%'],
formatter: (val: number) => `${val}`,
},
data: [{ value, name: title }],
},
],
};
}
/**
* 设备数量饼图配置
*/
export function getDeviceCountPieChartOptions(
data: Array<{ name: string; value: number }>,
): any {
return {
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,
},
],
};
}

View File

@@ -1,18 +1,11 @@
/**
* IoT 首页数据配置文件
*
* 该文件封装了 IoT 首页所需的:
* - 统计数据接口定义
* - 业务逻辑函数
* - 工具函数
* 该文件只包含统计数据接口定义
*/
import type { IotStatisticsApi } from '#/api/iot/statistics';
import { onMounted, ref } from 'vue';
import { getStatisticsSummary } from '#/api/iot/statistics';
/** 统计数据接口 - 使用 API 定义的类型 */
export type StatsData = IotStatisticsApi.StatisticsSummary;
@@ -31,84 +24,3 @@ export const defaultStatsData: StatsData = {
deviceInactiveCount: 0,
productCategoryDeviceCounts: {},
};
/**
* 加载统计数据
* @returns Promise<StatsData>
*/
export async function loadStatisticsData(): Promise<StatsData> {
try {
const data = await getStatisticsSummary();
return data;
} catch (error) {
console.error('获取统计数据出错:', error);
console.warn('使用 Mock 数据,请检查后端接口是否已实现');
// 返回 Mock 数据用于开发调试
return {
productCategoryCount: 12,
productCount: 45,
deviceCount: 328,
deviceMessageCount: 15_678,
productCategoryTodayCount: 2,
productTodayCount: 5,
deviceTodayCount: 23,
deviceMessageTodayCount: 1234,
deviceOnlineCount: 256,
deviceOfflineCount: 48,
deviceInactiveCount: 24,
productCategoryDeviceCounts: {
智能家居: 120,
工业设备: 98,
环境监测: 65,
智能穿戴: 45,
},
};
}
}
/**
* IoT 首页业务逻辑 Hook
* 封装了首页的所有业务逻辑和状态管理
*/
export function useIotHome() {
const loading = ref(true);
const statsData = ref<StatsData>(defaultStatsData);
/**
* 加载数据
*/
async function loadData() {
loading.value = true;
try {
statsData.value = await loadStatisticsData();
} catch (error) {
console.error('获取统计数据出错:', error);
} finally {
loading.value = false;
}
}
// 组件挂载时加载数据
onMounted(() => {
loadData();
});
return {
loading,
statsData,
loadData,
};
}
// TODO @haohao是不是删除下哈
/** 格式化数字 - 大数字显示为 K/M */
export function formatNumber(num: number): string {
if (num >= 1_000_000) {
return `${(num / 1_000_000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toString();
}

View File

@@ -1,18 +1,78 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import { ComparisonCard, Page } from '@vben/common-ui';
import { Col, Row } from 'ant-design-vue';
import { onMounted, ref } from 'vue';
import { useIotHome } from './data';
import ComparisonCard from './modules/comparison-card.vue';
import { getStatisticsSummary } from '#/api/iot/statistics';
import { defaultStatsData, type StatsData } from './data';
import DeviceCountCard from './modules/device-count-card.vue';
import DeviceStateCountCard from './modules/device-state-count-card.vue';
import MessageTrendCard from './modules/message-trend-card.vue';
defineOptions({ name: 'IoTHome' });
// TODO @haohao相关的方法拿到 index.vue 里data.ts 只放 schema
const { loading, statsData } = useIotHome();
const loading = ref(true);
const statsData = ref<StatsData>(defaultStatsData);
/**
* 加载统计数据
* @returns Promise<StatsData>
*/
async function loadStatisticsData(): Promise<StatsData> {
try {
const data = await getStatisticsSummary();
return data;
} catch (error) {
// 开发环境:记录错误信息,便于调试
console.error('获取统计数据出错:', error);
// 开发环境:提示使用 Mock 数据,提醒检查后端接口
console.warn('使用 Mock 数据,请检查后端接口是否已实现');
// 开发调试:返回 Mock 数据,确保前端功能正常开发
// 生产环境:建议移除 Mock 数据,直接抛出错误或返回空数据
return {
productCategoryCount: 12,
productCount: 45,
deviceCount: 328,
deviceMessageCount: 15_678,
productCategoryTodayCount: 2,
productTodayCount: 5,
deviceTodayCount: 23,
deviceMessageTodayCount: 1234,
deviceOnlineCount: 256,
deviceOfflineCount: 48,
deviceInactiveCount: 24,
productCategoryDeviceCounts: {
智能家居: 120,
工业设备: 98,
环境监测: 65,
智能穿戴: 45,
},
};
}
}
/**
* 加载数据
*/
async function loadData() {
loading.value = true;
try {
statsData.value = await loadStatisticsData();
} catch (error) {
// 开发环境:记录错误信息,便于调试
console.error('获取统计数据出错:', error);
} finally {
loading.value = false;
}
}
/** 组件挂载时加载数据 */
onMounted(() => {
loadData();
});
</script>
<template>

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CountTo } from '@vben/common-ui';
import { createIconifyIcon } from '@vben/icons';
import { Card } from 'ant-design-vue';
// TODO @haohao这个可以迁移到 packages/effects/common-ui/src/components/card/comparison-card
defineOptions({ name: 'ComparisonCard' });
const props = defineProps<{
icon: string;
iconColor?: string;
loading?: boolean;
title: string;
todayCount: number;
value: number;
}>();
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>
<template>
<Card class="stat-card" :loading="loading">
<div class="flex h-full flex-col">
<div class="mb-4 flex items-start justify-between">
<div class="flex flex-1 flex-col">
<span class="mb-2 text-sm font-medium text-gray-500">
{{ 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 border-t border-gray-100 pt-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-400">今日新增</span>
<span v-if="todayCount === -1" class="text-gray-400">--</span>
<span v-else class="font-medium text-green-500">
+{{ todayCount }}
</span>
</div>
</div>
</div>
</Card>
</template>
<style scoped>
/** TODO tindwind */
.stat-card {
height: 160px;
cursor: pointer;
transition: all 0.3s ease;
}
.stat-card:hover {
box-shadow: 0 6px 20px rgb(0 0 0 / 8%);
transform: translateY(-4px);
}
.stat-card :deep(.ant-card-body) {
display: flex;
flex-direction: column;
height: 100%;
}
</style>

View File

@@ -7,6 +7,8 @@ import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Card, Empty } from 'ant-design-vue';
import { getDeviceCountPieChartOptions } from '../chart-options';
defineOptions({ name: 'DeviceCountCard' });
const props = defineProps<{
@@ -27,77 +29,17 @@ const hasData = computed(() => {
});
/** 初始化图表 */
function initChart() {
async function initChart() {
if (!hasData.value) {
return;
}
// TODO @haohaoawait nextTick();
nextTick(() => {
const data = Object.entries(
props.statsData.productCategoryDeviceCounts,
).map(([name, value]) => ({ name, value }));
await nextTick();
const data = Object.entries(
props.statsData.productCategoryDeviceCounts,
).map(([name, value]) => ({ name, value }));
// TODO @haohao看看 chart-options 怎么提取出去,类似 apps/web-antd/src/views/mall/statistics/member/modules/area-chart-options.ts 写法
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,
},
],
});
});
renderEcharts(getDeviceCountPieChartOptions(data));
}
/** 监听数据变化 */
@@ -116,7 +58,7 @@ onMounted(() => {
</script>
<template>
<Card title="设备数量统计" :loading="loading" class="chart-card">
<Card title="设备数量统计" :loading="loading" class="h-full">
<div
v-if="loading && !hasData"
class="flex h-[300px] items-center justify-center"
@@ -136,12 +78,7 @@ onMounted(() => {
</template>
<style scoped>
/** TODO tindwind */
.chart-card {
height: 100%;
}
.chart-card :deep(.ant-card-body) {
:deep(.ant-card-body) {
padding: 20px;
}
</style>

View File

@@ -7,6 +7,8 @@ import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Card, Col, Empty, Row } from 'ant-design-vue';
import { getDeviceStateGaugeChartOptions } from '../chart-options';
defineOptions({ name: 'DeviceStateCountCard' });
const props = defineProps<{
@@ -30,81 +32,41 @@ const hasData = computed(() => {
return props.statsData.deviceCount !== 0;
});
/** 获取仪表盘配置 */
// TODO @haohao看看 chart-options 怎么提取出去,类似 apps/web-antd/src/views/mall/statistics/member/modules/area-chart-options.ts 写法
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,
},
},
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,
offsetCenter: [0, '10%'],
formatter: (val: number) => `${val}`,
},
data: [{ value, name: title }],
},
],
};
};
/** 初始化图表 */
function initCharts() {
async function initCharts() {
if (!hasData.value) {
return;
}
// TODO @haohaoawait nextTick();
nextTick(() => {
// 在线设备
renderOnlineChart(
getGaugeOption(props.statsData.deviceOnlineCount, '#52c41a', '在线设备'),
);
// 离线设备
renderOfflineChart(
getGaugeOption(props.statsData.deviceOfflineCount, '#ff4d4f', '离线设备'),
);
// 待激活设备
renderInactiveChart(
getGaugeOption(
props.statsData.deviceInactiveCount,
'#1890ff',
'待激活设备',
),
);
});
await nextTick();
const max = props.statsData.deviceCount || 100;
// 在线设备
renderOnlineChart(
getDeviceStateGaugeChartOptions(
props.statsData.deviceOnlineCount,
max,
'#52c41a',
'在线设备',
),
);
// 离线设备
renderOfflineChart(
getDeviceStateGaugeChartOptions(
props.statsData.deviceOfflineCount,
max,
'#ff4d4f',
'离线设备',
),
);
// 待激活设备
renderInactiveChart(
getDeviceStateGaugeChartOptions(
props.statsData.deviceInactiveCount,
max,
'#1890ff',
'待激活设备',
),
);
}
/** 监听数据变化 */
@@ -123,7 +85,7 @@ onMounted(() => {
</script>
<template>
<Card title="设备状态统计" :loading="loading" class="chart-card">
<Card title="设备状态统计" :loading="loading" class="h-full">
<div
v-if="loading && !hasData"
class="flex h-[300px] items-center justify-center"
@@ -151,12 +113,7 @@ onMounted(() => {
</template>
<style scoped>
/** TODO tindwind */
.chart-card {
height: 100%;
}
.chart-card :deep(.ant-card-body) {
:deep(.ant-card-body) {
padding: 20px;
}
</style>

View File

@@ -1,260 +1,181 @@
<script setup lang="ts">
import type { Dayjs } from 'dayjs';
import type { IotStatisticsApi } from '#/api/iot/statistics';
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Button, Card, DatePicker, Empty, Space } from 'ant-design-vue';
import { Card, Empty, Select } from 'ant-design-vue';
import dayjs from 'dayjs';
import ShortcutDateRangePicker from '#/components/shortcut-date-range-picker/shortcut-date-range-picker.vue';
import { getDeviceMessageSummaryByDate } from '#/api/iot/statistics';
defineOptions({ name: 'MessageTrendCard' });
import { getMessageTrendChartOptions } from '../chart-options';
const { RangePicker } = DatePicker;
defineOptions({ name: 'MessageTrendCard' });
const messageChartRef = ref();
const { renderEcharts } = useEcharts(messageChartRef);
const loading = ref(false);
const messageData = ref<IotStatisticsApi.DeviceMessageSummaryByDate[]>([]);
const activeTimeRange = ref('7d'); // 当前选中的时间范围
const dateRange = ref<[Dayjs, Dayjs] | undefined>(undefined);
// TODO @haohao这个貌似没迁移对。它是时间范围、事件间隔
/** 时间范围(仅日期,不包含时分秒) */
const dateRange = ref<[string, string]>([
// 默认显示最近一周的数据(包含今天和前六天)
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
]);
/**
* 将日期范围转换为带时分秒的格式
* @param dates 日期范围 [开始日期, 结束日期],格式为 YYYY-MM-DD
* @returns 带时分秒的日期范围 [开始日期 00:00:00, 结束日期 23:59:59]
*/
function formatDateRangeWithTime(dates: [string, string]): [string, string] {
return [
`${dates[0]} 00:00:00`,
`${dates[1]} 23:59:59`,
];
}
/** 查询参数 */
const queryParams = reactive<IotStatisticsApi.DeviceMessageReq>({
interval: 1, // 按天
times: [],
interval: 1, // 默认按天
times: formatDateRangeWithTime(dateRange.value),
});
// 是否有数据
/** 是否有数据 */
const hasData = computed(() => {
return messageData.value && messageData.value.length > 0;
});
// TODO @haohao注释风格应该是 /** */ 在方法上;然后变量在字段后面 // 。。。
// 设置时间范围
function setTimeRange(range: string) {
activeTimeRange.value = range;
dateRange.value = undefined; // 清空自定义时间选择
let start: Dayjs;
const end = dayjs();
switch (range) {
case '1h': {
start = dayjs().subtract(1, 'hour');
queryParams.interval = 1; // 按分钟
break;
}
case '7d': {
start = dayjs().subtract(7, 'day');
queryParams.interval = 1; // 按天
break;
}
case '24h': {
start = dayjs().subtract(24, 'hour');
queryParams.interval = 1; // 按小时
break;
}
default: {
start = dayjs().subtract(7, 'day');
queryParams.interval = 1;
}
}
// TODO @haohao可以使用 formatDateTime
queryParams.times = [
start.format('YYYY-MM-DD HH:mm:ss'),
end.format('YYYY-MM-DD HH:mm:ss'),
];
/** 时间间隔字典选项 */
const intervalOptions = computed(() =>
getDictOptions(DICT_TYPE.DATE_INTERVAL, 'number').map((item) => ({
label: item.label,
value: item.value as number,
})),
);
/** 处理查询操作 */
function handleQuery() {
fetchMessageData();
}
// 处理自定义日期选择
function handleDateChange() {
if (dateRange.value && dateRange.value.length === 2) {
activeTimeRange.value = ''; // 清空快捷选择
queryParams.interval = 1; // 按天
queryParams.times = [
// TODO @haohao可以使用 formatDateTime
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();
}
/** 处理时间范围变化 */
function handleDateRangeChange(times?: [Dayjs, Dayjs]) {
if (!times || times.length !== 2) return;
dateRange.value = [
dayjs(times[0]).format('YYYY-MM-DD'),
dayjs(times[1]).format('YYYY-MM-DD'),
];
// 将选择的日期转换为带时分秒的格式(开始日期 00:00:00结束日期 23:59:59
queryParams.times = formatDateRangeWithTime(dateRange.value);
handleQuery();
}
// 获取消息统计数据
/** 处理时间间隔变化 */
function handleIntervalChange() {
handleQuery();
}
/** 获取消息统计数据 */
async function fetchMessageData() {
if (!queryParams.times || queryParams.times.length !== 2) return;
loading.value = true;
try {
messageData.value = await getDeviceMessageSummaryByDate(queryParams);
await nextTick();
initChart();
} catch (error) {
// 开发环境:记录错误信息,便于调试
console.error('获取消息统计数据失败:', error);
// 错误时清空数据,避免显示错误的数据
messageData.value = [];
} finally {
loading.value = false;
await renderChartWhenReady();
}
}
// 初始化图表
/** 初始化图表 */
function 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);
// TODO @haohao看看 chart-options 怎么提取出去,类似 apps/web-antd/src/views/mall/statistics/member/modules/area-chart-options.ts 写法
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',
},
},
],
});
renderEcharts(
getMessageTrendChartOptions(times, upstreamData, downstreamData),
);
}
// 组件挂载时查询数据
/** 确保图表容器已经可见后再渲染 */
async function renderChartWhenReady() {
if (!hasData.value) return;
// 等待 Card loading 状态、v-show 等 DOM 更新完成
await nextTick();
await nextTick();
initChart();
}
/** 组件挂载时查询数据 */
onMounted(() => {
setTimeRange('7d'); // 默认显示近一周数据
fetchMessageData();
});
</script>
<template>
<Card class="chart-card" :loading="loading">
<Card class="h-full">
<template #title>
<div class="flex flex-wrap items-center justify-between 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 class="flex flex-wrap items-center justify-between gap-4">
<span class="text-base font-medium text-gray-600">消息量统计</span>
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-3">
<span class="text-sm text-gray-500 whitespace-nowrap">时间范围</span>
<ShortcutDateRangePicker @change="handleDateRangeChange" />
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">时间间隔</span>
<Select
v-model:value="queryParams.interval"
:options="intervalOptions"
placeholder="间隔类型"
:style="{ width: '80px' }"
@change="handleIntervalChange"
/>
</div>
</div>
</div>
</template>
<div v-if="loading" class="flex h-[350px] items-center justify-center">
<!-- 加载中状态 -->
<div
v-show="loading && !hasData"
class="flex h-[300px] items-center justify-center"
>
<Empty description="加载中..." />
</div>
<!-- 无数据状态 -->
<div
v-else-if="!hasData"
class="flex h-[350px] items-center justify-center"
v-show="!loading && !hasData"
class="flex h-[300px] items-center justify-center"
>
<Empty description="暂无数据" />
</div>
<div v-else>
<EchartsUI ref="messageChartRef" class="h-[350px] w-full" />
<!-- 图表容器 - 使用 v-show 而非 v-if确保组件始终挂载 -->
<div v-show="hasData">
<EchartsUI ref="messageChartRef" class="h-[300px] w-full" />
</div>
</Card>
</template>
<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>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { ComparisonCardProps } from './types';
import { computed } from 'vue';
import {
Card,
CardContent,
VbenCountToAnimator,
VbenIcon,
VbenLoading,
} from '@vben-core/shadcn-ui';
/** 对比卡片 */
defineOptions({ name: 'ComparisonCard' });
const props = defineProps<ComparisonCardProps>();
const iconMap: Record<string, string> = {
menu: 'ant-design:appstore-outlined',
box: 'ant-design:box-plot-outlined',
cpu: 'ant-design:cluster-outlined',
message: 'ant-design:message-outlined',
};
const iconName = computed(() => iconMap[props.icon] || iconMap.menu);
</script>
<template>
<Card
class="relative h-40 cursor-pointer transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<VbenLoading :spinning="loading" />
<CardContent class="flex h-full flex-col p-6">
<div class="mb-4 flex items-start justify-between">
<div class="flex flex-1 flex-col">
<span class="mb-2 text-sm font-medium text-gray-500">
{{ title }}
</span>
<span class="text-3xl font-bold text-gray-800">
<span v-if="value === -1">--</span>
<VbenCountToAnimator
v-else
:end-val="value"
:duration="1000"
/>
</span>
</div>
<div :class="`text-4xl ${iconColor || ''}`">
<VbenIcon :icon="iconName" />
</div>
</div>
<div class="mt-auto border-t border-gray-100 pt-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-400">今日新增</span>
<span v-if="todayCount === -1" class="text-gray-400">--</span>
<span v-else class="font-medium text-green-500">
+{{ todayCount }}
</span>
</div>
</div>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,4 @@
// add by 芋艿:对比卡片,目前 iot 模块在使用
export { default as ComparisonCard } from './comparison-card.vue';
export * from './types';

View File

@@ -0,0 +1,15 @@
export interface ComparisonCardProps {
/** 图标名称 */
icon: string;
/** 图标颜色类名 */
iconColor?: string;
/** 加载状态 */
loading?: boolean;
/** 标题 */
title: string;
/** 今日新增数量 */
todayCount: number;
/** 数值 */
value: number;
}

View File

@@ -1,5 +1,6 @@
export * from './api-component';
export * from './captcha';
export * from './card/comparison-card';
export * from './card/statistic-card';
export * from './card/summary-card';
export * from './col-page';