refactor(h5): 统一支付页 loading 视觉,消除 OAuth 跳转闪烁

- 新增 InitLoadingMask 组件统一全屏遮罩(聚合/收银台/码牌/回调页共用)
- 聚合/收银台/码牌引入 ready 状态,业务内容在就绪前不渲染
- 路由守卫拦截 entry 入口,导航阶段直接 replace 到环境页
- 订单数据 sessionStorage 缓存,OAuth 回跳后快速恢复
- 回调页删除成功态分支,init 提前到 setup,中性文案 common.processing
- 新增 OrderCardSkeleton 骨架屏组件(暂未接入业务页,留作扩展)
This commit is contained in:
bootx
2026-07-20 18:34:11 +08:00
parent cb85e10d88
commit dedc1d25da
30 changed files with 799 additions and 845 deletions

View File

@@ -1,13 +1,79 @@
import type { Router } from 'vue-router'
import type { RouteLocationNormalized, Router } from 'vue-router'
import NProgress from 'nprogress'
import { watch } from 'vue'
import { isNavigationFailure } from 'vue-router'
import i18n, { t } from '@/shared/locales'
import { useRouteStoreWithOut } from '@/shared/store/modules/route'
import {
detectAggregateClientEnv,
detectClientEnv,
} from '@/shared/utils/client-env'
import 'nprogress/nprogress.css'
NProgress.configure({ parent: '#app' })
/** 聚合 clientEnv → 路由 name指向 Aggregate 父级下的环境页子路由) */
const AGGREGATE_ENV_ROUTE_NAME: Record<string, string> = {
wechat: 'AggregateWechatPage',
alipay: 'AggregateAlipayPage',
union_pay: 'AggregateUnionPage',
douyin: 'AggregateDouyinPage',
}
/** 码牌 clientEnv → 路由 name */
const CODE_PAY_ENV_ROUTE_NAME: Record<string, string> = {
wechat: 'CodePayWechat',
alipay: 'CodePayAlipay',
union_pay: 'CodePayUnion',
douyin: 'CodePayDouyin',
}
/**
* entry 探测:在导航阶段直接 replace 到目标环境页,
* 避免 entry 组件 mount/unmount 带来的额外视觉切换(消除"闪一下"
*
* 与 entry.vue 的 onBeforeMount replace 形成兜底:守卫拦截成功则 entry 组件不会挂载;
* 守卫若因故未拦到return trueentry.vue 仍会兜底执行同样的探测逻辑
*/
function resolveEntryRedirect(to: RouteLocationNormalized): RouteLocationNormalized | Record<string, unknown> | null {
// 收银台入口 → 环境页detectClientEnv 总有返回值,含 browser
if (to.name === 'CashierEntry') {
const orderNo = to.params.orderNo as string
if (orderNo) {
const clientEnv = detectClientEnv()
return { name: 'CashierEnvPage', params: { orderNo, clientEnv }, replace: true }
}
}
// 聚合入口 → 各宿主环境页 / 非宿主提示页
if (to.name === 'AggregateEntry') {
const orderNo = to.params.orderNo as string
if (orderNo) {
const clientEnv = detectAggregateClientEnv()
if (!clientEnv) {
// 非宿主:提示页
return { name: 'AggregateUnsupportedPage', query: { orderNo }, replace: true }
}
const routeName = AGGREGATE_ENV_ROUTE_NAME[clientEnv]
if (routeName) {
return { name: routeName, params: { orderNo }, replace: true }
}
}
}
// 码牌分发入口 → 各宿主端页(非钱包宿主留在 CodePayPage 显示扫码提示)
if (to.name === 'CodePayPage') {
const code = to.params.code as string
if (code) {
const env = detectClientEnv()
const routeName = CODE_PAY_ENV_ROUTE_NAME[env]
if (routeName) {
return { name: routeName, params: { code }, replace: true }
}
// browser 等非钱包宿主:留在 CodePayPageonMounted 显示"请用钱包扫码"
}
}
return null
}
/** 根据路由 meta.titlei18n key设置浏览器标题 */
function applyRouteDocumentTitle(router: Router) {
const key = router.currentRoute.value.meta?.title as string | undefined
@@ -18,8 +84,13 @@ function applyRouteDocumentTitle(router: Router) {
}
export function createRouterGuards(router: Router) {
router.beforeEach(() => {
router.beforeEach((to) => {
NProgress.start()
// entry 探测:拦截入口路由,导航阶段直接 replace 到环境页
const redirect = resolveEntryRedirect(to)
if (redirect) {
return redirect
}
// 鉴权逻辑已移除:当前为开放式访问,待业务需要时在此补充登录/权限校验
return true
})

View File

@@ -8,6 +8,7 @@ import { showNotify, showSuccessToast } from 'vant'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import QrCodeDisplay from '@/shared/components/pay/QrCodeDisplay.vue'
import { useAggregatePay } from '@/shared/pay/use-aggregate-pay'
import AggregateResultCard from '../components/AggregateResultCard.vue'
@@ -22,7 +23,7 @@ const route = useRoute()
const orderNo = route.params.orderNo as string
const {
loading,
ready,
paying,
authorizing,
loadError,
@@ -54,6 +55,19 @@ const {
},
})
/**
* InitLoadingMask 阶段文案
*/
const maskTipKey = computed(() => {
if (authorizing.value) {
return 'aggregate.authorizing'
}
if (paying.value) {
return 'aggregate.paying'
}
return ''
})
/** 终态(非成功)的状态映射,供 AggregateResultCard 渲染 */
const terminalState = computed<AggregateResultState>(() => {
const s = order.value.status
@@ -99,25 +113,20 @@ onMounted(() => {
<template>
<div class="agg agg--alipay">
<!-- 顶部品牌色装饰区半透明圆点缀 -->
<div class="agg__brand" />
<!-- 加载/授权中/支付中 -->
<div v-if="loading || authorizing || paying" class="agg__card agg__card--loading">
<van-loading color="var(--agg-brand)" size="24px" />
<p v-if="authorizing" class="agg__tip">
{{ t('aggregate.authorizing') }}
</p>
<!-- 支付中提示 -->
<p v-else-if="paying" class="agg__tip">
{{ t('aggregate.paying') }}
</p>
</div>
<!-- 初始化/授权/支付中统一全屏遮罩 -->
<InitLoadingMask
v-if="!ready || authorizing || paying"
:brand-color="BRAND_COLOR"
:tip-key="maskTipKey"
/>
<!-- 加载错误 -->
<AggregateResultCard v-else-if="loadError" state="loadError" :message="loadError" />
<template v-else>
<!-- 顶部品牌色装饰区半透明圆点缀 -->
<div class="agg__brand" />
<!-- 订单信息卡片异常终态时隐藏订单信息降级到结果卡摘要 -->
<div v-if="!abnormalTerminal" class="agg__card agg__card--main">
<div class="agg__title">

View File

@@ -8,6 +8,7 @@ import { showNotify, showSuccessToast } from 'vant'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import QrCodeDisplay from '@/shared/components/pay/QrCodeDisplay.vue'
import { useAggregatePay } from '@/shared/pay/use-aggregate-pay'
import AggregateResultCard from '../components/AggregateResultCard.vue'
@@ -22,7 +23,7 @@ const route = useRoute()
const orderNo = route.params.orderNo as string
const {
loading,
ready,
paying,
authorizing,
loadError,
@@ -54,6 +55,19 @@ const {
},
})
/**
* InitLoadingMask 阶段文案
*/
const maskTipKey = computed(() => {
if (authorizing.value) {
return 'aggregate.authorizing'
}
if (paying.value) {
return 'aggregate.paying'
}
return ''
})
/** 终态(非成功)的状态映射,供 AggregateResultCard 渲染 */
const terminalState = computed<AggregateResultState>(() => {
const s = order.value.status
@@ -99,21 +113,20 @@ onMounted(() => {
<template>
<div class="agg agg--douyin">
<!-- 顶部品牌色装饰区半透明圆点缀 -->
<div class="agg__brand" />
<!-- 加载/授权中 -->
<div v-if="loading || authorizing" class="agg__card agg__card--loading">
<van-loading color="var(--agg-brand)" size="24px" />
<p v-if="authorizing" class="agg__tip">
{{ t('aggregate.authorizing') }}
</p>
</div>
<!-- 初始化/授权/支付中统一全屏遮罩 -->
<InitLoadingMask
v-if="!ready || authorizing || paying"
:brand-color="BRAND_COLOR"
:tip-key="maskTipKey"
/>
<!-- 加载错误 -->
<AggregateResultCard v-else-if="loadError" state="loadError" :message="loadError" />
<template v-else>
<!-- 顶部品牌色装饰区半透明圆点缀 -->
<div class="agg__brand" />
<!-- 订单信息卡片异常终态时隐藏订单信息降级到结果卡摘要 -->
<div v-if="!abnormalTerminal" class="agg__card agg__card--main">
<div class="agg__title">

View File

@@ -8,6 +8,7 @@ import { showNotify, showSuccessToast } from 'vant'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import QrCodeDisplay from '@/shared/components/pay/QrCodeDisplay.vue'
import { useAggregatePay } from '@/shared/pay/use-aggregate-pay'
import AggregateResultCard from '../components/AggregateResultCard.vue'
@@ -22,7 +23,7 @@ const route = useRoute()
const orderNo = route.params.orderNo as string
const {
loading,
ready,
paying,
authorizing,
loadError,
@@ -54,6 +55,19 @@ const {
},
})
/**
* InitLoadingMask 阶段文案
*/
const maskTipKey = computed(() => {
if (authorizing.value) {
return 'aggregate.authorizing'
}
if (paying.value) {
return 'aggregate.paying'
}
return ''
})
/** 终态(非成功)的状态映射,供 AggregateResultCard 渲染 */
const terminalState = computed<AggregateResultState>(() => {
const s = order.value.status
@@ -99,21 +113,20 @@ onMounted(() => {
<template>
<div class="agg agg--union">
<!-- 顶部品牌色装饰区半透明圆点缀 -->
<div class="agg__brand" />
<!-- 加载/授权中 -->
<div v-if="loading || authorizing" class="agg__card agg__card--loading">
<van-loading color="var(--agg-brand)" size="24px" />
<p v-if="authorizing" class="agg__tip">
{{ t('aggregate.authorizing') }}
</p>
</div>
<!-- 初始化/授权/支付中统一全屏遮罩 -->
<InitLoadingMask
v-if="!ready || authorizing || paying"
:brand-color="BRAND_COLOR"
:tip-key="maskTipKey"
/>
<!-- 加载错误 -->
<AggregateResultCard v-else-if="loadError" state="loadError" :message="loadError" />
<template v-else>
<!-- 顶部品牌色装饰区半透明圆点缀 -->
<div class="agg__brand" />
<!-- 订单信息卡片异常终态时隐藏订单信息降级到结果卡摘要 -->
<div v-if="!abnormalTerminal" class="agg__card agg__card--main">
<div class="agg__title">

View File

@@ -8,6 +8,7 @@ import { showNotify, showSuccessToast } from 'vant'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import QrCodeDisplay from '@/shared/components/pay/QrCodeDisplay.vue'
import { useAggregatePay } from '@/shared/pay/use-aggregate-pay'
import AggregateResultCard from '../components/AggregateResultCard.vue'
@@ -22,7 +23,7 @@ const route = useRoute()
const orderNo = route.params.orderNo as string
const {
loading,
ready,
paying,
authorizing,
loadError,
@@ -54,6 +55,22 @@ const {
},
})
/**
* InitLoadingMask 阶段文案:
* - authorizing授权中OAuth 跳转前 / 回调页过渡)
* - paying支付中
* - 其余loading不传 tipKey只显示转圈避免文案与订单卡加载冲突
*/
const maskTipKey = computed(() => {
if (authorizing.value) {
return 'aggregate.authorizing'
}
if (paying.value) {
return 'aggregate.paying'
}
return ''
})
/** 终态(非成功)的状态映射,供 AggregateResultCard 渲染 */
const terminalState = computed<AggregateResultState>(() => {
const s = order.value.status
@@ -99,21 +116,20 @@ onMounted(() => {
<template>
<div class="agg agg--wechat">
<!-- 顶部品牌色装饰区半透明圆点缀 -->
<div class="agg__brand" />
<!-- 加载/授权中 -->
<div v-if="loading || authorizing" class="agg__card agg__card--loading">
<van-loading color="var(--agg-brand)" size="24px" />
<p v-if="authorizing" class="agg__tip">
{{ t('aggregate.authorizing') }}
</p>
</div>
<!-- 初始化/授权/支付中统一全屏遮罩避免业务内容闪现 OAuth 跳转前后 -->
<InitLoadingMask
v-if="!ready || authorizing || paying"
:brand-color="BRAND_COLOR"
:tip-key="maskTipKey"
/>
<!-- 加载错误 -->
<AggregateResultCard v-else-if="loadError" state="loadError" :message="loadError" />
<template v-else>
<!-- 顶部品牌色装饰区半透明圆点缀 -->
<div class="agg__brand" />
<!-- 订单信息卡片异常终态时隐藏订单信息降级到结果卡摘要 -->
<div v-if="!abnormalTerminal" class="agg__card agg__card--main">
<div class="agg__title">

View File

@@ -1,11 +1,19 @@
<script lang="ts" setup>
import type { AuthResult } from '@/shared/api/channel-auth'
import { showFailToast, showSuccessToast } from 'vant'
import { onMounted, ref } from 'vue'
/**
* 支付宝 OAuth 重定向回调落地页
*
* 网关收银/聚合/码牌流程下:拿到 userId/openId 后由 finishGatewayAuthAndRedirect 直接
* 落盘 sessionStorage 并 location.replace 回业务页,本页始终处于 loading 态直到跳走,
* 不再展示"成功结果 + 复制 userId"分支(仅调试场景才用得到,已移除以减少视觉跳变)。
*
* 仅保留失败兜底:缺 auth_code/state 或后端换 userId 失败时显示错误卡 + 关闭按钮。
*/
import { showFailToast } from 'vant'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { authAndGet } from '@/shared/api/channel-auth'
import alipayLogo from '@/shared/assets/icons/channel/alipay.svg'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import { finishGatewayAuthAndRedirect } from '@/shared/utils/auth-return'
defineOptions({ name: 'AlipayAuthPage' })
@@ -18,14 +26,9 @@ const authToken = route.query.state as string
const code = route.query.auth_code as string | undefined
const loading = ref(true)
const authResult = ref<AuthResult>({})
const failed = ref(false)
const failMsg = ref('')
onMounted(() => {
init()
})
/**
* 标记失败状态
*/
@@ -38,6 +41,8 @@ function markFailed(msg: string) {
/**
* 页面初始化: 取回调 auth_code + authToken 换取 userId 后回写后端
*
* 提前到 setup 顶层执行(不等 onMounted减少 Vue 挂载到 init 的间隙
*/
function init() {
if (!authToken || !code) {
@@ -50,34 +55,19 @@ function init() {
authToken,
})
.then((data) => {
authResult.value = data ?? {}
// 网关收银等业务回跳
if (finishGatewayAuthAndRedirect(authResult.value)) {
if (finishGatewayAuthAndRedirect(data ?? {})) {
return
}
loading.value = false
markFailed(t('auth.alipay.authFail'))
})
.catch((err: Error) => {
markFailed(err?.message || t('auth.alipay.authFail'))
})
}
/**
* 复制用户标识
*/
async function handleCopy() {
const value = authResult.value.userId || authResult.value.openId
if (!value) {
return
}
try {
await navigator.clipboard.writeText(value)
showSuccessToast(t('auth.alipay.copySuccess'))
}
catch {
showFailToast(t('auth.alipay.copyFail'))
}
}
// 立即触发(不等 onMounted减少白屏时间
init()
/**
* 关闭支付宝内嵌 WebView
@@ -105,23 +95,14 @@ function handleClose() {
<template>
<div class="auth-container">
<!-- 加载中 -->
<div v-if="loading" class="loading-box">
<div class="logo-wrapper">
<img class="channel-logo" :src="alipayLogo" alt="Alipay" width="64" height="64">
</div>
<van-loading vertical color="#1677ff" size="32px">
<span class="loading-text">{{ t('auth.alipay.loading') }}</span>
</van-loading>
<div class="footer-tip">
<svg class="tip-icon" viewBox="0 0 1024 1024" width="14" height="14" aria-hidden="true">
<path fill="#1677ff" d="M512 64 128 224v320c0 198 154 366 384 416 230-50 384-218 384-416V224L512 64z m0 380c-70 0-128-58-128-128s58-128 128-128 128 58 128 128-58 128-128 128z" />
</svg>
<span>{{ t('auth.alipay.secureTip') }}</span>
</div>
</div>
<!-- 加载中统一全屏遮罩中性文案不暴露"获取信息"细节 -->
<InitLoadingMask
v-if="loading"
brand-color="#1677ff"
tip-key="common.processing"
/>
<!-- 失败 -->
<!-- 失败兜底 -->
<div v-else-if="failed" class="result-box">
<div class="status-icon">
<svg viewBox="0 0 1024 1024" width="64" height="64" aria-hidden="true">
@@ -141,38 +122,6 @@ function handleClose() {
</van-button>
</div>
</div>
<!-- 成功结果 -->
<div v-else class="result-box">
<div class="status-icon">
<svg viewBox="0 0 1024 1024" width="64" height="64" aria-hidden="true">
<circle cx="512" cy="512" r="448" fill="#1677ff" />
<path fill="#fff" d="M705.5 289.7L416 615.5 318.5 518l-45.3 45.3L416 706l351.8-351.8-45.3-45.3z" />
</svg>
</div>
<h3 class="result-title">
{{ t('auth.alipay.successTitle') }}
</h3>
<div class="info-card">
<div class="info-item">
<span class="label">{{ t('auth.alipay.userId') }}</span>
<div class="value-box" @click="handleCopy">
<span class="value">{{ authResult.userId || authResult.openId }}</span>
<svg class="copy-icon" viewBox="0 0 1024 1024" width="16" height="16" aria-hidden="true">
<path fill="#1677ff" d="M768 128H192a64 64 0 0 0-64 64v512h64V192h576V128z m192 192v512a64 64 0 0 1-64 64H384a64 64 0 0 1-64-64V320a64 64 0 0 1 64-64h512a64 64 0 0 1 64 64z m-64 0H384v512h512V320z" />
</svg>
</div>
</div>
</div>
<div class="action-buttons">
<van-button type="primary" color="#1677ff" round block @click="handleCopy">
{{ t('auth.alipay.copy') }}
</van-button>
<van-button plain round block class="close-btn" @click="handleClose">
{{ t('auth.alipay.close') }}
</van-button>
</div>
</div>
</div>
</template>
@@ -186,7 +135,6 @@ function handleClose() {
padding: 20px;
background-color: var(--h5-bg-page);
.loading-box,
.result-box {
display: flex;
flex-direction: column;
@@ -197,41 +145,7 @@ function handleClose() {
background: var(--h5-bg-card);
border-radius: 16px;
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
}
.loading-box {
.logo-wrapper {
margin-bottom: 24px;
.channel-logo {
display: block;
width: 64px;
height: 64px;
object-fit: contain;
}
}
.loading-text {
margin-top: 12px;
font-size: 14px;
color: var(--h5-text-secondary);
}
.footer-tip {
display: flex;
align-items: center;
margin-top: 32px;
font-size: 12px;
color: var(--h5-text-secondary);
.tip-icon {
flex-shrink: 0;
margin-right: 4px;
}
}
}
.result-box {
.status-icon {
margin-bottom: 16px;
line-height: 0;
@@ -252,55 +166,10 @@ function handleClose() {
word-break: break-all;
}
.info-card {
width: 100%;
padding: 16px;
margin-bottom: 32px;
background: var(--h5-bg-muted);
border-radius: 12px;
.info-item {
.label {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: var(--h5-text-secondary);
}
.value-box {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
background: var(--h5-bg-card);
border: 1px solid #eee;
border-radius: 8px;
&:active {
background: var(--h5-bg-muted);
}
.value {
margin-right: 8px;
font-family: monospace;
font-size: 14px;
color: var(--h5-text-primary);
word-break: break-all;
}
.copy-icon {
flex-shrink: 0;
}
}
}
}
.action-buttons {
width: 100%;
.close-btn {
margin-top: 12px;
color: var(--h5-text-secondary);
border-color: var(--h5-border);
}

View File

@@ -1,11 +1,19 @@
<script lang="ts" setup>
import type { AuthResult } from '@/shared/api/channel-auth'
import { showFailToast, showSuccessToast } from 'vant'
import { onMounted, ref } from 'vue'
/**
* 抖音 silent_auth 重定向回调落地页
*
* 网关收银/聚合/码牌流程下:拿到 openId 后由 finishGatewayAuthAndRedirect 直接
* 落盘 sessionStorage 并 location.replace 回业务页,本页始终处于 loading 态直到跳走,
* 不再展示"成功结果 + 复制 openId"分支(仅调试场景才用得到,已移除以减少视觉跳变)。
*
* 仅保留失败兜底:缺 code/state 或后端换 openId 失败时显示错误卡 + 关闭按钮。
*/
import { showFailToast } from 'vant'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { authAndGet } from '@/shared/api/channel-auth'
import douyinLogo from '@/shared/assets/icons/channel/douyin.svg'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import { finishGatewayAuthAndRedirect } from '@/shared/utils/auth-return'
defineOptions({ name: 'DouyinAuthPage' })
@@ -19,14 +27,9 @@ const authToken = route.query.state as string
const code = route.query.code as string | undefined
const loading = ref(true)
const authResult = ref<AuthResult>({})
const failed = ref(false)
const failMsg = ref('')
onMounted(() => {
init()
})
/**
* 标记失败状态
*/
@@ -39,6 +42,8 @@ function markFailed(msg: string) {
/**
* 页面初始化: 取回调 code + authToken 换取 openId 后回写后端
*
* 提前到 setup 顶层执行(不等 onMounted减少 Vue 挂载到 init 的间隙
*/
function init() {
if (!authToken || !code) {
@@ -51,34 +56,19 @@ function init() {
authToken,
})
.then((data) => {
authResult.value = data ?? {}
// 网关收银等业务回跳
if (finishGatewayAuthAndRedirect(authResult.value)) {
if (finishGatewayAuthAndRedirect(data ?? {})) {
return
}
loading.value = false
markFailed(t('auth.douyin.authFail'))
})
.catch((err: Error) => {
markFailed(err?.message || t('auth.douyin.authFail'))
})
}
/**
* 复制用户标识
*/
async function handleCopy() {
const value = authResult.value.openId
if (!value) {
return
}
try {
await navigator.clipboard.writeText(value)
showSuccessToast(t('auth.douyin.copySuccess'))
}
catch {
showFailToast(t('auth.douyin.copyFail'))
}
}
// 立即触发(不等 onMounted减少白屏时间
init()
/**
* 返回上一页(抖音内嵌 WebView 用历史回退兜底)
@@ -95,23 +85,14 @@ function handleBack() {
<template>
<div class="auth-container">
<!-- 加载中 -->
<div v-if="loading" class="loading-box">
<div class="logo-wrapper">
<img class="channel-logo" :src="douyinLogo" alt="Douyin" width="64" height="64">
</div>
<van-loading vertical color="#000000" size="32px">
<span class="loading-text">{{ t('auth.douyin.loading') }}</span>
</van-loading>
<div class="footer-tip">
<svg class="tip-icon" viewBox="0 0 1024 1024" width="14" height="14" aria-hidden="true">
<path fill="#000000" d="M512 64 128 224v320c0 198 154 366 384 416 230-50 384-218 384-416V224L512 64z m0 380c-70 0-128-58-128-128s58-128 128-128 128 58 128 128-58 128-128 128z" />
</svg>
<span>{{ t('auth.douyin.secureTip') }}</span>
</div>
</div>
<!-- 加载中统一全屏遮罩中性文案不暴露"获取信息"细节 -->
<InitLoadingMask
v-if="loading"
brand-color="#161823"
tip-key="common.processing"
/>
<!-- 失败 -->
<!-- 失败兜底 -->
<div v-else-if="failed" class="result-box">
<div class="status-icon">
<svg viewBox="0 0 1024 1024" width="64" height="64" aria-hidden="true">
@@ -131,38 +112,6 @@ function handleBack() {
</van-button>
</div>
</div>
<!-- 成功结果 -->
<div v-else class="result-box">
<div class="status-icon">
<svg viewBox="0 0 1024 1024" width="64" height="64" aria-hidden="true">
<circle cx="512" cy="512" r="448" fill="#000000" />
<path fill="#fff" d="M705.5 289.7L416 615.5 318.5 518l-45.3 45.3L416 706l351.8-351.8-45.3-45.3z" />
</svg>
</div>
<h3 class="result-title">
{{ t('auth.douyin.successTitle') }}
</h3>
<div class="info-card">
<div class="info-item">
<span class="label">{{ t('auth.douyin.openId') }}</span>
<div class="value-box" @click="handleCopy">
<span class="value">{{ authResult.openId }}</span>
<svg class="copy-icon" viewBox="0 0 1024 1024" width="16" height="16" aria-hidden="true">
<path fill="#000000" d="M768 128H192a64 64 0 0 0-64 64v512h64V192h576V128z m192 192v512a64 64 0 0 1-64 64H384a64 64 0 0 1-64-64V320a64 64 0 0 1 64-64h512a64 64 0 0 1 64 64z m-64 0H384v512h512V320z" />
</svg>
</div>
</div>
</div>
<div class="action-buttons">
<van-button type="primary" color="#000000" round block @click="handleCopy">
{{ t('auth.douyin.copy') }}
</van-button>
<van-button plain round block class="close-btn" @click="handleBack">
{{ t('auth.douyin.close') }}
</van-button>
</div>
</div>
</div>
</template>
@@ -176,7 +125,6 @@ function handleBack() {
padding: 20px;
background-color: var(--h5-bg-page);
.loading-box,
.result-box {
display: flex;
flex-direction: column;
@@ -187,41 +135,7 @@ function handleBack() {
background: var(--h5-bg-card);
border-radius: 16px;
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
}
.loading-box {
.logo-wrapper {
margin-bottom: 24px;
.channel-logo {
display: block;
width: 64px;
height: 64px;
object-fit: contain;
}
}
.loading-text {
margin-top: 12px;
font-size: 14px;
color: var(--h5-text-secondary);
}
.footer-tip {
display: flex;
align-items: center;
margin-top: 32px;
font-size: 12px;
color: var(--h5-text-secondary);
.tip-icon {
flex-shrink: 0;
margin-right: 4px;
}
}
}
.result-box {
.status-icon {
margin-bottom: 16px;
line-height: 0;
@@ -242,55 +156,10 @@ function handleBack() {
word-break: break-all;
}
.info-card {
width: 100%;
padding: 16px;
margin-bottom: 32px;
background: var(--h5-bg-muted);
border-radius: 12px;
.info-item {
.label {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: var(--h5-text-secondary);
}
.value-box {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
background: var(--h5-bg-card);
border: 1px solid #eee;
border-radius: 8px;
&:active {
background: var(--h5-bg-muted);
}
.value {
margin-right: 8px;
font-family: monospace;
font-size: 14px;
color: var(--h5-text-primary);
word-break: break-all;
}
.copy-icon {
flex-shrink: 0;
}
}
}
}
.action-buttons {
width: 100%;
.close-btn {
margin-top: 12px;
color: var(--h5-text-secondary);
border-color: var(--h5-border);
}

View File

@@ -1,11 +1,19 @@
<script lang="ts" setup>
import type { AuthResult } from '@/shared/api/channel-auth'
import { showFailToast, showSuccessToast } from 'vant'
import { onMounted, ref } from 'vue'
/**
* 微信 OAuth 重定向回调落地页
*
* 网关收银/聚合/码牌流程下:拿到 openId 后由 finishGatewayAuthAndRedirect 直接
* 落盘 sessionStorage 并 location.replace 回业务页,本页始终处于 loading 态直到跳走,
* 不再展示"成功结果 + 复制 openId"分支(仅调试场景才用得到,已移除以减少视觉跳变)。
*
* 仅保留失败兜底:缺 code/state 或后端换 openId 失败时显示错误卡 + 关闭按钮。
*/
import { showFailToast } from 'vant'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { authAndGet } from '@/shared/api/channel-auth'
import wechatLogo from '@/shared/assets/icons/channel/wechat.svg'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import { finishGatewayAuthAndRedirect } from '@/shared/utils/auth-return'
defineOptions({ name: 'WechatAuthPage' })
@@ -18,14 +26,9 @@ const authToken = route.query.state as string
const code = route.query.code as string | undefined
const loading = ref(true)
const authResult = ref<AuthResult>({})
const failed = ref(false)
const failMsg = ref('')
onMounted(() => {
init()
})
/**
* 标记失败状态
*/
@@ -38,6 +41,9 @@ function markFailed(msg: string) {
/**
* 页面初始化: 取回调 code + authToken 换取 openId 后回写后端
*
* 提前到 setup 顶层执行(不等 onMounted减少 Vue 挂载到 init 的间隙,
* 让 InitLoadingMask 第一时间接管视觉
*/
function init() {
if (!authToken || !code) {
@@ -50,34 +56,20 @@ function init() {
authToken,
})
.then((data) => {
authResult.value = data ?? {}
// 网关收银等业务回跳: 有 returnPath 则落盘 openId 并跳转, 不再停在展示页
if (finishGatewayAuthAndRedirect(authResult.value)) {
if (finishGatewayAuthAndRedirect(data ?? {})) {
return
}
loading.value = false
// 无 returnPath理论上不会走到网关流程总有 returnPath兜底标记失败
markFailed(t('auth.wechat.authFail'))
})
.catch((err: Error) => {
markFailed(err?.message || t('auth.wechat.authFail'))
})
}
/**
* 复制用户标识
*/
async function handleCopy() {
const value = authResult.value.openId
if (!value) {
return
}
try {
await navigator.clipboard.writeText(value)
showSuccessToast(t('auth.wechat.copySuccess'))
}
catch {
showFailToast(t('auth.wechat.copyFail'))
}
}
// 立即触发(不等 onMounted减少白屏时间
init()
/**
* 返回上一页(微信内嵌浏览器无直接关闭 API, 用历史回退兜底)
@@ -94,23 +86,14 @@ function handleBack() {
<template>
<div class="auth-container">
<!-- 加载中 -->
<div v-if="loading" class="loading-box">
<div class="logo-wrapper">
<img class="channel-logo" :src="wechatLogo" alt="WeChat" width="64" height="64">
</div>
<van-loading vertical color="#07c160" size="32px">
<span class="loading-text">{{ t('auth.wechat.loading') }}</span>
</van-loading>
<div class="footer-tip">
<svg class="tip-icon" viewBox="0 0 1024 1024" width="14" height="14" aria-hidden="true">
<path fill="#07c160" d="M512 64 128 224v320c0 198 154 366 384 416 230-50 384-218 384-416V224L512 64z m0 380c-70 0-128-58-128-128s58-128 128-128 128 58 128 128-58 128-128 128z" />
</svg>
<span>{{ t('auth.wechat.secureTip') }}</span>
</div>
</div>
<!-- 加载中统一全屏遮罩与业务页视觉一致中性文案不暴露"获取信息"细节 -->
<InitLoadingMask
v-if="loading"
brand-color="#07c160"
tip-key="common.processing"
/>
<!-- 失败 -->
<!-- 失败兜底 -->
<div v-else-if="failed" class="result-box">
<div class="status-icon">
<svg viewBox="0 0 1024 1024" width="64" height="64" aria-hidden="true">
@@ -130,38 +113,6 @@ function handleBack() {
</van-button>
</div>
</div>
<!-- 成功结果 -->
<div v-else class="result-box">
<div class="status-icon">
<svg viewBox="0 0 1024 1024" width="64" height="64" aria-hidden="true">
<circle cx="512" cy="512" r="448" fill="#07c160" />
<path fill="#fff" d="M705.5 289.7L416 615.5 318.5 518l-45.3 45.3L416 706l351.8-351.8-45.3-45.3z" />
</svg>
</div>
<h3 class="result-title">
{{ t('auth.wechat.successTitle') }}
</h3>
<div class="info-card">
<div class="info-item">
<span class="label">{{ t('auth.wechat.openId') }}</span>
<div class="value-box" @click="handleCopy">
<span class="value">{{ authResult.openId }}</span>
<svg class="copy-icon" viewBox="0 0 1024 1024" width="16" height="16" aria-hidden="true">
<path fill="#07c160" d="M768 128H192a64 64 0 0 0-64 64v512h64V192h576V128z m192 192v512a64 64 0 0 1-64 64H384a64 64 0 0 1-64-64V320a64 64 0 0 1 64-64h512a64 64 0 0 1 64 64z m-64 0H384v512h512V320z" />
</svg>
</div>
</div>
</div>
<div class="action-buttons">
<van-button type="primary" color="#07c160" round block @click="handleCopy">
{{ t('auth.wechat.copy') }}
</van-button>
<van-button plain round block class="close-btn" @click="handleBack">
{{ t('auth.wechat.close') }}
</van-button>
</div>
</div>
</div>
</template>
@@ -175,7 +126,6 @@ function handleBack() {
padding: 20px;
background-color: var(--h5-bg-page);
.loading-box,
.result-box {
display: flex;
flex-direction: column;
@@ -186,41 +136,7 @@ function handleBack() {
background: var(--h5-bg-card);
border-radius: 16px;
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
}
.loading-box {
.logo-wrapper {
margin-bottom: 24px;
.channel-logo {
display: block;
width: 64px;
height: 64px;
object-fit: contain;
}
}
.loading-text {
margin-top: 12px;
font-size: 14px;
color: var(--h5-text-secondary);
}
.footer-tip {
display: flex;
align-items: center;
margin-top: 32px;
font-size: 12px;
color: var(--h5-text-secondary);
.tip-icon {
flex-shrink: 0;
margin-right: 4px;
}
}
}
.result-box {
.status-icon {
margin-bottom: 16px;
line-height: 0;
@@ -241,55 +157,10 @@ function handleBack() {
word-break: break-all;
}
.info-card {
width: 100%;
padding: 16px;
margin-bottom: 32px;
background: var(--h5-bg-muted);
border-radius: 12px;
.info-item {
.label {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: var(--h5-text-secondary);
}
.value-box {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
background: var(--h5-bg-card);
border: 1px solid #eee;
border-radius: 8px;
&:active {
background: var(--h5-bg-muted);
}
.value {
margin-right: 8px;
font-family: monospace;
font-size: 14px;
color: var(--h5-text-primary);
word-break: break-all;
}
.copy-icon {
flex-shrink: 0;
}
}
}
}
.action-buttons {
width: 100%;
.close-btn {
margin-top: 12px;
color: var(--h5-text-secondary);
border-color: var(--h5-border);
}

View File

@@ -14,11 +14,13 @@ import {
getGatewayOrder,
listCashierItems,
} from '@/shared/api/gateway'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import PayMethodIcon from '@/shared/components/pay/PayMethodIcon.vue'
import QrCodeDisplay from '@/shared/components/pay/QrCodeDisplay.vue'
import { useGatewayOrderPoll } from '@/shared/hooks/use-gateway-order-poll'
import { closeWebview } from '@/shared/pay/close-webview'
import { detectClientEnv, isValidH5ClientEnv } from '@/shared/utils/client-env'
import { cacheOrder, clearCachedOrder, getCachedOrder } from '@/shared/utils/order-cache'
import { fenToYuan } from '@/shared/utils/pay-amount'
import { invokeJsapiByEnv } from '@/shared/utils/pay-jsapi'
import {
@@ -42,6 +44,8 @@ const orderNo = route.params.orderNo as string
const clientEnvParam = route.params.clientEnv as string
const loading = ref(true)
// 业务内容是否可渲染(与 loading 分离ready=false 期间由 InitLoadingMask 接管视觉
const ready = ref(false)
const paying = ref(false)
const loadError = ref('')
const order = ref<GatewayOrderInfo>({})
@@ -183,15 +187,26 @@ function guardClientEnv(): boolean {
/**
* 加载订单 + 支付项;支持授权回跳 autoPay
*
* ready 控制autoPay 触发时保持 ready=false 让遮罩持续到 pay() 结束,
* 避免订单卡闪现后立刻进入支付态
*/
async function loadPage() {
if (!guardClientEnv()) {
return
}
loading.value = true
ready.value = false
loadError.value = ''
// OAuth 回跳后先从 sessionStorage 缓存恢复订单,减少白屏时间
const cached = getCachedOrder<GatewayOrderInfo>(orderNo)
if (cached) {
order.value = cached
}
try {
order.value = await getGatewayOrder(orderNo)
// 写入缓存OAuth 跳转回跳时可快速恢复
cacheOrder(orderNo, order.value)
startCountdown(order.value.expiredTime)
// 仅可支付订单(非终态)请求支付项,避免对已关闭/失败/过期订单触发后端拦截异常
if (!isTerminal.value) {
@@ -218,10 +233,12 @@ async function loadPage() {
finally {
loading.value = false
}
// 授权成功回跳后自动继续支付
// 授权成功回跳后自动继续支付:保持 ready=false 让遮罩持续到支付发起
if (!isTerminal.value && !loadError.value && route.query.autoPay === '1' && selectId.value) {
await pay()
}
// 支付未触发或已结束(未跳走):渲染业务内容
ready.value = true
}
/**
@@ -324,6 +341,8 @@ async function pay() {
switch (action.type) {
case 'success':
order.value.status = 'paid'
// 支付成功后订单状态已变,清除缓存避免回显未付态
clearCachedOrder(orderNo)
showSuccessToast(t('cashier.paid'))
redirectIfNeeded()
break
@@ -374,9 +393,11 @@ onUnmounted(() => {
<template>
<div class="cashier">
<div v-if="loading" class="cashier__header enter-y">
<van-loading color="#5d9dfe" size="24px" />
</div>
<!-- 初始化/支付中统一全屏遮罩避免业务内容闪现 OAuth 回跳 autoPay 自动支付 -->
<InitLoadingMask
v-if="!ready || paying"
:tip-key="paying ? 'cashier.paying' : ''"
/>
<!-- 结果态订单已关闭/支付失败/已过期/加载失败 -->
<div v-else-if="resultState" class="cashier__result">

View File

@@ -2,20 +2,24 @@
/**
* 码牌支付-支付宝端
*/
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import CodePayShell from '../shared/CodePayShell.vue'
import { useCodePayPage } from '../shared/useCodePayPage'
defineOptions({ name: 'CodePayAlipay' })
// 支付宝品牌色
const BRAND_COLOR = '#1677ff'
const { t } = useI18n()
const route = useRoute()
const code = route.params.code as string
const {
loading,
ready,
paying,
loadError,
info,
@@ -32,6 +36,19 @@ const {
closePage,
} = useCodePayPage({ code, clientEnv: 'alipay' })
/**
* InitLoadingMask 阶段文案
*/
const maskTipKey = computed(() => {
if (authRedirecting.value) {
return 'codePay.authorizing'
}
if (paying.value) {
return 'codePay.paying'
}
return 'codePay.loading'
})
onMounted(() => {
init()
})
@@ -39,17 +56,12 @@ onMounted(() => {
<template>
<div class="code-pay-page">
<!-- 加载 / 授权品牌顶栏 + 上浮卡 -->
<template v-if="loading || authRedirecting">
<div class="code-pay-page__brand code-pay-page__brand--alipay" />
<div class="code-pay-page__card">
<van-loading color="#1677ff" size="28px" />
<!-- 正在授权 / 加载中 -->
<p class="code-pay-page__tip">
{{ authRedirecting ? t('codePay.authorizing') : t('codePay.loading') }}
</p>
</div>
</template>
<!-- 初始化/授权/支付中统一全屏遮罩 -->
<InitLoadingMask
v-if="!ready || authRedirecting || paying"
:brand-color="BRAND_COLOR"
:tip-key="maskTipKey"
/>
<!-- 加载失败 -->
<template v-else-if="loadError">

View File

@@ -2,20 +2,24 @@
/**
* 码牌支付-抖音端(黑白主视觉)
*/
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import CodePayShell from '../shared/CodePayShell.vue'
import { useCodePayPage } from '../shared/useCodePayPage'
defineOptions({ name: 'CodePayDouyin' })
// 抖音黑白主视觉
const BRAND_COLOR = '#161823'
const { t } = useI18n()
const route = useRoute()
const code = route.params.code as string
const {
loading,
ready,
paying,
loadError,
info,
@@ -32,6 +36,19 @@ const {
closePage,
} = useCodePayPage({ code, clientEnv: 'douyin' })
/**
* InitLoadingMask 阶段文案
*/
const maskTipKey = computed(() => {
if (authRedirecting.value) {
return 'codePay.authorizing'
}
if (paying.value) {
return 'codePay.paying'
}
return 'codePay.loading'
})
onMounted(() => {
init()
})
@@ -39,17 +56,12 @@ onMounted(() => {
<template>
<div class="code-pay-page">
<!-- 加载 / 授权品牌顶栏 + 上浮卡 -->
<template v-if="loading || authRedirecting">
<div class="code-pay-page__brand code-pay-page__brand--douyin" />
<div class="code-pay-page__card">
<van-loading color="#161823" size="28px" />
<!-- 正在授权 / 加载中 -->
<p class="code-pay-page__tip">
{{ authRedirecting ? t('codePay.authorizing') : t('codePay.loading') }}
</p>
</div>
</template>
<!-- 初始化/授权/支付中统一全屏遮罩 -->
<InitLoadingMask
v-if="!ready || authRedirecting || paying"
:brand-color="BRAND_COLOR"
:tip-key="maskTipKey"
/>
<!-- 加载失败 -->
<template v-else-if="loadError">

View File

@@ -37,6 +37,8 @@ export function useCodePayPage(options: UseCodePayPageOptions) {
const { code, clientEnv } = options
const loading = ref(true)
// 业务内容是否可渲染(与 loading 分离ready=false 期间由 InitLoadingMask 接管视觉
const ready = ref(false)
const paying = ref(false)
const loadError = ref('')
const info = ref<CodePayInfo>({})
@@ -72,9 +74,13 @@ export function useCodePayPage(options: UseCodePayPageOptions) {
*
* needOpenId 仅 true 时跳转授权;授权落地页 /auth/* 立即 code→openId
* 回跳后本处读 sessionStorage。支付只带 openId禁止支付时再换 code。
*
* ready 控制:跳转 OAuth / 错误态以外的完成场景才 ready=true
* 让 InitLoadingMask 持续覆盖到 OAuth 跳转 / 错误结果卡出现
*/
async function init() {
loading.value = true
ready.value = false
loadError.value = ''
// 授权回跳后 openId 已由 auth-return 写入(key=code+clientEnv)
const stored = getPayOpenId(code, clientEnv)
@@ -87,6 +93,7 @@ export function useCodePayPage(options: UseCodePayPageOptions) {
if (info.value.programType === 'mini_app') {
// 小程序码不可在 H5 支付
loadError.value = t('codePay.miniAppOnly')
ready.value = true
return
}
// 仅 needOpenId===true 且尚未拿到 openId → 整段 OAuth回跳即换 openId
@@ -100,10 +107,15 @@ export function useCodePayPage(options: UseCodePayPageOptions) {
}
loadError.value = t('codePay.authUrlFail')
authRedirecting.value = false
ready.value = true
return
}
// 正常态:可渲染码牌收款 UI
ready.value = true
}
catch (e: any) {
loadError.value = e?.message || t('codePay.loadFail')
ready.value = true
}
finally {
loading.value = false
@@ -307,6 +319,7 @@ export function useCodePayPage(options: UseCodePayPageOptions) {
return {
loading,
ready,
paying,
loadError,
info,

View File

@@ -2,20 +2,24 @@
/**
* 码牌支付-云闪付端(品牌红)
*/
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import CodePayShell from '../shared/CodePayShell.vue'
import { useCodePayPage } from '../shared/useCodePayPage'
defineOptions({ name: 'CodePayUnion' })
// 云闪付品牌红
const BRAND_COLOR = '#e21836'
const { t } = useI18n()
const route = useRoute()
const code = route.params.code as string
const {
loading,
ready,
paying,
loadError,
info,
@@ -32,6 +36,19 @@ const {
closePage,
} = useCodePayPage({ code, clientEnv: 'union_pay' })
/**
* InitLoadingMask 阶段文案
*/
const maskTipKey = computed(() => {
if (authRedirecting.value) {
return 'codePay.authorizing'
}
if (paying.value) {
return 'codePay.paying'
}
return 'codePay.loading'
})
onMounted(() => {
init()
})
@@ -39,17 +56,12 @@ onMounted(() => {
<template>
<div class="code-pay-page">
<!-- 加载 / 授权品牌顶栏 + 上浮卡 -->
<template v-if="loading || authRedirecting">
<div class="code-pay-page__brand code-pay-page__brand--union" />
<div class="code-pay-page__card">
<van-loading color="#E21836" size="28px" />
<!-- 正在授权 / 加载中 -->
<p class="code-pay-page__tip">
{{ authRedirecting ? t('codePay.authorizing') : t('codePay.loading') }}
</p>
</div>
</template>
<!-- 初始化/授权/支付中统一全屏遮罩 -->
<InitLoadingMask
v-if="!ready || authRedirecting || paying"
:brand-color="BRAND_COLOR"
:tip-key="maskTipKey"
/>
<!-- 加载失败 -->
<template v-else-if="loadError">

View File

@@ -2,20 +2,24 @@
/**
* 码牌支付-微信端
*/
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import CodePayShell from '../shared/CodePayShell.vue'
import { useCodePayPage } from '../shared/useCodePayPage'
defineOptions({ name: 'CodePayWechat' })
// 微信品牌色
const BRAND_COLOR = '#07c160'
const { t } = useI18n()
const route = useRoute()
const code = route.params.code as string
const {
loading,
ready,
paying,
loadError,
info,
@@ -32,6 +36,19 @@ const {
closePage,
} = useCodePayPage({ code, clientEnv: 'wechat' })
/**
* InitLoadingMask 阶段文案
*/
const maskTipKey = computed(() => {
if (authRedirecting.value) {
return 'codePay.authorizing'
}
if (paying.value) {
return 'codePay.paying'
}
return 'codePay.loading'
})
onMounted(() => {
init()
})
@@ -39,17 +56,12 @@ onMounted(() => {
<template>
<div class="code-pay-page">
<!-- 加载 / 授权品牌顶栏 + 上浮卡 -->
<template v-if="loading || authRedirecting">
<div class="code-pay-page__brand code-pay-page__brand--wechat" />
<div class="code-pay-page__card">
<van-loading color="#07c160" size="28px" />
<!-- 正在授权 / 加载中 -->
<p class="code-pay-page__tip">
{{ authRedirecting ? t('codePay.authorizing') : t('codePay.loading') }}
</p>
</div>
</template>
<!-- 初始化/授权/支付中统一全屏遮罩 -->
<InitLoadingMask
v-if="!ready || authRedirecting || paying"
:brand-color="BRAND_COLOR"
:tip-key="maskTipKey"
/>
<!-- 加载失败 -->
<template v-else-if="loadError">

View File

@@ -1,10 +1,18 @@
<script lang="ts" setup>
import type { AuthResult } from '@/shared/api/channel-auth'
import { onMounted, onUnmounted, ref } from 'vue'
/**
* PC 微信 OAuth 重定向回调落地页
*
* 网关收银/聚合流程下:拿到 openId 后由 finishGatewayAuthAndRedirect 直接
* 落盘 sessionStorage 并 location.replace 回业务页,本页始终处于 loading 态直到跳走,
* 不再展示"成功结果 + 复制 openId"分支(仅调试场景才用得到,已移除以减少视觉跳变)。
*
* 仅保留失败兜底:缺 code/state 或后端换 openId 失败时显示错误卡 + 关闭按钮。
*/
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { authAndGet } from '@/shared/api/channel-auth'
import wechatLogo from '@/shared/assets/icons/channel/wechat.svg'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import { finishGatewayAuthAndRedirect } from '@/shared/utils/auth-return'
defineOptions({ name: 'PcWechatAuth' })
@@ -17,39 +25,9 @@ const authToken = route.query.state as string
const code = route.query.code as string | undefined
const loading = ref(true)
const authResult = ref<AuthResult>({})
const failed = ref(false)
const failMsg = ref('')
// 复制提示浮层PC 端无 vant用 auto-hide 轻量 toast 代替)
const toastVisible = ref(false)
const toastMsg = ref('')
let toastTimer: ReturnType<typeof setTimeout> | null = null
onMounted(() => {
init()
})
onUnmounted(() => {
if (toastTimer) {
clearTimeout(toastTimer)
}
})
/**
* 显示轻量提示2 秒后自动隐藏)
*/
function showToast(msg: string) {
toastMsg.value = msg
toastVisible.value = true
if (toastTimer) {
clearTimeout(toastTimer)
}
toastTimer = setTimeout(() => {
toastVisible.value = false
}, 2000)
}
/**
* 标记失败状态
*/
@@ -61,6 +39,8 @@ function markFailed(msg: string) {
/**
* 页面初始化: 取回调 code + authToken 换取 openId 后回写后端
*
* 提前到 setup 顶层执行(不等 onMounted减少 Vue 挂载到 init 的间隙
*/
function init() {
if (!authToken || !code) {
@@ -73,34 +53,19 @@ function init() {
authToken,
})
.then((data) => {
authResult.value = data ?? {}
// 网关收银等业务回跳
if (finishGatewayAuthAndRedirect(authResult.value)) {
if (finishGatewayAuthAndRedirect(data ?? {})) {
return
}
loading.value = false
markFailed(t('auth.wechat.authFail'))
})
.catch((err: Error) => {
markFailed(err?.message || t('auth.wechat.authFail'))
})
}
/**
* 复制用户标识
*/
async function handleCopy() {
const value = authResult.value.openId
if (!value) {
return
}
try {
await navigator.clipboard.writeText(value)
showToast(t('auth.wechat.copySuccess'))
}
catch {
showToast(t('auth.wechat.copyFail'))
}
}
// 立即触发(不等 onMounted减少白屏时间
init()
/**
* 返回上一页PC 微信内置浏览器无直接关闭 API用历史回退兜底
@@ -117,74 +82,29 @@ function handleBack() {
<template>
<div class="pc-wechat-auth">
<div class="pc-wechat-auth__box">
<!-- 加载中 -->
<div v-if="loading" class="pc-wechat-auth__panel">
<div class="pc-wechat-auth__logo">
<img :src="wechatLogo" alt="WeChat" width="48" height="48">
</div>
<div class="pc-wechat-auth__spinner" />
<p class="pc-wechat-auth__loading-text">
{{ t('auth.wechat.loading') }}
</p>
<div class="pc-wechat-auth__secure-tip">
<svg viewBox="0 0 1024 1024" width="14" height="14" aria-hidden="true">
<path fill="#07c160" d="M512 64 128 224v320c0 198 154 366 384 416 230-50 384-218 384-416V224L512 64z m0 380c-70 0-128-58-128-128s58-128 128-128 128 58 128 128-58 128-128 128z" />
</svg>
<span>{{ t('auth.wechat.secureTip') }}</span>
</div>
</div>
<!-- 加载中统一全屏遮罩与移动端视觉一致中性文案 -->
<InitLoadingMask
v-if="loading"
brand-color="#07c160"
tip-key="common.processing"
/>
<!-- 失败 -->
<div v-else-if="failed" class="pc-wechat-auth__panel">
<svg class="pc-wechat-auth__status-icon pc-wechat-auth__status-icon--fail" viewBox="0 0 1024 1024" width="64" height="64" aria-hidden="true">
<circle cx="512" cy="512" r="448" fill="#ee0a24" />
<path fill="#fff" d="M705.5 625.7l-41.8 41.8L512 515.8 360.3 667.5l-41.8-41.8L470.2 474 318.5 322.3l41.8-41.8L512 432.2l151.7-151.7 41.8 41.8L553.8 474l151.7 151.7z" />
</svg>
<h3 class="pc-wechat-auth__result-title">
{{ t('auth.wechat.failTitle') }}
</h3>
<p class="pc-wechat-auth__fail-msg">
{{ failMsg }}
</p>
<button class="pc-wechat-auth__btn pc-wechat-auth__btn--ghost" type="button" @click="handleBack">
{{ t('auth.wechat.close') }}
</button>
</div>
<!-- 成功结果 -->
<div v-else class="pc-wechat-auth__panel">
<svg class="pc-wechat-auth__status-icon" viewBox="0 0 1024 1024" width="64" height="64" aria-hidden="true">
<circle cx="512" cy="512" r="448" fill="#07c160" />
<path fill="#fff" d="M705.5 289.7L416 615.5 318.5 518l-45.3 45.3L416 706l351.8-351.8-45.3-45.3z" />
</svg>
<h3 class="pc-wechat-auth__result-title">
{{ t('auth.wechat.successTitle') }}
</h3>
<div class="pc-wechat-auth__info-card">
<span class="pc-wechat-auth__info-label">{{ t('auth.wechat.openId') }}</span>
<div class="pc-wechat-auth__value-box" @click="handleCopy">
<span class="pc-wechat-auth__value">{{ authResult.openId }}</span>
<svg class="pc-wechat-auth__copy-icon" viewBox="0 0 1024 1024" width="16" height="16" aria-hidden="true">
<path fill="#07c160" d="M768 128H192a64 64 0 0 0-64 64v512h64V192h576V128z m192 192v512a64 64 0 0 1-64 64H384a64 64 0 0 1-64-64V320a64 64 0 0 1 64-64h512a64 64 0 0 1 64 64z m-64 0H384v512h512V320z" />
</svg>
</div>
</div>
<button class="pc-wechat-auth__btn pc-wechat-auth__btn--primary" type="button" @click="handleCopy">
{{ t('auth.wechat.copy') }}
</button>
<button class="pc-wechat-auth__btn pc-wechat-auth__btn--ghost" type="button" @click="handleBack">
{{ t('auth.wechat.close') }}
</button>
</div>
<!-- 失败兜底 -->
<div v-else-if="failed" class="pc-wechat-auth__panel">
<svg class="pc-wechat-auth__status-icon" viewBox="0 0 1024 1024" width="64" height="64" aria-hidden="true">
<circle cx="512" cy="512" r="448" fill="#ee0a24" />
<path fill="#fff" d="M705.5 625.7l-41.8 41.8L512 515.8 360.3 667.5l-41.8-41.8L470.2 474 318.5 322.3l41.8-41.8L512 432.2l151.7-151.7 41.8 41.8L553.8 474l151.7 151.7z" />
</svg>
<h3 class="pc-wechat-auth__result-title">
{{ t('auth.wechat.failTitle') }}
</h3>
<p class="pc-wechat-auth__fail-msg">
{{ failMsg }}
</p>
<button class="pc-wechat-auth__btn pc-wechat-auth__btn--ghost" type="button" @click="handleBack">
{{ t('auth.wechat.close') }}
</button>
</div>
<!-- 轻量提示浮层 -->
<Transition name="pc-wechat-auth-toast">
<div v-if="toastVisible" class="pc-wechat-auth__toast">
{{ toastMsg }}
</div>
</Transition>
</div>
</template>
@@ -201,11 +121,6 @@ function handleBack() {
box-sizing: border-box;
}
.pc-wechat-auth__box {
width: 100%;
max-width: 440px;
}
.pc-wechat-auth__panel {
display: flex;
flex-direction: column;
@@ -214,56 +129,10 @@ function handleBack() {
background: var(--h5-bg-card);
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.06);
max-width: 440px;
width: 100%;
}
/* 加载态 */
.pc-wechat-auth__logo {
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 28px;
}
.pc-wechat-auth__logo img {
display: block;
width: 64px;
height: 64px;
object-fit: contain;
}
.pc-wechat-auth__spinner {
width: 36px;
height: 36px;
border: 3px solid #e4f3eb;
border-top-color: #07c160;
border-radius: 50%;
animation: pc-wechat-auth-spin 0.8s linear infinite;
}
@keyframes pc-wechat-auth-spin {
to {
transform: rotate(360deg);
}
}
.pc-wechat-auth__loading-text {
margin: 18px 0 0;
font-size: 15px;
color: var(--h5-text-secondary);
}
.pc-wechat-auth__secure-tip {
display: flex;
align-items: center;
gap: 4px;
margin-top: 36px;
font-size: 13px;
color: var(--h5-text-secondary);
}
/* 状态图标 */
.pc-wechat-auth__status-icon {
margin-bottom: 18px;
}
@@ -284,52 +153,6 @@ function handleBack() {
word-break: break-all;
}
/* 信息卡片 */
.pc-wechat-auth__info-card {
width: 100%;
padding: 18px;
margin-bottom: 36px;
background: var(--h5-bg-muted);
border-radius: 12px;
}
.pc-wechat-auth__info-label {
display: block;
margin-bottom: 10px;
font-size: 13px;
color: var(--h5-text-secondary);
}
.pc-wechat-auth__value-box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
background: var(--h5-bg-card);
border: 1px solid #ebeef5;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease;
}
.pc-wechat-auth__value-box:hover {
background: var(--h5-bg-page);
}
.pc-wechat-auth__value {
flex: 1;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 14px;
color: var(--h5-text-primary);
word-break: break-all;
}
.pc-wechat-auth__copy-icon {
flex-shrink: 0;
}
/* 按钮 */
.pc-wechat-auth__btn {
width: 100%;
height: 46px;
@@ -341,20 +164,7 @@ function handleBack() {
transition: all 0.2s ease;
}
.pc-wechat-auth__btn--primary {
color: #fff;
background: #07c160;
box-shadow: 0 6px 16px rgba(7, 193, 96, 0.28);
}
.pc-wechat-auth__btn--primary:hover {
background: #06ad55;
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(7, 193, 96, 0.36);
}
.pc-wechat-auth__btn--ghost {
margin-top: 12px;
color: var(--h5-text-secondary);
background: var(--h5-bg-card);
border: 1px solid #ebeef5;
@@ -365,30 +175,4 @@ function handleBack() {
border-color: #07c160;
background: var(--h5-bg-muted);
}
/* 轻量提示浮层 */
.pc-wechat-auth__toast {
position: fixed;
top: 40px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
font-size: 14px;
color: #fff;
background: rgba(29, 33, 41, 0.9);
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
z-index: 9999;
}
.pc-wechat-auth-toast-enter-active,
.pc-wechat-auth-toast-leave-active {
transition: all 0.25s ease;
}
.pc-wechat-auth-toast-enter-from,
.pc-wechat-auth-toast-leave-to {
opacity: 0;
transform: translate(-50%, -12px);
}
</style>

View File

@@ -7,6 +7,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { cashierPay, getGatewayOrder, listCashierItems } from '@/shared/api/gateway'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import PayMethodIcon from '@/shared/components/pay/PayMethodIcon.vue'
import QrCodeDisplay from '@/shared/components/pay/QrCodeDisplay.vue'
import { useGatewayOrderPoll } from '@/shared/hooks/use-gateway-order-poll'
@@ -240,11 +241,8 @@ onUnmounted(() => {
<template>
<div class="pc-cashier">
<div class="pc-cashier__box">
<!-- 加载态 -->
<div v-if="loading" class="pc-cashier__loading">
<div class="pc-cashier__spinner" />
<span>{{ t('cashier.paying') }}</span>
</div>
<!-- 加载态统一全屏遮罩替代原卡片内 spinner -->
<InitLoadingMask v-if="loading" />
<!-- 结果态订单已关闭/支付失败/已过期/加载失败 -->
<div v-else-if="resultState" class="pc-cashier__result">
<div

View File

@@ -0,0 +1,134 @@
<script lang="ts" setup>
/**
* 统一的全屏初始化 Loading 遮罩
*
* 用于聚合扫码 / 收银台 / 码牌页的初始化、OAuth 授权跳转、支付发起等阶段的视觉占位,
* 消除「页面闪好几下」的视觉跳变(点阵 loading / van-loading / 品牌色 loading 来回切)。
*
* 设计要点:
* - fixed 全屏遮罩z-index 极高,盖住所有业务内容
* - 旋转圈使用品牌色,文案随阶段切换(加载中 / 授权中 / 支付中)
* - 不依赖 Vant纯 CSS 实现,移动端 / PC 端通用
* - fade 过渡,遮罩出现 / 消失都平滑
*/
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
defineOptions({ name: 'InitLoadingMask' })
const props = withDefaults(defineProps<{
/** 品牌色(旋转圈与文案色),默认主题色 */
brandColor?: string
/** 阶段化文案 i18n key不传则只显示转圈无文案 */
tipKey?: string
/** 品牌 Logo可选展示在旋转圈上方 */
logoSrc?: string
}>(), {
brandColor: 'var(--app-theme-color, #5d9dfe)',
tipKey: '',
logoSrc: '',
})
const { t } = useI18n()
// 阶段文案tipKey 为空时不渲染文案行
const tipText = computed(() => (props.tipKey ? t(props.tipKey) : ''))
</script>
<template>
<div class="init-loading-mask" :style="{ '--mask-brand': brandColor }">
<div class="init-loading-mask__inner">
<img v-if="logoSrc" class="init-loading-mask__logo" :src="logoSrc" alt="">
<div class="init-loading-mask__spinner">
<span class="init-loading-mask__dot" />
<span class="init-loading-mask__dot" />
<span class="init-loading-mask__dot" />
</div>
<p v-if="tipText" class="init-loading-mask__tip">
{{ tipText }}
</p>
</div>
</div>
</template>
<style scoped>
.init-loading-mask {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: var(--h5-bg-page, #f7f8fa);
animation: init-loading-fade-in 0.2s ease-out;
}
.init-loading-mask__inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 32px;
}
.init-loading-mask__logo {
display: block;
width: 56px;
height: 56px;
object-fit: contain;
border-radius: 12px;
margin-bottom: 4px;
}
.init-loading-mask__spinner {
display: flex;
gap: 8px;
}
.init-loading-mask__dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--mask-brand);
animation: init-loading-bounce 1s infinite ease-in-out both;
}
.init-loading-mask__dot:nth-child(1) {
animation-delay: -0.32s;
}
.init-loading-mask__dot:nth-child(2) {
animation-delay: -0.16s;
}
.init-loading-mask__tip {
margin: 0;
font-size: 14px;
color: var(--h5-text-secondary, #969799);
letter-spacing: 0.2px;
text-align: center;
}
@keyframes init-loading-bounce {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@keyframes init-loading-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,105 @@
<script lang="ts" setup>
/**
* 订单卡骨架屏
*
* 在订单数据加载完成前展示,视觉上比纯转圈更平滑,减少「空白 → 内容」的跳跃感。
* 模拟聚合/收银订单卡的视觉结构:顶部品牌色横条 + 上浮白卡(标题 / 金额 / 元信息行)。
*/
defineOptions({ name: 'OrderCardSkeleton' })
</script>
<template>
<div class="order-skeleton">
<!-- 顶部品牌色横条与业务页 agg__brand / cashier__header 对齐 -->
<div class="order-skeleton__brand" />
<!-- 上浮白卡 -->
<div class="order-skeleton__card">
<!-- 标题占位 -->
<div class="order-skeleton__line order-skeleton__line--title" />
<!-- 金额占位 -->
<div class="order-skeleton__line order-skeleton__line--amount" />
<!-- 元信息分隔 -->
<div class="order-skeleton__meta">
<div class="order-skeleton__line order-skeleton__line--meta" />
<div class="order-skeleton__line order-skeleton__line--meta order-skeleton__line--short" />
</div>
</div>
</div>
</template>
<style scoped>
.order-skeleton {
min-height: 100%;
background: var(--h5-bg-page, #f7f8fa);
}
.order-skeleton__brand {
height: 130px;
background: var(--app-theme-color, #5d9dfe);
opacity: 0.6;
}
.order-skeleton__card {
position: relative;
z-index: 1;
margin: -32px 16px 16px;
padding: 24px 20px;
background: var(--h5-bg-card, #fff);
border-radius: 12px;
box-shadow: var(--h5-shadow-card, 0 2px 12px rgb(0 0 0 / 5%));
text-align: center;
}
/* shimmer 占位条 */
.order-skeleton__line {
border-radius: 6px;
background: linear-gradient(
90deg,
var(--h5-bg-muted, #f0f0f0) 25%,
var(--h5-bg-card, #e5e5e5) 37%,
var(--h5-bg-muted, #f0f0f0) 63%
);
background-size: 400% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
.order-skeleton__line--title {
height: 16px;
width: 40%;
margin: 0 auto 16px;
}
.order-skeleton__line--amount {
height: 36px;
width: 50%;
margin: 0 auto 24px;
}
.order-skeleton__meta {
border-top: 1px solid var(--h5-border, #f0f0f0);
padding-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.order-skeleton__line--meta {
height: 12px;
width: 100%;
}
.order-skeleton__line--short {
width: 60%;
}
@keyframes skeleton-shimmer {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
</style>

View File

@@ -7,5 +7,6 @@
"loading": "Loading...",
"tip": "Notice",
"operationSuccess": "Operation succeeded!",
"operationFailed": "Operation failed!"
"operationFailed": "Operation failed!",
"processing": "Processing..."
}

View File

@@ -7,5 +7,6 @@
"loading": "Memuat...",
"tip": "Melihat",
"operationSuccess": "Operasi berhasil!",
"operationFailed": "Operasi gagal!"
"operationFailed": "Operasi gagal!",
"processing": "Memproses..."
}

View File

@@ -7,5 +7,6 @@
"loading": "読み込み中...",
"tip": "知らせ",
"operationSuccess": "作戦成功!",
"operationFailed": "操作は失敗しました!"
"operationFailed": "操作は失敗しました!",
"processing": "処理中..."
}

View File

@@ -7,5 +7,6 @@
"loading": "로드 중...",
"tip": "알아채다",
"operationSuccess": "작전 성공!",
"operationFailed": "작업이 실패했습니다!"
"operationFailed": "작업이 실패했습니다!",
"processing": "처리 중..."
}

View File

@@ -7,5 +7,6 @@
"loading": "Memuatkan...",
"tip": "Notis",
"operationSuccess": "Operasi berjaya!",
"operationFailed": "Operasi gagal!"
"operationFailed": "Operasi gagal!",
"processing": "Memproses..."
}

View File

@@ -7,5 +7,6 @@
"loading": "กำลังโหลด...",
"tip": "สังเกต",
"operationSuccess": "ปฏิบัติการสำเร็จ!",
"operationFailed": "การดำเนินการล้มเหลว!"
"operationFailed": "การดำเนินการล้มเหลว!",
"processing": "กำลังประมวลผล..."
}

View File

@@ -7,5 +7,6 @@
"loading": "Đang tải...",
"tip": "Để ý",
"operationSuccess": "Hoạt động đã thành công!",
"operationFailed": "Hoạt động không thành công!"
"operationFailed": "Hoạt động không thành công!",
"processing": "Đang xử lý..."
}

View File

@@ -7,5 +7,6 @@
"loading": "加载中...",
"tip": "提示",
"operationSuccess": "操作成功!",
"operationFailed": "操作失败!"
"operationFailed": "操作失败!",
"processing": "正在处理..."
}

View File

@@ -7,5 +7,6 @@
"loading": "加載中...",
"tip": "提示",
"operationSuccess": "操作成功!",
"operationFailed": "操作失敗!"
"operationFailed": "操作失敗!",
"processing": "正在處理..."
}

View File

@@ -7,5 +7,6 @@
"loading": "載入中...",
"tip": "提示",
"operationSuccess": "操作成功!",
"operationFailed": "操作失敗!"
"operationFailed": "操作失敗!",
"processing": "正在處理..."
}

View File

@@ -2,6 +2,11 @@ import type { AggregatePayMeta, AggregatePayResult, GatewayOrderInfo } from '@/s
/**
* 聚合扫码环境页共用编排
* loadOrder → meta → OAuth → doPay → handlePayResult
*
* ready 状态机(与 loading 分离,消除「订单卡闪现后进入支付」的视觉跳跃):
* - ready=false业务内容不渲染由 InitLoadingMask 接管视觉
* - ready=true可渲染订单卡 + 支付按钮
* - 跳 OAuth / autoLaunch 自动支付场景始终保持 ready=false让遮罩连续到下一阶段
*/
import type { AggregateClientEnv } from '@/shared/utils/client-env'
import { computed, onUnmounted, ref } from 'vue'
@@ -15,6 +20,7 @@ import { useGatewayOrderPoll } from '@/shared/hooks/use-gateway-order-poll'
import { closeWebview } from '@/shared/pay/close-webview'
import { invokeJsapiByEnv } from '@/shared/pay/jsapi'
import { buildAggregateEnvPath } from '@/shared/utils/client-env'
import { cacheOrder, clearCachedOrder, getCachedOrder } from '@/shared/utils/order-cache'
import { fenToYuan } from '@/shared/utils/pay-amount'
import { getPayOpenId } from '@/shared/utils/pay-openid'
import {
@@ -65,6 +71,8 @@ export function useAggregatePay(options: UseAggregatePayOptions) {
} = options
const loading = ref(true)
// 业务内容是否可渲染(与 loading 分离ready=false 期间由 InitLoadingMask 接管视觉
const ready = ref(false)
const paying = ref(false)
const authorizing = ref(false)
// 跳转类支付结果标志: location.href 触发后页面卸载前 finally 不重置 paying, 避免闪现订单卡
@@ -191,6 +199,8 @@ export function useAggregatePay(options: UseAggregatePayOptions) {
switch (action.type) {
case 'success':
order.value.status = 'paid'
// 支付成功后订单状态已变,清除缓存避免回显未付态
clearCachedOrder(orderNo)
onPaid?.()
redirectIfNeeded()
break
@@ -276,15 +286,31 @@ export function useAggregatePay(options: UseAggregatePayOptions) {
/**
* 加载订单 + meta按 autoLaunch 决定是否自动支付
*
* ready 状态控制:
* - 终态订单(已支付/失败/关闭/过期)→ ready=true展示结果卡
* - 需要 OAuth 跳转 → ready=false保持遮罩连续到回调页 + 回跳)
* - autoLaunch=true → ready=false遮罩直接进入 paying 阶段)
* - 其余可交互场景 → ready=true渲染订单卡 + 立即支付按钮)
*/
async function bootstrap() {
loading.value = true
ready.value = false
loadError.value = ''
openId.value = getPayOpenId(orderNo, clientEnv)
// OAuth 回跳后先从 sessionStorage 缓存恢复订单,减少白屏时间
const cached = getCachedOrder<GatewayOrderInfo>(orderNo)
if (cached) {
order.value = cached
}
try {
order.value = await getGatewayOrder(orderNo)
// 写入缓存OAuth 跳转回跳时可快速恢复
cacheOrder(orderNo, order.value)
startCountdown(order.value.expiredTime)
// 终态订单:显示结果卡
if (paid.value || terminal.value) {
ready.value = true
return
}
meta.value = await getAggregateMeta({
@@ -293,16 +319,23 @@ export function useAggregatePay(options: UseAggregatePayOptions) {
runtime: 'h5',
})
if (meta.value.needOpenId && !openId.value) {
// 需要 OAuth保持 ready=falseInitLoadingMask 持续显示直到跳转
await ensureOpenId()
return
}
// 仅配置显式 autoLaunch=true 时自动拉起
if (meta.value.autoLaunch === true) {
// autoLaunch保持 ready=false由 paying 接管遮罩,避免订单卡闪现
await doPay()
return
}
// 可交互:渲染订单卡 + 立即支付按钮
ready.value = true
}
catch (e: any) {
loadError.value = e?.message || t('aggregate.loadFail')
// 加载失败也要 ready让结果卡loadError显示
ready.value = true
}
finally {
loading.value = false
@@ -318,6 +351,7 @@ export function useAggregatePay(options: UseAggregatePayOptions) {
return {
loading,
ready,
paying,
authorizing,
loadError,

View File

@@ -0,0 +1,76 @@
/**
* 网关订单数据 sessionStorage 缓存
*
* 用于 OAuth 跳转回跳后快速恢复订单展示,减少二次加载造成的 loading 闪烁。
* 维度orderNoTTL 60s订单状态可能变化超时强制重拉
*
* 与 pay-openid.ts 同源设计sessionStorage 而非 localStorage
* 避免跨标签页污染;关闭标签即失效。
*/
interface CachedOrder<T = unknown> {
data: T
expireAt: number
}
const STORAGE_PREFIX = 'daxpay:gw:order:'
const DEFAULT_TTL = 60_000
function storageKey(orderNo: string): string {
return `${STORAGE_PREFIX}${orderNo}`
}
/**
* 写入订单缓存
*/
export function cacheOrder<T = unknown>(orderNo: string, data: T, ttl = DEFAULT_TTL): void {
if (!orderNo || !data) {
return
}
try {
const payload: CachedOrder<T> = { data, expireAt: Date.now() + ttl }
sessionStorage.setItem(storageKey(orderNo), JSON.stringify(payload))
}
catch {
// 隐私模式 / 配额满等忽略
}
}
/**
* 读取订单缓存(过期或不存在返回 undefined
*/
export function getCachedOrder<T = unknown>(orderNo: string): T | undefined {
if (!orderNo) {
return undefined
}
try {
const raw = sessionStorage.getItem(storageKey(orderNo))
if (!raw) {
return undefined
}
const payload = JSON.parse(raw) as CachedOrder<T>
if (!payload?.data || !payload.expireAt || Date.now() > payload.expireAt) {
sessionStorage.removeItem(storageKey(orderNo))
return undefined
}
return payload.data
}
catch {
return undefined
}
}
/**
* 清除订单缓存
*/
export function clearCachedOrder(orderNo: string): void {
if (!orderNo) {
return
}
try {
sessionStorage.removeItem(storageKey(orderNo))
}
catch {
// ignore
}
}