feat(h5): 支付宝通道认证中间页与本地联调配置

新增 /auth/alipay 移动端落地页(JSAPI 取码),开发代理指向主后端 9999。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bootx
2026-07-09 17:42:32 +08:00
parent 57441585e2
commit 65685e2aea
12 changed files with 428 additions and 6 deletions

View File

@@ -9,7 +9,7 @@ VITE_DROP_CONSOLE = false
# 跨域代理,可以配置多个,请注意不要换行
# /server 代理到本地后端网关(端口 19999
VITE_PROXY = [["/server","http://127.0.0.1:19999/"]]
VITE_PROXY = [["/server","http://127.0.0.1:9999/"]]
# API 接口地址
# 如果没有跨域问题,直接在这里配置即可

View File

@@ -48,11 +48,11 @@ pnpm run lint:fix
## 环境配置
| 文件 | 说明 |
| ------------------ | -------------------------------------------------------------------- |
| `.env` | 应用基础信息(标题、端口) |
| `.env.development` | 开发环境(代理 `/server``http://127.0.0.1:19999`、vconsole 开启) |
| `.env.production` | 生产环境(接口前缀、压缩配置) |
| 文件 | 说明 |
| ------------------ | ------------------------------------------------------------------- |
| `.env` | 应用基础信息(标题、端口) |
| `.env.development` | 开发环境(代理 `/server``http://127.0.0.1:9999`、vconsole 开启) |
| `.env.production` | 生产环境(接口前缀、压缩配置) |
## License

View File

@@ -75,6 +75,15 @@ const routeModuleList: Array<RouteRecordRaw> = [
},
],
},
// 支付宝认证落地页(移动独占:支付宝 App 内 JSAPI 取码)
{
path: RoutePath.AUTH_ALIPAY,
name: 'AlipayAuth',
component: () => import('@/mobile/views/auth/alipay/index.vue'),
meta: {
title: '支付宝认证',
},
},
]
export default routeModuleList

View File

@@ -0,0 +1,326 @@
<script lang="ts" setup>
import type { AuthResult } from '@/shared/api/channel-auth'
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'
defineOptions({ name: 'AlipayAuthPage' })
// AlipayJSAPI 全局类型
declare const ap: {
getAuthCode: (
options: { appId: string | string[], scopes: string[] },
callback: (res: { authCode?: string, error?: number, errorMessage?: string }) => void,
) => void
}
declare const AlipayJSBridge: {
call: (name: string) => void
}
const { t } = useI18n()
const route = useRoute()
const aliAppId = route.params.aliAppId as string
const queryCode = route.params.queryCode as string
const loading = ref(true)
const authResult = ref<AuthResult>({})
const failed = ref(false)
const failMsg = ref('')
// 动态加载支付宝 JSAPI
const script = document.createElement('script')
script.setAttribute(
'src',
'https://gw.alipayobjects.com/as/g/h5-lib/alipayjsapi/3.1.1/alipayjsapi.min.js',
)
document.head.appendChild(script)
script.onload = () => {
init()
}
script.onerror = () => {
markFailed(t('auth.alipay.sdkLoadFail'))
}
/**
* 标记失败状态
*/
function markFailed(msg: string) {
failed.value = true
failMsg.value = msg
loading.value = false
showFailToast(msg)
}
/**
* 页面初始化: JSAPI 取 authCode 后回写后端
*/
function init() {
if (!aliAppId || !queryCode) {
markFailed(t('auth.alipay.paramMissing'))
return
}
ap.getAuthCode(
{
appId: aliAppId,
scopes: ['auth_base'],
},
(res) => {
if (!res.authCode) {
markFailed(res.errorMessage || t('auth.alipay.authCodeFail'))
return
}
authAndGet({
authType: 'alipay',
authCode: res.authCode,
queryCode,
})
.then((data) => {
authResult.value = data ?? {}
loading.value = false
})
.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'))
}
}
/**
* 关闭支付宝内嵌 WebView
*/
function handleClose() {
try {
AlipayJSBridge.call('closeWebview')
}
catch {
// 非支付宝环境忽略
}
}
</script>
<template>
<div class="auth-container">
<!-- 加载中 -->
<div v-if="loading" class="loading-box">
<div class="logo-wrapper">
<div class="channel-logo">
</div>
</div>
<van-loading vertical color="#1677ff" size="32px">
<span class="loading-text">{{ t('auth.alipay.loading') }}</span>
</van-loading>
<div class="footer-tip">
<van-icon name="shield-o" />
<span>{{ t('auth.alipay.secureTip') }}</span>
</div>
</div>
<!-- 失败 -->
<div v-else-if="failed" class="result-box">
<div class="status-icon">
<van-icon name="close" color="#ee0a24" size="64px" />
</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="handleClose">
{{ t('auth.alipay.close') }}
</van-button>
</div>
</div>
<!-- 成功结果 -->
<div v-else class="result-box">
<div class="status-icon">
<van-icon name="checked" color="#07c160" size="64px" />
</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>
<van-icon name="records" class="copy-icon" />
</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>
<style scoped lang="less">
.auth-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
background-color: #f5f5f5;
.loading-box,
.result-box {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 340px;
padding: 40px 24px;
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
}
.loading-box {
.logo-wrapper {
margin-bottom: 24px;
.channel-logo {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
font-size: 28px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #1677ff, #69b1ff);
border-radius: 16px;
}
}
.loading-text {
margin-top: 12px;
font-size: 14px;
color: #666;
}
.footer-tip {
display: flex;
align-items: center;
margin-top: 32px;
font-size: 12px;
color: #999;
.van-icon {
margin-right: 4px;
font-size: 14px;
color: #1677ff;
}
}
}
.result-box {
.status-icon {
margin-bottom: 16px;
}
.result-title {
margin: 0 0 24px;
font-size: 20px;
font-weight: 600;
color: #333;
}
.fail-msg {
margin: 0 0 24px;
font-size: 14px;
color: #666;
text-align: center;
word-break: break-all;
}
.info-card {
width: 100%;
padding: 16px;
margin-bottom: 32px;
background: #f8f9fa;
border-radius: 12px;
.info-item {
.label {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #999;
}
.value-box {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
&:active {
background: #f0f0f0;
}
.value {
margin-right: 8px;
font-family: monospace;
font-size: 14px;
color: #333;
word-break: break-all;
}
.copy-icon {
flex-shrink: 0;
font-size: 16px;
color: #1677ff;
}
}
}
}
.action-buttons {
width: 100%;
.close-btn {
margin-top: 12px;
color: #666;
border-color: #eee;
}
}
}
}
</style>

View File

@@ -0,0 +1,43 @@
import { RequestEnum } from '@/shared/enums/httpEnum'
import { http } from '@/shared/utils/http/axios'
/**
* 通过 AuthCode 获取认证结果(H5 落地页回写)
*/
export function authAndGet(param: AuthCodeParam): Promise<AuthResult> {
return http.request<AuthResult>({
url: '/unipay/assist/channel/auth/auth',
method: RequestEnum.POST,
data: param,
}, {
// 由页面自行处理错误展示
isShowMessage: false,
})
}
/** 通道认证参数 */
export interface AuthCodeParam {
/** 认证类型 alipay/wechat/... */
authType?: string
/** 授权码 */
authCode?: string
/** 查询码(管理端轮询用) */
queryCode?: string
/** 通道 */
channel?: string
/** 应用号 */
appId?: string
/** 认证会话码 */
authToken?: string
/** 支付产品 */
product?: string
}
/** 认证结果 */
export interface AuthResult {
openId?: string
userId?: string
accessToken?: string
status?: string
returnPath?: string
}

View File

@@ -0,0 +1,17 @@
{
"alipay": {
"loading": "Fetching authorization...",
"secureTip": "Alipay secure auth",
"successTitle": "Success",
"failTitle": "Failed",
"userId": "Alipay User ID",
"copy": "Copy",
"close": "Close",
"copySuccess": "Copied",
"copyFail": "Copy failed",
"sdkLoadFail": "Failed to load Alipay SDK. Open in Alipay app.",
"paramMissing": "Missing auth parameters",
"authCodeFail": "Failed to get auth code",
"authFail": "Failed to get user id"
}
}

View File

@@ -1,3 +1,4 @@
import auth from './auth.json'
import cashier from './cashier.json'
import codePay from './codePay.json'
import common from './common.json'
@@ -10,6 +11,7 @@ import statement from './statement.json'
export default {
common,
http,
auth,
cashier,
codePay,
protocol,

View File

@@ -0,0 +1,17 @@
{
"alipay": {
"loading": "正在获取授权信息...",
"secureTip": "支付宝安全认证",
"successTitle": "获取成功",
"failTitle": "获取失败",
"userId": "支付宝用户ID",
"copy": "复制信息",
"close": "关闭页面",
"copySuccess": "复制成功",
"copyFail": "复制失败",
"sdkLoadFail": "支付宝 SDK 加载失败,请在支付宝内打开",
"paramMissing": "授权参数缺失",
"authCodeFail": "获取授权码失败",
"authFail": "获取用户标识失败"
}
}

View File

@@ -1,3 +1,4 @@
import auth from './auth.json'
import cashier from './cashier.json'
import codePay from './codePay.json'
import common from './common.json'
@@ -10,6 +11,7 @@ import statement from './statement.json'
export default {
common,
http,
auth,
cashier,
codePay,
protocol,

View File

@@ -27,6 +27,8 @@ export interface ExclusiveRoute {
export const EXCLUSIVE_ROUTES: ExclusiveRoute[] = [
// 码牌支付:仅移动端实现
{ path: RoutePath.CODE_PAY, name: 'CodePay', device: 'mobile' },
// 支付宝认证落地页:仅移动端实现(支付宝 App 内 JSAPI
{ path: RoutePath.AUTH_ALIPAY, name: 'AlipayAuth', device: 'mobile' },
// 商户对账单:仅 PC 端实现
{ path: RoutePath.MERCHANT_STATEMENT, name: 'MerchantStatement', device: 'pc' },
]

View File

@@ -23,4 +23,6 @@ export const RoutePath = {
MERCHANT_STATEMENT: '/merchant/statement',
/** 协议展示页(跨端:用户协议/隐私政策,链接可独立分享) */
PROTOCOL: '/protocol/:protocolType',
/** 支付宝认证落地页移动独占JSAPI 取码) */
AUTH_ALIPAY: '/auth/alipay/:aliAppId/:queryCode',
} as const

View File

@@ -142,6 +142,8 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
open: false,
// 服务端口号
port: Number(VITE_PORT),
// 允许 nginx 反代的自定义域名访问(抖音 OAuth 本地调试)
allowedHosts: true,
proxy: createProxy(VITE_PROXY),
// 预热文件以降低启动期间的初始页面加载时长
warmup: {