从0到一个前端后台管理框架(四)
文章列表:
上一篇我们实现动态菜单,这里我们接着实现Tba栏和面包屑,为什么需要一起实现,因为这两个功能是紧密连接在一起的,这里便放在一起写。代码量略多, 请耐心阅读。
上一篇博客的进度页面展示
修改布局
可以在本地运行npm run dev
查看,这个页面的布局这边略微调整下,修改MainLayout.vue
说明:代码块中’-‘开头表示删除,’+’开头表示增加,接下来的学习中,可能会出现很多这样的情况,因为直接粘贴全部代码,实在是太占篇幅了。
- <q-layout view="lHh Lpr lFf">
+ <q-layout view="hHh LpR lff">
修改过后运行代码,可以看到布局已经改了,关于布局,可以查看layout
,了解更多。
增加Tab栏
在layouts/comp
文件夹下新建HeadTab.vue
组件,编写静态代码,
<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
代码如下
<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的全局状态,思维上有点差异,也感觉处处受限制。
改动过后,新增部分工具方法,完整代码如下
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 : []);
// Tab栏的List
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;
})
/**
* id:menuitem
*/
const menuIdMap = computed(() => {
const map = new Map<string, WJ.MenuItem>();
menuList.value.forEach((item) => {
map.set(item.id, item);
});
return map
})
/**
* path:menuitem
*/
const menuPathMap = computed(() => {
const map = new Map<string, WJ.MenuItem>();
menuList.value.forEach((item) => {
map.set(item.path, item);
});
return map
})
/**
* true表示是http或https开头的url
* @param url 待检测url
*/
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', () => {
// token
const token = ref('');
return {
token
}
});
这个写好了,主要功能包括方便后期的一些扩展,比如下面的面包屑的展示,还有登陆用户的token
修改quasar.config.js
文件
编辑framework.plugins
内容为,安装quasar的一些插件关于插件,可在文档中查看,用到的时候查看一下API即可。
plugins: [
'LoadingBar',
'Notify',
'LocalStorage',
'SessionStorage',
'Loading',
'Dialog',
'BottomSheet',
'AppVisibility',
'AppFullscreen',
],
增加登录页
在pages
目录中新建LoginPage.vue
文件,完整代码如下
<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
文件
增加登录页的路由,与动态菜单中对应的路由配置,编辑过后完整代码如下
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') },
],
},
// Always leave this as last one,
// but you can also remove it
{
path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue'),
},
];
export default routes;
编写路由跳转前置拦截
这里的逻辑有点多,我这边一一列举一下
- 处理无需鉴权鉴权的路由,比如登录页面,
- 处理Tab栏的联动。
- 处理面包屑的联动
- 处理三方链接的跳转逻辑
编辑src\router\index.ts
,完整代码如下,
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';
/*
* If not building with SSR mode, you can
* directly export the Router instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Router instance.
*/
export default route(function (/* { store, ssrContext } */) {
const createHistory = process.env.SERVER
? createMemoryHistory
: (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory);
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(
process.env.MODE === 'ssr' ? void 0 : process.env.VUE_ROUTER_BASE
),
});
/* 数组里面的URL里面无需鉴权 */
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)
// 增加Tab栏
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的一些操作,如,关闭,关闭左侧,右侧等等,写这种代码需要一定的编码能力,修改过后完整代码,
<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
文件,完整代码如下
<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
文件,
- animations: [],
+ animations: 'all',
// (可选)每次重启项目都会打开浏览器是不是有点烦,修改这里即可关闭每次重启打开浏览器
devServer: {
server: {
type: 'http',
},
port: 8080,
- open: true, // opens browser window automatically
+ open: false, // opens browser window automatically
},
引入HeadCrumbs.vue
组件
修改MainLayout.vue
文件,引入相应组件,修改过后完整代码如下。
<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版本号的内容,替换成下面的内容,重启项目。即可看到最终结果
- <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>