vue3+TS+Vite2+Element Plus管理系统通用模板(2022最新)
2022管理系统最流行技术栈,vue3+TypeScript+Vite2+Pinia+Element Plus+VueRouter。搭建流程详细,这一套下来起码增加两年功力。
技术栈:vue3+TypeScript+Vite2+Pinia+Element Plus+VueRouter
初始化项目
安装vue-ts模板,和vite,并设置项目名称GBT(General Background Template)。
// npm
npm init vite@latest GBT --template vue-ts
// yarn
yarn create vite GBT --template vue-ts
启动项目:
初始化项目后安装项目依赖,之后执行npm run dev
命令启动项目,浏览器打开 http://127.0.0.1:5173/ 就可以看到启动后的项目
npm install --registry=https://registry.npm.taobao.org
//yarn install
npm run dev
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yBekxOiN-1670077065046)(https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/74adf791eda64198a00c5a8bc769629e~tplv-k3u1fbpfcp-watermark.image?)]
安装Element-Plus
Element-Plus官方文档有两种引入方式,这里使用全局引入。
// 图标组件需要单独安装
npm install element-plus @element-plus/icons-vue sass --registry=https://registry.npm.taobao.org
1.全局注册组件
在main.ts中引入ElementPlus和样式文件,并通过use方法安装ElementPlus插件。
// main.ts
import ElementPlus from 'element-plus'
import 'element-plus/theme-chalk/index.css'
createApp(App)
.use(ElementPlus)
.mount('#app')
2.全局组件类型声明&路径别名配置
在tsconfig.json文件中对ts进行配置,配置正确会有类型提示,这也是使用ts的好处。
// tsconfig.json
{
"compilerOptions": {
// ...
"types": ["element-plus/global"],
"baseUrl": "./", // 解析非相对模块的基础地址,默认是当前目录
"paths": {"@/*": ["src/*"]}, // 路径映射,相对于baseUrl
"allowSyntheticDefaultImports": true // 允许默认导入
}
}
配置环境变量
-
开发环境配置:.env.development
# 变量必须以 VITE_ 为前缀才能暴露给外部读取 VITE_APP_TITLE = 'oursBlog' VITE_APP_PORT = 3080 VITE_APP_BASE_API = '/dev-api' VITE_APP_BASE_API_MOCK = 'https://mock.mengxuegu.com/mock/636eff7ef22edd4bbbcd9919/mmServer'
-
生产环境配置:.env.production
VITE_APP_TITLE = 'oursBlog' VITE_APP_PORT = 3080 VITE_APP_BASE_API = '/prod-api'
-
测试环境配置:.env.testing
VITE_APP_TITLE = 'oursBlog' VITE_APP_PORT = 3080 VITE_APP_BASE_API = '/testing-api'
Vite配置
官方文档:Home | Vite中文网 (vitejs.cn)
先安装ts的类型描述文件,再在vite.config.ts文件中配置代理服务可以解决跨域问题。
在tsconfig.node.json文件中配置基础路径和路径映射。
// 安装TypeScript类型描述文件
npm install @types/node -S --registry=https://registry.npm.taobao.org
// vite.config.ts
import { UserConfig, ConfigEnv, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default ({ command, mode }: ConfigEnv): UserConfig => {
const env = loadEnv(mode, process.cwd())
return {
plugins: [ vue(),],
server: {
host: '0.0.0.0',
port: Number(env.VITE_APP_PORT),
open: true,
proxy: {
[env.VITE_APP_BASE_API]: {
target: 'https://mock.mengxuegu.com/mock/636eff7ef22edd4bbbcd9919/mmServer',
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
}
}
},
resolve: {
alias: {
'@': path.resolve('./src')
}
}
}
}
// tsconfig.node.json
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"baseUrl": "./", // 解析非相对模块的基地址,默认是当前目录
"paths": { "@/*": ["src/*"] } //路径映射,相对于baseUrl
},
"include": ["vite.config.ts"]
}
自动导入插件
由于vue3的api是要先引入再使用的,这样每次都要引入就会很麻烦,于是相应的插件应运而生,一个是引入api的一个是引入组件的。
npm install unplugin-auto-import unplugin-vue-components -D --registry=https://registry.npm.taobao.org
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/, /\.md$/],
imports: [
// 插件预设支持导入的api
'vue'
],
dts: './auto-imports.d.ts'
}),
Components({
resolvers: [ElementPlusResolver()]
})
],
tsconfig.json文件中添加"./auto-imports.d.ts",否则识别不到会不生效。
// tsconfig.json
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue","./auto-imports.d.ts"],
启动项目测试一下,能否正常运行。
// App.vue
<template>
<div>omg 成功了</div>
</template>
<script setup lang="ts"></script>
<style lang="scss"></style>
Pinia状态管理
新一代状态管理工具,好用!
1.pinia安装
npm install pinia --registry=https://registry.npm.taobao.org
2.pinia注册
// src/main.ts
import { createPinia } from "pinia"
createApp(App)
.use(ElementPlus)
.use(createPinia())
.mount('#app')
3.pinia模块封装
// store/modules/user.ts
import { defineStore } from 'pinia'
import { UserState } from '../storeTypes'
const useUserStore = defineStore({
id: 'user',
state: (): UserState => ({
token: '',
roles: [],
perms: []
}),
actions: {}
})
export default useUserStore
// src\store\storeTypes.ts
export interface UserState {
token: string
nickname?: string
avatar?: string
roles: string[]
perms: string[]
}
// store/index.ts
import useUserStore from './modules/user';
const useStore = () => ({
user: useUserStore(),
});
export default useStore;
Axios封装
1.安装axios
npm install axios --registry=https://registry.npm.taobao.org
2.浏览器缓存封装
// utils/storage.ts
// window.localStorage 浏览器永久缓存
export const localStorage = {
// 设置永久缓存
set(key: string, val: any) {
window.localStorage.setItem(key, JSON.stringify(val));
},
// 获取永久缓存
get(key: string) {
const json: any = window.localStorage.getItem(key);
return JSON.parse(json);
},
// 移除永久缓存
remove(key: string) {
window.localStorage.removeItem(key);
},
// 移除全部永久缓存
clear() {
window.localStorage.clear();
}
};
// window.sessionStorage 浏览器临时缓存
export const sessionStorage = {
// 设置临时缓存
set(key: string, val: any) {
window.sessionStorage.setItem(key, JSON.stringify(val));
},
// 获取临时缓存
get(key: string) {
const json: any = window.sessionStorage.getItem(key);
return JSON.parse(json);
},
// 移除临时缓存
remove(key: string) {
window.sessionStorage.removeItem(key);
},
// 移除全部临时缓存
clear() {
window.sessionStorage.clear();
}
};
3.请求封装
实际开发过程中接口不会很快就出来,所以mock接口是必要的,那么在封装的时候就可以考虑mock接口的功能加进去。
// src\utils\request.ts
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import { localStorage } from '../utils/storage'
import useStore from '../store'
const baseURLAll = ref(import.meta.env.VITE_APP_BASE_API)
type TOptions = {
mock?: boolean // 是否启用mock
mockUrl?: string // 自定义mock地址
}
const request = (options: TOptions & AxiosRequestConfig) => {
if (options?.mock) baseURLAll.value = options.mockUrl ?? import.meta.env.VITE_APP_BASE_API_MOCK
// 对当前环境进行二次判断 防止生产环境调用了测试环境接口------------------********待添加*********------------------
const service = axios.create({
baseURL: baseURLAll.value,
timeout: 50000
})
// 请求拦截器
service.interceptors.request.use(
(config: AxiosRequestConfig) => {
if (!config.headers) {
throw new Error(`Expected 'config' and 'config.headers' not to be undefined`)
}
const { user } = useStore()
if (user.token) {
config.headers.Authorization = `${localStorage.get('token')}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const { code, msg } = response.data
if (code === '200') {
return response.data
} else {
ElMessage({
message: msg || '系统出错',
type: 'error'
})
return Promise.reject(new Error(msg || 'Error'))
}
},
(error) => {
const { code, msg } = error.response.data
if (code === 'A0230') {
// token 过期
localStorage.clear() // 清除浏览器全部缓存
window.location.href = '/' // 跳转登录页
ElMessageBox.alert('当前页面已失效,请重新登录', '提示', {})
.then(() => {})
.catch(() => {})
} else {
ElMessage({
message: msg || '系统出错',
type: 'error'
})
}
return Promise.reject(new Error(msg || 'Error'))
}
)
return service(options)
}
export default request
4.API封装
// src\api\user\index.ts
import request from '@/utils/request'
import { AxiosPromise } from 'axios'
import { UserInfo } from './types'
export function getUserInfo(data: { userId: Number | null }): AxiosPromise<UserInfo> {
return request({
url: '/api/users/userInfo',
method: 'post',
data
})
}
// src\api\user\types.ts
export interface UserInfo {
nickname: string;
avatar: string;
roles: string[];
perms: string[];
}
utils
// src\utils\index.ts
/**
* Check if an element has a class
* @param {HTMLElement} elm
* @param {string} cls
* @returns {boolean}
*/
export function hasClass(ele: HTMLElement, cls: string) {
return !!ele.className.match(new RegExp('(\\s|^)' + cls + '(\\s|$)'));
}
/**
* Add class to element
* @param {HTMLElement} elm
* @param {string} cls
*/
export function addClass(ele: HTMLElement, cls: string) {
if (!hasClass(ele, cls)) ele.className += ' ' + cls;
}
/**
* Remove class from element
* @param {HTMLElement} elm
* @param {string} cls
*/
export function removeClass(ele: HTMLElement, cls: string) {
if (hasClass(ele, cls)) {
const reg = new RegExp('(\\s|^)' + cls + '(\\s|$)');
ele.className = ele.className.replace(reg, ' ');
}
}
export function mix(color1: string, color2: string, weight: number) {
weight = Math.max(Math.min(Number(weight), 1), 0);
const r1 = parseInt(color1.substring(1, 3), 16);
const g1 = parseInt(color1.substring(3, 5), 16);
const b1 = parseInt(color1.substring(5, 7), 16);
const r2 = parseInt(color2.substring(1, 3), 16);
const g2 = parseInt(color2.substring(3, 5), 16);
const b2 = parseInt(color2.substring(5, 7), 16);
const r = Math.round(r1 * (1 - weight) + r2 * weight);
const g = Math.round(g1 * (1 - weight) + g2 * weight);
const b = Math.round(b1 * (1 - weight) + b2 * weight);
const rStr = ('0' + (r || 0).toString(16)).slice(-2);
const gStr = ('0' + (g || 0).toString(16)).slice(-2);
const bStr = ('0' + (b || 0).toString(16)).slice(-2);
return '#' + rStr + gStr + bStr;
}
动态权限路由&router
1. 安装 vue-router
npm install vue-router@next nprogress @types/nprogress --registry=https://registry.npm.taobao.org
2. 创建路由实例
创建路由实例并导出,其中包括静态路由数据,动态路由后面将通过接口从后端获取并整合用户角色的权限控制。
// 新建src\layout\index.vue文件
// 新建src\views\error-page\401.vue文件
// 新建src\views\error-page\404.vue文件
// 新建src\views\login\index.vue文件
// 新建src\views\redirect\index.vue文件
// 新建src\views\dashboard\index.vue文件
// src/router/index.ts
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import useStore from '@/store'
export const Layout = () => import('@/layout/index.vue')
// 静态路由
export const constantRoutes: Array<RouteRecordRaw> = [
{
path: '/redirect',
component: Layout,
meta: { hidden: true },
children: [
{
path: '/redirect/:path(.*)',
component: () => import('@/views/redirect/index.vue')
}
]
},
{
path: '/login',
component: () => import('@/views/login/index.vue'),
meta: { hidden: true }
},
{
path: '/404',
component: () => import('@/views/error-page/404.vue'),
meta: { hidden: true }
},
{
path: '/401',
component: () => import('@/views/error-page/401.vue'),
meta: { hidden: true }
},
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
component: () => import('@/views/dashboard/index.vue'),
name: 'Dashboard',
meta: { title: 'dashboard', icon: 'dashboard', affix: true }
}
]
}
]
// 创建路由实例
const router = createRouter({
history: createWebHashHistory(),
routes: constantRoutes as RouteRecordRaw[],
// 刷新时,滚动条位置还原
scrollBehavior: () => ({ left: 0, top: 0 })
})
// 重置路由
export function resetRouter() {
// doing...
}
export default router
3. 路由实例全局注册
// main.ts
import router from "@/router";
// import '@/permission';
createApp(App).use(router).use(ElementPlus).use(createPinia()).mount('#app')
// App.vue
<template>
<router-view v-if="isRouterAlive" />
</template>
<script setup lang="ts">
const isRouterAlive = ref(true)
const reload = () => {
isRouterAlive.value = false
nextTick(() => (isRouterAlive.value = true))
}
provide('reload', reload)
</script>
<style lang="scss"></style>
试一试!启动项目!成功一大半!
4.登录退出等api封装
auth
// src\api\auth\types.ts
/**
* 登录表单类型声明
*/
export interface LoginForm {
username: string
password: string
grant_type: string
/**
* 验证码Code
*/
//verifyCode: string;
/**
* 验证码Code服务端缓存key(UUID)
*/
// verifyCodeKey: string;
}
/**
* 登录响应类型声明
*/
export interface LoginResult {
access_token: string
token_type: string
}
/**
* 验证码类型声明
*/
export interface VerifyCode {
verifyCodeImg: string
verifyCodeKey: string
}
// src\api\auth\index.ts
import request from '@/utils/request'
import { AxiosPromise } from 'axios'
import { LoginForm, VerifyCode } from './types'
/**
*
* @param data {LoginForm}
* @returns
*/
export function loginApi(data: LoginForm): AxiosPromise<string> {
return request({
url: '/api/auth/login',
method: 'post',
params: data,
headers: {
Authorization: 'Basic dnVlMy1lbGVtZW50LWFkbWluOnNlY3JldA==' // 客户端信息Base64明文:vue3-element-admin:secret
}
})
}
/**
* 注销
*/
export function logoutApi() {
return request({
url: '/api/auth/logout',
method: 'delete'
})
}
/**
* 获取图片验证码
*/
export function getCaptcha(): AxiosPromise<VerifyCode> {
return request({
url: '/captcha?t=' + new Date().getTime().toString(),
method: 'get'
})
}
// src\api\menu\types.ts
/**
* 菜单查询参数类型声明
*/
export interface MenuQuery {
keywords?: string
}
/**
* 菜单分页列表项声明
*/
export interface Menu {
id?: number
parentId: number
type?: string | 'CATEGORY' | 'MENU' | 'EXTLINK'
createTime: string
updateTime: string
name: string
icon: string
component: string
sort: number
visible: number
children: Menu[]
}
/**
* 菜单表单类型声明
*/
export interface MenuForm {
//菜单ID
id?: string
//父菜单ID
parentId: string
//菜单名称
name: string
//菜单是否可见(1:是;0:否;)
visible: number
icon?: string
//排序
sort: number
//组件路径
component?: string
//路由路径
path: string
//跳转路由路径
redirect?: string
//菜单类型
type: string
//权限标识
perm?: string
}
/**
* 资源(菜单+权限)类型
*/
export interface Resource {
// 菜单值
value: string
//菜单文本
label: string
//子菜单
children: Resource[]
}
/**
* 权限类型
*/
export interface Permission {
// 权限值
value: string
//权限文本
label: string
}
// src\api\menu\index.ts
import request from '@/utils/request'
import { AxiosPromise } from 'axios'
import { MenuQuery, Menu, Resource, MenuForm } from './types'
type OptionType = {
value: string
label: string
checked?: boolean
children?: OptionType[]
}
/**
* 获取路由列表
*/
export function listRoutes() {
return request({
url: '/api/v1/menus/routes',
method: 'get'
})
}
/**
* 获取菜单表格列表
*
* @param queryParams
*/
export function listMenus(queryParams: MenuQuery): AxiosPromise<Menu[]> {
return request({
url: '/api/v1/menus',
method: 'get',
params: queryParams
})
}
/**
* 获取菜单下拉树形列表
*/
export function listMenuOptions(): AxiosPromise<OptionType[]> {
return request({
url: '/api/v1/menus/options',
method: 'get'
})
}
/**
* 获取资源(菜单+权限)树形列表
*/
export function listResources(): AxiosPromise<Resource[]> {
return request({
url: '/api/v1/menus/resources',
method: 'get'
})
}
/**
* 获取菜单详情
* @param id
*/
export function getMenuDetail(id: string): AxiosPromise<MenuForm> {
return request({
url: '/api/v1/menus/' + id,
method: 'get'
})
}
/**
* 添加菜单
*
* @param data
*/
export function addMenu(data: MenuForm) {
return request({
url: '/api/v1/menus',
method: 'post',
data: data
})
}
/**
* 修改菜单
*
* @param id
* @param data
*/
export function updateMenu(id: string, data: MenuForm) {
return request({
url: '/api/v1/menus/' + id,
method: 'put',
data: data
})
}
/**
* 批量删除菜单
*
* @param ids 菜单ID,多个以英文逗号(,)分割
*/
export function deleteMenus(ids: string) {
return request({
url: '/api/v1/menus/' + ids,
method: 'delete'
})
}
5.store公共方法
// src\store\storeTypes.ts
import { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
export interface AppState {
device: string;
sidebar: {
opened: boolean;
withoutAnimation: boolean;
};
language?: string;
size: string;
}
export interface PermissionState {
routes: RouteRecordRaw[];
addRoutes: RouteRecordRaw[];
}
export interface SettingState {
theme: string;
tagsView: boolean;
fixedHeader: boolean;
showSettings: boolean;
sidebarLogo: boolean;
}
export interface UserState {
token: string;
nickname: string;
avatar: string;
roleList?: string[];
perms: string[];
roles: string[];
userId: Number | null;
}
export interface TagView extends Partial<RouteLocationNormalized> {
title?: string;
}
export interface TagsViewState {
visitedViews: TagView[];
cachedViews: string[];
}
// src/store/modules/user.ts
import { defineStore } from 'pinia'
import { UserState } from '../storeTypes'
import { localStorage } from '@/utils/storage'
import { loginApi, logoutApi } from '@/api/auth'
import { getUserInfo } from '@/api/user'
import { resetRouter } from '@/router'
import { LoginForm } from '@/api/auth/types'
const useUserStore = defineStore({
id: 'user',
state: (): UserState => ({
token: localStorage.get('token') || '',
nickname: '',
avatar: '',
userId: null,
roles: [],
perms: []
}),
actions: {
// 重置仓库到初始状态
async RESET_STATE() {
this.$reset()
},
// 登录
login(data: LoginForm) {
const { username, password } = data
return new Promise((resolve, reject) => {
loginApi({
grant_type: 'password',
username: username.trim(),
password: password
})
.then((response: { data: any }) => {
const { userId, token } = response.data
localStorage.set('token', token)
this.token = token
this.userId = userId
resolve(token)
})
.catch((error) => {
reject(error)
})
})
},
// 获取用户信息(昵称、头像、角色集合、权限集合)
getUserInfo() {
return new Promise((resolve, reject) => {
getUserInfo({ userId: this.userId })
.then(({ data }) => {
if (!data) {
return reject('Verification failed, please Login again.')
}
const { nickname, avatar, roles, perms } = data
if (!roles || roles.length <= 0) {
reject('getUserInfo: roles must be a non-null array!')
}
this.nickname = nickname
this.avatar = avatar
this.roles = roles
this.perms = perms
resolve(data)
})
.catch((error) => {
reject(error)
})
})
},
// 注销
logout() {
return new Promise((resolve, reject) => {
logoutApi()
.then(() => {
localStorage.remove('token')
this.RESET_STATE()
resetRouter()
resolve(null)
})
.catch((error) => {
reject(error)
})
})
},
// 清除 Token
resetToken() {
return new Promise((resolve) => {
localStorage.remove('token')
this.RESET_STATE()
resolve(null)
})
}
}
})
export default useUserStore
// src\store\modules\permission.ts
import { PermissionState } from '../storeTypes'
import { RouteRecordRaw } from 'vue-router'
import { defineStore } from 'pinia'
import { constantRoutes } from '@/router'
import { listRoutes } from '@/api/menu'
const modules = import.meta.glob('../../views/**/**.vue')
export const Layout = () => import('@/layout/index.vue')
const hasPermission = (roles: string[], route: RouteRecordRaw) => {
if (route.meta && route.meta.roles) {
if (roles.includes('ROOT')) {
return true
}
return roles.some((role) => {
if (route.meta?.roles !== undefined) {
return (route.meta.roles as string[]).includes(role)
}
})
}
return false
}
// 角色过滤路由
export const filterAsyncRoutes = (routes: RouteRecordRaw[], roles: string[]) => {
const res: RouteRecordRaw[] = []
routes.forEach((route) => {
const tmp = { ...route } as any
if (hasPermission(roles, tmp)) {
if (tmp.component == 'Layout') {
tmp.component = Layout
} else {
const component = modules[`../../views/${tmp.component}.vue`] as any
if (component) {
tmp.component = component
} else {
tmp.component = modules[`../../views/error-page/404.vue`]
}
}
res.push(tmp)
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, roles)
}
}
})
return res
}
const usePermissionStore = defineStore({
id: 'permission',
state: (): PermissionState => ({
routes: [], // 静态路由 + 动态路由
addRoutes: [] //动态路由
}),
actions: {
setRoutes(routes: RouteRecordRaw[]) {
this.addRoutes = routes
this.routes = constantRoutes.concat(routes)
},
generateRoutes(roles: string[]) {
return new Promise((resolve, reject) => {
listRoutes()
.then((response) => {
const asyncRoutes = response.data
const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
this.setRoutes(accessedRoutes)
resolve(accessedRoutes)
})
.catch((error) => {
reject(error)
})
})
}
}
})
export default usePermissionStore
// src\store\index.ts
import useUserStore from './modules/user'
import usePermissionStore from './modules/permission'
const useStore = () => ({
user: useUserStore(),
permission: usePermissionStore(),
})
export default useStore
6. 动态权限路由,路由鉴权,鉴权文件引入
// main.ts引入
// src/permission.ts
import router from '@/router'
import { ElMessage } from 'element-plus'
import useStore from '@/store'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
NProgress.configure({ showSpinner: false }) // 进度环显示/隐藏
// 白名单路由
const whiteList = ['/login']
router.beforeEach(async (to, form, next) => {
NProgress.start()
const { user, permission } = useStore()
const hasToken = user.token
// 有token
if (hasToken) {
// 登录成功,跳转到首页
if (to.path === '/login') {
next({ path: '/' })
NProgress.done()
} else {
const hasGetUserInfo = user.roles.length > 0
// 有用户信息
if (hasGetUserInfo) {
next()
} else {
// 无用户信息,重新获取用户信息路由信息
try {
await user.getUserInfo()
const roles = user.roles
// 用户拥有权限的路由集合(accessRoutes)
// 是根据用户角色获取拥有权限的路由(静态路由+动态路由)
const accessRoutes: any = await permission.generateRoutes(roles)
accessRoutes.forEach((route: any) => {
router.addRoute(route)
})
next({ ...to, replace: true })
} catch (error) {
// 移除 token 并跳转登录页
await user.resetToken()
ElMessage.error((error as any) || 'Has Error')
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
}
} else {
// 未登录可以访问白名单页面(登录页面),无token
if (whiteList.indexOf(to.path) !== -1) {
next()
} else {
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
})
router.afterEach(() => {
NProgress.done()
})
启动项目测试权限控制
SVG图标
1. 安装 vite-plugin-svg-icons
npm i fast-glob@3.2.11 vite-plugin-svg-icons@2.0.1 -D --registry=https://registry.npm.taobao.org
2. 创建图标文件夹
项目创建 src/assets/icons
文件夹,存放 iconfont 下载的 SVG 图标
3. main.ts 引入注册脚本
// main.ts
import 'virtual:svg-icons-register';
4. vite.config.ts 插件配置
// vite.config.ts
import {UserConfig, ConfigEnv, loadEnv} from 'vite'
import vue from '@vitejs/plugin-vue'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
export default ({command, mode}: ConfigEnv): UserConfig => {
// 获取 .env 环境配置文件
const env = loadEnv(mode, process.cwd())
return (
{
plugins: [
//...
createSvgIconsPlugin({
// 指定需要缓存的图标文件夹
iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
// 指定symbolId格式
symbolId: 'icon-[dir]-[name]',
})
]
}
)
}
5. TypeScript支持
// tsconfig.json
{
"compilerOptions": {
"types": ["vite-plugin-svg-icons/client"]
}
}
6. 组件封装
<!-- src/components/SvgIcon/index.vue -->
<template>
<svg aria-hidden="true" class="svg-icon">
<use :xlink:href="symbolId" :fill="color" />
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props=defineProps({
prefix: {
type: String,
default: 'icon',
},
iconClass: {
type: String,
required: true,
},
color: {
type: String,
default: ''
}
})
const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`);
</script>
<style scoped>
.svg-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
overflow: hidden;
fill: currentColor;
}
</style>
7. 使用示例
<template>
<svg-icon icon-class="menu"/>
</template>
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon/index.vue';
</script>
样式文件
// src\styles\index.scss
body {
margin: 0;
padding: 0;
height: 100%;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB,
Microsoft YaHei, Arial, sans-serif;
}
label {
font-weight: 700;
}
html {
height: 100%;
box-sizing: border-box;
}
#app {
height: 100%;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
a:focus,
a:active {
outline: none;
}
a,
a:focus,
a:hover {
cursor: pointer;
color: inherit;
text-decoration: none;
}
div:focus {
outline: none;
}
.clearfix {
&:after {
visibility: hidden;
display: block;
font-size: 0;
content: ' ';
clear: both;
height: 0;
}
}
// main-container global css
.app-container {
padding: 20px;
}
.search{
padding:18px 0 0 10px;
margin-bottom: 10px;
box-shadow: var(--el-box-shadow-light);
border-radius: var(--el-card-border-radius);
border: 1px solid var(--el-card-border-color);
}
登录页面
//src\views\login\index.vue
<template>
<div class="login-container">
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
auto-complete="on"
label-position="left"
>
<div class="title-container">
<h3 class="title">登录</h3>
</div>
<el-form-item prop="username">
<span class="svg-container">
<svg-icon icon-class="user" />
</span>
<el-input
ref="username"
v-model="loginForm.username"
placeholder="login.username"
name="username"
type="text"
tabindex="1"
auto-complete="on"
/>
</el-form-item>
<el-tooltip :disabled="capslockTooltipDisabled" content="Caps lock is On" placement="right">
<el-form-item prop="password">
<span class="svg-container">
<svg-icon icon-class="password" />
</span>
<el-input
ref="passwordRef"
:key="passwordType"
v-model="loginForm.password"
:type="passwordType"
placeholder="Password"
name="password"
tabindex="2"
auto-complete="on"
@keyup="checkCapslock"
@blur="capslockTooltipDisabled = true"
@keyup.enter="handleLogin"
/>
<span class="show-pwd" @click="showPwd">
<svg-icon :icon-class="passwordType === 'password' ? 'eye' : 'eye-open'" />
</span>
</el-form-item>
</el-tooltip>
<el-button
size="default"
:loading="loading"
type="primary"
style="width: 100%; margin-bottom: 30px"
@click.prevent="handleLogin"
>login
</el-button>
<!-- 账号密码提示 -->
<div class="tips">
<div style="position: relative">
<span style="margin-right: 20px">username: admin</span>
<span> password: 123456</span>
</div>
</div>
</el-form>
</div>
</template>
<script setup lang="ts">
import router from '@/router'
import SvgIcon from '@/components/SvgIcon/index.vue'
import useStore from '@/store'
import { useRoute } from 'vue-router'
import { LoginForm } from '@/api/auth/types'
const { user } = useStore()
const route = useRoute()
const loginFormRef = ref(ElForm)
const passwordRef = ref(ElInput)
const state = reactive({
redirect: '',
loginForm: {
username: 'admin',
password: '123456'
} as LoginForm,
loginRules: {
username: [{ required: true, trigger: 'blur' }],
password: [{ required: true, trigger: 'blur', validator: validatePassword }]
},
loading: false,
passwordType: 'password',
// 大写提示禁用
capslockTooltipDisabled: true,
otherQuery: {},
clientHeight: document.documentElement.clientHeight,
showDialog: false
})
function validatePassword(rule: any, value: any, callback: any) {
if (value.length < 6) {
callback(new Error('The password can not be less than 6 digits'))
} else {
callback()
}
}
const { loginForm, loginRules, loading, passwordType, capslockTooltipDisabled } = toRefs(state)
function checkCapslock(e: any) {
const { key } = e
state.capslockTooltipDisabled = key && key.length === 1 && key >= 'A' && key <= 'Z'
}
function showPwd() {
if (passwordType.value === 'password') {
passwordType.value = ''
} else {
passwordType.value = 'password'
}
nextTick(() => {
passwordRef.value.focus()
})
}
//登录
function handleLogin() {
loginFormRef.value.validate((valid: boolean) => {
if (valid) {
state.loading = true
user
.login(state.loginForm)
.then(() => {
router.push({ path: state.redirect || '/', query: state.otherQuery })
state.loading = false
})
.catch(() => {
state.loading = false
})
} else {
return false
}
})
}
watch(
route,
() => {
const query = route.query
if (query) {
state.redirect = query.redirect as string
state.otherQuery = getOtherQuery(query)
}
},
{
immediate: true
}
)
function getOtherQuery(query: any) {
return Object.keys(query).reduce((acc: any, cur: any) => {
if (cur !== 'redirect') {
acc[cur] = query[cur]
}
return acc
}, {})
}
</script>
<style lang="scss">
$bg: #a7c3e6;
$light_gray: #fff;
$cursor: #fff;
/* reset element-ui css */
.login-container {
.title-container {
position: relative;
.title {
font-size: 26px;
color: $light_gray;
margin: 0px auto 40px auto;
text-align: center;
font-weight: bold;
}
.set-language {
color: #fff;
position: absolute;
top: 3px;
font-size: 18px;
right: 0px;
cursor: pointer;
}
}
.el-input {
display: inline-block;
height: 36px;
width: 85%;
.el-input__wrapper {
padding: 0;
background: transparent;
box-shadow: none;
width: 100%;
.el-input__inner {
background: transparent;
border: 0px;
-webkit-appearance: none;
border-radius: 0px;
color: $light_gray;
height: 36px;
caret-color: $cursor;
&:-webkit-autofill {
box-shadow: 0 0 0px 1000px $bg inset !important;
-webkit-text-fill-color: $cursor !important;
}
}
}
}
.el-input__inner {
&:hover {
border-color: var(--el-input-hover-border, var(--el-border-color-hover));
box-shadow: none;
}
box-shadow: none;
}
.el-form-item {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.1);
border-radius: 5px;
color: #454545;
}
.copyright {
width: 100%;
position: absolute;
bottom: 0;
font-size: 12px;
text-align: center;
color: #cccccc;
}
}
</style>
<style lang="scss" scoped>
$bg: #2d3a4b;
$dark_gray: #889aa4;
$light_gray: #eee;
.login-container {
min-height: 100%;
width: 100%;
background-color: $bg;
overflow: hidden;
.login-form {
position: relative;
width: 520px;
max-width: 100%;
padding: 160px 35px 0;
margin: 0 auto;
overflow: hidden;
}
.tips {
font-size: 14px;
color: #fff;
margin-bottom: 10px;
span {
&:first-of-type {
margin-right: 16px;
}
}
}
.svg-container {
padding: 5px 10px;
color: $dark_gray;
vertical-align: middle;
width: 30px;
display: inline-block;
}
.title-container {
position: relative;
.title {
font-size: 26px;
color: $light_gray;
margin: 0px auto 40px auto;
text-align: center;
font-weight: bold;
}
}
.show-pwd {
position: absolute;
right: 10px;
top: 7px;
font-size: 16px;
color: $dark_gray;
cursor: pointer;
user-select: none;
}
.captcha {
position: absolute;
right: 0;
top: 0;
img {
height: 42px;
cursor: pointer;
vertical-align: middle;
}
}
}
.thirdparty-button {
position: absolute;
right: 40px;
bottom: 6px;
}
@media only screen and (max-width: 470px) {
.thirdparty-button {
display: none;
}
}
</style>
!!!到这里一个管理系统最基础的部分就算完成了,如果项目创新程度比较高可以从这里开始:refreshing分支。
如果要做一个比较传统的管理后台可以选择master分支!!!
layout布局组件
传统基础布局
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2VgbQpcd-1670077065047)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/10ae7707281c49599c77000226472d56~tplv-k3u1fbpfcp-watermark.image?)]
按钮权限
1. Directive 自定义指令
// src/directive/permission/index.ts
import useStore from "@/store";
import { Directive, DirectiveBinding } from "vue";
/**
* 按钮权限校验
*/
export const hasPerm: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
// 「超级管理员」拥有所有的按钮权限
const { user } = useStore()
const roles = user.roles;
if (roles.includes('ROOT')) {
return true
}
// 「其他角色」按钮权限校验
const { value } = binding;
if (value) {
const requiredPerms = value; // DOM绑定需要的按钮权限标识
const hasPerm = user.perms.some(perm => {
return requiredPerms.includes(perm)
})
if (!hasPerm) {
el.parentNode && el.parentNode.removeChild(el);
}
} else {
throw new Error("need perms! Like v-has-perm="['sys:user:add','sys:user:edit']"");
}
}
};
2. 自定义指令全局注册
// src/main.ts
const app = createApp(App)
// 自定义指令
import * as directive from "@/directive";
Object.keys(directive).forEach(key => {
app.directive(key, (directive as { [key: string]: Directive })[key]);
});
3. 指令使用
// src/views/system/user/index.vue
<el-button v-hasPerm="['sys:user:add']">新增</el-button>
<el-button v-hasPerm="['sys:user:delete']">删除</el-button>
更多推荐
所有评论(0)