959 lines
40 KiB
Vue
959 lines
40 KiB
Vue
<template>
|
||
<!-- 登录页面 — 左右分栏布局,包含登录和简历上传流程 -->
|
||
<div class="login-view">
|
||
|
||
<!-- ==================== 左侧视觉动画面板(所有阶段共用) ==================== -->
|
||
<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">
|
||
|
||
<!-- ===== 登录阶段(step 1 & 2)===== -->
|
||
<template v-if="step === 1 || step === 2">
|
||
<div class="login-view__logo-row login-view__logo-row--right">
|
||
<img src="@/assets/images/logo.png" alt="">
|
||
</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__phone-icon">
|
||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<rect x="4.5" y="2" width="9" height="14" rx="1.5" stroke="#0F6B66" stroke-width="1.5"/>
|
||
<line x1="9" y1="2" x2="9" y2="3.5" stroke="#0F6B66" stroke-width="1.5" stroke-linecap="round"/>
|
||
</svg>
|
||
</div>
|
||
<!-- 国家码标签 -->
|
||
<span class="login-view__country-label">中国 +86</span>
|
||
<!-- 下拉箭头 -->
|
||
<div class="login-view__country-chevron">
|
||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M6 8L10 12L14 8" stroke="#6F8586" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</div>
|
||
<!-- 分隔线 -->
|
||
<div class="login-view__input-divider"></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 dflex"><span class="dib">短信验证码已发送至 +86{{ phone }} </span><span class="fw600 ml5 dib cursor-po mlauto" @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"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="0.13rem" height="0.13rem"><path stroke="currentColor" fill="currentColor" d="M77.248 415.04a64 64 0 0 1 90.496 0l226.304 226.304L846.528 188.8a64 64 0 1 1 90.56 90.496l-543.04 543.04-316.8-316.8a64 64 0 0 1 0-90.496"></path></svg></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>
|
||
</template>
|
||
|
||
<!-- ===== 简历上传阶段(step 3)===== -->
|
||
<template v-if="step === 3">
|
||
<div class="login-view__form-wrap login-view__form-wrap--resume">
|
||
|
||
<!-- 状态1:上传区域 upload-->
|
||
<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">
|
||
<svg width="52" height="56" viewBox="0 0 52 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<rect x="0.5" y="0.5" width="51" height="55" rx="7.5" fill="#EAF8F7" stroke="#D6F1EF"/>
|
||
<rect x="36" width="16" height="16" rx="3" fill="#D6F1EF"/>
|
||
<rect x="13" y="21" width="26" height="4" rx="2" fill="#4FC2C9"/>
|
||
<rect x="13" y="33" width="30" height="4" rx="2" fill="#BFEAE8"/>
|
||
<rect x="13" y="45" width="20" height="4" rx="2" fill="#BFEAE8"/>
|
||
</svg>
|
||
|
||
</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>
|
||
<!-- <div class="tac"><span class="color-middle cursor-po fs14" @click="handleEnterDashboard">跳过,稍后填写</span></div>-->
|
||
</template>
|
||
|
||
<!-- 状态2:解析中 parsing -->
|
||
<template v-if="resumeState === 'parsing'">
|
||
<div class="login-view__heading m0auto">
|
||
<h2 class="login-view__title">正在解析简历</h2>
|
||
<p class="login-view__subtitle">正在识别教育背景、工作经历与技能关键词</p>
|
||
</div>
|
||
<!-- 解析面板 -->
|
||
<div class="login-view__parse-panel m0auto">
|
||
<!-- 顶部进度条 -->
|
||
<div class="login-view__parse-progress-header">
|
||
<span class="login-view__parse-progress-label">解析进度</span>
|
||
<span class="login-view__parse-progress-percent">{{ parsePercent }}%</span>
|
||
</div>
|
||
<div class="login-view__parse-progress-rail">
|
||
<div class="login-view__parse-progress-fill" :style="{ width: parsePercent + '%' }"></div>
|
||
</div>
|
||
<!-- 步骤列表 -->
|
||
<div class="login-view__parse-steps">
|
||
<div v-for="(ps, idx) in parseStepList" :key="idx" class="login-view__parse-step-item">
|
||
<!-- 步骤圆圈 + 连接线 -->
|
||
<div class="login-view__parse-step-left">
|
||
<!-- 完成状态:实心深色圆 -->
|
||
<div v-if="ps.status === 'done'" class="login-view__parse-dot login-view__parse-dot--done"></div>
|
||
<!-- 进行中:浅色光晕 + 呼吸动画深色内圆 -->
|
||
<div v-else-if="ps.status === 'active'" class="login-view__parse-dot login-view__parse-dot--active">
|
||
<div class="login-view__parse-dot-pulse"></div>
|
||
</div>
|
||
<!-- 等待状态:浅色圆 -->
|
||
<div v-else class="login-view__parse-dot login-view__parse-dot--pending"></div>
|
||
<!-- 连接线(最后一项不显示) -->
|
||
<div v-if="idx < parseStepList.length - 1" class="login-view__parse-connector"></div>
|
||
</div>
|
||
<!-- 步骤文字 -->
|
||
<span class="login-view__parse-step-text" :class="{ 'login-view__parse-step-text--pending': ps.status === 'pending' }">{{ ps.label }}</span>
|
||
<!-- 步骤状态文字 -->
|
||
<span class="login-view__parse-step-state" :class="{ 'login-view__parse-step-state--done': ps.status === 'done', 'login-view__parse-step-state--active': ps.status === 'active', 'login-view__parse-step-state--pending': ps.status === 'pending' }">{{ ps.stateText }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 解析中按钮(禁用状态) -->
|
||
<button class="login-view__parse-btn m0auto" disabled>解析中,请稍候</button>
|
||
</template>
|
||
|
||
<!-- 状态4:上传成功 success-->
|
||
<template v-if="resumeState === 'success'">
|
||
<div class="login-view__heading m0auto">
|
||
<h2 class="login-view__title">简历上传成功</h2>
|
||
<p class="login-view__subtitle">简历已完成解析,进入后台继续完善与投递</p>
|
||
</div>
|
||
<!-- 成功面板 -->
|
||
<div class="login-view__success-panel m0auto">
|
||
<!-- 成功图标和标题 -->
|
||
<div class="login-view__success-header">
|
||
<div class="login-view__success-icon-bg">
|
||
<span class="login-view__success-icon-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="0.15rem" height="0.15rem"><path stroke="currentColor" fill="currentColor" d="M77.248 415.04a64 64 0 0 1 90.496 0l226.304 226.304L846.528 188.8a64 64 0 1 1 90.56 90.496l-543.04 543.04-316.8-316.8a64 64 0 0 1 0-90.496"></path></svg></span>
|
||
</div>
|
||
<div class="login-view__success-header-text">
|
||
<span class="login-view__success-title">简历解析完成</span>
|
||
<span class="login-view__success-detail">已识别关键信息,岗位匹配已准备好</span>
|
||
</div>
|
||
</div>
|
||
<!-- 成功提示项 -->
|
||
<div class="login-view__success-tips">
|
||
<div class="login-view__success-tip-item">
|
||
<span class="login-view__success-tip-dot"></span>
|
||
<span class="login-view__success-tip-text">已提取教育背景、工作经历与技能</span>
|
||
</div>
|
||
<div class="login-view__success-tip-item">
|
||
<span class="login-view__success-tip-dot"></span>
|
||
<span class="login-view__success-tip-text">可进入后台查看推荐岗位并继续投递</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 进入后台按钮 -->
|
||
<button class="login-view__success-btn m0auto" @click="handleEnterDashboard">上传成功,进入后台</button>
|
||
</template>
|
||
|
||
<!-- 状态3:上传失败 failed-->
|
||
<template v-if="resumeState === 'failed'">
|
||
<div class="login-view__heading m0auto">
|
||
<h2 class="login-view__title">简历上传失败</h2>
|
||
<p class="login-view__subtitle">文件没有解析成功,请检查格式后重新上传
|
||
<!-- 或 <span class="color-accent cursor-po" @click="handleEnterDashboard">跳过,稍后填写</span>-->
|
||
</p>
|
||
</div>
|
||
<!-- 失败面板 -->
|
||
<div class="login-view__failed-panel">
|
||
<!-- 失败图标和标题 -->
|
||
<div class="login-view__failed-header">
|
||
<div class="login-view__failed-icon-bg">
|
||
<span class="login-view__failed-icon-mark">!</span>
|
||
</div>
|
||
<div class="login-view__failed-header-text">
|
||
<span class="login-view__failed-title">未能读取文件内容</span>
|
||
<span class="login-view__failed-detail">可能是文件加密、损坏或格式不支持</span>
|
||
</div>
|
||
</div>
|
||
<!-- 失败提示项 -->
|
||
<div class="login-view__failed-tips">
|
||
<div class="login-view__failed-tip-item">
|
||
<span class="login-view__failed-tip-dot"></span>
|
||
<span class="login-view__failed-tip-text">请上传 PDF / DOC / DOCX 格式</span>
|
||
</div>
|
||
<div class="login-view__failed-tip-item">
|
||
<span class="login-view__failed-tip-dot"></span>
|
||
<span class="login-view__failed-tip-text">文件大小不超过 20MB,且未加密</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 重新上传按钮 -->
|
||
<button class="login-view__failed-btn" @click="handleRetryUpload">重新上传</button>
|
||
</template>
|
||
|
||
</div>
|
||
</template>
|
||
<a class="login-view__right-footer" href="https://www.jianshixingqiu.com" target="_blank">
|
||
见识星球旗下 · AI 求职产品
|
||
</a>
|
||
</div>
|
||
|
||
<!-- 协议预览弹窗 -->
|
||
<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 { fetchJobIntention, saveJobIntention } from '@/api/jobs'
|
||
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()
|
||
|
||
|
||
// 声明友盟统计 _czc 对象
|
||
const _czc: any[] = (window as any)._czc || [];
|
||
(window as any)._czc = _czc;
|
||
// 绑定 siteid,请用您的 siteid 替换下方 "XXXXXXXX" 部分
|
||
_czc.push(["_setAccount", "1281490571"]);
|
||
|
||
// ==================== 左侧动画面板 ====================
|
||
/** 舞台引用 */
|
||
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=失败 success=成功 */
|
||
const resumeState = ref<'upload' | 'parsing' | 'failed' | 'success'>('upload')
|
||
/** 解析文字轮换定时器 */
|
||
let parsingTimer: ReturnType<typeof setInterval> | null = null
|
||
/** 文件选择器引用 */
|
||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||
|
||
/** 进度条百分比 */
|
||
const parsePercent = ref(0)
|
||
/** 进度条动画定时器 */
|
||
let progressTimer: ReturnType<typeof setInterval> | null = null
|
||
/** 接口是否已返回成功 */
|
||
let uploadDone = false
|
||
|
||
/** 解析步骤定义 */
|
||
interface ParseStep {
|
||
label: string
|
||
status: 'pending' | 'active' | 'done'
|
||
stateText: string
|
||
}
|
||
|
||
/** 4个解析步骤 */
|
||
const parseStepList = ref<ParseStep[]>([
|
||
{ label: '教育背景解析...', status: 'pending', stateText: '等待' },
|
||
{ label: '工作经历解析...', status: 'pending', stateText: '等待' },
|
||
{ label: '技能特长解析...', status: 'pending', stateText: '等待' },
|
||
{ label: '个人资料最终确认', status: 'pending', stateText: '等待' }
|
||
])
|
||
|
||
/** 每个步骤的时间分配(毫秒),总模拟时间约8秒 */
|
||
const stepDurations = [2000, 2000, 2000, 2000]
|
||
|
||
// ==================== 登录逻辑 ====================
|
||
|
||
/** 打开协议预览 */
|
||
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'
|
||
}
|
||
|
||
/** 进入后台(状态4按钮) */
|
||
function handleEnterDashboard() {
|
||
router.replace(loginRedirect)
|
||
}
|
||
|
||
/** 开始上传并解析简历 */
|
||
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'
|
||
uploadDone = false
|
||
parsePercent.value = 0
|
||
parseStepList.value = [
|
||
{ label: '教育背景解析...', status: 'pending', stateText: '等待' },
|
||
{ label: '工作经历解析...', status: 'pending', stateText: '等待' },
|
||
{ label: '技能特长解析...', status: 'pending', stateText: '等待' },
|
||
{ label: '个人资料最终确认', status: 'pending', stateText: '等待' }
|
||
]
|
||
startParsingAnimation()
|
||
|
||
try {
|
||
const res = await uploadResume(file)
|
||
if (res.code === 0 && res.data?.resumeId) {
|
||
// 上传成功,同步个人资料
|
||
await syncProfileFromResume(String(res.data.resumeId))
|
||
// 等待1秒后同步AI推荐岗位到求职意向
|
||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||
const intentionRes = await fetchJobIntention()
|
||
if (intentionRes.code === '0' && intentionRes.data) {
|
||
const intention = intentionRes.data
|
||
// 把 aiCategoryIds 复制到 categoryIds 后保存
|
||
if (intention.aiCategoryIds && intention.aiCategoryIds.length) {
|
||
intention.categoryIds = intention.aiCategoryIds.map(Number)
|
||
await saveJobIntention(intention)
|
||
}
|
||
}
|
||
// 标记接口已返回
|
||
uploadDone = true
|
||
// 完成所有步骤的动画后会自动过渡到 success 状态
|
||
finishParsingAnimation()
|
||
} else {
|
||
// 解析失败
|
||
stopParsingAnimation()
|
||
resumeState.value = 'failed'
|
||
}
|
||
} catch {
|
||
stopParsingAnimation()
|
||
resumeState.value = 'failed'
|
||
}
|
||
}
|
||
|
||
/** 开始解析步骤动画(模拟4步进度) */
|
||
function startParsingAnimation() {
|
||
let currentStep = 0
|
||
const totalSteps = parseStepList.value.length
|
||
/** 每个步骤对应的进度百分比段 */
|
||
const stepPercents = [25, 50, 75, 95]
|
||
|
||
// 第一个步骤立即进入进行中
|
||
parseStepList.value[0].status = 'active'
|
||
parseStepList.value[0].stateText = '进行中'
|
||
|
||
// 开始进度条平滑增长
|
||
let targetPercent = stepPercents[0]
|
||
const startTime = Date.now()
|
||
let lastStepTime = startTime
|
||
|
||
progressTimer = setInterval(() => {
|
||
const elapsed = Date.now() - lastStepTime
|
||
const stepDuration = stepDurations[currentStep] || 2000
|
||
|
||
if (elapsed >= stepDuration && currentStep < totalSteps - 1) {
|
||
// 当前步骤完成
|
||
parseStepList.value[currentStep].status = 'done'
|
||
parseStepList.value[currentStep].stateText = '完成'
|
||
currentStep++
|
||
// 下一个步骤进入进行中
|
||
parseStepList.value[currentStep].status = 'active'
|
||
parseStepList.value[currentStep].stateText = '进行中'
|
||
targetPercent = stepPercents[currentStep]
|
||
lastStepTime = Date.now()
|
||
} else if (currentStep >= totalSteps - 1 && elapsed >= stepDuration) {
|
||
// 最后一个步骤时间到,但如果接口还没返回就停在这里等
|
||
if (uploadDone) {
|
||
// 接口已返回,最后一步也完成
|
||
parseStepList.value[currentStep].status = 'done'
|
||
parseStepList.value[currentStep].stateText = '完成'
|
||
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
|
||
// 进度条跳到随机95-99再走完到100
|
||
const finalPercent = Math.floor(95 + Math.random() * 4)
|
||
parsePercent.value = finalPercent
|
||
setTimeout(() => {
|
||
parsePercent.value = 100
|
||
setTimeout(() => { resumeState.value = 'success' }, 600)
|
||
}, 1000)
|
||
return
|
||
}
|
||
// 接口未返回,保持在最后步骤的进行中状态
|
||
parsePercent.value = Math.min(parsePercent.value + 0.1, 94)
|
||
return
|
||
}
|
||
|
||
// 平滑增长进度
|
||
const progress = Math.min(elapsed / stepDuration, 1)
|
||
const prevPercent = currentStep > 0 ? stepPercents[currentStep - 1] : 0
|
||
parsePercent.value = Math.floor(prevPercent + (targetPercent - prevPercent) * progress)
|
||
}, 50)
|
||
}
|
||
|
||
/** 接口返回后完成动画 */
|
||
function finishParsingAnimation() {
|
||
// 如果进度动画还在跑,标记 uploadDone 即可让 progressTimer 逻辑处理完成
|
||
// 如果动画已经停了(最后一步时间到了等接口),手动完成
|
||
if (!progressTimer) {
|
||
const lastIdx = parseStepList.value.length - 1
|
||
parseStepList.value[lastIdx].status = 'done'
|
||
parseStepList.value[lastIdx].stateText = '完成'
|
||
const finalPercent = Math.floor(95 + Math.random() * 4)
|
||
parsePercent.value = finalPercent
|
||
setTimeout(() => {
|
||
parsePercent.value = 100
|
||
setTimeout(() => { resumeState.value = 'success' }, 600)
|
||
}, 1000)
|
||
}
|
||
}
|
||
|
||
/** 停止解析动画(失败时调用) */
|
||
function stopParsingAnimation() {
|
||
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
|
||
if (parsingTimer) { clearInterval(parsingTimer); parsingTimer = null }
|
||
}
|
||
|
||
/** 组件卸载时清理定时器 */
|
||
onBeforeUnmount(() => {
|
||
if (countdownTimer) { clearInterval(countdownTimer) }
|
||
if (parsingTimer) { clearInterval(parsingTimer) }
|
||
if (progressTimer) { clearInterval(progressTimer) }
|
||
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))
|
||
// translate 使用 rem(÷100)
|
||
stage.style.setProperty('--stage-x', `${(offsetX / 100).toFixed(4)}rem`)
|
||
stage.style.setProperty('--stage-y', `${(offsetY / 100).toFixed(4)}rem`)
|
||
}
|
||
|
||
/** 创建一个浮动节点面板并播放动画 */
|
||
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
|
||
// 使用 rem 单位(1rem = 100px)
|
||
img.style.setProperty('--x', `${(spec.x / 100).toFixed(4)}rem`)
|
||
img.style.setProperty('--y', `${(spec.y / 100).toFixed(4)}rem`)
|
||
img.style.setProperty('--w', `${(spec.w / 100).toFixed(4)}rem`)
|
||
img.style.setProperty('--h', `${(spec.h / 100).toFixed(4)}rem`)
|
||
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>
|