Files
offerpai_web/src/views/Login.vue
T
2026-06-28 14:53:37 +08:00

767 lines
31 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-view">
<!-- ==================== 登录阶段step 1 & 2==================== -->
<template v-if="step === 1 || step === 2">
<!-- 左侧视觉动画面板 -->
<section class="login-visual" ref="loginVisualRef" aria-label="OfferPai AI job platform">
<div class="ai-hub-stage" ref="stageRef">
<!-- 标题 -->
<div class="hero-title" aria-hidden="true">
AI 驱动的-
<span class="hero-title__second-line">一站式求职辅助平台</span>
</div>
<!-- Nova 机器人核心眼睛跟踪鼠标 -->
<div class="nova-core" ref="novaCoreRef" aria-hidden="true">
<img class="nova-core-base" :src="novaCoreSrc" alt="" draggable="false" />
<img class="nova-eye nova-eye-left" ref="novaEyeLeftRef" :src="novaEyeLeftSrc" alt="" draggable="false" />
<img class="nova-eye nova-eye-right" ref="novaEyeRightRef" :src="novaEyeRightSrc" alt="" draggable="false" />
</div>
</div>
<!-- 底部 logo -->
<img class="login-jianshi-logo" :src="jianshiLogoSrc" alt="" aria-hidden="true" draggable="false" />
</section>
<!-- 右侧表单面板 -->
<div class="login-view__right">
<div class="login-view__logo-row login-view__logo-row--right">
<div class="login-view__logo-box login-view__logo-box--green">O</div>
<span class="login-view__logo-text login-view__logo-text--dark">Offer派</span>
</div>
<div class="login-view__form-wrap">
<!-- 步骤一输入手机号发送验证码 -->
<template v-if="step === 1">
<div class="login-view__heading">
<h2 class="login-view__title">手机号登录/注册</h2>
<p class="login-view__subtitle">未注册用户验证后将自动注册并登录</p>
</div>
<div class="login-view__phone-row">
<div class="login-view__country-code">
<span class="login-view__flag">🇨🇳</span>
<span class="login-view__code-text">+86</span>
<span class="login-view__code-arrow"></span>
</div>
<div class="login-view__separator"></div>
<input v-model="phone" type="tel" class="login-view__phone-input" placeholder="请输入手机号码" maxlength="11" />
</div>
<button class="login-view__send-btn" :disabled="!canSend" @click="handleSendCode">发送验证码</button>
</template>
<!-- 步骤二输入验证码登录 -->
<template v-if="step === 2">
<div class="login-view__heading">
<h2 class="login-view__title">请输入验证码</h2>
<p class="login-view__subtitle">短信验证码已发送至 +86{{ phone }} <span class="fw600 ml5 cursor-po" @click="handleChangePhone">修改手机号</span></p>
</div>
<div class="login-view__otp-wrap" @click="focusOtpInput">
<input ref="otpInputRef" v-model="otpValue" type="text" inputmode="numeric" class="login-view__otp-hidden-input" maxlength="6" :disabled="isLoggingIn" @input="handleOtpInput" />
<div v-for="(_, idx) in 6" :key="idx" class="login-view__otp-box" :class="{ 'login-view__otp-box--active': idx === otpValue.length && !isLoggingIn, 'login-view__otp-box--filled': idx < otpValue.length }">
<span v-if="idx < otpValue.length" class="login-view__otp-digit">{{ otpValue[idx] }}</span>
<span v-else-if="idx === otpValue.length && !isLoggingIn" class="login-view__otp-cursor"></span>
</div>
</div>
<p v-if="smsStatusMsg" class="login-view__sms-status" :class="{ 'login-view__sms-status--error': smsStatusIsError }">{{ smsStatusMsg }}</p>
<button class="login-view__status-btn" :disabled="isLoggingIn || countdown > 0" @click="handleResendCode">
<template v-if="isLoggingIn"><span class="login-view__spinner"></span><span>登录中</span></template>
<template v-else-if="countdown > 0">{{ countdown }} 秒后可继续发送</template>
<template v-else>再次发送验证码</template>
</button>
</template>
<!-- 协议勾选步骤一二通用 -->
<div class="login-view__agreement">
<div class="login-view__checkbox" :class="{ 'login-view__checkbox--checked': agreedTerms }" @click="agreedTerms = !agreedTerms">
<span v-if="agreedTerms" class="login-view__check-icon"></span>
</div>
<span class="login-view__agreement-text">登录即表示同意</span>
<span class="login-view__agreement-link" @click="openAgreement('ae8065i3')">用户协议</span>
<span class="login-view__agreement-text"></span>
<span class="login-view__agreement-link" @click="openAgreement('hf8375i8')">隐私政策</span>
</div>
</div>
</div>
</template>
<!-- ==================== 简历上传阶段step 3==================== -->
<template v-if="step === 3">
<!-- 左侧品牌面板 职位卡片风格 -->
<div class="login-view__left login-view__left--resume">
<!-- 顶部 Logo -->
<div class="login-view__resume-logo">
<div class="login-view__logo-box login-view__logo-box--light">O</div>
<span class="login-view__logo-text login-view__logo-text--white">Offer派</span>
</div>
<!-- 浮动职位卡片 -->
<div class="login-view__job-cards">
<div v-for="(job, idx) in jobCards" :key="idx" class="login-view__job-card" :style="job.style">
<span class="login-view__job-card-icon" :style="{ background: job.iconBg, color: job.iconColor }">{{ job.icon }}</span>
<div class="login-view__job-card-info">
<span class="login-view__job-card-title">{{ job.title }}</span>
<span class="login-view__job-card-company">{{ job.company }}</span>
</div>
<span class="login-view__job-card-salary">{{ job.salary }}</span>
</div>
</div>
<!-- 标语 -->
<div class="login-view__resume-slogan">
<h1>校招批量海投</h1>
<h1>年轻人有更多的面试机会</h1>
</div>
</div>
<!-- 右侧简历上传面板 -->
<div class="login-view__right">
<div class="login-view__form-wrap login-view__form-wrap--resume">
<!-- 状态1上传区域 -->
<template v-if="resumeState === 'upload'">
<div class="login-view__heading">
<h2 class="login-view__title">一切美好从简历开始~</h2>
<p class="login-view__subtitle">自动解析您的简历为你匹配最合适的岗位</p>
</div>
<!-- 拖拽上传区域 -->
<div class="login-view__upload-area" @dragover.prevent @drop.prevent="handleDrop" @click="triggerFileInput">
<div class="login-view__upload-circle"></div>
<p class="login-view__upload-text">拖拽简历到这里或点击上传</p>
<p class="login-view__upload-hint">支持 PDF / DOC / DOCX不超过 20MB</p>
</div>
<!-- 隐藏的文件选择器 -->
<input ref="fileInputRef" type="file" accept=".pdf,.doc,.docx" class="login-view__file-input" @change="handleFileChange" />
<!-- 上传按钮 -->
<button class="login-view__upload-btn" @click="triggerFileInput">+ 上传简历</button>
</template>
<!-- 状态2解析中 -->
<template v-if="resumeState === 'parsing'">
<div class="login-view__parsing">
<h2 class="login-view__parsing-title">{{ parsingText }}</h2>
<p class="login-view__parsing-subtitle">自动解析您的简历为你匹配最合适的岗位</p>
</div>
</template>
<!-- 状态3上传失败 -->
<template v-if="resumeState === 'failed'">
<div class="login-view__heading">
<h2 class="login-view__title login-view__title--error">上传失败</h2>
<p class="login-view__subtitle">您的简历解析失败了请重试一次吧~</p>
</div>
<button class="login-view__upload-btn" @click="handleRetryUpload">重新上传</button>
</template>
</div>
</div>
</template>
<!-- 协议预览弹窗 -->
<AgreementPreviewDialog v-model="showAgreementDialog" :code="agreementCode" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useStore } from 'vuex'
import { useRouter, useRoute } from 'vue-router'
import { sendSmsCode, smsLogin } from '@/api/auth'
import { fetchProfile, syncProfileFromResume } from '@/api/profile'
import { uploadResume } from '@/utils/aiRequest'
import AgreementPreviewDialog from '@/components/tools/AgreementPreviewDialog.vue'
// 左侧动画面板图片资源
import novaCoreSrc from '@/assets/images/loging_nodes/nova_core_not_eyes.png'
import novaEyeLeftSrc from '@/assets/images/loging_nodes/nova_eye_left.png'
import novaEyeRightSrc from '@/assets/images/loging_nodes/nova_eye_right.png'
import jianshiLogoSrc from '@/assets/images/loging_nodes/logo_jianshixingqiu.png'
import bgSrc from '@/assets/images/loging_nodes/ai_hub_nodes_bj_figma.png'
// 节点批次图片
import node01 from '@/assets/images/loging_nodes/01_jobs_total_board.png'
import node02 from '@/assets/images/loging_nodes/02_jobs_reference_deck.png'
import node03 from '@/assets/images/loging_nodes/03_jobs_match_radar.png'
import node04 from '@/assets/images/loging_nodes/04_jobs_role_chips.png'
import node05 from '@/assets/images/loging_nodes/05_jobs_daily_update.png'
import node06 from '@/assets/images/loging_nodes/06_delivery_flow_status.png'
import node07 from '@/assets/images/loging_nodes/07_delivery_page_detect.png'
import node08 from '@/assets/images/loging_nodes/08_delivery_task_queue.png'
import node09 from '@/assets/images/loging_nodes/09_delivery_submit_check.png'
import node10 from '@/assets/images/loging_nodes/10_delivery_plugin_online.png'
import node11 from '@/assets/images/loging_nodes/11_mentor_shadow_panel.png'
import node12 from '@/assets/images/loging_nodes/12_mentor_resource_pool.png'
import node13 from '@/assets/images/loging_nodes/13_mentor_field_bars.png'
import node14 from '@/assets/images/loging_nodes/14_mentor_booking_slots.png'
import node15 from '@/assets/images/loging_nodes/15_mentor_review_chip.png'
const store = useStore()
const router = useRouter()
const route = useRoute()
// ==================== 左侧动画面板 ====================
/** 舞台引用 */
const stageRef = ref<HTMLDivElement | null>(null)
/** 视觉区域引用 */
const loginVisualRef = ref<HTMLElement | null>(null)
/** Nova 核心引用 */
const novaCoreRef = ref<HTMLDivElement | null>(null)
/** 左眼引用 */
const novaEyeLeftRef = ref<HTMLImageElement | null>(null)
/** 右眼引用 */
const novaEyeRightRef = ref<HTMLImageElement | null>(null)
/** 动画时间配置 */
const animTiming = { fadeMs: 1400, holdMs: 3900, stepMs: 5300, lifeMs: 6700 }
/** 波次定时器 */
let waveTimer: ReturnType<typeof setTimeout> | null = null
/** ResizeObserver 实例 */
let resizeObs: ResizeObserver | null = null
/** 眨眼定时器 */
let blinkTimer: ReturnType<typeof setTimeout> | null = null
/** 空闲注视定时器 */
let idleLookTimer: ReturnType<typeof setTimeout> | null = null
/** 指针事件引用(用于卸载时清理) */
let handlePointerMove: (e: PointerEvent) => void = () => {}
let handlePointerLeave: () => void = () => {}
/** 节点批次数据 */
const nodeBatches = [
[
{ src: node01, x: 53, y: 358, w: 226, h: 134 },
{ src: node02, x: 418, y: 182, w: 236, h: 128 },
{ src: node03, x: 696, y: 397, w: 208, h: 136 },
{ src: node04, x: 263, y: 700, w: 264, h: 116 },
{ src: node05, x: 630, y: 655, w: 214, h: 76 }
],
[
{ src: node06, x: 344, y: 168, w: 306, h: 126 },
{ src: node07, x: 39, y: 478, w: 226, h: 136 },
{ src: node08, x: 618, y: 638, w: 214, h: 166 },
{ src: node09, x: 274, y: 720, w: 240, h: 122 },
{ src: node10, x: 697, y: 443, w: 230, h: 76 }
],
[
{ src: node11, x: 372, y: 191, w: 232, h: 124 },
{ src: node12, x: 45, y: 395, w: 244, h: 140 },
{ src: node13, x: 688, y: 370, w: 214, h: 134 },
{ src: node14, x: 400, y: 707, w: 256, h: 124 },
{ src: node15, x: 688, y: 581, w: 222, h: 84 }
]
]
// ==================== 页面步骤控制 ====================
/** 当前步骤:1=输入手机号 2=输入验证码 3=上传简历 */
const step = ref(1)
// ==================== 登录相关状态 ====================
/** 手机号 */
const phone = ref('')
/** 验证码6位值 */
const otpValue = ref('')
/** 是否已勾选协议 */
const agreedTerms = ref(false)
/** 倒计时秒数 */
const countdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
/** 是否正在调用登录接口 */
const isLoggingIn = ref(false)
/** 验证码发送状态提示文字 */
const smsStatusMsg = ref('')
/** 状态提示是否为错误样式 */
const smsStatusIsError = ref(false)
/** OTP 输入框引用 */
const otpInputRef = ref<HTMLInputElement | null>(null)
/** 协议弹窗状态 */
const showAgreementDialog = ref(false)
const agreementCode = ref('')
/** 登录成功后的跳转路径(缓存) */
let loginRedirect = '/'
/** 发送按钮是否可用 */
const canSend = computed(() => phone.value.length === 11 && agreedTerms.value)
// ==================== 简历上传相关状态 ====================
/** 简历上传子状态:upload=上传区域 parsing=解析中 failed=失败 */
const resumeState = ref<'upload' | 'parsing' | 'failed'>('upload')
/** 解析中显示的文字 */
const parsingText = ref('正在解析个人资料...')
/** 解析文字轮换定时器 */
let parsingTimer: ReturnType<typeof setInterval> | null = null
/** 文件选择器引用 */
const fileInputRef = ref<HTMLInputElement | null>(null)
/** 左侧职位卡片硬编码数据 */
const jobCards = [
{ icon: '网', iconBg: '#FFE8E8', iconColor: '#E85635', title: '前端工程师', company: '网易', salary: '18-28K', style: 'top: 0.6rem; left: 0.4rem;' },
{ icon: '网', iconBg: '#FFE8E8', iconColor: '#E85635', title: '运营专员', company: '阿里巴巴', salary: '15-20K', style: 'top: 0.6rem; right: 0.8rem;' },
{ icon: '百', iconBg: '#E8F0FF', iconColor: '#4A7FE5', title: '算法工程师', company: '百度', salary: '30-45K', style: 'top: 1.5rem; left: 0.2rem;' },
{ icon: '美', iconBg: '#FFF3E8', iconColor: '#E8953F', title: '商业分析实习生', company: '美团', salary: '15K', style: 'top: 1.8rem; left: 2.8rem;' },
{ icon: '小', iconBg: '#E8FFE8', iconColor: '#4CAF50', title: '硬件产品', company: '小米', salary: '18-24K', style: 'top: 2.5rem; left: 0.3rem;' },
{ icon: '网', iconBg: '#FFE8E8', iconColor: '#E85635', title: '产品运营', company: '京东', salary: '18-25K', style: 'top: 2.8rem; right: 0.6rem;' },
{ icon: '字', iconBg: '#F0E8FF', iconColor: '#7C4DFF', title: '产品经理', company: '字节跳动', salary: '25-35K', style: 'top: 3.5rem; left: 1.2rem;' },
{ icon: '增', iconBg: '#E8FFF5', iconColor: '#12C7BE', title: '增长运营', company: '拼多多', salary: '20-28K', style: 'top: 3.5rem; right: 0.4rem;' },
{ icon: '滴', iconBg: '#FFF8E1', iconColor: '#FFC107', title: '后端开发', company: '滴滴', salary: '22-32K', style: 'top: 4.3rem; left: 0.5rem;' },
{ icon: '网', iconBg: '#FFE8E8', iconColor: '#E85635', title: '数据分析师', company: '腾讯', salary: '20-30K', style: 'top: 4.6rem; right: 0.8rem;' },
]
/** 解析中文字轮换列表 */
const parsingSteps = ['正在解析个人资料...', '正在解析教育背景...', '正在解析工作经历...', '正在解析技能特长...', '简历解析成功,即将跳转']
// ==================== 登录逻辑 ====================
/** 打开协议预览 */
function openAgreement(code: string) {
agreementCode.value = code
showAgreementDialog.value = true
}
/** 聚焦OTP隐藏输入框 */
function focusOtpInput() {
otpInputRef.value?.focus()
}
/** 修改手机号 — 回到步骤一并清空所有状态 */
function handleChangePhone() {
step.value = 1
phone.value = ''
otpValue.value = ''
countdown.value = 0
smsStatusMsg.value = ''
smsStatusIsError.value = false
isLoggingIn.value = false
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
}
/** 开始60秒倒计时 */
function startCountdown() {
countdown.value = 60
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0 && countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
}, 1000)
}
/** 步骤一:发送验证码 */
async function handleSendCode() {
if (!phone.value) { ElMessage.warning('请输入手机号'); return }
if (!agreedTerms.value) { ElMessage.warning('请先同意用户协议与隐私政策'); return }
try {
const res = await sendSmsCode(phone.value)
if (res.code === '0') {
smsStatusMsg.value = '验证码发送成功,请输入'
smsStatusIsError.value = false
step.value = 2
startCountdown()
nextTick(() => focusOtpInput())
} else if (res.msg && res.msg.includes('请勿重复发送')) {
smsStatusMsg.value = '验证码还在有效期内,请输入'
smsStatusIsError.value = false
step.value = 2
startCountdown()
nextTick(() => focusOtpInput())
} else {
smsStatusMsg.value = res.msg || '验证码发送失败'
smsStatusIsError.value = true
}
} catch (err: any) {
const msg = err?.response?.data?.msg || '验证码发送失败'
smsStatusMsg.value = msg
smsStatusIsError.value = true
}
}
/** OTP输入处理 */
function handleOtpInput() {
otpValue.value = otpValue.value.replace(/\D/g, '').slice(0, 6)
if (otpValue.value.length === 6) { handleLogin() }
}
/** 步骤二:再次发送验证码 */
async function handleResendCode() {
if (isLoggingIn.value || countdown.value > 0) return
try {
const res = await sendSmsCode(phone.value)
if (res.code === '0') {
smsStatusMsg.value = '验证码发送成功,请输入'
smsStatusIsError.value = false
otpValue.value = ''
startCountdown()
nextTick(() => focusOtpInput())
} else if (res.msg && res.msg.includes('请勿重复发送')) {
smsStatusMsg.value = '验证码还在有效期内,请输入'
smsStatusIsError.value = false
} else {
smsStatusMsg.value = res.msg || '验证码发送失败'
smsStatusIsError.value = true
}
} catch (err: any) {
const msg = err?.response?.data?.msg || '验证码发送失败'
smsStatusMsg.value = msg
smsStatusIsError.value = true
}
}
/** 调用登录接口 */
async function handleLogin() {
isLoggingIn.value = true
try {
const res = await smsLogin(phone.value, otpValue.value)
if (res.code !== '0') {
ElMessage.error('登录失败请核对验证码')
otpValue.value = ''
isLoggingIn.value = false
nextTick(() => focusOtpInput())
return
}
// 登录成功
ElMessage.success(`欢迎回来,${res.data?.nick || ''}`)
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
countdown.value = 0
store.commit('SET_AUTHENTICATED', true)
// 缓存跳转路径
loginRedirect = (route.query.redirect as string) || '/'
// 检测是否需要上传简历
try {
const profileRes = await fetchProfile()
if (profileRes.code === '0' && (!profileRes.data || profileRes.data === null)) {
// 无个人资料,进入上传简历步骤
step.value = 3
resumeState.value = 'upload'
isLoggingIn.value = false
return
}
} catch {
// 接口异常不阻塞,直接跳转
}
// 有个人资料,直接跳转
router.replace(loginRedirect)
} catch {
ElMessage.error('登录失败请核对验证码')
otpValue.value = ''
isLoggingIn.value = false
nextTick(() => focusOtpInput())
}
}
// ==================== 简历上传逻辑 ====================
/** 触发文件选择 */
function triggerFileInput() {
fileInputRef.value?.click()
}
/** 文件选择器 change 事件 */
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (input.files && input.files[0]) {
startUpload(input.files[0])
}
}
/** 拖放上传 */
function handleDrop(e: DragEvent) {
const file = e.dataTransfer?.files[0]
if (file) { startUpload(file) }
}
/** 重新上传 */
function handleRetryUpload() {
resumeState.value = 'upload'
}
/** 开始上传并解析简历 */
async function startUpload(file: File) {
// 校验文件类型和大小
const validTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']
if (!validTypes.includes(file.type)) {
ElMessage.warning('请上传 PDF / DOC / DOCX 格式文件')
return
}
if (file.size > 20 * 1024 * 1024) {
ElMessage.warning('文件大小不能超过 20MB')
return
}
// 进入解析状态
resumeState.value = 'parsing'
startParsingAnimation()
try {
const res = await uploadResume(file)
if (res.code === 0 && res.data?.resumeId) {
// 上传成功,同步个人资料
await syncProfileFromResume(String(res.data.resumeId))
// 停止轮换动画,显示成功
stopParsingAnimation()
parsingText.value = '简历解析成功,即将跳转'
// 等1秒后跳转
setTimeout(() => {
router.replace(loginRedirect)
}, 1000)
} else {
// 解析失败
stopParsingAnimation()
resumeState.value = 'failed'
}
} catch {
stopParsingAnimation()
resumeState.value = 'failed'
}
}
/** 开始解析文字轮换动画 */
function startParsingAnimation() {
let index = 0
parsingText.value = parsingSteps[0]
parsingTimer = setInterval(() => {
index++
if (index < parsingSteps.length - 1) {
// 前4个每2秒切换
parsingText.value = parsingSteps[index]
} else {
// 到第4个(技能特长)后停止轮换,保持显示直到接口返回
parsingText.value = parsingSteps[parsingSteps.length - 2]
if (parsingTimer) { clearInterval(parsingTimer); parsingTimer = null }
}
}, 2000)
}
/** 停止解析文字轮换动画 */
function stopParsingAnimation() {
if (parsingTimer) { clearInterval(parsingTimer); parsingTimer = null }
}
/** 组件卸载时清理定时器 */
onBeforeUnmount(() => {
if (countdownTimer) { clearInterval(countdownTimer) }
if (parsingTimer) { clearInterval(parsingTimer) }
if (waveTimer) { clearTimeout(waveTimer); waveTimer = null }
if (blinkTimer) { clearTimeout(blinkTimer); blinkTimer = null }
if (idleLookTimer) { clearTimeout(idleLookTimer); idleLookTimer = null }
if (resizeObs) { resizeObs.disconnect(); resizeObs = null }
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerleave', handlePointerLeave)
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
// ==================== 左侧动画面板逻辑 ====================
/** 工具函数 — 随机数 */
function random(min: number, max: number) { return min + Math.random() * (max - min) }
/** 适配舞台缩放,使其充满视觉区域 */
function fitVisualStage() {
const stage = stageRef.value
const visual = loginVisualRef.value
if (!stage || !visual) return
const baseWidth = 1100
const baseHeight = 900
const rect = visual.getBoundingClientRect()
const scale = Math.min(rect.width / baseWidth, rect.height / baseHeight)
const offsetX = (rect.width - baseWidth * scale) / 2
const offsetY = (rect.height - baseHeight * scale) / 2
stage.style.setProperty('--stage-scale', scale.toFixed(5))
stage.style.setProperty('--stage-x', `${offsetX.toFixed(2)}px`)
stage.style.setProperty('--stage-y', `${offsetY.toFixed(2)}px`)
}
/** 创建一个浮动节点面板并播放动画 */
function createPanel(spec: { src: string; x: number; y: number; w: number; h: number }) {
const stage = stageRef.value
if (!stage || document.hidden) return
const img = document.createElement('img')
img.className = 'node'
img.src = spec.src
img.alt = ''
img.draggable = false
img.style.setProperty('--x', `${spec.x}px`)
img.style.setProperty('--y', `${spec.y}px`)
img.style.setProperty('--w', `${spec.w}px`)
img.style.setProperty('--h', `${spec.h}px`)
img.style.setProperty('--life-ms', `${animTiming.lifeMs}ms`)
let fallbackTimer: ReturnType<typeof setTimeout> | null = null
const removePanel = () => {
if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null }
img.remove()
}
img.addEventListener('animationend', removePanel, { once: true })
stage.appendChild(img)
fallbackTimer = setTimeout(removePanel, animTiming.lifeMs + 500)
requestAnimationFrame(() => {
if (img.isConnected && !document.hidden) img.classList.add('is-running')
})
}
/** 移除所有活动节点 */
function removeActiveNodes() {
stageRef.value?.querySelectorAll('.node').forEach(n => n.remove())
}
/** 开始批次节点循环动画 */
function startNodeBatches() {
let batchIndex = 0
function spawnAndSchedule() {
if (!document.hidden) {
nodeBatches[batchIndex].forEach(createPanel)
batchIndex = (batchIndex + 1) % nodeBatches.length
}
if (waveTimer) clearTimeout(waveTimer)
waveTimer = setTimeout(() => { waveTimer = null; if (!document.hidden) spawnAndSchedule() }, animTiming.stepMs)
}
spawnAndSchedule()
}
/** 眼睛跟踪指针逻辑 */
function setupNovaEyeTracking() {
const core = novaCoreRef.value
const leftEye = novaEyeLeftRef.value
const rightEye = novaEyeRightRef.value
if (!core || !leftEye || !rightEye) return
const config = { maxH: 0.72, maxV: 0.27, deadZone: 0.025, trackSmooth: 0.14, idleSmooth: 0.055 }
const current = { lx: 0, ly: 0, rx: 0, ry: 0 }
const target = { lx: 0, ly: 0, rx: 0, ry: 0 }
let animating = false
let pointerInside = false
let eyeSmoothing = config.idleSmooth
let lastIdleAngle: number | null = null
function calcOffset(px: number, py: number, eye: HTMLElement) {
const eyeRect = eye.getBoundingClientRect()
const coreRect = core!.getBoundingClientRect()
const dx = px - (eyeRect.left + eyeRect.width / 2)
const dy = py - (eyeRect.top + eyeRect.height / 2)
const dist = Math.hypot(dx, dy)
const refSize = Math.max(coreRect.width, coreRect.height) * 0.35
const norm = refSize > 0 ? dist / refSize : 0
if (norm < config.deadZone) return { x: 0, y: 0 }
const angle = Math.atan2(dy, dx)
const t = Math.min((norm - config.deadZone) / (1 - config.deadZone), 1)
const factor = t * t * (3 - 2 * t)
return { x: Math.cos(angle) * eye.offsetWidth * config.maxH * factor, y: Math.sin(angle) * eye.offsetHeight * config.maxV * factor }
}
function applyTransforms() {
current.lx += (target.lx - current.lx) * eyeSmoothing
current.ly += (target.ly - current.ly) * eyeSmoothing
current.rx += (target.rx - current.rx) * eyeSmoothing
current.ry += (target.ry - current.ry) * eyeSmoothing
leftEye!.style.transform = `translate3d(${current.lx.toFixed(2)}px, ${current.ly.toFixed(2)}px, 0)`
rightEye!.style.transform = `translate3d(${current.rx.toFixed(2)}px, ${current.ry.toFixed(2)}px, 0)`
const delta = Math.abs(target.lx - current.lx) + Math.abs(target.ly - current.ly) + Math.abs(target.rx - current.rx) + Math.abs(target.ry - current.ry)
if (delta > 0.01) { requestAnimationFrame(applyTransforms) } else { animating = false }
}
function setTargets(lx: number, ly: number, rx = lx, ry = ly) {
target.lx = lx; target.ly = ly; target.rx = rx; target.ry = ry
if (!animating) { animating = true; requestAnimationFrame(applyTransforms) }
}
function blink() {
leftEye!.classList.add('is-blinking')
rightEye!.classList.add('is-blinking')
setTimeout(() => { leftEye!.classList.remove('is-blinking'); rightEye!.classList.remove('is-blinking') }, 200)
}
function setIdleLook() {
eyeSmoothing = config.idleSmooth
const maxX = Math.min(leftEye!.offsetWidth, rightEye!.offsetWidth) * 0.43
const maxY = Math.min(leftEye!.offsetHeight, rightEye!.offsetHeight) * 0.18
let angle = random(0, Math.PI * 2)
if (lastIdleAngle !== null) {
for (let i = 0; i < 8; i++) {
const diff = Math.abs(Math.atan2(Math.sin(angle - lastIdleAngle), Math.cos(angle - lastIdleAngle)))
if (diff > 0.72) break
angle = random(0, Math.PI * 2)
}
}
lastIdleAngle = angle
const x = Math.cos(angle) * maxX * random(0.92, 1)
const y = Math.sin(angle) * maxY * random(0.92, 1)
setTargets(x, y)
}
function scheduleIdleLook(delay = random(900, 2300)) {
if (idleLookTimer) clearTimeout(idleLookTimer)
idleLookTimer = setTimeout(() => {
idleLookTimer = null
if (!pointerInside && !document.hidden) {
setIdleLook()
if (Math.random() < 0.34) blink()
scheduleIdleLook(random(1400, 2200))
}
}, delay)
}
function scheduleBlink() {
blinkTimer = setTimeout(() => { blink(); scheduleBlink() }, 2400 + Math.random() * 4600)
}
// 暴露给外部事件处理
handlePointerMove = (e: PointerEvent) => {
const visual = loginVisualRef.value
if (!visual) return
const rect = visual.getBoundingClientRect()
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom
if (inside) {
pointerInside = true
eyeSmoothing = config.trackSmooth
if (idleLookTimer) { clearTimeout(idleLookTimer); idleLookTimer = null }
const lo = calcOffset(e.clientX, e.clientY, leftEye!)
const ro = calcOffset(e.clientX, e.clientY, rightEye!)
setTargets(lo.x, lo.y, ro.x, ro.y)
} else if (pointerInside) {
pointerInside = false
eyeSmoothing = config.idleSmooth
setTargets(0, 0)
scheduleIdleLook(650)
}
}
handlePointerLeave = () => {
pointerInside = false
eyeSmoothing = config.idleSmooth
setTargets(0, 0)
scheduleIdleLook(650)
}
window.addEventListener('pointermove', handlePointerMove, { passive: true } as any)
window.addEventListener('pointerleave', handlePointerLeave, { passive: true } as any)
scheduleBlink()
scheduleIdleLook(850)
}
/** 页面可见性切换处理 */
function handleVisibilityChange() {
if (document.hidden) {
if (waveTimer) { clearTimeout(waveTimer); waveTimer = null }
removeActiveNodes()
} else {
removeActiveNodes()
startNodeBatches()
}
}
/** 挂载时初始化动画 */
onMounted(() => {
// 设置背景图
if (stageRef.value) {
stageRef.value.style.backgroundImage = `url(${bgSrc})`
}
fitVisualStage()
// 监听容器尺寸变化
if (loginVisualRef.value && 'ResizeObserver' in window) {
resizeObs = new ResizeObserver(fitVisualStage)
resizeObs.observe(loginVisualRef.value)
}
// 启动眼睛跟踪
setupNovaEyeTracking()
// 启动节点动画
startNodeBatches()
// 监听页面可见性
document.addEventListener('visibilitychange', handleVisibilityChange)
})
</script>