mirror of
https://gitee.com/bootx/dax-pay-h5
synced 2026-08-09 22:45:58 +08:00
feat(stripe): 接入 Stripe Intent 支付 + 收银台 Stripe 方式
- StripeIntentPay 组件 + stripe.ts SDK 封装 - PayMethodIcon/pay-body-type/pay-result 适配 stripe - cashier.json(10语) 新增 stripe 图标词条 - stripe.svg 资源
This commit is contained in:
1
src/shared/assets/icons/channel/stripe.svg
Normal file
1
src/shared/assets/icons/channel/stripe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 24 24"><path fill="#635BFF" d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.594-7.305h.003z"/></svg>
|
||||
|
After Width: | Height: | Size: 597 B |
@@ -10,12 +10,13 @@ import alipaySvg from '@/shared/assets/icons/channel/alipay.svg'
|
||||
import douyinSvg from '@/shared/assets/icons/channel/douyin.svg'
|
||||
import mastercardSvg from '@/shared/assets/icons/channel/mastercard.svg'
|
||||
import otherSvg from '@/shared/assets/icons/channel/other.svg'
|
||||
import stripeSvg from '@/shared/assets/icons/channel/stripe.svg'
|
||||
import unionPaySvg from '@/shared/assets/icons/channel/union_pay.svg'
|
||||
import visaSvg from '@/shared/assets/icons/channel/visa.svg'
|
||||
import wechatSvg from '@/shared/assets/icons/channel/wechat.svg'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 图标编码: wechat / alipay / union_pay / douyin / aggregate_pay / visa / mastercard */
|
||||
/** 图标编码: wechat / alipay / union_pay / douyin / aggregate_pay / visa / mastercard / stripe */
|
||||
icon?: string
|
||||
/** 图标显示边长(px),容器略大以留内边距 */
|
||||
size?: number
|
||||
@@ -33,6 +34,7 @@ const ICON_MAP: Record<string, string> = {
|
||||
aggregate_pay: aggregatePaySvg,
|
||||
visa: visaSvg,
|
||||
mastercard: mastercardSvg,
|
||||
stripe: stripeSvg,
|
||||
}
|
||||
|
||||
const src = computed(() => {
|
||||
|
||||
354
src/shared/components/pay/StripeIntentPay.vue
Normal file
354
src/shared/components/pay/StripeIntentPay.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script lang="ts" setup>
|
||||
import type { StripeJsCardElement, StripeJsClient } from '@/shared/pay/stripe'
|
||||
|
||||
/**
|
||||
* Stripe PaymentIntent 卡支付面板(弹层)
|
||||
*
|
||||
* 动态加载 https://js.stripe.com/v3/ + Elements Card Element 收集卡信息,
|
||||
* 确认后调 confirmCardPayment 完成支付(3DS 由 Stripe.js 自动处理)。
|
||||
* 成功(Stripe 已确认)/ 已受理(需轮询)/ 取消均通过事件上抛,由收银台页面接管。
|
||||
*/
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useLocale } from '@/shared/locales'
|
||||
import {
|
||||
confirmStripePayment,
|
||||
createStripeClient,
|
||||
parseStripeIntentPayload,
|
||||
toStripeLocale,
|
||||
} from '@/shared/pay/stripe'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 面板可见性 */
|
||||
visible: boolean
|
||||
/** stripe_intent 原始 payBody(JSON 或纯 client_secret 字符串) */
|
||||
payload: string
|
||||
/** 支付标题(订单标题) */
|
||||
title?: string
|
||||
/** 支付金额(元,展示用) */
|
||||
amountYuan?: string
|
||||
}>(), {
|
||||
title: '',
|
||||
amountYuan: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Stripe 已确认扣款成功 */
|
||||
success: []
|
||||
/** 已受理未终态(3DS 处理中 / processing),需上层轮询订单 */
|
||||
pending: []
|
||||
/** 用户关闭面板 */
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { t, locale } = useLocale()
|
||||
|
||||
// Card Element 挂载容器
|
||||
const cardElementRef = ref<HTMLElement | null>(null)
|
||||
// Stripe.js / Elements 初始化中
|
||||
const loading = ref(true)
|
||||
// 初始化失败原因(脚本加载失败 / 缺少 publishableKey / 参数无效)
|
||||
const initError = ref('')
|
||||
// 支付确认错误(卡被拒 / 3DS 未完成等)
|
||||
const confirmError = ref('')
|
||||
// 卡信息是否完整(由 Card Element change 事件驱动,控制确认按钮)
|
||||
const cardComplete = ref(false)
|
||||
// 确认支付请求中
|
||||
const confirming = ref(false)
|
||||
|
||||
let stripeClient: StripeJsClient | null = null
|
||||
let cardElement: StripeJsCardElement | null = null
|
||||
|
||||
/**
|
||||
* 打开面板:解析 payload → 加载 Stripe.js → 创建并挂载 Card Element
|
||||
*/
|
||||
async function initStripe() {
|
||||
loading.value = true
|
||||
initError.value = ''
|
||||
confirmError.value = ''
|
||||
cardComplete.value = false
|
||||
const parsed = parseStripeIntentPayload(props.payload)
|
||||
if (!parsed?.clientSecret) {
|
||||
// 后端未返回 client_secret,无法发起支付
|
||||
initError.value = t('cashier.stripePayloadInvalid')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
if (!parsed.publishableKey) {
|
||||
// 缺 publishableKey 无法初始化 Stripe.js
|
||||
initError.value = t('cashier.stripeKeyMissing')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
stripeClient = await createStripeClient(parsed.publishableKey)
|
||||
// 卡输入框语言随当前界面语言
|
||||
cardElement = stripeClient.elements({ locale: toStripeLocale(locale.value) }).create('card')
|
||||
cardElement.on('change', (event) => {
|
||||
cardComplete.value = !!event.complete
|
||||
confirmError.value = event.error?.message || ''
|
||||
})
|
||||
// 等 Card Element 容器渲染完成再挂载
|
||||
await nextTick()
|
||||
cardElement.mount(cardElementRef.value as HTMLElement)
|
||||
}
|
||||
catch (e: any) {
|
||||
// 脚本加载 / 初始化异常
|
||||
initError.value = e?.message || t('cashier.stripeLoadFail')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭面板并释放 Card Element(下次打开重建)
|
||||
*/
|
||||
function closePanel() {
|
||||
if (cardElement) {
|
||||
cardElement.destroy()
|
||||
cardElement = null
|
||||
}
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认支付:卡信息完整且已就绪时提交
|
||||
*/
|
||||
async function handleConfirm() {
|
||||
const parsed = parseStripeIntentPayload(props.payload)
|
||||
if (confirming.value || !stripeClient || !cardElement || !cardComplete.value || !parsed) {
|
||||
return
|
||||
}
|
||||
confirming.value = true
|
||||
confirmError.value = ''
|
||||
try {
|
||||
const result = await confirmStripePayment(
|
||||
stripeClient,
|
||||
parsed.clientSecret,
|
||||
cardElement,
|
||||
parsed.returnUrl,
|
||||
)
|
||||
if (!result.ok) {
|
||||
// 卡被拒等:展示错误,允许修改卡信息重试
|
||||
confirmError.value = result.message
|
||||
return
|
||||
}
|
||||
if (result.status === 'succeeded') {
|
||||
// 已确认扣款成功:由页面渲染成功卡片
|
||||
emit('success')
|
||||
}
|
||||
else {
|
||||
// 已受理未终态:由页面轮询订单状态
|
||||
emit('pending')
|
||||
}
|
||||
}
|
||||
catch (e: any) {
|
||||
// 兜底(理论上 confirmStripePayment 已捕获异常)
|
||||
confirmError.value = e?.message || t('cashier.payFail')
|
||||
}
|
||||
finally {
|
||||
confirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible) {
|
||||
void initStripe()
|
||||
}
|
||||
else if (cardElement) {
|
||||
// 关闭:释放 Card Element
|
||||
cardElement.destroy()
|
||||
cardElement = null
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (cardElement) {
|
||||
cardElement.destroy()
|
||||
cardElement = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="stripe-intent">
|
||||
<div class="stripe-intent__mask" @click="closePanel" />
|
||||
<div class="stripe-intent__panel">
|
||||
<div class="stripe-intent__header">
|
||||
<span class="stripe-intent__brand">Stripe</span>
|
||||
<button class="stripe-intent__close" @click="closePanel">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="stripe-intent__title">
|
||||
{{ title || t('cashier.stripeIntentTitle') }}
|
||||
</div>
|
||||
<div v-if="amountYuan" class="stripe-intent__amount">
|
||||
¥{{ amountYuan }}
|
||||
</div>
|
||||
<p class="stripe-intent__desc">
|
||||
{{ t('cashier.stripeCardTip') }}
|
||||
</p>
|
||||
<div class="stripe-intent__card">
|
||||
<div v-if="loading" class="stripe-intent__loading">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-if="initError" class="stripe-intent__error">
|
||||
{{ initError }}
|
||||
</div>
|
||||
<div v-show="!loading && !initError" ref="cardElementRef" class="stripe-intent__element" />
|
||||
</div>
|
||||
<div v-if="confirmError" class="stripe-intent__error">
|
||||
{{ confirmError }}
|
||||
</div>
|
||||
<button
|
||||
class="stripe-intent__pay-btn"
|
||||
:disabled="confirming || !cardComplete || !!initError || loading"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirming ? t('cashier.paying') : t('cashier.stripePayNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
// Stripe 支付面板:居中弹层,移动/PC 通用
|
||||
.stripe-intent {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
&__panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: var(--h5-bg-card);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.2);
|
||||
animation: stripe-intent-pop 0.3s ease-out;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
// Stripe 品牌字:斜体 + 品牌紫
|
||||
&__brand {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
color: #635bff;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
&__close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--h5-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--h5-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 金额红:与收银台一致
|
||||
&__amount {
|
||||
margin-top: 6px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 8px 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--h5-text-secondary);
|
||||
}
|
||||
|
||||
// Card Element 输入容器(iframe 由 Stripe 注入)
|
||||
&__card {
|
||||
position: relative;
|
||||
border: 1px solid var(--h5-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
min-height: 44px;
|
||||
box-sizing: border-box;
|
||||
background: var(--h5-bg-muted);
|
||||
}
|
||||
|
||||
&__element {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
font-size: 13px;
|
||||
color: var(--h5-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #ff4d4f;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__pay-btn {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--h5-brand-cashier) 0%, var(--h5-brand-cashier-deep) 100%);
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stripe-intent-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.92) translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* 后端枚举: link / jsapi / from / identifier / qr_code / json
|
||||
* 历史误写 url 在分发层按 link 兼容
|
||||
* Stripe 通道扩展: stripe_checkout(Checkout Session 跳转) / stripe_intent(PaymentIntent client_secret)
|
||||
*/
|
||||
export const PayBodyType = {
|
||||
/** 支付链接 */
|
||||
@@ -19,6 +20,10 @@ export const PayBodyType = {
|
||||
JSON: 'json',
|
||||
/** 历史误写, 按 link 处理 */
|
||||
URL_LEGACY: 'url',
|
||||
/** Stripe Checkout Session 跳转 URL(前端直接跳转, 与 link 行为一致) */
|
||||
STRIPE_CHECKOUT: 'stripe_checkout',
|
||||
/** Stripe PaymentIntent client_secret(前端用 Stripe.js Elements 调 confirmCardPayment) */
|
||||
STRIPE_INTENT: 'stripe_intent',
|
||||
} as const
|
||||
|
||||
export type PayBodyTypeCode = (typeof PayBodyType)[keyof typeof PayBodyType]
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"douyin": "Douyin Pay",
|
||||
"aggregate_pay": "Aggregate Pay",
|
||||
"visa": "Visa",
|
||||
"mastercard": "Mastercard"
|
||||
"mastercard": "Mastercard",
|
||||
"stripe": "Stripe"
|
||||
},
|
||||
"loadFail": "Failed to load order",
|
||||
"payFail": "Failed to start payment",
|
||||
@@ -42,6 +43,12 @@
|
||||
"autoRedirectTip": "Auto-return to merchant in {n}s",
|
||||
"backToMerchant": "Return to merchant",
|
||||
"payTime": "Payment time",
|
||||
"stripeIntentTitle": "Stripe Card Payment",
|
||||
"stripeCardTip": "Enter your card details to complete the payment",
|
||||
"stripePayNow": "Pay",
|
||||
"stripePayloadInvalid": "Invalid payment payload, please try again",
|
||||
"stripeKeyMissing": "Payment configuration is missing, unable to pay",
|
||||
"stripeLoadFail": "Failed to load Stripe, please try again",
|
||||
"miniScanTitle": "Scan to open the cashier mini-program",
|
||||
"miniScanDesc": "Scan the QR code with WeChat to open the cashier and complete payment",
|
||||
"miniInWalletTitle": "Cashier mini-program unavailable",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"douyin": "Douyin Pay",
|
||||
"aggregate_pay": "Pembayaran agregat",
|
||||
"visa": "Visa",
|
||||
"mastercard": "Mastercard"
|
||||
"mastercard": "Mastercard",
|
||||
"stripe": "Stripe"
|
||||
},
|
||||
"loadFail": "Gagal memuat pesanan",
|
||||
"payFail": "Gagal memulai pembayaran",
|
||||
@@ -42,6 +43,12 @@
|
||||
"autoRedirectTip": "Kembali ke merchant otomatis dalam {n} detik",
|
||||
"backToMerchant": "Kembali ke merchant",
|
||||
"payTime": "Waktu pembayaran",
|
||||
"stripeIntentTitle": "Pembayaran Kartu Stripe",
|
||||
"stripeCardTip": "Masukkan detail kartu untuk menyelesaikan pembayaran",
|
||||
"stripePayNow": "Bayar",
|
||||
"stripePayloadInvalid": "Parameter pembayaran tidak valid, coba lagi",
|
||||
"stripeKeyMissing": "Konfigurasi pembayaran tidak ada",
|
||||
"stripeLoadFail": "Gagal memuat Stripe, coba lagi",
|
||||
"miniScanTitle": "Pindai untuk membuka mini-program kasir",
|
||||
"miniScanDesc": "Pindai kode QR dengan WeChat untuk membuka kasir dan menyelesaikan pembayaran",
|
||||
"miniInWalletTitle": "Mini-program kasir tidak tersedia",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"douyin": "Douyin Pay",
|
||||
"aggregate_pay": "統合決済",
|
||||
"visa": "Visa",
|
||||
"mastercard": "Mastercard"
|
||||
"mastercard": "Mastercard",
|
||||
"stripe": "Stripe"
|
||||
},
|
||||
"loadFail": "注文の読み込みに失敗しました",
|
||||
"payFail": "支払いの開始に失敗しました",
|
||||
@@ -42,6 +43,12 @@
|
||||
"autoRedirectTip": "{n} 秒後に自動的に店舗に戻ります",
|
||||
"backToMerchant": "店舗に戻る",
|
||||
"payTime": "支払い日時",
|
||||
"stripeIntentTitle": "Stripe カード支払い",
|
||||
"stripeCardTip": "カード情報を入力して支払いを完了してください",
|
||||
"stripePayNow": "支払いを確定",
|
||||
"stripePayloadInvalid": "支払いパラメータが無効です。もう一度お試しください",
|
||||
"stripeKeyMissing": "支払い設定がありません",
|
||||
"stripeLoadFail": "Stripe の読み込みに失敗しました。もう一度お試しください",
|
||||
"miniScanTitle": "スキャンして決済ミニプログラムを開く",
|
||||
"miniScanDesc": "WeChatでQRコードをスキャンして決済ミニプログラムを開き、支払いを完了してください",
|
||||
"miniInWalletTitle": "決済ミニプログラムは現在利用できません",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"douyin": "Douyin Pay",
|
||||
"aggregate_pay": "통합 결제",
|
||||
"visa": "Visa",
|
||||
"mastercard": "Mastercard"
|
||||
"mastercard": "Mastercard",
|
||||
"stripe": "Stripe"
|
||||
},
|
||||
"loadFail": "주문을 불러오지 못했습니다",
|
||||
"payFail": "결제를 시작하지 못했습니다",
|
||||
@@ -42,6 +43,12 @@
|
||||
"autoRedirectTip": "{n}초 후 자동으로 가맹점으로 돌아갑니다",
|
||||
"backToMerchant": "가맹점으로 돌아가기",
|
||||
"payTime": "결제 시간",
|
||||
"stripeIntentTitle": "Stripe 카드 결제",
|
||||
"stripeCardTip": "카드 정보를 입력하여 결제를 완료하세요",
|
||||
"stripePayNow": "결제하기",
|
||||
"stripePayloadInvalid": "결제 파라미터가 올바르지 않습니다. 다시 시도해 주세요",
|
||||
"stripeKeyMissing": "결제 설정이 없습니다",
|
||||
"stripeLoadFail": "Stripe 로드에 실패했습니다. 다시 시도해 주세요",
|
||||
"miniScanTitle": "스캔하여 결제 미니프로그램 열기",
|
||||
"miniScanDesc": "WeChat으로 QR 코드를 스캔하여 결제 미니프로그램을 열고 결제를 완료하세요",
|
||||
"miniInWalletTitle": "결제 미니프로그램을 사용할 수 없습니다",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"douyin": "Douyin Pay",
|
||||
"aggregate_pay": "Bayaran agregat",
|
||||
"visa": "Visa",
|
||||
"mastercard": "Mastercard"
|
||||
"mastercard": "Mastercard",
|
||||
"stripe": "Stripe"
|
||||
},
|
||||
"loadFail": "Gagal memuatkan pesanan",
|
||||
"payFail": "Gagal memulakan pembayaran",
|
||||
@@ -42,6 +43,12 @@
|
||||
"autoRedirectTip": "Kembali ke merchant automatik dalam {n} saat",
|
||||
"backToMerchant": "Kembali ke merchant",
|
||||
"payTime": "Masa pembayaran",
|
||||
"stripeIntentTitle": "Pembayaran Kad Stripe",
|
||||
"stripeCardTip": "Masukkan maklumat kad untuk melengkapkan pembayaran",
|
||||
"stripePayNow": "Bayar",
|
||||
"stripePayloadInvalid": "Parameter pembayaran tidak sah, sila cuba lagi",
|
||||
"stripeKeyMissing": "Konfigurasi pembayaran tiada",
|
||||
"stripeLoadFail": "Gagal memuat Stripe, sila cuba lagi",
|
||||
"miniScanTitle": "Imbas untuk membuka mini-program kaunter",
|
||||
"miniScanDesc": "Sila imbas kod QR dengan WeChat untuk membuka kaunter dan melengkapkan bayaran",
|
||||
"miniInWalletTitle": "Mini-program kaunter tidak tersedia",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"douyin": "Douyin Pay",
|
||||
"aggregate_pay": "ชำระแบบรวม",
|
||||
"visa": "Visa",
|
||||
"mastercard": "Mastercard"
|
||||
"mastercard": "Mastercard",
|
||||
"stripe": "Stripe"
|
||||
},
|
||||
"loadFail": "โหลดคำสั่งซื้อไม่สำเร็จ",
|
||||
"payFail": "เริ่มชำระเงินไม่สำเร็จ",
|
||||
@@ -42,6 +43,12 @@
|
||||
"autoRedirectTip": "กลับสู่ร้านค้าอัตโนมัติใน {n} วินาที",
|
||||
"backToMerchant": "กลับสู่ร้านค้า",
|
||||
"payTime": "เวลาที่ชำระ",
|
||||
"stripeIntentTitle": "ชำระเงินด้วยบัตร Stripe",
|
||||
"stripeCardTip": "กรุณากรอกข้อมูลบัตรเพื่อชำระเงิน",
|
||||
"stripePayNow": "ชำระเงิน",
|
||||
"stripePayloadInvalid": "พารามิเตอร์การชำระเงินไม่ถูกต้อง โปรดลองอีกครั้ง",
|
||||
"stripeKeyMissing": "ไม่มีการกำหนดค่าการชำระเงิน",
|
||||
"stripeLoadFail": "โหลด Stripe ไม่สำเร็จ โปรดลองอีกครั้ง",
|
||||
"miniScanTitle": "สแกนเพื่อเปิดมินิโปรแกรมแคชเชียร์",
|
||||
"miniScanDesc": "โปรดสแกนคิวอาร์โค้ดด้วย WeChat เพื่อเปิดแคชเชียร์และชำระเงินให้เสร็จสิ้น",
|
||||
"miniInWalletTitle": "มินิโปรแกรมแคชเชียร์ไม่พร้อมใช้งาน",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"douyin": "Douyin Pay",
|
||||
"aggregate_pay": "Thanh toán tổng hợp",
|
||||
"visa": "Visa",
|
||||
"mastercard": "Mastercard"
|
||||
"mastercard": "Mastercard",
|
||||
"stripe": "Stripe"
|
||||
},
|
||||
"loadFail": "Không tải được đơn hàng",
|
||||
"payFail": "Không thể bắt đầu thanh toán",
|
||||
@@ -42,6 +43,12 @@
|
||||
"autoRedirectTip": "Tự động quay lại merchant sau {n} giây",
|
||||
"backToMerchant": "Quay lại merchant",
|
||||
"payTime": "Thời gian thanh toán",
|
||||
"stripeIntentTitle": "Thanh toán thẻ Stripe",
|
||||
"stripeCardTip": "Nhập thông tin thẻ để hoàn tất thanh toán",
|
||||
"stripePayNow": "Thanh toán",
|
||||
"stripePayloadInvalid": "Tham số thanh toán không hợp lệ, vui lòng thử lại",
|
||||
"stripeKeyMissing": "Thiếu cấu hình thanh toán",
|
||||
"stripeLoadFail": "Không tải được Stripe, vui lòng thử lại",
|
||||
"miniScanTitle": "Quét để mở mini-program thu ngân",
|
||||
"miniScanDesc": "Vui lòng quét mã QR bằng WeChat để mở thu ngân và hoàn tất thanh toán",
|
||||
"miniInWalletTitle": "Mini-program thu ngân không khả dụng",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"douyin": "抖音支付",
|
||||
"aggregate_pay": "聚合支付",
|
||||
"visa": "Visa",
|
||||
"mastercard": "Mastercard"
|
||||
"mastercard": "Mastercard",
|
||||
"stripe": "Stripe"
|
||||
},
|
||||
"loadFail": "加载订单失败",
|
||||
"payFail": "支付发起失败",
|
||||
@@ -42,6 +43,12 @@
|
||||
"autoRedirectTip": "{n} 秒后自动返回商户",
|
||||
"backToMerchant": "返回商户",
|
||||
"payTime": "支付时间",
|
||||
"stripeIntentTitle": "Stripe 卡支付",
|
||||
"stripeCardTip": "请输入银行卡信息完成支付",
|
||||
"stripePayNow": "确认支付",
|
||||
"stripePayloadInvalid": "支付参数无效,请重试",
|
||||
"stripeKeyMissing": "支付配置缺失,无法完成支付",
|
||||
"stripeLoadFail": "Stripe 加载失败,请重试",
|
||||
"miniScanTitle": "请扫码打开收银台小程序",
|
||||
"miniScanDesc": "请使用微信扫描二维码进入统一收银台并完成支付",
|
||||
"miniInWalletTitle": "收银台小程序暂不可用",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"douyin": "抖音支付",
|
||||
"aggregate_pay": "聚合支付",
|
||||
"visa": "Visa",
|
||||
"mastercard": "Mastercard"
|
||||
"mastercard": "Mastercard",
|
||||
"stripe": "Stripe"
|
||||
},
|
||||
"loadFail": "載入訂單失敗",
|
||||
"payFail": "支付發起失敗",
|
||||
@@ -42,6 +43,12 @@
|
||||
"autoRedirectTip": "{n} 秒後自動返回商戶",
|
||||
"backToMerchant": "返回商戶",
|
||||
"payTime": "支付時間",
|
||||
"stripeIntentTitle": "Stripe 卡支付",
|
||||
"stripeCardTip": "請輸入信用卡資料完成付款",
|
||||
"stripePayNow": "確認付款",
|
||||
"stripePayloadInvalid": "付款參數無效,請重試",
|
||||
"stripeKeyMissing": "付款設定遺失,無法完成付款",
|
||||
"stripeLoadFail": "Stripe 載入失敗,請重試",
|
||||
"miniScanTitle": "請掃碼開啟收銀台小程式",
|
||||
"miniScanDesc": "請使用微信掃描二維碼進入統一收銀台並完成付款",
|
||||
"miniInWalletTitle": "收銀台小程式暫不可用",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"douyin": "抖音支付",
|
||||
"aggregate_pay": "聚合支付",
|
||||
"visa": "Visa",
|
||||
"mastercard": "Mastercard"
|
||||
"mastercard": "Mastercard",
|
||||
"stripe": "Stripe"
|
||||
},
|
||||
"loadFail": "載入訂單失敗",
|
||||
"payFail": "支付發起失敗",
|
||||
@@ -42,6 +43,12 @@
|
||||
"autoRedirectTip": "{n} 秒後自動返回商戶",
|
||||
"backToMerchant": "返回商戶",
|
||||
"payTime": "支付時間",
|
||||
"stripeIntentTitle": "Stripe 卡支付",
|
||||
"stripeCardTip": "請輸入信用卡資訊完成付款",
|
||||
"stripePayNow": "確認付款",
|
||||
"stripePayloadInvalid": "付款參數無效,請重試",
|
||||
"stripeKeyMissing": "付款設定遺失,無法完成付款",
|
||||
"stripeLoadFail": "Stripe 載入失敗,請重試",
|
||||
"miniScanTitle": "請掃碼開啟收銀台小程式",
|
||||
"miniScanDesc": "請使用微信掃描二維碼進入統一收銀台並完成付款",
|
||||
"miniInWalletTitle": "收銀台小程式暫不可用",
|
||||
|
||||
202
src/shared/pay/stripe.ts
Normal file
202
src/shared/pay/stripe.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Stripe 国际卡支付(PaymentIntent + Elements 模式)
|
||||
*
|
||||
* 项目不引入 @stripe/stripe-js npm 包:运行时动态加载 https://js.stripe.com/v3/,
|
||||
* 以 Card Element 收集卡信息,调 confirmCardPayment 完成支付(3DS 由 Stripe.js 自动处理)。
|
||||
* 与后端 stripe_intent 支付参数体类型对应(Checkout Session 跳转走 stripe_checkout/link 分支)。
|
||||
*/
|
||||
|
||||
import { loadScript } from '@/shared/utils/load-script'
|
||||
|
||||
/** Stripe.js 官方 CDN 地址 */
|
||||
const STRIPE_JS_URL = 'https://js.stripe.com/v3/'
|
||||
|
||||
/** Stripe Card Element(最小化声明,仅用到的成员) */
|
||||
export interface StripeJsCardElement {
|
||||
mount: (el: HTMLElement | string) => void
|
||||
unmount: () => void
|
||||
destroy: () => void
|
||||
on: (event: 'change', handler: (event: StripeJsCardChangeEvent) => void) => void
|
||||
}
|
||||
|
||||
/** Card Element change 事件负载 */
|
||||
export interface StripeJsCardChangeEvent {
|
||||
/** 卡信息是否完整(可提交支付) */
|
||||
complete?: boolean
|
||||
/** 输入错误(卡号/有效期/CVC 校验失败) */
|
||||
error?: { message?: string, type?: string } | null
|
||||
}
|
||||
|
||||
/** confirmCardPayment 错误(卡被拒/3DS 未完成等) */
|
||||
export interface StripeJsConfirmError {
|
||||
message?: string
|
||||
code?: string
|
||||
decline_code?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
/** confirmCardPayment 结果 */
|
||||
export interface StripeJsConfirmResult {
|
||||
paymentIntent?: { id?: string, status?: string }
|
||||
error?: StripeJsConfirmError
|
||||
}
|
||||
|
||||
/** Stripe Elements 实例(仅声明 card 元素创建) */
|
||||
export interface StripeJsElements {
|
||||
create: (type: 'card', options?: Record<string, unknown>) => StripeJsCardElement
|
||||
}
|
||||
|
||||
/** Stripe 客户端(最小化声明) */
|
||||
export interface StripeJsClient {
|
||||
elements: (options?: { locale?: string }) => StripeJsElements
|
||||
confirmCardPayment: (
|
||||
clientSecret: string,
|
||||
options?: {
|
||||
payment_method?: { card: StripeJsCardElement }
|
||||
return_url?: string
|
||||
},
|
||||
) => Promise<StripeJsConfirmResult>
|
||||
}
|
||||
|
||||
/** js.stripe.com/v3 注入的全局构造函数 */
|
||||
type StripeJsGlobal = (publishableKey: string) => StripeJsClient
|
||||
|
||||
/** 已加载的 Stripe.js 全局对象(单例) */
|
||||
let stripeJsPromise: Promise<StripeJsGlobal> | null = null
|
||||
|
||||
/**
|
||||
* 动态加载 Stripe.js(全局只加载一次;失败清除缓存允许重试)
|
||||
*/
|
||||
export function loadStripeJs(): Promise<StripeJsGlobal> {
|
||||
if (stripeJsPromise) {
|
||||
return stripeJsPromise
|
||||
}
|
||||
stripeJsPromise = loadScript(STRIPE_JS_URL)
|
||||
.then(() => {
|
||||
const Stripe = (window as unknown as { Stripe?: StripeJsGlobal }).Stripe
|
||||
if (!Stripe) {
|
||||
// 脚本加载成功但全局对象缺失,按失败处理
|
||||
throw new Error('Stripe.js global not found')
|
||||
}
|
||||
return Stripe
|
||||
})
|
||||
.catch((err) => {
|
||||
// 失败后允许下次重新加载
|
||||
stripeJsPromise = null
|
||||
throw err
|
||||
})
|
||||
return stripeJsPromise
|
||||
}
|
||||
|
||||
/** stripe_intent 参数体(后端 payBody JSON 字段) */
|
||||
export interface StripeIntentPayload {
|
||||
/** PaymentIntent client_secret(pi_xxx_secret_xxx) */
|
||||
clientSecret: string
|
||||
/** Stripe publishableKey(pk_xxx),用于初始化 Stripe.js */
|
||||
publishableKey: string
|
||||
/** 3DS 认证完成后的回跳地址(可选) */
|
||||
returnUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 stripe_intent 的 payBody
|
||||
*
|
||||
* 优先按 JSON 解析(clientSecret / publishableKey / returnUrl);
|
||||
* 兼容后端直接返回纯 client_secret 字符串的场景(此时缺 publishableKey 由上层提示)。
|
||||
*/
|
||||
export function parseStripeIntentPayload(payBody: string): StripeIntentPayload | null {
|
||||
if (!payBody) {
|
||||
return null
|
||||
}
|
||||
let json: unknown = null
|
||||
try {
|
||||
json = JSON.parse(payBody)
|
||||
}
|
||||
catch {
|
||||
// 非 JSON:视为纯 client_secret 字符串
|
||||
return { clientSecret: payBody, publishableKey: '' }
|
||||
}
|
||||
if (json && typeof json === 'object') {
|
||||
const data = json as Record<string, unknown>
|
||||
return {
|
||||
clientSecret: String(data.clientSecret ?? data.client_secret ?? ''),
|
||||
publishableKey: String(data.publishableKey ?? data.publishable_key ?? ''),
|
||||
returnUrl: typeof data.returnUrl === 'string' && data.returnUrl ? data.returnUrl : undefined,
|
||||
}
|
||||
}
|
||||
// JSON 标量(如带引号的字符串字面量):按纯 client_secret 处理
|
||||
return { clientSecret: payBody, publishableKey: '' }
|
||||
}
|
||||
|
||||
/** BCP47 语言码 → Stripe Elements locale */
|
||||
const STRIPE_LOCALE_MAP: Record<string, string> = {
|
||||
'zh-CN': 'zh',
|
||||
'zh-TW': 'zh-Hant',
|
||||
'zh-HK': 'zh-Hant',
|
||||
'en-US': 'en',
|
||||
'ja-JP': 'ja',
|
||||
'ko-KR': 'ko',
|
||||
'id-ID': 'id',
|
||||
'vi-VN': 'vi',
|
||||
'th-TH': 'th',
|
||||
'ms-MY': 'ms',
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前界面语言转 Stripe locale(未命中返回 undefined,由 Stripe 按浏览器语言自动)
|
||||
*/
|
||||
export function toStripeLocale(locale?: string): string | undefined {
|
||||
if (!locale) {
|
||||
return undefined
|
||||
}
|
||||
return STRIPE_LOCALE_MAP[locale.replace('_', '-')]
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Stripe 客户端(publishableKey 必填,页面语言在 elements() 时传入)
|
||||
*/
|
||||
export function createStripeClient(publishableKey: string): Promise<StripeJsClient> {
|
||||
return loadStripeJs().then(Stripe => Stripe(publishableKey))
|
||||
}
|
||||
|
||||
/** 确认支付结果 */
|
||||
export type ConfirmStripePaymentResult
|
||||
= | { ok: true, status: 'succeeded' }
|
||||
| { ok: true, status: 'pending' }
|
||||
| { ok: false, message: string }
|
||||
|
||||
/**
|
||||
* 提交卡信息确认支付
|
||||
*
|
||||
* - succeeded:Stripe 已确认扣款成功
|
||||
* - pending:已受理但未终态(processing / 3DS 处理中),由上层轮询订单兜底
|
||||
* - ok=false:支付被拒/参数错误,message 为 Stripe 已按 locale 本地化的错误文案
|
||||
*/
|
||||
export async function confirmStripePayment(
|
||||
client: StripeJsClient,
|
||||
clientSecret: string,
|
||||
cardElement: StripeJsCardElement,
|
||||
returnUrl?: string,
|
||||
): Promise<ConfirmStripePaymentResult> {
|
||||
let result: StripeJsConfirmResult
|
||||
try {
|
||||
result = await client.confirmCardPayment(clientSecret, {
|
||||
payment_method: { card: cardElement },
|
||||
...(returnUrl ? { return_url: returnUrl } : {}),
|
||||
})
|
||||
}
|
||||
catch (e: any) {
|
||||
// Stripe.js 内部异常(网络/SDK),非卡信息错误
|
||||
return { ok: false, message: e?.message || 'stripe confirm failed' }
|
||||
}
|
||||
if (result.error) {
|
||||
// 卡被拒 / 3DS 未完成等,message 已按 locale 本地化
|
||||
return { ok: false, message: result.error.message || 'stripe confirm failed' }
|
||||
}
|
||||
const status = result.paymentIntent?.status
|
||||
if (status === 'succeeded') {
|
||||
return { ok: true, status: 'succeeded' }
|
||||
}
|
||||
// processing / requires_action 等:已受理,等待最终结果
|
||||
return { ok: true, status: 'pending' }
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export type PayResultAction
|
||||
| { type: 'qrcode', content: string }
|
||||
| { type: 'form', html: string }
|
||||
| { type: 'jsapi', payload: string }
|
||||
| { type: 'stripe_intent', payload: string }
|
||||
| { type: 'poll' }
|
||||
| { type: 'unsupported', payBodyType?: string, payBody?: string }
|
||||
|
||||
@@ -63,6 +64,16 @@ export function resolvePayResult(
|
||||
return { type: 'jsapi', payload: body }
|
||||
}
|
||||
|
||||
// Stripe Checkout Session: 跳转 URL(与 link 分支行为一致)
|
||||
if (body && bodyType === PayBodyType.STRIPE_CHECKOUT) {
|
||||
return { type: 'redirect', url: body }
|
||||
}
|
||||
|
||||
// Stripe PaymentIntent: client_secret(JSON 或纯字符串), 由调用方弹 Elements 卡输入面板
|
||||
if (body && bodyType === PayBodyType.STRIPE_INTENT) {
|
||||
return { type: 'stripe_intent', payload: body }
|
||||
}
|
||||
|
||||
// json / identifier / 未知:有 body 时先轮询并交给上层展示;无 body 纯轮询
|
||||
if (body && (bodyType === PayBodyType.JSON || bodyType === PayBodyType.IDENTIFIER)) {
|
||||
return { type: 'unsupported', payBodyType: bodyType, payBody: body }
|
||||
|
||||
Reference in New Issue
Block a user