refactor(h5/auth): 合并 mobile OAuth 落地页为 AuthCallback 共享组件

将 auth/wechat、auth/alipay、auth/douyin 三个高度重复的落地页(单文件 270+ 行,
重复率 90%+) 合并为一个 AuthCallback.vue 共享组件 + 3 个薄壳。

差异点(channel/codeField/brandColor/i18n 命名空间/identifierKey) 通过 props
驱动的 CHANNEL_CONFIG 配置表内化, 消除三份独立实现。

路由 path 与第三方 redirect_uri 配置完全不动; PC wechat 落地页因视觉层完全
不同(Vant+LESS vs 原生 button+scoped px+BEM) 不合并, 保持独立。
This commit is contained in:
bootx
2026-07-20 20:18:27 +08:00
parent c5d6fb485b
commit 305f114178
4 changed files with 314 additions and 788 deletions

View File

@@ -0,0 +1,299 @@
<script lang="ts" setup>
/**
* 通道 OAuth 重定向回调共享落地页
*
* 由 wechat / alipay / douyin 三个薄壳通过 `channel` prop 驱动, 差异点(codeField /
* brandColor / i18n 命名空间) 内化到 CHANNEL_CONFIG 配置表, 消除三份高度重复的实现。
*
* 两种落地场景:
* - **网关业务场景**(收银/聚合/码牌): 拿到 openId/userId 后由 finishGatewayAuthAndRedirect
* 直接落盘 sessionStorage 并 location.replace 回业务页, 本页始终处于 loading 态直到跳走
* - **调试场景**(Web 端 ChannelAuth 扫码): AuthSession 无 returnPath, 拿到标识后
* 展示"获取成功"卡片(点击即可复制), 供 PC 端扫码调试查看
*
* 失败兜底: 缺 code/state 或后端换标识失败时显示错误卡 + 关闭按钮。
*/
import { showFailToast, showSuccessToast } from 'vant'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { authAndGet } from '@/shared/api/channel-auth'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import { closeWebview } from '@/shared/pay/close-webview'
import { finishGatewayAuthAndRedirect } from '@/shared/utils/auth-return'
type AuthChannel = 'wechat' | 'alipay' | 'douyin'
interface ChannelConfig {
/** query 中取 code 用的字段名(支付宝回传 auth_code, 其余回传 code) */
codeField: 'code' | 'auth_code'
/** 品牌色(loading 圈色 + 调试卡片 value 色) */
brandColor: string
/** i18n 命名空间(对应 auth.json 中的 wechat/alipay/douyin 组) */
i18nNs: string
/**
* 标识字段名(用于调试卡片 label i18n key 拼接)
* wechat/douyin 用 openId, alipay 用 userId(C 方案 i18n 重组后统一为 identifierLabel)
*/
identifierKey: 'openId' | 'userId'
}
const props = defineProps<{ channel: AuthChannel }>()
const CHANNEL_CONFIG: Record<AuthChannel, ChannelConfig> = {
wechat: { codeField: 'code', brandColor: '#07c160', i18nNs: 'wechat', identifierKey: 'openId' },
alipay: { codeField: 'auth_code', brandColor: '#1677ff', i18nNs: 'alipay', identifierKey: 'userId' },
douyin: { codeField: 'code', brandColor: '#161823', i18nNs: 'douyin', identifierKey: 'openId' },
}
const cfg = computed(() => CHANNEL_CONFIG[props.channel])
const { t } = useI18n()
const route = useRoute()
// OAuth 重定向回调: query 携带 code(微信/抖音) 或 auth_code(支付宝) + state(会话标识 authToken)
const authToken = route.query.state as string
const code = route.query[cfg.value.codeField] as string | undefined
const loading = ref(true)
const failed = ref(false)
const failMsg = ref('')
// 调试场景(Web 端 ChannelAuth 扫码)成功态: 展示可点击复制的标识卡片
const success = ref(false)
const identifierValue = ref('')
/**
* 标记调试场景成功: 无 returnPath 但后端已返回 openId/userId
* 展示可点击复制的标识卡片, 供 PC 端扫码调试查看
*/
function markSuccess(identifier: string) {
success.value = true
identifierValue.value = identifier
loading.value = false
}
/**
* 标记失败状态
*/
function markFailed(msg: string) {
failed.value = true
failMsg.value = msg
loading.value = false
showFailToast(msg)
}
/**
* 页面初始化: 取回调 code + authToken 换取 openId/userId 后回写后端
*
* 提前到 setup 顶层执行(不等 onMounted), 减少 Vue 挂载到 init 的间隙,
* 让 InitLoadingMask 第一时间接管视觉
*/
function init() {
if (!authToken || !code) {
markFailed(t(`auth.${cfg.value.i18nNs}.codeMissing`))
return
}
authAndGet({
authType: props.channel,
authCode: code,
authToken,
})
.then((data) => {
// 网关业务回跳: 有 returnPath 则落盘 openId/userId 并跳转业务页
if (finishGatewayAuthAndRedirect(data ?? {})) {
return
}
// 调试场景: 无 returnPath 但拿到标识, 展示成功卡片(供 PC 端扫码调试查看)
const identifier = data?.openId || data?.userId
if (identifier) {
markSuccess(identifier)
return
}
// 真正失败: 既无 returnPath 也无标识
markFailed(t(`auth.${cfg.value.i18nNs}.authFail`))
})
.catch((err: Error) => {
markFailed(err?.message || t(`auth.${cfg.value.i18nNs}.authFail`))
})
}
// 立即触发(不等 onMounted, 减少白屏时间)
init()
/**
* 复制标识到剪贴板(兼容微信/支付宝/抖音内嵌 WebView 的 navigator.clipboard 缺失场景)
*/
async function copyIdentifier() {
if (!identifierValue.value) {
return
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(identifierValue.value)
}
else {
// 兜底: 临时 textarea + execCommand('copy')
const textarea = document.createElement('textarea')
textarea.value = identifierValue.value
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
showSuccessToast(t(`auth.${cfg.value.i18nNs}.copySuccess`))
}
catch {
showFailToast(t(`auth.${cfg.value.i18nNs}.copyFail`))
}
}
</script>
<template>
<div class="auth-container">
<!-- 加载中统一全屏遮罩(与业务页视觉一致, 中性文案不暴露"获取信息"细节) -->
<InitLoadingMask
v-if="loading"
:brand-color="cfg.brandColor"
tip-key="common.processing"
/>
<!-- 调试场景成功卡片( returnPath, 展示可复制的 openId/userId) -->
<div v-else-if="success" 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="M432 660.3l-145.6-145.6 45.2-45.2L432 569.7l260.4-260.4 45.2 45.2z" />
</svg>
</div>
<h3 class="result-title">
{{ t(`auth.${cfg.i18nNs}.successTitle`) }}
</h3>
<div class="identifier-card" @click="copyIdentifier">
<span class="card-label">{{ t(`auth.${cfg.i18nNs}.${cfg.identifierKey}`) }}</span>
<div class="card-value" :style="{ color: cfg.brandColor }">
{{ identifierValue }}
</div>
<span class="copy-hint">{{ t(`auth.${cfg.i18nNs}.copy`) }}</span>
</div>
<div class="action-buttons">
<van-button plain round block class="close-btn" @click="closeWebview">
{{ t(`auth.${cfg.i18nNs}.close`) }}
</van-button>
</div>
</div>
<!-- 失败兜底 -->
<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">
<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>
</div>
<h3 class="result-title">
{{ t(`auth.${cfg.i18nNs}.failTitle`) }}
</h3>
<p class="fail-msg">
{{ failMsg }}
</p>
<div class="action-buttons">
<van-button plain round block class="close-btn" @click="closeWebview">
{{ t(`auth.${cfg.i18nNs}.close`) }}
</van-button>
</div>
</div>
</div>
</template>
<style scoped lang="less">
.auth-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
background-color: var(--h5-bg-page);
.result-box {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 340px;
padding: 40px 24px;
background: var(--h5-bg-card);
border-radius: 16px;
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
.status-icon {
margin-bottom: 16px;
line-height: 0;
}
.result-title {
margin: 0 0 24px;
font-size: 20px;
font-weight: 600;
color: var(--h5-text-primary);
}
.fail-msg {
margin: 0 0 24px;
font-size: 14px;
color: var(--h5-text-secondary);
text-align: center;
word-break: break-all;
}
// 调试场景: 标识展示卡片(可点击复制)
.identifier-card {
width: 100%;
margin: 0 0 20px;
padding: 14px 16px;
background: var(--h5-bg-page);
border-radius: 10px;
cursor: pointer;
transition: background-color 0.2s;
&:active {
background: rgb(0 0 0 / 6%);
}
.card-label {
display: block;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: var(--h5-text-secondary);
letter-spacing: 0.05em;
}
.card-value {
font-size: 14px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
word-break: break-all;
}
.copy-hint {
display: block;
margin-top: 8px;
font-size: 12px;
color: var(--h5-text-secondary);
text-align: right;
}
}
.action-buttons {
width: 100%;
.close-btn {
color: var(--h5-text-secondary);
border-color: var(--h5-border);
}
}
}
}
</style>

View File

@@ -1,272 +1,15 @@
<script lang="ts" setup>
/**
* 支付宝 OAuth 重定向回调落地页
* 支付宝 OAuth 重定向回调落地页(薄壳)
*
* 两种落地场景:
* - **网关业务场景**(收银/聚合/码牌): 拿到 userId 后由 finishGatewayAuthAndRedirect 直接
* 落盘 sessionStorage 并 location.replace 回业务页, 本页始终处于 loading 态直到跳走
* - **调试场景**(Web 端 ChannelAuth 扫码): AuthSession 无 returnPath, 拿到 userId 后
* 展示"获取成功"卡片(点击 userId 即可复制), 供 PC 端扫码调试查看
*
* 失败兜底: 缺 auth_code/state 或后端换 userId 失败时显示错误卡 + 关闭按钮。
* 实际逻辑由 AuthCallback 共享组件承载, 本文件仅声明组件 name 与通道,
* 便于路由 name 对齐与未来按通道独立拆分
*/
import { showFailToast, showSuccessToast } from 'vant'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { authAndGet } from '@/shared/api/channel-auth'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import { closeWebview } from '@/shared/pay/close-webview'
import { finishGatewayAuthAndRedirect } from '@/shared/utils/auth-return'
import AuthCallback from '@/mobile/views/auth/AuthCallback.vue'
defineOptions({ name: 'AlipayAuthPage' })
const { t } = useI18n()
const route = useRoute()
// 支付宝 OAuth 重定向回调: query 携带 auth_code(支付宝回传) + state(会话标识 authToken)
const authToken = route.query.state as string
const code = route.query.auth_code as string | undefined
const loading = ref(true)
const failed = ref(false)
const failMsg = ref('')
// 调试场景(Web 端 ChannelAuth 扫码)成功态: 展示可点击复制的 userId 卡片
const success = ref(false)
const userIdValue = ref('')
/**
* 标记调试场景成功: 无 returnPath 但后端已返回 userId
* 展示可点击复制的 userId 卡片, 供 PC 端扫码调试查看
*/
function markSuccess(userId: string) {
success.value = true
userIdValue.value = userId
loading.value = false
}
/**
* 标记失败状态
*/
function markFailed(msg: string) {
failed.value = true
failMsg.value = msg
loading.value = false
showFailToast(msg)
}
/**
* 页面初始化: 取回调 auth_code + authToken 换取 userId 后回写后端
*
* 提前到 setup 顶层执行(不等 onMounted减少 Vue 挂载到 init 的间隙
*/
function init() {
if (!authToken || !code) {
markFailed(t('auth.alipay.codeMissing'))
return
}
authAndGet({
authType: 'alipay',
authCode: code,
authToken,
})
.then((data) => {
// 网关业务回跳: 有 returnPath 则落盘 userId 并跳转业务页
if (finishGatewayAuthAndRedirect(data ?? {})) {
return
}
// 调试场景: 无 returnPath 但拿到 userId, 展示成功卡片(供 PC 端扫码调试查看)
const userId = data?.openId || data?.userId
if (userId) {
markSuccess(userId)
return
}
// 真正失败: 既无 returnPath 也无 userId
markFailed(t('auth.alipay.authFail'))
})
.catch((err: Error) => {
markFailed(err?.message || t('auth.alipay.authFail'))
})
}
// 立即触发(不等 onMounted减少白屏时间
init()
/**
* 复制 userId 到剪贴板(兼容支付宝内嵌 WebView 的 navigator.clipboard 缺失场景)
*/
async function copyUserId() {
if (!userIdValue.value) {
return
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(userIdValue.value)
}
else {
// 兜底: 临时 textarea + execCommand('copy')
const textarea = document.createElement('textarea')
textarea.value = userIdValue.value
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
showSuccessToast(t('auth.alipay.copySuccess'))
}
catch {
showFailToast(t('auth.alipay.copyFail'))
}
}
</script>
<template>
<div class="auth-container">
<!-- 加载中统一全屏遮罩中性文案不暴露"获取信息"细节 -->
<InitLoadingMask
v-if="loading"
brand-color="#1677ff"
tip-key="common.processing"
/>
<!-- 调试场景成功卡片( returnPath, 展示可复制的 userId) -->
<div v-else-if="success" 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="M432 660.3l-145.6-145.6 45.2-45.2L432 569.7l260.4-260.4 45.2 45.2z" />
</svg>
</div>
<h3 class="result-title">
{{ t('auth.alipay.successTitle') }}
</h3>
<div class="open-id-card" @click="copyUserId">
<span class="card-label">{{ t('auth.alipay.userId') }}</span>
<div class="card-value">
{{ userIdValue }}
</div>
<span class="copy-hint">{{ t('auth.alipay.copy') }}</span>
</div>
<div class="action-buttons">
<van-button plain round block class="close-btn" @click="closeWebview">
{{ t('auth.alipay.close') }}
</van-button>
</div>
</div>
<!-- 失败兜底 -->
<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">
<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>
</div>
<h3 class="result-title">
{{ t('auth.alipay.failTitle') }}
</h3>
<p class="fail-msg">
{{ failMsg }}
</p>
<div class="action-buttons">
<van-button plain round block class="close-btn" @click="closeWebview">
{{ t('auth.alipay.close') }}
</van-button>
</div>
</div>
</div>
<AuthCallback channel="alipay" />
</template>
<style scoped lang="less">
.auth-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
background-color: var(--h5-bg-page);
.result-box {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 340px;
padding: 40px 24px;
background: var(--h5-bg-card);
border-radius: 16px;
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
.status-icon {
margin-bottom: 16px;
line-height: 0;
}
.result-title {
margin: 0 0 24px;
font-size: 20px;
font-weight: 600;
color: var(--h5-text-primary);
}
.fail-msg {
margin: 0 0 24px;
font-size: 14px;
color: var(--h5-text-secondary);
text-align: center;
word-break: break-all;
}
// 调试场景: userId 展示卡片(可点击复制)
.open-id-card {
width: 100%;
margin: 0 0 20px;
padding: 14px 16px;
background: var(--h5-bg-page);
border-radius: 10px;
cursor: pointer;
transition: background-color 0.2s;
&:active {
background: rgb(0 0 0 / 6%);
}
.card-label {
display: block;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: var(--h5-text-secondary);
letter-spacing: 0.05em;
}
.card-value {
font-size: 14px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
color: #1677ff;
word-break: break-all;
}
.copy-hint {
display: block;
margin-top: 8px;
font-size: 12px;
color: var(--h5-text-secondary);
text-align: right;
}
}
.action-buttons {
width: 100%;
.close-btn {
color: var(--h5-text-secondary);
border-color: var(--h5-border);
}
}
}
}
</style>

View File

@@ -1,273 +1,15 @@
<script lang="ts" setup>
/**
* 抖音 silent_auth 重定向回调落地页
* 抖音 silent_auth 重定向回调落地页(薄壳)
*
* 两种落地场景:
* - **网关业务场景**(收银/聚合/码牌): 拿到 openId 后由 finishGatewayAuthAndRedirect 直接
* 落盘 sessionStorage 并 location.replace 回业务页, 本页始终处于 loading 态直到跳走
* - **调试场景**(Web 端 ChannelAuth 扫码): AuthSession 无 returnPath, 拿到 openId 后
* 展示"获取成功"卡片(点击 openId 即可复制), 供 PC 端扫码调试查看
*
* 失败兜底: 缺 code/state 或后端换 openId 失败时显示错误卡 + 关闭按钮。
* 实际逻辑由 AuthCallback 共享组件承载, 本文件仅声明组件 name 与通道,
* 便于路由 name 对齐与未来按通道独立拆分
*/
import { showFailToast, showSuccessToast } from 'vant'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { authAndGet } from '@/shared/api/channel-auth'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import { closeWebview } from '@/shared/pay/close-webview'
import { finishGatewayAuthAndRedirect } from '@/shared/utils/auth-return'
import AuthCallback from '@/mobile/views/auth/AuthCallback.vue'
defineOptions({ name: 'DouyinAuthPage' })
const { t } = useI18n()
const route = useRoute()
// 抖音 silent_auth 重定向回调: 固定路径(无 path 参数), code 和 state 均在 query 中
// state 携带 authToken(由后端 generateDouyinAuthUrl 写入), 用于恢复认证会话
const authToken = route.query.state as string
const code = route.query.code as string | undefined
const loading = ref(true)
const failed = ref(false)
const failMsg = ref('')
// 调试场景(Web 端 ChannelAuth 扫码)成功态: 展示可点击复制的 openId 卡片
const success = ref(false)
const openIdValue = ref('')
/**
* 标记调试场景成功: 无 returnPath 但后端已返回 openId
* 展示可点击复制的 openId 卡片, 供 PC 端扫码调试查看
*/
function markSuccess(openId: string) {
success.value = true
openIdValue.value = openId
loading.value = false
}
/**
* 标记失败状态
*/
function markFailed(msg: string) {
failed.value = true
failMsg.value = msg
loading.value = false
showFailToast(msg)
}
/**
* 页面初始化: 取回调 code + authToken 换取 openId 后回写后端
*
* 提前到 setup 顶层执行(不等 onMounted减少 Vue 挂载到 init 的间隙
*/
function init() {
if (!authToken || !code) {
markFailed(t('auth.douyin.codeMissing'))
return
}
authAndGet({
authType: 'douyin',
authCode: code,
authToken,
})
.then((data) => {
// 网关业务回跳: 有 returnPath 则落盘 openId 并跳转业务页
if (finishGatewayAuthAndRedirect(data ?? {})) {
return
}
// 调试场景: 无 returnPath 但拿到 openId, 展示成功卡片(供 PC 端扫码调试查看)
const openId = data?.openId || data?.userId
if (openId) {
markSuccess(openId)
return
}
// 真正失败: 既无 returnPath 也无 openId
markFailed(t('auth.douyin.authFail'))
})
.catch((err: Error) => {
markFailed(err?.message || t('auth.douyin.authFail'))
})
}
// 立即触发(不等 onMounted减少白屏时间
init()
/**
* 复制 openId 到剪贴板(兼容抖音内嵌 WebView 的 navigator.clipboard 缺失场景)
*/
async function copyOpenId() {
if (!openIdValue.value) {
return
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(openIdValue.value)
}
else {
// 兜底: 临时 textarea + execCommand('copy')
const textarea = document.createElement('textarea')
textarea.value = openIdValue.value
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
showSuccessToast(t('auth.douyin.copySuccess'))
}
catch {
showFailToast(t('auth.douyin.copyFail'))
}
}
</script>
<template>
<div class="auth-container">
<!-- 加载中统一全屏遮罩中性文案不暴露"获取信息"细节 -->
<InitLoadingMask
v-if="loading"
brand-color="#161823"
tip-key="common.processing"
/>
<!-- 调试场景成功卡片( returnPath, 展示可复制的 openId) -->
<div v-else-if="success" 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="M432 660.3l-145.6-145.6 45.2-45.2L432 569.7l260.4-260.4 45.2 45.2z" />
</svg>
</div>
<h3 class="result-title">
{{ t('auth.douyin.successTitle') }}
</h3>
<div class="open-id-card" @click="copyOpenId">
<span class="card-label">{{ t('auth.douyin.openId') }}</span>
<div class="card-value">
{{ openIdValue }}
</div>
<span class="copy-hint">{{ t('auth.douyin.copy') }}</span>
</div>
<div class="action-buttons">
<van-button plain round block class="close-btn" @click="closeWebview">
{{ t('auth.douyin.close') }}
</van-button>
</div>
</div>
<!-- 失败兜底 -->
<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">
<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>
</div>
<h3 class="result-title">
{{ t('auth.douyin.failTitle') }}
</h3>
<p class="fail-msg">
{{ failMsg }}
</p>
<div class="action-buttons">
<van-button plain round block class="close-btn" @click="closeWebview">
{{ t('auth.douyin.close') }}
</van-button>
</div>
</div>
</div>
<AuthCallback channel="douyin" />
</template>
<style scoped lang="less">
.auth-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
background-color: var(--h5-bg-page);
.result-box {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 340px;
padding: 40px 24px;
background: var(--h5-bg-card);
border-radius: 16px;
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
.status-icon {
margin-bottom: 16px;
line-height: 0;
}
.result-title {
margin: 0 0 24px;
font-size: 20px;
font-weight: 600;
color: var(--h5-text-primary);
}
.fail-msg {
margin: 0 0 24px;
font-size: 14px;
color: var(--h5-text-secondary);
text-align: center;
word-break: break-all;
}
// 调试场景: openId 展示卡片(可点击复制)
.open-id-card {
width: 100%;
margin: 0 0 20px;
padding: 14px 16px;
background: var(--h5-bg-page);
border-radius: 10px;
cursor: pointer;
transition: background-color 0.2s;
&:active {
background: rgb(0 0 0 / 6%);
}
.card-label {
display: block;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: var(--h5-text-secondary);
letter-spacing: 0.05em;
}
.card-value {
font-size: 14px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
color: #161823;
word-break: break-all;
}
.copy-hint {
display: block;
margin-top: 8px;
font-size: 12px;
color: var(--h5-text-secondary);
text-align: right;
}
}
.action-buttons {
width: 100%;
.close-btn {
color: var(--h5-text-secondary);
border-color: var(--h5-border);
}
}
}
}
</style>

View File

@@ -1,273 +1,15 @@
<script lang="ts" setup>
/**
* 微信 OAuth 重定向回调落地页
* 微信 OAuth 重定向回调落地页(薄壳)
*
* 两种落地场景:
* - **网关业务场景**(收银/聚合/码牌): 拿到 openId 后由 finishGatewayAuthAndRedirect 直接
* 落盘 sessionStorage 并 location.replace 回业务页, 本页始终处于 loading 态直到跳走
* - **调试场景**(Web 端 ChannelAuth 扫码): AuthSession 无 returnPath, 拿到 openId 后
* 展示"获取成功"卡片(点击 openId 即可复制), 供 PC 端扫码调试查看
*
* 失败兜底: 缺 code/state 或后端换 openId 失败时显示错误卡 + 关闭按钮。
* 实际逻辑由 AuthCallback 共享组件承载, 本文件仅声明组件 name 与通道,
* 便于路由 name 对齐与未来按通道独立拆分
*/
import { showFailToast, showSuccessToast } from 'vant'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { authAndGet } from '@/shared/api/channel-auth'
import InitLoadingMask from '@/shared/components/pay/InitLoadingMask.vue'
import { closeWebview } from '@/shared/pay/close-webview'
import { finishGatewayAuthAndRedirect } from '@/shared/utils/auth-return'
import AuthCallback from '@/mobile/views/auth/AuthCallback.vue'
defineOptions({ name: 'WechatAuthPage' })
const { t } = useI18n()
const route = useRoute()
// 微信 OAuth 重定向回调: query 携带 code(微信回传) + state(会话标识 authToken)
const authToken = route.query.state as string
const code = route.query.code as string | undefined
const loading = ref(true)
const failed = ref(false)
const failMsg = ref('')
// 调试场景(Web 端 ChannelAuth 扫码)成功态: 展示可点击复制的 openId 卡片
const success = ref(false)
const openIdValue = ref('')
/**
* 标记调试场景成功: 无 returnPath 但后端已返回 openId
* 展示可点击复制的 openId 卡片, 供 PC 端扫码调试查看
*/
function markSuccess(openId: string) {
success.value = true
openIdValue.value = openId
loading.value = false
}
/**
* 标记失败状态
*/
function markFailed(msg: string) {
failed.value = true
failMsg.value = msg
loading.value = false
showFailToast(msg)
}
/**
* 页面初始化: 取回调 code + authToken 换取 openId 后回写后端
*
* 提前到 setup 顶层执行(不等 onMounted减少 Vue 挂载到 init 的间隙,
* 让 InitLoadingMask 第一时间接管视觉
*/
function init() {
if (!authToken || !code) {
markFailed(t('auth.wechat.codeMissing'))
return
}
authAndGet({
authType: 'wechat',
authCode: code,
authToken,
})
.then((data) => {
// 网关业务回跳: 有 returnPath 则落盘 openId 并跳转业务页
if (finishGatewayAuthAndRedirect(data ?? {})) {
return
}
// 调试场景: 无 returnPath 但拿到 openId, 展示成功卡片(供 PC 端扫码调试查看)
const openId = data?.openId || data?.userId
if (openId) {
markSuccess(openId)
return
}
// 真正失败: 既无 returnPath 也无 openId
markFailed(t('auth.wechat.authFail'))
})
.catch((err: Error) => {
markFailed(err?.message || t('auth.wechat.authFail'))
})
}
// 立即触发(不等 onMounted减少白屏时间
init()
/**
* 复制 openId 到剪贴板(兼容微信内嵌 WebView 的 navigator.clipboard 缺失场景)
*/
async function copyOpenId() {
if (!openIdValue.value) {
return
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(openIdValue.value)
}
else {
// 兜底: 临时 textarea + execCommand('copy')
const textarea = document.createElement('textarea')
textarea.value = openIdValue.value
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
showSuccessToast(t('auth.wechat.copySuccess'))
}
catch {
showFailToast(t('auth.wechat.copyFail'))
}
}
</script>
<template>
<div class="auth-container">
<!-- 加载中统一全屏遮罩与业务页视觉一致中性文案不暴露"获取信息"细节 -->
<InitLoadingMask
v-if="loading"
brand-color="#07c160"
tip-key="common.processing"
/>
<!-- 调试场景成功卡片( returnPath, 展示可复制的 openId) -->
<div v-else-if="success" 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="M432 660.3l-145.6-145.6 45.2-45.2L432 569.7l260.4-260.4 45.2 45.2z" />
</svg>
</div>
<h3 class="result-title">
{{ t('auth.wechat.successTitle') }}
</h3>
<div class="open-id-card" @click="copyOpenId">
<span class="card-label">{{ t('auth.wechat.openId') }}</span>
<div class="card-value">
{{ openIdValue }}
</div>
<span class="copy-hint">{{ t('auth.wechat.copy') }}</span>
</div>
<div class="action-buttons">
<van-button plain round block class="close-btn" @click="closeWebview">
{{ t('auth.wechat.close') }}
</van-button>
</div>
</div>
<!-- 失败兜底 -->
<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">
<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>
</div>
<h3 class="result-title">
{{ t('auth.wechat.failTitle') }}
</h3>
<p class="fail-msg">
{{ failMsg }}
</p>
<div class="action-buttons">
<van-button plain round block class="close-btn" @click="closeWebview">
{{ t('auth.wechat.close') }}
</van-button>
</div>
</div>
</div>
<AuthCallback channel="wechat" />
</template>
<style scoped lang="less">
.auth-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
background-color: var(--h5-bg-page);
.result-box {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 340px;
padding: 40px 24px;
background: var(--h5-bg-card);
border-radius: 16px;
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
.status-icon {
margin-bottom: 16px;
line-height: 0;
}
.result-title {
margin: 0 0 24px;
font-size: 20px;
font-weight: 600;
color: var(--h5-text-primary);
}
.fail-msg {
margin: 0 0 24px;
font-size: 14px;
color: var(--h5-text-secondary);
text-align: center;
word-break: break-all;
}
// 调试场景: openId 展示卡片(可点击复制)
.open-id-card {
width: 100%;
margin: 0 0 20px;
padding: 14px 16px;
background: var(--h5-bg-page);
border-radius: 10px;
cursor: pointer;
transition: background-color 0.2s;
&:active {
background: rgb(0 0 0 / 6%);
}
.card-label {
display: block;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: var(--h5-text-secondary);
letter-spacing: 0.05em;
}
.card-value {
font-size: 14px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
color: #07c160;
word-break: break-all;
}
.copy-hint {
display: block;
margin-top: 8px;
font-size: 12px;
color: var(--h5-text-secondary);
text-align: right;
}
}
.action-buttons {
width: 100%;
.close-btn {
color: var(--h5-text-secondary);
border-color: var(--h5-border);
}
}
}
}
</style>