feat: complete huihui square avatar workflows

This commit is contained in:
stefanfeng
2026-07-24 14:04:21 +08:00
parent 2ef58a44b8
commit e3bda469bb
68 changed files with 8062 additions and 132 deletions

View File

@@ -0,0 +1,536 @@
<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>