Merge pull request #511

This commit is contained in:
fawney19
2026-05-20 01:22:55 +08:00
40 changed files with 4674 additions and 365 deletions

View File

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

View File

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

View 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>

View File

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

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

View File

@@ -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: '礼品卡',

View File

@@ -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/H5JSAPI 还需要后续接入 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 PaymentIntentWebhook 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) {

View File

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

View File

@@ -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('-', '')