feat: 新增二级菜单子导航系统(SubNav + redirectMenu)

- 新增 SubNav 组件:Navbar 下方水平子导航条,展示当前二级菜单下的三级页面,支持滚动和下拉分组
- 新增 redirectMenu 页面:点击二级菜单时卡片式展示子页面列表
- SidebarItem 深度 >=1 时改为叶子节点渲染(小圆点标记),激活高亮回退到二级菜单
- Navbar 布局重构为 Flexbox,面包屑/SubNav 根据场景切换
- 新增 productionLine Vuex 模块,轧辊研磨页改为 store 方式读取产线数据
- Sidebar activeMenu 逻辑增强:自动定位当前页所属二级菜单
This commit is contained in:
2026-06-11 11:28:42 +08:00
parent 87913ba0a0
commit 3ad7bf40b5
11 changed files with 705 additions and 15 deletions

View File

@@ -303,4 +303,17 @@
#app .sidebar-container .nest-menu .el-submenu .el-submenu__title {
margin: 0 4px;
}
// 二级菜单叶子页面小圆点
.el-menu-item.is-page-item::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--current-color, #409EFF);
margin-right: 8px;
flex-shrink: 0;
vertical-align: middle;
}

View File

@@ -2,7 +2,8 @@
<div class="navbar">
<hamburger id="hamburger-container" :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
<breadcrumb id="breadcrumb-container" class="breadcrumb-container" v-if="!topNav"/>
<breadcrumb id="breadcrumb-container" class="breadcrumb-container" v-show="topNav || !showSubNav"/>
<sub-nav id="subnav-container" class="subnav-container" v-if="!topNav" @has-items="showSubNav = $event"/>
<top-nav id="topmenu-container" class="topmenu-container" v-if="topNav"/>
<div class="right-menu">
@@ -52,6 +53,7 @@
import { mapGetters } from 'vuex'
import Breadcrumb from '@/components/Breadcrumb'
import TopNav from '@/components/TopNav'
import SubNav from './SubNav'
import Hamburger from '@/components/Hamburger'
import Screenfull from '@/components/Screenfull'
// import SizeSelect from '@/components/SizeSelect'
@@ -64,6 +66,7 @@ export default {
components: {
Breadcrumb,
TopNav,
SubNav,
Hamburger,
Screenfull,
// SizeSelect,
@@ -74,7 +77,8 @@ export default {
data() {
return {
hasWarning: false,
warningTimer: null
warningTimer: null,
showSubNav: true
}
},
computed: {
@@ -153,6 +157,8 @@ export default {
height: 50px;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
// 金属质感渐变背景
// background: #454c51;
// border-bottom: 1px solid #8d939b;
@@ -162,7 +168,7 @@ export default {
.hamburger-container {
line-height: 46px;
height: 100%;
float: left;
flex-shrink: 0;
cursor: pointer;
transition: all .3s ease;
-webkit-tap-highlight-color: transparent;
@@ -177,10 +183,16 @@ export default {
}
.breadcrumb-container {
float: left;
flex-shrink: 0;
padding-left: 15px;
}
.subnav-container {
flex: 1;
min-width: 0;
padding-left: 8px;
}
.topmenu-container {
position: absolute;
left: 50px;
@@ -192,7 +204,8 @@ export default {
}
.right-menu {
float: right;
flex-shrink: 0;
margin-left: auto;
height: 100%;
line-height: 50px;

View File

@@ -1,6 +1,23 @@
<template>
<div v-if="!item.hidden">
<template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
<template v-if="depth >= 1">
<template v-if="hasVisibleChild">
<app-link :to="subMenuRoute">
<el-menu-item :index="basePath" :class="{'submenu-title-noDropdown':!isNest}">
<item :icon="item.meta && item.meta.icon" :title="item.meta && item.meta.title" />
</el-menu-item>
</app-link>
</template>
<template v-else>
<app-link :to="selfRoute">
<el-menu-item :index="basePath" :class="{'submenu-title-noDropdown':!isNest, 'is-page-item': true}">
<item :icon="item.meta && item.meta.icon" :title="item.meta && item.meta.title" />
</el-menu-item>
</app-link>
</template>
</template>
<template v-else-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
<item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" />
@@ -18,6 +35,7 @@
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
:depth="depth + 1"
class="nest-menu"
/>
</el-submenu>
@@ -48,12 +66,38 @@ export default {
basePath: {
type: String,
default: ''
},
depth: {
type: Number,
default: 0
}
},
data() {
this.onlyOneChild = null
return {}
},
computed: {
hasVisibleChild() {
if (!this.item.children || !this.item.children.length) return false
return this.item.children.some(c => !c.hidden)
},
selfRoute() {
if (this.item.query) {
return { path: this.basePath, query: JSON.parse(this.item.query) }
}
return this.basePath
},
subMenuRoute() {
const title = (this.item.meta && this.item.meta.title) || this.item.name || ''
return {
path: '/redirect/subMenu',
query: {
parent: this.basePath,
title: title
}
}
}
},
methods: {
hasOneShowingChild(children = [], parent) {
if (!children) {

View File

@@ -24,6 +24,7 @@
</template>
<script>
import path from 'path'
import { mapGetters, mapState } from "vuex";
import Logo from "./Logo";
import SidebarItem from "./SidebarItem";
@@ -36,12 +37,15 @@ export default {
...mapGetters(["sidebarRouters", "sidebar"]),
activeMenu() {
const route = this.$route;
const { meta, path } = route;
// if set path, the sidebar will highlight the path you set
const { meta, path: routePath } = route;
if (meta.activeMenu) {
return meta.activeMenu;
}
return path;
const level2 = this.findLevel2Parent(routePath);
if (level2) {
return level2;
}
return routePath;
},
showLogo() {
return this.$store.state.settings.sidebarLogo;
@@ -53,5 +57,28 @@ export default {
return !this.sidebar.opened;
}
},
methods: {
findLevel2Parent(currentPath) {
return this.walkTree(this.sidebarRouters, currentPath, 0, '');
},
walkTree(routes, currentPath, depth, basePath) {
for (const route of routes) {
if (route.hidden) continue;
const fullPath = basePath ? path.resolve(basePath, route.path) : route.path;
if (currentPath !== fullPath && !currentPath.startsWith(fullPath + '/')) continue;
if (depth >= 1) {
return fullPath;
}
if (route.children && route.children.length) {
const result = this.walkTree(route.children, currentPath, depth + 1, fullPath);
if (result) return result;
}
if (currentPath === fullPath) {
return depth >= 1 ? fullPath : null;
}
}
return null;
}
},
};
</script>

View File

@@ -0,0 +1,306 @@
<template>
<div v-if="subNavItems.length > 0" class="sub-nav">
<span class="sub-nav__arrow" :class="{ 'is-disabled': scrollLeft <= 0 }" @click="scrollTo('left')">
<i class="el-icon-arrow-left" />
</span>
<div ref="subNavWrap" class="sub-nav__wrap" @mousewheel="handleWheel">
<div ref="subNavInner" class="sub-nav__inner">
<template v-for="item in subNavItems">
<el-dropdown
v-if="item._hasChildren"
:key="item._ownPath"
trigger="click"
class="sub-nav__dropdown"
@command="handleCommand"
>
<span class="sub-nav__item sub-nav__item--dropdown" :class="{ 'is-active': isActive(item) }">
{{ item._title }}
<i class="el-icon-arrow-down el-icon--right" />
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-for="child in item._children"
:key="child._ownPath"
:command="child._fullPath"
:class="{ 'is-active': isChildActive(child) }"
>
{{ child._title }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<router-link
v-else
:key="item._ownPath"
:to="item._fullPath"
class="sub-nav__item"
:class="{ 'is-active': isActive(item) }"
>
{{ item._title }}
</router-link>
</template>
</div>
</div>
<span class="sub-nav__arrow" :class="{ 'is-disabled': !canScrollRight }" @click="scrollTo('right')">
<i class="el-icon-arrow-right" />
</span>
</div>
</template>
<script>
import path from 'path'
export default {
name: 'SubNav',
data() {
return {
scrollLeft: 0,
canScrollRight: false,
resizeObserver: null
}
},
computed: {
subNavItems() {
const sidebarRouters = this.$store.state.permission.sidebarRouters
const items = this.getThirdLevelMenus(sidebarRouters)
return items
},
hasItems() {
return this.subNavItems.length > 0
}
},
watch: {
$route() {
this.$nextTick(() => {
this.checkArrows()
this.scrollToActive()
})
},
hasItems(val) {
this.$emit('has-items', val)
}
},
mounted() {
this.$nextTick(() => {
this.checkArrows()
this.scrollToActive()
})
if (window.ResizeObserver) {
this.resizeObserver = new ResizeObserver(() => {
this.checkArrows()
})
if (this.$refs.subNavWrap) {
this.resizeObserver.observe(this.$refs.subNavWrap)
}
}
},
beforeDestroy() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
},
methods: {
handleCommand(command) {
this.$router.push(command)
},
isActive(item) {
return this.$route.path === item._ownPath || this.$route.path.startsWith(item._ownPath + '/')
},
isChildActive(child) {
return this.$route.path === child._ownPath || this.$route.path.startsWith(child._ownPath + '/')
},
getThirdLevelMenus(routes) {
let currentPath = this.$route.path
if (currentPath === '/redirect/subMenu' && this.$route.query.parent) {
currentPath = this.$route.query.parent
}
return this.findActiveLevel2(routes, currentPath, 0, '') || []
},
findActiveLevel2(routes, currentPath, depth, basePath) {
for (const route of routes) {
if (route.hidden) continue
if (!route.children || route.children.length === 0) continue
const fullPath = basePath ? path.resolve(basePath, route.path) : route.path
if (currentPath !== fullPath && !currentPath.startsWith(fullPath + '/')) continue
if (currentPath === fullPath && depth >= 1) {
if (depth === 1) {
return this.buildItems(route.children, fullPath)
}
return null
}
const nested = this.findActiveLevel2(route.children, currentPath, depth + 1, fullPath)
if (nested) return nested
if (depth === 1) {
return this.buildItems(route.children, fullPath)
}
}
return null
},
buildItems(children, parentPath) {
return children.filter(c => !c.hidden).map(c => this.buildItem(c, parentPath))
},
buildItem(c, parentPath) {
const ownPath = c.path.startsWith('/') ? c.path : path.resolve(parentPath, c.path)
const title = (c.meta && c.meta.title) || c.name || c.path
const hasChildren = c.children && c.children.length > 0 && c.children.some(gc => !gc.hidden)
const item = {
...c,
_ownPath: ownPath,
_fullPath: ownPath,
_title: title,
_hasChildren: hasChildren
}
if (hasChildren) {
item._children = this.buildItems(c.children, ownPath)
}
return item
},
handleWheel(e) {
if (!this.$refs.subNavWrap) return
const delta = e.deltaY || e.wheelDelta
this.$refs.subNavWrap.scrollLeft += delta
this.scrollLeft = this.$refs.subNavWrap.scrollLeft
this.checkArrows()
},
scrollTo(direction) {
if (!this.$refs.subNavWrap) return
const wrap = this.$refs.subNavWrap
const scrollAmount = 200
if (direction === 'left') {
wrap.scrollLeft = Math.max(0, wrap.scrollLeft - scrollAmount)
} else {
wrap.scrollLeft = Math.min(wrap.scrollWidth - wrap.clientWidth, wrap.scrollLeft + scrollAmount)
}
this.scrollLeft = wrap.scrollLeft
this.checkArrows()
},
checkArrows() {
if (!this.$refs.subNavWrap) return
const wrap = this.$refs.subNavWrap
this.scrollLeft = wrap.scrollLeft
this.canScrollRight = wrap.scrollWidth > wrap.clientWidth + wrap.scrollLeft + 1
},
scrollToActive() {
if (!this.$refs.subNavWrap) return
const activeEl = this.$refs.subNavWrap.querySelector('.sub-nav__item.is-active')
if (activeEl) {
const wrap = this.$refs.subNavWrap
const activeLeft = activeEl.offsetLeft
const activeRight = activeLeft + activeEl.offsetWidth
if (activeLeft < wrap.scrollLeft) {
wrap.scrollLeft = activeLeft
} else if (activeRight > wrap.scrollLeft + wrap.clientWidth) {
wrap.scrollLeft = activeRight - wrap.clientWidth
}
this.scrollLeft = wrap.scrollLeft
this.checkArrows()
}
}
}
}
</script>
<style lang="scss" scoped>
.sub-nav {
display: flex;
align-items: center;
height: 50px;
flex: 1;
min-width: 0;
padding: 0 4px;
&__arrow {
flex-shrink: 0;
width: 24px;
height: 28px;
line-height: 28px;
text-align: center;
cursor: pointer;
border-radius: 4px;
color: #606266;
font-size: 12px;
transition: all 0.2s;
&:hover:not(.is-disabled) {
background: rgba(0, 0, 0, 0.06);
color: #111;
}
&.is-disabled {
color: #c0c4cc;
cursor: not-allowed;
}
}
&__wrap {
flex: 1;
overflow-x: auto;
overflow-y: hidden;
min-width: 0;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
&__inner {
display: flex;
align-items: center;
white-space: nowrap;
}
&__dropdown {
flex-shrink: 0;
}
&__item {
flex-shrink: 0;
padding: 6px 14px;
margin: 0 2px;
font-size: 13px;
color: #606266;
border-radius: 4px;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
&:hover {
background: rgba(0, 0, 0, 0.05);
color: #111;
}
&.is-active {
background: var(--current-color, #409EFF);
color: #fff;
}
&.is-active:hover {
background: var(--current-color, #409EFF);
color: #fff;
opacity: 0.9;
}
&--dropdown {
display: inline-flex;
align-items: center;
gap: 4px;
.el-icon--right {
margin-left: 2px;
font-size: 12px;
}
}
}
}
::v-deep .sub-nav__dropdown .el-dropdown-menu__item.is-active {
color: var(--current-color, #409EFF);
font-weight: 600;
}
</style>

View File

@@ -35,6 +35,12 @@ export const constantRoutes = [
component: Layout,
hidden: true,
children: [
{
path: 'subMenu',
component: () => import('@/views/redirectMenu'),
name: 'RedirectMenu',
meta: { title: '子菜单' }
},
{
path: '/redirect/:path(.*)',
component: () => import('@/views/redirect')

View File

@@ -23,5 +23,6 @@ const getters = {
bomMap: state => state.category.bomMap,
financialAccounts: state => state.finance.financialAccounts,
processList: state => state.craft.processList,
productionLines: state => state.productionLine.productionLines,
}
export default getters

View File

@@ -9,6 +9,7 @@ import settings from './modules/settings'
import category from './modules/category'
import finance from './modules/finance'
import craft from './modules/craft'
import productionLine from './modules/productionLine'
import getters from './getters'
Vue.use(Vuex)
@@ -23,7 +24,8 @@ const store = new Vuex.Store({
settings,
category,
finance,
craft
craft,
productionLine
},
getters
})

View File

@@ -0,0 +1,27 @@
import { listProductionLine } from "@/api/wms/productionLine";
const state = {
productionLines: [],
}
const mutations = {
SET_PRODUCTION_LINES(state, list) {
state.productionLines = list;
},
}
const actions = {
getProductionLines({ commit, state }) {
if (state.productionLines.length > 0) return Promise.resolve()
return listProductionLine({ pageNum: 1, pageSize: 100 }).then(response => {
commit('SET_PRODUCTION_LINES', response.rows || []);
});
}
}
export default {
namespaced: true,
state,
mutations,
actions
}

View File

@@ -494,9 +494,9 @@
<script>
import { listRollInfo, getRollInfo, addRollInfo, updateRollInfo, delRollInfo } from '@/api/mes/roll/rollInfo'
import { listRollGrind, addRollGrind, updateRollGrind, delRollGrind, getMonthlyStats } from '@/api/mes/roll/rollGrind'
import { listProductionLine } from '@/api/wms/productionLine'
import { listData, addData, updateData, delData } from '@/api/system/dict/data'
import rollLineMixin from '../rollLineMixin'
import { mapGetters } from 'vuex'
export default {
name: 'GrindRoom',
@@ -504,7 +504,6 @@ export default {
dicts: ['mes_roll_operator'],
data() {
return {
productionLines: [],
filterLineId: null,
lineTabOrder: [],
@@ -549,6 +548,8 @@ export default {
},
computed: {
...mapGetters(['productionLines']),
currentUserName() {
return this.$store.state.user.name || this.$store.getters.name || ''
},
@@ -606,9 +607,7 @@ export default {
this.filterLineId = this.lineId
const uid = this.$store.state.user?.userId || 0
try { this.lineTabOrder = JSON.parse(localStorage.getItem(`grind_line_order_${uid}`) || '[]') } catch { /* ignore */ }
listProductionLine({ pageNum: 1, pageSize: 100 }).then(res => {
this.productionLines = res.rows || []
})
this.$store.dispatch('productionLine/getProductionLines')
this.loadRolls()
},

View File

@@ -0,0 +1,252 @@
<template>
<div class="submenu-page">
<div class="submenu-page__header">
<span class="submenu-page__parent-title">{{ parentTitle }}</span>
</div>
<div v-if="tree.length" class="submenu-page__list">
<div v-for="group in tree" :key="group._ownPath" class="submenu-group">
<div class="submenu-group__title" @click="goTo(group)">
<span class="submenu-group__icon">
<svg-icon v-if="group.meta && group.meta.icon" :icon-class="group.meta.icon" />
<i v-else class="el-icon-folder-opened" />
</span>
<span class="submenu-group__text">{{ group._title }}</span>
<span v-if="group._hasChildren" class="submenu-group__count">{{ group._children.length }}</span>
<i v-if="group._hasChildren" class="el-icon-arrow-right submenu-group__arrow" />
</div>
<div v-if="group._hasChildren" class="submenu-group__items">
<div
v-for="child in group._children"
:key="child._ownPath"
class="submenu-item"
:class="{ 'submenu-item--leaf': !child._hasChildren }"
@click="goTo(child)"
>
<i v-if="child._hasChildren" class="el-icon-folder submenu-item__folder" />
<i v-else class="el-icon-document submenu-item__doc" />
<span class="submenu-item__text">{{ child._title }}</span>
<i v-if="child._hasChildren" class="el-icon-arrow-right submenu-item__arrow" />
</div>
</div>
</div>
</div>
<div v-else class="submenu-page__empty">暂无可访问的菜单</div>
</div>
</template>
<script>
import path from 'path'
export default {
name: 'RedirectMenu',
computed: {
parentPath() {
return this.$route.query.parent || ''
},
parentTitle() {
return this.$route.query.title || this.parentPath
},
tree() {
const sidebarRouters = this.$store.state.permission.sidebarRouters
const children = this.findChildren(sidebarRouters, this.parentPath, '')
if (!children) return []
return this.buildTree(children, this.parentPath)
}
},
methods: {
findChildren(routes, targetPath, basePath) {
for (const route of routes) {
if (route.hidden) continue
const fullPath = basePath ? path.resolve(basePath, route.path) : route.path
if (fullPath === targetPath) {
return route.children || []
}
if (route.children && route.children.length > 0) {
const result = this.findChildren(route.children, targetPath, fullPath)
if (result) return result
}
}
return null
},
buildTree(children, parentPath) {
return children
.filter(c => !c.hidden)
.map(c => {
const ownPath = c.path.startsWith('/') ? c.path : path.resolve(parentPath, c.path)
const title = (c.meta && c.meta.title) || c.name || c.path || ''
const hasChildren = c.children && c.children.length > 0 && c.children.some(gc => !gc.hidden)
const item = {
...c,
_ownPath: ownPath,
_fullPath: ownPath,
_title: title,
_hasChildren: hasChildren
}
if (hasChildren) {
item._children = this.buildTree(c.children, ownPath)
}
return item
})
},
goTo(item) {
if (item._hasChildren) {
this.$router.push({
path: '/redirect/subMenu',
query: { parent: item._ownPath, title: item._title }
})
} else if (item.path) {
this.$router.push(item._fullPath)
}
}
}
}
</script>
<style lang="scss" scoped>
.submenu-page {
padding: 16px 20px;
max-width: 900px;
margin: 0 auto;
}
.submenu-page__header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
.submenu-page__parent-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.submenu-page__list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.submenu-page__empty {
text-align: center;
padding: 40px 0;
color: #909399;
font-size: 14px;
}
.submenu-group {
width: calc(50% - 6px);
min-width: 280px;
background: #fafbfc;
border: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.2s;
&:hover {
border-color: #c0c4cc;
}
}
.submenu-group__title {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
background: #fff;
border-bottom: 1px solid #f0f0f0;
transition: background 0.15s;
&:hover {
background: #f5f7fa;
}
}
.submenu-group__icon {
font-size: 16px;
color: var(--current-color, #409EFF);
margin-right: 8px;
flex-shrink: 0;
}
.submenu-group__text {
font-size: 14px;
font-weight: 500;
color: #303133;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.submenu-group__count {
font-size: 11px;
color: #909399;
margin-right: 6px;
flex-shrink: 0;
}
.submenu-group__arrow {
font-size: 12px;
color: #c0c4cc;
flex-shrink: 0;
}
.submenu-group__items {
padding: 4px 0;
}
.submenu-item {
display: flex;
align-items: center;
padding: 8px 16px 8px 24px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #f0f2f5;
}
&:not(:last-child) {
border-bottom: 1px solid #f5f5f5;
}
}
.submenu-item__folder {
font-size: 13px;
color: #e6a23c;
margin-right: 8px;
flex-shrink: 0;
}
.submenu-item__doc {
font-size: 13px;
color: #909399;
margin-right: 8px;
flex-shrink: 0;
}
.submenu-item__text {
font-size: 13px;
color: #606266;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.submenu-item__arrow {
font-size: 12px;
color: #c0c4cc;
flex-shrink: 0;
margin-left: 4px;
}
.submenu-item--leaf {
.submenu-item__doc {
color: #c0c4cc;
}
}
</style>