feat: 迁移自定义功能至 1.5.9.9 版本

包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
2026-09-12 19:26:42 +08:00
parent 34f0679117
commit 1106aeea6c
161 changed files with 18963 additions and 27900 deletions
+143 -108
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+190 -255
View File
@@ -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)
}
+175 -98
View File
@@ -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>
+252
View File
@@ -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>
+720
View File
@@ -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>
+49 -55
View File
@@ -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
}
+25 -35
View File
@@ -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