mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
@@ -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],
|
||||
})
|
||||
}
|
||||
|
||||
// 设置保存状态
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user