请求层
请求层
业务用途
请求层是前端与后端交互的统一通道。本平台在 frontend/src/utils/request.ts 中对 axios 做了一层封装,实现:
- 统一
baseURL: /api,配合 Vite 代理转发到后端 52856 端口。 - 请求拦截器自动注入 JWT Token 与租户 ID。
- 响应拦截器统一解包后端
Result<T>结构(code === 200才返回data),自动弹出错误提示,401 自动跳登录。 - 二进制响应(文件下载)跳过 JSON 解包。
- 响应数据中的 ISO 时间字符串自动格式化为
yyyy-MM-dd HH:mm:ss。 - 封装
request.get/post/put/delete四个方法,智能处理params参数。
涉及文件
frontend/src/utils/request.ts-- axios 封装(核心)frontend/src/store/user.ts-- 提供 Token / 租户 ID /clearSessionfrontend/src/router/index.ts-- 401 时跳转登录页frontend/vite.config.ts--/api代理配置frontend/src/api/auth.ts-- 登录接口定义
实现机制
axios 实例创建
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import router from '@/router'
const service = axios.create({
baseURL: '/api', // 所有请求自动带 /api 前缀
timeout: 120000, // 超时 120 秒(适配大文件 / 复杂报表)
headers: {
'Content-Type': 'application/json;charset=utf-8'
}
})为什么 baseURL 是 /api
后端 Controller 的路径本身就带 /api 前缀(如 /api/system/user/list)。前端 baseURL: '/api' 加上接口路径(如 /system/user/list)拼出 /api/system/user/list,Vite 代理匹配 /api 前缀原样转发到 http://127.0.0.1:52856/api/system/user/list,前后端路径自然对齐。详见 。
请求拦截器:Token 与租户注入
service.interceptors.request.use(
(config) => {
const userStore = useUserStore()
// 注入 JWT Token
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
// 注入租户 ID(多租户场景)
if (userStore.tenantId) {
config.headers['Tenant-Id'] = userStore.tenantId
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)每次请求都会从 Pinia Store 读取 token 和 tenantId,注入到请求头:
Authorization: Bearer <token>-- 后端据此鉴权。Tenant-Id: <租户ID>-- 多租户数据隔离,后端根据此头过滤当前租户的数据。
为什么用 useUserStore() 而不是直接读 localStorage
useUserStore() 返回的是 Pinia 响应式状态,与 localStorage 同步(Store 的 setter 会同时写 localStorage)。用 Store 的好处是:如果未来 Token 刷新逻辑更新了 Store 状态,请求拦截器能立即拿到最新值;而直接读 localStorage 在某些异步场景下可能读到旧值。
响应拦截器:统一解包 Result
后端统一返回 Result<T> 结构:
{
"code": 200,
"data": { ... },
"message": "success"
}响应拦截器负责解包:
service.interceptors.response.use(
(response) => {
// ① 二进制响应(文件下载)直接返回,不走 JSON code 判断
const responseType = response.config.responseType
if (responseType === 'blob' || responseType === 'arraybuffer') {
return response.data
}
const res = response.data
// ② code 非 200:弹错误提示,401 跳登录
if (res.code !== 200) {
const isLoginRequest = response.config.url?.includes('/auth/login')
const message = res.message || (isLoginRequest ? '账号密码错误' : '请求失败')
ElMessage.error(message)
// 401 = 身份失效,跳登录页;403 = 已登录但权限不足,只提示不踢出
if (res.code === 401 && !isLoginRequest) {
redirectToLogin()
}
return Promise.reject(new Error(message))
}
// ③ code === 200:格式化时间字段,返回完整 res(含 code/data/message)
formatDateTimeFields(res.data)
return res
},
(error) => {
// ④ HTTP 层错误(非 2xx)
console.error('响应错误:', error)
let message = '网络错误,请稍后重试'
const isLoginRequest = error.config?.url?.includes('/auth/login')
if (error.response) {
switch (error.response.status) {
case 401: message = error.response.data?.message || (isLoginRequest ? '账号密码错误' : '未授权,请重新登录'); break
case 403: message = error.response.data?.message || '无访问权限'; break
case 404: message = '请求地址不存在'; break
case 500: message = '服务器内部错误'; break
default: message = error.response.data?.message || '请求失败'
}
}
ElMessage.error(message)
// 401 且非登录请求:跳登录页
if (error.response?.status === 401 && !isLoginRequest) {
redirectToLogin()
}
return Promise.reject(error)
}
)两层 401 处理
401 有两种来源,分别处理:
| 来源 | 触发场景 | 处理 |
|---|---|---|
响应体 res.code === 401 | 后端业务层返回 401(Token 无效 / 过期) | redirectToLogin() |
HTTP 状态码 error.response.status === 401 | 网关 / Filter 层直接拒绝 | redirectToLogin() |
两者都排除了登录请求本身(/auth/login),避免登录失败被误判为「身份失效」循环跳转。
401 与 403 的区别
- 401(Unauthorized):身份失效(Token 过期 / 无效),需要重新登录。
redirectToLogin()清除登录态并跳登录页。 - 403(Forbidden):已登录但无权限访问该资源。只弹错误提示,不踢出登录,用户仍可访问其他有权限的页面。
redirectToLogin 防抖
let redirectingToLogin = false
const redirectToLogin = () => {
if (redirectingToLogin) return // 防止并发 401 触发多次跳转
redirectingToLogin = true
const userStore = useUserStore()
userStore.clearSession() // 清除 Pinia + localStorage
if (router.currentRoute.value.path !== '/login') {
router.replace({
path: '/login',
query: { redirect: router.currentRoute.value.fullPath }
})
}
setTimeout(() => { redirectingToLogin = false }, 500)
}并发请求同时收到 401 时,redirectingToLogin 标志位确保只跳转一次,500ms 后重置。
时间字段自动格式化
后端返回的时间可能是 ISO 格式 2024-01-15T14:30:00,前端展示需要 2024-01-15 14:30:00。拦截器递归处理响应数据:
const dateTimePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/
const formatDateTimeFields = (value: any): any => {
if (Array.isArray(value)) {
value.forEach((item, index) => { value[index] = formatDateTimeFields(item) })
return value
}
if (value && typeof value === 'object') {
Object.keys(value).forEach((key) => { value[key] = formatDateTimeFields(value[key]) })
return value
}
// 匹配 ISO 时间字符串,将 T 替换为空格,去掉毫秒
if (typeof value === 'string' && dateTimePattern.test(value)) {
return value.replace('T', ' ').split('.')[0]
}
return value
}该函数递归遍历数组与对象,把所有匹配 yyyy-MM-ddTHH:mm:ss 的字符串转为 yyyy-MM-dd HH:mm:ss。
统一封装 request 方法
拦截器返回的是 res(即 { code, data, message }),因此业务调用拿到的 Promise resolve 值就是整个 Result 对象。request.ts 进一步封装了四个方法,智能处理 params:
interface ApiResponse<T = any> {
code: number
data: T
message: string
}
export const request = {
get<T = any>(url: string, paramsOrConfig?: any, config?: any): Promise<ApiResponse<T>> {
if (config) {
return service.get(url, { ...config, params: paramsOrConfig })
}
// 如果传入的是 { params: {...} } 形式,直接用
if (paramsOrConfig && typeof paramsOrConfig === 'object'
&& Object.prototype.hasOwnProperty.call(paramsOrConfig, 'params')) {
return service.get(url, paramsOrConfig)
}
// 否则把 paramsOrConfig 当作 params
return service.get(url, { params: paramsOrConfig })
},
post<T = any>(url: string, data?: any, config?: any): Promise<ApiResponse<T>> {
return service.post(url, data, config)
},
put<T = any>(url: string, data?: any, config?: any): Promise<ApiResponse<T>> {
return service.put(url, data, config)
},
delete<T = any>(url: string, paramsOrConfig?: any, config?: any): Promise<ApiResponse<T>> {
// 同 get 的智能 params 处理
if (config) {
return service.delete(url, { ...config, params: paramsOrConfig })
}
if (paramsOrConfig && typeof paramsOrConfig === 'object'
&& Object.prototype.hasOwnProperty.call(paramsOrConfig, 'params')) {
return service.delete(url, paramsOrConfig)
}
return service.delete(url, { params: paramsOrConfig })
}
}
export default service两种 GET 调用方式
request.get 支持两种传参方式,都能正确拼到 query string:
// 方式一:直接传 params 对象
const res = await request.get('/system/user/list', { pageNum: 1, pageSize: 10 })
// 实际请求:GET /api/system/user/list?pageNum=1&pageSize=10
// 方式二:传 { params: {...} } 配置对象
const res = await request.get('/system/user/list', { params: { pageNum: 1, pageSize: 10 } })
// 效果相同get 方法通过 Object.prototype.hasOwnProperty.call(paramsOrConfig, 'params') 判断是哪种形式。
业务调用示例
frontend/src/api/dict.ts 是典型的接口模块:
import { request } from '@/utils/request'
// 获取字典类型列表
export function getDictTypeList(params: any) {
return request.get('/system/dict/type/list', { params })
}
// 根据类型获取字典数据
export function getDictDataByType(dictType: string, appId?: number | null) {
return request.get(`/system/dict/data/byType/${dictType}`, appId ? { params: { appId } } : undefined)
}
// 新增字典类型
export function addDictType(data: any) {
return request.post('/system/dict/type', data)
}业务页面调用时,直接 await 拿到 ApiResponse:
const res = await getDictTypeList({ pageNum: 1, pageSize: 10 })
// res.code === 200, res.data.records 是列表数据
tableData.value = res.data.records
total.value = res.data.total双重 /api 前缀陷阱
这是最高频的坑
baseURL 已经是 /api,所有经过 axios 实例的请求路径都不要再带 /api 前缀。否则会产生 /api/api/xxx 的双重前缀,导致 404。
错误写法
// 错误!baseURL=/api + 路径=/api/bpm/model -> 实际请求 /api/api/bpm/model -> 404
request.get('/api/bpm/model')正确写法
// 正确!baseURL=/api + 路径=/bpm/model -> 实际请求 /api/bpm/model
request.get('/bpm/model')平台所有 src/api/ 下的接口模块都遵循此规范,例如 frontend/src/api/bpm.ts:
export const bpmApi = {
getDefinitionList: (params?: any) => request.get('/bpm/definition/list', { params }),
getDefinition: (id: number) => request.get(`/bpm/definition/${id}`),
saveDefinition: (data: any) => request.post('/bpm/definition/save', data),
deployDefinition: (id: number) => request.post(`/bpm/definition/deploy/${id}`)
}何时需要写完整的 /api 路径
有些场景不走 axios 实例,这些地方必须写完整的 /api/... 路径,否则 Vite 代理匹配不到:
| 场景 | 示例 | 原因 |
|---|---|---|
el-upload 的 action | action="/api/upload" | el-upload 用自己的 XHR,不经过 axios |
window.location.href | /api/auth/sso/authorize/xxx | 浏览器导航,不经过 axios |
| AI 对话接口配置 | api: '/api/ai/form-designer-chat' | 设计器内部 fetch,不经过封装的 axios |
frontend/src/components/FormComponents/Attachment.vue 与 frontend/src/views/runtime/components/RuntimeList.vue 中的上传地址都写作:
<el-upload :action="action || '/api/upload'">frontend/src/views/login/Login.vue 中 SSO 跳转:
const handleSsoLogin = (provider: any) => {
window.location.href = `/api/auth/sso/authorize/${provider.providerCode}`
}frontend/src/views/designer/form/builder.vue 中 AI 配置:
const designerConfig = reactive({
ai: {
api: '/api/ai/form-designer-chat',
token: userStore.token || ''
}
})判断规则
只要不是通过 request.get/post/put/delete 或 service 发出的请求,就需要写完整 /api 路径;通过 request / service 发出的,路径以 / 开头但不带 /api。
操作步骤
新增一个接口模块
- 在
frontend/src/api/下创建.ts文件,例如src/api/notice.ts。 - 从
@/utils/request导入request,定义接口函数:
import { request } from '@/utils/request'
export function getNoticeList(params: any) {
return request.get('/system/notice/list', { params })
}
export function createNotice(data: any) {
return request.post('/system/notice', data)
}
export function deleteNotice(id: number) {
return request.delete(`/system/notice/${id}`)
}- 在业务页面中导入并调用:
import { getNoticeList } from '@/api/notice'
const res = await getNoticeList({ pageNum: 1, pageSize: 10 })
// res.data.records 为列表新增文件下载接口
文件下载需要拿二进制响应,调用时传入 responseType: 'blob':
export function downloadTemplate(id: number) {
return request.get(`/system/template/download/${id}`, {}, { responseType: 'blob' })
}拦截器检测到 responseType === 'blob' 会直接返回 response.data(Blob 对象),跳过 JSON 解包。
常见问题
接口返回 404 但路径看起来没错
第一步检查浏览器 Network 面板里的实际请求 URL:
- 如果是
/api/api/system/...-- 双重前缀,把接口路径里的/api去掉。 - 如果是
/system/...(没有/api) -- 检查vite.config.ts代理是否配置正确,dev server 是否重启。 - 如果 URL 正确但仍 404 -- 后端 Controller 路径是否一致,后端服务是否在 52856 端口运行。
登录接口失败后弹了两次错误提示
登录失败时,后端可能返回 HTTP 200 + code !== 200,也可能返回 HTTP 401。拦截器对两种情况都做了处理,但通过 isLoginRequest 判断排除了登录请求的 401 跳转。如果仍出现重复提示,检查后端登录接口是否同时返回了非 200 状态码和业务错误码。
时间字段显示为 ISO 格式
formatDateTimeFields 只处理匹配 ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} 的字符串。如果后端返回的时间格式不同(如带时区 2024-01-15T14:30:00+08:00),不会被格式化。解决方法:让后端统一返回无时区的 yyyy-MM-dd HH:mm:ss 格式,或扩展正则。
el-upload 上传报 401
el-upload 不经过 axios 拦截器,不会自动注入 Token。需要手动携带请求头:
<el-upload
action="/api/upload"
:headers="uploadHeaders"
>const uploadHeaders = computed(() => ({
Authorization: `Bearer ${userStore.token}`,
'Tenant-Id': userStore.tenantId
}))下一步
- 了解 Token / 权限如何持久化存储:
- 了解路由守卫如何配合 401 处理:
