从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>

重启项目或者刷新页面,可以看到如图所示

headTab

Tab栏已经正常的添加,现在还没有和菜单联动起来,接下来我们编写代码让菜单和Tab栏联动起来,

修改layout-store.ts文件,笔者这里习惯了Setup写法,这里便改为使用pinia的setup写法,改动也不大,主要使用了vue的自带的功能,感觉是更容易理解的,ref就是state,getters就是computed,普通的函数就是actions,最后需要暴露出去的return即可。非常经典的组合式API

主要原因可能是项目中都是setup组件,使用optionsAPI的全局状态,思维上有点差异,也感觉处处受限制。

改动过后,新增部分工具方法,完整代码如下

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>

最后结果展示

结果展示

此篇博客进度对代码分支——tab-and-crumbs

下一篇教程地址

从0到一个前端后台管理框架(五)

动漫女孩 蓝色的头发


从0到一个前端后台管理框架(四)
https://wangijun.com/2023/06/06/vue-11/
作者
无良芳
发布于
2023年6月6日
许可协议