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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}>()
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user