mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge remote-tracking branch 'upstream/main' into feat/500-api-key-ip-whitelist
This commit is contained in:
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@stripe/stripe-js": "^9.6.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/three": "^0.180.0",
|
||||
@@ -1574,6 +1575,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.6.0.tgz",
|
||||
"integrity": "sha512-v5MebYvJbddSRn15fknxTVwypJPzjeIXI1Q2HBxCBrQieWna8PC+RXEVUZ3F4ANAeILEj97HzFlr0r7CXvG7ZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"version": "git describe --tags --always"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/stripe-js": "^9.6.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/three": "^0.180.0",
|
||||
|
||||
@@ -8,33 +8,44 @@ export type WalletCreditBucket = 'recharge' | 'gift'
|
||||
export interface EpayChannelConfig {
|
||||
channel: string
|
||||
display_name: string
|
||||
fee_rate?: number
|
||||
}
|
||||
|
||||
export interface EpayGatewayConfig {
|
||||
provider: 'epay'
|
||||
provider: PaymentGatewayProvider
|
||||
enabled: boolean
|
||||
endpoint_url?: string | null
|
||||
callback_base_url?: string | null
|
||||
merchant_id?: string | null
|
||||
has_secret: boolean
|
||||
has_secret_keys?: string[]
|
||||
pay_currency?: string | null
|
||||
usd_exchange_rate?: number | null
|
||||
min_recharge_usd?: number | null
|
||||
channels?: EpayChannelConfig[]
|
||||
refund_enabled?: boolean
|
||||
allow_user_refund?: boolean
|
||||
config?: Record<string, unknown>
|
||||
created_at?: number | null
|
||||
updated_at?: number | null
|
||||
}
|
||||
|
||||
export type PaymentGatewayProvider = 'epay' | 'alipay' | 'wxpay' | 'stripe'
|
||||
|
||||
export interface UpdateEpayGatewayConfigRequest {
|
||||
enabled: boolean
|
||||
endpoint_url: string
|
||||
endpoint_url?: string
|
||||
callback_base_url?: string | null
|
||||
merchant_id: string
|
||||
merchant_id?: string
|
||||
merchant_key?: string
|
||||
pay_currency: string
|
||||
usd_exchange_rate: number
|
||||
min_recharge_usd: number
|
||||
channels: EpayChannelConfig[]
|
||||
refund_enabled?: boolean
|
||||
allow_user_refund?: boolean
|
||||
config?: Record<string, unknown>
|
||||
secrets?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface GatewayTestResponse {
|
||||
@@ -142,9 +153,11 @@ function normalizeChannels(channels: EpayGatewayConfig['channels']): EpayChannel
|
||||
.map((item) => {
|
||||
const raw = item as EpayChannelConfig & { type?: string }
|
||||
const channel = String(raw.channel || raw.type || '').trim()
|
||||
const feeRate = Number(raw.fee_rate ?? 0)
|
||||
return {
|
||||
channel,
|
||||
display_name: String(raw.display_name || channel).trim(),
|
||||
fee_rate: Number.isFinite(feeRate) && feeRate >= 0 ? feeRate : 0,
|
||||
}
|
||||
})
|
||||
.filter((item) => item.channel && item.display_name)
|
||||
@@ -152,39 +165,49 @@ function normalizeChannels(channels: EpayGatewayConfig['channels']): EpayChannel
|
||||
}
|
||||
|
||||
function normalizeGatewayConfig(config: EpayGatewayConfig): EpayGatewayConfig {
|
||||
const refundEnabled = Boolean(config.refund_enabled)
|
||||
return {
|
||||
provider: 'epay',
|
||||
provider: config.provider || 'epay',
|
||||
enabled: Boolean(config.enabled),
|
||||
endpoint_url: config.endpoint_url ?? '',
|
||||
callback_base_url: config.callback_base_url ?? '',
|
||||
merchant_id: config.merchant_id ?? '',
|
||||
has_secret: Boolean(config.has_secret),
|
||||
has_secret_keys: Array.isArray(config.has_secret_keys) ? config.has_secret_keys : [],
|
||||
pay_currency: config.pay_currency ?? 'CNY',
|
||||
usd_exchange_rate: Number(config.usd_exchange_rate ?? 7.2),
|
||||
min_recharge_usd: Number(config.min_recharge_usd ?? 1),
|
||||
channels: normalizeChannels(config.channels),
|
||||
refund_enabled: refundEnabled,
|
||||
allow_user_refund: refundEnabled && Boolean(config.allow_user_refund),
|
||||
config: config.config && typeof config.config === 'object' ? config.config : {},
|
||||
created_at: config.created_at ?? null,
|
||||
updated_at: config.updated_at ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export const epayGatewayApi = {
|
||||
async get(): Promise<EpayGatewayConfig> {
|
||||
const response = await apiClient.get<EpayGatewayConfig>('/api/admin/payments/gateways/epay')
|
||||
async get(provider: PaymentGatewayProvider = 'epay'): Promise<EpayGatewayConfig> {
|
||||
const response = await apiClient.get<EpayGatewayConfig>(`/api/admin/payments/gateways/${provider}`)
|
||||
return normalizeGatewayConfig(response.data)
|
||||
},
|
||||
|
||||
async update(payload: UpdateEpayGatewayConfigRequest): Promise<EpayGatewayConfig> {
|
||||
async update(
|
||||
payload: UpdateEpayGatewayConfigRequest,
|
||||
provider: PaymentGatewayProvider = 'epay'
|
||||
): Promise<EpayGatewayConfig> {
|
||||
const request: UpdateEpayGatewayConfigRequest = {
|
||||
...payload,
|
||||
channels: normalizeChannels(payload.channels),
|
||||
refund_enabled: Boolean(payload.refund_enabled),
|
||||
allow_user_refund: Boolean(payload.refund_enabled && payload.allow_user_refund),
|
||||
}
|
||||
const response = await apiClient.put<EpayGatewayConfig>('/api/admin/payments/gateways/epay', request)
|
||||
const response = await apiClient.put<EpayGatewayConfig>(`/api/admin/payments/gateways/${provider}`, request)
|
||||
return normalizeGatewayConfig(response.data)
|
||||
},
|
||||
|
||||
async test(): Promise<GatewayTestResponse> {
|
||||
const response = await apiClient.post<GatewayTestResponse>('/api/admin/payments/gateways/epay/test', {})
|
||||
async test(provider: PaymentGatewayProvider = 'epay'): Promise<GatewayTestResponse> {
|
||||
const response = await apiClient.post<GatewayTestResponse>(`/api/admin/payments/gateways/${provider}/test`, {})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ export interface WalletRechargeOption {
|
||||
pay_currency?: string
|
||||
usd_exchange_rate?: number
|
||||
min_recharge_usd?: number
|
||||
fee_rate?: number
|
||||
}
|
||||
|
||||
export interface WalletRefundCreateRequest {
|
||||
@@ -183,6 +184,10 @@ export interface WalletRefundCreateRequest {
|
||||
idempotency_key?: string
|
||||
}
|
||||
|
||||
export interface WalletRefundEligibilityResponse {
|
||||
payment_methods: string[]
|
||||
}
|
||||
|
||||
export interface WalletRedeemRequest {
|
||||
code: string
|
||||
}
|
||||
@@ -258,6 +263,11 @@ export const walletApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listRefundEligibleProviders(): Promise<WalletRefundEligibilityResponse> {
|
||||
const response = await apiClient.get<WalletRefundEligibilityResponse>('/api/wallet/refunds/eligible-providers')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createRefund(payload: WalletRefundCreateRequest): Promise<RefundRequest> {
|
||||
const response = await apiClient.post<RefundRequest>('/api/wallet/refunds', payload)
|
||||
return response.data
|
||||
|
||||
394
frontend/src/components/common/StripePaymentDialog.vue
Normal file
394
frontend/src/components/common/StripePaymentDialog.vue
Normal file
@@ -0,0 +1,394 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:open="dialogOpen"
|
||||
max-width="2xl"
|
||||
:persistent="initializing || submitting"
|
||||
:close-on-backdrop="!(initializing || submitting)"
|
||||
>
|
||||
<template #header>
|
||||
<DialogHeader>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<DialogTitle>
|
||||
{{ title }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ description }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="shrink-0"
|
||||
>
|
||||
Stripe
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
PaymentIntent
|
||||
</div>
|
||||
<div class="break-all font-mono text-sm text-foreground">
|
||||
{{ stripeIntentId || stripeGatewayOrderId || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
支付方式
|
||||
</div>
|
||||
<div class="text-sm text-foreground">
|
||||
{{ stripeDisplayName }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
应付金额
|
||||
</div>
|
||||
<div class="text-sm text-foreground">
|
||||
{{ stripeAmountLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
支付通道
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="method in stripePaymentMethodTypes"
|
||||
:key="method"
|
||||
variant="outline"
|
||||
>
|
||||
{{ paymentMethodTypeLabel(method) }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="stripePaymentMethodTypes.length === 0"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative rounded-xl border border-border/60 bg-background p-3">
|
||||
<div
|
||||
ref="paymentElementRoot"
|
||||
class="min-h-[360px]"
|
||||
:class="{ 'pointer-events-none opacity-20': initializing }"
|
||||
/>
|
||||
<div
|
||||
v-if="initializing"
|
||||
class="absolute inset-3 flex items-center justify-center gap-2 rounded-lg bg-background/80 text-sm text-muted-foreground backdrop-blur-sm"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
正在加载 Stripe 支付组件...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-700 dark:text-rose-300"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="initializing || submitting"
|
||||
@click="closeDialog"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!canSubmit"
|
||||
@click="submitPayment"
|
||||
>
|
||||
<Loader2
|
||||
v-if="submitting"
|
||||
class="mr-2 h-4 w-4 animate-spin"
|
||||
/>
|
||||
{{ submitting ? '支付中...' : confirmText }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { loadStripe, type Stripe, type StripeElements, type StripePaymentElement } from '@stripe/stripe-js'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { Badge, Button, Dialog, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import {
|
||||
getStripePaymentInstructions,
|
||||
type PaymentInstructionMap,
|
||||
} from '@/utils/paymentInstructions'
|
||||
import { paymentMethodLabel } from '@/utils/walletDisplay'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
instructions: PaymentInstructionMap | null
|
||||
title?: string
|
||||
description?: string
|
||||
confirmText?: string
|
||||
returnUrl?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: 'Stripe 支付',
|
||||
description: '请在弹窗内完成支付,成功后系统会自动入账。',
|
||||
confirmText: '确认支付',
|
||||
returnUrl: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
success: [payload: { intentId: string; status?: string | null }]
|
||||
}>()
|
||||
|
||||
const dialogOpen = computed({
|
||||
get: () => props.open,
|
||||
set: value => emit('update:open', value),
|
||||
})
|
||||
|
||||
const paymentElementRoot = ref<HTMLDivElement | null>(null)
|
||||
const stripeInstance = ref<Stripe | null>(null)
|
||||
const elementsInstance = ref<StripeElements | null>(null)
|
||||
const paymentElement = ref<StripePaymentElement | null>(null)
|
||||
const initializing = ref(false)
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const mountedSignature = ref('')
|
||||
let mountSequence = 0
|
||||
|
||||
const stripeInstructions = computed(() => getStripePaymentInstructions(props.instructions))
|
||||
const stripeIntentId = computed(() => stripeInstructions.value?.intentId || '')
|
||||
const stripeGatewayOrderId = computed(() => stripeInstructions.value?.gatewayOrderId || '')
|
||||
const stripeDisplayName = computed(() => stripeInstructions.value?.displayName || 'Stripe')
|
||||
const stripePaymentMethodTypes = computed(() => stripeInstructions.value?.paymentMethodTypes || [])
|
||||
const stripeAmountLabel = computed(() => {
|
||||
const amount = stripeInstructions.value?.payAmount
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
|
||||
return '-'
|
||||
}
|
||||
const currency = stripeInstructions.value?.payCurrency || ''
|
||||
return `${amount.toFixed(2)}${currency ? ` ${currency}` : ''}`
|
||||
})
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
Boolean(
|
||||
stripeInstance.value
|
||||
&& elementsInstance.value
|
||||
&& stripeInstructions.value
|
||||
&& !initializing.value
|
||||
&& !submitting.value
|
||||
)
|
||||
)
|
||||
|
||||
const stripeLoaderCache = new Map<string, Promise<Stripe | null>>()
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.open,
|
||||
() => stripeInstructions.value?.clientSecret || '',
|
||||
() => stripeInstructions.value?.publishableKey || '',
|
||||
],
|
||||
async ([open]) => {
|
||||
if (!open) {
|
||||
cleanupStripe()
|
||||
return
|
||||
}
|
||||
await initializeStripe()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupStripe()
|
||||
})
|
||||
|
||||
async function initializeStripe() {
|
||||
const instructions = stripeInstructions.value
|
||||
if (!props.open) return
|
||||
if (!instructions) {
|
||||
errorMessage.value = '缺少 Stripe 支付参数'
|
||||
return
|
||||
}
|
||||
|
||||
const signature = [
|
||||
instructions.publishableKey,
|
||||
instructions.clientSecret,
|
||||
instructions.intentId,
|
||||
instructions.paymentChannel,
|
||||
].join('::')
|
||||
|
||||
if (mountedSignature.value === signature && stripeInstance.value && elementsInstance.value && paymentElement.value) {
|
||||
return
|
||||
}
|
||||
|
||||
cleanupStripe()
|
||||
if (!props.open) return
|
||||
|
||||
initializing.value = true
|
||||
errorMessage.value = ''
|
||||
const sequence = ++mountSequence
|
||||
|
||||
try {
|
||||
await nextTick()
|
||||
if (!paymentElementRoot.value) {
|
||||
throw new Error('支付容器未准备好')
|
||||
}
|
||||
|
||||
const stripe = await loadStripeCached(instructions.publishableKey)
|
||||
if (!stripe) {
|
||||
throw new Error('Stripe 初始化失败')
|
||||
}
|
||||
if (sequence !== mountSequence) return
|
||||
|
||||
const elements = stripe.elements({
|
||||
clientSecret: instructions.clientSecret,
|
||||
})
|
||||
const element = elements.create('payment', {
|
||||
layout: 'tabs',
|
||||
})
|
||||
element.mount(paymentElementRoot.value)
|
||||
|
||||
stripeInstance.value = stripe
|
||||
elementsInstance.value = elements
|
||||
paymentElement.value = element
|
||||
mountedSignature.value = signature
|
||||
} catch (error) {
|
||||
if (sequence !== mountSequence) return
|
||||
errorMessage.value = formatStripeError(error)
|
||||
} finally {
|
||||
if (sequence === mountSequence) {
|
||||
initializing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPayment() {
|
||||
const instructions = stripeInstructions.value
|
||||
if (!instructions || !stripeInstance.value || !elementsInstance.value) {
|
||||
errorMessage.value = 'Stripe 支付组件未就绪'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const submitResult = await elementsInstance.value.submit()
|
||||
if (submitResult.error) {
|
||||
errorMessage.value = submitResult.error.message || '请检查支付信息'
|
||||
return
|
||||
}
|
||||
|
||||
const { error, paymentIntent } = await stripeInstance.value.confirmPayment({
|
||||
elements: elementsInstance.value,
|
||||
confirmParams: {
|
||||
return_url: props.returnUrl || buildReturnUrl(),
|
||||
},
|
||||
redirect: 'if_required',
|
||||
})
|
||||
|
||||
if (error) {
|
||||
errorMessage.value = error.message || '支付失败'
|
||||
return
|
||||
}
|
||||
|
||||
const intentId = paymentIntent?.id || instructions.intentId || instructions.gatewayOrderId || ''
|
||||
if (paymentIntent?.status === 'succeeded' || paymentIntent?.status === 'processing') {
|
||||
emit('success', {
|
||||
intentId,
|
||||
status: paymentIntent.status,
|
||||
})
|
||||
emit('update:open', false)
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = paymentIntent?.status
|
||||
? `当前支付状态: ${paymentIntent.status}`
|
||||
: '支付已提交,请稍后刷新订单状态'
|
||||
} catch (error) {
|
||||
errorMessage.value = formatStripeError(error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function cleanupStripe() {
|
||||
mountSequence += 1
|
||||
initializing.value = false
|
||||
submitting.value = false
|
||||
errorMessage.value = ''
|
||||
mountedSignature.value = ''
|
||||
|
||||
try {
|
||||
paymentElement.value?.unmount()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
paymentElement.value = null
|
||||
elementsInstance.value = null
|
||||
stripeInstance.value = null
|
||||
|
||||
if (paymentElementRoot.value) {
|
||||
paymentElementRoot.value.innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStripeCached(publishableKey: string): Promise<Stripe | null> {
|
||||
if (!stripeLoaderCache.has(publishableKey)) {
|
||||
stripeLoaderCache.set(publishableKey, loadStripe(publishableKey))
|
||||
}
|
||||
return stripeLoaderCache.get(publishableKey) || null
|
||||
}
|
||||
|
||||
function formatStripeError(error: unknown): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message
|
||||
}
|
||||
if (typeof error === 'string' && error.trim()) {
|
||||
return error.trim()
|
||||
}
|
||||
if (typeof error === 'object' && error && 'message' in error) {
|
||||
const message = (error as { message?: unknown }).message
|
||||
if (typeof message === 'string' && message.trim()) {
|
||||
return message.trim()
|
||||
}
|
||||
}
|
||||
return 'Stripe 支付处理失败'
|
||||
}
|
||||
|
||||
function paymentMethodTypeLabel(method: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
card: '银行卡/信用卡',
|
||||
alipay: '支付宝',
|
||||
wechat_pay: '微信支付',
|
||||
link: 'Link',
|
||||
us_bank_account: '美国银行账户',
|
||||
}
|
||||
return labels[method] || paymentMethodLabel(method) || method
|
||||
}
|
||||
|
||||
function buildReturnUrl(): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
const url = new URL(window.location.href)
|
||||
url.hash = ''
|
||||
url.searchParams.set('stripe_return', '1')
|
||||
return url.toString()
|
||||
}
|
||||
</script>
|
||||
@@ -7,6 +7,7 @@
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as AlertDialog } from './AlertDialog.vue'
|
||||
export { default as LoadingState } from './LoadingState.vue'
|
||||
export { default as StripePaymentDialog } from './StripePaymentDialog.vue'
|
||||
|
||||
// 表单组件
|
||||
export { default as MultiSelect } from './MultiSelect.vue'
|
||||
|
||||
99
frontend/src/utils/paymentInstructions.ts
Normal file
99
frontend/src/utils/paymentInstructions.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export type PaymentInstructionMap = Record<string, unknown>
|
||||
|
||||
export interface StripePaymentInstructions {
|
||||
gateway: string
|
||||
displayName: string
|
||||
gatewayOrderId: string
|
||||
intentId: string
|
||||
clientSecret: string
|
||||
publishableKey: string
|
||||
expiresAt: string
|
||||
payAmount: number | null
|
||||
payCurrency: string
|
||||
paymentChannel: string
|
||||
paymentMethodTypes: string[]
|
||||
submitMethod: string
|
||||
}
|
||||
|
||||
function getInstructionValue(
|
||||
instructions: PaymentInstructionMap | null | undefined,
|
||||
key: string
|
||||
): unknown {
|
||||
if (!instructions || typeof instructions !== 'object' || Array.isArray(instructions)) {
|
||||
return undefined
|
||||
}
|
||||
return instructions[key]
|
||||
}
|
||||
|
||||
export function getPaymentInstructionString(
|
||||
instructions: PaymentInstructionMap | null | undefined,
|
||||
key: string
|
||||
): string {
|
||||
const value = getInstructionValue(instructions, key)
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
export function getPaymentInstructionNumber(
|
||||
instructions: PaymentInstructionMap | null | undefined,
|
||||
key: string
|
||||
): number | null {
|
||||
const value = getInstructionValue(instructions, key)
|
||||
const parsed = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
export function getPaymentInstructionStringArray(
|
||||
instructions: PaymentInstructionMap | null | undefined,
|
||||
key: string
|
||||
): string[] {
|
||||
const value = getInstructionValue(instructions, key)
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function getStripePaymentInstructions(
|
||||
instructions: PaymentInstructionMap | null | undefined
|
||||
): StripePaymentInstructions | null {
|
||||
const clientSecret = getPaymentInstructionString(instructions, 'client_secret')
|
||||
const publishableKey = getPaymentInstructionString(instructions, 'publishable_key')
|
||||
if (!clientSecret || !publishableKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
const intentId = getPaymentInstructionString(instructions, 'intent_id')
|
||||
|| getPaymentInstructionString(instructions, 'gateway_order_id')
|
||||
const gatewayOrderId = getPaymentInstructionString(instructions, 'gateway_order_id')
|
||||
|| intentId
|
||||
const paymentChannel = getPaymentInstructionString(instructions, 'payment_channel')
|
||||
|
||||
return {
|
||||
gateway: getPaymentInstructionString(instructions, 'gateway') || 'stripe',
|
||||
displayName: getPaymentInstructionString(instructions, 'display_name') || paymentChannel || 'Stripe',
|
||||
gatewayOrderId,
|
||||
intentId,
|
||||
clientSecret,
|
||||
publishableKey,
|
||||
expiresAt: getPaymentInstructionString(instructions, 'expires_at'),
|
||||
payAmount: getPaymentInstructionNumber(instructions, 'pay_amount'),
|
||||
payCurrency: getPaymentInstructionString(instructions, 'pay_currency'),
|
||||
paymentChannel,
|
||||
paymentMethodTypes: getPaymentInstructionStringArray(instructions, 'payment_method_types'),
|
||||
submitMethod: getPaymentInstructionString(instructions, 'submit_method') || 'stripe_payment_intent',
|
||||
}
|
||||
}
|
||||
|
||||
export function hasStripePaymentInstructions(
|
||||
instructions: PaymentInstructionMap | null | undefined
|
||||
): boolean {
|
||||
return getStripePaymentInstructions(instructions) !== null
|
||||
}
|
||||
@@ -72,7 +72,11 @@ export function paymentMethodLabel(method: string | null | undefined): string {
|
||||
alipay: '支付宝',
|
||||
wechat: '微信支付',
|
||||
wxpay: '微信支付',
|
||||
wechat_pay: '微信支付',
|
||||
epay: '易支付',
|
||||
stripe: 'Stripe',
|
||||
card: '银行卡/信用卡',
|
||||
link: 'Stripe Link',
|
||||
admin_manual: '人工充值',
|
||||
card_code: '充值卡',
|
||||
gift_code: '礼品卡',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="支付配置"
|
||||
description="配置易支付商户、回调地址、汇率和可用通道"
|
||||
description="配置易支付、支付宝官方、微信支付官方和 Stripe"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -28,6 +28,20 @@
|
||||
</PageHeader>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<Card class="p-4">
|
||||
<div class="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<Button
|
||||
v-for="provider in providers"
|
||||
:key="provider.key"
|
||||
:variant="activeProvider === provider.key ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
@click="selectProvider(provider.key)"
|
||||
>
|
||||
{{ provider.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="py-16"
|
||||
@@ -36,7 +50,7 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<Card class="p-5">
|
||||
<div class="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
网关状态
|
||||
@@ -50,7 +64,36 @@
|
||||
</Card>
|
||||
<Card class="p-5">
|
||||
<div class="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
商户密钥
|
||||
退款能力
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<Badge :variant="form.refund_enabled ? 'success' : 'secondary'">
|
||||
{{ form.refund_enabled ? '允许退款' : '关闭退款' }}
|
||||
</Badge>
|
||||
<Switch
|
||||
:model-value="form.refund_enabled"
|
||||
@update:model-value="setRefundEnabled"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-5">
|
||||
<div class="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
用户退款
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<Badge :variant="form.allow_user_refund && form.refund_enabled ? 'success' : 'secondary'">
|
||||
{{ form.allow_user_refund && form.refund_enabled ? '允许用户退款' : '关闭用户退款' }}
|
||||
</Badge>
|
||||
<Switch
|
||||
:model-value="form.allow_user_refund"
|
||||
:disabled="!form.refund_enabled"
|
||||
@update:model-value="setAllowUserRefund"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-5">
|
||||
<div class="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
密钥状态
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<Badge :variant="hasSecret ? 'success' : 'warning'">
|
||||
@@ -68,54 +111,197 @@
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<CardSection
|
||||
title="易支付商户"
|
||||
description="密钥留空会保留原密钥;回调地址留空时后端会使用当前 API 访问地址,生产环境建议显式填写公网根地址"
|
||||
>
|
||||
<CardSection>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-medium leading-6 text-foreground">
|
||||
{{ activeProviderMeta.label }}商户
|
||||
</h3>
|
||||
<div
|
||||
v-if="activeProvider === 'alipay'"
|
||||
ref="paymentHelpRef"
|
||||
class="relative inline-flex"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-6 w-6 cursor-pointer list-none items-center justify-center rounded-full border border-border/70 bg-background/60 text-muted-foreground transition hover:border-primary/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&::-webkit-details-marker]:hidden"
|
||||
title="支付宝支付模式说明"
|
||||
aria-label="支付宝支付模式说明"
|
||||
:aria-expanded="paymentHelpOpen === 'alipay'"
|
||||
aria-controls="alipay-payment-help"
|
||||
@click.stop="togglePaymentHelp('alipay')"
|
||||
>
|
||||
<CircleHelp class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div
|
||||
v-if="paymentHelpOpen === 'alipay'"
|
||||
id="alipay-payment-help"
|
||||
class="absolute left-0 top-full z-[240] mt-2 w-[320px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-border/60 bg-card/95 p-0 text-card-foreground shadow-xl shadow-black/5 backdrop-blur supports-[backdrop-filter]:bg-card/90"
|
||||
role="dialog"
|
||||
aria-label="支付宝支付模式说明"
|
||||
>
|
||||
<div class="space-y-4 p-4 text-xs leading-6">
|
||||
<p class="font-medium text-foreground">
|
||||
桌面优先扫码单,失败再走收银台;移动优先手机网站支付。
|
||||
</p>
|
||||
|
||||
<div class="border-t border-border/60 pt-3">
|
||||
<h4 class="mb-1 font-semibold text-foreground">
|
||||
当面付 / 扫码支付
|
||||
</h4>
|
||||
<p>开通:需开通当面付或扫码支付能力。</p>
|
||||
<p>
|
||||
调用:桌面端下单时优先调用 <code class="rounded bg-muted px-1 py-0.5 text-foreground">alipay.trade.precreate</code>,前台直接渲染二维码。
|
||||
</p>
|
||||
<p>降级:接口不可用或返回失败时,自动降级到电脑网站支付。</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border/60 pt-3">
|
||||
<h4 class="mb-1 font-semibold text-foreground">
|
||||
电脑网站支付
|
||||
</h4>
|
||||
<p>开通:需开通电脑网站支付。</p>
|
||||
<p>
|
||||
调用:桌面端当面付不可用时调用 <code class="rounded bg-muted px-1 py-0.5 text-foreground">alipay.trade.page.pay</code>,并继续以返回链接渲染成二维码。
|
||||
</p>
|
||||
<p>降级:同时保留打开收银台入口,用户可手动重新拉起支付页。</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border/60 pt-3">
|
||||
<h4 class="mb-1 font-semibold text-foreground">
|
||||
手机网站支付
|
||||
</h4>
|
||||
<p>开通:需开通手机网站支付。</p>
|
||||
<p>
|
||||
调用:移动端优先调用 <code class="rounded bg-muted px-1 py-0.5 text-foreground">alipay.trade.wap.pay</code>,跳转支付宝收银台。
|
||||
</p>
|
||||
<p>降级:未开通或返回异常时,前端自动改走扫码支付并提示未开通移动支付。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="activeProvider === 'wxpay'"
|
||||
ref="paymentHelpRef"
|
||||
class="relative inline-flex"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-6 w-6 cursor-pointer list-none items-center justify-center rounded-full border border-border/70 bg-background/60 text-muted-foreground transition hover:border-primary/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&::-webkit-details-marker]:hidden"
|
||||
title="微信支付模式说明"
|
||||
aria-label="微信支付模式说明"
|
||||
:aria-expanded="paymentHelpOpen === 'wxpay'"
|
||||
aria-controls="wxpay-payment-help"
|
||||
@click.stop="togglePaymentHelp('wxpay')"
|
||||
>
|
||||
<CircleHelp class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div
|
||||
v-if="paymentHelpOpen === 'wxpay'"
|
||||
id="wxpay-payment-help"
|
||||
class="absolute left-0 top-full z-[240] mt-2 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-border/60 bg-card/95 p-0 text-card-foreground shadow-xl shadow-black/5 backdrop-blur supports-[backdrop-filter]:bg-card/90"
|
||||
role="dialog"
|
||||
aria-label="微信支付模式说明"
|
||||
>
|
||||
<div class="space-y-4 p-4 text-xs leading-6">
|
||||
<p class="font-medium text-foreground">
|
||||
桌面优先 Native 扫码,移动端微信浏览器优先 JSAPI,非微信浏览器兜底 H5。
|
||||
</p>
|
||||
|
||||
<div class="border-t border-border/60 pt-3">
|
||||
<h4 class="mb-1 font-semibold text-foreground">
|
||||
Native / 扫码支付
|
||||
</h4>
|
||||
<p>开通:需开通 Native 或扫码支付能力。</p>
|
||||
<p>
|
||||
调用:桌面端默认调用 Native,前台会渲染二维码内容。
|
||||
</p>
|
||||
<p>降级:移动端无法走 JSAPI 或 H5 时,也会自动回退到这里。</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border/60 pt-3">
|
||||
<h4 class="mb-1 font-semibold text-foreground">
|
||||
JSAPI / 公众号支付
|
||||
</h4>
|
||||
<p>开通:需开通公众号支付,并保证当前浏览器在微信内且能拿到 OpenID。</p>
|
||||
<p>
|
||||
调用:微信内浏览器完成授权后调用 JSAPI,直接拉起微信收银台。
|
||||
</p>
|
||||
<p>降级:未配置或拉起失败时,自动改走扫码支付。</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border/60 pt-3">
|
||||
<h4 class="mb-1 font-semibold text-foreground">
|
||||
H5 支付
|
||||
</h4>
|
||||
<p>开通:需开通 H5 支付。</p>
|
||||
<p>
|
||||
调用:移动端非微信浏览器且客户端 IP 可用时调用 H5,跳转微信收银台。
|
||||
</p>
|
||||
<p>降级:未开通 H5 或下单失败时,自动改走扫码支付。</p>
|
||||
</div>
|
||||
|
||||
<p class="border-t border-border/60 pt-3">
|
||||
当前表单默认共用一个 App ID,适合同主体下统一配置网页、移动和公众号场景。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ activeProviderMeta.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="epay-endpoint">易支付接口地址</Label>
|
||||
<div
|
||||
v-if="activeProvider === 'epay'"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Label for="gateway-endpoint">易支付接口地址</Label>
|
||||
<Input
|
||||
id="epay-endpoint"
|
||||
id="gateway-endpoint"
|
||||
v-model="form.endpoint_url"
|
||||
placeholder="https://pay.example.com/submit.php"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="epay-callback-base">回调站点根地址</Label>
|
||||
<div
|
||||
v-if="activeProvider !== 'stripe'"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Label for="gateway-callback-base">回调站点根地址</Label>
|
||||
<Input
|
||||
id="epay-callback-base"
|
||||
id="gateway-callback-base"
|
||||
v-model="form.callback_base_url"
|
||||
:placeholder="defaultCallbackBaseUrl || 'https://aether.example.com'"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
留空保存为空配置;下单时默认使用当前 API 地址或 AETHER_PUBLIC_BASE_URL。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="epay-merchant-id">商户 ID</Label>
|
||||
<Input
|
||||
id="epay-merchant-id"
|
||||
v-model="form.merchant_id"
|
||||
placeholder="1000"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="epay-merchant-key">
|
||||
商户密钥
|
||||
<span class="text-xs font-normal text-muted-foreground">
|
||||
{{ hasSecret ? '(留空保持不变)' : '' }}
|
||||
<div
|
||||
v-for="field in visibleFields"
|
||||
:key="field.key"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Label :for="`gateway-field-${field.key}`">
|
||||
{{ field.label }}
|
||||
<span
|
||||
v-if="field.secret && hasSecretKey(field.key)"
|
||||
class="text-xs font-normal text-muted-foreground"
|
||||
>
|
||||
(留空保持不变)
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="epay-merchant-key"
|
||||
v-model="form.merchant_key"
|
||||
masked
|
||||
:placeholder="hasSecret ? '已设置,输入新密钥后覆盖' : '请输入商户密钥'"
|
||||
:id="`gateway-field-${field.key}`"
|
||||
v-model="fieldValues[field.key]"
|
||||
:masked="field.secret"
|
||||
:placeholder="field.placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,22 +309,22 @@
|
||||
|
||||
<CardSection
|
||||
title="计费参数"
|
||||
description="用户充值按美元金额下单,易支付按这里的币种和汇率收款"
|
||||
description="用户充值按美元金额下单,实际收款金额按这里的币种、汇率和通道手续费率计算"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="epay-currency">支付币种</Label>
|
||||
<Label for="gateway-currency">支付币种</Label>
|
||||
<Input
|
||||
id="epay-currency"
|
||||
id="gateway-currency"
|
||||
v-model="form.pay_currency"
|
||||
maxlength="16"
|
||||
placeholder="CNY"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="epay-rate">USD 汇率</Label>
|
||||
<Label for="gateway-rate">USD 汇率</Label>
|
||||
<Input
|
||||
id="epay-rate"
|
||||
id="gateway-rate"
|
||||
v-model.number="form.usd_exchange_rate"
|
||||
type="number"
|
||||
min="0.0001"
|
||||
@@ -146,9 +332,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="epay-min">最低充值金额 (USD)</Label>
|
||||
<Label for="gateway-min">最低充值金额 (USD)</Label>
|
||||
<Input
|
||||
id="epay-min"
|
||||
id="gateway-min"
|
||||
v-model.number="form.min_recharge_usd"
|
||||
type="number"
|
||||
min="0.01"
|
||||
@@ -160,7 +346,7 @@
|
||||
|
||||
<CardSection
|
||||
title="支付通道"
|
||||
description="通道值会传给易支付 type 字段"
|
||||
:description="activeProvider === 'epay' ? '通道值会传给易支付 type 字段' : '通道值决定用户侧展示和后端创建订单模式'"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
@@ -177,24 +363,35 @@
|
||||
<div
|
||||
v-for="(channel, index) in form.channels"
|
||||
:key="index"
|
||||
class="grid grid-cols-1 gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 md:grid-cols-[1fr_1fr_auto]"
|
||||
class="grid grid-cols-1 gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 md:grid-cols-[1fr_1fr_160px_auto]"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label :for="`epay-channel-${index}`">通道值</Label>
|
||||
<Label :for="`gateway-channel-${index}`">通道值</Label>
|
||||
<Input
|
||||
:id="`epay-channel-${index}`"
|
||||
:id="`gateway-channel-${index}`"
|
||||
v-model="channel.channel"
|
||||
placeholder="alipay"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label :for="`epay-channel-name-${index}`">显示名称</Label>
|
||||
<Label :for="`gateway-channel-name-${index}`">显示名称</Label>
|
||||
<Input
|
||||
:id="`epay-channel-name-${index}`"
|
||||
:id="`gateway-channel-name-${index}`"
|
||||
v-model="channel.display_name"
|
||||
placeholder="支付宝"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label :for="`gateway-channel-fee-${index}`">手续费率 (%)</Label>
|
||||
<Input
|
||||
:id="`gateway-channel-fee-${index}`"
|
||||
v-model.number="channel.fee_rate"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -222,9 +419,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { PlugZap, Plus, Save, Trash2 } from 'lucide-vue-next'
|
||||
import { epayGatewayApi, type EpayChannelConfig } from '@/api/billing'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { CircleHelp, PlugZap, Plus, Save, Trash2 } from 'lucide-vue-next'
|
||||
import { epayGatewayApi, type EpayChannelConfig, type PaymentGatewayProvider } from '@/api/billing'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -239,29 +436,112 @@ import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
type ProviderField = {
|
||||
key: string
|
||||
label: string
|
||||
secret?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const providers: Array<{
|
||||
key: PaymentGatewayProvider
|
||||
label: string
|
||||
description: string
|
||||
fields: ProviderField[]
|
||||
defaultChannels: EpayChannelConfig[]
|
||||
}> = [
|
||||
{
|
||||
key: 'epay',
|
||||
label: '易支付',
|
||||
description: '密钥留空会保留原密钥;回调地址留空时后端会使用当前 API 访问地址',
|
||||
fields: [
|
||||
{ key: 'merchant_id', label: '商户 ID', placeholder: '1000' },
|
||||
{ key: 'merchant_key', label: '商户密钥', secret: true, placeholder: '请输入商户密钥' },
|
||||
],
|
||||
defaultChannels: [
|
||||
{ channel: 'alipay', display_name: '支付宝', fee_rate: 0 },
|
||||
{ channel: 'wxpay', display_name: '微信支付', fee_rate: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'alipay',
|
||||
label: '支付宝官方',
|
||||
description: '用于支付宝官方当面付、手机网站支付或电脑网站支付',
|
||||
fields: [
|
||||
{ key: 'app_id', label: 'App ID', placeholder: '202100...' },
|
||||
{ key: 'payment_mode', label: '支付模式', placeholder: 'precreate / page / wap' },
|
||||
{ key: 'private_key', label: '应用私钥', secret: true, placeholder: 'PKCS#1 或 PKCS#8 私钥' },
|
||||
{ key: 'alipay_public_key', label: '支付宝公钥', secret: true, placeholder: '支付宝开放平台公钥' },
|
||||
],
|
||||
defaultChannels: [{ channel: 'alipay', display_name: '支付宝官方', fee_rate: 0 }],
|
||||
},
|
||||
{
|
||||
key: 'wxpay',
|
||||
label: '微信支付官方',
|
||||
description: '用于微信支付 Native/H5,JSAPI 还需要后续接入 OpenID 获取流程',
|
||||
fields: [
|
||||
{ key: 'app_id', label: 'App ID', placeholder: 'wx...' },
|
||||
{ key: 'mch_id', label: '商户号', placeholder: '1900000000' },
|
||||
{ key: 'cert_serial', label: '商户证书序列号', placeholder: '证书序列号' },
|
||||
{ key: 'public_key_id', label: '微信支付公钥 ID', placeholder: 'PUB_KEY_ID_...' },
|
||||
{ key: 'private_key', label: '商户 API 私钥', secret: true, placeholder: 'BEGIN PRIVATE KEY' },
|
||||
{ key: 'api_v3_key', label: 'API v3 密钥', secret: true, placeholder: '32 位 API v3 key' },
|
||||
{ key: 'public_key', label: '微信支付公钥', secret: true, placeholder: 'BEGIN PUBLIC KEY' },
|
||||
],
|
||||
defaultChannels: [
|
||||
{ channel: 'native', display_name: '微信 Native', fee_rate: 0 },
|
||||
{ channel: 'h5', display_name: '微信 H5', fee_rate: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'stripe',
|
||||
label: 'Stripe',
|
||||
description: '用于 Stripe PaymentIntent;Webhook Secret 用于回调验签',
|
||||
fields: [
|
||||
{ key: 'publishable_key', label: 'Publishable Key', placeholder: 'pk_live_...' },
|
||||
{ key: 'secret_key', label: 'Secret Key', secret: true, placeholder: 'sk_live_...' },
|
||||
{ key: 'webhook_secret', label: 'Webhook Secret', secret: true, placeholder: 'whsec_...' },
|
||||
],
|
||||
defaultChannels: [
|
||||
{ channel: 'card', display_name: 'Card', fee_rate: 0 },
|
||||
{ channel: 'alipay', display_name: 'Alipay', fee_rate: 0 },
|
||||
{ channel: 'wechat_pay', display_name: 'WeChat Pay', fee_rate: 0 },
|
||||
{ channel: 'link', display_name: 'Link', fee_rate: 0 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const activeProvider = ref<PaymentGatewayProvider>('epay')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const hasSecret = ref(false)
|
||||
const hasSecretKeys = ref<string[]>([])
|
||||
const updatedAt = ref<number | null>(null)
|
||||
const fieldValues = reactive<Record<string, string>>({})
|
||||
const paymentHelpOpen = ref<PaymentGatewayProvider | null>(null)
|
||||
const paymentHelpRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
endpoint_url: '',
|
||||
callback_base_url: '',
|
||||
merchant_id: '',
|
||||
merchant_key: '',
|
||||
pay_currency: 'CNY',
|
||||
usd_exchange_rate: 7.2,
|
||||
min_recharge_usd: 1,
|
||||
channels: [
|
||||
{ channel: 'alipay', display_name: '支付宝' },
|
||||
{ channel: 'wxpay', display_name: '微信支付' },
|
||||
] as EpayChannelConfig[],
|
||||
refund_enabled: false,
|
||||
allow_user_refund: false,
|
||||
channels: providers[0].defaultChannels.map((item) => ({ ...item })) as EpayChannelConfig[],
|
||||
})
|
||||
|
||||
const activeProviderMeta = computed(() =>
|
||||
providers.find(provider => provider.key === activeProvider.value) || providers[0]
|
||||
)
|
||||
|
||||
const visibleFields = computed(() => activeProviderMeta.value.fields)
|
||||
|
||||
const updatedAtText = computed(() => {
|
||||
if (!updatedAt.value) return ''
|
||||
return new Date(updatedAt.value * 1000).toLocaleString('zh-CN')
|
||||
@@ -273,50 +553,114 @@ const defaultCallbackBaseUrl = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', handlePaymentHelpOutsidePointerDown, true)
|
||||
void loadConfig()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', handlePaymentHelpOutsidePointerDown, true)
|
||||
})
|
||||
|
||||
async function selectProvider(provider: PaymentGatewayProvider) {
|
||||
if (activeProvider.value === provider) return
|
||||
closePaymentHelp()
|
||||
activeProvider.value = provider
|
||||
await loadConfig()
|
||||
}
|
||||
|
||||
function togglePaymentHelp(provider: PaymentGatewayProvider) {
|
||||
paymentHelpOpen.value = paymentHelpOpen.value === provider ? null : provider
|
||||
}
|
||||
|
||||
function closePaymentHelp() {
|
||||
paymentHelpOpen.value = null
|
||||
}
|
||||
|
||||
function handlePaymentHelpOutsidePointerDown(event: PointerEvent) {
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (paymentHelpRef.value?.contains(target)) return
|
||||
closePaymentHelp()
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const config = await epayGatewayApi.get()
|
||||
const config = await epayGatewayApi.get(activeProvider.value)
|
||||
form.enabled = config.enabled
|
||||
form.endpoint_url = config.endpoint_url || ''
|
||||
form.callback_base_url = config.callback_base_url || ''
|
||||
form.merchant_id = config.merchant_id || ''
|
||||
form.merchant_key = ''
|
||||
form.pay_currency = config.pay_currency || 'CNY'
|
||||
form.usd_exchange_rate = Number(config.usd_exchange_rate || 7.2)
|
||||
form.min_recharge_usd = Number(config.min_recharge_usd || 1)
|
||||
form.refund_enabled = Boolean(config.refund_enabled)
|
||||
form.allow_user_refund = form.refund_enabled && Boolean(config.allow_user_refund)
|
||||
form.channels = config.channels?.length
|
||||
? config.channels.map((item) => ({ ...item }))
|
||||
: [
|
||||
{ channel: 'alipay', display_name: '支付宝' },
|
||||
{ channel: 'wxpay', display_name: '微信支付' },
|
||||
]
|
||||
? config.channels.map((item) => {
|
||||
const feeRate = Number(item.fee_rate ?? 0)
|
||||
return { ...item, fee_rate: Number.isFinite(feeRate) && feeRate >= 0 ? feeRate : 0 }
|
||||
})
|
||||
: activeProviderMeta.value.defaultChannels.map((item) => ({ ...item }))
|
||||
hasSecret.value = config.has_secret
|
||||
hasSecretKeys.value = config.has_secret_keys || []
|
||||
updatedAt.value = config.updated_at ?? null
|
||||
|
||||
resetFieldValues()
|
||||
const savedConfig = config.config || {}
|
||||
for (const field of visibleFields.value) {
|
||||
if (field.key === 'merchant_id') {
|
||||
fieldValues[field.key] = config.merchant_id || ''
|
||||
} else {
|
||||
const value = savedConfig[field.key]
|
||||
fieldValues[field.key] = typeof value === 'string' ? value : ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('加载易支付配置失败:', err)
|
||||
showError(parseApiError(err, '加载易支付配置失败'))
|
||||
log.error('加载支付配置失败:', err)
|
||||
showError(parseApiError(err, '加载支付配置失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFieldValues() {
|
||||
for (const key of Object.keys(fieldValues)) {
|
||||
delete fieldValues[key]
|
||||
}
|
||||
for (const field of visibleFields.value) {
|
||||
fieldValues[field.key] = ''
|
||||
}
|
||||
}
|
||||
|
||||
function hasSecretKey(key: string): boolean {
|
||||
if (activeProvider.value === 'epay' && key === 'merchant_key') return hasSecret.value
|
||||
return hasSecretKeys.value.includes(key)
|
||||
}
|
||||
|
||||
function setRefundEnabled(value: boolean) {
|
||||
form.refund_enabled = value
|
||||
if (!value) form.allow_user_refund = false
|
||||
}
|
||||
|
||||
function setAllowUserRefund(value: boolean) {
|
||||
form.allow_user_refund = form.refund_enabled && value
|
||||
}
|
||||
|
||||
function normalizeChannels(): EpayChannelConfig[] {
|
||||
return form.channels
|
||||
.map((item) => ({
|
||||
channel: item.channel.trim(),
|
||||
display_name: item.display_name.trim(),
|
||||
}))
|
||||
.map((item) => {
|
||||
const feeRate = Number(item.fee_rate ?? 0)
|
||||
return {
|
||||
channel: item.channel.trim(),
|
||||
display_name: item.display_name.trim(),
|
||||
fee_rate: Number.isFinite(feeRate) && feeRate >= 0 ? feeRate : 0,
|
||||
}
|
||||
})
|
||||
.filter((item) => item.channel && item.display_name)
|
||||
}
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!form.endpoint_url.trim()) return '请输入易支付接口地址'
|
||||
if (!form.merchant_id.trim()) return '请输入商户 ID'
|
||||
if (!hasSecret.value && !form.merchant_key.trim()) return '首次配置需要填写商户密钥'
|
||||
if (activeProvider.value === 'epay' && !form.endpoint_url.trim()) return '请输入易支付接口地址'
|
||||
if (!form.pay_currency.trim()) return '请输入支付币种'
|
||||
if (!Number.isFinite(Number(form.usd_exchange_rate)) || Number(form.usd_exchange_rate) <= 0) {
|
||||
return 'USD 汇率必须大于 0'
|
||||
@@ -324,7 +668,20 @@ function validateForm(): string | null {
|
||||
if (!Number.isFinite(Number(form.min_recharge_usd)) || Number(form.min_recharge_usd) <= 0) {
|
||||
return '最低充值金额必须大于 0'
|
||||
}
|
||||
if (normalizeChannels().length === 0) return '至少需要一个支付通道'
|
||||
const channels = normalizeChannels()
|
||||
if (channels.length === 0) return '至少需要一个支付通道'
|
||||
for (const [index, channel] of form.channels.entries()) {
|
||||
if (!channel.channel.trim() || !channel.display_name.trim()) continue
|
||||
const feeRate = Number(channel.fee_rate ?? 0)
|
||||
if (!Number.isFinite(feeRate) || feeRate < 0) {
|
||||
return `第 ${index + 1} 个通道手续费率必须大于等于 0`
|
||||
}
|
||||
}
|
||||
for (const field of visibleFields.value) {
|
||||
const value = fieldValues[field.key]?.trim() || ''
|
||||
if (!field.secret && !value) return `请输入${field.label}`
|
||||
if (field.secret && !hasSecretKey(field.key) && !value) return `首次配置需要填写${field.label}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -337,27 +694,47 @@ async function saveConfig() {
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const callbackBaseUrl = form.callback_base_url.trim()
|
||||
const configFields: Record<string, unknown> = {}
|
||||
const secrets: Record<string, string> = {}
|
||||
for (const field of visibleFields.value) {
|
||||
const value = fieldValues[field.key]?.trim() || ''
|
||||
if (field.secret) {
|
||||
if (value) secrets[field.key] = value
|
||||
} else if (field.key !== 'merchant_id') {
|
||||
configFields[field.key] = value
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
enabled: form.enabled,
|
||||
endpoint_url: form.endpoint_url.trim(),
|
||||
callback_base_url: callbackBaseUrl || null,
|
||||
merchant_id: form.merchant_id.trim(),
|
||||
callback_base_url: form.callback_base_url.trim() || null,
|
||||
merchant_id: fieldValues.merchant_id?.trim() || '',
|
||||
pay_currency: form.pay_currency.trim().toUpperCase(),
|
||||
usd_exchange_rate: Number(form.usd_exchange_rate),
|
||||
min_recharge_usd: Number(form.min_recharge_usd),
|
||||
channels: normalizeChannels(),
|
||||
...(form.merchant_key.trim() ? { merchant_key: form.merchant_key.trim() } : {}),
|
||||
refund_enabled: form.refund_enabled,
|
||||
allow_user_refund: form.refund_enabled && form.allow_user_refund,
|
||||
config: configFields,
|
||||
secrets,
|
||||
...(activeProvider.value === 'epay' && fieldValues.merchant_key?.trim()
|
||||
? { merchant_key: fieldValues.merchant_key.trim() }
|
||||
: {}),
|
||||
}
|
||||
const config = await epayGatewayApi.update(payload)
|
||||
const config = await epayGatewayApi.update(payload, activeProvider.value)
|
||||
hasSecret.value = config.has_secret
|
||||
hasSecretKeys.value = config.has_secret_keys || []
|
||||
updatedAt.value = config.updated_at ?? null
|
||||
form.callback_base_url = config.callback_base_url || ''
|
||||
form.merchant_key = ''
|
||||
form.refund_enabled = Boolean(config.refund_enabled)
|
||||
form.allow_user_refund = form.refund_enabled && Boolean(config.allow_user_refund)
|
||||
fieldValues.merchant_key = ''
|
||||
for (const field of visibleFields.value) {
|
||||
if (field.secret) fieldValues[field.key] = ''
|
||||
}
|
||||
success('支付配置已保存')
|
||||
} catch (err) {
|
||||
log.error('保存易支付配置失败:', err)
|
||||
showError(parseApiError(err, '保存易支付配置失败'))
|
||||
log.error('保存支付配置失败:', err)
|
||||
showError(parseApiError(err, '保存支付配置失败'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -366,18 +743,18 @@ async function saveConfig() {
|
||||
async function testGateway() {
|
||||
testing.value = true
|
||||
try {
|
||||
await epayGatewayApi.test()
|
||||
success('易支付配置可用')
|
||||
await epayGatewayApi.test(activeProvider.value)
|
||||
success('支付配置可用')
|
||||
} catch (err) {
|
||||
log.error('测试易支付配置失败:', err)
|
||||
showError(parseApiError(err, '测试易支付配置失败'))
|
||||
log.error('测试支付配置失败:', err)
|
||||
showError(parseApiError(err, '测试支付配置失败'))
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addChannel() {
|
||||
form.channels.push({ channel: '', display_name: '' })
|
||||
form.channels.push({ channel: '', display_name: '', fee_rate: 0 })
|
||||
}
|
||||
|
||||
function removeChannel(index: number) {
|
||||
|
||||
@@ -114,14 +114,14 @@
|
||||
<Select v-model="selectedChannel">
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
:placeholder="epayOptions.length ? '选择支付通道' : '暂无可用支付通道'"
|
||||
:placeholder="checkoutOptions.length ? '选择支付通道' : '暂无可用支付通道'"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in epayOptions"
|
||||
:key="`${option.payment_channel}-${option.display_name}`"
|
||||
:value="option.payment_channel || ''"
|
||||
v-for="option in checkoutOptions"
|
||||
:key="option.key"
|
||||
:value="option.key"
|
||||
>
|
||||
{{ option.display_name }}
|
||||
</SelectItem>
|
||||
@@ -131,7 +131,7 @@
|
||||
class="w-full"
|
||||
:disabled="
|
||||
checkoutLoadingPlanId === plan.id
|
||||
|| epayOptions.length === 0
|
||||
|| checkoutOptions.length === 0
|
||||
|| !selectedChannel
|
||||
"
|
||||
@click="checkoutPlan(plan)"
|
||||
@@ -168,16 +168,25 @@
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="latestPaymentUrl"
|
||||
v-if="latestCheckoutActionLabel"
|
||||
variant="outline"
|
||||
@click="openPaymentUrl(latestPaymentUrl)"
|
||||
@click="openLatestPayment"
|
||||
>
|
||||
打开支付链接
|
||||
{{ latestCheckoutActionLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<StripePaymentDialog
|
||||
v-model:open="stripeDialogOpen"
|
||||
:instructions="stripePaymentInstructions"
|
||||
title="套餐 Stripe 支付"
|
||||
description="完成支付后,套餐权益会由 Stripe Webhook 自动发放。"
|
||||
confirm-text="支付套餐"
|
||||
@success="handleStripePaymentSuccess"
|
||||
/>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
@@ -203,11 +212,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import { EmptyState, LoadingState } from '@/components/common'
|
||||
import { EmptyState, LoadingState, StripePaymentDialog } from '@/components/common'
|
||||
import { CardSection, PageContainer, PageHeader } from '@/components/layout'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
import {
|
||||
getPaymentInstructionString,
|
||||
getStripePaymentInstructions,
|
||||
type PaymentInstructionMap,
|
||||
} from '@/utils/paymentInstructions'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
@@ -218,14 +232,29 @@ const rechargeOptions = ref<WalletRechargeOption[]>([])
|
||||
const selectedChannel = ref('')
|
||||
const checkoutLoadingPlanId = ref<string | null>(null)
|
||||
const latestCheckout = ref<BillingCheckoutResponse | null>(null)
|
||||
const stripeDialogOpen = ref(false)
|
||||
const stripePaymentInstructions = ref<PaymentInstructionMap | null>(null)
|
||||
|
||||
const epayOptions = computed(() =>
|
||||
rechargeOptions.value.filter((option) =>
|
||||
(option.payment_provider === 'epay' || option.payment_method === 'epay')
|
||||
&& Boolean(option.payment_channel)
|
||||
)
|
||||
const checkoutOptions = computed(() =>
|
||||
rechargeOptions.value
|
||||
.filter((option) => Boolean(paymentOptionProvider(option)) && Boolean(option.payment_channel))
|
||||
.map((option, index) => ({
|
||||
...option,
|
||||
key: [
|
||||
paymentOptionProvider(option),
|
||||
option.payment_method,
|
||||
option.payment_channel || '',
|
||||
index,
|
||||
].join(':'),
|
||||
}))
|
||||
)
|
||||
|
||||
const selectedCheckoutOption = computed(() => {
|
||||
if (checkoutOptions.value.length === 0) return null
|
||||
return checkoutOptions.value.find(option => option.key === selectedChannel.value)
|
||||
|| checkoutOptions.value[0]
|
||||
})
|
||||
|
||||
const activeEntitlements = computed(() =>
|
||||
entitlements.value.filter((item) =>
|
||||
item.active !== false
|
||||
@@ -243,12 +272,17 @@ const latestPaymentUrl = computed(() => {
|
||||
return typeof value === 'string' && value ? value : ''
|
||||
})
|
||||
|
||||
watch(epayOptions, (options) => {
|
||||
const channels = options
|
||||
.map(option => option.payment_channel || '')
|
||||
.filter(Boolean)
|
||||
if (!channels.includes(selectedChannel.value)) {
|
||||
selectedChannel.value = channels[0] || ''
|
||||
const latestCheckoutActionLabel = computed(() => {
|
||||
const instructions = latestCheckout.value?.payment_instructions
|
||||
if (getStripePaymentInstructions(instructions)) return '打开 Stripe 支付'
|
||||
if (latestPaymentUrl.value) return '打开支付链接'
|
||||
return ''
|
||||
})
|
||||
|
||||
watch(checkoutOptions, (options) => {
|
||||
const keys = options.map(option => option.key)
|
||||
if (!keys.includes(selectedChannel.value)) {
|
||||
selectedChannel.value = keys[0] || ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -285,8 +319,8 @@ async function loadRechargeOptions() {
|
||||
try {
|
||||
const response = await walletApi.listRechargeOptions()
|
||||
rechargeOptions.value = response.items
|
||||
if (!selectedChannel.value && epayOptions.value.length > 0) {
|
||||
selectedChannel.value = epayOptions.value[0].payment_channel || ''
|
||||
if (!selectedChannel.value && checkoutOptions.value.length > 0) {
|
||||
selectedChannel.value = checkoutOptions.value[0].key
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('加载支付通道失败:', err)
|
||||
@@ -299,12 +333,22 @@ async function checkoutPlan(plan: BillingPlan) {
|
||||
const confirmed = window.confirm('购买成功后,同类旧套餐会自动失效。确定继续购买吗?')
|
||||
if (!confirmed) return
|
||||
}
|
||||
const option = selectedCheckoutOption.value
|
||||
if (!option) {
|
||||
showError('请选择支付方式')
|
||||
return
|
||||
}
|
||||
const provider = paymentOptionProvider(option)
|
||||
if (!provider) {
|
||||
showError('支付方式配置无效')
|
||||
return
|
||||
}
|
||||
checkoutLoadingPlanId.value = plan.id
|
||||
try {
|
||||
const response = await billingApi.checkout(plan.id, {
|
||||
payment_method: 'epay',
|
||||
payment_provider: 'epay',
|
||||
payment_channel: selectedChannel.value,
|
||||
payment_method: option.payment_method || provider,
|
||||
payment_provider: provider,
|
||||
payment_channel: option.payment_channel,
|
||||
})
|
||||
latestCheckout.value = response
|
||||
success('套餐订单已创建')
|
||||
@@ -317,14 +361,20 @@ async function checkoutPlan(plan: BillingPlan) {
|
||||
}
|
||||
}
|
||||
|
||||
function openPaymentUrl(url: string) {
|
||||
submitPaymentInstructions(latestCheckout.value?.payment_instructions || { payment_url: url })
|
||||
function openLatestPayment() {
|
||||
submitPaymentInstructions(latestCheckout.value?.payment_instructions || { payment_url: latestPaymentUrl.value })
|
||||
}
|
||||
|
||||
function submitPaymentInstructions(instructions: Record<string, unknown> | null | undefined) {
|
||||
if (!instructions) return
|
||||
const paymentUrl = instructions.payment_url
|
||||
if (typeof paymentUrl !== 'string' || !paymentUrl) return
|
||||
const stripeInstructions = getStripePaymentInstructions(instructions)
|
||||
if (stripeInstructions) {
|
||||
stripePaymentInstructions.value = instructions
|
||||
stripeDialogOpen.value = true
|
||||
return
|
||||
}
|
||||
const paymentUrl = getPaymentInstructionString(instructions, 'payment_url')
|
||||
if (!paymentUrl) return
|
||||
const paymentParams = instructions.payment_params
|
||||
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
|
||||
submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>)
|
||||
@@ -360,6 +410,15 @@ function isSafariBrowser(): boolean {
|
||||
return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome')
|
||||
}
|
||||
|
||||
async function handleStripePaymentSuccess() {
|
||||
success('支付已完成,正在刷新套餐状态')
|
||||
await Promise.all([loadEntitlements(), loadPlans()])
|
||||
}
|
||||
|
||||
function paymentOptionProvider(option: WalletRechargeOption): string {
|
||||
return (option.payment_provider || option.provider || option.payment_method || '').trim()
|
||||
}
|
||||
|
||||
function planTitle(planId: string): string {
|
||||
return plans.value.find((plan) => plan.id === planId)?.title || planId
|
||||
}
|
||||
|
||||
@@ -195,6 +195,12 @@
|
||||
>
|
||||
· {{ option.pay_currency }}
|
||||
</span>
|
||||
<span
|
||||
v-if="Number(option.fee_rate || 0) > 0"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
· 手续费 {{ Number(option.fee_rate || 0).toFixed(2) }}%
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -208,10 +214,14 @@
|
||||
预计支付:
|
||||
<span class="font-medium text-foreground">
|
||||
{{ estimatedRechargePayAmount }}
|
||||
{{ selectedRechargeOption.pay_currency || 'CNY' }}
|
||||
{{ rechargePayCurrency }}
|
||||
</span>
|
||||
· 1 USD = {{ Number(selectedRechargeOption.usd_exchange_rate).toFixed(4) }}
|
||||
{{ selectedRechargeOption.pay_currency || 'CNY' }}
|
||||
{{ rechargePayCurrency }}
|
||||
<template v-if="estimatedRechargeFeeAmount > 0">
|
||||
· 手续费 {{ estimatedRechargeFeeAmount.toFixed(2) }} {{ rechargePayCurrency }}
|
||||
({{ estimatedRechargeFeeRate.toFixed(2) }}%)
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -239,15 +249,23 @@
|
||||
</Badge>
|
||||
</div>
|
||||
<a
|
||||
v-if="latestRecharge.payment_instructions?.payment_url"
|
||||
v-if="latestRechargePaymentUrl"
|
||||
class="inline-flex text-xs text-primary hover:underline"
|
||||
:href="String(latestRecharge.payment_instructions.payment_url)"
|
||||
:href="latestRechargePaymentUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click.prevent="submitPaymentInstructions(latestRecharge.payment_instructions)"
|
||||
>
|
||||
打开支付链接
|
||||
</a>
|
||||
<button
|
||||
v-if="latestRechargeStripeInstructions"
|
||||
type="button"
|
||||
class="inline-flex text-xs text-primary hover:underline"
|
||||
@click="submitPaymentInstructions(latestRecharge?.payment_instructions)"
|
||||
>
|
||||
打开 Stripe 支付
|
||||
</button>
|
||||
<div
|
||||
v-if="latestRecharge.payment_instructions?.qr_code"
|
||||
class="text-xs text-muted-foreground break-all"
|
||||
@@ -263,11 +281,18 @@
|
||||
申请退款
|
||||
</h3>
|
||||
<RefreshButton
|
||||
:loading="loadingRefunds"
|
||||
@click="loadRefunds"
|
||||
:loading="loadingRefunds || loadingRefundEligibility"
|
||||
@click="refreshRefundPanel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!loadingRefundEligibility && refundableOrders.length === 0"
|
||||
class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground"
|
||||
>
|
||||
当前没有开启用户自助退款的可退充值订单。
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label>退款金额 (USD)</Label>
|
||||
@@ -299,15 +324,12 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>关联充值订单(可选)</Label>
|
||||
<Label>关联充值订单</Label>
|
||||
<Select v-model="refundForm.payment_order_id">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="不指定订单,直接从钱包余额退款" />
|
||||
<SelectValue placeholder="选择允许用户退款的订单" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">
|
||||
不指定
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="order in refundableOrders"
|
||||
:key="order.id"
|
||||
@@ -329,13 +351,13 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
仅充值余额可退款,赠款余额不可退款。
|
||||
仅开启“允许用户退款”的支付方式可由用户自助提交退款申请。
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="w-full"
|
||||
variant="outline"
|
||||
:disabled="submittingRefund"
|
||||
:disabled="submittingRefund || refundableOrders.length === 0"
|
||||
@click="submitRefund"
|
||||
>
|
||||
{{ submittingRefund ? '提交中...' : '提交退款申请' }}
|
||||
@@ -647,6 +669,15 @@
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<StripePaymentDialog
|
||||
v-model:open="stripeDialogOpen"
|
||||
:instructions="stripePaymentInstructions"
|
||||
title="钱包 Stripe 支付"
|
||||
description="完成支付后,钱包余额会由 Stripe Webhook 自动入账。"
|
||||
confirm-text="支付充值"
|
||||
@success="handleStripePaymentSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -677,7 +708,7 @@ import {
|
||||
TabsTrigger,
|
||||
Textarea,
|
||||
} from '@/components/ui'
|
||||
import { EmptyState, LoadingState } from '@/components/common'
|
||||
import { EmptyState, LoadingState, StripePaymentDialog } from '@/components/common'
|
||||
import {
|
||||
walletApi,
|
||||
type DailyUsageRecord,
|
||||
@@ -691,6 +722,11 @@ import {
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
import {
|
||||
getPaymentInstructionString,
|
||||
getStripePaymentInstructions,
|
||||
type PaymentInstructionMap,
|
||||
} from '@/utils/paymentInstructions'
|
||||
import {
|
||||
dailyUsageCategoryLabel,
|
||||
formatTokenCount,
|
||||
@@ -715,6 +751,7 @@ const loadingInitial = ref(true)
|
||||
const loadingTransactions = ref(false)
|
||||
const loadingOrders = ref(false)
|
||||
const loadingRefunds = ref(false)
|
||||
const loadingRefundEligibility = ref(false)
|
||||
const submittingRedeem = ref(false)
|
||||
const submittingRecharge = ref(false)
|
||||
const submittingRefund = ref(false)
|
||||
@@ -723,6 +760,8 @@ const walletBalance = ref<WalletBalanceResponse | null>(null)
|
||||
const latestRecharge = ref<{ order: PaymentOrder; payment_instructions: Record<string, unknown> } | null>(null)
|
||||
const latestRedeem = ref<WalletRedeemResponse | null>(null)
|
||||
const rechargeOptions = ref<WalletRechargeOption[]>([])
|
||||
const stripeDialogOpen = ref(false)
|
||||
const stripePaymentInstructions = ref<PaymentInstructionMap | null>(null)
|
||||
|
||||
const flowItems = ref<FlowItem[]>([])
|
||||
const todayUsage = ref<DailyUsageRecord | null>(null)
|
||||
@@ -734,6 +773,7 @@ const rechargeOrders = ref<PaymentOrder[]>([])
|
||||
const orderTotal = ref(0)
|
||||
const orderPage = ref(1)
|
||||
const orderPageSize = ref(20)
|
||||
const refundEligiblePaymentMethods = ref<Set<string>>(new Set())
|
||||
|
||||
const refunds = ref<RefundRequest[]>([])
|
||||
const refundTotal = ref(0)
|
||||
@@ -750,7 +790,7 @@ const rechargeForm = reactive({
|
||||
|
||||
const refundForm = reactive({
|
||||
amount_usd: 0,
|
||||
payment_order_id: '__none__',
|
||||
payment_order_id: '',
|
||||
refund_mode: 'offline_payout',
|
||||
reason: '',
|
||||
})
|
||||
@@ -760,7 +800,10 @@ const redeemForm = reactive({
|
||||
})
|
||||
|
||||
const refundableOrders = computed(() =>
|
||||
rechargeOrders.value.filter(o => (o.refundable_amount_usd || 0) > 0)
|
||||
rechargeOrders.value.filter(order =>
|
||||
(order.refundable_amount_usd || 0) > 0
|
||||
&& refundEligiblePaymentMethods.value.has(refundPaymentMethod(order))
|
||||
)
|
||||
)
|
||||
|
||||
const rechargeOptionsWithKey = computed(() =>
|
||||
@@ -781,12 +824,45 @@ const selectedRechargeOption = computed(() => {
|
||||
|| rechargeOptionsWithKey.value[0]
|
||||
})
|
||||
|
||||
const estimatedRechargePayAmount = computed(() => {
|
||||
function roundPayAmount(value: number): number {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
const rechargePaymentBreakdown = computed(() => {
|
||||
const rate = Number(selectedRechargeOption.value?.usd_exchange_rate || 0)
|
||||
if (!Number.isFinite(rate) || rate <= 0) return '-'
|
||||
return (Number(rechargeForm.amount_usd || 0) * rate).toFixed(2)
|
||||
const amount = Number(rechargeForm.amount_usd || 0)
|
||||
if (!Number.isFinite(rate) || rate <= 0 || !Number.isFinite(amount) || amount <= 0) return null
|
||||
const rawFeeRate = Number(selectedRechargeOption.value?.fee_rate || 0)
|
||||
const feeRate = Number.isFinite(rawFeeRate) && rawFeeRate > 0 ? rawFeeRate : 0
|
||||
const basePayAmount = roundPayAmount(amount * rate)
|
||||
const feeAmount = roundPayAmount(basePayAmount * feeRate / 100)
|
||||
return {
|
||||
basePayAmount,
|
||||
feeAmount,
|
||||
feeRate,
|
||||
totalPayAmount: roundPayAmount(basePayAmount + feeAmount),
|
||||
}
|
||||
})
|
||||
|
||||
const rechargePayCurrency = computed(() => selectedRechargeOption.value?.pay_currency || 'CNY')
|
||||
|
||||
const estimatedRechargePayAmount = computed(() => {
|
||||
if (!rechargePaymentBreakdown.value) return '-'
|
||||
return rechargePaymentBreakdown.value.totalPayAmount.toFixed(2)
|
||||
})
|
||||
const estimatedRechargeFeeAmount = computed(() =>
|
||||
rechargePaymentBreakdown.value?.feeAmount || 0
|
||||
)
|
||||
const estimatedRechargeFeeRate = computed(() =>
|
||||
rechargePaymentBreakdown.value?.feeRate || 0
|
||||
)
|
||||
const latestRechargePaymentUrl = computed(() =>
|
||||
getPaymentInstructionString(latestRecharge.value?.payment_instructions, 'payment_url')
|
||||
)
|
||||
const latestRechargeStripeInstructions = computed(() =>
|
||||
getStripePaymentInstructions(latestRecharge.value?.payment_instructions)
|
||||
)
|
||||
|
||||
const dailyQuota = computed(() => walletBalance.value?.daily_quota ?? null)
|
||||
const hasActiveDailyQuota = computed(() => Boolean(dailyQuota.value?.has_active))
|
||||
const walletOnlyBalance = computed(() => {
|
||||
@@ -836,6 +912,7 @@ onMounted(async () => {
|
||||
loadTodayCost(),
|
||||
loadOrders(),
|
||||
loadRefunds(),
|
||||
loadRefundEligibility(),
|
||||
loadRechargeOptions(),
|
||||
])
|
||||
syncTodayCostPolling()
|
||||
@@ -853,6 +930,10 @@ watch(activeTab, () => {
|
||||
syncTodayCostPolling()
|
||||
})
|
||||
|
||||
watch(refundableOrders, () => {
|
||||
syncRefundOrderSelection()
|
||||
})
|
||||
|
||||
async function loadBalance() {
|
||||
walletBalance.value = await walletApi.getBalance()
|
||||
}
|
||||
@@ -928,6 +1009,7 @@ async function loadOrders() {
|
||||
const resp = await walletApi.listRechargeOrders({ limit: orderPageSize.value, offset })
|
||||
rechargeOrders.value = resp.items
|
||||
orderTotal.value = resp.total
|
||||
syncRefundOrderSelection()
|
||||
} catch (error) {
|
||||
log.error('加载充值订单失败:', error)
|
||||
showError(parseApiError(error, '加载充值订单失败'))
|
||||
@@ -936,6 +1018,24 @@ async function loadOrders() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRefundEligibility() {
|
||||
loadingRefundEligibility.value = true
|
||||
try {
|
||||
const resp = await walletApi.listRefundEligibleProviders()
|
||||
refundEligiblePaymentMethods.value = new Set(
|
||||
(resp.payment_methods || [])
|
||||
.map(item => item.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
syncRefundOrderSelection()
|
||||
} catch (error) {
|
||||
refundEligiblePaymentMethods.value = new Set()
|
||||
log.error('加载退款资格失败:', error)
|
||||
} finally {
|
||||
loadingRefundEligibility.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRefunds() {
|
||||
loadingRefunds.value = true
|
||||
try {
|
||||
@@ -951,6 +1051,10 @@ async function loadRefunds() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRefundPanel() {
|
||||
await Promise.all([loadRefunds(), loadRefundEligibility(), loadOrders()])
|
||||
}
|
||||
|
||||
async function submitRedeem() {
|
||||
if (!redeemForm.code.trim()) {
|
||||
showError('请输入兑换码')
|
||||
@@ -1011,14 +1115,23 @@ async function submitRecharge() {
|
||||
|
||||
function submitPaymentInstructions(instructions: Record<string, unknown> | null | undefined) {
|
||||
if (!instructions) return
|
||||
const paymentUrl = instructions.payment_url
|
||||
if (typeof paymentUrl !== 'string' || !paymentUrl) return
|
||||
const stripeInstructions = getStripePaymentInstructions(instructions)
|
||||
if (stripeInstructions) {
|
||||
stripePaymentInstructions.value = instructions
|
||||
stripeDialogOpen.value = true
|
||||
return
|
||||
}
|
||||
const paymentUrl = getPaymentInstructionString(instructions, 'payment_url')
|
||||
if (!paymentUrl) return
|
||||
const paymentParams = instructions.payment_params
|
||||
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
|
||||
submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>)
|
||||
return
|
||||
}
|
||||
window.open(paymentUrl, '_blank', 'noopener,noreferrer')
|
||||
const opened = window.open(paymentUrl, '_blank', 'noopener,noreferrer')
|
||||
if (!opened) {
|
||||
window.location.href = paymentUrl
|
||||
}
|
||||
}
|
||||
|
||||
function submitPaymentForm(url: string, params: Record<string, unknown>) {
|
||||
@@ -1045,11 +1158,26 @@ function isSafariBrowser(): boolean {
|
||||
return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome')
|
||||
}
|
||||
|
||||
async function handleStripePaymentSuccess() {
|
||||
success('支付已完成,正在刷新钱包余额')
|
||||
await Promise.all([loadBalance(), loadOrders(), loadTransactions(), loadTodayCost()])
|
||||
activeTab.value = 'orders'
|
||||
}
|
||||
|
||||
async function submitRefund() {
|
||||
if (!refundForm.amount_usd || refundForm.amount_usd <= 0) {
|
||||
showError('请输入有效的退款金额')
|
||||
return
|
||||
}
|
||||
const selectedOrder = refundableOrders.value.find(order => order.id === refundForm.payment_order_id)
|
||||
if (!selectedOrder) {
|
||||
showError('请选择允许用户退款的充值订单')
|
||||
return
|
||||
}
|
||||
if (refundForm.amount_usd > (selectedOrder.refundable_amount_usd || 0)) {
|
||||
showError(`退款金额超过该订单可退金额(当前可退 ${formatCurrency(selectedOrder.refundable_amount_usd || 0)})`)
|
||||
return
|
||||
}
|
||||
const refundableBalance =
|
||||
walletBalance.value?.wallet?.refundable_balance ?? walletBalance.value?.refundable_balance ?? null
|
||||
if (refundableBalance !== null && refundForm.amount_usd > refundableBalance) {
|
||||
@@ -1061,19 +1189,15 @@ async function submitRefund() {
|
||||
try {
|
||||
await walletApi.createRefund({
|
||||
amount_usd: refundForm.amount_usd,
|
||||
payment_order_id:
|
||||
refundForm.payment_order_id && refundForm.payment_order_id !== '__none__'
|
||||
? refundForm.payment_order_id
|
||||
: undefined,
|
||||
payment_order_id: selectedOrder.id,
|
||||
refund_mode: refundForm.refund_mode || undefined,
|
||||
reason: refundForm.reason || undefined,
|
||||
idempotency_key: `web_refund_${buildRefundIdempotencyKey()}`,
|
||||
})
|
||||
success('退款申请已提交')
|
||||
refundForm.amount_usd = 0
|
||||
refundForm.payment_order_id = '__none__'
|
||||
refundForm.reason = ''
|
||||
await Promise.all([loadRefunds(), loadBalance(), loadOrders(), loadTransactions(), loadTodayCost()])
|
||||
await Promise.all([loadRefunds(), loadBalance(), loadOrders(), loadRefundEligibility(), loadTransactions(), loadTodayCost()])
|
||||
activeTab.value = 'refunds'
|
||||
} catch (error) {
|
||||
log.error('提交退款申请失败:', error)
|
||||
@@ -1083,6 +1207,15 @@ async function submitRefund() {
|
||||
}
|
||||
}
|
||||
|
||||
function refundPaymentMethod(order: PaymentOrder): string {
|
||||
return String(order.payment_provider || order.payment_method || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function syncRefundOrderSelection() {
|
||||
if (refundableOrders.value.some(order => order.id === refundForm.payment_order_id)) return
|
||||
refundForm.payment_order_id = refundableOrders.value[0]?.id || ''
|
||||
}
|
||||
|
||||
function buildRefundIdempotencyKey(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID().replaceAll('-', '')
|
||||
|
||||
Reference in New Issue
Block a user