Files
ruoyi-vue/src/components/MobileBottomNav/index.vue
2026-03-11 21:56:24 +08:00

250 lines
6.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="mobile-bottom-nav" :class="{ 'scrollable': navItems.length > 5 }" v-if="isMobile && show">
<div
v-for="item in navItems"
:key="item.path"
class="nav-item"
:class="{ 'active': isActive(item.path) }"
@click="handleNavClick(item)"
>
<div class="nav-icon">
<i :class="item.icon" v-if="item.icon"></i>
<svg-icon :icon-class="item.iconClass" v-else-if="item.iconClass" />
<el-badge :value="item.badge" :hidden="!item.badge" v-if="item.badge">
<div class="icon-placeholder"></div>
</el-badge>
</div>
<div class="nav-label">{{ item.label }}</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'MobileBottomNav',
props: {
items: {
type: Array,
default: () => []
},
show: {
type: Boolean,
default: true
}
},
computed: {
...mapGetters(['device', 'sidebarRouters']),
isMobile() {
return this.device === 'mobile' || window.innerWidth < 768
},
navItems() {
// 如果提供了自定义 items直接使用
if (this.items && this.items.length > 0) {
return this.items
}
// 始终从 store 的 sidebarRouters 计算,保证与接口返回、路由注册一致,避免移动端菜单/跳转错乱
const routes = this.sidebarRouters || []
const flatRoutes = this.flattenRoutes(routes)
const mainRoutes = flatRoutes.filter(route => {
const excludePaths = ['/redirect', '/login', '/register', '/404', '/401', '/user/profile']
const path = route.path || ''
return path &&
path !== '/' &&
!excludePaths.some(exclude => path.includes(exclude)) &&
!path.startsWith('/user/')
})
if (mainRoutes.length > 0) {
return mainRoutes
}
return [
{ path: '/sloworder/index', label: '首页', icon: 'el-icon-s-home' }
]
}
},
methods: {
/** 扁平化路由为叶子节点,路径与 Vue Router 注册的完整 path 一致 */
flattenRoutes(routes, parentPath = '') {
if (!routes || !Array.isArray(routes)) return []
const result = []
routes.forEach(route => {
if (route.hidden) return
let fullPath = (route.path || '').trim()
if (parentPath) {
if (fullPath.startsWith('/')) {
// 已是绝对路径,直接使用
} else {
const base = parentPath.endsWith('/') ? parentPath.slice(0, -1) : parentPath
fullPath = `${base}/${fullPath}`.replace(/\/+/g, '/')
}
}
if (fullPath && !fullPath.startsWith('/')) {
fullPath = '/' + fullPath
}
if (route.children && route.children.length > 0) {
result.push(...this.flattenRoutes(route.children, fullPath))
} else if (route.meta && route.meta.title && fullPath) {
result.push({
path: fullPath,
label: route.meta.title,
icon: route.meta.icon || 'el-icon-menu',
iconClass: route.meta.icon,
route
})
}
})
return result
},
isActive(path) {
if (!path) return false
const currentPath = this.$route.path
return currentPath === path || currentPath.startsWith(path + '/')
},
handleNavClick(item) {
if (item.handler) {
item.handler()
this.$emit('nav-click', item)
return
}
if (item.path) {
// 确保路径正确
let path = item.path
if (!path.startsWith('/')) {
path = `/${path}`
}
// 移除末尾的斜杠(除了根路径)
if (path !== '/' && path.endsWith('/')) {
path = path.slice(0, -1)
}
// 尝试导航
this.$router.push(path).catch(err => {
// 如果push失败尝试replace
if (err.name !== 'NavigationDuplicated') {
this.$router.replace(path).catch(() => {
console.error('Navigation to', path, 'failed')
// 不显示错误消息,避免打扰用户
})
}
})
}
this.$emit('nav-click', item)
}
}
}
</script>
<style lang="scss" scoped>
.mobile-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: #fff;
border-top: 1px solid #e4e7ed;
display: flex;
justify-content: flex-start;
align-items: center;
z-index: 1000;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
padding-bottom: env(safe-area-inset-bottom);
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
padding: 0 4px;
// 隐藏滚动条但保持滚动功能
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
.nav-item {
flex: 0 0 auto;
min-width: 70px;
max-width: 90px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6px 4px;
cursor: pointer;
transition: all 0.3s;
-webkit-tap-highlight-color: transparent;
.nav-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
position: relative;
i, .svg-icon {
font-size: 22px;
color: #909399;
transition: all 0.3s;
}
.icon-placeholder {
width: 22px;
height: 22px;
}
}
.nav-label {
font-size: 11px;
color: #909399;
transition: all 0.3s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-align: center;
line-height: 1.2;
}
&.active {
.nav-icon {
i, .svg-icon {
color: #409eff;
font-size: 24px;
}
}
.nav-label {
color: #409eff;
font-weight: 600;
}
}
&:active {
transform: scale(0.95);
opacity: 0.8;
}
}
}
// 桌面端隐藏
@media (min-width: 769px) {
.mobile-bottom-nav {
display: none;
}
}
// 为底部导航预留空间
@media (max-width: 768px) {
.app-main {
padding-bottom: 60px !important;
}
}
</style>