From d463fbf71a305b5561a1e1bd613e2bf1d8c04588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Sun, 20 Sep 2026 16:59:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=89=8D=E7=AB=AF=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/index.js | 16 ++++++----- frontend/src/utils/errorText.js | 33 ++++++++++++++++++++++ frontend/src/utils/errorText.test.js | 41 ++++++++++++++++++++++++++++ frontend/src/views/Inbound.test.js | 5 ++-- frontend/src/views/Inbound.vue | 8 ++++-- frontend/src/views/Login.vue | 3 +- frontend/src/views/Materials.vue | 13 +++++++-- frontend/src/views/Stock.vue | 3 +- internal/repository/material.go | 2 +- 9 files changed, 107 insertions(+), 17 deletions(-) create mode 100644 frontend/src/utils/errorText.js create mode 100644 frontend/src/utils/errorText.test.js diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index bdb7394f..53aa0e36 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1,6 +1,7 @@ import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' +import { hasChinese, responseErrorText } from '../utils/errorText' const request = axios.create({ baseURL: '/api', @@ -30,15 +31,18 @@ request.interceptors.response.use( if (res.code === 0) { return res } - ElMessage.error(res.message || '请求失败') + ElMessage.error(hasChinese(res.message) ? res.message : '请求失败') return Promise.reject(new Error(res.message)) }, (error) => { if (error.response?.status === 401) { localStorage.removeItem('wmms_token') localStorage.removeItem('wmms_user') - router.push('/login') - ElMessage.error('登录已过期,请重新登录') + // 登录页自己会把失败原因写在表单里,再弹一句"登录已过期"只会让人困惑 + 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 @@ -55,11 +59,9 @@ request.interceptors.response.use( msg = fields.map(f => labels[f] || f).join('、') + '不能为空' } } - ElMessage.error(msg || '请检查必填字段') - } else if (error.response?.data?.message) { - ElMessage.error(error.response.data.message) + ElMessage.error(hasChinese(msg) ? msg : '请检查必填字段') } else { - ElMessage.error(error.message || '网络错误') + ElMessage.error(responseErrorText(error)) } return Promise.reject(error) } diff --git a/frontend/src/utils/errorText.js b/frontend/src/utils/errorText.js new file mode 100644 index 00000000..26647a2d --- /dev/null +++ b/frontend/src/utils/errorText.js @@ -0,0 +1,33 @@ +// 后端有一部分消息是英文(如 "material not found"),axios 自己生成的 +// "Request failed with status code 400" 也是英文。面向用户的提示只放行带中文的 +// 消息,其余换成中文,保证界面上不出现英文报错。 + +const STATUS_TEXT = { + 400: '请求参数有误,请检查填写内容', + 403: '没有操作权限', + 404: '请求的数据不存在', + 409: '数据已被改动,请刷新后重试', + 429: '操作过于频繁,请稍后再试' +} + +export function hasChinese(text) { + return typeof text === 'string' && /[一-龥]/.test(text) +} + +// 组件层取错误文案用这个:后端给了中文就用原文,英文一律换成中文兜底。 +export function errorText(error, fallback = '操作失败') { + const serverMessage = error?.response?.data?.message + if (hasChinese(serverMessage)) return serverMessage + if (hasChinese(error?.message)) return error.message + return fallback +} + +// 拦截器自己弹提示时用这个:拿不到中文消息就按状态码兜底。 +export function responseErrorText(error) { + const serverMessage = error?.response?.data?.message + if (hasChinese(serverMessage)) return serverMessage + const status = error?.response?.status + if (STATUS_TEXT[status]) return STATUS_TEXT[status] + if (status >= 500) return '服务器出错了,请稍后重试' + return error?.response ? '请求失败' : '网络异常,请检查网络连接' +} diff --git a/frontend/src/utils/errorText.test.js b/frontend/src/utils/errorText.test.js new file mode 100644 index 00000000..96a18a79 --- /dev/null +++ b/frontend/src/utils/errorText.test.js @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'vitest' +import { errorText, responseErrorText } from './errorText' + +// 后端返回英文消息时 axios 的 error.message 也是英文,两者都不该漏到界面上。 +function axiosError(status, message) { + const error = new Error(`Request failed with status code ${status}`) + error.response = { status, data: message ? { message } : {} } + return error +} + +describe('面向用户的错误文案', () => { + test('后端给了中文就用后端原文', () => { + expect(errorText(axiosError(400, '物料编码已存在'), '保存失败')).toBe('物料编码已存在') + }) + + test('后端消息是英文时换成中文兜底', () => { + expect(errorText(axiosError(400, 'material code already exists'), '保存失败')).toBe('保存失败') + }) + + test('拿不到响应时用中文兜底,不暴露 axios 的英文', () => { + expect(errorText(new Error('Network Error'), '保存失败')).toBe('保存失败') + }) + + test('自定义的中文错误保持原文', () => { + expect(errorText(new Error('图片上传失败'), '保存失败')).toBe('图片上传失败') + }) + + test('拦截器兜底时按状态码给中文', () => { + expect(responseErrorText(axiosError(400, 'invalid material id'))).toBe('请求参数有误,请检查填写内容') + expect(responseErrorText(axiosError(404, 'material not found'))).toBe('请求的数据不存在') + expect(responseErrorText(axiosError(500, 'internal error'))).toBe('服务器出错了,请稍后重试') + }) + + test('拦截器兜底时网络中断有单独提示', () => { + expect(responseErrorText(new Error('Network Error'))).toBe('网络异常,请检查网络连接') + }) + + test('拦截器兜底时中文消息优先于状态码文案', () => { + expect(responseErrorText(axiosError(409, '该物料仍有库存,无法作废'))).toBe('该物料仍有库存,无法作废') + }) +}) diff --git a/frontend/src/views/Inbound.test.js b/frontend/src/views/Inbound.test.js index c6a814ce..7bb461cf 100644 --- a/frontend/src/views/Inbound.test.js +++ b/frontend/src/views/Inbound.test.js @@ -225,7 +225,8 @@ describe('入库单表单必填校验', () => { }) // 保存过程中抛出的运行时异常(不是接口错误)不会被 axios 拦截器提示, - // 若不在这里兜住,用户看到的就只是"点了保存没反应"。 + // 若不在这里兜住,用户看到的就只是"点了保存没反应"。异常自带的英文消息 + // 只写进控制台,界面上给中文兜底文案。 test('保存过程抛出运行时异常时给出提示,而不是静默卡住', async () => { const wrapper = mountInbound() await flushPromises() @@ -240,7 +241,7 @@ describe('入库单表单必填校验', () => { await findButton(wrapper, '保存').trigger('click') await flushPromises() - expect(errorSpy).toHaveBeenCalledWith('boom') + expect(errorSpy).toHaveBeenCalledWith('保存失败') wrapper.unmount() }) diff --git a/frontend/src/views/Inbound.vue b/frontend/src/views/Inbound.vue index 449f97e3..e3d98a28 100644 --- a/frontend/src/views/Inbound.vue +++ b/frontend/src/views/Inbound.vue @@ -145,6 +145,7 @@ import BaseTable from '../components/business/BaseTable.vue' import StatusBadge from '../components/business/StatusBadge.vue' import DeleteConfirm from '../components/business/DeleteConfirm.vue' import { ElMessage } from 'element-plus' +import { errorText } from '../utils/errorText' const inboundStore = useInboundStore() const materialsStore = useMaterialsStore() @@ -253,8 +254,7 @@ async function showDetail(row) { currentRow.value = detail } catch (error) { console.error('获取详情失败:', error) - const msg = error.response?.data?.message || error.message || '获取详情失败' - ElMessage.error(msg) + ElMessage.error(errorText(error, '获取详情失败')) currentRow.value = { ...row, items: [] } } detailVisible.value = true @@ -359,7 +359,9 @@ async function handleSave() { // (比如代码里的运行时错误)。没有这层捕获的话,异常会让弹窗原地不动、 // 也不给任何提示,用户只能看到"点了保存没反应"。 if (!error?.response) { - ElMessage.error(error?.message || '保存失败') + // 运行时异常的原始信息(多为英文栈信息)留给控制台,界面上只给中文 + console.error('保存入库单失败:', error) + ElMessage.error(errorText(error, '保存失败')) } } finally { saveLoading.value = false diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index e262d026..f1ca0935 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -28,6 +28,7 @@ import { ref } from 'vue' import { useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' import { ElMessage } from 'element-plus' +import { errorText } from '../utils/errorText' const router = useRouter() const authStore = useAuthStore() @@ -51,7 +52,7 @@ async function handleLogin() { await authStore.login(form.value.username, form.value.password) router.push('/stock') } catch (e) { - errorMessage.value = e.message || '登录失败' + errorMessage.value = errorText(e, '用户名或密码错误') } finally { loading.value = false } diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue index 0acf22b5..eef1b20c 100644 --- a/frontend/src/views/Materials.vue +++ b/frontend/src/views/Materials.vue @@ -164,6 +164,7 @@ import { createMaterialForm, toMaterialPayload } from './materialForm' import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData' import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories' import { searchMaterialOptions } from '../utils/materialSearch' +import { errorText } from '../utils/errorText' const materialsStore = useMaterialsStore() const authStore = useAuthStore() @@ -349,7 +350,10 @@ async function handleSave() { headers: { 'Authorization': `Bearer ${authStore.token}` }, body: uploadForm }) - if (!res.ok) throw new Error('图片上传失败') + if (!res.ok) { + ElMessage.error('图片上传失败') + return + } const data = await res.json() formData.material_image = (data.data && data.data.url) ? data.data.url : '' } @@ -362,7 +366,12 @@ async function handleSave() { } dialogVisible.value = false } catch (e) { - ElMessage.error(e.message || '保存失败') + // 接口失败的原因已由拦截器弹过中文提示,这里只兜住它覆盖不到的异常, + // 避免出现"点了保存没反应" + if (!e?.response) { + console.error('保存物料失败:', e) + ElMessage.error(errorText(e, '保存失败')) + } } finally { saveLoading.value = false } diff --git a/frontend/src/views/Stock.vue b/frontend/src/views/Stock.vue index 3a9efa33..22d436ff 100644 --- a/frontend/src/views/Stock.vue +++ b/frontend/src/views/Stock.vue @@ -214,6 +214,7 @@ import BaseTable from '../components/business/BaseTable.vue' import StatusBadge from '../components/business/StatusBadge.vue' import { ElMessage } from 'element-plus' import WarehouseSettings from './inventory/WarehouseSettings.vue' +import { errorText } from '../utils/errorText' import { searchMaterialOptions } from '../utils/materialSearch' import { materialCategoriesApi } from '../api/masterData' import { getMajorCategories } from '../utils/materialCategories' @@ -314,7 +315,7 @@ async function saveWarning() { warningDialogVisible.value = false ElMessage.success('库存预警已更新') } catch (error) { - ElMessage.error(error.message || '库存预警更新失败') + ElMessage.error(errorText(error, '库存预警更新失败')) } finally { warningSaving.value = false } diff --git a/internal/repository/material.go b/internal/repository/material.go index 6d1172e6..78c02ec9 100644 --- a/internal/repository/material.go +++ b/internal/repository/material.go @@ -11,7 +11,7 @@ import ( ) // ErrDuplicateMaterialCode 表示同组织下物料编码已被占用。 -var ErrDuplicateMaterialCode = errors.New("material code already exists") +var ErrDuplicateMaterialCode = errors.New("物料编码已存在") // BeginTx starts a new database transaction func BeginTx() (*sql.Tx, error) {