初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
@@ -0,0 +1,363 @@
<template>
<div class="space-y-6">
<form class="space-y-5" @submit.prevent="handleChangePassword">
<div v-if="!isFirstLogin" class="space-y-2">
<label
for="current-password"
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-zinc-500 group-focus-within:text-blue-500 transition-colors"
>
<Lock :size="18" />
</div>
<input
id="current-password"
v-model="currentPassword"
:class="[
inputClass,
error
? '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'"
placeholder="请输入当前密码"
required
@input="error = ''"
>
<button
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
type="button"
@click="showCurrentPassword = !showCurrentPassword"
>
<Eye v-if="!showCurrentPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
</div>
<div class="space-y-2">
<label
for="new-password"
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-zinc-500 group-focus-within:text-blue-500 transition-colors"
>
<KeyRound :size="18" />
</div>
<input
id="new-password"
v-model="newPassword"
:class="[
inputClass,
error
? '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'"
placeholder="请输入新密码"
required
@input="
error = '';
validatePassword()
"
>
<button
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
type="button"
@click="showNewPassword = !showNewPassword"
>
<Eye v-if="!showNewPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
<!-- 密码强度指示器 -->
<div v-if="newPassword" class="px-1 pt-1 space-y-2">
<div class="h-1 w-full bg-zinc-800 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-500"
:class="passwordStrength.colorClass"
:style="{ width: passwordStrength.width }"
/>
</div>
<div class="flex justify-between items-center">
<span class="text-[10px] font-black uppercase tracking-widest text-zinc-500"
>密码强度</span
>
<span
class="text-[10px] font-black uppercase tracking-widest"
:class="passwordStrength.textColorClass"
>
{{ passwordStrength.text }}
</span>
</div>
</div>
</div>
<div class="space-y-2">
<label
for="confirm-password"
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-zinc-500 group-focus-within:text-blue-500 transition-colors"
>
<CheckCircle2 :size="18" />
</div>
<input
id="confirm-password"
v-model="confirmPassword"
:class="[
inputClass,
error || (confirmPassword && newPassword !== confirmPassword)
? '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'"
placeholder="请再次输入新密码"
required
@input="error = ''"
>
<button
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
type="button"
@click="showConfirmPassword = !showConfirmPassword"
>
<Eye v-if="!showConfirmPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
<!-- 密码匹配提示 -->
<div v-if="confirmPassword" class="px-1">
<div
v-if="newPassword !== confirmPassword"
class="flex items-center gap-1.5 text-rose-500"
>
<XCircle :size="12" />
<span class="text-[10px] font-bold">密码不匹配</span>
</div>
<div v-else class="flex items-center gap-1.5 text-emerald-500">
<CheckCircle2 :size="12" />
<span class="text-[10px] font-bold">密码匹配</span>
</div>
</div>
</div>
<!-- 状态消息 -->
<div
v-if="error"
class="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl flex items-center gap-3"
>
<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"
class="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-3"
>
<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-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 ? '处理中...' : isFirstLogin ? '设置初始密码' : '确认修改密码' }}</span>
</button>
</form>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import {
Lock,
KeyRound,
Eye,
EyeOff,
CheckCircle2,
XCircle,
AlertCircle,
Loader2
} from 'lucide-vue-next'
// 组件属性
const props = defineProps({
isFirstLogin: {
type: Boolean,
default: false
}
})
const auth = useAuth()
const router = useRouter()
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const error = ref('')
const success = ref('')
const loading = ref(false)
// 样式类
const inputClass =
'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)
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
// 密码强度计算
const passwordStrength = computed(() => {
const password = newPassword.value
if (!password) return { width: '0%', colorClass: '', textColorClass: '', text: '' }
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(() => {
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 validatePassword = () => {
if (newPassword.value && newPassword.value.length < 8) {
error.value = '密码长度至少为8位'
} else {
error.value = ''
}
}
const handleChangePassword = async () => {
if (newPassword.value !== confirmPassword.value) {
error.value = '新密码和确认密码不匹配'
return
}
if (newPassword.value.length < 8) {
error.value = '新密码长度至少为8位'
return
}
loading.value = true
error.value = ''
success.value = ''
try {
if (props.isFirstLogin) {
await auth.setInitialPassword(newPassword.value)
success.value = '密码设置成功!正在跳转...'
// 清空表单
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
// 密码设置完成后跳转
setTimeout(async () => {
// 更新用户状态
await auth.refreshUser()
if (auth.isAdmin.value) {
router.push('/dashboard')
} else {
router.push('/')
}
}, 2000)
} else {
await auth.changePassword(currentPassword.value, newPassword.value)
success.value = '密码修改成功!请重新登录'
// 清空表单
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
// 密码修改后登出
setTimeout(() => {
auth.logout()
router.push('/login')
}, 2000)
}
} catch (err) {
// 提取错误信息,支持多种错误格式
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
}
}
</script>
@@ -0,0 +1,651 @@
<template>
<div class="login-form">
<div class="form-header">
<h2>{{ isBindMode ? '绑定账号' : '欢迎回来' }}</h2>
<p v-if="isBindMode">即将绑定 {{ providerName }} 账号: {{ providerUsername }}</p>
<p v-else>登录您的VoiceHub账户</p>
</div>
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleLogin">
<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 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" 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">{{ isBindMode ? '绑定中...' : '登录中...' }}</span>
<span v-else>{{ isBindMode ? '绑定并登录' : '登录' }}</span>
</button>
</form>
<div v-if="!isBindMode && isWebAuthnSupported" class="webauthn-section">
<div class="divider">
<span>或</span>
</div>
<button
type="button"
class="webauthn-btn"
:disabled="loading"
@click="handleWebAuthnLogin"
>
<Fingerprint :size="20" class="webauthn-icon" />
<span>使用 Windows Hello / Passkey 登录</span>
</button>
</div>
<AuthOAuthButtons v-if="!isBindMode" />
<div class="form-footer">
<p class="help-text">不同VoiceHub平台的账号不互通</p>
</div>
<AuthTwoFactorVerify
:show="show2FA"
:user-id="userId2FA"
:available-methods="methods2FA"
:masked-email="maskedEmail2FA"
:temp-token="tempToken2FA"
@success="handle2FASuccess"
@cancel="show2FA = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuth } from '~/composables/useAuth'
import { getProviderDisplayName } from '~/utils/oauth'
import { startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser'
import { Fingerprint } from 'lucide-vue-next'
const route = useRoute()
const isBindMode = computed(() => route.query.action === 'bind')
const providerUsername = computed(() => route.query.username || '')
const providerName = computed(() => {
const provider = (route.query.provider as string) || '第三方'
return getProviderDisplayName(provider)
})
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const showPassword = ref(false)
const isWebAuthnSupported = ref(false)
const show2FA = ref(false)
const userId2FA = ref(0)
const methods2FA = ref<string[]>([])
const tempToken2FA = ref('')
const maskedEmail2FA = ref('')
const auth = useAuth()
const handle2FASuccess = async () => {
if (auth.isAdmin.value) {
await navigateTo('/dashboard')
} else {
await navigateTo('/')
}
}
onMounted(async () => {
const isApiSupported = browserSupportsWebAuthn()
let isPlatformAuthenticatorAvailable = false
if (isApiSupported && window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) {
try {
isPlatformAuthenticatorAvailable = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
} catch (e) {
console.warn('WebAuthn 平台认证器检查失败:', e)
}
}
// 兼容外部安全密钥(如 YubiKey),即使没有内置平台认证器也允许尝试
isWebAuthnSupported.value = isApiSupported
})
const handleLogin = async () => {
if (!username.value || !password.value) {
error.value = '请填写完整的登录信息'
return
}
error.value = ''
loading.value = true
try {
if (isBindMode.value) {
await $fetch('/api/auth/bind', {
method: 'POST',
body: {
username: username.value,
password: password.value
}
})
// 绑定成功后刷新认证状态
await auth.initAuth()
await navigateTo('/')
} else {
const response = await auth.login(username.value, password.value)
if (response.requires2FA) {
userId2FA.value = response.userId
methods2FA.value = response.methods
tempToken2FA.value = response.tempToken
maskedEmail2FA.value = response.maskedEmail || ''
show2FA.value = true
return
}
// 登录成功后跳转
if (auth.isAdmin.value) {
await navigateTo('/dashboard')
} else {
await navigateTo('/')
}
}
} catch (err) {
error.value =
err.message || (isBindMode.value ? '绑定失败,请检查账号密码' : '登录失败,请检查账号密码')
// 密码错误时清空密码字段
if (error.value.includes('密码') || error.value.includes('错误')) {
password.value = ''
}
} finally {
loading.value = false
}
}
const handleWebAuthnLogin = async () => {
loading.value = true
error.value = ''
try {
// 1. 获取登录选项
const options = await $fetch('/api/auth/webauthn/login/options', {
method: 'POST'
})
// 2. 调用浏览器 WebAuthn API
const credential = await startAuthentication(options)
// 3. 验证登录
const verification = await $fetch('/api/auth/webauthn/login/verify', {
method: 'POST',
body: credential
})
if (verification.success) {
// 登录成功
await auth.initAuth()
await navigateTo(verification.redirect || '/')
}
} catch (e) {
console.error('WebAuthn 登录错误:', e)
const apiError = e as { data?: { message?: string }, message?: string }
error.value = apiError.data?.message || apiError.message || 'Passkey 登录失败'
} 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;
}
.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(--btn-primary-bg);
color: var(--btn-primary-text);
border: 1px solid var(--btn-primary-border);
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::before {
content: none;
}
.submit-btn:hover:not(:disabled) {
background: var(--btn-primary-hover);
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);
}
.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>
@@ -0,0 +1,496 @@
<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-blue-500 animate-spin mb-3" />
<p class="text-zinc-500 text-xs font-medium">加载绑定状态...</p>
</div>
<div v-else class="space-y-4">
<!-- 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-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
>
<AuthProvidersGitHubIcon class="w-5 h-5" />
</div>
<div class="flex flex-col">
<span class="text-sm font-bold text-zinc-200">{{
getProviderDisplayName('github')
}}</span>
<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="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="confirmUnbind('github')"
>
{{ 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
v-if="isWebAuthnSupported || webauthnIdentities.length > 0 || !isSecureContext"
:class="[
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-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-zinc-200">Windows Hello / Passkey</span>
<ChevronDown
v-if="webauthnIdentities.length > 0"
:size="14"
class="text-zinc-500 transition-transform duration-300"
:class="{ 'rotate-180': isWebAuthnExpanded }"
/>
</div>
<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-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 ? '处理中...' : '添加设备' }}
</button>
<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">需要 HTTPS 环境</span>
</div>
</div>
<!-- WebAuthn 设备列表 -->
<Transition name="expand">
<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-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-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"
@click.stop
ref="editInput"
/>
</div>
<div v-else class="flex items-center gap-2 mb-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="重命名"
>
<Pencil :size="12" />
</button>
</div>
<span class="text-[10px] text-zinc-600"
>添加于 {{ new Date(cred.createdAt).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}) }}</span
>
</div>
<div class="flex items-center gap-1">
<template v-if="editingId === cred.id">
<button
class="text-zinc-400 hover:text-green-400 transition-colors p-1"
:disabled="isRenaming"
@click.stop="saveEditing(cred.id)"
title="保存"
>
<Check :size="14" />
</button>
<button
class="text-zinc-400 hover:text-zinc-200 transition-colors p-1"
:disabled="isRenaming"
@click.stop="cancelEditing"
title="取消"
>
<X :size="14" />
</button>
</template>
<button
v-else
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)"
>
移除
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
<!-- 确认对话框 -->
<ConfirmDialog
v-model:show="showConfirmDialog"
:loading="confirmDialog.loading"
:message="confirmDialog.message"
:title="confirmDialog.title"
:type="confirmDialog.type"
@cancel="confirmDialog.onCancel"
@confirm="confirmDialog.onConfirm"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { Loader2, Shield, Fingerprint, ChevronDown, Pencil, Check, X, AlertTriangle } from 'lucide-vue-next'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useToast } from '~/composables/useToast'
import { getProviderDisplayName } from '~/utils/oauth'
import { startRegistration, browserSupportsWebAuthn } from '@simplewebauthn/browser'
const config = useRuntimeConfig()
const { showToast } = useToast()
const identities = ref([])
const loading = ref(true)
const actionLoading = ref(false)
const isWebAuthnSupported = ref(false)
const isSecureContext = ref(true)
// 编辑相关
const editingId = ref<string | null>(null)
const editingName = ref('')
const isRenaming = ref(false)
const editInput = ref<HTMLInputElement | null>(null)
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 as HTMLInputElement)?.focus()
}
}
const cancelEditing = () => {
editingId.value = null
editingName.value = ''
}
const saveEditing = async (id: string) => {
if (!editingName.value.trim()) {
showToast('设备名称不能为空', 'error')
return
}
if (editingName.value.trim().length > 50) {
showToast('设备名称过长 (最大50个字符)', 'error')
return
}
isRenaming.value = true
try {
await $fetch('/api/auth/webauthn/rename', {
method: 'POST',
body: { id, name: editingName.value }
})
showToast('设备名称修改成功', 'success')
await fetchIdentities()
cancelEditing()
} catch (e: any) {
showToast(e.data?.message || '修改失败', 'error')
} finally {
isRenaming.value = false
}
}
const isWebAuthnExpanded = ref(false)
const toggleWebAuthnList = () => {
if (webauthnIdentities.value.length > 0) {
isWebAuthnExpanded.value = !isWebAuthnExpanded.value
}
}
// 确认对话框相关
const showConfirmDialog = ref(false)
const confirmDialog = ref({
title: '',
message: '',
type: 'warning',
loading: false,
onConfirm: () => {},
onCancel: () => {}
})
// 样式类
const itemClass =
'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')
} catch (e) {
console.error('获取绑定信息失败', e)
} finally {
loading.value = false
}
}
const handleBind = (provider) => {
actionLoading.value = true
// 绑定也是通过 OAuth 流程,最终回调时会自动识别已登录状态并执行绑定
navigateTo(`/api/auth/${provider}`, { external: true })
}
const confirmUnbind = (provider) => {
const providerName = getProviderDisplayName(provider)
confirmDialog.value = {
title: '解除绑定',
message: `确定要解除 ${providerName} 账号的绑定吗?解除后您将无法使用该账号登录。`,
type: 'danger',
loading: false,
onConfirm: () => handleUnbind(provider),
onCancel: () => {
showConfirmDialog.value = false
}
}
showConfirmDialog.value = true
}
const confirmUnbindWebAuthn = (cred) => {
confirmDialog.value = {
title: '移除 Passkey',
message: `确定要移除设备 "${cred.providerUsername}" 吗?移除后将无法使用该设备登录。`,
type: 'danger',
loading: false,
onConfirm: () => handleUnbind('webauthn', cred.id),
onCancel: () => {
showConfirmDialog.value = false
}
}
showConfirmDialog.value = true
}
const handleUnbind = async (provider, id = null) => {
confirmDialog.value.loading = true
actionLoading.value = true
try {
await $fetch('/api/auth/unbind', {
method: 'POST',
body: { provider, id }
})
await fetchIdentities()
showToast('解除绑定成功', 'success')
showConfirmDialog.value = false
} catch (e) {
showToast(e.data?.message || '解绑失败', 'error')
} finally {
actionLoading.value = false
confirmDialog.value.loading = false
}
}
const handleWebAuthnRegister = async () => {
if (!isWebAuthnSupported.value) {
showToast('您的浏览器不支持 Windows Hello / Passkey', 'error')
return
}
actionLoading.value = true
try {
const options = await $fetch('/api/auth/webauthn/register/options')
let attResp
try {
attResp = await startRegistration(options)
} catch (e) {
if (e.name === 'NotAllowedError') {
throw new Error('用户取消了操作')
}
throw e
}
// 提示用户输入设备名称(可选,这里先用默认的)
// attResp.label = 'Windows Hello'
await $fetch('/api/auth/webauthn/register/verify', {
method: 'POST',
body: attResp
})
showToast('设备添加成功', 'success')
await fetchIdentities()
} catch (e) {
console.error('WebAuthn 注册错误:', e)
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
}
}
onMounted(async () => {
fetchIdentities()
isSecureContext.value = window.isSecureContext
const isApiSupported = browserSupportsWebAuthn()
let isPlatformAuthenticatorAvailable = false
if (isApiSupported && window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) {
try {
isPlatformAuthenticatorAvailable = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
} catch (e) {
console.warn('WebAuthn 平台认证器检查失败:', e)
}
}
// 兼容外部安全密钥(如 YubiKey),即使没有内置平台认证器也允许尝试
isWebAuthnSupported.value = isApiSupported
})
</script>
<style scoped>
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease-in-out;
max-height: 500px;
opacity: 1;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
</style>
@@ -0,0 +1,45 @@
<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">或使用第三方账号登录</span>
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
</div>
<div class="flex justify-center gap-4">
<button
v-if="config.public.oauth.github"
type="button"
class="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)] hover:bg-[#24292e] hover:text-white hover:border-[#24292e]"
title="使用 GitHub 登录"
@click="loginWith('github')"
>
<AuthProvidersGitHubIcon />
</button>
<button
v-if="config.public.oauth.google"
type="button"
class="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)] hover:bg-white hover:text-black hover:border-[#dadce0]"
title="使用 Google 登录"
@click="loginWith('google')"
>
<AuthProvidersGoogleIcon />
</button>
<!-- 其他提供商可在此添加 -->
</div>
</div>
</template>
<script setup lang="ts">
const config = useRuntimeConfig()
const hasEnabledProviders = computed(() => {
return config.public.oauth && Object.values(config.public.oauth).some((enabled) => enabled)
})
const loginWith = (provider: string) => {
// 外部导航到 API 端点
navigateTo(`/api/auth/${provider}`, { external: true })
}
</script>
@@ -0,0 +1,3 @@
<template>
<img src="https://casdoor.org/img/casdoor.png" alt="Casdoor" class="h-5 w-auto object-contain" >
</template>
@@ -0,0 +1,13 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
</template>
@@ -0,0 +1,22 @@
<template>
<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="#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="#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="#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="#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>
</svg>
</template>
@@ -0,0 +1,234 @@
<template>
<div class="space-y-6">
<!-- 状态显示 -->
<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"
: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-zinc-100">双重认证 (2FA)</h3>
<p class="text-xs text-zinc-500">
{{ isEnabled ? '您的账户已开启双重认证保护' : '未开启,建议开启以提升账户安全性' }}
</p>
</div>
</div>
<button
v-if="!isEnabled"
@click="startSetup"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-lg transition-colors"
>
开启
</button>
<button
v-else
@click="confirmDisable"
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"
>
关闭
</button>
</div>
<!-- 开启流程 -->
<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-zinc-100 mb-1">设置双重认证</h4>
<p class="text-sm text-zinc-400">请使用 Google Authenticator 或其他验证器应用扫描下方二维码</p>
</div>
<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-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-zinc-100 text-zinc-400">
<Loader2 class="animate-spin" />
</div>
</div>
<!-- 验证输入区域 -->
<div class="flex-1 space-y-4 w-full">
<div>
<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-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-zinc-800 rounded-lg text-zinc-400 transition-colors"
title="复制密钥"
>
<Copy :size="16" />
</button>
</div>
</div>
<div>
<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="请输入6位验证码"
maxlength="6"
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-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>验证并开启</span>
</button>
</div>
</div>
</div>
</div>
</div>
<UIConfirmDialog
:show="showDisableConfirm"
title="关闭双重认证"
content="关闭后,您的账户将不再受到双重认证保护。为确保安全,请输入您的登录密码以确认。"
confirm-text="验证并关闭"
confirm-type="danger"
:show-input="true"
input-placeholder="请输入当前登录密码"
input-type="password"
@confirm="disable2FA"
@cancel="showDisableConfirm = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ShieldCheck, X, Copy, Loader2 } from 'lucide-vue-next'
import { useToast } from '~/composables/useToast'
const props = defineProps<{
initialEnabled?: boolean
}>()
const isEnabled = ref(props.initialEnabled || false)
const showSetup = ref(false)
const showDisableConfirm = ref(false)
const loading = ref(false)
const qrCodeUrl = ref('')
const secret = ref('')
const verificationCode = ref('')
const { showToast } = useToast()
// 获取当前状态(可选,如果父组件没传可以通过API获取,这里暂时假设父组件会传或者初始化为false,
// 实际生产中最好有一个 API check status,但为了简化,这里先依赖父组件或默认)
// 修正:我们应该在组件挂载时检查状态,但目前没有专门的 check status API,
// 通常这包含在 user profile 信息中。如果 user 对象里有 has2FA 字段最好。
// 假设父组件通过 prop 传入,或者我们添加一个 check API。
// 为了稳健,我们可以复用 generate 接口之前的逻辑,或者在 login 时返回的信息里带。
// 这里的实现先依赖 props,如果需要更精确,可以加一个 api。
const startSetup = async () => {
try {
loading.value = true
const data = await $fetch('/api/user/2fa/generate', {
method: 'POST'
})
if (data) {
qrCodeUrl.value = data.qrCode
secret.value = data.secret
showSetup.value = true
verificationCode.value = ''
}
} catch (err: any) {
showToast(err.data?.message || err.message || '获取验证码失败', 'error')
} finally {
loading.value = false
}
}
const cancelSetup = () => {
showSetup.value = false
qrCodeUrl.value = ''
secret.value = ''
verificationCode.value = ''
}
const copySecret = async () => {
try {
await navigator.clipboard.writeText(secret.value)
showToast('密钥已复制', 'success')
} catch (err) {
showToast('复制失败', 'error')
}
}
const enable2FA = async () => {
if (verificationCode.value.length !== 6) return
try {
loading.value = true
const { error } = await useFetch('/api/user/2fa/enable', {
method: 'POST',
body: {
token: verificationCode.value,
secret: secret.value
}
})
if (error.value) throw error.value
showToast('双重认证已开启', 'success')
isEnabled.value = true
cancelSetup()
} catch (err: any) {
showToast(err.data?.message || err.message || '验证失败', 'error')
} finally {
loading.value = false
}
}
const confirmDisable = () => {
showDisableConfirm.value = true
}
const disable2FA = async (password: string) => {
if (!password) {
showToast('请输入密码', 'warning')
return
}
try {
loading.value = true
const { error } = await useFetch('/api/user/2fa/disable', {
method: 'POST',
body: { password }
})
if (error.value) throw error.value
showToast('双重认证已关闭', 'success')
isEnabled.value = false
showDisableConfirm.value = false
} catch (err: any) {
showToast(err.data?.message || err.message || '关闭失败', 'error')
} finally {
loading.value = false
}
}
</script>
@@ -0,0 +1,233 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
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-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-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-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="请输入完整邮箱地址"
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()"
/>
</div>
</div>
<!-- 验证码输入 -->
<div v-if="method === 'totp' || emailSent">
<div class="relative">
<input
v-model="code"
type="text"
maxlength="6"
placeholder="000000"
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"
/>
</div>
</div>
<!-- 发送邮箱验证码按钮 -->
<div v-if="method === 'email'" class="text-center">
<button
type="button"
@click="sendEmailCode"
:disabled="cooldown > 0 || sending || !emailInput"
class="text-sm text-blue-500 hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ cooldown > 0 ? `${cooldown}秒后重新发送` : (emailSent ? '重新发送验证码' : '发送验证码') }}
</button>
</div>
<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>
<button
@click="handleVerify"
:disabled="loading || code.length !== 6"
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>验证登录</span>
</button>
<!-- 切换验证方式按钮 -->
<button
v-if="hasMultipleMethods"
@click="toggleMethod"
class="w-full py-2 text-blue-500 hover:text-blue-400 text-sm transition-colors"
>
{{ method === 'totp' ? '无法使用验证器?切换到邮箱验证' : '使用验证器应用验证' }}
</button>
<button
@click="$emit('cancel')"
class="w-full py-2 text-zinc-500 hover:text-zinc-300 text-sm transition-colors"
>
返回登录
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, computed, onUnmounted } from 'vue'
import { Loader2, AlertCircle } from 'lucide-vue-next'
import { useToast } from '~/composables/useToast'
const props = defineProps<{
show: boolean
userId: number
availableMethods?: string[]
maskedEmail?: string
tempToken?: string
}>()
const emit = defineEmits<{
(e: 'success', data: any): void
(e: 'cancel'): void
}>()
const { showToast } = useToast()
// 默认优先使用 TOTP,如果没有则使用 Email
const defaultMethod = computed(() => props.availableMethods?.includes('totp') ? 'totp' : 'email')
const method = ref(defaultMethod.value)
const methods = computed(() => props.availableMethods || ['totp', 'email'])
const hasMultipleMethods = computed(() => methods.value.length > 1)
const code = ref('')
const emailInput = ref('')
const emailSent = ref(false)
const loading = ref(false)
const sending = ref(false)
const cooldown = ref(0)
const error = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
let timer: NodeJS.Timeout | undefined
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = undefined
}
})
const toggleMethod = () => {
method.value = method.value === 'totp' ? 'email' : 'totp'
// 切换回 totp 时清空邮箱输入
if (method.value === 'totp') {
emailInput.value = ''
emailSent.value = false
}
}
const sendEmailCode = async () => {
try {
if (!emailInput.value) {
error.value = '请输入您的邮箱地址'
return
}
sending.value = true
error.value = ''
await $fetch('/api/auth/2fa/send-email', {
method: 'POST',
body: {
userId: props.userId,
token: props.tempToken,
email: emailInput.value
}
})
showToast('验证码已发送', 'success')
emailSent.value = true
cooldown.value = 60
timer = setInterval(() => {
cooldown.value--
if (cooldown.value <= 0) {
clearInterval(timer)
timer = undefined
}
}, 1000)
// 发送成功后自动聚焦验证码输入框
nextTick(() => inputRef.value?.focus())
} catch (err: any) {
error.value = err.data?.message || err.message || '发送失败'
} finally {
sending.value = false
}
}
const handleVerify = async () => {
if (code.value.length !== 6) return
try {
loading.value = true
error.value = ''
const { verify2FA } = useAuth()
const response = await verify2FA(props.userId, code.value, method.value as 'totp' | 'email', props.tempToken)
emit('success', response)
} catch (err: any) {
error.value = err.data?.message || err.message || '验证失败'
code.value = '' // 出错清空
} finally {
loading.value = false
}
}
// 切换方式时清空输入
watch(method, () => {
code.value = ''
error.value = ''
nextTick(() => inputRef.value?.focus())
})
// 显示时聚焦输入框
watch(() => props.show, (newVal) => {
if (newVal) {
// 每次重新打开时重置为默认方式(优先 TOTP)
method.value = defaultMethod.value
nextTick(() => inputRef.value?.focus())
}
})
</script>