feat: Provider 异步删除、可配置密码策略、Hub 超时优化及多项改进

- 新增 Provider 异步删除任务系统,后台分阶段删除子资源并清理残留引用
- 新增可配置密码策略等级(weak/medium/strong),支持系统设置面板调整
- aether-hub 升级至 0.1.4,idle timeout 支持禁用(设为 0),worker 默认超时调整为 120s
- OAuth 手动续期增加 Redis 分布式锁,防止并发刷新冲突
- ProxyNode 心跳检测改为 asyncio.to_thread,避免阻塞事件循环
- 删除 ModelMultiSelect 和 useInvalidModels,MultiSelect 组件通用化
- 明确 allowed_providers/allowed_api_formats 的 NULL 与空数组语义
- 前端 StandaloneKeyFormDialog、UserFormDialog 等多处 UI 优化
- 新增 Alembic 迁移脚本清理 Provider 删除后的残留引用
- 补充相关测试用例
This commit is contained in:
fawney19
2026-03-12 01:11:35 +08:00
parent 0d770d1c4d
commit 6e51a3f45d
55 changed files with 3219 additions and 862 deletions

View File

@@ -81,6 +81,7 @@ export interface RegistrationSettingsResponse {
enable_registration: boolean
require_email_verification: boolean
email_configured: boolean
password_policy_level: string
}
export interface AuthSettingsResponse {

View File

@@ -114,8 +114,36 @@ export async function createProvider(
/**
* 删除 Provider
*/
export async function deleteProvider(providerId: string): Promise<{ message: string }> {
const response = await client.delete(`/api/admin/providers/${providerId}`)
export interface ProviderDeleteSubmitResponse {
task_id: string
status: string
message: string
}
export interface ProviderDeleteTaskResponse {
task_id: string
provider_id: string
status: string
stage: string
total_keys: number
deleted_keys: number
total_endpoints: number
deleted_endpoints: number
message: string
}
export async function deleteProvider(providerId: string): Promise<ProviderDeleteSubmitResponse> {
const response = await client.delete<ProviderDeleteSubmitResponse>(`/api/admin/providers/${providerId}`)
return response.data
}
export async function getProviderDeleteTask(
providerId: string,
taskId: string,
): Promise<ProviderDeleteTaskResponse> {
const response = await client.get<ProviderDeleteTaskResponse>(
`/api/admin/providers/${providerId}/delete-task/${taskId}`,
)
return response.data
}

View File

@@ -1,183 +0,0 @@
<template>
<div class="space-y-2">
<Label class="text-sm font-medium">允许的模型</Label>
<div class="relative">
<button
type="button"
class="flex h-10 w-full items-center justify-between rounded-lg border bg-background px-3 text-left transition-colors hover:bg-muted/50"
@click="isOpen = !isOpen"
>
<span
class="truncate text-sm"
:class="
modelValue.length ? 'text-foreground' : 'text-muted-foreground'
"
>
{{
modelValue.length ? `已选择 ${modelValue.length}` : '全部可用'
}}
<span
v-if="invalidModels.length"
class="text-destructive"
>({{ invalidModels.length }} 个已失效)</span>
</span>
<ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="isOpen ? 'rotate-180' : ''"
/>
</button>
<div
v-if="isOpen"
class="fixed inset-0 z-[80]"
@click.stop="isOpen = false"
/>
<div
v-if="isOpen"
class="absolute z-[90] mt-1 w-full rounded-lg border bg-popover shadow-lg"
>
<div
v-if="showSearch"
class="sticky top-0 z-10 border-b bg-popover/95 p-1 backdrop-blur supports-[backdrop-filter]:bg-popover/85"
>
<div class="relative">
<Search
class="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
v-model="searchQuery"
:placeholder="searchPlaceholder"
class="h-8 rounded-md border-border/60 bg-popover pl-8 text-xs"
@keydown.stop
/>
</div>
</div>
<div class="max-h-48 overflow-y-auto">
<div
v-for="modelName in filteredInvalidModels"
:key="modelName"
class="flex cursor-pointer items-center gap-2 bg-destructive/5 px-3 py-2 hover:bg-muted/50"
@click="removeModel(modelName)"
>
<input
type="checkbox"
:checked="true"
class="h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300"
@click.stop
@change="removeModel(modelName)"
>
<span class="min-w-0 truncate text-sm text-destructive">{{ modelName }}</span>
<span class="shrink-0 text-xs text-destructive/70">(已失效)</span>
</div>
<div
v-for="model in filteredModels"
:key="model.name"
class="flex cursor-pointer items-center gap-2 px-3 py-2 hover:bg-muted/50"
@click="toggleModel(model.name)"
>
<input
type="checkbox"
:checked="modelValue.includes(model.name)"
class="h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300"
@click.stop
@change="toggleModel(model.name)"
>
<span class="min-w-0 truncate text-sm">{{ model.name }}</span>
</div>
<div
v-if="
filteredModels.length === 0 && filteredInvalidModels.length === 0
"
class="px-3 py-2 text-sm text-muted-foreground"
>
{{ searchQuery.trim() ? '未找到匹配项' : '暂无可用模型' }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Input, Label } from '@/components/ui'
import { ChevronDown, Search } from 'lucide-vue-next'
import { useInvalidModels } from '@/composables/useInvalidModels'
import { matchesSearchQuery } from '@/utils/search'
export interface ModelWithName {
name: string
}
const props = withDefaults(
defineProps<{
modelValue: string[]
models: ModelWithName[]
searchThreshold?: number
searchPlaceholder?: string
}>(),
{
searchThreshold: 8,
searchPlaceholder: '输入模型名搜索...',
},
)
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const isOpen = ref(false)
const searchQuery = ref('')
const { invalidModels } = useInvalidModels(
computed(() => props.modelValue),
computed(() => props.models),
)
const showSearch = computed(
() =>
props.models.length + invalidModels.value.length >= props.searchThreshold,
)
const filteredInvalidModels = computed(() => {
if (!showSearch.value || !searchQuery.value.trim()) {
return invalidModels.value
}
return invalidModels.value.filter((modelName) =>
matchesSearchQuery(searchQuery.value, modelName),
)
})
const filteredModels = computed(() => {
if (!showSearch.value || !searchQuery.value.trim()) {
return props.models
}
return props.models.filter((model) =>
matchesSearchQuery(searchQuery.value, model.name),
)
})
watch(isOpen, (open) => {
if (!open) {
searchQuery.value = ''
}
})
function toggleModel(name: string) {
const newValue = [...props.modelValue]
const index = newValue.indexOf(name)
if (index === -1) {
newValue.push(name)
} else {
newValue.splice(index, 1)
}
emit('update:modelValue', newValue)
}
function removeModel(name: string) {
const newValue = props.modelValue.filter((m) => m !== name)
emit('update:modelValue', newValue)
}
</script>

View File

@@ -4,7 +4,8 @@
type="button"
:class="
cn(
'flex h-10 w-full items-center justify-between rounded-lg border bg-background px-3 text-left transition-colors hover:bg-muted/50',
'flex h-10 w-full items-center justify-between rounded-lg border bg-background px-3 text-left transition-colors',
disabled ? 'cursor-not-allowed opacity-60 hover:bg-background' : 'hover:bg-muted/50',
triggerClass,
)
"
@@ -16,6 +17,10 @@
class="truncate text-sm"
>
{{ displayText }}
<span
v-if="invalidItems.length"
class="text-destructive"
>({{ invalidItems.length }} 个已失效)</span>
</span>
<ChevronDown
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform"
@@ -50,6 +55,23 @@
</div>
<div class="max-h-48 overflow-y-auto">
<div
v-for="item in filteredInvalidItems"
:key="'invalid-' + item"
class="flex cursor-pointer items-center gap-2 bg-destructive/5 px-3 py-2 hover:bg-muted/50"
@click="remove(item)"
>
<input
type="checkbox"
:checked="true"
class="h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300"
@click.stop
@change="remove(item)"
>
<span class="min-w-0 truncate text-sm text-destructive">{{ item }}</span>
<span class="shrink-0 text-xs text-destructive/70">(已失效)</span>
</div>
<div
v-for="item in filteredOptions"
:key="item.value"
@@ -66,7 +88,7 @@
<span class="min-w-0 truncate text-sm">{{ item.label }}</span>
</div>
<div
v-if="filteredOptions.length === 0"
v-if="filteredOptions.length === 0 && filteredInvalidItems.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
{{ searchQuery.trim() ? noResultsText : emptyText }}
@@ -122,9 +144,27 @@ const emit = defineEmits<{
const isOpen = ref(false)
const searchQuery = ref('')
const showSearch = computed(
() => props.searchable && props.options.length >= props.searchThreshold,
const validValues = computed(() => new Set(props.options.map(o => o.value)))
const invalidItems = computed(() =>
props.modelValue.filter(v => !validValues.value.has(v)),
)
const totalCount = computed(() => props.options.length + invalidItems.value.length)
const showSearch = computed(
() => props.searchable && totalCount.value >= props.searchThreshold,
)
const filteredInvalidItems = computed(() => {
if (!showSearch.value || !searchQuery.value.trim()) {
return invalidItems.value
}
return invalidItems.value.filter((item) =>
matchesSearchQuery(searchQuery.value, item),
)
})
const filteredOptions = computed(() => {
if (!showSearch.value || !searchQuery.value.trim()) {
return props.options
@@ -161,4 +201,8 @@ function toggle(value: string) {
}
emit('update:modelValue', newValue)
}
function remove(value: string) {
emit('update:modelValue', props.modelValue.filter(v => v !== value))
}
</script>

View File

@@ -9,6 +9,5 @@ export { default as AlertDialog } from './AlertDialog.vue'
export { default as LoadingState } from './LoadingState.vue'
// 表单组件
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
export { default as MultiSelect } from './MultiSelect.vue'
export { default as TimeRangePicker } from './TimeRangePicker.vue'

View File

@@ -1,34 +0,0 @@
import { computed, type Ref, type ComputedRef } from 'vue'
/**
* 检测失效模型的 composable
*
* 用于检测 allowed_models 中已不存在于 globalModels 的模型名称,
* 这些模型可能已被删除但引用未清理。
*
* @example
* ```typescript
* const { invalidModels } = useInvalidModels(
* computed(() => form.value.allowed_models),
* globalModels
* )
* ```
*/
export interface ModelWithName {
name: string
}
export function useInvalidModels<T extends ModelWithName>(
allowedModels: Ref<string[]> | ComputedRef<string[]>,
globalModels: Ref<T[]>
): { invalidModels: ComputedRef<string[]> } {
const validModelNames = computed(() =>
new Set(globalModels.value.map(m => m.name))
)
const invalidModels = computed(() =>
allowedModels.value.filter(name => !validModelNames.value.has(name))
)
return { invalidModels }
}

View File

@@ -113,90 +113,111 @@
@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
:model-value="form.unlimited_balance ?? false"
@update:model-value="(v) => form.unlimited_balance = v"
/>
<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">
<span class="text-sm font-medium">访问限制</span>
<span class="text-xs text-muted-foreground">(留空不限)</span>
</div>
<!-- Provider 多选下拉框 -->
<!-- Provider -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的 Provider</Label>
<MultiSelect
v-model="form.allowed_providers"
:options="providerOptions"
:search-threshold="0"
placeholder="全部可用"
empty-text="暂无可用 Provider"
no-results-text="未找到匹配的 Provider"
search-placeholder="搜索 Provider 名称..."
/>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<MultiSelect
v-model="form.allowed_providers"
:options="providerOptions"
:search-threshold="0"
:disabled="form.provider_unrestricted"
:placeholder="form.provider_unrestricted ? '不限制' : '未选择(全部禁用)'"
empty-text="暂无可用 Provider"
no-results-text="未找到匹配的 Provider"
search-placeholder="搜索 Provider 名称..."
/>
</div>
<Switch
v-model="form.provider_unrestricted"
class="shrink-0"
/>
</div>
</div>
<!-- API 格式多选下拉框 -->
<!-- API 格式 -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的 API 格式</Label>
<MultiSelect
v-model="form.allowed_api_formats"
:options="apiFormatOptions"
:search-threshold="0"
placeholder="全部可用"
empty-text="暂无可用 API 格式"
no-results-text="未找到匹配的 API 格式"
search-placeholder="搜索 API 格式..."
/>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<MultiSelect
v-model="form.allowed_api_formats"
:options="apiFormatOptions"
:search-threshold="0"
:disabled="form.api_format_unrestricted"
:placeholder="form.api_format_unrestricted ? '不限制' : '未选择(全部禁用)'"
empty-text="暂无可用 API 格式"
no-results-text="未找到匹配的 API 格式"
search-placeholder="搜索 API 格式..."
/>
</div>
<Switch
v-model="form.api_format_unrestricted"
class="shrink-0"
/>
</div>
</div>
<!-- 模型多选下拉框 -->
<ModelMultiSelect
v-model="form.allowed_models"
:models="globalModels"
:search-threshold="0"
/>
<!-- 模型 -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的模型</Label>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<MultiSelect
v-model="form.allowed_models"
:options="modelOptions"
:search-threshold="0"
:disabled="form.model_unrestricted"
:placeholder="form.model_unrestricted ? '不限制' : '未选择(全部禁用)'"
empty-text="暂无可用模型"
no-results-text="未找到匹配的模型"
search-placeholder="输入模型名搜索..."
/>
</div>
<Switch
v-model="form.model_unrestricted"
class="shrink-0"
/>
</div>
</div>
<!-- 额度 -->
<div class="space-y-2">
<Label class="text-sm font-medium">额度</Label>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<Input
v-if="!isEditMode && !form.unlimited_balance"
id="form-balance"
:model-value="form.initial_balance_usd ?? ''"
type="number"
step="0.01"
min="0.01"
placeholder="初始额度 (USD)"
class="h-10"
@update:model-value="(v) => form.initial_balance_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
/>
<span
v-else-if="form.unlimited_balance"
class="flex h-10 w-full items-center rounded-lg border bg-background px-3 text-sm text-muted-foreground opacity-60"
>无限制</span>
</div>
<Switch
:model-value="form.unlimited_balance ?? false"
class="shrink-0"
@update:model-value="(v) => form.unlimited_balance = v"
/>
</div>
</div>
</div>
</div>
</form>
@@ -232,7 +253,7 @@ import {
} from '@/components/ui'
import { Plus, SquarePen, X } from 'lucide-vue-next'
import { useFormDialog } from '@/composables/useFormDialog'
import { ModelMultiSelect, MultiSelect } from '@/components/common'
import { MultiSelect } from '@/components/common'
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getGlobalModels } from '@/api/global-models'
import { adminApi } from '@/api/admin'
@@ -248,6 +269,22 @@ export interface StandaloneKeyFormData {
expires_at?: string // ISO 日期字符串,如 "2025-12-31"undefined = 永不过期
rate_limit?: number
auto_delete_on_expiry: boolean
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
}
interface StandaloneKeyFormState {
id?: string
name: string
initial_balance_usd?: number
unlimited_balance?: boolean
expires_at?: string
rate_limit?: number
auto_delete_on_expiry: boolean
provider_unrestricted: boolean
api_format_unrestricted: boolean
model_unrestricted: boolean
allowed_providers: string[]
allowed_api_formats: string[]
allowed_models: string[]
@@ -283,15 +320,24 @@ const apiFormatOptions = computed(() =>
label: format,
}))
)
const modelOptions = computed(() =>
globalModels.value.map((model) => ({
value: model.name,
label: model.name,
}))
)
// 表单数据
const form = ref<StandaloneKeyFormData>({
const form = ref<StandaloneKeyFormState>({
name: '',
initial_balance_usd: 10,
unlimited_balance: false,
expires_at: undefined,
rate_limit: undefined,
auto_delete_on_expiry: false,
provider_unrestricted: true,
api_format_unrestricted: true,
model_unrestricted: true,
allowed_providers: [],
allowed_api_formats: [],
allowed_models: [],
@@ -312,10 +358,13 @@ function resetForm() {
expires_at: undefined,
rate_limit: undefined,
auto_delete_on_expiry: false,
provider_unrestricted: true,
api_format_unrestricted: true,
model_unrestricted: true,
allowed_providers: [],
allowed_api_formats: [],
allowed_models: [],
}
} as typeof form.value
}
function loadKeyData() {
@@ -328,10 +377,13 @@ function loadKeyData() {
expires_at: props.apiKey.expires_at,
rate_limit: props.apiKey.rate_limit,
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
allowed_providers: props.apiKey.allowed_providers || [],
allowed_api_formats: props.apiKey.allowed_api_formats || [],
allowed_models: props.apiKey.allowed_models || [],
}
provider_unrestricted: props.apiKey.allowed_providers == null,
api_format_unrestricted: props.apiKey.allowed_api_formats == null,
model_unrestricted: props.apiKey.allowed_models == null,
allowed_providers: props.apiKey.allowed_providers ? [...props.apiKey.allowed_providers] : [],
allowed_api_formats: props.apiKey.allowed_api_formats ? [...props.apiKey.allowed_api_formats] : [],
allowed_models: props.apiKey.allowed_models ? [...props.apiKey.allowed_models] : [],
} as typeof form.value
}
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
@@ -367,7 +419,18 @@ function clearExpiryDate() {
// 提交表单
function handleSubmit() {
emit('submit', { ...form.value })
emit('submit', {
id: form.value.id,
name: form.value.name,
initial_balance_usd: form.value.initial_balance_usd,
unlimited_balance: form.value.unlimited_balance,
expires_at: form.value.expires_at,
rate_limit: form.value.rate_limit,
auto_delete_on_expiry: form.value.auto_delete_on_expiry,
allowed_providers: form.value.provider_unrestricted ? null : [...form.value.allowed_providers],
allowed_api_formats: form.value.api_format_unrestricted ? null : [...form.value.allowed_api_formats],
allowed_models: form.value.model_unrestricted ? null : [...form.value.allowed_models],
})
}
// 设置保存状态

View File

@@ -226,6 +226,7 @@
v-model:open="showRegisterDialog"
:require-email-verification="requireEmailVerification"
:email-configured="emailConfigured"
:password-policy-level="passwordPolicyLevel"
@success="handleRegisterSuccess"
@switch-to-login="handleSwitchToLogin"
/>
@@ -241,6 +242,7 @@ import Label from '@/components/ui/label.vue'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { normalizePasswordPolicyLevel, type PasswordPolicyLevel } from '@/utils/passwordPolicy'
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
import RegisterDialog from './RegisterDialog.vue'
import { authApi } from '@/api/auth'
@@ -266,6 +268,7 @@ const isDemo = computed(() => isDemoMode())
const showRegisterDialog = ref(false)
const requireEmailVerification = ref(false)
const emailConfigured = ref(true) // 邮箱服务是否已配置
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
// LDAP authentication settings
@@ -378,6 +381,7 @@ onMounted(async () => {
allowRegistration.value = !!regSettings.enable_registration
requireEmailVerification.value = !!regSettings.require_email_verification
emailConfigured.value = !!regSettings.email_configured
passwordPolicyLevel.value = normalizePasswordPolicyLevel(regSettings.password_policy_level)
localEnabled.value = authSettings.local_enabled
ldapEnabled.value = authSettings.ldap_enabled
@@ -402,6 +406,7 @@ onMounted(async () => {
allowRegistration.value = false
requireEmailVerification.value = false
emailConfigured.value = false
passwordPolicyLevel.value = 'weak'
localEnabled.value = true
ldapEnabled.value = false
ldapExclusive.value = false

View File

@@ -157,11 +157,23 @@
data-lpignore="true"
data-1p-ignore="true"
:name="`pwd-${formNonce}`"
placeholder="至少 6 个字符"
:placeholder="getPasswordPolicyPlaceholder(props.passwordPolicyLevel)"
required
class="-webkit-text-security-disc"
:disabled="isLoading"
/>
<p
v-if="passwordError"
class="text-xs text-destructive"
>
{{ passwordError }}
</p>
<p
v-else
class="text-xs text-muted-foreground"
>
{{ passwordHint }}
</p>
</div>
<!-- Confirm Password -->
@@ -223,6 +235,12 @@ import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { authApi } from '@/api/auth'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import {
getPasswordPolicyHint,
getPasswordPolicyPlaceholder,
validatePasswordByPolicy,
type PasswordPolicyLevel,
} from '@/utils/passwordPolicy'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
@@ -232,6 +250,7 @@ interface Props {
open?: boolean
requireEmailVerification?: boolean
emailConfigured?: boolean
passwordPolicyLevel?: PasswordPolicyLevel
}
interface Emits {
@@ -243,7 +262,8 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
open: false,
requireEmailVerification: false,
emailConfigured: true
emailConfigured: true,
passwordPolicyLevel: 'weak'
})
const emit = defineEmits<Emits>()
@@ -386,6 +406,11 @@ const usernameError = computed(() => {
return ''
})
const passwordHint = computed(() => getPasswordPolicyHint(props.passwordPolicyLevel))
const passwordError = computed(() =>
validatePasswordByPolicy(formData.value.password, props.passwordPolicyLevel)
)
const canSubmit = computed(() => {
// 基本信息:用户名和密码必填
const hasBasicInfo =
@@ -410,8 +435,7 @@ const canSubmit = computed(() => {
return false
}
// Check password length
if (formData.value.password.length < 6) {
if (passwordError.value) {
return false
}
@@ -623,9 +647,8 @@ const handleSubmit = async () => {
return
}
// Validate password length
if (formData.value.password.length < 6) {
showError('密码长度至少 6 位', '密码过短')
if (passwordError.value) {
showError(passwordError.value, '密码错误')
return
}

View File

@@ -122,17 +122,11 @@
class="text-[10px] px-1 py-0 h-4 shrink-0"
>{{ key.oauth_plan_type }}</Badge>
<Badge
v-if="getPrimaryOAuthOrganizationTitle(key)"
variant="secondary"
class="text-[10px] px-1 py-0 h-4 shrink-0 max-w-[92px] truncate"
:title="getOAuthOrganizationsTooltip(key)"
>{{ getPrimaryOAuthOrganizationTitle(key) }}</Badge>
<Badge
v-if="key.oauth_account_id"
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[10px] px-1 py-0 h-4 shrink-0"
:title="key.oauth_account_id"
>acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}</Badge>
:title="getOAuthOrgBadge(key)?.id"
>{{ getOAuthOrgBadge(key)?.label }}</Badge>
<Badge
v-if="isBannedKey(key)"
variant="destructive"
@@ -141,10 +135,6 @@
</div>
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
<span
v-if="key.oauth_account_user_id"
:title="key.oauth_account_user_id"
>AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}</span>
<span v-if="key.account_quota">{{ shortenQuota(key.account_quota) }}</span>
<span v-if="key.proxy?.node_id">独立代理</span>
<span
@@ -293,7 +283,7 @@ import {
import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { formatOAuthIdentityShort, getPrimaryOAuthOrganizationTitle, getOAuthOrganizationsTooltip } from '@/utils/oauthIdentity'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
type QuickSelectorValue =
| 'banned'

View File

@@ -290,20 +290,12 @@
{{ formatOAuthPlanType(key.oauth_plan_type) }}
</Badge>
<Badge
v-if="getPrimaryOAuthOrganizationTitle(key)"
variant="secondary"
class="text-[10px] px-1.5 py-0 shrink-0 max-w-[120px] truncate"
:title="getOAuthOrganizationsTooltip(key)"
>
{{ getPrimaryOAuthOrganizationTitle(key) }}
</Badge>
<Badge
v-if="key.oauth_account_id"
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[10px] px-1.5 py-0 shrink-0"
:title="key.oauth_account_id"
:title="getOAuthOrgBadge(key)?.id"
>
acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}
{{ getOAuthOrgBadge(key)?.label }}
</Badge>
<!-- Kiro 订阅类型标签 -->
<Badge
@@ -319,13 +311,6 @@
<span class="text-[11px] font-mono text-muted-foreground">
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'service_account' ? '[Service Account]' : key.api_key_masked) }}
</span>
<span
v-if="key.oauth_account_user_id"
class="text-[10px] text-muted-foreground"
:title="key.oauth_account_user_id"
>
AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}
</span>
<Button
v-if="key.auth_type === 'oauth'"
variant="ghost"
@@ -1126,7 +1111,7 @@ import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/ty
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
import { isAccountLevelBlockReason, cleanAccountBlockReason } from '@/utils/accountBlock'
import { formatOAuthIdentityShort, getPrimaryOAuthOrganizationTitle, getOAuthOrganizationsTooltip } from '@/utils/oauthIdentity'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
// 扩展端点类型,包含密钥列表
interface ProviderEndpointWithKeys extends ProviderEndpoint {

View File

@@ -89,20 +89,27 @@
:name="`field-${formNonce}`"
:required="!isEditMode"
minlength="6"
:placeholder="isEditMode ? '留空保持原密码' : '至少6个字符'"
:class="
:placeholder="isEditMode ? '留空保持原密码' : getPasswordPolicyPlaceholder(passwordPolicyLevel)"
:class="[
!passwordFocused && form.password.length === 0
? 'h-10 text-transparent'
: 'h-10'
"
: 'h-10',
passwordError ? 'border-destructive' : '',
]"
@focus="passwordFocused = true"
@blur="passwordFocused = form.password.length > 0"
/>
<p
v-if="!isEditMode"
v-if="passwordError"
class="text-xs text-destructive"
>
{{ passwordError }}
</p>
<p
v-else-if="!isEditMode"
class="text-xs text-muted-foreground"
>
密码至少需要6个字符
{{ passwordHint }}
</p>
</div>
@@ -176,110 +183,109 @@
</Select>
</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>
</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">
<span class="text-sm font-medium">访问限制</span>
<span class="text-xs text-muted-foreground">(留空不限)</span>
</div>
<!-- Provider 多选下拉框 -->
<!-- Provider -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的 Provider</Label>
<MultiSelect
v-model="form.allowed_providers"
:options="providerOptions"
:search-threshold="0"
placeholder="全部可用"
empty-text="暂无可用 Provider"
no-results-text="未找到匹配的 Provider"
search-placeholder="搜索 Provider 名称..."
/>
</div>
<!-- API 格式多选下拉框 -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的 API 格式</Label>
<MultiSelect
v-model="form.allowed_api_formats"
:options="apiFormatOptions"
:search-threshold="0"
placeholder="全部可用"
empty-text="暂无可用 API 格式"
no-results-text="未找到匹配的 API 格式"
search-placeholder="搜索 API 格式..."
/>
</div>
<!-- 模型多选下拉框 -->
<ModelMultiSelect
v-model="form.allowed_models"
:models="globalModels"
:search-threshold="0"
/>
<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 class="flex-1 min-w-0">
<MultiSelect
v-model="form.allowed_providers"
:options="providerOptions"
:search-threshold="0"
:disabled="form.provider_unrestricted"
:placeholder="form.provider_unrestricted ? '限制' : '未选择(全部禁用)'"
empty-text="暂无可用 Provider"
no-results-text="未找到匹配的 Provider"
search-placeholder="搜索 Provider 名称..."
/>
</div>
<Switch
v-model="form.provider_unrestricted"
class="shrink-0"
/>
</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>
<!-- API 格式 -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的 API 格式</Label>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<MultiSelect
v-model="form.allowed_api_formats"
:options="apiFormatOptions"
:search-threshold="0"
:disabled="form.api_format_unrestricted"
:placeholder="form.api_format_unrestricted ? '不限制' : '未选择(全部禁用)'"
empty-text="暂无可用 API 格式"
no-results-text="未找到匹配的 API 格式"
search-placeholder="搜索 API 格式..."
/>
</div>
<Switch
v-model="form.api_format_unrestricted"
class="shrink-0"
/>
</div>
</div>
<!-- 模型 -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的模型</Label>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<MultiSelect
v-model="form.allowed_models"
:options="modelOptions"
:search-threshold="0"
:disabled="form.model_unrestricted"
:placeholder="form.model_unrestricted ? '不限制' : '未选择(全部禁用)'"
empty-text="暂无可用模型"
no-results-text="未找到匹配的模型"
search-placeholder="输入模型名搜索..."
/>
</div>
<Switch
v-model="form.model_unrestricted"
class="shrink-0"
/>
</div>
</div>
<!-- 额度 -->
<div class="space-y-2">
<Label class="text-sm font-medium">额度</Label>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<Input
v-if="!isEditMode && !form.unlimited"
id="form-initial-gift"
:model-value="form.initial_gift_usd ?? ''"
type="number"
step="0.01"
min="0.01"
placeholder="初始额度 (USD)"
class="h-10"
@update:model-value="(v) => form.initial_gift_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
/>
<span
v-else-if="form.unlimited"
class="flex h-10 w-full items-center rounded-lg border bg-background px-3 text-sm text-muted-foreground opacity-60"
>无限制</span>
</div>
<Switch
v-model="form.unlimited"
class="shrink-0"
/>
</div>
</div>
</div>
</div>
@@ -321,12 +327,19 @@ import {
} from '@/components/ui'
import { UserPlus, SquarePen } from 'lucide-vue-next'
import { useFormDialog } from '@/composables/useFormDialog'
import { ModelMultiSelect, MultiSelect } from '@/components/common'
import { MultiSelect } from '@/components/common'
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 {
getPasswordPolicyHint,
getPasswordPolicyPlaceholder,
normalizePasswordPolicyLevel,
validatePasswordByPolicy,
type PasswordPolicyLevel,
} from '@/utils/passwordPolicy'
import type {
ProviderWithEndpointsSummary,
GlobalModelResponse,
@@ -359,6 +372,7 @@ const isOpen = computed(() => props.open)
const saving = ref(false)
const formNonce = ref(createFieldNonce())
const passwordFocused = ref(false)
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
// 选项数据
const providers = ref<ProviderWithEndpointsSummary[]>([])
@@ -377,6 +391,12 @@ const apiFormatOptions = computed(() =>
label: format.label,
})),
)
const modelOptions = computed(() =>
globalModels.value.map((model) => ({
value: model.name,
label: model.name,
})),
)
// 表单数据
const form = ref({
@@ -388,6 +408,9 @@ const form = ref({
role: 'user' as 'admin' | 'user',
unlimited: false,
is_active: true,
provider_unrestricted: true,
api_format_unrestricted: true,
model_unrestricted: true,
allowed_providers: [] as string[],
allowed_api_formats: [] as string[],
allowed_models: [] as string[],
@@ -409,6 +432,9 @@ function resetForm() {
role: 'user',
unlimited: false,
is_active: true,
provider_unrestricted: true,
api_format_unrestricted: true,
model_unrestricted: true,
allowed_providers: [],
allowed_api_formats: [],
allowed_models: [],
@@ -429,9 +455,12 @@ function loadUserData() {
role: props.user.role,
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 || [])],
allowed_models: [...(props.user.allowed_models || [])],
provider_unrestricted: props.user.allowed_providers == null,
api_format_unrestricted: props.user.allowed_api_formats == null,
model_unrestricted: props.user.allowed_models == null,
allowed_providers: props.user.allowed_providers ? [...props.user.allowed_providers] : [],
allowed_api_formats: props.user.allowed_api_formats ? [...props.user.allowed_api_formats] : [],
allowed_models: props.user.allowed_models ? [...props.user.allowed_models] : [],
}
}
@@ -456,13 +485,14 @@ 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 passwordHint = computed(() => getPasswordPolicyHint(passwordPolicyLevel.value))
const passwordError = computed(() => {
if (!form.value.password) {
return ''
}
return validatePasswordByPolicy(form.value.password, passwordPolicyLevel.value)
})
// 表单验证
const isFormValid = computed(() => {
@@ -470,7 +500,7 @@ const isFormValid = computed(() => {
const usernameValid = !usernameError.value
const passwordFilled = form.value.password.length > 0
const passwordValid = passwordFilled
? !getPasswordValidationError(form.value.password)
? !passwordError.value
: isEditMode.value
// 编辑模式下可留空;填写时必须确认一致。创建模式不展示确认输入框。
const passwordConfirmed = isEditMode.value
@@ -486,16 +516,19 @@ const isFormValid = computed(() => {
// 加载访问控制选项
async function loadAccessControlOptions(): Promise<void> {
try {
const [providersResponse, modelsData, formatsData] = await Promise.all([
const [providersResponse, modelsData, formatsData, passwordPolicyResponse] = await Promise.all([
getProvidersSummary({ page_size: 9999 }),
getGlobalModels({ limit: 1000, is_active: true }),
adminApi.getApiFormats(),
adminApi.getSystemConfig('password_policy_level').catch(() => ({ value: 'weak' })),
])
providers.value = providersResponse.items
globalModels.value = modelsData.models || []
apiFormats.value = formatsData.formats || []
passwordPolicyLevel.value = normalizePasswordPolicyLevel(passwordPolicyResponse.value)
} catch (err) {
log.error('加载访问限制选项失败:', err)
passwordPolicyLevel.value = 'weak'
}
}
@@ -508,16 +541,15 @@ async function handleSubmit() {
email: form.value.email.trim() || '',
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,
allowed_providers: form.value.provider_unrestricted
? null
: [...form.value.allowed_providers],
allowed_api_formats: form.value.api_format_unrestricted
? null
: [...form.value.allowed_api_formats],
allowed_models: form.value.model_unrestricted
? null
: [...form.value.allowed_models],
}
if (isEditMode.value && props.user?.id) {

View File

@@ -1,6 +1,6 @@
import type { OAuthOrganizationInfo } from '@/api/endpoints/types/provider'
export function formatOAuthIdentityShort(
function formatOAuthIdentityShort(
value: string | null | undefined,
head = 8,
tail = 6,
@@ -11,33 +11,24 @@ export function formatOAuthIdentityShort(
return `${normalized.slice(0, head)}...${normalized.slice(-tail)}`
}
export function getPrimaryOAuthOrganizationTitle(
function getPrimaryOAuthOrganizationId(
value: { oauth_organizations?: OAuthOrganizationInfo[] | null } | null | undefined,
): string | null {
const organizations = Array.isArray(value?.oauth_organizations) ? value.oauth_organizations : []
const defaultOrg = organizations.find(
(org) => org?.is_default && typeof org?.title === 'string' && org.title.trim(),
(org) => org?.is_default && typeof org?.id === 'string' && org.id.trim(),
)
if (defaultOrg?.title) return defaultOrg.title.trim()
const firstWithTitle = organizations.find(
(org) => typeof org?.title === 'string' && org.title.trim(),
if (defaultOrg?.id) return defaultOrg.id.trim()
const firstWithId = organizations.find(
(org) => typeof org?.id === 'string' && org.id.trim(),
)
return firstWithTitle?.title?.trim() || null
return firstWithId?.id?.trim() || null
}
export function getOAuthOrganizationsTooltip(
export function getOAuthOrgBadge(
value: { oauth_organizations?: OAuthOrganizationInfo[] | null } | null | undefined,
): string {
const organizations = Array.isArray(value?.oauth_organizations) ? value.oauth_organizations : []
if (organizations.length === 0) return ''
return organizations
.map((org) => {
const title =
typeof org?.title === 'string' && org.title.trim() ? org.title.trim() : '未命名组织'
const role =
typeof org?.role === 'string' && org.role.trim() ? ` (${org.role.trim()})` : ''
const suffix = org?.is_default ? ' [default]' : ''
return `${title}${role}${suffix}`
})
.join('\n')
): { id: string; label: string } | null {
const id = getPrimaryOAuthOrganizationId(value)
if (!id) return null
return { id, label: formatOAuthIdentityShort(id) }
}

View File

@@ -0,0 +1,98 @@
export type PasswordPolicyLevel = 'weak' | 'medium' | 'strong'
export const PASSWORD_POLICY_OPTIONS: Array<{
value: PasswordPolicyLevel
label: string
description: string
}> = [
{
value: 'weak',
label: '弱密码',
description: '至少 6 个字符',
},
{
value: 'medium',
label: '中等密码',
description: '至少 8 个字符,且包含字母和数字',
},
{
value: 'strong',
label: '强密码',
description: '至少 8 个字符,且包含大小写字母、数字和特殊字符',
},
]
export function normalizePasswordPolicyLevel(value: unknown): PasswordPolicyLevel {
if (value === 'medium' || value === 'strong') {
return value
}
return 'weak'
}
export function getPasswordPolicyHint(level: unknown): string {
switch (normalizePasswordPolicyLevel(level)) {
case 'medium':
return '至少 8 个字符,且需包含字母和数字'
case 'strong':
return '至少 8 个字符,且需包含大写字母、小写字母、数字和特殊字符'
case 'weak':
default:
return '至少 6 个字符'
}
}
export function getPasswordPolicyPlaceholder(level: unknown): string {
switch (normalizePasswordPolicyLevel(level)) {
case 'medium':
return '至少 8 位,含字母和数字'
case 'strong':
return '至少 8 位,含大小写字母、数字和特殊字符'
case 'weak':
default:
return '至少 6 个字符'
}
}
export function validatePasswordByPolicy(password: string, level: unknown): string {
if (!password) {
return ''
}
const normalized = normalizePasswordPolicyLevel(level)
if (password.length < 6) {
return '密码长度至少为6个字符'
}
if (normalized === 'medium') {
if (password.length < 8) {
return '密码长度至少为8个字符'
}
if (!/[A-Za-z]/.test(password)) {
return '密码必须包含至少一个字母'
}
if (!/[0-9]/.test(password)) {
return '密码必须包含至少一个数字'
}
}
if (normalized === 'strong') {
if (password.length < 8) {
return '密码长度至少为8个字符'
}
if (!/[A-Z]/.test(password)) {
return '密码必须包含至少一个大写字母'
}
if (!/[a-z]/.test(password)) {
return '密码必须包含至少一个小写字母'
}
if (!/[0-9]/.test(password)) {
return '密码必须包含至少一个数字'
}
if (!/[!@#$%^&*()_+\-=[\]{};:'",.<>?/\\|`~]/.test(password)) {
return '密码必须包含至少一个特殊字符'
}
}
return ''
}

View File

@@ -852,9 +852,9 @@ function editApiKey(apiKey: AdminApiKey) {
expires_at: expiresAt,
rate_limit: apiKey.rate_limit ?? undefined,
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
allowed_providers: apiKey.allowed_providers || [],
allowed_api_formats: apiKey.allowed_api_formats || [],
allowed_models: apiKey.allowed_models || []
allowed_providers: apiKey.allowed_providers == null ? null : [...apiKey.allowed_providers],
allowed_api_formats: apiKey.allowed_api_formats == null ? null : [...apiKey.allowed_api_formats],
allowed_models: apiKey.allowed_models == null ? null : [...apiKey.allowed_models]
}
showKeyFormDialog.value = true

View File

@@ -493,28 +493,13 @@
{{ formatOAuthPlanType(key.oauth_plan_type) }}
</Badge>
<Badge
v-if="getPrimaryOAuthOrganizationTitle(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0 max-w-[92px] truncate"
:title="getOAuthOrganizationsTooltip(key)"
>
{{ getPrimaryOAuthOrganizationTitle(key) }}
</Badge>
<Badge
v-if="key.oauth_account_id"
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="key.oauth_account_id"
:title="getOAuthOrgBadge(key)?.id"
>
acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}
{{ getOAuthOrgBadge(key)?.label }}
</Badge>
<span
v-if="key.oauth_account_user_id"
class="text-[10px] text-muted-foreground shrink-0"
:title="key.oauth_account_user_id"
>
AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}
</span>
</div>
</div>
</TableCell>
@@ -818,28 +803,13 @@
{{ formatOAuthPlanType(key.oauth_plan_type) }}
</Badge>
<Badge
v-if="getPrimaryOAuthOrganizationTitle(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0 max-w-[92px] truncate"
:title="getOAuthOrganizationsTooltip(key)"
>
{{ getPrimaryOAuthOrganizationTitle(key) }}
</Badge>
<Badge
v-if="key.oauth_account_id"
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="key.oauth_account_id"
:title="getOAuthOrgBadge(key)?.id"
>
acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}
{{ getOAuthOrgBadge(key)?.label }}
</Badge>
<span
v-if="key.oauth_account_user_id"
class="text-[10px] text-muted-foreground shrink-0"
:title="key.oauth_account_user_id"
>
AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}
</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0 flex-wrap justify-end max-w-[210px]">
@@ -1225,7 +1195,7 @@ import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDial
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import { isAccountLevelBlockReason, classifyAccountBlockLabel, cleanAccountBlockReason } from '@/utils/accountBlock'
import { formatOAuthIdentityShort, getPrimaryOAuthOrganizationTitle, getOAuthOrganizationsTooltip } from '@/utils/oauthIdentity'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
const { success, error: showError, warning: showWarning } = useToast()
const { confirm } = useConfirm()

View File

@@ -1,5 +1,72 @@
<template>
<div class="space-y-4">
<Card
v-if="providerDeleteProgress"
class="border-primary/30 bg-primary/5"
>
<div class="px-5 py-4 space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-semibold text-foreground">
正在删除提供商{{ providerDeleteProgress.providerName }}
</div>
<div class="mt-1 text-xs text-muted-foreground">
{{ providerDeleteStageLabel }} · {{ providerDeleteProgress.message || '后台处理中' }}
</div>
</div>
<div class="shrink-0 text-right">
<div class="text-xs font-medium text-primary">
{{ providerDeleteOverallPercent }}%
</div>
<div class="text-[11px] text-muted-foreground">
{{ providerDeleteCompletedUnits }}/{{ providerDeleteTotalUnits }}
</div>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>总体进度</span>
<span>{{ providerDeleteCompletedUnits }}/{{ providerDeleteTotalUnits }}</span>
</div>
<div class="h-2 rounded-full bg-primary/10 overflow-hidden">
<div
class="h-full bg-primary transition-all duration-300"
:style="{ width: `${providerDeleteOverallPercent}%` }"
/>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-2">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>账号删除</span>
<span>{{ providerDeleteProgress.deletedKeys }}/{{ providerDeleteProgress.totalKeys || '...' }}</span>
</div>
<div class="h-2 rounded-full bg-primary/10 overflow-hidden">
<div
class="h-full bg-primary/80 transition-all duration-300"
:style="{ width: `${providerDeleteKeysPercent}%` }"
/>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>端点删除</span>
<span>{{ providerDeleteProgress.deletedEndpoints }}/{{ providerDeleteProgress.totalEndpoints || '...' }}</span>
</div>
<div class="h-2 rounded-full bg-primary/10 overflow-hidden">
<div
class="h-full bg-primary/60 transition-all duration-300"
:style="{ width: `${providerDeleteEndpointsPercent}%` }"
/>
</div>
</div>
</div>
</div>
</Card>
<!-- 提供商表格 -->
<Card
variant="default"
@@ -212,6 +279,7 @@ import {
getProvidersSummary,
getProvider,
deleteProvider,
getProviderDeleteTask,
updateProvider,
getGlobalModels,
type ProviderWithEndpointsSummary,
@@ -219,7 +287,20 @@ import {
import { adminApi } from '@/api/admin'
import { parseApiError } from '@/utils/errorParser'
const { error: showError, success: showSuccess } = useToast()
interface ProviderDeleteProgressState {
providerId: string
providerName: string
taskId: string
status: string
stage: string
totalKeys: number
deletedKeys: number
totalEndpoints: number
deletedEndpoints: number
message: string
}
const { error: showError, success: showSuccess, info: showInfo } = useToast()
const { confirmDanger } = useConfirm()
// 状态
@@ -232,6 +313,113 @@ const priorityDialogOpen = ref(false)
const priorityMode = ref<'provider' | 'global_key'>('provider')
const providerDrawerOpen = ref(false)
const selectedProviderId = ref<string | null>(null)
const providerDeleteProgress = ref<ProviderDeleteProgressState | null>(null)
let deletePollAbort: AbortController | null = null
const DELETE_POLL_INTERVAL_MS = 2000
const DELETE_POLL_MAX_MS = 30 * 60 * 1000
const DELETE_POLL_MAX_FAILURES = 3
async function pollProviderDeleteTask(providerId: string, taskId: string) {
deletePollAbort?.abort()
const abort = new AbortController()
deletePollAbort = abort
const deadline = Date.now() + DELETE_POLL_MAX_MS
let consecutiveFailures = 0
while (Date.now() < deadline) {
if (abort.signal.aborted) return null
try {
const task = await getProviderDeleteTask(providerId, taskId)
consecutiveFailures = 0
if (providerDeleteProgress.value?.taskId === taskId) {
providerDeleteProgress.value = {
...providerDeleteProgress.value,
status: task.status,
stage: task.stage,
totalKeys: task.total_keys,
deletedKeys: task.deleted_keys,
totalEndpoints: task.total_endpoints,
deletedEndpoints: task.deleted_endpoints,
message: task.message,
}
}
if (task.status === 'completed' || task.status === 'failed') {
return task
}
} catch {
consecutiveFailures += 1
if (consecutiveFailures >= DELETE_POLL_MAX_FAILURES) {
throw new Error('provider delete task polling failed')
}
}
await new Promise((resolve) => {
const timer = setTimeout(resolve, DELETE_POLL_INTERVAL_MS)
abort.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(undefined) }, { once: true })
})
}
throw new Error('provider delete task timeout')
}
const providerDeleteStageLabel = computed(() => {
switch (providerDeleteProgress.value?.stage) {
case 'preparing':
return '准备删除'
case 'disabling':
return '停用提供商'
case 'cleaning_restrictions':
return '清理访问限制'
case 'cleaning_provider_refs':
return '清理历史引用'
case 'deleting_keys':
return '删除号池账号'
case 'deleting_endpoints':
return '删除端点'
case 'completed':
return '删除完成'
case 'failed':
return '删除失败'
default:
return '等待执行'
}
})
const providerDeleteTotalUnits = computed(() => {
const progress = providerDeleteProgress.value
if (!progress) return 0
return progress.totalKeys + progress.totalEndpoints
})
const providerDeleteCompletedUnits = computed(() => {
const progress = providerDeleteProgress.value
if (!progress) return 0
return Math.min(progress.deletedKeys + progress.deletedEndpoints, providerDeleteTotalUnits.value)
})
const providerDeleteOverallPercent = computed(() => {
const progress = providerDeleteProgress.value
if (!progress) return 0
if (progress.status === 'completed') return 100
if (providerDeleteTotalUnits.value <= 0) return 0
return Math.min(
100,
Math.round((providerDeleteCompletedUnits.value / providerDeleteTotalUnits.value) * 100),
)
})
const providerDeleteKeysPercent = computed(() => {
const progress = providerDeleteProgress.value
if (!progress?.totalKeys) return 0
return Math.min(100, Math.round((progress.deletedKeys / progress.totalKeys) * 100))
})
const providerDeleteEndpointsPercent = computed(() => {
const progress = providerDeleteProgress.value
if (!progress?.totalEndpoints) return 0
return Math.min(100, Math.round((progress.deletedEndpoints / progress.totalEndpoints) * 100))
})
// 全局模型数据(用于模型筛选下拉)
const globalModels = ref<{ id: string; name: string }[]>([])
@@ -477,10 +665,32 @@ async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
if (!confirmed) return
try {
await deleteProvider(provider.id)
const result = await deleteProvider(provider.id)
providerDeleteProgress.value = {
providerId: provider.id,
providerName: provider.name,
taskId: result.task_id,
status: result.status,
stage: 'queued',
totalKeys: provider.total_keys || 0,
deletedKeys: 0,
totalEndpoints: provider.total_endpoints || 0,
deletedEndpoints: 0,
message: result.message || '删除任务已提交,后台处理中',
}
showInfo(result.message || '删除任务已提交,后台处理中')
const task = await pollProviderDeleteTask(provider.id, result.task_id)
if (!task) return // aborted
if (task.status === 'failed') {
throw new Error(task.message || 'provider delete task failed')
}
showSuccess('提供商已删除')
providerDeleteProgress.value = null
loadProviders()
} catch (err: unknown) {
providerDeleteProgress.value = null
showError(parseApiError(err, '删除提供商失败'), '错误')
}
}
@@ -525,6 +735,7 @@ onMounted(() => {
})
onUnmounted(() => {
deletePollAbort?.abort()
if (debounceTimer) clearTimeout(debounceTimer)
document.removeEventListener('click', handleGlobalClick, true)
stopTick()

View File

@@ -59,6 +59,7 @@
: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"
:password-policy-level="systemConfig.password_policy_level"
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
:enable-format-conversion="systemConfig.enable_format_conversion"
:loading="basicConfigLoading"
@@ -67,6 +68,7 @@
@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:password-policy-level="systemConfig.password_policy_level = $event"
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
/>

View File

@@ -1069,9 +1069,9 @@ function editUser(user: User) {
unlimited: user.unlimited,
role: user.role,
is_active: user.is_active,
allowed_providers: [...(user.allowed_providers || [])],
allowed_api_formats: [...(user.allowed_api_formats || [])],
allowed_models: [...(user.allowed_models || [])]
allowed_providers: user.allowed_providers == null ? null : [...user.allowed_providers],
allowed_api_formats: user.allowed_api_formats == null ? null : [...user.allowed_api_formats],
allowed_models: user.allowed_models == null ? null : [...user.allowed_models]
}
showUserFormDialog.value = true
}

View File

@@ -54,6 +54,40 @@
</p>
</div>
<div>
<Label
for="password-policy-level"
class="block text-sm font-medium mb-2"
>
密码策略
</Label>
<Select
:model-value="passwordPolicyLevel"
@update:model-value="$emit('update:passwordPolicyLevel', $event)"
>
<SelectTrigger
id="password-policy-level"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="weak">
弱密码 - 至少 6 个字符
</SelectItem>
<SelectItem value="medium">
中等密码 - 至少 8 含字母和数字
</SelectItem>
<SelectItem value="strong">
强密码 - 至少 8 含大小写字母数字和特殊字符
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
影响注册创建用户重置/修改密码的校验规则
</p>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
@@ -125,12 +159,18 @@ import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.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'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { CardSection } from '@/components/layout'
defineProps<{
defaultUserInitialGiftUsd: number
rateLimitPerMinute: number
enableRegistration: boolean
passwordPolicyLevel: string
autoDeleteExpiredKeys: boolean
enableFormatConversion: boolean
loading: boolean
@@ -142,6 +182,7 @@ defineEmits<{
'update:defaultUserInitialGiftUsd': [value: number]
'update:rateLimitPerMinute': [value: number]
'update:enableRegistration': [value: boolean]
'update:passwordPolicyLevel': [value: string]
'update:autoDeleteExpiredKeys': [value: boolean]
'update:enableFormatConversion': [value: boolean]
}>()

View File

@@ -14,6 +14,7 @@ export interface SystemConfig {
default_user_initial_gift_usd: number
rate_limit_per_minute: number
enable_registration: boolean
password_policy_level: string
// 独立余额 Key 过期管理
auto_delete_expired_keys: boolean
// 格式转换
@@ -47,6 +48,7 @@ const CONFIG_KEYS = [
'default_user_initial_gift_usd',
'rate_limit_per_minute',
'enable_registration',
'password_policy_level',
// 独立余额 Key 过期管理
'auto_delete_expired_keys',
// 格式转换
@@ -81,6 +83,7 @@ function createDefaultConfig(): SystemConfig {
default_user_initial_gift_usd: 10.0,
rate_limit_per_minute: 0,
enable_registration: false,
password_policy_level: 'weak',
// 独立余额 Key 过期管理
auto_delete_expired_keys: false,
// 格式转换
@@ -140,6 +143,7 @@ export function useSystemConfig() {
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.password_policy_level !== originalConfig.value.password_policy_level ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion
)
@@ -296,6 +300,11 @@ export function useSystemConfig() {
value: systemConfig.value.enable_registration,
description: '是否开放用户注册',
},
{
key: 'password_policy_level',
value: systemConfig.value.password_policy_level,
description: '密码策略等级',
},
{
key: 'auto_delete_expired_keys',
value: systemConfig.value.auto_delete_expired_keys,
@@ -317,6 +326,7 @@ export function useSystemConfig() {
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.password_policy_level = systemConfig.value.password_policy_level
originalConfig.value.auto_delete_expired_keys =
systemConfig.value.auto_delete_expired_keys
originalConfig.value.enable_format_conversion =

View File

@@ -119,6 +119,18 @@
type="password"
class="mt-1"
/>
<p
v-if="passwordError"
class="mt-1 text-xs text-destructive"
>
{{ passwordError }}
</p>
<p
v-else
class="mt-1 text-xs text-muted-foreground"
>
{{ passwordPolicyHint }}
</p>
</div>
<div>
<Label for="confirm-password">确认{{ profile?.has_password ? '新' : '' }}密码</Label>
@@ -465,6 +477,12 @@ import { authApi } from '@/api/auth'
import { oauthApi, type OAuthLinkInfo, type OAuthProviderInfo } from '@/api/oauth'
import { getOAuthIcon } from '@/utils/oauth-icons'
import { useDarkMode, type ThemeMode } from '@/composables/useDarkMode'
import {
getPasswordPolicyHint,
normalizePasswordPolicyLevel,
validatePasswordByPolicy,
type PasswordPolicyLevel,
} from '@/utils/passwordPolicy'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
@@ -516,6 +534,7 @@ const preferencesForm = ref({
const savingProfile = ref(false)
const changingPassword = ref(false)
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
const themeSelectOpen = ref(false)
const languageSelectOpen = ref(false)
@@ -539,6 +558,11 @@ const hasProfileChanges = computed(() => {
)
})
const passwordPolicyHint = computed(() => getPasswordPolicyHint(passwordPolicyLevel.value))
const passwordError = computed(() =>
validatePasswordByPolicy(passwordForm.value.new_password, passwordPolicyLevel.value)
)
// 检测密码表单是否有内容
const hasPasswordChanges = computed(() => {
const hasPassword = profile.value?.has_password
@@ -577,8 +601,10 @@ async function loadEmailConfigured() {
try {
const settings = await authApi.getRegistrationSettings()
emailConfigured.value = !!settings.email_configured
passwordPolicyLevel.value = normalizePasswordPolicyLevel(settings.password_policy_level)
} catch {
emailConfigured.value = false
passwordPolicyLevel.value = 'weak'
}
}
@@ -766,8 +792,8 @@ async function changePassword() {
return
}
if (passwordForm.value.new_password.length < 6) {
showError('密码长度至少6位', '密码错误')
if (passwordError.value) {
showError(passwordError.value, '密码错误')
return
}