feat: 迁移自定义功能至 1.5.9.9 版本
包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
@@ -4,12 +4,12 @@
|
||||
<div v-if="!isFirstLogin" class="space-y-2">
|
||||
<label
|
||||
for="current-password"
|
||||
class="text-xs font-black text-text-tertiary uppercase tracking-widest ml-1"
|
||||
>{{ locale.currentPassword }}</label
|
||||
class="text-xs font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>当前密码</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary group-focus-within:text-primary transition-colors"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<Lock :size="18" />
|
||||
</div>
|
||||
@@ -19,21 +19,17 @@
|
||||
:class="[
|
||||
inputClass,
|
||||
error
|
||||
? 'border-error shadow-[0_0_15px_var(--auth-error-input-shadow)]'
|
||||
: 'border-border-secondary focus:border-primary-30'
|
||||
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
|
||||
: 'border-zinc-800 focus:border-blue-500/30'
|
||||
]"
|
||||
:type="showCurrentPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
:placeholder="locale.currentPasswordPlaceholder"
|
||||
placeholder="请输入当前密码"
|
||||
required
|
||||
@input="error = ''"
|
||||
/>
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
type="button"
|
||||
:aria-label="
|
||||
showCurrentPassword ? locale.hideCurrentPassword : locale.showCurrentPassword
|
||||
"
|
||||
@click="showCurrentPassword = !showCurrentPassword"
|
||||
>
|
||||
<Eye v-if="!showCurrentPassword" :size="18" />
|
||||
@@ -45,12 +41,12 @@
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="new-password"
|
||||
class="text-xs font-black text-text-tertiary uppercase tracking-widest ml-1"
|
||||
>{{ locale.newPassword }}</label
|
||||
class="text-xs font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>新密码</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary group-focus-within:text-primary transition-colors"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<KeyRound :size="18" />
|
||||
</div>
|
||||
@@ -60,19 +56,20 @@
|
||||
:class="[
|
||||
inputClass,
|
||||
error
|
||||
? 'border-error shadow-[0_0_15px_var(--auth-error-input-shadow)]'
|
||||
: 'border-border-secondary focus:border-primary-30'
|
||||
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
|
||||
: 'border-zinc-800 focus:border-blue-500/30'
|
||||
]"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
:placeholder="locale.newPasswordPlaceholder"
|
||||
placeholder="请输入新密码"
|
||||
required
|
||||
@input="handleNewPasswordInput"
|
||||
/>
|
||||
@input="
|
||||
error = '';
|
||||
validatePassword()
|
||||
"
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
type="button"
|
||||
:aria-label="showNewPassword ? locale.hideNewPassword : locale.showNewPassword"
|
||||
@click="showNewPassword = !showNewPassword"
|
||||
>
|
||||
<Eye v-if="!showNewPassword" :size="18" />
|
||||
@@ -82,7 +79,7 @@
|
||||
|
||||
<!-- 密码强度指示器 -->
|
||||
<div v-if="newPassword" class="px-1 pt-1 space-y-2">
|
||||
<div class="h-1 w-full bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div class="h-1 w-full bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-500"
|
||||
:class="passwordStrength.colorClass"
|
||||
@@ -90,8 +87,8 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-text-tertiary"
|
||||
>{{ locale.passwordStrength }}</span
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-zinc-500"
|
||||
>密码强度</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-black uppercase tracking-widest"
|
||||
@@ -106,12 +103,12 @@
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="confirm-password"
|
||||
class="text-xs font-black text-text-tertiary uppercase tracking-widest ml-1"
|
||||
>{{ locale.confirmPassword }}</label
|
||||
class="text-xs font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>确认新密码</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary group-focus-within:text-primary transition-colors"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<CheckCircle2 :size="18" />
|
||||
</div>
|
||||
@@ -121,21 +118,17 @@
|
||||
:class="[
|
||||
inputClass,
|
||||
error || (confirmPassword && newPassword !== confirmPassword)
|
||||
? 'border-error shadow-[0_0_15px_var(--auth-error-input-shadow)]'
|
||||
: 'border-border-secondary focus:border-primary-30'
|
||||
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
|
||||
: 'border-zinc-800 focus:border-blue-500/30'
|
||||
]"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
:placeholder="locale.confirmPasswordPlaceholder"
|
||||
placeholder="请再次输入新密码"
|
||||
required
|
||||
@input="error = ''"
|
||||
/>
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
type="button"
|
||||
:aria-label="
|
||||
showConfirmPassword ? locale.hideConfirmPassword : locale.showConfirmPassword
|
||||
"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
>
|
||||
<Eye v-if="!showConfirmPassword" :size="18" />
|
||||
@@ -147,14 +140,14 @@
|
||||
<div v-if="confirmPassword" class="px-1">
|
||||
<div
|
||||
v-if="newPassword !== confirmPassword"
|
||||
class="flex items-center gap-1.5 text-error"
|
||||
class="flex items-center gap-1.5 text-rose-500"
|
||||
>
|
||||
<XCircle :size="12" />
|
||||
<span class="text-[10px] font-bold">{{ locale.mismatch }}</span>
|
||||
<span class="text-[10px] font-bold">密码不匹配</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1.5 text-success">
|
||||
<div v-else class="flex items-center gap-1.5 text-emerald-500">
|
||||
<CheckCircle2 :size="12" />
|
||||
<span class="text-[10px] font-bold">{{ locale.matched }}</span>
|
||||
<span class="text-[10px] font-bold">密码匹配</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,36 +155,34 @@
|
||||
<!-- 状态消息 -->
|
||||
<div
|
||||
v-if="error"
|
||||
aria-live="polite"
|
||||
class="p-3 bg-error-10 border border-error-20 rounded-xl flex items-center gap-3"
|
||||
class="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl flex items-center gap-3"
|
||||
>
|
||||
<AlertCircle :size="16" class="text-error shrink-0" />
|
||||
<span class="text-xs text-error font-medium">{{ error }}</span>
|
||||
<AlertCircle :size="16" class="text-rose-500 shrink-0" />
|
||||
<span class="text-xs text-rose-500 font-medium">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="success"
|
||||
aria-live="polite"
|
||||
class="p-3 bg-success-10 border border-success-20 rounded-xl flex items-center gap-3"
|
||||
class="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-3"
|
||||
>
|
||||
<CheckCircle2 :size="16" class="text-success shrink-0" />
|
||||
<span class="text-xs text-success font-medium">{{ success }}</span>
|
||||
<CheckCircle2 :size="16" class="text-emerald-500 shrink-0" />
|
||||
<span class="text-xs text-emerald-500 font-medium">{{ success }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:disabled="loading || !isFormValid"
|
||||
class="w-full flex items-center justify-center gap-2 py-3 bg-primary-hover hover:bg-primary text-text-primary text-sm font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-[0.98] disabled:opacity-50"
|
||||
class="w-full flex items-center justify-center gap-2 py-3 bg-blue-600 hover:bg-blue-500 text-white text-sm font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-[0.98] disabled:opacity-50"
|
||||
type="submit"
|
||||
>
|
||||
<Loader2 v-if="loading" :size="18" class="animate-spin" />
|
||||
<span>{{ loading ? locale.processing : isFirstLogin ? locale.setInitial : locale.submitChange }}</span>
|
||||
<span>{{ loading ? '处理中...' : isFirstLogin ? '设置初始密码' : '确认修改密码' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
Lock,
|
||||
KeyRound,
|
||||
@@ -202,9 +193,6 @@ import {
|
||||
AlertCircle,
|
||||
Loader2
|
||||
} from '@lucide/vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { usePasswordStrength } from '~/composables/usePasswordStrength'
|
||||
import { validateInitialPasswordPolicy, validatePasswordPolicy } from '~/utils/password-policy'
|
||||
|
||||
// 组件属性
|
||||
const props = defineProps({
|
||||
@@ -216,9 +204,6 @@ const props = defineProps({
|
||||
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const { auth: authLocale } = useLocale()
|
||||
const locale = computed(() => authLocale.value?.changePasswordForm || {})
|
||||
const { localize: localizeServerError } = useServerErrors()
|
||||
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
@@ -226,20 +211,10 @@ const confirmPassword = ref('')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const loading = ref(false)
|
||||
let redirectTimer = null
|
||||
|
||||
const scheduleRedirect = (callback) => {
|
||||
if (redirectTimer) clearTimeout(redirectTimer)
|
||||
redirectTimer = setTimeout(callback, 2000)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (redirectTimer) clearTimeout(redirectTimer)
|
||||
})
|
||||
|
||||
// 样式类
|
||||
const inputClass =
|
||||
'w-full bg-bg-primary border rounded-xl pl-11 pr-11 py-3 text-sm text-text-primary placeholder:text-text-disabled focus:outline-none transition-all'
|
||||
'w-full bg-zinc-950 border rounded-xl pl-11 pr-11 py-3 text-sm text-zinc-200 placeholder:text-zinc-600 focus:outline-none transition-all'
|
||||
|
||||
// 密码显示状态
|
||||
const showCurrentPassword = ref(false)
|
||||
@@ -247,43 +222,84 @@ const showNewPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
|
||||
// 密码强度计算
|
||||
const passwordStrength = usePasswordStrength(newPassword)
|
||||
const passwordStrength = computed(() => {
|
||||
const password = newPassword.value
|
||||
if (!password) return { width: '0%', colorClass: '', textColorClass: '', text: '' }
|
||||
|
||||
const passwordPolicyError = computed(() =>
|
||||
props.isFirstLogin
|
||||
? validateInitialPasswordPolicy(newPassword.value)
|
||||
: validatePasswordPolicy(newPassword.value)
|
||||
)
|
||||
let score = 0
|
||||
|
||||
if (password.length >= 8) score += 25
|
||||
if (/[A-Z]/.test(password)) score += 25
|
||||
if (/[a-z]/.test(password)) score += 25
|
||||
if (/[0-9]/.test(password) && /[^A-Za-z0-9]/.test(password)) score += 25
|
||||
|
||||
if (score < 50) {
|
||||
return {
|
||||
width: `${score || 10}%`,
|
||||
colorClass: 'bg-rose-500',
|
||||
textColorClass: 'text-rose-500',
|
||||
text: '弱'
|
||||
}
|
||||
} else if (score < 75) {
|
||||
return {
|
||||
width: `${score}%`,
|
||||
colorClass: 'bg-amber-500',
|
||||
textColorClass: 'text-amber-500',
|
||||
text: '中等'
|
||||
}
|
||||
} else if (score < 100) {
|
||||
return {
|
||||
width: `${score}%`,
|
||||
colorClass: 'bg-blue-500',
|
||||
textColorClass: 'text-blue-500',
|
||||
text: '强'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
width: '100%',
|
||||
colorClass: 'bg-emerald-500',
|
||||
textColorClass: 'text-emerald-500',
|
||||
text: '极强'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 表单验证
|
||||
const isFormValid = computed(() => {
|
||||
return Boolean(
|
||||
(props.isFirstLogin || currentPassword.value) &&
|
||||
newPassword.value &&
|
||||
confirmPassword.value &&
|
||||
newPassword.value === confirmPassword.value &&
|
||||
!passwordPolicyError.value
|
||||
)
|
||||
if (props.isFirstLogin) {
|
||||
return (
|
||||
newPassword.value &&
|
||||
confirmPassword.value &&
|
||||
newPassword.value === confirmPassword.value &&
|
||||
newPassword.value.length >= 8
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
currentPassword.value &&
|
||||
newPassword.value &&
|
||||
confirmPassword.value &&
|
||||
newPassword.value === confirmPassword.value &&
|
||||
newPassword.value.length >= 8
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const handleNewPasswordInput = () => {
|
||||
error.value = newPassword.value ? passwordPolicyError.value || '' : ''
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
const validatePassword = () => {
|
||||
if (newPassword.value && newPassword.value.length < 8) {
|
||||
error.value = '密码长度至少为8位'
|
||||
} else {
|
||||
error.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
error.value = locale.value.newPasswordMismatch
|
||||
error.value = '新密码和确认密码不匹配'
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordPolicyError.value) {
|
||||
error.value = passwordPolicyError.value
|
||||
if (newPassword.value.length < 8) {
|
||||
error.value = '新密码长度至少为8位'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -294,33 +310,52 @@ const handleChangePassword = async () => {
|
||||
try {
|
||||
if (props.isFirstLogin) {
|
||||
await auth.setInitialPassword(newPassword.value)
|
||||
success.value = locale.value.initialSuccess
|
||||
success.value = '密码设置成功!正在跳转...'
|
||||
|
||||
resetForm()
|
||||
// 清空表单
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
|
||||
// 密码设置完成后跳转
|
||||
scheduleRedirect(async () => {
|
||||
setTimeout(async () => {
|
||||
// 更新用户状态
|
||||
await auth.refreshUser()
|
||||
|
||||
if (auth.isAdmin.value) {
|
||||
router.replace('/dashboard')
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
router.replace('/')
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
}, 2000)
|
||||
} else {
|
||||
await auth.changePassword(currentPassword.value, newPassword.value)
|
||||
success.value = locale.value.changeSuccess
|
||||
success.value = '密码修改成功!请重新登录'
|
||||
|
||||
resetForm()
|
||||
// 清空表单
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
|
||||
// 密码修改后登出
|
||||
scheduleRedirect(async () => {
|
||||
await auth.logout(false)
|
||||
await router.replace('/login')
|
||||
})
|
||||
setTimeout(() => {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
}
|
||||
} catch (err) {
|
||||
// 统一按错误码本地化服务端错误,未命中再回退到默认文案
|
||||
error.value = localizeServerError(err, locale.value.failed)
|
||||
// 提取错误信息,支持多种错误格式
|
||||
if (err.data && err.data.statusMessage) {
|
||||
error.value = err.data.statusMessage
|
||||
} else if (err.data && err.data.message) {
|
||||
error.value = err.data.message
|
||||
} else if (err.statusMessage) {
|
||||
error.value = err.statusMessage
|
||||
} else if (err.message) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = '操作失败,请重试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
+129
-1017
File diff suppressed because it is too large
Load Diff
@@ -1,172 +1,182 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 :size="24" class="text-primary animate-spin mb-3" />
|
||||
<p class="text-text-tertiary text-xs font-medium">{{ locale.loading }}</p>
|
||||
<Loader2 :size="24" class="text-blue-500 animate-spin mb-3" />
|
||||
<p class="text-zinc-500 text-xs font-medium">加载绑定状态...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="provider in enabledProviders"
|
||||
:key="provider.key"
|
||||
:class="[itemClass, 'items-start']"
|
||||
>
|
||||
<div class="flex items-start gap-4 flex-1 min-w-0">
|
||||
<!-- GitHub -->
|
||||
<div v-if="config.public.oauth.github" :class="itemClass">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-bg-primary flex items-center justify-center border border-border-secondary text-text-primary"
|
||||
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<AuthProvidersGitHubIcon v-if="provider.key === 'github'" class="w-5 h-5" />
|
||||
<AuthProvidersCasdoorIcon v-else-if="provider.key === 'casdoor'" class="w-5 h-5" />
|
||||
<AuthProvidersGoogleIcon v-else-if="provider.key === 'google'" class="w-5 h-5" />
|
||||
<Icon
|
||||
v-else-if="provider.routeProvider === 'aggregate'"
|
||||
:name="getAggregateOAuthLoginTypeIcon(provider.loginType)"
|
||||
:size="23"
|
||||
:class="aggregateIconClass(provider.loginType)"
|
||||
/>
|
||||
<Shield v-else :size="20" />
|
||||
<AuthProvidersGitHubIcon class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-sm font-bold text-text-primary">{{
|
||||
provider.name || getProviderDisplayName(provider.key)
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold text-zinc-200">{{
|
||||
getProviderDisplayName('github')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="getIdentitiesByProvider(provider).length === 0"
|
||||
class="text-[11px] text-text-tertiary mt-0.5"
|
||||
>{{ locale.unbound }}</span
|
||||
>
|
||||
<div
|
||||
v-if="getIdentitiesByProvider(provider).length > 0"
|
||||
class="flex flex-col gap-2 mt-2"
|
||||
>
|
||||
<div
|
||||
v-for="identity in getIdentitiesByProvider(provider)"
|
||||
:key="identity.id"
|
||||
class="flex items-center gap-3 p-2.5 bg-bg-primary-20 border border-border-secondary rounded-xl"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-bg-primary border border-border-secondary overflow-hidden flex items-center justify-center text-xs font-bold text-text-secondary shrink-0"
|
||||
>
|
||||
<img
|
||||
v-if="identity.avatar && !failedAvatarIds.includes(identity.id)"
|
||||
:src="identity.avatar"
|
||||
:alt="identity.providerUsername || provider.name"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
@error="markAvatarFailed(identity.id)"
|
||||
>
|
||||
<span v-else>{{
|
||||
(identity.providerUsername || provider.name || '?').charAt(0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-xs font-medium text-text-secondary truncate">{{
|
||||
identity.providerUsername
|
||||
}}</span>
|
||||
<span
|
||||
v-if="identity.isAvatarSource"
|
||||
class="text-[10px] font-bold text-success bg-success-10 border border-success-20 px-2 py-0.5 rounded-full w-fit mt-1"
|
||||
>{{ locale.currentAvatar }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
v-if="!identity.isAvatarSource"
|
||||
class="px-2.5 py-1.5 bg-primary-hover hover:bg-primary text-text-primary text-[11px] font-bold rounded-lg transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="avatarLoadingId === identity.id || actionLoading"
|
||||
@click="setAsAvatar(identity)"
|
||||
>
|
||||
{{ avatarLoadingId === identity.id ? locale.processing : locale.setAsAvatar }}
|
||||
</button>
|
||||
<button
|
||||
class="px-2.5 py-1.5 bg-error-10 border border-error-20 hover:bg-error-20 text-error text-[11px] font-bold rounded-lg transition-all disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="confirmUnbind(provider, identity)"
|
||||
>
|
||||
{{ locale.unbind }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="githubIdentity" class="text-[11px] text-blue-500 font-medium mt-0.5">{{
|
||||
githubIdentity.providerUsername
|
||||
}}</span>
|
||||
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="getIdentitiesByProvider(provider).length === 0"
|
||||
class="px-4 py-1.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95 disabled:opacity-50"
|
||||
v-if="githubIdentity"
|
||||
class="px-4 py-1.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-xs font-black rounded-xl transition-all disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="handleBind(provider)"
|
||||
@click="confirmUnbind('github')"
|
||||
>
|
||||
{{ actionLoading ? locale.redirecting : locale.bindNow }}
|
||||
{{ actionLoading ? '处理中...' : '解绑' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="handleBind('github')"
|
||||
>
|
||||
{{ actionLoading ? '跳转中...' : '立即绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Casdoor (如果启用) -->
|
||||
<div v-if="config.public.oauth.casdoor" :class="itemClass">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<Shield :size="20" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold text-zinc-200">{{
|
||||
getProviderDisplayName('casdoor')
|
||||
}}</span>
|
||||
<span v-if="casdoorIdentity" class="text-[11px] text-blue-500 font-medium mt-0.5">{{
|
||||
casdoorIdentity.providerUsername
|
||||
}}</span>
|
||||
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="casdoorIdentity"
|
||||
class="px-4 py-1.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-xs font-black rounded-xl transition-all disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="confirmUnbind('casdoor')"
|
||||
>
|
||||
{{ actionLoading ? '处理中...' : '解绑' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="handleBind('casdoor')"
|
||||
>
|
||||
{{ actionLoading ? '跳转中...' : '立即绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Google (如果启用) -->
|
||||
<div v-if="config.public.oauth.google" :class="itemClass">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<AuthProvidersGoogleIcon class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold text-zinc-200">{{
|
||||
getProviderDisplayName('google')
|
||||
}}</span>
|
||||
<span v-if="googleIdentity" class="text-[11px] text-blue-500 font-medium mt-0.5">{{
|
||||
googleIdentity.providerUsername
|
||||
}}</span>
|
||||
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="googleIdentity"
|
||||
class="px-4 py-1.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-xs font-black rounded-xl transition-all disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="confirmUnbind('google')"
|
||||
>
|
||||
{{ actionLoading ? '处理中...' : '解绑' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="handleBind('google')"
|
||||
>
|
||||
{{ actionLoading ? '跳转中...' : '立即绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- WebAuthn / Passkey -->
|
||||
<div
|
||||
<div
|
||||
v-if="isWebAuthnSupported || webauthnIdentities.length > 0 || !isSecureContext"
|
||||
:class="[
|
||||
itemClass,
|
||||
webauthnIdentities.length > 0 ? 'cursor-pointer hover:bg-bg-secondary-70' : ''
|
||||
]"
|
||||
itemClass,
|
||||
webauthnIdentities.length > 0 ? 'cursor-pointer hover:bg-zinc-900/70' : ''
|
||||
]"
|
||||
@click="toggleWebAuthnList"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-bg-primary flex items-center justify-center border border-border-secondary text-text-primary"
|
||||
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<Fingerprint :size="20" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-text-primary">Passkey</span>
|
||||
<ChevronDown
|
||||
<span class="text-sm font-bold text-zinc-200">Windows Hello / Passkey</span>
|
||||
<ChevronDown
|
||||
v-if="webauthnIdentities.length > 0"
|
||||
:size="14"
|
||||
class="text-text-tertiary transition-transform duration-300"
|
||||
:size="14"
|
||||
class="text-zinc-500 transition-transform duration-300"
|
||||
:class="{ 'rotate-180': isWebAuthnExpanded }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[11px] text-text-tertiary mt-0.5"
|
||||
>{{ locale.boundDevices }} {{ webauthnIdentities.length }} {{ locale.devices }}</span
|
||||
<span class="text-[11px] text-zinc-500 mt-0.5"
|
||||
>已绑定 {{ webauthnIdentities.length }} 个设备</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="isWebAuthnSupported"
|
||||
class="px-4 py-1.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95 disabled:opacity-50"
|
||||
class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click.stop="handleWebAuthnRegister"
|
||||
>
|
||||
{{ actionLoading ? locale.processing : locale.addDevice }}
|
||||
{{ actionLoading ? '处理中...' : '添加设备' }}
|
||||
</button>
|
||||
<div
|
||||
v-else-if="!isSecureContext"
|
||||
class="flex items-center gap-1 text-warning bg-warning-10 px-3 py-1.5 rounded-lg border border-warning-20"
|
||||
>
|
||||
<div v-else-if="!isSecureContext" class="flex items-center gap-1 text-amber-500 bg-amber-500/10 px-3 py-1.5 rounded-lg border border-amber-500/20">
|
||||
<AlertTriangle :size="12" />
|
||||
<span class="text-[10px] font-medium">{{ locale.httpsRequired }}</span>
|
||||
<span class="text-[10px] font-medium">需要 HTTPS 环境</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebAuthn 设备列表 -->
|
||||
<Transition name="expand">
|
||||
<div
|
||||
v-if="isWebAuthnExpanded && webauthnIdentities.length > 0"
|
||||
class="pl-16 -mt-2 overflow-hidden"
|
||||
>
|
||||
<div v-if="isWebAuthnExpanded && webauthnIdentities.length > 0" class="pl-16 -mt-2 overflow-hidden">
|
||||
<div class="space-y-2 pt-2">
|
||||
<div
|
||||
v-for="cred in webauthnIdentities"
|
||||
:key="cred.id"
|
||||
class="flex items-center justify-between p-3 bg-bg-primary-20 border border-border-secondary rounded-xl group/item"
|
||||
class="flex items-center justify-between p-3 bg-zinc-950/20 border border-zinc-900 rounded-xl group/item"
|
||||
>
|
||||
<div class="flex flex-col flex-1 mr-4">
|
||||
<div v-if="editingId === cred.id" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="editingName"
|
||||
type="text"
|
||||
class="bg-bg-secondary border border-border-tertiary rounded px-2 py-0.5 text-xs text-text-primary focus:outline-none focus:border-primary w-full"
|
||||
class="bg-zinc-900 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 w-full"
|
||||
:disabled="isRenaming"
|
||||
@keyup.enter="saveEditing(cred.id)"
|
||||
@keyup.esc="cancelEditing"
|
||||
@@ -175,18 +185,18 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 mb-0.5">
|
||||
<span class="text-xs font-medium text-text-secondary">{{ cred.providerUsername }}</span>
|
||||
<button
|
||||
class="text-text-tertiary hover:text-text-secondary opacity-0 group-hover/item:opacity-100 transition-opacity p-0.5"
|
||||
<span class="text-xs font-medium text-zinc-300">{{ cred.providerUsername }}</span>
|
||||
<button
|
||||
class="text-zinc-500 hover:text-zinc-300 opacity-0 group-hover/item:opacity-100 transition-opacity p-0.5"
|
||||
@click.stop="startEditing(cred)"
|
||||
:title="locale.rename"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-[10px] text-text-disabled"
|
||||
>{{ locale.addedAt }} {{ new Date(cred.createdAt).toLocaleString(currentLocale.value, {
|
||||
|
||||
<span class="text-[10px] text-zinc-600"
|
||||
>添加于 {{ new Date(cred.createdAt).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@@ -201,28 +211,28 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-if="editingId === cred.id">
|
||||
<button
|
||||
class="text-text-tertiary hover:text-success transition-colors p-1"
|
||||
class="text-zinc-400 hover:text-green-400 transition-colors p-1"
|
||||
:disabled="isRenaming"
|
||||
@click.stop="saveEditing(cred.id)"
|
||||
:title="locale.save"
|
||||
title="保存"
|
||||
>
|
||||
<Check :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="text-text-tertiary hover:text-text-primary transition-colors p-1"
|
||||
class="text-zinc-400 hover:text-zinc-200 transition-colors p-1"
|
||||
:disabled="isRenaming"
|
||||
@click.stop="cancelEditing"
|
||||
:title="locale.cancel"
|
||||
title="取消"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
class="text-xs text-error hover:text-error font-medium px-2 py-1 opacity-0 group-hover/item:opacity-100 transition-opacity"
|
||||
class="text-xs text-rose-500 hover:text-rose-400 font-medium px-2 py-1 opacity-0 group-hover/item:opacity-100 transition-opacity"
|
||||
@click="confirmUnbindWebAuthn(cred)"
|
||||
>
|
||||
{{ locale.remove }}
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,60 +254,42 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import {
|
||||
Loader2,
|
||||
Shield,
|
||||
Fingerprint,
|
||||
ChevronDown,
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
AlertTriangle
|
||||
} from '@lucide/vue'
|
||||
import { Loader2, Shield, Fingerprint, ChevronDown, Pencil, Check, X, AlertTriangle } from '@lucide/vue'
|
||||
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { getAggregateOAuthLoginTypeIcon, getProviderDisplayName } from '~/utils/oauth'
|
||||
import { browserSupportsWebAuthn } from '@simplewebauthn/browser'
|
||||
import {
|
||||
getWebAuthnErrorMessage,
|
||||
signalUnknownWebAuthnCredential,
|
||||
startWebAuthnRegistration
|
||||
} from '~/utils/webauthn'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { useServerErrors } from '~/composables/useLocaleText'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { getProviderDisplayName } from '~/utils/oauth'
|
||||
import { startRegistration, browserSupportsWebAuthn } from '@simplewebauthn/browser'
|
||||
|
||||
const { oauthProviders, refreshSiteConfig } = useSiteConfig()
|
||||
const config = useRuntimeConfig()
|
||||
const { showToast } = useToast()
|
||||
const { refreshUser } = useAuth()
|
||||
const { localize: localizeServerError } = useServerErrors()
|
||||
const { auth, currentLocale } = useLocale()
|
||||
const locale = computed(() => auth.value?.oauthBindingCard || {})
|
||||
const { t: callLocale } = useLocaleText(locale)
|
||||
const identities = ref([])
|
||||
const loading = ref(true)
|
||||
const actionLoading = ref(false)
|
||||
const avatarLoadingId = ref(null)
|
||||
const failedAvatarIds = ref([])
|
||||
const isWebAuthnSupported = ref(false)
|
||||
const isSecureContext = ref(true)
|
||||
|
||||
// 编辑相关
|
||||
const editingId = ref(null)
|
||||
const editingId = ref<string | null>(null)
|
||||
const editingName = ref('')
|
||||
const isRenaming = ref(false)
|
||||
const editInput = ref(null)
|
||||
const editInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const startEditing = async (cred) => {
|
||||
interface WebAuthnCredential {
|
||||
id: string
|
||||
providerUsername: string
|
||||
createdAt: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const startEditing = async (cred: WebAuthnCredential) => {
|
||||
editingId.value = cred.id
|
||||
editingName.value = cred.providerUsername
|
||||
// 聚焦输入框
|
||||
await nextTick()
|
||||
if (editInput.value) {
|
||||
editInput.value?.focus()
|
||||
(editInput.value as HTMLInputElement)?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,28 +298,28 @@ const cancelEditing = () => {
|
||||
editingName.value = ''
|
||||
}
|
||||
|
||||
const saveEditing = async (id) => {
|
||||
const saveEditing = async (id: string) => {
|
||||
if (!editingName.value.trim()) {
|
||||
showToast(locale.value.nameRequired, 'error')
|
||||
showToast('设备名称不能为空', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (editingName.value.trim().length > 50) {
|
||||
showToast(locale.value.nameTooLong, 'error')
|
||||
showToast('设备名称过长 (最大50个字符)', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
isRenaming.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/webauthn/rename', {
|
||||
method: 'POST',
|
||||
body: { id, name: editingName.value }
|
||||
})
|
||||
showToast(locale.value.renameSuccess, 'success')
|
||||
showToast('设备名称修改成功', 'success')
|
||||
await fetchIdentities()
|
||||
cancelEditing()
|
||||
} catch (e) {
|
||||
showToast(localizeServerError(e, locale.value.renameFailed), 'error')
|
||||
} catch (e: any) {
|
||||
showToast(e.data?.message || '修改失败', 'error')
|
||||
} finally {
|
||||
isRenaming.value = false
|
||||
}
|
||||
@@ -353,65 +345,17 @@ const confirmDialog = ref({
|
||||
|
||||
// 样式类
|
||||
const itemClass =
|
||||
'flex items-center justify-between p-4 bg-bg-primary-30 border border-border-secondary rounded-2xl hover:bg-bg-secondary-50 transition-all group'
|
||||
|
||||
const aggregateIconClass = (loginType) => {
|
||||
const classes = {
|
||||
qq: 'text-[var(--oauth-qq)]',
|
||||
wx: 'text-[var(--oauth-wx)]',
|
||||
alipay: 'text-[var(--oauth-alipay)]',
|
||||
sina: 'text-[var(--oauth-sina)]',
|
||||
baidu: 'text-[var(--oauth-baidu)]',
|
||||
douyin: 'text-[var(--oauth-douyin)]',
|
||||
huawei: 'text-[var(--oauth-huawei)]',
|
||||
xiaomi: 'text-[var(--oauth-xiaomi)]',
|
||||
gitee: 'text-[var(--oauth-gitee)]',
|
||||
gitea: 'text-text-primary [--gitea-cutout:var(--panel-bg-dialog)]',
|
||||
bilibili: 'text-[var(--oauth-bilibili)]',
|
||||
kuaishou: 'text-[var(--oauth-kuaishou)]'
|
||||
}
|
||||
return classes[loginType] || 'text-text-primary'
|
||||
}
|
||||
|
||||
const enabledProviders = computed(() => oauthProviders.value || [])
|
||||
|
||||
const getProviderName = (provider) => {
|
||||
const matched = enabledProviders.value.find((item) => item.key === provider)
|
||||
return matched?.name || getProviderDisplayName(provider)
|
||||
}
|
||||
|
||||
const getIdentitiesByProvider = (provider) =>
|
||||
identities.value.filter((item) => item.provider === provider.key)
|
||||
|
||||
const markAvatarFailed = (id) => {
|
||||
if (!failedAvatarIds.value.includes(id)) {
|
||||
failedAvatarIds.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
const setAsAvatar = async (identity) => {
|
||||
avatarLoadingId.value = identity.id
|
||||
try {
|
||||
await $fetch('/api/user/avatar', {
|
||||
method: 'POST',
|
||||
body: { identityId: identity.id }
|
||||
})
|
||||
showToast(locale.value.avatarSetSuccess, 'success')
|
||||
await Promise.all([fetchIdentities(), refreshUser()])
|
||||
} catch (e) {
|
||||
showToast(localizeServerError(e, locale.value.avatarSetFailed), 'error')
|
||||
} finally {
|
||||
avatarLoadingId.value = null
|
||||
}
|
||||
}
|
||||
'flex items-center justify-between p-4 bg-zinc-950/30 border border-zinc-900 rounded-2xl hover:bg-zinc-900/50 transition-all group'
|
||||
|
||||
const githubIdentity = computed(() => identities.value.find((i) => i.provider === 'github'))
|
||||
const casdoorIdentity = computed(() => identities.value.find((i) => i.provider === 'casdoor'))
|
||||
const googleIdentity = computed(() => identities.value.find((i) => i.provider === 'google'))
|
||||
const webauthnIdentities = computed(() => identities.value.filter((i) => i.provider === 'webauthn'))
|
||||
|
||||
const fetchIdentities = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
identities.value = await $fetch('/api/auth/identities')
|
||||
failedAvatarIds.value = []
|
||||
} catch (e) {
|
||||
console.error('获取绑定信息失败', e)
|
||||
} finally {
|
||||
@@ -422,24 +366,18 @@ const fetchIdentities = async () => {
|
||||
const handleBind = (provider) => {
|
||||
actionLoading.value = true
|
||||
// 绑定也是通过 OAuth 流程,最终回调时会自动识别已登录状态并执行绑定
|
||||
const routeProvider = provider.routeProvider || provider.key
|
||||
const query = new URLSearchParams()
|
||||
if (provider.loginType) query.set('type', provider.loginType)
|
||||
const queryString = query.toString()
|
||||
navigateTo(`/api/auth/${routeProvider}${queryString ? `?${queryString}` : ''}`, {
|
||||
external: true
|
||||
})
|
||||
navigateTo(`/api/auth/${provider}`, { external: true })
|
||||
}
|
||||
|
||||
const confirmUnbind = (provider, identity) => {
|
||||
const providerName = getProviderName(provider.key)
|
||||
const confirmUnbind = (provider) => {
|
||||
const providerName = getProviderDisplayName(provider)
|
||||
|
||||
confirmDialog.value = {
|
||||
title: locale.value.unbindTitle,
|
||||
message: callLocale('unbindMessage', '', providerName),
|
||||
title: '解除绑定',
|
||||
message: `确定要解除 ${providerName} 账号的绑定吗?解除后您将无法使用该账号登录。`,
|
||||
type: 'danger',
|
||||
loading: false,
|
||||
onConfirm: () => handleUnbind(provider.key, identity.id),
|
||||
onConfirm: () => handleUnbind(provider),
|
||||
onCancel: () => {
|
||||
showConfirmDialog.value = false
|
||||
}
|
||||
@@ -449,8 +387,8 @@ const confirmUnbind = (provider, identity) => {
|
||||
|
||||
const confirmUnbindWebAuthn = (cred) => {
|
||||
confirmDialog.value = {
|
||||
title: locale.value.removePasskeyTitle,
|
||||
message: callLocale('removePasskeyMessage', '', cred.providerUsername),
|
||||
title: '移除 Passkey',
|
||||
message: `确定要移除设备 "${cred.providerUsername}" 吗?移除后将无法使用该设备登录。`,
|
||||
type: 'danger',
|
||||
loading: false,
|
||||
onConfirm: () => handleUnbind('webauthn', cred.id),
|
||||
@@ -465,28 +403,15 @@ const handleUnbind = async (provider, id = null) => {
|
||||
confirmDialog.value.loading = true
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const result = await $fetch('/api/auth/unbind', {
|
||||
await $fetch('/api/auth/unbind', {
|
||||
method: 'POST',
|
||||
body: { provider, id }
|
||||
})
|
||||
const cleanupResults = await Promise.all(
|
||||
(result.passkeyCleanup || []).map(signalUnknownWebAuthnCredential)
|
||||
)
|
||||
await fetchIdentities()
|
||||
try {
|
||||
await refreshUser()
|
||||
} catch (e) {
|
||||
// 头像来源变化后的用户刷新失败不阻塞解绑流程
|
||||
}
|
||||
const deviceCleanupSucceeded = cleanupResults.length > 0 && cleanupResults.every(Boolean)
|
||||
if (provider === 'webauthn' && !deviceCleanupSucceeded) {
|
||||
showToast(locale.value.passkeyCleanupRequired, 'warning', 6000)
|
||||
} else {
|
||||
showToast(locale.value.unbindSuccess, 'success')
|
||||
}
|
||||
showToast('解除绑定成功', 'success')
|
||||
showConfirmDialog.value = false
|
||||
} catch (e) {
|
||||
showToast(localizeServerError(e, locale.value.unbindFailed), 'error')
|
||||
showToast(e.data?.message || '解绑失败', 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
confirmDialog.value.loading = false
|
||||
@@ -495,27 +420,39 @@ const handleUnbind = async (provider, id = null) => {
|
||||
|
||||
const handleWebAuthnRegister = async () => {
|
||||
if (!isWebAuthnSupported.value) {
|
||||
showToast(locale.value.browserNotSupported, 'error')
|
||||
showToast('您的浏览器不支持 Windows Hello / Passkey', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const options = await $fetch('/api/auth/webauthn/register/options')
|
||||
const attResp = await startWebAuthnRegistration(options)
|
||||
|
||||
let attResp
|
||||
try {
|
||||
attResp = await startRegistration(options)
|
||||
} catch (e) {
|
||||
if (e.name === 'NotAllowedError') {
|
||||
throw new Error('用户取消了操作')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
// 提示用户输入设备名称(可选,这里先用默认的)
|
||||
// attResp.label = 'Windows Hello'
|
||||
|
||||
// attResp.label = 'Windows Hello'
|
||||
|
||||
await $fetch('/api/auth/webauthn/register/verify', {
|
||||
method: 'POST',
|
||||
body: attResp
|
||||
})
|
||||
showToast(locale.value.addDeviceSuccess, 'success')
|
||||
|
||||
showToast('设备添加成功', 'success')
|
||||
await fetchIdentities()
|
||||
} catch (e) {
|
||||
console.error('WebAuthn 注册错误:', e)
|
||||
const message = getWebAuthnErrorMessage(e, locale.value, locale.value.addDeviceFailed)
|
||||
const apiError = e as { data?: { message?: string }; message?: string }
|
||||
const err = e as Error
|
||||
const message = apiError.data?.message || err.message || '添加设备失败'
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
@@ -523,9 +460,8 @@ const handleWebAuthnRegister = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshSiteConfig()
|
||||
fetchIdentities()
|
||||
|
||||
|
||||
isSecureContext.value = window.isSecureContext
|
||||
|
||||
const isApiSupported = browserSupportsWebAuthn()
|
||||
@@ -533,8 +469,7 @@ onMounted(async () => {
|
||||
|
||||
if (isApiSupported && window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) {
|
||||
try {
|
||||
isPlatformAuthenticatorAvailable =
|
||||
await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
isPlatformAuthenticatorAvailable = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
} catch (e) {
|
||||
console.warn('WebAuthn 平台认证器检查失败:', e)
|
||||
}
|
||||
|
||||
@@ -1,117 +1,194 @@
|
||||
<template>
|
||||
<div v-if="hasEnabledProviders" class="mt-6 w-full">
|
||||
<div class="flex items-center text-center text-[var(--text-tertiary)] text-xs mb-4">
|
||||
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
|
||||
<span class="px-2.5">{{ locale.divider }}</span>
|
||||
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
|
||||
<div v-if="hasEnabledProviders" class="oauth-buttons-section">
|
||||
<div class="oauth-divider">
|
||||
<span>或使用第三方账号登录</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<div class="oauth-buttons">
|
||||
<button
|
||||
v-for="provider in enabledProviders"
|
||||
:key="provider.key"
|
||||
v-if="config.public.oauth.github"
|
||||
type="button"
|
||||
:class="providerButtonClass(provider)"
|
||||
:title="formatLocale(locale.loginWith, provider.name)"
|
||||
@click="loginWith(provider)"
|
||||
class="oauth-btn oauth-btn-github"
|
||||
title="使用 GitHub 登录"
|
||||
@click="loginWith('github')"
|
||||
>
|
||||
<AuthProvidersGitHubIcon v-if="provider.key === 'github'" />
|
||||
<AuthProvidersCasdoorIcon v-else-if="provider.key === 'casdoor'" />
|
||||
<AuthProvidersGoogleIcon v-else-if="provider.key === 'google'" />
|
||||
<Icon
|
||||
v-else-if="provider.routeProvider === 'aggregate'"
|
||||
:name="getAggregateOAuthLoginTypeIcon(provider.loginType)"
|
||||
:size="26"
|
||||
/>
|
||||
<Shield v-else :size="18" />
|
||||
<AuthProvidersGitHubIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="config.public.oauth.google"
|
||||
type="button"
|
||||
class="oauth-btn oauth-btn-google"
|
||||
title="使用 Google 登录"
|
||||
@click="loginWith('google')"
|
||||
>
|
||||
<AuthProvidersGoogleIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="config.public.oauth.aggregate"
|
||||
type="button"
|
||||
class="oauth-btn oauth-btn-aggregate"
|
||||
title="使用 QQ / 微信登录"
|
||||
@click="loginWith('aggregate')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8.5 4C4.9 4 2 6.8 2 10.3c0 2 1.1 3.7 2.8 4.9L4 18l3-1.5c.5.1 1 .2 1.5.2h.6c-.1-.4-.1-.8 0-1.2H8.5C5.5 15.5 3 13.2 3 10.3 3 7.4 5.5 5 8.5 5h1.6c.3-1.1 1.3-1.9 2.5-1.9.5 0 1 .2 1.4.5C13.3 3 10.7 4 8.5 4zM10.1 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm8.4 1.2c-2.5 0-4.5 2-4.5 4.5s2 4.5 4.5 4.5c.6 0 1.2-.1 1.7-.3L22 17.5l-.8-2.3c1.4-1 2.3-2.6 2.3-4.3C23.5 9.2 21.5 7.2 19 7.2c-.5 0-1 0-1.4.1C16.9 7.8 17.9 7.2 19 7.2zM17 9.7c-.8 0-1.5.7-1.5 1.5s.7 1.5 1.5 1.5 1.5-.7 1.5-1.5-.7-1.5-1.5-1.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="showPlaceholderHint" class="oauth-buttons-section">
|
||||
<div class="oauth-divider oauth-divider-dim">
|
||||
<span>第三方登录</span>
|
||||
</div>
|
||||
<div class="oauth-empty-hint">
|
||||
<svg
|
||||
class="oauth-empty-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p>请联系管理员在 <code>.env</code> 中配置第三方 OAuth 应用后即可启用</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Shield } from '@lucide/vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { getAggregateOAuthLoginTypeIcon } from '~/utils/oauth'
|
||||
|
||||
const { oauthProviders, refreshSiteConfig } = useSiteConfig()
|
||||
const route = useRoute()
|
||||
const { auth } = useLocale()
|
||||
const locale = computed(() => auth.value?.oauthButtons || {})
|
||||
const formatLocale = (value, ...args) => {
|
||||
if (typeof value === 'function') return value(...args)
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(/{(\d+)}/g, (match, index) =>
|
||||
args[Number(index)] !== undefined ? String(args[Number(index)]) : match
|
||||
)
|
||||
}
|
||||
return 'OAuth 登录'
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
alwaysShow?: boolean
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshSiteConfig()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
alwaysShow: false
|
||||
})
|
||||
|
||||
const enabledProviders = computed(() => oauthProviders.value || [])
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const hasEnabledProviders = computed(() => {
|
||||
return enabledProviders.value.length > 0
|
||||
return config.public.oauth && Object.values(config.public.oauth).some((enabled) => enabled)
|
||||
})
|
||||
|
||||
const providerButtonClass = (provider) => {
|
||||
const key = provider.routeProvider || provider.key
|
||||
const baseClass =
|
||||
'w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)]'
|
||||
const showPlaceholderHint = computed(() => props.alwaysShow && !hasEnabledProviders.value)
|
||||
|
||||
if (key === 'github') {
|
||||
return `${baseClass} hover:bg-[var(--panel-bg-subtle)] hover:text-text-primary hover:border-[var(--panel-bg-subtle)]`
|
||||
}
|
||||
if (key === 'casdoor') {
|
||||
return `${baseClass} hover:border-[var(--oauth-casdoor-green)]`
|
||||
}
|
||||
if (key === 'google') {
|
||||
return `${baseClass} hover:bg-bg-secondary hover:text-black hover:border-[var(--panel-border)]`
|
||||
}
|
||||
if (key === 'aggregate') {
|
||||
const aggregateClasses = {
|
||||
qq: 'hover:bg-[var(--oauth-qq)] hover:text-text-primary hover:border-[var(--oauth-qq)]',
|
||||
wx: 'hover:bg-[var(--oauth-wx)] hover:text-text-primary hover:border-[var(--oauth-wx)]',
|
||||
alipay: 'hover:bg-[var(--oauth-alipay)] hover:text-text-primary hover:border-[var(--oauth-alipay)]',
|
||||
sina: 'hover:bg-[var(--oauth-sina)] hover:text-text-primary hover:border-[var(--oauth-sina)]',
|
||||
baidu: 'hover:bg-[var(--oauth-baidu)] hover:text-text-primary hover:border-[var(--oauth-baidu)]',
|
||||
douyin: 'hover:bg-[var(--panel-bg-darkest)] hover:text-text-primary hover:border-[var(--oauth-douyin)]',
|
||||
huawei: 'hover:bg-[var(--oauth-huawei)] hover:text-text-primary hover:border-[var(--oauth-huawei)]',
|
||||
xiaomi: 'hover:bg-[var(--oauth-xiaomi)] hover:text-text-primary hover:border-[var(--oauth-xiaomi)]',
|
||||
gitee: 'hover:bg-[var(--oauth-gitee)] hover:text-text-primary hover:border-[var(--oauth-gitee)]',
|
||||
gitea:
|
||||
'[--gitea-cutout:var(--bg-secondary)] hover:[--gitea-cutout:var(--text-primary)] hover:bg-bg-secondary hover:text-black hover:border-primary',
|
||||
bilibili: 'hover:bg-[var(--oauth-bilibili)] hover:text-text-primary hover:border-[var(--oauth-bilibili)]',
|
||||
kuaishou: 'hover:bg-[var(--oauth-kuaishou)] hover:text-text-primary hover:border-[var(--oauth-kuaishou)]'
|
||||
}
|
||||
return `${baseClass} ${aggregateClasses[provider.loginType] || ''}`
|
||||
}
|
||||
if (key === 'oauth2') {
|
||||
return `${baseClass} hover:bg-[var(--status-success-icon)] hover:text-text-primary hover:border-[var(--status-success-icon)]`
|
||||
}
|
||||
|
||||
return `${baseClass} hover:border-primary-40 hover:text-primary`
|
||||
}
|
||||
|
||||
const loginWith = (provider) => {
|
||||
const queryRedirect = route.query.redirect
|
||||
const redirect = Array.isArray(queryRedirect) ? queryRedirect[0] : queryRedirect
|
||||
const safeRedirect =
|
||||
redirect?.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')
|
||||
? redirect
|
||||
: null
|
||||
const query = new URLSearchParams()
|
||||
if (provider.loginType) query.set('type', provider.loginType)
|
||||
if (safeRedirect) query.set('redirect', safeRedirect)
|
||||
const queryString = query.toString()
|
||||
const routeProvider = provider.routeProvider || provider.key
|
||||
// 外部导航到 API 端点
|
||||
navigateTo(`/api/auth/${routeProvider}${queryString ? `?${queryString}` : ''}`, {
|
||||
external: true
|
||||
})
|
||||
const loginWith = (provider: string) => {
|
||||
navigateTo(`/api/auth/${provider}`, { external: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oauth-buttons-section {
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.oauth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 0 0 18px 0;
|
||||
color: var(--text-quaternary, #9ca3af);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.oauth-divider::before,
|
||||
.oauth-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--border-secondary, #d1d5db);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.oauth-divider span {
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oauth-divider-dim {
|
||||
color: var(--text-quaternary, #9ca3af);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.oauth-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.oauth-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-secondary, #d1d5db);
|
||||
background: var(--bg-secondary, #f5f7fa);
|
||||
color: var(--text-primary, #1a2332);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.oauth-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.oauth-btn-github:hover {
|
||||
background: var(--bg-tertiary, #24292e);
|
||||
color: var(--text-primary, #ffffff);
|
||||
border-color: var(--border-secondary, #24292e);
|
||||
}
|
||||
|
||||
.oauth-btn-google:hover {
|
||||
background: var(--card-bg, #ffffff);
|
||||
color: var(--text-primary, #111111);
|
||||
border-color: #dadce0;
|
||||
}
|
||||
|
||||
.oauth-btn-aggregate:hover {
|
||||
background: var(--bg-secondary, #12b7f5);
|
||||
color: var(--text-primary, #ffffff);
|
||||
border-color: var(--border-secondary, #12b7f5);
|
||||
}
|
||||
|
||||
.oauth-empty-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border: 1px dashed var(--border-secondary, #d1d5db);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-tertiary, #edf0f5);
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.oauth-empty-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.oauth-empty-hint p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.oauth-empty-hint code {
|
||||
background: var(--input-bg, #ffffff);
|
||||
color: var(--primary, #1e40af);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="bg-zinc-900/40 border border-zinc-900 rounded-3xl p-6 md:p-8 shadow-2xl">
|
||||
<div class="flex items-center gap-3 border-b border-zinc-800/50 pb-5 mb-6">
|
||||
<div class="p-2.5 bg-green-500/10 rounded-xl">
|
||||
<UserCircle2 :size="20" class="text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-black text-zinc-100">个人信息设置</h2>
|
||||
<p class="text-xs text-zinc-500 mt-0.5">修改您的个人资料和学校信息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleUpdateProfile" class="space-y-5 max-w-md">
|
||||
<!-- 昵称 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-zinc-300">昵称</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="留空则显示用户名"
|
||||
class="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-sm text-zinc-100 focus:outline-none focus:border-green-500/50 focus:ring-1 focus:ring-green-500/50 transition-all placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 年级与班级 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-zinc-300">年级</label>
|
||||
<CustomSelect
|
||||
v-model="form.grade"
|
||||
:options="gradeOptions"
|
||||
placeholder="请选择年级"
|
||||
class-name="w-full custom-select-profile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-zinc-300">班级</label>
|
||||
<CustomSelect
|
||||
v-model="form.class"
|
||||
:options="classOptions"
|
||||
placeholder="请选择班级"
|
||||
class-name="w-full custom-select-profile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 学号 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-zinc-300">学号</label>
|
||||
<input
|
||||
v-model="form.studentId"
|
||||
type="text"
|
||||
placeholder="选填"
|
||||
class="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-sm text-zinc-100 focus:outline-none focus:border-green-500/50 focus:ring-1 focus:ring-green-500/50 transition-all placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 邮箱 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-zinc-300">邮箱</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="选填"
|
||||
class="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-sm text-zinc-100 focus:outline-none focus:border-green-500/50 focus:ring-1 focus:ring-green-500/50 transition-all placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="emailChangedAndNotEmpty" class="space-y-2">
|
||||
<label class="text-sm font-medium text-zinc-300">邮箱验证码</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.emailCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="输入6位数字验证码"
|
||||
class="flex-1 px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-sm text-zinc-100 focus:outline-none focus:border-green-500/50 focus:ring-1 focus:ring-green-500/50 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!form.email || emailActionLoading"
|
||||
class="px-4 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap border border-zinc-700"
|
||||
@click="sendProfileEmailCode"
|
||||
>
|
||||
{{ emailActionLoading ? '发送中...' : '发送验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || !isChanged"
|
||||
class="w-full py-3.5 bg-green-600 hover:bg-green-500 disabled:opacity-50 disabled:hover:bg-green-600 text-white rounded-xl text-sm font-black tracking-widest transition-all shadow-lg shadow-green-900/20 mt-6"
|
||||
>
|
||||
<span v-if="loading" class="flex items-center justify-center gap-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
保存中...
|
||||
</span>
|
||||
<span v-else>保存修改</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { UserCircle2, Loader2 } from '@lucide/vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
|
||||
const auth = useAuth()
|
||||
const { showToast } = useToast()
|
||||
const loading = ref(false)
|
||||
const emailActionLoading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
grade: '',
|
||||
class: '',
|
||||
studentId: '',
|
||||
email: '',
|
||||
emailCode: ''
|
||||
})
|
||||
|
||||
const gradeOptions = [
|
||||
{ label: '高一', value: '高一' },
|
||||
{ label: '高二', value: '高二' },
|
||||
{ label: '高三', value: '高三' }
|
||||
]
|
||||
|
||||
const classOptions = Array.from({ length: 16 }, (_, i) => ({
|
||||
label: `${i + 1}班`,
|
||||
value: (i + 1).toString()
|
||||
}))
|
||||
|
||||
// 初始数据用于比较是否被修改
|
||||
const initialData = ref({})
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.user.value) {
|
||||
form.name = auth.user.value.name || ''
|
||||
form.grade = auth.user.value.grade || ''
|
||||
form.class = auth.user.value.class || ''
|
||||
form.studentId = auth.user.value.studentId || ''
|
||||
form.email = auth.user.value.email || ''
|
||||
form.emailCode = ''
|
||||
|
||||
initialData.value = { ...form }
|
||||
}
|
||||
})
|
||||
|
||||
const emailChangedAndNotEmpty = computed(() => {
|
||||
const currentEmail = (form.email || '').trim().toLowerCase()
|
||||
const prevEmail = (initialData.value?.email || '').trim().toLowerCase()
|
||||
return currentEmail && currentEmail !== prevEmail
|
||||
})
|
||||
|
||||
const isChanged = computed(() => {
|
||||
return form.name !== initialData.value.name ||
|
||||
form.grade !== initialData.value.grade ||
|
||||
form.class !== initialData.value.class ||
|
||||
form.studentId !== initialData.value.studentId ||
|
||||
form.email !== initialData.value.email ||
|
||||
(emailChangedAndNotEmpty.value && form.emailCode.length === 6)
|
||||
})
|
||||
|
||||
const sendProfileEmailCode = async () => {
|
||||
const emailStr = (form.email || '').toString().trim()
|
||||
if (!emailStr) return
|
||||
|
||||
emailActionLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/user/email/send-code', {
|
||||
method: 'POST',
|
||||
body: { email: emailStr },
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
showToast('验证码已发送,请查收邮箱', 'success')
|
||||
} catch (err) {
|
||||
showToast(err?.data?.message || err?.message || '验证码发送失败', 'error')
|
||||
} finally {
|
||||
emailActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
if (form.name) {
|
||||
const chineseRegex = /^[\u4e00-\u9fa5]+$/
|
||||
if (!chineseRegex.test(form.name)) {
|
||||
showToast('昵称只能包含汉字', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (emailChangedAndNotEmpty.value && !form.emailCode) {
|
||||
showToast('请填写邮箱验证码', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
body: form
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast('个人信息更新成功', 'success')
|
||||
initialData.value = { ...form, emailCode: '' }
|
||||
form.emailCode = ''
|
||||
await auth.refreshUser()
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.data?.message || '更新失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-select-profile :deep(> .flex) {
|
||||
padding: 10px 16px;
|
||||
background-color: var(--input-bg, #09090b);
|
||||
border: 1px solid var(--input-border, #27272a);
|
||||
border-radius: 0.75rem;
|
||||
color: var(--input-text, #f4f4f5);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.custom-select-profile :deep(> .flex:hover) {
|
||||
border-color: var(--input-border-focus, #3f3f46);
|
||||
}
|
||||
|
||||
.custom-select-profile :deep(> .border-blue-500\/50) {
|
||||
border-color: var(--primary, rgba(34, 197, 94, 0.5)) !important;
|
||||
--tw-ring-color: var(--primary-light, rgba(34, 197, 94, 0.5));
|
||||
box-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
background-color: var(--input-bg, #09090b) !important;
|
||||
}
|
||||
|
||||
.custom-select-profile :deep(.text-\[11px\]) {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -2,19 +2,19 @@
|
||||
<svg viewBox="0 0 24 24" width="1.2em" height="1.2em" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
|
||||
<path
|
||||
fill="var(--oauth-google-blue)"
|
||||
fill="#4285F4"
|
||||
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
|
||||
/>
|
||||
<path
|
||||
fill="var(--oauth-google-green)"
|
||||
fill="#34A853"
|
||||
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
|
||||
/>
|
||||
<path
|
||||
fill="var(--oauth-google-yellow)"
|
||||
fill="#FBBC05"
|
||||
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
|
||||
/>
|
||||
<path
|
||||
fill="var(--oauth-google-red)"
|
||||
fill="#EA4335"
|
||||
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
|
||||
/>
|
||||
</g>
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
<template>
|
||||
<div class="login-form">
|
||||
<div class="form-header">
|
||||
<h2>注册账号</h2>
|
||||
<p>创建您的VoiceHub账户</p>
|
||||
</div>
|
||||
|
||||
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleRegister">
|
||||
<div class="form-group">
|
||||
<label for="username">真实姓名(大名)</label>
|
||||
<div class="input-wrapper">
|
||||
<svg
|
||||
class="input-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
:class="{ 'input-error': error }"
|
||||
placeholder="请输入真实姓名(大名),仅限汉字"
|
||||
required
|
||||
type="text"
|
||||
@input="error = ''"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<div class="input-wrapper">
|
||||
<svg
|
||||
class="input-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<rect height="11" rx="2" ry="2" width="18" x="3" y="11" />
|
||||
<circle cx="12" cy="16" r="1" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
:class="{ 'input-error': error }"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
@input="error = ''"
|
||||
>
|
||||
<button class="password-toggle" type="button" @click="showPassword = !showPassword">
|
||||
<svg
|
||||
v-if="showPassword"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
|
||||
/>
|
||||
<line x1="1" x2="23" y1="1" y2="23" />
|
||||
</svg>
|
||||
<svg v-else fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="display: flex; gap: 12px;">
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="grade">年级</label>
|
||||
<div class="input-wrapper">
|
||||
<CustomSelect
|
||||
v-model="grade"
|
||||
:options="gradeOptions"
|
||||
placeholder="请选择年级"
|
||||
class-name="w-full custom-select-override"
|
||||
@update:model-value="error = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="class">班级</label>
|
||||
<div class="input-wrapper">
|
||||
<CustomSelect
|
||||
v-model="studentClass"
|
||||
:options="classOptions"
|
||||
placeholder="请选择班级"
|
||||
class-name="w-full custom-select-override"
|
||||
@update:model-value="error = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="studentId">学号 (选填)</label>
|
||||
<div class="input-wrapper">
|
||||
<svg class="input-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
<input
|
||||
id="studentId"
|
||||
v-model="studentId"
|
||||
:class="{ 'input-error': error }"
|
||||
placeholder="请输入学号"
|
||||
type="text"
|
||||
@input="error = ''"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱 (选填)</label>
|
||||
<div class="input-wrapper">
|
||||
<svg
|
||||
class="input-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M4 4h16v16H4z" />
|
||||
<path d="m4 6 8 7 8-7" />
|
||||
</svg>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
:class="{ 'input-error': error }"
|
||||
placeholder="例如:name@example.com"
|
||||
type="email"
|
||||
@input="error = ''"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="email" class="form-group">
|
||||
<label for="emailCode">邮箱验证码</label>
|
||||
<div class="flex gap-2" style="display: flex; gap: 8px;">
|
||||
<div class="input-wrapper flex-1" style="flex: 1;">
|
||||
<svg
|
||||
class="input-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
<input
|
||||
id="emailCode"
|
||||
v-model="emailCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="6位数字验证码"
|
||||
class="form-input"
|
||||
:disabled="loading"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!email || emailActionLoading"
|
||||
class="px-4 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap border border-zinc-700"
|
||||
@click="sendRegisterEmailCode"
|
||||
>
|
||||
{{ emailActionLoading ? '发送中...' : '发送验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-container">
|
||||
<svg
|
||||
class="error-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
<span class="error-message">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<button :disabled="loading" class="submit-btn register-btn" type="submit">
|
||||
<svg v-if="loading" class="loading-spinner" viewBox="0 0 24 24">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
fill="none"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-dasharray="31.416"
|
||||
stroke-dashoffset="31.416"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dasharray"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
values="0 31.416;15.708 15.708;0 31.416"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
values="0;-15.708;-31.416"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
<span v-if="loading">注册中...</span>
|
||||
<span v-else>注 册</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="form-footer" style="margin-top: 12px; margin-bottom: 12px;">
|
||||
<p class="help-text">
|
||||
已有账号? <NuxtLink to="/login" style="color: var(--primary);">去登录</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const email = ref('')
|
||||
const emailCode = ref('')
|
||||
const grade = ref('')
|
||||
const studentClass = ref('')
|
||||
const studentId = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const emailActionLoading = ref(false)
|
||||
const pendingRedirectPath = ref('/')
|
||||
|
||||
const auth = useAuth()
|
||||
|
||||
const gradeOptions = [
|
||||
{ label: '高一', value: '高一' },
|
||||
{ label: '高二', value: '高二' },
|
||||
{ label: '高三', value: '高三' }
|
||||
]
|
||||
|
||||
const classOptions = Array.from({ length: 16 }, (_, i) => ({
|
||||
label: `${i + 1}班`,
|
||||
value: (i + 1).toString()
|
||||
}))
|
||||
|
||||
const sendRegisterEmailCode = async () => {
|
||||
if (!email.value || !email.value.trim()) {
|
||||
error.value = '请先输入邮箱'
|
||||
return
|
||||
}
|
||||
|
||||
emailActionLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/email/send-code', {
|
||||
method: 'POST',
|
||||
body: { email: email.value.trim() }
|
||||
})
|
||||
if ((window as any).$showNotification) {
|
||||
;(window as any).$showNotification('验证码已发送,请查收邮箱', 'success')
|
||||
}
|
||||
} catch (err: any) {
|
||||
if ((window as any).$showNotification) {
|
||||
;(window as any).$showNotification(err.data?.message || err.message || '验证码发送失败', 'error')
|
||||
}
|
||||
} finally {
|
||||
emailActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username.value || !password.value || !grade.value || !studentClass.value) {
|
||||
error.value = '请填写完整的注册信息'
|
||||
return
|
||||
}
|
||||
|
||||
const chineseRegex = /^[\u4e00-\u9fa5]+$/
|
||||
if (!chineseRegex.test(username.value)) {
|
||||
error.value = '真实姓名(大名)只能包含汉字'
|
||||
return
|
||||
}
|
||||
|
||||
if (email.value && !emailCode.value) {
|
||||
error.value = '请填写邮箱验证码'
|
||||
return
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await auth.register({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
email: email.value,
|
||||
emailCode: emailCode.value,
|
||||
grade: grade.value,
|
||||
class: studentClass.value,
|
||||
studentId: studentId.value
|
||||
})
|
||||
|
||||
if (response?.success) {
|
||||
pendingRedirectPath.value = auth.isAdmin.value ? '/dashboard' : '/'
|
||||
await navigateTo(pendingRedirectPath.value)
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.data?.message || err.message || '注册失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
animation: fadeInUp 0.4s ease both;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 28px;
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-wrapper::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 8px;
|
||||
height: 2px;
|
||||
background: var(--primary);
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transform: scaleX(0.2);
|
||||
transition:
|
||||
transform var(--transition-normal),
|
||||
opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within::after {
|
||||
opacity: 0.35;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-quaternary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.custom-select-override {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-select-override :deep(> .flex) {
|
||||
padding: 14px 16px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.custom-select-override :deep(> .flex:hover) {
|
||||
filter: brightness(1.03);
|
||||
}
|
||||
|
||||
.custom-select-override :deep(> .border-blue-500\/50) {
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--input-shadow-focus);
|
||||
}
|
||||
|
||||
.custom-select-override :deep(.text-\[11px\]) {
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.custom-select-override :deep(.text-\[8px\]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
width: 100%;
|
||||
padding: 16px 16px 16px 48px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: 16px;
|
||||
transition:
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.input-wrapper input::placeholder {
|
||||
color: var(--input-placeholder);
|
||||
}
|
||||
|
||||
.input-wrapper input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--input-shadow-focus);
|
||||
}
|
||||
|
||||
.input-wrapper input:focus + .input-icon,
|
||||
.input-wrapper input:not(:placeholder-shown) + .input-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.input-wrapper input:hover {
|
||||
filter: brightness(1.03);
|
||||
}
|
||||
|
||||
.input-wrapper input.input-error {
|
||||
border-color: var(--error);
|
||||
box-shadow: 0 0 0 3px var(--error-light);
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-quaternary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
transform var(--transition-fast);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.password-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.password-toggle svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--error-light);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.auth-form.has-error {
|
||||
animation: shake 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
15% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
30% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
45% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
90% {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 16px;
|
||||
font-weight: var(--font-semibold);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-normal),
|
||||
box-shadow var(--transition-normal),
|
||||
transform var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--primary-dark);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.submit-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.register-btn {
|
||||
padding: 18px;
|
||||
font-size: 18px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-text code {
|
||||
background: var(--input-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
color: var(--primary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.webauthn-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
color: var(--text-quaternary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
.divider span {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.webauthn-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 15px;
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.webauthn-btn:hover:not(:disabled) {
|
||||
background: var(--surface-tertiary);
|
||||
border-color: var(--input-border-focus);
|
||||
}
|
||||
|
||||
.webauthn-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.webauthn-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.form-header h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
padding: 14px 14px 14px 44px;
|
||||
font-size: 16px; /* 防止iOS缩放 */
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 状态显示 -->
|
||||
<div class="flex items-center justify-between p-4 bg-bg-primary-50 rounded-xl border border-border-secondary">
|
||||
<div class="flex items-center justify-between p-4 bg-zinc-950/50 rounded-xl border border-zinc-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="p-2 rounded-lg flex items-center justify-center"
|
||||
:class="isEnabled ? 'bg-success-10 text-success' : 'bg-bg-tertiary text-text-tertiary'"
|
||||
class="p-2 rounded-lg"
|
||||
:class="isEnabled ? 'bg-green-500/10 text-green-500' : 'bg-zinc-800 text-zinc-500'"
|
||||
>
|
||||
<ShieldCheck :size="20" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-text-primary">{{ locale.title }}</h3>
|
||||
<p class="text-xs text-text-tertiary">
|
||||
{{ isEnabled ? locale.enabledDesc : locale.disabledDesc }}
|
||||
<h3 class="font-bold text-zinc-100">双重认证 (2FA)</h3>
|
||||
<p class="text-xs text-zinc-500">
|
||||
{{ isEnabled ? '您的账户已开启双重认证保护' : '未开启,建议开启以提升账户安全性' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,36 +20,36 @@
|
||||
<button
|
||||
v-if="!isEnabled"
|
||||
@click="startSetup"
|
||||
class="px-4 py-2 bg-primary-hover hover:bg-primary text-text-primary text-sm font-bold rounded-lg transition-colors"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-lg transition-colors"
|
||||
>
|
||||
{{ locale.enable }}
|
||||
开启
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="confirmDisable"
|
||||
class="px-4 py-2 bg-error-10 hover:bg-error-20 text-error border border-error-20 text-sm font-bold rounded-lg transition-colors"
|
||||
class="px-4 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 text-sm font-bold rounded-lg transition-colors"
|
||||
>
|
||||
{{ locale.disable }}
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 开启流程 -->
|
||||
<div v-if="showSetup" class="bg-bg-primary-30 rounded-xl border border-border-secondary p-6 space-y-6 animate-in fade-in slide-in-from-top-4">
|
||||
<div v-if="showSetup" class="bg-zinc-950/30 rounded-xl border border-zinc-800 p-6 space-y-6 animate-in fade-in slide-in-from-top-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 class="font-bold text-text-primary mb-1">{{ locale.setupTitle }}</h4>
|
||||
<p class="text-sm text-text-tertiary">{{ locale.setupDesc }}</p>
|
||||
<h4 class="font-bold text-zinc-100 mb-1">设置双重认证</h4>
|
||||
<p class="text-sm text-zinc-400">请使用 Google Authenticator 或其他验证器应用扫描下方二维码</p>
|
||||
</div>
|
||||
<button @click="cancelSetup" class="text-text-tertiary hover:text-text-secondary">
|
||||
<button @click="cancelSetup" class="text-zinc-500 hover:text-zinc-300">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8 items-center md:items-start">
|
||||
<!-- 二维码区域 -->
|
||||
<div class="flex-shrink-0 bg-bg-secondary p-2 rounded-lg">
|
||||
<div class="flex-shrink-0 bg-white p-2 rounded-lg">
|
||||
<img v-if="qrCodeUrl" :src="qrCodeUrl" alt="2FA QR Code" class="w-48 h-48" />
|
||||
<div v-else class="w-48 h-48 flex items-center justify-center bg-bg-secondary text-text-tertiary">
|
||||
<div v-else class="w-48 h-48 flex items-center justify-center bg-zinc-100 text-zinc-400">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,17 +57,17 @@
|
||||
<!-- 验证输入区域 -->
|
||||
<div class="flex-1 space-y-4 w-full">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-text-tertiary uppercase tracking-wider mb-2">
|
||||
{{ locale.manualKey }}
|
||||
<label class="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">
|
||||
手动输入密钥
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="px-3 py-2 bg-bg-secondary rounded-lg text-text-secondary font-mono text-sm border border-border-secondary select-all">
|
||||
<code class="px-3 py-2 bg-zinc-900 rounded-lg text-zinc-300 font-mono text-sm border border-zinc-800 select-all">
|
||||
{{ secret }}
|
||||
</code>
|
||||
<button
|
||||
@click="copySecret"
|
||||
class="p-2 hover:bg-bg-tertiary rounded-lg text-text-tertiary transition-colors"
|
||||
:title="locale.copyKey"
|
||||
class="p-2 hover:bg-zinc-800 rounded-lg text-zinc-400 transition-colors"
|
||||
title="复制密钥"
|
||||
>
|
||||
<Copy :size="16" />
|
||||
</button>
|
||||
@@ -75,25 +75,25 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-text-tertiary uppercase tracking-wider mb-2">
|
||||
{{ locale.code }}
|
||||
<label class="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">
|
||||
验证码
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="verificationCode"
|
||||
type="text"
|
||||
:placeholder="locale.codePlaceholder"
|
||||
placeholder="请输入6位验证码"
|
||||
maxlength="6"
|
||||
class="flex-1 bg-bg-secondary border border-border-secondary rounded-lg px-4 py-2 text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all font-mono text-center tracking-widest"
|
||||
class="flex-1 bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-2 text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-mono text-center tracking-widest"
|
||||
@keyup.enter="enable2FA"
|
||||
/>
|
||||
<button
|
||||
@click="enable2FA"
|
||||
:disabled="loading || verificationCode.length !== 6"
|
||||
class="px-6 py-2 bg-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary font-bold rounded-lg transition-colors flex items-center gap-2"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Loader2 v-if="loading" class="animate-spin" :size="16" />
|
||||
<span>{{ locale.verifyAndEnable }}</span>
|
||||
<span>验证并开启</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,12 +103,12 @@
|
||||
|
||||
<UIConfirmDialog
|
||||
:show="showDisableConfirm"
|
||||
:title="locale.disableTitle"
|
||||
:message="locale.disableContent"
|
||||
:confirm-text="locale.verifyAndDisable"
|
||||
type="danger"
|
||||
title="关闭双重认证"
|
||||
content="关闭后,您的账户将不再受到双重认证保护。为确保安全,请输入您的登录密码以确认。"
|
||||
confirm-text="验证并关闭"
|
||||
confirm-type="danger"
|
||||
:show-input="true"
|
||||
:input-placeholder="locale.passwordPlaceholder"
|
||||
input-placeholder="请输入当前登录密码"
|
||||
input-type="password"
|
||||
@confirm="disable2FA"
|
||||
@cancel="showDisableConfirm = false"
|
||||
@@ -116,18 +116,14 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ShieldCheck, X, Copy, Loader2 } from '@lucide/vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const props = defineProps({
|
||||
initialEnabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
const props = defineProps<{
|
||||
initialEnabled?: boolean
|
||||
}>()
|
||||
|
||||
const isEnabled = ref(props.initialEnabled || false)
|
||||
const showSetup = ref(false)
|
||||
@@ -137,8 +133,6 @@ const qrCodeUrl = ref('')
|
||||
const secret = ref('')
|
||||
const verificationCode = ref('')
|
||||
const { showToast } = useToast()
|
||||
const { auth } = useLocale()
|
||||
const locale = computed(() => auth.value?.twoFactorSetup || {})
|
||||
|
||||
// 获取当前状态(可选,如果父组件没传可以通过API获取,这里暂时假设父组件会传或者初始化为false,
|
||||
// 实际生产中最好有一个 API check status,但为了简化,这里先依赖父组件或默认)
|
||||
@@ -161,8 +155,8 @@ const startSetup = async () => {
|
||||
showSetup.value = true
|
||||
verificationCode.value = ''
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.data?.message || err.message || locale.value.fetchFailed, 'error')
|
||||
} catch (err: any) {
|
||||
showToast(err.data?.message || err.message || '获取验证码失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -178,9 +172,9 @@ const cancelSetup = () => {
|
||||
const copySecret = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret.value)
|
||||
showToast(locale.value.copied, 'success')
|
||||
showToast('密钥已复制', 'success')
|
||||
} catch (err) {
|
||||
showToast(locale.value.copyFailed, 'error')
|
||||
showToast('复制失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,11 +193,11 @@ const enable2FA = async () => {
|
||||
|
||||
if (error.value) throw error.value
|
||||
|
||||
showToast(locale.value.enabled, 'success')
|
||||
showToast('双重认证已开启', 'success')
|
||||
isEnabled.value = true
|
||||
cancelSetup()
|
||||
} catch (err) {
|
||||
showToast(err.data?.message || err.message || locale.value.verifyFailed, 'error')
|
||||
} catch (err: any) {
|
||||
showToast(err.data?.message || err.message || '验证失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -213,9 +207,9 @@ const confirmDisable = () => {
|
||||
showDisableConfirm.value = true
|
||||
}
|
||||
|
||||
const disable2FA = async (password) => {
|
||||
const disable2FA = async (password: string) => {
|
||||
if (!password) {
|
||||
showToast(locale.value.passwordRequired, 'warning')
|
||||
showToast('请输入密码', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -228,11 +222,11 @@ const disable2FA = async (password) => {
|
||||
|
||||
if (error.value) throw error.value
|
||||
|
||||
showToast(locale.value.disabled, 'success')
|
||||
showToast('双重认证已关闭', 'success')
|
||||
isEnabled.value = false
|
||||
showDisableConfirm.value = false
|
||||
} catch (err) {
|
||||
showToast(err.data?.message || err.message || locale.value.disableFailed, 'error')
|
||||
} catch (err: any) {
|
||||
showToast(err.data?.message || err.message || '关闭失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -7,35 +7,35 @@
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[2000] bg-bg-primary-80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
class="fixed inset-0 z-[2000] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md bg-bg-primary border border-border-secondary rounded-2xl p-6 shadow-2xl space-y-6"
|
||||
class="w-full max-w-md bg-zinc-950 border border-zinc-800 rounded-2xl p-6 shadow-2xl space-y-6"
|
||||
@click.stop
|
||||
>
|
||||
<div class="text-center space-y-2">
|
||||
<h3 class="text-xl font-bold text-text-primary">{{ locale.title }}</h3>
|
||||
<p class="text-sm text-text-tertiary">
|
||||
{{ method === 'totp' ? locale.totpDesc : locale.emailDesc }}
|
||||
<h3 class="text-xl font-bold text-zinc-100">双重认证</h3>
|
||||
<p class="text-sm text-zinc-400">
|
||||
{{ method === 'totp' ? '请输入您的认证器应用生成的6位验证码' : '请输入发送到您邮箱的验证码' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 邮箱输入 (仅 Email 模式) -->
|
||||
<div v-if="method === 'email'" class="space-y-2">
|
||||
<label class="block text-sm text-text-tertiary">
|
||||
{{ locale.emailLabel }}
|
||||
<span v-if="maskedEmail" class="block text-xs text-text-tertiary mt-1">
|
||||
{{ locale.maskedEmailTip }} {{ maskedEmail }}
|
||||
<label class="block text-sm text-zinc-400">
|
||||
请补全您的邮箱以接收验证码
|
||||
<span v-if="maskedEmail" class="block text-xs text-zinc-500 mt-1">
|
||||
提示: {{ maskedEmail }}
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="emailInput"
|
||||
type="email"
|
||||
:placeholder="locale.emailPlaceholder"
|
||||
class="w-full bg-bg-secondary border border-border-secondary rounded-lg px-4 py-3 text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="请输入完整邮箱地址"
|
||||
class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
:disabled="emailSent && cooldown > 0"
|
||||
@keyup.enter="!emailSent && sendEmailCode()"
|
||||
/>
|
||||
@@ -50,7 +50,7 @@
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="000000"
|
||||
class="w-full bg-bg-secondary border border-border-secondary rounded-lg px-4 py-3 text-center text-2xl font-mono tracking-widest text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-center text-2xl font-mono tracking-widest text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
@keyup.enter="handleVerify"
|
||||
ref="inputRef"
|
||||
/>
|
||||
@@ -63,13 +63,13 @@
|
||||
type="button"
|
||||
@click="sendEmailCode"
|
||||
:disabled="cooldown > 0 || sending || !emailInput"
|
||||
class="text-sm text-primary hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
class="text-sm text-blue-500 hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ resendButtonText }}
|
||||
{{ cooldown > 0 ? `${cooldown}秒后重新发送` : (emailSent ? '重新发送验证码' : '发送验证码') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="p-3 bg-error-10 border border-error-20 rounded-lg flex items-center gap-2 text-error text-sm">
|
||||
<div v-if="error" class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2 text-red-500 text-sm">
|
||||
<AlertCircle :size="16" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
@@ -77,26 +77,26 @@
|
||||
<button
|
||||
@click="handleVerify"
|
||||
:disabled="loading || code.length !== 6"
|
||||
class="w-full py-3 bg-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
class="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Loader2 v-if="loading" class="animate-spin" :size="18" />
|
||||
<span>{{ locale.verifyLogin }}</span>
|
||||
<span>验证登录</span>
|
||||
</button>
|
||||
|
||||
<!-- 切换验证方式按钮 -->
|
||||
<button
|
||||
v-if="hasMultipleMethods"
|
||||
@click="toggleMethod"
|
||||
class="w-full py-2 text-primary hover:text-primary text-sm transition-colors"
|
||||
class="w-full py-2 text-blue-500 hover:text-blue-400 text-sm transition-colors"
|
||||
>
|
||||
{{ method === 'totp' ? locale.switchToEmail : locale.switchToTotp }}
|
||||
{{ method === 'totp' ? '无法使用验证器?切换到邮箱验证' : '使用验证器应用验证' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="$emit('cancel')"
|
||||
class="w-full py-2 text-text-tertiary hover:text-text-secondary text-sm transition-colors"
|
||||
class="w-full py-2 text-zinc-500 hover:text-zinc-300 text-sm transition-colors"
|
||||
>
|
||||
{{ locale.backLogin }}
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,7 +109,6 @@
|
||||
import { ref, watch, nextTick, computed, onUnmounted } from 'vue'
|
||||
import { Loader2, AlertCircle } from '@lucide/vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
@@ -125,15 +124,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { showToast } = useToast()
|
||||
const { auth } = useLocale()
|
||||
const locale = computed(() => auth.value?.twoFactorVerify || {})
|
||||
const { format: formatLocaleValue } = useLocaleText(locale)
|
||||
const resendButtonText = computed(() => {
|
||||
if (cooldown.value > 0) {
|
||||
return formatLocaleValue(locale.value.resendCountdown, '', cooldown.value)
|
||||
}
|
||||
return emailSent.value ? locale.value.resendCode : locale.value.sendCode
|
||||
})
|
||||
// 默认优先使用 TOTP,如果没有则使用 Email
|
||||
const defaultMethod = computed(() => props.availableMethods?.includes('totp') ? 'totp' : 'email')
|
||||
const method = ref(defaultMethod.value)
|
||||
@@ -170,7 +160,7 @@ const toggleMethod = () => {
|
||||
const sendEmailCode = async () => {
|
||||
try {
|
||||
if (!emailInput.value) {
|
||||
error.value = locale.value.emailRequired || '请输入邮箱'
|
||||
error.value = '请输入您的邮箱地址'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,7 +176,7 @@ const sendEmailCode = async () => {
|
||||
}
|
||||
})
|
||||
|
||||
showToast(locale.value.codeSent, 'success')
|
||||
showToast('验证码已发送', 'success')
|
||||
emailSent.value = true
|
||||
cooldown.value = 60
|
||||
timer = setInterval(() => {
|
||||
@@ -200,7 +190,7 @@ const sendEmailCode = async () => {
|
||||
// 发送成功后自动聚焦验证码输入框
|
||||
nextTick(() => inputRef.value?.focus())
|
||||
} catch (err: any) {
|
||||
error.value = err.data?.message || err.message || locale.value.sendFailed || '发送失败'
|
||||
error.value = err.data?.message || err.message || '发送失败'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
@@ -218,7 +208,7 @@ const handleVerify = async () => {
|
||||
|
||||
emit('success', response)
|
||||
} catch (err: any) {
|
||||
error.value = err.data?.message || err.message || locale.value.verifyFailed || '验证失败'
|
||||
error.value = err.data?.message || err.message || '验证失败'
|
||||
code.value = '' // 出错清空
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
Reference in New Issue
Block a user