diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js index b5733c36..6bbcfb2a 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -19,7 +19,8 @@ export const authApi = { }) }, // 换取 WebSocket 短期一次性票据:属于组织级请求,拦截器会自动带上当前公司头。 + // 标记 silent:取票失败属于后台辅助失败,不应弹与实时连接相关的全局错误提示。 createWebSocketTicket() { - return request.post('/auth/websocket-ticket') + return request.post('/auth/websocket-ticket', undefined, { silent: true }) } } diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index dee3b066..018a149a 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -37,6 +37,13 @@ function isOrganizationExempt(url) { 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) => { @@ -75,7 +82,9 @@ request.interceptors.response.use( if (res.code === 0) { return res } - ElMessage.error(hasChinese(res.message) ? res.message : '请求失败') + if (!isSilent(response.config)) { + ElMessage.error(hasChinese(res.message) ? res.message : '请求失败') + } return Promise.reject(new Error(res.message)) }, (error) => { @@ -88,7 +97,10 @@ request.interceptors.response.use( 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') // 登录页自己会把失败原因写在表单里,再弹一句"登录已过期"只会让人困惑 @@ -112,9 +124,13 @@ request.interceptors.response.use( msg = fields.map(f => labels[f] || f).join('、') + '不能为空' } } - ElMessage.error(hasChinese(msg) ? msg : '请检查必填字段') + if (!silent) { + ElMessage.error(hasChinese(msg) ? msg : '请检查必填字段') + } } else { - ElMessage.error(responseErrorText(error)) + if (!silent) { + ElMessage.error(responseErrorText(error)) + } } return Promise.reject(error) } diff --git a/frontend/src/api/index.test.js b/frontend/src/api/index.test.js new file mode 100644 index 00000000..8c8830f2 --- /dev/null +++ b/frontend/src/api/index.test.js @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { ElMessage } from 'element-plus' +import request from './index' +import { commitOrganizationId, resetOrganizationContext } from '../utils/organizationContext' + +// 断言 WebSocket 取票这条后台辅助请求失败时静默:不弹全局错误提示, +// 同时不破坏拦截器的组织上下文逻辑(仍注入 X-Organization-ID)。 +describe('请求拦截器的 silent 静默开关', () => { + const originalAdapter = request.defaults.adapter + + beforeEach(() => { + resetOrganizationContext() + commitOrganizationId(2) + }) + + afterEach(() => { + request.defaults.adapter = originalAdapter + vi.restoreAllMocks() + }) + + // 适配器直接 reject,模拟 HTTP 层失败,走响应拦截器的错误分支。 + function rejectingAdapter(status, data) { + return (config) => { + const error = new Error(`Request failed with status code ${status}`) + error.config = config + error.response = { status, statusText: '', headers: {}, data } + error.isAxiosError = true + return Promise.reject(error) + } + } + + // 适配器解析出响应体,模拟业务层失败(res.code !== 0)。 + function resolvingAdapter(data, status = 200) { + return async (config) => ({ data, status, statusText: 'OK', headers: {}, config }) + } + + test('取票 403 失败静默:不弹全局错误提示', async () => { + const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => {}) + request.defaults.adapter = rejectingAdapter(403, { message: '当前公司不可访问' }) + + await expect( + request.post('/auth/websocket-ticket', undefined, { silent: true }) + ).rejects.toBeDefined() + + expect(errorSpy).not.toHaveBeenCalled() + }) + + test('取票业务失败(res.code !== 0)同样静默', async () => { + const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => {}) + request.defaults.adapter = resolvingAdapter({ code: 403, message: '当前公司不可访问' }) + + await expect( + request.post('/auth/websocket-ticket', undefined, { silent: true }) + ).rejects.toThrow() + + expect(errorSpy).not.toHaveBeenCalled() + }) + + test('未标记 silent 的同类失败仍会弹全局错误提示(未被全局静默)', async () => { + const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => {}) + request.defaults.adapter = rejectingAdapter(500, { message: '服务器错误' }) + + await expect(request.post('/materials')).rejects.toBeDefined() + + expect(errorSpy).toHaveBeenCalled() + }) + + test('取票请求仍按组织级请求注入 X-Organization-ID(白名单语义未变)', async () => { + let captured + request.defaults.adapter = async (config) => { + captured = config + return { data: { code: 0, data: { ticket: 't' } }, status: 200, statusText: 'OK', headers: {}, config } + } + + await request.post('/auth/websocket-ticket', undefined, { silent: true }) + + expect(captured.headers['X-Organization-ID']).toBe('2') + expect(captured.silent).toBe(true) + }) +})