import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' import { hasChinese, responseErrorText } from '../utils/errorText' import { captureOrganizationContext, currentReadSignal, getOrganizationId, isCurrentOrganizationContext, OrganizationContextSupersededError } from '../utils/organizationContext' const request = axios.create({ baseURL: '/api', timeout: 30000, headers: { 'Content-Type': 'application/json' } }) // 不需要公司上下文的接口白名单:认证、用户/组织/角色管理。 // 其余业务接口一律带 X-Organization-ID;websocket-ticket 属于组织级,不在白名单内。 const ORGANIZATION_EXEMPT_PATHS = [ '/auth/login', '/auth/logout', '/auth/me', '/auth/organizations', '/auth/organization-context', '/organizations', '/roles', '/permissions', '/users' ] function isOrganizationExempt(url) { const path = (url || '').split('?')[0] return ORGANIZATION_EXEMPT_PATHS.some(exempt => path === exempt || path.startsWith(`${exempt}/`)) } // 单次请求可通过 config.silent 关闭全局错误提示。取票等后台辅助请求失败时, // 不应让用户看到与自己操作无关的报错;默认不静默(未显式置 true 即照旧提示)。 // 注意:silent 只抑制“业务/连接类”提示,不影响 401 的登录态全局处理。 function isSilent(config) { return config?.silent === true } // Request interceptor - add auth token request.interceptors.request.use( (config) => { const token = localStorage.getItem('wmms_token') if (token) { config.headers.Authorization = `Bearer ${token}` } if (!isOrganizationExempt(config.url)) { const organizationId = getOrganizationId() if (organizationId) { config.headers['X-Organization-ID'] = String(organizationId) } // 记下发起时的上下文快照,响应回来时用它判断是否已被切换作废。 config.__organizationContext = captureOrganizationContext() // 读请求挂上共享信号:切换公司时统一 abort,尚未返回的旧读请求被取消。 const method = (config.method || 'get').toLowerCase() if (!config.signal && method === 'get') { config.signal = currentReadSignal() } } console.log('API请求:', config.method?.toUpperCase(), config.url) return config }, (error) => Promise.reject(error) ) // Response interceptor - handle errors and 401 request.interceptors.response.use( (response) => { // 响应属于已被切换掉的旧公司:丢弃且不提示,页面只应关心新公司的数据。 const context = response.config?.__organizationContext if (context && !isCurrentOrganizationContext(context)) { return Promise.reject(new OrganizationContextSupersededError()) } const res = response.data if (res.code === 0) { return res } if (!isSilent(response.config)) { ElMessage.error(hasChinese(res.message) ? res.message : '请求失败') } return Promise.reject(new Error(res.message)) }, (error) => { // 主动取消(切换公司)不是失败,静默丢弃,不要弹提示。 if (axios.isCancel(error)) { return Promise.reject(error) } // 旧公司的延迟错误同样不得影响新页面,也不能弹提示。 const context = error.config?.__organizationContext if (context && !isCurrentOrganizationContext(context)) { return Promise.reject(new OrganizationContextSupersededError()) } const silent = isSilent(error.config) if (error.response?.status === 401) { // 登录态失效的全局处理保持原样:清令牌并跳登录页。这与请求是否为 silent 无关, // silent 只抑制“业务/连接类”错误提示。 localStorage.removeItem('wmms_token') localStorage.removeItem('wmms_user') // 登录页自己会把失败原因写在表单里,再弹一句"登录已过期"只会让人困惑 if (router.currentRoute.value.path !== '/login') { router.push('/login') ElMessage.error('登录已过期,请重新登录') } } else if (error.response?.status === 422) { // Validation errors - parse and show friendly message const data = error.response?.data let msg = data?.message || '' if (data?.error && typeof data.error === 'string') { // Go validator errors format: "Key: 'Field' Error:Field validation ..." const matches = data.error.match(/Error: (Field validation.*?)(\n|$)/gi) if (matches) { const fields = matches.map(m => { const fieldMatch = m.match(/Key: '([^']+)'/) return fieldMatch ? fieldMatch[1] : m }) const labels = { Handler: '经手人', Supplier: '供应商', Receiver: '接收方' } msg = fields.map(f => labels[f] || f).join('、') + '不能为空' } } if (!silent) { ElMessage.error(hasChinese(msg) ? msg : '请检查必填字段') } } else { if (!silent) { ElMessage.error(responseErrorText(error)) } } return Promise.reject(error) } ) export default request