From ca2a3c2dee355815bea0e4598c01d6d6a804156a Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 14 Sep 2026 14:40:49 +0800 Subject: [PATCH] fix: localize API error messages --- .../javascript/dashboard/composables/index.js | 10 +- .../javascript/dashboard/helper/APIHelper.js | 11 +- .../i18n/locale/zh_CN/inboxMgmt.json | 30 +-- .../i18n/locale/zh_CN/onboarding.json | 6 +- .../dashboard/settings/inbox/Settings.vue | 2 +- .../settings/integrations/Shopify.vue | 2 +- .../javascript/dashboard/store/utils/api.js | 187 +++++++++++++++--- .../dashboard/store/utils/specs/api.spec.js | 26 ++- frontend/app/javascript/v3/api/apiClient.js | 13 ++ .../app/javascript/v3/views/login/Index.vue | 25 +-- 10 files changed, 249 insertions(+), 63 deletions(-) diff --git a/frontend/app/javascript/dashboard/composables/index.js b/frontend/app/javascript/dashboard/composables/index.js index cf4cdf83..d210383b 100644 --- a/frontend/app/javascript/dashboard/composables/index.js +++ b/frontend/app/javascript/dashboard/composables/index.js @@ -1,4 +1,5 @@ import { emitter } from 'shared/helpers/mitt'; +import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index'; /** @@ -20,5 +21,12 @@ export const useTrack = (...args) => { * @param {Object|null} action - Optional callback function or object to execute. */ export const useAlert = (message, action = null) => { - emitter.emit('newToastMessage', { message, action }); + const normalizedMessage = + message === null || message === undefined || message === '' + ? message + : parseAPIErrorResponse(message); + emitter.emit('newToastMessage', { + message: normalizedMessage, + action, + }); }; diff --git a/frontend/app/javascript/dashboard/helper/APIHelper.js b/frontend/app/javascript/dashboard/helper/APIHelper.js index cfc6d408..fe522030 100644 --- a/frontend/app/javascript/dashboard/helper/APIHelper.js +++ b/frontend/app/javascript/dashboard/helper/APIHelper.js @@ -1,6 +1,12 @@ import Auth from '../api/auth'; +import { parseAPIErrorResponse } from '../store/utils/api'; -const parseErrorCode = error => Promise.reject(error); +const parseErrorCode = error => { + if (error && typeof error === 'object') { + error.message = parseAPIErrorResponse(error); + } + return Promise.reject(error); +}; const AUTH_HEADERS = ['access-token', 'token-type', 'client', 'expiry', 'uid']; export default axios => { @@ -28,12 +34,15 @@ export default axios => { const request = error.config; if ( error.response?.status !== 401 || + // eslint-disable-next-line no-underscore-dangle request?._retry || + // eslint-disable-next-line no-underscore-dangle request?._sessionRefresh || !Auth.hasAuthCookie() ) { return parseErrorCode(error); } + // eslint-disable-next-line no-underscore-dangle request._retry = true; refreshRequest ||= wootApi .get('/auth/validate_token', { _sessionRefresh: true }) diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json index fcf4f2c4..0055efe5 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json @@ -94,7 +94,7 @@ "COPY_SUCCESS": "Secret copied to clipboard", "TOGGLE": "Toggle secret visibility", "RESET_SUCCESS": "Webhook secret regenerated successfully", - "RESET_ERROR": "Unable to regenerate webhook secret. Please try again" + "RESET_ERROR": "无法重新生成 Webhook 密钥,请重试" }, "CHANNEL_DOMAIN": { "LABEL": "网站域名", @@ -314,9 +314,9 @@ "CANCELLED": "WhatsApp 注册已取消", "SUCCESS_TITLE": "WhatsApp Business Account Connected!", "WAITING_FOR_AUTH": "正在等待认证...", - "INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.", + "INVALID_BUSINESS_DATA": "从 Facebook 收到的企业数据无效,请重试。", "SIGNUP_ERROR": "注册时发生错误", - "AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.", + "AUTH_NOT_COMPLETED": "认证未完成,请重新开始操作。", "SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured", "MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow", "MANUAL_LINK_TEXT": "手动设置流程" @@ -331,7 +331,7 @@ "PHONE_NUMBER": { "LABEL": "电话号码", "PLACEHOLDER": "Enter your phone number (e.g. +1234567890)", - "ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)" + "ERROR": "请输入有效的 E.164 格式电话号码(例如:+1234567890)" }, "TWILIO": { "ACCOUNT_SID": { @@ -735,12 +735,12 @@ "WEBHOOK": { "TITLE": "Webhook Configuration", "DESCRIPTION": "Webhook URL is required for your WhatsApp Business Account to receive messages from customers", - "ACTION_REQUIRED": "Webhook not configured", - "REGISTER_BUTTON": "Register Webhook", - "REGISTER_SUCCESS": "Webhook registered successfully", - "REGISTER_ERROR": "Failed to register webhook. Please try again.", - "CONFIGURED_SUCCESS": "Webhook configured successfully", - "URL_MISMATCH": "Webhook URL mismatch" + "ACTION_REQUIRED": "未配置 Webhook", + "REGISTER_BUTTON": "注册 Webhook", + "REGISTER_SUCCESS": "Webhook 注册成功", + "REGISTER_ERROR": "Webhook 注册失败,请重试。", + "CONFIGURED_SUCCESS": "Webhook 配置成功", + "URL_MISMATCH": "Webhook URL 不匹配" } }, "SETTINGS": "设置", @@ -816,12 +816,12 @@ "WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.", "WHATSAPP_CONNECT_BUTTON": "连接", "WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!", - "WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.", + "WHATSAPP_CONNECT_ERROR": "连接 WhatsApp Business 失败,请重试。", "WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!", - "WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.", + "WHATSAPP_RECONFIGURE_ERROR": "重新配置 WhatsApp Business 失败,请重试。", "WHATSAPP_APP_ID_MISSING": "WhatsApp ID未配置。请联系您的管理员。", - "WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.", - "WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.", + "WHATSAPP_CONFIG_ID_MISSING": "WhatsApp 配置 ID 未设置,请联系管理员。", + "WHATSAPP_LOGIN_CANCELLED": "WhatsApp 登录已取消,请重试。", "WHATSAPP_WEBHOOK_TITLE": "Webhook 验证令牌", "WHATSAPP_WEBHOOK_SUBHEADER": "此令牌用于验证webhook端点的真实性。", "WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates", @@ -933,7 +933,7 @@ }, "BUTTON_TEXT": { "LABEL": "Button text", - "PLACEHOLDER": "Please rate us" + "PLACEHOLDER": "请为我们评分" }, "LANGUAGE": { "LABEL": "语言", diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/onboarding.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/onboarding.json index 5db2b27d..bd8fc5c1 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/onboarding.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/onboarding.json @@ -27,8 +27,8 @@ "SETTING_UP": "Setting up your account...", "CONTINUE": "Continue", "SAVING": "保存中...", - "VALIDATION_ERROR": "Please fill in all required fields", - "SUCCESS": "Details saved successfully", - "ERROR": "Could not save details. Please try again." + "VALIDATION_ERROR": "请填写所有必填项", + "SUCCESS": "信息保存成功", + "ERROR": "无法保存信息,请重试。" } } diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index ed4ab4fe..162f7a3d 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -494,7 +494,7 @@ export default { const response = await InboxHealthAPI.getHealthStatus(this.inbox.id); this.healthData = response.data; } catch (error) { - this.healthError = error.message || 'Failed to fetch health data'; + this.healthError = error.message || '无法获取健康状态,请稍后再试。'; } finally { this.isLoadingHealth = false; } diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue index 494b1a17..6ab8af3f 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue @@ -61,7 +61,7 @@ const handleStoreUrlSubmit = async () => { storeUrlError.value = ''; if (!validateStoreUrl(storeUrl.value)) { storeUrlError.value = - 'Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)'; + '请输入有效的 Shopify 店铺网址(例如:your-store.myshopify.com)'; return; } diff --git a/frontend/app/javascript/dashboard/store/utils/api.js b/frontend/app/javascript/dashboard/store/utils/api.js index 9f471ae9..d9cde572 100644 --- a/frontend/app/javascript/dashboard/store/utils/api.js +++ b/frontend/app/javascript/dashboard/store/utils/api.js @@ -71,6 +71,16 @@ export const deleteIndexedDBOnLogout = async () => { localStorage.removeItem('cw-idb-names'); }; +const getSafeLogoutRedirectLink = redirectLink => { + try { + const url = new URL(redirectLink || '/', window.location.origin); + if (url.origin !== window.location.origin) return '/'; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return '/'; + } +}; + export const clearCookiesOnLogout = () => { emitter.emit(CHATWOOT_RESET); emitter.emit(ANALYTICS_RESET); @@ -78,36 +88,163 @@ export const clearCookiesOnLogout = () => { clearLocalStorageOnLogout(); clearSessionStorageOnLogout(); const globalConfig = window.globalConfig || {}; - const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || '/'; - window.location = logoutRedirectLink; + const logoutRedirectLink = getSafeLogoutRedirectLink( + globalConfig.LOGOUT_REDIRECT_LINK + ); + window.location.assign(logoutRedirectLink); +}; + +const API_ERROR_MESSAGES = Object.freeze({ + 400: '请求参数有误,请检查后重试。', + 401: '身份验证失败,请重新登录。', + 403: '您没有权限执行此操作。', + 404: '请求的资源不存在。', + 408: '请求超时,请稍后再试。', + 409: '操作冲突,请刷新后重试。', + 422: '提交的信息有误,请检查后重试。', + 429: '操作过于频繁,请稍后再试。', + 500: '服务器发生错误,请稍后再试。', + 502: '服务暂时不可用,请稍后再试。', + 503: '服务暂时不可用,请稍后再试。', + 504: '服务暂时不可用,请稍后再试。', +}); + +const extractStatusFromMessage = message => { + const match = message.match( + /(?:request failed with status code|status code|error)\s*[:=]?\s*([45]\d{2})/i + ); + return match ? Number(match[1]) : null; +}; + +const getStatusMessage = status => { + const numericStatus = Number(status); + if (!Number.isInteger(numericStatus)) return ''; + return ( + API_ERROR_MESSAGES[numericStatus] || + (numericStatus >= 500 ? API_ERROR_MESSAGES[500] : '') + ); +}; + +const normalizeAPIErrorText = (message, status) => { + if (typeof message !== 'string') return message; + + const text = message.trim(); + if (!text) return text; + if (/^(network error|failed to fetch|network request failed)$/i.test(text)) { + return '网络连接失败,请检查网络后重试。'; + } + if (/timeout|timed out|超时/i.test(text)) { + return '请求超时,请稍后再试。'; + } + if (/^invalid email or password$/i.test(text)) { + return '邮箱或密码错误,请重试。'; + } + if (/^email not confirmed\b/i.test(text)) { + return '邮箱尚未验证,请先完成邮箱验证。'; + } + if (/^user account is inactive\b/i.test(text)) { + return '账户已停用,请联系管理员。'; + } + if (/^this account uses .* authentication/i.test(text)) { + return '此账户使用第三方登录,请通过对应方式登录。'; + } + if ( + /^(invalid (token|credentials|headers)|unauthorized|unauthenticated|authentication failed)$/i.test( + text + ) + ) { + return '身份验证失败,请重新登录。'; + } + if (/^account not identified$/i.test(text)) { + return '无法识别当前账户,请重新登录。'; + } + if (/administrator role required/i.test(text)) { + return '需要管理员权限才能执行此操作。'; + } + if ( + /^(not authorized|authorization header required|bearer token required|widget_token required)$/i.test( + text + ) + ) { + return '缺少有效的登录凭证,请重新登录。'; + } + if (/^account is suspended$/i.test(text)) { + return '账户已被停用,请联系管理员。'; + } + if (/^sso session (expired or invalid|data corrupt)$/i.test(text)) { + return '单点登录会话无效,请重新登录。'; + } + if (/^invalid (source )?url/i.test(text)) { + return '请求地址无效,请检查后重试。'; + } + + const messageStatus = extractStatusFromMessage(text); + if (messageStatus) { + return getStatusMessage(messageStatus) || text; + } + + if ( + status && + /^(bad request|unauthorized|unauthenticated|forbidden|not found|request timeout|unprocessable entity|too many requests|internal server error|bad gateway|service unavailable|gateway timeout|invalid credentials|authentication failed)$/i.test( + text + ) + ) { + return getStatusMessage(status) || text; + } + if ( + status >= 500 && + /\b(error|failed|unavailable|gateway|server)\b/i.test(text) + ) { + return getStatusMessage(status) || text; + } + + if (/^(axioserror:\s*)?(error:\s*)?\[object object\]$/i.test(text)) { + return '请求失败,请稍后再试。'; + } + if ( + /^(failed|unable to|could not|cannot|can't|something went wrong|an error occurred)\b/i.test( + text + ) + ) { + return '请求失败,请稍后再试。'; + } + return message; +}; + +const extractErrorText = value => { + if (typeof value === 'string') return value; + if (value?.message && typeof value.message === 'string') { + return value.message; + } + if (Array.isArray(value)) return extractErrorText(value[0]); + if (value && typeof value === 'object') return JSON.stringify(value); + return ''; }; export const parseAPIErrorResponse = error => { - if (error?.response?.data?.message) { - return error?.response?.data?.message; + if (typeof error === 'string') return normalizeAPIErrorText(error); + + const status = error?.response?.status ?? error?.status; + if (error?.code === 'ERR_NETWORK') { + return '网络连接失败,请检查网络后重试。'; } - if (error?.response?.data?.error) { - // error could be a string or an object with a message property - if (typeof error.response.data.error === 'string') { - return error.response.data.error; - } - if (error.response.data.error?.message) { - return error.response.data.error.message; - } - return JSON.stringify(error.response.data.error); + if (error?.code === 'ECONNABORTED') { + return '请求超时,请稍后再试。'; } - if (error?.response?.data?.errors) { - const first = error.response.data.errors; - if (Array.isArray(first)) { - return first[0]; - } - return first; - } - // Fallback: return a useful default message rather than the raw error object - if (error?.message && typeof error.message === 'string') { - return error.message; - } - return 'Request failed. Please try again.'; + const data = error?.response?.data; + const message = extractErrorText(data?.message); + if (message) return normalizeAPIErrorText(message, status); + + const apiError = extractErrorText(data?.error); + if (apiError) return normalizeAPIErrorText(apiError, status); + + const errors = extractErrorText(data?.errors); + if (errors) return normalizeAPIErrorText(errors, status); + + const errorMessage = extractErrorText(error?.message); + if (errorMessage) return normalizeAPIErrorText(errorMessage, status); + + return getStatusMessage(status) || '请求失败,请稍后再试。'; }; export const throwErrorMessage = error => { diff --git a/frontend/app/javascript/dashboard/store/utils/specs/api.spec.js b/frontend/app/javascript/dashboard/store/utils/specs/api.spec.js index a49c655a..f8e41f64 100644 --- a/frontend/app/javascript/dashboard/store/utils/specs/api.spec.js +++ b/frontend/app/javascript/dashboard/store/utils/specs/api.spec.js @@ -35,7 +35,31 @@ describe('#parseAPIErrorResponse', () => { ).toBe('Error Message [error]'); expect(parseAPIErrorResponse('Error: 422 Failed')).toBe( - 'Request failed. Please try again.' + '提交的信息有误,请检查后重试。' + ); + expect( + parseAPIErrorResponse(new Error('Request failed with status code 401')) + ).toBe('身份验证失败,请重新登录。'); + expect( + parseAPIErrorResponse({ + response: { status: 401, data: { error: 'invalid email or password' } }, + }) + ).toBe('邮箱或密码错误,请重试。'); + expect( + parseAPIErrorResponse({ + response: { + status: 403, + data: { error: 'administrator role required' }, + }, + }) + ).toBe('需要管理员权限才能执行此操作。'); + expect( + parseAPIErrorResponse({ + response: { status: 500, data: { error: 'database error' } }, + }) + ).toBe('服务器发生错误,请稍后再试。'); + expect(parseAPIErrorResponse(new Error('Network Error'))).toBe( + '网络连接失败,请检查网络后重试。' ); }); }); diff --git a/frontend/app/javascript/v3/api/apiClient.js b/frontend/app/javascript/v3/api/apiClient.js index cdc3ae35..07059b69 100644 --- a/frontend/app/javascript/v3/api/apiClient.js +++ b/frontend/app/javascript/v3/api/apiClient.js @@ -1,6 +1,19 @@ import axios from 'axios'; +import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; const { apiHost = '' } = window.chatwootConfig || {}; const wootAPI = axios.create({ baseURL: `${apiHost}/`, withCredentials: true }); +if (wootAPI?.interceptors?.response) { + wootAPI.interceptors.response.use( + response => response, + error => { + if (error && typeof error === 'object') { + error.message = parseAPIErrorResponse(error); + } + return Promise.reject(error); + } + ); +} + export default wootAPI; diff --git a/frontend/app/javascript/v3/views/login/Index.vue b/frontend/app/javascript/v3/views/login/Index.vue index d1dbb3c9..0c2c3c51 100644 --- a/frontend/app/javascript/v3/views/login/Index.vue +++ b/frontend/app/javascript/v3/views/login/Index.vue @@ -2,23 +2,17 @@ // utils and composables import { login } from '../../api/auth'; import { useAlert } from 'dashboard/composables'; +import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; import { required, email } from '@vuelidate/validators'; import { useVuelidate } from '@vuelidate/core'; import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage'; import SessionStorage from 'shared/helpers/sessionStorage'; // components -import SimpleDivider from '../../components/Divider/SimpleDivider.vue'; import FormInput from '../../components/Form/Input.vue'; import Spinner from 'shared/components/Spinner.vue'; -import Icon from 'dashboard/components-next/icon/Icon.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; -const ERROR_MESSAGES = { - 'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND', - 'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY', -}; - const IMPERSONATION_URL_SEARCH_KEY = 'impersonation'; const USER_NOT_CONFIRMED_ERROR_CODE = 'user_not_confirmed'; @@ -27,8 +21,6 @@ export default { FormInput, Spinner, NextButton, - SimpleDivider, - Icon, }, props: { ssoAuthToken: { type: String, default: '' }, @@ -91,7 +83,7 @@ export default { }, showAlertMessage(message) { this.loginApi.showLoading = false; - this.loginApi.message = message; + this.loginApi.message = parseAPIErrorResponse(message); useAlert(this.loginApi.message); }, handleImpersonation() { @@ -134,9 +126,13 @@ export default { window.location = '/app/login'; } this.loginApi.hasErrored = true; - this.showAlertMessage( - response?.message || this.$t('LOGIN.API.UNAUTH') - ); + let errorMessage = this.$t('LOGIN.API.ERROR_MESSAGE'); + if (response?.response?.status === 401) { + errorMessage = this.$t('LOGIN.API.UNAUTH'); + } else if (response) { + errorMessage = parseAPIErrorResponse(response); + } + this.showAlertMessage(errorMessage); }); }, submitFormLogin() { @@ -157,9 +153,8 @@ export default { >