本项目使用springboot+vue3+typescript的技术栈开发。
1.创建项目的准备
在某个工程文件夹下创建项目
npm init vue@latest
各种工具选择都选“是”,本项目使用pinia、typescript的技术选型。安装环境node_modules之后,显示如图:
在开始项目之前,请确保掌握typescript、pinia、vue3的基本语法:
typescript入门
vue3学习
因为typescript可以对类型进行检验,便于查错,实际编码时当成js来写代码也可以,所以选择用typescript。
1.1 引入UI框架element-plus
在vue2的时候常用的ui框架是element-ui,在vue3时应该使用它的继承者element-plus。
同时,为保证能自动引入插件,可以使用插件unplugin-vue-components和unplugin-auto-import。
接下来下载插件。
npm install element-plus --save
npm install -D unplugin-vue-components unplugin-auto-import
在配置文件vite.config.ts
中修改加上以下配置:
import Components from "unplugin-vue-components/vite"
const { ElementPlusResolver } = require("unplugin-vue-components/resolvers")
import AutoImport from 'unplugin-auto-import/vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(),
vueJsx(),
AutoImport({ resolvers: [ElementPlusResolver()] }),
Components({ resolvers: [ElementPlusResolver()] })],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
同时,不要忘记在main.ts
文件中加入css文件。如果不引入,你将无法正确地展示 Element Plus 的样式。
import 'element-plus/theme-chalk/index.css'
1.1.1 引入Element Plus图标集
由于本项目还使用了Element Plus的图标,像左侧导航的图标是根据后台数据配置确定的,因此使用什么图标是不确定的。
npm install @element-plus/icons-vue
在main.ts
文件中加入以下配置
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
1.2 安装CSS插件
现在用SASS比较多,所以选择安装sass依赖
npm install -D sass
在package.json的文件,如图所示:
1.3 安装开发者工具vue-tools
下载地址:https://gitcode.net/mirrors/vuejs/devtools?utm_source=csdn_github_accelerator
下载vue-tools解压之后,执行以下命令
# 如果没有安装yarn的话
# 不知道自己是否安装 可以通过 yarn -v 查看一下
# 安装Vue-Devtools的依赖需要用到yarn,而不是npm,所以首先我们要安装yarn。命令行进入到解压后的Vue-Devtools目录。
npm install -g yarn
yarn install
yarn run build:watch
yarn run dev:chrome
看到本界面,说明安装成功了。之后Ctrl+C结束命令框。
之后,在google浏览器开启开发者模式,加载devtools-6.5.0\packages
下的shell-chrome
文件夹即可。
2. 添加页面路由
在views目录下新建Login.vue、404.vue、 Home.vue页面等3个简单页面
<template>
<div class='page'>
<h2>登录页</h2>
</div>
</template>
在router/index.ts中
import Login from '@/views/Login.vue'
import Home from '@/views/Home.vue'
import NotFound from '@/views/404.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/404',
name: 'NotFound',
component: NotFound
},
]
})
运行后,如图所示:
2.1 改进页面路由
将router/index.ts文件修改成以下代码:
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/404',
name: 'NotFound',
component: NotFound
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
export default router
3. 构建静态页面布局
3.1 修改App.vue
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import zhCn from 'element-plus/dist/locale/zh-cn'
</script>
<template>
<el-config-provider :locale="zhCn">
<RouterView />
</el-config-provider>
</template>
3.2 布局文件
在src/views/layout下创建index.vue文件,用于存放总体布局。
<template>
<div class="app-wrapper">
<page-header class="app-header"></page-header>
<div class="app-container">
<page-sidebar class="sidebar-container"></page-sidebar>
<div class="main-wrapper">
<main-content></main-content>
</div>
</div>
</div>
</template>
<script>
import PageHeader from '@/views/layout/components/PageHeader.vue'
import PageSidebar from '@/views/layout/components/PageSidebar.vue'
import MainContent from '@/views/layout/components/MainContent.vue'
export default {
name: "layout",
components: {
PageHeader,
PageSidebar,
MainContent,
},
}
</script>
Wrapper(整体布局):包裹整个页面或页面的一部分,通常用于设置背景、边框等样式。
Container(容器):包裹页面的主要内容,通常用于设置宽度、居中等样式。
Content(内容):包裹页面的具体内容,通常用于设置字体、颜色等样式。
Main(主要内容):包裹页面的主要内容,通常用于设置页面的主要样式。
在在src/views/layout/compnents下创建各布局组件。文件,用于存放总体布局。
//PageHeader.vue
<template>
<div class="app-wrapper">
首栏
</div>
</template>
//PageSidebar.vue
<template>
<div>
侧边栏
</div>
</template>
//MainContent.vue
<template>
<div>
<router-view></router-view>
</div>
</template>
在router文件中,修改代码将布局Layout应该到界面中。我们可以在views/personal下创建个人中心页面文件index.vue,将其设置为Layout的子路由。
import Layout from '@/views/layout/index.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Layout,
children: [
{
path: 'personal',
name: 'personal',
component: () => import('@/views/personal/index.vue')
},
]
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/404',
name: 'NotFound',
component: NotFound
},
]
如上所示,其实布局Layout.vue、Login登录页和404错误页都挂载到App.vue的router-view标签中。而子路由children挂载到MainContent.vue的router-view标签中,它们都应用了Layout的布局。
再根据需要调整CSS样式,访问http://localhost:5173/personal 的链接,最终设计的布局如图:
登录流程分析:
(1)登录需要将用户信息提交到后台进行校验处理,因此各种API访问操作要使用常用工具axios
发送请求,为便于重用代码,axios需要重新封装。
(2)在前后端分离项目中,为追求效率,可使用mockjs
进行模拟数据的测试。
(3)当请求发送成功时,需要保留登录状态,登录状态将在整个项目中使用,这里使用pinia
工具
(4)任何操作都需要在已登录状态下完成,这样才能保证用户数据或网站数据的安全
(5)如果登录状态失效,则需要更新状态并退出系统,用户需要重新登录,才可以重新执行相应操作,所以需要考虑状态管理。
4. 封装axios
例如,登录需要将用户信息提交到后台进行校验处理,因此各种API访问操作要使用常用工具axios发送请求,为便于重用代码,axios需要重新封装。
4.1 安装axios
先安装axios插件
npm install axios
在package.json的文件,如图所示:
4.2 设置配置文件
首先,后台的返回规则有一个通用的规则,前端的请求也有统一的规则,所以可以考虑设置一个通用的配置文件。
在src/http下创建一个config.js文件,将Axios通用配置写入文件中。
export default {
method: 'get',
// 基础url前缀
baseUrl: 'http://localhost:8001',
// 请求头信息
headers: {
'Content-Type': 'application/json;charset=UTF-8'
},
// 参数
data: {},
// 设置超时时间
timeout: 10000,
// 携带凭证
withCredentials: true,
// 返回数据类型
responseType: 'json'
}
4.3 设置拦截器
现在 统一API请求,并设置拦截器。
在src/http下创建一个request.js文件,引入Axios,再引入上一步创建的config.js文件,然后创建一个request方法,返回Promise,并导出这个方法,以便在其他文件中使用。在这个方法中通过axios.create创建一个Axios实例,代码如下:
import axios from 'axios'
import config from './config'
import { ElMessage, ElMessageBox } from 'element-plus'
export default function request(options) {
return new Promise((resolve, reject) => {
//创建axios实例
const instance = axios.create({
baseURL: config.baseUrl,
headers: config.headers,
timeout: config.timeout,
withCredentials: config.withCredentials
})
const user = useUserStore()
// request 请求拦截器
instance.interceptors.request.use(
config => {
//请求必须携带身份信息token
if (user.token != '') {
config.headers['Authorization'] = getToken() //从cookie中获取token的值,
}
return config
},
error => {
console.log(error) // 请求发生错误时,通过控制台查看报错的逻辑
return Promise.reject(error) // 在调用的那边可以拿到(catch)你想返回的错误信息
}
)
// response 响应拦截器
instance.interceptors.response.use(
response => {
//返回响应的逻辑,若返回了后台的错误码不是正确响应,则前端提示消息,并拦截下一步操作
const res = response.data
if (res.errcode !== "00000") {
ElMessage({
message: res.errmsg,
duration: 3 * 1000,
type: 'error',
})
console.log(res)
return Promise.reject('error')
}
return response.data
},
err => {
//错误返回响应的逻辑,例如后台出现未知报错,则前端提示消息,并拦截下一步操作
console.error(err)
ElMessage({
message: err.errmsg,
type: 'error',
duration: 3 * 1000
})
return Promise.reject(err) // 返回接口返回的错误信息
}
)
// 请求处理
instance(options).then(res => {
resolve(res)
return false
}).catch(error => {
reject(error)
})
})
}
分析:
(1)请求方式正确时,应该校验访问者的token信息,token信息校验正确则进入访问页面,若校验错误则重定向到登录页面。
(2)请求方式错误时,应该提示报错信息。
(3)返回正确的响应时,不做处理
(4)返回错误的响应时,应该提示报错信息。
携带的token信息跟实现机制有关,可能由cookie,localStorage,vuex等实现。
提示的报错信息跟使用的ui框架有关,应该使用ui框架提供的消息提示工具,同时在console也要提示报错。
正确返回结果时,统一返回格式为JSON,包含3个属性:errcode、errmsg和data。code表示成功标识,为00000时表示成功,成功时通常会带回数据data,如果不是00000,则为失败,需要读取提示信息。
{
errcode: "00000",
errmsg: "success",
data: {
//....
}
}
4.4 统一管理请求
为了统一管理请求,在src下创建api文件夹,用于存放各模块的远程请求方法。
例如,在api文件夹中创建login.js文件,放入以下请求。
import request from '@/http/request'
export function login(username, password) {
return request({
url: '/api/ums/admin/login',
method: 'post',
data: {
username,
password
}
})
}
该方法调用axios封装方法request来发送请求,当请求返回登录成功时,正常情况下会带回一个登录标识token,在处理请求返回时,将这个token存到本地缓存localStorage中,然后跳转到系统首页。
5. 封装Mock.js
为了能看到登录效果,要么依赖后台功能正常返回,要么考虑前端模拟后端返回。
先安装 mockjs插件。
npm install -D mockjs
5.1 编写模拟数据
和Axios请求模拟一样,数据的模拟也区分不同模块,因此继续在src/mock下创建一个modules文件夹,用于存放不同模块的模拟函数。并在modules文件夹下创建login.js文件。
export function login(){
return {
url : "login",
type: "post",
data: {
errcode : "00000",
errmsg : "success",
data : {
token : "abc123456789",
username : "admin"
}
}
}
}
5.2 封装所有模块
在src目录下新建一个mock目录,创建index.js。在index.js文件中引入各模块文件
import Mock from 'mockjs'
import * as login from './modules/login.js'
// 开启/关闭模块的拦截
const openMock = true
const baseUrl = "http://localhost:8001"
//模拟所有模块
mockAll([login],openMock)
function mockAll(modules,isOpen = true){
for (const k in modules){
mock(modules[k],isOpen)
}
}
function mock(mod,isOpen = true){
if(isOpen){
for(var key in mod){
((res)=>{
let url = baseUrl
if(!url.endsWith("/")){
url = url +"/"
}
url = url + res.url
Mock.mock(new RegExp(url),res.type,(opts) => {
opts['data'] = opts.body ? JSON.parse(opts.body) :null
delete opts.body
console.log('\n')
console.log('%cmock拦截,请求:','color:blue',opts)
console.log('%cmock拦截,请求:','color:blue',res.data)
return res.data
})
})(mod[key]()|| {})
}
}
}
该代码统一管理所有模块的模拟数据。首先,通过openMock统一管理所有模块模拟数据的开启/关闭。
然后url必须拼接baseUrl且以“/”结尾,各模块的url不能以“/”开头。
至此,Mock.js封装完成。只需要在入口文件src/main.js中引入上面的入口文件即可,即修改main.js如下(注意加粗部分):
import './assets/main.css'
import './mock'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.mount('#app')
5.3 使用mockjs
通过axios访问对应的api,得到如下结果
axios.get('http://localhost:8001/api/ums/admin/info',{
params :{
pageNum:1,
pageSize:20
}
}).then(res => { // url即在mock.js中定义的
console.log(res) // 打印一下响应数据
})
6.引入状态管理库Pinia及管理登录状态
若未安装pinia,则先引入
npm install pinia
确保在main.ts文件中应用了pinia
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')
6.1 设置用户登录状态
在src/stores目录下新建user.ts文件,在管理用户登录状态时,需要管理用户的token、用户名、权限等。同时,实现用户的登录,注销、获取用户信息的功能
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
token: getToken(),//token
username:'', //用户名
avatar: '', // 用户头像
roles: [] //权限
}),
getters: {
getToken: (state) => state.token && true,
},
persist: {
enabled: true // true 表示开启持久化保存
},
actions: {
//登录
login(username: string, password: string) {
},
logout() { },
getInfo(){},
},
})
在上述代码中,token是存在cookie中的,作为长期存储,若手动清楚cookie,则会清除登录状态。
其他信息存放在store的内存中,每次刷新浏览器,都需要重新获取权限。
6.2 安装js-cookie
在上面的axios.js中,会用到Cookie获取token,所以需要把相关依赖安装一下。执行以下命令,安装依赖包,如图22-3所示。
npm install js-cookie
在http/auth.js文件中编写代码
import Cookies from 'js-cookie'
const TokenKey = 'loginToken'
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token)
}
export function removeToken() {
return Cookies.remove(TokenKey)
}
6.3 关于pinia的持久化存储
这里有一个细节需要注意!
由于pinia和vuex一样将数据保存在内存中,当刷新浏览器时,state里的数据就会丢失,意味着登录状态不能被保存,这是不可接受的!
若是每次都需要记住登录状态,比如token的值,还需要引入一个持久化工具pinia-plugin-persist
插件。它可以自动将state里的值缓存起来,存在localStorage或sessionStorage、Cookie中,这可以自行配置。
用法如下:
(1)引入pinia-plugin-persist插件
npm install pinia-plugin-persist
修改配置文件main.js如下
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPersist from 'pinia-plugin-persist'
import App from './App.vue'
const pinia = createPinia()
pinia.use(piniaPersist)
const app = createApp(App)
app.use(pinia)
app.mount('#app')
(3)然后,在store的需要的模块文件中开启持久化配置
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
username:'', //用户名
roles: [] //用户角色
}),
getters: {
//...
},
persist: {
enabled: true, // true 表示开启持久化保存
strategies: [ //可自行配置存储方案
{ storage: sessionStorage, paths: ['username'] },
{ storage: localStorage, paths: ['roles'] },
],
},
actions: {
//...
},
})
但是!!!!!
这其实多此一举!
我们可以直接把token信息存在cookie中作为长期存储,并不需要这个插件的存在。
而其他信息比如用户名,权限等等,在刷新页面后丢失信息!这正是我们想要的!!!!
在后台为用户更新用户名和权限时,我们需要这种更改立马生效,用户应该尽早去后台取得新的身份信息。
这里有一点巧妙的地方,就是首次访问目标页面时,经过导航守卫处理时,store里没有值,需要去后台获取身份信息存在store里,然后再经过导航守卫重定向到目标页面。
7.完善登录流程
用户登录设计思路:
(1)用户无论是填入页面地址还是刷新页面,首先第一个是判断页面路由是否需要权限访问,第二个才是验证该用户是否拥有访问该页面的权限。
(2)在登录页面的表单填写账号密码后,需要将登录后返回的身份信息传递给状态管理库store,原因是状态管理需要保存用户信息。所以应该由状态管理访问api,接受返回的数据。
//login.vue
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { reactive, ref } from 'vue'
const loading = ref(false)
const loginForm = reactive({
username: '',
password: ''
})
//登录的异步操作,若登录成功则跳转首页,若登录失败则报错
const handleLogin = async () => {
loading.value = true
const user = useUserStore() //获取pinia管理的用户状态
try {
await user.login(loginForm.username, loginForm.password) //委托给状态管理库处理登录
router.push({ path: '/' })
} catch (error) {
console.error('登录失败:', error)
}
loading.value = false
}
</script>
//stores/user.ts
export const useUserStore = defineStore('user', {
// 定义状态:一个函数,返回一个对象
state: () => ({
token: getToken(),//token
username: '', //用户名
avatar: '', // 用户头像
roles: [] //权限
}),
// persist: {
// enabled: true // true 表示开启持久化保存
// },
// 定义 getters,等同于组件的计算属性
getters: {
isLogin: (state) => state.token && true,
},
// 定义 actions,有同步和异步两种类型
actions: {
login(username: string, password: string) {
return new Promise((resolve, reject) => {
login(username, password).then(response => {
const data = response.data
const tokenStr = data.tokenHead + data.token
//登录成功后,将包含头信息的token值存在cookie和状态管理中
setToken(tokenStr) //将token保存到cookie
this.token = tokenStr //token保存到pinia
resolve()
}).catch(error => {
reject(error)
})
})
},
/**
* 退出登录,先在后台退出登录,后在前台删除token值
*/
async logout() {
await logout(this.username)
this.token = null
removeToken()
},
getInfo() {
return new Promise((resolve, reject) => {
//获取当前登录用户信息,在headers['Authorization']中携带了包含用户信息的参数,直接在后台解析即可
getInfo().then(response => {
const data = response.data
if (data.roles && data.roles.length > 0) { // 验证返回的roles是否是一个非空数组
this.roles = data.roles
} else {
console.log('getInfo: roles must be a non-null array !')
}
this.username = data.username
this.avatar = data.icon
resolve(data)
}).catch(error => {
console.log(error)
reject(error)
})
})
},
setRoles() { }
},
})
7.1 添加导航守卫
可以在每次路由之前进行一些相关处理,也叫导航守卫。
我们这里就通过导航守卫实现在每次路由时,判断用户会话是否过期以及动态菜单的加载。
修改router/index.js文件,添加导航守卫。
(1)如果登录有效且跳转的是登录界面,则禁止进行登录并路由到后台首页;若跳转的非登录页面,则获取用户信息并加载动态菜单,并路由到目标页面。
(2)如果会话过期等登录无效状态且跳转到白名单路径,就路由到白名单所在的路径;否则,就跳到登录页面要求登录。
/**
* (1)进行登录操作时,会先访问api再进行跳转。进行刷新或跳转时,会先跳转再访问api。两者都需要进行验证。
* (2)如果登录有效且跳转的是登录界面,则禁止进行登录并路由到后台首页;若跳转的非登录页面,则获取用户信息并加载动态菜单,并路由到目标页面。
* (3)如果会话过期等登录无效状态且跳转到白名单路径,就路由到白名单所在的路径;否则,就跳到登录页面要求登录。
* (4)注意这里其实跳转了两次路由,第1次路由中store中没有值,这时去获取getInfo信息,有了值之后,再次重定向目标页面,重新判断权限
*/
router.beforeEach(async (to, from, next) => {
const user = useUserStore()
if (to.path === '/login') {
//如果访问的是登录页,且登录信息存在,则重定向到主页
if (user.isLogin) {
next({
path: '/'
})
} else {
next()
}
} else {
//如果访问的时白名单,则放行
if (whiteList.indexOf(to.path) !== -1) {
next()
} else {
//如果用户已登录,则获取登录信息,并跳转到目标页面,若未登录则重定向到登录页
if (user.isLogin) {
//用户登录成功后,有cookie的token信息,但是不一定获取到了身份,应该去数据库获取身份信息
if (user.roles.length === 0) {
try {
const data = await user.getInfo()
addDynamicRoutes(data.menus)
next({
...to,
replace: true
})
} catch (error) {
console.log(error)
await user.logout()
next({
path: '/'
})
}
} else {
next()
}
} else {
next('/login')
}
}
}
})
/**
* 获取route动态菜单
*/
function addDynamicRoutes() {
//...
}
7.2 动态加载菜单
在stores目录下新建menu.ts文件,里面保存着加载后的导航菜单树数据。
export const useMenuStore = defineStore('menu', {
// 定义状态:一个函数,返回一个对象
state: () => ({
navTree: [],//导航树
routeLoaded:false,//判断动态路由是否已加载
}),
getters: {
},
actions: {
generateRoutes(menus) {
console.log(menus);
},
},
})
分析:
(1)在页面中用户是通过router跳转的,如果后台导航目录运营人员新增菜单后,前端人员得在路由表中手动添加上,这样导航才能点击才能对应上页面,这样比较麻烦。
(2)因此在中大型项目采用的都是添加动态路由的方式解决的。用户在未登录前,可以访问静态路由列表constantRouterMap
。登录成功后,需要去后台获取权限允许的菜单添加到路由列表router中。
(3)首先,后台给的菜单数据应该具有树形结构,才能方便渲染到导航菜单栏<el-menu>
中。同时,数据需要经过处理成route对象,包括path、name、component等数据,添加到routes数组中。
(4)当没有匹配到任何路径时候应该前往404路由,且必须最后再添加404路由。因为若该路由定义存在,则在动态路由未添加完成之前,访问的系统内页路由将找不到匹配路由,会直接匹配该路由定义而跳转404页面,无法达到正确跳转,所以将其抽出来,待权限路由添加完成后,再手动添加404路由。
首先判断动态菜单是否已经存在,如果存在就不再重复加载损耗性能,否则调用后台接口加载数据库存储菜单数据,加载成功之后通过router.addRoutes方法将菜单数据动态添加到路由器并同时保存菜单数据及加载状态以备后用。导航菜单加载成功之后,调用后台接口查找用户权限数据并保存起来,供权限判断时读取。