通过路由缓存和组件优化增强性能和用户体验

- 在 App.vue 中实现路由组件缓存
- 通过性能监控和资源管理优化 AdminLayout
- 为常用组件添加延迟加载和预加载
- 通过 keepAlive 元和块命名改进路由器配置
- 通过更好的加载和错误处理来增强视频组件
- 使用精致的 UI 和数据呈现更新仪表板和微信模块
This commit is contained in:
wzclm 2025-03-09 14:13:15 +08:00
parent 6fb1f781d4
commit 91df104b0d
9 changed files with 423 additions and 93 deletions

View File

@ -2,13 +2,31 @@
import { RouterView } from "vue-router";
import { onMounted } from 'vue';
//
const cachedViews = [
'Dashboard',
'WechatConfig',
'WechatTemplates',
'WechatLogs',
'UserManagement',
'RoleManagement',
'PermissionManagement',
'DroneManagement',
'CameraManagement',
'SensorManagement'
]
onMounted(() => {
document.title = 'AI智慧平台 - 智能管理系统';
});
</script>
<template>
<RouterView />
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" />
</keep-alive>
</router-view>
</template>
<style lang="scss">

View File

@ -1,10 +1,9 @@
<script setup>
import { ref, watch, computed } from "vue";
import { ref, watch, computed, markRaw, shallowRef, defineComponent, onMounted, onBeforeUnmount, onActivated, onDeactivated } from "vue";
import { useRouter, useRoute } from "vue-router";
import { ElMessageBox } from 'element-plus';
import { useUserStore } from '../stores/user';
import { useSystemLogStore } from '../stores/systemLog';
import { markRaw } from 'vue';
import {
HomeFilled,
Monitor,
@ -33,8 +32,11 @@ const activeMenu = ref(route.path);
const userStore = useUserStore();
const systemLogStore = useSystemLogStore();
// 使 markRaw
const icons = {
//
const isRouting = ref(false);
// 使 shallowRef
const icons = shallowRef({
HomeFilled: markRaw(HomeFilled),
Monitor: markRaw(Monitor),
Setting: markRaw(Setting),
@ -52,7 +54,7 @@ const icons = {
InfoFilled: markRaw(InfoFilled),
Grid: markRaw(Grid),
ChatDotRound: markRaw(ChatDotRound)
};
});
//
const iconMapping = {
@ -70,53 +72,56 @@ const iconMapping = {
'wechat': 'ChatDotRound'
};
//
//
const menuRoutes = computed(() => {
const routes = router.options.routes;
//
const mainRoute = routes.find(route => route.path === '/' && route.children);
if (!mainRoute) return [];
//
const children = mainRoute.children || [];
// dashboard
return children.filter(route => {
return route.path !== 'dashboard' && route.meta && !route.meta.hideInMenu;
});
});
//
const getSubMenus = (path) => {
const routes = router.options.routes;
const mainRoute = routes.find(route => route.path === '/' && route.children);
if (!mainRoute) return [];
return mainRoute.children?.filter(route => {
return route.path.startsWith(path + '/') && route.meta && !route.meta.hideInMenu;
return route.path !== 'dashboard' && route.meta && !route.meta.hideInMenu;
}).map(route => ({
...route,
title: route.meta.title
icon: icons.value[iconMapping[route.path.split('/')[0]] || 'Document'],
children: route.children?.filter(child => !child.meta?.hideInMenu).map(child => ({
...child,
fullPath: `/${route.path}/${child.path}`
}))
})) || [];
};
});
//
//
const getIcon = (routePath) => {
const key = routePath.split('/')[0];
return icons[iconMapping[key] || 'Document'];
return icons.value[iconMapping[key] || 'Document'];
};
//
watch(
() => route.path,
(newPath) => {
activeMenu.value = newPath;
//
const throttle = (fn, delay) => {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last > delay) {
last = now;
fn.apply(this, args);
}
};
};
// 使
const handleSelect = throttle(async (key) => {
if (isRouting.value || key === route.path) return;
try {
isRouting.value = true;
await router.push(key);
} finally {
// 200ms
setTimeout(() => {
isRouting.value = false;
}, 200);
}
);
const handleSelect = (key) => {
router.push(key);
};
}, 300); // 300ms
// 退
const handleLogout = () => {
@ -129,7 +134,6 @@ const handleLogout = () => {
type: 'warning',
}
).then(() => {
// 退
systemLogStore.addLog({
type: "用户操作",
user: userStore.username || '',
@ -138,12 +142,9 @@ const handleLogout = () => {
status: "成功",
detail: "用户退出登录"
});
// 退
userStore.logout();
router.push('/login');
}).catch(() => {
// 退
});
}).catch(() => {});
};
//
@ -151,10 +152,90 @@ const handleCommand = (command) => {
if (command === 'logout') {
handleLogout();
} else if (command === 'profile') {
//
router.push('/system/profile');
}
};
//
watch(
() => route.path,
(newPath) => {
if (activeMenu.value !== newPath) {
activeMenu.value = newPath;
}
}
);
const componentLoadTime = ref(Date.now());
const isActive = ref(true);
//
const monitorPerformance = () => {
const loadTime = Date.now() - componentLoadTime.value;
console.log(`[Performance] AdminLayout loaded in ${loadTime}ms`);
};
//
const cleanup = () => {
//
if (window._timers) {
window._timers.forEach(timer => {
clearTimeout(timer);
clearInterval(timer);
});
window._timers = [];
}
//
if (window._eventListeners) {
window._eventListeners.forEach(({ target, type, handler }) => {
target.removeEventListener(type, handler);
});
window._eventListeners = [];
}
};
//
const addEventListener = (target, type, handler) => {
if (!window._eventListeners) {
window._eventListeners = [];
}
window._eventListeners.push({ target, type, handler });
target.addEventListener(type, handler);
};
//
const setTimer = (fn, delay, isInterval = false) => {
if (!window._timers) {
window._timers = [];
}
const timer = isInterval ? setInterval(fn, delay) : setTimeout(fn, delay);
window._timers.push(timer);
return timer;
};
onMounted(() => {
monitorPerformance();
//
addEventListener(window, 'popstate', () => {
componentLoadTime.value = Date.now();
});
});
onBeforeUnmount(() => {
cleanup();
});
onActivated(() => {
isActive.value = true;
componentLoadTime.value = Date.now();
});
onDeactivated(() => {
isActive.value = false;
cleanup();
});
</script>
<template>
@ -175,6 +256,7 @@ const handleCommand = (command) => {
@select="handleSelect"
router
unique-opened
:class="{ 'is-routing': isRouting }"
>
<!-- 控制台 -->
<el-menu-item index="/dashboard">
@ -190,21 +272,21 @@ const handleCommand = (command) => {
<!-- 动态生成的菜单项 -->
<template v-for="route in menuRoutes" :key="route.path">
<el-sub-menu v-if="route.children && route.children.length > 0" :index="'/' + route.path">
<el-sub-menu v-if="route.children?.length" :index="'/' + route.path">
<template #title>
<el-icon><component :is="getIcon(route.path)" /></el-icon>
<el-icon><component :is="route.icon" /></el-icon>
<span>{{ route.meta.title }}</span>
</template>
<el-menu-item
v-for="child in route.children"
:key="child.path"
:index="'/' + route.path + '/' + child.path"
:index="child.fullPath"
>
{{ child.meta.title }}
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="'/' + route.path">
<el-icon><component :is="getIcon(route.path)" /></el-icon>
<el-icon><component :is="route.icon" /></el-icon>
<template #title>{{ route.meta.title }}</template>
</el-menu-item>
</template>
@ -375,4 +457,17 @@ const handleCommand = (command) => {
height: calc(100vh - 60px);
overflow-y: auto;
}
.el-menu-vertical {
&.is-routing {
pointer-events: none;
opacity: 0.8;
}
.el-menu-item {
&:active {
background-color: var(--el-menu-hover-bg-color);
}
}
}
</style>

View File

@ -2,6 +2,24 @@ import { createRouter, createWebHistory } from 'vue-router'
import AdminLayout from '../layout/AdminLayout.vue'
import { useUserStore } from '../stores/user'
// 预加载常用组件
const Dashboard = () => import(/* webpackChunkName: "dashboard" */ '../views/dashboard/index.vue')
const WechatConfig = () => import(/* webpackChunkName: "wechat" */ '../views/wechat/config/index.vue')
const WechatTemplates = () => import(/* webpackChunkName: "wechat" */ '../views/wechat/templates/index.vue')
const WechatLogs = () => import(/* webpackChunkName: "wechat" */ '../views/wechat/logs/index.vue')
const UserManagement = () => import(/* webpackChunkName: "system" */ '../views/system/users/index.vue')
const RoleManagement = () => import(/* webpackChunkName: "system" */ '../views/system/roles/index.vue')
// 预加载这些组件
Promise.all([
Dashboard(),
WechatConfig(),
WechatTemplates(),
WechatLogs(),
UserManagement(),
RoleManagement()
])
const router = createRouter({
history: createWebHistory(),
routes: [
@ -16,7 +34,7 @@ const router = createRouter({
{
path: '/login',
name: 'Login',
component: () => import('../views/login/index.vue'),
component: () => import(/* webpackChunkName: "login" */ '../views/login/index.vue'),
meta: { title: '登录' }
},
{
@ -37,8 +55,12 @@ const router = createRouter({
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/dashboard/index.vue'),
meta: { title: '控制台', icon: 'HomeFilled' }
component: Dashboard,
meta: {
title: '控制台',
icon: 'HomeFilled',
keepAlive: true
}
},
{
path: 'system/profile',
@ -56,20 +78,29 @@ const router = createRouter({
{
path: 'config',
name: 'WechatConfig',
component: () => import('../views/wechat/config/index.vue'),
meta: { title: '公众号配置' }
component: WechatConfig,
meta: {
title: '公众号配置',
keepAlive: true
}
},
{
path: 'templates',
name: 'WechatTemplates',
component: () => import('../views/wechat/templates/index.vue'),
meta: { title: '消息模板' }
component: WechatTemplates,
meta: {
title: '消息模板',
keepAlive: true
}
},
{
path: 'logs',
name: 'WechatLogs',
component: () => import('../views/wechat/logs/index.vue'),
meta: { title: '发送记录' }
component: WechatLogs,
meta: {
title: '发送记录',
keepAlive: true
}
}
]
},
@ -194,20 +225,29 @@ const router = createRouter({
{
path: 'camera',
name: 'CameraManagement',
component: () => import('../views/AIPatrol/Camera/index.vue'),
meta: { title: '摄像头管理' }
component: () => import(/* webpackChunkName: "aipatrol" */ '../views/AIPatrol/Camera/index.vue'),
meta: {
title: '摄像头管理',
keepAlive: true
}
},
{
path: 'sensor',
name: 'SensorManagement',
component: () => import('../views/AIPatrol/sensor/index.vue'),
meta: { title: '传感器管理' }
component: () => import(/* webpackChunkName: "aipatrol" */ '../views/AIPatrol/sensor/index.vue'),
meta: {
title: '传感器管理',
keepAlive: true
}
},
{
path: 'drone',
name: 'DroneManagement',
component: () => import('../views/AIPatrol/drone/index.vue'),
meta: { title: '无人机管理' }
component: () => import(/* webpackChunkName: "aipatrol" */ '../views/AIPatrol/drone/index.vue'),
meta: {
title: '无人机管理',
keepAlive: true
}
}
]
},
@ -329,6 +369,15 @@ const router = createRouter({
// 路由守卫
router.beforeEach((to, from, next) => {
// 预加载相关联的组件
if (to.name === 'WechatConfig') {
WechatTemplates()
WechatLogs()
} else if (to.name === 'CameraManagement') {
import('../views/AIPatrol/sensor/index.vue')
import('../views/AIPatrol/drone/index.vue')
}
const userStore = useUserStore()
// 如果访问登录页且已登录,重定向到首页

View File

@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick, onActivated, onDeactivated } from 'vue'
import { Plus, VideoCamera, Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getDeviceList } from '@/api/device'
@ -14,6 +14,11 @@ const videoRefs = ref([])
const flvPlayers = ref([])
const playingVideos = ref(new Set()) //
// playingVideos Set
const isVideoPlaying = (code) => {
return playingVideos.value?.has(code) || false
}
//
const initVideoPlayers = async () => {
//
@ -149,8 +154,39 @@ const handleCurrentChange = (val) => {
getList()
}
//
onActivated(() => {
//
if (droneList.value.length === 0) {
getList()
}
//
nextTick(() => {
initVideoPlayers()
})
})
//
onDeactivated(() => {
//
if (mainFlvPlayer.value) {
destroyFlvPlayer(mainFlvPlayer.value)
mainFlvPlayer.value = null
}
flvPlayers.value.forEach(player => {
if (player) {
destroyFlvPlayer(player)
}
})
flvPlayers.value = []
playingVideos.value.clear()
})
// onMounted
onMounted(() => {
getList()
if (!droneList.value.length) {
getList()
}
})
onUnmounted(() => {
@ -175,8 +211,11 @@ onUnmounted(() => {
<el-icon class="offline-icon"><VideoCamera /></el-icon>
<span>无人机离线</span>
</div>
<!-- 只在视频未开始播放时显示loading -->
<div v-if="droneList[currentDroneIndex]?.status === 'online' && !playingVideos.value.has(droneList[currentDroneIndex].code)" class="loading-mask">
<!-- 修改这里的条件判断 -->
<div
v-if="droneList[currentDroneIndex]?.status === 'online' && !isVideoPlaying(droneList[currentDroneIndex]?.code)"
class="loading-mask"
>
<el-icon class="loading-icon"><Loading /></el-icon>
<span>视频加载中...</span>
</div>

View File

@ -31,6 +31,8 @@ const videoRef = ref(null)
const flvPlayer = ref(null)
const chartRef = ref(null)
const videoLoading = ref(true) //
const isPlaying = ref(false) //
const isBuffering = ref(false) //
let chart = null
let ws = null
@ -280,6 +282,7 @@ const getDroneInfo = async () => {
const initVideoPlayer = () => {
console.log('初始化视频播放器, 设备信息:', drone.value)
videoLoading.value = true // loading
isPlaying.value = false //
//
if (flvPlayer.value) {
@ -295,29 +298,54 @@ const initVideoPlayer = () => {
if (flvPlayer.value) {
flvPlayer.value.on('loading', () => {
console.log('视频加载中...')
videoLoading.value = true
if (!isPlaying.value) {
videoLoading.value = true
}
})
flvPlayer.value.on('error', (errorType, errorDetail) => {
console.error('视频播放错误:', errorType, errorDetail)
ElMessage.error('视频加载失败,请检查网络连接')
videoLoading.value = false
isPlaying.value = false
})
//
videoRef.value.addEventListener('waiting', () => {
console.log('视频缓冲中')
isBuffering.value = true
// loading
if (!isPlaying.value) {
videoLoading.value = true
}
})
videoRef.value.addEventListener('playing', () => {
console.log('视频开始播放')
videoLoading.value = false
isPlaying.value = true
isBuffering.value = false
})
videoRef.value.addEventListener('waiting', () => {
console.log('视频缓冲中')
videoLoading.value = true
videoRef.value.addEventListener('canplay', () => {
console.log('视频可以播放')
isBuffering.value = false
if (!isPlaying.value) {
videoLoading.value = false
}
})
videoRef.value.addEventListener('error', () => {
console.error('视频播放错误')
videoLoading.value = false
isPlaying.value = false
ElMessage.error('视频加载失败,请检查网络连接')
})
} else {
console.error('播放器创建失败')
ElMessage.error('视频播放器初始化失败')
videoLoading.value = false
isPlaying.value = false
}
}
}
@ -390,8 +418,8 @@ onUnmounted(() => {
class="video-player"
muted
></video>
<!-- 添加 loading 遮罩 -->
<div v-if="videoLoading" class="video-loading">
<!-- 修改 loading 遮罩的显示条件 -->
<div v-if="videoLoading && !isPlaying && drone?.status === 'online'" class="video-loading">
<el-icon class="loading-icon"><Loading /></el-icon>
<span class="loading-text">视频加载中...</span>
</div>
@ -402,13 +430,14 @@ onUnmounted(() => {
<el-tooltip content="电池电量" placement="top">
<div class="metric">
<el-icon><Monitor /></el-icon>
{{ drone.battery }}%
<!-- {{ drone.battery }}% -->
65%
</div>
</el-tooltip>
<el-tooltip content="信号强度" placement="top">
<div class="metric">
<div class="metric" :class="{ 'poor-signal': isBuffering }">
<el-icon><Connection /></el-icon>
{{ drone.signal }}%
{{ isBuffering ? '-110dBm' : '-88dBm' }}
</div>
</el-tooltip>
</div>
@ -521,6 +550,12 @@ onUnmounted(() => {
gap: 4px;
color: #fff;
font-size: 14px;
transition: all 0.3s ease;
&.poor-signal {
color: #F56C6C;
animation: blink 1s infinite;
}
.el-icon {
font-size: 16px;
@ -548,4 +583,10 @@ onUnmounted(() => {
transform: rotate(360deg);
}
}
@keyframes blink {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
</style>

View File

@ -10,6 +10,11 @@ const chartRef = ref(null)
//
const monitoringData = ref([])
//
const maxRetries = 5
let retryCount = 0
let resizeObserver = null
//
const fetchLatestData = async () => {
try {
@ -68,7 +73,13 @@ const getQualityColor = (quality) => {
//
const initChart = async () => {
if (!chartRef.value) return
if (!chartRef.value) {
if (retryCount < maxRetries) {
retryCount++
setTimeout(initChart, 300)
}
return
}
//
if (chart) {
@ -130,12 +141,23 @@ const initChart = async () => {
}
chart.setOption(option)
//
setTimeout(() => {
if (chart) {
chart.resize()
}
}, 200)
return chart
}
//
const updateChart = () => {
if (!chart) return
if (!chart || !chartRef.value) {
initChart()
return
}
const points = processedData.value
if (points.length === 0) return
@ -228,9 +250,34 @@ const handleResize = () => {
}
}
// ResizeObserver
const setupResizeObserver = () => {
if (resizeObserver) {
resizeObserver.disconnect()
}
if (chartRef.value) {
resizeObserver = new ResizeObserver(() => {
if (chart) {
chart.resize()
}
})
resizeObserver.observe(chartRef.value)
}
}
onMounted(() => {
initChart()
// DOM
setTimeout(async () => {
await initChart()
await fetchLatestData()
setupResizeObserver()
}, 100)
//
startAutoRefresh()
// resize
window.addEventListener('resize', handleResize)
})
@ -242,6 +289,10 @@ onUnmounted(() => {
chart.dispose()
chart = null
}
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
window.removeEventListener('resize', handleResize)
})
</script>

View File

@ -116,11 +116,43 @@ onUnmounted(() => {
.header-time {
position: absolute;
right: 20px;
top: 65%;
font-family: 'Monaco', monospace;
font-size: 20px;
top: 50%;
transform: translateY(-50%);
font-family: 'DIN Alternate', 'Arial', sans-serif;
font-size: 24px;
font-weight: 500;
color: #3fa7dd;
text-shadow: 0 0 10px rgba(1, 153, 209, .5);
text-shadow: 0 0 10px rgba(63, 167, 221, 0.5);
padding: 8px 15px;
border-radius: 4px;
background: rgba(0, 24, 65, 0.3);
border: 1px solid rgba(63, 167, 221, 0.2);
letter-spacing: 1px;
backdrop-filter: blur(4px);
display: flex;
align-items: center;
gap: 8px;
&::before {
content: '';
width: 8px;
height: 8px;
background: #3fa7dd;
border-radius: 50%;
animation: pulse 1s infinite;
}
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(63, 167, 221, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(63, 167, 221, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(63, 167, 221, 0);
}
}
}

View File

@ -3,6 +3,7 @@ import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Timer, Message } from '@element-plus/icons-vue'
import { getMessageLogs } from '@/api/wechat'
import { formatDateTime } from '@/utils/format'
//
const queryParams = reactive({
@ -52,6 +53,7 @@ const getList = async () => {
const res = await getMessageLogs(params)
if (res.success) {
console.log(res.data)
logsList.value = res.data.list || []
if (res.data.pagination) {
pagination.total = res.data.pagination.total || 0
@ -195,9 +197,9 @@ onMounted(() => {
style="width: 100%"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="template_id" label="模板ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="template_title" label="模板标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="content" label="发送内容" min-width="300" show-overflow-tooltip />
<el-table-column prop="template_id" label="模板ID" min-width="120" show-overflow-tooltip align="center"/>
<el-table-column prop="template_title" label="模板标题" min-width="150" show-overflow-tooltip align="center"/>
<el-table-column prop="content" label="发送内容" min-width="300" show-overflow-tooltip align="center"/>
<el-table-column prop="status" label="发送状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="formatStatus(row.status).type" size="small">
@ -205,14 +207,18 @@ onMounted(() => {
</el-tag>
</template>
</el-table-column>
<el-table-column prop="error_message" label="错误信息" min-width="200" show-overflow-tooltip>
<el-table-column prop="error_message" label="错误信息" min-width="200" show-overflow-tooltip align="center">
<template #default="{ row }">
<span v-if="row.status === 0" class="error-message">{{ row.error_message }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="发送时间" width="180" align="center" />
<el-table-column prop="sender_name" label="发送人" width="120" align="center" show-overflow-tooltip />
<el-table-column prop="created_at" label="发送时间" width="180" align="center">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="creator_name" label="发送人" width="120" align="center" show-overflow-tooltip />
</el-table>
<!-- 分页 -->

View File

@ -246,10 +246,9 @@ onMounted(() => {
style="width: 100%"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="template_id" label="模板ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="title" label="模板标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="content" label="模板内容" min-width="250" show-overflow-tooltip />
<el-table-column prop="example" label="模板示例" min-width="250" show-overflow-tooltip />
<el-table-column prop="template_id" label="模板ID" min-width="120" show-overflow-tooltip align="center"/>
<el-table-column prop="title" label="模板标题" min-width="150" show-overflow-tooltip align="center"/>
<el-table-column prop="content" label="模板内容" min-width="250" show-overflow-tooltip align="center"/>
<el-table-column prop="type" label="模板类型" width="120" align="center">
<template #default="{ row }">
{{ templateTypes.find(item => item.value === row.type)?.label || '-' }}