自定义监控大屏项目实现方案
创建一个包含完整前后端的监控大屏项目,支持拖拽、鉴权、自动更新等功能。以下是详细实现步骤:
一、项目结构设计
monitor-dashboard/
├── client/ # 前端Vue3项目
└── server/ # 后端Node.js服务
二、后端服务搭建(Node.js)
- 创建后端项目
mkdir monitor-dashboard && cd monitor-dashboard mkdir server && cd server npm init -y npm install express jsonwebtoken cors express-rate-limit helmet body-parser npm install nodemon -D - 创建基础文件结构
mkdir controllers routes middleware models utils touch server.js - 编写服务器配置(server.js)
const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const bodyParser = require('body-parser'); const authRoutes = require('./routes/auth.routes'); const dashboardRoutes = require('./routes/dashboard.routes');const app = express(); const PORT = process.env.PORT || 3001;// 安全中间件 app.use(helmet()); // 增强HTTP头安全性 app.use(cors()); // 处理跨域 app.use(bodyParser.json());// 限流保护 const limiter = rateLimit({windowMs: 15 * 60 * 1000, // 15分钟max: 100 // 每IP限制请求数 }); app.use('/api/', limiter);// 路由 app.use('/api/auth', authRoutes); app.use('/api/dashboard', dashboardRoutes);// 错误处理中间件 app.use((err, req, res, next) => {console.error(err.stack);res.status(500).json({ message: '服务器内部错误' }); });app.listen(PORT, () => {console.log(`服务器运行在 http://localhost:${PORT}`); }); - 创建认证中间件(middleware/auth.middleware.js)
const jwt = require('jsonwebtoken');// JWT密钥,实际项目中应使用环境变量 const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';// 验证令牌中间件 const authenticateToken = (req, res, next) => {const authHeader = req.headers['authorization'];const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKENif (!token) return res.status(401).json({ message: '未提供令牌' });jwt.verify(token, JWT_SECRET, (err, user) => {if (err) return res.status(403).json({ message: '令牌无效或已过期' });req.user = user;next();}); };// 生成新令牌 const generateToken = (user) => {// 设置1小时过期return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '1h' }); };module.exports = { authenticateToken, generateToken }; - 创建认证路由(routes/auth.routes.js)
const express = require('express'); const router = express.Router(); const { generateToken } = require('../middleware/auth.middleware');// 模拟用户数据库 const users = [{ id: 1, username: 'admin', password: 'admin123' } // 实际项目中使用加密存储 ];// 登录接口 router.post('/login', (req, res) => {const { username, password } = req.body;// 查找用户const user = users.find(u => u.username === username && u.password === password);if (!user) {return res.status(401).json({ message: '用户名或密码错误' });}// 生成JWT令牌const token = generateToken(user);res.json({message: '登录成功',token,user: { id: user.id, username: user.username }}); });// 刷新令牌接口 router.post('/refresh-token', (req, res) => {// 在实际应用中,这里应该验证refresh tokenconst { username } = req.body;const user = users.find(u => u.username === username);if (!user) {return res.status(401).json({ message: '用户不存在' });}const newToken = generateToken(user);res.json({ token: newToken }); });module.exports = router; - 创建数据接口(routes/dashboard.routes.js)
const express = require('express'); const router = express.Router(); const { authenticateToken } = require('../middleware/auth.middleware');// 模拟数据生成函数 const generateMockData = () => {return {systemStatus: {online: Math.random() > 0.1, // 90%概率在线cpuUsage: Math.floor(Math.random() * 80) + 10, // 10-90%memoryUsage: Math.floor(Math.random() * 70) + 20, // 20-90%diskUsage: Math.floor(Math.random() * 60) + 30, // 30-90%timestamp: new Date().toISOString()},trafficData: Array.from({ length: 24 }, (_, i) => ({hour: i,value: Math.floor(Math.random() * 1000) + 100})),deviceStatus: Array.from({ length: 10 }, (_, i) => ({id: `device-${i+1}`,name: `设备 ${i+1}`,status: Math.random() > 0.2 ? '正常' : '异常', // 80%正常率temperature: Math.floor(Math.random() * 30) + 20 // 20-50°C}))}; };// 获取仪表盘数据(需要认证) router.get('/data', authenticateToken, (req, res) => {// 生成模拟数据const data = generateMockData();res.json(data); });module.exports = router; - 配置启动脚本(package.json)
"scripts": {"start": "node server.js","dev": "nodemon server.js" }
三、前端项目搭建(Vue3 + ECharts)
- 创建 Vue3 项目
cd .. # 返回项目根目录 npm create vue@latest client - 按照提示选择:
- 项目名称:client
- 类型:Customize with create-vue
- TypeScript:Yes
- JSX Support:Yes
- Vue Router:Yes
- Pinia:Yes
- ESLint:Yes
- Prettier:Yes
- 其他选项:No
3.进入前端目录并安装依赖
cd client
npm install echarts vuedraggable@next axios jwt-decode
4.创建基础目录结构
mkdir -p src/components/dashboard src/utils src/stores src/views
5.配置 Axios(src/utils/axios.js)
import axios from 'axios';
import { useAuthStore } from '../stores/auth';const api = axios.create({baseURL: 'http://localhost:3001/api',timeout: 5000
});// 请求拦截器 - 添加令牌
api.interceptors.request.use((config) => {const authStore = useAuthStore();if (authStore.token) {config.headers.Authorization = `Bearer ${authStore.token}`;}return config;},(error) => Promise.reject(error)
);// 响应拦截器 - 处理令牌过期
api.interceptors.response.use((response) => response,async (error) => {const originalRequest = error.config;// 如果是403错误且未尝试刷新令牌if (error.response.status === 403 && !originalRequest._retry) {originalRequest._retry = true;try {const authStore = useAuthStore();// 尝试刷新令牌const response = await axios.post('http://localhost:3001/api/auth/refresh-token', {username: authStore.user?.username});// 存储新令牌authStore.setToken(response.data.token);// 用新令牌重试原始请求originalRequest.headers.Authorization = `Bearer ${response.data.token}`;return api(originalRequest);} catch (refreshError) {// 刷新令牌失败,需要重新登录const authStore = useAuthStore();authStore.logout();return Promise.reject(refreshError);}}return Promise.reject(error);}
);export default api;
6.创建认证存储(src/stores/auth.js)
import { defineStore } from 'pinia';
import api from '../utils/axios';
import jwtDecode from 'jwt-decode';export const useAuthStore = defineStore('auth', {state: () => ({token: localStorage.getItem('token') || null,user: JSON.parse(localStorage.getItem('user')) || null,isAuthenticated: !!localStorage.getItem('token')}),actions: {// 登录async login(username, password) {try {const response = await api.post('/auth/login', { username, password });this.token = response.data.token;this.user = response.data.user;this.isAuthenticated = true;// 保存到本地存储localStorage.setItem('token', this.token);localStorage.setItem('user', JSON.stringify(this.user));// 设置定期刷新令牌(每55分钟)this.setupTokenRefresh();return true;} catch (error) {console.error('登录失败:', error);return false;}},// 登出logout() {this.token = null;this.user = null;this.isAuthenticated = false;// 清除本地存储localStorage.removeItem('token');localStorage.removeItem('user');// 清除刷新定时器if (this.refreshTimer) {clearInterval(this.refreshTimer);}},// 设置令牌自动刷新setupTokenRefresh() {// 每55分钟刷新一次令牌this.refreshTimer = setInterval(async () => {try {const response = await api.post('/auth/refresh-token', {username: this.user?.username});this.token = response.data.token;localStorage.setItem('token', this.token);} catch (error) {console.error('令牌刷新失败:', error);this.logout();}}, 55 * 60 * 1000); // 55分钟},// 检查令牌是否过期isTokenExpired() {if (!this.token) return true;try {const decoded = jwtDecode(this.token);return decoded.exp * 1000 < Date.now();} catch (error) {return true;}}}
});
7.创建仪表盘数据存储(src/stores/dashboard.js)
import { defineStore } from 'pinia';
import api from '../utils/axios';export const useDashboardStore = defineStore('dashboard', {state: () => ({data: null,loading: false,error: null,widgets: [{ id: 'system-status', type: 'system', title: '系统状态', x: 10, y: 10, width: 300, height: 200 },{ id: 'traffic-chart', type: 'traffic', title: '流量统计', x: 320, y: 10, width: 600, height: 400 },{ id: 'device-list', type: 'devices', title: '设备状态', x: 10, y: 220, width: 300, height: 300 }],updateInterval: null}),actions: {// 获取仪表盘数据async fetchData() {this.loading = true;this.error = null;try {const response = await api.get('/dashboard/data');this.data = response.data;return true;} catch (err) {this.error = '获取数据失败,请重试';console.error(err);return false;} finally {this.loading = false;}},// 启动数据自动更新(每分钟)startAutoUpdate() {this.fetchData();this.updateInterval = setInterval(() => {this.fetchData();}, 60 * 1000); // 1分钟},// 停止自动更新stopAutoUpdate() {if (this.updateInterval) {clearInterval(this.updateInterval);this.updateInterval = null;}},// 更新组件位置和大小updateWidget(id, newProps) {const index = this.widgets.findIndex(w => w.id === id);if (index !== -1) {this.widgets[index] = { ...this.widgets[index], ...newProps };}},// 添加新组件addWidget(type) {const id = `${type}-${Date.now()}`;this.widgets.push({id,type,title: this.getWidgetTitle(type),x: 10,y: 10,width: 300,height: 200});},// 获取组件标题getWidgetTitle(type) {const titles = {system: '系统状态',traffic: '流量统计',devices: '设备状态'};return titles[type] || '新组件';}}
});
8.创建拖拽组件容器(src/components/dashboard/DashboardContainer.vue)
<template><div class="dashboard-container"><draggable v-model="widgets" :sort="false"class="widgets-wrapper"><template #item="{ element }"><Widget :widget="element" @update:widget="updateWidget"/></template></draggable><div class="add-widgets"><button @click="addWidget('system')">添加系统状态</button><button @click="addWidget('traffic')">添加流量统计</button><button @click="addWidget('devices')">添加设备状态</button></div></div>
</template><script setup>
import { ref, computed } from 'vue';
import { useDashboardStore } from '../../stores/dashboard';
import Widget from './Widget.vue';
import draggable from 'vuedraggable';const dashboardStore = useDashboardStore();
const widgets = computed({get: () => dashboardStore.widgets,set: (value) => { dashboardStore.widgets = value; }
});const updateWidget = (id, newProps) => {dashboardStore.updateWidget(id, newProps);
};const addWidget = (type) => {dashboardStore.addWidget(type);
};
</script><style scoped>
.dashboard-container {position: relative;width: 100%;min-height: 800px;padding: 20px;background-color: #1a1a2e;
}.widgets-wrapper {display: grid;grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));gap: 20px;width: 100%;
}.add-widgets {position: fixed;bottom: 20px;right: 20px;display: flex;gap: 10px;
}.add-widgets button {padding: 8px 16px;background-color: #4CAF50;color: white;border: none;border-radius: 4px;cursor: pointer;
}
</style>
9.创建可拖拽调整大小的组件(src/components/dashboard/Widget.vue)
<template><div class="widget":style="{width: `${widget.width}px`,height: `${widget.height}px`,left: `${widget.x}px`,top: `${widget.y}px`}"v-draggable="dragOptions"><div class="widget-header"><h3>{{ widget.title }}</h3><div class="resize-handle" @mousedown="startResize"></div></div><div class="widget-content"><component :is="getWidgetComponent()" :data="getWidgetData()"/></div></div>
</template><script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useDashboardStore } from '../../stores/dashboard';
import SystemStatus from './widgets/SystemStatus.vue';
import TrafficChart from './widgets/TrafficChart.vue';
import DeviceList from './widgets/DeviceList.vue';
import { draggable } from 'vuedraggable';const props = defineProps({widget: {type: Object,required: true}
});const emit = defineEmits(['update:widget']);
const dashboardStore = useDashboardStore();
const isResizing = ref(false);
const startX = ref(0);
const startY = ref(0);
const startWidth = ref(0);
const startHeight = ref(0);// 拖拽配置
const dragOptions = {onEnd: (event) => {emit('update:widget', props.widget.id, {x: event.clientX,y: event.clientY});}
};// 根据类型获取组件
const getWidgetComponent = () => {const components = {system: SystemStatus,traffic: TrafficChart,devices: DeviceList};return components[props.widget.type] || SystemStatus;
};// 获取组件所需数据
const getWidgetData = () => {if (!dashboardStore.data) return null;const dataMap = {system: dashboardStore.data.systemStatus,traffic: dashboardStore.data.trafficData,devices: dashboardStore.data.deviceStatus};return dataMap[props.widget.type] || null;
};// 开始调整大小
const startResize = (e) => {e.preventDefault();isResizing.value = true;startX.value = e.clientX;startY.value = e.clientY;startWidth.value = props.widget.width;startHeight.value = props.widget.height;document.addEventListener('mousemove', resize);document.addEventListener('mouseup', stopResize);
};// 调整大小
const resize = (e) => {if (!isResizing.value) return;const newWidth = Math.max(200, startWidth.value + (e.clientX - startX.value));const newHeight = Math.max(150, startHeight.value + (e.clientY - startY.value));emit('update:widget', props.widget.id, {width: newWidth,height: newHeight});
};// 停止调整大小
const stopResize = () => {isResizing.value = false;document.removeEventListener('mousemove', resize);document.removeEventListener('mouseup', stopResize);
};onMounted(() => {// 如果数据未加载,触发一次加载if (!dashboardStore.data) {dashboardStore.fetchData();}
});
</script><style scoped>
.widget {position: absolute;background-color: #252a41;border-radius: 8px;box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);color: white;overflow: hidden;
}.widget-header {padding: 10px 15px;background-color: #16213e;display: flex;justify-content: space-between;align-items: center;cursor: move;
}.widget-header h3 {margin: 0;font-size: 16px;
}.widget-content {padding: 15px;width: 100%;height: calc(100% - 44px);box-sizing: border-box;
}.resize-handle {width: 15px;height: 15px;background-color: #4CAF50;border-radius: 0 0 0 5px;cursor: nwse-resize;
}
</style>
10.创建系统状态组件(src/components/dashboard/widgets/SystemStatus.vue)
<template><div class="system-status"><div class="status-indicator"><span :class="data?.online ? 'online' : 'offline'"></span><span>{{ data?.online ? '在线' : '离线' }}</span></div><div class="metrics"><div class="metric"><span>CPU使用率</span><span class="value">{{ data?.cpuUsage }}%</span></div><div class="metric"><span>内存使用率</span><span class="value">{{ data?.memoryUsage }}%</span></div><div class="metric"><span>磁盘使用率</span><span class="value">{{ data?.diskUsage }}%</span></div></div><div class="update-time">最后更新: {{ formatTime(data?.timestamp) }}</div></div>
</template><script setup>
import { defineProps } from 'vue';const props = defineProps({data: {type: Object,default: null}
});const formatTime = (timestamp) => {if (!timestamp) return '无数据';const date = new Date(timestamp);return date.toLocaleString();
};
</script><style scoped>
.system-status {display: flex;flex-direction: column;height: 100%;
}.status-indicator {display: flex;align-items: center;gap: 8px;margin-bottom: 20px;
}.status-indicator span:first-child {width: 12px;height: 12px;border-radius: 50%;display: inline-block;
}.online {background-color: #4CAF50;
}.offline {background-color: #f44336;
}.metrics {flex-grow: 1;display: flex;flex-direction: column;justify-content: center;gap: 15px;
}.metric {display: flex;justify-content: space-between;
}.metric .value {font-weight: bold;
}.update-time {margin-top: auto;font-size: 12px;color: #aaa;text-align: right;
}
</style>
11.创建流量图表组件(src/components/dashboard/widgets/TrafficChart.vue)
<template><div class="traffic-chart" ref="chartContainer"></div>
</template><script setup>
import { defineProps, ref, onMounted, watch } from 'vue';
import * as echarts from 'echarts';const props = defineProps({data: {type: Array,default: () => []}
});const chartContainer = ref(null);
let chart = null;// 初始化图表
const initChart = () => {if (chart) {chart.dispose();}chart = echarts.init(chartContainer.value);// 设置图表配置const option = {grid: {left: '3%',right: '4%',bottom: '3%',containLabel: true},xAxis: {type: 'category',data: props.data.map(item => `${item.hour}:00`),axisLine: {lineStyle: {color: '#666'}},axisLabel: {color: '#ccc'}},yAxis: {type: 'value',axisLine: {lineStyle: {color: '#666'}},axisLabel: {color: '#ccc'}},series: [{data: props.data.map(item => item.value),type: 'line',smooth: true,areaStyle: {color: {type: 'linear',x: 0,y: 0,x2: 0,y2: 1,colorStops: [{ offset: 0, color: 'rgba(76, 175, 80, 0.6)' },{ offset: 1, color: 'rgba(76, 175, 80, 0)' }]}},lineStyle: {color: '#4CAF50'},itemStyle: {color: '#4CAF50'}}]};chart.setOption(option);
};// 监听数据变化,更新图表
watch(() => props.data,(newData) => {if (newData.length && chartContainer.value) {initChart();}},{ deep: true }
);// 窗口大小变化时重绘图表
const handleResize = () => {if (chart) {chart.resize();}
};onMounted(() => {if (props.data.length) {initChart();}window.addEventListener('resize', handleResize);
});// 清理
onUnmounted(() => {window.removeEventListener('resize', handleResize);if (chart) {chart.dispose();}
});
</script><style scoped>
.traffic-chart {width: 100%;height: 100%;
}
</style>
12.创建设备列表组件(src/components/dashboard/widgets/DeviceList.vue)
<template><div class="device-list"><div class="list-header"><div class="id-column">ID</div><div class="name-column">名称</div><div class="status-column">状态</div><div class="temp-column">温度</div></div><div class="list-body"><div class="device-item" v-for="device in data" :key="device.id"><div class="id-column">{{ device.id }}</div><div class="name-column">{{ device.name }}</div><div class="status-column"><span :class="device.status === '正常' ? 'status-normal' : 'status-error'">{{ device.status }}</span></div><div class="temp-column">{{ device.temperature }}°C</div></div><div v-if="!data || data.length === 0" class="no-data">暂无设备数据</div></div></div>
</template><script setup>
import { defineProps } from 'vue';const props = defineProps({data: {type: Array,default: () => []}
});
</script><style scoped>
.device-list {display: flex;flex-direction: column;height: 100%;overflow: hidden;
}.list-header {display: flex;padding: 8px 0;font-weight: bold;border-bottom: 1px solid #444;
}.list-body {flex-grow: 1;overflow-y: auto;padding: 5px 0;
}.device-item {display: flex;padding: 8px 0;border-bottom: 1px solid #333;
}.device-item:last-child {border-bottom: none;
}.id-column {width: 20%;
}.name-column {width: 30%;
}.status-column {width: 25%;
}.temp-column {width: 25%;
}.status-normal {color: #4CAF50;
}.status-error {color: #f44336;
}.no-data {text-align: center;padding: 20px;color: #aaa;
}
</style>
13.创建登录页面(src/views/LoginView.vue)
<template><div class="login-container"><div class="login-form"><h2>监控大屏登录</h2><div class="form-group"><label for="username">用户名</label><input type="text" id="username" v-model="username" placeholder="请输入用户名"></div><div class="form-group"><label for="password">密码</label><input type="password" id="password" v-model="password" placeholder="请输入密码"></div><button @click="handleLogin" :disabled="loading"><span v-if="loading">登录中...</span><span v-else>登录</span></button><p class="error-message" v-if="error">{{ error }}</p></div></div>
</template><script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';const username = ref('admin');
const password = ref('admin123');
const loading = ref(false);
const error = ref('');
const router = useRouter();
const authStore = useAuthStore();const handleLogin = async () => {if (!username.value || !password.value) {error.value = '请输入用户名和密码';return;}loading.value = true;error.value = '';try {const success = await authStore.login(username.value, password.value);if (success) {router.push('/dashboard');} else {error.value = '登录失败,请检查用户名和密码';}} catch (err) {error.value = '登录过程中发生错误';console.error(err);} finally {loading.value = false;}
};
</script><style scoped>
.login-container {display: flex;justify-content: center;align-items: center;height: 100vh;background-color: #f5f5f5;
}.login-form {width: 350px;padding: 30px;background-color: white;border-radius: 8px;box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}.login-form h2 {text-align: center;margin-bottom: 25px;color: #333;
}.form-group {margin-bottom: 20px;
}.form-group label {display: block;margin-bottom: 8px;color: #666;
}.form-group input {width: 100%;padding: 10px;border: 1px solid #ddd;border-radius: 4px;box-sizing: border-box;
}.login-form button {width: 100%;padding: 12px;background-color: #4CAF50;color: white;border: none;border-radius: 4px;cursor: pointer;font-size: 16px;
}.login-form button:disabled {background-color: #cccccc;cursor: not-allowed;
}.error-message {color: #f44336;text-align: center;margin-top: 15px;margin-bottom: 0;
}
</style>
14.创建仪表盘页面(src/views/DashboardView.vue)
<template><div class="dashboard-view"><header><h1>自定义监控大屏</h1><button @click="handleLogout">退出登录</button></header><main><DashboardContainer /></main></div>
</template><script setup>
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useDashboardStore } from '../stores/dashboard';
import DashboardContainer from '../components/dashboard/DashboardContainer.vue';const router = useRouter();
const authStore = useAuthStore();
const dashboardStore = useDashboardStore();// 检查登录状态
onMounted(() => {if (!authStore.isAuthenticated || authStore.isTokenExpired()) {router.push('/login');return;}// 启动数据自动更新dashboardStore.startAutoUpdate();
});// 退出登录
const handleLogout = () => {authStore.logout();dashboardStore.stopAutoUpdate();router.push('/login');
};
</script><style scoped>
.dashboard-view {width: 100%;min-height: 100vh;background-color: #1a1a2e;color: white;
}header {display: flex;justify-content: space-between;align-items: center;padding: 15px 30px;background-color: #16213e;
}header h1 {margin: 0;font-size: 24px;
}header button {padding: 8px 16px;background-color: #f44336;color: white;border: none;border-radius: 4px;cursor: pointer;
}main {padding: 20px;
}
</style>
15.配置路由(src/router/index.js)
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import LoginView from '../views/LoginView.vue';
import DashboardView from '../views/DashboardView.vue';const router = createRouter({history: createWebHistory(import.meta.env.BASE_URL),routes: [{path: '/login',name: 'login',component: LoginView},{path: '/dashboard',name: 'dashboard',component: DashboardView,meta: { requiresAuth: true }},{path: '/',redirect: '/dashboard'}]
});// 路由守卫 - 检查认证状态
router.beforeEach((to, from, next) => {const authStore = useAuthStore();// 如果路由需要认证if (to.meta.requiresAuth) {// 检查是否已登录且令牌未过期if (authStore.isAuthenticated && !authStore.isTokenExpired()) {next();} else {// 未登录或令牌过期,重定向到登录页next('/login');}} else {// 不需要认证的路由next();}
});export default router;
16.启动前端服务
四、项目运行说明
五、功能说明
这个项目提供了一个完整的监控大屏解决方案,你可以根据实际需求扩展更多类型的组件和数据接口。
- 先启动后端服务
cd server npm run dev - 再启动前端服务
cd ../client npm run dev -
访问 http://localhost:5173 即可看到登录页面,使用默认账号密码 admin/admin123 登录
-
登录后进入监控大屏,可以:
- 拖拽组件改变位置
- 拖动右下角调整组件大小
- 点击底部按钮添加新组件
- 数据会每分钟自动更新
- 令牌会每 55 分钟自动刷新
- 长时间未操作也不会因令牌过期而退出
- 拖拽功能:使用 vuedraggable 实现组件拖拽,自定义指令实现大小调整
- 鉴权功能:基于 JWT 的认证机制,保护 API 接口
- 令牌自动更新:55 分钟自动刷新一次令牌,避免会话过期
- 数据自动更新:每分钟自动获取最新数据并更新图表
- 响应式设计:图表会根据组件大小自动调整
这个项目提供了一个完整的监控大屏解决方案,你可以根据实际需求扩展更多类型的组件和数据接口。
