第11章 - 前端Vue项目详解
第11章 - 前端Vue项目详解
11.1 项目概述
11.1.1 技术栈
RuoYi-Cloud前端项目(ruoyi-ui)基于Vue 2.x + Element UI构建:
| 技术 | 版本 | 说明 |
|---|---|---|
| Vue.js | 2.6.x | 渐进式JavaScript框架 |
| Vue Router | 3.x | 路由管理 |
| Vuex | 3.x | 状态管理 |
| Element UI | 2.15.x | UI组件库 |
| Axios | 0.24.x | HTTP请求库 |
| JS-Cookie | 3.x | Cookie操作 |
| Nprogress | 0.2.x | 进度条 |
| Echarts | 5.x | 图表库 |
11.1.2 项目结构
ruoyi-ui/
├── public/ # 静态资源
│ ├── favicon.ico # 网站图标
│ └── index.html # HTML模板
├── src/
│ ├── api/ # API接口
│ │ ├── login.js # 登录接口
│ │ ├── menu.js # 菜单接口
│ │ └── system/ # 系统管理接口
│ ├── assets/ # 静态资源
│ │ ├── icons/ # SVG图标
│ │ ├── images/ # 图片
│ │ └── styles/ # 全局样式
│ ├── components/ # 公共组件
│ │ ├── Breadcrumb/ # 面包屑
│ │ ├── DictTag/ # 字典标签
│ │ ├── Editor/ # 富文本编辑器
│ │ ├── FileUpload/ # 文件上传
│ │ ├── Hamburger/ # 折叠按钮
│ │ ├── HeaderSearch/ # 搜索
│ │ ├── IconSelect/ # 图标选择
│ │ ├── ImagePreview/ # 图片预览
│ │ ├── ImageUpload/ # 图片上传
│ │ ├── Pagination/ # 分页
│ │ ├── PanThumb/ # 头像
│ │ ├── ParentView/ # 父视图
│ │ ├── RightPanel/ # 右侧面板
│ │ ├── RightToolbar/ # 右侧工具栏
│ │ ├── Screenfull/ # 全屏
│ │ ├── SizeSelect/ # 尺寸选择
│ │ ├── SvgIcon/ # SVG图标
│ │ └── TopNav/ # 顶部导航
│ ├── directive/ # 自定义指令
│ │ ├── dialog/ # 弹窗拖拽
│ │ ├── hasPermi.js # 权限指令
│ │ └── hasRole.js # 角色指令
│ ├── layout/ # 布局组件
│ │ ├── components/ # 布局子组件
│ │ └── index.vue # 主布局
│ ├── plugins/ # 插件
│ │ ├── auth.js # 权限验证
│ │ ├── cache.js # 缓存
│ │ ├── download.js # 下载
│ │ ├── modal.js # 模态框
│ │ └── tab.js # 标签页
│ ├── router/ # 路由配置
│ │ └── index.js # 路由入口
│ ├── store/ # Vuex状态管理
│ │ ├── modules/ # 模块
│ │ ├── getters.js # 计算属性
│ │ └── index.js # Store入口
│ ├── utils/ # 工具类
│ │ ├── auth.js # Token操作
│ │ ├── dict.js # 字典工具
│ │ ├── errorCode.js # 错误码
│ │ ├── jsencrypt.js # 加密
│ │ ├── permission.js # 权限工具
│ │ ├── request.js # Axios封装
│ │ ├── ruoyi.js # 通用工具
│ │ ├── scroll-to.js # 滚动
│ │ └── validate.js # 验证
│ ├── views/ # 页面视图
│ │ ├── components/ # 页面组件
│ │ ├── dashboard/ # 首页
│ │ ├── error/ # 错误页面
│ │ ├── login.vue # 登录页
│ │ ├── monitor/ # 监控
│ │ ├── redirect.vue # 重定向
│ │ ├── register.vue # 注册页
│ │ ├── system/ # 系统管理
│ │ └── tool/ # 系统工具
│ ├── App.vue # 根组件
│ ├── main.js # 入口文件
│ ├── permission.js # 路由守卫
│ └── settings.js # 系统配置
├── .env.development # 开发环境配置
├── .env.production # 生产环境配置
├── babel.config.js # Babel配置
├── package.json # 依赖配置
└── vue.config.js # Vue CLI配置
11.2 核心配置
11.2.1 环境变量配置
.env.development(开发环境)
# 页面标题
VUE_APP_TITLE = 若依管理系统
# 开发环境配置
ENV = 'development'
# 若依管理系统/开发环境
VUE_APP_BASE_API = '/dev-api'
.env.production(生产环境)
# 页面标题
VUE_APP_TITLE = 若依管理系统
# 生产环境配置
ENV = 'production'
# 若依管理系统/生产环境
VUE_APP_BASE_API = '/prod-api'
11.2.2 Vue配置文件
// vue.config.js
const CompressionPlugin = require('compression-webpack-plugin')
module.exports = {
publicPath: process.env.NODE_ENV === "production" ? "/" : "/",
outputDir: 'dist',
assetsDir: 'static',
lintOnSave: process.env.NODE_ENV === 'development',
productionSourceMap: false,
devServer: {
host: '0.0.0.0',
port: 80,
open: true,
proxy: {
[process.env.VUE_APP_BASE_API]: {
target: `http://localhost:8080`,
changeOrigin: true,
pathRewrite: {
['^' + process.env.VUE_APP_BASE_API]: ''
}
}
},
disableHostCheck: true
},
configureWebpack: {
name: process.env.VUE_APP_TITLE,
resolve: {
alias: {
'@': resolve('src')
}
},
plugins: [
new CompressionPlugin({
cache: false,
test: /\.(js|css|html|jpe?g|png|gif|svg)?$/i,
filename: '[path].gz[query]',
algorithm: 'gzip',
minRatio: 0.8
})
]
},
chainWebpack(config) {
config.plugins.delete('preload')
config.plugins.delete('prefetch')
// SVG图标配置
config.module
.rule('svg')
.exclude.add(resolve('src/assets/icons'))
.end()
config.module
.rule('icons')
.test(/\.svg$/)
.include.add(resolve('src/assets/icons'))
.end()
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({
symbolId: 'icon-[name]'
})
.end()
}
}
11.2.3 系统设置
// settings.js
module.exports = {
// 网页标题
title: process.env.VUE_APP_TITLE,
// 侧边栏主题 深色主题theme-dark,浅色主题theme-light
sideTheme: 'theme-dark',
// 是否系统布局配置
showSettings: false,
// 是否显示顶部导航
topNav: false,
// 是否显示 tagsView
tagsView: true,
// 是否固定头部
fixedHeader: false,
// 是否显示logo
sidebarLogo: true,
// 是否显示动态标题
dynamicTitle: false,
// 错误日志
errorLog: 'production'
}
11.3 路由与权限
11.3.1 路由配置
// router/index.js
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
// 公共路由
export const constantRoutes = [
{
path: '/redirect',
component: Layout,
hidden: true,
children: [
{
path: '/redirect/:path(.*)',
component: () => import('@/views/redirect')
}
]
},
{
path: '/login',
component: () => import('@/views/login'),
hidden: true
},
{
path: '/register',
component: () => import('@/views/register'),
hidden: true
},
{
path: '/404',
component: () => import('@/views/error/404'),
hidden: true
},
{
path: '/401',
component: () => import('@/views/error/401'),
hidden: true
},
{
path: '',
component: Layout,
redirect: 'index',
children: [
{
path: 'index',
component: () => import('@/views/index'),
name: 'Index',
meta: { title: '首页', icon: 'dashboard', affix: true }
}
]
},
{
path: '/user',
component: Layout,
hidden: true,
redirect: 'noredirect',
children: [
{
path: 'profile',
component: () => import('@/views/system/user/profile/index'),
name: 'Profile',
meta: { title: '个人中心', icon: 'user' }
}
]
}
]
// 动态路由,根据用户权限加载
export const dynamicRoutes = [
{
path: '/system/user-auth',
component: Layout,
hidden: true,
permissions: ['system:user:edit'],
children: [
{
path: 'role/:userId(\\d+)',
component: () => import('@/views/system/user/authRole'),
name: 'AuthRole',
meta: { title: '分配角色', activeMenu: '/system/user' }
}
]
},
// 更多动态路由...
]
export default new Router({
mode: 'history',
scrollBehavior: () => ({ y: 0 }),
routes: constantRoutes
})
11.3.2 路由守卫
// permission.js
import router from './router'
import store from './store'
import { Message } from 'element-ui'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { getToken } from '@/utils/auth'
import { isRelogin } from '@/utils/request'
NProgress.configure({ showSpinner: false })
const whiteList = ['/login', '/register']
router.beforeEach((to, from, next) => {
NProgress.start()
if (getToken()) {
to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
if (to.path === '/login') {
next({ path: '/' })
NProgress.done()
} else if (whiteList.indexOf(to.path) !== -1) {
next()
} else {
if (store.getters.roles.length === 0) {
isRelogin.show = true
// 判断当前用户是否已拉取完user_info信息
store.dispatch('GetInfo').then(() => {
isRelogin.show = false
store.dispatch('GenerateRoutes').then(accessRoutes => {
// 根据roles权限生成可访问的路由表
router.addRoutes(accessRoutes)
next({ ...to, replace: true })
})
}).catch(err => {
store.dispatch('LogOut').then(() => {
Message.error(err)
next({ path: '/' })
})
})
} else {
next()
}
}
} else {
// 没有token
if (whiteList.indexOf(to.path) !== -1) {
// 在免登录白名单,直接进入
next()
} else {
next(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
NProgress.done()
}
}
})
router.afterEach(() => {
NProgress.done()
})
11.3.3 权限指令
// directive/hasPermi.js
import store from '@/store'
export default {
inserted(el, binding, vnode) {
const { value } = binding
const all_permission = "*:*:*"
const permissions = store.getters && store.getters.permissions
if (value && value instanceof Array && value.length > 0) {
const permissionFlag = value
const hasPermissions = permissions.some(permission => {
return all_permission === permission || permissionFlag.includes(permission)
})
if (!hasPermissions) {
el.parentNode && el.parentNode.removeChild(el)
}
} else {
throw new Error(`请设置操作权限标签值`)
}
}
}
// 使用示例
// <el-button v-hasPermi="['system:user:add']">新增</el-button>
11.4 Axios封装
11.4.1 请求封装
// utils/request.js
import axios from 'axios'
import { Notification, MessageBox, Message, Loading } from 'element-ui'
import store from '@/store'
import { getToken } from '@/utils/auth'
import errorCode from '@/utils/errorCode'
import { tansParams, blobValidate } from "@/utils/ruoyi"
import cache from '@/plugins/cache'
import { saveAs } from 'file-saver'
let downloadLoadingInstance
// 是否显示重新登录
export let isRelogin = { show: false }
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 10000
})
// request拦截器
service.interceptors.request.use(config => {
// 是否需要设置 token
const isToken = (config.headers || {}).isToken === false
// 是否需要防止数据重复提交
const isRepeatSubmit = (config.headers || {}).repeatSubmit === false
if (getToken() && !isToken) {
config.headers['Authorization'] = 'Bearer ' + getToken()
}
// get请求映射params参数
if (config.method === 'get' && config.params) {
let url = config.url + '?' + tansParams(config.params)
url = url.slice(0, -1)
config.params = {}
config.url = url
}
if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {
const requestObj = {
url: config.url,
data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data,
time: new Date().getTime()
}
const sessionObj = cache.session.getJSON('sessionObj')
if (sessionObj === undefined || sessionObj === null || sessionObj === '') {
cache.session.setJSON('sessionObj', requestObj)
} else {
const s_url = sessionObj.url
const s_data = sessionObj.data
const s_time = sessionObj.time
const interval = 1000
if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url) {
const message = '数据正在处理,请勿重复提交'
console.warn(`[${s_url}]: ` + message)
return Promise.reject(new Error(message))
} else {
cache.session.setJSON('sessionObj', requestObj)
}
}
}
return config
}, error => {
console.log(error)
Promise.reject(error)
})
// 响应拦截器
service.interceptors.response.use(res => {
// 未设置状态码则默认成功状态
const code = res.data.code || 200
// 获取错误信息
const msg = errorCode[code] || res.data.msg || errorCode['default']
// 二进制数据则直接返回
if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {
return res.data
}
if (code === 401) {
if (!isRelogin.show) {
isRelogin.show = true
MessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
isRelogin.show = false
store.dispatch('LogOut').then(() => {
location.href = '/index'
})
}).catch(() => {
isRelogin.show = false
})
}
return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
} else if (code === 500) {
Message({ message: msg, type: 'error' })
return Promise.reject(new Error(msg))
} else if (code === 601) {
Message({ message: msg, type: 'warning' })
return Promise.reject(new Error(msg))
} else if (code !== 200) {
Notification.error({ title: msg })
return Promise.reject('error')
} else {
return res.data
}
}, error => {
console.log('err' + error)
let { message } = error
if (message == "Network Error") {
message = "后端接口连接异常"
} else if (message.includes("timeout")) {
message = "系统接口请求超时"
} else if (message.includes("Request failed with status code")) {
message = "系统接口" + message.substr(message.length - 3) + "异常"
}
Message({ message: message, type: 'error', duration: 5 * 1000 })
return Promise.reject(error)
})
export default service
11.4.2 API接口定义
// api/system/user.js
import request from '@/utils/request'
import { parseStrEmpty } from "@/utils/ruoyi"
// 查询用户列表
export function listUser(query) {
return request({
url: '/system/user/list',
method: 'get',
params: query
})
}
// 查询用户详细
export function getUser(userId) {
return request({
url: '/system/user/' + parseStrEmpty(userId),
method: 'get'
})
}
// 新增用户
export function addUser(data) {
return request({
url: '/system/user',
method: 'post',
data: data
})
}
// 修改用户
export function updateUser(data) {
return request({
url: '/system/user',
method: 'put',
data: data
})
}
// 删除用户
export function delUser(userId) {
return request({
url: '/system/user/' + userId,
method: 'delete'
})
}
// 用户密码重置
export function resetUserPwd(userId, password) {
const data = {
userId,
password
}
return request({
url: '/system/user/resetPwd',
method: 'put',
data: data
})
}
// 用户状态修改
export function changeUserStatus(userId, status) {
const data = {
userId,
status
}
return request({
url: '/system/user/changeStatus',
method: 'put',
data: data
})
}
// 导出用户
export function exportUser(query) {
return request({
url: '/system/user/export',
method: 'post',
data: query,
responseType: 'blob'
})
}
11.5 Vuex状态管理
11.5.1 用户模块
// store/modules/user.js
import { login, logout, getInfo } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth'
const user = {
state: {
token: getToken(),
id: '',
name: '',
avatar: '',
roles: [],
permissions: []
},
mutations: {
SET_TOKEN: (state, token) => {
state.token = token
},
SET_ID: (state, id) => {
state.id = id
},
SET_NAME: (state, name) => {
state.name = name
},
SET_AVATAR: (state, avatar) => {
state.avatar = avatar
},
SET_ROLES: (state, roles) => {
state.roles = roles
},
SET_PERMISSIONS: (state, permissions) => {
state.permissions = permissions
}
},
actions: {
// 登录
Login({ commit }, userInfo) {
const username = userInfo.username.trim()
const password = userInfo.password
const code = userInfo.code
const uuid = userInfo.uuid
return new Promise((resolve, reject) => {
login(username, password, code, uuid).then(res => {
setToken(res.token)
commit('SET_TOKEN', res.token)
resolve()
}).catch(error => {
reject(error)
})
})
},
// 获取用户信息
GetInfo({ commit, state }) {
return new Promise((resolve, reject) => {
getInfo().then(res => {
const user = res.user
const avatar = (user.avatar == "" || user.avatar == null) ?
require("@/assets/images/profile.jpg") :
process.env.VUE_APP_BASE_API + user.avatar
if (res.roles && res.roles.length > 0) {
commit('SET_ROLES', res.roles)
commit('SET_PERMISSIONS', res.permissions)
} else {
commit('SET_ROLES', ['ROLE_DEFAULT'])
}
commit('SET_ID', user.userId)
commit('SET_NAME', user.userName)
commit('SET_AVATAR', avatar)
resolve(res)
}).catch(error => {
reject(error)
})
})
},
// 退出系统
LogOut({ commit, state }) {
return new Promise((resolve, reject) => {
logout(state.token).then(() => {
commit('SET_TOKEN', '')
commit('SET_ROLES', [])
commit('SET_PERMISSIONS', [])
removeToken()
resolve()
}).catch(error => {
reject(error)
})
})
}
}
}
export default user
11.5.2 权限模块
// store/modules/permission.js
import { constantRoutes, dynamicRoutes } from '@/router'
import { getRouters } from '@/api/menu'
import Layout from '@/layout/index'
import ParentView from '@/components/ParentView'
import InnerLink from '@/layout/components/InnerLink'
const permission = {
state: {
routes: [],
addRoutes: [],
defaultRoutes: [],
topbarRouters: [],
sidebarRouters: []
},
mutations: {
SET_ROUTES: (state, routes) => {
state.addRoutes = routes
state.routes = constantRoutes.concat(routes)
},
SET_DEFAULT_ROUTES: (state, routes) => {
state.defaultRoutes = constantRoutes.concat(routes)
},
SET_TOPBAR_ROUTES: (state, routes) => {
state.topbarRouters = routes
},
SET_SIDEBAR_ROUTERS: (state, routes) => {
state.sidebarRouters = routes
}
},
actions: {
// 生成路由
GenerateRoutes({ commit }) {
return new Promise(resolve => {
getRouters().then(res => {
const sdata = JSON.parse(JSON.stringify(res.data))
const rdata = JSON.parse(JSON.stringify(res.data))
const defaultData = JSON.parse(JSON.stringify(res.data))
const sidebarRoutes = filterAsyncRouter(sdata)
const rewriteRoutes = filterAsyncRouter(rdata, false, true)
const defaultRoutes = filterAsyncRouter(defaultData)
const asyncRoutes = filterDynamicRoutes(dynamicRoutes)
asyncRoutes.forEach(route => {
router.addRoute(route)
})
commit('SET_ROUTES', rewriteRoutes)
commit('SET_SIDEBAR_ROUTERS', constantRoutes.concat(sidebarRoutes))
commit('SET_DEFAULT_ROUTES', sidebarRoutes)
commit('SET_TOPBAR_ROUTES', defaultRoutes)
resolve(rewriteRoutes)
})
})
}
}
}
// 遍历后台传来的路由字符串,转换为组件对象
function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
return asyncRouterMap.filter(route => {
if (type && route.children) {
route.children = filterChildren(route.children)
}
if (route.component) {
if (route.component === 'Layout') {
route.component = Layout
} else if (route.component === 'ParentView') {
route.component = ParentView
} else if (route.component === 'InnerLink') {
route.component = InnerLink
} else {
route.component = loadView(route.component)
}
}
if (route.children != null && route.children && route.children.length) {
route.children = filterAsyncRouter(route.children, route, type)
} else {
delete route['children']
delete route['redirect']
}
return true
})
}
export const loadView = (view) => {
if (process.env.NODE_ENV === 'development') {
return (resolve) => require([`@/views/${view}`], resolve)
} else {
// 使用 import 实现生产环境的路由懒加载
return () => import(`@/views/${view}`)
}
}
export default permission
11.6 常用组件
11.6.1 字典标签组件
<!-- components/DictTag/index.vue -->
<template>
<div>
<template v-for="(item, index) in options">
<template v-if="values.includes(item.value)">
<span
v-if="(item.elTagType == 'default' || item.elTagType == '') && (item.elTagClass == '' || item.elTagClass == null)"
:key="item.value"
:index="index"
:class="item.elTagClass"
>{{ item.label + " " }}</span>
<el-tag
v-else
:disable-transitions="true"
:key="item.value + ''"
:index="index"
:type="item.elTagType === 'primary' ? '' : item.elTagType"
:class="item.elTagClass"
>{{ item.label + " " }}</el-tag>
</template>
</template>
<template v-if="unmatch && showValue">
{{ unmatchArray | handleArray }}
</template>
</div>
</template>
<script>
export default {
name: "DictTag",
props: {
options: {
type: Array,
default: null,
},
value: [Number, String, Array],
showValue: {
type: Boolean,
default: true,
},
},
computed: {
values() {
if (this.value !== null && typeof this.value !== 'undefined') {
return Array.isArray(this.value) ? this.value : [String(this.value)]
} else {
return []
}
},
unmatch() {
this.unmatchArray = []
if (this.value !== null && typeof this.value !== 'undefined') {
this.values.forEach(value => {
let unmatch = true
this.options.forEach(v => {
if (v.value === value) {
unmatch = false
}
})
if (unmatch) {
this.unmatchArray.push(value)
}
})
}
return this.unmatchArray.length
}
},
filters: {
handleArray(array) {
if (array.length === 0) return ''
return array.reduce((pre, cur) => {
return pre + ' ' + cur
})
}
}
}
</script>
11.6.2 分页组件
<!-- components/Pagination/index.vue -->
<template>
<div :class="{ 'hidden': hidden }" class="pagination-container">
<el-pagination
:background="background"
:current-page.sync="currentPage"
:page-size.sync="pageSize"
:layout="layout"
:page-sizes="pageSizes"
:pager-count="pagerCount"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<script>
import { scrollTo } from '@/utils/scroll-to'
export default {
name: 'Pagination',
props: {
total: {
required: true,
type: Number
},
page: {
type: Number,
default: 1
},
limit: {
type: Number,
default: 20
},
pageSizes: {
type: Array,
default() {
return [10, 20, 30, 50]
}
},
pagerCount: {
type: Number,
default: document.body.clientWidth < 992 ? 5 : 7
},
layout: {
type: String,
default: 'total, sizes, prev, pager, next, jumper'
},
background: {
type: Boolean,
default: true
},
autoScroll: {
type: Boolean,
default: true
},
hidden: {
type: Boolean,
default: false
}
},
computed: {
currentPage: {
get() {
return this.page
},
set(val) {
this.$emit('update:page', val)
}
},
pageSize: {
get() {
return this.limit
},
set(val) {
this.$emit('update:limit', val)
}
}
},
methods: {
handleSizeChange(val) {
if (this.currentPage * val > this.total) {
this.currentPage = 1
}
this.$emit('pagination', { page: this.currentPage, limit: val })
if (this.autoScroll) {
scrollTo(0, 800)
}
},
handleCurrentChange(val) {
this.$emit('pagination', { page: val, limit: this.pageSize })
if (this.autoScroll) {
scrollTo(0, 800)
}
}
}
}
</script>
11.7 小结
本章详细介绍了RuoYi-Cloud前端Vue项目,包括:
- 项目结构:目录组织和文件说明
- 核心配置:环境变量、Vue配置、系统设置
- 路由权限:动态路由、路由守卫、权限指令
- Axios封装:请求拦截、响应处理、API定义
- Vuex状态管理:用户模块、权限模块
- 常用组件:字典标签、分页组件
掌握前端项目结构是进行二次开发的基础,理解这些核心模块可以帮助你更好地定制和扩展前端功能。

浙公网安备 33010602011771号