vue3+TS从0到1手撸后台管理系统

时间:2024-07-17 14:55:16

1.路由配置

1.1路由组件的雏形

src\views\home\index.vue(以home组件为例) image.png

1.2路由配置

1.2.1路由index文件

src\router\index.ts

//通过vue-router插件实现模板路由配置
import { createRouter, createWebHashHistory } from 'vue-router'
import { constantRoute } from './router'
//创建路由器
const router = createRouter({
  //路由模式hash
  history: createWebHashHistory(),
  routes: constantRoute,
  //滚动行为
  scrollBehavior() {
    return {
      left: 0,
      top: 0,
    }
  },
})
export default router
1.2.2路由配置

src\router\router.ts

//对外暴露配置路由(常量路由)
export const constantRoute = [
  {
    //登录路由
    path: '/login',
    component: () => import('@/views/login/index.vue'),
    name: 'login', //命名路由
  },
  {
    //登录成功以后展示数据的路由
    path: '/',
    component: () => import('@/views/home/index.vue'),
    name: 'layout',
  },
  {
    path: '/404',
    component: () => import('@/views/404/index.vue'),
    name: '404',
  },
  {
    //重定向
    path: '/:pathMatch(.*)*',
    redirect: '/404',
    name: 'Any',
  },
]

1.3路由出口

src\App.vue image.png

2.登录模块

2.1 登录路由静态组件

src\views\login\index.vue

<template>
  <div class="login_container">
    <el-row>
      <el-col :span="12" :xs="0"></el-col>
      <el-col :span="12" :xs="24">
        <el-form class="login_form">
          <h1>Hello</h1>
          <h2>欢迎来到硅谷甄选</h2>
          <el-form-item>
            <el-input
              :prefix-icon="User"
              v-model="loginForm.username"
              ></el-input>
          </el-form-item>
          <el-form-item>
            <el-input
              type="password"
              :prefix-icon="Lock"
              v-model="loginForm.password"
              show-password
              ></el-input>
          </el-form-item>
          <el-form-item>
            <el-button class="login_btn" type="primary" size="default">
              登录
            </el-button>
          </el-form-item>
        </el-form>
      </el-col>
    </el-row>
  </div>
</template>

<script setup lang="ts">
  import { User, Lock } from '@element-plus/icons-vue'
  import { reactive } from 'vue'
  //收集账号与密码数据
  let loginForm = reactive({ username: 'admin', password: '111111' })
</script>

<style lang="scss" scoped>
  .login_container {
    width: 100%;
    height: 100vh;
    background: url('@/assets/images/background.jpg') no-repeat;
    background-size: cover;
    .login_form {
      position: relative;
      width: 80%;
      top: 30vh;
      background: url('@/assets/images/login_form.png') no-repeat;
      background-size: cover;
      padding: 40px;
      h1 {
        color: white;
        font-size: 40px;
      }
      h2 {
        color: white;
        font-size: 20px;
        margin: 20px 0px;
      }
      .login_btn {
        width: 100%;
      }
    }
  }
</style>

注意:el-col是24份的,在此左右分为了12份。我们在右边放置我们的结构。:xs="0"是为了响应式。el-form下的element-plus元素都用el-form-item包裹起来。

2.2 登陆业务实现

2.2.1 登录按钮绑定回调

image.png 回调应该做的事情

const login =  () => {
  //点击登录按钮以后干什么
  //通知仓库发起请求
  //请求成功->路由跳转
  //请求失败->弹出登陆失败信息
}
2.2.2 仓库store初始化
  1. 大仓库(笔记只写一次)

安装pinia:pnpm i pinia@2.0.34 src\store\index.ts

//仓库大仓库
import { createPinia } from 'pinia'
//创建大仓库
const pinia = createPinia()
//对外暴露:入口文件需要安装仓库
export default pinia
  1. 用户相关的小仓库

src\store\modules\user.ts

//创建用户相关的小仓库
import { defineStore } from 'pinia'
//创建用户小仓库
const useUserStore = defineStore('User', {
  //小仓库存储数据地方
  state: () => {},
  //处理异步|逻辑地方
  actions: {},
  getters: {},
})
//对外暴露小仓库
export default useUserStore
2.2.3 按钮回调
//登录按钮的回调
const login = async () => {
  //按钮加载效果
  loading.value = true
  //点击登录按钮以后干什么
  //通知仓库发起请求
  //请求成功->路由跳转
  //请求失败->弹出登陆失败信息
  try {
    //也可以书写.then语法
    await useStore.userLogin(loginForm)
    //编程式导航跳转到展示数据的首页
    $router.push('/')
    //登录成功的提示信息
    ElNotification({
      type: 'success',
      message: '登录成功!',
    })
    //登录成功,加载效果也消失
    loading.value = false
  } catch (error) {
    //登陆失败加载效果消失
    loading.value = false
    //登录失败的提示信息
    ElNotification({
      type: 'error',
      message: (error as Error).message,
    })
  }
}
2.2.4 用户仓库
//创建用户相关的小仓库
import { defineStore } from 'pinia'
//引入接口
import { reqLogin } from '@/api/user'
//引入数据类型
import type { loginForm } from '@/api/user/type'
//创建用户小仓库
const useUserStore = defineStore('User', {
  //小仓库存储数据地方
  state: () => {
    return {
      token: localStorage.getItem('TOKEN'), //用户唯一标识token
    }
  },
  //处理异步|逻辑地方
  actions: {
    //用户登录的方法
    async userLogin(data: loginForm) {
      //登录请求
      const result: any = await reqLogin(data)
      if (result.code == 200) {
        //pinia仓库存储token
        //由于pinia|vuex存储数据其实利用js对象
        this.token = result.data.token
        //本地存储持久化存储一份
        localStorage.setItem('TOKEN', result.data.token)
        //保证当前async函数返回一个成功的promise函数
        return 'ok'
      } else {
        return Promise.reject(new Error(result.data.message))
      }
    },
  },
  getters: {},
})
//对外暴露小仓库
export default useUserStore
2.2.5 小结
  1. Element-plus中ElNotification用法(弹窗):

引入:import { ElNotification } from 'element-plus' 使用:

//登录失败的提示信息
    ElNotification({
      type: 'error',
      message: (error as Error).message,
    })
  1. Element-plus中el-buttonloading属性。
  2. pinia使用actions、state的方式和vuex不同:需要引入函数创建实例
  3. $router的使用:也需要引入函数创建实例
  4. 在actions中使用state的token数据:this.token
  5. 类型定义需要注意。
  6. promise的使用和vue2现在看来是一样的。

2.3模板封装登陆业务

2.3.1 result返回类型封装
interface dataType {
  token?: string
  message?: string
}

//登录接口返回的数据类型
export interface loginResponseData {
  code: number
  data: dataType
}
2.3.2 State仓库类型封装
//定义小仓库数据state类型
export interface UserState {
  token: string | null
}
2.3.3 本地存储封装

将本地存储的方法封装到一起

//封装本地存储存储数据与读取数据方法
export const SET_TOKEN = (token: string) => {
  localStorage.setItem('TOKEN', token)
}

export const GET_TOKEN = () => {
  return localStorage.getItem('TOKEN')
}

2.4 登录时间的判断

  1. 封装函数
//封装函数:获取当前时间段
export const getTime = () => {
  let message = ''
  //通过内置构造函数Date
  const hour = new Date().getHours()
  if (hour <= 9) {
    message = '早上'
  } else if (hour <= 14) {
    message = '上午'
  } else if (hour <= 18) {
    message = '下午'
  } else {
    message = '晚上'
  }
  return message
}
  1. 使用(引入后)

image.png

  1. 效果

image.png

2.5 表单校验规则

2.5.1 表单校验
  1. 表单绑定项

image.png:model:绑定的数据

//收集账号与密码数据
let loginForm = reactive({ username: 'admin', password: '111111' })

:rules:对应要使用的规则

//定义表单校验需要的配置对象
const rules = {}

ref="loginForms":获取表单元素

//获取表单元素
let loginForms = ref()
  1. 表单元素绑定项

Form 组件提供了表单验证的功能,只需为 rules 属性传入约定的验证规则,并将 form-Item 的 prop 属性设置为需要验证的特殊键值即可 image.png

  1. 使用规则rules
//定义表单校验需要的配置对象
const rules = {
  username: [
    //规则对象属性:
    {
      required: true, // required,代表这个字段务必要校验的
      min: 5, //min:文本长度至少多少位
      max: 10, // max:文本长度最多多少位
      message: '长度应为6-10位', // message:错误的提示信息
      trigger: 'change', //trigger:触发校验表单的时机 change->文本发生变化触发校验, blur:失去焦点的时候触发校验规则
    }, 

  ],
  password: [
   {
      required: true,
      min: 6,
      max: 10,
      message: '长度应为6-15位',
      trigger: 'change',
    }, 
  ],
}
  1. 校验规则通过后运行
const login = async () => {
  //保证全部表单项校验通过
  await loginForms.value.validate()
    。。。。。。
}
2.5.2自定义表单校验
  1. 修改使用规则rules

使用自己编写的函数作为规则校验。

//定义表单校验需要的配置对象
const rules = {
  username: [
    //规则对象属性:
    /* {
      required: true, // required,代表这个字段务必要校验的
      min: 5, //min:文本长度至少多少位
      max: 10, // max:文本长度最多多少位
      message: '长度应为6-10位', // message:错误的提示信息
      trigger: 'change', //trigger:触发校验表单的时机 change->文本发生变化触发校验, blur:失去焦点的时候触发校验规则
    }, */
    { trigger: 'change', validator: validatorUserName },
  ],
  password: [
    { trigger: 'change', validator: validatorPassword },
  ],
}
  1. 自定义校验规则函数
//自定义校验规则函数
const validatorUserName = (rule: any, value: any, callback: any) => {
  //rule:校验规则对象
  //value:表单元素文本内容
  //callback:符合条件,callback放行通过,不符合:注入错误提示信息
  if (value.length >= 5) {
    callback()
  } else {
    callback(new Error('账号长度至少5位'))
  }
}

const validatorPassword = (rule: any, value: any, callback: any) => {
  if (value.length >= 6) {
    callback()
  } else {
    callback(new Error('密码长度至少6位'))
  }
}

3. Layout模块(主界面)

3.1 组件的静态页面

3.1.1 组件的静态页面

注意:我们将主界面单独放一个文件夹(顶替原来的home路由组件)。注意修改一下路由配置

<template>
  <div class="layout_container">
    <!-- 左侧菜单 -->
    <div class="layout_slider"></div>
    <!-- 顶部导航 -->
    <div class="layout_tabbar"></div>
    <!-- 内容展示区域 -->
    <div class="layout_main">
      <p style="height: 1000000px"></p>
    </div>
  </div>
</template>

<script setup lang="ts"></script>

<style lang="scss" scoped>
.layout_container {
  width: 100%;
  height: 100vh;
  .layout_slider {
    width: $base-menu-width;
    height: 100vh;
    background: $base-menu-background;
  }
  .layout_tabbar {
    position: fixed;
    width: calc(100% - $base-menu-width);
    height: $base-tabbar-height;
    background: cyan;
    top: 0;
    left: $base-menu-width;
  }
  .layout_main {
    position: absolute;
    width: calc(100% - $base-menu-width);
    height: calc(100vh - $base-tabbar-height);
    background-color: yellowgreen;
    left: $base-menu-width;
    top: $base-tabbar-height;
    padding: 20px;
    overflow: auto;
  }
}
</style>
3.1.2定义部分全局变量&滚动条

scss全局变量

//左侧菜单宽度
$base-menu-width :260px;
//左侧菜单背景颜色
$base-menu-background: #001529;

//顶部导航的高度
$base-tabbar-height:50px;

滚动条

//滚动条外观设置

::-webkit-scrollbar{
  width: 10px;
}

::-webkit-scrollbar-track{
  background: $base-menu-background;
}

::-webkit-scrollbar-thumb{
  width: 10px;
  background-color: yellowgreen;
  border-radius: 10px;
}

3.2 Logo子组件的搭建

页面左上角的这部分,我们将它做成子组件,并且封装方便维护以及修改。 image.png

3.2.1 Logo子组件

在这里我们引用了封装好的setting

<template>
  <div class="logo" v-if="setting.logoHidden">
    <img :src="setting.logo" alt="" />
    <p>{{ setting.title }}</p>
  </div>
</template>

<script setup lang="ts">
  //引入设置标题与logo配置文件
  import setting from '@/setting'
</script>

<style lang="scss" scoped>
  .logo {
    width: 100%;
    height: $base-menu-logo-height;
    color: white;
    display: flex;
    align-items: center;
    padding: 20px;
    img {
      width: 40px;
      height: 40px;
    }
    p {
      font-size: $base-logo-title-fontSize;
      margin-left: 10px;
    }
  }
</style>
3.2.2 封装setting

为了方便我们以后对logo以及标题的修改。

//用于项目logo|标题配置
export default {
  title: '硅谷甄选运营平台', //项目的标题
  logo: '/public/logo.png', //项目logo设置
  logoHidden: true, //logo组件是否隐藏
}
3.2.3 使用

在layout组件中引入并使用

3.3 左侧菜单组件

3.3.1静态页面(未封装)

主要使用到了element-plus的menu组件。附带使用了滚动组件

<!-- 左侧菜单 -->
<div class="layout_slider">
  <Logo></Logo>
  <!-- 展示菜单 -->
  <!-- 滚动组件 -->
  <el-scrollbar class="scrollbar">
    <!-- 菜单组件 -->
    <el-menu background-color="#001529" text-color="white">
      <el-menu-item index="1">首页</el-menu-item>
      <el-menu-item index="2">数据大屏</el-menu-item>
      <!-- 折叠菜单 -->
      <el-sub-menu index="3">
        <template #title>
          <span>权限管理</span>
        </template>
        <el-menu-item index="3-1">用户管理</el-menu-item>
        <el-menu-item index="3-2">角色管理</el-menu-item>
        <el-menu-item index="3-3">菜单管理</el-menu-item>
      </el-sub-menu>
    </el-menu>
  </el-scrollbar>
</div>
3.3.2 递归组件生成动态菜单

在这一部分,我们要根据路由生成左侧的菜单栏

  1. 父组件中写好的子组件结构提取出去
      <!-- 展示菜单 -->
      <!-- 滚动组件 -->
      <el-scrollbar class="scrollbar">
        <!-- 菜单组件 -->
        <el-menu background-color="#001529" text-color="white">
          <!-- 更具路由动态生成菜单 -->
          <Menu></Menu>
        </el-menu>
      </el-scrollbar>
  1. 动态菜单子组件:src\layout\menu\index.vue
  2. 处理路由

因为我们要根据路由以及其子路由作为我们菜单的一级|二级标题。因此我们要获取路由信息。

给路由中加入了路由元信息meta:它包含了2个属性:title以及hidden

{
  //登录路由
  path: '/login',
    component: () => import('@/views/login/index.vue'),
    name: 'login', //命名路由
    meta: {
        title: '登录', //菜单标题
      hidden: true, //路由的标题在菜单中是否隐藏
      },
      },
  1. 仓库引入路由并对路由信息类型声明(vue-router有对应函数)
//引入路由(常量路由)
import { constantRoute } from '@/router/routes'
。。。。。
//小仓库存储数据地方
state: (): UserState => {
  return {
    token: GET_TOKEN(), //用户唯一标识token
    menuRoutes: constantRoute, //仓库存储生成菜单需要数组(路由)
}

image.png

  1. 父组件拿到仓库路由信息并传递给子组件
<script setup lang="ts">
。。。。。。
//引入菜单组件
import Menu from './menu/index.vue'
//获取用户相关的小仓库
import useUserStore from '@/store/modules/user'
let userStore = useUserStore()
</script>

image.png

  1. 子组件prps接收并且处理结构
<template>
  <template v-for="(item, index) in menuList" :key="item.path">
    <!-- 没有子路由 -->
    <template v-if="!item.children">
      <el-menu-item v-if="!item.meta.hidden" :index="item.path">
        <template #title>
          <span>标</span>
          <span>{{ item.meta.title }}</span>
        </template>
      </el-menu-item>
    </template>
    <!-- 有且只有一个子路由 -->
    <template v-if="item.children && item.children.length == 1">
      <el-menu-item
        index="item.children[0].path"
        v-if="!item.children[0].meta.hidden"
      >
        <template #title>
          <span>标</span>
          <span>{{ item.children[0].meta.title }}</span>
        </template>
      </el-menu-item>
    </template>
    <!-- 有子路由且个数大于一个 -->
    <el-sub-menu
      :index="item.path"
      v-if="item.children && item.children.length >= 2"
    >
      <template #title>
        <span>{{ item.meta.title }}</span>
      </template>
      <Menu :menuList="item.children"></Menu>
    </el-sub-menu>
  </template>
</template>

<script setup lang="ts">
//获取父组件传递过来的全部路由数组
defineProps(['menuList'])
</script>
<script lang="ts">
export default {
  name: 'Menu',
}
</script>
<style lang="scss" scoped></style>

注意: 1:因为每一个项我们要判断俩次(是否要隐藏,以及子组件个数),所以在el-menu-item外面又套了一层模板 2:当子路由个数大于等于一个时,并且或许子路由还有后代路由时。这里我们使用了递归组件。递归组件需要命名(另外使用一个script标签,vue2格式)。

3.3.3 菜单图标

image.png

  1. 注册图标组件

因为我们要根据路由配置对应的图标,也要为了后续方便更改。因此我们将所有的图标注册为全局组件。(使用之前将分页器以及矢量图注册全局组件的自定义插件)(所有图标全局注册的方法element-plus文档中已给出)

。。。。。。
//引入element-plus提供全部图标组件
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
。。。。。。

//对外暴露插件对象
export default {
  //必须叫做install方法
  //会接收我们的app
 。。。。。。
  //将element-plus提供全部图标注册为全局组件 
    for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
      app.component(key, component)
    }
  },
}
  1. 给路由元信息添加属性:icon

以laytou和其子组件为例首先在element-puls找到你要使用的图标的名字。将它添加到路由元信息的icon属性

  {
    //登录成功以后展示数据的路由
    path: '/',
    component: () => import('@/layout/index.vue'),
    name: 'layout',
    meta: {
      title: 'layout',
      hidden: false,
      icon: 'Avatar',
    },
    children: [
      {
        path: '/home',
        component: () => import('@/views/home/index.vue'),
        meta: {
          title: '首页',
          hidden: false,
          icon: 'HomeFilled',
        },
      },
    ],
  },
  1. 菜单组件使用

以只有一个子路由的组件为例:

<!-- 有且只有一个子路由 -->
<template v-if="item.children && item.children.length == 1">
  <el-menu-item
    index="item.children[0].path"
    v-if="!item.children[0].meta.hidden"
    >
    <template #title>
      <el-icon>
        <component :is="item.children[0].meta.icon"></component>
      </el-icon>
      <span>{{ item.children[0].meta.title }}</span>
    </template>
  </el-menu-item>
</template>

image.png

3.3.4 项目全部路由配置
  1. 全部路由配置(以权限管理为例)
{
  path: '/acl',
    component: () => import('@/layout/index.vue'),
    name: 'Acl',
    meta: {
    hidden: false,
      title: '权限管理',
      icon: 'Lock',
      },
  children: [
    {
      path: '/acl/user',
      component: () => import('@/views/acl/user/index.vue'),
      name: 'User',
      meta: {
        hidden: false,
        title: '用户管理',
        icon: 'User',
      },
    },
    {
      path: '/acl/role',
      component: () => import('@/views/acl/role/index.vue'),
      name: 'Role',
      meta: {
        hidden: false,
        title: '角色管理',
        icon: 'UserFilled',
      },
    },
    {
      path: '/acl/permission',
      component: () => import('@/views/acl/permission/index.vue'),
      name: 'Permission',
      meta: {
        hidden: false,
        title: '菜单管理',
        icon: 'Monitor',
      },
    },
  ],
    },
  1. 添加路由跳转函数

第三种情况我们使用组件递归,所以只需要给前面的2个添加函数 image.pngimage.png

<script setup lang="ts">
。。。。。。
//获取路由器对象
let $router = useRouter()
const goRoute = (vc: any) => {
  //路由跳转
  $router.push(vc.index)
}
</script>
  1. layout组件

image.png

3.3.5 Bug&&总结

在这部分对router-link遇到一些bug,理解也更深了,特意写一个小结总结一下

bug:router-link不生效。 描述:当我点击跳转函数的时候,直接跳转到一个新页面,而不是layout组件展示的部分更新。 思路:首先输出了一下路径,发现路径没有错。其次,因为跳转到新页面,代表layout组件中的router-link不生效,删除router-link,发现没有影响。所以确定了是router-link没有生效。 解决:仔细检查了src\router\routes.ts文件,最后发现一级路由的component关键字写错。导致下面的二级路由没有和以及路由构成父子关系。所以会跳转到APP组件下的router-link 总结:router-link会根据下面的子路由来进行展示。如果发生了路由跳转不对的情况,去仔细检查一下路由关系有没有写对。APP是所有一级路由组件的父组件

3.3.6 动画 && 自动展示
  1. 将router-link封装成单独的文件并且添加一些动画
<template>
  <!-- 路由组件出口的位置 -->
  <router-view v-slot="{ Component }">
    <transition name="fade">
      <!-- 渲染layout一级路由的子路由 -->
      <component :is="Component" />
    </transition>
  </router-view>
</template>

<script setup lang="ts"></script>

<style lang="scss" scoped>
  .fade-enter-from {
    opacity: 0;
  }
  .fade-enter-active {
    transition: all 0.3s;
  }
  .fade-enter-to {
    opacity: 1;
  }
</style>
  1. 自动展示

当页面刷新时,菜单会自动收起。我们使用element-plus的*default-active *处理。$router.path为当前路由。 src\layout\index.vue image.png

3.4 顶部tabbar组件

3.4.1静态页面

element-plus:breadcrumb el-button el-dropdown

<template>
  <div class="tabbar">
    <div class="tabbar_left">
      <!-- 顶部左侧的图标 -->
      <el-icon style="margin-right: 10px">
        <Expand></Expand>
      </el-icon>
      <!-- 左侧的面包屑 -->
      <el-breadcrumb separator-icon="ArrowRight">
        <el-breadcrumb-item>权限挂历</el-breadcrumb-item>
        <el-breadcrumb-item>用户管理</el-breadcrumb-item>
      </el-breadcrumb>
    </div>
    <div class="tabbar_right">
      <el-button size="small" icon="Refresh" circle></el-button>
      <el-button size="small" icon="FullScreen" circle></el-button>
      <el-button size="small" icon="Setting" circle></el-button>
      <img
        src="../../../public/logo.png"
        style="width: 24px; height: 24px; margin: 0px 10px"
      />
      <!-- 下拉菜单 -->
      <el-dropdown>
        <span class="el-dropdown-link">
          admin
          <el-icon class="el-icon--right">
            <arrow-down />
          </el-icon>
        </span>
        <template #dropdown>
          <el-dropdown-menu>
            <el-dropdown-item>退出登陆</el-dropdown-item>
          </el-dropdown-menu>
        </template>
      </el-dropdown>
    </div>
  </div>
</template>

<script setup lang="ts"></script>

<style lang="scss" scoped>
.tabbar {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: space-between;
  background-image: linear-gradient(
    to right,
    rgb(236, 229, 229),
    rgb(151, 136, 136),
    rgb(240, 234, 234)
  );
  .tabbar_left {
    display: flex;
    align-items: center;
    margin-left: 20px;
  }
  .tabbar_right {
    display: flex;
    align-items: center;
  }
}
</style>

组件拆分:

<template>
  <!-- 顶部左侧的图标 -->
  <el-icon style="margin-right: 10px">
    <Expand></Expand>
  </el-icon>
  <!-- 左侧的面包屑 -->
  <el-breadcrumb separator-icon="ArrowRight">
    <el-breadcrumb-item>权限挂历</el-breadcrumb-item>
    <el-breadcrumb-item>用户管理</el-breadcrumb-item>
  </el-breadcrumb>
</template>

<script setup lang="ts"></script>

<style lang="scss" scoped></style>
<template>
  <el-button size="small" icon="Refresh" circle></el-button>
  <el-button size="small" icon="FullScreen" circle></el-button>
  <el-button size="small" icon="Setting" circle></el-button>
  <img
    src="../../../../public/logo.png"
    style="width: 24px; height: 24px; margin: 0px 10px"
  />
  <!-- 下拉菜单 -->
  <el-dropdown>
    <span class="el-dropdown-link">
      admin
      <el-icon class="el-icon--right">
        <arrow-down />
      </el-icon>
    </span>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item>退出登陆</el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

<script setup lang="ts"></script>

<style lang="scss" scoped></style>
3.4.2 菜单折叠
  1. 折叠变量

定义一个折叠变量来判断现在的状态是否折叠。因为这个变量同时给breadcrumb组件以及父组件layout使用,因此将这个变量定义在pinia中

//小仓库:layout组件相关配置仓库
import { defineStore } from 'pinia'

let useLayOutSettingStore = defineStore('SettingStore', {
  state: () => {
    return {
      fold: false, //用户控制菜单折叠还是收起的控制
    }
  },
})

export default useLayOutSettingStore
  1. 面包屑组件点击图标切换状态
<template>
  <!-- 顶部左侧的图标 -->
  <el-icon style="margin-right: 10px" @click="changeIcon">
    <component :is="LayOutSettingStore.fold ? 'Fold' : 'Expand'"></component>
  </el-icon>
  。。。。。。。
</template>

<script setup lang="ts">
import useLayOutSettingStore from '@/store/modules/setting'
//获取layout配置相关的仓库
let LayOutSettingStore = useLayOutSettingStore()

//点击图标的切换
const changeIcon = () => {
  //图标进行切换
  LayOutSettingStore.fold = !LayOutSettingStore.fold
}
</script>
。。。。。。
  1. layout组件根据fold状态来修改个子组件的样式(以左侧菜单为例)

image.png 绑定动态样式修改scss image.png

  1. 左侧菜单使用element-plus折叠collapse属性

image.png效果图: image.png 注意:折叠文字的时候会把图标也折叠起来。在menu组件中吧图标放到template外面就可以。 image.png

3.4.3 顶部面包屑动态展示
  1. 引入$route

注意$router和$route是不一样的

<script setup lang="ts">
import { useRoute } from 'vue-router'
//获取路由对象
let $route = useRoute()
//点击图标的切换

</script>
  1. 结构展示

注意:使用了$route.matched函数,此函数能得到当前路由的信息 image.png

  1. 首页修改

访问首页时,因为它是二级路由,会遍历出layout面包屑,处理:删除layout路由的title。再加上一个判断 image.png

  1. 面包屑点击跳转

注意:将路由中的一级路由权限管理以及商品管理重定向到第一个孩子,这样点击跳转的时候会定向到第一个孩子。 image.png

3.4.4 刷新业务的实现
  1. 使用pinia定义一个变量作为标记

image.png

  1. 点击刷新按钮,修改标记

image.png

<script setup lang="ts">
//使用layout的小仓库
import useLayOutSettingStore from '@/store/modules/setting'
let layoutSettingStore = useLayOutSettingStore()
//刷新按钮点击的回调
const updateRefresh = () => {
  layoutSettingStore.refresh = !layoutSettingStore.refresh
}
</script>
  1. main组件检测标记销毁&重加载组件(nextTick

image.png

<script setup lang="ts">
import { watch, ref, nextTick } from 'vue'
//使用layout的小仓库
import useLayOutSettingStore from '@/store/modules/setting'
let layOutSettingStore = useLayOutSettingStore()
//控制当前组件是否销毁重建
let flag = ref(true)
//监听仓库内部的数据是否发生变化,如果发生变化,说明用户点击过刷新按钮
watch(
  () => layOutSettingStore.refresh,
  () => {
    //点击刷新按钮:路由组件销毁
    flag.value = false
    nextTick(() => {
      flag.value = true
    })
  },
)
</script>
3.4.5 全屏模式的实现
  1. 给全屏按钮绑定函数

image.png

  1. 实现全屏效果(利用docment根节点的方法)

image-20240629231436529

//全屏按钮点击的回调
const fullScreen = () => {
  //DOM对象的一个属性:可以用来判断当前是不是全屏的模式【全屏:true,不是全屏:false】
  let full = document.fullscreenElement
  //切换成全屏
  if (!full) {
    //文档根节点的方法requestFullscreen实现全屏
    document.documentElement.requestFullscreen()
  } else {
    //退出全屏
    document.exitFullscreen()
  }

4.部分功能处理完善

==登录这一块大概逻辑,前端发送用户名密码到后端,后端返回token,前端保存,并且请求拦截器,请求头有token就要携带token==

image-20240629233534030

4.1 登录获取用户信息(TOKEN)

登录之后页面(home)上来就要获取用户信息。并且将它使用到页面中

  1. home组件挂载获取用户信息
<script setup lang="ts">
//引入组合是API生命周期函数
import { onMounted } from 'vue'
import useUserStore from '@/store/modules/user'
let userStore = useUserStore()
onMounted(() => {
  userStore.userInfo()
})
</script>
  1. 小仓库中定义用户信息以及type声明

image.png

import type { RouteRecordRaw } from 'vue-router'
//定义小仓库数据state类型
export interface UserState {
  token: string | null
  menuRoutes: RouteRecordRaw[]
  username: string
  avatar: string
}
  1. 请求头添加TOKEN
//引入用户相关的仓库
import useUserStore from '@/store/modules/user'
    。。。。。。
//请求拦截器
request.interceptors.request.use((config) => {
  //获取用户相关的小仓库,获取token,登录成功以后携带个i服务器
  const userStore = useUserStore()
  if (userStore.token) {
    config.headers.token = userStore.token
  }
  //config配置对象,headers请求头,经常给服务器端携带公共参数
  //返回配置对象
  return config
})
  1. 小仓库发请求并且拿到用户信息
    //获取用户信息方法
    async userInfo() {
      //获取用户信息进行存储
      let result = await reqUserInfo()
      if (result.code == 200) {
        this.username = result.data.checkUser.username
        this.avatar = result.data.checkUser.avatar
      }
    },
  1. 更新tabbar的信息(记得先引入并创建实例)

src\layout\tabbar\setting\index.vue image.pngimage.png

4.2 退出功能

  1. 退出登录绑定函数,调用仓库函数

image.png

//退出登陆点击的回调
const logout = () => {
  //第一件事:需要项服务器发请求【退出登录接口】(我们这里没有)
  //第二件事:仓库当中和关于用户的相关的数据清空
  userStore.userLogout()
  //第三件事:跳转到登陆页面
}
  1. pinia仓库
    //退出登录
    userLogout() {
      //当前没有mock接口(不做):服务器数据token失效
      //本地数据清空
      this.token = ''
      this.username = ''
      this.avatar = ''
      REMOVE_TOKEN()
    },
  1. 退出登录,路由跳转

注意:携带的query参数方便下次登陆时直接跳转到当时推出的界面 个人觉得这个功能没什么作用。但是可以学习方法

//退出登陆点击的回调
const logout = () => {
  //第一件事:需要项服务器发请求【退出登录接口】(我们这里没有)
  //第二件事:仓库当中和关于用户的相关的数据清空
  userStore.userLogout()
  //第三件事:跳转到登陆页面
  $router.push({ path: '/login', query: { redirect: $route.path } })
}
  1. 登录按钮进行判断

image.png

4.3 路由守卫

src\permisstion.ts(新建文件) main.ts引入 image.png

4.3.1 进度条
  1. 安装

pnpm i nprogress

  1. 引入并使用
//路由鉴权:鉴权:项目当中路由能不能被访问的权限
import router from '@/router'
import nprogress from 'nprogress'
//引入进度条样式
import 'nprogress/nprogress.css'
//全局前置守卫
router.beforeEach((to: any, from: any, next: any) => {
  //访问某一个路由之前的守卫
  nprogress.start()
  next()
})

//全局后置守卫
router.afterEach((to: any, from: any) => {
  // to and from are both route objects.
  nprogress.done()
})

//第一个问题:任意路由切换实现进度条业务 ----nprogress
4.3.2 路由鉴权
//路由鉴权:鉴权:项目当中路由能不能被访问的权限
import router from '@/router'
import setting from './setting'
import nprogress from 'nprogress'
//引入进度条样式
import 'nprogress/nprogress.css'
//进度条的加载圆圈不要
nprogress.configure({ showSpinner: false })
//获取用户相关的小仓库内部token数据,去判断用户是否登陆成功
import useUserStore from './store/modules/user'
//为什么要引pinia
import pinia from './store'
const userStore = useUserStore(pinia)

//全局前置守卫
router.beforeEach(async (to: any, from: any, next: any) => {
  //网页的名字
  document.title = `${setting.title}-${to.meta.title}`
  //访问某一个路由之前的守卫
  nprogress.start()
  //获取token,去判断用户登录、还是未登录
  const token = userStore.token
  //获取用户名字
  let username = userStore.username
  //用户登录判断
  if (token) {
    //登陆成功,访问login。指向首页
    if (to.path == '/login') {
      next('/home')
    } else {
      //登陆成功访问其余的,放行
      //有用户信息
      if (username) {
        //放行
        next()
      } else {
        //如果没有用户信息,在收尾这里发请求获取到了用户信息再放行
        try {
          //获取用户信息
          await userStore.userInfo()
          next()
        } catch (error) {
          //token过期|用户手动处理token
          //退出登陆->用户相关的数据清空
          userStore.userLogout()
          next({ path: '/login', query: { redirect: to.path } })
        }
      }
    }
  } else {
    //用户未登录
    if (to.path == '/login') {
      next()
    } else {
      next({ path: '/login', query: { redirect: to.path } })
    }
  }
  next()
})

//全局后置守卫
router.afterEach((to: any, from: any) => {
  // to and from are both route objects.
  nprogress.done()
})

//第一个问题:任意路由切换实现进度条业务 ----nprogress
//第二个问题:路由鉴权
//全部路由组件 :登录|404|任意路由|首页|数据大屏|权限管理(三个子路由)|商品管理(4个子路由)

//用户未登录 :可以访问login 其余都不行
//登陆成功:不可以访问login 其余都可以

路由鉴权几个注意点

  1. 获取用户小仓库为什么要导入pinia?

image.png 个人理解:之前在app中是不需要导入pinia的,是因为我们这次的文件时写在和main.ts同级的下面,所以我们使用的时候是没有pinia的。而之前使用时app已经使用了pinia了,所以我们不需要导入pina。

  1. 全局路由守卫将获取用户信息的请求放在了跳转之前。实现了刷新后用户信息丢失的功能。

4.4 真实接口替代mock接口

接口文档: http://139.198.104.58:8209/swagger-ui.html http://139.198.104.58:8212/swagger-ui.html#/

  1. 修改服务器域名

将.env.development,.env.production .env.test,三个环境文件下的服务器域名写为: image.png

  1. 代理跨域
import { loadEnv } from 'vite'
。。。。。。
export default defineConfig(({ command, mode }) => {
  //获取各种环境下的对应的变量
  let env = loadEnv(mode, process.cwd())
  return {
    。。。。。。。
    //代理跨域
    server: {
      proxy: {
        [env.VITE_APP_BASE_API]: {
          //获取数据服务器地址的设置
          target: env.VITE_SERVE,
          //需要代理跨域
          changeOrigin: true,
          //路径重写
          rewrite: (path) => path.replace(/^\/api/, ''),
        },
      },
    },
  }
})
  1. 修改api

在这里退出登录有了自己的api

//统一管理项目用户相关的接口
import request from '@/utils/request'

//项目用户相关的请求地址
enum API {
  LOGIN_URL = '/admin/acl/index/login',
  USERINFO_URL = '/admin/acl/index/info',
  LOGOUT_URL = '/admin/acl/index/logout',
}
//对外暴露请求函数
//登录接口方法
export const reqLogin = (data: any) => {
  return request.post<any, any>(API.LOGIN_URL, data)
}

//获取用户信息接口方法
export const reqUserInfo = () => {
  return request.get<any, any>(API.USERINFO_URL)
}

//退出登录
export const reqLogout = () => {
  return request.post<any, any>(API.LOGOUT_URL)
}
  1. 小仓库(user)

替换原有的请求接口函数,以及修改退出登录函数。以及之前引入的类型显示我们展示都设置为any

//创建用户相关的小仓库
import { defineStore } from 'pinia'
//引入接口
import { reqLogin, reqUserInfo, reqLogout } from '@/api/user'
import type { UserState } from './types/type'
//引入操作本地存储的工具方法
import { SET_TOKEN, GET_TOKEN, REMOVE_TOKEN } from '@/utils/token'
//引入路由(常量路由)
import { constantRoute } from '@/router/routes'

//创建用户小仓库
const useUserStore = defineStore('User', {
  //小仓库存储数据地方
  state: (): UserState => {
    return {
      token: GET_TOKEN(), //用户唯一标识token
      menuRoutes: constantRoute, //仓库存储生成菜单需要数组(路由)
      username: '',
      avatar: '',
    }
  },
  //处理异步|逻辑地方
  actions: {
    //用户登录的方法
    async userLogin(data: any) {
      //登录请求
      const result: any = await reqLogin(data)

      if (result.code == 200) {
        //pinia仓库存储token
        //由于pinia|vuex存储数据其实利用js对象
        this.token = result.data as string
        //本地存储持久化存储一份
        SET_TOKEN(result.data as string)
        //保证当前async函数返回一个成功的promise函数
        return 'ok'
      } else {
        return Promise.reject(new Error(result.data))
      }
    },
    //获取用户信息方法
    async userInfo() {
      //获取用户信息进行存储
      const result = await reqUserInfo()
      console.log(result)

      if (result.code == 200) {
        this.username = result.data.name
        this.avatar = result.data.avatar
        return 'ok'
      } else {
        return Promise.reject(new Error(result.message))
      }
    },
    //退出登录
    async userLogout() {
      const result = await reqLogout()
      if (result.code == 200) {
        //本地数据清空
        this.token = ''
        this.username = ''
        this.avatar = ''
        REMOVE_TOKEN()
        return 'ok'
      } else {
        return Promise.reject(new Error(result.message))
      }
    },
  },
  getters: {},
})
//对外暴露小仓库
export default useUserStore
  1. 退出登录按钮的点击函数修改

退出成功后再跳转 image.png

  1. 路由跳转判断条件修改

src\permisstion.ts 也是退出成功后再跳转 image.png

4.5 接口类型定义

//登录接口需要携带参数类型
export interface loginFormData {
  username: string
  password: string
}

//定义全部接口返回数据都有的数据类型
export interface ResponseData {
  code: number
  message: string
  ok: boolean
}
//定义登录接口返回数据类型
export interface loginResponseData extends ResponseData {
  data: string
}

//定义获取用户信息返回的数据类型
export interface userInfoResponseData extends ResponseData {
  data: {
    routes: string[]
    button: string[]
    roles: string[]
    name: string
    avatar: string
  }
}

注意:在src\store\modules\user.ts以及src\api\user\index.ts文件中对发请求时的参数以及返回的数据添加类型定义

5.品牌管理模块

5.1 静态组件

使用element-plus。

<template>
  <el-card class="box-card">
    <!-- 卡片顶部添加品牌按钮 -->
    <el-button type="primary" size="default" icon="Plus">添加品牌</el-button>
    <!-- 表格组件,用于展示已有的数据 -->
    <!-- 
      table
      ---border:是否有纵向的边框
      table-column
      ---lable:某一个列表
      ---width:设置这一列的宽度
      ---align:设置这一列对齐方式
     -->
    <el-table style="margin: 10px 0px" border>
      <el-table-column
        label="序号"
        width="80px"
        align="center"
      ></el-table-column>
      <el-table-column label="品牌名称"></el-table-column>
      <el-table-column label="品牌LOGO"></el-table-column>
      <el-table-column label="品牌操作"></el-table-column>
    </el-table>
    <!-- 分页器组件 -->
    <!-- 
      pagination
      ---v-model:current-page:设置当前分页器页码
      ---v-model:page-size:设置每一也展示数据条数
      ---page-sizes:每页显示个数选择器的选项设置
      ---background:背景颜色
      ---layout:分页器6个子组件布局的调整 "->"把后面的子组件顶到右侧
     -->
    <el-pagination
      v-model:current-page="pageNo"
      v-model:page-size="limit"
      :page-sizes="[3, 5, 7, 9]"
      :background="true"
      layout=" prev, pager, next, jumper,->,total, sizes,"
      :total="400"
    />
  </el-card>
</template>

<script setup lang="ts">
//引入组合式API函数
import { ref } from 'vue'
//当前页码
let pageNo = ref<number>(1)
//每一页展示的数据
let limit = ref<number>(3)
</script>

<style lang="scss" scoped></style>

5.2 数据模块

5.2.1 API
  1. api函数
//书写品牌管理模块接口
import request from '@/utils/request'
//品牌管理模块接口地址
enum API {
  //获取已有品牌接口
  TRADEMARK_URL = '/admin/product/baseTrademark/',
}
//获取一样偶品牌的接口方法
//page:获取第几页 ---默认第一页
//limit:获取几个已有品牌的数据
export const reqHasTrademark = (page: number, limit: number) =>
  request.get<any, any>(API.TRADEMARK_URL + `${page}/${limit}`)
  1. 获取数据

我们获取数据没有放在pinia中,二是放在组件中挂载时获取数据

<script setup lang="ts">
import { reqHasTrademark } from '@/api/product/trademark'
//引入组合式API函数
import { ref, onMounted } from 'vue'
//当前页码
let pageNo = ref<number>(1)
//每一页展示的数据
let limit = ref<number>(3)
//存储已有品牌数据总数
let total = ref<number>(0)
//存储已有品牌的数据
let trademarkArr = ref<any>([])
//获取已有品牌的接口封装为一个函数:在任何情况下向获取数据,调用次函数即可
const getHasTrademark = async (pager = 1) => {
  //当前页码
  pageNo.value = pager
  let result = await reqHasTrademark(pageNo.value, limit.value)
  console.log(result)
  if (result.code == 200) {
    //存储已有品牌总个数
    total.value = result.data.total
    trademarkArr.value = result.data.records
    console.log(trademarkArr)

  }
}
//组件挂载完毕钩子---发一次请求,获取第一页、一页三个已有品牌数据
onMounted(() => {
  getHasTrademark()
})
</script>
5.2.2 数据展示

在数据展示模块,我们使用了element-plus的el-table,下面组要讲解属性和注意点。

  1. data属性:显示的数据

比如我们这里绑定的trademarkArr是个三个对象的数组,就会多出来3行。image.png

  1. el-table-column的type属性:对应列的类型。 如果设置了selection则显示多选框; 如果设置了 index 则显示该行的索引(从 1 开始计算); 如果设置了 expand 则显示为一个可展开的按钮

image.png

  1. el-table-column的prop属性:字段名称 对应列内容的字段名, 也可以使用 property属性

注意:因为我们之前已经绑定了数据,所以在这里直接使用数据的属性tmName