Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e103627f8 | ||
|
|
8b9ebd6ddc | ||
|
|
5abd6d27d6 | ||
|
|
bb7a8c55b2 | ||
|
|
a0f01dd697 | ||
|
|
58a027296a | ||
|
|
010a80be81 | ||
|
|
6c03793daf | ||
|
|
33ecccdcb0 | ||
|
|
d429bae98a | ||
|
|
0b9f2e7333 | ||
|
|
db46766d48 | ||
|
|
27d2592d06 | ||
|
|
c81c9b79dd | ||
|
|
5f4aab8d04 | ||
|
|
15971bffd9 |
@@ -31,17 +31,15 @@ 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']
|
||||
IndustrySelector: typeof import('./src/components/tools/IndustrySelector.vue')['default']
|
||||
JobApplyMethodDialog: typeof import('./src/components/JobApplyMethodDialog.vue')['default']
|
||||
JobCategorySelector: typeof import('./src/components/tools/JobCategorySelector.vue')['default']
|
||||
JobDislikeDialog: typeof import('./src/components/JobDislikeDialog.vue')['default']
|
||||
JobFeedbackDialog: typeof import('./src/components/JobFeedbackDialog.vue')['default']
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
<title>Offer派 - 大学生AI求职平台 | 智能岗位匹配·一键自动网申·AI简历优化</title>
|
||||
<meta name="description" content="Offer派是专为大学生打造的AI求职平台,提供智能岗位匹配、一键自动网申、岗位定制简历、内推人脉直通等功能,让校招求职效率提升80%。" />
|
||||
<meta name="keywords" content="大学生求职,校招,AI求职,智能匹配,自动网申,简历优化,内推,Offer派" />
|
||||
<!-- 友盟统计 -->
|
||||
<script>
|
||||
var _czc = _czc || [];
|
||||
(function () {
|
||||
var um = document.createElement("script");
|
||||
um.src = "https://v1.cnzz.com/z.js?id=1281490571&async=1";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(um, s);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
google-site-verification: google18c40aea647ea1cd.html
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -101,7 +101,8 @@ export interface JobListParams {
|
||||
recruitCategory?: number
|
||||
/** 排除岗位ID列表(用于推荐时排除已推荐过的) */
|
||||
excludeJobIds?: number[]
|
||||
|
||||
/** 公司类型列表(上市企业、独角兽、国企等,可多选) */
|
||||
companyTypes?: string[]
|
||||
}
|
||||
|
||||
// ==================== 求职意向 ====================
|
||||
|
||||
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 462 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 485 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧滑入动画
|
||||
|
||||
@@ -251,17 +251,15 @@
|
||||
|
||||
&__tag {
|
||||
font-size: 0.12rem;
|
||||
color: $text-dark;
|
||||
background: $bg-main;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.16rem;
|
||||
padding: 0.05rem 0.16rem;
|
||||
color: $accent;
|
||||
background: #fff;
|
||||
border: 1px solid $accent;
|
||||
border-radius: 0.04rem;
|
||||
padding: 0.05rem 0.08rem;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: $accent;
|
||||
color: $accent;
|
||||
background: $theme-color;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -344,7 +344,7 @@
|
||||
&__quote-avatar {
|
||||
width: 0.38rem;
|
||||
height: 0.38rem;
|
||||
background: #D9E3EA;
|
||||
//background: #D9E3EA;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -148,6 +148,9 @@
|
||||
letter-spacing: 0.03rem;
|
||||
color: #111;
|
||||
margin: 0;
|
||||
>span{
|
||||
width: 1.51rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 副标题描述
|
||||
@@ -1615,10 +1618,18 @@
|
||||
margin: 0.6rem auto 0;
|
||||
text-align: center;
|
||||
padding-top: 0.4rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
|
||||
|
||||
p {
|
||||
font-size: 0.12rem;
|
||||
line-height: 0.16rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
a{
|
||||
font-size: 0.12rem;
|
||||
text-decoration: none;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,14 +167,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.08rem 0.16rem;
|
||||
width: 0.88rem;
|
||||
height: 0.37rem;
|
||||
line-height: 0.37rem;
|
||||
background: $accent;
|
||||
border: 1px solid $accent;
|
||||
border-radius: 0.08rem;
|
||||
color: $bg-white;
|
||||
font-size: 0.14rem;
|
||||
font-weight: 600;
|
||||
line-height: 0.19rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
}
|
||||
|
||||
.login-view__sms-status--error {
|
||||
color: #E85635;
|
||||
color: #EF4444;
|
||||
}
|
||||
|
||||
/* ==================== 状态按钮(步骤二) ==================== */
|
||||
|
||||
@@ -92,6 +92,13 @@
|
||||
&:hover {
|
||||
background: #EDF9FA;
|
||||
}
|
||||
|
||||
// 小屏(视口宽度 < 1440px)只显示第一个标签
|
||||
@media (max-width: 1439px) {
|
||||
& ~ & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索框
|
||||
|
||||
@@ -726,7 +726,7 @@
|
||||
background: $bg-white;
|
||||
border: 1px solid $accent;
|
||||
border-radius: 0.04rem;
|
||||
padding: 0.05rem 0.14rem;
|
||||
padding: 0.05rem 0.08rem;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 业务配置文件 — 集中管理可开关的业务策略
|
||||
*/
|
||||
|
||||
/** 是否开启"个人资料为空时强制跳转到登录页简历上传"机制 */
|
||||
export const ENABLE_FORCE_RESUME_UPLOAD = true
|
||||
|
||||
/** 浏览器插件目标版本(用于判断用户是否需要更新插件) */
|
||||
export const PLUGIN_TARGET_VERSION = '0.1.1'
|
||||
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<!-- 选择投递方式弹窗 -->
|
||||
<div v-if="visible" class="job-apply-method__overlay" @click.self="close">
|
||||
<div class="job-apply-method__dialog">
|
||||
<!-- 渐变背景层 -->
|
||||
<div class="job-apply-method__bg"></div>
|
||||
<!-- 标题 -->
|
||||
<p class="job-apply-method__title">选择投递方式</p>
|
||||
<!-- 关闭按钮 -->
|
||||
<span class="job-apply-method__close" @click="close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6l12 12" stroke="#6A7282" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</span>
|
||||
<!-- 两个选项卡 -->
|
||||
<div class="job-apply-method__options">
|
||||
<!-- 直接前往官网 -->
|
||||
<div class="job-apply-method__card" :class="{ 'job-apply-method__card--active': choice === 'direct' }" @click="choice = 'direct'">
|
||||
<p class="job-apply-method__card-title">直接前往官网</p>
|
||||
<p class="job-apply-method__card-desc">手动填写网申信息<br>自行记录投递进度</p>
|
||||
</div>
|
||||
<!-- 智能投递助手 -->
|
||||
<div class="job-apply-method__card" :class="{ 'job-apply-method__card--active': choice === 'agent' }" @click="choice = 'agent'">
|
||||
<!-- 左下角圆形装饰 -->
|
||||
<div class="job-apply-method__card-circle"></div>
|
||||
<div class="job-apply-method__card-header">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 1L10 5.5L15 6.5L11.5 10L12.5 15L8 12.5L3.5 15L4.5 10L1 6.5L6 5.5L8 1Z" fill="#52CAD1"/></svg>
|
||||
<span class="job-apply-method__card-title job-apply-method__card-title--accent">智能投递助手</span>
|
||||
</div>
|
||||
<p class="job-apply-method__card-desc">{{ agentCardDesc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部操作按钮 -->
|
||||
<button class="job-apply-method__action" :class="actionClass" :disabled="!choice" @click="handleAction">
|
||||
<svg v-if="actionIcon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_apply_method)">
|
||||
<path d="M6.0714 3.63204C6.47007 2.46537 8.08207 2.43004 8.55474 3.52604L8.59474 3.63271L9.13274 5.20604C9.25603 5.56686 9.45527 5.89705 9.71702 6.17432C9.97877 6.4516 10.2969 6.66952 10.6501 6.81337L10.7947 6.86737L12.3681 7.40471C13.5347 7.80337 13.5701 9.41537 12.4747 9.88804L12.3681 9.92804L10.7947 10.466C10.4338 10.5893 10.1035 10.7885 9.82608 11.0502C9.54869 11.312 9.33067 11.6302 9.18674 11.9834L9.13274 12.1274L8.5954 13.7014C8.19674 14.868 6.58474 14.9034 6.11274 13.808L6.0714 13.7014L5.53407 12.128C5.41086 11.7671 5.21165 11.4368 4.9499 11.1594C4.68814 10.882 4.36993 10.664 4.01674 10.52L3.87274 10.466L2.2994 9.92871C1.13207 9.53004 1.09674 7.91804 2.19274 7.44604L2.2994 7.40471L3.87274 6.86737C4.23356 6.74408 4.56374 6.54484 4.84102 6.28309C5.11829 6.02134 5.33621 5.70317 5.48007 5.35004L5.53407 5.20604L6.0714 3.63204ZM7.3334 4.06271L6.79607 5.63604C6.60833 6.18624 6.30292 6.68891 5.90109 7.10904C5.49927 7.52917 5.0107 7.85666 4.4694 8.06871L4.30274 8.12937L2.7294 8.66671L4.30274 9.20404C4.85294 9.39178 5.35561 9.6972 5.77573 10.099C6.19586 10.5008 6.52335 10.9894 6.7354 11.5307L6.79607 11.6974L7.3334 13.2707L7.87074 11.6974C8.05848 11.1472 8.36389 10.6445 8.76571 10.2244C9.16753 9.80425 9.65611 9.47676 10.1974 9.26471L10.3641 9.20471L11.9374 8.66671L10.3641 8.12937C9.81387 7.94163 9.3112 7.63622 8.89107 7.2344C8.47094 6.83258 8.14345 6.34401 7.9314 5.80271L7.8714 5.63604L7.3334 4.06271ZM12.6667 1.33337C12.7915 1.33337 12.9137 1.36836 13.0195 1.43436C13.1253 1.50035 13.2105 1.59471 13.2654 1.70671L13.2974 1.78471L13.5307 2.46871L14.2154 2.70204C14.3404 2.7445 14.45 2.82312 14.5302 2.92792C14.6105 3.03272 14.6578 3.15899 14.6663 3.29072C14.6747 3.42246 14.6438 3.55373 14.5775 3.6679C14.5113 3.78207 14.4126 3.874 14.2941 3.93204L14.2154 3.96404L13.5314 4.19737L13.2981 4.88204C13.2555 5.00699 13.1769 5.11651 13.072 5.1967C12.9672 5.2769 12.8409 5.32417 12.7092 5.33252C12.5774 5.34088 12.4462 5.30994 12.3321 5.24363C12.2179 5.17732 12.1261 5.07862 12.0681 4.96004L12.0361 4.88204L11.8027 4.19804L11.1181 3.96471C10.9931 3.92224 10.8835 3.84363 10.8033 3.73883C10.723 3.63403 10.6756 3.50776 10.6672 3.37603C10.6588 3.24429 10.6897 3.11302 10.7559 2.99885C10.8222 2.88468 10.9208 2.79275 11.0394 2.73471L11.1181 2.70271L11.8021 2.46937L12.0354 1.78471C12.0804 1.65299 12.1654 1.53865 12.2786 1.45771C12.3918 1.37677 12.5276 1.33329 12.6667 1.33337Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_apply_method">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
{{ actionText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
import { createChannelBridge } from '@/utils/channelBridge'
|
||||
import { fetchAgentConfig, applyJob } from '@/api/agent'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// ==================== Props & Emits ====================
|
||||
|
||||
const props = defineProps<{
|
||||
/** 控制弹窗显隐(v-model) */
|
||||
modelValue: boolean
|
||||
/** 岗位来源链接 */
|
||||
sourceUrl: string
|
||||
/** 岗位 ID */
|
||||
jobId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
/** 需要打开会员购买弹窗 */
|
||||
(e: 'openMember'): void
|
||||
}>()
|
||||
|
||||
// ==================== 基础依赖 ====================
|
||||
|
||||
const router = useRouter()
|
||||
const store = useStore()
|
||||
|
||||
// ==================== 弹窗显隐 ====================
|
||||
|
||||
/** 弹窗是否显示 */
|
||||
const visible = computed(() => props.modelValue)
|
||||
|
||||
// ==================== 状态变量 ====================
|
||||
|
||||
/** 用户选择的投递方式:null=未选择, 'direct'=直接前往官网, 'agent'=智能投递助手 */
|
||||
const choice = ref<'direct' | 'agent' | null>(null)
|
||||
|
||||
/** AI助手是否已完成配置(status === 1) */
|
||||
const agentConfigReady = ref(false)
|
||||
|
||||
/** 是否正在加载 AI 助手配置 */
|
||||
const agentConfigLoading = ref(false)
|
||||
|
||||
// ==================== 从 Store 读取的状态 ====================
|
||||
|
||||
/** 用户会员状态 */
|
||||
const memberStatus = computed(() => store.state.memberStatus)
|
||||
|
||||
/** 用户是否是会员(正式或试用) */
|
||||
const isMember = computed(() => !!memberStatus.value?.isMember)
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
/** 智能投递助手选项卡内的描述文字 — 根据会员和配置状态动态显示 */
|
||||
const agentCardDesc = computed(() => {
|
||||
if (!isMember.value) {
|
||||
return '自动填写网申信息\n统一管理投递进度'
|
||||
}
|
||||
if (!agentConfigReady.value) {
|
||||
return '完成助手设置后\n可使用智能投递'
|
||||
}
|
||||
return '将该岗位加入待投递列表\n在投递助手中统一处理'
|
||||
})
|
||||
|
||||
/** 底部操作按钮文字 */
|
||||
const actionText = computed(() => {
|
||||
if (!choice.value) return '请选择投递方式'
|
||||
if (choice.value === 'direct') return '去官网投递'
|
||||
if (!isMember.value) return '开通会员使用'
|
||||
if (!agentConfigReady.value) return '去完成设置'
|
||||
return '加入待投递列表'
|
||||
})
|
||||
|
||||
/** 底部操作按钮是否显示 icon */
|
||||
const actionIcon = computed(() => {
|
||||
if (!choice.value) return false
|
||||
if (choice.value === 'direct') return false
|
||||
return true
|
||||
})
|
||||
|
||||
/** 底部操作按钮样式 class */
|
||||
const actionClass = computed(() => {
|
||||
if (!choice.value) return 'job-apply-method__action--disabled'
|
||||
return 'job-apply-method__action--active'
|
||||
})
|
||||
|
||||
// ==================== 方法 ====================
|
||||
|
||||
/** 加载 AI 助手配置状态 */
|
||||
async function loadAgentConfigStatus() {
|
||||
if (agentConfigLoading.value) return
|
||||
agentConfigLoading.value = true
|
||||
try {
|
||||
const res = await fetchAgentConfig()
|
||||
if (res.code === '0' && res.data && res.data.status === 1) {
|
||||
agentConfigReady.value = true
|
||||
} else {
|
||||
agentConfigReady.value = false
|
||||
}
|
||||
} catch {
|
||||
agentConfigReady.value = false
|
||||
} finally {
|
||||
agentConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开弹窗时重置状态并加载配置 */
|
||||
function open() {
|
||||
choice.value = null
|
||||
loadAgentConfigStatus()
|
||||
}
|
||||
|
||||
/** 关闭弹窗 */
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
/** 底部操作按钮点击处理 */
|
||||
async function handleAction() {
|
||||
if (!choice.value) return
|
||||
|
||||
if (choice.value === 'direct') {
|
||||
// 直接前往官网 — 先跳转,再异步推送链接给插件(不阻塞跳转)
|
||||
close()
|
||||
if (props.sourceUrl) {
|
||||
window.open(props.sourceUrl, '_blank')
|
||||
// 异步推送投递链接到插件,不影响用户跳转体验
|
||||
const url = props.sourceUrl
|
||||
const bridge = createChannelBridge({ domain: store.state.bridgeDomain })
|
||||
bridge.get<{ linkList: string[] }>(store.state.bridgeDomain, 'offerpaiDeliveryLinkList', true)
|
||||
.then((existing) => {
|
||||
const linkList: string[] = existing?.linkList || []
|
||||
if (!linkList.includes(url)) {
|
||||
linkList.push(url)
|
||||
}
|
||||
return bridge.put<{ linkList: string[] }>(store.state.bridgeDomain, 'offerpaiDeliveryLinkList', { linkList }, true)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[JobApplyMethodDialog] 跨域 put 投递链接失败', e)
|
||||
})
|
||||
.finally(() => {
|
||||
bridge.destroy()
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 智能投递助手分支
|
||||
if (!isMember.value) {
|
||||
// 非会员 — 打开会员购买弹窗
|
||||
close()
|
||||
emit('openMember')
|
||||
return
|
||||
}
|
||||
|
||||
if (!agentConfigReady.value) {
|
||||
// 有会员但没完成 AI 助手配置 — 跳转 Agent 页面
|
||||
close()
|
||||
router.push('/agent')
|
||||
return
|
||||
}
|
||||
|
||||
// 有会员且配置完成 — 加入待投递列表
|
||||
try {
|
||||
const res = await applyJob({ jobId: props.jobId, status: -1 })
|
||||
if (res.code === '0') {
|
||||
ElMessage.success('已加入待投递列表')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加入待投递列表失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加入待投递列表失败')
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
// ==================== 暴露方法 ====================
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '../assets/styles/variables' as *;
|
||||
|
||||
/* 组件最外层字号基准 */
|
||||
.job-apply-method__overlay {
|
||||
font-size: 0.14rem;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $overlay-bg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.job-apply-method__dialog {
|
||||
position: relative;
|
||||
width: 4.8rem;
|
||||
background: $bg-white;
|
||||
border: 1px solid #F0F0F0;
|
||||
box-shadow: 0 0 0.16rem rgba(0, 0, 0, 0.05);
|
||||
border-radius: 0.12rem;
|
||||
padding: 0.28rem 0.32rem 0.32rem;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.job-apply-method__bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, #EDF9FA 0%, #FFFFFF 20.63%);
|
||||
border-radius: 0.12rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.job-apply-method__title {
|
||||
position: relative;
|
||||
font-size: 0.18rem;
|
||||
font-weight: 620;
|
||||
color: $text-dark;
|
||||
text-align: center;
|
||||
margin: 0 0 0.2rem 0;
|
||||
line-height: 0.24rem;
|
||||
}
|
||||
|
||||
.job-apply-method__close {
|
||||
position: absolute;
|
||||
top: 0.24rem;
|
||||
right: 0.24rem;
|
||||
width: 0.24rem;
|
||||
height: 0.24rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.job-apply-method__options {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0.16rem;
|
||||
margin-bottom: 0.24rem;
|
||||
}
|
||||
|
||||
.job-apply-method__card {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 1.06rem;
|
||||
background: $bg-white;
|
||||
border: 1px solid #F0F0F0;
|
||||
border-radius: 0.08rem;
|
||||
padding: 0.2rem 0;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: #D0D0D0;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: $accent;
|
||||
background: #EDF9FA;
|
||||
}
|
||||
}
|
||||
|
||||
.job-apply-method__card-title {
|
||||
font-size: 0.14rem;
|
||||
font-weight: 450;
|
||||
color: #000;
|
||||
margin: 0;
|
||||
line-height: 0.19rem;
|
||||
|
||||
&--accent {
|
||||
color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
.job-apply-method__card-desc {
|
||||
font-size: 0.12rem;
|
||||
font-weight: 330;
|
||||
color: $text-light;
|
||||
line-height: 0.2rem;
|
||||
margin: 0.06rem 0 0 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.job-apply-method__card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.04rem;
|
||||
}
|
||||
|
||||
.job-apply-method__card-circle {
|
||||
position: absolute;
|
||||
width: 0.46rem;
|
||||
height: 0.46rem;
|
||||
left: -0.05rem;
|
||||
bottom: -0.08rem;
|
||||
background: #AEE6EA;
|
||||
border-radius: 50%;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0.04rem;
|
||||
height: 0.09rem;
|
||||
background: $bg-white;
|
||||
border-radius: 0.02rem;
|
||||
top: 40%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 42%;
|
||||
}
|
||||
|
||||
&::after {
|
||||
left: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.job-apply-method__action {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0.35rem;
|
||||
border: none;
|
||||
border-radius: 0.08rem;
|
||||
font-size: 0.14rem;
|
||||
font-weight: 450;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
|
||||
&--disabled {
|
||||
background: #EDF9FA;
|
||||
color: #AEE6EA;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: $accent;
|
||||
color: $bg-white;
|
||||
|
||||
&:hover {
|
||||
background: $accent-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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 }">
|
||||
@@ -48,8 +34,8 @@
|
||||
<!-- 匹配度标题区域 -->
|
||||
<div class="job-resume-custom-dialog__gap-header">
|
||||
<div class="job-resume-custom-dialog__gap-left">
|
||||
<h3 class="job-resume-custom-dialog__gap-title">你的简历与该岗位的<span class="job-resume-custom-dialog__gap-title-highlight">匹配度{{ isLowMatch ? '较低' : '较高' }}</span></h3>
|
||||
<p class="job-resume-custom-dialog__gap-subtitle">匹配度低于 6.0 分的简历,在筛选环节可能会被优先淘汰。我们会帮你快速优化提升。</p>
|
||||
<h3 class="job-resume-custom-dialog__gap-title">你的简历与该岗位任职要求差距<span class="job-resume-custom-dialog__gap-title-highlight">{{ isLowMatch ? '较大' : '较小' }}</span></h3>
|
||||
<p class="job-resume-custom-dialog__gap-subtitle">竞争力低于 6.0 分的简历,在筛选环节可能会被优先淘汰。我们会帮你快速优化提升。</p>
|
||||
</div>
|
||||
<!-- 右侧匹配度分数 -->
|
||||
<div class="job-resume-custom-dialog__gap-score-area">
|
||||
@@ -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分) */
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
<div class="h1 border-EDF9FA-bottom-2 mrl14"></div>
|
||||
|
||||
<!-- 技能 -->
|
||||
<div ref="skillsRef" class="profile-page-content__card" :class="{ 'profile-page-content__card-hover': editingModule !== 'skills' }">
|
||||
<div ref="skillsRef" class="profile-page-content__card mtb16" :class="{ 'profile-page-content__card-hover': editingModule !== 'skills' }">
|
||||
<template v-if="editingModule !== 'skills'">
|
||||
<div class="profile-page-content__card-header">
|
||||
<h3 class="profile-page-content__card-title">技能</h3>
|
||||
@@ -270,7 +270,7 @@
|
||||
<div class="h1 border-EDF9FA-bottom-2 mrl14"></div>
|
||||
|
||||
<!-- 证书 -->
|
||||
<div ref="certificateRef" class="profile-page-content__card" :class="{ 'profile-page-content__card-hover': editingModule !== 'certificate' }">
|
||||
<div ref="certificateRef" class="profile-page-content__card mtb16" :class="{ 'profile-page-content__card-hover': editingModule !== 'certificate' }">
|
||||
<template v-if="editingModule !== 'certificate'">
|
||||
<div class="profile-page-content__card-header">
|
||||
<h3 class="profile-page-content__card-title">证书</h3>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<div class="profile-edit-inline__fields-row">
|
||||
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">学校</label><input class="profile-edit-inline__input" placeholder="请输入学校名称" v-model="form.school" /></div>
|
||||
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">专业</label><input class="profile-edit-inline__input" placeholder="请输入专业名称" v-model="form.major" /></div>
|
||||
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">学历</label><select class="profile-edit-inline__input" v-model="form.degree"><option value="大专">大专</option><option value="本科">本科</option><option value="硕士">硕士</option><option value="博士">博士</option></select></div>
|
||||
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">学历类型</label><select class="profile-edit-inline__input" v-model="form.studyType"><option value="全日制">全日制</option><option value="非全日制">非全日制</option></select></div>
|
||||
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">学历</label><select class="profile-edit-inline__input" v-model="form.degree"><option :value="1">大专</option><option :value="2">本科</option><option :value="3">硕士</option><option :value="4">博士</option></select></div>
|
||||
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">学历类型</label><select class="profile-edit-inline__input" v-model="form.studyType"><option :value="0">全日制</option><option :value="1">非全日制</option></select></div>
|
||||
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">就读时间</label>
|
||||
<div class="profile-edit-inline__date-range">
|
||||
<el-date-picker v-model="form.startDate" type="month" placeholder="开始" format="YYYY-MM" value-format="YYYY-MM" class="profile-edit-inline__date-picker" :teleported="true" popper-class="profile-drawer-date-popper" />
|
||||
@@ -180,6 +180,17 @@ function initForm() {
|
||||
aiPolishResult.value = ''
|
||||
if (props.initialData) {
|
||||
form.value = { ...props.initialData }
|
||||
// 教育经历:确保 degree 和 studyType 为整数(兼容旧数据可能是中文字符串)
|
||||
if (props.moduleType === 'education') {
|
||||
const degreeMap: Record<string, number> = { '大专': 1, '本科': 2, '硕士': 3, '博士': 4 }
|
||||
const studyTypeMap: Record<string, number> = { '全日制': 0, '非全日制': 1 }
|
||||
if (typeof form.value.degree === 'string') {
|
||||
form.value.degree = degreeMap[form.value.degree] ?? (Number(form.value.degree) || null)
|
||||
}
|
||||
if (typeof form.value.studyType === 'string') {
|
||||
form.value.studyType = studyTypeMap[form.value.studyType] ?? (Number(form.value.studyType) || null)
|
||||
}
|
||||
}
|
||||
// 将 description 数组拼接为文本
|
||||
if (props.initialData.description?.length) {
|
||||
descriptionText.value = props.initialData.description.map((d: DescriptionParagraph) => d.text).join('\n')
|
||||
|
||||
@@ -105,7 +105,7 @@ watch(() => props.modelValue, async (val) => {
|
||||
const inviteCode = computed(() => store.state.userInfo?.inviteCode || 'NOVA2026')
|
||||
|
||||
/** 邀请链接地址 */
|
||||
const inviteUrl = computed(() => `https://www.offerpai.com.cn/invite?code=${inviteCode.value}`)
|
||||
const inviteUrl = computed(() => `https://www.offerpai.com.cn/?invite_code=${inviteCode.value}`)
|
||||
|
||||
/** 复制链接到剪贴板 */
|
||||
async function handleCopy() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { RouteRecordRaw } from 'vue-router'
|
||||
import store from '@/stores'
|
||||
import { checkLogin } from '@/api/auth'
|
||||
import { fetchMemberStatus } from '@/api/member'
|
||||
import { fetchProfile } from '@/api/profile'
|
||||
import { ENABLE_FORCE_RESUME_UPLOAD } from '@/business.config'
|
||||
|
||||
/**
|
||||
* 静态路由 — 不需要权限,应用启动就注册
|
||||
@@ -11,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',
|
||||
@@ -33,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({
|
||||
@@ -98,9 +106,13 @@ router.beforeEach(async (to, _from, next) => {
|
||||
// 每次页面切换时静默刷新路由菜单数据(更新权限状态),不阻塞导航
|
||||
store.dispatch('refreshDynamicMenus')
|
||||
|
||||
// 已登录用户访问登录页,直接跳转首页
|
||||
// 已登录用户访问登录页,如果带 resumeUpload=1 参数则放行(简历上传流程),否则跳转首页
|
||||
if (to.name === 'Login' && store.state.isAuthenticated) {
|
||||
next({ name: 'Home' })
|
||||
if (to.query.resumeUpload === '1') {
|
||||
next()
|
||||
} else {
|
||||
next({ name: 'Home' })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,6 +124,16 @@ router.beforeEach(async (to, _from, next) => {
|
||||
if (res.code === '0' && res.data === true) {
|
||||
// Cookie 有效,同步前端状态并放行
|
||||
store.commit('SET_AUTHENTICATED', true)
|
||||
// 检查个人资料是否为空(强制简历上传机制)
|
||||
if (ENABLE_FORCE_RESUME_UPLOAD) {
|
||||
try {
|
||||
const profileRes = await fetchProfile()
|
||||
if (profileRes.code === '0' && !profileRes.data) {
|
||||
next({ name: 'Login', query: { resumeUpload: '1' } })
|
||||
return
|
||||
}
|
||||
} catch { /* 接口异常不阻断 */ }
|
||||
}
|
||||
next()
|
||||
} else {
|
||||
// 未登录或 Cookie 失效 — 跳转登录页
|
||||
@@ -129,6 +151,16 @@ router.beforeEach(async (to, _from, next) => {
|
||||
// Agent 页面需要会员权限 — 等待会员信息加载后判断
|
||||
if (to.name === 'Agent') {
|
||||
try {
|
||||
// 先检查个人资料是否为空(强制简历上传机制)
|
||||
if (ENABLE_FORCE_RESUME_UPLOAD) {
|
||||
try {
|
||||
const profileRes = await fetchProfile()
|
||||
if (profileRes.code === '0' && !profileRes.data) {
|
||||
next({ name: 'Login', query: { resumeUpload: '1' } })
|
||||
return
|
||||
}
|
||||
} catch { /* 接口异常不阻断 */ }
|
||||
}
|
||||
// 确保会员信息已加载到 store
|
||||
if (!store.state.memberStatus) {
|
||||
const res = await fetchMemberStatus()
|
||||
@@ -169,7 +201,32 @@ router.beforeEach(async (to, _from, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 已登录 + 非首页/登录页/移动首页:检查个人资料是否为空,为空则强制跳转简历上传
|
||||
const skipProfileCheck = ['Home', 'HomeMobile', 'Login']
|
||||
if (
|
||||
ENABLE_FORCE_RESUME_UPLOAD &&
|
||||
store.state.isAuthenticated &&
|
||||
!skipProfileCheck.includes(to.name as string)
|
||||
) {
|
||||
try {
|
||||
const profileRes = await fetchProfile()
|
||||
if (profileRes.code === '0' && !profileRes.data) {
|
||||
// 个人资料为空 — 跳转到登录页简历上传步骤
|
||||
next({ name: 'Login', query: { resumeUpload: '1' } })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 接口异常不阻断导航
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
// 友盟统计 - SPA 路由切换上报页面浏览(PV)
|
||||
router.afterEach((to) => {
|
||||
const _czc = (window as any)._czc || []
|
||||
_czc.push(['_trackPageview', to.fullPath, document.referrer])
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { UserInfo } from '@/api/auth'
|
||||
import { fetchMemberStatus } from '@/api/member'
|
||||
import type { MemberStatus } from '@/api/member'
|
||||
import { createChannelBridge } from '@/utils/channelBridge'
|
||||
import { PLUGIN_TARGET_VERSION } from '@/business.config'
|
||||
|
||||
/** 招聘分类选项:label → 接口参数 recruitCategory */
|
||||
export const JOB_TYPE_OPTIONS: { label: string; value: number }[] = [
|
||||
@@ -212,13 +213,13 @@ export default createStore<RootState>({
|
||||
inviteCode: '',
|
||||
showMemberAccessDialog: false,
|
||||
/** 通信桥接组件的域名标识(用于 BroadcastChannel 频道隔离) */
|
||||
bridgeDomain: 'test.offerpai.com.cn',
|
||||
bridgeDomain: 'offerpai.com.cn',
|
||||
/** 浏览器插件是否在线 */
|
||||
plugIsOnline: false,
|
||||
/** 插件当前使用版本 */
|
||||
plugUseVersion: '',
|
||||
/** 插件目标版本 */
|
||||
plugTargetVersion: '0.1.0',
|
||||
plugTargetVersion: PLUGIN_TARGET_VERSION,
|
||||
/** 插件是否是最新版本 */
|
||||
isLatestPlugin: true,
|
||||
},
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import axios from 'axios'
|
||||
|
||||
/** 默认 Token(Nova 对话接口使用) */
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
* ------------------------------------------------------------
|
||||
* | 数据名(name) | 中文名称 | 说明 |
|
||||
* | ----------------------------- | -------------- | --------------------------------- |
|
||||
* | offerpieBrowserPlugUsage | 插件使用状态 | 标识插件正在运行 { usage: string } |
|
||||
* | offerpieBrowserPlugUsage | 插件使用状态 | 标识插件正在运行 { usage: string, version: string } |
|
||||
* | offerpaiDeliveryLinkList | 投递链接列表 | 记录要投递职位的来源链接,目前用于解决投递网站的重定向 { linkList: string[] } |
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
@@ -107,8 +108,6 @@ export interface ChannelBridgeOptions {
|
||||
|
||||
/** IndexedDB 数据库名称(含固定雪花ID保证唯一性) */
|
||||
const DB_NAME = 'offerpie_comm_7394028156183472'
|
||||
/** IndexedDB 版本号 */
|
||||
const DB_VERSION = 1
|
||||
/** get 方法 BroadcastChannel 超时时间(毫秒) */
|
||||
const GET_TIMEOUT_MS = 60
|
||||
/** emit 方法节流写库间隔(毫秒) */
|
||||
@@ -234,10 +233,30 @@ function _getCslProxyChannel(domain: string): BroadcastChannel {
|
||||
|
||||
// ============ IndexedDB 操作 ============
|
||||
|
||||
/** 打开/创建 IndexedDB 数据库,按 domain 动态创建 ObjectStore */
|
||||
/** 数据库连接缓存(避免重复打开和版本冲突) */
|
||||
const _dbCache = new Map<string, Promise<IDBDatabase>>()
|
||||
|
||||
/** 打开/创建 IndexedDB 数据库,按 domain 动态创建 ObjectStore,带连接缓存防并发冲突 */
|
||||
function openDB(storeName: string): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
// 如果已有进行中或已完成的连接,直接复用
|
||||
if (_dbCache.has(storeName)) {
|
||||
return _dbCache.get(storeName)!.then(db => {
|
||||
// 检查连接是否还有效(可能被 close 了)
|
||||
try {
|
||||
// 尝试创建事务验证连接有效性
|
||||
if (db.objectStoreNames.contains(storeName)) {
|
||||
return db
|
||||
}
|
||||
} catch {
|
||||
// 连接已关闭,清除缓存重新打开
|
||||
_dbCache.delete(storeName)
|
||||
}
|
||||
return openDB(storeName)
|
||||
})
|
||||
}
|
||||
|
||||
const promise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME)
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
@@ -247,9 +266,10 @@ function openDB(storeName: string): Promise<IDBDatabase> {
|
||||
}
|
||||
request.onsuccess = () => {
|
||||
const db = request.result
|
||||
// 如果 store 不存在(版本没升级的情况),关闭后升版本重建
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
// store 不存在,需要升版本。关闭当前连接再重开
|
||||
db.close()
|
||||
_dbCache.delete(storeName)
|
||||
const version = db.version + 1
|
||||
const req2 = indexedDB.open(DB_NAME, version)
|
||||
req2.onupgradeneeded = () => {
|
||||
@@ -260,37 +280,60 @@ function openDB(storeName: string): Promise<IDBDatabase> {
|
||||
}
|
||||
}
|
||||
req2.onsuccess = () => resolve(req2.result)
|
||||
req2.onerror = () => reject(req2.error)
|
||||
req2.onerror = () => { _dbCache.delete(storeName); reject(req2.error) }
|
||||
req2.onblocked = () => {
|
||||
// 其他标签页占用数据库导致升级被阻塞,超时后放弃
|
||||
console.warn('[ChannelBridge] IndexedDB upgrade blocked, retrying...')
|
||||
_dbCache.delete(storeName)
|
||||
reject(new Error('IndexedDB upgrade blocked'))
|
||||
}
|
||||
} else {
|
||||
resolve(db)
|
||||
}
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onerror = () => { _dbCache.delete(storeName); reject(request.error) }
|
||||
request.onblocked = () => {
|
||||
console.warn('[ChannelBridge] IndexedDB open blocked')
|
||||
_dbCache.delete(storeName)
|
||||
reject(new Error('IndexedDB open blocked'))
|
||||
}
|
||||
})
|
||||
|
||||
_dbCache.set(storeName, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
/** 写入或更新一条记录到 IndexedDB */
|
||||
async function dbPut(storeName: string, record: DBRecord): Promise<void> {
|
||||
const db = await openDB(storeName)
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, 'readwrite')
|
||||
const store = tx.objectStore(storeName)
|
||||
store.put(record)
|
||||
tx.oncomplete = () => { db.close(); resolve() }
|
||||
tx.onerror = () => { db.close(); reject(tx.error) }
|
||||
})
|
||||
try {
|
||||
const db = await openDB(storeName)
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, 'readwrite')
|
||||
const store = tx.objectStore(storeName)
|
||||
store.put(record)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[ChannelBridge] dbPut failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 IndexedDB 读取一条记录 */
|
||||
async function dbGet(storeName: string, name: string): Promise<DBRecord | null> {
|
||||
const db = await openDB(storeName)
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, 'readonly')
|
||||
const store = tx.objectStore(storeName)
|
||||
const req = store.get(name)
|
||||
req.onsuccess = () => { db.close(); resolve(req.result || null) }
|
||||
req.onerror = () => { db.close(); reject(req.error) }
|
||||
})
|
||||
try {
|
||||
const db = await openDB(storeName)
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, 'readonly')
|
||||
const store = tx.objectStore(storeName)
|
||||
const req = store.get(name)
|
||||
req.onsuccess = () => resolve(req.result || null)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[ChannelBridge] dbGet failed:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 核心:创建通信桥接实例 ============
|
||||
@@ -498,19 +541,23 @@ export function createChannelBridge(options: ChannelBridgeOptions) {
|
||||
}
|
||||
|
||||
// 默认模式:IndexedDB
|
||||
// 先尝试获取 createTime(从暂存或数据库)
|
||||
// 先尝试获取 createTime(从暂存或内存缓存)
|
||||
let createTime: string
|
||||
const pending = emitPending.get(key)
|
||||
if (pending) {
|
||||
createTime = pending.createTime
|
||||
} else {
|
||||
const existing = await dbGet(targetDomain, name)
|
||||
// 只在首次查库获取 createTime,失败就用当前时间(不阻塞后续 emit)
|
||||
let existing: DBRecord | null = null
|
||||
try {
|
||||
existing = await dbGet(targetDomain, name)
|
||||
} catch {}
|
||||
createTime = existing?.createTime || now
|
||||
}
|
||||
const updateTime = now
|
||||
const record: DBRecord = { name, data, createTime, updateTime }
|
||||
|
||||
// 暂存最新数据
|
||||
// 暂存最新数据(不再 delete,保证后续 emit 总能命中缓存不走 dbGet)
|
||||
emitPending.set(key, record)
|
||||
|
||||
// BroadcastChannel 每次都广播(保证 watch 实时性)
|
||||
@@ -524,7 +571,7 @@ export function createChannelBridge(options: ChannelBridgeOptions) {
|
||||
emitTimers.delete(key)
|
||||
const latestRecord = emitPending.get(key)
|
||||
if (latestRecord) {
|
||||
emitPending.delete(key)
|
||||
// 注意:不再 delete emitPending,让下次 emit 继续命中缓存
|
||||
await dbPut(targetDomain, latestRecord)
|
||||
}
|
||||
}, EMIT_THROTTLE_MS))
|
||||
|
||||
@@ -102,7 +102,7 @@ export function exportResumeWord(element: HTMLElement, fileName: string) {
|
||||
const blob = new Blob([fullHtml], { type: 'application/msword' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `${fileName}.doc`
|
||||
link.download = `${fileName}.docx`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
@@ -262,10 +262,17 @@ import { fetchResumeList } from '@/api/resume'
|
||||
import { fetchMemberStatus } from '@/api/member'
|
||||
import JobGoalDialog from '@/components/JobGoalDialog.vue'
|
||||
|
||||
|
||||
// ==================== 路由 & Store ====================
|
||||
const router = useRouter()
|
||||
const store = useStore()
|
||||
|
||||
// 声明友盟统计 _czc 对象
|
||||
const _czc: any[] = (window as any)._czc || [];
|
||||
(window as any)._czc = _czc;
|
||||
// 绑定 siteid,请用您的 siteid 替换下方 "XXXXXXXX" 部分
|
||||
_czc.push(["_setAccount", "1281490571"]);
|
||||
|
||||
// ==================== sessionStorage 缓存 key ====================
|
||||
const STORAGE_KEY = 'agent_apply_progress_list'
|
||||
const COMPLETED_STORAGE_KEY = 'agent_completed_apply_list'
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<img src="@/assets/images/logo.png" alt="Offer派" class="home-nav__logo-img" />
|
||||
</div>
|
||||
<div class="dflex-end aliite-c">
|
||||
<!-- 未登录时显示登陆和立即加入按钮 -->
|
||||
<div v-if="!store.state.isAuthenticated" class="fs14 color-1 mr40 fw600 cursor-po" @click="handleLoginClick">登陆</div>
|
||||
<!-- 未登录时显示登录和立即加入按钮 -->
|
||||
<div v-if="!store.state.isAuthenticated" class="fs14 color-1 mr40 fw600 cursor-po" @click="handleLoginClick">登录</div>
|
||||
<button v-if="!store.state.isAuthenticated" class="home-nav__btn" @click="router.push('/jobs')">立即加入</button>
|
||||
<!-- 已登录时显示进入平台按钮 -->
|
||||
<button v-else class="home-nav__btn" @click="router.push('/jobs')">进入平台</button>
|
||||
@@ -23,7 +23,7 @@
|
||||
</header>
|
||||
<div class="home-hero__inner">
|
||||
<div class="home-hero__left">
|
||||
<h1 class="home-hero__title">AI驱动的<br/><span class="dib w56"></span>一站式求职辅助平台</h1>
|
||||
<h1 class="home-hero__title">Offer派,<br/><span class="dib"></span>收offer就是快!</h1>
|
||||
<p class="home-hero__desc">智能匹配职位、自动填写申请、量身定制简历、推荐内部人脉——<br/>不到1分钟,统统搞定!</p>
|
||||
<button class="home-hero__cta" @click="router.push('/jobs')">免费体验</button>
|
||||
</div>
|
||||
@@ -479,6 +479,7 @@
|
||||
</div>
|
||||
<div class="home-footer__bottom">
|
||||
<p>©2016-2026 - 广州油梨信息科技有限公司 版权所有</p>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" class="">粤ICP备16019697号-7</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -506,6 +507,12 @@ const router = useRouter()
|
||||
/** Vuex 状态管理实例 */
|
||||
const store = useStore()
|
||||
|
||||
// 声明友盟统计 _czc 对象
|
||||
const _czc: any[] = (window as any)._czc || [];
|
||||
(window as any)._czc = _czc;
|
||||
// 绑定 siteid,请用您的 siteid 替换下方 "XXXXXXXX" 部分
|
||||
_czc.push(["_setAccount", "1281490571"]);
|
||||
|
||||
/** 页面是否已向下滚动超过 20px — 用于导航栏背景切换 */
|
||||
const isScrolled = ref(false)
|
||||
|
||||
@@ -681,7 +688,7 @@ onMounted(() => {
|
||||
store.dispatch('loadCommonData')
|
||||
})
|
||||
|
||||
/** 点击登陆按钮 — 跳转到登录页面,登录成功后跳转到 jobs 页面 */
|
||||
/** 点击登录按钮 — 跳转到登录页面,登录成功后跳转到 jobs 页面 */
|
||||
function handleLoginClick() {
|
||||
router.push({ name: 'Login', query: { redirect: '/jobs' } })
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"真没想到校招居然可以这么轻松高效!一周就收到了3个面试邀约。"
|
||||
</p>
|
||||
<div class="home-mobile__quote-author">
|
||||
<div class="home-mobile__quote-avatar"></div>
|
||||
<div class="home-mobile__quote-avatar"><img class="w45 h45 " src="@/assets/images/home/student-avatar-0.png" alt=""></div>
|
||||
<div class="home-mobile__quote-info">
|
||||
<span class="home-mobile__quote-name">李同学</span>
|
||||
<span class="home-mobile__quote-school">西安交通大学</span>
|
||||
@@ -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>
|
||||
@@ -170,9 +170,57 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { getDailyJobCount } from '@/utils/dailyJobCount'
|
||||
|
||||
// ==================== 移动端缩放重置 ====================
|
||||
/**
|
||||
* 确保 HomeMobile 页面不受全局 transform scale 缩放影响
|
||||
* 部分浏览器中 remAdapt 插件的 recalc 在路由切换后未及时重新执行,
|
||||
* 导致 body 上仍残留 PC 端 scale 样式,这里在组件挂载时主动重置
|
||||
*/
|
||||
function resetMobileScale() {
|
||||
const docEl = document.documentElement
|
||||
const body = document.body
|
||||
const clientWidth = docEl.clientWidth || window.innerWidth
|
||||
// 移动端首页 1rem = 视口宽度 / 3.90(设计稿 390px)
|
||||
const mobileFontSize = clientWidth / 3.90
|
||||
docEl.style.fontSize = mobileFontSize + 'px'
|
||||
// 清除 scale 缩放相关样式
|
||||
body.style.width = ''
|
||||
body.style.height = ''
|
||||
body.style.minHeight = ''
|
||||
body.style.overflowY = ''
|
||||
body.style.transform = ''
|
||||
body.style.transformOrigin = ''
|
||||
docEl.style.height = ''
|
||||
docEl.style.overflow = ''
|
||||
docEl.style.setProperty('--app-height', '100vh')
|
||||
docEl.style.setProperty('--chat-width', '100%')
|
||||
}
|
||||
|
||||
/** 窗口 resize 时重新执行重置 */
|
||||
function onResize() {
|
||||
resetMobileScale()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resetMobileScale()
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('orientationchange', onResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('orientationchange', onResize)
|
||||
})
|
||||
|
||||
// 声明友盟统计 _czc 对象
|
||||
const _czc: any[] = (window as any)._czc || [];
|
||||
(window as any)._czc = _czc;
|
||||
// 绑定 siteid,请用您的 siteid 替换下方 "XXXXXXXX" 部分
|
||||
_czc.push(["_setAccount", "1281490571"]);
|
||||
|
||||
/** 今日更新岗位数量 */
|
||||
const dailyJobCount = getDailyJobCount()
|
||||
|
||||
|
||||
@@ -169,6 +169,15 @@
|
||||
<MemberAccessDialog v-model="showMemberAccessDialog" @open-member="showMemberDialog = true" />
|
||||
<!-- 会员购买弹窗 -->
|
||||
<MemberDialog v-model="showMemberDialog" />
|
||||
|
||||
<!-- 选择投递方式弹窗 -->
|
||||
<JobApplyMethodDialog
|
||||
ref="applyMethodDialogRef"
|
||||
v-model="showApplyMethodDialog"
|
||||
:source-url="job.sourceUrl"
|
||||
:job-id="jobId"
|
||||
@open-member="showMemberDialog = true"
|
||||
/>
|
||||
<!-- 全屏加载遮罩 -->
|
||||
<FullscreenLoading :visible="fullLoading" :text="fullLoadingText" title="Nova 正在帮您努力处理~" />
|
||||
|
||||
@@ -187,7 +196,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
import { formatEmploymentType } from '@/stores/index'
|
||||
@@ -199,16 +208,26 @@ import MemberAccessDialog from '@/components/MemberAccessDialog.vue'
|
||||
import FullscreenLoading from '@/components/FullscreenLoading.vue'
|
||||
import MemberDialog from '@/components/MemberDialog.vue'
|
||||
import StepProgressOverlay from '@/components/StepProgressOverlay.vue'
|
||||
import JobApplyMethodDialog from '@/components/JobApplyMethodDialog.vue'
|
||||
import { fetchJobDetail, toggleJobFavorite, removeJobFavorite, fetchSkillGap } from '@/api/jobs'
|
||||
import { fetchMemberStatus } from '@/api/member'
|
||||
|
||||
import type { JobDetailData, SkillGapData } from '@/api/jobs'
|
||||
|
||||
|
||||
|
||||
// ==================== 路由相关 ====================
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useStore()
|
||||
|
||||
|
||||
// 声明友盟统计 _czc 对象
|
||||
const _czc: any[] = (window as any)._czc || [];
|
||||
(window as any)._czc = _czc;
|
||||
// 绑定 siteid,请用您的 siteid 替换下方 "XXXXXXXX" 部分
|
||||
_czc.push(["_setAccount", "1281490571"]);
|
||||
|
||||
// ==================== 全屏加载状态 ====================
|
||||
/** 全屏加载是否显示 */
|
||||
const fullLoading = ref(false)
|
||||
@@ -482,16 +501,20 @@ const resumeCustomJobInfo = computed(() => ({
|
||||
|
||||
/** 生成岗位专属简历 — 先检查会员状态,非会员弹权限拦截弹窗 */
|
||||
async function handleGenerateResume() {
|
||||
// 先检查会员状态
|
||||
try {
|
||||
const statusRes = await fetchMemberStatus()
|
||||
if (statusRes.code === '0' && statusRes.data && !statusRes.data.isMember) {
|
||||
// 非会员 — 弹出会员权限拦截弹窗
|
||||
showMemberAccessDialog.value = true
|
||||
return
|
||||
// 优先从 vuex store 读取会员状态,没有则请求接口
|
||||
let isMember = store.state.memberStatus?.isMember
|
||||
if (isMember === undefined || isMember === null) {
|
||||
try {
|
||||
await store.dispatch('loadMemberStatus')
|
||||
isMember = store.state.memberStatus?.isMember
|
||||
} catch {
|
||||
// 接口异常时不阻断,继续执行
|
||||
}
|
||||
} catch {
|
||||
// 接口异常时不阻断,继续执行
|
||||
}
|
||||
if (!isMember) {
|
||||
// 非会员 — 弹出会员权限拦截弹窗
|
||||
showMemberAccessDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 显示步骤进度遮罩
|
||||
@@ -573,10 +596,14 @@ async function toggleFavorite() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 去投递 — 跳转到来源链接 */
|
||||
/** 去投递 — 打开选择投递方式弹窗 */
|
||||
const showApplyMethodDialog = ref(false)
|
||||
const applyMethodDialogRef = ref<InstanceType<typeof JobApplyMethodDialog> | null>(null)
|
||||
|
||||
function handleApply() {
|
||||
if (job.sourceUrl) {
|
||||
window.open(job.sourceUrl, '_blank')
|
||||
}
|
||||
showApplyMethodDialog.value = true
|
||||
nextTick(() => {
|
||||
applyMethodDialogRef.value?.open()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -63,9 +63,9 @@
|
||||
:displayStyle="selectorDisplayStyle"
|
||||
@update:industryIds="onIndustryChange"
|
||||
/>
|
||||
<!-- 其他筛选条件 -->
|
||||
<!-- 招聘分类筛选条件 -->
|
||||
<div
|
||||
v-else
|
||||
v-else-if="filter.key === 'jobType'"
|
||||
class="jobs-page__filter-item"
|
||||
@click="handleFilterClick(filter)"
|
||||
>
|
||||
@@ -73,7 +73,7 @@
|
||||
<svg class="jobs-page__filter-arrow-icon" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<!-- 工作类型下拉菜单 -->
|
||||
<!-- 招聘分类下拉菜单 -->
|
||||
<div
|
||||
v-if="filter.key === 'jobType' && showJobTypeDropdown"
|
||||
class="jobs-page__filter-dropdown"
|
||||
@@ -90,6 +90,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 公司类型筛选条件 -->
|
||||
<div
|
||||
v-else-if="filter.key === 'companyType'"
|
||||
class="jobs-page__filter-item"
|
||||
@click.stop="handleFilterClick(filter)"
|
||||
>
|
||||
<span>{{ filter.selected || filter.label }}</span>
|
||||
<svg class="jobs-page__filter-arrow-icon" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<!-- 公司类型下拉菜单 -->
|
||||
<div
|
||||
v-if="showCompanyTypeDropdown"
|
||||
class="jobs-page__filter-dropdown"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
class="jobs-page__filter-dropdown-item"
|
||||
v-for="option in companyTypeOptions"
|
||||
:key="option.label"
|
||||
:class="{ 'jobs-page__filter-dropdown-item--active': filter.selected === option.label || (!filter.selected && !option.value.length) }"
|
||||
@click.stop="selectCompanyType(filter, option)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
<div class="jobs-page__search-box">
|
||||
@@ -116,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>
|
||||
@@ -264,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>
|
||||
@@ -282,50 +319,13 @@
|
||||
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="feedbackJobId" />
|
||||
|
||||
<!-- 选择投递方式弹窗 -->
|
||||
<div v-if="showAgentRemindDialog" class="jobs-page__apply-method-overlay" @click.self="closeAgentRemind">
|
||||
<div class="jobs-page__apply-method-dialog">
|
||||
<!-- 渐变背景层 -->
|
||||
<div class="jobs-page__apply-method-bg"></div>
|
||||
<!-- 标题 -->
|
||||
<p class="jobs-page__apply-method-title">选择投递方式</p>
|
||||
<!-- 关闭按钮 -->
|
||||
<span class="jobs-page__apply-method-close" @click="closeAgentRemind">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6l12 12" stroke="#6A7282" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</span>
|
||||
<!-- 两个选项卡 -->
|
||||
<div class="jobs-page__apply-method-options">
|
||||
<!-- 直接前往官网 -->
|
||||
<div class="jobs-page__apply-method-card" :class="{ 'jobs-page__apply-method-card--active': applyMethodChoice === 'direct' }" @click="applyMethodChoice = 'direct'">
|
||||
<p class="jobs-page__apply-method-card-title">直接前往官网</p>
|
||||
<p class="jobs-page__apply-method-card-desc">手动填写网申信息<br>自行记录投递进度</p>
|
||||
</div>
|
||||
<!-- 智能投递助手 -->
|
||||
<div class="jobs-page__apply-method-card" :class="{ 'jobs-page__apply-method-card--active': applyMethodChoice === 'agent' }" @click="applyMethodChoice = 'agent'">
|
||||
<!-- 左下角圆形装饰 -->
|
||||
<div class="jobs-page__apply-method-card-circle"></div>
|
||||
<div class="jobs-page__apply-method-card-header">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 1L10 5.5L15 6.5L11.5 10L12.5 15L8 12.5L3.5 15L4.5 10L1 6.5L6 5.5L8 1Z" fill="#52CAD1"/></svg>
|
||||
<span class="jobs-page__apply-method-card-title jobs-page__apply-method-card-title--accent">智能投递助手</span>
|
||||
</div>
|
||||
<p class="jobs-page__apply-method-card-desc">{{ agentCardDesc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部操作按钮 -->
|
||||
<button class="jobs-page__apply-method-action" :class="applyMethodActionClass" :disabled="!applyMethodChoice" @click="handleApplyMethodAction">
|
||||
<svg v-if="applyMethodActionIcon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_166_1554)">
|
||||
<path d="M6.0714 3.63204C6.47007 2.46537 8.08207 2.43004 8.55474 3.52604L8.59474 3.63271L9.13274 5.20604C9.25603 5.56686 9.45527 5.89705 9.71702 6.17432C9.97877 6.4516 10.2969 6.66952 10.6501 6.81337L10.7947 6.86737L12.3681 7.40471C13.5347 7.80337 13.5701 9.41537 12.4747 9.88804L12.3681 9.92804L10.7947 10.466C10.4338 10.5893 10.1035 10.7885 9.82608 11.0502C9.54869 11.312 9.33067 11.6302 9.18674 11.9834L9.13274 12.1274L8.5954 13.7014C8.19674 14.868 6.58474 14.9034 6.11274 13.808L6.0714 13.7014L5.53407 12.128C5.41086 11.7671 5.21165 11.4368 4.9499 11.1594C4.68814 10.882 4.36993 10.664 4.01674 10.52L3.87274 10.466L2.2994 9.92871C1.13207 9.53004 1.09674 7.91804 2.19274 7.44604L2.2994 7.40471L3.87274 6.86737C4.23356 6.74408 4.56374 6.54484 4.84102 6.28309C5.11829 6.02134 5.33621 5.70317 5.48007 5.35004L5.53407 5.20604L6.0714 3.63204ZM7.3334 4.06271L6.79607 5.63604C6.60833 6.18624 6.30292 6.68891 5.90109 7.10904C5.49927 7.52917 5.0107 7.85666 4.4694 8.06871L4.30274 8.12937L2.7294 8.66671L4.30274 9.20404C4.85294 9.39178 5.35561 9.6972 5.77573 10.099C6.19586 10.5008 6.52335 10.9894 6.7354 11.5307L6.79607 11.6974L7.3334 13.2707L7.87074 11.6974C8.05848 11.1472 8.36389 10.6445 8.76571 10.2244C9.16753 9.80425 9.65611 9.47676 10.1974 9.26471L10.3641 9.20471L11.9374 8.66671L10.3641 8.12937C9.81387 7.94163 9.3112 7.63622 8.89107 7.2344C8.47094 6.83258 8.14345 6.34401 7.9314 5.80271L7.8714 5.63604L7.3334 4.06271ZM12.6667 1.33337C12.7915 1.33337 12.9137 1.36836 13.0195 1.43436C13.1253 1.50035 13.2105 1.59471 13.2654 1.70671L13.2974 1.78471L13.5307 2.46871L14.2154 2.70204C14.3404 2.7445 14.45 2.82312 14.5302 2.92792C14.6105 3.03272 14.6578 3.15899 14.6663 3.29072C14.6747 3.42246 14.6438 3.55373 14.5775 3.6679C14.5113 3.78207 14.4126 3.874 14.2941 3.93204L14.2154 3.96404L13.5314 4.19737L13.2981 4.88204C13.2555 5.00699 13.1769 5.11651 13.072 5.1967C12.9672 5.2769 12.8409 5.32417 12.7092 5.33252C12.5774 5.34088 12.4462 5.30994 12.3321 5.24363C12.2179 5.17732 12.1261 5.07862 12.0681 4.96004L12.0361 4.88204L11.8027 4.19804L11.1181 3.96471C10.9931 3.92224 10.8835 3.84363 10.8033 3.73883C10.723 3.63403 10.6756 3.50776 10.6672 3.37603C10.6588 3.24429 10.6897 3.11302 10.7559 2.99885C10.8222 2.88468 10.9208 2.79275 11.0394 2.73471L11.1181 2.70271L11.8021 2.46937L12.0354 1.78471C12.0804 1.65299 12.1654 1.53865 12.2786 1.45771C12.3918 1.37677 12.5276 1.33329 12.6667 1.33337Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_166_1554">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
{{ applyMethodActionText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<JobApplyMethodDialog
|
||||
ref="applyMethodDialogRef"
|
||||
v-model="showAgentRemindDialog"
|
||||
:source-url="pendingApplyUrl"
|
||||
:job-id="pendingApplyJobId"
|
||||
@open-member="showMemberDialog = true"
|
||||
/>
|
||||
|
||||
<!-- 会员购买弹窗 -->
|
||||
<MemberDialog v-model="showMemberDialog" />
|
||||
@@ -366,7 +366,6 @@
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
import { createChannelBridge } from '@/utils/channelBridge'
|
||||
import { JOB_TYPE_OPTIONS, formatEmploymentType, formatEducation } from '@/stores/index'
|
||||
import SideNav from '@/components/SideNav.vue'
|
||||
import AiChat from '@/components/AiChat.vue'
|
||||
@@ -374,6 +373,7 @@ import JobPageHeader from '@/components/JobPageHeader.vue'
|
||||
import JobDislikeDialog from '@/components/JobDislikeDialog.vue'
|
||||
import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
|
||||
import MemberDialog from '@/components/MemberDialog.vue'
|
||||
import JobApplyMethodDialog from '@/components/JobApplyMethodDialog.vue'
|
||||
import IndustrySelector from '@/components/tools/IndustrySelector.vue'
|
||||
import JobCategorySelector from '@/components/tools/JobCategorySelector.vue'
|
||||
import RegionSelector from '@/components/tools/RegionSelector.vue'
|
||||
@@ -384,12 +384,12 @@ import type { JobListItem, JobListParams, FavoriteListParams, ApplyListParams, A
|
||||
import { getTimeEvent, setTimeEvent, timestampToLocalDateTime } from '@/utils/time'
|
||||
import { fetchIndustryTree, fetchJobCategoryTree } from '@/api/common'
|
||||
import type { IndustryItem, JobCategoryItem } from '@/api/common'
|
||||
import { fetchAgentConfig, applyJob } from '@/api/agent'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
|
||||
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
|
||||
|
||||
|
||||
|
||||
// 2. 注意:这里的字符串不需要再手动转义双引号了
|
||||
const paymentFormHtml = ref(`
|
||||
<form name="punchout_form" method="post" action="https://openapi-sandbox.dl.alipaydev.com/gateway.do?charset=UTF-8&method=alipay.trade.page.pay&sign=o8MQ7cEAh4yrf6ri5iT9gXbtR%2BTncX36p9JNZf8nmXxEp1WKO6dF1HDC6DPF8nxJbus6OG9E5UPSoiHgqrEiu3aeOs4LMcYzXXZP0UQzG%2F6pXyZrvlL70G8uwpnV6MgQojzhKheG0vYIJgSPaGsHKjwcp%2BT8yOZg3eEDIgFYEBf2a3c7kANc7%2FuOOGCSD9Ip9zqwX9LQ3b433NPH3AbGQkFSLsdGOTY62I7yI8hEF7EM7nNm%2BYryieqju21yN76JrV4k%2BgIGHn83I5oT3y7zurzdTqHLD4UawpPWdJ5B7p6wnJ4cN4uzO8JgRpNqogYqqKBPcHIlB9o4uax3bcRqhg%3D%3D¬ify_url=http%3A%2F%2F39.108.134.218%3A10106%2Fapi%2F%2Fpublic%2FalipayNotify%2Fpay&version=1.0&app_id=2018062160374941&sign_type=RSA2×tamp=2026-05-20+15%3A29%3A21&alipay_sdk=alipay-sdk-java-4.40.791.ALL&format=json">
|
||||
@@ -405,6 +405,13 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useStore()
|
||||
|
||||
|
||||
// 声明友盟统计 _czc 对象
|
||||
const _czc: any[] = (window as any)._czc || [];
|
||||
(window as any)._czc = _czc;
|
||||
// 绑定 siteid,请用您的 siteid 替换下方 "XXXXXXXX" 部分
|
||||
_czc.push(["_setAccount", "1281490571"]);
|
||||
|
||||
/** 招聘分类标签背景色:校招=#3AC6E5,社招=#B95DDD,实习=#50D3B5 */
|
||||
function recruitCategoryColor(category: number | undefined | null): string {
|
||||
if (category === 1) return '#3AC6E5' // 校招
|
||||
@@ -570,6 +577,7 @@ const filters = ref<FilterItem[]>([
|
||||
{ label: '岗位', key: 'position', selected: '' },
|
||||
{ label: '行业', key: 'industry', selected: '' },
|
||||
{ label: '招聘分类', key: 'jobType', selected: '' },
|
||||
{ label: '公司类型', key: 'companyType', selected: '' },
|
||||
])
|
||||
|
||||
/** 招聘分类选项映射:从全局 store 常量统一引入 */
|
||||
@@ -578,6 +586,54 @@ const jobTypeOptions = JOB_TYPE_OPTIONS
|
||||
/** 招聘分类下拉菜单是否显示 */
|
||||
const showJobTypeDropdown = ref(false)
|
||||
|
||||
/** 公司类型选项(label=显示文字,value=传给接口的字符串数组) */
|
||||
const companyTypeOptions: { label: string; value: string[] }[] = [
|
||||
{ label: '全部', value: [] },
|
||||
{ label: '央国企', value: ['央企', '国企'] },
|
||||
{ label: '独角兽', value: ['独角兽'] },
|
||||
{ label: '事业单位', value: ['事业单位'] },
|
||||
{ label: '上市企业', value: ['上市企业'] },
|
||||
{ label: '外资企业', value: ['外资企业'] },
|
||||
{ label: '合资企业', value: ['合资企业'] },
|
||||
{ label: '民营企业', value: ['民营企业'] },
|
||||
]
|
||||
|
||||
/** 公司类型下拉菜单是否显示 */
|
||||
const showCompanyTypeDropdown = ref(false)
|
||||
|
||||
/** localStorage 缓存 key(带用户 ID 隔离不同用户) */
|
||||
const COMPANY_TYPE_CACHE_KEY = 'offerpai_jobs_company_type_filter_'
|
||||
|
||||
/** 获取当前用户的公司类型缓存 key */
|
||||
function getCompanyTypeCacheKey(): string {
|
||||
const userId = store.state.userInfo?.id || 'anonymous'
|
||||
return COMPANY_TYPE_CACHE_KEY + userId
|
||||
}
|
||||
|
||||
/** 从 localStorage 读取缓存的公司类型筛选 */
|
||||
function loadCachedCompanyTypes(): string[] {
|
||||
try {
|
||||
const cached = localStorage.getItem(getCompanyTypeCacheKey())
|
||||
if (!cached || cached === 'null') return []
|
||||
return JSON.parse(cached) as string[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存公司类型筛选到 localStorage */
|
||||
function saveCachedCompanyTypes(types: string[]) {
|
||||
const key = getCompanyTypeCacheKey()
|
||||
if (types.length === 0) {
|
||||
localStorage.setItem(key, 'null')
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(types))
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前选中的公司类型(传给接口的字符串数组),onMounted 中从缓存恢复 */
|
||||
const selectedCompanyTypes = ref<string[]>([])
|
||||
|
||||
/** 当前选中的招聘分类 — 直接读 store.jobIntention.recruitCategory */
|
||||
const selectedEmploymentType = computed<number | null>(
|
||||
() => store.state.jobIntention.recruitCategory ?? null,
|
||||
@@ -653,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)
|
||||
@@ -665,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)
|
||||
@@ -692,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)
|
||||
@@ -718,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', {
|
||||
@@ -730,6 +822,10 @@ function onRegionChange(codes: string[]) {
|
||||
function handleFilterClick(filter: FilterItem) {
|
||||
if (filter.key === 'jobType') {
|
||||
showJobTypeDropdown.value = !showJobTypeDropdown.value
|
||||
showCompanyTypeDropdown.value = false
|
||||
} else if (filter.key === 'companyType') {
|
||||
showCompanyTypeDropdown.value = !showCompanyTypeDropdown.value
|
||||
showJobTypeDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,6 +839,15 @@ function selectJobType(filter: FilterItem, option: { label: string; value: numbe
|
||||
})
|
||||
}
|
||||
|
||||
/** 选中公司类型选项 */
|
||||
function selectCompanyType(filter: FilterItem, option: { label: string; value: string[] }) {
|
||||
filter.selected = option.value.length ? option.label : ''
|
||||
showCompanyTypeDropdown.value = false
|
||||
selectedCompanyTypes.value = option.value
|
||||
saveCachedCompanyTypes(option.value)
|
||||
reloadFirstPage()
|
||||
}
|
||||
|
||||
/** 监听 store 中 recruitCategory 变化,同步招聘分类筛选按钮的显示文字 */
|
||||
watch(selectedEmploymentType, (val) => {
|
||||
const jobTypeFilter = filters.value.find(f => f.key === 'jobType')
|
||||
@@ -757,6 +862,7 @@ function closeDropdownOnClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.jobs-page__filter-item')) {
|
||||
showJobTypeDropdown.value = false
|
||||
showCompanyTypeDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -802,16 +908,18 @@ function handleReport(job: JobListItem) {
|
||||
// 暂存当前岗位信息
|
||||
pendingApplyUrl.value = job.sourceUrl
|
||||
pendingApplyJobId.value = job.id
|
||||
// 重置选项状态
|
||||
applyMethodChoice.value = null
|
||||
// 每次触发都展示弹窗
|
||||
// 展示弹窗并调用组件 open 方法初始化
|
||||
showAgentRemindDialog.value = true
|
||||
// 异步加载 AI 助手配置状态
|
||||
loadAgentConfigStatus()
|
||||
nextTick(() => {
|
||||
applyMethodDialogRef.value?.open()
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 选择投递方式弹窗状态 ====================
|
||||
|
||||
/** 弹窗组件 ref */
|
||||
const applyMethodDialogRef = ref<InstanceType<typeof JobApplyMethodDialog> | null>(null)
|
||||
|
||||
/** 弹窗是否显示 */
|
||||
const showAgentRemindDialog = ref(false)
|
||||
|
||||
@@ -821,150 +929,39 @@ const pendingApplyUrl = ref('')
|
||||
/** 暂存当前触发弹窗的岗位 ID */
|
||||
const pendingApplyJobId = ref('')
|
||||
|
||||
/** 用户在弹窗中选择的投递方式:null=未选择, 'direct'=直接前往官网, 'agent'=智能投递助手 */
|
||||
const applyMethodChoice = ref<'direct' | 'agent' | null>(null)
|
||||
|
||||
/** AI助手是否已完成配置(status === 1) */
|
||||
const agentConfigReady = ref(false)
|
||||
|
||||
/** 是否正在加载 AI 助手配置 */
|
||||
const agentConfigLoading = ref(false)
|
||||
|
||||
/** 会员购买弹窗显示状态 */
|
||||
const showMemberDialog = ref(false)
|
||||
|
||||
/** 用户会员状态 — 从 store 读取 */
|
||||
const memberStatus = computed(() => store.state.memberStatus)
|
||||
|
||||
/** 用户是否是会员(正式或试用) */
|
||||
const isMember = computed(() => !!memberStatus.value?.isMember)
|
||||
|
||||
/** 智能投递助手选项卡内的描述文字 — 根据会员和配置状态动态显示 */
|
||||
const agentCardDesc = computed(() => {
|
||||
if (!isMember.value) {
|
||||
return '自动填写网申信息\n统一管理投递进度'
|
||||
}
|
||||
if (!agentConfigReady.value) {
|
||||
return '完成助手设置后\n可使用智能投递'
|
||||
}
|
||||
return '将该岗位加入待投递列表\n在投递助手中统一处理'
|
||||
})
|
||||
|
||||
/** 底部操作按钮文字 */
|
||||
const applyMethodActionText = computed(() => {
|
||||
if (!applyMethodChoice.value) return '请选择投递方式'
|
||||
if (applyMethodChoice.value === 'direct') return '去官网投递'
|
||||
// agent 分支
|
||||
if (!isMember.value) return '开通会员使用'
|
||||
if (!agentConfigReady.value) return '去完成设置'
|
||||
return '加入待投递列表'
|
||||
})
|
||||
|
||||
/** 底部操作按钮是否显示 icon */
|
||||
const applyMethodActionIcon = computed(() => {
|
||||
if (!applyMethodChoice.value) return false
|
||||
if (applyMethodChoice.value === 'direct') return false
|
||||
// agent 分支都有 icon
|
||||
return true
|
||||
})
|
||||
|
||||
/** 底部操作按钮样式 class */
|
||||
const applyMethodActionClass = computed(() => {
|
||||
if (!applyMethodChoice.value) return 'jobs-page__apply-method-action--disabled'
|
||||
return 'jobs-page__apply-method-action--active'
|
||||
})
|
||||
|
||||
/** 加载 AI 助手配置状态 */
|
||||
async function loadAgentConfigStatus() {
|
||||
if (agentConfigLoading.value) return
|
||||
agentConfigLoading.value = true
|
||||
try {
|
||||
const res = await fetchAgentConfig()
|
||||
if (res.code === '0' && res.data && res.data.status === 1) {
|
||||
agentConfigReady.value = true
|
||||
} else {
|
||||
agentConfigReady.value = false
|
||||
}
|
||||
} catch {
|
||||
agentConfigReady.value = false
|
||||
} finally {
|
||||
agentConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭选择投递方式弹窗 */
|
||||
function closeAgentRemind() {
|
||||
showAgentRemindDialog.value = false
|
||||
pendingApplyUrl.value = ''
|
||||
pendingApplyJobId.value = ''
|
||||
}
|
||||
|
||||
/** 底部操作按钮点击处理 */
|
||||
async function handleApplyMethodAction() {
|
||||
if (!applyMethodChoice.value) return
|
||||
|
||||
if (applyMethodChoice.value === 'direct') {
|
||||
// 直接前往官网 — 同时跨域 put 链接给插件
|
||||
showAgentRemindDialog.value = false
|
||||
if (pendingApplyUrl.value) {
|
||||
// 跨域推送投递链接到插件
|
||||
const bridge = createChannelBridge({ domain: store.state.bridgeDomain })
|
||||
try {
|
||||
const existing = await bridge.get<{ linkList: string[] }>(store.state.bridgeDomain, 'offerpaiDeliveryLinkList', true)
|
||||
const linkList: string[] = existing?.linkList || []
|
||||
if (!linkList.includes(pendingApplyUrl.value)) {
|
||||
linkList.push(pendingApplyUrl.value)
|
||||
}
|
||||
await bridge.put<{ linkList: string[] }>(store.state.bridgeDomain, 'offerpaiDeliveryLinkList', { linkList }, true)
|
||||
} catch (e) {
|
||||
console.error('[Jobs] 跨域 put 投递链接失败', e)
|
||||
} finally {
|
||||
bridge.destroy()
|
||||
}
|
||||
window.open(pendingApplyUrl.value, '_blank')
|
||||
}
|
||||
pendingApplyUrl.value = ''
|
||||
pendingApplyJobId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 智能投递助手分支
|
||||
if (!isMember.value) {
|
||||
// 非会员 — 打开会员购买弹窗
|
||||
showAgentRemindDialog.value = false
|
||||
showMemberDialog.value = true
|
||||
pendingApplyUrl.value = ''
|
||||
pendingApplyJobId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (!agentConfigReady.value) {
|
||||
// 有会员但没完成 AI 助手配置 — 跳转 Agent 页面
|
||||
showAgentRemindDialog.value = false
|
||||
pendingApplyUrl.value = ''
|
||||
pendingApplyJobId.value = ''
|
||||
router.push('/agent')
|
||||
return
|
||||
}
|
||||
|
||||
// 有会员且配置完成 — 加入待投递列表
|
||||
try {
|
||||
const res = await applyJob({ jobId: pendingApplyJobId.value, status: -1 })
|
||||
if (res.code === '0') {
|
||||
ElMessage.success('已加入待投递列表')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加入待投递列表失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加入待投递列表失败')
|
||||
}
|
||||
showAgentRemindDialog.value = false
|
||||
pendingApplyUrl.value = ''
|
||||
pendingApplyJobId.value = ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', closeDropdownOnClickOutside)
|
||||
|
||||
// 从缓存恢复公司类型筛选按钮的显示文字
|
||||
// 需要等 userInfo 加载后再读取(刷新时 userInfo 可能还没有)
|
||||
const restoreCompanyTypeFilter = () => {
|
||||
const cached = loadCachedCompanyTypes()
|
||||
if (cached.length) {
|
||||
selectedCompanyTypes.value = cached
|
||||
const matched = companyTypeOptions.find(o => JSON.stringify(o.value) === JSON.stringify(cached))
|
||||
const companyTypeFilter = filters.value.find(f => f.key === 'companyType')
|
||||
if (companyTypeFilter && matched) {
|
||||
companyTypeFilter.selected = matched.label
|
||||
}
|
||||
}
|
||||
}
|
||||
if (store.state.userInfo?.id) {
|
||||
restoreCompanyTypeFilter()
|
||||
} else {
|
||||
// userInfo 还没加载,监听变化后恢复
|
||||
const unwatch = watch(() => store.state.userInfo, (info) => {
|
||||
if (info?.id) {
|
||||
restoreCompanyTypeFilter()
|
||||
unwatch()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 窗口 resize 时更新尖角位置
|
||||
window.addEventListener('resize', updateAiTipArrowPosition)
|
||||
|
||||
@@ -1153,6 +1150,10 @@ function buildParams(): JobListParams {
|
||||
if (keyword.value.trim()) {
|
||||
params.keyword = keyword.value.trim()
|
||||
}
|
||||
// 公司类型筛选
|
||||
if (selectedCompanyTypes.value.length) {
|
||||
params.companyTypes = selectedCompanyTypes.value
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -1236,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) {
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<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>
|
||||
<button class="login-view__send-btn " :disabled="!canSend" @click="handleSendCode">发送验证码</button>
|
||||
</template>
|
||||
|
||||
<!-- 步骤二:输入验证码登录 -->
|
||||
@@ -76,7 +76,7 @@
|
||||
<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>
|
||||
<p v-if="smsStatusMsg" class="login-view__sms-status tal" :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>
|
||||
@@ -291,6 +291,13 @@ 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)
|
||||
@@ -506,7 +513,9 @@ async function handleLogin() {
|
||||
try {
|
||||
const res = await smsLogin(phone.value, otpValue.value)
|
||||
if (res.code !== '0') {
|
||||
ElMessage.error('登录失败请核对验证码')
|
||||
// 验证码错误或已过期 — 显示行内错误提示
|
||||
smsStatusMsg.value = '验证码有误,请重新输入'
|
||||
smsStatusIsError.value = true
|
||||
otpValue.value = ''
|
||||
isLoggingIn.value = false
|
||||
nextTick(() => focusOtpInput())
|
||||
@@ -539,7 +548,8 @@ async function handleLogin() {
|
||||
// 有个人资料,直接跳转
|
||||
router.replace(loginRedirect)
|
||||
} catch {
|
||||
ElMessage.error('登录失败请核对验证码')
|
||||
smsStatusMsg.value = '验证码有误,请重新输入'
|
||||
smsStatusIsError.value = true
|
||||
otpValue.value = ''
|
||||
isLoggingIn.value = false
|
||||
nextTick(() => focusOtpInput())
|
||||
@@ -931,6 +941,13 @@ function handleVisibilityChange() {
|
||||
|
||||
/** 挂载时初始化动画 */
|
||||
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})`
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<!-- <span class="mentor-page__title-icon">✦</span>-->
|
||||
<div class=" ">
|
||||
<div class="mentor-page__title dib mr10">导师推荐</div>
|
||||
<div class="mentor-page__subtitle dib">自有合作导师,真实在职身份,全部实名认证</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dflex-end aliite-c flex-warp">
|
||||
@@ -20,7 +19,7 @@
|
||||
<div class="mentor-page__quick-tags">
|
||||
<span class="mentor-page__quick-label">推荐搜索:</span>
|
||||
<span
|
||||
v-for="tag in quickSearchTags"
|
||||
v-for="(tag,index) in quickSearchTags"
|
||||
:key="tag"
|
||||
class="mentor-page__quick-tag"
|
||||
@click="handleQuickSearch(tag)"
|
||||
@@ -164,6 +163,12 @@ import { toOssUrl } from '@/utils/image'
|
||||
const router = useRouter()
|
||||
const store = useStore()
|
||||
|
||||
// 声明友盟统计 _czc 对象
|
||||
const _czc: any[] = (window as any)._czc || [];
|
||||
(window as any)._czc = _czc;
|
||||
// 绑定 siteid,请用您的 siteid 替换下方 "XXXXXXXX" 部分
|
||||
_czc.push(["_setAccount", "1281490571"]);
|
||||
|
||||
/** 行业 Tab 标签列表 */
|
||||
const industryTabs = ['推荐', '互联网', '金融', '快消', '咨询', '央国企', '硬科技', '生涯规划', '名校导师', '心理咨询', '其他']
|
||||
|
||||
|
||||
@@ -125,6 +125,12 @@ import MentorQrCodeDialog from '@/components/MentorQrCodeDialog.vue'
|
||||
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"]);
|
||||
|
||||
/** 导师 ID,从路由参数获取(字符串,避免大数精度丢失) */
|
||||
const mentorId = route.params.id as string
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -55,6 +55,12 @@ import type { SaveEducationItem, SaveWorkItem, SaveProjectItem, SaveCompetitionI
|
||||
const router = useRouter()
|
||||
const store = useStore()
|
||||
|
||||
// 声明友盟统计 _czc 对象
|
||||
const _czc: any[] = (window as any)._czc || [];
|
||||
(window as any)._czc = _czc;
|
||||
// 绑定 siteid,请用您的 siteid 替换下方 "XXXXXXXX" 部分
|
||||
_czc.push(["_setAccount", "1281490571"]);
|
||||
|
||||
// ==================== 全屏加载状态 ====================
|
||||
/** 全屏加载是否显示 */
|
||||
const fullLoading = ref(false)
|
||||
|
||||
@@ -142,6 +142,12 @@ import StepProgressOverlay from '@/components/StepProgressOverlay.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 声明友盟统计 _czc 对象
|
||||
const _czc: any[] = (window as any)._czc || [];
|
||||
(window as any)._czc = _czc;
|
||||
// 绑定 siteid,请用您的 siteid 替换下方 "XXXXXXXX" 部分
|
||||
_czc.push(["_setAccount", "1281490571"]);
|
||||
|
||||
// ==================== 全屏加载状态 ====================
|
||||
/** 全屏加载是否显示 */
|
||||
const fullLoading = ref(false)
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
<template v-if="expEditingId !== edu.id">
|
||||
<div class="resume-detail__exp-row">
|
||||
<span class="resume-detail__exp-name">{{ edu.school }}</span>
|
||||
<span v-if="edu.major || edu.degree" class="resume-detail__exp-role">{{ edu.major }}{{ edu.degree ? '·' + edu.degree : '' }}</span>
|
||||
<span v-if="edu.major || edu.degree" class="resume-detail__exp-role">{{ edu.major }}{{ edu.degree ? '·' + (EDUCATION_MAP[Number(edu.degree)] || edu.degree) : '' }}</span>
|
||||
<span v-if="hasDiagnosis && getIssueByRecord('education', edu.id!) && getIssueByRecord('education', edu.id!)!.status === 0" class="resume-detail__fix-tag">
|
||||
<svg viewBox="0 0 16 16" class="resume-detail__fix-tag-icon"><circle cx="8" cy="8" r="7" fill="#FF8D0B"/><text x="8" y="12" text-anchor="middle" font-size="11" fill="#fff" font-weight="bold">!</text></svg>
|
||||
建议使用AI润色修复
|
||||
@@ -498,7 +498,7 @@
|
||||
<!-- 技能 -->
|
||||
<div ref="resumeSectionSkillsRef" class="resume-detail__card" :class="{ 'resume-detail__card-hover': editModule !== 'skills' || !showEditDrawer }">
|
||||
<template v-if="editModule !== 'skills' || !showEditDrawer">
|
||||
<div class="resume-detail__section-header">
|
||||
<div class="resume-detail__section-header pl0">
|
||||
<h3 class="resume-detail__section-title">技能</h3>
|
||||
<!-- 编辑按钮 — hover时显示 -->
|
||||
<button class="resume-detail__edit-btn-inline" @click="openEditDrawer('skills')" aria-label="编辑技能">
|
||||
@@ -529,7 +529,7 @@
|
||||
<!-- 证书 -->
|
||||
<div ref="resumeSectionCertificateRef" class="resume-detail__card" :class="{ 'resume-detail__card-hover': editModule !== 'certificate' || !showEditDrawer }">
|
||||
<template v-if="editModule !== 'certificate' || !showEditDrawer">
|
||||
<div class="resume-detail__section-header">
|
||||
<div class="resume-detail__section-header pl0">
|
||||
<h3 class="resume-detail__section-title">证书</h3>
|
||||
<!-- 编辑按钮 — hover时显示 -->
|
||||
<button class="resume-detail__edit-btn-inline" @click="openEditDrawer('certificate')" aria-label="编辑证书">
|
||||
@@ -633,12 +633,19 @@ import {
|
||||
} from '@/api/resume'
|
||||
import {ArrowLeft, EditPen, Plus, DeleteFilled, ChatLineRound} from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { EDUCATION_MAP } from '@/stores/index'
|
||||
import FullscreenLoading from '@/components/FullscreenLoading.vue'
|
||||
import StepProgressOverlay from '@/components/StepProgressOverlay.vue'
|
||||
|
||||
// ==================== Store 实例 ====================
|
||||
const store = useStore()
|
||||
|
||||
// 声明友盟统计 _czc 对象
|
||||
const _czc: any[] = (window as any)._czc || [];
|
||||
(window as any)._czc = _czc;
|
||||
// 绑定 siteid,请用您的 siteid 替换下方 "XXXXXXXX" 部分
|
||||
_czc.push(["_setAccount", "1281490571"]);
|
||||
|
||||
// ==================== Tab 导航栏相关 ====================
|
||||
|
||||
/** 简历详情 Tab 导航列表 */
|
||||
|
||||
@@ -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>
|
||||