mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat(wallet): 钱包系统替代配额系统,新增支付与退款机制
- 新增钱包余额管理、充值、扣费、退款完整流程 - 新增支付网关抽象层(支持手动/支付宝/微信) - 用量计费从配额系统迁移到钱包余额扣费 - 新增管理员钱包管理与支付订单管理页面 - 新增用户钱包中心页面 - 移除独立 Key 锁定机制,统一由钱包余额控制 - 新增相关 API 路由、序列化器与数据库迁移 - 新增钱包、支付、退款相关测试
This commit is contained in:
@@ -22,7 +22,7 @@
|
||||
{{ isEditMode ? '编辑独立余额 API Key' : '创建独立余额 API Key' }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ isEditMode ? '修改密钥名称、有效期和访问限制' : '用于非注册用户调用接口,不关联用户配额,必须设置余额限制' }}
|
||||
{{ isEditMode ? '修改密钥名称、有效期和访问限制' : '用于非注册用户调用接口,可设置初始余额或无限制额度' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +34,6 @@
|
||||
<!-- 左侧:基础设置 -->
|
||||
<div class="pr-6 space-y-4">
|
||||
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
|
||||
<Key class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">基础设置</span>
|
||||
</div>
|
||||
|
||||
@@ -52,31 +51,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 初始余额 - 仅创建模式显示 -->
|
||||
<div
|
||||
v-if="!isEditMode"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label
|
||||
for="form-balance"
|
||||
class="text-sm font-medium"
|
||||
>初始余额 (USD) <span class="text-rose-500">*</span></Label>
|
||||
<Input
|
||||
id="form-balance"
|
||||
:model-value="form.initial_balance_usd ?? ''"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
placeholder="10.00"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.initial_balance_usd = parseNumberInput(v, { allowFloat: true }) ?? 10"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
独立Key必须设置余额限制,最小值 $0.01
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="form-expires-at"
|
||||
@@ -137,12 +111,49 @@
|
||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v, { min: 1, max: 10000 })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">无限制额度</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch v-model="form.unlimited_balance" />
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-foreground">
|
||||
{{ form.unlimited_balance ? '已启用' : '已关闭' }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ form.unlimited_balance ? '无限制:忽略钱包余额校验' : '有限制:按钱包余额校验' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isEditMode && !form.unlimited_balance"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label
|
||||
for="form-balance"
|
||||
class="text-sm font-medium"
|
||||
>初始余额 (USD) <span class="text-rose-500">*</span></Label>
|
||||
<Input
|
||||
id="form-balance"
|
||||
:model-value="form.initial_balance_usd ?? ''"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="10.00"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.initial_balance_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
最小值 $0.01
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:访问限制 -->
|
||||
<div class="pl-6 space-y-4 border-l border-border">
|
||||
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
|
||||
<Shield class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">访问限制</span>
|
||||
<span class="text-xs text-muted-foreground">(留空不限)</span>
|
||||
</div>
|
||||
@@ -279,8 +290,9 @@ import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
} from '@/components/ui'
|
||||
import { Plus, SquarePen, Key, Shield, ChevronDown, X } from 'lucide-vue-next'
|
||||
import { Plus, SquarePen, ChevronDown, X } from 'lucide-vue-next'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { ModelMultiSelect } from '@/components/common'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
@@ -294,6 +306,7 @@ export interface StandaloneKeyFormData {
|
||||
id?: string
|
||||
name: string
|
||||
initial_balance_usd?: number
|
||||
unlimited_balance?: boolean
|
||||
expires_at?: string // ISO 日期字符串,如 "2025-12-31",undefined = 永不过期
|
||||
rate_limit?: number
|
||||
auto_delete_on_expiry: boolean
|
||||
@@ -328,6 +341,7 @@ const allApiFormats = ref<string[]>([])
|
||||
const form = ref<StandaloneKeyFormData>({
|
||||
name: '',
|
||||
initial_balance_usd: 10,
|
||||
unlimited_balance: false,
|
||||
expires_at: undefined,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
@@ -347,6 +361,7 @@ function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
initial_balance_usd: 10,
|
||||
unlimited_balance: false,
|
||||
expires_at: undefined,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
@@ -364,6 +379,7 @@ function loadKeyData() {
|
||||
id: props.apiKey.id,
|
||||
name: props.apiKey.name || '',
|
||||
initial_balance_usd: props.apiKey.initial_balance_usd,
|
||||
unlimited_balance: props.apiKey.initial_balance_usd == null,
|
||||
expires_at: props.apiKey.expires_at,
|
||||
rate_limit: props.apiKey.rate_limit,
|
||||
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
|
||||
@@ -432,6 +448,17 @@ watch(isOpen, (val) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => form.value.unlimited_balance,
|
||||
(unlimited) => {
|
||||
if (unlimited) {
|
||||
form.value.initial_balance_usd = undefined
|
||||
} else if (form.value.initial_balance_usd == null) {
|
||||
form.value.initial_balance_usd = 10
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
setSaving
|
||||
})
|
||||
|
||||
@@ -142,51 +142,16 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="form-quota"
|
||||
class="text-sm font-medium"
|
||||
>配额(美元)</Label>
|
||||
<div class="flex items-center space-x-3">
|
||||
<Input
|
||||
id="form-quota"
|
||||
v-model.number="form.quota"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="10000"
|
||||
:placeholder="isEditMode ? '10' : '使用系统默认'"
|
||||
:disabled="form.unlimited"
|
||||
:class="form.unlimited ? 'flex-1 h-10 opacity-50' : 'flex-1 h-10'"
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-2 border rounded-lg px-3 py-2 bg-muted/50 w-24">
|
||||
<input
|
||||
id="form-unlimited"
|
||||
v-model="form.unlimited"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
>
|
||||
<Label
|
||||
for="form-unlimited"
|
||||
class="whitespace-nowrap cursor-pointer text-sm"
|
||||
>无限制</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="form-role"
|
||||
class="text-sm font-medium"
|
||||
>用户角色</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Select
|
||||
v-model="form.role"
|
||||
class="flex-1"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Select v-model="form.role">
|
||||
<SelectTrigger
|
||||
id="form-role"
|
||||
class="h-10"
|
||||
class="h-10 w-full text-sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -199,20 +164,29 @@
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div
|
||||
v-if="!isEditMode"
|
||||
class="flex items-center justify-center gap-2 border rounded-lg px-3 py-2 bg-muted/50 w-24"
|
||||
>
|
||||
<input
|
||||
id="form-active"
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
>
|
||||
<Label
|
||||
for="form-active"
|
||||
class="whitespace-nowrap cursor-pointer text-sm"
|
||||
>启用用户</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isEditMode"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label
|
||||
for="form-active"
|
||||
class="text-sm font-medium"
|
||||
>启用用户</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch
|
||||
id="form-active"
|
||||
v-model="form.is_active"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-foreground">
|
||||
{{ form.is_active ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ form.is_active ? '允许登录与请求' : '阻止登录与请求' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,6 +306,44 @@
|
||||
v-model="form.allowed_models"
|
||||
:models="globalModels"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">无限制额度</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch v-model="form.unlimited" />
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-foreground">
|
||||
{{ form.unlimited ? '已启用' : '已关闭' }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ form.unlimited ? '无限制:忽略钱包余额校验' : '有限制:按钱包余额校验' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isEditMode && !form.unlimited"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label
|
||||
for="form-initial-gift"
|
||||
class="text-sm font-medium"
|
||||
>初始赠款额度 (USD) <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
id="form-initial-gift"
|
||||
:model-value="form.initial_gift_usd ?? ''"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="10.00"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.initial_gift_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
最小值 $0.01
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -363,6 +375,7 @@ import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
@@ -376,13 +389,15 @@ import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getGlobalModels } from '@/api/global-models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||
|
||||
export interface UserFormData {
|
||||
id?: string
|
||||
username: string
|
||||
email: string
|
||||
quota_usd?: number | null
|
||||
initial_gift_usd?: number | null
|
||||
unlimited?: boolean
|
||||
role: 'admin' | 'user'
|
||||
is_active?: boolean
|
||||
allowed_providers?: string[] | null
|
||||
@@ -397,7 +412,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [data: UserFormData & { password?: string }]
|
||||
submit: [data: UserFormData & { password?: string; unlimited?: boolean }]
|
||||
}>()
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
@@ -420,7 +435,7 @@ const form = ref({
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: '',
|
||||
quota: null as number | null,
|
||||
initial_gift_usd: 10 as number | undefined,
|
||||
role: 'user' as 'admin' | 'user',
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
@@ -441,7 +456,7 @@ function resetForm() {
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: '',
|
||||
quota: null,
|
||||
initial_gift_usd: 10,
|
||||
role: 'user',
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
@@ -461,9 +476,9 @@ function loadUserData() {
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: props.user.email || '',
|
||||
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
|
||||
initial_gift_usd: undefined,
|
||||
role: props.user.role,
|
||||
unlimited: props.user.quota_usd == null,
|
||||
unlimited: props.user.unlimited ?? false,
|
||||
is_active: props.user.is_active ?? true,
|
||||
allowed_providers: [...(props.user.allowed_providers || [])],
|
||||
allowed_api_formats: [...(props.user.allowed_api_formats || [])],
|
||||
@@ -491,16 +506,33 @@ const usernameError = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
function getPasswordValidationError(password: string): string | null {
|
||||
if (password.length < 8) return '密码长度至少为8个字符'
|
||||
if (!/[A-Z]/.test(password)) return '密码必须包含至少一个大写字母'
|
||||
if (!/[a-z]/.test(password)) return '密码必须包含至少一个小写字母'
|
||||
if (!/[0-9]/.test(password)) return '密码必须包含至少一个数字'
|
||||
return null
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
const isFormValid = computed(() => {
|
||||
const hasUsername = form.value.username.trim().length > 0
|
||||
const usernameValid = !usernameError.value
|
||||
const hasPassword = isEditMode.value || form.value.password.length >= 6
|
||||
// 编辑模式下如果填写了密码,必须确认密码一致
|
||||
const passwordConfirmed = !isEditMode.value || form.value.password.length === 0 || form.value.password === form.value.confirmPassword
|
||||
return hasUsername && usernameValid && hasPassword && passwordConfirmed
|
||||
const passwordFilled = form.value.password.length > 0
|
||||
const passwordValid = passwordFilled
|
||||
? !getPasswordValidationError(form.value.password)
|
||||
: isEditMode.value
|
||||
// 编辑模式下可留空;填写时必须确认一致。创建模式不展示确认输入框。
|
||||
const passwordConfirmed = isEditMode.value
|
||||
? !passwordFilled || form.value.password === form.value.confirmPassword
|
||||
: true
|
||||
const initialGiftValid = isEditMode.value ||
|
||||
form.value.unlimited ||
|
||||
(typeof form.value.initial_gift_usd === 'number' && form.value.initial_gift_usd >= 0.01)
|
||||
return hasUsername && usernameValid && passwordValid && passwordConfirmed && initialGiftValid
|
||||
})
|
||||
|
||||
|
||||
// 加载访问控制选项
|
||||
async function loadAccessControlOptions(): Promise<void> {
|
||||
try {
|
||||
@@ -532,27 +564,25 @@ function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'a
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
try {
|
||||
const data: UserFormData & { password?: string; unlimited?: boolean } = {
|
||||
const data: UserFormData & { password?: string; unlimited: boolean } = {
|
||||
username: form.value.username,
|
||||
email: form.value.email.trim() || '',
|
||||
quota_usd: form.value.unlimited ? null : form.value.quota,
|
||||
unlimited: form.value.unlimited,
|
||||
role: form.value.role,
|
||||
allowed_providers: form.value.allowed_providers.length > 0 ? form.value.allowed_providers : null,
|
||||
allowed_api_formats: form.value.allowed_api_formats.length > 0 ? form.value.allowed_api_formats : null,
|
||||
allowed_models: form.value.allowed_models.length > 0 ? form.value.allowed_models : null
|
||||
}
|
||||
|
||||
// 创建模式下传递 unlimited 字段
|
||||
if (!isEditMode.value) {
|
||||
data.unlimited = form.value.unlimited
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.user?.id) {
|
||||
data.id = props.user.id
|
||||
}
|
||||
|
||||
if (!isEditMode.value) {
|
||||
data.is_active = form.value.is_active
|
||||
if (!form.value.unlimited && form.value.initial_gift_usd != null) {
|
||||
data.initial_gift_usd = form.value.initial_gift_usd
|
||||
}
|
||||
}
|
||||
|
||||
if (form.value.password) {
|
||||
@@ -580,6 +610,20 @@ watch(isOpen, (val) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => form.value.unlimited,
|
||||
(unlimited) => {
|
||||
if (isEditMode.value) {
|
||||
return
|
||||
}
|
||||
if (unlimited) {
|
||||
form.value.initial_gift_usd = undefined
|
||||
} else if (form.value.initial_gift_usd == null) {
|
||||
form.value.initial_gift_usd = 10
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
setSaving
|
||||
})
|
||||
|
||||
1023
frontend/src/features/wallet/components/WalletOpsDrawer.vue
Normal file
1023
frontend/src/features/wallet/components/WalletOpsDrawer.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user