Files
huihuiSquare/digital-avatar-app/src/views/SmsLogin.vue
2026-07-24 14:04:21 +08:00

537 lines
12 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-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>