上一篇我们实现动态菜单,这里我们接着实现Tba栏和面包屑,为什么需要一起实现,因为这两个功能是紧密连接在一起的,这里便放在一起写。代码量略多, 请耐心阅读。
上一篇博客的进度页面展示
修改布局 可以在本地运行npm run dev
查看,这个页面的布局这边略微调整下,修改MainLayout.vue
说明:代码块中’-‘开头表示删除,’+’开头表示增加,接下来的学习中,可能会出现很多这样的情况,因为直接粘贴全部代码,实在是太占篇幅了。
1 2 - <q-layout view ="lHh Lpr lFf" > + <q-layout view ="hHh LpR lff" >
修改过后运行代码,可以看到布局已经改了,关于布局,可以查看layout
,了解更多。
增加Tab栏 在layouts/comp
文件夹下新建HeadTab.vue
组件,编写静态代码,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <template> <q-tabs v-model="tab" align="left" dense inline-label> <q-tab name="main" icon="mail" label="主页" /> <q-tab name="alarms" icon="alarm" label="菜单管理" /> <q-tab name="movies" icon="movie" label="用户管理" /> </q-tabs> </template> <script setup lang="ts"> import { ref } from 'vue'; const tab = ref('main'); </script> <style scoped></style>
紧接着修改MainLayout.vue
文件引入HeadTab.vue
组件,编辑过后完整MainLayout.vue
代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 <template> <q-layout view="hHh LpR lff"> <q-header elevated> <q-toolbar> <q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleLeftDrawer" /> <q-toolbar-title> Quasar App </q-toolbar-title> <div>Quasar v{{ $q.version }}</div> </q-toolbar> <head-tab></head-tab> </q-header> <q-drawer v-model="leftDrawerOpen" show-if-above bordered> <LeftDrawer></LeftDrawer> </q-drawer> <q-page-container> <router-view /> </q-page-container> </q-layout> </template> <script setup lang="ts"> import { ref } from 'vue'; import LeftDrawer from './comp/LeftDrawer.vue'; import HeadTab from './comp/HeadTab.vue'; const leftDrawerOpen = ref(false); function toggleLeftDrawer() { leftDrawerOpen.value = !leftDrawerOpen.value; } </script>
重启项目或者刷新页面,可以看到如图所示
Tab栏已经正常的添加,现在还没有和菜单联动起来,接下来我们编写代码让菜单和Tab栏联动起来,
修改layout-store.ts
文件,笔者这里习惯了Setup写法,这里便改为使用pinia
的setup写法,改动也不大,主要使用了vue的自带的功能,感觉是更容易理解的,ref
就是state
,getters
就是computed
,普通的函数就是actions
,最后需要暴露出去的return
即可。非常经典的组合式API
主要原因可能是项目中都是setup组件,使用options
API的全局状态,思维上有点差异,也感觉处处受限制。
改动过后,新增部分工具方法,完整代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 import { defineStore } from 'pinia' ;import { ref, computed } from 'vue' ;import { LocalStorage } from 'quasar' export const useLayoutStore = defineStore ('layout' , () => { const mainPage : WJ .MenuItem = { id : '0' , pid : '0' , name : '首页' , sort : 0 , icon : 'home' , auth : '' , path : '/' } const menuKey = 'menu' ; const menu = LocalStorage .getItem <Array <WJ .MenuItem >>(menuKey) const menuList = ref<Array <WJ .MenuItem >>(menu ? menu : []); const tabList = ref<Array <WJ .MenuItem >>([]); const crumbsList = ref<Array <WJ .MenuItem >>([]); const leftDrawerWeight = ref (250 ); const menuSort = (a: WJ.MenuItem, b: WJ.MenuItem ) => a.sort - b.sort const menuTree = computed (() => { const res : Array <WJ .MenuItem > = []; menuList.value .forEach ((item ) => { const parent = menuIdMap.value .get (item.pid ); if (parent) { const pc = (parent.children || (parent.children = [])); pc.indexOf (item) === -1 ? pc.push (item) : '' pc.sort (menuSort) } else { res.push (item) } }); res.sort (menuSort) return res; }) const menuIdMap = computed (() => { const map = new Map <string , WJ .MenuItem >(); menuList.value .forEach ((item ) => { map.set (item.id , item); }); return map }) const menuPathMap = computed (() => { const map = new Map <string , WJ .MenuItem >(); menuList.value .forEach ((item ) => { map.set (item.path , item); }); return map }) function isHttp (url: string ) { return url.indexOf ('http' ) !== -1 ; }; function toBind (item: WJ.MenuItem ) { return isHttp (item.path ) ? { path : '/outside-link' , query : { url : item.path , }, } : item.path } function findP (item: WJ.MenuItem ) { const result : Array <WJ .MenuItem > = [] const findFn = (i: WJ.MenuItem ) => { result.push (i) if (i.pid !== '0' ) { const it = menuIdMap.value .get (i.pid ); if (it) { findFn (it) } } } findFn (item); return result.reverse (); } return { menuList, tabList, crumbsList, menuTree, findP, menuPathMap, menuIdMap, mainPage, leftDrawerWeight, menuKey, toBind, isHttp } }); export const useAuthStore = defineStore ('auth' , () => { const token = ref ('' ); return { token } });
这个写好了,主要功能包括方便后期的一些扩展,比如下面的面包屑的展示,还有登陆用户的token
修改quasar.config.js
文件 编辑framework.plugins
内容为,安装quasar的一些插件关于插件 ,可在文档中查看,用到的时候查看一下API即可。
1 2 3 4 5 6 7 8 9 10 11 plugins : [ 'LoadingBar' , 'Notify' , 'LocalStorage' , 'SessionStorage' , 'Loading' , 'Dialog' , 'BottomSheet' , 'AppVisibility' , 'AppFullscreen' , ],
增加登录页 在pages
目录中新建LoginPage.vue
文件,完整代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 <template> <q-layout> <q-page-container> <q-page class="flex bg-image flex-center"> <q-card v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }" > <q-card-section> <q-avatar size="103px" class="absolute-center shadow-10"> <img src="https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fc-ssl.duitang.com%2Fuploads%2Fblog%2F202107%2F19%2F20210719150601_4401e.thumb.1000_0.jpg&refer=http%3A%2F%2Fc-ssl.duitang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1685108172&t=232426938cbc64554c6c143fdb13fb4c" /> </q-avatar> </q-card-section> <q-card-section> <div class="text-center q-pt-lg"> <div class="col text-h6 ellipsis">登录</div> </div> </q-card-section> <q-card-section> <q-form class="q-gutter-md"> <q-input filled v-model="username" label="用户名" lazy-rules /> <q-input type="password" filled v-model="password" label="密码" lazy-rules /> <div> <q-btn label="登录" type="button" @click="login" color="primary" /> </div> </q-form> </q-card-section> </q-card> </q-page> </q-page-container> </q-layout> </template> <script setup lang="ts"> import { useAuthStore, useLayoutStore } from 'src/stores/layout-store'; import { ref } from 'vue'; import myRoutes from 'src/router/my-router'; import { useRouter } from 'vue-router'; import { useQuasar } from 'quasar'; const username = ref('小王'); const password = ref('12345'); const auth = useAuthStore(); const layout = useLayoutStore(); const router = useRouter(); const $q = useQuasar(); const login = () => { // 点击登录写入token auth.token = '2333333333'; layout.menuList = myRoutes as Array<WJ.MenuItem>; $q.localStorage.set(layout.menuKey, myRoutes); router.push({ path: layout.mainPage.path }).then(() => { $q.notify({ position: 'top', color: 'positive', message: '欢迎回来~ ' + username.value, }); }); }; </script> <style scoped> .bg-image { background-image: linear-gradient(135deg, #7028e4 0%, #e5b2ca 100%); } </style>
编辑src\router\routes.ts
文件 增加登录页的路由,与动态菜单中对应的路由配置,编辑过后完整代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import { RouteRecordRaw } from 'vue-router' ;const routes : RouteRecordRaw [] = [ { path : '/login' , component : () => import ('pages/LoginPage.vue' ), }, { path : '/' , component : () => import ('layouts/MainLayout.vue' ), children : [ { path : '' , component : () => import ('pages/IndexPage.vue' ) }, { path : '/sys-menu' , component : () => import ('pages/IndexPage.vue' ) }, { path : '/sys-user' , component : () => import ('pages/IndexPage.vue' ) }, ], }, { path : '/:catchAll(.*)*' , component : () => import ('pages/ErrorNotFound.vue' ), }, ]; export default routes;
编写路由跳转前置拦截 这里的逻辑有点多,我这边一一列举一下
处理无需鉴权鉴权的路由,比如登录页面,
处理Tab栏的联动。
处理面包屑的联动
处理三方链接的跳转逻辑
编辑src\router\index.ts
,完整代码如下,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 import { route } from 'quasar/wrappers' ;import { createMemoryHistory, createRouter, createWebHashHistory, createWebHistory, } from 'vue-router' ; import routes from './routes' ;import { LoadingBar } from 'quasar' import type { RouteLocationNormalized } from 'vue-router' ;import { useAuthStore, useLayoutStore } from 'src/stores/layout-store' ;export default route (function ( ) { const createHistory = process.env .SERVER ? createMemoryHistory : (process.env .VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory); const Router = createRouter ({ scrollBehavior : () => ({ left : 0 , top : 0 }), routes, history : createHistory ( process.env .MODE === 'ssr' ? void 0 : process.env .VUE_ROUTER_BASE ), }); const notCheckPath = ['/login' ]; Router .beforeEach ((to: RouteLocationNormalized, form: RouteLocationNormalized, next ) => { LoadingBar .start () console .log ('form: ' , form); console .log ('to: ' , to); const auth = useAuthStore (); const layout = useLayoutStore (); if (notCheckPath.indexOf (to.fullPath ) > -1 ) { next () } else { if (auth.token ) { if (to.fullPath === '/' ) { layout.crumbsList = [layout.mainPage ] } else { const item = to.query .url ? layout.menuPathMap .get (to.query .url as string ) : layout.menuPathMap .get (to.fullPath ) console .log ('layout.menuPathMap: ' , layout.menuPathMap ); console .log ('item: ' , item); if (item) { layout.crumbsList = layout.findP (item) if (layout.tabList .filter (i => i.id === item.id ).length === 0 ) { layout.tabList .push (item); } } } next () } else { next ({ path : '/login' }) } } }) Router .afterEach (() => { console .log ('afterEach: ' ); LoadingBar .stop () LoadingBar .stop () }) return Router ; });
最后修改HeadTab.vue,再加一点细节, Tab的一些操作,如,关闭,关闭左侧,右侧等等,写这种代码需要一定的编码能力,修改过后完整代码,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 <template> <!-- Tba栏 --> <q-tabs align="left" dense inline-label> <transition-group name="tab-list"> <q-route-tab to="/" class="q-px-xs" :key="layout.mainPage.id"> <q-icon :name="layout.mainPage.icon" size="1.2rem" /> <div class="q-mx-sm">{{ layout.mainPage.name }}</div> </q-route-tab> <q-route-tab :to="layout.toBind(item)" class="q-px-xs" v-for="item in layout.tabList" :key="item.id" > <q-icon v-if="item.icon" :name="item.icon" size="1.2rem" /> <div class="q-mx-sm">{{ item.name }}</div> <div @click.prevent.stop="closeThis(item)"> <q-icon name="close" size="1.2rem" class="remove-icon" /> <q-tooltip> 移除一个标签 </q-tooltip> </div> <q-menu touch-position context-menu> <q-list dense> <q-item clickable v-close-popup @click="closeThis(item)"> <q-item-section>移除当前</q-item-section> </q-item> <q-item clickable v-close-popup @click="closeLeft(item)"> <q-item-section>关闭左侧</q-item-section> </q-item> <q-item clickable v-close-popup @click="closeRight(item)"> <q-item-section>关闭右侧</q-item-section> </q-item> <q-item clickable v-close-popup @click="closeAll()"> <q-item-section>移除全部</q-item-section> </q-item> </q-list> </q-menu> </q-route-tab> </transition-group> </q-tabs> </template> <script setup lang="ts"> import { useLayoutStore } from 'src/stores/layout-store'; import { useRoute, useRouter } from 'vue-router'; const layout = useLayoutStore(); const router = useRouter(); const route = useRoute(); // 关闭当前 const closeThis = (item: WJ.MenuItem) => { let tabl = layout.tabList.slice(0); const it = tabl[tabl.indexOf(item) - 1]; tabl = tabl.filter((i) => i.id !== item.id); // 如果移除的是当前路由 if ( route.query.url ? route.query.url === item.path : route.fullPath === item.path ) { if (it) { router.push(layout.toBind(it)).then(() => { layout.tabList = tabl; }); } else { router.push({ path: layout.mainPage.path }).then(() => { layout.tabList = tabl; }); } } else { layout.tabList = tabl; } }; // 关闭左侧 const closeLeft = (item: WJ.MenuItem) => { let tabl = layout.tabList.slice(0); const index = tabl.indexOf(item); if (index !== 0) { tabl = tabl.slice(tabl.indexOf(item)); if ( route.query.url ? route.query.url !== item.path : route.fullPath !== item.path ) { router.push(layout.toBind(item)).then(() => { layout.tabList = tabl; }); } else { layout.tabList = tabl; } } }; // 关闭右侧 const closeRight = (item: WJ.MenuItem) => { let tabl = layout.tabList.slice(0); const index = tabl.indexOf(item) + 1; if (index !== tabl.length) { tabl = tabl.slice(0, index); if ( route.query.url ? route.query.url !== item.path : route.fullPath !== item.path ) { router.push(layout.toBind(item)).then(() => { layout.tabList = tabl; }); } else { layout.tabList = tabl; } } }; // 全部关闭 const closeAll = () => { router.push({ path: layout.mainPage.path }).then(() => { layout.tabList = []; }); }; </script> <style lang="scss" scoped> .remove-icon { opacity: 0.58; transition: all 0.3s; &:hover { opacity: 1; } } .tab-list { /* 对移动中的元素应用的过渡 */ &-move, &-enter-active, &-leave-active { transition: all 0.5s ease; } &-enter-from, &-leave-to { opacity: 0; transform: translateX(30px); } /* 确保将离开的元素从布局流中删除 以便能够正确地计算移动的动画。 */ &-leave-active { position: absolute; } } </style>
最后总结 修改完成过后重启项目,访问主页,发现自动跳转到登录页了,点击登录,进入主界面,点点点菜单发现侧边菜单功能已经实现。
增加面包屑 新建HeadCrumbs.vue
文件 在src\layouts\comp\下新建HeadCrumbs.vue
文件,完整代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <template> <!-- 面包屑 --> <q-breadcrumbs active-color="none"> <transition-group appear enter-active-class="animated fadeInLeft"> <q-breadcrumbs-el :icon="item.icon" :label="item.name" v-for="item in layout.crumbsList" :key="item.id" /> </transition-group> </q-breadcrumbs> </template> <script setup lang="ts"> import { useLayoutStore } from 'src/stores/layout-store'; const layout = useLayoutStore(); </script> <style scoped></style>
面包屑组件中使用了一些动画,quasar集成animations
动画库十分简单,修改quasar.config.js
文件,
1 2 3 4 5 6 7 8 9 10 11 12 13 - animations : [], + animations : 'all' , devServer : { server : { type : 'http' , }, port : 8080 , - open : true , + open : false , },
引入HeadCrumbs.vue
组件 修改MainLayout.vue
文件,引入相应组件,修改过后完整代码如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 <template> <q-layout view="hHh LpR lff"> <q-header elevated> <q-toolbar> <q-btn dense flat round :icon="leftDrawerOpen ? 'menu_open' : 'menu'" @click="toggleLeftDrawer" /> <q-btn flat no-caps no-wrap class="q-ml-xs" v-if="$q.screen.gt.xs"> <q-icon name="catching_pokemon" size="2rem" /> <q-toolbar-title shrink class="text-weight-bold"> W_LF </q-toolbar-title> </q-btn> <head-crumbs v-if="$q.screen.gt.xs"></head-crumbs> <q-space /> <div>Quasar v{{ $q.version }}</div> </q-toolbar> <head-tab></head-tab> </q-header> <q-drawer v-model="leftDrawerOpen" show-if-above bordered> <LeftDrawer></LeftDrawer> </q-drawer> <q-page-container> <router-view /> </q-page-container> </q-layout> </template> <script setup lang="ts"> import { ref } from 'vue'; import LeftDrawer from './comp/LeftDrawer.vue'; import HeadTab from './comp/HeadTab.vue'; import HeadCrumbs from './comp/HeadCrumbs.vue'; const leftDrawerOpen = ref(false); function toggleLeftDrawer() { leftDrawerOpen.value = !leftDrawerOpen.value; } </script>
重启项目,不报错,继续优化,现在项目的雏形已经完成个七七八八了。最后我们让Head栏变得好看一点。首先编辑quasar.config.js
文件,在extras
块中添加fontawesome-v6
图标,继续编辑MainLayout.vue
文件,删去显示Quasar版本号的内容,替换成下面的内容,重启项目。即可看到最终结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 - <div>Quasar v{{ $q.version }}</div> <div class="q-gutter-sm row items-center no-wrap"> <q-btn round dense flat color="white" :icon="$q.fullscreen.isActive ? 'fullscreen_exit' : 'fullscreen'" @click="$q.fullscreen.toggle()" v-if="$q.screen.gt.sm" > </q-btn> <q-btn v-if="$q.screen.gt.sm" round dense flat color="white" icon="fab fa-github" type="a" href="https://gitee.com/wlf213/wlf-admin" target="_blank" > </q-btn> <q-btn v-if="$q.screen.gt.sm" round dense flat class="text-red" type="a" href="https://gitee.com/wlf213" target="_blank" > <i class="fa fa-heart fa-2x fa-beat"></i> </q-btn> <q-btn round dense flat color="white" icon="notifications"> <q-badge color="red" text-color="white" floating> 5 </q-badge> <q-menu> <q-list style="min-width: 100px"> <q-card class="text-center no-shadow no-border"> <q-btn label="全部查看" style="max-width: 120px !important" flat dense class="text-indigo-8" ></q-btn> </q-card> </q-list> </q-menu> </q-btn> <q-btn round flat> <q-avatar size="26px"> <img src="https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fc-ssl.duitang.com%2Fuploads%2Fblog%2F202107%2F19%2F20210719150601_4401e.thumb.1000_0.jpg&refer=http%3A%2F%2Fc-ssl.duitang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1685108172&t=232426938cbc64554c6c143fdb13fb4c" /> </q-avatar> <q-menu transition-show="scale" transition-hide="scale"> <q-list dense> <q-item v-ripple> <q-item-section avatar> <q-avatar> <img src="https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fc-ssl.duitang.com%2Fuploads%2Fblog%2F202107%2F19%2F20210719150601_4401e.thumb.1000_0.jpg&refer=http%3A%2F%2Fc-ssl.duitang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1685108172&t=232426938cbc64554c6c143fdb13fb4c" /> </q-avatar> </q-item-section> <q-item-section>你好, W</q-item-section> </q-item> <q-item clickable v-close-popup> <q-item-section class="text-center">个人主页</q-item-section> </q-item> <q-item clickable v-close-popup> <q-item-section class="text-center">退出登录</q-item-section> </q-item> </q-list> </q-menu> </q-btn> </div>
最后结果展示