mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(wallet): 钱包系统替代配额系统,新增支付与退款机制
- 新增钱包余额管理、充值、扣费、退款完整流程 - 新增支付网关抽象层(支持手动/支付宝/微信) - 用量计费从配额系统迁移到钱包余额扣费 - 新增管理员钱包管理与支付订单管理页面 - 新增用户钱包中心页面 - 移除独立 Key 锁定机制,统一由钱包余额控制 - 新增相关 API 路由、序列化器与数据库迁移 - 新增钱包、支付、退款相关测试
This commit is contained in:
93
frontend/src/api/admin-payments.ts
Normal file
93
frontend/src/api/admin-payments.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import apiClient from './client'
|
||||
import type { PaymentOrder } from './wallet'
|
||||
|
||||
export interface PaymentCallbackRecord {
|
||||
id: string
|
||||
payment_order_id: string | null
|
||||
payment_method: string
|
||||
callback_key: string
|
||||
order_no: string | null
|
||||
gateway_order_id: string | null
|
||||
payload_hash: string | null
|
||||
signature_valid: boolean
|
||||
status: string
|
||||
payload: Record<string, unknown> | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
processed_at: string | null
|
||||
}
|
||||
|
||||
export interface AdminPaymentOrderListResponse {
|
||||
items: PaymentOrder[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface AdminPaymentCallbacksResponse {
|
||||
items: PaymentCallbackRecord[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface AdminPaymentCreditRequest {
|
||||
gateway_order_id?: string
|
||||
pay_amount?: number
|
||||
pay_currency?: string
|
||||
exchange_rate?: number
|
||||
gateway_response?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const adminPaymentsApi = {
|
||||
async listOrders(params?: {
|
||||
status?: string
|
||||
payment_method?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<AdminPaymentOrderListResponse> {
|
||||
const response = await apiClient.get<AdminPaymentOrderListResponse>('/api/admin/payments/orders', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getOrder(orderId: string): Promise<{ order: PaymentOrder }> {
|
||||
const response = await apiClient.get<{ order: PaymentOrder }>(`/api/admin/payments/orders/${orderId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async expireOrder(orderId: string): Promise<{ order: PaymentOrder; expired: boolean }> {
|
||||
const response = await apiClient.post<{ order: PaymentOrder; expired: boolean }>(
|
||||
`/api/admin/payments/orders/${orderId}/expire`,
|
||||
{}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async failOrder(orderId: string): Promise<{ order: PaymentOrder }> {
|
||||
const response = await apiClient.post<{ order: PaymentOrder }>(
|
||||
`/api/admin/payments/orders/${orderId}/fail`,
|
||||
{}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async creditOrder(
|
||||
orderId: string,
|
||||
payload: AdminPaymentCreditRequest
|
||||
): Promise<{ order: PaymentOrder; credited: boolean }> {
|
||||
const response = await apiClient.post<{ order: PaymentOrder; credited: boolean }>(
|
||||
`/api/admin/payments/orders/${orderId}/credit`,
|
||||
payload
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listCallbacks(params?: {
|
||||
payment_method?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<AdminPaymentCallbacksResponse> {
|
||||
const response = await apiClient.get<AdminPaymentCallbacksResponse>('/api/admin/payments/callbacks', { params })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
247
frontend/src/api/admin-wallets.ts
Normal file
247
frontend/src/api/admin-wallets.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import apiClient from './client'
|
||||
import type { RefundRequest, WalletSummary, WalletTransaction } from './wallet'
|
||||
|
||||
export interface AdminWallet extends WalletSummary {
|
||||
user_id: string | null
|
||||
api_key_id: string | null
|
||||
owner_type: 'user' | 'api_key'
|
||||
owner_name: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminWalletListResponse {
|
||||
items: AdminWallet[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface AdminWalletDetailResponse extends AdminWallet {
|
||||
pending_refund_count: number
|
||||
}
|
||||
|
||||
export interface AdminWalletTransactionsResponse {
|
||||
wallet: AdminWallet
|
||||
items: WalletTransaction[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface AdminWalletRefundsResponse {
|
||||
wallet: AdminWallet
|
||||
items: RefundRequest[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface AdminLedgerTransaction extends WalletTransaction {
|
||||
wallet_id: string
|
||||
owner_type: 'user' | 'api_key'
|
||||
owner_name: string | null
|
||||
wallet_status?: string | null
|
||||
}
|
||||
|
||||
export interface AdminGlobalRefund extends RefundRequest {
|
||||
wallet_id: string
|
||||
owner_type: 'user' | 'api_key'
|
||||
owner_name: string | null
|
||||
wallet_status?: string | null
|
||||
}
|
||||
|
||||
export interface AdminLedgerResponse {
|
||||
items: AdminLedgerTransaction[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface AdminGlobalRefundsListResponse {
|
||||
items: AdminGlobalRefund[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface ManualRechargeRequest {
|
||||
amount_usd: number
|
||||
payment_method?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface WalletAdjustRequest {
|
||||
amount_usd: number
|
||||
balance_type?: 'recharge' | 'gift'
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface RefundFailRequest {
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface RefundCompleteRequest {
|
||||
gateway_refund_id?: string
|
||||
payout_reference?: string
|
||||
payout_proof?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const adminWalletApi = {
|
||||
async listWallets(params?: {
|
||||
status?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<AdminWalletListResponse> {
|
||||
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listAllWallets(params?: {
|
||||
status?: string
|
||||
}): Promise<AdminWallet[]> {
|
||||
const items: AdminWallet[] = []
|
||||
const limit = 200
|
||||
const maxPages = 200
|
||||
let offset = 0
|
||||
let page = 0
|
||||
|
||||
while (page < maxPages) {
|
||||
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', {
|
||||
params: {
|
||||
...params,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
})
|
||||
const data = response.data
|
||||
items.push(...data.items)
|
||||
|
||||
if (items.length >= data.total || data.items.length < limit) {
|
||||
break
|
||||
}
|
||||
|
||||
const nextOffset = offset + data.items.length
|
||||
if (nextOffset <= offset) {
|
||||
throw new Error('分页游标未前进,终止全量钱包拉取以避免死循环')
|
||||
}
|
||||
offset = nextOffset
|
||||
page += 1
|
||||
}
|
||||
|
||||
if (page >= maxPages) {
|
||||
throw new Error(`钱包列表分页超过最大页数 ${maxPages},已中止请求`)
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
|
||||
async getWalletDetail(walletId: string): Promise<AdminWalletDetailResponse> {
|
||||
const response = await apiClient.get<AdminWalletDetailResponse>(`/api/admin/wallets/${walletId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listLedger(params?: {
|
||||
category?: string
|
||||
reason_code?: string
|
||||
owner_type?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<AdminLedgerResponse> {
|
||||
const response = await apiClient.get<AdminLedgerResponse>('/api/admin/wallets/ledger', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listGlobalRefunds(params?: {
|
||||
status?: string
|
||||
owner_type?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<AdminGlobalRefundsListResponse> {
|
||||
const response = await apiClient.get<AdminGlobalRefundsListResponse>('/api/admin/wallets/refund-requests', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getWalletTransactions(
|
||||
walletId: string,
|
||||
params?: { limit?: number; offset?: number }
|
||||
): Promise<AdminWalletTransactionsResponse> {
|
||||
const response = await apiClient.get<AdminWalletTransactionsResponse>(
|
||||
`/api/admin/wallets/${walletId}/transactions`,
|
||||
{ params }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getWalletRefunds(
|
||||
walletId: string,
|
||||
params?: { limit?: number; offset?: number }
|
||||
): Promise<AdminWalletRefundsResponse> {
|
||||
const response = await apiClient.get<AdminWalletRefundsResponse>(
|
||||
`/api/admin/wallets/${walletId}/refunds`,
|
||||
{ params }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async rechargeWallet(walletId: string, payload: ManualRechargeRequest): Promise<{
|
||||
wallet: AdminWallet
|
||||
payment_order: {
|
||||
id: string
|
||||
order_no: string
|
||||
amount_usd: number
|
||||
payment_method: string
|
||||
status: string
|
||||
created_at: string
|
||||
credited_at: string | null
|
||||
}
|
||||
}> {
|
||||
const response = await apiClient.post(`/api/admin/wallets/${walletId}/recharge`, payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async adjustWallet(walletId: string, payload: WalletAdjustRequest): Promise<{
|
||||
wallet: AdminWallet
|
||||
transaction: WalletTransaction
|
||||
}> {
|
||||
const response = await apiClient.post(`/api/admin/wallets/${walletId}/adjust`, payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async processRefund(walletId: string, refundId: string): Promise<{
|
||||
wallet: AdminWallet
|
||||
refund: RefundRequest
|
||||
transaction: WalletTransaction
|
||||
}> {
|
||||
const response = await apiClient.post(
|
||||
`/api/admin/wallets/${walletId}/refunds/${refundId}/process`,
|
||||
{}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async failRefund(walletId: string, refundId: string, payload: RefundFailRequest): Promise<{
|
||||
wallet: AdminWallet
|
||||
refund: RefundRequest
|
||||
transaction: WalletTransaction | null
|
||||
}> {
|
||||
const response = await apiClient.post(
|
||||
`/api/admin/wallets/${walletId}/refunds/${refundId}/fail`,
|
||||
payload
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async completeRefund(
|
||||
walletId: string,
|
||||
refundId: string,
|
||||
payload: RefundCompleteRequest
|
||||
): Promise<{ refund: RefundRequest }> {
|
||||
const response = await apiClient.post(
|
||||
`/api/admin/wallets/${walletId}/refunds/${refundId}/complete`,
|
||||
payload
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import apiClient from './client'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { BillingSummary } from './auth'
|
||||
|
||||
// LDAP 配置导出结构
|
||||
export interface LDAPConfigExport {
|
||||
@@ -69,9 +70,8 @@ export interface UserExport {
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
model_capability_settings?: Record<string, Record<string, boolean>>
|
||||
quota_usd?: number | null
|
||||
used_usd?: number
|
||||
total_usd?: number
|
||||
unlimited?: boolean
|
||||
wallet?: BillingSummary | null
|
||||
is_active: boolean
|
||||
api_keys: UserApiKeyExport[]
|
||||
}
|
||||
@@ -82,8 +82,6 @@ export interface UserApiKeyExport {
|
||||
key_encrypted?: string | null
|
||||
name?: string | null
|
||||
is_standalone: boolean
|
||||
balance_used_usd?: number
|
||||
current_balance_usd?: number | null
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
@@ -329,10 +327,7 @@ export interface AdminApiKey {
|
||||
name?: string
|
||||
key_display?: string // 脱敏后的密钥显示
|
||||
is_active: boolean
|
||||
is_locked: boolean // 管理员锁定标志
|
||||
is_standalone: boolean // 是否为独立余额Key
|
||||
balance_used_usd?: number // 已使用余额(仅独立Key)
|
||||
current_balance_usd?: number | null // 当前余额(独立Key预付费模式,null表示无限制)
|
||||
total_requests?: number
|
||||
total_tokens?: number
|
||||
total_cost_usd?: number
|
||||
@@ -354,7 +349,8 @@ export interface CreateStandaloneApiKeyRequest {
|
||||
allowed_models?: string[] | null
|
||||
rate_limit?: number | null // null = 无限制
|
||||
expires_at?: string | null // ISO 日期字符串,如 "2025-12-31",null = 永不过期
|
||||
initial_balance_usd: number // 初始余额,必须设置
|
||||
initial_balance_usd: number | null // 初始余额,null = 无限制
|
||||
unlimited_balance?: boolean | null // 编辑时仅切换额度模式,不调整余额数值
|
||||
auto_delete_on_expiry?: boolean // 过期后是否自动删除
|
||||
}
|
||||
|
||||
@@ -502,7 +498,10 @@ export const adminApi = {
|
||||
},
|
||||
|
||||
// 更新独立余额Key
|
||||
async updateApiKey(keyId: string, data: Partial<CreateStandaloneApiKeyRequest>): Promise<AdminApiKey & { message: string }> {
|
||||
async updateApiKey(
|
||||
keyId: string,
|
||||
data: Partial<CreateStandaloneApiKeyRequest>
|
||||
): Promise<AdminApiKey & { message: string }> {
|
||||
const response = await apiClient.put<AdminApiKey & { message: string }>(
|
||||
`/api/admin/api-keys/${keyId}`,
|
||||
data
|
||||
@@ -526,27 +525,10 @@ export const adminApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 切换API密钥锁定状态(锁定/解锁)
|
||||
async toggleLockApiKey(keyId: string): Promise<ApiKeyLockResponse> {
|
||||
// 切换用户普通 API Key 锁定状态(锁定/解锁)
|
||||
async toggleUserApiKeyLock(userId: string, keyId: string): Promise<ApiKeyLockResponse> {
|
||||
const response = await apiClient.patch<ApiKeyLockResponse>(
|
||||
`/api/admin/api-keys/${keyId}/lock`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 为独立余额Key调整余额
|
||||
async addApiKeyBalance(keyId: string, amountUsd: number): Promise<AdminApiKey & { message: string }> {
|
||||
const response = await apiClient.patch<AdminApiKey & { message: string }>(
|
||||
`/api/admin/api-keys/${keyId}/balance`,
|
||||
{ amount_usd: amountUsd }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 重置独立余额Key的已使用额度
|
||||
async resetApiKeyUsage(keyId: string): Promise<AdminApiKey & { message: string }> {
|
||||
const response = await apiClient.patch<AdminApiKey & { message: string }>(
|
||||
`/api/admin/api-keys/${keyId}/reset-usage`
|
||||
`/api/admin/users/${userId}/api-keys/${keyId}/lock`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -89,15 +89,30 @@ export interface AuthSettingsResponse {
|
||||
ldap_exclusive: boolean
|
||||
}
|
||||
|
||||
export interface BillingSummary {
|
||||
id?: string | null
|
||||
balance: number
|
||||
recharge_balance: number
|
||||
gift_balance: number
|
||||
refundable_balance: number
|
||||
currency: string
|
||||
status: string
|
||||
limit_mode: 'finite' | 'unlimited'
|
||||
unlimited: boolean
|
||||
total_recharged: number
|
||||
total_consumed: number
|
||||
total_refunded: number
|
||||
total_adjusted: number
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string // UUID
|
||||
username: string
|
||||
email?: string
|
||||
role: string // 'admin' or 'user'
|
||||
is_active: boolean
|
||||
quota_usd?: number | null
|
||||
used_usd?: number
|
||||
total_usd?: number
|
||||
billing?: BillingSummary
|
||||
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
|
||||
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
|
||||
allowed_models?: string[] | null // 允许使用的模型名称列表
|
||||
|
||||
@@ -2,6 +2,7 @@ import apiClient from './client'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
import type { TieredPricingConfig } from './endpoints/types'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { BillingSummary } from './auth'
|
||||
|
||||
export interface Profile {
|
||||
id: string // UUID
|
||||
@@ -9,9 +10,7 @@ export interface Profile {
|
||||
username: string
|
||||
role: string
|
||||
is_active: boolean
|
||||
quota_usd: number | null
|
||||
used_usd: number
|
||||
total_usd?: number // 累积消费总额
|
||||
billing: BillingSummary
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
last_login_at?: string
|
||||
@@ -103,8 +102,7 @@ export interface UsageResponse {
|
||||
total_cost: number // 官方费率
|
||||
total_actual_cost?: number // 倍率消耗(仅管理员可见)
|
||||
avg_response_time: number
|
||||
quota_usd: number | null
|
||||
used_usd: number
|
||||
billing: BillingSummary
|
||||
summary_by_model: ModelSummary[]
|
||||
summary_by_provider?: ProviderSummary[]
|
||||
pagination?: {
|
||||
|
||||
@@ -6,14 +6,13 @@ export interface User {
|
||||
email: string
|
||||
role: 'admin' | 'user'
|
||||
is_active: boolean
|
||||
quota_usd: number | null
|
||||
used_usd: number
|
||||
total_usd: number
|
||||
unlimited: boolean
|
||||
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
|
||||
allowed_api_formats: string[] | null // 允许使用的 API 格式列表
|
||||
allowed_models: string[] | null // 允许使用的模型名称列表
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
last_login_at?: string | null
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
@@ -21,7 +20,7 @@ export interface CreateUserRequest {
|
||||
password: string
|
||||
email: string
|
||||
role?: 'admin' | 'user'
|
||||
quota_usd?: number | null
|
||||
initial_gift_usd?: number | null
|
||||
unlimited?: boolean
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
@@ -32,7 +31,7 @@ export interface UpdateUserRequest {
|
||||
email?: string
|
||||
is_active?: boolean
|
||||
role?: 'admin' | 'user'
|
||||
quota_usd?: number | null
|
||||
unlimited?: boolean
|
||||
password?: string
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
@@ -50,8 +49,6 @@ export interface ApiKey {
|
||||
is_active: boolean
|
||||
is_locked: boolean // 管理员锁定标志
|
||||
is_standalone: boolean // 是否为独立余额Key
|
||||
balance_used_usd?: number // 已使用余额(仅独立Key)
|
||||
current_balance_usd?: number | null // 当前余额(独立Key预付费模式,null表示无限制)
|
||||
rate_limit?: number // 速率限制(请求/分钟)
|
||||
total_requests?: number // 总请求数
|
||||
total_cost_usd?: number // 总费用
|
||||
@@ -96,10 +93,12 @@ export const usersApi = {
|
||||
await apiClient.delete(`/api/admin/users/${userId}/api-keys/${keyId}`)
|
||||
},
|
||||
|
||||
async resetUserQuota(userId: string): Promise<void> {
|
||||
await apiClient.patch(`/api/admin/users/${userId}/quota`)
|
||||
async getFullApiKey(userId: string, keyId: string): Promise<{ key: string }> {
|
||||
const response = await apiClient.get<{ key: string }>(
|
||||
`/api/admin/users/${userId}/api-keys/${keyId}/full-key`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 管理员统计
|
||||
async getUsageStats(): Promise<Record<string, unknown>> {
|
||||
const response = await apiClient.get('/api/admin/usage/stats')
|
||||
|
||||
176
frontend/src/api/wallet.ts
Normal file
176
frontend/src/api/wallet.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface WalletSummary {
|
||||
id: string
|
||||
// balance = 总可用余额(充值余额 + 赠款余额)
|
||||
balance: number
|
||||
recharge_balance: number
|
||||
gift_balance: number
|
||||
refundable_balance: number
|
||||
currency: string
|
||||
status: string
|
||||
limit_mode?: 'finite' | 'unlimited'
|
||||
unlimited?: boolean
|
||||
total_recharged: number
|
||||
total_consumed: number
|
||||
total_refunded: number
|
||||
total_adjusted: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WalletBalanceResponse {
|
||||
wallet: WalletSummary | null
|
||||
unlimited: boolean
|
||||
limit_mode: 'finite' | 'unlimited'
|
||||
// balance = 总可用余额(充值余额 + 赠款余额)
|
||||
balance: number | null
|
||||
recharge_balance?: number | null
|
||||
gift_balance?: number | null
|
||||
refundable_balance?: number | null
|
||||
currency: string
|
||||
pending_refund_count?: number
|
||||
}
|
||||
|
||||
export interface WalletTransaction {
|
||||
id: string
|
||||
category: string
|
||||
reason_code: string
|
||||
amount: number
|
||||
// 总可用余额(充值+赠款)快照
|
||||
balance_before: number
|
||||
balance_after: number
|
||||
// 分账户快照
|
||||
recharge_balance_before: number
|
||||
recharge_balance_after: number
|
||||
gift_balance_before: number
|
||||
gift_balance_after: number
|
||||
link_type?: string | null
|
||||
link_id?: string | null
|
||||
operator_id?: string | null
|
||||
operator_name?: string | null
|
||||
operator_email?: string | null
|
||||
description?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface WalletTransactionsResponse extends WalletBalanceResponse {
|
||||
items: WalletTransaction[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface PaymentOrder {
|
||||
id: string
|
||||
order_no: string
|
||||
wallet_id: string
|
||||
user_id: string | null
|
||||
amount_usd: number
|
||||
pay_amount: number | null
|
||||
pay_currency: string | null
|
||||
exchange_rate: number | null
|
||||
refunded_amount_usd: number
|
||||
refundable_amount_usd: number
|
||||
payment_method: string
|
||||
gateway_order_id: string | null
|
||||
gateway_response: Record<string, unknown> | null
|
||||
status: string
|
||||
created_at: string
|
||||
paid_at: string | null
|
||||
credited_at: string | null
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
export interface RefundRequest {
|
||||
id: string
|
||||
refund_no: string
|
||||
payment_order_id: string | null
|
||||
source_type: string
|
||||
source_id: string | null
|
||||
refund_mode: string
|
||||
amount_usd: number
|
||||
status: string
|
||||
reason: string | null
|
||||
failure_reason: string | null
|
||||
gateway_refund_id: string | null
|
||||
payout_method: string | null
|
||||
payout_reference: string | null
|
||||
payout_proof: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
processed_at: string | null
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
export interface WalletRechargeCreateRequest {
|
||||
amount_usd: number
|
||||
payment_method: string
|
||||
pay_amount?: number
|
||||
pay_currency?: string
|
||||
exchange_rate?: number
|
||||
}
|
||||
|
||||
export interface WalletRefundCreateRequest {
|
||||
amount_usd: number
|
||||
payment_order_id?: string
|
||||
source_type?: string
|
||||
source_id?: string
|
||||
refund_mode?: string
|
||||
reason?: string
|
||||
idempotency_key?: string
|
||||
}
|
||||
|
||||
export const walletApi = {
|
||||
async getBalance(): Promise<WalletBalanceResponse> {
|
||||
const response = await apiClient.get<WalletBalanceResponse>('/api/wallet/balance')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getTransactions(params?: { limit?: number; offset?: number }): Promise<WalletTransactionsResponse> {
|
||||
const response = await apiClient.get<WalletTransactionsResponse>('/api/wallet/transactions', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createRechargeOrder(payload: WalletRechargeCreateRequest): Promise<{
|
||||
order: PaymentOrder
|
||||
payment_instructions: Record<string, unknown>
|
||||
}> {
|
||||
const response = await apiClient.post('/api/wallet/recharge', payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listRechargeOrders(params?: { limit?: number; offset?: number }): Promise<{
|
||||
items: PaymentOrder[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}> {
|
||||
const response = await apiClient.get('/api/wallet/recharge', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getRechargeOrder(orderId: string): Promise<{ order: PaymentOrder }> {
|
||||
const response = await apiClient.get(`/api/wallet/recharge/${orderId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listRefunds(params?: { limit?: number; offset?: number }): Promise<{
|
||||
items: RefundRequest[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}> {
|
||||
const response = await apiClient.get('/api/wallet/refunds', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getRefund(refundId: string): Promise<RefundRequest> {
|
||||
const response = await apiClient.get<RefundRequest>(`/api/wallet/refunds/${refundId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createRefund(payload: WalletRefundCreateRequest): Promise<RefundRequest> {
|
||||
const response = await apiClient.post<RefundRequest>('/api/wallet/refunds', payload)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
:z-index="80"
|
||||
:z-index="120"
|
||||
@update:model-value="handleClose"
|
||||
>
|
||||
<template #header>
|
||||
@@ -28,9 +28,8 @@
|
||||
v-for="(line, index) in descriptionLines"
|
||||
:key="index"
|
||||
:class="getLineClass(index)"
|
||||
>
|
||||
{{ line }}
|
||||
</p>
|
||||
v-html="renderLine(line)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 自定义内容插槽 -->
|
||||
@@ -103,6 +102,24 @@ const descriptionLines = computed(() => {
|
||||
return props.description.split('\n').filter(line => line.trim())
|
||||
})
|
||||
|
||||
function escapeHtml(raw: string): string {
|
||||
return raw
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function renderLine(line: string): string {
|
||||
const escaped = escapeHtml(line)
|
||||
// 支持最小语法加粗:**text**
|
||||
return escaped.replace(
|
||||
/\*\*(.+?)\*\*/g,
|
||||
'<strong class="font-semibold text-foreground">$1</strong>'
|
||||
)
|
||||
}
|
||||
|
||||
// 根据行索引获取样式(中间行高亮)
|
||||
function getLineClass(index: number): string {
|
||||
const total = descriptionLines.value.length
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{{ isEditMode ? '编辑独立余额 API Key' : '创建独立余额 API Key' }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ isEditMode ? '修改密钥名称、有效期和访问限制' : '用于非注册用户调用接口,不关联用户配额,必须设置余额限制' }}
|
||||
{{ isEditMode ? '修改密钥名称、有效期和访问限制' : '用于非注册用户调用接口,可设置初始余额或无限制额度' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +34,6 @@
|
||||
<!-- 左侧:基础设置 -->
|
||||
<div class="pr-6 space-y-4">
|
||||
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
|
||||
<Key class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">基础设置</span>
|
||||
</div>
|
||||
|
||||
@@ -52,31 +51,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 初始余额 - 仅创建模式显示 -->
|
||||
<div
|
||||
v-if="!isEditMode"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label
|
||||
for="form-balance"
|
||||
class="text-sm font-medium"
|
||||
>初始余额 (USD) <span class="text-rose-500">*</span></Label>
|
||||
<Input
|
||||
id="form-balance"
|
||||
:model-value="form.initial_balance_usd ?? ''"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
placeholder="10.00"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.initial_balance_usd = parseNumberInput(v, { allowFloat: true }) ?? 10"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
独立Key必须设置余额限制,最小值 $0.01
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="form-expires-at"
|
||||
@@ -137,12 +111,49 @@
|
||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v, { min: 1, max: 10000 })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">无限制额度</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch v-model="form.unlimited_balance" />
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-foreground">
|
||||
{{ form.unlimited_balance ? '已启用' : '已关闭' }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ form.unlimited_balance ? '无限制:忽略钱包余额校验' : '有限制:按钱包余额校验' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isEditMode && !form.unlimited_balance"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label
|
||||
for="form-balance"
|
||||
class="text-sm font-medium"
|
||||
>初始余额 (USD) <span class="text-rose-500">*</span></Label>
|
||||
<Input
|
||||
id="form-balance"
|
||||
:model-value="form.initial_balance_usd ?? ''"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="10.00"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.initial_balance_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
最小值 $0.01
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:访问限制 -->
|
||||
<div class="pl-6 space-y-4 border-l border-border">
|
||||
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
|
||||
<Shield class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">访问限制</span>
|
||||
<span class="text-xs text-muted-foreground">(留空不限)</span>
|
||||
</div>
|
||||
@@ -279,8 +290,9 @@ import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
} from '@/components/ui'
|
||||
import { Plus, SquarePen, Key, Shield, ChevronDown, X } from 'lucide-vue-next'
|
||||
import { Plus, SquarePen, ChevronDown, X } from 'lucide-vue-next'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { ModelMultiSelect } from '@/components/common'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
@@ -294,6 +306,7 @@ export interface StandaloneKeyFormData {
|
||||
id?: string
|
||||
name: string
|
||||
initial_balance_usd?: number
|
||||
unlimited_balance?: boolean
|
||||
expires_at?: string // ISO 日期字符串,如 "2025-12-31",undefined = 永不过期
|
||||
rate_limit?: number
|
||||
auto_delete_on_expiry: boolean
|
||||
@@ -328,6 +341,7 @@ const allApiFormats = ref<string[]>([])
|
||||
const form = ref<StandaloneKeyFormData>({
|
||||
name: '',
|
||||
initial_balance_usd: 10,
|
||||
unlimited_balance: false,
|
||||
expires_at: undefined,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
@@ -347,6 +361,7 @@ function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
initial_balance_usd: 10,
|
||||
unlimited_balance: false,
|
||||
expires_at: undefined,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
@@ -364,6 +379,7 @@ function loadKeyData() {
|
||||
id: props.apiKey.id,
|
||||
name: props.apiKey.name || '',
|
||||
initial_balance_usd: props.apiKey.initial_balance_usd,
|
||||
unlimited_balance: props.apiKey.initial_balance_usd == null,
|
||||
expires_at: props.apiKey.expires_at,
|
||||
rate_limit: props.apiKey.rate_limit,
|
||||
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
|
||||
@@ -432,6 +448,17 @@ watch(isOpen, (val) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => form.value.unlimited_balance,
|
||||
(unlimited) => {
|
||||
if (unlimited) {
|
||||
form.value.initial_balance_usd = undefined
|
||||
} else if (form.value.initial_balance_usd == null) {
|
||||
form.value.initial_balance_usd = 10
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
setSaving
|
||||
})
|
||||
|
||||
@@ -142,51 +142,16 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="form-quota"
|
||||
class="text-sm font-medium"
|
||||
>配额(美元)</Label>
|
||||
<div class="flex items-center space-x-3">
|
||||
<Input
|
||||
id="form-quota"
|
||||
v-model.number="form.quota"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="10000"
|
||||
:placeholder="isEditMode ? '10' : '使用系统默认'"
|
||||
:disabled="form.unlimited"
|
||||
:class="form.unlimited ? 'flex-1 h-10 opacity-50' : 'flex-1 h-10'"
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-2 border rounded-lg px-3 py-2 bg-muted/50 w-24">
|
||||
<input
|
||||
id="form-unlimited"
|
||||
v-model="form.unlimited"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
>
|
||||
<Label
|
||||
for="form-unlimited"
|
||||
class="whitespace-nowrap cursor-pointer text-sm"
|
||||
>无限制</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="form-role"
|
||||
class="text-sm font-medium"
|
||||
>用户角色</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Select
|
||||
v-model="form.role"
|
||||
class="flex-1"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Select v-model="form.role">
|
||||
<SelectTrigger
|
||||
id="form-role"
|
||||
class="h-10"
|
||||
class="h-10 w-full text-sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -199,20 +164,29 @@
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div
|
||||
v-if="!isEditMode"
|
||||
class="flex items-center justify-center gap-2 border rounded-lg px-3 py-2 bg-muted/50 w-24"
|
||||
>
|
||||
<input
|
||||
id="form-active"
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
>
|
||||
<Label
|
||||
for="form-active"
|
||||
class="whitespace-nowrap cursor-pointer text-sm"
|
||||
>启用用户</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isEditMode"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label
|
||||
for="form-active"
|
||||
class="text-sm font-medium"
|
||||
>启用用户</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch
|
||||
id="form-active"
|
||||
v-model="form.is_active"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-foreground">
|
||||
{{ form.is_active ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ form.is_active ? '允许登录与请求' : '阻止登录与请求' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,6 +306,44 @@
|
||||
v-model="form.allowed_models"
|
||||
:models="globalModels"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">无限制额度</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch v-model="form.unlimited" />
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-foreground">
|
||||
{{ form.unlimited ? '已启用' : '已关闭' }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ form.unlimited ? '无限制:忽略钱包余额校验' : '有限制:按钱包余额校验' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isEditMode && !form.unlimited"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label
|
||||
for="form-initial-gift"
|
||||
class="text-sm font-medium"
|
||||
>初始赠款额度 (USD) <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
id="form-initial-gift"
|
||||
:model-value="form.initial_gift_usd ?? ''"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="10.00"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.initial_gift_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
最小值 $0.01
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -363,6 +375,7 @@ import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
@@ -376,13 +389,15 @@ import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getGlobalModels } from '@/api/global-models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||
|
||||
export interface UserFormData {
|
||||
id?: string
|
||||
username: string
|
||||
email: string
|
||||
quota_usd?: number | null
|
||||
initial_gift_usd?: number | null
|
||||
unlimited?: boolean
|
||||
role: 'admin' | 'user'
|
||||
is_active?: boolean
|
||||
allowed_providers?: string[] | null
|
||||
@@ -397,7 +412,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [data: UserFormData & { password?: string }]
|
||||
submit: [data: UserFormData & { password?: string; unlimited?: boolean }]
|
||||
}>()
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
@@ -420,7 +435,7 @@ const form = ref({
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: '',
|
||||
quota: null as number | null,
|
||||
initial_gift_usd: 10 as number | undefined,
|
||||
role: 'user' as 'admin' | 'user',
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
@@ -441,7 +456,7 @@ function resetForm() {
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: '',
|
||||
quota: null,
|
||||
initial_gift_usd: 10,
|
||||
role: 'user',
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
@@ -461,9 +476,9 @@ function loadUserData() {
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: props.user.email || '',
|
||||
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
|
||||
initial_gift_usd: undefined,
|
||||
role: props.user.role,
|
||||
unlimited: props.user.quota_usd == null,
|
||||
unlimited: props.user.unlimited ?? false,
|
||||
is_active: props.user.is_active ?? true,
|
||||
allowed_providers: [...(props.user.allowed_providers || [])],
|
||||
allowed_api_formats: [...(props.user.allowed_api_formats || [])],
|
||||
@@ -491,16 +506,33 @@ const usernameError = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
function getPasswordValidationError(password: string): string | null {
|
||||
if (password.length < 8) return '密码长度至少为8个字符'
|
||||
if (!/[A-Z]/.test(password)) return '密码必须包含至少一个大写字母'
|
||||
if (!/[a-z]/.test(password)) return '密码必须包含至少一个小写字母'
|
||||
if (!/[0-9]/.test(password)) return '密码必须包含至少一个数字'
|
||||
return null
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
const isFormValid = computed(() => {
|
||||
const hasUsername = form.value.username.trim().length > 0
|
||||
const usernameValid = !usernameError.value
|
||||
const hasPassword = isEditMode.value || form.value.password.length >= 6
|
||||
// 编辑模式下如果填写了密码,必须确认密码一致
|
||||
const passwordConfirmed = !isEditMode.value || form.value.password.length === 0 || form.value.password === form.value.confirmPassword
|
||||
return hasUsername && usernameValid && hasPassword && passwordConfirmed
|
||||
const passwordFilled = form.value.password.length > 0
|
||||
const passwordValid = passwordFilled
|
||||
? !getPasswordValidationError(form.value.password)
|
||||
: isEditMode.value
|
||||
// 编辑模式下可留空;填写时必须确认一致。创建模式不展示确认输入框。
|
||||
const passwordConfirmed = isEditMode.value
|
||||
? !passwordFilled || form.value.password === form.value.confirmPassword
|
||||
: true
|
||||
const initialGiftValid = isEditMode.value ||
|
||||
form.value.unlimited ||
|
||||
(typeof form.value.initial_gift_usd === 'number' && form.value.initial_gift_usd >= 0.01)
|
||||
return hasUsername && usernameValid && passwordValid && passwordConfirmed && initialGiftValid
|
||||
})
|
||||
|
||||
|
||||
// 加载访问控制选项
|
||||
async function loadAccessControlOptions(): Promise<void> {
|
||||
try {
|
||||
@@ -532,27 +564,25 @@ function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'a
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
try {
|
||||
const data: UserFormData & { password?: string; unlimited?: boolean } = {
|
||||
const data: UserFormData & { password?: string; unlimited: boolean } = {
|
||||
username: form.value.username,
|
||||
email: form.value.email.trim() || '',
|
||||
quota_usd: form.value.unlimited ? null : form.value.quota,
|
||||
unlimited: form.value.unlimited,
|
||||
role: form.value.role,
|
||||
allowed_providers: form.value.allowed_providers.length > 0 ? form.value.allowed_providers : null,
|
||||
allowed_api_formats: form.value.allowed_api_formats.length > 0 ? form.value.allowed_api_formats : null,
|
||||
allowed_models: form.value.allowed_models.length > 0 ? form.value.allowed_models : null
|
||||
}
|
||||
|
||||
// 创建模式下传递 unlimited 字段
|
||||
if (!isEditMode.value) {
|
||||
data.unlimited = form.value.unlimited
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.user?.id) {
|
||||
data.id = props.user.id
|
||||
}
|
||||
|
||||
if (!isEditMode.value) {
|
||||
data.is_active = form.value.is_active
|
||||
if (!form.value.unlimited && form.value.initial_gift_usd != null) {
|
||||
data.initial_gift_usd = form.value.initial_gift_usd
|
||||
}
|
||||
}
|
||||
|
||||
if (form.value.password) {
|
||||
@@ -580,6 +610,20 @@ watch(isOpen, (val) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => form.value.unlimited,
|
||||
(unlimited) => {
|
||||
if (isEditMode.value) {
|
||||
return
|
||||
}
|
||||
if (unlimited) {
|
||||
form.value.initial_gift_usd = undefined
|
||||
} else if (form.value.initial_gift_usd == null) {
|
||||
form.value.initial_gift_usd = 10
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
setSaving
|
||||
})
|
||||
|
||||
1023
frontend/src/features/wallet/components/WalletOpsDrawer.vue
Normal file
1023
frontend/src/features/wallet/components/WalletOpsDrawer.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -363,6 +363,7 @@ import {
|
||||
SunMoon,
|
||||
ChevronRight,
|
||||
Megaphone,
|
||||
Wallet,
|
||||
Menu,
|
||||
X,
|
||||
Puzzle,
|
||||
@@ -521,6 +522,7 @@ const navigation = computed(() => {
|
||||
{
|
||||
title: '账户',
|
||||
items: [
|
||||
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
|
||||
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 },
|
||||
{ name: '异步任务', href: '/dashboard/async-tasks', icon: Zap },
|
||||
]
|
||||
@@ -579,6 +581,7 @@ const navigation = computed(() => {
|
||||
{ name: '模型管理', href: '/admin/models', icon: Layers },
|
||||
{ name: '号池管理', href: '/admin/pool', icon: Database },
|
||||
{ name: '独立密钥', href: '/admin/keys', icon: Key },
|
||||
{ name: '钱包管理', href: '/admin/wallets', icon: Wallet },
|
||||
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
|
||||
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
|
||||
]
|
||||
|
||||
@@ -12,15 +12,47 @@ import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/en
|
||||
|
||||
// ========== 用户数据 ==========
|
||||
|
||||
const MOCK_ADMIN_BILLING = {
|
||||
id: 'wallet-demo-admin',
|
||||
balance: 0,
|
||||
recharge_balance: 0,
|
||||
gift_balance: 0,
|
||||
refundable_balance: 0,
|
||||
currency: 'USD',
|
||||
status: 'active',
|
||||
limit_mode: 'unlimited' as const,
|
||||
unlimited: true,
|
||||
total_recharged: 0,
|
||||
total_consumed: 1234.56,
|
||||
total_refunded: 0,
|
||||
total_adjusted: 0,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const MOCK_USER_BILLING = {
|
||||
id: 'wallet-demo-user',
|
||||
balance: 54.68,
|
||||
recharge_balance: 40,
|
||||
gift_balance: 14.68,
|
||||
refundable_balance: 40,
|
||||
currency: 'USD',
|
||||
status: 'active',
|
||||
limit_mode: 'finite' as const,
|
||||
unlimited: false,
|
||||
total_recharged: 100,
|
||||
total_consumed: 45.32,
|
||||
total_refunded: 0,
|
||||
total_adjusted: 0,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
export const MOCK_ADMIN_USER: User = {
|
||||
id: 'demo-admin-uuid-0001',
|
||||
username: 'Demo Admin',
|
||||
email: 'admin@demo.aether.io',
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
quota_usd: null,
|
||||
used_usd: 156.78,
|
||||
total_usd: 1234.56,
|
||||
billing: MOCK_ADMIN_BILLING,
|
||||
allowed_providers: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
@@ -34,9 +66,7 @@ export const MOCK_NORMAL_USER: User = {
|
||||
email: 'user@demo.aether.io',
|
||||
role: 'user',
|
||||
is_active: true,
|
||||
quota_usd: 100,
|
||||
used_usd: 45.32,
|
||||
total_usd: 245.32,
|
||||
billing: MOCK_USER_BILLING,
|
||||
allowed_providers: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
@@ -74,9 +104,7 @@ export const MOCK_ADMIN_PROFILE: Profile = {
|
||||
username: MOCK_ADMIN_USER.username,
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
quota_usd: null,
|
||||
used_usd: 156.78,
|
||||
total_usd: 1234.56,
|
||||
billing: MOCK_ADMIN_BILLING,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
last_login_at: new Date().toISOString(),
|
||||
@@ -92,9 +120,7 @@ export const MOCK_USER_PROFILE: Profile = {
|
||||
username: MOCK_NORMAL_USER.username,
|
||||
role: 'user',
|
||||
is_active: true,
|
||||
quota_usd: 100,
|
||||
used_usd: 45.32,
|
||||
total_usd: 245.32,
|
||||
billing: MOCK_USER_BILLING,
|
||||
created_at: '2024-06-01T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
last_login_at: new Date().toISOString(),
|
||||
@@ -269,10 +295,8 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
username: 'Demo Admin',
|
||||
email: 'admin@demo.aether.io',
|
||||
role: 'admin',
|
||||
unlimited: true,
|
||||
is_active: true,
|
||||
quota_usd: null,
|
||||
used_usd: 156.78,
|
||||
total_usd: 1234.56,
|
||||
allowed_providers: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
@@ -283,10 +307,8 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
username: 'Demo User',
|
||||
email: 'user@demo.aether.io',
|
||||
role: 'user',
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
quota_usd: 100,
|
||||
used_usd: 45.32,
|
||||
total_usd: 245.32,
|
||||
allowed_providers: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
@@ -297,10 +319,8 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
username: 'Alice Wang',
|
||||
email: 'alice@example.com',
|
||||
role: 'user',
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
quota_usd: 50,
|
||||
used_usd: 23.45,
|
||||
total_usd: 123.45,
|
||||
allowed_providers: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
@@ -311,10 +331,8 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
username: 'Bob Zhang',
|
||||
email: 'bob@example.com',
|
||||
role: 'user',
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
quota_usd: 200,
|
||||
used_usd: 89.12,
|
||||
total_usd: 589.12,
|
||||
allowed_providers: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
@@ -325,10 +343,8 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
username: 'Charlie Li',
|
||||
email: 'charlie@example.com',
|
||||
role: 'user',
|
||||
unlimited: false,
|
||||
is_active: false,
|
||||
quota_usd: 30,
|
||||
used_usd: 30.00,
|
||||
total_usd: 30.00,
|
||||
allowed_providers: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
@@ -387,8 +403,6 @@ export const MOCK_ADMIN_API_KEYS: AdminApiKeysResponse = {
|
||||
key_display: 'sk-sa...abc1',
|
||||
is_active: true,
|
||||
is_standalone: true,
|
||||
balance_used_usd: 25.50,
|
||||
current_balance_usd: 74.50,
|
||||
total_requests: 500,
|
||||
total_tokens: 1500000,
|
||||
total_cost_usd: 25.50,
|
||||
@@ -404,8 +418,6 @@ export const MOCK_ADMIN_API_KEYS: AdminApiKeysResponse = {
|
||||
key_display: 'sk-sa...def2',
|
||||
is_active: true,
|
||||
is_standalone: true,
|
||||
balance_used_usd: 45.00,
|
||||
current_balance_usd: 55.00,
|
||||
total_requests: 800,
|
||||
total_tokens: 2400000,
|
||||
total_cost_usd: 45.00,
|
||||
@@ -808,8 +820,7 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
|
||||
total_cost: 45.67,
|
||||
total_actual_cost: 33.33,
|
||||
avg_response_time: 1.23,
|
||||
quota_usd: 100,
|
||||
used_usd: 45.32,
|
||||
billing: MOCK_USER_BILLING,
|
||||
summary_by_model: [
|
||||
{ model: 'claude-sonnet-4-5-20250929', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
|
||||
{ model: 'gpt-5.1', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
|
||||
|
||||
@@ -588,8 +588,22 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
total_cost: Number((totalCost * 20).toFixed(2)),
|
||||
total_actual_cost: Number((totalActualCost * 20).toFixed(2)),
|
||||
avg_response_time: Number(avgResponseTime.toFixed(2)) || 1.23,
|
||||
quota_usd: 100,
|
||||
used_usd: Number((totalCost * 20).toFixed(2)),
|
||||
billing: {
|
||||
id: 'wallet-demo-user',
|
||||
balance: Number((100 - totalCost * 20).toFixed(2)),
|
||||
recharge_balance: Number((100 - totalCost * 20).toFixed(2)),
|
||||
gift_balance: 0,
|
||||
refundable_balance: Number((100 - totalCost * 20).toFixed(2)),
|
||||
currency: 'USD',
|
||||
status: 'active',
|
||||
limit_mode: 'finite',
|
||||
unlimited: false,
|
||||
total_recharged: 100,
|
||||
total_consumed: Number((totalCost * 20).toFixed(2)),
|
||||
total_refunded: 0,
|
||||
total_adjusted: 0,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
activity_heatmap: heatmap,
|
||||
summary_by_model: Array.from(modelStats.entries()).map(([model, stats]) => ({
|
||||
model,
|
||||
@@ -726,10 +740,8 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
role: body.role || 'user',
|
||||
unlimited: Boolean(body.unlimited),
|
||||
is_active: true,
|
||||
quota_usd: body.quota_usd || null,
|
||||
used_usd: 0,
|
||||
total_usd: 0,
|
||||
allowed_providers: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
@@ -757,8 +769,6 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
key_display: 'sk-sa...demo',
|
||||
is_active: true,
|
||||
is_standalone: true,
|
||||
balance_used_usd: 0,
|
||||
current_balance_usd: body.initial_balance_usd || 100,
|
||||
total_requests: 0,
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -122,6 +122,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'Settings',
|
||||
component: () => importWithRetry(() => import('@/views/user/Settings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'wallet',
|
||||
name: 'WalletCenter',
|
||||
component: () => importWithRetry(() => import('@/views/user/WalletCenter.vue'))
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'ModelCatalog',
|
||||
@@ -154,6 +159,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'ApiKeys',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ApiKeys.vue'))
|
||||
},
|
||||
{
|
||||
path: 'wallets',
|
||||
name: 'WalletsManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/WalletsManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'management-tokens',
|
||||
name: 'AdminManagementTokens',
|
||||
|
||||
@@ -102,19 +102,12 @@ export const useUsersStore = defineStore('users', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetUserQuota(userId: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
async function getFullApiKey(userId: string, keyId: string): Promise<{ key: string }> {
|
||||
try {
|
||||
await usersApi.resetUserQuota(userId)
|
||||
// 刷新用户列表以获取最新数据
|
||||
await fetchUsers()
|
||||
return await usersApi.getFullApiKey(userId, keyId)
|
||||
} catch (err: unknown) {
|
||||
error.value = parseApiError(err, '重置配额失败')
|
||||
error.value = parseApiError(err, '获取完整 API Key 失败')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +122,6 @@ export const useUsersStore = defineStore('users', () => {
|
||||
getUserApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
resetUserQuota
|
||||
getFullApiKey
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1192,6 +1192,50 @@ body[theme-mode='dark'] .literary-annotation {
|
||||
background-color: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* Tabs styled as existing outline buttons (used by wallet/payment pages) */
|
||||
.tabs-button-list {
|
||||
background-color: transparent !important;
|
||||
border: 0 !important;
|
||||
padding: 0 !important;
|
||||
height: auto !important;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tabs-button-list .tabs-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tabs-button-list button[data-value] {
|
||||
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent) !important;
|
||||
background-color: color-mix(in srgb, var(--card) 60%, transparent) !important;
|
||||
color: var(--foreground) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.tabs-button-list button[data-state='active'] {
|
||||
background-color: var(--primary) !important;
|
||||
color: var(--primary-foreground) !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tabs-button-list button[data-state='active']:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.tabs-button-list button[data-state='inactive'] {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.tabs-button-list button[data-state='inactive']:hover {
|
||||
border-color: rgb(var(--color-primary-rgb) / 0.6) !important;
|
||||
color: var(--primary) !important;
|
||||
background-color: rgb(var(--color-primary-rgb) / 0.1) !important;
|
||||
}
|
||||
|
||||
/* Password masking without type="password" to prevent browser autofill */
|
||||
.-webkit-text-security-disc {
|
||||
-webkit-text-security: disc;
|
||||
|
||||
153
frontend/src/utils/walletDisplay.ts
Normal file
153
frontend/src/utils/walletDisplay.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
export function walletStatusLabel(status: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
active: '正常',
|
||||
suspended: '已冻结',
|
||||
closed: '已关闭',
|
||||
}
|
||||
if (!status) return '未知'
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
export function formatWalletCurrency(
|
||||
value: number | null | undefined,
|
||||
options?: { decimals?: number }
|
||||
): string {
|
||||
const decimals = options?.decimals ?? 2
|
||||
const amount = Number(value ?? 0)
|
||||
return `$${amount.toFixed(decimals)}`
|
||||
}
|
||||
|
||||
export function walletStatusBadge(status: string | null | undefined): string {
|
||||
if (status === 'active') return 'success'
|
||||
if (status === 'suspended') return 'warning'
|
||||
if (status === 'closed') return 'destructive'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
export function walletTransactionCategoryLabel(category: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
recharge: '充值',
|
||||
gift: '赠款',
|
||||
adjust: '调账',
|
||||
refund: '退款',
|
||||
}
|
||||
if (!category) return '未知'
|
||||
return labels[category] || category
|
||||
}
|
||||
|
||||
export function walletTransactionReasonLabel(reasonCode: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
topup_admin_manual: '人工充值',
|
||||
topup_gateway: '支付充值',
|
||||
topup_card_code: '卡密充值',
|
||||
gift_initial: '初始赠款',
|
||||
gift_campaign: '活动赠款',
|
||||
gift_expire_reclaim: '赠款回收',
|
||||
adjust_admin: '人工调账',
|
||||
adjust_system: '系统调账',
|
||||
refund_out: '退款扣减',
|
||||
refund_revert: '退款回补',
|
||||
}
|
||||
if (!reasonCode) return '未知'
|
||||
return labels[reasonCode] || reasonCode
|
||||
}
|
||||
|
||||
export function paymentMethodLabel(method: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
alipay: '支付宝',
|
||||
wechat: '微信支付',
|
||||
admin_manual: '人工充值',
|
||||
card_code: '充值卡',
|
||||
gift_code: '礼品卡',
|
||||
card_recharge: '卡密充值',
|
||||
bank_transfer: '银行转账',
|
||||
offline: '线下转账',
|
||||
}
|
||||
if (!method) return '-'
|
||||
return labels[method] || method
|
||||
}
|
||||
|
||||
export function paymentStatusLabel(status: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
pending: '待支付',
|
||||
paid: '已支付',
|
||||
credited: '已到账',
|
||||
failed: '支付失败',
|
||||
expired: '已过期',
|
||||
refunding: '退款中',
|
||||
refunded: '已退款',
|
||||
}
|
||||
if (!status) return '未知'
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
export function walletLinkTypeLabel(type: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
payment_order: '充值订单',
|
||||
refund_request: '退款申请',
|
||||
admin_action: '后台操作',
|
||||
system_task: '系统任务',
|
||||
campaign: '活动批次',
|
||||
usage: '用量记录',
|
||||
}
|
||||
if (!type) return '-'
|
||||
return labels[type] || '其他'
|
||||
}
|
||||
|
||||
export function paymentStatusBadge(status: string | null | undefined): string {
|
||||
if (status === 'credited' || status === 'refunded') return 'success'
|
||||
if (status === 'paid' || status === 'refunding') return 'outline'
|
||||
if (status === 'pending') return 'secondary'
|
||||
if (status === 'expired') return 'warning'
|
||||
if (status === 'failed') return 'destructive'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
export function refundModeLabel(mode: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
original_channel: '原路退回',
|
||||
offline_payout: '线下打款',
|
||||
}
|
||||
if (!mode) return '-'
|
||||
return labels[mode] || mode
|
||||
}
|
||||
|
||||
export function refundStatusLabel(status: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
pending_approval: '待审批',
|
||||
approved: '已审批',
|
||||
processing: '处理中',
|
||||
succeeded: '已完成',
|
||||
failed: '已失败',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
if (!status) return '未知'
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
export function refundStatusBadge(status: string | null | undefined): string {
|
||||
if (status === 'succeeded') return 'success'
|
||||
if (status === 'processing') return 'outline'
|
||||
if (status === 'pending_approval' || status === 'approved') return 'secondary'
|
||||
if (status === 'failed' || status === 'cancelled') return 'destructive'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
export function callbackStatusLabel(status: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
processed: '已处理',
|
||||
duplicate: '重复回调',
|
||||
ignored: '已忽略',
|
||||
invalid_signature: '验签失败',
|
||||
error: '处理失败',
|
||||
}
|
||||
if (!status) return '未知'
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
export function callbackStatusBadge(status: string | null | undefined): string {
|
||||
if (status === 'processed') return 'success'
|
||||
if (status === 'duplicate' || status === 'ignored') return 'secondary'
|
||||
if (status === 'invalid_signature' || status === 'error') return 'destructive'
|
||||
return 'outline'
|
||||
}
|
||||
@@ -89,7 +89,7 @@
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
@click="loadApiKeys"
|
||||
@click="refreshApiKeys"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,8 +102,8 @@
|
||||
<TableHead class="w-[200px] h-12 font-semibold">
|
||||
密钥信息
|
||||
</TableHead>
|
||||
<TableHead class="w-[160px] h-12 font-semibold">
|
||||
余额 (已用/总额)
|
||||
<TableHead class="w-[240px] h-12 font-semibold">
|
||||
钱包
|
||||
</TableHead>
|
||||
<TableHead class="w-[130px] h-12 font-semibold">
|
||||
使用统计
|
||||
@@ -114,7 +114,7 @@
|
||||
<TableHead class="w-[140px] h-12 font-semibold">
|
||||
最近使用
|
||||
</TableHead>
|
||||
<TableHead class="w-[70px] h-12 font-semibold text-center">
|
||||
<TableHead class="w-[180px] h-12 font-semibold">
|
||||
状态
|
||||
</TableHead>
|
||||
<TableHead class="w-[130px] h-12 font-semibold text-center">
|
||||
@@ -189,12 +189,28 @@
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="text-xs">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-mono font-medium">${{ (apiKey.balance_used_usd || 0).toFixed(2) }}</span>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span :class="isBalanceLimited(apiKey) ? 'font-mono font-medium text-primary' : 'font-mono text-muted-foreground'">
|
||||
{{ isBalanceLimited(apiKey) ? `$${(apiKey.current_balance_usd || 0).toFixed(2)}` : '无限' }}
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span>余额:</span>
|
||||
<Badge
|
||||
v-if="isApiKeyUnlimited(apiKey)"
|
||||
variant="secondary"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
无限额度
|
||||
</Badge>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm font-semibold tabular-nums"
|
||||
:class="isNegativeWalletAmount(getApiKeyWalletTotalBalance(apiKey)) ? 'text-rose-600' : 'text-foreground'"
|
||||
>
|
||||
{{ formatWalletAmount(getApiKeyWalletTotalBalance(apiKey), '-') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px] text-muted-foreground flex-wrap">
|
||||
<span>
|
||||
已消费:
|
||||
<span class="font-medium tabular-nums text-foreground">${{ getApiKeyWalletConsumed(apiKey).toFixed(2) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,20 +258,20 @@
|
||||
>暂无记录</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<TableCell class="py-4">
|
||||
<div class="flex flex-col items-start gap-1.5">
|
||||
<Badge
|
||||
:variant="apiKey.is_active ? 'success' : 'destructive'"
|
||||
class="font-medium"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ apiKey.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="apiKey.is_locked"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
v-if="getApiKeyWallet(apiKey.id)"
|
||||
:variant="walletStatusBadge(getApiKeyWalletStatus(apiKey.id))"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
已锁定
|
||||
{{ walletStatusLabel(getApiKeyWalletStatus(apiKey.id)) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -274,36 +290,11 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="调整余额"
|
||||
title="资金操作"
|
||||
@click="openAddBalanceDialog(apiKey)"
|
||||
>
|
||||
<DollarSign class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="重置额度"
|
||||
@click="resetKeyUsage(apiKey)"
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="apiKey.is_locked ? '解锁' : '锁定'"
|
||||
@click="toggleLockApiKey(apiKey)"
|
||||
>
|
||||
<Lock
|
||||
v-if="apiKey.is_locked"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<LockOpen
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -329,207 +320,207 @@
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="xl:hidden divide-y divide-border/40">
|
||||
<div class="xl:hidden bg-muted/[0.14] p-3 sm:p-4">
|
||||
<div
|
||||
v-if="apiKeys.length === 0"
|
||||
class="p-8 text-center"
|
||||
v-if="filteredApiKeys.length === 0"
|
||||
class="rounded-2xl border border-dashed border-border/60 bg-card/70 px-6 py-10 text-center"
|
||||
>
|
||||
<Key class="h-12 w-12 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p class="text-muted-foreground">
|
||||
暂无独立余额 Key
|
||||
<Key class="mx-auto mb-3 h-12 w-12 text-muted-foreground/50" />
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ hasActiveFilters ? '未找到匹配的 Key' : '暂无独立余额 Key' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="hasActiveFilters"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
尝试调整筛选条件
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="apiKey in apiKeys"
|
||||
:key="apiKey.id"
|
||||
class="p-4 sm:p-5 hover:bg-muted/30 transition-colors"
|
||||
v-else
|
||||
class="space-y-3.5"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="inline-flex rounded-lg bg-muted px-3 py-1.5 text-xs font-mono font-semibold">
|
||||
{{ apiKey.key_display || 'sk-****' }}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 hover:bg-muted flex-shrink-0"
|
||||
title="复制完整密钥"
|
||||
@click="copyKeyPrefix(apiKey)"
|
||||
<div
|
||||
v-for="apiKey in filteredApiKeys"
|
||||
:key="apiKey.id"
|
||||
class="rounded-2xl border border-border/60 bg-card/95 p-4 shadow-[0_10px_26px_-22px_hsl(var(--foreground))]"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="inline-flex max-w-[190px] sm:max-w-[240px] truncate rounded-lg bg-muted px-3 py-1.5 text-[11px] font-mono font-semibold text-foreground/90">
|
||||
{{ apiKey.key_display || 'sk-****' }}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 flex-shrink-0 hover:bg-muted"
|
||||
title="复制完整密钥"
|
||||
@click="copyKeyPrefix(apiKey)"
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="truncate text-sm font-semibold text-foreground"
|
||||
:class="{ 'text-muted-foreground': !apiKey.name }"
|
||||
:title="apiKey.name || '未命名 Key'"
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-semibold text-foreground"
|
||||
:class="{ 'text-muted-foreground': !apiKey.name }"
|
||||
>
|
||||
{{ apiKey.name || '未命名 Key' }}
|
||||
{{ apiKey.name || '未命名 Key' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
:variant="apiKey.is_active ? 'success' : 'destructive'"
|
||||
class="text-xs flex-shrink-0"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ apiKey.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="apiKey.is_locked"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
v-if="getApiKeyWallet(apiKey.id)"
|
||||
:variant="walletStatusBadge(getApiKeyWalletStatus(apiKey.id))"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
已锁定
|
||||
{{ walletStatusLabel(getApiKeyWalletStatus(apiKey.id)) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="apiKey.auto_delete_on_expiry"
|
||||
variant="secondary"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
过期自动删除
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
<span class="inline-flex items-center gap-1 rounded-full border border-border/60 px-2.5 py-0.5">
|
||||
{{ isBalanceLimited(apiKey) ? '限额 Key' : '无限额度' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="apiKey.auto_delete_on_expiry"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-0.5"
|
||||
>
|
||||
过期自动删除
|
||||
</span>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border/60 bg-muted/40 p-3.5">
|
||||
<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="isApiKeyUnlimited(apiKey)"
|
||||
variant="secondary"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
无限额度
|
||||
</Badge>
|
||||
<p
|
||||
v-else
|
||||
class="text-base font-semibold tabular-nums leading-none"
|
||||
:class="isNegativeWalletAmount(getApiKeyWalletTotalBalance(apiKey)) ? 'text-rose-600' : 'text-foreground'"
|
||||
>
|
||||
{{ formatWalletAmount(getApiKeyWalletTotalBalance(apiKey), '-') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
已消费:
|
||||
</p>
|
||||
<p class="text-sm font-medium tabular-nums text-foreground">
|
||||
${{ getApiKeyWalletConsumed(apiKey).toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 p-3 bg-muted/50 rounded-lg text-xs">
|
||||
<div class="flex items-center justify-between text-muted-foreground">
|
||||
<span>已用</span>
|
||||
<span class="font-semibold">${{ (apiKey.balance_used_usd || 0).toFixed(2) }}</span>
|
||||
<div class="grid grid-cols-2 gap-2.5 text-xs">
|
||||
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
|
||||
<div class="mb-1 text-muted-foreground">
|
||||
速率限制
|
||||
</div>
|
||||
<div class="font-semibold text-foreground">
|
||||
{{ apiKey.rate_limit ? `${apiKey.rate_limit}/min` : '未设置' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
|
||||
<div class="mb-1 text-muted-foreground">
|
||||
请求次数
|
||||
</div>
|
||||
<div class="font-semibold text-foreground">
|
||||
{{ (apiKey.total_requests || 0).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2 rounded-lg border border-border/50 bg-background/70 p-2.5">
|
||||
<div class="mb-1 text-muted-foreground">
|
||||
有效期
|
||||
</div>
|
||||
<div class="font-semibold text-foreground">
|
||||
{{ apiKey.expires_at ? formatDate(apiKey.expires_at) : '永不过期' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="apiKey.expires_at"
|
||||
class="text-[11px] text-muted-foreground"
|
||||
>
|
||||
{{ getRelativeTime(apiKey.expires_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-muted-foreground">
|
||||
<span>剩余</span>
|
||||
<span :class="getBalanceRemaining(apiKey) > 0 ? 'font-semibold text-emerald-600' : 'font-semibold text-rose-600'">
|
||||
{{ isBalanceLimited(apiKey) ? `$${getBalanceRemaining(apiKey).toFixed(2)}` : '无限制' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-amber-600">
|
||||
<span>总费用</span>
|
||||
<span>${{ (apiKey.total_cost_usd || 0).toFixed(4) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isBalanceLimited(apiKey)"
|
||||
class="h-1.5 rounded-full bg-background/40 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-emerald-500"
|
||||
:style="{ width: `${getBalanceProgress(apiKey)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="p-2 bg-muted/40 rounded-lg">
|
||||
<div class="text-muted-foreground mb-1">
|
||||
速率限制
|
||||
<div class="rounded-lg bg-muted/35 p-2.5 text-[11px] text-muted-foreground">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span>创建</span>
|
||||
<span class="font-medium text-foreground">{{ formatDate(apiKey.created_at) }}</span>
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ apiKey.rate_limit ? `${apiKey.rate_limit}/min` : '未设置' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 bg-muted/40 rounded-lg">
|
||||
<div class="text-muted-foreground mb-1">
|
||||
请求次数
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ (apiKey.total_requests || 0).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 bg-muted/40 rounded-lg col-span-2">
|
||||
<div class="text-muted-foreground mb-1">
|
||||
有效期
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ apiKey.expires_at ? formatDate(apiKey.expires_at) : '永不过期' }}
|
||||
<div class="mt-1 flex items-center justify-between gap-2">
|
||||
<span>最近使用</span>
|
||||
<span
|
||||
v-if="apiKey.last_used_at"
|
||||
class="font-medium text-foreground"
|
||||
>{{ formatDate(apiKey.last_used_at) }}</span>
|
||||
<span v-else>暂无记录</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="apiKey.expires_at"
|
||||
class="text-[11px] text-muted-foreground"
|
||||
class="mt-1 flex items-center justify-between gap-2"
|
||||
>
|
||||
{{ getRelativeTime(apiKey.expires_at) }}
|
||||
<span>过期后</span>
|
||||
<span>{{ apiKey.auto_delete_on_expiry ? '自动删除' : '仅禁用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<p>创建: {{ formatDate(apiKey.created_at) }}</p>
|
||||
<p>
|
||||
最近使用:
|
||||
<span
|
||||
v-if="apiKey.last_used_at"
|
||||
class="font-medium text-foreground"
|
||||
>{{ formatDate(apiKey.last_used_at) }}</span>
|
||||
<span v-else>暂无记录</span>
|
||||
</p>
|
||||
<p v-if="apiKey.expires_at">
|
||||
过期后: {{ apiKey.auto_delete_on_expiry ? '自动删除' : '仅禁用' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="editApiKey(apiKey)"
|
||||
>
|
||||
<SquarePen class="h-3.5 w-3.5 mr-1.5" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-blue-600"
|
||||
@click="openAddBalanceDialog(apiKey)"
|
||||
>
|
||||
<DollarSign class="h-3.5 w-3.5 mr-1.5" />
|
||||
调整
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-amber-600"
|
||||
@click="resetKeyUsage(apiKey)"
|
||||
>
|
||||
<RotateCcw class="h-3.5 w-3.5 mr-1.5" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="toggleLockApiKey(apiKey)"
|
||||
>
|
||||
<Lock
|
||||
v-if="apiKey.is_locked"
|
||||
class="h-3.5 w-3.5 mr-1.5"
|
||||
/>
|
||||
<LockOpen
|
||||
v-else
|
||||
class="h-3.5 w-3.5 mr-1.5"
|
||||
/>
|
||||
{{ apiKey.is_locked ? '解锁' : '锁定' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="toggleApiKey(apiKey)"
|
||||
>
|
||||
<Power class="h-3.5 w-3.5 mr-1.5" />
|
||||
{{ apiKey.is_active ? '禁用' : '启用' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-rose-600 col-span-2"
|
||||
@click="deleteApiKey(apiKey)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5 mr-1.5" />
|
||||
删除
|
||||
</Button>
|
||||
<div class="grid grid-cols-2 gap-2 pt-0.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@click="editApiKey(apiKey)"
|
||||
>
|
||||
<SquarePen class="mr-1.5 h-3.5 w-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@click="openAddBalanceDialog(apiKey)"
|
||||
>
|
||||
<DollarSign class="mr-1.5 h-3.5 w-3.5" />
|
||||
资金
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@click="toggleApiKey(apiKey)"
|
||||
>
|
||||
<Power class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ apiKey.is_active ? '禁用' : '启用' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="col-span-2 h-8 border-rose-200 text-xs text-rose-600 hover:bg-rose-50 dark:border-rose-900/60 dark:hover:bg-rose-950/40"
|
||||
@click="deleteApiKey(apiKey)"
|
||||
>
|
||||
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -611,96 +602,17 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 余额调整对话框 -->
|
||||
<Dialog
|
||||
v-model="showAddBalanceDialog"
|
||||
size="md"
|
||||
>
|
||||
<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 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/30 flex-shrink-0">
|
||||
<DollarSign class="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||
余额调整
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
增加或扣除 API Key 余额
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="p-3 bg-muted/50 rounded-lg text-sm">
|
||||
<div class="font-medium mb-2">
|
||||
当前余额信息
|
||||
</div>
|
||||
<div class="space-y-1 text-xs text-muted-foreground">
|
||||
<div>已用: <span class="font-semibold text-foreground">${{ (addBalanceKey.balance_used_usd || 0).toFixed(2) }}</span></div>
|
||||
<div>当前余额: <span class="font-semibold text-foreground">${{ (addBalanceKey.current_balance_usd || 0).toFixed(2) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="addBalanceAmount"
|
||||
class="text-sm font-medium"
|
||||
>调整金额 (USD)</Label>
|
||||
<Input
|
||||
id="addBalanceAmount"
|
||||
:model-value="addBalanceAmount ?? ''"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="正数为增加,负数为扣除"
|
||||
class="h-11"
|
||||
@update:model-value="(v) => addBalanceAmount = parseNumberInput(v, { allowFloat: true })"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span
|
||||
v-if="addBalanceAmount && addBalanceAmount > 0"
|
||||
class="text-emerald-600"
|
||||
>
|
||||
增加 ${{ addBalanceAmount.toFixed(2) }},调整后余额: ${{ ((addBalanceKey.current_balance_usd || 0) + addBalanceAmount).toFixed(2) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="addBalanceAmount && addBalanceAmount < 0"
|
||||
class="text-rose-600"
|
||||
>
|
||||
扣除 ${{ Math.abs(addBalanceAmount).toFixed(2) }},调整后余额: ${{ Math.max(0, (addBalanceKey.current_balance_usd || 0) + addBalanceAmount).toFixed(2) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
输入正数增加余额,负数扣除余额
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-10 px-5"
|
||||
@click="showAddBalanceDialog = false"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="addingBalance || !addBalanceAmount || addBalanceAmount === 0"
|
||||
class="h-10 px-5"
|
||||
@click="handleAddBalance"
|
||||
>
|
||||
{{ addingBalance ? '调整中...' : '确认调整' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<WalletOpsDrawer
|
||||
:open="showWalletActionDrawer"
|
||||
:wallet="walletActionTarget?.wallet || null"
|
||||
:owner-name="walletActionTarget?.apiKey.name || walletActionTarget?.apiKey.key_display || '未命名 Key'"
|
||||
:owner-subtitle="walletActionTarget?.apiKey.key_display || walletActionTarget?.apiKey.username || ''"
|
||||
context-label="独立密钥钱包"
|
||||
accent="blue"
|
||||
:show-refunds="false"
|
||||
@close="closeWalletActionDrawer"
|
||||
@changed="handleWalletDrawerChanged"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -710,6 +622,9 @@ import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
|
||||
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
||||
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
|
||||
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -743,14 +658,10 @@ import {
|
||||
Copy,
|
||||
CheckCircle,
|
||||
SquarePen,
|
||||
Search,
|
||||
Lock,
|
||||
LockOpen,
|
||||
RotateCcw
|
||||
Search
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
@@ -759,6 +670,7 @@ const { confirmDanger } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const apiKeys = ref<AdminApiKey[]>([])
|
||||
const apiKeyWalletMap = ref<Record<string, AdminWallet>>({})
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
@@ -839,19 +751,11 @@ const filteredApiKeys = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
// 充值相关状态
|
||||
const showAddBalanceDialog = ref(false)
|
||||
const addBalanceKey = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
balance_used_usd: 0,
|
||||
current_balance_usd: 0
|
||||
})
|
||||
const addBalanceAmount = ref<number | undefined>(undefined)
|
||||
const addingBalance = ref(false)
|
||||
const showWalletActionDrawer = ref(false)
|
||||
const walletActionTarget = ref<{ apiKey: AdminApiKey; wallet: AdminWallet } | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadApiKeys()
|
||||
await refreshApiKeys()
|
||||
})
|
||||
|
||||
async function loadApiKeys() {
|
||||
@@ -871,9 +775,29 @@ async function loadApiKeys() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKeyWallets() {
|
||||
try {
|
||||
const wallets = await adminWalletApi.listAllWallets()
|
||||
apiKeyWalletMap.value = wallets
|
||||
.filter((wallet) => wallet.owner_type === 'api_key' && !!wallet.api_key_id)
|
||||
.reduce<Record<string, AdminWallet>>((acc, wallet) => {
|
||||
acc[wallet.api_key_id as string] = wallet
|
||||
return acc
|
||||
}, {})
|
||||
} catch (err: unknown) {
|
||||
log.error('加载独立 Key 钱包失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshApiKeys() {
|
||||
// 先拉取 Key 列表,再拉钱包,避免并发请求导致新钱包映射短暂缺失。
|
||||
await loadApiKeys()
|
||||
await loadApiKeyWallets()
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
loadApiKeys()
|
||||
refreshApiKeys()
|
||||
}
|
||||
|
||||
async function toggleApiKey(apiKey: AdminApiKey) {
|
||||
@@ -890,20 +814,6 @@ async function toggleApiKey(apiKey: AdminApiKey) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLockApiKey(apiKey: AdminApiKey) {
|
||||
try {
|
||||
const response = await adminApi.toggleLockApiKey(apiKey.id)
|
||||
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
|
||||
if (index !== -1) {
|
||||
apiKeys.value[index].is_locked = response.is_locked
|
||||
}
|
||||
success(response.message)
|
||||
} catch (err: unknown) {
|
||||
log.error('切换密钥锁定状态失败:', err)
|
||||
error(parseApiError(err, '操作失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApiKey(apiKey: AdminApiKey) {
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要删除这个独立余额 Key 吗?\n\n${apiKey.name || apiKey.key_display || 'sk-****'}\n\n此操作无法撤销。`,
|
||||
@@ -916,6 +826,7 @@ async function deleteApiKey(apiKey: AdminApiKey) {
|
||||
const response = await adminApi.deleteApiKey(apiKey.id)
|
||||
apiKeys.value = apiKeys.value.filter(k => k.id !== apiKey.id)
|
||||
total.value = total.value - 1
|
||||
delete apiKeyWalletMap.value[apiKey.id]
|
||||
success(response.message)
|
||||
} catch (err: unknown) {
|
||||
log.error('删除密钥失败:', err)
|
||||
@@ -936,6 +847,8 @@ function editApiKey(apiKey: AdminApiKey) {
|
||||
editingKeyData.value = {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name || '',
|
||||
initial_balance_usd: isApiKeyUnlimited(apiKey) ? undefined : (getApiKeyWalletTotalBalance(apiKey) ?? undefined),
|
||||
unlimited_balance: isApiKeyUnlimited(apiKey),
|
||||
expires_at: expiresAt,
|
||||
rate_limit: apiKey.rate_limit ?? undefined,
|
||||
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
|
||||
@@ -947,63 +860,73 @@ function editApiKey(apiKey: AdminApiKey) {
|
||||
showKeyFormDialog.value = true
|
||||
}
|
||||
|
||||
function getApiKeyWallet(apiKeyId: string): AdminWallet | null {
|
||||
return apiKeyWalletMap.value[apiKeyId] || null
|
||||
}
|
||||
|
||||
function isApiKeyUnlimited(apiKey: AdminApiKey): boolean {
|
||||
const wallet = getApiKeyWallet(apiKey.id)
|
||||
return wallet?.limit_mode === 'unlimited' || wallet?.unlimited === true
|
||||
}
|
||||
|
||||
function getApiKeyWalletTotalBalance(apiKey: AdminApiKey): number | null {
|
||||
if (isApiKeyUnlimited(apiKey)) {
|
||||
return null
|
||||
}
|
||||
const wallet = getApiKeyWallet(apiKey.id)
|
||||
return wallet ? wallet.balance : 0
|
||||
}
|
||||
|
||||
function getApiKeyWalletConsumed(apiKey: AdminApiKey): number {
|
||||
return getApiKeyWallet(apiKey.id)?.total_consumed ?? (apiKey.total_cost_usd || 0)
|
||||
}
|
||||
|
||||
function getApiKeyWalletStatus(apiKeyId: string): string | null {
|
||||
return getApiKeyWallet(apiKeyId)?.status ?? null
|
||||
}
|
||||
|
||||
function formatWalletAmount(value: number | null, nullLabel = '无限制'): string {
|
||||
if (value == null) {
|
||||
return nullLabel
|
||||
}
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
function isNegativeWalletAmount(value: number | null): boolean {
|
||||
return typeof value === 'number' && value < 0
|
||||
}
|
||||
|
||||
function openAddBalanceDialog(apiKey: AdminApiKey) {
|
||||
addBalanceKey.value = {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name || apiKey.key_display || 'sk-****',
|
||||
balance_used_usd: apiKey.balance_used_usd || 0,
|
||||
current_balance_usd: apiKey.current_balance_usd || 0
|
||||
}
|
||||
|
||||
addBalanceAmount.value = undefined
|
||||
showAddBalanceDialog.value = true
|
||||
}
|
||||
|
||||
async function handleAddBalance() {
|
||||
if (!addBalanceAmount.value || addBalanceAmount.value === 0) {
|
||||
error('调整金额不能为 0')
|
||||
const wallet = getApiKeyWallet(apiKey.id)
|
||||
if (!wallet) {
|
||||
error('该独立 Key 的钱包尚未初始化,暂时无法进行资金操作')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证扣除金额不能超过当前余额
|
||||
if (addBalanceAmount.value < 0 && Math.abs(addBalanceAmount.value) > (addBalanceKey.value.current_balance_usd || 0)) {
|
||||
error('扣除金额不能超过当前余额')
|
||||
walletActionTarget.value = {
|
||||
apiKey,
|
||||
wallet
|
||||
}
|
||||
showWalletActionDrawer.value = true
|
||||
}
|
||||
|
||||
function closeWalletActionDrawer() {
|
||||
showWalletActionDrawer.value = false
|
||||
}
|
||||
|
||||
async function handleWalletDrawerChanged() {
|
||||
await refreshApiKeys()
|
||||
if (!walletActionTarget.value) {
|
||||
return
|
||||
}
|
||||
|
||||
addingBalance.value = true
|
||||
try {
|
||||
const response = await adminApi.addApiKeyBalance(addBalanceKey.value.id, addBalanceAmount.value)
|
||||
|
||||
// 重新加载列表
|
||||
await loadApiKeys()
|
||||
|
||||
showAddBalanceDialog.value = false
|
||||
const action = addBalanceAmount.value > 0 ? '增加' : '扣除'
|
||||
const amount = Math.abs(addBalanceAmount.value).toFixed(2)
|
||||
success(response.message || `余额${action}成功,${action} $${amount}`)
|
||||
} catch (err: unknown) {
|
||||
log.error('余额调整失败:', err)
|
||||
error(parseApiError(err, '调整失败'))
|
||||
} finally {
|
||||
addingBalance.value = false
|
||||
const latestKey = apiKeys.value.find((item) => item.id === walletActionTarget.value?.apiKey.id)
|
||||
const latestWallet = getApiKeyWallet(walletActionTarget.value.apiKey.id)
|
||||
if (latestKey) {
|
||||
walletActionTarget.value.apiKey = latestKey
|
||||
}
|
||||
}
|
||||
|
||||
async function resetKeyUsage(apiKey: AdminApiKey) {
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要重置此 Key 的已使用额度吗?\n\n${apiKey.name || apiKey.key_display || 'sk-****'}\n\n已使用额度将归零,当前余额不变。`,
|
||||
'重置使用额度'
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const response = await adminApi.resetApiKeyUsage(apiKey.id)
|
||||
await loadApiKeys()
|
||||
success(response.message)
|
||||
} catch (err: unknown) {
|
||||
log.error('重置使用额度失败:', err)
|
||||
error(parseApiError(err, '重置失败'))
|
||||
if (latestWallet) {
|
||||
walletActionTarget.value.wallet = latestWallet
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1032,27 +955,7 @@ function closeNewKeyDialog() {
|
||||
}
|
||||
|
||||
function isBalanceLimited(apiKey: AdminApiKey): boolean {
|
||||
return apiKey.current_balance_usd !== null && apiKey.current_balance_usd !== undefined
|
||||
}
|
||||
|
||||
function getBalanceProgress(apiKey: AdminApiKey): number {
|
||||
if (!isBalanceLimited(apiKey)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 总额 = 当前余额 + 已使用
|
||||
const used = apiKey.balance_used_usd || 0
|
||||
const remaining = apiKey.current_balance_usd || 0
|
||||
const total = used + remaining
|
||||
|
||||
if (total <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 进度条显示剩余比例(绿色部分)
|
||||
const ratio = (remaining / total) * 100
|
||||
const normalized = Number.isFinite(ratio) ? ratio : 0
|
||||
return Math.max(0, Math.min(100, normalized))
|
||||
return !isApiKeyUnlimited(apiKey)
|
||||
}
|
||||
|
||||
function isExpiringSoon(apiKey: AdminApiKey): boolean {
|
||||
@@ -1066,15 +969,6 @@ function isExpiringSoon(apiKey: AdminApiKey): boolean {
|
||||
return diffDays > 0 && diffDays <= EXPIRY_SOON_DAYS
|
||||
}
|
||||
|
||||
function getBalanceRemaining(apiKey: AdminApiKey): number {
|
||||
// 计算剩余余额 = 当前余额 - 已使用余额
|
||||
if (apiKey.current_balance_usd === null || apiKey.current_balance_usd === undefined) {
|
||||
return 0
|
||||
}
|
||||
const remaining = apiKey.current_balance_usd - (apiKey.balance_used_usd || 0)
|
||||
return Math.max(0, remaining) // 不能为负数
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
@@ -1133,6 +1027,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||
// 更新
|
||||
const updateData: Partial<CreateStandaloneApiKeyRequest> = {
|
||||
name: data.name || undefined,
|
||||
unlimited_balance: Boolean(data.unlimited_balance),
|
||||
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
|
||||
expires_at: data.expires_at || null, // undefined/空 = 永不过期
|
||||
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
||||
@@ -1141,22 +1036,27 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
allowed_models: data.allowed_models
|
||||
}
|
||||
const { message: _, ...updated } = await adminApi.updateApiKey(data.id, updateData)
|
||||
// 局部更新:直接替换列表中对应的记录
|
||||
const { message: _, wallet: __, ...updated } = await adminApi.updateApiKey(data.id, updateData)
|
||||
// 局部更新:合并字段,避免覆盖丢失列表已有信息
|
||||
const index = apiKeys.value.findIndex(k => k.id === data.id)
|
||||
if (index !== -1) {
|
||||
apiKeys.value[index] = updated
|
||||
apiKeys.value[index] = {
|
||||
...apiKeys.value[index],
|
||||
...updated,
|
||||
}
|
||||
}
|
||||
await loadApiKeyWallets()
|
||||
success('API Key 更新成功')
|
||||
} else {
|
||||
// 创建
|
||||
if (!data.initial_balance_usd || data.initial_balance_usd <= 0) {
|
||||
const isUnlimited = Boolean(data.unlimited_balance)
|
||||
if (!isUnlimited && (!data.initial_balance_usd || data.initial_balance_usd <= 0)) {
|
||||
error('初始余额必须大于 0')
|
||||
return
|
||||
}
|
||||
const createData: CreateStandaloneApiKeyRequest = {
|
||||
name: data.name || undefined,
|
||||
initial_balance_usd: data.initial_balance_usd,
|
||||
initial_balance_usd: isUnlimited ? null : (data.initial_balance_usd as number),
|
||||
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
|
||||
expires_at: data.expires_at || null, // undefined/空 = 永不过期
|
||||
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
||||
@@ -1169,7 +1069,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||
newKeyValue.value = response.key
|
||||
showNewKeyDialog.value = true
|
||||
success('独立 Key 创建成功')
|
||||
await loadApiKeys()
|
||||
await refreshApiKeys()
|
||||
}
|
||||
closeKeyFormDialog()
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<!-- 基础配置 -->
|
||||
<BasicConfigSection
|
||||
id="section-basic"
|
||||
:default-user-quota-usd="systemConfig.default_user_quota_usd"
|
||||
:default-user-initial-gift-usd="systemConfig.default_user_initial_gift_usd"
|
||||
:rate-limit-per-minute="systemConfig.rate_limit_per_minute"
|
||||
:enable-registration="systemConfig.enable_registration"
|
||||
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
|
||||
@@ -64,7 +64,7 @@
|
||||
:loading="basicConfigLoading"
|
||||
:has-changes="hasBasicConfigChanges"
|
||||
@save="saveBasicConfig"
|
||||
@update:default-user-quota-usd="systemConfig.default_user_quota_usd = $event"
|
||||
@update:default-user-initial-gift-usd="systemConfig.default_user_initial_gift_usd = $event"
|
||||
@update:rate-limit-per-minute="systemConfig.rate_limit_per_minute = $event"
|
||||
@update:enable-registration="systemConfig.enable_registration = $event"
|
||||
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
|
||||
@@ -113,15 +113,6 @@
|
||||
<ScheduledTasksSection
|
||||
id="section-scheduled"
|
||||
:scheduled-tasks="scheduledTasks"
|
||||
:quota-reset-interval-days="systemConfig.user_quota_reset_interval_days"
|
||||
:standalone-key-reset-interval-days="systemConfig.standalone_key_quota_reset_interval_days"
|
||||
:standalone-key-reset-mode="systemConfig.standalone_key_quota_reset_mode"
|
||||
:standalone-key-reset-key-ids="systemConfig.standalone_key_quota_reset_key_ids"
|
||||
:standalone-keys="standaloneKeys"
|
||||
@update:quota-reset-interval-days="systemConfig.user_quota_reset_interval_days = $event"
|
||||
@update:standalone-key-reset-interval-days="systemConfig.standalone_key_quota_reset_interval_days = $event"
|
||||
@update:standalone-key-reset-mode="handleStandaloneKeyResetModeChange"
|
||||
@toggle-standalone-key-reset-key-id="handleToggleStandaloneKeyResetKeyId"
|
||||
/>
|
||||
|
||||
<!-- 系统版本信息 -->
|
||||
@@ -201,7 +192,6 @@
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { PageHeader, PageContainer } from '@/components/layout'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
// Composables
|
||||
import { useSystemConfig } from './system-settings/composables/useSystemConfig'
|
||||
@@ -344,50 +334,13 @@ const {
|
||||
const {
|
||||
scheduledTasks,
|
||||
initPreviousValues,
|
||||
saveStandaloneKeyResetMode,
|
||||
saveStandaloneKeyResetKeyIds,
|
||||
} = useScheduledTasks(systemConfig)
|
||||
|
||||
// 独立密钥列表(用于定时任务配置中的密钥选择)
|
||||
const standaloneKeys = ref<Array<{ id: string; name?: string; key_display?: string; current_balance_usd?: number | null }>>([])
|
||||
|
||||
async function loadStandaloneKeys() {
|
||||
try {
|
||||
const result = await adminApi.getAllApiKeys({ limit: 2000 })
|
||||
standaloneKeys.value = result.api_keys.map((k) => ({
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
key_display: k.key_display,
|
||||
current_balance_usd: k.current_balance_usd,
|
||||
}))
|
||||
} catch {
|
||||
// 加载失败不影响其他功能
|
||||
}
|
||||
}
|
||||
|
||||
function handleStandaloneKeyResetModeChange(mode: string) {
|
||||
systemConfig.value.standalone_key_quota_reset_mode = mode
|
||||
saveStandaloneKeyResetMode(mode)
|
||||
}
|
||||
|
||||
function handleToggleStandaloneKeyResetKeyId(keyId: string) {
|
||||
const ids = [...systemConfig.value.standalone_key_quota_reset_key_ids]
|
||||
const idx = ids.indexOf(keyId)
|
||||
if (idx >= 0) {
|
||||
ids.splice(idx, 1)
|
||||
} else {
|
||||
ids.push(keyId)
|
||||
}
|
||||
systemConfig.value.standalone_key_quota_reset_key_ids = ids
|
||||
saveStandaloneKeyResetKeyIds(ids)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadSystemConfig(),
|
||||
loadSystemVersion(),
|
||||
proxyNodesStore.ensureLoaded(),
|
||||
loadStandaloneKeys(),
|
||||
])
|
||||
// 配置加载完成后初始化定时任务的原始值
|
||||
initPreviousValues()
|
||||
|
||||
@@ -173,22 +173,19 @@
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="w-[200px] h-12 font-semibold">
|
||||
<TableHead class="w-[260px] h-12 font-semibold">
|
||||
用户信息
|
||||
</TableHead>
|
||||
<TableHead class="w-[180px] h-12 font-semibold">
|
||||
邮箱
|
||||
<TableHead class="w-[240px] h-12 font-semibold">
|
||||
钱包
|
||||
</TableHead>
|
||||
<TableHead class="w-[180px] h-12 font-semibold">
|
||||
<TableHead class="w-[170px] h-12 font-semibold">
|
||||
使用统计
|
||||
</TableHead>
|
||||
<TableHead class="w-[180px] h-12 font-semibold">
|
||||
配额(美元)
|
||||
</TableHead>
|
||||
<TableHead class="w-[110px] h-12 font-semibold">
|
||||
创建时间
|
||||
</TableHead>
|
||||
<TableHead class="w-[90px] h-12 font-semibold text-center">
|
||||
<TableHead class="w-[180px] h-12 font-semibold">
|
||||
状态
|
||||
</TableHead>
|
||||
<TableHead class="w-[220px] h-12 font-semibold text-center">
|
||||
@@ -210,28 +207,55 @@
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="truncate text-sm font-semibold mb-1"
|
||||
:title="user.username"
|
||||
>
|
||||
{{ user.username }}
|
||||
<div class="mb-1 flex items-center gap-1.5">
|
||||
<div
|
||||
class="truncate text-sm font-semibold"
|
||||
:title="user.username"
|
||||
>
|
||||
{{ user.username }}
|
||||
</div>
|
||||
<Badge
|
||||
:variant="user.role === 'admin' ? 'default' : 'secondary'"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
|
||||
>
|
||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="user.role === 'admin' ? 'default' : 'secondary'"
|
||||
class="text-xs px-2 py-0.5"
|
||||
<div
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
:title="user.email || '-'"
|
||||
>
|
||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</Badge>
|
||||
{{ user.email || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<span
|
||||
class="block truncate text-sm text-muted-foreground"
|
||||
:title="user.email || '-'"
|
||||
>
|
||||
{{ user.email || '-' }}
|
||||
</span>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span>余额:</span>
|
||||
<Badge
|
||||
v-if="isUserUnlimited(user)"
|
||||
variant="secondary"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
无限额度
|
||||
</Badge>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm font-semibold tabular-nums"
|
||||
:class="isNegativeWalletValue(getUserWalletTotalBalance(user)) ? 'text-rose-600' : 'text-foreground'"
|
||||
>
|
||||
{{ formatCurrencyValue(getUserWalletTotalBalance(user), '-') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px] text-muted-foreground flex-wrap">
|
||||
<span>
|
||||
已消费:
|
||||
<span class="font-medium tabular-nums text-foreground">${{ getUserWalletConsumed(user).toFixed(2) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div
|
||||
@@ -255,35 +279,25 @@
|
||||
<span v-else>无数据</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="space-y-1.5 text-xs">
|
||||
<div
|
||||
v-if="user.quota_usd != null"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
当前: <span class="font-semibold text-foreground">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="font-medium">${{ user.quota_usd.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
当前: <span class="font-semibold text-foreground">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="font-medium text-amber-600">无限制</span>
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
累计: <span class="font-medium text-foreground">${{ (user.total_usd || 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-xs text-muted-foreground">
|
||||
{{ formatDate(user.created_at) }}
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge
|
||||
:variant="user.is_active ? 'success' : 'destructive'"
|
||||
class="font-medium px-3 py-1"
|
||||
>
|
||||
{{ user.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex flex-col items-start gap-1.5">
|
||||
<Badge
|
||||
:variant="user.is_active ? 'success' : 'destructive'"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ user.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getUserWallet(user.id)"
|
||||
:variant="walletStatusBadge(getUserWalletStatus(user.id))"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ walletStatusLabel(getUserWalletStatus(user.id)) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex justify-center gap-1">
|
||||
@@ -300,7 +314,16 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="查看API Keys"
|
||||
title="资金操作"
|
||||
@click="openWalletActionDialog(user)"
|
||||
>
|
||||
<DollarSign class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="API Keys"
|
||||
@click="manageApiKeys(user)"
|
||||
>
|
||||
<Key class="h-4 w-4" />
|
||||
@@ -321,15 +344,6 @@
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="重置配额"
|
||||
@click="resetQuota(user)"
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -347,145 +361,197 @@
|
||||
</div>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="xl:hidden divide-y divide-border/40">
|
||||
<div class="xl:hidden bg-muted/[0.14] p-3 sm:p-4">
|
||||
<div
|
||||
v-for="user in paginatedUsers"
|
||||
:key="user.id"
|
||||
class="p-4 sm:p-5 hover:bg-muted/30 transition-colors"
|
||||
v-if="paginatedUsers.length === 0"
|
||||
class="rounded-2xl border border-dashed border-border/60 bg-card/70 px-6 py-10 text-center"
|
||||
>
|
||||
<!-- 用户头部 -->
|
||||
<div class="flex items-start justify-between mb-3 sm:mb-4">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<Avatar class="h-10 w-10 sm:h-12 sm:w-12 ring-2 ring-background shadow-md flex-shrink-0">
|
||||
<AvatarFallback class="bg-primary text-sm sm:text-base font-bold text-white">
|
||||
{{ user.username.charAt(0).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-sm sm:text-base mb-1 truncate">
|
||||
{{ user.username }}
|
||||
<Avatar class="mx-auto mb-3 h-12 w-12">
|
||||
<AvatarFallback class="bg-muted text-base font-semibold text-muted-foreground">
|
||||
U
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ searchQuery || filterRole !== 'all' || filterStatus !== 'all' ? '未找到匹配的用户' : '暂无用户' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="searchQuery || filterRole !== 'all' || filterStatus !== 'all'"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
尝试调整筛选条件
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-3.5"
|
||||
>
|
||||
<div
|
||||
v-for="user in paginatedUsers"
|
||||
:key="user.id"
|
||||
class="rounded-2xl border border-border/60 bg-card/95 p-4 shadow-[0_10px_26px_-22px_hsl(var(--foreground))]"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md flex-shrink-0">
|
||||
<AvatarFallback class="bg-primary text-sm font-bold text-white">
|
||||
{{ user.username.charAt(0).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
class="truncate text-sm font-semibold text-foreground"
|
||||
:title="user.username"
|
||||
>
|
||||
{{ user.username }}
|
||||
</div>
|
||||
<Badge
|
||||
:variant="user.role === 'admin' ? 'default' : 'secondary'"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
|
||||
>
|
||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
class="truncate text-[11px] text-muted-foreground"
|
||||
:title="user.email || '-'"
|
||||
>
|
||||
{{ user.email || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
:variant="user.role === 'admin' ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
:variant="user.is_active ? 'success' : 'destructive'"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
{{ user.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getUserWallet(user.id)"
|
||||
:variant="walletStatusBadge(getUserWalletStatus(user.id))"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ walletStatusLabel(getUserWalletStatus(user.id)) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="user.is_active ? 'success' : 'destructive'"
|
||||
class="font-medium text-xs flex-shrink-0"
|
||||
>
|
||||
{{ user.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<div class="space-y-2 sm:space-y-3 mb-3 sm:mb-4">
|
||||
<div class="text-xs sm:text-sm">
|
||||
<span class="text-muted-foreground">邮箱:</span>
|
||||
<span class="ml-2 text-foreground truncate block sm:inline">{{ user.email || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="userStats[user.id]"
|
||||
class="grid grid-cols-2 gap-2 p-2 sm:p-3 bg-muted/50 rounded-lg text-xs"
|
||||
>
|
||||
<div>
|
||||
<div class="text-muted-foreground mb-1">
|
||||
请求次数
|
||||
</div>
|
||||
<div class="font-semibold text-sm text-foreground">
|
||||
{{ formatNumber(userStats[user.id]?.request_count) }}
|
||||
<div class="rounded-xl border border-border/60 bg-muted/40 p-3.5">
|
||||
<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)"
|
||||
variant="secondary"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
无限额度
|
||||
</Badge>
|
||||
<p
|
||||
v-else
|
||||
class="text-base font-semibold tabular-nums leading-none"
|
||||
:class="isNegativeWalletValue(getUserWalletTotalBalance(user)) ? 'text-rose-600' : 'text-foreground'"
|
||||
>
|
||||
{{ formatCurrencyValue(getUserWalletTotalBalance(user), '-') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
已消费:
|
||||
</p>
|
||||
<p class="text-sm font-medium tabular-nums text-foreground">
|
||||
${{ getUserWalletConsumed(user).toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground mb-1">
|
||||
Tokens
|
||||
|
||||
<div class="grid grid-cols-2 gap-2.5 text-xs">
|
||||
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
|
||||
<div class="mb-1 text-muted-foreground">
|
||||
请求次数
|
||||
</div>
|
||||
<div class="font-semibold text-foreground">
|
||||
{{ formatNumber(userStats[user.id]?.request_count) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-semibold text-sm text-foreground">
|
||||
{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}
|
||||
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
|
||||
<div class="mb-1 text-muted-foreground">
|
||||
Tokens
|
||||
</div>
|
||||
<div class="font-semibold text-foreground">
|
||||
{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2 sm:p-3 bg-muted/50 rounded-lg text-xs space-y-1">
|
||||
<div v-if="user.quota_usd != null">
|
||||
<span class="text-muted-foreground">当前配额:</span>
|
||||
<span class="ml-2 font-semibold text-sm">${{ (user.used_usd || 0).toFixed(2) }}</span> / ${{ user.quota_usd.toFixed(2) }}
|
||||
<div class="rounded-lg bg-muted/35 p-2.5 text-[11px] text-muted-foreground">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span>创建时间</span>
|
||||
<span class="font-medium text-foreground">{{ formatDate(user.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span class="text-muted-foreground">当前配额:</span>
|
||||
<span class="ml-2 font-semibold text-sm">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="text-amber-600">无限制</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">累计消费:</span>
|
||||
<span class="ml-2 font-semibold text-sm">${{ (user.total_usd || 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">创建时间:</span>
|
||||
<span class="ml-2 text-sm">{{ formatDate(user.created_at) }}</span>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 pt-0.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@click="editUser(user)"
|
||||
>
|
||||
<SquarePen class="mr-1.5 h-3.5 w-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@click="openWalletActionDialog(user)"
|
||||
>
|
||||
<DollarSign class="mr-1.5 h-3.5 w-3.5" />
|
||||
资金
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@click="manageApiKeys(user)"
|
||||
>
|
||||
<Key class="mr-1.5 h-3.5 w-3.5" />
|
||||
API Keys
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@click="toggleUserStatus(user)"
|
||||
>
|
||||
<PauseCircle
|
||||
v-if="user.is_active"
|
||||
class="mr-1.5 h-3.5 w-3.5"
|
||||
/>
|
||||
<PlayCircle
|
||||
v-else
|
||||
class="mr-1.5 h-3.5 w-3.5"
|
||||
/>
|
||||
{{ user.is_active ? '禁用' : '启用' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="col-span-2 h-8 border-rose-200 text-xs text-rose-600 hover:bg-rose-50 dark:border-rose-900/60 dark:hover:bg-rose-950/40"
|
||||
@click="deleteUser(user)"
|
||||
>
|
||||
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 - 响应式布局 -->
|
||||
<div class="grid grid-cols-2 sm:flex sm:flex-wrap gap-1.5 sm:gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-xs sm:text-sm h-8 sm:h-9 sm:flex-1 sm:min-w-[90px]"
|
||||
@click="editUser(user)"
|
||||
>
|
||||
<SquarePen class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
|
||||
<span class="hidden sm:inline">编辑</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-xs sm:text-sm h-8 sm:h-9 sm:flex-1 sm:min-w-[100px]"
|
||||
@click="manageApiKeys(user)"
|
||||
>
|
||||
<Key class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
|
||||
<span class="hidden sm:inline">API Keys</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-xs sm:text-sm h-8 sm:h-9 sm:flex-1 sm:min-w-[90px]"
|
||||
:class="user.is_active ? 'text-amber-600' : 'text-emerald-600'"
|
||||
@click="toggleUserStatus(user)"
|
||||
>
|
||||
<PauseCircle
|
||||
v-if="user.is_active"
|
||||
class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5"
|
||||
/>
|
||||
<PlayCircle
|
||||
v-else
|
||||
class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5"
|
||||
/>
|
||||
<span class="hidden sm:inline">{{ user.is_active ? '禁用' : '启用' }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-xs sm:text-sm h-8 sm:h-9"
|
||||
@click="resetQuota(user)"
|
||||
>
|
||||
<RotateCcw class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
|
||||
<span class="hidden sm:inline">重置</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="col-span-2 text-xs sm:text-sm h-8 sm:h-9 text-rose-600 sm:col-span-1"
|
||||
@click="deleteUser(user)"
|
||||
>
|
||||
<Trash2 class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
|
||||
<span class="hidden sm:inline">删除</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -659,6 +725,17 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<WalletOpsDrawer
|
||||
:open="showWalletActionDialogState"
|
||||
:wallet="walletActionTarget?.wallet || null"
|
||||
:owner-name="walletActionTarget?.user.username || ''"
|
||||
:owner-subtitle="walletActionTarget?.user.email || '未设置邮箱'"
|
||||
context-label="用户钱包"
|
||||
accent="emerald"
|
||||
@close="closeWalletActionDrawer"
|
||||
@changed="handleWalletDrawerChanged"
|
||||
/>
|
||||
|
||||
<!-- 新 API Key 显示对话框 -->
|
||||
<Dialog
|
||||
v-model="showNewApiKeyDialog"
|
||||
@@ -720,11 +797,13 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import type { User, ApiKey } from '@/api/users'
|
||||
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { usageApi, type UsageByUser } from '@/api/usage'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
|
||||
|
||||
// UI 组件
|
||||
import {
|
||||
@@ -757,7 +836,7 @@ import {
|
||||
Key,
|
||||
PauseCircle,
|
||||
PlayCircle,
|
||||
RotateCcw,
|
||||
DollarSign,
|
||||
Trash2,
|
||||
Copy,
|
||||
Search,
|
||||
@@ -768,11 +847,12 @@ import {
|
||||
|
||||
// 功能组件
|
||||
import UserFormDialog, { type UserFormData } from '@/features/users/components/UserFormDialog.vue'
|
||||
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger, confirmWarning } = useConfirm()
|
||||
const { confirmDanger } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const usersStore = useUsersStore()
|
||||
|
||||
@@ -794,6 +874,10 @@ const apiKeyInput = ref<HTMLInputElement>()
|
||||
const userStats = ref<Record<string, UsageByUser>>({})
|
||||
const loadingStats = ref(false)
|
||||
let userStatsRequestId = 0
|
||||
const userWalletMap = ref<Record<string, AdminWallet>>({})
|
||||
|
||||
const showWalletActionDialogState = ref(false)
|
||||
const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterRole = ref('all')
|
||||
@@ -847,16 +931,14 @@ watch([searchQuery, filterRole, filterStatus], () => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
usersStore.fetchUsers(),
|
||||
loadUserStats()
|
||||
])
|
||||
await refreshUsers()
|
||||
})
|
||||
|
||||
async function refreshUsers() {
|
||||
await Promise.all([
|
||||
usersStore.fetchUsers(),
|
||||
loadUserStats()
|
||||
loadUserStats(),
|
||||
loadUserWallets()
|
||||
])
|
||||
}
|
||||
|
||||
@@ -883,6 +965,20 @@ async function loadUserStats() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserWallets() {
|
||||
try {
|
||||
const wallets = await adminWalletApi.listAllWallets()
|
||||
userWalletMap.value = wallets
|
||||
.filter((wallet) => wallet.owner_type === 'user' && !!wallet.user_id)
|
||||
.reduce<Record<string, AdminWallet>>((acc, wallet) => {
|
||||
acc[wallet.user_id as string] = wallet
|
||||
return acc
|
||||
}, {})
|
||||
} catch (err) {
|
||||
log.error('加载用户钱包失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`
|
||||
@@ -897,6 +993,48 @@ function formatNumber(value?: number | null): string {
|
||||
return numericValue.toLocaleString()
|
||||
}
|
||||
|
||||
function getUserWallet(userId: string): AdminWallet | null {
|
||||
return userWalletMap.value[userId] || null
|
||||
}
|
||||
|
||||
function isUserUnlimited(user: User): boolean {
|
||||
const wallet = getUserWallet(user.id)
|
||||
if (wallet?.limit_mode === 'unlimited' || wallet?.unlimited === true) {
|
||||
return true
|
||||
}
|
||||
return Boolean(user.unlimited)
|
||||
}
|
||||
|
||||
function getUserWalletTotalBalance(user: User): number | null {
|
||||
if (isUserUnlimited(user)) {
|
||||
return null
|
||||
}
|
||||
const wallet = getUserWallet(user.id)
|
||||
if (!wallet) {
|
||||
return null
|
||||
}
|
||||
return wallet.balance
|
||||
}
|
||||
|
||||
function getUserWalletConsumed(user: User): number {
|
||||
return getUserWallet(user.id)?.total_consumed ?? 0
|
||||
}
|
||||
|
||||
function getUserWalletStatus(userId: string): string | null {
|
||||
return getUserWallet(userId)?.status ?? null
|
||||
}
|
||||
|
||||
function formatCurrencyValue(value: number | null, nullLabel = '-'): string {
|
||||
if (value == null) {
|
||||
return nullLabel
|
||||
}
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
function isNegativeWalletValue(value: number | null): boolean {
|
||||
return typeof value === 'number' && value < 0
|
||||
}
|
||||
|
||||
async function toggleUserStatus(user: User) {
|
||||
const action = user.is_active ? '禁用' : '启用'
|
||||
const confirmed = await confirmDanger(
|
||||
@@ -928,7 +1066,7 @@ function editUser(user: User) {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
quota_usd: user.quota_usd,
|
||||
unlimited: user.unlimited,
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
allowed_providers: [...(user.allowed_providers || [])],
|
||||
@@ -943,7 +1081,7 @@ function closeUserFormDialog() {
|
||||
editingUser.value = null
|
||||
}
|
||||
|
||||
async function handleUserFormSubmit(data: UserFormData & { password?: string }) {
|
||||
async function handleUserFormSubmit(data: UserFormData & { password?: string; unlimited?: boolean }) {
|
||||
userFormDialogRef.value?.setSaving(true)
|
||||
try {
|
||||
if (data.id) {
|
||||
@@ -951,7 +1089,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
|
||||
const updateData: Record<string, unknown> = {
|
||||
username: data.username,
|
||||
email: data.email || undefined,
|
||||
quota_usd: data.quota_usd,
|
||||
unlimited: data.unlimited,
|
||||
role: data.role,
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
@@ -961,6 +1099,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
|
||||
updateData.password = data.password
|
||||
}
|
||||
await usersStore.updateUser(data.id, updateData)
|
||||
await loadUserWallets()
|
||||
success('用户信息已更新')
|
||||
} else {
|
||||
// 创建用户
|
||||
@@ -968,8 +1107,8 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
|
||||
username: data.username,
|
||||
password: data.password ?? '',
|
||||
email: data.email || undefined,
|
||||
quota_usd: data.quota_usd,
|
||||
unlimited: (data as Record<string, unknown>).unlimited as boolean | undefined,
|
||||
initial_gift_usd: data.initial_gift_usd,
|
||||
unlimited: data.unlimited,
|
||||
role: data.role,
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
@@ -979,6 +1118,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
|
||||
if (data.is_active === false && newUser) {
|
||||
await usersStore.updateUser(newUser.id, { is_active: false })
|
||||
}
|
||||
await loadUserWallets()
|
||||
success('用户创建成功')
|
||||
}
|
||||
closeUserFormDialog()
|
||||
@@ -1055,8 +1195,9 @@ async function deleteApiKey(apiKey: ApiKey) {
|
||||
}
|
||||
|
||||
async function toggleLockApiKey(apiKey: ApiKey) {
|
||||
if (!selectedUser.value) return
|
||||
try {
|
||||
const response = await adminApi.toggleLockApiKey(apiKey.id)
|
||||
const response = await adminApi.toggleUserApiKeyLock(selectedUser.value.id, apiKey.id)
|
||||
// 更新本地状态
|
||||
const index = userApiKeys.value.findIndex(k => k.id === apiKey.id)
|
||||
if (index !== -1) {
|
||||
@@ -1070,9 +1211,9 @@ async function toggleLockApiKey(apiKey: ApiKey) {
|
||||
}
|
||||
|
||||
async function copyFullKey(apiKey: ApiKey) {
|
||||
if (!selectedUser.value) return
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||
const response = await usersStore.getFullApiKey(selectedUser.value.id, apiKey.id)
|
||||
await copyToClipboard(response.key)
|
||||
} catch (err: unknown) {
|
||||
log.error('复制密钥失败:', err)
|
||||
@@ -1080,19 +1221,30 @@ async function copyFullKey(apiKey: ApiKey) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetQuota(user: User) {
|
||||
const confirmed = await confirmWarning(
|
||||
`确定要重置用户 ${user.username} 的配额使用量吗?\n\n这将把已使用金额重置为0。`,
|
||||
'重置配额'
|
||||
)
|
||||
function openWalletActionDialog(user: User) {
|
||||
const wallet = getUserWallet(user.id)
|
||||
if (!wallet) {
|
||||
error('该用户的钱包尚未初始化,暂时无法进行资金操作')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirmed) return
|
||||
walletActionTarget.value = {
|
||||
user,
|
||||
wallet,
|
||||
}
|
||||
showWalletActionDialogState.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
await usersStore.resetUserQuota(user.id)
|
||||
success('配额已重置')
|
||||
} catch (err: unknown) {
|
||||
error(parseApiError(err, '未知错误'), '重置配额失败')
|
||||
function closeWalletActionDrawer() {
|
||||
showWalletActionDialogState.value = false
|
||||
}
|
||||
|
||||
async function handleWalletDrawerChanged() {
|
||||
await loadUserWallets()
|
||||
if (!walletActionTarget.value) return
|
||||
const latestWallet = getUserWallet(walletActionTarget.value.user.id)
|
||||
if (latestWallet) {
|
||||
walletActionTarget.value.wallet = latestWallet
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1612
frontend/src/views/admin/WalletsManagement.vue
Normal file
1612
frontend/src/views/admin/WalletsManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,19 +18,19 @@
|
||||
for="default-quota"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
默认用户配额(美元)
|
||||
默认用户初始赠款(美元)
|
||||
</Label>
|
||||
<Input
|
||||
id="default-quota"
|
||||
:model-value="defaultUserQuotaUsd"
|
||||
:model-value="defaultUserInitialGiftUsd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="10.00"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:defaultUserQuotaUsd', Number($event))"
|
||||
@update:model-value="$emit('update:defaultUserInitialGiftUsd', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
新用户注册时的默认配额
|
||||
新用户注册时的默认初始赠款
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,7 @@ import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
|
||||
defineProps<{
|
||||
defaultUserQuotaUsd: number
|
||||
defaultUserInitialGiftUsd: number
|
||||
rateLimitPerMinute: number
|
||||
enableRegistration: boolean
|
||||
autoDeleteExpiredKeys: boolean
|
||||
@@ -139,7 +139,7 @@ defineProps<{
|
||||
|
||||
defineEmits<{
|
||||
save: []
|
||||
'update:defaultUserQuotaUsd': [value: number]
|
||||
'update:defaultUserInitialGiftUsd': [value: number]
|
||||
'update:rateLimitPerMinute': [value: number]
|
||||
'update:enableRegistration': [value: boolean]
|
||||
'update:autoDeleteExpiredKeys': [value: boolean]
|
||||
|
||||
@@ -14,9 +14,7 @@
|
||||
? 'border-primary/30 bg-primary/[0.02] shadow-sm shadow-primary/5'
|
||||
: 'border-border bg-card hover:border-border/80'"
|
||||
>
|
||||
<!-- 主行 -->
|
||||
<div class="flex items-center gap-4 p-4">
|
||||
<!-- 左侧:开关 -->
|
||||
<div class="shrink-0">
|
||||
<Switch
|
||||
:id="`enable-${task.id}`"
|
||||
@@ -25,7 +23,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 中间:图标、标题、描述 -->
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div
|
||||
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors duration-300"
|
||||
@@ -48,7 +45,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:时间选择器 + 保存按钮 -->
|
||||
<div
|
||||
v-if="task.enabled && task.hasTimeConfig"
|
||||
class="flex items-center gap-2 shrink-0"
|
||||
@@ -118,123 +114,6 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 额外配置区域(仅用户配额重置任务有) -->
|
||||
<div
|
||||
v-if="task.id === 'user-quota-reset' && task.enabled"
|
||||
class="px-4 pb-4 pt-0"
|
||||
>
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">重置周期</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">每</span>
|
||||
<Input
|
||||
:model-value="quotaResetIntervalDays"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="w-14 h-7 text-xs text-center px-2"
|
||||
@update:model-value="$emit('update:quotaResetIntervalDays', Number($event))"
|
||||
/>
|
||||
<span class="text-muted-foreground">天</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground mt-2 ml-1">
|
||||
滚动计算:距离上次成功执行满 N 天后再次执行
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 独立密钥额度重置额外配置 -->
|
||||
<div
|
||||
v-if="task.id === 'standalone-key-quota-reset' && task.enabled"
|
||||
class="px-4 pb-4 pt-0 space-y-3"
|
||||
>
|
||||
<!-- 重置周期 -->
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">重置周期</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">每</span>
|
||||
<Input
|
||||
:model-value="standaloneKeyResetIntervalDays"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="w-14 h-7 text-xs text-center px-2"
|
||||
@update:model-value="$emit('update:standaloneKeyResetIntervalDays', Number($event))"
|
||||
/>
|
||||
<span class="text-muted-foreground">天</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置范围 -->
|
||||
<div class="p-3 rounded-lg bg-muted/30 border border-border/50 space-y-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">重置范围</span>
|
||||
<Select
|
||||
:model-value="standaloneKeyResetMode"
|
||||
@update:model-value="$emit('update:standaloneKeyResetMode', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-32 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
全部独立密钥
|
||||
</SelectItem>
|
||||
<SelectItem value="selected">
|
||||
指定密钥
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 密钥多选列表 -->
|
||||
<div
|
||||
v-if="standaloneKeyResetMode === 'selected'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
选择需要重置的密钥:
|
||||
</div>
|
||||
<div
|
||||
v-if="standaloneKeys.length === 0"
|
||||
class="text-xs text-muted-foreground/60 py-2"
|
||||
>
|
||||
暂无独立密钥
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="max-h-48 overflow-y-auto space-y-1"
|
||||
>
|
||||
<label
|
||||
v-for="key in standaloneKeys"
|
||||
:key="key.id"
|
||||
class="flex items-center gap-2 p-2 rounded hover:bg-muted/50 cursor-pointer text-xs"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="standaloneKeyResetKeyIds.includes(key.id)"
|
||||
@update:model-value="$emit('toggleStandaloneKeyResetKeyId', key.id)"
|
||||
/>
|
||||
<span class="truncate">{{ key.name || key.key_display || 'sk-****' }}</span>
|
||||
<span
|
||||
v-if="key.current_balance_usd != null"
|
||||
class="text-muted-foreground ml-auto shrink-0"
|
||||
>
|
||||
${{ key.current_balance_usd.toFixed(2) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-muted-foreground ml-1">
|
||||
滚动计算:距离上次成功执行满 N 天后再次执行
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -244,9 +123,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Clock, Check, Loader2, X } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
@@ -272,26 +149,7 @@ interface ScheduledTask {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
interface StandaloneKeyOption {
|
||||
id: string
|
||||
name?: string
|
||||
key_display?: string
|
||||
current_balance_usd?: number | null
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
scheduledTasks: ScheduledTask[]
|
||||
quotaResetIntervalDays: number
|
||||
standaloneKeyResetIntervalDays: number
|
||||
standaloneKeyResetMode: string
|
||||
standaloneKeyResetKeyIds: string[]
|
||||
standaloneKeys: StandaloneKeyOption[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:quotaResetIntervalDays': [value: number]
|
||||
'update:standaloneKeyResetIntervalDays': [value: number]
|
||||
'update:standaloneKeyResetMode': [value: string]
|
||||
'toggleStandaloneKeyResetKeyId': [keyId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { CalendarCheck, RotateCcw, RefreshCw, KeyRound } from 'lucide-vue-next'
|
||||
import { CalendarCheck, RefreshCw } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
@@ -9,25 +9,13 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
const { success, error } = useToast()
|
||||
|
||||
const checkinConfigLoading = ref(false)
|
||||
const quotaResetConfigLoading = ref(false)
|
||||
const standaloneKeyResetConfigLoading = ref(false)
|
||||
|
||||
// 签到时间的原始值(用于回滚)
|
||||
const previousCheckinTime = ref('')
|
||||
// 用户配额重置时间的原始值
|
||||
const previousUserQuotaResetTime = ref('')
|
||||
const previousUserQuotaResetIntervalDays = ref(1)
|
||||
// 独立密钥额度重置的原始值
|
||||
const previousStandaloneKeyResetTime = ref('')
|
||||
const previousStandaloneKeyResetIntervalDays = ref(1)
|
||||
|
||||
// 初始化原始值(在配置加载完成后调用)
|
||||
function initPreviousValues() {
|
||||
previousCheckinTime.value = systemConfig.value.provider_checkin_time
|
||||
previousUserQuotaResetTime.value = systemConfig.value.user_quota_reset_time
|
||||
previousUserQuotaResetIntervalDays.value = systemConfig.value.user_quota_reset_interval_days
|
||||
previousStandaloneKeyResetTime.value = systemConfig.value.standalone_key_quota_reset_time
|
||||
previousStandaloneKeyResetIntervalDays.value = systemConfig.value.standalone_key_quota_reset_interval_days
|
||||
}
|
||||
|
||||
// 签到时间
|
||||
@@ -51,70 +39,6 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
return systemConfig.value.provider_checkin_time !== previousCheckinTime.value
|
||||
})
|
||||
|
||||
// 用户配额重置时间
|
||||
const userQuotaResetHour = computed(() => {
|
||||
const time = systemConfig.value.user_quota_reset_time
|
||||
if (!time || !time.includes(':')) return '05'
|
||||
return time.split(':')[0]
|
||||
})
|
||||
|
||||
const userQuotaResetMinute = computed(() => {
|
||||
const time = systemConfig.value.user_quota_reset_time
|
||||
if (!time || !time.includes(':')) return '00'
|
||||
return time.split(':')[1]
|
||||
})
|
||||
|
||||
function updateUserQuotaResetTime(hour: string, minute: string) {
|
||||
systemConfig.value.user_quota_reset_time = `${hour}:${minute}`
|
||||
}
|
||||
|
||||
const hasUserQuotaResetTimeChanged = computed(() => {
|
||||
return systemConfig.value.user_quota_reset_time !== previousUserQuotaResetTime.value
|
||||
})
|
||||
|
||||
const hasUserQuotaResetIntervalChanged = computed(() => {
|
||||
return (
|
||||
systemConfig.value.user_quota_reset_interval_days !==
|
||||
previousUserQuotaResetIntervalDays.value
|
||||
)
|
||||
})
|
||||
|
||||
const hasQuotaResetConfigChanged = computed(() => {
|
||||
return hasUserQuotaResetTimeChanged.value || hasUserQuotaResetIntervalChanged.value
|
||||
})
|
||||
|
||||
// 独立密钥额度重置时间
|
||||
const standaloneKeyResetHour = computed(() => {
|
||||
const time = systemConfig.value.standalone_key_quota_reset_time
|
||||
if (!time || !time.includes(':')) return '05'
|
||||
return time.split(':')[0]
|
||||
})
|
||||
|
||||
const standaloneKeyResetMinute = computed(() => {
|
||||
const time = systemConfig.value.standalone_key_quota_reset_time
|
||||
if (!time || !time.includes(':')) return '00'
|
||||
return time.split(':')[1]
|
||||
})
|
||||
|
||||
function updateStandaloneKeyResetTime(hour: string, minute: string) {
|
||||
systemConfig.value.standalone_key_quota_reset_time = `${hour}:${minute}`
|
||||
}
|
||||
|
||||
const hasStandaloneKeyResetTimeChanged = computed(() => {
|
||||
return systemConfig.value.standalone_key_quota_reset_time !== previousStandaloneKeyResetTime.value
|
||||
})
|
||||
|
||||
const hasStandaloneKeyResetIntervalChanged = computed(() => {
|
||||
return (
|
||||
systemConfig.value.standalone_key_quota_reset_interval_days !==
|
||||
previousStandaloneKeyResetIntervalDays.value
|
||||
)
|
||||
})
|
||||
|
||||
const hasStandaloneKeyResetConfigChanged = computed(() => {
|
||||
return hasStandaloneKeyResetTimeChanged.value || hasStandaloneKeyResetIntervalChanged.value
|
||||
})
|
||||
|
||||
// Toggle handlers
|
||||
async function handleProviderCheckinToggle(enabled: boolean) {
|
||||
const previousValue = systemConfig.value.enable_provider_checkin
|
||||
@@ -133,23 +57,6 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUserQuotaResetToggle(enabled: boolean) {
|
||||
const previousValue = systemConfig.value.enable_user_quota_reset
|
||||
systemConfig.value.enable_user_quota_reset = enabled
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'enable_user_quota_reset',
|
||||
enabled,
|
||||
'是否启用用户配额自动重置任务'
|
||||
)
|
||||
success(enabled ? '已启用用户配额自动重置' : '已禁用用户配额自动重置')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存用户配额自动重置配置失败:', err)
|
||||
systemConfig.value.enable_user_quota_reset = previousValue
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOAuthTokenRefreshToggle(enabled: boolean) {
|
||||
const previousValue = systemConfig.value.enable_oauth_token_refresh
|
||||
systemConfig.value.enable_oauth_token_refresh = enabled
|
||||
@@ -167,38 +74,11 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStandaloneKeyResetToggle(enabled: boolean) {
|
||||
const previousValue = systemConfig.value.enable_standalone_key_quota_reset
|
||||
systemConfig.value.enable_standalone_key_quota_reset = enabled
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'enable_standalone_key_quota_reset',
|
||||
enabled,
|
||||
'是否启用独立密钥额度自动重置任务'
|
||||
)
|
||||
success(enabled ? '已启用独立密钥额度自动重置' : '已禁用独立密钥额度自动重置')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存独立密钥额度自动重置配置失败:', err)
|
||||
systemConfig.value.enable_standalone_key_quota_reset = previousValue
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel handlers
|
||||
function handleCheckinTimeCancel() {
|
||||
systemConfig.value.provider_checkin_time = previousCheckinTime.value
|
||||
}
|
||||
|
||||
function handleQuotaResetConfigCancel() {
|
||||
systemConfig.value.user_quota_reset_time = previousUserQuotaResetTime.value
|
||||
systemConfig.value.user_quota_reset_interval_days = previousUserQuotaResetIntervalDays.value
|
||||
}
|
||||
|
||||
function handleStandaloneKeyResetConfigCancel() {
|
||||
systemConfig.value.standalone_key_quota_reset_time = previousStandaloneKeyResetTime.value
|
||||
systemConfig.value.standalone_key_quota_reset_interval_days = previousStandaloneKeyResetIntervalDays.value
|
||||
}
|
||||
|
||||
// Save handlers
|
||||
async function handleCheckinTimeSave() {
|
||||
const newTime = systemConfig.value.provider_checkin_time
|
||||
@@ -224,171 +104,6 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuotaResetConfigSave() {
|
||||
const configItems: Array<{
|
||||
key: string
|
||||
value: unknown
|
||||
description: string
|
||||
onSuccess: () => void
|
||||
}> = []
|
||||
|
||||
if (hasUserQuotaResetTimeChanged.value) {
|
||||
const newTime = systemConfig.value.user_quota_reset_time
|
||||
if (!newTime || !/^\d{2}:\d{2}$/.test(newTime)) {
|
||||
error('请输入有效的时间格式 (HH:MM)')
|
||||
return
|
||||
}
|
||||
configItems.push({
|
||||
key: 'user_quota_reset_time',
|
||||
value: newTime,
|
||||
description: '用户配额自动重置执行时间(HH:MM 格式)',
|
||||
onSuccess: () => {
|
||||
previousUserQuotaResetTime.value = newTime
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (hasUserQuotaResetIntervalChanged.value) {
|
||||
let intervalDays = Number(systemConfig.value.user_quota_reset_interval_days)
|
||||
if (!Number.isFinite(intervalDays) || intervalDays < 1) intervalDays = 1
|
||||
intervalDays = Math.trunc(intervalDays)
|
||||
|
||||
systemConfig.value.user_quota_reset_interval_days = intervalDays
|
||||
|
||||
configItems.push({
|
||||
key: 'user_quota_reset_interval_days',
|
||||
value: intervalDays,
|
||||
description: '用户配额重置周期(天数),滚动计算',
|
||||
onSuccess: () => {
|
||||
previousUserQuotaResetIntervalDays.value = intervalDays
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (configItems.length === 0) return
|
||||
|
||||
quotaResetConfigLoading.value = true
|
||||
const failedKeys: string[] = []
|
||||
|
||||
try {
|
||||
for (const item of configItems) {
|
||||
try {
|
||||
await adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
item.onSuccess()
|
||||
} catch (err) {
|
||||
failedKeys.push(item.key)
|
||||
log.error(`保存配额重置配置失败: ${item.key}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedKeys.length > 0) {
|
||||
error(`部分配置保存失败: ${failedKeys.join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
success('配额重置配置已保存')
|
||||
} finally {
|
||||
quotaResetConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStandaloneKeyResetConfigSave() {
|
||||
const configItems: Array<{
|
||||
key: string
|
||||
value: unknown
|
||||
description: string
|
||||
onSuccess: () => void
|
||||
}> = []
|
||||
|
||||
if (hasStandaloneKeyResetTimeChanged.value) {
|
||||
const newTime = systemConfig.value.standalone_key_quota_reset_time
|
||||
if (!newTime || !/^\d{2}:\d{2}$/.test(newTime)) {
|
||||
error('请输入有效的时间格式 (HH:MM)')
|
||||
return
|
||||
}
|
||||
configItems.push({
|
||||
key: 'standalone_key_quota_reset_time',
|
||||
value: newTime,
|
||||
description: '独立密钥额度自动重置执行时间(HH:MM 格式)',
|
||||
onSuccess: () => {
|
||||
previousStandaloneKeyResetTime.value = newTime
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (hasStandaloneKeyResetIntervalChanged.value) {
|
||||
let intervalDays = Number(systemConfig.value.standalone_key_quota_reset_interval_days)
|
||||
if (!Number.isFinite(intervalDays) || intervalDays < 1) intervalDays = 1
|
||||
intervalDays = Math.trunc(intervalDays)
|
||||
|
||||
systemConfig.value.standalone_key_quota_reset_interval_days = intervalDays
|
||||
|
||||
configItems.push({
|
||||
key: 'standalone_key_quota_reset_interval_days',
|
||||
value: intervalDays,
|
||||
description: '独立密钥额度重置周期(天数),滚动计算',
|
||||
onSuccess: () => {
|
||||
previousStandaloneKeyResetIntervalDays.value = intervalDays
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (configItems.length === 0) return
|
||||
|
||||
standaloneKeyResetConfigLoading.value = true
|
||||
const failedKeys: string[] = []
|
||||
|
||||
try {
|
||||
for (const item of configItems) {
|
||||
try {
|
||||
await adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
item.onSuccess()
|
||||
} catch (err) {
|
||||
failedKeys.push(item.key)
|
||||
log.error(`保存独立密钥额度重置配置失败: ${item.key}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedKeys.length > 0) {
|
||||
error(`部分配置保存失败: ${failedKeys.join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
success('独立密钥额度重置配置已保存')
|
||||
} finally {
|
||||
standaloneKeyResetConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存独立密钥额度重置模式和选中密钥
|
||||
async function saveStandaloneKeyResetMode(mode: string) {
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'standalone_key_quota_reset_mode',
|
||||
mode,
|
||||
'独立密钥额度重置模式'
|
||||
)
|
||||
success('重置模式已保存')
|
||||
} catch (err) {
|
||||
error('保存重置模式失败')
|
||||
log.error('保存独立密钥额度重置模式失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStandaloneKeyResetKeyIds(keyIds: string[]) {
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'standalone_key_quota_reset_key_ids',
|
||||
keyIds,
|
||||
'独立密钥额度重置指定的密钥 ID 列表'
|
||||
)
|
||||
success('已保存选中密钥')
|
||||
} catch (err) {
|
||||
error('保存选中密钥失败')
|
||||
log.error('保存独立密钥额度重置密钥列表失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 定时任务配置列表
|
||||
const scheduledTasks = computed(() => [
|
||||
{
|
||||
@@ -407,22 +122,6 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
onSave: handleCheckinTimeSave,
|
||||
onCancel: handleCheckinTimeCancel,
|
||||
},
|
||||
{
|
||||
id: 'user-quota-reset',
|
||||
icon: RotateCcw,
|
||||
title: '用户配额自动重置',
|
||||
description: '定时将用户已使用配额重置为零',
|
||||
enabled: systemConfig.value.enable_user_quota_reset,
|
||||
hasTimeConfig: true,
|
||||
hour: userQuotaResetHour.value,
|
||||
minute: userQuotaResetMinute.value,
|
||||
updateTime: updateUserQuotaResetTime,
|
||||
hasChanges: hasQuotaResetConfigChanged.value,
|
||||
loading: quotaResetConfigLoading.value,
|
||||
onToggle: handleUserQuotaResetToggle,
|
||||
onSave: handleQuotaResetConfigSave,
|
||||
onCancel: handleQuotaResetConfigCancel,
|
||||
},
|
||||
{
|
||||
id: 'oauth-token-refresh',
|
||||
icon: RefreshCw,
|
||||
@@ -439,31 +138,11 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
},
|
||||
{
|
||||
id: 'standalone-key-quota-reset',
|
||||
icon: KeyRound,
|
||||
title: '独立密钥额度自动重置',
|
||||
description: '定时将独立密钥已使用额度重置为零',
|
||||
enabled: systemConfig.value.enable_standalone_key_quota_reset,
|
||||
hasTimeConfig: true,
|
||||
hour: standaloneKeyResetHour.value,
|
||||
minute: standaloneKeyResetMinute.value,
|
||||
updateTime: updateStandaloneKeyResetTime,
|
||||
hasChanges: hasStandaloneKeyResetConfigChanged.value,
|
||||
loading: standaloneKeyResetConfigLoading.value,
|
||||
onToggle: handleStandaloneKeyResetToggle,
|
||||
onSave: handleStandaloneKeyResetConfigSave,
|
||||
onCancel: handleStandaloneKeyResetConfigCancel,
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
checkinConfigLoading,
|
||||
quotaResetConfigLoading,
|
||||
standaloneKeyResetConfigLoading,
|
||||
scheduledTasks,
|
||||
initPreviousValues,
|
||||
saveStandaloneKeyResetMode,
|
||||
saveStandaloneKeyResetKeyIds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface SystemConfig {
|
||||
// 网络代理
|
||||
system_proxy_node_id: string | null
|
||||
// 基础配置
|
||||
default_user_quota_usd: number
|
||||
default_user_initial_gift_usd: number
|
||||
rate_limit_per_minute: number
|
||||
enable_registration: boolean
|
||||
// 独立余额 Key 过期管理
|
||||
@@ -34,16 +34,7 @@ export interface SystemConfig {
|
||||
// 定时任务
|
||||
enable_provider_checkin: boolean
|
||||
provider_checkin_time: string
|
||||
enable_user_quota_reset: boolean
|
||||
user_quota_reset_time: string
|
||||
user_quota_reset_interval_days: number
|
||||
enable_oauth_token_refresh: boolean
|
||||
// 独立密钥额度重置
|
||||
enable_standalone_key_quota_reset: boolean
|
||||
standalone_key_quota_reset_time: string
|
||||
standalone_key_quota_reset_interval_days: number
|
||||
standalone_key_quota_reset_mode: string
|
||||
standalone_key_quota_reset_key_ids: string[]
|
||||
}
|
||||
|
||||
const CONFIG_KEYS = [
|
||||
@@ -53,7 +44,7 @@ const CONFIG_KEYS = [
|
||||
// 网络代理
|
||||
'system_proxy_node_id',
|
||||
// 基础配置
|
||||
'default_user_quota_usd',
|
||||
'default_user_initial_gift_usd',
|
||||
'rate_limit_per_minute',
|
||||
'enable_registration',
|
||||
// 独立余额 Key 过期管理
|
||||
@@ -76,16 +67,7 @@ const CONFIG_KEYS = [
|
||||
// 定时任务
|
||||
'enable_provider_checkin',
|
||||
'provider_checkin_time',
|
||||
'enable_user_quota_reset',
|
||||
'user_quota_reset_time',
|
||||
'user_quota_reset_interval_days',
|
||||
'enable_oauth_token_refresh',
|
||||
// 独立密钥额度重置
|
||||
'enable_standalone_key_quota_reset',
|
||||
'standalone_key_quota_reset_time',
|
||||
'standalone_key_quota_reset_interval_days',
|
||||
'standalone_key_quota_reset_mode',
|
||||
'standalone_key_quota_reset_key_ids',
|
||||
]
|
||||
|
||||
function createDefaultConfig(): SystemConfig {
|
||||
@@ -96,7 +78,7 @@ function createDefaultConfig(): SystemConfig {
|
||||
// 网络代理
|
||||
system_proxy_node_id: null,
|
||||
// 基础配置
|
||||
default_user_quota_usd: 10.0,
|
||||
default_user_initial_gift_usd: 10.0,
|
||||
rate_limit_per_minute: 0,
|
||||
enable_registration: false,
|
||||
// 独立余额 Key 过期管理
|
||||
@@ -119,16 +101,7 @@ function createDefaultConfig(): SystemConfig {
|
||||
// 定时任务
|
||||
enable_provider_checkin: true,
|
||||
provider_checkin_time: '01:05',
|
||||
enable_user_quota_reset: false,
|
||||
user_quota_reset_time: '05:00',
|
||||
user_quota_reset_interval_days: 1,
|
||||
enable_oauth_token_refresh: true,
|
||||
// 独立密钥额度重置
|
||||
enable_standalone_key_quota_reset: false,
|
||||
standalone_key_quota_reset_time: '05:00',
|
||||
standalone_key_quota_reset_interval_days: 1,
|
||||
standalone_key_quota_reset_mode: 'all',
|
||||
standalone_key_quota_reset_key_ids: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +137,7 @@ export function useSystemConfig() {
|
||||
const hasBasicConfigChanges = computed(() => {
|
||||
if (!originalConfig.value) return false
|
||||
return (
|
||||
systemConfig.value.default_user_quota_usd !== originalConfig.value.default_user_quota_usd ||
|
||||
systemConfig.value.default_user_initial_gift_usd !== originalConfig.value.default_user_initial_gift_usd ||
|
||||
systemConfig.value.rate_limit_per_minute !== originalConfig.value.rate_limit_per_minute ||
|
||||
systemConfig.value.enable_registration !== originalConfig.value.enable_registration ||
|
||||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
|
||||
@@ -234,7 +207,7 @@ export function useSystemConfig() {
|
||||
; (systemConfig.value as Record<string, unknown>)[key] = response.value
|
||||
}
|
||||
} catch {
|
||||
// 配置不存在时使用默认值,无需处理
|
||||
// 单个配置项加载失败时忽略,使用默认值
|
||||
}
|
||||
}
|
||||
originalConfig.value = JSON.parse(JSON.stringify(systemConfig.value))
|
||||
@@ -309,9 +282,9 @@ export function useSystemConfig() {
|
||||
try {
|
||||
const configItems = [
|
||||
{
|
||||
key: 'default_user_quota_usd',
|
||||
value: systemConfig.value.default_user_quota_usd,
|
||||
description: '默认用户配额(美元)',
|
||||
key: 'default_user_initial_gift_usd',
|
||||
value: systemConfig.value.default_user_initial_gift_usd,
|
||||
description: '默认用户初始赠款(美元)',
|
||||
},
|
||||
{
|
||||
key: 'rate_limit_per_minute',
|
||||
@@ -341,7 +314,7 @@ export function useSystemConfig() {
|
||||
)
|
||||
)
|
||||
if (originalConfig.value) {
|
||||
originalConfig.value.default_user_quota_usd = systemConfig.value.default_user_quota_usd
|
||||
originalConfig.value.default_user_initial_gift_usd = systemConfig.value.default_user_initial_gift_usd
|
||||
originalConfig.value.rate_limit_per_minute = systemConfig.value.rate_limit_per_minute
|
||||
originalConfig.value.enable_registration = systemConfig.value.enable_registration
|
||||
originalConfig.value.auto_delete_expired_keys =
|
||||
|
||||
@@ -339,7 +339,7 @@
|
||||
使用提醒
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
当接近配额限制时提醒
|
||||
当余额接近不足时提醒
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -407,30 +407,45 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 使用配额 -->
|
||||
<!-- 钱包状态 -->
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-foreground mb-4">
|
||||
使用配额
|
||||
钱包状态
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">总余额</span>
|
||||
<span class="text-foreground">
|
||||
<template v-if="isUnlimitedBilling()">
|
||||
无限制
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatCurrency(profile?.billing?.balance || 0) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">充值余额</span>
|
||||
<span class="text-foreground">{{ formatCurrency(profile?.billing?.recharge_balance || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">赠款余额</span>
|
||||
<span class="text-foreground">{{ formatCurrency(profile?.billing?.gift_balance || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">累计消费</span>
|
||||
<span class="text-foreground">{{ formatCurrency(profile?.billing?.total_consumed || 0) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!isUnlimitedBilling()">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-muted-foreground">配额使用(美元)</span>
|
||||
<span class="text-foreground">
|
||||
<template v-if="isUnlimitedQuota()">
|
||||
{{ formatCurrency(profile?.used_usd || 0) }} /
|
||||
<span class="text-warning">无限制</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatCurrency(profile?.used_usd || 0) }} /
|
||||
{{ formatCurrency(profile?.quota_usd || 0) }}
|
||||
</template>
|
||||
</span>
|
||||
<span class="text-muted-foreground">累计消费占比</span>
|
||||
<span class="text-foreground">{{ getBillingUsagePercentage().toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-muted rounded-full h-2.5">
|
||||
<div
|
||||
class="bg-success h-2.5 rounded-full"
|
||||
:style="`width: ${getUsagePercentage()}%`"
|
||||
:style="`width: ${getBillingUsagePercentage()}%`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -804,17 +819,17 @@ async function updatePreferences() {
|
||||
}
|
||||
}
|
||||
|
||||
function getUsagePercentage(): number {
|
||||
if (!profile.value) return 0
|
||||
|
||||
const quota = profile.value.quota_usd
|
||||
const used = profile.value.used_usd
|
||||
if (quota == null || quota === 0) return 0
|
||||
return Math.min(100, (used / quota) * 100)
|
||||
function getBillingUsagePercentage(): number {
|
||||
const billing = profile.value?.billing
|
||||
if (!billing) return 0
|
||||
const consumed = billing.total_consumed || 0
|
||||
const denominator = consumed + (billing.balance || 0)
|
||||
if (denominator <= 0) return 0
|
||||
return Math.min(100, (consumed / denominator) * 100)
|
||||
}
|
||||
|
||||
function isUnlimitedQuota(): boolean {
|
||||
return profile.value?.quota_usd == null
|
||||
function isUnlimitedBilling(): boolean {
|
||||
return profile.value?.billing?.unlimited === true
|
||||
}
|
||||
|
||||
function formatDate(dateString?: string): string {
|
||||
|
||||
751
frontend/src/views/user/WalletCenter.vue
Normal file
751
frontend/src/views/user/WalletCenter.vue
Normal file
@@ -0,0 +1,751 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<div
|
||||
v-if="loadingInitial"
|
||||
class="py-16"
|
||||
>
|
||||
<LoadingState message="正在加载钱包数据..." />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 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) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
充值余额: {{ formatCurrency(walletBalance?.wallet?.recharge_balance) }} · 赠款余额: {{ formatCurrency(walletBalance?.wallet?.gift_balance) }}
|
||||
</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>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
累计退款: {{ formatCurrency(walletBalance?.wallet?.total_refunded) }} · 可退款余额: {{ formatCurrency(walletBalance?.wallet?.refundable_balance) }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-5 space-y-2">
|
||||
<div class="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
钱包状态
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge :variant="walletStatusBadge(walletBalance?.wallet?.status)">
|
||||
{{ walletStatusLabel(walletBalance?.wallet?.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
v-if="walletBalance?.unlimited"
|
||||
class="text-xs text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
当前账号处于无限制模式,余额仅用于账务统计。
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
待处理退款: {{ walletBalance?.pending_refund_count || 0 }}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- TODO(wallet): 充值/退款用户主动操作入口暂未启用,待支付链路联调完成后再开放 -->
|
||||
<div
|
||||
v-if="ENABLE_WALLET_ACTION_FORMS"
|
||||
class="grid grid-cols-1 xl:grid-cols-2 gap-4"
|
||||
>
|
||||
<Card class="p-5 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold">
|
||||
发起充值
|
||||
</h3>
|
||||
<RefreshButton
|
||||
:loading="loadingOrders"
|
||||
@click="loadOrders"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label>充值金额 (USD)</Label>
|
||||
<Input
|
||||
v-model.number="rechargeForm.amount_usd"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>支付方式</Label>
|
||||
<Select v-model="rechargeForm.payment_method">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择支付方式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="alipay">
|
||||
支付宝
|
||||
</SelectItem>
|
||||
<SelectItem value="wechat">
|
||||
微信支付
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="submittingRecharge"
|
||||
@click="submitRecharge"
|
||||
>
|
||||
{{ submittingRecharge ? '创建订单中...' : '创建充值订单' }}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="latestRecharge"
|
||||
class="rounded-xl border border-border/60 bg-muted/30 p-3 space-y-1.5"
|
||||
>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
最新订单: <span class="font-medium text-foreground">{{ latestRecharge.order.order_no }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
状态:
|
||||
<Badge
|
||||
:variant="paymentStatusBadge(latestRecharge.order.status)"
|
||||
class="ml-1"
|
||||
>
|
||||
{{ paymentStatusLabel(latestRecharge.order.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<a
|
||||
v-if="latestRecharge.payment_instructions?.payment_url"
|
||||
class="inline-flex text-xs text-primary hover:underline"
|
||||
:href="String(latestRecharge.payment_instructions.payment_url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
打开支付链接
|
||||
</a>
|
||||
<div
|
||||
v-if="latestRecharge.payment_instructions?.qr_code"
|
||||
class="text-xs text-muted-foreground break-all"
|
||||
>
|
||||
二维码标识: {{ latestRecharge.payment_instructions.qr_code }}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-5 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold">
|
||||
申请退款
|
||||
</h3>
|
||||
<RefreshButton
|
||||
:loading="loadingRefunds"
|
||||
@click="loadRefunds"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label>退款金额 (USD)</Label>
|
||||
<Input
|
||||
v-model.number="refundForm.amount_usd"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
placeholder="5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>退款模式</Label>
|
||||
<Select v-model="refundForm.refund_mode">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择退款模式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original_channel">
|
||||
原路退回
|
||||
</SelectItem>
|
||||
<SelectItem value="offline_payout">
|
||||
线下打款
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>关联充值订单(可选)</Label>
|
||||
<Select v-model="refundForm.payment_order_id">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="不指定订单,直接从钱包余额退款" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">
|
||||
不指定
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="order in refundableOrders"
|
||||
:key="order.id"
|
||||
:value="order.id"
|
||||
>
|
||||
{{ order.order_no }} (可退 {{ formatCurrency(order.refundable_amount_usd) }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>退款原因(可选)</Label>
|
||||
<Textarea
|
||||
v-model="refundForm.reason"
|
||||
placeholder="填写退款原因,便于审核"
|
||||
rows="3"
|
||||
/>
|
||||
</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"
|
||||
@click="submitRefund"
|
||||
>
|
||||
{{ submittingRefund ? '提交中...' : '提交退款申请' }}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="overflow-hidden">
|
||||
<div class="px-5 pt-5 pb-2">
|
||||
<Tabs v-model="activeTab">
|
||||
<TabsList class="tabs-button-list grid grid-cols-3 w-full max-w-xl">
|
||||
<TabsTrigger value="transactions">
|
||||
资金流水
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="orders">
|
||||
充值订单
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="refunds">
|
||||
退款记录
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="transactions"
|
||||
class="mt-4 space-y-4"
|
||||
>
|
||||
<div class="px-5 flex items-center justify-between">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
共 {{ txTotal }} 条
|
||||
</div>
|
||||
<RefreshButton
|
||||
:loading="loadingTransactions"
|
||||
@click="loadTransactions"
|
||||
/>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>变动</TableHead>
|
||||
<TableHead>余额变化</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="tx in transactions"
|
||||
:key="tx.id"
|
||||
>
|
||||
<TableCell class="text-xs text-muted-foreground">
|
||||
{{ formatDateTime(tx.created_at) }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="space-y-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="font-mono"
|
||||
>
|
||||
{{ walletTransactionCategoryLabel(tx.category) }}
|
||||
</Badge>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{{ walletTransactionReasonLabel(tx.reason_code) }}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
:class="tx.amount >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'"
|
||||
>
|
||||
{{ tx.amount >= 0 ? '+' : '' }}{{ tx.amount.toFixed(4) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-xs tabular-nums">
|
||||
{{ tx.balance_before.toFixed(4) }} → {{ tx.balance_after.toFixed(4) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground">
|
||||
{{ tx.description || '-' }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!loadingTransactions && transactions.length === 0">
|
||||
<TableCell
|
||||
colspan="5"
|
||||
class="py-10"
|
||||
>
|
||||
<EmptyState
|
||||
title="暂无资金流水"
|
||||
description="充值或退款后会在这里显示"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Pagination
|
||||
:current="txPage"
|
||||
:total="txTotal"
|
||||
:page-size="txPageSize"
|
||||
@update:current="handleTxPageChange"
|
||||
@update:page-size="handleTxPageSizeChange"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="orders"
|
||||
class="mt-4 space-y-4"
|
||||
>
|
||||
<div class="px-5 flex items-center justify-between">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
共 {{ orderTotal }} 条
|
||||
</div>
|
||||
<RefreshButton
|
||||
:loading="loadingOrders"
|
||||
@click="loadOrders"
|
||||
/>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>订单号</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>支付方式</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>可退金额</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="order in rechargeOrders"
|
||||
:key="order.id"
|
||||
>
|
||||
<TableCell class="font-mono text-xs">
|
||||
{{ order.order_no }}
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ formatCurrency(order.amount_usd) }}
|
||||
</TableCell>
|
||||
<TableCell>{{ paymentMethodLabel(order.payment_method) }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="paymentStatusBadge(order.status)">
|
||||
{{ paymentStatusLabel(order.status) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ formatCurrency(order.refundable_amount_usd) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground">
|
||||
{{ formatDateTime(order.created_at) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!loadingOrders && rechargeOrders.length === 0">
|
||||
<TableCell
|
||||
colspan="6"
|
||||
class="py-10"
|
||||
>
|
||||
<EmptyState
|
||||
title="暂无充值订单"
|
||||
description="发起充值后会在这里显示"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Pagination
|
||||
:current="orderPage"
|
||||
:total="orderTotal"
|
||||
:page-size="orderPageSize"
|
||||
@update:current="handleOrderPageChange"
|
||||
@update:page-size="handleOrderPageSizeChange"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="refunds"
|
||||
class="mt-4 space-y-4"
|
||||
>
|
||||
<div class="px-5 flex items-center justify-between">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
共 {{ refundTotal }} 条
|
||||
</div>
|
||||
<RefreshButton
|
||||
:loading="loadingRefunds"
|
||||
@click="loadRefunds"
|
||||
/>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>退款单号</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>模式</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>原因</TableHead>
|
||||
<TableHead>申请时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="refund in refunds"
|
||||
:key="refund.id"
|
||||
>
|
||||
<TableCell class="font-mono text-xs">
|
||||
{{ refund.refund_no }}
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ formatCurrency(refund.amount_usd) }}
|
||||
</TableCell>
|
||||
<TableCell>{{ refundModeLabel(refund.refund_mode) }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="refundStatusBadge(refund.status)">
|
||||
{{ refundStatusLabel(refund.status) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground max-w-[220px] truncate">
|
||||
{{ refund.reason || refund.failure_reason || '-' }}
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground">
|
||||
{{ formatDateTime(refund.created_at) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!loadingRefunds && refunds.length === 0">
|
||||
<TableCell
|
||||
colspan="6"
|
||||
class="py-10"
|
||||
>
|
||||
<EmptyState
|
||||
title="暂无退款记录"
|
||||
description="提交退款申请后会在这里显示"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Pagination
|
||||
:current="refundPage"
|
||||
:total="refundTotal"
|
||||
:page-size="refundPageSize"
|
||||
@update:current="handleRefundPageChange"
|
||||
@update:page-size="handleRefundPageSizeChange"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Label,
|
||||
Pagination,
|
||||
RefreshButton,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Textarea,
|
||||
} from '@/components/ui'
|
||||
import { EmptyState, LoadingState } from '@/components/common'
|
||||
import {
|
||||
walletApi,
|
||||
type PaymentOrder,
|
||||
type RefundRequest,
|
||||
type WalletBalanceResponse,
|
||||
type WalletTransaction,
|
||||
} from '@/api/wallet'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
import {
|
||||
formatWalletCurrency as formatCurrency,
|
||||
paymentMethodLabel,
|
||||
paymentStatusBadge,
|
||||
paymentStatusLabel,
|
||||
refundModeLabel,
|
||||
refundStatusBadge,
|
||||
refundStatusLabel,
|
||||
walletStatusBadge,
|
||||
walletStatusLabel,
|
||||
walletTransactionCategoryLabel,
|
||||
walletTransactionReasonLabel,
|
||||
} from '@/utils/walletDisplay'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// TODO(wallet): 充值和退款前台入口尚未正式启用;联调完成后改为 true 即可恢复显示。
|
||||
const ENABLE_WALLET_ACTION_FORMS = false
|
||||
|
||||
const loadingInitial = ref(true)
|
||||
const loadingTransactions = ref(false)
|
||||
const loadingOrders = ref(false)
|
||||
const loadingRefunds = ref(false)
|
||||
const submittingRecharge = ref(false)
|
||||
const submittingRefund = ref(false)
|
||||
|
||||
const walletBalance = ref<WalletBalanceResponse | null>(null)
|
||||
const latestRecharge = ref<{ order: PaymentOrder; payment_instructions: Record<string, unknown> } | null>(null)
|
||||
|
||||
const transactions = ref<WalletTransaction[]>([])
|
||||
const txTotal = ref(0)
|
||||
const txPage = ref(1)
|
||||
const txPageSize = ref(20)
|
||||
|
||||
const rechargeOrders = ref<PaymentOrder[]>([])
|
||||
const orderTotal = ref(0)
|
||||
const orderPage = ref(1)
|
||||
const orderPageSize = ref(20)
|
||||
|
||||
const refunds = ref<RefundRequest[]>([])
|
||||
const refundTotal = ref(0)
|
||||
const refundPage = ref(1)
|
||||
const refundPageSize = ref(20)
|
||||
|
||||
const activeTab = ref('transactions')
|
||||
|
||||
const rechargeForm = reactive({
|
||||
amount_usd: 10,
|
||||
payment_method: 'alipay',
|
||||
})
|
||||
|
||||
const refundForm = reactive({
|
||||
amount_usd: 0,
|
||||
payment_order_id: '__none__',
|
||||
refund_mode: 'offline_payout',
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const refundableOrders = computed(() =>
|
||||
rechargeOrders.value.filter(o => (o.refundable_amount_usd || 0) > 0)
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
loadBalance(),
|
||||
loadTransactions(),
|
||||
loadOrders(),
|
||||
loadRefunds(),
|
||||
])
|
||||
} finally {
|
||||
loadingInitial.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function loadBalance() {
|
||||
walletBalance.value = await walletApi.getBalance()
|
||||
}
|
||||
|
||||
async function loadTransactions() {
|
||||
loadingTransactions.value = true
|
||||
try {
|
||||
const offset = (txPage.value - 1) * txPageSize.value
|
||||
const resp = await walletApi.getTransactions({ limit: txPageSize.value, offset })
|
||||
transactions.value = resp.items
|
||||
txTotal.value = resp.total
|
||||
} catch (error) {
|
||||
log.error('加载钱包流水失败:', error)
|
||||
showError(parseApiError(error, '加载钱包流水失败'))
|
||||
} finally {
|
||||
loadingTransactions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
loadingOrders.value = true
|
||||
try {
|
||||
const offset = (orderPage.value - 1) * orderPageSize.value
|
||||
const resp = await walletApi.listRechargeOrders({ limit: orderPageSize.value, offset })
|
||||
rechargeOrders.value = resp.items
|
||||
orderTotal.value = resp.total
|
||||
} catch (error) {
|
||||
log.error('加载充值订单失败:', error)
|
||||
showError(parseApiError(error, '加载充值订单失败'))
|
||||
} finally {
|
||||
loadingOrders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRefunds() {
|
||||
loadingRefunds.value = true
|
||||
try {
|
||||
const offset = (refundPage.value - 1) * refundPageSize.value
|
||||
const resp = await walletApi.listRefunds({ limit: refundPageSize.value, offset })
|
||||
refunds.value = resp.items
|
||||
refundTotal.value = resp.total
|
||||
} catch (error) {
|
||||
log.error('加载退款记录失败:', error)
|
||||
showError(parseApiError(error, '加载退款记录失败'))
|
||||
} finally {
|
||||
loadingRefunds.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRecharge() {
|
||||
if (!rechargeForm.amount_usd || rechargeForm.amount_usd <= 0) {
|
||||
showError('请输入有效的充值金额')
|
||||
return
|
||||
}
|
||||
|
||||
submittingRecharge.value = true
|
||||
try {
|
||||
latestRecharge.value = await walletApi.createRechargeOrder({
|
||||
amount_usd: rechargeForm.amount_usd,
|
||||
payment_method: rechargeForm.payment_method,
|
||||
})
|
||||
success('充值订单创建成功')
|
||||
await Promise.all([loadOrders(), loadBalance()])
|
||||
activeTab.value = 'orders'
|
||||
} catch (error) {
|
||||
log.error('创建充值订单失败:', error)
|
||||
showError(parseApiError(error, '创建充值订单失败'))
|
||||
} finally {
|
||||
submittingRecharge.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRefund() {
|
||||
if (!refundForm.amount_usd || refundForm.amount_usd <= 0) {
|
||||
showError('请输入有效的退款金额')
|
||||
return
|
||||
}
|
||||
const refundableBalance =
|
||||
walletBalance.value?.wallet?.refundable_balance ?? walletBalance.value?.refundable_balance ?? null
|
||||
if (refundableBalance !== null && refundForm.amount_usd > refundableBalance) {
|
||||
showError(`退款金额超过可退款余额(当前可退 ${formatCurrency(refundableBalance)})`)
|
||||
return
|
||||
}
|
||||
|
||||
submittingRefund.value = true
|
||||
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,
|
||||
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()])
|
||||
activeTab.value = 'refunds'
|
||||
} catch (error) {
|
||||
log.error('提交退款申请失败:', error)
|
||||
showError(parseApiError(error, '提交退款申请失败'))
|
||||
} finally {
|
||||
submittingRefund.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildRefundIdempotencyKey(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID().replaceAll('-', '')
|
||||
}
|
||||
return `${Date.now()}_${Math.random().toString(16).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function handleTxPageChange(page: number) {
|
||||
txPage.value = page
|
||||
void loadTransactions()
|
||||
}
|
||||
|
||||
function handleTxPageSizeChange(size: number) {
|
||||
txPageSize.value = size
|
||||
txPage.value = 1
|
||||
void loadTransactions()
|
||||
}
|
||||
|
||||
function handleOrderPageChange(page: number) {
|
||||
orderPage.value = page
|
||||
void loadOrders()
|
||||
}
|
||||
|
||||
function handleOrderPageSizeChange(size: number) {
|
||||
orderPageSize.value = size
|
||||
orderPage.value = 1
|
||||
void loadOrders()
|
||||
}
|
||||
|
||||
function handleRefundPageChange(page: number) {
|
||||
refundPage.value = page
|
||||
void loadRefunds()
|
||||
}
|
||||
|
||||
function handleRefundPageSizeChange(size: number) {
|
||||
refundPageSize.value = size
|
||||
refundPage.value = 1
|
||||
void loadRefunds()
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): 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',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user