mirror of
https://gitee.com/bootx/dax-pay-h5
synced 2026-08-08 06:05:33 +08:00
refactor(h5): 统一支付页 loading 视觉,消除 OAuth 跳转闪烁
- 新增 InitLoadingMask 组件统一全屏遮罩(聚合/收银台/码牌/回调页共用) - 聚合/收银台/码牌引入 ready 状态,业务内容在就绪前不渲染 - 路由守卫拦截 entry 入口,导航阶段直接 replace 到环境页 - 订单数据 sessionStorage 缓存,OAuth 回跳后快速恢复 - 回调页删除成功态分支,init 提前到 setup,中性文案 common.processing - 新增 OrderCardSkeleton 骨架屏组件(暂未接入业务页,留作扩展)
This commit is contained in:
@@ -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 true),entry.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 等非钱包宿主:留在 CodePayPage(onMounted 显示"请用钱包扫码")
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 根据路由 meta.title(i18n 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
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
134
src/shared/components/pay/InitLoadingMask.vue
Normal file
134
src/shared/components/pay/InitLoadingMask.vue
Normal 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>
|
||||
105
src/shared/components/pay/OrderCardSkeleton.vue
Normal file
105
src/shared/components/pay/OrderCardSkeleton.vue
Normal 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>
|
||||
@@ -7,5 +7,6 @@
|
||||
"loading": "Loading...",
|
||||
"tip": "Notice",
|
||||
"operationSuccess": "Operation succeeded!",
|
||||
"operationFailed": "Operation failed!"
|
||||
"operationFailed": "Operation failed!",
|
||||
"processing": "Processing..."
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"loading": "Memuat...",
|
||||
"tip": "Melihat",
|
||||
"operationSuccess": "Operasi berhasil!",
|
||||
"operationFailed": "Operasi gagal!"
|
||||
"operationFailed": "Operasi gagal!",
|
||||
"processing": "Memproses..."
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"loading": "読み込み中...",
|
||||
"tip": "知らせ",
|
||||
"operationSuccess": "作戦成功!",
|
||||
"operationFailed": "操作は失敗しました!"
|
||||
"operationFailed": "操作は失敗しました!",
|
||||
"processing": "処理中..."
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"loading": "로드 중...",
|
||||
"tip": "알아채다",
|
||||
"operationSuccess": "작전 성공!",
|
||||
"operationFailed": "작업이 실패했습니다!"
|
||||
"operationFailed": "작업이 실패했습니다!",
|
||||
"processing": "처리 중..."
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"loading": "Memuatkan...",
|
||||
"tip": "Notis",
|
||||
"operationSuccess": "Operasi berjaya!",
|
||||
"operationFailed": "Operasi gagal!"
|
||||
"operationFailed": "Operasi gagal!",
|
||||
"processing": "Memproses..."
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"loading": "กำลังโหลด...",
|
||||
"tip": "สังเกต",
|
||||
"operationSuccess": "ปฏิบัติการสำเร็จ!",
|
||||
"operationFailed": "การดำเนินการล้มเหลว!"
|
||||
"operationFailed": "การดำเนินการล้มเหลว!",
|
||||
"processing": "กำลังประมวลผล..."
|
||||
}
|
||||
|
||||
@@ -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ý..."
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"loading": "加载中...",
|
||||
"tip": "提示",
|
||||
"operationSuccess": "操作成功!",
|
||||
"operationFailed": "操作失败!"
|
||||
"operationFailed": "操作失败!",
|
||||
"processing": "正在处理..."
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"loading": "加載中...",
|
||||
"tip": "提示",
|
||||
"operationSuccess": "操作成功!",
|
||||
"operationFailed": "操作失敗!"
|
||||
"operationFailed": "操作失敗!",
|
||||
"processing": "正在處理..."
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"loading": "載入中...",
|
||||
"tip": "提示",
|
||||
"operationSuccess": "操作成功!",
|
||||
"operationFailed": "操作失敗!"
|
||||
"operationFailed": "操作失敗!",
|
||||
"processing": "正在處理..."
|
||||
}
|
||||
|
||||
@@ -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=false,InitLoadingMask 持续显示直到跳转
|
||||
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,
|
||||
|
||||
76
src/shared/utils/order-cache.ts
Normal file
76
src/shared/utils/order-cache.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 网关订单数据 sessionStorage 缓存
|
||||
*
|
||||
* 用于 OAuth 跳转回跳后快速恢复订单展示,减少二次加载造成的 loading 闪烁。
|
||||
* 维度:orderNo;TTL 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user