修改前端报错文案

This commit is contained in:
文睿 张 2026-09-20 16:59:38 +08:00
parent a7c7936e0b
commit d463fbf71a
9 changed files with 107 additions and 17 deletions

View File

@ -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)
}

View File

@ -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 ? '请求失败' : '网络异常,请检查网络连接'
}

View File

@ -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('该物料仍有库存,无法作废')
})
})

View File

@ -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()
})

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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) {