refactor(aggregate): 环境页路由合并至父级并清理 EnvPage

- 各环境页(alipay/wechat/union-pay/douyin)合并至 Aggregate 父级子路由, 避免 entry 到环境页跳转触发顶层 transition remount 白屏

- 删除通用 EnvPage.vue(已由各环境专属页替代)

- defaultTitle 由聚合支付改为订单支付

- 码牌 CodePayShell 注释对齐
This commit is contained in:
bootx
2026-07-20 15:37:26 +08:00
parent 741fcf29ac
commit 8138d93f62
17 changed files with 55 additions and 368 deletions

View File

@@ -47,78 +47,48 @@ const routeModuleList: Array<RouteRecordRaw> = [
},
],
},
// 聚合扫码:环境页须先于入口注册(避免 wechat 等被 :orderNo 吞掉)
{
path: RoutePath.AGGREGATE_WECHAT,
name: 'AggregateWechat',
component: Layout,
meta: { title: '聚合支付' },
children: [
{
path: '',
name: 'AggregateWechatPage',
component: () => import('@/mobile/views/aggregate/wechat/Index.vue'),
},
],
},
{
path: RoutePath.AGGREGATE_ALIPAY,
name: 'AggregateAlipay',
component: Layout,
meta: { title: '聚合支付' },
children: [
{
path: '',
name: 'AggregateAlipayPage',
component: () => import('@/mobile/views/aggregate/alipay/Index.vue'),
},
],
},
{
path: RoutePath.AGGREGATE_UNION,
name: 'AggregateUnion',
component: Layout,
meta: { title: '聚合支付' },
children: [
{
path: '',
name: 'AggregateUnionPage',
component: () => import('@/mobile/views/aggregate/union-pay/Index.vue'),
},
],
},
{
path: RoutePath.AGGREGATE_DOUYIN,
name: 'AggregateDouyin',
component: Layout,
meta: { title: '聚合支付' },
children: [
{
path: '',
name: 'AggregateDouyinPage',
component: () => import('@/mobile/views/aggregate/douyin/Index.vue'),
},
],
},
// 聚合扫码:入口 + 非宿主提示 统一在 Aggregate 父级下,避免跨顶层路由跳转
// 触发 App.vue <transition mode="out-in"> 整页 remount Layout 与 Entry.vue 的
// onBeforeMount router.replace 竞争(曾导致 /aggregate/unsupported 首次白屏、刷新才正常)
// 注:环境页 wechat/alipay/union-pay/douyin 仍为独立顶层路由(仅宿主内访问,无此问题)
// 聚合扫码:入口 + 非宿主提示 + 各宿主环境页 统一在 Aggregate 父级下作为子路由
// 让 App.vue 顶层 transition keymatched[0].name在 entry → 环境页跳转时保持不变(都是 Aggregate
// 避免整页 remount 与 Entry.vue 的 onBeforeMount router.replace 竞争导致白屏
// (曾发生在支付宝 webviewonBeforeMount 跨顶层 replace 与 <transition mode="out-in" appear> 的 enter 死锁)
// 注意静态段unsupported/alipay/wechat/union-pay/douyin必须排在 :orderNo 之前,否则会被当订单号吞掉
// URL 契约不变,仍是 /aggregate/:orderNo、/aggregate/unsupported、/aggregate/{env}/:orderNo
{
path: RoutePath.AGGREGATE_GROUP,
name: 'Aggregate',
component: Layout,
meta: {
title: '聚合支付',
},
children: [
// 非宿主提示:静态 path 段,须在 :orderNo 之前注册,避免 'unsupported' 被当 orderNo 吞掉
// 非宿主提示:静态 path 段
{
path: 'unsupported',
name: 'AggregateUnsupportedPage',
component: () => import('@/mobile/views/aggregate/unsupported.vue'),
},
// 入口UA 探测后 replace 到环境页
// 微信环境页
{
path: 'wechat/:orderNo',
name: 'AggregateWechatPage',
component: () => import('@/mobile/views/aggregate/wechat/Index.vue'),
},
// 支付宝环境页
{
path: 'alipay/:orderNo',
name: 'AggregateAlipayPage',
component: () => import('@/mobile/views/aggregate/alipay/Index.vue'),
},
// 云闪付环境页
{
path: 'union-pay/:orderNo',
name: 'AggregateUnionPage',
component: () => import('@/mobile/views/aggregate/union-pay/Index.vue'),
},
// 抖音环境页
{
path: 'douyin/:orderNo',
name: 'AggregateDouyinPage',
component: () => import('@/mobile/views/aggregate/douyin/Index.vue'),
},
// 入口UA 探测后 replace 到环境页(静态段已注册完毕,:orderNo 兜底匹配订单号)
{
path: ':orderNo',
name: 'AggregateEntry',

View File

@@ -12,12 +12,12 @@ defineOptions({ name: 'AggregateEntry' })
const route = useRoute()
const router = useRouter()
/** clientEnv → 路由 name */
/** clientEnv → 路由 name(指向 Aggregate 父级下的环境页子路由,不跨顶层以避免 transition 竞争) */
const ENV_ROUTE_NAME: Record<string, string> = {
wechat: 'AggregateWechat',
alipay: 'AggregateAlipay',
union_pay: 'AggregateUnion',
douyin: 'AggregateDouyin',
wechat: 'AggregateWechatPage',
alipay: 'AggregateAlipayPage',
union_pay: 'AggregateUnionPage',
douyin: 'AggregateDouyinPage',
}
// onBeforeMount 探测并 replace挂载前跳走避免与 App.vue <transition mode="out-in" appear> 的 enter 动画

View File

@@ -1,274 +0,0 @@
<script lang="ts" setup>
/**
* 聚合扫码环境页wechat/alipay/union_pay/douyin 共用)
* clientEnv 由路由注入,不二次猜 UAOAuth 回跳后仍稳定)
*/
import type { AggregateClientEnv } from '@/shared/utils/client-env'
import { showNotify, showSuccessToast } from 'vant'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import QrCodeDisplay from '@/shared/components/pay/QrCodeDisplay.vue'
import { useAggregatePay } from '@/shared/pay/use-aggregate-pay'
import { isAggregateClientEnv } from '@/shared/utils/client-env'
defineOptions({ name: 'AggregateEnvPage' })
const props = defineProps<{
/** 固定客户端环境 */
clientEnv: AggregateClientEnv
}>()
const { t } = useI18n()
const route = useRoute()
const orderNo = route.params.orderNo as string
if (!isAggregateClientEnv(props.clientEnv)) {
throw new Error(`invalid aggregate clientEnv: ${props.clientEnv}`)
}
const {
loading,
paying,
authorizing,
loadError,
order,
meta,
payResult,
showQrcode,
qrContent,
amountYuan,
paid,
terminal,
expired,
countdown,
remainSeconds,
bootstrap,
doPay,
} = useAggregatePay({
orderNo,
clientEnv: props.clientEnv,
device: 'mobile',
t,
onPaid() {
showSuccessToast(t('aggregate.paid'))
},
onError(message) {
showNotify({ type: 'danger', message })
},
})
/**
* 环境展示名
*/
function clientEnvLabel() {
if (props.clientEnv === 'alipay') {
return t('aggregate.clientEnv.alipay')
}
if (props.clientEnv === 'union_pay') {
return t('aggregate.clientEnv.union')
}
if (props.clientEnv === 'douyin') {
return t('aggregate.clientEnv.douyin')
}
return t('aggregate.clientEnv.wechat')
}
/**
* 终态文案
*/
function terminalMessage() {
const s = order.value.status
if (s === 'paid') {
return t('aggregate.paid')
}
if (s === 'expired') {
return t('aggregate.expired')
}
if (s === 'closed') {
return t('aggregate.closed')
}
if (s === 'failed') {
return t('aggregate.failed')
}
return ''
}
onMounted(() => {
bootstrap()
})
</script>
<template>
<div class="aggregate">
<div class="aggregate__brand" />
<div v-if="loading || authorizing" class="aggregate__card">
<van-loading color="#5d9dfe" size="24px" />
<p v-if="authorizing" class="aggregate__tip">
{{ t('aggregate.authorizing') }}
</p>
</div>
<div v-else-if="loadError" class="aggregate__card">
<p class="aggregate__error">
{{ loadError }}
</p>
</div>
<template v-else>
<div class="aggregate__card">
<div class="aggregate__title">
{{ order.title || t('aggregate.defaultTitle') }}
</div>
<div class="aggregate__amount">
<span class="aggregate__currency">¥</span>{{ amountYuan }}
</div>
<div v-if="order.description" class="aggregate__desc">
{{ order.description }}
</div>
<div class="aggregate__meta">
<div>{{ t('aggregate.orderNo') }}: {{ order.orderNo }}</div>
<div v-if="remainSeconds > 0 && !paid">
{{ t('aggregate.countdown') }}: {{ countdown }}
</div>
<div>{{ t('aggregate.clientEnvLabel') }}: {{ clientEnvLabel() }}</div>
</div>
</div>
<div v-if="paid" class="aggregate__card aggregate__success">
{{ t('aggregate.paid') }}
</div>
<div v-else-if="terminal" class="aggregate__card">
<p class="aggregate__error">
{{ terminalMessage() }}
</p>
</div>
<div v-else-if="expired" class="aggregate__card">
<p class="aggregate__error">
{{ t('aggregate.expired') }}
</p>
</div>
<div v-else-if="showQrcode && qrContent" class="aggregate__card">
<p class="aggregate__tip">
{{ t('aggregate.scanQr') }}
</p>
<div class="aggregate__qr">
<QrCodeDisplay :content="qrContent" :size="200" />
</div>
</div>
<div
v-else-if="payResult?.payBody && payResult.payBodyType !== 'link' && payResult.payBodyType !== 'jsapi'"
class="aggregate__card"
>
<div class="aggregate__pay-tip">
{{ t('aggregate.useAppPay') }}
</div>
</div>
<div v-if="!paid && !terminal && !expired" class="aggregate__actions">
<van-button
type="primary"
round
block
:loading="paying || authorizing"
@click="doPay"
>
{{
paying
? t('aggregate.paying')
: (meta.autoLaunch ? t('aggregate.retry') : t('aggregate.payNow'))
}}
</van-button>
</div>
</template>
</div>
</template>
<style scoped>
.aggregate {
min-height: 100%;
padding-bottom: 40px;
background: #f5f7fa;
}
.aggregate__brand {
height: 120px;
background: linear-gradient(135deg, #5d9dfe, #4787f7);
}
.aggregate__card {
margin: -48px 16px 16px;
padding: 24px 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 16px rgb(0 0 0 / 6%);
text-align: center;
}
.aggregate__title {
font-size: 16px;
color: #303133;
margin-bottom: 12px;
}
.aggregate__amount {
font-size: 36px;
font-weight: 600;
color: #303133;
line-height: 1.2;
}
.aggregate__currency {
font-size: 20px;
margin-right: 4px;
}
.aggregate__desc {
margin-top: 8px;
font-size: 13px;
color: #909399;
}
.aggregate__meta {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
font-size: 12px;
color: #909399;
text-align: left;
line-height: 1.8;
}
.aggregate__error {
color: #ee0a24;
font-size: 14px;
}
.aggregate__success {
color: #07c160;
font-size: 16px;
font-weight: 500;
}
.aggregate__tip,
.aggregate__pay-tip {
font-size: 14px;
color: #606266;
margin: 8px 0;
}
.aggregate__qr {
display: flex;
justify-content: center;
margin-top: 12px;
}
.aggregate__actions {
margin: 0 16px;
}
</style>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
/**
* 聚合扫码:非微信/支付宝/云闪付/抖音宿主提示
* 结构对齐 EnvPage(品牌条 + 上浮卡);四钱包仅图标、无标签,减少与描述重复
* 结构对齐各环境页(品牌条 + 上浮卡);四钱包仅图标、无标签,减少与描述重复
*/
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'

View File

@@ -2,7 +2,7 @@
/**
* 码牌收款共用 UI
*
* 视觉对齐聚合 EnvPage(上浮卡 + 灰阶 token与收银结果态
* 视觉对齐聚合各环境页(上浮卡 + 灰阶 token与收银结果态
* 通道品牌色由端页注入(微信绿 / 支付宝蓝)。
*/
import { computed, ref } from 'vue'

View File

@@ -31,14 +31,12 @@ const routes: RouteRecordRaw[] = [
path: RoutePath.AGGREGATE_UNSUPPORTED,
name: 'PcAggregateUnsupported',
component: () => import('@/pc/views/aggregate/Unsupported.vue'),
meta: { title: '聚合支付' },
},
// 聚合扫码支付(跨端)
{
path: RoutePath.AGGREGATE,
name: 'PcAggregate',
component: () => import('@/pc/views/aggregate/Index.vue'),
meta: { title: '聚合支付' },
},
// 商户对账单PC 独占:移动端由注册表派生 device-only 存根)
{

View File

@@ -1,5 +1,5 @@
{
"defaultTitle": "Aggregate Pay",
"defaultTitle": "Pay Order",
"orderNo": "Order No",
"countdown": "Time left",
"clientEnvLabel": "Scene",

View File

@@ -1,5 +1,5 @@
{
"defaultTitle": "Aggregate Pay",
"defaultTitle": "Pembayaran Pesanan",
"orderNo": "Order No",
"countdown": "Time left",
"clientEnvLabel": "Scene",

View File

@@ -1,5 +1,5 @@
{
"defaultTitle": "統合決済",
"defaultTitle": "注文のお支払い",
"orderNo": "注文番号",
"countdown": "残り時間",
"clientEnvLabel": "決済環境",

View File

@@ -1,5 +1,5 @@
{
"defaultTitle": "통합 결제",
"defaultTitle": "주문 결제",
"orderNo": "주문번호",
"countdown": "남은 시간",
"clientEnvLabel": "결제 환경",

View File

@@ -1,5 +1,5 @@
{
"defaultTitle": "Aggregate Pay",
"defaultTitle": "Bayaran Pesanan",
"orderNo": "Order No",
"countdown": "Time left",
"clientEnvLabel": "Scene",

View File

@@ -1,5 +1,5 @@
{
"defaultTitle": "Aggregate Pay",
"defaultTitle": "ชำระคำสั่งซื้อ",
"orderNo": "Order No",
"countdown": "Time left",
"clientEnvLabel": "Scene",

View File

@@ -1,5 +1,5 @@
{
"defaultTitle": "Aggregate Pay",
"defaultTitle": "Thanh toán đơn hàng",
"orderNo": "Order No",
"countdown": "Time left",
"clientEnvLabel": "Scene",

View File

@@ -1,5 +1,5 @@
{
"defaultTitle": "聚合支付",
"defaultTitle": "订单支付",
"orderNo": "订单号",
"countdown": "剩余时间",
"clientEnvLabel": "支付环境",

View File

@@ -1,5 +1,5 @@
{
"defaultTitle": "聚合支付",
"defaultTitle": "訂單付款",
"orderNo": "訂單號",
"countdown": "剩餘時間",
"clientEnvLabel": "支付環境",

View File

@@ -1,5 +1,5 @@
{
"defaultTitle": "聚合支付",
"defaultTitle": "訂單支付",
"orderNo": "訂單號",
"countdown": "剩餘時間",
"clientEnvLabel": "支付環境",

View File

@@ -33,22 +33,15 @@ export const RoutePath = {
AGGREGATE: '/aggregate/:orderNo',
/**
* 聚合扫码父级分组(仅 Mobile 路由注册用)
* Mobile 把入口 + unsupported 提示合并到此父级下作为子路由,让 App.vue
* 顶层 transition keymatched[0].name entry → unsupported 跳转时保持
* 不变,避免整页 remount 与 onBeforeMount 内 router.replace 竞争导致白屏。
* URL 契约不变,仍是 /aggregate/:orderNo 与 /aggregate/unsupported
* Mobile 把入口 + 非宿主提示 + 各宿主环境页wechat/alipay/union-pay/douyin全部
* 合并到此父级下作为子路由,让 App.vue 顶层 transition keymatched[0].name
* entry → 环境页跳转时保持不变(都是 Aggregate避免整页 remount 与 Entry.vue 的
* onBeforeMount router.replace 竞争导致白屏(曾发生在支付宝 webview
* URL 契约不变:/aggregate/:orderNo、/aggregate/unsupported、/aggregate/{env}/:orderNo。
* PC 仍用 AGGREGATE/aggregate/:orderNo作为单层路由。
*/
AGGREGATE_GROUP: '/aggregate',
/** 聚合-微信环境页Mobile */
AGGREGATE_WECHAT: '/aggregate/wechat/:orderNo',
/** 聚合-支付宝环境页Mobile */
AGGREGATE_ALIPAY: '/aggregate/alipay/:orderNo',
/** 聚合-云闪付环境页Mobile */
AGGREGATE_UNION: '/aggregate/union-pay/:orderNo',
/** 聚合-抖音环境页Mobile */
AGGREGATE_DOUYIN: '/aggregate/douyin/:orderNo',
/** 聚合-不支持的宿主环境提示Mobile */
/** 聚合-不支持的宿主环境提示Mobile + PC 注册用 */
AGGREGATE_UNSUPPORTED: '/aggregate/unsupported',
/** 码牌支付入口跨端Mobile 分发/收款PC 扫码引导path 与后端 getCodeLink /h/{code} 一致) */
CODE_PAY: '/h/:code',