Merge remote-tracking branch 'entropy-xu/payment-billing-plans'

# Conflicts:
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
#	frontend/src/views/admin/Users.vue
This commit is contained in:
fawney19
2026-05-15 16:37:17 +08:00
97 changed files with 14795 additions and 402 deletions

View File

@@ -1,6 +1,6 @@
import apiClient from './client'
import { buildCacheKey, cachedRequest } from '@/utils/cache'
import type { RefundRequest, WalletSummary, WalletTransaction } from './wallet'
import type { RefundRequest, WalletDailyQuotaSummary, WalletSummary, WalletTransaction } from './wallet'
export interface AdminWallet extends WalletSummary {
user_id: string | null
@@ -8,6 +8,11 @@ export interface AdminWallet extends WalletSummary {
owner_type: 'user' | 'api_key'
owner_name: string | null
created_at: string
wallet_balance?: number | null
package_balance?: number | null
total_available_balance?: number | null
daily_quota?: WalletDailyQuotaSummary | null
deduction_order?: string[]
}
export interface AdminWalletListResponse {

236
frontend/src/api/billing.ts Normal file
View File

@@ -0,0 +1,236 @@
import apiClient from './client'
import type { PaymentOrder } from './wallet'
export type BillingDurationUnit = 'day' | 'month' | 'year' | 'custom'
export type BillingPurchaseLimitScope = 'active_period' | 'lifetime' | 'unlimited'
export type WalletCreditBucket = 'recharge' | 'gift'
export interface EpayChannelConfig {
channel: string
display_name: string
}
export interface EpayGatewayConfig {
provider: 'epay'
enabled: boolean
endpoint_url?: string | null
callback_base_url?: string | null
merchant_id?: string | null
has_secret: boolean
pay_currency?: string | null
usd_exchange_rate?: number | null
min_recharge_usd?: number | null
channels?: EpayChannelConfig[]
created_at?: number | null
updated_at?: number | null
}
export interface UpdateEpayGatewayConfigRequest {
enabled: boolean
endpoint_url: string
callback_base_url?: string | null
merchant_id: string
merchant_key?: string
pay_currency: string
usd_exchange_rate: number
min_recharge_usd: number
channels: EpayChannelConfig[]
}
export interface GatewayTestResponse {
ok: boolean
provider: string
}
export interface WalletCreditEntitlement {
type: 'wallet_credit'
amount_usd: number
balance_bucket?: WalletCreditBucket
}
export interface DailyQuotaEntitlement {
type: 'daily_quota'
daily_quota_usd: number
reset_timezone?: string
carry_over?: boolean
allow_wallet_overage?: boolean
}
export interface MembershipGroupEntitlement {
type: 'membership_group'
grant_user_groups: string[]
}
export type BillingEntitlement =
| WalletCreditEntitlement
| DailyQuotaEntitlement
| MembershipGroupEntitlement
export interface BillingPlan {
id: string
title: string
description?: string | null
price_amount: number
price_currency: string
duration_unit: BillingDurationUnit
duration_value: number
enabled: boolean
sort_order: number
max_active_per_user: number
purchase_limit_scope: BillingPurchaseLimitScope
entitlements: BillingEntitlement[]
created_at?: number | null
updated_at?: number | null
}
export interface BillingPlanWriteRequest {
title: string
description?: string | null
price_amount: number
price_currency: string
duration_unit: BillingDurationUnit
duration_value: number
enabled: boolean
sort_order: number
max_active_per_user: number
purchase_limit_scope: BillingPurchaseLimitScope
entitlements: BillingEntitlement[]
}
export interface BillingPlanListResponse {
items: BillingPlan[]
total: number
}
export interface BillingCheckoutRequest {
payment_method?: string
payment_provider?: string
payment_channel?: string
}
export interface BillingCheckoutResponse {
order: PaymentOrder & {
order_kind?: string
product_id?: string | null
product?: BillingPlan | null
}
payment_instructions: Record<string, unknown>
}
export interface UserPlanEntitlement {
id: string
user_id: string
plan_id: string
payment_order_id: string
status: string
starts_at: string | null
expires_at: string | null
entitlements: BillingEntitlement[]
active?: boolean
created_at?: string | null
updated_at?: string | null
}
export interface UserPlanEntitlementsResponse {
items: UserPlanEntitlement[]
total: number
}
function normalizeChannels(channels: EpayGatewayConfig['channels']): EpayChannelConfig[] {
return Array.isArray(channels)
? channels
.map((item) => {
const raw = item as EpayChannelConfig & { type?: string }
const channel = String(raw.channel || raw.type || '').trim()
return {
channel,
display_name: String(raw.display_name || channel).trim(),
}
})
.filter((item) => item.channel && item.display_name)
: []
}
function normalizeGatewayConfig(config: EpayGatewayConfig): EpayGatewayConfig {
return {
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),
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),
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')
return normalizeGatewayConfig(response.data)
},
async update(payload: UpdateEpayGatewayConfigRequest): Promise<EpayGatewayConfig> {
const request: UpdateEpayGatewayConfigRequest = {
...payload,
channels: normalizeChannels(payload.channels),
}
const response = await apiClient.put<EpayGatewayConfig>('/api/admin/payments/gateways/epay', request)
return normalizeGatewayConfig(response.data)
},
async test(): Promise<GatewayTestResponse> {
const response = await apiClient.post<GatewayTestResponse>('/api/admin/payments/gateways/epay/test', {})
return response.data
},
}
export const adminBillingPlansApi = {
async list(): Promise<BillingPlanListResponse> {
const response = await apiClient.get<BillingPlanListResponse>('/api/admin/billing/plans')
return response.data
},
async create(payload: BillingPlanWriteRequest): Promise<BillingPlan> {
const response = await apiClient.post<BillingPlan>('/api/admin/billing/plans', payload)
return response.data
},
async update(planId: string, payload: BillingPlanWriteRequest): Promise<BillingPlan> {
const response = await apiClient.put<BillingPlan>(`/api/admin/billing/plans/${planId}`, payload)
return response.data
},
async setStatus(planId: string, enabled: boolean): Promise<BillingPlan> {
const response = await apiClient.patch<BillingPlan>(`/api/admin/billing/plans/${planId}/status`, { enabled })
return response.data
},
async delete(planId: string): Promise<void> {
await apiClient.delete(`/api/admin/billing/plans/${planId}`)
},
}
export const billingApi = {
async listPlans(): Promise<BillingPlanListResponse> {
const response = await apiClient.get<BillingPlanListResponse>('/api/billing/plans')
return response.data
},
async checkout(planId: string, payload: BillingCheckoutRequest): Promise<BillingCheckoutResponse> {
const response = await apiClient.post<BillingCheckoutResponse>(
`/api/billing/plans/${planId}/checkout`,
payload
)
return response.data
},
async listEntitlements(): Promise<UserPlanEntitlementsResponse> {
const response = await apiClient.get<UserPlanEntitlementsResponse>('/api/billing/entitlements')
return response.data
},
}

View File

@@ -1,6 +1,7 @@
import apiClient from './client'
import { cachedRequest } from '@/utils/cache'
import type { UserSession as SessionRecord } from '@/types/session'
import type { BillingPlan, UserPlanEntitlement } from './billing'
export type UserRole = 'admin' | 'user'
export type ListPolicyMode = 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
@@ -233,6 +234,26 @@ export interface UpsertUserApiKeyRequest {
export type UserSession = SessionRecord
export interface AdminUserPlanEntitlement extends UserPlanEntitlement {
plan_title?: string | null
plan?: BillingPlan | null
}
export interface AdminUserPlanEntitlementsResponse {
items: AdminUserPlanEntitlement[]
total: number
}
export interface GrantUserPlanRequest {
plan_id: string
reason?: string | null
}
export interface GrantUserPlanResponse extends AdminUserPlanEntitlementsResponse {
order?: Record<string, unknown>
credited?: boolean
}
export interface GetAllUsersOptions {
search?: string
role?: UserRole
@@ -367,6 +388,24 @@ export const usersApi = {
return response.data
},
async listUserPlanEntitlements(userId: string): Promise<AdminUserPlanEntitlementsResponse> {
const response = await apiClient.get<AdminUserPlanEntitlementsResponse>(
`/api/admin/users/${userId}/billing/entitlements`
)
return response.data
},
async grantUserPlan(
userId: string,
payload: GrantUserPlanRequest
): Promise<GrantUserPlanResponse> {
const response = await apiClient.post<GrantUserPlanResponse>(
`/api/admin/users/${userId}/billing/grant-plan`,
payload
)
return response.data
},
async revokeUserSession(userId: string, sessionId: string): Promise<{ message: string }> {
const response = await apiClient.delete<{ message: string }>(`/api/admin/users/${userId}/sessions/${sessionId}`)
return response.data

View File

@@ -2,7 +2,7 @@ import apiClient from './client'
export interface WalletSummary {
id: string
// balance = 可用余额(充值余额 + 赠款余额)
// balance = 钱包可用余额(充值余额 + 赠款余额),不包含套餐每日额度
balance: number
recharge_balance: number
gift_balance: number
@@ -18,15 +18,28 @@ export interface WalletSummary {
updated_at: string
}
export interface WalletDailyQuotaSummary {
has_active: boolean
total_usd: number
used_usd: number
remaining_usd: number
allow_wallet_overage: boolean
}
export interface WalletBalanceResponse {
wallet: WalletSummary | null
unlimited: boolean
limit_mode: 'finite' | 'unlimited'
// balance = 可用余额(充值余额 + 赠款余额)
// balance = 钱包可用余额(充值余额 + 赠款余额),不包含套餐每日额度
balance: number | null
recharge_balance?: number | null
gift_balance?: number | null
refundable_balance?: number | null
wallet_balance?: number | null
package_balance?: number | null
total_available_balance?: number | null
daily_quota?: WalletDailyQuotaSummary | null
deduction_order?: string[]
currency: string
pending_refund_count?: number
}
@@ -102,6 +115,13 @@ export interface PaymentOrder {
refunded_amount_usd: number
refundable_amount_usd: number
payment_method: string
payment_provider?: string | null
payment_channel?: string | null
order_kind?: 'wallet_recharge' | 'plan_purchase' | string
product_id?: string | null
product_snapshot?: Record<string, unknown> | null
fulfillment_status?: string | null
fulfillment_error?: string | null
gateway_order_id: string | null
gateway_response: Record<string, unknown> | null
status: string
@@ -135,11 +155,24 @@ export interface RefundRequest {
export interface WalletRechargeCreateRequest {
amount_usd: number
payment_method: string
payment_provider?: string
payment_channel?: string
pay_amount?: number
pay_currency?: string
exchange_rate?: number
}
export interface WalletRechargeOption {
payment_method: string
display_name: string
provider?: string
payment_provider?: string
payment_channel?: string
pay_currency?: string
usd_exchange_rate?: number
min_recharge_usd?: number
}
export interface WalletRefundCreateRequest {
amount_usd: number
payment_order_id?: string
@@ -190,6 +223,11 @@ export const walletApi = {
return response.data
},
async listRechargeOptions(): Promise<{ items: WalletRechargeOption[] }> {
const response = await apiClient.get<{ items: WalletRechargeOption[] }>('/api/wallet/recharge/options')
return response.data
},
async listRechargeOrders(params?: { limit?: number; offset?: number }): Promise<{
items: PaymentOrder[]
total: number

View File

@@ -23,7 +23,7 @@ const props = withDefaults(defineProps<Props>(), {
const contentClass = computed(() =>
cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'z-[220] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
)

View File

@@ -51,16 +51,41 @@
<div class="p-4 sm:p-6 space-y-5">
<div class="rounded-2xl border border-border/60 bg-muted/30 p-4">
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="rounded-xl bg-background/80 p-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
总可用
总可用额
</div>
<div
class="mt-1 text-lg font-semibold"
:class="localWallet.balance < 0 ? 'text-rose-600' : 'text-foreground'"
:class="totalAvailableAmount !== null && totalAvailableAmount < 0 ? 'text-rose-600' : 'text-foreground'"
>
${{ formatFixed(localWallet.balance, 2) }}
{{ totalAvailableAmount === null ? '不限额' : `$${formatFixed(totalAvailableAmount, 2)}` }}
</div>
</div>
<div class="rounded-xl bg-background/80 p-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
套餐今日额度
</div>
<div class="mt-1 text-lg font-semibold text-foreground">
{{ isApiKeyWallet ? '不适用' : `$${formatFixed(packageBalanceAmount, 2)}` }}
</div>
<div
v-if="dailyQuota?.has_active"
class="mt-1 text-[11px] text-muted-foreground"
>
已用 ${{ formatFixed(dailyQuota.used_usd, 2) }} / ${{ formatFixed(dailyQuota.total_usd, 2) }}
</div>
</div>
<div class="rounded-xl bg-background/80 p-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
钱包余额
</div>
<div
class="mt-1 text-lg font-semibold"
:class="walletBalanceAmount < 0 ? 'text-rose-600' : 'text-foreground'"
>
${{ formatFixed(walletBalanceAmount, 2) }}
</div>
</div>
<div class="rounded-xl bg-background/80 p-3">
@@ -88,6 +113,12 @@
</div>
</div>
</div>
<p
v-if="!isApiKeyWallet"
class="mt-3 text-xs text-muted-foreground"
>
实际扣费顺序为套餐每日额度充值余额赠款余额套餐额度不通过资金操作调整请在用户套餐中发放或替换
</p>
</div>
<Tabs v-model="activeTab">
@@ -583,6 +614,17 @@ const accentClasses = computed(() => {
return props.accent === 'blue' ? 'bg-blue-500/10 text-blue-600' : 'bg-emerald-500/10 text-emerald-600'
})
const isApiKeyWallet = computed(() => localWallet.value?.owner_type === 'api_key')
const dailyQuota = computed(() => localWallet.value?.daily_quota ?? null)
const packageBalanceAmount = computed(() => toFiniteNumber(localWallet.value?.package_balance, 0))
const walletBalanceAmount = computed(() => toFiniteNumber(localWallet.value?.wallet_balance ?? localWallet.value?.balance, 0))
const totalAvailableAmount = computed(() => {
if (!localWallet.value) return 0
if (localWallet.value.unlimited || localWallet.value.total_available_balance === null) return null
return toFiniteNumber(
localWallet.value.total_available_balance,
walletBalanceAmount.value + packageBalanceAmount.value
)
})
const showRefunds = computed(() => props.showRefunds)
const tabsListClass = computed(() => {
return [
@@ -751,7 +793,7 @@ async function submitRecharge() {
const totalAfter = totalBefore + actionAmount.value
const confirmed = await confirm({
title: '确认人工充值',
message: `将为 ${props.ownerName || '该钱包'} 充值 **$${formatFixed(actionAmount.value, 4)}**\n该账户**充值余额**将从 **$${formatFixed(rechargeBefore, 4)}** 变为 **$${formatFixed(rechargeAfter, 4)}****总可用余额**将从 **$${formatFixed(totalBefore, 4)}** 变为 **$${formatFixed(totalAfter, 4)}**`,
message: `将为 ${props.ownerName || '该钱包'} 充值 **$${formatFixed(actionAmount.value, 4)}**\n该账户**充值余额**将从 **$${formatFixed(rechargeBefore, 4)}** 变为 **$${formatFixed(rechargeAfter, 4)}****钱包余额**将从 **$${formatFixed(totalBefore, 4)}** 变为 **$${formatFixed(totalAfter, 4)}**`,
confirmText: '确认充值',
variant: 'warning',
})
@@ -857,7 +899,7 @@ async function submitAdjust() {
: `该账户**${balanceTypeLabel}**将从 **$${formatFixed(currentBucketBalance, 4)}** 变为 **$${formatFixed(afterBalance, 4)}**`
const confirmed = await confirm({
title: '确认钱包调账',
message: `将对 ${props.ownerName || '该钱包'} 的**${balanceTypeLabel}**${actionAmount.value > 0 ? '增加' : '扣减'} **$${formatFixed(Math.abs(actionAmount.value), 4)}**\n${detailLine}**总可用余额**将从 **$${formatFixed(totalBefore, 4)}** 变为 **$${formatFixed(totalAfter, 4)}**`,
message: `将对 ${props.ownerName || '该钱包'} 的**${balanceTypeLabel}**${actionAmount.value > 0 ? '增加' : '扣减'} **$${formatFixed(Math.abs(actionAmount.value), 4)}**\n${detailLine}**钱包余额**将从 **$${formatFixed(totalBefore, 4)}** 变为 **$${formatFixed(totalAfter, 4)}**`,
confirmText: '确认调账',
variant: 'warning',
})

View File

@@ -391,6 +391,8 @@ import {
ChevronRight,
Megaphone,
Wallet,
CreditCard,
Package,
Menu,
X,
Puzzle,
@@ -637,8 +639,8 @@ const navigation = computed(() => {
title: '账户',
items: [
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
{ name: '套餐中心', href: '/dashboard/billing', icon: Package },
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 },
{ name: '异步任务', href: '/dashboard/async-tasks', icon: Zap },
]
}
]
@@ -697,6 +699,8 @@ const navigation = computed(() => {
{ name: '号池管理', href: '/admin/pool', icon: Database },
{ name: '独立密钥', href: '/admin/keys', icon: Key },
{ name: '钱包管理', href: '/admin/wallets', icon: Wallet },
{ name: '支付配置', href: '/admin/payment-gateways', icon: CreditCard },
{ name: '套餐管理', href: '/admin/billing-plans', icon: Package },
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
]

View File

@@ -127,6 +127,11 @@ const routes: RouteRecordRaw[] = [
name: 'WalletCenter',
component: () => importWithRetry(() => import('@/views/user/WalletCenter.vue'))
},
{
path: 'billing',
name: 'BillingPlans',
component: () => importWithRetry(() => import('@/views/user/BillingPlans.vue'))
},
{
path: 'models',
name: 'ModelCatalog',
@@ -164,6 +169,16 @@ const routes: RouteRecordRaw[] = [
name: 'WalletsManagement',
component: () => importWithRetry(() => import('@/views/admin/WalletsManagement.vue'))
},
{
path: 'payment-gateways',
name: 'PaymentGatewaySettings',
component: () => importWithRetry(() => import('@/views/admin/PaymentGatewaySettings.vue'))
},
{
path: 'billing-plans',
name: 'BillingPlansManagement',
component: () => importWithRetry(() => import('@/views/admin/BillingPlansManagement.vue'))
},
{
path: 'management-tokens',
name: 'AdminManagementTokens',

View File

@@ -16,6 +16,9 @@ import {
type UserGroupMember,
type UpsertUserGroupRequest,
type ListUserGroupsResponse,
type AdminUserPlanEntitlementsResponse,
type GrantUserPlanRequest,
type GrantUserPlanResponse,
} from '@/api/users'
import { parseApiError } from '@/utils/errorParser'
@@ -248,6 +251,29 @@ export const useUsersStore = defineStore('users', () => {
}
}
async function listUserPlanEntitlements(
userId: string,
): Promise<AdminUserPlanEntitlementsResponse> {
try {
return await usersApi.listUserPlanEntitlements(userId)
} catch (err: unknown) {
error.value = parseApiError(err, '获取用户套餐失败')
throw err
}
}
async function grantUserPlan(
userId: string,
payload: GrantUserPlanRequest,
): Promise<GrantUserPlanResponse> {
try {
return await usersApi.grantUserPlan(userId, payload)
} catch (err: unknown) {
error.value = parseApiError(err, '发放用户套餐失败')
throw err
}
}
async function revokeUserSession(userId: string, sessionId: string): Promise<{ message: string }> {
try {
return await usersApi.revokeUserSession(userId, sessionId)
@@ -291,6 +317,8 @@ export const useUsersStore = defineStore('users', () => {
deleteApiKey,
getFullApiKey,
getUserSessions,
listUserPlanEntitlements,
grantUserPlan,
revokeUserSession,
revokeAllUserSessions,
}

View File

@@ -1,5 +1,6 @@
import { adminWalletApi } from '@/api/admin-wallets'
import { adminApi } from '@/api/admin'
import { adminBillingPlansApi, epayGatewayApi } from '@/api/billing'
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getPoolOverview, getPoolSchedulingPresets, listPoolKeys } from '@/api/endpoints/pool'
import { listGlobalModels } from '@/api/global-models'
@@ -71,6 +72,18 @@ const adminRouteWarmers: Record<string, () => Promise<void>> = {
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
)
},
'/admin/payment-gateways': async () => {
await Promise.allSettled([
import('@/views/admin/PaymentGatewaySettings.vue'),
epayGatewayApi.get(),
])
},
'/admin/billing-plans': async () => {
await Promise.allSettled([
import('@/views/admin/BillingPlansManagement.vue'),
adminBillingPlansApi.list(),
])
},
}
export function prefetchAdminNavigationTarget(href: string): void {

View File

@@ -71,6 +71,8 @@ export function paymentMethodLabel(method: string | null | undefined): string {
const labels: Record<string, string> = {
alipay: '支付宝',
wechat: '微信支付',
wxpay: '微信支付',
epay: '易支付',
admin_manual: '人工充值',
card_code: '充值卡',
gift_code: '礼品卡',

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,387 @@
<template>
<PageContainer>
<PageHeader
title="支付配置"
description="配置易支付商户、回调地址、汇率和可用通道"
>
<template #actions>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
:disabled="testing || loading"
@click="testGateway"
>
<PlugZap class="mr-2 h-4 w-4" />
{{ testing ? '测试中...' : '测试配置' }}
</Button>
<Button
size="sm"
:disabled="saving || loading"
@click="saveConfig"
>
<Save class="mr-2 h-4 w-4" />
{{ saving ? '保存中...' : '保存' }}
</Button>
</div>
</template>
</PageHeader>
<div class="mt-6 space-y-6">
<div
v-if="loading"
class="py-16"
>
<LoadingState message="正在加载支付配置..." />
</div>
<template v-else>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<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.enabled ? 'success' : 'secondary'">
{{ form.enabled ? '已启用' : '未启用' }}
</Badge>
<Switch v-model="form.enabled" />
</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'">
{{ hasSecret ? '已保存' : '未设置' }}
</Badge>
</div>
</Card>
<Card class="p-5">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
汇率
</div>
<div class="mt-2 text-2xl font-semibold tabular-nums">
1 USD = {{ Number(form.usd_exchange_rate || 0).toFixed(4) }} {{ form.pay_currency }}
</div>
</Card>
</div>
<CardSection
title="易支付商户"
description="密钥留空会保留原密钥;回调地址留空时后端会使用当前 API 访问地址,生产环境建议显式填写公网根地址"
>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<div class="space-y-1.5">
<Label for="epay-endpoint">易支付接口地址</Label>
<Input
id="epay-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>
<Input
id="epay-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 ? '(留空保持不变)' : '' }}
</span>
</Label>
<Input
id="epay-merchant-key"
v-model="form.merchant_key"
masked
:placeholder="hasSecret ? '已设置,输入新密钥后覆盖' : '请输入商户密钥'"
/>
</div>
</div>
</CardSection>
<CardSection
title="计费参数"
description="用户充值按美元金额下单,易支付按这里的币种和汇率收款"
>
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
<div class="space-y-1.5">
<Label for="epay-currency">支付币种</Label>
<Input
id="epay-currency"
v-model="form.pay_currency"
maxlength="16"
placeholder="CNY"
/>
</div>
<div class="space-y-1.5">
<Label for="epay-rate">USD 汇率</Label>
<Input
id="epay-rate"
v-model.number="form.usd_exchange_rate"
type="number"
min="0.0001"
step="0.0001"
/>
</div>
<div class="space-y-1.5">
<Label for="epay-min">最低充值金额 (USD)</Label>
<Input
id="epay-min"
v-model.number="form.min_recharge_usd"
type="number"
min="0.01"
step="0.01"
/>
</div>
</div>
</CardSection>
<CardSection
title="支付通道"
description="通道值会传给易支付 type 字段"
>
<template #actions>
<Button
variant="outline"
size="sm"
@click="addChannel"
>
<Plus class="mr-2 h-4 w-4" />
添加通道
</Button>
</template>
<div class="space-y-3">
<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]"
>
<div class="space-y-1.5">
<Label :for="`epay-channel-${index}`">通道值</Label>
<Input
:id="`epay-channel-${index}`"
v-model="channel.channel"
placeholder="alipay"
/>
</div>
<div class="space-y-1.5">
<Label :for="`epay-channel-name-${index}`">显示名称</Label>
<Input
:id="`epay-channel-name-${index}`"
v-model="channel.display_name"
placeholder="支付宝"
/>
</div>
<div class="flex items-end">
<Button
variant="ghost"
size="icon"
title="移除通道"
:disabled="form.channels.length <= 1"
@click="removeChannel(index)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardSection>
<p
v-if="updatedAtText"
class="text-xs text-muted-foreground"
>
最后更新{{ updatedAtText }}
</p>
</template>
</div>
</PageContainer>
</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 {
Badge,
Button,
Card,
Input,
Label,
Switch,
} from '@/components/ui'
import { LoadingState } 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'
const { success, error: showError } = useToast()
const loading = ref(true)
const saving = ref(false)
const testing = ref(false)
const hasSecret = ref(false)
const updatedAt = ref<number | 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[],
})
const updatedAtText = computed(() => {
if (!updatedAt.value) return ''
return new Date(updatedAt.value * 1000).toLocaleString('zh-CN')
})
const defaultCallbackBaseUrl = computed(() => {
if (typeof window === 'undefined') return ''
return window.location.origin
})
onMounted(() => {
void loadConfig()
})
async function loadConfig() {
loading.value = true
try {
const config = await epayGatewayApi.get()
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.channels = config.channels?.length
? config.channels.map((item) => ({ ...item }))
: [
{ channel: 'alipay', display_name: '支付宝' },
{ channel: 'wxpay', display_name: '微信支付' },
]
hasSecret.value = config.has_secret
updatedAt.value = config.updated_at ?? null
} catch (err) {
log.error('加载易支付配置失败:', err)
showError(parseApiError(err, '加载易支付配置失败'))
} finally {
loading.value = false
}
}
function normalizeChannels(): EpayChannelConfig[] {
return form.channels
.map((item) => ({
channel: item.channel.trim(),
display_name: item.display_name.trim(),
}))
.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 (!form.pay_currency.trim()) return '请输入支付币种'
if (!Number.isFinite(Number(form.usd_exchange_rate)) || Number(form.usd_exchange_rate) <= 0) {
return 'USD 汇率必须大于 0'
}
if (!Number.isFinite(Number(form.min_recharge_usd)) || Number(form.min_recharge_usd) <= 0) {
return '最低充值金额必须大于 0'
}
if (normalizeChannels().length === 0) return '至少需要一个支付通道'
return null
}
async function saveConfig() {
const validationError = validateForm()
if (validationError) {
showError(validationError)
return
}
saving.value = true
try {
const callbackBaseUrl = form.callback_base_url.trim()
const payload = {
enabled: form.enabled,
endpoint_url: form.endpoint_url.trim(),
callback_base_url: callbackBaseUrl || null,
merchant_id: form.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() } : {}),
}
const config = await epayGatewayApi.update(payload)
hasSecret.value = config.has_secret
updatedAt.value = config.updated_at ?? null
form.callback_base_url = config.callback_base_url || ''
form.merchant_key = ''
success('支付配置已保存')
} catch (err) {
log.error('保存易支付配置失败:', err)
showError(parseApiError(err, '保存易支付配置失败'))
} finally {
saving.value = false
}
}
async function testGateway() {
testing.value = true
try {
await epayGatewayApi.test()
success('易支付配置可用')
} catch (err) {
log.error('测试易支付配置失败:', err)
showError(parseApiError(err, '测试易支付配置失败'))
} finally {
testing.value = false
}
}
function addChannel() {
form.channels.push({ channel: '', display_name: '' })
}
function removeChannel(index: number) {
if (form.channels.length <= 1) return
form.channels.splice(index, 1)
}
</script>

View File

@@ -337,7 +337,7 @@
/>
</template>
</SortableTableHead>
<TableHead class="w-[220px] h-12 font-semibold text-center">
<TableHead class="w-[260px] h-12 font-semibold text-center">
操作
</TableHead>
</TableRow>
@@ -402,7 +402,7 @@
<TableCell class="py-4">
<div class="space-y-1.5">
<div class="flex items-center gap-1 text-[11px] text-muted-foreground">
<span>余额</span>
<span>总可用</span>
<Badge
v-if="isUserUnlimited(user)"
variant="secondary"
@@ -418,6 +418,13 @@
{{ formatCurrencyValue(getUserWalletTotalBalance(user), '-') }}
</span>
</div>
<div
v-if="!isUserUnlimited(user) && getUserWallet(user.id)"
class="text-[11px] text-muted-foreground"
>
套餐 {{ formatCurrencyValue(getUserPackageBalance(user), '$0.00') }}
· 钱包 {{ formatCurrencyValue(getUserWalletBalance(user), '$0.00') }}
</div>
<div class="flex items-center gap-2 text-[11px] text-muted-foreground flex-wrap">
<span>
已消费
@@ -498,6 +505,15 @@
</Button>
<Button
v-if="authStore.canOperateAdmin"
variant="ghost"
size="icon"
class="h-8 w-8"
title="套餐"
@click="manageUserPlans(user)"
>
<PackageCheck class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@@ -651,7 +667,7 @@
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-[11px] text-muted-foreground">
余额
总可用
</p>
<Badge
v-if="isUserUnlimited(user)"
@@ -667,6 +683,13 @@
>
{{ formatCurrencyValue(getUserWalletTotalBalance(user), '-') }}
</p>
<p
v-if="!isUserUnlimited(user) && getUserWallet(user.id)"
class="text-[11px] text-muted-foreground"
>
套餐 {{ formatCurrencyValue(getUserPackageBalance(user), '$0.00') }}
· 钱包 {{ formatCurrencyValue(getUserWalletBalance(user), '$0.00') }}
</p>
</div>
<div class="text-right">
<p class="text-[11px] text-muted-foreground">
@@ -728,6 +751,15 @@
</Button>
<Button
v-if="authStore.canOperateAdmin"
variant="outline"
size="sm"
class="h-8 text-xs"
@click="manageUserPlans(user)"
>
<PackageCheck class="mr-1.5 h-3.5 w-3.5" />
套餐
</Button>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@@ -816,6 +848,172 @@
@changed="handleUserGroupsChanged"
/>
<Dialog
v-model="showUserPlansDialog"
size="xl"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-kraft/10">
<PackageCheck class="h-5 w-5 text-kraft" />
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold leading-tight text-foreground">
用户套餐
</h3>
<p class="text-xs text-muted-foreground">
{{ selectedUser?.username || '-' }} · 查看当前套餐并手动发放
</p>
</div>
</div>
</div>
</template>
<div class="max-h-[64vh] space-y-4 overflow-y-auto">
<div class="rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-100/90">
后台发放会立即生效如果新套餐包含每日额度或会员权益用户已有的同类旧套餐会自动失效
</div>
<section class="space-y-2.5">
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-semibold text-foreground">
当前有效套餐
</h4>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[11px]"
:disabled="loadingUserPlans || !selectedUser"
@click="selectedUser && loadUserPlanEntitlements(selectedUser.id)"
>
{{ loadingUserPlans ? '加载中...' : '刷新' }}
</Button>
</div>
<div
v-if="loadingUserPlans"
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
>
正在加载用户套餐...
</div>
<div
v-else-if="userPlanEntitlements.length === 0"
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
>
当前没有有效套餐
</div>
<div
v-else
class="space-y-2.5"
>
<div
v-for="item in userPlanEntitlements"
:key="item.id"
class="rounded-lg border border-border bg-card/80 p-3"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-foreground">
{{ item.plan_title || item.plan?.title || item.plan_id }}
</span>
<Badge
:variant="item.active ? 'success' : 'secondary'"
class="h-5 px-1.5 py-0 text-[10px]"
>
{{ item.active ? '生效中' : item.status }}
</Badge>
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<Badge
v-for="label in entitlementLabels(item.entitlements)"
:key="label"
variant="outline"
class="h-5 px-1.5 py-0 text-[10px]"
>
{{ label }}
</Badge>
</div>
</div>
<div class="text-left text-[11px] text-muted-foreground sm:text-right">
<div>开始{{ formatDateTime(item.starts_at) }}</div>
<div>到期{{ formatDateTime(item.expires_at) }}</div>
</div>
</div>
</div>
</div>
</section>
<section class="space-y-3 rounded-lg border border-border bg-card/70 p-4">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-foreground">
发放套餐
</h4>
<p class="text-xs text-muted-foreground">
仅发放套餐权益不产生用户付款同类旧套餐会按现有规则自动替换
</p>
</div>
<Select v-model="selectedGrantPlanId">
<SelectTrigger
class="h-9 rounded-md bg-muted/50 px-3"
:disabled="loadingBillingPlans || grantableBillingPlans.length === 0"
>
<SelectValue :placeholder="loadingBillingPlans ? '加载套餐中...' : '选择要发放的套餐'" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="plan in grantableBillingPlans"
:key="plan.id"
:value="plan.id"
>
<div class="flex min-w-0 items-center gap-2">
<span class="truncate">{{ plan.title }}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{{ formatPlanPrice(plan) }} · {{ formatPlanDuration(plan) }}
</span>
<span
v-if="!plan.enabled"
class="shrink-0 text-[10px] text-amber-400"
>
已下架
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Textarea
v-model="grantReason"
class="min-h-[60px] resize-y rounded-md bg-muted/50 text-sm"
maxlength="512"
placeholder="备注(可选,例如:人工补偿、活动赠送)"
/>
<div class="flex justify-end">
<Button
size="sm"
:disabled="grantingUserPlan || !selectedUser || !selectedGrantPlanId"
@click="grantPlanToSelectedUser"
>
{{ grantingUserPlan ? '发放中...' : '发放套餐' }}
</Button>
</div>
</section>
</div>
<template #footer>
<Button
variant="outline"
class="h-10 px-5"
@click="showUserPlansDialog = false"
>
关闭
</Button>
</template>
</Dialog>
<!-- API Keys 管理对话框 -->
<Dialog
v-model="showApiKeysDialog"
@@ -1254,9 +1452,10 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useUsersStore } from '@/stores/users'
import { useAuthStore } from '@/stores/auth'
import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters, UserGroup } from '@/api/users'
import { usersApi, type User, type ApiKey, type UserSession, type UserBatchActionResponse, type UserBatchSelectionFilters, type UserGroup, type AdminUserPlanEntitlement } from '@/api/users'
import { formatSessionMeta } from '@/types/session'
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
import { adminBillingPlansApi, type BillingEntitlement, type BillingPlan } from '@/api/billing'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
@@ -1271,6 +1470,7 @@ import {
Badge,
Input,
Label,
Textarea,
Select,
SelectTrigger,
SelectValue,
@@ -1307,6 +1507,7 @@ import {
LockOpen,
MonitorSmartphone,
FolderKanban,
PackageCheck,
} from 'lucide-vue-next'
// 功能组件
@@ -1338,14 +1539,22 @@ const userFormDialogRef = ref<InstanceType<typeof UserFormDialog>>()
// API Keys 对话框状态
const showApiKeysDialog = ref(false)
const showUserSessionsDialog = ref(false)
const showUserPlansDialog = ref(false)
const showNewApiKeyDialog = ref(false)
const showUserApiKeyFormDialog = ref(false)
const selectedUser = ref<User | null>(null)
const userApiKeys = ref<ApiKey[]>([])
const userSessions = ref<UserSession[]>([])
const userPlanEntitlements = ref<AdminUserPlanEntitlement[]>([])
const availableBillingPlans = ref<BillingPlan[]>([])
const selectedGrantPlanId = ref('')
const grantReason = ref('')
const newApiKey = ref('')
const creatingApiKey = ref(false)
const loadingUserSessions = ref(false)
const loadingUserPlans = ref(false)
const loadingBillingPlans = ref(false)
const grantingUserPlan = ref(false)
const sessionDialogActionLoading = ref<string | null>(null)
const apiKeyInput = ref<HTMLInputElement>()
const editingUserApiKey = ref<ApiKey | null>(null)
@@ -1464,6 +1673,10 @@ const batchSelectionFilters = computed<UserBatchSelectionFilters>(() => {
return filters
})
const grantableBillingPlans = computed(() =>
availableBillingPlans.value.filter((plan) => hasPackageEntitlement(plan.entitlements))
)
// Watch filter changes and reset to first page
watch([searchQuery, filterRole, filterStatus, filterGroup], () => {
currentPage.value = 1
@@ -1527,6 +1740,51 @@ function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('zh-CN')
}
function formatDateTime(value?: string | null): string {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function formatPlanPrice(plan: BillingPlan): string {
return `${Number(plan.price_amount || 0).toFixed(2)} ${plan.price_currency || 'CNY'}`
}
function formatPlanDuration(plan: BillingPlan): string {
const labels: Record<string, string> = {
day: '天',
month: '个月',
year: '年',
custom: '天',
}
const unit = labels[plan.duration_unit] || '天'
return `${Number(plan.duration_value || 1)}${unit}`
}
function entitlementLabels(items: BillingEntitlement[] | undefined): string[] {
return (items || []).map((item) => {
if (item.type === 'wallet_credit') {
return `附赠余额 $${Number(item.amount_usd || 0).toFixed(2)}`
}
if (item.type === 'daily_quota') {
return `每日额度 $${Number(item.daily_quota_usd || 0).toFixed(2)}`
}
if (item.type === 'membership_group') {
return '会员权益'
}
return item.type
})
}
function hasPackageEntitlement(items: BillingEntitlement[] | undefined): boolean {
return (items || []).some((item) => item.type === 'daily_quota' || item.type === 'membership_group')
}
async function loadUserWallets(options: { cacheTtlMs?: number } = {}) {
const requestId = ++userWalletsRequestId
try {
@@ -1572,7 +1830,21 @@ function getUserWalletTotalBalance(user: User): number | null {
if (!wallet) {
return null
}
return wallet.balance
if (typeof wallet.total_available_balance === 'number' && Number.isFinite(wallet.total_available_balance)) {
return wallet.total_available_balance
}
return getUserWalletBalance(user) + getUserPackageBalance(user)
}
function getUserWalletBalance(user: User): number {
const wallet = getUserWallet(user.id)
const value = wallet?.wallet_balance ?? wallet?.balance ?? 0
return Number.isFinite(value) ? value : 0
}
function getUserPackageBalance(user: User): number {
const value = getUserWallet(user.id)?.package_balance ?? 0
return Number.isFinite(value) ? value : 0
}
function getUserWalletConsumed(user: User): number {
@@ -1728,6 +2000,70 @@ async function manageUserSessions(user: User) {
}
}
async function manageUserPlans(user: User) {
selectedUser.value = user
showUserPlansDialog.value = true
selectedGrantPlanId.value = ''
grantReason.value = ''
await Promise.all([
loadUserPlanEntitlements(user.id),
loadAvailableBillingPlans(),
])
if (!selectedGrantPlanId.value && grantableBillingPlans.value.length > 0) {
selectedGrantPlanId.value = grantableBillingPlans.value[0].id
}
}
async function loadUserPlanEntitlements(userId: string) {
loadingUserPlans.value = true
try {
const response = await usersApi.listUserPlanEntitlements(userId)
userPlanEntitlements.value = response.items
} catch (err) {
error(parseApiError(err, '加载用户套餐失败'))
userPlanEntitlements.value = []
} finally {
loadingUserPlans.value = false
}
}
async function loadAvailableBillingPlans() {
loadingBillingPlans.value = true
try {
const response = await adminBillingPlansApi.list()
availableBillingPlans.value = response.items
if (
selectedGrantPlanId.value
&& !response.items.some((plan) => plan.id === selectedGrantPlanId.value)
) {
selectedGrantPlanId.value = ''
}
} catch (err) {
error(parseApiError(err, '加载套餐列表失败'))
availableBillingPlans.value = []
} finally {
loadingBillingPlans.value = false
}
}
async function grantPlanToSelectedUser() {
if (!selectedUser.value || !selectedGrantPlanId.value) return
grantingUserPlan.value = true
try {
const response = await usersApi.grantUserPlan(selectedUser.value.id, {
plan_id: selectedGrantPlanId.value,
reason: grantReason.value.trim() || null,
})
userPlanEntitlements.value = response.items
grantReason.value = ''
success('套餐已发放')
} catch (err) {
error(parseApiError(err, '发放套餐失败'))
} finally {
grantingUserPlan.value = false
}
}
async function loadUserApiKeys(userId: string) {
try {
userApiKeys.value = await usersStore.getUserApiKeys(userId)

View File

@@ -0,0 +1,436 @@
<template>
<PageContainer>
<PageHeader
title="套餐中心"
description="购买每日额度或会员权益"
/>
<div class="mt-6 space-y-6">
<div
v-if="loading"
class="py-16"
>
<LoadingState message="正在加载套餐..." />
</div>
<template v-else>
<CardSection
title="当前权益"
description="只展示仍在有效期内的套餐权益"
>
<div
v-if="activeEntitlements.length"
class="grid grid-cols-1 gap-3 lg:grid-cols-2"
>
<div
v-for="item in activeEntitlements"
:key="item.id"
class="rounded-lg border border-border/60 bg-muted/20 p-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium">
{{ planTitle(item.plan_id) }}
</div>
<div class="mt-1 text-xs text-muted-foreground">
{{ formatDate(item.starts_at) }} - {{ formatDate(item.expires_at) }}
</div>
</div>
<Badge variant="success">
生效中
</Badge>
</div>
<div class="mt-3 flex flex-wrap gap-1.5">
<Badge
v-for="label in entitlementLabels(item.entitlements)"
:key="label"
variant="outline"
>
{{ label }}
</Badge>
</div>
</div>
</div>
<EmptyState
v-else
title="暂无有效套餐"
description="购买套餐后,有效权益会显示在这里"
/>
</CardSection>
<CardSection
title="可购买套餐"
description="支付成功后由回调自动发放权益"
>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card
v-for="plan in purchaseablePlans"
:key="plan.id"
class="flex flex-col p-5"
>
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-base font-semibold">
{{ plan.title }}
</h3>
<p class="mt-1 min-h-[32px] text-xs text-muted-foreground">
{{ plan.description || '标准套餐' }}
</p>
</div>
<Badge variant="outline">
{{ formatDuration(plan.duration_unit, plan.duration_value) }}
</Badge>
</div>
<div class="mt-5">
<span class="text-3xl font-semibold tabular-nums">
{{ Number(plan.price_amount || 0).toFixed(2) }}
</span>
<span class="ml-1 text-sm text-muted-foreground">
{{ plan.price_currency }}
</span>
</div>
<div class="mt-5 flex flex-wrap gap-1.5">
<Badge
v-for="label in entitlementLabels(plan.entitlements)"
:key="label"
variant="outline"
>
{{ label }}
</Badge>
</div>
<div
v-if="replacementNotice(plan)"
class="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-200"
>
{{ replacementNotice(plan) }}
</div>
<div class="mt-5 flex-1" />
<div class="mt-5 space-y-3">
<Select v-model="selectedChannel">
<SelectTrigger>
<SelectValue
:placeholder="epayOptions.length ? '选择支付通道' : '暂无可用支付通道'"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in epayOptions"
:key="`${option.payment_channel}-${option.display_name}`"
:value="option.payment_channel || ''"
>
{{ option.display_name }}
</SelectItem>
</SelectContent>
</Select>
<Button
class="w-full"
:disabled="
checkoutLoadingPlanId === plan.id
|| epayOptions.length === 0
|| !selectedChannel
"
@click="checkoutPlan(plan)"
>
<CreditCard class="mr-2 h-4 w-4" />
{{ checkoutLoadingPlanId === plan.id ? '创建订单中...' : '购买套餐' }}
</Button>
</div>
</Card>
<div
v-if="purchaseablePlans.length === 0"
class="xl:col-span-3"
>
<EmptyState
title="暂无可购买套餐"
description="管理员上架套餐后会显示在这里"
/>
</div>
</div>
</CardSection>
<Card
v-if="latestCheckout"
class="p-4"
>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div class="text-sm font-medium">
最新订单<span class="font-mono">{{ latestCheckout.order.order_no }}</span>
</div>
<div class="mt-1 text-xs text-muted-foreground">
应付 {{ latestCheckout.order.pay_amount ?? '-' }} {{ latestCheckout.order.pay_currency || '' }}
</div>
</div>
<Button
v-if="latestPaymentUrl"
variant="outline"
@click="openPaymentUrl(latestPaymentUrl)"
>
打开支付链接
</Button>
</div>
</Card>
</template>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { CreditCard } from 'lucide-vue-next'
import {
billingApi,
type BillingDurationUnit,
type BillingEntitlement,
type BillingCheckoutResponse,
type BillingPlan,
type UserPlanEntitlement,
} from '@/api/billing'
import { walletApi, type WalletRechargeOption } from '@/api/wallet'
import {
Badge,
Button,
Card,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { EmptyState, LoadingState } 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'
const { success, error: showError } = useToast()
const loading = ref(true)
const plans = ref<BillingPlan[]>([])
const entitlements = ref<UserPlanEntitlement[]>([])
const rechargeOptions = ref<WalletRechargeOption[]>([])
const selectedChannel = ref('')
const checkoutLoadingPlanId = ref<string | null>(null)
const latestCheckout = ref<BillingCheckoutResponse | null>(null)
const epayOptions = computed(() =>
rechargeOptions.value.filter((option) =>
(option.payment_provider === 'epay' || option.payment_method === 'epay')
&& Boolean(option.payment_channel)
)
)
const activeEntitlements = computed(() =>
entitlements.value.filter((item) =>
item.active !== false
&& item.status === 'active'
&& hasPackageEntitlement(item.entitlements)
)
)
const purchaseablePlans = computed(() =>
plans.value.filter((plan) => hasPackageEntitlement(plan.entitlements))
)
const latestPaymentUrl = computed(() => {
const value = latestCheckout.value?.payment_instructions?.payment_url
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] || ''
}
}, { immediate: true })
onMounted(async () => {
await Promise.all([
loadPlans(),
loadEntitlements(),
loadRechargeOptions(),
])
loading.value = false
})
async function loadPlans() {
try {
const response = await billingApi.listPlans()
plans.value = response.items
} catch (err) {
log.error('加载套餐失败:', err)
showError(parseApiError(err, '加载套餐失败'))
}
}
async function loadEntitlements() {
try {
const response = await billingApi.listEntitlements()
entitlements.value = response.items
} catch (err) {
log.error('加载套餐权益失败:', err)
showError(parseApiError(err, '加载套餐权益失败'))
}
}
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 || ''
}
} catch (err) {
log.error('加载支付通道失败:', err)
showError(parseApiError(err, '加载支付通道失败'))
}
}
async function checkoutPlan(plan: BillingPlan) {
if (hasMatchingActivePlan(plan)) {
const confirmed = window.confirm('购买成功后,同类旧套餐会自动失效。确定继续购买吗?')
if (!confirmed) return
}
checkoutLoadingPlanId.value = plan.id
try {
const response = await billingApi.checkout(plan.id, {
payment_method: 'epay',
payment_provider: 'epay',
payment_channel: selectedChannel.value,
})
latestCheckout.value = response
success('套餐订单已创建')
submitPaymentInstructions(response.payment_instructions)
} catch (err) {
log.error('创建套餐订单失败:', err)
showError(parseApiError(err, '创建套餐订单失败'))
} finally {
checkoutLoadingPlanId.value = null
}
}
function openPaymentUrl(url: string) {
submitPaymentInstructions(latestCheckout.value?.payment_instructions || { payment_url: url })
}
function submitPaymentInstructions(instructions: Record<string, unknown> | null | undefined) {
if (!instructions) return
const paymentUrl = instructions.payment_url
if (typeof paymentUrl !== 'string' || !paymentUrl) return
const paymentParams = instructions.payment_params
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>)
return
}
const opened = window.open(paymentUrl, '_blank', 'noopener,noreferrer')
if (!opened) {
window.location.href = paymentUrl
}
}
function submitPaymentForm(url: string, params: Record<string, unknown>) {
const form = document.createElement('form')
form.action = url
form.method = 'POST'
if (!isSafariBrowser()) {
form.target = '_blank'
}
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === undefined) return
const input = document.createElement('input')
input.type = 'hidden'
input.name = key
input.value = String(value)
form.appendChild(input)
})
document.body.appendChild(form)
form.submit()
document.body.removeChild(form)
}
function isSafariBrowser(): boolean {
return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome')
}
function planTitle(planId: string): string {
return plans.value.find((plan) => plan.id === planId)?.title || planId
}
function hasMatchingActivePlan(plan: BillingPlan): boolean {
const replacesDailyQuota = hasDailyQuotaEntitlement(plan.entitlements)
const replacesMembership = hasMembershipEntitlement(plan.entitlements)
if (!replacesDailyQuota && !replacesMembership) return false
return activeEntitlements.value.some((item) =>
(replacesDailyQuota && hasDailyQuotaEntitlement(item.entitlements))
|| (replacesMembership && hasMembershipEntitlement(item.entitlements))
)
}
function replacementNotice(plan: BillingPlan): string {
const labels = replacementClassLabels(plan.entitlements)
if (labels.length === 0) return ''
if (hasMatchingActivePlan(plan)) {
return `你已有有效${labels.join('和')},购买成功后旧同类套餐会自动失效。`
}
return `若已有有效${labels.join('和')},购买成功后旧同类套餐会自动失效。`
}
function entitlementLabels(items: BillingEntitlement[]): string[] {
return (items || []).map((item) => {
if (item.type === 'wallet_credit') {
return `附赠余额 $${Number(item.amount_usd || 0).toFixed(2)}`
}
if (item.type === 'daily_quota') {
return `每日 $${Number(item.daily_quota_usd || 0).toFixed(2)}`
}
if (item.type === 'membership_group') {
return `会员组 ${item.grant_user_groups.join(', ')}`
}
return item.type
})
}
function hasPackageEntitlement(items: BillingEntitlement[] | undefined): boolean {
return (items || []).some((item) =>
item.type === 'daily_quota' || item.type === 'membership_group'
)
}
function hasDailyQuotaEntitlement(items: BillingEntitlement[] | undefined): boolean {
return (items || []).some((item) => item.type === 'daily_quota')
}
function hasMembershipEntitlement(items: BillingEntitlement[] | undefined): boolean {
return (items || []).some((item) => item.type === 'membership_group')
}
function replacementClassLabels(items: BillingEntitlement[] | undefined): string[] {
const labels: string[] = []
if (hasDailyQuotaEntitlement(items)) labels.push('每日额度套餐')
if (hasMembershipEntitlement(items)) labels.push('会员权益包')
return labels
}
function formatDuration(unit: BillingDurationUnit, value: number): string {
const labels: Record<BillingDurationUnit, string> = {
day: '天',
month: '个月',
year: '年',
custom: '自定义周期',
}
return unit === 'custom' ? `${value} ${labels[unit]}` : `${value}${labels[unit]}`
}
function formatDate(value: string | null | undefined): string {
if (!value) return '-'
return new Date(value).toLocaleDateString('zh-CN')
}
</script>

View File

@@ -8,30 +8,65 @@
</div>
<template v-else>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<Card class="p-5 space-y-2">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
可用
可用额
</div>
<div class="text-3xl font-bold tabular-nums">
{{ formatCurrency(walletBalance?.balance) }}
{{ walletBalance?.unlimited ? '无限制' : formatCurrency(totalAvailableBalance) }}
</div>
<div class="text-xs text-muted-foreground">
充值余额: {{ formatCurrency(walletBalance?.wallet?.recharge_balance) }} · 赠款余额: {{ formatCurrency(walletBalance?.wallet?.gift_balance) }}
套餐额度: {{ formatCurrency(packageBalance) }} · 钱包余额: {{ formatCurrency(walletOnlyBalance) }}
</div>
</Card>
<Card class="p-5 space-y-3">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
套餐今日额度
</div>
<div class="text-2xl font-bold tabular-nums">
<template v-if="hasActiveDailyQuota">
{{ formatCurrency(packageBalance) }}
</template>
<template v-else>
未开通
</template>
</div>
<div
v-if="hasActiveDailyQuota"
class="space-y-1.5"
>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all"
:style="{ width: `${dailyQuotaRemainingPercent}%` }"
/>
</div>
<div class="text-xs text-muted-foreground">
已用 {{ formatCurrency(dailyQuotaUsed) }} / 每日 {{ formatCurrency(dailyQuotaTotal) }}
</div>
<div class="text-xs text-muted-foreground">
{{ dailyQuota?.allow_wallet_overage ? '套餐不足时继续扣钱包余额' : '套餐额度不足时会拒绝请求' }}
</div>
</div>
<div
v-else
class="text-xs text-muted-foreground"
>
开通每日额度套餐后会优先消耗这里的额度
</div>
</Card>
<Card class="p-5 space-y-2">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
累计充值 / 消费
钱包余额
</div>
<div class="text-lg font-semibold tabular-nums">
{{ formatCurrency(walletBalance?.wallet?.total_recharged) }}
<span class="text-muted-foreground font-normal mx-1">/</span>
{{ formatCurrency(walletBalance?.wallet?.total_consumed) }}
<div class="text-2xl font-semibold tabular-nums">
{{ formatCurrency(walletOnlyBalance) }}
</div>
<div class="text-xs text-muted-foreground">
累计退款: {{ formatCurrency(walletBalance?.wallet?.total_refunded) }} · 可退款余额: {{ formatCurrency(walletBalance?.wallet?.refundable_balance) }}
充值余额: {{ formatCurrency(walletBalance?.wallet?.recharge_balance) }} · 款余额: {{ formatCurrency(walletBalance?.wallet?.gift_balance) }}
</div>
</Card>
@@ -44,6 +79,15 @@
{{ walletStatusLabel(walletBalance?.wallet?.status) }}
</Badge>
</div>
<div class="text-xs text-muted-foreground">
累计充值 / 消费:
{{ formatCurrency(walletBalance?.wallet?.total_recharged) }}
<span class="text-muted-foreground font-normal mx-1">/</span>
{{ formatCurrency(walletBalance?.wallet?.total_consumed) }}
</div>
<div class="text-xs text-muted-foreground">
累计退款: {{ formatCurrency(walletBalance?.wallet?.total_refunded) }} · 可退款余额: {{ formatCurrency(walletBalance?.wallet?.refundable_balance) }}
</div>
<div
v-if="walletBalance?.unlimited"
class="text-xs text-amber-600 dark:text-amber-400"
@@ -132,25 +176,47 @@
<div class="space-y-1.5">
<Label>支付方式</Label>
<Select v-model="rechargeForm.payment_method">
<Select v-model="rechargeForm.payment_option_key">
<SelectTrigger>
<SelectValue placeholder="选择支付方式" />
<SelectValue
:placeholder="rechargeOptionsWithKey.length ? '选择支付方式' : '暂无可用支付方式'"
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="alipay">
支付宝
</SelectItem>
<SelectItem value="wechat">
微信支付
<SelectItem
v-for="option in rechargeOptionsWithKey"
:key="option.key"
:value="option.key"
>
{{ option.display_name }}
<span
v-if="option.pay_currency && option.usd_exchange_rate"
class="text-xs text-muted-foreground"
>
· {{ option.pay_currency }}
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div
v-if="selectedRechargeOption?.usd_exchange_rate"
class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground"
>
预计支付:
<span class="font-medium text-foreground">
{{ estimatedRechargePayAmount }}
{{ selectedRechargeOption.pay_currency || 'CNY' }}
</span>
· 1 USD = {{ Number(selectedRechargeOption.usd_exchange_rate).toFixed(4) }}
{{ selectedRechargeOption.pay_currency || 'CNY' }}
</div>
<Button
class="w-full"
:disabled="submittingRecharge"
:disabled="submittingRecharge || rechargeOptionsWithKey.length === 0"
@click="submitRecharge"
>
{{ submittingRecharge ? '创建订单中...' : '创建充值订单' }}
@@ -178,6 +244,7 @@
:href="String(latestRecharge.payment_instructions.payment_url)"
target="_blank"
rel="noopener noreferrer"
@click.prevent="submitPaymentInstructions(latestRecharge.payment_instructions)"
>
打开支付链接
</a>
@@ -619,6 +686,7 @@ import {
type RefundRequest,
type WalletBalanceResponse,
type WalletRedeemResponse,
type WalletRechargeOption,
} from '@/api/wallet'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
@@ -641,8 +709,7 @@ import {
const { success, error: showError } = useToast()
// TODO(wallet): 充值和退款前台入口尚未正式启用;联调完成后改为 true 即可恢复显示。
const ENABLE_WALLET_ACTION_FORMS = false
const ENABLE_WALLET_ACTION_FORMS = true
const loadingInitial = ref(true)
const loadingTransactions = ref(false)
@@ -655,6 +722,7 @@ const submittingRefund = ref(false)
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 flowItems = ref<FlowItem[]>([])
const todayUsage = ref<DailyUsageRecord | null>(null)
@@ -677,7 +745,7 @@ let todayCostPollTimer: ReturnType<typeof setInterval> | null = null
const rechargeForm = reactive({
amount_usd: 10,
payment_method: 'alipay',
payment_option_key: '',
})
const refundForm = reactive({
@@ -695,6 +763,70 @@ const refundableOrders = computed(() =>
rechargeOrders.value.filter(o => (o.refundable_amount_usd || 0) > 0)
)
const rechargeOptionsWithKey = computed(() =>
rechargeOptions.value.map((option, index) => ({
...option,
key: [
option.payment_provider || option.provider || option.payment_method,
option.payment_method,
option.payment_channel || '',
index,
].join(':'),
}))
)
const selectedRechargeOption = computed(() => {
if (rechargeOptionsWithKey.value.length === 0) return null
return rechargeOptionsWithKey.value.find(option => option.key === rechargeForm.payment_option_key)
|| rechargeOptionsWithKey.value[0]
})
const estimatedRechargePayAmount = 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 dailyQuota = computed(() => walletBalance.value?.daily_quota ?? null)
const hasActiveDailyQuota = computed(() => Boolean(dailyQuota.value?.has_active))
const walletOnlyBalance = computed(() => {
const explicitBalance = walletBalance.value?.wallet_balance
if (typeof explicitBalance === 'number' && Number.isFinite(explicitBalance)) {
return explicitBalance
}
return Number(walletBalance.value?.balance ?? 0)
})
const packageBalance = computed(() => {
const quotaRemaining = dailyQuota.value?.remaining_usd
if (hasActiveDailyQuota.value && typeof quotaRemaining === 'number' && Number.isFinite(quotaRemaining)) {
return Math.max(0, quotaRemaining)
}
const explicitBalance = walletBalance.value?.package_balance
if (typeof explicitBalance === 'number' && Number.isFinite(explicitBalance)) {
return Math.max(0, explicitBalance)
}
return 0
})
const totalAvailableBalance = computed(() => {
const explicitBalance = walletBalance.value?.total_available_balance
if (typeof explicitBalance === 'number' && Number.isFinite(explicitBalance)) {
return explicitBalance
}
return walletOnlyBalance.value + packageBalance.value
})
const dailyQuotaTotal = computed(() => {
const value = dailyQuota.value?.total_usd
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
})
const dailyQuotaUsed = computed(() => {
const value = dailyQuota.value?.used_usd
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
})
const dailyQuotaRemainingPercent = computed(() => {
if (!hasActiveDailyQuota.value || dailyQuotaTotal.value <= 0) return 0
return Math.min(100, Math.max(0, (packageBalance.value / dailyQuotaTotal.value) * 100))
})
onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityChange)
try {
@@ -704,6 +836,7 @@ onMounted(async () => {
loadTodayCost(),
loadOrders(),
loadRefunds(),
loadRechargeOptions(),
])
syncTodayCostPolling()
} finally {
@@ -724,6 +857,21 @@ async function loadBalance() {
walletBalance.value = await walletApi.getBalance()
}
async function loadRechargeOptions() {
try {
const response = await walletApi.listRechargeOptions()
rechargeOptions.value = response.items
if (!rechargeForm.payment_option_key && rechargeOptionsWithKey.value.length > 0) {
const preferred = rechargeOptionsWithKey.value.find(option => option.payment_provider === 'epay')
|| rechargeOptionsWithKey.value[0]
rechargeForm.payment_option_key = preferred.key
}
} catch (error) {
log.error('加载充值方式失败:', error)
showError(parseApiError(error, '加载充值方式失败'))
}
}
async function loadTransactions() {
loadingTransactions.value = true
try {
@@ -831,16 +979,28 @@ async function submitRecharge() {
showError('请输入有效的充值金额')
return
}
const option = selectedRechargeOption.value
if (!option) {
showError('请选择支付方式')
return
}
if (option.min_recharge_usd && rechargeForm.amount_usd < option.min_recharge_usd) {
showError(`充值金额不能低于 ${formatCurrency(option.min_recharge_usd)}`)
return
}
submittingRecharge.value = true
try {
latestRecharge.value = await walletApi.createRechargeOrder({
amount_usd: rechargeForm.amount_usd,
payment_method: rechargeForm.payment_method,
payment_method: option.payment_method,
payment_provider: option.payment_provider,
payment_channel: option.payment_channel,
})
success('充值订单创建成功')
await Promise.all([loadOrders(), loadBalance()])
activeTab.value = 'orders'
submitPaymentInstructions(latestRecharge.value.payment_instructions)
} catch (error) {
log.error('创建充值订单失败:', error)
showError(parseApiError(error, '创建充值订单失败'))
@@ -849,6 +1009,42 @@ 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 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')
}
function submitPaymentForm(url: string, params: Record<string, unknown>) {
const form = document.createElement('form')
form.action = url
form.method = 'POST'
if (!isSafariBrowser()) {
form.target = '_blank'
}
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === undefined) return
const input = document.createElement('input')
input.type = 'hidden'
input.name = key
input.value = String(value)
form.appendChild(input)
})
document.body.appendChild(form)
form.submit()
document.body.removeChild(form)
}
function isSafariBrowser(): boolean {
return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome')
}
async function submitRefund() {
if (!refundForm.amount_usd || refundForm.amount_usd <= 0) {
showError('请输入有效的退款金额')