Files
offerpai_web/src/views/Login.vue
T
2026-07-16 16:30:07 +08:00

959 lines
40 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">
<!-- ==================== 左侧视觉动画面板所有阶段共用 ==================== -->
<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>