登陆页左侧新广告特效
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -8,55 +8,169 @@
|
||||
background: #F7FEFC;
|
||||
}
|
||||
|
||||
/* ==================== 左侧品牌面板 ==================== */
|
||||
.login-view__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* ==================== 左侧视觉动画面板 ==================== */
|
||||
.login-visual {
|
||||
position: relative;
|
||||
width: 57%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, #0A9E97 0%, #12C7BE 50%, #06B6D4 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #ddf4f2;
|
||||
}
|
||||
|
||||
/* 装饰圆圈 */
|
||||
.login-view__deco-circle {
|
||||
border-radius: 50%;
|
||||
.login-jianshi-logo {
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
bottom: 10px;
|
||||
width: 82px;
|
||||
height: 21px;
|
||||
object-fit: contain;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.login-view__deco-circle--lg {
|
||||
width: 4.8rem;
|
||||
height: 4.8rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
.ai-hub-stage {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1100px;
|
||||
height: 900px;
|
||||
overflow: hidden;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1405px 1269px;
|
||||
background-position: -210px -117px;
|
||||
z-index: 1;
|
||||
transform-origin: top left;
|
||||
transform: translate3d(var(--stage-x, 0px), var(--stage-y, 0px), 0) scale(var(--stage-scale, 1));
|
||||
}
|
||||
|
||||
.login-view__deco-circle--sm {
|
||||
width: 3.2rem;
|
||||
height: 3.2rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
/* 标题 */
|
||||
.hero-title {
|
||||
position: absolute;
|
||||
left: 35px;
|
||||
top: 64px;
|
||||
width: 374px;
|
||||
height: 88px;
|
||||
color: #2a8186;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
line-height: 44px;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 左侧中心内容 */
|
||||
.login-view__left-content {
|
||||
.hero-title__second-line {
|
||||
display: block;
|
||||
padding-left: 72px;
|
||||
}
|
||||
|
||||
/* Nova 核心机器人 */
|
||||
.nova-core {
|
||||
position: absolute;
|
||||
left: 183px;
|
||||
top: 259px;
|
||||
width: 592px;
|
||||
height: 443px;
|
||||
pointer-events: auto;
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nova-core-base {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nova-eye {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
will-change: transform;
|
||||
transition: transform .05s linear;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.nova-eye-left {
|
||||
left: 253.77px;
|
||||
top: 177px;
|
||||
}
|
||||
|
||||
.nova-eye-right {
|
||||
left: 310.77px;
|
||||
top: 177px;
|
||||
}
|
||||
|
||||
.nova-eye.is-blinking {
|
||||
animation: nova-eye-blink .2s ease-in-out;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
@keyframes nova-eye-blink {
|
||||
0%, 100% { scale: 1 1; }
|
||||
40%, 60% { scale: 1 .06; }
|
||||
}
|
||||
|
||||
/* 浮动节点 */
|
||||
.node {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
left: var(--x);
|
||||
top: var(--y);
|
||||
width: var(--w);
|
||||
height: var(--h);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
object-fit: contain;
|
||||
will-change: opacity, transform, filter;
|
||||
transform: translate3d(0, 4px, 0) scale(.996);
|
||||
filter: drop-shadow(0 16px 28px rgba(56, 255, 202, .10)) brightness(1);
|
||||
}
|
||||
|
||||
.node.is-running {
|
||||
visibility: visible;
|
||||
animation: node-life var(--life-ms, 6700ms) linear forwards;
|
||||
}
|
||||
|
||||
@keyframes node-life {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 4px, 0) scale(.996);
|
||||
filter: drop-shadow(0 16px 28px rgba(56, 255, 202, .10)) brightness(1);
|
||||
}
|
||||
20.896%, 79.104% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
filter: drop-shadow(0 16px 28px rgba(56, 255, 202, .20)) brightness(1.045);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -4px, 0) scale(.996);
|
||||
filter: drop-shadow(0 16px 28px rgba(56, 255, 202, .10)) brightness(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 右侧表单面板 ==================== */
|
||||
.login-view__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.32rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 43%;
|
||||
height: 100%;
|
||||
background: #F6FDFB;
|
||||
}
|
||||
|
||||
/* Logo 行 */
|
||||
/* Logo 行(右侧及简历阶段通用) */
|
||||
.login-view__logo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -99,56 +213,6 @@
|
||||
color: #351616;
|
||||
}
|
||||
|
||||
/* 标语 */
|
||||
.login-view__slogan {
|
||||
font-weight: 700;
|
||||
font-size: 0.4rem;
|
||||
line-height: 0.56rem;
|
||||
text-align: center;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.login-view__sub-slogan {
|
||||
font-weight: 400;
|
||||
font-size: 0.16rem;
|
||||
line-height: 0.19rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* 特性药丸标签 */
|
||||
.login-view__pills {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 0.24rem;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.login-view__pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.12rem 0.2rem;
|
||||
gap: 0.08rem;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.24rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.14rem;
|
||||
line-height: 0.17rem;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* ==================== 右侧表单面板 ==================== */
|
||||
.login-view__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 43%;
|
||||
height: 100%;
|
||||
background: #F6FDFB;
|
||||
}
|
||||
|
||||
.login-view__logo-row--right {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
@@ -446,6 +510,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 简历上传阶段 — 左侧基础(step 3 使用) ==================== */
|
||||
.login-view__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 57%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, #0A9E97 0%, #12C7BE 50%, #06B6D4 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ==================== 简历上传阶段 — 左侧职位卡片面板 ==================== */
|
||||
.login-view__left--resume {
|
||||
position: relative;
|
||||
|
||||
@@ -4,24 +4,24 @@
|
||||
|
||||
<!-- ==================== 登录阶段(step 1 & 2)==================== -->
|
||||
<template v-if="step === 1 || step === 2">
|
||||
<!-- 左侧品牌面板 -->
|
||||
<div class="login-view__left">
|
||||
<div class="login-view__deco-circle login-view__deco-circle--lg"></div>
|
||||
<div class="login-view__left-content">
|
||||
<div class="login-view__logo-row">
|
||||
<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>
|
||||
<!-- 左侧视觉动画面板 -->
|
||||
<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>
|
||||
<h1 class="login-view__slogan">在Offer派<br/>找到你的理想工作</h1>
|
||||
<p class="login-view__sub-slogan">AI 驱动的求职平台,为你匹配最合适的岗位</p>
|
||||
<div class="login-view__pills">
|
||||
<div class="login-view__pill">✦ AI 智能岗位匹配</div>
|
||||
<div class="login-view__pill">◱ 一键生成专属简历</div>
|
||||
<div class="login-view__pill">○ 模拟面试精准备考</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>
|
||||
<div class="login-view__deco-circle login-view__deco-circle--sm"></div>
|
||||
</div>
|
||||
<!-- 底部 logo -->
|
||||
<img class="login-jianshi-logo" :src="jianshiLogoSrc" alt="" aria-hidden="true" draggable="false" />
|
||||
</section>
|
||||
|
||||
<!-- 右侧表单面板 -->
|
||||
<div class="login-view__right">
|
||||
@@ -160,7 +160,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { sendSmsCode, smsLogin } from '@/api/auth'
|
||||
@@ -168,10 +168,84 @@ 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)
|
||||
@@ -462,5 +536,231 @@ function stopParsingAnimation() {
|
||||
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>
|
||||
|
||||