feat(wallet): 钱包系统替代配额系统,新增支付与退款机制

- 新增钱包余额管理、充值、扣费、退款完整流程
- 新增支付网关抽象层(支持手动/支付宝/微信)
- 用量计费从配额系统迁移到钱包余额扣费
- 新增管理员钱包管理与支付订单管理页面
- 新增用户钱包中心页面
- 移除独立 Key 锁定机制,统一由钱包余额控制
- 新增相关 API 路由、序列化器与数据库迁移
- 新增钱包、支付、退款相关测试
This commit is contained in:
LewisPen
2026-03-08 00:05:48 +08:00
committed by fawney19
parent 9cdcce1b5f
commit 783f654953
108 changed files with 13152 additions and 3372 deletions

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

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

View File

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

View File

@@ -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 // 允许使用的模型名称列表

View File

@@ -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?: {

View File

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

View File

@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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