0. 实战案例
本小节,提供大家开发管理后台的功能时,最常用的普通列表、树形列表、新增与修改的表单弹窗、详情表单弹窗的实战案例。
0.1 普通列表
可参考 [系统管理 -> 岗位管理] 菜单:
为什么界面拆成列表和表单两个 Vue 文件?
每个 Vue 文件,只实现一个功能,更简洁,维护性更好,Git 代码冲突概率低。
0.2 树形列表
可参考 [系统管理 -> 部门管理] 菜单:
0.3 高性能列表
可参考 [系统管理 -> 地区管理] 菜单,对应 /src/views/system/area/index.vue
(opens new window) 列表界面
基于 Virtualized Table 虚拟化表格 (opens new window) 实现,解决一屏里超过 1000 条数据记录时,就会出现卡顿等性能问题。
0.4 详情弹窗
可参考 [基础设施 -> API 日志 -> 访问日志] 菜单,对应 /src/views/infra/apiAccessLog/ApiAccessLogDetail.vue
(opens new window) 详情弹窗
1. view 页面
在 @views
(opens new window) 目录下,每个模块对应一个目录,它的所有功能的 .vue
都放在该目录里。
一般来说,一个路由对应一个 index.vue
文件。
2. api 请求
在 @/api
(opens new window) 目录下,每个模块对应一个 index.ts
API 文件。
- API 方法:会调用
request
方法,发起对后端 RESTful API 的调用。 interface
类型:定义了 API 的请求参数和返回结果的类型,对应后端的 VO 类型。
2.1 请求封装
/src/config/axios/index.ts
(opens new window) 基于 axios (opens new window) 封装,统一处理 GET、POST 方法的请求参数、请求头,以及错误提示信息等。
2.1.1 创建 axios 实例
baseURL
基础路径 timeout
超时时间,默认为 30000 毫秒
实现代码 /src/config/axios/service.ts
1 2 3 4 5 6 7 8 9 10
| import axios from 'axios'
const { result_code, base_url, request_timeout } = config
const service: AxiosInstance = axios.create({ baseURL: base_url, timeout: request_timeout, withCredentials: false })
|
2.1.2 Request 拦截器
- 【重点】
Authorization
、tenant-id
请求头 - GET 请求参数的拼接
实现代码 /src/config/axios/service.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
| import axios, { AxiosInstance, AxiosRequestHeaders, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'
import { getAccessToken, getTenantId } from '@/utils/auth'
const tenantEnable = import.meta.env.VITE_APP_TENANT_ENABLE
service.interceptors.request.use( (config: InternalAxiosRequestConfig) => { let isToken = (config!.headers || {}).isToken === false whiteList.some((v) => { if (config.url) { config.url.indexOf(v) > -1 return (isToken = false) } }) if (getAccessToken() && !isToken) { (config as Recordable).headers.Authorization = 'Bearer ' + getAccessToken() } if (tenantEnable && tenantEnable === 'true') { const tenantId = getTenantId() if (tenantId) (config as Recordable).headers['tenant-id'] = tenantId } const params = config.params || {} const data = config.data || false if ( config.method?.toUpperCase() === 'POST' && (config.headers as AxiosRequestHeaders)['Content-Type'] === 'application/x-www-form-urlencoded' ) { config.data = qs.stringify(data) } if (config.method?.toUpperCase() === 'GET' && params) { let url = config.url + '?' for (const propName of Object.keys(params)) { const value = params[propName] if (value !== void 0 && value !== null && typeof value !== 'undefined') { if (typeof value === 'object') { for (const val of Object.keys(value)) { const params = propName + '[' + val + ']' const subPart = encodeURIComponent(params) + '=' url += subPart + encodeURIComponent(value[val]) + '&' } } else { url += `${propName}=${encodeURIComponent(value)}&` } } } url = url.slice(0, -1) config.params = {} config.url = url } return config }, (error: AxiosError) => { console.log(error) Promise.reject(error) } )
|
2.1.3 Response 拦截器
- 访问令牌 AccessToken 过期时,使用刷新令牌 RefreshToken 刷新,获得新的访问令牌
- 刷新令牌失败(过期)时,跳回首页进行登录
- 请求失败,Message 错误提示
实现代码 /src/config/axios/service.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158
| import axios, { AxiosInstance, AxiosRequestHeaders, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios' import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import { getAccessToken, getRefreshToken, removeToken, setToken } from '@/utils/auth'
const ignoreMsgs = [ '无效的刷新令牌', '刷新令牌已过期' ]
export const isRelogin = { show: false } import errorCode from './errorCode'
import { resetRouter } from '@/router' import { useCache } from '@/hooks/web/useCache'
service.interceptors.response.use( async (response: AxiosResponse<any>) => { const { data } = response const config = response.config if (!data) { throw new Error() } const { t } = useI18n() const code = data.code || result_code if ( response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer' ) { return response.data } const msg = data.msg || errorCode[code] || errorCode['default'] if (ignoreMsgs.indexOf(msg) !== -1) { return Promise.reject(msg) } else if (code === 401) { if (!isRefreshToken) { isRefreshToken = true if (!getRefreshToken()) { return handleAuthorized() } try { const refreshTokenRes = await refreshToken() setToken((await refreshTokenRes).data.data) config.headers!.Authorization = 'Bearer ' + getAccessToken() requestList.forEach((cb: any) => { cb() }) requestList = [] return service(config) } catch (e) { requestList.forEach((cb: any) => { cb() }) return handleAuthorized() } finally { requestList = [] isRefreshToken = false } } else { return new Promise((resolve) => { requestList.push(() => { config.headers!.Authorization = 'Bearer ' + getAccessToken() resolve(service(config)) }) }) } } else if (code === 500) { ElMessage.error(t('sys.api.errMsg500')) return Promise.reject(new Error(msg)) } else if (code === 901) { ElMessage.error({ offset: 300, dangerouslyUseHTMLString: true, message: '<div>' + t('sys.api.errMsg901') + '</div>' + '<div> </div>' + '<div>参考 https://doc.iocoder.cn/ 教程</div>' + '<div> </div>' + '<div>5 分钟搭建本地环境</div>' }) return Promise.reject(new Error(msg)) } else if (code !== 200) { if (msg === '无效的刷新令牌') { console.log(msg) } else { ElNotification.error({ title: msg }) } return Promise.reject('error') } else { return data } }, (error: AxiosError) => { console.log('err' + error) let { message } = error const { t } = useI18n() if (message === 'Network Error') { message = t('sys.api.errorMessage') } else if (message.includes('timeout')) { message = t('sys.api.apiTimeoutMessage') } else if (message.includes('Request failed with status code')) { message = t('sys.api.apiRequestFailed') + message.substr(message.length - 3) } ElMessage.error(message) return Promise.reject(error) } )
const refreshToken = async () => { axios.defaults.headers.common['tenant-id'] = getTenantId() return await axios.post(base_url + '/system/auth/refresh-token?refreshToken=' + getRefreshToken()) } const handleAuthorized = () => { const { t } = useI18n() if (!isRelogin.show) { isRelogin.show = true ElMessageBox.confirm(t('sys.api.timeoutMessage'), t('common.confirmTitle'), { confirmButtonText: t('login.relogin'), cancelButtonText: t('common.cancel'), type: 'warning' }) .then(() => { const { wsCache } = useCache() resetRouter() wsCache.clear() removeToken() isRelogin.show = false window.location.href = '/' }) .catch(() => { isRelogin.show = false }) } return Promise.reject(t('sys.api.timeoutMessage')) }
|
2.2 交互流程
一个完整的前端 UI 交互到服务端处理流程,如下图所示:
继续以 [系统管理 -> 岗位管理] 菜单为例,查看它是如何读取岗位列表的。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| import request from '@/config/axios'
export const getPostPage = async (params: PageParam) => { return await request.get({ url: '/system/post/page', params }) }
<script setup lang="tsx"> const loading = ref(true) const total = ref(0) const list = ref([]) const queryParams = reactive({ pageNo: 1, pageSize: 10, code: '', name: '', status: undefined })
const getList = async () => { loading.value = true try { const data = await PostApi.getPostPage(queryParams) list.value = data.list total.value = data.total } finally { loading.value = false } } </script>
|
3. component
组件
3.1 全局组件
在
@/components
(
opens new window) 目录下,实现全局组件,被所有模块所公用。
例如说,富文本编辑器、各种各搜索组件、封装的分页组件等等。
3.2 模块内组件
每个模块的业务组件,可实现在 views
目录下,自己模块的目录的 components
目录下,避免单个 .vue
文件过大,降低维护成功。
例如说,
@/views/pay/app/components/xxx.vue
:
4. style 样式
①
在
@/styles
(
opens new window) 目录下,实现全局
样式,被所有页面所公用。
② 每个 .vue
页面,可在 <style />
标签中添加样式,注意需要添加 scoped
表示只作用在当前页面里,避免造成全局的样式污染。
更多也可以看看如下两篇文档:
5.
项目规范
可参考
《vue-element-plus-admin ——
项目规范》 (
opens new window) 文档。