admin管理员组

文章数量:1532346

本项目采用最新的Vue3+组合式API开发方式

使用主流技术栈:vue3+typescript+vue-router+pinia+element-plus+axios+echarts

GitHub仓库地址

初始化项目

环境准备:

  • node v18.17.0
  • pnpm v8.6.12

pnpm安装:参考

  1. 使用vite构建项目:
pnpm create vite
  1. 进入项目目录后安装依赖:
pnpm install
  1. 启动项目
pnpm run dev --host

  1. 删除默认的/src/style.css文件,同时在main.ts中也删除
  2. 安装Vue VSCode Snippets扩展
  3. 删除默认的App.Vue文件内容,输入v3ts选组合式API生成模板后修改:
<template>
  <div>
    <h1>App根组件</h1>
  </div>
</template>
  1. 删除自带的/src/components/HelloWorld.vue组件和/src/assets/vue.svg图标
  2. 修改页面标题index.html
<title>Vue3-template</title>
  1. VSCode中搜索并安装扩展:Volar
  2. 设置Volar Takeover 模式
  3. tsconfig.jsontsconfig.node.json中的moduleResolution选项设置为nodeCTRL+SHIFT+PRELOAD WINDOW

项目集成

集成element-plus

  1. 安装依赖
pnpm i element-plus
  1. 在入口文件main.ts中注册插件
import { createApp } from 'vue'
import App from './App.vue'
//引入element-plus 插件与样式
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

//获取应用实例对象
const app = createApp(App)
//注册element-plus插件
app.use(ElementPlus)
//将应用挂载到挂载点上
app.mount('#app')
  1. 安装图标组件库
pnpm i @element-plus/icons-vue
  1. 安装Element UI Snippets扩展
  2. main.ts配置element组件使用中文
//配置element-plus中文
//@ts-ignore忽略当前文件ts类型的检测否则有红色提示(打包会失败)
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
注册element-plus插件
app.use(ElementPlus, {
    locale: zhCn, //配置element-plus中文
  })
  1. App.vue中测试效果:
<template>
  <div>
    <el-button type="primary" size="default" :icon="Plus">主要按钮</el-button>
    <el-button type="success" size="small" :icon="Edit">编辑按钮</el-button>
    <el-button type="danger" size="default" :icon="Delete">删除按钮</el-button>
    <el-pagination
      :page-sizes="[100, 200, 300, 400]"
      layout="total, sizes, prev, pager, next, jumper"
      :total="400"
    />
  </div>
</template>

<script setup lang="ts">
//引入图表组件
import { Plus,Edit,Delete } from '@element-plus/icons-vue'
</script>

<style scoped></style>

src别名配置

  1. 安装依赖
pnpm install @types/node
  1. vite.config.ts中引入path
import path from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      "@": path.resolve("./src") // 相对路径别名配置,使用 @ 代替 src
    }
  }
})
  1. TypeScript编译配置
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "./", // 解析非相对模块的基地址,默认是当前目录
    "paths": { //路径映射,相对于baseUrl
      "@/*": ["src/*"] 
    }
  }
}
  1. main.ts中使用@引入App.vue
import App from '@/App.vue'
  1. 新建@/components/Test.vue测试组件@
<template>
    <div>
        <h1>测试组件@</h1>
    </div>
</template>

<script setup lang="ts">

</script>

<style scoped></style>
  1. App.vue直接引用测试组件
<template>
  <div>
    <el-button type="primary" size="default" :icon="Plus">主要按钮</el-button>
    <el-button type="success" size="small" :icon="Edit">编辑按钮</el-button>
    <el-button type="danger" size="default" :icon="Delete">删除按钮</el-button>
    <el-pagination
      :page-sizes="[100, 200, 300, 400]"
      layout="total, sizes, prev, pager, next, jumper"
      :total="400"
    />
    <!-- 引入测试组件 -->
    <Test></Test>
  </div>
</template>

<script setup lang="ts">
//引入图表组件
import { Plus,Edit,Delete } from '@element-plus/icons-vue'
import Test from '@/components/Test.vue'//引入测试组件
</script>

<style scoped></style>

环境变量的配置

  1. 项目根目录分别添加 开发、生产和测试环境的文件
.env.development
.env.production
.env.test
  1. 文件内容
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
VITE_APP_TITLE = 'xxxx'
VITE_APP_BASE_API = '/api'
VITE_SERVE="http://xxx"

# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'production'
VITE_APP_TITLE = 'xxx系统'
VITE_APP_BASE_API = '/prod-api'
VITE_SERVE="http://xxx"

# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'test'
VITE_APP_TITLE = 'xxx系统'
VITE_APP_BASE_API = '/test-api'
VITE_SERVE="http://xxx"
  1. 配置运行命令:package.json
"scripts": {
    "dev": "vite --open",
    "build:test": "vue-tsc && vite build --mode test",
    "build:pro": "vue-tsc && vite build --mode production",
    "preview": "vite preview"
  },
  1. 在项目中可以通过import.meta.env获取环境变量

SVG图标配置

在开发项目的时候经常会用到svg矢量图,而且我们使用SVG以后,页面上加载的不再是图片资源,这对页面性能来说是个很大的提升,而且我们SVG文件比img要小的很多,放在项目中几乎不占用资源。

  1. 安装SVG依赖插件
pnpm install vite-plugin-svg-icons -D
pnpm install fast-glob
  1. vite.config.ts中配置插件
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'

export default () => {
  return {
    plugins: [
      createSvgIconsPlugin({
        // Specify the icon folder to be cached
        iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
        // Specify symbolId format
        symbolId: 'icon-[dir]-[name]',
      }),
    ],
  }
}
  1. main.ts入口文件中导入
import 'virtual:svg-icons-register'
  1. 新建src/assets/icons,导入用到的svg图标

  2. svg封装为全局组件:因为项目很多模块需要使用图标,因此把它封装为全局组件,在src/components目录下创建一个SvgIcon文件夹,在SvgIcon文件夹下再新建index.vue

<template>
    <div>
        <svg :style="{ width: width, height: height }">
            <use :xlink:href="prefix + name" :fill="color"></use>
        </svg>
    </div>
</template>
  
<script setup lang="ts">
defineProps({
    //xlink:href属性值的前缀
    prefix: {
        type: String,
        default: '#icon-'
    },
    //svg矢量图的名字
    name: String,
    //svg图标的颜色
    color: {
        type: String,
        default: ""
    },
    //svg宽度
    width: {
        type: String,
        default: '16px'
    },
    //svg高度
    height: {
        type: String,
        default: '16px'
    }

})
</script>
<style scoped></style>
  1. 修改App.vue测试SVG,删除之前测试@创建的Test.vue
<template>
  <div>
    <h1>测试SVG</h1>
    <!-- 测试SVG图标使用 -->
    <svg-icon name="welcome"></svg-icon>
  </div>
</template>

<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon/index.vue'
</script>

<style scoped>

</style>

使用自定义插件方式注册SVG为全局组件

注册为全局组件后就不用在每次使用的时候都用import引入

  1. src/components文件夹下创建index.ts文件:用于注册components文件夹内部全部全局组件(./Pagination/index.vue直接用v3ts模板显示一句话就行)
//引入项目中全部的全局组件
import SvgIcon from './SvgIcon/index.vue';
import Pagination from './Pagination/index.vue';
import type { App, Component } from 'vue';
//全局对象
const allGlobalComponents: { [name: string]: Component } = { SvgIcon, Pagination };
export default {
    //务必叫做install方法
    install(app: App) {
        //注册项目全部的全局组件
        Object.keys(allGlobalComponents).forEach((key: string) => {
            //注册为全局组件
            app.component(key, allGlobalComponents[key]);
        })
    }
}
  1. main.ts入口文件中引入src/index.ts文件,通过app.use方法安装自定义插件
import gloablComponent from '@/components';
app.use(gloablComponent);
  1. App.vue中直接使用全局组件,不用import导入
<template>
  <div>
    <h1>测试SVG</h1>
    <!-- 测试SVG图标使用 -->
    <svg-icon name="welcome"></svg-icon>
    <!-- 测试自定义组件方式注册的全局组件 -->
    <Pagination />
  </div>
</template>

<script setup lang="ts">
// import SvgIcon from '@/components/SvgIcon/index.vue'
</script>

<style scoped></style>

集成sass

  1. 安装依赖
pnpm install sass sas-loader
  1. App.vue中测试项目能否使用scss语法
<template>
  <div>
<h1>测试SCSS</h1>
  </div>
</template>

<script setup lang="ts">

</script>

<style scoped lang="scss">
  div{
    h1{
      color: red;
    }
  }
</style>
  1. 为项目添加全局样式:在src/styles下新建index.scss文件和reset.scss,在npm官网搜索reset.scss后复制内容到项目文件中,项目中可能需要用到清除默认样式,因此在index.scss中引入reset.scss
//引入q默认样式
@import './reset.scss';
  1. main.ts入口文件引入全局样式
import '@/styles/index.scss'
  1. 给项目中引入全局变量:在styles下创建一个variable.scss文件,在vite.config.ts文件配置如下
export default defineConfig((config) => {
    css: {
      preprocessorOptions: {
        scss: {
          javascriptEnabled: true,
          additionalData: '@import "./src/styles/variable.scss";',
        },
      },
    },
  }
}
  1. variable.scss下设置全局变量
//为项目提供scss全局变量

//定义项目主题颜色
$base-color: purple;
  1. App.vue中使用全局变量
<style scoped lang="scss">
div {
  h1 {
    color: $base-color;
  }
}
</style>

mock数据

  1. 安装依赖
pnpm install -D vite-plugin-mock@2.9.6 mockjs
  1. vite.config.js配置文件启用插件
import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig(({ command }) => {
  return {
    plugins: [
    ...,
    viteMockServe({
      //保证开发阶段可以使用mock数据
      localEnabled: command === 'serve',
    })
   	],
    ...
})
  1. 在根目录创建mock文件夹:在mock文件夹内部创建一个user.ts文件,写入需要的mock数据与接口
//用户信息数据
//createUserList此函数执行会返回一个用户信息数组,包含两个用户信息
function createUserList() {
    return [
        {
            userId: 1,
            avatar:
                'https://wpimg.wallstcn/f778738c-e4f8-4870-b634-56703b4acafe.gif',
            username: 'admin',
            password: '111111',
            desc: '平台管理员',
            roles: ['平台管理员'],
            buttons: ['cuser.detail'],
            routes: ['home'],
            token: 'Admin Token',
        },
        {
            userId: 2,
            avatar:
                'https://wpimg.wallstcn/f778738c-e4f8-4870-b634-56703b4acafe.gif',
            username: 'system',
            password: '111111',
            desc: '系统管理员',
            roles: ['系统管理员'],
            buttons: ['cuser.detail', 'cuser.user'],
            routes: ['home'],
            token: 'System Token',
        },
    ]
}

//对外暴露一个数组:数组里面包含两个接口:登录接口、获取用户信息接口
export default [
    // 用户登录接口
    {
        url: '/api/user/login',//请求地址
        method: 'post',//请求方式
        response: ({ body }) => {
            //获取请求体携带过来的用户名与密码
            const { username, password } = body;
            //调用获取用户信息函数,用于判断是否有此用户
            const checkUser = createUserList().find(
                (item) => item.username === username && item.password === password,
            )
            //没有用户返回失败信息
            if (!checkUser) {
                return { code: 201, data: { message: '账号或者密码不正确' } }
            }
            //如果有返回成功信息
            const { token } = checkUser
            return { code: 200, data: { token } }
        },
    },
    // 获取用户信息
    {
        url: '/api/user/info',
        method: 'get',
        response: (request) => {
            //获取请求头携带token
            const token = request.headers.token;
            //查看用户信息是否包含有次token用户
            const checkUser = createUserList().find((item) => item.token === token)
            //没有返回失败的信息
            if (!checkUser) {
                return { code: 201, data: { message: '获取用户信息失败' } }
            }
            //如果有返回成功信息
            return { code: 200, data: { checkUser } }
        },
    },
]
  1. 安装axios
pnpm i axios
  1. main.ts测试mock能否使用(测试完后删除)
//测试代码:测试mock数据和接口能否使用
import axios from 'axios'
//登录接口
axios({
  url: '/api/user/login',//请求地址
  method: 'post',//请求方式
  data: {
    username: 'admin',
    password: '111111'
  }
})

axios二次封装

在开发项目的时候避免不了与后端进行交互,因此需要使用axios插件实现发送网络请求。在开发项目的时候经常会把axios进行二次封装。

  • 使用请求拦截器,可以在请求拦截器中处理一些业务(开始进度条、请求头携带公共参数)
  • 使用响应拦截器,可以在响应拦截器中处理一些业务(进度条结束、简化服务器返回的数据、处理http网络错误)
  1. src目录下创建utils/request.ts
//进行axios二次封装:使用请求与响应拦截器
import axios from 'axios'
import { ElMessage } from 'element-plus'
// 1.利用axios对象的create方法创建一个新的axios实例
let request = axios.create({
    // 配置基础路径
    baseURL: import.meta.env.VITE_APP_BASE_API,//基础路径上会携带/api
    timeout: 5000//超时设置
})
//2.请求拦截器
request.interceptors.request.use((config) => {
    //config是请求配置对象,headers是请求头对象,经常给服务器端传递token
    //返回配置对象
    return config
})

//3.响应拦截器
request.interceptors.response.use((response) => {
    //成功回调
    //简化数据:在/mock/user.ts中可以看到响应数据是code和data,响应拦截器只返回data即可
    return response.data
}, (error) => {
    //失败回调:处理http网络错误
    //定义一个变量:存储网络错误信息
    let message = '';
    //http状态码判断
    let statusCode = error.response.status;
    switch (statusCode) {
        case 401:
            message = 'token过期';
            break;
        case 403:
            message = '无权访问';
            break;
        case 404:
            message = '请求资源不存在';
            break;
        case 500:
            message = '服务器内部错误';
            break;
        default:
            message = '网络错误';
            break;
    }
    //提示错误信息
    ElMessage({
        type: 'error',
        message
    })
    //返回一个失败的promise对象
    return Promise.reject(error)
})

//4.导出axios实例 对外暴露
export default request
  1. App.vue测试(测试完后删除)
<template>
  <div>
    <h1>测试axios二次封装</h1>
  </div>
</template>

<script setup lang="ts">
import request from './utils/request';
import { onMounted } from 'vue';
//当组件挂载完成后执行
onMounted(() => {
  request({
    url: '/user/login',
    method: 'post',
    data: {
      username: 'admin',
      password: '111111'
    }
  }).then(res => {
    console.log(res);
  })
})
</script>

<style scoped></style>

有真实接口的可以在这直接跳到:完善部分功能——直接使用真实接口

API接口统一管理

在开发项目的时候接口可能很多,因此需要统一管理

  1. src目录下创建api/user/index.tsapi/user/type.ts文件统一管理用户登录、用户信息获取相关的接口和数据类型
//统一管理项目用户相关的接口
import request from '@/utils/request'
import type { LoginParams, LoginResultModel, UserInfoModel } from './type'
//统一管理接口
enum API {
    LOGIN_URL = '/user/login',//登录接口
    USERINFO_URL = '/user/info',//获取用户信息接口
}
//对外暴露请求函数
//登录接口方法
export const reqLogin = (data: LoginParams) => request.post<any, LoginResultModel>(API.LOGIN_URL, data)
//获取用户信息接口方法
export const reqUserInfo = () => request.get<any, UserInfoModel>(API.USERINFO_URL)
  1. types.ts嫌麻烦可以不用,直接在上面用any就行,但是后期维护不方便
//登录接口需要携带参数ts类型
export interface LoginParams {
    username: string,
    password: string
}

interface dataType {
    token?: string
    message?: string
}

//登录接口返回数据类型
export interface LoginResultModel {
    code: number,
    data: dataType
}

interface userInfo {
    userId: number,
    avatar: string,
    username: string,
    password: string,
    desc: string,
    roles: string[],
    buttons: string[], 
    routes: string[],
    token: string
}

interface user{
    checkUser: userInfo
}

//定义服务器返回用户信息的数据类型
export interface UserInfoModel {
    code: number,
    data: user
}
  1. App.vue中测试(测试结束后删除)
<template>
  <div>
    <h1>App根组件</h1>
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue';
import { reqLogin } from './api/user';
onMounted(() => {
  reqLogin({ username: 'admin', password: '111111' })
}
)
</script>

<style scoped></style>

路由配置

  1. 安装依赖
pnpm install vue-router@4.1.6
  1. 新建src/views文件夹专门放置路由的页面,/views下新建loginhome404目录
  2. login下新建index.vue
<template>
    <div>
        <h1>我是一级路由登录</h1>
    </div>
</template>

<script setup lang="ts">

</script>

<style scoped></style>
  1. home下新建index.vue
<template>
    <div>
        <h1>我是一级路由展示登录成功以后的数据</h1>
    </div>
</template>

<script setup lang="ts">

</script>

<style scoped></style>
  1. 404下新建index.vue
<template>
    <div>
        <h1>我是一级路由404</h1>
    </div>
</template>

<script setup lang="ts">

</script>

<style scoped></style>
  1. 新建src/router/routers.ts文件夹专门放置路由表
//对外暴露配置路由(常量路由)
export const constantRoutes = [
    {
        //登录
        path: '/login',
        component: () => import('@/views/login/index.vue'),
        name: 'login'//命名路由,后面做权限管理时用到
    }
    ,
    {
        //登录成功以后展示数据的路由
        path: '/',
        component: () => import('@/views/home/index.vue'),
        name: 'home'//命名路由,后面做权限管理时用到
    }
    ,
    {
        //404
        path: '/404',
        component: () => import('@/views/404/index.vue'),
        name: '404'//命名路由,后面做权限管理时用到
    }
    ,
    {
        //任意路由
        path: '/:pathMatch(.*)*',
        redirect: '/404',
        name: 'Any'

    }
]
  1. 新建src/router/index.ts实现模板路由配置
//通过vue-router插件实现模板路由配置
import { createRouter, createWebHashHistory } from 'vue-router'
import { constantRoutes } from './routers'
//创建路由器
let router = createRouter({
    //路由模式hash
    history: createWebHashHistory(),
    routes: constantRoutes,
    //滚动行为
    scrollBehavior() {
        return {
            left: 0,
            top: 0
        }
    }
})

export default router

  1. main.ts中引入
//引入路由
import router from './router';
//注册模板路由
app.use(router);
  1. App.vue中展示
<template>
  <div>
    <router-view></router-view>
  </div>
</template>

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

<style scoped></style>

登录模块

  1. 安装仓库依赖pinia
pnpm i pinia
  1. 新建src\store\index.ts
//仓库大仓库
import {createPinia} from 'pinia'
//创建大仓库
let pinia = createPinia()
//导出大仓库:入口文件需要安装仓库
export default pinia
  1. 在入口文件main.ts引入仓库
//引入仓库
import pinia from './store';
//安装仓库pinia
app.use(pinia);
  1. 新建/src/utils/storage.ts封装本地存储的读取与存储方法
//封装本地存储的读取与存储方法
//本地存储存储TOKEN
export const SET_TOKEN = (token: string) => {
    localStorage.setItem('TOKEN', token)
}
//本地存储读取TOKEN
export const GET_TOKEN = () => {
    return localStorage.getItem("TOKEN")
}
  1. 创建用户仓库/store/moudules/types/type.ts声明用到的数据类型
//定义小仓库数据state类型
export interface UserState {
    token: string | null
}
  1. 新建/src/utils/time.ts获取一个结果:当前早上|上午|下午|晚上
//封装一个函数:获取一个结果:当前早上|上午|下午|晚上
export const getTime = () => {
    let message = ''
    //使用内置构造函数Date()获取当前时间
    let hours = new Date().getHours()
    //判断当前时间
    if (hours >= 6 && hours < 9) {
        message = '早上'
    } else if (hours >= 9 && hours < 12) {
        message = '上午'
    } else if (hours >= 12 && hours < 18) {
        message = '下午'
    } else {
        message = '晚上'
    }
    return message
}
  1. 创建用户仓库/store/moudules/user.ts
//创建用户相关的小仓库
import { defineStore } from 'pinia'
//引入接口
import { reqLogin } from '@/api/user'
//引入数据类型
import type { LoginParams, LoginResultModel } from '@/api/user/type'
import type { UserState } from './types/type'
//引入操作本地存储的工具方法
import { SET_TOKEN, GET_TOKEN } from '@/utils/storage'
//创建用户小仓库
let useUserStore = defineStore(
    //小仓库的名字
    'User', {
    //小仓库存储数据的地方
    state: (): UserState => {
        return {
            token: GET_TOKEN(),//用户唯一标识
        }
    },
    //处理异步和逻辑的地方
    actions: {
        //用户登录的方法
        async login(data: LoginParams) {
            //登录请求
            let result: LoginResultModel = await reqLogin(data)
            //登录请求:成功200->token
            //登录请求:失败201->message
            if (result.code === 200) {
                //登录成功
                //pinia仓库存储一下token
                //由于pinia存储数据利用的是js对象,所以未持久化
                this.token = (result.data.token as string)
                //本地存储持久化token
                SET_TOKEN((result.data.token as string))
                //保证当前async函数返回值是一个成功的promise
                return 'ok'
            } else {
                //登录失败
                return Promise.reject(new Error(result.data.message))
            }

        }
    },
    getters: {

    }
})
//对外暴露获取小仓库的方法
export default useUserStore
  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">
                <el-form class="login_form">
                    <h1>你好</h1>
                    <h2>欢迎来到xxx系统</h2>
                    <el-form-item>
                        <el-input :prefix-icon="User" v-model="loginFrom.username"></el-input>
                    </el-form-item>
                    <el-form-item>
                        <el-input type="password" :prefix-icon="Lock" v-model="loginFrom.password" show-password></el-input>
                    </el-form-item>
                    <el-form-item>
                        <el-button :loading="loading" class="login_btn" type="primary" size="default"
                            @click="login">登录</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, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElNotification } from 'element-plus';
//引入用户相关的小仓库
import useUserStore from '@/store/modules/user';
//引入获取当前时间的函数
import { getTime } from '@/utils/time';
let userStore = useUserStore()
//获取路由器
let $router = useRouter()
//定义变量控制按钮加载效果
let loading = ref(false)
//收集账号与密码数据
let loginFrom = reactive({
    //默认值
    username: 'admin',
    password: '111111'
})
//点击了登录按钮,登录按钮回调
const login = async () => {
    //按钮加载效果:开始加载
    loading.value = true
    // 通知仓库发送登录请求
    try {
        //请求成功->进入首页展示数据
        await userStore.login(loginFrom)
        //编程式导航跳转到展示数据首页
        $router.push('/')
        //登录成功的提示信息
        ElNotification({
            type: 'success',
            title: `嗨,${getTime()}好`,
            message: '欢迎回来'
        })
        //登录成功后,按钮加载效果:结束加载
        loading.value = false
    } catch (error) {
        //请求失败->弹出登陆失败信息
        //按钮加载效果:结束加载
        loading.value = false
        //登录失败的提示信息
        ElNotification({
            type: 'error',
            title: '登录失败',
            message: (error as Error).message
        })
    }
}
</script>

<style scoped lang="scss">
.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 0;
    }

    .login_btn {
        width: 100%;
    }
}
</style>

表单数据校验

Form 表单 | Element Plus (element-plus):Form 组件允许你验证用户的输入是否符合规范,来帮助你找到和纠正错误。

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

  1. src\views\login\index.vue
<template>
<el-form class="login_form" :model="loginFrom" :rules="rules" ref = "loginForms">
    <el-form-item prop="username"></el-form-item>
    <el-form-item prop="password"></el-form-item>
    </el-form-item> 
</template>
<script setup lang="ts">  
//获取el-form组件
let loginForms = ref()
//点击了登录按钮,登录按钮回调
const login = async () => {
    await loginForms.value.validate()
    ...
}
//定义表单校验需要的配置对象
const rules = {
    //规则对象属性
    username: [
        {
            required: true, // required,代表这个字段务必要校验的
            min: 6, //min:文本长度至少多少位
            max: 10, // max:文本长度最多多少位
            message: '账号长度至少六位', // message:错误的提示信息
            trigger: 'change' //trigger:触发校验表单的时机 change->文本发生变化触发校验, blur:失去焦点的时候触发校验规则
        }
    ],
    password: [
        {
            required: true,
            min: 6,
            max: 15,
            message: '密码长度至少六位',
            trigger: 'change'
        }
    ]
}
</srcipt> 
  1. 自定义校验规则:src\views\login\index.vue
<script setup lang="ts">  
//自定义校验规则需要的函数
const validatorUserName = (rule: any, value: any, callback: any) => {
    //rule:当前校验规则对象
    //value:当前表单项的值
    //如果符合条件callBack放行即为通过
    //如果不符合条件callBack传入错误信息即为不通过
    if (/^\d{5,10}$/.test(value)) {
        callback()
    } else {
        callback(new Error('账号必须是5-10位的数字'))
    }
}
const rules = {
    username: [
        {
            trigger: 'change',
            validator: validatorUserName
        }
    ],
    ...
}
</script>

Layout模块

  1. 新建src/layout/index.vue
<template>
    <div class="layout_container">
        <!-- 左侧菜单 -->
        <div class="layout_slider" :class="{ fold: LayoutSettingStore.fold ? true : false }">
            <Logo />
            <!-- 展示菜单 -->
            <!-- 滚动组件 -->
            <el-scrollbar class="scollbar">
                <!-- 菜单组件 -->
                <el-menu :collapse="LayoutSettingStore.fold ? true : false" :default-active="$route.path"
                    background-color="#001529" text-color="white" active-text-color="yellowgreen">
                    <!-- 根据路由动态生成菜单 -->
                    <Menu :menuList="userStore.menuRoutes"></Menu>
                </el-menu>
            </el-scrollbar>
        </div>
        <!-- 顶部导航 -->
        <div class="layout_tabbar" :class="{ fold: LayoutSettingStore.fold ? true : false }">
            <!-- layout组件的顶部导航tabbar -->
            <Tabbar />
        </div>
        <!-- 内容展示区 -->
        <div class="layout_main" :class="{ fold: LayoutSettingStore.fold ? true : false }">
            <!-- <p style="height:10000px;background-color:red;">我是一个段落</p> -->
            <Main />
        </div>

    </div>
</template>

<script setup lang="ts">
//引入左侧菜单logo子组件
import Logo from './logo/index.vue'
//引入菜单组件
import Menu from './menu/index.vue'
//右侧内容展示区
import Main from './main/index.vue'
//获取路由对象
import { useRoute } from 'vue-router'
//获取用户相关的小仓库
import useUserStore from '@/store/modules/user';
//引入顶部导航组件tabbar
import Tabbar from './tabbar/index.vue'
import useLayOutSettingStore from '@/store/modules/setting';
//获取layout配置仓库
let LayoutSettingStore = useLayOutSettingStore()

let userStore = useUserStore();
//获取路由对象
let $router = useRoute()
console.log($router.path)
</script>

<script lang="ts">
export default {
    name: 'Layout'
}
</script>

<style scoped lang="scss">
.layout_container {
    width: 100%;
    height: 100vh;

    .layout_slider {
        color: white;
        width: $base-menu-width;
        height: 100vh;
        background: $base-menu-bg;
        transition: all 0.3s;

        .scollbar {
            color: white;
            width: 100%;
            height: calc(100vh - #{$base-menu-logo-height});

            .el-menu {
                border-right: none;
            }
        }

        &.fold {
            width: $base-menu-min-width;
        }
    }

    .layout_tabbar {
        position: fixed;
        width: calc(100% - #{$base-menu-width});
        height: $base-tabbar-height;
        top: 0px;
        left: $base-menu-width;
        transition: all 0.3s;

        &.fold {
            width: calc(100vw - $base-menu-min-width);
            left: $base-menu-min-width;
        }
    }

    .layout_main {
        position: absolute;
        width: calc(100% - #{$base-menu-width});
        height: calc(100vh - #{$base-tabbar-height});
        background: yellowgreen;
        top: $base-tabbar-height;
        left: $base-menu-width;
        padding: 20px;
        overflow: auto;
        transition: all 0.3s;

        &.fold {
            width: calc(100vw - $base-menu-min-width);
            left: $base-menu-min-width;
        }
    }
}</style>
  1. /src/styles/variable.scss添加全局变量变量
//为项目提供scss全局变量

//定义项目主题颜色
$base-color: purple;

//左侧的菜单的宽度
$base-menu-width: 260px;
$base-menu-min-width: 50px;
//左侧菜单的背景色
$base-menu-bg: #001529;

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

//左侧菜单logo高度
$base-menu-logo-height: 50px;

//左侧菜单logo右侧文字大小
$base-logo-title-fontSize: 20px
  1. src/styles/index.scss添加全局的样式
//引入清楚默认样式
@import './reset.scss';

//滚动条外观设置
::-webkit-scrollbar {
  width: 10px;
}

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

::-webkit-scrollbar-thumb {
  width: 10px;
  background: yellowgreen;
  border-radius: 10px;
}
  1. 新建src/layout/logo/index.vuelogo拆分为一个子组件
<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>
<script lang="ts">
export default {
    name: 'Logo'
}
</script>
<style scoped lang="scss">
.logo {
    width: 100%;
    height: $base-menu-logo-height;
    color: white;
    display: flex;
    align-items: center;
    padding: 10px;

    img {
        width: 40px;
        height: 40px;
    }

    p {
        font-size: $base-logo-title-fontSize;
        margin-left: 50px;
    }
}
</style>
  1. 新建src/setting.ts:用于项目logo和标题配置
//用于项目logo和标题的配置
export default {
    title: 'XXXXXX系统',//项目的标题
    logo: '/public/logo.png',//项目的logo 
    logoHidden: true,//是否隐藏logo
}
  1. 新建src/layout/menu/index.vue:封装动态菜单项,根据项目路由生成
<template>
    <template v-for="(item, index) in menuList" :key="item.path">
        <!-- 没有子路由 -->
        <template v-if="!item.children">
            <el-menu-item :index="item.path" v-if="!item.meta.hidden" @click="goRoute">
                <el-icon>
                    <component :is="item.meta.icon"></component>
                </el-icon>
                <template #title>
                    <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" @click="goRoute">
                <el-icon>
                    <component :is="item.children[0].meta.icon"></component>
                </el-icon>
                <template #title>
                    <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 > 1">
            <template #title>
                <el-icon>
                    <component :is="item.meta.icon"></component>
                </el-icon>
                <span>{{ item.meta.title }}</span>
            </template>
            <Menu :menuList="item.children"></Menu>
        </el-sub-menu>
    </template>
</template>

<script setup lang="ts">
//获取父组件传递过来的全部路由数组
defineProps(['menuList'])
import { useRouter } from 'vue-router'
//获取路由器对象
let $router = useRouter()
//点击菜单的回调
const goRoute = (vc: any) => {
    //跳转路由
    $router.push(vc.index)
}
</script>
<script lang="ts">
export default {
    name: 'Menu'
}
</script>
<style scoped></style>
  1. 将路由放到仓库/src/store/modules/user.ts
//引入路由(常量路由)
import { constantRoutes } from '@/router/routers'
//创建用户小仓库
let useUserStore = defineStore(
	...,
    state: (): UserState => {
        return {
            token: GET_TOKEN(),//用户唯一标识
            menuRoutes: constantRoutes,//用来生成用户菜单路由(数组)
        }
    },
    ...
})
  1. 定义数据类型src/store/modules/types/type.ts
import { RouteRecordRaw } from "vue-router";
//定义小仓库数据state类型
export interface UserState {
    token: string | null;
    menuRoutes: RouteRecordRaw[]
}
  1. src/components/index.ts引入element-plusicon图标组件
//引入element-plus提供的全部图标组件
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
export default {
    install(app: App) {
		...;
        //注册element-plus提供的全部图标组件
        for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
            app.component(key, component)
        }
    }
}
  1. 修改路由/src/router/routers.ts:登录后跳转到刚才新建的一级路由页面;并根据路由动态生成菜单项;展示路由元信息
//对外暴露配置路由(常量路由)
export const constantRoutes = [
    {
        //登录
        path: '/login',
        component: () => import('@/views/login/index.vue'),
        name: 'login',//命名路由,后面做权限管理时用到
        meta: {
            title: '登录',//菜单标题
            hidden: true,//是否隐藏菜单 true隐藏 false显示
            icon: "promotion"//菜单图标 支持element-plus提供的图标组件
        }
    }
    ,
    {
        //登录成功以后展示数据的路由
        path: '/',
        component: () => import('@/layout/index.vue'),
        name: 'layout',//命名路由,后面做权限管理时用到
        meta: {
            title: '',//菜单标题
            hidden: false,
            icon: ''
        },
        redirect: '/home',
        children: [
            {
                path: '/home',
                component: () => import('@/views/home/index.vue'),
                meta: {
                    title: '首页',//菜单标题
                    hidden: false,
                    icon: 'HomeFilled'
                },
            }
        ]
    }
    ,
    {
        //404
        path: '/404',
        component: () => import('@/views/404/index.vue'),
        name: '404',//命名路由,后面做权限管理时用到
        meta: {
            title: '404',//菜单标题
            hidden: true
        },

    }
    ,
    {
        path: '/screen',
        component: () => import('@/views/screen/index.vue'),
        name: 'Screen',
        meta: {
            title: '数据大屏',//菜单标题
            hidden: false,
            icon: 'Monitor'
        }
    },
    {
        path: '/acl',
        component: () => import('@/layout/index.vue'),
        name: 'Acl',
        meta: {
            title: '权限管理',//菜单标题
            hidden: false,
            icon: 'Lock'
        },
        redirect: '/acl/user',
        children: [
            {
                path: '/acl/user',
                component: () => import('@/views/acl/user/index.vue'),
                name: 'User',
                meta: {
                    title: '用户管理',//菜单标题
                    hidden: false,
                    icon: 'User'
                }
            },
            {
                path: '/acl/role',
                component: () => import('@/views/acl/role/index.vue'),
                name: 'Role',
                meta: {
                    title: '角色管理',//菜单标题
                    hidden: false,
                    icon: 'UserFilled'
                }
            },
            {
                path: '/acl/permission',
                component: () => import('@/views/acl/permission/index.vue'),
                name: 'permission',
                meta: {
                    title: '菜单管理',//菜单标题
                    hidden: false,
                    icon: 'Tools'
                }
            }
        ]
    },
    {
        path: '/product',
        component: () => import('@/layout/index.vue'),
        name: 'Product',
        meta: {
            title: '商品管理',//菜单标题
            hidden: false,
            icon: 'Shop'
        },
        redirect: '/product/trademark',
        children: [
            {
                path: '/product/trademark',
                component: () => import('@/views/product/trademark/index.vue'),
                name: 'Trademark',
                meta: {
                    title: '品牌管理',//菜单标题
                    hidden: false,
                    icon: 'ShoppingCartFull'
                }
            },
            {
                path: '/product/attr',
                component: () => import('@/views/product/attr/index.vue'),
                name: 'Attr',
                meta: {
                    title: '属性管理',//菜单标题
                    hidden: false,
                    icon: 'ChromeFilled'
                }
            },
            {
                path: '/product/spu',
                component: () => import('@/views/product/spu/index.vue'),
                name: 'Spu',
                meta: {
                    title: 'SPU管理',//菜单标题
                    hidden: false,
                    icon: 'Calendar'
                }
            },
            {
                path: '/product/sku',
                component: () => import('@/views/product/sku/index.vue'),
                name: 'Sku',
                meta: {
                    title: 'SKU管理',//菜单标题
                    hidden: false,
                    icon: 'Orange'
                }
            }
        ]
    },
    {
        //任意路由
        path: '/:pathMatch(.*)*',
        redirect: '/404',
        name: 'Any',
        meta: {
            title: '任意路由',//菜单标题
            hidden: true
        },

    }
]
  1. 新建src/views/screen/index.vue数据大屏组件
<template>
    <div>
        <h1>我是 数据大屏一级路由组件</h1>
    </div>
</template>

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

<style scoped></style>
  1. 新建src/views/acl权限管理组件,其下有三个二级路由,新建三个文件user/index.vuerole/index.vuepermission/index.vue,模板如下
<template>
    <div>
        <h1>菜单管理</h1>
    </div>
</template>

<script setup lang="ts">

</script>

<style scoped></style>
<template>
    <div>
        <h1>角色管理</h1>
    </div>
</template>

<script setup lang="ts">

</script>

<style scoped></style>

<template>
    <div class="box">
        <h1>用户管理</h1>
    </div>
</template>

<script setup lang="ts">

</script>

<style scoped lang="scss">
.box {
    width: 100%;
    height: 400px;
    background-color: rgb(216, 101, 216);
}
</style>
  1. 新建src/views/product产品管理组件,其下有attr/index.vuesku/index.vuespu/index.vuetrademark/index.vue
<template>
    <div>
        <h1>属性管理</h1>
    </div>
</template>

<script setup lang="ts">

</script>

<style scoped></style>
  1. 新建src/layout/main/index.vue,封装右侧内容展示区。可以添加过渡动画
<template>
    <!-- 路由组件出口 -->
    <router-view v-slot="{ Component }">
        <transition name="fade">
            <!-- 渲染layout一级路由组件的子路由 -->
            <component :is="Component" v-if="flag" />
        </transition>
    </router-view>
</template>

<script setup lang="ts">
import { watch, ref, nextTick } from "vue";
import useLayOutSettingStore from '@/store/modules/setting';
let layoutSettingStore = useLayOutSettingStore()
//控制当前组件是否销毁重建
let flag = ref(true)
//监听仓库内部数据是否发生变化,如果发生变化,说明用户点击过刷新按钮
watch(() => layoutSettingStore.refresh, () => {
    //点击刷新按钮,路由组件销毁
    flag.value = false
    nextTick(() => {
        //路由组件销毁后,再次渲染路由组件
        flag.value = true
    })
})

</script>
<script lang="ts">
export default {
    name: 'Main'
}
</script>
<style scoped lang="scss">
.fade-enter-from {
    opacity: 0;
    transform: scale(0)
}

.fade-enter-active {
    transition: all .3s;
}

.fade-enter-to {
    opacity: 1;
    transform: scale(1)
}
</style>
  1. 新建顶部组件src/layout/tabbar/index.vue
<template>
    <div class="tabbar">
        <div class="tabbar_left">
            <Breadcrumb />
        </div>
        <div class="tabbar_right">
            <Setting />
        </div>
    </div>
</template>

<script setup lang="ts">
import Breadcrumb from './breadcrumb/index.vue'
import Setting from './setting/index.vue'
</script>
<script lang="ts">
export default {
    name: 'Tabbar'
}
</script>
<style scoped lang="scss">
.tabbar {
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: space-between;
    background-image: linear-gradient(to right, rgb(232, 223, 223), rgb(201, 178, 178), rgb(197, 165, 165));

    .tabbar_left {
        display: flex;
        align-items: center;
        margin-left: 20px;
    }

    .tabbar_right {
        display: flex;
        align-items: center;
    }
}
</style>
  1. 新建src/layout/tabbar/breadcrumb/index.vuesrc/layout/tabbar/setting/index.vue:分割顶部组件的左右区域组件
<template>
    <!-- 顶部左侧静态 -->
    <el-icon style="margin-right:10px" @click="changeIcon">
        <component :is="LayoutSettingStore.fold?'Fold':'Expand'"></component>
    </el-icon>
    <!-- 左侧面包屑 -->
    <el-breadcrumb separator-icon="ArrowRight">
        <!-- 面包动态展示路由名字与标题 -->
        <el-breadcrumb-item v-for="(item, index) in $router.matched" :key="index" v-show="item.meta.title" :to="item.path">
            <!-- 图标 -->
            <el-icon>
                <component :is="item.meta.icon"></component>
            </el-icon>
            <!-- 面包屑展示匹配路由的标题 -->
            <span>{{ item.meta.title }}</span>
        </el-breadcrumb-item>
    </el-breadcrumb>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import useLayOutSettingStore from '@/store/modules/setting'
//获取layout配置相关的仓库
let LayoutSettingStore = useLayOutSettingStore()
//获取路由对象
let $router = useRoute()
//点击图标的方法
const changeIcon = () => {
    //图标进行切换
    LayoutSettingStore.fold = !LayoutSettingStore.fold
}
const handler = () => {
    console.log($router.matched)
}

</script>
<script lang="ts">
export default {
    name: 'Breadcrumb'
}
</script>
<style scoped></style>
<template>
    <el-button size="small" icon="Refresh" circle=true @click="updateRefresh"></el-button>
    <el-button size="small" icon="FullScreen" circle=true @click="fullScreen"></el-button>
    <el-button size="small" icon="Setting" circle=true></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">
//获取小仓库
import useLayOutSettingStore from '@/store/modules/setting';
let LayoutSettingStore = useLayOutSettingStore()
//刷新按钮点击回调
const updateRefresh = () => {
    //刷新当前路由
    LayoutSettingStore.refresh = !LayoutSettingStore.refresh
}
const fullScreen = () => {
    //DOM对象的一个属性:可以用来当前是否为全屏【全屏:true】
    let full = document.fullscreenElement
    if (!full) {
        //切换为全屏模式
        document.documentElement.requestFullscreen()
    } else {
        //退出全屏模式
        document.exitFullscreen()
    }
}
</script>


<script lang="ts">
export default {
    name: 'Setting'
}
</script>
<style scoped></style>
  1. 新建仓库src/store/modules/setting.ts:保存折叠变量;刷新变量
//小仓库:layout组件相关配置仓库
import { defineStore } from 'pinia';

let useLayOutSettingStore = defineStore('SettingStore', {
    state: () => {
        return {
            fold: false,//是否折叠菜单
            refresh: false,//是否刷新页面
        }
    }
})
export default useLayOutSettingStore;

完善部分功能

登陆获取用户信息

  1. 修改src/store/modules/user.ts:获取用户信息后存储到pinia仓库
//引入接口
import { reqLogin, reqUserInfo } from '@/api/user'
    //小仓库存储数据的地方
    state: (): UserState => {
        return {
            token: GET_TOKEN(),//用户唯一标识
            menuRoutes: constantRoutes,//用来生成用户菜单路由(数组)
            username: '',
            avatar: '',
        }
    },
    //处理异步和逻辑的地方
    actions: {
        //用户登录的方法
        async login(data: LoginParams) {
            //登录请求
            let result: LoginResultModel = await reqLogin(data)
            //登录请求:成功200->token
            //登录请求:失败201->message
            if (result.code === 200) {
                //登录成功
                //pinia仓库存储一下token
                //由于pinia存储数据利用的是js对象,所以未持久化
                this.token = (result.data.token as string)
                //本地存储持久化token
                SET_TOKEN((result.data.token as string))
                //保证当前async函数返回值是一个成功的promise
                return 'ok'
            } else {
                //登录失败
                return Promise.reject(new Error(result.data.message))
            }

        },
        //获取用户信息方法
        async getUserInfo() {
            //获取用户信息存储到仓库中
            let result = await reqUserInfo()
            //如果获取用户信息成功,则存储
            if (result.code == 200) {
                this.username = result.data.checkUser.username
                this.avatar = result.data.checkUser.avatar
            } else {
				return Promise.reject(new Error(result.message))
            }
        }
    },     
  1. 修改src/utils/request.ts:请求拦截器在发送请求时从仓库获取token放入请求头
//引入用户相关的仓库
import useUserStore from '@/store/modules/user'
request.interceptors.request.use((config) => {
    //获取用户相关的小仓库:获取token
    let userStore = useUserStore();
    if (userStore.token) {
        config.headers.token = userStore.token
    }
    //config是请求配置对象,headers是请求头对象,经常给服务器端传递token
    // config.headers.token='123456'
    //返回配置对象
    return config
})
  1. 新增数据类型src/store/modules/types/type.ts:用户名和头像地址
export interface UserState {
    token: string | null;
    menuRoutes: RouteRecordRaw[],
    username: string,
    avatar: string
}
  1. 修改src/layout/tabbar/setting/index.vue:显示用户名和头像
<img :src="userStore.avatar" style="width:24px;height:24px;margin:0px 10px; border-radius:50%;">
        <span class="el-dropdown-link">
            {{ userStore.username }}
            <el-icon class="el-icon--right">
                <arrow-down />
            </el-icon>
        </span>
import useUserStore from '@/store/modules/user';
let userStore = useUserStore()
  1. src/views/home/index.vue中测试使用仓库中存储的用户名
<template>
    <div>
        <h1>我是一级路由展示登录成功以后的数据{{ UserStore.username }}</h1>
    </div>
</template>

<script setup lang="ts">
//引入组合式API函数之生命周期函数
import { onMounted } from 'vue'
//获取仓库
import useUserStore from '@/store/modules/user'
let UserStore = useUserStore()
//目前首页挂载完毕发请求获取用户信息
onMounted(() => {
    UserStore.getUserInfo()
})
</script>

<style scoped></style>

退出登录

  1. 修改src/layout/tabbar/setting/index.vue:给退出绑定事件
<el-dropdown-item @click="logout">退出登录</el-dropdown-item>
<script setup lang="ts">
    import { useRouter } from 'vue-router';
    //获取路由对象
	let $router = useRouter()
	//退出登录点击回调
	const logout = () => {
    //1.向服务器发送请求【退出登录接口,服务器将TOKEN设置为无效】
    //2.清空仓库的TOKEN
    //3.跳转到登陆页面
    userStore.userLogout()
    $router.push({ path: '/login' })
}
</script>
  1. src/store/modules/user.ts添加退出登录的方法
//引入操作本地存储的工具方法
import { SET_TOKEN, GET_TOKEN, REMOVE_TOKEN } from '@/utils/storage'
		//退出登录
        async userLogout() {
            //清空PINIA仓库
            this.token = ''
            this.username = ''
            this.avatar = ''
            REMOVE_TOKEN()
        },
  1. src/utils/storage.ts封装删除TOKEN的方法
//本地存储删除数据方法
export const REMOVE_TOKEN = () => {
    localStorage.removeItem('TOKEN')
}

路由鉴权

  1. 安装进度条插件
pnpm i nprogress
  1. src下新建permission.ts:利用全局前置守卫和全局后置守卫完成路由鉴权
//路由鉴权
//鉴权:某一个路由什么条件下才能访问
import router from '@/router'
import setting from './setting'
import nprogress from 'nprogress'  //引入进度条
//关闭进度条加载的右侧小圈
nprogress.configure({ showSpinner: false })
//获取仓库中的token
import useUserStore from './store/modules/user'
import pinia from './store'
let userStore = useUserStore(pinia)  //获取仓库中的token
//引入进度条样式
import 'nprogress/nprogress.css'

//全局前置守卫:项目中任何一个路由发生改变之前,都会先经过这里
router.beforeEach(async (to: any, from: any, next: any) => {
    document.title = `${setting.title} - ${to.meta.title}`
    //to:即将要进入的目标路由对象
    //from:当前导航正要离开的路由
    //next:调用该方法后,才能进入下一个钩子
    nprogress.start();  //开启进度条
    //获取仓库中的token
    let token = userStore.token
    //获取用户名
    let username = userStore.username
    //用户登录判断
    if (token) {  //如果有token
        //登陆成功,访问登录页,直接跳转到首页
        if (to.path == '/login') {
            next({ path: '/' })
        } else {
            //登陆成功访问其余的路由,放行
            //有用户信息
            if (username) {
                next()
            } else {
                //没有用户信息,发请求获取用户信息再放行
                try {
                    //发请求获取用户信息
                    await userStore.getUserInfo()
                    next()
                } catch (error) {//请求失败
                    //token失效,重新登录
                    //用户手动修改了本地存储的token
                    userStore.userLogout()
                    next({ path: '/login' })
                }

            }
        }
    } else {
        //用户未登录判断
        if (to.path === '/login') {  //如果访问的是登录页
            next()  //放行
        } else {//访问的不是登录页
            next({ path: '/login' })  //跳转到登录页
        }
    }
})

//全局后置首位
router.afterEach((to: any, from: any) => {
    nprogress.done()  //关闭进度条
})  //不需要next(),因为已经跳转了

//根据是否有TOKEN判断是否登录
//登陆后可以访问除login外所有路由
//未登录只能访问login,访问其他路由就跳转到login
  1. src/main.ts引入permission.ts
//引入路由鉴权文件
import './permission';
  1. 修改src/store/modules/user.ts
            if (result.code == 200) {
                this.username = result.data.checkUser.username
                this.avatar = result.data.checkUser.avatar
                return 'ok'
            } else {
                return Promise.reject('获取用户信息失败')
            }
  1. 删除views/home/index.vue中发送请求用户信息的代码,因为已经在路由前置守卫中实现了获取用户信息
<template>
    <div>
        <h1>我是一级路由展示登录成功以后的数据</h1>
    </div>
</template>

<script setup lang="ts">

</script>

<style scoped></style>
  1. 修改src/layout/index.vue的过渡动画,删除左侧菜单的过渡动画
<!-- 滚动组件 -->
<el-scrollbar class="scollbar">
.layout_container {
    width: 100%;
    height: 100vh;

    .layout_slider {
        color: white;
        width: $base-menu-width;
        height: 100vh;
        background: $base-menu-bg;
        transition: all 0.3s;

        .scollbar {
            color: white;
            width: 100%;
            height: calc(100vh - #{$base-menu-logo-height});

            .el-menu {
                border-right: none;
            }
        }
    }

真实接口替代mock接口

  1. 修改三个.env文件:配置服务器地址
VITE_SERVE="http://sph-api.atguigu"
  1. vite.config.ts配置代理跨域
import { defineConfig,loadEnv } from 'vite'
export default defineConfig(({ command,mode }) => {
    //获取各种运行环境下的变量(dev/prod/test)
    let env = loadEnv(mode,process.cwd())
    return {
        ...,
    	//代理跨域
        server: {
      		proxy: {
        		[env.VITE_APP_BASE_API]: {
          		//服务器域名
          		target: env.VITE_SERVE,
          		//是否需要代理跨域
          		changeOrigin: true,
          		//路径重写:由于服务器接口不含/api,所以将/api替换为空
          		rewrite: (path) => path.replace(/^\/api/, '')
        		}
      		}
    	}
  	}
})
  1. 重写src/api/user/type.ts
//登录接口需要携带参数ts类型
export interface LoginFormDate {
    username: string,
    password: string
}

//定义全部接口返回数据都拥有的ts类型
export interface ResponseData {
    code: number,
    message: string,
    ok: boolean
}

//定义登录接口返回数据的ts类型
export interface LoginResponseData extends ResponseData {
    data: string
}

//定义获取用户信息返回的数据类型
export interface UserInfoResponseData extends ResponseData {
    data: {
        routers: string[],
        buttons: string[],
        roles: string[],
        name: string,
        avatar: string,
    }
}
  1. 重写src/api/user/index.ts
//统一管理项目用户相关的接口
import request from '@/utils/request'
import type { LoginFormDate, LoginResponseData, UserInfoResponseData } from './type'
//统一管理接口
enum API {
    LOGIN_URL = '/admin/acl/index/login',//登录接口
    USERINFO_URL = '/admin/acl/index/info',//获取用户信息接口
    LOGOUT_URL = '/admin/acl/index/logout',//退出登录接口
}
//对外暴露请求函数
//登录接口方法
export const reqLogin = (data: LoginFormDate) => request.post<any, LoginResponseData>(API.LOGIN_URL, data)
//获取用户信息接口方法
export const reqUserInfo = () => request.get<any, UserInfoResponseData>(API.USERINFO_URL)
//退出登录接口方法
export const reqLogout = () => request.post<any, any>(API.LOGOUT_URL)
  1. 修改src/store/modules/user.ts的用户登录、获取用户信息和退出登录的方法
//引入接口
import { reqLogin, reqUserInfo,reqLogout } from '@/api/user'
//引入数据类型
import type { LoginFormDate, LoginResponseData, UserInfoResponseData } from '@/api/type'
//创建用户小仓库
let useUserStore = defineStore(
    //小仓库的名字
    'User', {
    //小仓库存储数据的地方
    state: (): UserState => {
        return {
            token: GET_TOKEN(),//用户唯一标识
            menuRoutes: constantRoutes,//用来生成用户菜单路由(数组)
            username: '',
            avatar: '',
        }
    },
    //处理异步和逻辑的地方
    actions: {
        //用户登录的方法
        async login(data: LoginFormDate) {
            //登录请求
            let result: LoginResponseData = await reqLogin(data)
            //登录请求:成功200->token
            //登录请求:失败201->message
            if (result.code === 200) {
                //登录成功
                //pinia仓库存储一下token
                //由于pinia存储数据利用的是js对象,所以未持久化
                this.token = (result.data as string)
                //本地存储持久化token
                SET_TOKEN((result.data as string))
                //保证当前async函数返回值是一个成功的promise
                return 'ok'
            } else {
                //登录失败
                return Promise.reject(new Error(result.data))
            }

        },
        //获取用户信息方法
        async getUserInfo() {
            //获取用户信息存储到仓库中
            let result: UserInfoResponseData = 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() {
            //退出登录请求
            let result: any = await reqLogout()
            if (result.code == 200) {
                //清空PINIA仓库
                this.token = ''
                this.username = ''
                this.avatar = ''
                REMOVE_TOKEN()
                return 'ok'
            } else {
                //退出登录的接口请求失败
                return Promise.reject(new Error(result.message))
            }

        },
    },
    getters: {

    }
})
  1. src/views/login/index.vue修改默认密码
//收集账号与密码数据
let loginFrom = reactive({
    //默认值
    username: 'admin',
    password: 'atguigu123'
})
  1. src/layout/tabbar/setting/index.vue添加等待退出登录成功的代码
const logout = async () => {
    //1.向服务器发送请求【退出登录接口,服务器将TOKEN设置为无效】
    //2.清空仓库的TOKEN
    //3.跳转到登陆页面
    await userStore.userLogout()
    $router.push({ path: '/login' })
}
  1. src/permission.ts中也添加等待退出登录成功代码
try {
    //发请求获取用户信息
    await userStore.getUserInfo()
    next()
} catch (error) {//请求失败
    //token失效,重新登录
    //用户手动修改了本地存储的token
    await userStore.userLogout()
    next({ path: '/login' })
}

首页模块

  1. 修改src/views/home/index.vue
<template>
    <el-card>
        <div class="box">
            <img :src="userStore.avatar" alt="" class="avatar">
            <div class="bottom">
                <h3 class="title">{{ getTime() }}好~{{ userStore.username }}</h3>
                <p class="subtitle">{{ setting.title }}</p>
            </div>
        </div>
    </el-card>
    <div class="bottoms">
        <svg-icon name="welcome" width="500px" height="500px"></svg-icon>
    </div>
</template>

<script setup lang="ts">
//引入用户相关仓库
import useUserStore from '@/store/modules/user'
import { getTime } from '@/utils/time'
import setting from '@/setting';
//获取用户仓库实例
let userStore = useUserStore()
</script>

<style scoped>
.box {
    display: flex;

    .avatar {
        width: 100px;
        height: 100px;
        border-radius: 50%;
    }

    .bottom {
        margin-left: 20px;

        .title {
            font-size: 30px;
            font-weight: 900;
            margin-bottom: 30px;
        }

        .subtitle {
            font-style: italic;
            color: rgb(121, 129, 135);
        }
    }
}

.bottoms {
    display: flex;
    justify-content: center;
    margin-top: 10px;
}</style>

品牌管理模块

  1. src/layout/index.vue删除主题模块的背景颜色,使用默认白色
 .layout_main {
	...
 	//background: yellowgreen;
 }
  1. 完成src/views/trademark/index.vue
<template>
    <el-card class="box-card">
        <!-- 卡片顶部添加品牌按钮 -->
        <el-button type="primary" size="default" icon="Plus" @click="addTradeMark">添加品牌</el-button>
        <!-- 表格组件:用于展示已有数据 -->
        <el-table style="margin: 10px 0px;" border :data="trademarkList">
            <el-table-column label="序号" width="80px" align="center" type="index"></el-table-column>
            <!-- <el-table-column label="品牌名称" prop="tmName"></el-table-column> -->
            <!-- el-table-colum默认用div展示数据,也可以用插槽展示数据,样式可以自定义 -->
            <el-table-column label="品牌名称">
                <template #="{ row, $index }">
                    <pre style="color:hotpink">{{ row.tmName }}</pre>
                </template>
            </el-table-column>
            <el-table-column label="品牌LOGO">
                <template #="{ row, $index }">
                    <img :src="row.logoUrl" alt="无图片" style="width:100px;height: 100px;">
                </template>
            </el-table-column>
            <el-table-column label="品牌操作">
                <template #="{ row, $index }">
                    <el-button type="primary" size="mini" icon="Edit" @click="$event => updateTradeMark(row)">编辑</el-button>
                    <el-popconfirm :title="`您确定要删除${row.tmName}?`" width="230px" icon="Delete"
                        @confirm='removeTradeMark(row.id)'>
                        <template #reference>
                            <el-button type="danger" size="mini" icon="Delete">删除</el-button>
                        </template>
                    </el-popconfirm>
                </template>
            </el-table-column>
        </el-table>
        <!-- 分页器组件
            paginaation组件的属性:
            1. current-page:当前页码
            2. page-size:每页显示条数
            3. page-sizes:每页显示条数的下拉框选项
            4. background:是否为分页按钮添加背景色
            5. layout:组件布局,子组件名用逗号分隔
            6. total:总条数
        -->
        <el-pagination @size-change="sizeChange" @current-change="getTrademarkList" v-model:current-page="pageNum"
            v-model:page-size="pageSize" :page-sizes="[5, 10, 15, 20]" :background="true"
            layout="prev, pager, next, jumper,->, sizes,total " :total="total" />
    </el-card>

    <!-- 对话框组件:添加品牌、修改品牌业务时使用 -->
    <!-- v-model:控制对话框显示【true】与隐藏【false】
    title:设置对话框左上角的标题 -->
    <el-dialog v-model="dialogTableVisible" :title="trademarkForm.id ? '修改品牌' : '添加品牌'">
        <el-form style="width:80%" :model="trademarkForm" :rules="rules" ref="formRef">
            <el-form-item label="品牌名称" label-width="100px" prop="tmName">
                <el-input placeholder="请输入品牌名称" v-model="trademarkForm.tmName"></el-input>
            </el-form-item>
            <el-form-item label="品牌LOGO" label-width="100px" prop="logoUrl">
                <!-- upload相关属性: action:上传的API地址,不带/api代理服务器不工作-->
                <el-upload class="avatar-uploader" action="/api/admin/product/baseTrademark/save" :show-file-list="false"
                    :on-success="handleAvatarSuccess" :before-upload="beforeAvatarUpload">
                    <img v-if="trademarkForm.logoUrl" :src="trademarkForm.logoUrl" class="avatar" />
                    <el-icon v-else class="avatar-uploader-icon">
                        <Plus />
                    </el-icon>
                </el-upload>
            </el-form-item>
        </el-form>
        <!-- 具名插槽footer -->
        <template #footer>
            <el-button @click="cancel">取 消</el-button>
            <el-button type="primary" @click="confirm">确 定</el-button>
        </template>
    </el-dialog>
</template>

<script setup lang="ts">
//引入组合式API函数ref
import { ref, onMounted, reactive } from 'vue'
import { reqGetTrademarkList, reqAddOrUpdateTrademark, reqDeleteTrademark } from '@/api/product/trademark'
import type { Records, TrademarkRespnseData, Trademark } from '@/api/product/trademark/types'
import { ElMessage, type UploadProps } from 'element-plus'
import { async } from 'fast-glob';

//当前页码
let pageNum = ref<number>(1)
//每页展示多少条数据
let pageSize = ref<number>(5)
//存储已有品牌数据总数
let total = ref<number>(0)
//存储已有品牌的数据
let trademarkList = ref<Records>([])
//控制对话框显示与隐藏
let dialogTableVisible = ref<boolean>(false)
//收集新增品牌数据
let trademarkForm = reactive<Trademark>({
    tmName: '',
    logoUrl: ''
})
//获取el-form组件实例
let formRef = ref()

//将获取已有品牌数据的接口封装为一个函数:在任何情况下都可以调用
const getTrademarkList = async (pager = 1) => {
    //当每次调用获取品牌数据的函数时,将pageNum变量的值重置为1
    //当切换页码时,传入参数会替换掉默认值1
    pageNum.value = pager
    //调用接口
    let res: TrademarkRespnseData = await reqGetTrademarkList(pageNum.value, pageSize.value)
    if (res.code == 200) {
        //将获取到的数据赋值给total变量
        total.value = res.data.total
        //将获取到的数据赋值给trademarkList变量
        trademarkList.value = res.data.records
    }
}
//组件挂载完成后调用获取品牌数据的函数
onMounted(() => {
    getTrademarkList()
})

//当下拉菜单改变每一页数据量时调用的函数
const sizeChange = () => {
    //调用获取品牌数据的函数(向后端发请求获取数据)
    getTrademarkList()
}

//点击【添加品牌】按钮的回调
const addTradeMark = () => {
    //重置表单数据
    trademarkForm.id = 0
    trademarkForm.tmName = ''
    trademarkForm.logoUrl = ''
    //重置表单校验结果
    formRef.value?.clearValidate('tmName')
    formRef.value?.clearValidate('logoUrl')
    //将对话框显示
    dialogTableVisible.value = true
}

//点击【编辑】按钮的回调
const updateTradeMark = (row: Trademark) => {
    //将获取到的品牌数据赋值给trademarkForm变量
    trademarkForm.id = row.id
    trademarkForm.logoUrl = row.logoUrl
    trademarkForm.tmName = row.tmName
    //重置表单校验结果
    formRef.value?.clearValidate('tmName')
    formRef.value?.clearValidate('logoUrl')
    //将对话框显示
    dialogTableVisible.value = true
}

//点击添加|修改品牌的【取消】按钮的回调
const cancel = () => {
    //将对话框隐藏
    dialogTableVisible.value = false
}

//点击添加|修改品牌的【确定】按钮的回调
const confirm = async () => {
    //在发送请求之前,先进行整个表单的校验
    //如果通过校验才执行下面的代码
    await formRef.value.validate()
    //将对话框隐藏
    dialogTableVisible.value = false
    //调用添加|修改品牌的接口
    let result: any = reqAddOrUpdateTrademark(trademarkForm)
    //判断接口调用是否成功
    if (result.code == 200) {
        //添加|修改成功
        //重新获取品牌数据
        getTrademarkList()
        //提示用户添加成功
        ElMessage.success(trademarkForm.id ? '修改品牌成功' : '添加品牌成功')
    } else {
        //添加|修改失败
        //提示用户添加失败
        ElMessage.error(trademarkForm.id ? '修改品牌失败' : '添加品牌失败')
    }
    //再次发请求获取品牌数据
    getTrademarkList(trademarkForm.id ? pageNum.value : 1)
    // getTrademarkList(pageNum.value)
}

//上传图片之前的钩子函数——约束文件的类型和大小
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
    //rawFile:上传的文件
    //文件类型
    const isJPG = rawFile.type === 'image/jpeg'
    const isPNG = rawFile.type === 'image/png'
    //文件大小
    const isLt2M = rawFile.size / 1024 / 1024 < 2

    if (!isJPG && !isPNG) {
        //提示用户文件类型不符合要求
        ElMessage.error('上传头像图片只能是 JPG/PNG 格式!')
        return false
    }
    if (!isLt2M) {
        //提示用户文件大小不符合要求
        ElMessage.error('上传头像图片大小不能超过 2MB!')
        return false
    }
    return true
}

//上传图片成功的钩子函数
const handleAvatarSuccess: UploadProps['onSuccess'] = (response, uploadFile) => {
    //response:后端返回的数据
    //uploadFile:上传的文件

    if (response.code == 200) {
        //将上传成功的图片地址赋值给logoUrl变量,用于点击【确定】时提交给后端
        trademarkForm.logoUrl = response.data
        //清除图片校验结果
        formRef.value.clearValidate('logoUrl')
        ElMessage.success('上传图片成功')
    } else {
        ElMessage.error('上传图片失败')
    }
}

//品牌自定义校验规则
// const validtorTmName = (rule: any, value: any, callBack: any) => {
//     //rule:当前校验规则
//     //value:用户输入的品牌名称
//     //callBack:回调函数
//     if (value.trim().length >= 2) {
//         callBack()
//     } else {
//         //和下面的message不能并存
//         callBack(new Error('品牌名称不能少于2个字符'))
//     }
// }
//品牌LOGO自定义校验规则
const validtorLogoUrl = (rule: any, value: any, callBack: any) => {
    //value:图片的地址
    if (value) {
        callBack()
    } else {
        callBack(new Error('品牌LOGO务必上传'))
    }
}
//表单验证规则
const rules = {
    tmName: [
        { required: true, message: '品牌名称不能为空', trigger: 'change' }
    ],
    logoUrl: [//没有触发校验的时机
        { required: true, trigger: 'change', validator: validtorLogoUrl }
    ]
}

//删除品牌的气泡确认框的【确认】按钮的回调
const removeTradeMark = async (id: number) => {
    //调用删除品牌的接口
    let result: any = await reqDeleteTrademark(id)
    console.log(result)
    console.log(result.code)
    //判断接口调用是否成功
    if (result.code == 200) {
        //删除成功
        //重新获取品牌数据
        getTrademarkList()
        //提示用户删除成功
        ElMessage.success('删除品牌成功')
        //再次发请求获取品牌数据
        getTrademarkList(trademarkList.value.length > 1 ? pageNum.value : pageNum.value - 1)
    } else {
        //删除失败
        //提示用户删除失败
        ElMessage.error('删除品牌失败')
    }
}
</script>

<style scoped>
.avatar-uploader .avatar {
    width: 178px;
    height: 178px;
    display: block;
}

.avatar-uploader .el-upload {
    border: 1px dashed var(--el-border-color);
    border-radius: 6px;
    cursor: pointer;
    position: relative;
    overflow: hidden;
    transition: var(--el-transition-duration-fast);
}

.avatar-uploader .el-upload:hover {
    border-color: var(--el-color-primary);
}

.el-icon.avatar-uploader-icon {
    font-size: 28px;
    color: #8c939d;
    width: 178px;
    height: 178px;
    text-align: center;
}
</style>
  1. 新建一个文件夹src/api/product/trademark/index.ts封装请求
//统一管理品牌管理模块接口
import request from "@/utils/request";
import type { TrademarkRespnseData, Trademark } from "./types";

//品牌管理模块接口地址
enum API {
    //获取品牌分页列表的接口地址
    GET_TRADEMARK_LIST = "/admin/product/baseTrademark/",
    //添加品牌
    ADD_TRADEMARK = "/admin/product/baseTrademark/save",
    //修改品牌
    UPDATE_TRADEMARK = "/admin/product/baseTrademark/update",
    //删除品牌
    Delete_TRADEMARK = "/admin/product/baseTrademark/remove/"
}
//获取品牌分页列表的接口方法
//page:获取第几页的数据——默认值为1
//limit:每页显示多少条数据——默认值为10
export const reqGetTrademarkList = (page: number, limit: number) => request.get<any, TrademarkRespnseData>(API.GET_TRADEMARK_LIST + `${page}/${limit}`);
//添加品牌的接口方法
//export const reqAddTrademark = (data: Trademark) => request.post<any,any>(API.ADD_TRADEMARK, data);
//修改品牌的接口方法
//export const reqUpdateTrademark = (data: Trademark) => request.put<any,any>(API.UPDATE_TRADEMARK, data);
//添加与修改已有品牌接口方法
export const reqAddOrUpdateTrademark = (data: Trademark) => {
    //修改已有品牌的数据
    if (data.id) {
        return request.put<any, any>(API.UPDATE_TRADEMARK, data)
    } else {
        //新增品牌
        return request.post<any, any>(API.ADD_TRADEMARK, data)
    }
}
//删除品牌的接口方法
export const reqDeleteTrademark = (id: number) => request.delete<any, any>(API.Delete_TRADEMARK + id);

  1. 配一个types.ts定义数据类型
export interface ResponseData {
    code: number;
    message: string;
    ok: boolean;
}
//已有的品牌数据类型
export interface Trademark {
    //?表示可选,可有可无,新增时没有id,修改时有id
    id?: number;
    tmName: string;
    logoUrl: string;
}

//包含全部品牌数据类型
export type Records = Trademark[];

//获取的已有品牌数据类型
export interface TrademarkRespnseData extends ResponseData {
    data: {
        records: Records;
        total: number;
        size: number;
        pages: number;
        current: number;
        searchCount: boolean;
    }
}

本文标签: 实战项目vue