537 lines
12 KiB
Vue
537 lines
12 KiB
Vue
<template>
|
||
<div class="login-page">
|
||
<div class="glow glow-top" aria-hidden="true" />
|
||
<div class="glow glow-bottom" aria-hidden="true" />
|
||
|
||
<div class="login-content">
|
||
<div class="brand">
|
||
<div class="brand-logo" aria-hidden="true">
|
||
<svg width="88" height="88" viewBox="0 0 88 88" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<linearGradient id="brand-grad" x1="0" y1="0" x2="88" y2="88" gradientUnits="userSpaceOnUse">
|
||
<stop stop-color="#FFB36B" />
|
||
<stop offset="1" stop-color="#FF7A1A" />
|
||
</linearGradient>
|
||
</defs>
|
||
<circle cx="44" cy="44" r="42" fill="url(#brand-grad)" />
|
||
<circle cx="44" cy="44" r="42" fill="none" stroke="#FFFFFF" stroke-opacity="0.5" stroke-width="2" />
|
||
<rect x="26" y="30" width="36" height="32" rx="10" fill="#FFFFFF" />
|
||
<circle cx="37" cy="46" r="3.5" fill="#FF7A1A" />
|
||
<circle cx="51" cy="46" r="3.5" fill="#FF7A1A" />
|
||
<rect x="36" y="53" width="16" height="3.5" rx="1.75" fill="#FF7A1A" />
|
||
<rect x="42" y="18" width="4" height="10" rx="2" fill="#FFFFFF" />
|
||
<circle cx="44" cy="16" r="4" fill="#FFFFFF" />
|
||
</svg>
|
||
</div>
|
||
<h1 class="brand-title">会会数字分身</h1>
|
||
<p class="brand-sub">你的专属 AI 数字分身</p>
|
||
</div>
|
||
|
||
<!-- 登录方式切换:默认账号密码 -->
|
||
<div class="tabs" role="tablist">
|
||
<button
|
||
class="tab"
|
||
:class="{ active: activeTab === 'password' }"
|
||
role="tab"
|
||
:aria-selected="activeTab === 'password'"
|
||
@click="activeTab = 'password'"
|
||
>
|
||
密码登录
|
||
</button>
|
||
<button
|
||
class="tab"
|
||
:class="{ active: activeTab === 'sms' }"
|
||
role="tab"
|
||
:aria-selected="activeTab === 'sms'"
|
||
@click="activeTab = 'sms'"
|
||
>
|
||
短信登录
|
||
</button>
|
||
<span class="tab-indicator" :class="activeTab" aria-hidden="true" />
|
||
</div>
|
||
|
||
<!-- 账号密码登录 -->
|
||
<form v-if="activeTab === 'password'" class="form" @submit.prevent="onPwdLogin" novalidate>
|
||
<div class="input-card">
|
||
<input
|
||
v-model="account"
|
||
class="input"
|
||
type="text"
|
||
autocomplete="username"
|
||
placeholder="手机号 / 会会账号"
|
||
aria-label="账号"
|
||
@input="errorMsg = ''"
|
||
/>
|
||
</div>
|
||
<div class="input-card">
|
||
<input
|
||
v-model="password"
|
||
class="input"
|
||
type="password"
|
||
autocomplete="current-password"
|
||
placeholder="请输入密码"
|
||
aria-label="密码"
|
||
@input="errorMsg = ''"
|
||
/>
|
||
</div>
|
||
|
||
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
|
||
|
||
<button type="submit" class="login-btn" :disabled="!canPwdLogin || loading">
|
||
{{ loading ? '登录中...' : '登录 / 创建分身' }}
|
||
</button>
|
||
</form>
|
||
|
||
<!-- 短信验证码登录 -->
|
||
<form v-else class="form" @submit.prevent="onSmsLogin" novalidate>
|
||
<div class="input-card">
|
||
<span class="prefix" aria-hidden="true">+86</span>
|
||
<span class="divider" aria-hidden="true" />
|
||
<input
|
||
v-model="phone"
|
||
class="input"
|
||
type="tel"
|
||
inputmode="numeric"
|
||
maxlength="11"
|
||
placeholder="请输入手机号"
|
||
aria-label="手机号"
|
||
@input="onPhoneInput"
|
||
/>
|
||
</div>
|
||
|
||
<div class="input-card code-card">
|
||
<input
|
||
v-model="code"
|
||
class="input"
|
||
type="tel"
|
||
inputmode="numeric"
|
||
maxlength="6"
|
||
placeholder="请输入验证码"
|
||
aria-label="验证码"
|
||
@input="onCodeInput"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="code-btn"
|
||
:disabled="!canSend || counting"
|
||
@click="onSendCode"
|
||
>
|
||
{{ counting ? `${countdown}s 后重发` : '获取验证码' }}
|
||
</button>
|
||
</div>
|
||
|
||
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
|
||
|
||
<button type="submit" class="login-btn" :disabled="!canLogin || loading">
|
||
{{ loading ? '登录中...' : '登录 / 创建分身' }}
|
||
</button>
|
||
</form>
|
||
|
||
<div class="footer">
|
||
<p class="footer-hint">
|
||
{{ activeTab === 'password' ? '使用会会账号密码登录,首次登录将自动创建分身' : '未注册的手机号验证后将自动创建分身账号' }}
|
||
</p>
|
||
<p class="footer-agree">登录即代表同意《用户协议》与《隐私政策》</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { reactive, ref, computed, onUnmounted } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { useUserStore } from '@/store/user'
|
||
import { useAvatarStore } from '@/store/avatar'
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const userStore = useUserStore()
|
||
const avatarStore = useAvatarStore()
|
||
|
||
const activeTab = ref<'password' | 'sms'>('password')
|
||
|
||
// 短信登录
|
||
const phone = ref('')
|
||
const code = ref('')
|
||
|
||
// 密码登录
|
||
const account = ref('')
|
||
const password = ref('')
|
||
|
||
const loading = ref(false)
|
||
const errorMsg = ref('')
|
||
|
||
const counting = ref(false)
|
||
const countdown = ref(60)
|
||
let timer: any = null
|
||
|
||
const canSend = computed(() => /^1\d{10}$/.test(phone.value))
|
||
const canLogin = computed(() => canSend.value && /^\d{4,6}$/.test(code.value))
|
||
const canPwdLogin = computed(() => account.value.trim().length > 0 && password.value.length > 0)
|
||
|
||
const onPhoneInput = () => {
|
||
phone.value = phone.value.replace(/\D/g, '').slice(0, 11)
|
||
errorMsg.value = ''
|
||
}
|
||
const onCodeInput = () => {
|
||
code.value = code.value.replace(/\D/g, '').slice(0, 6)
|
||
errorMsg.value = ''
|
||
}
|
||
|
||
const startCountdown = () => {
|
||
counting.value = true
|
||
countdown.value = 60
|
||
timer = setInterval(() => {
|
||
countdown.value--
|
||
if (countdown.value <= 0) {
|
||
clearInterval(timer)
|
||
counting.value = false
|
||
}
|
||
}, 1000)
|
||
}
|
||
|
||
const onSendCode = async () => {
|
||
if (!canSend.value || counting.value) return
|
||
errorMsg.value = ''
|
||
try {
|
||
await userStore.sendCode(phone.value)
|
||
startCountdown()
|
||
} catch (e: any) {
|
||
errorMsg.value = e?.message || '验证码发送失败,请稍后重试'
|
||
}
|
||
}
|
||
|
||
const afterLogin = (res: any) => {
|
||
avatarStore.setNativeProfile({
|
||
userId: res.huihui?.userId || res.user?.huihuiUserId || '',
|
||
nickname: res.huihui?.nickname || res.user?.nickname || '',
|
||
avatarUrl: res.huihui?.avatarUrl || res.user?.avatarUrl || ''
|
||
})
|
||
const redirect = (route.query.redirect as string) || '/'
|
||
router.replace(redirect)
|
||
}
|
||
|
||
const onSmsLogin = async () => {
|
||
if (!canLogin.value || loading.value) return
|
||
loading.value = true
|
||
errorMsg.value = ''
|
||
try {
|
||
const res: any = await userStore.login(phone.value, code.value)
|
||
afterLogin(res)
|
||
} catch (e: any) {
|
||
errorMsg.value = e?.message || '登录失败,请检查验证码'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const onPwdLogin = async () => {
|
||
if (!canPwdLogin.value || loading.value) return
|
||
loading.value = true
|
||
errorMsg.value = ''
|
||
try {
|
||
const res: any = await userStore.loginByPwd(account.value.trim(), password.value)
|
||
afterLogin(res)
|
||
} catch (e: any) {
|
||
errorMsg.value = e?.message || '登录失败,请检查账号或密码'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onUnmounted(() => {
|
||
if (timer) clearInterval(timer)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.login-page {
|
||
position: relative;
|
||
min-height: 100vh;
|
||
min-height: 100dvh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
padding-top: calc(20px + env(safe-area-inset-top));
|
||
padding-bottom: calc(20px + env(safe-area-inset-bottom));
|
||
overflow: hidden;
|
||
background: #fff6ec;
|
||
animation: fadeIn 0.45s cubic-bezier(0.16, 1, 0.3, 1);
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
.glow {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
pointer-events: none;
|
||
filter: blur(50px);
|
||
z-index: 0;
|
||
}
|
||
|
||
.glow-top {
|
||
width: 280px;
|
||
height: 280px;
|
||
top: -90px;
|
||
right: -40px;
|
||
background: rgba(255, 179, 107, 0.5);
|
||
}
|
||
|
||
.glow-bottom {
|
||
width: 320px;
|
||
height: 320px;
|
||
bottom: -120px;
|
||
left: -110px;
|
||
background: rgba(255, 205, 166, 0.55);
|
||
}
|
||
|
||
.login-content {
|
||
position: relative;
|
||
width: 100%;
|
||
max-width: 400px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
z-index: 1;
|
||
}
|
||
|
||
.brand {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 14px;
|
||
text-align: center;
|
||
}
|
||
|
||
.brand-logo {
|
||
width: 88px;
|
||
height: 88px;
|
||
filter: drop-shadow(0 12px 24px rgba(255, 122, 26, 0.3));
|
||
}
|
||
|
||
.brand-title {
|
||
margin: 0;
|
||
font-size: 26px;
|
||
font-weight: 700;
|
||
color: #4a2511;
|
||
letter-spacing: -0.5px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.brand-sub {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
font-weight: 400;
|
||
color: #a9744f;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
/* ── 分段切换 ── */
|
||
.tabs {
|
||
position: relative;
|
||
display: flex;
|
||
padding: 5px;
|
||
border-radius: 14px;
|
||
background: rgba(255, 255, 255, 0.5);
|
||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||
box-shadow: inset 0 1px 2px rgba(74, 37, 17, 0.05);
|
||
-webkit-backdrop-filter: blur(12px);
|
||
backdrop-filter: blur(12px);
|
||
}
|
||
|
||
.tab {
|
||
flex: 1;
|
||
position: relative;
|
||
z-index: 1;
|
||
height: 40px;
|
||
border: none;
|
||
background: transparent;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #a9744f;
|
||
cursor: pointer;
|
||
border-radius: 10px;
|
||
transition: color 0.25s;
|
||
}
|
||
|
||
.tab.active {
|
||
color: #4a2511;
|
||
}
|
||
|
||
.tab-indicator {
|
||
position: absolute;
|
||
top: 5px;
|
||
bottom: 5px;
|
||
width: calc(50% - 5px);
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
box-shadow: 0 4px 12px rgba(74, 37, 17, 0.1);
|
||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||
z-index: 0;
|
||
}
|
||
|
||
.tab-indicator.password {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.tab-indicator.sms {
|
||
transform: translateX(100%);
|
||
}
|
||
|
||
/* ── 表单 ── */
|
||
.form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
animation: formIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||
}
|
||
|
||
@keyframes formIn {
|
||
from { opacity: 0; transform: translateY(6px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
|
||
.input-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
height: 56px;
|
||
padding: 0 18px;
|
||
border-radius: 16px;
|
||
background: rgba(255, 255, 255, 0.55);
|
||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||
box-shadow:
|
||
0 8px 24px rgba(74, 37, 17, 0.06),
|
||
inset 0 1px 1px rgba(255, 255, 255, 0.8);
|
||
-webkit-backdrop-filter: blur(16px);
|
||
backdrop-filter: blur(16px);
|
||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
||
}
|
||
|
||
.input-card:focus-within {
|
||
border-color: rgba(255, 140, 66, 0.65);
|
||
box-shadow:
|
||
0 8px 24px rgba(74, 37, 17, 0.08),
|
||
0 0 0 3px rgba(255, 140, 66, 0.15),
|
||
inset 0 1px 1px rgba(255, 255, 255, 0.9);
|
||
}
|
||
|
||
.prefix {
|
||
flex-shrink: 0;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #4a2511;
|
||
}
|
||
|
||
.divider {
|
||
flex-shrink: 0;
|
||
width: 1px;
|
||
height: 20px;
|
||
background: rgba(74, 37, 17, 0.15);
|
||
}
|
||
|
||
.input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: none;
|
||
outline: none;
|
||
background: transparent;
|
||
padding: 0;
|
||
font-size: 16px;
|
||
line-height: 1.4;
|
||
color: #4a2511;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.input::placeholder {
|
||
color: #a9744f;
|
||
}
|
||
|
||
.code-card {
|
||
gap: 10px;
|
||
}
|
||
|
||
.code-btn {
|
||
flex-shrink: 0;
|
||
height: 38px;
|
||
padding: 0 14px;
|
||
border: none;
|
||
border-radius: 12px;
|
||
background: #ff8c42;
|
||
color: #fff;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: opacity 0.2s, transform 0.15s;
|
||
}
|
||
|
||
.code-btn:active {
|
||
transform: scale(0.97);
|
||
}
|
||
|
||
.code-btn:disabled {
|
||
background: #f3d2c3;
|
||
color: #fff;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.error {
|
||
margin: -4px 0 0;
|
||
font-size: 13px;
|
||
color: #ef4444;
|
||
text-align: center;
|
||
}
|
||
|
||
.login-btn {
|
||
width: 100%;
|
||
height: 54px;
|
||
margin-top: 4px;
|
||
border: none;
|
||
border-radius: 16px;
|
||
background: linear-gradient(135deg, #ff8c42 0%, #f96e1c 100%);
|
||
color: #fff;
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
box-shadow: 0 6px 20px rgba(255, 140, 66, 0.45);
|
||
transition: transform 0.15s, opacity 0.2s, box-shadow 0.2s;
|
||
}
|
||
|
||
.login-btn:active {
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
.login-btn:disabled {
|
||
background: #e5e7eb;
|
||
color: #fff;
|
||
box-shadow: none;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.footer {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
text-align: center;
|
||
}
|
||
|
||
.footer-hint {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
color: #a9744f;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.footer-agree {
|
||
margin: 0;
|
||
font-size: 11px;
|
||
color: #c9a88e;
|
||
line-height: 1.5;
|
||
}
|
||
</style>
|