From d7b7b25db2d95ac1a50c7d423f85edb89cefc2e0 Mon Sep 17 00:00:00 2001 From: bootx Date: Mon, 29 Jun 2026 09:09:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(h5):=20=E6=8E=A5=E5=85=A5=E5=9B=BD?= =?UTF-8?q?=E9=99=85=E5=8C=96(i18n)=E4=B8=8E=E7=BB=9F=E4=B8=80=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 vue-i18n + dayjs, 新增 zh-CN/en-US 语言包 - 双端 app 注入 i18n, van-config-provider 联动 vant 语言包 - 自动探测浏览器语言, 持久化仅记显式选择 - 持久化键名加 -v2 后缀作废历史陈旧值 - 日期统一 ISO yyyy-MM-dd, 消除 MM/DD vs DD/MM 歧义 - 后端 timestamptz 转浏览器本地时区 - HTTP 提示/网络异常/操作成功 等全量改 t() - statement 状态与通道枚举改 code + i18n - 清理 axios/helper 中 moment 死代码 --- package.json | 2 + pnpm-lock.yaml | 64 +++++++++++ src/mobile/App.vue | 5 +- src/mobile/app.ts | 3 + src/mobile/views/cashier/index.vue | 40 ++++--- src/mobile/views/code-pay/index.vue | 24 ++-- src/mobile/views/device-only.vue | 9 +- src/mobile/views/exception/404.vue | 7 +- src/mobile/views/home/index.vue | 10 +- src/pc/App.vue | 9 +- src/pc/app.ts | 3 + src/pc/views/Home.vue | 10 +- src/pc/views/NotFound.vue | 6 +- src/pc/views/cashier/Index.vue | 40 ++++--- src/pc/views/device-only.vue | 9 +- src/pc/views/statement/Index.vue | 83 ++++++++------ src/shared/locales/en-US/cashier.json | 20 ++++ src/shared/locales/en-US/codePay.json | 10 ++ src/shared/locales/en-US/common.json | 11 ++ src/shared/locales/en-US/error.json | 11 ++ src/shared/locales/en-US/home.json | 5 + src/shared/locales/en-US/http.json | 19 ++++ src/shared/locales/en-US/index.ts | 19 ++++ src/shared/locales/en-US/protocol.json | 8 ++ src/shared/locales/en-US/statement.json | 25 +++++ src/shared/locales/index.ts | 124 +++++++++++++++++++++ src/shared/locales/lang.ts | 94 ++++++++++++++++ src/shared/locales/zh-CN/cashier.json | 20 ++++ src/shared/locales/zh-CN/codePay.json | 10 ++ src/shared/locales/zh-CN/common.json | 11 ++ src/shared/locales/zh-CN/error.json | 11 ++ src/shared/locales/zh-CN/home.json | 5 + src/shared/locales/zh-CN/http.json | 19 ++++ src/shared/locales/zh-CN/index.ts | 19 ++++ src/shared/locales/zh-CN/protocol.json | 8 ++ src/shared/locales/zh-CN/statement.json | 25 +++++ src/shared/utils/datetime.ts | 68 +++++++++++ src/shared/utils/http/axios/checkStatus.ts | 29 +++-- src/shared/utils/http/axios/helper.ts | 7 +- src/shared/utils/http/axios/index.ts | 21 ++-- 40 files changed, 800 insertions(+), 123 deletions(-) create mode 100644 src/shared/locales/en-US/cashier.json create mode 100644 src/shared/locales/en-US/codePay.json create mode 100644 src/shared/locales/en-US/common.json create mode 100644 src/shared/locales/en-US/error.json create mode 100644 src/shared/locales/en-US/home.json create mode 100644 src/shared/locales/en-US/http.json create mode 100644 src/shared/locales/en-US/index.ts create mode 100644 src/shared/locales/en-US/protocol.json create mode 100644 src/shared/locales/en-US/statement.json create mode 100644 src/shared/locales/index.ts create mode 100644 src/shared/locales/lang.ts create mode 100644 src/shared/locales/zh-CN/cashier.json create mode 100644 src/shared/locales/zh-CN/codePay.json create mode 100644 src/shared/locales/zh-CN/common.json create mode 100644 src/shared/locales/zh-CN/error.json create mode 100644 src/shared/locales/zh-CN/home.json create mode 100644 src/shared/locales/zh-CN/http.json create mode 100644 src/shared/locales/zh-CN/index.ts create mode 100644 src/shared/locales/zh-CN/protocol.json create mode 100644 src/shared/locales/zh-CN/statement.json create mode 100644 src/shared/utils/datetime.ts diff --git a/package.json b/package.json index 5d7b410..9839f47 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "axios": "^1.18.1", + "dayjs": "^1.11.21", "lodash-es": "^4.18.1", "nprogress": "^0.2.0", "pinia": "^3.0.4", @@ -35,6 +36,7 @@ "qs": "^6.15.2", "vant": "^4.9.24", "vue": "^3.5.38", + "vue-i18n": "^11.4.6", "vue-router": "^5.1.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf7bc22..d5ffa51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: axios: specifier: ^1.18.1 version: 1.18.1 + dayjs: + specifier: ^1.11.21 + version: 1.11.21 lodash-es: specifier: ^4.18.1 version: 4.18.1 @@ -32,6 +35,9 @@ importers: vue: specifier: ^3.5.38 version: 3.5.38(typescript@5.9.3) + vue-i18n: + specifier: ^11.4.6 + version: 11.4.6(vue@3.5.38(typescript@5.9.3)) vue-router: specifier: ^5.1.0 version: 5.1.0(@vue/compiler-sfc@3.5.38)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3)))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.7)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) @@ -669,6 +675,22 @@ packages: '@iconify/utils@3.1.3': resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@intlify/core-base@11.4.6': + resolution: {integrity: sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw==} + engines: {node: '>= 22'} + + '@intlify/devtools-types@11.4.6': + resolution: {integrity: sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA==} + engines: {node: '>= 22'} + + '@intlify/message-compiler@11.4.6': + resolution: {integrity: sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q==} + engines: {node: '>= 22'} + + '@intlify/shared@11.4.6': + resolution: {integrity: sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA==} + engines: {node: '>= 22'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1509,6 +1531,9 @@ packages: '@vue/compiler-ssr@3.5.38': resolution: {integrity: sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==} + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + '@vue/devtools-api@7.7.9': resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} @@ -1917,6 +1942,9 @@ packages: resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} engines: {node: '>=12'} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -3880,6 +3908,12 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-i18n@11.4.6: + resolution: {integrity: sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==} + engines: {node: '>= 22'} + peerDependencies: + vue: ^3.0.0 + vue-router@5.1.0: resolution: {integrity: sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==} peerDependencies: @@ -4476,6 +4510,24 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@intlify/core-base@11.4.6': + dependencies: + '@intlify/devtools-types': 11.4.6 + '@intlify/message-compiler': 11.4.6 + '@intlify/shared': 11.4.6 + + '@intlify/devtools-types@11.4.6': + dependencies: + '@intlify/core-base': 11.4.6 + '@intlify/shared': 11.4.6 + + '@intlify/message-compiler@11.4.6': + dependencies: + '@intlify/shared': 11.4.6 + source-map-js: 1.2.1 + + '@intlify/shared@11.4.6': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5231,6 +5283,8 @@ snapshots: '@vue/compiler-dom': 3.5.38 '@vue/shared': 3.5.38 + '@vue/devtools-api@6.6.4': {} + '@vue/devtools-api@7.7.9': dependencies: '@vue/devtools-kit': 7.7.9 @@ -5646,6 +5700,8 @@ snapshots: dargs@8.1.0: {} + dayjs@1.11.21: {} + debug@4.3.4: dependencies: ms: 2.1.2 @@ -7855,6 +7911,14 @@ snapshots: transitivePeerDependencies: - supports-color + vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)): + dependencies: + '@intlify/core-base': 11.4.6 + '@intlify/devtools-types': 11.4.6 + '@intlify/shared': 11.4.6 + '@vue/devtools-api': 6.6.4 + vue: 3.5.38(typescript@5.9.3) + vue-router@5.1.0(@vue/compiler-sfc@3.5.38)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3)))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.7)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)): dependencies: '@babel/generator': 8.0.0 diff --git a/src/mobile/App.vue b/src/mobile/App.vue index e4c478a..1cf5125 100644 --- a/src/mobile/App.vue +++ b/src/mobile/App.vue @@ -1,10 +1,13 @@ diff --git a/src/shared/locales/en-US/cashier.json b/src/shared/locales/en-US/cashier.json new file mode 100644 index 0000000..e4f77dd --- /dev/null +++ b/src/shared/locales/en-US/cashier.json @@ -0,0 +1,20 @@ +{ + "demoOrderTitle": "DaxPay Demo Order", + "remainTime": "Payment time left", + "remainTimeShort": "Time left", + "orderTitle": "Order title", + "orderNo": "Order No.", + "orderNoLabel": "Order No.: ", + "payableAmount": "Amount payable", + "selectMethod": "Select a payment method", + "recommend": "Recommended", + "payNow": "Pay Now", + "demoPayStarted": "Demo: {name} payment initiated", + "qrcodePayDemo": "Scan to pay (demo)", + "qrcodeTip": "Scan the QR code with {name} to complete the payment", + "method": { + "wechat": "WeChat Pay", + "alipay": "Alipay", + "union": "UnionPay" + } +} diff --git a/src/shared/locales/en-US/codePay.json b/src/shared/locales/en-US/codePay.json new file mode 100644 index 0000000..1925883 --- /dev/null +++ b/src/shared/locales/en-US/codePay.json @@ -0,0 +1,10 @@ +{ + "demoMerchant": "Demo Merchant", + "amountLabel": "Amount", + "remark": "Remark", + "addRemark": "Add remark", + "remarkPlaceholder": "Enter payment remark", + "amountZero": "Amount cannot be 0", + "confirmPay": "Confirm Payment", + "demoPay": "Demo: code {code} payment ¥{amount}" +} diff --git a/src/shared/locales/en-US/common.json b/src/shared/locales/en-US/common.json new file mode 100644 index 0000000..8355ceb --- /dev/null +++ b/src/shared/locales/en-US/common.json @@ -0,0 +1,11 @@ +{ + "confirm": "Confirm", + "cancel": "Cancel", + "save": "Save", + "back": "Back", + "backHome": "Back to Home", + "loading": "Loading...", + "tip": "Notice", + "operationSuccess": "Operation succeeded!", + "operationFailed": "Operation failed!" +} diff --git a/src/shared/locales/en-US/error.json b/src/shared/locales/en-US/error.json new file mode 100644 index 0000000..cab8eae --- /dev/null +++ b/src/shared/locales/en-US/error.json @@ -0,0 +1,11 @@ +{ + "notFound": "Sorry, the page you visited does not exist", + "deviceOnlyPc": { + "title": "This page is only available on desktop", + "desc": "Please open this page in a desktop browser" + }, + "deviceOnlyMobile": { + "title": "This page is only available on mobile", + "desc": "Please scan the QR code or open this page in a mobile browser" + } +} diff --git a/src/shared/locales/en-US/home.json b/src/shared/locales/en-US/home.json new file mode 100644 index 0000000..7224373 --- /dev/null +++ b/src/shared/locales/en-US/home.json @@ -0,0 +1,5 @@ +{ + "welcome": "Welcome to DaxPay", + "version": "Version", + "buildTime": "Build time" +} diff --git a/src/shared/locales/en-US/http.json b/src/shared/locales/en-US/http.json new file mode 100644 index 0000000..ad6fb18 --- /dev/null +++ b/src/shared/locales/en-US/http.json @@ -0,0 +1,19 @@ +{ + "requestError": "Request failed, please try again later", + "timeout": "Request timed out, please refresh the page and retry!", + "networkError": "Network error", + "networkErrorDesc": "Please check your network connection", + "status": { + "401": "Unauthorized (invalid token, username or password)!", + "403": "Authorized, but access is forbidden!", + "404": "Network request error, resource not found!", + "405": "Network request error, method not allowed!", + "408": "Network request timed out", + "500": "Server error, please contact the administrator!", + "501": "Network not implemented", + "502": "Network error", + "503": "Service unavailable, server temporarily overloaded or under maintenance!", + "504": "Network timeout", + "505": "HTTP version not supported!" + } +} diff --git a/src/shared/locales/en-US/index.ts b/src/shared/locales/en-US/index.ts new file mode 100644 index 0000000..ad6b3c9 --- /dev/null +++ b/src/shared/locales/en-US/index.ts @@ -0,0 +1,19 @@ +import cashier from './cashier.json' +import codePay from './codePay.json' +import common from './common.json' +import error from './error.json' +import home from './home.json' +import http from './http.json' +import protocol from './protocol.json' +import statement from './statement.json' + +export default { + common, + http, + cashier, + codePay, + protocol, + statement, + home, + error, +} diff --git a/src/shared/locales/en-US/protocol.json b/src/shared/locales/en-US/protocol.json new file mode 100644 index 0000000..76e6c4c --- /dev/null +++ b/src/shared/locales/en-US/protocol.json @@ -0,0 +1,8 @@ +{ + "title": "Agreement", + "typeNotExist": "Agreement type does not exist", + "loadFailed": "Failed to load the agreement", + "noContent": "No agreement content", + "version": "Version {version}", + "effectiveTime": "Effective from {time}" +} diff --git a/src/shared/locales/en-US/statement.json b/src/shared/locales/en-US/statement.json new file mode 100644 index 0000000..85f7cf4 --- /dev/null +++ b/src/shared/locales/en-US/statement.json @@ -0,0 +1,25 @@ +{ + "heading": "Merchant Statement", + "totalAmountSuccess": "Total Amount (Success)", + "tradeCount": "Transactions", + "successRate": "Success Rate", + "empty": "No transactions", + "column": { + "time": "Time", + "orderNo": "Order No.", + "title": "Item", + "amount": "Amount", + "channel": "Channel", + "status": "Status" + }, + "status": { + "success": "Success", + "fail": "Failed", + "pending": "Processing" + }, + "channel": { + "wechat": "WeChat", + "alipay": "Alipay", + "union": "UnionPay" + } +} diff --git a/src/shared/locales/index.ts b/src/shared/locales/index.ts new file mode 100644 index 0000000..a6dc099 --- /dev/null +++ b/src/shared/locales/index.ts @@ -0,0 +1,124 @@ +/** + * 国际化核心装配 + * + * - locale 状态以 vue-i18n 实例为单一事实源(composition 模式) + * - 语言切换副作用:dayjs locale / vant 语言包 / `` / localStorage 持久化 + * - mobile 与 pc 是两个独立 Vue 实例(各自独立 pinia),i18n 实例由两端 app.ts 分别 app.use(i18n) + */ +import type { App } from 'vue' +import type { AppLocaleCode, AppLocaleOption } from './lang' +import dayjs from 'dayjs' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' +import enUS from './en-US' +import { + APP_LOCALES, + DEFAULT_LOCALE, + getLocaleOption, + LOCALE_STORAGE_KEY, + resolveInitialLocale, +} from './lang' +import zhCN from './zh-CN' + +export type { AppLocaleCode, AppLocaleOption } + +const i18n = createI18n({ + legacy: false, + locale: resolveInitialLocale(), + fallbackLocale: DEFAULT_LOCALE, + messages: { + 'zh-CN': zhCN, + 'en-US': enUS, + }, + missingWarn: false, + fallbackWarn: false, +}) + +/** vant 语言包响应式引用,供 `van-config-provider` 的 `:locale` 绑定 */ +const vantLocalePkg = ref | null>(null) + +/** 动态加载 vant 语言包 */ +async function loadVantLocale(code: AppLocaleCode) { + const opt = getLocaleOption(code) + if (!opt) { + return + } + try { + const mod = await opt.vantLoader() + vantLocalePkg.value = mod.default ?? mod + } + catch (error) { + console.warn('[i18n] failed to load vant locale:', error) + } +} + +/** + * 应用语言变更的运行时副作用:dayjs / vant / `` + * + * 注意:此处**不**写 localStorage。自动探测/初始化若落盘, + * 会让陈旧值锁定语言、使后续浏览器语言变化失效。 + * 仅显式切换(`setLocale`)才持久化。 + */ +function applySideEffects(code: AppLocaleCode) { + const opt: AppLocaleOption = getLocaleOption(code) ?? APP_LOCALES[0]! + // dayjs 全局 locale(影响 fromNow / localizedFormat 等本地化输出) + dayjs.locale(opt.dayjsLocale) + // 同步 属性 + if (typeof document !== 'undefined') { + document.documentElement.lang = opt.htmlLang + } + // 异步加载 vant 语言包 + void loadVantLocale(code) +} + +/** + * 切换当前语言(显式选择,持久化到 localStorage) + */ +export function setLocale(code: AppLocaleCode) { + if (!getLocaleOption(code)) { + return + } + i18n.global.locale.value = code + applySideEffects(code) + // 仅显式切换才落盘,覆盖浏览器自动探测 + if (typeof localStorage !== 'undefined') { + localStorage.setItem(LOCALE_STORAGE_KEY, code) + } +} + +/** 当前语言代码(非响应式读取,供非组件模块使用) */ +export function getCurrentLocale(): AppLocaleCode { + return i18n.global.locale.value as AppLocaleCode +} + +/** + * 供非组件模块使用的翻译函数(如 axios 拦截器、http checkStatus) + */ +export function t(key: string, named?: Record): string { + return i18n.global.t(key, named ?? ({} as Record)) +} + +/** + * 组件内语言组合式 API + * @returns locale 当前语言(响应式 ref)、setLocale 切换函数、t 翻译函数、vantLocale vant 语言包 ref + */ +export function useLocale() { + return { + locale: i18n.global.locale, + setLocale, + t: i18n.global.t, + vantLocale: vantLocalePkg, + locales: APP_LOCALES, + } +} + +/** + * 安装 i18n 到应用,并执行初始语言的副作用 + */ +export function setupI18n(app: App) { + app.use(i18n) + applySideEffects(i18n.global.locale.value as AppLocaleCode) + return i18n +} + +export default i18n diff --git a/src/shared/locales/lang.ts b/src/shared/locales/lang.ts new file mode 100644 index 0000000..37ac391 --- /dev/null +++ b/src/shared/locales/lang.ts @@ -0,0 +1,94 @@ +/** + * 语言选项与浏览器探测 + * + * 语言代码与后端 common-i18n 对齐:仅支持 zh-CN / en-US。 + */ + +/** 应用支持的语言代码(与后端 common-i18n 一致) */ +export type AppLocaleCode = 'zh-CN' | 'en-US' + +export interface AppLocaleOption { + /** 语言代码 */ + code: AppLocaleCode + /** `` 属性值 */ + htmlLang: string + /** dayjs locale 名称 */ + dayjsLocale: string + /** vant 语言包动态导入函数 */ + vantLoader: () => Promise<{ default: Record }> +} + +/** 全部支持的语言选项 */ +export const APP_LOCALES: AppLocaleOption[] = [ + { + code: 'zh-CN', + htmlLang: 'zh-cmn-Hans', + dayjsLocale: 'zh-cn', + vantLoader: () => import('vant/es/locale/lang/zh-CN.mjs'), + }, + { + code: 'en-US', + htmlLang: 'en', + dayjsLocale: 'en', + vantLoader: () => import('vant/es/locale/lang/en-US.mjs'), + }, +] + +/** 默认语言 */ +export const DEFAULT_LOCALE: AppLocaleCode = 'zh-CN' + +/** + * localStorage 持久化键 + * + * 带 `-v2` 后缀:旧版本会在自动探测时落盘,导致陈旧值锁定语言、 + * 切换浏览器语言不再生效。新版仅由显式 `setLocale` 写入, + * 加版本后缀可让历史陈旧的 `APP-LOCALE` 值自动作废,无需手动清理。 + */ +export const LOCALE_STORAGE_KEY = 'APP-LOCALE-v2' + +const LOCALE_MAP = new Map(APP_LOCALES.map(l => [l.code, l])) + +/** 根据语言代码获取选项 */ +export function getLocaleOption(code: string): AppLocaleOption | undefined { + return LOCALE_MAP.get(code) +} + +/** + * 从浏览器 navigator.language 匹配已支持的语言 + * - 任意中文变体(zh / zh-CN / zh-TW / zh-HK)→ zh-CN + * - 任意英文变体(en / en-US / en-GB)→ en-US + * - 其余回退到默认语言 + */ +export function matchBrowserLocale(): AppLocaleCode { + if (typeof navigator === 'undefined') { + return DEFAULT_LOCALE + } + const langs = navigator.languages?.length ? navigator.languages : [navigator.language] + for (const lang of langs) { + if (!lang) { + continue + } + const lower = lang.toLowerCase() + if (lower.startsWith('zh')) { + return 'zh-CN' + } + if (lower.startsWith('en')) { + return 'en-US' + } + } + return DEFAULT_LOCALE +} + +/** + * 解析初始语言,优先级:localStorage > 浏览器语言 > 默认 + */ +export function resolveInitialLocale(): AppLocaleCode { + if (typeof localStorage === 'undefined') { + return matchBrowserLocale() + } + const saved = localStorage.getItem(LOCALE_STORAGE_KEY) + if (saved && getLocaleOption(saved)) { + return saved as AppLocaleCode + } + return matchBrowserLocale() +} diff --git a/src/shared/locales/zh-CN/cashier.json b/src/shared/locales/zh-CN/cashier.json new file mode 100644 index 0000000..3e5ed81 --- /dev/null +++ b/src/shared/locales/zh-CN/cashier.json @@ -0,0 +1,20 @@ +{ + "demoOrderTitle": "DaxPay 演示订单", + "remainTime": "支付剩余时间", + "remainTimeShort": "剩余支付时间", + "orderTitle": "订单标题", + "orderNo": "订单编号", + "orderNoLabel": "订单编号:", + "payableAmount": "应付金额", + "selectMethod": "请选择支付方式", + "recommend": "推荐", + "payNow": "立即支付", + "demoPayStarted": "演示:已发起 {name} 支付", + "qrcodePayDemo": "扫码支付(演示)", + "qrcodeTip": "请使用 {name} 扫码完成支付", + "method": { + "wechat": "微信支付", + "alipay": "支付宝", + "union": "银联支付" + } +} diff --git a/src/shared/locales/zh-CN/codePay.json b/src/shared/locales/zh-CN/codePay.json new file mode 100644 index 0000000..8b9fdcd --- /dev/null +++ b/src/shared/locales/zh-CN/codePay.json @@ -0,0 +1,10 @@ +{ + "demoMerchant": "演示收款商户", + "amountLabel": "付款金额", + "remark": "备注", + "addRemark": "添加备注", + "remarkPlaceholder": "请输入支付备注内容", + "amountZero": "金额不可为0", + "confirmPay": "确认支付", + "demoPay": "演示:码牌 {code} 支付 ¥{amount}" +} diff --git a/src/shared/locales/zh-CN/common.json b/src/shared/locales/zh-CN/common.json new file mode 100644 index 0000000..b619011 --- /dev/null +++ b/src/shared/locales/zh-CN/common.json @@ -0,0 +1,11 @@ +{ + "confirm": "确认", + "cancel": "取消", + "save": "保存", + "back": "返回", + "backHome": "返回首页", + "loading": "加载中...", + "tip": "提示", + "operationSuccess": "操作成功!", + "operationFailed": "操作失败!" +} diff --git a/src/shared/locales/zh-CN/error.json b/src/shared/locales/zh-CN/error.json new file mode 100644 index 0000000..6392601 --- /dev/null +++ b/src/shared/locales/zh-CN/error.json @@ -0,0 +1,11 @@ +{ + "notFound": "抱歉,你访问的页面不存在", + "deviceOnlyPc": { + "title": "此页面仅限电脑端访问", + "desc": "请在电脑浏览器中打开本页面" + }, + "deviceOnlyMobile": { + "title": "此页面仅限手机端访问", + "desc": "请用手机扫描二维码,或在手机浏览器中打开本页面" + } +} diff --git a/src/shared/locales/zh-CN/home.json b/src/shared/locales/zh-CN/home.json new file mode 100644 index 0000000..ffe9380 --- /dev/null +++ b/src/shared/locales/zh-CN/home.json @@ -0,0 +1,5 @@ +{ + "welcome": "欢迎使用 DaxPay", + "version": "版本号", + "buildTime": "构建时间" +} diff --git a/src/shared/locales/zh-CN/http.json b/src/shared/locales/zh-CN/http.json new file mode 100644 index 0000000..362abe7 --- /dev/null +++ b/src/shared/locales/zh-CN/http.json @@ -0,0 +1,19 @@ +{ + "requestError": "请求出错,请稍候重试", + "timeout": "接口请求超时,请刷新页面重试!", + "networkError": "网络异常", + "networkErrorDesc": "请检查您的网络连接是否正常", + "status": { + "401": "用户没有权限(令牌、用户名、密码错误)!", + "403": "用户得到授权,但是访问是被禁止的!", + "404": "网络请求错误,未找到该资源!", + "405": "网络请求错误,请求方法未允许!", + "408": "网络请求超时", + "500": "服务器错误,请联系管理员!", + "501": "网络未实现", + "502": "网络错误", + "503": "服务不可用,服务器暂时过载或维护!", + "504": "网络超时", + "505": "http版本不支持该请求!" + } +} diff --git a/src/shared/locales/zh-CN/index.ts b/src/shared/locales/zh-CN/index.ts new file mode 100644 index 0000000..ad6b3c9 --- /dev/null +++ b/src/shared/locales/zh-CN/index.ts @@ -0,0 +1,19 @@ +import cashier from './cashier.json' +import codePay from './codePay.json' +import common from './common.json' +import error from './error.json' +import home from './home.json' +import http from './http.json' +import protocol from './protocol.json' +import statement from './statement.json' + +export default { + common, + http, + cashier, + codePay, + protocol, + statement, + home, + error, +} diff --git a/src/shared/locales/zh-CN/protocol.json b/src/shared/locales/zh-CN/protocol.json new file mode 100644 index 0000000..371c01f --- /dev/null +++ b/src/shared/locales/zh-CN/protocol.json @@ -0,0 +1,8 @@ +{ + "title": "协议", + "typeNotExist": "协议类型不存在", + "loadFailed": "协议加载失败", + "noContent": "暂无协议内容", + "version": "版本 {version}", + "effectiveTime": "生效时间 {time}" +} diff --git a/src/shared/locales/zh-CN/statement.json b/src/shared/locales/zh-CN/statement.json new file mode 100644 index 0000000..3bbfc5e --- /dev/null +++ b/src/shared/locales/zh-CN/statement.json @@ -0,0 +1,25 @@ +{ + "heading": "商户对账单", + "totalAmountSuccess": "交易总额(成功)", + "tradeCount": "交易笔数", + "successRate": "支付成功率", + "empty": "暂无交易记录", + "column": { + "time": "交易时间", + "orderNo": "订单号", + "title": "商品标题", + "amount": "金额", + "channel": "支付通道", + "status": "状态" + }, + "status": { + "success": "成功", + "fail": "失败", + "pending": "处理中" + }, + "channel": { + "wechat": "微信", + "alipay": "支付宝", + "union": "银联" + } +} diff --git a/src/shared/utils/datetime.ts b/src/shared/utils/datetime.ts new file mode 100644 index 0000000..f0d4aaa --- /dev/null +++ b/src/shared/utils/datetime.ts @@ -0,0 +1,68 @@ +/** + * 日期时间格式化工具(基于 dayjs) + * + * - 后端时间字段统一为 `timestamptz(6)`,返回带偏移的 ISO 8601,此处 `.local()` 转为浏览器本地时区显示 + * - 日期/日期时间统一使用 ISO 8601 年-月-日顺序(`YYYY-MM-DD`),消除各国 MM/DD vs DD/MM 歧义 + * - dayjs 全局 locale 由 `setupI18n` 的副作用函数随语言切换同步(仅影响相对时间 fromNow 的本地化文本) + */ +import dayjs from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import localizedFormat from 'dayjs/plugin/localizedFormat' +import relativeTime from 'dayjs/plugin/relativeTime' +import timezone from 'dayjs/plugin/timezone' +import utc from 'dayjs/plugin/utc' +import 'dayjs/locale/en' +import 'dayjs/locale/zh-cn' + +dayjs.extend(utc) +dayjs.extend(timezone) +dayjs.extend(customParseFormat) +dayjs.extend(localizedFormat) +dayjs.extend(relativeTime) + +/** + * 统一日期格式 + * + * 采用 ISO 8601 年-月-日(`YYYY-MM-DD`),不随语言变化, + * 以消除 US 的 `MM/DD/YYYY` 与欧洲的 `DD/MM/YYYY` 之间的歧义。 + */ +const DATE_FORMAT = 'YYYY-MM-DD' + +/** 统一日期时间格式 */ +const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm' + +/** + * 格式化为日期(`YYYY-MM-DD`,统一格式不随语言变化) + * @param iso ISO 8601 字符串(可带时区偏移),转浏览器本地时区显示 + */ +export function formatDate(iso?: string | null): string { + if (!iso) { + return '' + } + const d = dayjs(iso).local() + return d.isValid() ? d.format(DATE_FORMAT) : '' +} + +/** + * 格式化为日期时间(`YYYY-MM-DD HH:mm`,统一格式不随语言变化) + * @param iso ISO 8601 字符串(可带时区偏移),转浏览器本地时区显示 + */ +export function formatDateTime(iso?: string | null): string { + if (!iso) { + return '' + } + const d = dayjs(iso).local() + return d.isValid() ? d.format(DATETIME_FORMAT) : '' +} + +/** + * 相对时间(如"3分钟前"/"3 minutes ago"),跟随 dayjs 全局 locale 本地化 + * @param iso ISO 8601 字符串(可带时区偏移),转浏览器本地时区显示 + */ +export function formatRelativeTime(iso?: string | null): string { + if (!iso) { + return '' + } + const d = dayjs(iso).local() + return d.isValid() ? d.fromNow() : '' +} diff --git a/src/shared/utils/http/axios/checkStatus.ts b/src/shared/utils/http/axios/checkStatus.ts index aeb2acc..c122519 100644 --- a/src/shared/utils/http/axios/checkStatus.ts +++ b/src/shared/utils/http/axios/checkStatus.ts @@ -1,5 +1,12 @@ import { showFailToast } from 'vant' +import { t } from '@/shared/locales' +/** + * HTTP 状态码错误提示 + * + * - 400 及未知状态:提示后端返回的 `msg`(动态内容,不翻译) + * - 401/403/404/405/408/500/501/502/503/504/505:固定文案,走 i18n(http.status.{code}) + */ export function checkStatus(status: number, msg: string): void { switch (status) { case 400: @@ -9,38 +16,38 @@ export function checkStatus(status: number, msg: string): void { // 未登录则跳转登录页面,并携带当前页面的路径 // 在登录成功后返回当前页面,这一步需要在登录页操作。 case 401: - showFailToast('用户没有权限(令牌、用户名、密码错误)!') + showFailToast(t(`http.status.401`)) break case 403: - showFailToast('用户得到授权,但是访问是被禁止的。!') + showFailToast(t('http.status.403')) break // 404请求不存在 case 404: - showFailToast('网络请求错误,未找到该资源!') + showFailToast(t('http.status.404')) break case 405: - showFailToast('网络请求错误,请求方法未允许!') + showFailToast(t('http.status.405')) break case 408: - showFailToast('网络请求超时') + showFailToast(t('http.status.408')) break case 500: - showFailToast('服务器错误,请联系管理员!') + showFailToast(t('http.status.500')) break case 501: - showFailToast('网络未实现') + showFailToast(t('http.status.501')) break case 502: - showFailToast('网络错误') + showFailToast(t('http.status.502')) break case 503: - showFailToast('服务不可用,服务器暂时过载或维护!') + showFailToast(t('http.status.503')) break case 504: - showFailToast('网络超时') + showFailToast(t('http.status.504')) break case 505: - showFailToast('http版本不支持该请求!') + showFailToast(t('http.status.505')) break default: showFailToast(msg) diff --git a/src/shared/utils/http/axios/helper.ts b/src/shared/utils/http/axios/helper.ts index a7cd913..c5915a0 100644 --- a/src/shared/utils/http/axios/helper.ts +++ b/src/shared/utils/http/axios/helper.ts @@ -1,7 +1,5 @@ import { isObject, isString } from '@/shared/utils/is' -const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm' - export function joinTimestamp( join: boolean, restful: T, @@ -19,7 +17,7 @@ export function joinTimestamp(join: boolean, restful = false): string | object { } /** - * @description: Format request parameter time + * @description: 规整请求参数(去除字符串首尾空白) */ export function formatRequestDate(params: Recordable) { if (Object.prototype.toString.call(params) !== '[object Object]') { @@ -27,9 +25,6 @@ export function formatRequestDate(params: Recordable) { } for (const key in params) { - if (params[key] && params[key]._isAMomentObject) { - params[key] = params[key].format(DATE_TIME_FORMAT) - } if (isString(key)) { const value = params[key] if (value) { diff --git a/src/shared/utils/http/axios/index.ts b/src/shared/utils/http/axios/index.ts index 377acbf..d56d766 100644 --- a/src/shared/utils/http/axios/index.ts +++ b/src/shared/utils/http/axios/index.ts @@ -6,6 +6,7 @@ import axios from 'axios' import { showDialog, showFailToast } from 'vant' import { ContentTypeEnum, RequestEnum, ResultEnum } from '@/shared/enums/httpEnum' import { useGlobSetting } from '@/shared/hooks/setting' +import { t } from '@/shared/locales' import { deepMerge, isUrl } from '@/shared/utils' import { isString } from '@/shared/utils/is/' import { setObjToUrlParams } from '@/shared/utils/urlUtils' @@ -50,29 +51,29 @@ const transform: AxiosTransform = { if (!data) { // return '[HTTP] Request has no return value'; - throw new Error('请求出错,请稍候重试') + throw new Error(t('http.requestError')) } - // 这里 code,result,message为 后台统一的字段,需要修改为项目自己的接口返回格式 - const { code, result, message } = data + // 后台统一返回格式:{ code, message, data } + const { code, data: resultData, message } = data // 请求成功 const hasSuccess = data && Reflect.has(data, 'code') && code === ResultEnum.SUCCESS // 是否显示提示信息 if (isShowMessage) { if (hasSuccess && (successMessageText || isShowSuccessMessage)) { showDialog({ - message: successMessageText || message || '操作成功!', + message: successMessageText || message || t('common.operationSuccess'), }).then(() => { // on close }) } else if (!hasSuccess && (errorMessageText || isShowErrorMessage)) { // 是否显示自定义信息提示 - showFailToast(message || errorMessageText || '操作失败!') + showFailToast(message || errorMessageText || t('common.operationFailed')) } else if (!hasSuccess && options.errorMessageMode === 'modal') { // errorMessageMode=‘custom-modal’的时候会显示modal错误弹窗,而不是消息提示,用于一些比较重要的错误 showDialog({ - title: '提示', + title: t('common.tip'), message, }).then(() => { // on close @@ -82,7 +83,7 @@ const transform: AxiosTransform = { // 接口请求成功,直接返回结果 if (code === ResultEnum.SUCCESS) { - return result + return resultData } // 接口请求错误,统一提示错误信息 这里逻辑可以根据项目进行修改 const errorMsg = message @@ -173,13 +174,13 @@ const transform: AxiosTransform = { const err: string = error.toString() try { if (code === 'ECONNABORTED' && message.includes('timeout')) { - showFailToast('接口请求超时,请刷新页面重试!') + showFailToast(t('http.timeout')) return } if (err && err.includes('Network Error')) { showDialog({ - title: '网络异常', - message: '请检查您的网络连接是否正常', + title: t('http.networkError'), + message: t('http.networkErrorDesc'), }) .then(() => {}) .catch(() => {})