7 Commits
Author SHA1 Message Date
xuxin 1e103627f8 加一个超级管理员登陆页面 2026-08-05 14:32:33 +08:00
xuxin 8b9ebd6ddc 删掉之前测试时用过的一个无效旧token 2026-07-29 15:18:24 +08:00
xuxin 5abd6d27d6 联系二维码图片存oss图片链接缓存oss里的 2026-07-27 14:31:02 +08:00
xuxin bb7a8c55b2 4个mvp补丁 2026-07-24 18:15:31 +08:00
xuxin a0f01dd697 插件上传谷歌商店验证文件 2026-07-22 10:01:10 +08:00
xuxin 58a027296a 隐私协议单独加个页面 2026-07-21 11:29:26 +08:00
xuxin 010a80be81 隐私协议单独加个页面 2026-07-21 11:20:01 +08:00
52 changed files with 1227 additions and 49 deletions
-3
View File
@@ -31,13 +31,10 @@ declare module 'vue' {
ElDialog: typeof import('element-plus/es')['ElDialog']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSkeletonItem: typeof import('element-plus/es')['ElSkeletonItem']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
FullscreenLoading: typeof import('./src/components/FullscreenLoading.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
+1
View File
@@ -0,0 +1 @@
google-site-verification: google18c40aea647ea1cd.html
+1 -3
View File
@@ -301,9 +301,7 @@ export interface OptimizeResumeParams {
*/
export function optimizeAgentResume(params: OptimizeResumeParams) {
return aiService.post('/job-agent/optimize-resume', params, {
headers: {
Token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjIwMzUyNTM4OTg5MTk4NzA0NjUsInV1SWQiOiI2MmQ5MDE2NTcyNzY0ZmNjODNjZTIyYjRjODA5ZmU5MiJ9.eE-Q5rio5J5kxkS-XPYdmk-1Tgvg6kj6NGoKWMFNU14',
},
}).then(res => res.data)
}
+13
View File
@@ -54,6 +54,19 @@ export async function smsLogin(mobileNumber: string, code: string, inviteCode?:
return res
}
/**
* 超级管理员登录
* POST /public/login/superAdminLogin
* Body: { mobileNumber, password }
* 返回结构与 smsLogin 一致
*/
export function superAdminLogin(mobileNumber: string, password: string) {
return request.post<any, ApiResult<LoginData>>('/public/login/superAdminLogin', {
mobileNumber,
password,
})
}
/**
* 检查登录状态
* GET /public/checkLogin
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

@@ -1398,9 +1398,9 @@
}
}
// 立即去投递按钮
// 下载专属简历按钮
&__submit-btn {
flex: 1;
width: 100%;
height: 0.48rem;
background: $btn-dark;
color: $bg-white;
@@ -1415,6 +1415,20 @@
background: $btn-dark-hover;
}
}
// 下载专属简历按钮包裹(含下拉菜单定位)
&__submit-wrap {
position: relative;
flex: 1;
.job-resume-custom-dialog__top-download-menu {
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: 0.06rem;
width: 100%;
}
}
}
// 右侧滑入动画
@@ -53,6 +53,7 @@
}
&__item {
position: relative;
display: flex;
align-items: center;
gap: 0.04rem;
@@ -120,6 +121,26 @@
flex: none;
}
// 公测群二维码弹出层(hover索引5时显示)
&__qrcode-popup {
position: absolute;
top: calc(100% + 0.08rem);
left: 0;
padding: 0.08rem;
background: #fff;
border-radius: 0.08rem;
box-shadow: 0 0.02rem 0.12rem rgba(0, 0, 0, 0.08);
z-index: 100;
}
&__qrcode-img {
display: block;
width: auto;
height: auto;
max-width: 1.6rem;
border-radius: 0.04rem;
}
&__footer {
display: flex;
flex-direction: column;
+65 -2
View File
@@ -168,11 +168,34 @@
&__ai-tip-text {
font-size: 0.12rem;
line-height: 0.16rem;
color: $accent;
color: $text-light;
vertical-align: text-top;
margin-left: 0.03rem;
}
// AI 推荐的可点击岗位/行业关键词
&__ai-tip-keyword {
color: $accent;
text-decoration: underline;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
}
// 关键词之间的顿号(无下划线,text-light 色)
&__ai-tip-comma {
color: $text-light;
text-decoration: none;
}
// ";行业:" 分隔符保持 text-light 色
&__ai-tip-separator {
color: $text-light;
}
// 筛选条件
&__filters {
display: flex;
@@ -185,6 +208,7 @@
display: flex;
align-items: center;
gap: 0.08rem;
flex-wrap: wrap;
}
&__filter-item {
@@ -777,7 +801,46 @@
// 暂无数据提示
&__empty {
text-align: center;
padding: 1rem 0;
font-size: 0.14rem;
color: $text-light;
}
// 空状态图片
&__empty-img {
width: 1.24rem;
height: 1.3rem;
display: block;
margin: 1.6rem auto 0.2rem;
}
// 空状态主标题
&__empty-title {
font-size: 0.14rem;
color: $text-light;
margin-bottom: 0.08rem;
}
// 空状态副标题
&__empty-subtitle {
font-size: 0.14rem;
color: $text-light;
}
// "减少筛选条件" 可点击链接
&__empty-link {
color: $accent;
text-decoration: underline;
cursor: pointer;
&:hover {
opacity: 0.75;
}
}
// 列表结果较少时的底部提示
&__few-results {
text-align: center;
padding: 0.12rem 0;
font-size: 0.14rem;
color: $text-light;
}
+12 -19
View File
@@ -12,21 +12,7 @@
</button>
<h2 class="job-resume-custom-dialog__drawer-title">生成你的岗位专属简历</h2>
</div>
<!-- 下载简历按钮步骤三显示位于步骤指示器上方 -->
<div v-if="currentStep === 4" class="job-resume-custom-dialog__top-download mt10 ml12">
<button class="job-resume-custom-dialog__top-download-btn" @click="toggleDownloadMenu">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__top-download-icon">
<path d="M8 2v8M8 10l-3-3M8 10l3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 12h10" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
下载简历
</button>
<!-- 下载下拉菜单 -->
<div v-if="showDownloadMenu" class="job-resume-custom-dialog__top-download-menu">
<button class="job-resume-custom-dialog__top-download-option" @click="handleDownload('pdf')">下载PDF</button>
<button class="job-resume-custom-dialog__top-download-option" @click="handleDownload('word')">下载Word</button>
</div>
</div>
<!-- 步骤指示器 -->
<div class="job-resume-custom-dialog__steps pt10">
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 2 }">
@@ -222,7 +208,7 @@
</svg>
<span class="job-resume-custom-dialog__match-score">{{ cachedOptimizedScore.toFixed(1) }}</span>
</div>
<span class="job-resume-custom-dialog__match-label job-resume-custom-dialog__match-label--high">{{ cachedOptimizedScore >= 9 ? '非常匹配' : cachedOptimizedScore >= 6 ? '高匹配度' : '低匹配度' }}</span>
<span class="job-resume-custom-dialog__match-label job-resume-custom-dialog__match-label--high">{{ cachedOptimizedScore >= 9 ? '非常匹配' : cachedOptimizedScore >= 6 ? '高匹配度' : '低竞争力' }}</span>
</div>
</div>
<!-- 改写说明卡片外面平铺 -->
@@ -327,8 +313,15 @@
<div v-if="currentStep === 4" class="job-resume-custom-dialog__preview-footer mt10 pt30">
<!-- 左侧上一步按钮 -->
<button class="job-resume-custom-dialog__footer-prev-btn" @click="goToStep(3)">上一步</button>
<!-- 右侧立即去投递按钮 -->
<button class="job-resume-custom-dialog__submit-btn" @click="handleSubmit">立即去投递</button>
<!-- 右侧下载专属简历按钮 -->
<div class="job-resume-custom-dialog__submit-wrap">
<button class="job-resume-custom-dialog__submit-btn" @click="toggleDownloadMenu">下载专属简历</button>
<!-- 下载下拉菜单 -->
<div v-if="showDownloadMenu" class="job-resume-custom-dialog__top-download-menu">
<button class="job-resume-custom-dialog__top-download-option" @click="handleDownload('pdf')">下载PDF</button>
<button class="job-resume-custom-dialog__top-download-option" @click="handleDownload('word')">下载Word</button>
</div>
</div>
</div>
</div>
@@ -482,7 +475,7 @@ const missingSkills = computed(() => props.jobInfo.missingSkills || [])
const matchLevelText = computed(() => {
const score = props.jobInfo.matchScore
if (score >= 6) return '高匹配度'
return '低匹配度'
return '低竞争力'
})
/** 是否为低匹配度(低于6分) */
+7
View File
@@ -28,6 +28,13 @@
<!-- 索引0的时候显示"全网"小标签 -->
<span v-if="index === 0" class="side-nav__tag-quanwang mt2 ml8">全网</span>
<!-- 索引5的时候显示"公测群"小标签 -->
<span v-if="index === 5" class="side-nav__tag-quanwang mt2 ml8">公测群</span>
<!-- 索引5 hover时显示公测群二维码 -->
<div v-if="index === 5 && hoverIndex === 5" class="side-nav__qrcode-popup">
<img src="https://offerpie.oss-cn-guangzhou.aliyuncs.com/images/mobile-qcCode.png" alt="公测群二维码" class="side-nav__qrcode-img" />
</div>
<span v-if="item.badge" class="side-nav__badge">{{ item.badge }}</span>
</a>
</nav>
+6
View File
@@ -13,6 +13,7 @@ const staticRoutes: RouteRecordRaw[] = [
{ path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
{ path: '/m', name: 'HomeMobile', component: () => import('@/views/HomeMobile.vue') },
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
{ path: '/super-rjtfoewjksfewj-login', name: 'SuperRjtfoewjksfewjLogin', component: () => import('@/views/SuperRjtfoewjksfewjLogin.vue') },
{
path: '/jobs',
name: 'Jobs',
@@ -35,6 +36,11 @@ const staticRoutes: RouteRecordRaw[] = [
name: 'MentorDetail',
component: () => import('@/views/MentorDetail.vue'),
},
{
path: '/privacy',
name: 'PrivacyPolicy',
component: () => import('@/views/PrivacyPolicy.vue'),
},
]
const router = createRouter({
+2 -5
View File
@@ -1,8 +1,5 @@
import axios from 'axios'
/** 默认 TokenNova 对话接口使用) */
export const DEFAULT_TOKEN = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjIwMzUyNTM4OTg5MTk4NzA0NjUsInV1SWQiOiI2MmQ5MDE2NTcyNzY0ZmNjODNjZTIyYjRjODA5ZmU5MiJ9.eE-Q5rio5J5kxkS-XPYdmk-1Tgvg6kj6NGoKWMFNU14'
/**
* AI 端专用 axios 实例
* 基础地址指向 AI 服务,超时时间较长(大模型响应慢)
@@ -17,7 +14,7 @@ const aiService = axios.create({
* 浏览器安全策略不允许 JS 直接设置 Cookie header,改用 Token header 传递
*/
aiService.interceptors.request.use((config) => {
// config.headers['Token'] = DEFAULT_TOKEN
return config
})
@@ -110,7 +107,7 @@ export interface NovaChatResponse {
export function sendNovaChat(params: NovaChatParams) {
return aiService.post<AiResult<NovaChatResponse>>('/nova-chat/chat', params, {
headers: {
Token: DEFAULT_TOKEN,
},
}).then(res => res.data)
}
+1 -1
View File
@@ -157,7 +157,7 @@
</div>
<p class="home-mobile__dialog-qr-title">保存二维码扫码加入求职交流群</p>
<div class="home-mobile__dialog-qr">
<img src="@/assets/images/home/mobile-qcCode.png" alt="二维码" class="home-mobile__dialog-qr-img" />
<img src="https://offerpie.oss-cn-guangzhou.aliyuncs.com/images/mobile-qcCode.png" alt="二维码" class="home-mobile__dialog-qr-img" />
</div>
<p class="home-mobile__dialog-qr-hint">长按保存二维码获取更多求职资料公开课</p>
<button class="home-mobile__dialog-btn" @click="showDialog = false">知道了</button>
+88 -14
View File
@@ -144,7 +144,7 @@
<div class="jobs-page__ai-tip-content">
<img class="jobs-page__ai-tip-logo" src="@/assets/images/logo-offerpai-2.png" alt="Nova" />
<span class="jobs-page__ai-tip-text">
Nova根据你的个人背景建议的<template v-if="aiCategoryNames">岗位:{{ aiCategoryNames }}</template><template v-if="aiIndustryNames">行业{{ aiIndustryNames }}</template>
Nova根据你的个人背景建议的<template v-if="aiCategoryItems.length">岗位:<template v-for="(item, idx) in aiCategoryItems" :key="'cat-' + item.id"><span class="jobs-page__ai-tip-keyword" @click="handleAddCategory(item.id)">{{ item.name }}</span><span v-if="idx < aiCategoryItems.length - 1" class="jobs-page__ai-tip-comma">、</span></template></template><template v-if="aiIndustryItems.length"><span class="jobs-page__ai-tip-separator">;行业:</span><template v-for="(item, idx) in aiIndustryItems" :key="'ind-' + item.id"><span class="jobs-page__ai-tip-keyword" @click="handleAddIndustry(item.id)">{{ item.name }}</span><span v-if="idx < aiIndustryItems.length - 1" class="jobs-page__ai-tip-comma"></span></template></template>
</span>
</div>
</div>
@@ -292,7 +292,16 @@
<span>加载中...</span>
</div>
<!-- 暂无数据提示 -->
<div v-if="!loading && jobList.length === 0" class="jobs-page__empty">暂无数据</div>
<div v-if="!loading && jobList.length === 0" class="jobs-page__empty">
<img class="jobs-page__empty-img" src="@/assets/images/list-empty.png" alt="暂无数据" />
<p class="jobs-page__empty-title">符合条件的岗位较少</p>
<p class="jobs-page__empty-subtitle">试试 <span class="jobs-page__empty-link" @click="clearAllFilters">减少筛选条件</span> 或调整您意向的关键词哦~</p>
</div>
<!-- 列表结果少于5条时的提示 -->
<div v-if="!loading && jobList.length > 0 && jobList.length < 5 && hasActiveFilters" class="jobs-page__few-results">
<p class="jobs-page__empty-title">符合条件的岗位较少</p>
<p class="jobs-page__empty-subtitle">试试 <span class="jobs-page__empty-link" @click="clearAllFilters">减少筛选条件</span> 或调整您意向的关键词哦~</p>
</div>
<!-- 加载更多提示 -->
<div v-if="loadingMore" class="jobs-page__loading-box">
<div class="jobs-page__loading-spinner"></div>
@@ -700,11 +709,11 @@ function resolveFullCategoryName(id: string): string {
return ''
}
/** AI 推荐的行业名称ID 转文字,基于完整行业树 */
const aiIndustryNames = computed<string>(() => {
if (!aiIndustryIds.value.length || !fullIndustryTree.value.length) return ''
/** AI 推荐的行业列表ID + 名称,用于提示条展示和点击 */
const aiIndustryItems = computed<{ id: string; name: string }[]>(() => {
if (!aiIndustryIds.value.length || !fullIndustryTree.value.length) return []
const idSet = new Set(aiIndustryIds.value.map(String))
const names: string[] = []
const items: { id: string; name: string }[] = []
for (const id of aiIndustryIds.value) {
// 如果该 ID 是一级父节点且子节点也在列表中,跳过父级避免重复
const parent = fullIndustryTree.value.find(p => String(p.id) === id)
@@ -712,16 +721,16 @@ const aiIndustryNames = computed<string>(() => {
continue
}
const name = resolveFullIndustryName(id)
if (name) names.push(name)
if (name) items.push({ id, name })
}
return names.join('、')
return items
})
/** AI 推荐的岗位名称ID 转文字,基于完整岗位树 */
const aiCategoryNames = computed<string>(() => {
if (!aiCategoryIds.value.length || !fullJobCategoryTree.value.length) return ''
/** AI 推荐的岗位列表ID + 名称,用于提示条展示和点击 */
const aiCategoryItems = computed<{ id: string; name: string }[]>(() => {
if (!aiCategoryIds.value.length || !fullJobCategoryTree.value.length) return []
const idSet = new Set(aiCategoryIds.value.map(String))
const names: string[] = []
const items: { id: string; name: string }[] = []
for (const id of aiCategoryIds.value) {
// 如果该 ID 是一级父节点且下级子节点也在列表中,跳过
const lv1 = fullJobCategoryTree.value.find(p => String(p.id) === id)
@@ -739,11 +748,17 @@ const aiCategoryNames = computed<string>(() => {
}
if (isParentWithChild) continue
const name = resolveFullCategoryName(id)
if (name) names.push(name)
if (name) items.push({ id, name })
}
return names.join('、')
return items
})
/** AI 推荐的行业名称(兼容旧逻辑,拼接字符串用于条件判断) */
const aiIndustryNames = computed<string>(() => aiIndustryItems.value.map(i => i.name).join('、'))
/** AI 推荐的岗位名称(兼容旧逻辑,拼接字符串用于条件判断) */
const aiCategoryNames = computed<string>(() => aiCategoryItems.value.map(i => i.name).join('、'))
/** 是否显示 AI 推荐提示条 */
const showAiRecommendTip = computed(() => {
return isRecommendTab.value && (aiIndustryNames.value || aiCategoryNames.value)
@@ -765,6 +780,36 @@ function onCategoryChange(ids: number[]) {
})
}
/** 点击 AI 推荐岗位词 — 将该岗位 ID 添加到筛选器(去重,最多3个) */
function handleAddCategory(id: string) {
const numId = Number(id)
const current = selectedCategoryIds.value
if (current.includes(numId)) return // 已存在,不重复添加
if (current.length >= 3) {
ElMessage.warning('最多选择3个分类,请先去掉一个再添加')
return
}
store.dispatch('saveJobIntention', {
...store.state.jobIntention,
categoryIds: [...current, numId],
})
}
/** 点击 AI 推荐行业词 — 将该行业 ID 添加到筛选器(去重,最多3个) */
function handleAddIndustry(id: string) {
const numId = Number(id)
const current = selectedIndustryIds.value
if (current.includes(numId)) return // 已存在,不重复添加
if (current.length >= 3) {
ElMessage.warning('最多选择3个分类,请先去掉一个再添加')
return
}
store.dispatch('saveJobIntention', {
...store.state.jobIntention,
industryIds: [...current, numId],
})
}
/** 地区选择变更回调 */
function onRegionChange(codes: string[]) {
store.dispatch('saveJobIntention', {
@@ -1192,6 +1237,35 @@ function reloadFirstPage() {
loadCurrentTab()
}
/** 是否有任何筛选条件或搜索关键词处于激活状态 */
const hasActiveFilters = computed(() => {
return selectedCategoryIds.value.length > 0
|| selectedIndustryIds.value.length > 0
|| selectedRegionCodes.value.length > 0
|| selectedEmploymentType.value !== null
|| selectedCompanyTypes.value.length > 0
|| keyword.value.trim() !== ''
})
/** 清空所有筛选条件和搜索关键词,重新查询 */
function clearAllFilters() {
// 清空搜索关键词
keyword.value = ''
// 清空公司类型(本地状态 + 缓存)
selectedCompanyTypes.value = []
saveCachedCompanyTypes([])
// 清空筛选按钮的显示文字
filters.value.forEach(f => { f.selected = '' })
// 清空 store 中的分类筛选条件
store.dispatch('saveJobIntention', {
...store.state.jobIntention,
categoryIds: [],
industryIds: [],
regionCodes: [],
recruitCategory: null,
})
}
/** 根据当前 Tab 加载对应列表 */
function loadCurrentTab() {
if (isRecommendTab.value) {
+131
View File
@@ -0,0 +1,131 @@
<template>
<!-- 隐私协议页面 独立页面用于外部访问 -->
<div class="privacy-policy-page">
<div class="privacy-policy-page__content">
<!-- 加载中状态 -->
<div v-if="loading" class="privacy-policy-page__loading">加载中...</div>
<!-- 协议内容渲染 -->
<div v-else class="privacy-policy-page__body" v-html="contentHtml"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { fetchAgreement } from '@/api/common'
// @ts-ignore
import markdownit from 'markdown-it'
/** markdown-it 实例 */
const md = markdownit({ html: true, breaks: true, linkify: true })
/** 渲染后的 HTML 内容 */
const contentHtml = ref('')
/** 加载状态 */
const loading = ref(true)
/** 隐私协议 code */
const PRIVACY_CODE = 'hf8375i8'
/** 页面加载时获取协议内容 */
onMounted(async () => {
try {
const res = await fetchAgreement(PRIVACY_CODE)
if (res.data?.content) {
contentHtml.value = md.render(res.data.content)
} else {
contentHtml.value = '<p>暂无协议内容</p>'
}
} catch {
contentHtml.value = '<p>加载失败,请稍后重试</p>'
} finally {
loading.value = false
}
})
</script>
<style lang="scss" scoped>
@use '../assets/styles/variables' as *;
/* 隐私协议页面容器 */
.privacy-policy-page {
font-size: 0.14rem;
min-height: var(--app-height, 100vh);
background: $bg-white;
padding: 1rem 0;
/* 内容区域 — 居中,12rem宽 */
&__content {
width: 12rem;
margin: 0 auto;
}
/* 加载中提示 */
&__loading {
text-align: center;
color: $text-light;
padding: 2rem 0;
}
/* 协议正文样式 */
&__body {
color: $text-dark;
line-height: 1.8;
:deep(h1) {
font-size: 0.24rem;
font-weight: 700;
margin-bottom: 0.2rem;
}
:deep(h2) {
font-size: 0.2rem;
font-weight: 600;
margin-top: 0.3rem;
margin-bottom: 0.15rem;
}
:deep(h3) {
font-size: 0.17rem;
font-weight: 600;
margin-top: 0.2rem;
margin-bottom: 0.1rem;
}
:deep(p) {
margin-bottom: 0.12rem;
}
:deep(ul), :deep(ol) {
padding-left: 0.2rem;
margin-bottom: 0.12rem;
}
:deep(li) {
margin-bottom: 0.06rem;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin-bottom: 0.16rem;
}
:deep(th), :deep(td) {
border: 1px solid $border-color;
padding: 0.08rem 0.12rem;
text-align: left;
}
:deep(th) {
background: $bg-main;
font-weight: 600;
}
:deep(a) {
color: $accent;
text-decoration: underline;
}
}
}
</style>
+863
View File
@@ -0,0 +1,863 @@
<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手机号+密码登录===== -->
<template v-if="step === 1">
<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">
<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>
<!-- 密码输入组 -->
<div class="login-view__phone-row" style="margin-top: 0.16rem;">
<!-- 密码图标 -->
<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" y="8" width="10" height="8" rx="1.5" stroke="#0F6B66" stroke-width="1.5"/>
<path d="M6.5 8V5.5C6.5 4.12 7.62 3 9 3C10.38 3 11.5 4.12 11.5 5.5V8" stroke="#0F6B66" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<!-- 密码输入框 -->
<input v-model="password" type="password" class="login-view__phone-input" placeholder="请输入密码" @keyup.enter="handleSuperLogin" />
</div>
<!-- 错误提示 -->
<p v-if="loginErrorMsg" class="login-view__sms-status tal login-view__sms-status--error">{{ loginErrorMsg }}</p>
<!-- 确认登录按钮 -->
<button class="login-view__send-btn" :disabled="!canLogin || isLoggingIn" @click="handleSuperLogin">
<template v-if="isLoggingIn"><span class="login-view__spinner"></span><span>登录中</span></template>
<template v-else>确认登录</template>
</button>
<!-- 协议勾选 -->
<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, onMounted, onBeforeUnmount } from 'vue'
import { useStore } from 'vuex'
import { useRouter, useRoute } from 'vue-router'
import { superAdminLogin } 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=手机号+密码登录 3=上传简历 */
const step = ref(1)
// ==================== 登录相关状态 ====================
/** 手机号 */
const phone = ref('')
/** 密码 */
const password = ref('')
/** 是否已勾选协议 */
const agreedTerms = ref(false)
/** 是否正在调用登录接口 */
const isLoggingIn = ref(false)
/** 登录错误提示 */
const loginErrorMsg = ref('')
/** 协议弹窗状态 */
const showAgreementDialog = ref(false)
const agreementCode = ref('')
/** 登录成功后的跳转路径(缓存) */
let loginRedirect = '/'
/** 登录按钮是否可用 */
const canLogin = computed(() => phone.value.length === 11 && password.value.length > 0 && 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
}
/** 超级管理员登录 */
async function handleSuperLogin() {
if (!phone.value || !password.value) { ElMessage.warning('请输入手机号和密码'); return }
if (!agreedTerms.value) { ElMessage.warning('请先同意用户协议与隐私政策'); return }
isLoggingIn.value = true
loginErrorMsg.value = ''
try {
const res = await superAdminLogin(phone.value, password.value)
if (res.code !== '0') {
loginErrorMsg.value = res.msg || '登录失败,请检查手机号和密码'
isLoggingIn.value = false
return
}
// 登录成功
ElMessage.success(`欢迎回来,${res.data?.nick || ''}`)
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 (err: any) {
loginErrorMsg.value = err?.response?.data?.msg || '登录失败,请稍后再试'
isLoggingIn.value = false
}
}
// ==================== 简历上传逻辑 ====================
/** 触发文件选择 */
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 (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(() => {
// 如果路由带 resumeUpload=1 参数,直接跳到简历上传步骤
if (route.query.resumeUpload === '1') {
step.value = 3
resumeState.value = 'upload'
loginRedirect = (route.query.redirect as string) || '/jobs'
}
// 设置背景图
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>