feat: 实现邮箱验证注册功能

添加完整的邮箱验证注册系统,包括验证码发送、验证和限流控制:
  - 新增邮箱验证服务模块(email_sender, email_template, email_verification)
  - 更新认证API支持邮箱验证注册流程
  - 添加注册对话框和验证码输入组件
  - 完善IP限流器支持邮箱验证场景
  - 修复前端类型定义问题,升级esbuild依赖

  🤖 Generated with [Claude Code](https://claude.com/claude-code)

  Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
RWDai
2025-12-30 17:15:48 +08:00
parent b89a4af0cf
commit 6bd8cdb9cf
21 changed files with 2464 additions and 404 deletions

View File

@@ -386,5 +386,14 @@ export const adminApi = {
{ provider_id: providerId, api_key_id: apiKeyId }
)
return response.data
},
// 测试 SMTP 连接,支持传入未保存的配置
async testSmtpConnection(config: Record<string, any> = {}): Promise<{ success: boolean; message: string }> {
const response = await apiClient.post<{ success: boolean; message: string }>(
'/api/admin/system/smtp/test',
config
)
return response.data
}
}

View File

@@ -31,6 +31,45 @@ export interface UserStats {
[key: string]: unknown // 允许扩展其他统计数据
}
export interface SendVerificationCodeRequest {
email: string
}
export interface SendVerificationCodeResponse {
message: string
success: boolean
expire_minutes?: number
}
export interface VerifyEmailRequest {
email: string
code: string
}
export interface VerifyEmailResponse {
message: string
success: boolean
}
export interface RegisterRequest {
email: string
username: string
password: string
}
export interface RegisterResponse {
user_id: string
email: string
username: string
message: string
}
export interface RegistrationSettingsResponse {
enable_registration: boolean
require_email_verification: boolean
verification_code_expire_minutes?: number
}
export interface User {
id: string // UUID
username: string
@@ -87,5 +126,33 @@ export const authApi = {
localStorage.setItem('refresh_token', response.data.refresh_token)
}
return response.data
},
async sendVerificationCode(email: string): Promise<SendVerificationCodeResponse> {
const response = await apiClient.post<SendVerificationCodeResponse>(
'/api/auth/send-verification-code',
{ email }
)
return response.data
},
async verifyEmail(email: string, code: string): Promise<VerifyEmailResponse> {
const response = await apiClient.post<VerifyEmailResponse>(
'/api/auth/verify-email',
{ email, code }
)
return response.data
},
async register(data: RegisterRequest): Promise<RegisterResponse> {
const response = await apiClient.post<RegisterResponse>('/api/auth/register', data)
return response.data
},
async getRegistrationSettings(): Promise<RegistrationSettingsResponse> {
const response = await apiClient.get<RegistrationSettingsResponse>(
'/api/auth/registration-settings'
)
return response.data
}
}

View File

@@ -0,0 +1,192 @@
<template>
<div class="verification-code-input">
<div class="code-inputs flex gap-2">
<input
v-for="(digit, index) in digits"
:key="index"
:ref="(el) => (inputRefs[index] = el as HTMLInputElement)"
v-model="digits[index]"
type="text"
inputmode="numeric"
maxlength="1"
class="code-digit"
:class="{ error: hasError }"
@input="handleInput(index, $event)"
@keydown="handleKeyDown(index, $event)"
@paste="handlePaste"
>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface Props {
modelValue?: string
length?: number
hasError?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'complete', value: string): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
length: 6,
hasError: false
})
const emit = defineEmits<Emits>()
const digits = ref<string[]>(Array(props.length).fill(''))
const inputRefs = ref<HTMLInputElement[]>([])
// Watch modelValue changes from parent
watch(
() => props.modelValue,
(newValue) => {
if (newValue.length <= props.length) {
digits.value = newValue.split('').concat(Array(props.length - newValue.length).fill(''))
}
},
{ immediate: true }
)
const updateValue = () => {
const value = digits.value.join('')
emit('update:modelValue', value)
// Emit complete event when all digits are filled
if (value.length === props.length && /^\d+$/.test(value)) {
emit('complete', value)
}
}
const handleInput = (index: number, event: Event) => {
const input = event.target as HTMLInputElement
const value = input.value
// Only allow digits
if (!/^\d*$/.test(value)) {
input.value = digits.value[index]
return
}
digits.value[index] = value
// Auto-focus next input
if (value && index < props.length - 1) {
inputRefs.value[index + 1]?.focus()
}
updateValue()
}
const handleKeyDown = (index: number, event: KeyboardEvent) => {
// Handle backspace
if (event.key === 'Backspace') {
if (!digits.value[index] && index > 0) {
// If current input is empty, move to previous and clear it
inputRefs.value[index - 1]?.focus()
digits.value[index - 1] = ''
updateValue()
} else {
// Clear current input
digits.value[index] = ''
updateValue()
}
}
// Handle arrow keys
else if (event.key === 'ArrowLeft' && index > 0) {
inputRefs.value[index - 1]?.focus()
} else if (event.key === 'ArrowRight' && index < props.length - 1) {
inputRefs.value[index + 1]?.focus()
}
}
const handlePaste = (event: ClipboardEvent) => {
event.preventDefault()
const pastedData = event.clipboardData?.getData('text') || ''
const cleanedData = pastedData.replace(/\D/g, '').slice(0, props.length)
if (cleanedData) {
digits.value = cleanedData.split('').concat(Array(props.length - cleanedData.length).fill(''))
updateValue()
// Focus the next empty input or the last input
const nextEmptyIndex = digits.value.findIndex((d) => !d)
const focusIndex = nextEmptyIndex >= 0 ? nextEmptyIndex : props.length - 1
inputRefs.value[focusIndex]?.focus()
}
}
// Expose method to clear inputs
const clear = () => {
digits.value = Array(props.length).fill('')
inputRefs.value[0]?.focus()
updateValue()
}
// Expose method to focus first input
const focus = () => {
inputRefs.value[0]?.focus()
}
defineExpose({
clear,
focus
})
</script>
<style scoped>
.code-inputs {
display: flex;
justify-content: center;
align-items: center;
}
.code-digit {
width: 3rem;
height: 3.5rem;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
border: 2px solid hsl(var(--border));
border-radius: var(--radius);
background-color: hsl(var(--background));
color: hsl(var(--foreground));
transition: all 0.2s;
}
.code-digit:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
.code-digit:hover:not(:focus) {
border-color: hsl(var(--primary) / 0.5);
}
.code-digit.error {
border-color: hsl(var(--destructive));
}
.code-digit.error:focus {
box-shadow: 0 0 0 3px hsl(var(--destructive) / 0.1);
}
/* Prevent spinner buttons on number inputs */
.code-digit::-webkit-outer-spin-button,
.code-digit::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.code-digit[type='number'] {
-moz-appearance: textfield;
}
</style>

View File

@@ -98,12 +98,27 @@
<!-- 提示信息 -->
<p
v-if="!isDemo"
v-if="!isDemo && !allowRegistration"
class="text-xs text-slate-400 dark:text-muted-foreground/80"
>
如需开通账户请联系管理员配置访问权限
</p>
</form>
<!-- 注册链接 -->
<div
v-if="allowRegistration"
class="mt-4 text-center text-sm"
>
还没有账户
<Button
variant="link"
class="h-auto p-0"
@click="handleSwitchToRegister"
>
立即注册
</Button>
</div>
</div>
<template #footer>
@@ -124,10 +139,18 @@
</Button>
</template>
</Dialog>
<!-- Register Dialog -->
<RegisterDialog
v-model:open="showRegisterDialog"
:require-email-verification="requireEmailVerification"
@success="handleRegisterSuccess"
@switch-to-login="handleSwitchToLogin"
/>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
@@ -136,6 +159,8 @@ import Label from '@/components/ui/label.vue'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
import RegisterDialog from './RegisterDialog.vue'
import { authApi } from '@/api/auth'
const props = defineProps<{
modelValue: boolean
@@ -151,6 +176,9 @@ const { success: showSuccess, warning: showWarning, error: showError } = useToas
const isOpen = ref(props.modelValue)
const isDemo = computed(() => isDemoMode())
const showRegisterDialog = ref(false)
const requireEmailVerification = ref(false)
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
watch(() => props.modelValue, (val) => {
isOpen.value = val
@@ -201,4 +229,33 @@ async function handleLogin() {
showError(authStore.error || '登录失败,请检查邮箱和密码')
}
}
function handleSwitchToRegister() {
isOpen.value = false
showRegisterDialog.value = true
}
function handleRegisterSuccess() {
showRegisterDialog.value = false
showSuccess('注册成功!请登录')
isOpen.value = true
}
function handleSwitchToLogin() {
showRegisterDialog.value = false
isOpen.value = true
}
// Load registration settings on mount
onMounted(async () => {
try {
const settings = await authApi.getRegistrationSettings()
allowRegistration.value = !!settings.enable_registration
requireEmailVerification.value = !!settings.require_email_verification
} catch (error) {
// If获取失败保持默认关闭注册 & 关闭邮箱验证
allowRegistration.value = false
requireEmailVerification.value = false
}
})
</script>

View File

@@ -0,0 +1,443 @@
<template>
<Dialog
v-model:open="isOpen"
size="lg"
>
<DialogContent>
<!-- Logo -->
<div class="flex justify-center mb-6">
<div
class="w-16 h-16 rounded-full border-2 border-primary/20 flex items-center justify-center bg-primary/5"
>
<img
src="@/assets/logo.svg"
alt="Logo"
class="w-10 h-10"
>
</div>
</div>
<DialogHeader>
<DialogTitle class="text-center text-2xl">
注册新账户
</DialogTitle>
<DialogDescription class="text-center">
请填写您的邮箱和个人信息完成注册
</DialogDescription>
</DialogHeader>
<form
class="space-y-4 mt-4"
@submit.prevent="handleSubmit"
>
<!-- Email -->
<div class="space-y-2">
<Label for="register-email">邮箱</Label>
<Input
id="register-email"
v-model="formData.email"
type="email"
placeholder="your@email.com"
required
:disabled="isLoading || emailVerified"
/>
</div>
<!-- Verification Code Section -->
<div
v-if="requireEmailVerification"
class="space-y-2"
>
<div class="flex items-center justify-between">
<Label for="verification-code">验证码</Label>
<Button
type="button"
variant="link"
size="sm"
class="h-auto p-0 text-xs"
:disabled="isLoading || !canSendCode || emailVerified"
@click="handleSendCode"
>
{{ sendCodeButtonText }}
</Button>
</div>
<VerificationCodeInput
ref="codeInputRef"
v-model="formData.verificationCode"
:has-error="verificationError"
:length="6"
@complete="handleCodeComplete"
/>
<p
v-if="verificationError"
class="text-xs text-destructive"
>
验证码错误请重新输入
</p>
<p
v-if="emailVerified"
class="text-xs text-green-600"
>
邮箱验证成功
</p>
</div>
<!-- Username -->
<div class="space-y-2">
<Label for="register-username">用户名</Label>
<Input
id="register-username"
v-model="formData.username"
type="text"
placeholder="请输入用户名"
required
:disabled="isLoading"
/>
</div>
<!-- Password -->
<div class="space-y-2">
<Label for="register-password">密码</Label>
<Input
id="register-password"
v-model="formData.password"
type="password"
placeholder="至少 8 位字符"
required
:disabled="isLoading"
/>
<p class="text-xs text-muted-foreground">
密码长度至少 8
</p>
</div>
<!-- Confirm Password -->
<div class="space-y-2">
<Label for="register-confirm-password">确认密码</Label>
<Input
id="register-confirm-password"
v-model="formData.confirmPassword"
type="password"
placeholder="再次输入密码"
required
:disabled="isLoading"
/>
</div>
<DialogFooter class="gap-2">
<Button
type="button"
variant="outline"
:disabled="isLoading"
@click="handleCancel"
>
取消
</Button>
<Button
type="submit"
:disabled="isLoading || !canSubmit"
>
<span
v-if="isLoading"
class="flex items-center gap-2"
>
<span class="animate-spin"></span>
{{ loadingText }}
</span>
<span v-else>注册</span>
</Button>
</DialogFooter>
</form>
<div class="mt-4 text-center text-sm">
已有账户
<Button
variant="link"
class="h-auto p-0"
@click="handleSwitchToLogin"
>
立即登录
</Button>
</div>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { authApi } from '@/api/auth'
import { useToast } from '@/composables/useToast'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Button,
Input,
Label
} from '@/components/ui'
import VerificationCodeInput from '@/components/VerificationCodeInput.vue'
interface Props {
open?: boolean
requireEmailVerification?: boolean
}
interface Emits {
(e: 'update:open', value: boolean): void
(e: 'success'): void
(e: 'switchToLogin'): void
}
const props = withDefaults(defineProps<Props>(), {
open: false,
requireEmailVerification: false
})
const emit = defineEmits<Emits>()
const { showToast, success, error: showError } = useToast()
const codeInputRef = ref<InstanceType<typeof VerificationCodeInput> | null>(null)
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
const formData = ref({
email: '',
username: '',
password: '',
confirmPassword: '',
verificationCode: ''
})
const isLoading = ref(false)
const loadingText = ref('注册中...')
const emailVerified = ref(false)
const verificationError = ref(false)
const codeSentAt = ref<number | null>(null)
const cooldownSeconds = ref(0)
const expireMinutes = ref(30)
const cooldownTimer = ref<number | null>(null)
// Send code cooldown timer
const canSendCode = computed(() => {
if (!formData.value.email) return false
if (cooldownSeconds.value > 0) return false
return true
})
const sendCodeButtonText = computed(() => {
if (emailVerified.value) return '已验证'
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
if (codeSentAt.value) return '重新发送验证码'
return '发送验证码'
})
const canSubmit = computed(() => {
const hasBasicInfo =
formData.value.email &&
formData.value.username &&
formData.value.password &&
formData.value.confirmPassword
if (!hasBasicInfo) return false
// If email verification is required, check if verified
if (props.requireEmailVerification && !emailVerified.value) {
return false
}
// Check password match
if (formData.value.password !== formData.value.confirmPassword) {
return false
}
// Check password length
if (formData.value.password.length < 8) {
return false
}
return true
})
// Reset form when dialog opens
watch(isOpen, (newValue) => {
if (newValue) {
resetForm()
}
})
// Start cooldown timer
const startCooldown = (seconds: number) => {
// Clear existing timer if any
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
}
cooldownSeconds.value = seconds
cooldownTimer.value = window.setInterval(() => {
cooldownSeconds.value--
if (cooldownSeconds.value <= 0) {
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
cooldownTimer.value = null
}
}
}, 1000)
}
// Cleanup timer on unmount
onUnmounted(() => {
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
}
})
const resetForm = () => {
formData.value = {
email: '',
username: '',
password: '',
confirmPassword: '',
verificationCode: ''
}
emailVerified.value = false
verificationError.value = false
codeSentAt.value = null
cooldownSeconds.value = 0
// Clear timer
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
cooldownTimer.value = null
}
codeInputRef.value?.clear()
}
const handleSendCode = async () => {
if (!formData.value.email) {
showError('请输入邮箱')
return
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(formData.value.email)) {
showError('请输入有效的邮箱地址', '邮箱格式错误')
return
}
isLoading.value = true
loadingText.value = '发送中...'
try {
const response = await authApi.sendVerificationCode(formData.value.email)
if (response.success) {
codeSentAt.value = Date.now()
if (response.expire_minutes) {
expireMinutes.value = response.expire_minutes
}
success(`请查收邮件,验证码有效期 ${expireMinutes.value} 分钟`, '验证码已发送')
// Start 60 second cooldown
startCooldown(60)
// Focus the verification code input
setTimeout(() => {
codeInputRef.value?.focus()
}, 100)
} else {
showError(response.message || '请稍后重试', '发送失败')
}
} catch (error: any) {
showError(error.response?.data?.detail || error.message || '网络错误,请重试', '发送失败')
} finally {
isLoading.value = false
}
}
const handleCodeComplete = async (code: string) => {
if (!formData.value.email || code.length !== 6) return
isLoading.value = true
loadingText.value = '验证中...'
verificationError.value = false
try {
const response = await authApi.verifyEmail(formData.value.email, code)
if (response.success) {
emailVerified.value = true
success('邮箱验证通过,请继续完成注册', '验证成功')
} else {
verificationError.value = true
showError(response.message || '验证码错误', '验证失败')
// Clear the code input
codeInputRef.value?.clear()
}
} catch (error: any) {
verificationError.value = true
showError(error.response?.data?.detail || error.message || '验证码错误,请重试', '验证失败')
// Clear the code input
codeInputRef.value?.clear()
} finally {
isLoading.value = false
}
}
const handleSubmit = async () => {
// Validate password match
if (formData.value.password !== formData.value.confirmPassword) {
showError('两次输入的密码不一致', '密码不匹配')
return
}
// Validate password length
if (formData.value.password.length < 8) {
showError('密码长度至少 8 位', '密码过短')
return
}
// Check email verification if required
if (props.requireEmailVerification && !emailVerified.value) {
showError('请先完成邮箱验证')
return
}
isLoading.value = true
loadingText.value = '注册中...'
try {
const response = await authApi.register({
email: formData.value.email,
username: formData.value.username,
password: formData.value.password
})
success(response.message || '欢迎加入!请登录以继续', '注册成功')
emit('success')
isOpen.value = false
} catch (error: any) {
showError(error.response?.data?.detail || error.message || '注册失败,请重试', '注册失败')
} finally {
isLoading.value = false
}
}
const handleCancel = () => {
isOpen.value = false
}
const handleSwitchToLogin = () => {
emit('switchToLogin')
isOpen.value = false
}
</script>

View File

@@ -374,8 +374,6 @@ import {
} from '@/api/endpoints'
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
const props = defineProps<{
open: boolean
providerId: string
@@ -388,6 +386,8 @@ const emit = defineEmits<{
'changed': []
}>()
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
const { error: showError, success } = useToast()
// 状态

View File

@@ -177,8 +177,8 @@
<Label for="proxy_user">用户名可选</Label>
<Input
:id="`proxy_user_${formId}`"
:name="`proxy_user_${formId}`"
v-model="form.proxy_username"
:name="`proxy_user_${formId}`"
placeholder="代理认证用户名"
autocomplete="off"
data-form-type="other"
@@ -191,8 +191,8 @@
<Label :for="`proxy_pass_${formId}`">密码可选</Label>
<Input
:id="`proxy_pass_${formId}`"
:name="`proxy_pass_${formId}`"
v-model="form.proxy_password"
:name="`proxy_pass_${formId}`"
type="text"
:placeholder="passwordPlaceholder"
autocomplete="off"

View File

@@ -126,8 +126,14 @@
:disabled="testingModelName === model.global_model_name"
@click.stop="testModelConnection(model)"
>
<Loader2 v-if="testingModelName === model.global_model_name" class="w-3.5 h-3.5 animate-spin" />
<Play v-else class="w-3.5 h-3.5" />
<Loader2
v-if="testingModelName === model.global_model_name"
class="w-3.5 h-3.5 animate-spin"
/>
<Play
v-else
class="w-3.5 h-3.5"
/>
</Button>
</div>
</div>

View File

@@ -131,8 +131,14 @@
:disabled="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`"
@click="testMapping(group, mapping)"
>
<Loader2 v-if="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`" class="w-3 h-3 animate-spin" />
<Play v-else class="w-3 h-3" />
<Loader2
v-if="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`"
class="w-3 h-3 animate-spin"
/>
<Play
v-else
class="w-3 h-3"
/>
</Button>
</div>
</div>

View File

@@ -1057,7 +1057,10 @@ onBeforeUnmount(() => {
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔推荐合适的缓存 TTL</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<Select v-model="analysisHours" v-model:open="analysisHoursSelectOpen">
<Select
v-model="analysisHours"
v-model:open="analysisHoursSelectOpen"
>
<SelectTrigger class="w-24 sm:w-28 h-8">
<SelectValue placeholder="时间段" />
</SelectTrigger>

View File

@@ -185,6 +185,218 @@
</div>
</CardSection>
<!-- SMTP 邮件配置 -->
<CardSection
title="SMTP 邮件配置"
description="配置 SMTP 服务用于发送验证码邮件"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label
for="smtp-host"
class="block text-sm font-medium"
>
SMTP 服务器地址
</Label>
<Input
id="smtp-host"
v-model="systemConfig.smtp_host"
type="text"
placeholder="smtp.gmail.com"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
邮件服务器地址
</p>
</div>
<div>
<Label
for="smtp-port"
class="block text-sm font-medium"
>
SMTP 端口
</Label>
<Input
id="smtp-port"
v-model.number="systemConfig.smtp_port"
type="number"
placeholder="587"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
常用端口: 587 (TLS), 465 (SSL), 25 (非加密)
</p>
</div>
<div>
<Label
for="smtp-user"
class="block text-sm font-medium"
>
SMTP 用户名
</Label>
<Input
id="smtp-user"
v-model="systemConfig.smtp_user"
type="text"
placeholder="your-email@example.com"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
通常是您的邮箱地址
</p>
</div>
<div>
<Label
for="smtp-password"
class="block text-sm font-medium"
>
SMTP 密码
</Label>
<Input
id="smtp-password"
v-model="systemConfig.smtp_password"
type="password"
placeholder="********"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
邮箱密码或应用专用密码
</p>
</div>
<div>
<Label
for="smtp-from-email"
class="block text-sm font-medium"
>
发件人邮箱
</Label>
<Input
id="smtp-from-email"
v-model="systemConfig.smtp_from_email"
type="email"
placeholder="noreply@example.com"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
显示为发件人的邮箱地址
</p>
</div>
<div>
<Label
for="smtp-from-name"
class="block text-sm font-medium"
>
发件人名称
</Label>
<Input
id="smtp-from-name"
v-model="systemConfig.smtp_from_name"
type="text"
placeholder="Aether"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
显示为发件人的名称
</p>
</div>
<div>
<Label
for="verification-code-expire"
class="block text-sm font-medium"
>
验证码有效期分钟
</Label>
<Input
id="verification-code-expire"
v-model.number="systemConfig.verification_code_expire_minutes"
type="number"
placeholder="30"
class="mt-1"
/>
<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
id="smtp-use-tls"
v-model:checked="systemConfig.smtp_use_tls"
/>
<div>
<Label
for="smtp-use-tls"
class="cursor-pointer"
>
使用 TLS 加密
</Label>
<p class="text-xs text-muted-foreground">
推荐开启以提高安全性
</p>
</div>
</div>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="smtp-use-ssl"
v-model:checked="systemConfig.smtp_use_ssl"
/>
<div>
<Label
for="smtp-use-ssl"
class="cursor-pointer"
>
使用 SSL 加密 (465)
</Label>
<p class="text-xs text-muted-foreground">
部分服务需要隐式 SSL一般使用端口 465
</p>
</div>
</div>
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
type="button"
variant="outline"
:disabled="testSmtpLoading"
@click="handleTestSmtp"
>
{{ testSmtpLoading ? '测试中...' : '测试 SMTP 连接' }}
</Button>
</div>
<div
v-if="smtpTestResult"
class="mt-4 p-4 rounded-lg"
:class="smtpTestResult.success ? 'bg-green-50 dark:bg-green-950' : 'bg-destructive/10'"
>
<p
class="text-sm font-medium"
:class="smtpTestResult.success ? 'text-green-700 dark:text-green-300' : 'text-destructive'"
>
{{ smtpTestResult.success ? '✓ SMTP 连接测试成功' : '✗ SMTP 连接测试失败' }}
</p>
<p
v-if="smtpTestResult.message"
class="text-xs mt-1"
:class="smtpTestResult.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'"
>
{{ smtpTestResult.message }}
</p>
</div>
</CardSection>
<!-- 独立余额 Key 过期管理 -->
<CardSection
title="独立余额 Key 过期管理"
@@ -464,7 +676,6 @@
</div>
</div>
</CardSection>
</div>
<!-- 导入配置对话框 -->
@@ -798,6 +1009,16 @@ interface SystemConfig {
// 用户注册
enable_registration: boolean
require_email_verification: boolean
// SMTP 邮件配置
smtp_host: string | null
smtp_port: number
smtp_user: string | null
smtp_password: string | null
smtp_use_tls: boolean
smtp_use_ssl: boolean
smtp_from_email: string | null
smtp_from_name: string
verification_code_expire_minutes: number
// 独立余额 Key 过期管理
auto_delete_expired_keys: boolean
// 日志记录
@@ -817,6 +1038,8 @@ interface SystemConfig {
const loading = ref(false)
const logLevelSelectOpen = ref(false)
const testSmtpLoading = ref(false)
const smtpTestResult = ref<{ success: boolean; message?: string } | null>(null)
// 导出/导入相关
const exportLoading = ref(false)
@@ -847,6 +1070,16 @@ const systemConfig = ref<SystemConfig>({
// 用户注册
enable_registration: false,
require_email_verification: false,
// SMTP 邮件配置
smtp_host: null,
smtp_port: 587,
smtp_user: null,
smtp_password: null,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_from_email: null,
smtp_from_name: 'Aether',
verification_code_expire_minutes: 30,
// 独立余额 Key 过期管理
auto_delete_expired_keys: false,
// 日志记录
@@ -903,6 +1136,16 @@ async function loadSystemConfig() {
// 用户注册
'enable_registration',
'require_email_verification',
// SMTP 邮件配置
'smtp_host',
'smtp_port',
'smtp_user',
'smtp_password',
'smtp_use_tls',
'smtp_use_ssl',
'smtp_from_email',
'smtp_from_name',
'verification_code_expire_minutes',
// 独立余额 Key 过期管理
'auto_delete_expired_keys',
// 日志记录
@@ -962,6 +1205,52 @@ async function saveSystemConfig() {
value: systemConfig.value.require_email_verification,
description: '是否需要邮箱验证'
},
// SMTP 邮件配置
{
key: 'smtp_host',
value: systemConfig.value.smtp_host,
description: 'SMTP 服务器地址'
},
{
key: 'smtp_port',
value: systemConfig.value.smtp_port,
description: 'SMTP 端口'
},
{
key: 'smtp_user',
value: systemConfig.value.smtp_user,
description: 'SMTP 用户名'
},
{
key: 'smtp_password',
value: systemConfig.value.smtp_password,
description: 'SMTP 密码'
},
{
key: 'smtp_use_tls',
value: systemConfig.value.smtp_use_tls,
description: '是否使用 TLS 加密'
},
{
key: 'smtp_use_ssl',
value: systemConfig.value.smtp_use_ssl,
description: '是否使用 SSL 加密'
},
{
key: 'smtp_from_email',
value: systemConfig.value.smtp_from_email,
description: '发件人邮箱'
},
{
key: 'smtp_from_name',
value: systemConfig.value.smtp_from_name,
description: '发件人名称'
},
{
key: 'verification_code_expire_minutes',
value: systemConfig.value.verification_code_expire_minutes,
description: '验证码有效期(分钟)'
},
// 独立余额 Key 过期管理
{
key: 'auto_delete_expired_keys',
@@ -1041,6 +1330,41 @@ async function saveSystemConfig() {
}
}
// 测试 SMTP 连接
async function handleTestSmtp() {
testSmtpLoading.value = true
smtpTestResult.value = null
try {
const result = await adminApi.testSmtpConnection({
smtp_host: systemConfig.value.smtp_host,
smtp_port: systemConfig.value.smtp_port,
smtp_user: systemConfig.value.smtp_user,
smtp_password: systemConfig.value.smtp_password,
smtp_use_tls: systemConfig.value.smtp_use_tls,
smtp_use_ssl: systemConfig.value.smtp_use_ssl,
smtp_from_email: systemConfig.value.smtp_from_email,
smtp_from_name: systemConfig.value.smtp_from_name
})
smtpTestResult.value = result
if (result.success) {
success('SMTP 连接测试成功')
} else {
error('SMTP 连接测试失败')
}
} catch (err: any) {
log.error('SMTP 连接测试失败:', err)
smtpTestResult.value = {
success: false,
message: err.response?.data?.detail || err.message || 'SMTP 连接测试失败'
}
error('SMTP 连接测试失败')
} finally {
testSmtpLoading.value = false
}
}
// 导出配置
async function handleExportConfig() {
exportLoading.value = true

View File

@@ -179,8 +179,8 @@
</Badge>
</div>
<div
class="grid gap-2 sm:gap-3"
:class="[
'grid gap-2 sm:gap-3',
hasCacheData ? 'grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 max-w-xs'
]"
>