!269 refactor:【antd】【iot】重构首页统计组件,优化图表配置和数据加载逻辑,移除未使用的比较卡片组件
Merge pull request !269 from haohaoMT/dev
This commit is contained in:
@@ -1,39 +1,74 @@
|
|||||||
/** 设备数量饼图配置 */
|
/**
|
||||||
// TODO @haohao:貌似没用到??
|
* 消息趋势图表配置
|
||||||
export function getDeviceCountChartOptions(
|
*/
|
||||||
productCategoryDeviceCounts: Record<string, number>,
|
export function getMessageTrendChartOptions(
|
||||||
|
times: string[],
|
||||||
|
upstreamData: number[],
|
||||||
|
downstreamData: number[],
|
||||||
): any {
|
): any {
|
||||||
const data = Object.entries(productCategoryDeviceCounts).map(
|
|
||||||
([name, value]) => ({ name, value }),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'axis',
|
||||||
formatter: '{b}: {c} 个 ({d}%)',
|
axisPointer: {
|
||||||
|
type: 'cross',
|
||||||
|
label: {
|
||||||
|
backgroundColor: '#6a7985',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
|
data: ['上行消息', '下行消息'],
|
||||||
top: '5%',
|
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: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '设备数量',
|
name: '上行消息',
|
||||||
type: 'pie',
|
type: 'line',
|
||||||
radius: ['50%', '80%'],
|
smooth: true,
|
||||||
center: ['30%', '50%'],
|
areaStyle: {
|
||||||
data,
|
opacity: 0.3,
|
||||||
|
},
|
||||||
emphasis: {
|
emphasis: {
|
||||||
|
focus: 'series',
|
||||||
|
},
|
||||||
|
data: upstreamData,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
shadowBlur: 10,
|
color: '#1890ff',
|
||||||
shadowOffsetX: 0,
|
|
||||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
label: {
|
{
|
||||||
show: true,
|
name: '下行消息',
|
||||||
formatter: '{b}: {c}',
|
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,
|
value: number,
|
||||||
max: number,
|
max: number,
|
||||||
color: string,
|
color: string,
|
||||||
@@ -53,12 +88,12 @@ export function getGaugeChartOptions(
|
|||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'gauge',
|
type: 'gauge',
|
||||||
startAngle: 180,
|
startAngle: 225,
|
||||||
endAngle: 0,
|
endAngle: -45,
|
||||||
min: 0,
|
min: 0,
|
||||||
max,
|
max,
|
||||||
center: ['50%', '70%'],
|
center: ['50%', '50%'],
|
||||||
radius: '120%',
|
radius: '80%',
|
||||||
progress: {
|
progress: {
|
||||||
show: true,
|
show: true,
|
||||||
width: 12,
|
width: 12,
|
||||||
@@ -69,29 +104,95 @@ export function getGaugeChartOptions(
|
|||||||
axisLine: {
|
axisLine: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
width: 12,
|
width: 12,
|
||||||
color: [[1, '#E5E7EB']],
|
color: [[1, '#E5E7EB']] as [number, string][],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
splitLine: { show: false },
|
splitLine: { show: false },
|
||||||
axisLabel: { show: false },
|
axisLabel: { show: false },
|
||||||
pointer: { show: false },
|
pointer: { show: false },
|
||||||
detail: {
|
|
||||||
valueAnimation: true,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color,
|
|
||||||
offsetCenter: [0, '-20%'],
|
|
||||||
formatter: '{value}',
|
|
||||||
},
|
|
||||||
title: {
|
title: {
|
||||||
show: true,
|
show: true,
|
||||||
offsetCenter: [0, '20%'],
|
offsetCenter: [0, '80%'],
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: '#666',
|
color: '#666',
|
||||||
},
|
},
|
||||||
|
detail: {
|
||||||
|
valueAnimation: true,
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color,
|
||||||
|
offsetCenter: [0, '10%'],
|
||||||
|
formatter: (val: number) => `${val} 个`,
|
||||||
|
},
|
||||||
data: [{ value, name: title }],
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* IoT 首页数据配置文件
|
* IoT 首页数据配置文件
|
||||||
*
|
*
|
||||||
* 该文件封装了 IoT 首页所需的:
|
* 该文件只包含统计数据接口定义
|
||||||
* - 统计数据接口定义
|
|
||||||
* - 业务逻辑函数
|
|
||||||
* - 工具函数
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IotStatisticsApi } from '#/api/iot/statistics';
|
import type { IotStatisticsApi } from '#/api/iot/statistics';
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
|
||||||
|
|
||||||
import { getStatisticsSummary } from '#/api/iot/statistics';
|
|
||||||
|
|
||||||
/** 统计数据接口 - 使用 API 定义的类型 */
|
/** 统计数据接口 - 使用 API 定义的类型 */
|
||||||
export type StatsData = IotStatisticsApi.StatisticsSummary;
|
export type StatsData = IotStatisticsApi.StatisticsSummary;
|
||||||
|
|
||||||
@@ -31,84 +24,3 @@ export const defaultStatsData: StatsData = {
|
|||||||
deviceInactiveCount: 0,
|
deviceInactiveCount: 0,
|
||||||
productCategoryDeviceCounts: {},
|
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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,78 @@
|
|||||||
<script setup lang="ts">
|
<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 { Col, Row } from 'ant-design-vue';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { useIotHome } from './data';
|
import { getStatisticsSummary } from '#/api/iot/statistics';
|
||||||
import ComparisonCard from './modules/comparison-card.vue';
|
|
||||||
|
import { defaultStatsData, type StatsData } from './data';
|
||||||
import DeviceCountCard from './modules/device-count-card.vue';
|
import DeviceCountCard from './modules/device-count-card.vue';
|
||||||
import DeviceStateCountCard from './modules/device-state-count-card.vue';
|
import DeviceStateCountCard from './modules/device-state-count-card.vue';
|
||||||
import MessageTrendCard from './modules/message-trend-card.vue';
|
import MessageTrendCard from './modules/message-trend-card.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'IoTHome' });
|
defineOptions({ name: 'IoTHome' });
|
||||||
|
|
||||||
// TODO @haohao:相关的方法,拿到 index.vue 里,data.ts 只放 schema;
|
const loading = ref(true);
|
||||||
const { loading, statsData } = useIotHome();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -7,6 +7,8 @@ import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
|||||||
|
|
||||||
import { Card, Empty } from 'ant-design-vue';
|
import { Card, Empty } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getDeviceCountPieChartOptions } from '../chart-options';
|
||||||
|
|
||||||
defineOptions({ name: 'DeviceCountCard' });
|
defineOptions({ name: 'DeviceCountCard' });
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -27,77 +29,17 @@ const hasData = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** 初始化图表 */
|
/** 初始化图表 */
|
||||||
function initChart() {
|
async function initChart() {
|
||||||
if (!hasData.value) {
|
if (!hasData.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @haohao:await nextTick();
|
await nextTick();
|
||||||
nextTick(() => {
|
|
||||||
const data = Object.entries(
|
const data = Object.entries(
|
||||||
props.statsData.productCategoryDeviceCounts,
|
props.statsData.productCategoryDeviceCounts,
|
||||||
).map(([name, value]) => ({ name, value }));
|
).map(([name, value]) => ({ name, value }));
|
||||||
|
|
||||||
// TODO @haohao:看看 chart-options 怎么提取出去,类似 apps/web-antd/src/views/mall/statistics/member/modules/area-chart-options.ts 写法
|
renderEcharts(getDeviceCountPieChartOptions(data));
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 监听数据变化 */
|
/** 监听数据变化 */
|
||||||
@@ -116,7 +58,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card title="设备数量统计" :loading="loading" class="chart-card">
|
<Card title="设备数量统计" :loading="loading" class="h-full">
|
||||||
<div
|
<div
|
||||||
v-if="loading && !hasData"
|
v-if="loading && !hasData"
|
||||||
class="flex h-[300px] items-center justify-center"
|
class="flex h-[300px] items-center justify-center"
|
||||||
@@ -136,12 +78,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/** TODO tindwind */
|
:deep(.ant-card-body) {
|
||||||
.chart-card {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card :deep(.ant-card-body) {
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
|||||||
|
|
||||||
import { Card, Col, Empty, Row } from 'ant-design-vue';
|
import { Card, Col, Empty, Row } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getDeviceStateGaugeChartOptions } from '../chart-options';
|
||||||
|
|
||||||
defineOptions({ name: 'DeviceStateCountCard' });
|
defineOptions({ name: 'DeviceStateCountCard' });
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -30,81 +32,41 @@ const hasData = computed(() => {
|
|||||||
return props.statsData.deviceCount !== 0;
|
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) {
|
if (!hasData.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @haohao:await nextTick();
|
await nextTick();
|
||||||
nextTick(() => {
|
const max = props.statsData.deviceCount || 100;
|
||||||
// 在线设备
|
// 在线设备
|
||||||
renderOnlineChart(
|
renderOnlineChart(
|
||||||
getGaugeOption(props.statsData.deviceOnlineCount, '#52c41a', '在线设备'),
|
getDeviceStateGaugeChartOptions(
|
||||||
|
props.statsData.deviceOnlineCount,
|
||||||
|
max,
|
||||||
|
'#52c41a',
|
||||||
|
'在线设备',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
// 离线设备
|
// 离线设备
|
||||||
renderOfflineChart(
|
renderOfflineChart(
|
||||||
getGaugeOption(props.statsData.deviceOfflineCount, '#ff4d4f', '离线设备'),
|
getDeviceStateGaugeChartOptions(
|
||||||
|
props.statsData.deviceOfflineCount,
|
||||||
|
max,
|
||||||
|
'#ff4d4f',
|
||||||
|
'离线设备',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
// 待激活设备
|
// 待激活设备
|
||||||
renderInactiveChart(
|
renderInactiveChart(
|
||||||
getGaugeOption(
|
getDeviceStateGaugeChartOptions(
|
||||||
props.statsData.deviceInactiveCount,
|
props.statsData.deviceInactiveCount,
|
||||||
|
max,
|
||||||
'#1890ff',
|
'#1890ff',
|
||||||
'待激活设备',
|
'待激活设备',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 监听数据变化 */
|
/** 监听数据变化 */
|
||||||
@@ -123,7 +85,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card title="设备状态统计" :loading="loading" class="chart-card">
|
<Card title="设备状态统计" :loading="loading" class="h-full">
|
||||||
<div
|
<div
|
||||||
v-if="loading && !hasData"
|
v-if="loading && !hasData"
|
||||||
class="flex h-[300px] items-center justify-center"
|
class="flex h-[300px] items-center justify-center"
|
||||||
@@ -151,12 +113,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/** TODO tindwind */
|
:deep(.ant-card-body) {
|
||||||
.chart-card {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card :deep(.ant-card-body) {
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,260 +1,181 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
import type { IotStatisticsApi } from '#/api/iot/statistics';
|
import type { IotStatisticsApi } from '#/api/iot/statistics';
|
||||||
|
|
||||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
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 { 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 dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import ShortcutDateRangePicker from '#/components/shortcut-date-range-picker/shortcut-date-range-picker.vue';
|
||||||
import { getDeviceMessageSummaryByDate } from '#/api/iot/statistics';
|
import { getDeviceMessageSummaryByDate } from '#/api/iot/statistics';
|
||||||
|
|
||||||
defineOptions({ name: 'MessageTrendCard' });
|
import { getMessageTrendChartOptions } from '../chart-options';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
defineOptions({ name: 'MessageTrendCard' });
|
||||||
|
|
||||||
const messageChartRef = ref();
|
const messageChartRef = ref();
|
||||||
const { renderEcharts } = useEcharts(messageChartRef);
|
const { renderEcharts } = useEcharts(messageChartRef);
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const messageData = ref<IotStatisticsApi.DeviceMessageSummaryByDate[]>([]);
|
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>({
|
const queryParams = reactive<IotStatisticsApi.DeviceMessageReq>({
|
||||||
interval: 1, // 按天
|
interval: 1, // 默认按天
|
||||||
times: [],
|
times: formatDateRangeWithTime(dateRange.value),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 是否有数据
|
/** 是否有数据 */
|
||||||
const hasData = computed(() => {
|
const hasData = computed(() => {
|
||||||
return messageData.value && messageData.value.length > 0;
|
return messageData.value && messageData.value.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO @haohao:注释风格,应该是 /** */ 在方法上;然后变量在字段后面 // 。。。
|
/** 时间间隔字典选项 */
|
||||||
// 设置时间范围
|
const intervalOptions = computed(() =>
|
||||||
function setTimeRange(range: string) {
|
getDictOptions(DICT_TYPE.DATE_INTERVAL, 'number').map((item) => ({
|
||||||
activeTimeRange.value = range;
|
label: item.label,
|
||||||
dateRange.value = undefined; // 清空自定义时间选择
|
value: item.value as number,
|
||||||
|
})),
|
||||||
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'),
|
|
||||||
];
|
|
||||||
|
|
||||||
|
/** 处理查询操作 */
|
||||||
|
function handleQuery() {
|
||||||
fetchMessageData();
|
fetchMessageData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理自定义日期选择
|
/** 处理时间范围变化 */
|
||||||
function handleDateChange() {
|
function handleDateRangeChange(times?: [Dayjs, Dayjs]) {
|
||||||
if (dateRange.value && dateRange.value.length === 2) {
|
if (!times || times.length !== 2) return;
|
||||||
activeTimeRange.value = ''; // 清空快捷选择
|
dateRange.value = [
|
||||||
queryParams.interval = 1; // 按天
|
dayjs(times[0]).format('YYYY-MM-DD'),
|
||||||
queryParams.times = [
|
dayjs(times[1]).format('YYYY-MM-DD'),
|
||||||
// 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();
|
// 将选择的日期转换为带时分秒的格式(开始日期 00:00:00,结束日期 23:59:59)
|
||||||
}
|
queryParams.times = formatDateRangeWithTime(dateRange.value);
|
||||||
|
handleQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取消息统计数据
|
/** 处理时间间隔变化 */
|
||||||
|
function handleIntervalChange() {
|
||||||
|
handleQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取消息统计数据 */
|
||||||
async function fetchMessageData() {
|
async function fetchMessageData() {
|
||||||
if (!queryParams.times || queryParams.times.length !== 2) return;
|
if (!queryParams.times || queryParams.times.length !== 2) return;
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
messageData.value = await getDeviceMessageSummaryByDate(queryParams);
|
messageData.value = await getDeviceMessageSummaryByDate(queryParams);
|
||||||
await nextTick();
|
|
||||||
initChart();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 开发环境:记录错误信息,便于调试
|
||||||
console.error('获取消息统计数据失败:', error);
|
console.error('获取消息统计数据失败:', error);
|
||||||
|
// 错误时清空数据,避免显示错误的数据
|
||||||
messageData.value = [];
|
messageData.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
await renderChartWhenReady();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化图表
|
/** 初始化图表 */
|
||||||
function initChart() {
|
function initChart() {
|
||||||
|
// 检查数据是否存在
|
||||||
if (!hasData.value) return;
|
if (!hasData.value) return;
|
||||||
|
|
||||||
const times = messageData.value.map((item) => item.time);
|
const times = messageData.value.map((item) => item.time);
|
||||||
const upstreamData = messageData.value.map((item) => item.upstreamCount);
|
const upstreamData = messageData.value.map((item) => item.upstreamCount);
|
||||||
const downstreamData = messageData.value.map((item) => item.downstreamCount);
|
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(
|
||||||
renderEcharts({
|
getMessageTrendChartOptions(times, upstreamData, downstreamData),
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件挂载时查询数据
|
/** 确保图表容器已经可见后再渲染 */
|
||||||
|
async function renderChartWhenReady() {
|
||||||
|
if (!hasData.value) return;
|
||||||
|
// 等待 Card loading 状态、v-show 等 DOM 更新完成
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
initChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组件挂载时查询数据 */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setTimeRange('7d'); // 默认显示近一周数据
|
fetchMessageData();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card class="chart-card" :loading="loading">
|
<Card class="h-full">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
<span class="text-base font-medium">上下行消息量统计</span>
|
<span class="text-base font-medium text-gray-600">消息量统计</span>
|
||||||
<Space :size="8">
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
<Button
|
<div class="flex items-center gap-3">
|
||||||
:type="activeTimeRange === '1h' ? 'primary' : 'default'"
|
<span class="text-sm text-gray-500 whitespace-nowrap">时间范围</span>
|
||||||
size="small"
|
<ShortcutDateRangePicker @change="handleDateRangeChange" />
|
||||||
@click="setTimeRange('1h')"
|
</div>
|
||||||
>
|
<div class="flex items-center gap-2">
|
||||||
最近1小时
|
<span class="text-sm text-gray-500">时间间隔</span>
|
||||||
</Button>
|
<Select
|
||||||
<Button
|
v-model:value="queryParams.interval"
|
||||||
:type="activeTimeRange === '24h' ? 'primary' : 'default'"
|
:options="intervalOptions"
|
||||||
size="small"
|
placeholder="间隔类型"
|
||||||
@click="setTimeRange('24h')"
|
:style="{ width: '80px' }"
|
||||||
>
|
@change="handleIntervalChange"
|
||||||
最近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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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="加载中..." />
|
<Empty description="加载中..." />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 无数据状态 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="!hasData"
|
v-show="!loading && !hasData"
|
||||||
class="flex h-[350px] items-center justify-center"
|
class="flex h-[300px] items-center justify-center"
|
||||||
>
|
>
|
||||||
<Empty description="暂无数据" />
|
<Empty description="暂无数据" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<!-- 图表容器 - 使用 v-show 而非 v-if,确保组件始终挂载 -->
|
||||||
<EchartsUI ref="messageChartRef" class="h-[350px] w-full" />
|
<div v-show="hasData">
|
||||||
|
<EchartsUI ref="messageChartRef" class="h-[300px] w-full" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// add by 芋艿:对比卡片,目前 iot 模块在使用
|
||||||
|
export { default as ComparisonCard } from './comparison-card.vue';
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface ComparisonCardProps {
|
||||||
|
/** 图标名称 */
|
||||||
|
icon: string;
|
||||||
|
/** 图标颜色类名 */
|
||||||
|
iconColor?: string;
|
||||||
|
/** 加载状态 */
|
||||||
|
loading?: boolean;
|
||||||
|
/** 标题 */
|
||||||
|
title: string;
|
||||||
|
/** 今日新增数量 */
|
||||||
|
todayCount: number;
|
||||||
|
/** 数值 */
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './api-component';
|
export * from './api-component';
|
||||||
export * from './captcha';
|
export * from './captcha';
|
||||||
|
export * from './card/comparison-card';
|
||||||
export * from './card/statistic-card';
|
export * from './card/statistic-card';
|
||||||
export * from './card/summary-card';
|
export * from './card/summary-card';
|
||||||
export * from './col-page';
|
export * from './col-page';
|
||||||
|
|||||||
Reference in New Issue
Block a user