feat(h5): 接入国际化(i18n)与统一日期格式

- 引入 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 死代码
This commit is contained in:
bootx
2026-06-29 09:09:09 +08:00
parent 53e4b8d497
commit d7b7b25db2
40 changed files with 800 additions and 123 deletions

View File

@@ -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": {

64
pnpm-lock.yaml generated
View File

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

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { useDesignSetting } from '@/shared/hooks/setting/useDesignSetting'
import { useLocale } from '@/shared/locales'
import { useRouteStore } from '@/shared/store/modules/route'
import { darken, lighten } from '@/shared/utils'
const routeStore = useRouteStore()
const { getDarkMode, getAppTheme, getIsPageAnimate, getPageAnimateType } = useDesignSetting()
// vant 语言包(跟随当前语言,驱动 vant 内置组件文案Dialog 按钮、Picker 等)
const { vantLocale } = useLocale()
// 需要缓存的路由组件
const keepAliveComponents = computed(() => routeStore.keepAliveComponents)
@@ -59,7 +62,7 @@ const getTransitionName = computed(() => {
</script>
<template>
<vanConfigProvider :theme="getDarkMode" :theme-vars="getThemeVars()">
<vanConfigProvider :theme="getDarkMode" :theme-vars="getThemeVars()" :locale="vantLocale">
<RouterView v-slot="{ Component }">
<div class="absolute bottom-0 top-0 w-full overflow-hidden">
<transition :name="getTransitionName" mode="out-in" appear>

View File

@@ -1,6 +1,7 @@
import { createApp } from 'vue'
import MobileApp from '@/mobile/App.vue'
import router, { setupRouter } from '@/mobile/router'
import { setupI18n } from '@/shared/locales'
import { setupStore } from '@/shared/store'
import { useDesignSettingWithOut } from '@/shared/store/modules/designSetting'
import 'vant/es/toast/style'
@@ -25,6 +26,8 @@ export async function createMobileApp() {
const app = createApp(MobileApp)
// 挂载状态管理
setupStore(app)
// 挂载国际化(含 dayjs / vant / <html lang> 初始化副作用)
setupI18n(app)
// 初始化全局主题:跟随系统 prefers-color-scheme不支持手动修改
useDesignSettingWithOut().initSystemListener()
// 挂载路由

View File

@@ -1,34 +1,40 @@
<script lang="ts" setup>
import { showToast } from 'vant'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
defineOptions({ name: 'CashierPage' })
const { t } = useI18n()
const route = useRoute()
// 演示订单数据mock不连后端
const order = {
title: 'DaxPay 演示订单',
const order = computed(() => ({
title: t('cashier.demoOrderTitle'),
orderNo: route.params.orderNo as string,
amount: 0.01,
// 15 分钟后过期
expiredTime: Date.now() + 15 * 60 * 1000,
}
}))
// 演示支付方式mock
// 演示支付方式mock— name 由 i18n 按 icon 渲染
interface PayMethod {
id: string
name: string
icon: 'wechat' | 'alipay' | 'union'
recommend?: boolean
}
const payMethods: PayMethod[] = [
{ id: '1', name: '微信支付', icon: 'wechat', recommend: true },
{ id: '2', name: '支付宝', icon: 'alipay' },
{ id: '3', name: '银联支付', icon: 'union' },
{ id: '1', icon: 'wechat', recommend: true },
{ id: '2', icon: 'alipay' },
{ id: '3', icon: 'union' },
]
// 支付方式名称(跟随语言)
function methodName(item: PayMethod) {
return t(`cashier.method.${item.icon}`)
}
// 选中的支付方式(默认推荐项)
const selectId = ref<string>(payMethods[0]!.id)
@@ -44,7 +50,7 @@ const countdown = computed(() => {
})
function startCountdown() {
remainSeconds.value = Math.max(0, Math.floor((order.expiredTime - Date.now()) / 1000))
remainSeconds.value = Math.max(0, Math.floor((order.value.expiredTime - Date.now()) / 1000))
timer = setInterval(() => {
if (remainSeconds.value > 0) {
remainSeconds.value--
@@ -60,7 +66,7 @@ function pay() {
const selected = payMethods.find(it => it.id === selectId.value)
showToast({
type: 'success',
message: `演示:已发起 ${selected?.name ?? ''} 支付`,
message: t('cashier.demoPayStarted', { name: selected ? methodName(selected) : '' }),
})
}
@@ -81,18 +87,18 @@ onUnmounted(() => {
<span class="cashier__amount">{{ order.amount }}</span>
</div>
<div class="cashier__countdown">
<span class="cashier__countdown-label">支付剩余时间</span>
<span class="cashier__countdown-label">{{ t('cashier.remainTime') }}</span>
<span class="cashier__countdown-time">
{{ countdown.h }}:{{ countdown.m }}:{{ countdown.s }}
</span>
</div>
<div class="cashier__detail">
<div class="cashier__detail-row">
<span>订单标题</span>
<span>{{ t('cashier.orderTitle') }}</span>
<span>{{ order.title }}</span>
</div>
<div class="cashier__detail-row">
<span>订单编号</span>
<span>{{ t('cashier.orderNo') }}</span>
<span>{{ order.orderNo }}</span>
</div>
</div>
@@ -101,7 +107,7 @@ onUnmounted(() => {
<!-- 支付方式选择 -->
<div class="cashier__body enter-y">
<div class="cashier__section-title">
请选择支付方式
{{ t('cashier.selectMethod') }}
</div>
<div class="cashier__list">
<div
@@ -125,8 +131,8 @@ onUnmounted(() => {
</svg>
</div>
<div class="cashier__pay-name">
{{ item.name }}
<span v-if="item.recommend" class="cashier__recommend">推荐</span>
{{ methodName(item) }}
<span v-if="item.recommend" class="cashier__recommend">{{ t('cashier.recommend') }}</span>
</div>
</div>
<div class="cashier__radio" :class="{ 'cashier__radio--checked': item.id === selectId }" />
@@ -137,7 +143,7 @@ onUnmounted(() => {
<!-- 底部支付按钮 -->
<div class="cashier__footer enter-y">
<button class="cashier__pay-btn" @click="pay">
立即支付 {{ order.amount }}
{{ t('cashier.payNow') }} {{ order.amount }}
</button>
</div>
</div>

View File

@@ -1,16 +1,18 @@
<script lang="ts" setup>
import { showNotify } from 'vant'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
defineOptions({ name: 'CodePayPage' })
const { t } = useI18n()
const route = useRoute()
const { code } = route.params
// 演示码牌配置mock金额类型 random 展示自定义金额输入)
const cashierInfo = ref({
name: '演示收款商户',
name: t('codePay.demoMerchant'),
amountType: 'random' as 'random' | 'fixed',
amount: '0',
})
@@ -41,12 +43,12 @@ function onDelete() {
function pay() {
const value = Number(amount.value)
if (!value) {
showNotify({ type: 'warning', message: '金额不可为0' })
showNotify({ type: 'warning', message: t('codePay.amountZero') })
return
}
showNotify({
type: 'success',
message: `演示:码牌 ${code} 支付 ¥${amount.value}`,
message: t('codePay.demoPay', { code, amount: amount.value }),
})
}
</script>
@@ -68,7 +70,7 @@ function pay() {
{{ cashierInfo.name }}
</div>
<div class="code-pay__amount-label">
付款金额
{{ t('codePay.amountLabel') }}
</div>
<div class="code-pay__amount">
<span class="code-pay__currency">¥</span>
@@ -80,9 +82,9 @@ function pay() {
<!-- 备注单元格 -->
<div class="code-pay__remark enter-y" @click="showRemark = true">
<span class="code-pay__remark-label">备注</span>
<span class="code-pay__remark-label">{{ t('codePay.remark') }}</span>
<div class="code-pay__remark-value">
<span v-if="!description" class="code-pay__remark-placeholder">添加备注</span>
<span v-if="!description" class="code-pay__remark-placeholder">{{ t('codePay.addRemark') }}</span>
<span v-else>{{ description }}</span>
</div>
<!-- 箭头图标用 SVG 替代文字 -->
@@ -94,10 +96,10 @@ function pay() {
<!-- 备注弹窗 -->
<van-dialog
v-model:show="showRemark"
title="添加备注"
:title="t('codePay.addRemark')"
show-cancel-button
confirm-button-text="保存"
cancel-button-text="取消"
:confirm-button-text="t('common.save')"
:cancel-button-text="t('common.cancel')"
confirm-button-color="#5d9dfe"
cancel-button-color="#999"
>
@@ -107,7 +109,7 @@ function pay() {
autosize
type="textarea"
:maxlength="50"
placeholder="请输入支付备注内容"
:placeholder="t('codePay.remarkPlaceholder')"
show-word-limit
class="code-pay__remark-field"
/>
@@ -118,7 +120,7 @@ function pay() {
v-if="cashierInfo.amountType === 'random'"
theme="custom"
extra-key="."
close-button-text="确认支付"
:close-button-text="t('codePay.confirmPay')"
:show="true"
@close="pay"
@input="onInput"

View File

@@ -1,6 +1,9 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
defineOptions({ name: 'MobileDeviceOnly' })
const { t } = useI18n()
const router = useRouter()
function goHome() {
@@ -11,13 +14,13 @@ function goHome() {
<template>
<div class="device-only">
<p class="device-only__title">
此页面仅限电脑端访问
{{ t('error.deviceOnlyPc.title') }}
</p>
<p class="device-only__desc">
请在电脑浏览器中打开本页面
{{ t('error.deviceOnlyPc.desc') }}
</p>
<van-button type="primary" @click="goHome">
返回首页
{{ t('common.backHome') }}
</van-button>
</div>
</template>

View File

@@ -1,6 +1,9 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
defineOptions({ name: 'ErrorPageSon' })
const { t } = useI18n()
const router = useRouter()
function goHome() {
router.push('/')
@@ -14,10 +17,10 @@ function goHome() {
</div>
<div class="text-center">
<h1 class="m-4 text-base">
抱歉你访问的页面不存在
{{ t('error.notFound') }}
</h1>
<van-button type="primary" @click="goHome">
回到首页
{{ t('common.backHome') }}
</van-button>
</div>
</div>

View File

@@ -1,6 +1,10 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineOptions({ name: 'HomePage' })
const { t } = useI18n()
// 由 vite define 注入的项目信息
const { pkg, lastBuildTime } = __APP_INFO__
const version = pkg.version
@@ -14,14 +18,14 @@ const version = pkg.version
<img class="home__logo" src="/logo.svg" alt="DaxPay">
<!-- 欢迎语 -->
<div class="home__title">
欢迎使用 DaxPay
{{ t('home.welcome') }}
</div>
</div>
</div>
<!-- 底部项目信息 -->
<div class="home__footer">
<p>版本号: v{{ version }}</p>
<p>构建时间: {{ lastBuildTime }}</p>
<p>{{ t('home.version') }}: v{{ version }}</p>
<p>{{ t('home.buildTime') }}: {{ lastBuildTime }}</p>
</div>
</div>
</template>

View File

@@ -1,7 +1,14 @@
<script setup lang="ts">
import { useLocale } from '@/shared/locales'
defineOptions({ name: 'PcApp' })
// PC 端 vant 组件(如收银台、协议页的 Dialog/NavBar语言包跟随当前语言
const { vantLocale } = useLocale()
</script>
<template>
<RouterView />
<vanConfigProvider :locale="vantLocale">
<RouterView />
</vanConfigProvider>
</template>

View File

@@ -4,6 +4,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { createApp } from 'vue'
import PcApp from '@/pc/App.vue'
import { pcRouter, setupPCRouter } from '@/pc/router'
import { setupI18n } from '@/shared/locales'
// PC 端独立的 pinia 实例(与移动端状态隔离,同一时刻仅一套应用运行)
const pcStore = createPinia()
@@ -26,6 +27,8 @@ function setupPCStore(app: App) {
export async function createPCApp() {
const app = createApp(PcApp)
setupPCStore(app)
// 挂载国际化PC 端独立实例,与移动端共用同一 i18n 模块)
setupI18n(app)
setupPCRouter(app)
await pcRouter.isReady()
return app

View File

@@ -1,6 +1,10 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineOptions({ name: 'PcHome' })
const { t } = useI18n()
// 由 vite define 注入的项目信息(与移动端首页一致)
const { pkg, lastBuildTime } = __APP_INFO__
const version = pkg.version
@@ -14,14 +18,14 @@ const version = pkg.version
<img class="pc-home__logo" src="/logo.svg" alt="DaxPay">
<!-- 欢迎语 -->
<div class="pc-home__title">
欢迎使用 DaxPay
{{ t('home.welcome') }}
</div>
</div>
</div>
<!-- 底部项目信息 -->
<div class="pc-home__footer">
<p>版本号: v{{ version }}</p>
<p>构建时间: {{ lastBuildTime }}</p>
<p>{{ t('home.version') }}: v{{ version }}</p>
<p>{{ t('home.buildTime') }}: {{ lastBuildTime }}</p>
</div>
</div>
</template>

View File

@@ -1,8 +1,10 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import notFoundImg from '@/shared/assets/icons/exception/404.svg'
defineOptions({ name: 'PcNotFound' })
const { t } = useI18n()
const router = useRouter()
function goHome() {
@@ -14,10 +16,10 @@ function goHome() {
<div class="pc-notfound">
<img class="pc-notfound__img" :src="notFoundImg" alt="404">
<h1 class="pc-notfound__text">
抱歉你访问的页面不存在
{{ t('error.notFound') }}
</h1>
<button class="pc-notfound__btn" type="button" @click="goHome">
返回首页
{{ t('common.backHome') }}
</button>
</div>
</template>

View File

@@ -1,32 +1,38 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
defineOptions({ name: 'PcCashier' })
const { t } = useI18n()
const route = useRoute()
// 演示订单数据mock不连后端
const order = {
title: 'DaxPay 演示订单',
const order = computed(() => ({
title: t('cashier.demoOrderTitle'),
orderNo: route.params.orderNo as string,
amount: 0.01,
expiredTime: Date.now() + 15 * 60 * 1000,
}
}))
// 演示支付方式mock
// 演示支付方式mock— name 由 i18n 按 icon 渲染
interface PayMethod {
id: string
name: string
icon: 'wechat' | 'alipay' | 'union'
recommend?: boolean
}
const payMethods: PayMethod[] = [
{ id: '1', name: '微信支付', icon: 'wechat', recommend: true },
{ id: '2', name: '支付宝', icon: 'alipay' },
{ id: '3', name: '银联支付', icon: 'union' },
{ id: '1', icon: 'wechat', recommend: true },
{ id: '2', icon: 'alipay' },
{ id: '3', icon: 'union' },
]
// 支付方式名称(跟随语言)
function methodName(item: PayMethod) {
return t(`cashier.method.${item.icon}`)
}
const selectId = ref<string>(payMethods[0]!.id)
// 倒计时(原生 setInterval项目无 @vueuse/core
@@ -41,7 +47,7 @@ const countdown = computed(() => {
})
function startCountdown() {
remainSeconds.value = Math.max(0, Math.floor((order.expiredTime - Date.now()) / 1000))
remainSeconds.value = Math.max(0, Math.floor((order.value.expiredTime - Date.now()) / 1000))
timer = setInterval(() => {
if (remainSeconds.value > 0) {
remainSeconds.value--
@@ -73,7 +79,7 @@ onUnmounted(() => {
<!-- 订单头部 -->
<div class="pc-cashier__header">
<div class="pc-cashier__countdown">
<span class="pc-cashier__countdown-label">剩余支付时间</span>
<span class="pc-cashier__countdown-label">{{ t('cashier.remainTimeShort') }}</span>
<span class="pc-cashier__countdown-time">
{{ countdown.h }}:{{ countdown.m }}:{{ countdown.s }}
</span>
@@ -82,13 +88,13 @@ onUnmounted(() => {
{{ order.title }}
</div>
<div class="pc-cashier__price">
<span class="pc-cashier__price-label">应付金额</span>
<span class="pc-cashier__price-label">{{ t('cashier.payableAmount') }}</span>
<p>
<span></span>{{ order.amount }}
</p>
</div>
<div class="pc-cashier__order-no">
订单编号: {{ order.orderNo }}
{{ t('cashier.orderNoLabel') }}{{ order.orderNo }}
</div>
</div>
@@ -115,8 +121,8 @@ onUnmounted(() => {
<path fill="#fff" d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm130.4 590.4c-39.1 27.8-86.8 43.6-138.4 43.6-130.4 0-236-105.6-236-236s105.6-236 236-236c45.6 0 88.2 13 124 35.4l-58.5 58.5c-19.4-9-40.9-14-63.5-14-85.2 0-154.4 69.2-154.4 154.4S384.8 544.3 470 544.3c52.6 0 98.9-26.4 126.4-66.6l70.6 70.6c-7.8 2.1-15.8 4-24.6 6.7z" />
</svg>
</span>
<span class="pc-cashier__method-name">{{ item.name }}</span>
<span v-if="item.recommend" class="pc-cashier__recommend">推荐</span>
<span class="pc-cashier__method-name">{{ methodName(item) }}</span>
<span v-if="item.recommend" class="pc-cashier__recommend">{{ t('cashier.recommend') }}</span>
</div>
</div>
@@ -156,10 +162,10 @@ onUnmounted(() => {
</div>
<div class="pc-cashier__qrcode-tip">
<p class="pc-cashier__qrcode-title">
扫码支付演示
{{ t('cashier.qrcodePayDemo') }}
</p>
<p class="pc-cashier__qrcode-sub">
请使用 {{ payMethods.find(i => i.id === selectId)?.name }} 扫码完成支付
{{ t('cashier.qrcodeTip', { name: methodName(payMethods.find(i => i.id === selectId)!) }) }}
</p>
</div>
</div>
@@ -167,7 +173,7 @@ onUnmounted(() => {
<!-- 立即支付按钮支付前 -->
<button v-if="!showQrcode" class="pc-cashier__pay-btn" @click="pay">
立即支付
{{ t('cashier.payNow') }}
</button>
</div>
</div>

View File

@@ -1,6 +1,9 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
defineOptions({ name: 'PcDeviceOnly' })
const { t } = useI18n()
const router = useRouter()
function goHome() {
@@ -11,13 +14,13 @@ function goHome() {
<template>
<div class="pc-device-only">
<p class="pc-device-only__title">
此页面仅限手机端访问
{{ t('error.deviceOnlyMobile.title') }}
</p>
<p class="pc-device-only__desc">
请用手机扫描二维码或在手机浏览器中打开本页面
{{ t('error.deviceOnlyMobile.desc') }}
</p>
<button class="pc-device-only__btn" type="button" @click="goHome">
返回首页
{{ t('common.backHome') }}
</button>
</div>
</template>

View File

@@ -1,33 +1,40 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
defineOptions({ name: 'MerchantStatement' })
const { t } = useI18n()
// 支付通道与状态用 code 表示,文案由 i18n 渲染
type ChannelCode = 'wechat' | 'alipay' | 'union'
type StatusCode = 'success' | 'fail' | 'pending'
interface Trade {
time: string
orderNo: string
title: string
amount: number
channel: '微信' | '支付宝' | '银联'
status: '成功' | '失败' | '处理中'
channel: ChannelCode
status: StatusCode
}
// 演示交易流水mock
const trades: Trade[] = [
{ time: '2026-06-24 10:23:11', orderNo: 'DEMO20260624102311001', title: '会员订阅', amount: 99.00, channel: '微信', status: '成功' },
{ time: '2026-06-24 09:48:32', orderNo: 'DEMO20260624094832002', title: '商品订单', amount: 158.50, channel: '支付宝', status: '成功' },
{ time: '2026-06-24 09:12:05', orderNo: 'DEMO20260624091205003', title: '虚拟商品', amount: 12.00, channel: '微信', status: '处理中' },
{ time: '2026-06-23 22:01:47', orderNo: 'DEMO20260623220147004', title: '服务费', amount: 300.00, channel: '银联', status: '成功' },
{ time: '2026-06-23 18:34:20', orderNo: 'DEMO20260623183420005', title: '商品订单', amount: 46.80, channel: '支付宝', status: '失败' },
{ time: '2026-06-23 15:09:56', orderNo: 'DEMO20260623150956006', title: '会员订阅', amount: 99.00, channel: '微信', status: '成功' },
{ time: '2026-06-23 11:42:13', orderNo: 'DEMO20260623114213007', title: '虚拟商品', amount: 6.00, channel: '微信', status: '成功' },
{ time: '2026-06-23 08:55:30', orderNo: 'DEMO20260623085530008', title: '商品订单', amount: 218.00, channel: '支付宝', status: '成功' },
{ time: '2026-06-24 10:23:11', orderNo: 'DEMO20260624102311001', title: '会员订阅', amount: 99.00, channel: 'wechat', status: 'success' },
{ time: '2026-06-24 09:48:32', orderNo: 'DEMO20260624094832002', title: '商品订单', amount: 158.50, channel: 'alipay', status: 'success' },
{ time: '2026-06-24 09:12:05', orderNo: 'DEMO20260624091205003', title: '虚拟商品', amount: 12.00, channel: 'wechat', status: 'pending' },
{ time: '2026-06-23 22:01:47', orderNo: 'DEMO20260623220147004', title: '服务费', amount: 300.00, channel: 'union', status: 'success' },
{ time: '2026-06-23 18:34:20', orderNo: 'DEMO20260623183420005', title: '商品订单', amount: 46.80, channel: 'alipay', status: 'fail' },
{ time: '2026-06-23 15:09:56', orderNo: 'DEMO20260623150956006', title: '会员订阅', amount: 99.00, channel: 'wechat', status: 'success' },
{ time: '2026-06-23 11:42:13', orderNo: 'DEMO20260623114213007', title: '虚拟商品', amount: 6.00, channel: 'wechat', status: 'success' },
{ time: '2026-06-23 08:55:30', orderNo: 'DEMO20260623085530008', title: '商品订单', amount: 218.00, channel: 'alipay', status: 'success' },
]
// 汇总
const summary = computed(() => {
const success = trades.filter(t => t.status === '成功')
const totalAmount = success.reduce((sum, t) => sum + t.amount, 0)
const success = trades.filter(trade => trade.status === 'success')
const totalAmount = success.reduce((sum, trade) => sum + trade.amount, 0)
const successRate = trades.length ? (success.length / trades.length) * 100 : 0
return {
totalAmount: totalAmount.toFixed(2),
@@ -38,9 +45,19 @@ const summary = computed(() => {
}
})
// 状态文案
function statusText(status: StatusCode) {
return t(`statement.status.${status}`)
}
// 通道文案
function channelText(channel: ChannelCode) {
return t(`statement.channel.${channel}`)
}
// 状态对应样式类
function statusClass(status: Trade['status']) {
return { 成功: 'is-success', 失败: 'is-fail', 处理中: 'is-pending' }[status]
function statusClass(status: StatusCode) {
return { success: 'is-success', fail: 'is-fail', pending: 'is-pending' }[status]
}
</script>
@@ -48,7 +65,7 @@ function statusClass(status: Trade['status']) {
<div class="statement">
<h1 class="statement__heading">
<span class="statement__heading-bar" />
商户对账单
{{ t('statement.heading') }}
</h1>
<!-- 汇总卡片分色 + 图标 + 左侧色条 -->
@@ -63,7 +80,7 @@ function statusClass(status: Trade['status']) {
<path fill="currentColor" d="M832 64 192 64C121.3 64 64 121.3 64 192l0 640c0 70.7 57.3 128 128 128l640 0c70.7 0 128-57.3 128-128L960 192C960 121.3 902.7 64 832 64zM192 128l640 0c35.4 0 64 28.6 64 64L192 192C156.6 192 128 163.4 128 128 128 92.6 156.6 128 192 128zM896 832c0 35.4-28.6 64-64 64L192 896c-35.4 0-64-28.6-64-64L128 244.2C148.4 252.6 170.6 256 192 256l704 0c35.4 0 64 28.6 64 64L960 832zM704 544c-35.4 0-64-28.6-64-64s28.6-64 64-64 64 28.6 64 64S739.4 544 704 544z" />
</svg>
</span>
<span class="statement__summary-label">交易总额成功</span>
<span class="statement__summary-label">{{ t('statement.totalAmountSuccess') }}</span>
</div>
<span class="statement__summary-value statement__summary-value--primary">{{ summary.totalAmount }}</span>
</div>
@@ -78,7 +95,7 @@ function statusClass(status: Trade['status']) {
<path fill="currentColor" d="M832 128 192 128c-35.2 0-64 28.8-64 64l0 640c0 35.2 28.8 64 64 64l640 0c35.2 0 64-28.8 64-64L896 192C896 156.8 867.2 128 832 128zM832 832 192 832 192 192l640 0L832 832zM320 416l384 0c17.6 0 32-14.4 32-32s-14.4-32-32-32L320 352c-17.6 0-32 14.4-32 32S302.4 416 320 416zM320 544l384 0c17.6 0 32-14.4 32-32s-14.4-32-32-32L320 480c-17.6 0-32 14.4-32 32S302.4 544 320 544zM320 672l256 0c17.6 0 32-14.4 32-32s-14.4-32-32-32L320 608c-17.6 0-32 14.4-32 32S302.4 672 320 672z" />
</svg>
</span>
<span class="statement__summary-label">交易笔数</span>
<span class="statement__summary-label">{{ t('statement.tradeCount') }}</span>
</div>
<span class="statement__summary-value">{{ summary.count }}</span>
</div>
@@ -93,7 +110,7 @@ function statusClass(status: Trade['status']) {
<path fill="currentColor" d="M832 192 640 192c-17.6 0-32 14.4-32 32s14.4 32 32 32l115.2 0L512 499.2 379.2 366.4c-12.8-12.8-32-12.8-44.8 0L160 540.8c-12.8 12.8-12.8 32 0 44.8 6.4 6.4 14.4 9.6 22.4 9.6s16-3.2 22.4-9.6l152-152 132.8 132.8c12.8 12.8 32 12.8 44.8 0L832 268.8 832 384c0 17.6 14.4 32 32 32s32-14.4 32-32L896 224C896 206.4 881.6 192 864 192L832 192zM864 768 160 768c-17.6 0-32 14.4-32 32s14.4 32 32 32l704 0c17.6 0 32-14.4 32-32S881.6 768 864 768z" />
</svg>
</span>
<span class="statement__summary-label">支付成功率</span>
<span class="statement__summary-label">{{ t('statement.successRate') }}</span>
</div>
<span class="statement__summary-value" :class="summary.rateHealthy ? 'statement__summary-value--success' : 'statement__summary-value--warning'">{{ summary.successRate }}%</span>
</div>
@@ -104,31 +121,31 @@ function statusClass(status: Trade['status']) {
<table class="statement__table">
<thead>
<tr>
<th>交易时间</th>
<th>订单号</th>
<th>商品标题</th>
<th>{{ t('statement.column.time') }}</th>
<th>{{ t('statement.column.orderNo') }}</th>
<th>{{ t('statement.column.title') }}</th>
<th class="statement__th-amount">
金额
{{ t('statement.column.amount') }}
</th>
<th>支付通道</th>
<th>状态</th>
<th>{{ t('statement.column.channel') }}</th>
<th>{{ t('statement.column.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="t in trades" :key="t.orderNo">
<td>{{ t.time }}</td>
<tr v-for="trade in trades" :key="trade.orderNo">
<td>{{ trade.time }}</td>
<td class="statement__mono">
{{ t.orderNo }}
{{ trade.orderNo }}
</td>
<td>{{ t.title }}</td>
<td>{{ trade.title }}</td>
<td class="statement__amount">
{{ t.amount.toFixed(2) }}
{{ trade.amount.toFixed(2) }}
</td>
<td>{{ t.channel }}</td>
<td>{{ channelText(trade.channel) }}</td>
<td>
<span class="statement__status" :class="statusClass(t.status)">
<span class="statement__status" :class="statusClass(trade.status)">
<i class="statement__status-dot" />
{{ t.status }}
{{ statusText(trade.status) }}
</span>
</td>
</tr>
@@ -138,7 +155,7 @@ function statusClass(status: Trade['status']) {
<!-- 空状态 -->
<div v-else class="statement__empty">
<span class="statement__empty-text">暂无交易记录</span>
<span class="statement__empty-text">{{ t('statement.empty') }}</span>
</div>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
{
"welcome": "Welcome to DaxPay",
"version": "Version",
"buildTime": "Build time"
}

View File

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

View File

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

View File

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

View File

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

124
src/shared/locales/index.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* 国际化核心装配
*
* - locale 状态以 vue-i18n 实例为单一事实源composition 模式)
* - 语言切换副作用dayjs locale / vant 语言包 / `<html lang>` / localStorage 持久化
* - mobile 与 pc 是两个独立 Vue 实例(各自独立 piniai18n 实例由两端 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<Record<string, any> | 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 / `<html lang>`
*
* 注意:此处**不**写 localStorage。自动探测/初始化若落盘,
* 会让陈旧值锁定语言、使后续浏览器语言变化失效。
* 仅显式切换(`setLocale`)才持久化。
*/
function applySideEffects(code: AppLocaleCode) {
const opt: AppLocaleOption = getLocaleOption(code) ?? APP_LOCALES[0]!
// dayjs 全局 locale影响 fromNow / localizedFormat 等本地化输出)
dayjs.locale(opt.dayjsLocale)
// 同步 <html lang> 属性
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, unknown>): string {
return i18n.global.t(key, named ?? ({} as Record<string, unknown>))
}
/**
* 组件内语言组合式 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

View File

@@ -0,0 +1,94 @@
/**
* 语言选项与浏览器探测
*
* 语言代码与后端 common-i18n 对齐:仅支持 zh-CN / en-US。
*/
/** 应用支持的语言代码(与后端 common-i18n 一致) */
export type AppLocaleCode = 'zh-CN' | 'en-US'
export interface AppLocaleOption {
/** 语言代码 */
code: AppLocaleCode
/** `<html lang>` 属性值 */
htmlLang: string
/** dayjs locale 名称 */
dayjsLocale: string
/** vant 语言包动态导入函数 */
vantLoader: () => Promise<{ default: Record<string, any> }>
}
/** 全部支持的语言选项 */
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<string, AppLocaleOption>(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()
}

View File

@@ -0,0 +1,20 @@
{
"demoOrderTitle": "DaxPay 演示订单",
"remainTime": "支付剩余时间",
"remainTimeShort": "剩余支付时间",
"orderTitle": "订单标题",
"orderNo": "订单编号",
"orderNoLabel": "订单编号:",
"payableAmount": "应付金额",
"selectMethod": "请选择支付方式",
"recommend": "推荐",
"payNow": "立即支付",
"demoPayStarted": "演示:已发起 {name} 支付",
"qrcodePayDemo": "扫码支付(演示)",
"qrcodeTip": "请使用 {name} 扫码完成支付",
"method": {
"wechat": "微信支付",
"alipay": "支付宝",
"union": "银联支付"
}
}

View File

@@ -0,0 +1,10 @@
{
"demoMerchant": "演示收款商户",
"amountLabel": "付款金额",
"remark": "备注",
"addRemark": "添加备注",
"remarkPlaceholder": "请输入支付备注内容",
"amountZero": "金额不可为0",
"confirmPay": "确认支付",
"demoPay": "演示:码牌 {code} 支付 ¥{amount}"
}

View File

@@ -0,0 +1,11 @@
{
"confirm": "确认",
"cancel": "取消",
"save": "保存",
"back": "返回",
"backHome": "返回首页",
"loading": "加载中...",
"tip": "提示",
"operationSuccess": "操作成功!",
"operationFailed": "操作失败!"
}

View File

@@ -0,0 +1,11 @@
{
"notFound": "抱歉,你访问的页面不存在",
"deviceOnlyPc": {
"title": "此页面仅限电脑端访问",
"desc": "请在电脑浏览器中打开本页面"
},
"deviceOnlyMobile": {
"title": "此页面仅限手机端访问",
"desc": "请用手机扫描二维码,或在手机浏览器中打开本页面"
}
}

View File

@@ -0,0 +1,5 @@
{
"welcome": "欢迎使用 DaxPay",
"version": "版本号",
"buildTime": "构建时间"
}

View File

@@ -0,0 +1,19 @@
{
"requestError": "请求出错,请稍候重试",
"timeout": "接口请求超时,请刷新页面重试!",
"networkError": "网络异常",
"networkErrorDesc": "请检查您的网络连接是否正常",
"status": {
"401": "用户没有权限(令牌、用户名、密码错误)!",
"403": "用户得到授权,但是访问是被禁止的!",
"404": "网络请求错误,未找到该资源!",
"405": "网络请求错误,请求方法未允许!",
"408": "网络请求超时",
"500": "服务器错误,请联系管理员!",
"501": "网络未实现",
"502": "网络错误",
"503": "服务不可用,服务器暂时过载或维护!",
"504": "网络超时",
"505": "http版本不支持该请求!"
}
}

View File

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

View File

@@ -0,0 +1,8 @@
{
"title": "协议",
"typeNotExist": "协议类型不存在",
"loadFailed": "协议加载失败",
"noContent": "暂无协议内容",
"version": "版本 {version}",
"effectiveTime": "生效时间 {time}"
}

View File

@@ -0,0 +1,25 @@
{
"heading": "商户对账单",
"totalAmountSuccess": "交易总额(成功)",
"tradeCount": "交易笔数",
"successRate": "支付成功率",
"empty": "暂无交易记录",
"column": {
"time": "交易时间",
"orderNo": "订单号",
"title": "商品标题",
"amount": "金额",
"channel": "支付通道",
"status": "状态"
},
"status": {
"success": "成功",
"fail": "失败",
"pending": "处理中"
},
"channel": {
"wechat": "微信",
"alipay": "支付宝",
"union": "银联"
}
}

View File

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

View File

@@ -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固定文案走 i18nhttp.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)

View File

@@ -1,7 +1,5 @@
import { isObject, isString } from '@/shared/utils/is'
const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm'
export function joinTimestamp<T extends boolean>(
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) {

View File

@@ -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'))
}
// 这里 coderesultmessage为 后台统一的字段,需要修改为项目自己的接口返回格式
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(() => {})