feat(admin): 完善代理节点与 OAuth 授权管理

This commit is contained in:
fawney19
2026-04-14 14:09:24 +08:00
parent 593640ac19
commit 861ae81ff0
44 changed files with 2757 additions and 365 deletions

View File

@@ -370,7 +370,7 @@ export interface CreateStandaloneApiKeyRequest {
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null // null = 跟随系统默认0 = 不限制
expires_at?: string | null // ISO 日期字符串,如 "2025-12-31"null = 永不过期
expires_at?: string | null // RFC3339 时间null = 永不过期
initial_balance_usd: number | null // 初始余额null = 无限制
unlimited_balance?: boolean | null // 编辑时仅切换额度模式,不调整余额数值
auto_delete_on_expiry?: boolean // 过期后是否自动删除

View File

@@ -222,7 +222,7 @@
<span
v-else
class="flex h-10 w-full items-center rounded-lg border bg-background px-3 text-sm text-muted-foreground opacity-60"
>{{ form.unlimited_balance ? '无限制' : '按钱包余额限制' }}</span>
>{{ balanceDisplayText }}</span>
</div>
<Switch
:model-value="form.unlimited_balance ?? false"
@@ -230,6 +230,12 @@
@update:model-value="(v) => form.unlimited_balance = v"
/>
</div>
<p
v-if="isEditMode"
class="text-xs text-muted-foreground"
>
{{ form.unlimited_balance ? '该 Key 当前使用独立无限额度。' : '该 Key 当前按独立钱包余额限制;增减金额请在列表页使用“资金”操作。' }}
</p>
</div>
</div>
</div>
@@ -278,6 +284,7 @@ export interface StandaloneKeyFormData {
id?: string
name: string
initial_balance_usd?: number
current_balance_usd?: number | null
unlimited_balance?: boolean
expires_at?: string // ISO 日期字符串,如 "2025-12-31"undefined = 永不过期
rate_limit?: number | null
@@ -291,6 +298,7 @@ interface StandaloneKeyFormState {
id?: string
name: string
initial_balance_usd?: number
current_balance_usd?: number | null
unlimited_balance?: boolean
expires_at?: string
rate_limit_inherited: boolean
@@ -345,6 +353,7 @@ const modelOptions = computed(() =>
const form = ref<StandaloneKeyFormState>({
name: '',
initial_balance_usd: 10,
current_balance_usd: undefined,
unlimited_balance: false,
expires_at: undefined,
rate_limit_inherited: true,
@@ -358,17 +367,37 @@ const form = ref<StandaloneKeyFormState>({
allowed_models: [],
})
function formatDateInputValue(date: Date): string {
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
// 计算最小可选日期(明天)
const minExpiryDate = computed(() => {
const tomorrow = new Date()
tomorrow.setHours(0, 0, 0, 0)
tomorrow.setDate(tomorrow.getDate() + 1)
return tomorrow.toISOString().split('T')[0]
return formatDateInputValue(tomorrow)
})
const balanceDisplayText = computed(() => {
if (form.value.unlimited_balance) {
return '独立无限额度'
}
if (isEditMode.value) {
const currentBalance = form.value.current_balance_usd ?? form.value.initial_balance_usd ?? 0
return `当前独立钱包余额 $${currentBalance.toFixed(2)}`
}
return '按独立钱包余额限制'
})
function resetForm() {
form.value = {
name: '',
initial_balance_usd: 10,
current_balance_usd: undefined,
unlimited_balance: false,
expires_at: undefined,
rate_limit_inherited: true,
@@ -389,6 +418,7 @@ function loadKeyData() {
id: props.apiKey.id,
name: props.apiKey.name || '',
initial_balance_usd: props.apiKey.initial_balance_usd,
current_balance_usd: props.apiKey.current_balance_usd ?? props.apiKey.initial_balance_usd ?? null,
unlimited_balance: props.apiKey.initial_balance_usd == null,
expires_at: props.apiKey.expires_at,
rate_limit_inherited: props.apiKey.rate_limit == null,

View File

@@ -2213,7 +2213,7 @@ registerDynamicRoute('GET', '/api/admin/api-keys/:keyId', async (_config, params
})
// API Key 更新
registerDynamicRoute('PATCH', '/api/admin/api-keys/:keyId', async (config, params) => {
registerDynamicRoute('PUT', '/api/admin/api-keys/:keyId', async (config, params) => {
await delay()
requireAdmin()
const key = MOCK_ADMIN_API_KEYS.api_keys.find(k => k.id === params.keyId)

View File

@@ -814,9 +814,16 @@ async function refreshApiKeys() {
skip: skip.value,
limit: limit.value
})
apiKeys.value = response.api_keys
const standaloneKeys = response.api_keys.filter((key) => key.is_standalone === true)
if (standaloneKeys.length !== response.api_keys.length) {
log.warn('独立 Key 页面收到了非 standalone 记录,已在前端过滤', {
received: response.api_keys.length,
kept: standaloneKeys.length
})
}
apiKeys.value = standaloneKeys
total.value = response.total
apiKeyWalletMap.value = buildApiKeyWalletMap(response.api_keys)
apiKeyWalletMap.value = buildApiKeyWalletMap(standaloneKeys)
} catch (err: unknown) {
log.error('加载独立Keys失败:', err)
error(parseApiError(err, '加载独立 Keys 失败'))
@@ -864,20 +871,52 @@ async function deleteApiKey(apiKey: AdminApiKey) {
}
}
function formatDateForInput(dateString: string): string | undefined {
const date = new Date(dateString)
if (Number.isNaN(date.getTime())) {
return undefined
}
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
function parseDateInput(dateString: string): Date | null {
const [year, month, day] = dateString.split('-').map(part => Number.parseInt(part, 10))
if (!year || !month || !day) {
return null
}
const date = new Date(year, month - 1, day)
return Number.isNaN(date.getTime()) ? null : date
}
function serializeExpiryDate(dateString?: string): string | null {
if (!dateString) {
return null
}
const date = parseDateInput(dateString)
if (!date) {
return null
}
date.setHours(23, 59, 59, 999)
return date.toISOString()
}
function editApiKey(apiKey: AdminApiKey) {
// 解析过期日期为 YYYY-MM-DD 格式
// 保留原始日期,不做时间过滤(避免编辑当天过期的 Key 时意外清空)
let expiresAt: string | undefined = undefined
if (apiKey.expires_at) {
const expiresDate = new Date(apiKey.expires_at)
expiresAt = expiresDate.toISOString().split('T')[0]
expiresAt = formatDateForInput(apiKey.expires_at)
}
editingKeyData.value = {
id: apiKey.id,
name: apiKey.name || '',
initial_balance_usd: isApiKeyUnlimited(apiKey) ? undefined : (getApiKeyWalletTotalBalance(apiKey) ?? undefined),
current_balance_usd: isApiKeyUnlimited(apiKey) ? null : getApiKeyWalletTotalBalance(apiKey),
unlimited_balance: isApiKeyUnlimited(apiKey),
expires_at: expiresAt,
rate_limit: apiKey.rate_limit ?? undefined,
@@ -1049,7 +1088,12 @@ function closeKeyFormDialog() {
async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
// 验证过期日期(如果设置了,必须晚于今天)
if (data.expires_at) {
const selectedDate = new Date(data.expires_at)
const selectedDate = parseDateInput(data.expires_at)
if (!selectedDate) {
error('过期日期格式无效')
return
}
selectedDate.setHours(0, 0, 0, 0)
const today = new Date()
today.setHours(0, 0, 0, 0)
if (selectedDate <= today) {
@@ -1066,7 +1110,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
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/空 = 永不过期
expires_at: serializeExpiryDate(data.expires_at),
auto_delete_on_expiry: data.auto_delete_on_expiry,
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
allowed_providers: data.allowed_providers,
@@ -1095,7 +1139,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
name: data.name || undefined,
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/空 = 永不过期
expires_at: serializeExpiryDate(data.expires_at),
auto_delete_on_expiry: data.auto_delete_on_expiry,
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL
allowed_providers: data.allowed_providers,