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:
daxpay
2026-08-05 14:28:12 +08:00
parent d03ba574ac
commit b30f9c3ff0
16 changed files with 656 additions and 11 deletions

View 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

View File

@@ -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(() => {

View 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 原始 payBodyJSON 或纯 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>

View File

@@ -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]

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "決済ミニプログラムは現在利用できません",

View File

@@ -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": "결제 미니프로그램을 사용할 수 없습니다",

View File

@@ -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",

View File

@@ -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": "มินิโปรแกรมแคชเชียร์ไม่พร้อมใช้งาน",

View File

@@ -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",

View File

@@ -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": "收银台小程序暂不可用",

View File

@@ -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": "收銀台小程式暫不可用",

View File

@@ -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
View 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_secretpi_xxx_secret_xxx */
clientSecret: string
/** Stripe publishableKeypk_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 }
/**
* 提交卡信息确认支付
*
* - succeededStripe 已确认扣款成功
* - 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' }
}

View File

@@ -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 }