mirror of
https://gitee.com/bootx/dax-pay-h5
synced 2026-08-07 05:45:45 +08:00
feat(h5): 支付宝通道认证中间页与本地联调配置
新增 /auth/alipay 移动端落地页(JSAPI 取码),开发代理指向主后端 9999。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 接口地址
|
||||
# 如果没有跨域问题,直接在这里配置即可
|
||||
|
||||
10
README.md
10
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
326
src/mobile/views/auth/alipay/index.vue
Normal file
326
src/mobile/views/auth/alipay/index.vue
Normal 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>
|
||||
43
src/shared/api/channel-auth.ts
Normal file
43
src/shared/api/channel-auth.ts
Normal 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
|
||||
}
|
||||
17
src/shared/locales/en-US/auth.json
Normal file
17
src/shared/locales/en-US/auth.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
17
src/shared/locales/zh-CN/auth.json
Normal file
17
src/shared/locales/zh-CN/auth.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"alipay": {
|
||||
"loading": "正在获取授权信息...",
|
||||
"secureTip": "支付宝安全认证",
|
||||
"successTitle": "获取成功",
|
||||
"failTitle": "获取失败",
|
||||
"userId": "支付宝用户ID",
|
||||
"copy": "复制信息",
|
||||
"close": "关闭页面",
|
||||
"copySuccess": "复制成功",
|
||||
"copyFail": "复制失败",
|
||||
"sdkLoadFail": "支付宝 SDK 加载失败,请在支付宝内打开",
|
||||
"paramMissing": "授权参数缺失",
|
||||
"authCodeFail": "获取授权码失败",
|
||||
"authFail": "获取用户标识失败"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -23,4 +23,6 @@ export const RoutePath = {
|
||||
MERCHANT_STATEMENT: '/merchant/statement',
|
||||
/** 协议展示页(跨端:用户协议/隐私政策,链接可独立分享) */
|
||||
PROTOCOL: '/protocol/:protocolType',
|
||||
/** 支付宝认证落地页(移动独占:JSAPI 取码) */
|
||||
AUTH_ALIPAY: '/auth/alipay/:aliAppId/:queryCode',
|
||||
} as const
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user