Files
BYW-VoiceHub/app/components/Auth/LoginForm.vue
T
Henry_Du 94832e2c82
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 2m43s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 3s
fix(ui): 强制浅色主题与登录页细节修正
- 主题:将 CSS 变量默认值由深色改为 ClassicLight 浅色基准(背景/文字/边框/按钮/输入框/滚动条),追加 prefers-color-scheme: dark 下的 color-scheme: light 覆盖,补齐 surface-xxx 别名避免部分组件底色色值缺失
- 语言按钮:删除首页顶栏语言切换按钮及其点击处理逻辑
- 第三方登录:OAuthButtons 重写为使用语义化 CSS 变量而非无效的 --border-color,新增 alwaysShow 选项在未配置 provider 时显示占位提示;登录页强制启用提示
- 页脚与登录页:全部文字颜色与分割线由硬编码 rgba(255*) 改为 var(--text-tertiary/quaternary/border-primary),在浅色背景下保证对比度
2026-09-12 12:32:27 +08:00

699 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 class="forgot-password-row">
<NuxtLink to="/forgot-password" class="forgot-password-link">
忘记密码?
</NuxtLink>
</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" :always-show="true" />
<div class="form-footer">
<p class="help-text">不同VoiceHub平台的账号不互通</p>
<div style="margin-top: 16px;">
<NuxtLink to="/register" class="register-link-btn">
还没有账号?去注册
</NuxtLink>
</div>
</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'
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;
}
.forgot-password-row {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.forgot-password-link {
font-size: 13px;
color: var(--primary);
text-decoration: none;
font-weight: var(--font-medium);
transition: opacity 0.2s ease;
}
.forgot-password-link:hover {
opacity: 0.8;
text-decoration: underline;
}
.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;
}
.register-link-btn {
display: inline-block;
width: 100%;
padding: 14px;
border-radius: var(--radius-lg);
font-size: 16px;
font-weight: var(--font-semibold);
color: var(--primary);
background-color: var(--primary-opacity-10, rgba(59, 130, 246, 0.1));
text-decoration: none;
transition: all 0.2s ease;
}
.register-link-btn:hover {
background-color: var(--primary-opacity-20, rgba(59, 130, 246, 0.2));
transform: translateY(-1px);
}
.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>