个人资料和岗位列表联调,简历优化部分页面
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
---
|
||||
inclusion: always
|
||||
---
|
||||
|
||||
# 项目开发规范与注意事项
|
||||
|
||||
<!-- 在这里写上你希望 Kiro 每次都遵守的规范和注意事项 -->
|
||||
<!-- 例如: -->
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3 + TypeScript + Vite
|
||||
- 状态管理:Pinia
|
||||
- 样式:SCSS
|
||||
|
||||
## 编码规范
|
||||
|
||||
- 颜色用尽量用variables.scss里的统一颜色变量,特别是背景,按钮,文字的颜色
|
||||
- 除了Home.vue首页外,其他页面我上传了截图要写页面或组件的,需要参考图片里文字和布局结构,在保证美观前提上自由发挥
|
||||
- 全局用1rem=100px的格式并注意对某些特殊元素组件的line-height行高影响,纵布局如非必要不用flex-direction: column布局
|
||||
- 如果是建一个组件,这个组件看我说是用在views里哪个页面的,比如用在Profile.vue里的组件,组件名字最前面要加Profile,而且整个组件的命名不能过度简化,要容易看懂组件的用途;如果检测到某种名字开头的组件数量比如Profile开头的超过15个,就在components里新建个类似profile这样的页面名字的文件夹,把这类命名的组件都移到文件夹里并查找更新组件所有被引用地方的文件地址
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 页面结构和ts的常量变量和方法都要加中文注释
|
||||
Vendored
+3
@@ -22,6 +22,7 @@ declare module 'vue' {
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||
IndustrySelector: typeof import('./src/components/tools/IndustrySelector.vue')['default']
|
||||
JobCategorySelector: typeof import('./src/components/tools/JobCategorySelector.vue')['default']
|
||||
@@ -29,6 +30,8 @@ declare module 'vue' {
|
||||
JobFeedbackDialog: typeof import('./src/components/JobFeedbackDialog.vue')['default']
|
||||
JobGoalDialog: typeof import('./src/components/JobGoalDialog.vue')['default']
|
||||
JobPageHeader: typeof import('./src/components/JobPageHeader.vue')['default']
|
||||
JobResumeCustomDialog: typeof import('./src/components/JobResumeCustomDialog.vue')['default']
|
||||
JobResumeTemplate: typeof import('./src/components/JobResumeTemplate.vue')['default']
|
||||
LoginDialog: typeof import('./src/components/LoginDialog.vue')['default']
|
||||
MemberDialog: typeof import('./src/components/MemberDialog.vue')['default']
|
||||
ProfileEditDrawer: typeof import('./src/components/ProfileEditDrawer.vue')['default']
|
||||
|
||||
+3
-1
@@ -4,7 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>jobassistantweb</title>
|
||||
<title>Offer派 - 大学生AI求职平台 | 智能岗位匹配·一键自动网申·AI简历优化</title>
|
||||
<meta name="description" content="Offer派是专为大学生打造的AI求职平台,提供智能岗位匹配、一键自动网申、岗位定制简历、内推人脉直通等功能,让校招求职效率提升80%。" />
|
||||
<meta name="keywords" content="大学生求职,校招,AI求职,智能匹配,自动网申,简历优化,内推,Offer派" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -40,6 +40,15 @@ export function smsLogin(mobileNumber: string, code: string, inviteCode?: string
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查登录状态
|
||||
* GET /public/checkLogin
|
||||
* 有 Cookie 且认证通过返回 data: true,否则返回 data: false
|
||||
*/
|
||||
export function checkLogin() {
|
||||
return request.get<any, ApiResult<boolean>>('/public/checkLogin')
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
* POST /public/logout
|
||||
|
||||
+175
-2
@@ -6,7 +6,7 @@ import type { ApiResult } from '@/api/auth'
|
||||
/** 匹配度详情 */
|
||||
export interface MatchDetail {
|
||||
/** 行业匹配分 */
|
||||
industryScore: number
|
||||
educationScore: number
|
||||
/** 技能匹配分 */
|
||||
skillScore: number
|
||||
/** 经验匹配分 */
|
||||
@@ -79,6 +79,38 @@ export interface JobListParams {
|
||||
jobIds?: number[]
|
||||
/** 岗位状态过滤(0=有效 1=已下架 2=已过期,可多选,null或空=查所有) */
|
||||
statusFilter?: number[]
|
||||
/** 搜索关键词 */
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
// ==================== 求职意向 ====================
|
||||
|
||||
/** 求职意向出参/入参 */
|
||||
export interface JobIntention {
|
||||
/** 期望岗位分类 ID 列表 */
|
||||
categoryIds?: number[]
|
||||
/** 期望地区编码列表 */
|
||||
regionCodes?: string[]
|
||||
/** 期望行业 ID 列表 */
|
||||
industryIds?: number[]
|
||||
/** 就业类型:0=全职,1=实习 */
|
||||
employmentType?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询求职意向
|
||||
* GET /job-intention
|
||||
*/
|
||||
export function fetchJobIntention() {
|
||||
return request.get<any, ApiResult<JobIntention>>('/job-intention')
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存求职意向
|
||||
* POST /job-intention
|
||||
*/
|
||||
export function saveJobIntention(data: JobIntention) {
|
||||
return request.post<any, ApiResult<any>>('/job-intention', data)
|
||||
}
|
||||
|
||||
// ==================== 接口方法 ====================
|
||||
@@ -96,12 +128,128 @@ export function fetchJobList(params: JobListParams = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 收藏列表 ====================
|
||||
|
||||
/** 收藏列表请求参数 */
|
||||
export interface FavoriteListParams {
|
||||
/** 当前页码,从1开始,默认1 */
|
||||
pageNum?: number
|
||||
/** 每页条数,默认10 */
|
||||
pageSize?: number
|
||||
/** 是否只查询有效收藏(true=只查有效岗位,false=只查失效岗位,null=查所有) */
|
||||
valid?: boolean | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收藏列表
|
||||
* POST /job/favorite/list
|
||||
*/
|
||||
export function fetchFavoriteList(params: FavoriteListParams = {}) {
|
||||
return request.post<any, ApiResult<JobPageData>>('/job/favorite/list', {
|
||||
pageNum: params.pageNum ?? 1,
|
||||
pageSize: params.pageSize ?? 10,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏/取消收藏岗位
|
||||
* POST /job/favorite?jobId=xxx
|
||||
* @param jobId 岗位 ID
|
||||
*/
|
||||
export function toggleJobFavorite(jobId: string) {
|
||||
return request.post<any, ApiResult<any>>('/job/favorite', null, {
|
||||
params: { jobId },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消收藏岗位
|
||||
* DELETE /job/favorite?jobId=xxx
|
||||
* @param jobId 岗位 ID
|
||||
*/
|
||||
export function removeJobFavorite(jobId: string) {
|
||||
return request.delete<any, ApiResult<any>>('/job/favorite', {
|
||||
params: { jobId },
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 收藏统计 ====================
|
||||
|
||||
/** 收藏统计结果 */
|
||||
export interface FavoriteCountData {
|
||||
/** 收藏总数 */
|
||||
totalCount: number
|
||||
/** 有效收藏数(岗位status=0) */
|
||||
validCount: number
|
||||
/** 失效收藏数(岗位status!=0或已删除) */
|
||||
invalidCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收藏统计
|
||||
* GET /job/favorite/count
|
||||
*/
|
||||
export function fetchFavoriteCount() {
|
||||
return request.get<any, ApiResult<FavoriteCountData>>('/job/favorite/count')
|
||||
}
|
||||
|
||||
// ==================== 投递列表 ====================
|
||||
|
||||
/** 投递列表请求参数 */
|
||||
export interface ApplyListParams {
|
||||
/** 当前页码,从1开始,默认1 */
|
||||
pageNum?: number
|
||||
/** 每页条数,默认10 */
|
||||
pageSize?: number
|
||||
/** 投递状态筛选(0=已投递 1=面试中 2=有Offer 3=未通过 4=已结束) */
|
||||
status?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取投递列表
|
||||
* POST /job/apply/list
|
||||
*/
|
||||
export function fetchApplyList(params: ApplyListParams = {}) {
|
||||
return request.post<any, ApiResult<JobPageData>>('/job/apply/list', {
|
||||
pageNum: params.pageNum ?? 1,
|
||||
pageSize: params.pageSize ?? 10,
|
||||
...(params.status !== null && params.status !== undefined ? { status: params.status } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 投递统计 ====================
|
||||
|
||||
/** 投递统计结果 */
|
||||
export interface ApplyCountData {
|
||||
/** 投递总数 */
|
||||
totalCount: number
|
||||
/** 已投递数(status=0) */
|
||||
appliedCount: number
|
||||
/** 面试中数(status=1) */
|
||||
interviewingCount: number
|
||||
/** 有Offer数(status=2) */
|
||||
offerCount: number
|
||||
/** 未通过数(status=3) */
|
||||
rejectedCount: number
|
||||
/** 已结束数(status=4) */
|
||||
closedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取投递统计
|
||||
* GET /job/apply/count
|
||||
*/
|
||||
export function fetchApplyCount() {
|
||||
return request.get<any, ApiResult<ApplyCountData>>('/job/apply/count')
|
||||
}
|
||||
|
||||
// ==================== 岗位详情 ====================
|
||||
|
||||
/** 匹配度详情(岗位详情用) */
|
||||
export interface JobMatchScoreDto {
|
||||
/** 行业得分(0-100) */
|
||||
industryScore: number
|
||||
educationScore: number
|
||||
/** 技能得分(0-100) */
|
||||
skillScore: number
|
||||
/** 经验得分(0-100) */
|
||||
@@ -180,6 +328,31 @@ export interface JobDetailData {
|
||||
isFavorite: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 从推荐列表中移除岗位
|
||||
* TODO: 接口待后端提供,当前仅做前端列表移除
|
||||
* POST /job/remove?jobId=xxx
|
||||
* @param jobId 岗位 ID
|
||||
*/
|
||||
export function removeJobFromList(jobId: string) {
|
||||
// TODO: 接口待对接,暂时返回模拟成功结果
|
||||
return Promise.resolve({ code: '0', msg: '', data: null, timestamp: '', uuid: '' } as ApiResult<any>)
|
||||
// 对接时替换为:
|
||||
// return request.post<any, ApiResult<any>>('/job/remove', null, { params: { jobId } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 不感兴趣
|
||||
* POST /job/dislike?jobId=xxx
|
||||
* @param jobId 岗位 ID
|
||||
* @param reason 不感兴趣原因 0-5
|
||||
*/
|
||||
export function dislikeJob(jobId: string, reason: number) {
|
||||
return request.post<any, ApiResult<any>>('/job/dislike', { reason }, {
|
||||
params: { jobId },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取岗位详情
|
||||
* GET /job/detail?jobId=xxx
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@use './variables' as *;
|
||||
html{
|
||||
font-size: 100px;
|
||||
}
|
||||
@@ -602,6 +603,12 @@ body:not(#_) {
|
||||
.bgccc{
|
||||
background: #ccc;
|
||||
}
|
||||
.bg-main{
|
||||
background: $bg-main;
|
||||
}
|
||||
.bg-white{
|
||||
background: $bg-white;
|
||||
}
|
||||
|
||||
/*文字超出隐藏*/
|
||||
.text-over-h{
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// 颜色变量已移至全局 variables.scss
|
||||
@use '../variables' as *;
|
||||
|
||||
.ai-chat {
|
||||
width: 3.6rem;
|
||||
width: 4.0rem;
|
||||
height: 100vh;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
@@ -14,7 +17,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #4FC2C9 0%, #42A8B3 100%);
|
||||
background: linear-gradient(90deg, #94EF9E , #53D9C8 );
|
||||
color: #fff;
|
||||
padding: 0.1rem 0.16rem;
|
||||
font-size: 0.13rem;
|
||||
@@ -120,6 +123,7 @@
|
||||
flex-direction: column;
|
||||
gap: 0.08rem;
|
||||
margin-top: 0.12rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
&__quick-item {
|
||||
@@ -127,7 +131,7 @@
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.08rem;
|
||||
padding: 0.1rem 0.14rem;
|
||||
color: #6b7280;
|
||||
color: $text-middle;
|
||||
font-size: 0.12rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
.industry-selector {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
//display: inline-block;
|
||||
|
||||
// ==================== 触发按钮 ====================
|
||||
&__trigger {
|
||||
@@ -38,7 +38,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 1.6rem;
|
||||
max-width: 1.2rem;
|
||||
}
|
||||
|
||||
// 下拉箭头图标
|
||||
@@ -66,6 +66,11 @@
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
padding: 0.1rem;
|
||||
|
||||
// 只显示一栏时缩窄面板
|
||||
&--one-col {
|
||||
width: 2.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 选中区(已选标签) ====================
|
||||
@@ -191,6 +196,15 @@
|
||||
}
|
||||
|
||||
// ==================== 分栏联动选择区 ====================
|
||||
|
||||
// 双击提示文字
|
||||
&__hint {
|
||||
font-size: 0.11rem;
|
||||
color: $text-light;
|
||||
margin-bottom: 0.06rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__columns {
|
||||
display: flex;
|
||||
border: 1px solid $border-color;
|
||||
@@ -204,12 +218,13 @@
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
|
||||
// 左栏:一级行业列表
|
||||
// 左栏:一级行业列表(禁止双击选中文字)
|
||||
&--left {
|
||||
width: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid $border-color;
|
||||
background: $bg-main;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// 右栏:二级行业列表
|
||||
@@ -217,6 +232,13 @@
|
||||
flex: 1;
|
||||
background: $bg-white;
|
||||
}
|
||||
|
||||
// 单栏模式:左栏占满
|
||||
&--full {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 栏内每一行
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
.job-category-selector {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
//display: inline-block;
|
||||
|
||||
// ==================== 触发按钮 ====================
|
||||
&__trigger {
|
||||
@@ -39,7 +39,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 1.6rem;
|
||||
max-width: 1.2rem;
|
||||
}
|
||||
|
||||
// 下拉箭头图标
|
||||
@@ -67,6 +67,16 @@
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
padding: 0.1rem;
|
||||
|
||||
// 只显示一栏时缩窄面板
|
||||
&--one-col {
|
||||
width: 2.4rem;
|
||||
}
|
||||
|
||||
// 只显示两栏时缩窄面板
|
||||
&--two-col {
|
||||
width: 3.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 选中区(已选标签) ====================
|
||||
@@ -192,6 +202,15 @@
|
||||
}
|
||||
|
||||
// ==================== 三栏联动选择区 ====================
|
||||
|
||||
// 双击提示文字
|
||||
&__hint {
|
||||
font-size: 0.11rem;
|
||||
color: $text-light;
|
||||
margin-bottom: 0.06rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__columns {
|
||||
display: flex;
|
||||
border: 1px solid $border-color;
|
||||
@@ -205,20 +224,29 @@
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
|
||||
// 左栏:一级分类
|
||||
// 左栏:一级分类(禁止双击选中文字)
|
||||
&--left {
|
||||
width: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid $border-color;
|
||||
background: $bg-main;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// 中栏:二级分类
|
||||
// 中栏:二级分类(禁止双击选中文字)
|
||||
&--mid {
|
||||
width: 1.4rem;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid $border-color;
|
||||
background: $bg-white;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// 中栏作为末级栏时展开
|
||||
&--mid-end {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
// 右栏:三级分类(末级可选中)
|
||||
@@ -226,6 +254,13 @@
|
||||
flex: 1;
|
||||
background: $bg-white;
|
||||
}
|
||||
|
||||
// 单栏模式:左栏占满
|
||||
&--full {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 栏内每一行
|
||||
|
||||
@@ -113,8 +113,8 @@
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: $text-light;
|
||||
border-color: $text-light;
|
||||
background: $btn-dark;
|
||||
border-color: $btn-dark;
|
||||
color: $bg-white;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -127,9 +127,9 @@
|
||||
}
|
||||
|
||||
&__save-btn {
|
||||
width: 60%;
|
||||
width: 100%;
|
||||
padding: 0.12rem 0;
|
||||
background: $btn-dark;
|
||||
background: $accent;
|
||||
color: $bg-white;
|
||||
border: none;
|
||||
border-radius: 0.24rem;
|
||||
@@ -139,7 +139,7 @@
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: $btn-dark-hover;
|
||||
background: $accent-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,17 +31,19 @@
|
||||
}
|
||||
|
||||
&__goal-btn {
|
||||
font-size: 0.18rem;
|
||||
font-size: 0.12rem;
|
||||
font-weight: 700;
|
||||
color: $text-dark;
|
||||
background: none;
|
||||
border: none;
|
||||
background: $bg-main;
|
||||
border-radius: 0.2rem;
|
||||
border: 0.01rem solid $border-color;
|
||||
cursor: pointer;
|
||||
padding: 0.04rem 0.16rem;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s;
|
||||
margin-left: auto;
|
||||
|
||||
|
||||
&:hover {
|
||||
color: $accent-hover;
|
||||
}
|
||||
@@ -51,7 +53,7 @@
|
||||
font-size: 0.13rem;
|
||||
color: $text-light;
|
||||
cursor: pointer;
|
||||
padding: 0.06rem 0.22rem;
|
||||
padding: 0.06rem 0.16rem;
|
||||
border-radius: 0.18rem;
|
||||
transition: all 0.25s ease;
|
||||
user-select: none;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
@use '../variables' as *;
|
||||
|
||||
// ==================== 简历HTML模板组件 ====================
|
||||
// 注意:此组件样式全部使用 px 单位,不用 rem
|
||||
// 原因:后续需要提取简历 HTML 生成 PDF/Word,脱离页面环境后
|
||||
// rem 会按浏览器默认 16px 计算导致尺寸错乱,px 可保证一致性
|
||||
|
||||
.job-resume-template {
|
||||
width: 100%;
|
||||
max-width: 1668px;
|
||||
background: $bg-white;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 简历HTML内容区
|
||||
.resume-html {
|
||||
padding: 50px 60px;
|
||||
font-family: 'SimSun', 'Songti SC', serif;
|
||||
color: $text-dark;
|
||||
line-height: 1.6;
|
||||
|
||||
// 姓名
|
||||
&__name {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 6px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
// 联系方式
|
||||
&__contact {
|
||||
font-size: 12px;
|
||||
color: $text-dark;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__contact-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
&__separator {
|
||||
margin: 0 6px;
|
||||
color: $text-middle;
|
||||
}
|
||||
|
||||
// 板块标题
|
||||
&__section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-dark;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
// 分割线
|
||||
&__divider {
|
||||
height: 1.5px;
|
||||
background: $text-dark;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
// 个人概述
|
||||
&__summary {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
// 经历条目
|
||||
&__item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__item-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__item-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__item-main {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__item-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__item-location {
|
||||
font-size: 12px;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
&__item-date {
|
||||
font-size: 12px;
|
||||
color: $text-dark;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__item-desc {
|
||||
font-size: 12px;
|
||||
color: $text-dark;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// 描述列表
|
||||
&__desc-list {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
list-style: disc;
|
||||
|
||||
li {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: $text-dark;
|
||||
}
|
||||
}
|
||||
|
||||
// 技能区域
|
||||
&__skills {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&__skill-row {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__skill-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
background: $bg-white;
|
||||
border-radius: 0.12rem;
|
||||
padding: 0.2rem 0.24rem;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__card-header {
|
||||
@@ -140,6 +142,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 作品集链接
|
||||
&__portfolio-link {
|
||||
font-size: 0.13rem;
|
||||
line-height: 1.6;
|
||||
color: $accent;
|
||||
word-break: break-all;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
display: inline-block;
|
||||
|
||||
&:hover {
|
||||
color: $accent-hover;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// 作品集空状态提示
|
||||
&__portfolio-empty {
|
||||
font-size: 0.12rem;
|
||||
line-height: 1.5;
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
// 标签(技能 / 证书)
|
||||
&__tags {
|
||||
display: flex;
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 1.6rem;
|
||||
max-width: 1.2rem;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
|
||||
@@ -331,9 +331,30 @@
|
||||
}
|
||||
|
||||
&__reminder-target {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
&__reminder-group {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
&__reminder-group-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.12rem;
|
||||
color: $text-light;
|
||||
line-height: 0.28rem;
|
||||
min-width: 0.32rem;
|
||||
}
|
||||
|
||||
&__reminder-block-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.14rem;
|
||||
}
|
||||
|
||||
&__reminder-tags {
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
@use './components/industry-selector.scss';
|
||||
@use './components/job-category-selector.scss';
|
||||
@use './components/region-selector.scss';
|
||||
@use './components/job-resume-custom-dialog.scss';
|
||||
@use './components/job-resume-template.scss';
|
||||
|
||||
// 全局样式(优先级最高)
|
||||
@use './auto.scss';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
.job-detail {
|
||||
&__content {
|
||||
margin-left: 2rem;
|
||||
margin-right: 3.2rem;
|
||||
margin-right: 3.6rem;
|
||||
flex: 1;
|
||||
padding: 0.12rem 0.56rem 0.12rem 0.18rem;
|
||||
height: 100vh;
|
||||
@@ -13,6 +13,23 @@
|
||||
background: $bg-main;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// 手动包裹的两层 div,需要撑满剩余高度并允许内部滚动
|
||||
> .bg-white {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
> .bg-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 页面标题 ----
|
||||
@@ -138,7 +155,7 @@
|
||||
}
|
||||
|
||||
&__apply-btn {
|
||||
background: $btn-dark;
|
||||
background: $accent;
|
||||
color: $bg-white;
|
||||
border: none;
|
||||
border-radius: 0.2rem;
|
||||
@@ -379,7 +396,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: $btn-dark;
|
||||
background: linear-gradient(90deg, #94EF9E , #53D9C8 );
|
||||
color: $bg-white;
|
||||
border-radius: 0.1rem;
|
||||
padding: 0.14rem 0.2rem;
|
||||
@@ -563,6 +580,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.08rem;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
&__company-meta-item {
|
||||
@@ -621,7 +639,7 @@
|
||||
|
||||
&__news-desc {
|
||||
font-size: 0.11rem;
|
||||
color: $text-light;
|
||||
color: $text-middle;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.jobs-page {
|
||||
&__content {
|
||||
margin-left: 2rem;
|
||||
margin-right: 3.6rem;
|
||||
margin-right: 4.0rem;
|
||||
flex: 1;
|
||||
padding: 0.12rem 0.18rem;
|
||||
height: 100vh;
|
||||
@@ -89,6 +89,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.12rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__filter-group {
|
||||
@@ -111,6 +112,7 @@
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
height: 0.234rem;
|
||||
|
||||
&:hover {
|
||||
border-color: $accent;
|
||||
@@ -219,7 +221,7 @@
|
||||
&:hover {
|
||||
box-shadow: 0 0.04rem 0.16rem rgba(0, 0, 0, 0.06);
|
||||
background: #fbfbfb;
|
||||
transform: translateY(-0.01rem);
|
||||
//transform: translateY(-0.01rem);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
@@ -237,14 +239,12 @@
|
||||
|
||||
&__job-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 0.16rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__job-icon {
|
||||
width: 0.44rem;
|
||||
height: 0.44rem;
|
||||
width: 0.54rem;
|
||||
height: 0.54rem;
|
||||
border-radius: 0.1rem;
|
||||
background: $theme-color;
|
||||
display: flex;
|
||||
@@ -300,9 +300,31 @@
|
||||
height: 0.16rem;
|
||||
}
|
||||
|
||||
&__job-meta {
|
||||
font-size: 0.12rem;
|
||||
&__job-remove {
|
||||
color: $text-light;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.04rem;
|
||||
border-radius: 0.04rem;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: $danger;
|
||||
background: #FFF0ED;
|
||||
}
|
||||
}
|
||||
|
||||
&__remove-svg {
|
||||
width: 0.14rem;
|
||||
height: 0.14rem;
|
||||
}
|
||||
|
||||
&__job-meta {
|
||||
font-size: 0.14rem;
|
||||
color: $text-middle;
|
||||
margin-top: 0.04rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -414,8 +436,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.04rem;
|
||||
background: none;
|
||||
border: none;
|
||||
background: $bg-main;
|
||||
border: 1px solid #E8E8E8;
|
||||
padding: 0.04rem 0.08rem;
|
||||
border-radius: 0.14rem;
|
||||
|
||||
@@ -431,20 +453,18 @@
|
||||
}
|
||||
|
||||
&__job-apply-btn {
|
||||
background: $bg-main;
|
||||
border: 1px solid #E8E8E8;
|
||||
background: $btn-dark;
|
||||
border: 0.02rem solid $btn-dark;
|
||||
border-radius: 0.2rem;
|
||||
padding: 0.06rem 0.2rem;
|
||||
color: $text-dark;
|
||||
padding: 0.04rem 0.2rem;
|
||||
color: $bg-white;
|
||||
font-size: 0.12rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border-color: $accent;
|
||||
color: $accent;
|
||||
background: $theme-color;
|
||||
background: $btn-dark-hover;
|
||||
}
|
||||
|
||||
&--active {
|
||||
@@ -549,6 +569,52 @@
|
||||
margin-top: 0.01rem;
|
||||
}
|
||||
|
||||
// 未登录锁定态
|
||||
&__job-match--locked {
|
||||
background: $bg-main;
|
||||
border: 1px solid #E8E8E8;
|
||||
}
|
||||
|
||||
&__lock-icon {
|
||||
width: 0.32rem;
|
||||
height: 0.32rem;
|
||||
color: $text-light;
|
||||
margin-bottom: 0.06rem;
|
||||
}
|
||||
|
||||
&__match-lock-text {
|
||||
font-size: 0.1rem;
|
||||
color: $text-light;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 收藏统计栏
|
||||
&__fav-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
&__fav-stats-item {
|
||||
font-size: 0.14rem;
|
||||
color: $text-dark;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.04rem 0.08rem;
|
||||
border-radius: 0.06rem;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: $accent;
|
||||
font-weight: 600;
|
||||
background: $theme-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多提示
|
||||
&__loading-more {
|
||||
text-align: center;
|
||||
@@ -588,7 +654,6 @@
|
||||
color: $text-dark;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0.2rem;
|
||||
margin-top: 0.12rem;
|
||||
|
||||
// 居中标题(问题反馈弹窗用)
|
||||
&--center {
|
||||
|
||||
@@ -20,6 +20,9 @@ $text-dark: #000000;
|
||||
// 浅色文字(副标题、占位符、辅助信息)
|
||||
$text-light: #BFBFBF;
|
||||
|
||||
// 中间色文字(使用优先级高于浅色文字,稍微要深色一点的副标题、占位符、辅助信息)
|
||||
$text-middle: #777777;
|
||||
|
||||
// 强调色 / 品牌色
|
||||
$accent: #4FC2C9;
|
||||
|
||||
@@ -36,7 +39,12 @@ $border-color: #E8E8E8;
|
||||
$overlay-bg: rgba(0, 0, 0, 0.5);
|
||||
|
||||
// 按钮深色背景(确认提交等)
|
||||
$btn-dark: #1A1A2E;
|
||||
// $btn-dark: #1A1A2E;
|
||||
$btn-dark: #4FC2C9;
|
||||
|
||||
// 按钮深色悬停态
|
||||
$btn-dark-hover: #2E3142;
|
||||
// $btn-dark-hover: #2E3142;
|
||||
$btn-dark-hover: #42A8B3;
|
||||
|
||||
// 渐变色背景
|
||||
$gradient-bg: linear-gradient(to right, #4FC2C9, #42A8B3);
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { dislikeJob } from '@/api/jobs'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -48,6 +49,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'disliked'): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
@@ -55,39 +57,50 @@ const visible = computed({
|
||||
set: (val: boolean) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
/** 不感兴趣的原因(单选) */
|
||||
const dislikeReason = ref('')
|
||||
/** 不感兴趣的原因(单选,0-5) */
|
||||
const dislikeReason = ref<number | undefined>(undefined)
|
||||
|
||||
/** 不感兴趣的补充描述 */
|
||||
const dislikeDetail = ref('')
|
||||
|
||||
/** 不感兴趣原因选项列表 */
|
||||
/** 不感兴趣原因选项列表(与接口 reason 0-5 对应) */
|
||||
const dislikeOptions = [
|
||||
{ value: 'company', label: '对这家公司不感兴趣' },
|
||||
{ value: 'position', label: '对这个岗位不感兴趣' },
|
||||
{ value: 'location', label: '工作地点不合适' },
|
||||
{ value: 'other', label: '其他原因' },
|
||||
{ value: 0, label: '对公司不感兴趣' },
|
||||
{ value: 1, label: '对岗位不感兴趣' },
|
||||
{ value: 2, label: '对行业不感兴趣' },
|
||||
// { value: 3, label: '技能不符合' },
|
||||
{ value: 4, label: '地点不合适' },
|
||||
{ value: 5, label: '其他' },
|
||||
]
|
||||
|
||||
/** 是否正在提交 */
|
||||
const submitting = ref(false)
|
||||
|
||||
/** 提交不感兴趣反馈 */
|
||||
function handleDislikeSubmit() {
|
||||
if (!dislikeReason.value) {
|
||||
async function handleDislikeSubmit() {
|
||||
if (dislikeReason.value === undefined) {
|
||||
ElMessage.warning('请选择一个原因')
|
||||
return
|
||||
}
|
||||
// TODO: 调用接口提交反馈,参数:props.jobId, dislikeReason.value, dislikeDetail.value
|
||||
console.log('提交不感兴趣反馈', {
|
||||
jobId: props.jobId,
|
||||
reason: dislikeReason.value,
|
||||
detail: dislikeDetail.value,
|
||||
})
|
||||
if (!props.jobId) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await dislikeJob(props.jobId, dislikeReason.value)
|
||||
if (res.code === '0') {
|
||||
ElMessage.success('反馈已提交,感谢您的反馈')
|
||||
visible.value = false
|
||||
emit('disliked')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('提交不感兴趣反馈失败', e)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 弹窗打开时重置表单 */
|
||||
function resetForm() {
|
||||
dislikeReason.value = ''
|
||||
dislikeReason.value = undefined
|
||||
dislikeDetail.value = ''
|
||||
}
|
||||
|
||||
|
||||
@@ -22,99 +22,44 @@
|
||||
</div>
|
||||
|
||||
<!-- 岗位选择模块 -->
|
||||
<div class="job-goal-dialog__section ">
|
||||
<div class="job-goal-dialog__section">
|
||||
<div class="job-goal-dialog__label">*岗位</div>
|
||||
<!-- 已选岗位标签列表 -->
|
||||
<div class="job-goal-dialog__tags">
|
||||
<span
|
||||
v-for="(item, index) in form.positions"
|
||||
:key="index"
|
||||
class="job-goal-dialog__tag"
|
||||
>
|
||||
{{ item }}
|
||||
<!-- 删除岗位标签 -->
|
||||
<el-icon class="job-goal-dialog__tag-close" @click="removePosition(index)"><Close /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 岗位下拉选择器 -->
|
||||
<el-select
|
||||
v-model="newPosition"
|
||||
placeholder="新增岗位"
|
||||
filterable
|
||||
class="job-goal-dialog__select"
|
||||
@change="addPosition"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in positionOptions"
|
||||
:key="opt"
|
||||
:label="opt"
|
||||
:value="opt"
|
||||
<JobCategorySelector
|
||||
:categoryIds="selectedCategoryIds"
|
||||
:maxSelect="3"
|
||||
:level="3"
|
||||
:allowParentSelect="false"
|
||||
:triggerStyle="selectorTriggerStyle"
|
||||
:displayStyle="selectorDisplayStyle"
|
||||
@update:categoryIds="onCategoryChange"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 行业选择模块 -->
|
||||
<div class="job-goal-dialog__section ">
|
||||
<div class="job-goal-dialog__section">
|
||||
<div class="job-goal-dialog__label">*行业</div>
|
||||
<!-- 已选行业标签列表 -->
|
||||
<div class="job-goal-dialog__tags">
|
||||
<span
|
||||
v-for="(item, index) in form.industries"
|
||||
:key="index"
|
||||
class="job-goal-dialog__tag"
|
||||
>
|
||||
{{ item }}
|
||||
<!-- 删除行业标签 -->
|
||||
<el-icon class="job-goal-dialog__tag-close" @click="removeIndustry(index)"><Close /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 行业下拉选择器 -->
|
||||
<el-select
|
||||
v-model="newIndustry"
|
||||
placeholder="新增行业"
|
||||
filterable
|
||||
class="job-goal-dialog__select"
|
||||
@change="addIndustry"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in industryOptions"
|
||||
:key="opt"
|
||||
:label="opt"
|
||||
:value="opt"
|
||||
<IndustrySelector
|
||||
:industryIds="selectedIndustryIds"
|
||||
:maxSelect="3"
|
||||
:level="2"
|
||||
:allowParentSelect="false"
|
||||
:triggerStyle="selectorTriggerStyle"
|
||||
:displayStyle="selectorDisplayStyle"
|
||||
@update:industryIds="onIndustryChange"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 城市选择模块 -->
|
||||
<div class="job-goal-dialog__section ">
|
||||
<div class="job-goal-dialog__section">
|
||||
<div class="job-goal-dialog__label">*城市</div>
|
||||
<!-- 已选城市标签列表 -->
|
||||
<div class="job-goal-dialog__tags">
|
||||
<span
|
||||
v-for="(item, index) in form.cities"
|
||||
:key="index"
|
||||
class="job-goal-dialog__tag"
|
||||
>
|
||||
{{ item }}
|
||||
<!-- 删除城市标签 -->
|
||||
<el-icon class="job-goal-dialog__tag-close" @click="removeCity(index)"><Close /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 城市下拉选择器 -->
|
||||
<el-select
|
||||
v-model="newCity"
|
||||
placeholder="新增城市"
|
||||
filterable
|
||||
class="job-goal-dialog__select"
|
||||
@change="addCity"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in cityOptions"
|
||||
:key="opt"
|
||||
:label="opt"
|
||||
:value="opt"
|
||||
<RegionSelector
|
||||
:regionCodes="selectedRegionCodes"
|
||||
:level="2"
|
||||
:maxSelect="3"
|
||||
:triggerStyle="selectorTriggerStyle"
|
||||
:displayStyle="selectorDisplayStyle"
|
||||
@update:regionCodes="onRegionChange"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 工作类型选择模块 -->
|
||||
@@ -126,8 +71,8 @@
|
||||
v-for="t in jobTypes"
|
||||
:key="t"
|
||||
class="job-goal-dialog__type-btn"
|
||||
:class="{ 'job-goal-dialog__type-btn--active': form.jobType === t }"
|
||||
@click="form.jobType = t"
|
||||
:class="{ 'job-goal-dialog__type-btn--active': selectedJobType === t }"
|
||||
@click="selectedJobType = t"
|
||||
>
|
||||
{{ t }}
|
||||
</button>
|
||||
@@ -142,18 +87,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
import { useStore } from 'vuex'
|
||||
import RegionSelector from './tools/RegionSelector.vue'
|
||||
import IndustrySelector from './tools/IndustrySelector.vue'
|
||||
import JobCategorySelector from './tools/JobCategorySelector.vue'
|
||||
|
||||
/** 组件属性:控制弹窗显示/隐藏 */
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
|
||||
/** 组件事件:更新弹窗状态、保存表单数据 */
|
||||
/** 组件事件:更新弹窗状态 */
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'save', data: { positions: string[]; industries: string[]; cities: string[]; jobType: string }): void
|
||||
}>()
|
||||
|
||||
const store = useStore()
|
||||
|
||||
/** 弹窗可见状态 */
|
||||
const visible = ref(props.modelValue)
|
||||
/** 监听外部传入的 modelValue 同步弹窗状态 */
|
||||
@@ -161,102 +111,75 @@ watch(() => props.modelValue, (v) => { visible.value = v })
|
||||
/** 监听弹窗状态变化并通知父组件 */
|
||||
watch(visible, (v) => { emit('update:modelValue', v) })
|
||||
|
||||
/** 表单数据 */
|
||||
const form = reactive({
|
||||
/** 已选岗位列表 */
|
||||
positions: ['产品经理'] as string[],
|
||||
/** 已选行业列表 */
|
||||
industries: ['互联网'] as string[],
|
||||
/** 已选城市列表 */
|
||||
cities: ['北京'] as string[],
|
||||
/** 工作类型 */
|
||||
jobType: '全职',
|
||||
})
|
||||
/** 本地编辑副本 — 打开弹窗时从 store 拷贝,保存时写回 store */
|
||||
const selectedCategoryIds = ref<number[]>([])
|
||||
const selectedIndustryIds = ref<number[]>([])
|
||||
const selectedRegionCodes = ref<string[]>([])
|
||||
const selectedJobType = ref('全职')
|
||||
|
||||
/** 新增岗位的绑定值 */
|
||||
const newPosition = ref('')
|
||||
/** 新增行业的绑定值 */
|
||||
const newIndustry = ref('')
|
||||
/** 新增城市的绑定值 */
|
||||
const newCity = ref('')
|
||||
|
||||
/** 岗位选项列表 */
|
||||
const positionOptions = ['产品经理', '前端工程师', '后端工程师', 'UI设计师', '数据分析师', '运营', '市场营销']
|
||||
/** 行业选项列表 */
|
||||
const industryOptions = ['互联网', '金融', '教育', '医疗健康', '电子商务', '人工智能', '游戏', '新能源', '房地产', '制造业']
|
||||
/** 城市选项列表 */
|
||||
const cityOptions = ['北京', '上海', '广州', '深圳', '杭州', '成都', '南京', '武汉']
|
||||
/** 工作类型选项列表 */
|
||||
const jobTypes = ['实习', '全职']
|
||||
|
||||
/**
|
||||
* 添加岗位
|
||||
* @param val 选中的岗位名称
|
||||
*/
|
||||
function addPosition(val: string) {
|
||||
if (val && !form.positions.includes(val)) {
|
||||
form.positions.push(val)
|
||||
/** 弹窗打开时从 store 同步数据到本地编辑副本 */
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v) {
|
||||
const intention = store.state.jobIntention
|
||||
selectedCategoryIds.value = [...(intention.categoryIds || [])]
|
||||
selectedIndustryIds.value = [...(intention.industryIds || [])]
|
||||
selectedRegionCodes.value = [...(intention.regionCodes || [])]
|
||||
selectedJobType.value = intention.employmentType === 1 ? '实习' : '全职'
|
||||
}
|
||||
newPosition.value = ''
|
||||
})
|
||||
|
||||
/** 选择器触发按钮的自定义样式,适配弹窗内布局(参考 ProfileEditDrawer 中 regionTriggerStyle) */
|
||||
const selectorTriggerStyle: Record<string, string> = {
|
||||
width: '100%',
|
||||
'box-sizing': 'border-box',
|
||||
padding: '0.1rem 0.14rem',
|
||||
'font-size': '0.13rem',
|
||||
color: '#1a1a2e',
|
||||
background: '#f6f6f9',
|
||||
border: '1px solid transparent',
|
||||
'border-radius': '0.06rem',
|
||||
'max-width': 'none',
|
||||
'justify-content': 'space-between',
|
||||
}
|
||||
|
||||
/** 选择器显示文字的自定义样式,覆盖默认 max-width: 1.6rem */
|
||||
const selectorDisplayStyle: Record<string, string> = {
|
||||
'max-width': 'none',
|
||||
}
|
||||
|
||||
/** 岗位选择变更回调 */
|
||||
function onCategoryChange(ids: number[]) {
|
||||
selectedCategoryIds.value = ids
|
||||
}
|
||||
|
||||
/** 行业选择变更回调 */
|
||||
function onIndustryChange(ids: number[]) {
|
||||
selectedIndustryIds.value = ids
|
||||
}
|
||||
|
||||
/** 地区选择变更回调 */
|
||||
function onRegionChange(codes: string[]) {
|
||||
selectedRegionCodes.value = codes
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除岗位
|
||||
* @param index 要移除的岗位索引
|
||||
* 保存表单数据:调用 store action 保存到后端并同步 store,然后关闭弹窗
|
||||
*/
|
||||
function removePosition(index: number) {
|
||||
form.positions.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加行业
|
||||
* @param val 选中的行业名称
|
||||
*/
|
||||
function addIndustry(val: string) {
|
||||
if (val && !form.industries.includes(val)) {
|
||||
form.industries.push(val)
|
||||
}
|
||||
newIndustry.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除行业
|
||||
* @param index 要移除的行业索引
|
||||
*/
|
||||
function removeIndustry(index: number) {
|
||||
form.industries.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加城市
|
||||
* @param val 选中的城市名称
|
||||
*/
|
||||
function addCity(val: string) {
|
||||
if (val && !form.cities.includes(val)) {
|
||||
form.cities.push(val)
|
||||
}
|
||||
newCity.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除城市
|
||||
* @param index 要移除的城市索引
|
||||
*/
|
||||
function removeCity(index: number) {
|
||||
form.cities.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存表单数据并关闭弹窗
|
||||
*/
|
||||
function handleSave() {
|
||||
emit('save', {
|
||||
...form,
|
||||
positions: [...form.positions],
|
||||
industries: [...form.industries],
|
||||
cities: [...form.cities],
|
||||
async function handleSave() {
|
||||
try {
|
||||
await store.dispatch('saveJobIntention', {
|
||||
categoryIds: [...selectedCategoryIds.value],
|
||||
industryIds: [...selectedIndustryIds.value],
|
||||
regionCodes: [...selectedRegionCodes.value],
|
||||
employmentType: selectedJobType.value === '实习' ? 1 : 0,
|
||||
})
|
||||
visible.value = false
|
||||
} catch (e) {
|
||||
console.error('保存求职意向失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,41 +4,34 @@
|
||||
<h2 class="job-page-header__title">发现理想职位</h2>
|
||||
<p class="job-page-header__subtitle">找到最适合你的工作机会</p>
|
||||
</div>
|
||||
<div class="job-page-header__tabs">
|
||||
<div class="job-page-header__tabs mt20">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
v-for="(tab,index) in tabs"
|
||||
:key="tab.key"
|
||||
class="job-page-header__tab"
|
||||
:class="{ 'job-page-header__tab--active': activeTab === tab.key }"
|
||||
:style="index==0?'padding:0.06rem 0.30rem;':''"
|
||||
@click="handleTabClick(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
<button class="job-page-header__goal-btn" @click="showGoalDialog = true">
|
||||
我的求职目标
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<JobGoalDialog v-model="showGoalDialog" @save="onGoalSave" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import JobGoalDialog from './JobGoalDialog.vue'
|
||||
|
||||
const showGoalDialog = ref(false)
|
||||
|
||||
function onGoalSave(data: { positions: string[]; cities: string[]; jobType: string }) {
|
||||
console.log('求职目标已保存', data)
|
||||
}
|
||||
|
||||
// ==================== Props & Emits ====================
|
||||
|
||||
/** activeTab: 当前激活的 Tab key,由父组件通过 v-model 传入 */
|
||||
const props = defineProps<{
|
||||
activeTab: string
|
||||
/** 收藏总数,由父组件传入 */
|
||||
favoriteCount?: number
|
||||
/** 投递总数,由父组件传入 */
|
||||
applyCount?: number
|
||||
}>()
|
||||
|
||||
/** 双向绑定事件,在 Jobs 页面内切换 Tab 时触发 */
|
||||
@@ -54,11 +47,11 @@ const route = useRoute()
|
||||
// ==================== 常量数据 ====================
|
||||
|
||||
/** Tab 选项列表 */
|
||||
const tabs = [
|
||||
const tabs = computed(() => [
|
||||
{ key: 'recommend', label: '推荐' },
|
||||
{ key: 'collected', label: '收藏(1)' },
|
||||
{ key: 'applied', label: '已投递(2)' },
|
||||
]
|
||||
{ key: 'collected', label: `收藏(${props.favoriteCount ?? 0})` },
|
||||
{ key: 'applied', label: `投递(${props.applyCount ?? 0})` },
|
||||
])
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
<template>
|
||||
<!-- 岗位专属简历定制弹窗:步骤1居中弹窗,步骤2+右侧抽屉 -->
|
||||
<div v-if="modelValue" class="job-resume-custom-dialog" :class="{ 'job-resume-custom-dialog--drawer': currentStep >= 2 }">
|
||||
<div class="job-resume-custom-dialog__overlay" @click="handleClose"></div>
|
||||
<!-- ===== 步骤一:居中弹窗 ===== -->
|
||||
<div v-if="currentStep === 1" class="job-resume-custom-dialog__panel">
|
||||
<div class="job-resume-custom-dialog__header">
|
||||
<h2 class="job-resume-custom-dialog__title">10s快速定制岗位专属简历</h2>
|
||||
<button class="job-resume-custom-dialog__close-btn" @click="handleClose" aria-label="关闭">
|
||||
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__close-icon"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__tip-bar"><span>你与该岗位的匹配度较低,简历可能无法通过机筛。</span></div>
|
||||
<div class="job-resume-custom-dialog__job-card">
|
||||
<div class="job-resume-custom-dialog__job-left">
|
||||
<div class="job-resume-custom-dialog__company-icon">
|
||||
<img v-if="jobInfo.companyLogoUrl" :src="jobInfo.companyLogoUrl" :alt="jobInfo.company" class="job-resume-custom-dialog__company-logo-img" />
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__company-svg"><rect x="3" y="7" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M7 7V5a2 2 0 012-2h6a2 2 0 012 2v2" stroke="currentColor" stroke-width="1.5"/><path d="M3 13h18" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__job-info">
|
||||
<span class="job-resume-custom-dialog__job-title">{{ jobInfo.title }}</span>
|
||||
<span class="job-resume-custom-dialog__job-sub">{{ jobInfo.location }} · {{ jobInfo.company }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__match-area">
|
||||
<div class="job-resume-custom-dialog__match-ring">
|
||||
<svg viewBox="0 0 60 60" class="job-resume-custom-dialog__ring-svg"><circle cx="30" cy="30" r="24" stroke-width="4" stroke="#E8E8E8" fill="none" opacity="0.3"/><circle cx="30" cy="30" r="24" stroke-width="4" fill="none" stroke="#4FC2C9" stroke-linecap="round" :stroke-dasharray="2*Math.PI*24" :stroke-dashoffset="2*Math.PI*24*(1-jobInfo.matchScore/10)" transform="rotate(-90 30 30)"/></svg>
|
||||
<span class="job-resume-custom-dialog__match-score">{{ jobInfo.matchScore }}</span>
|
||||
</div>
|
||||
<span class="job-resume-custom-dialog__match-label">{{ matchLevelText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__skills-section">
|
||||
<p class="job-resume-custom-dialog__skills-title">缺少{{ missingSkills.length }}项技能</p>
|
||||
<div class="job-resume-custom-dialog__skills-list">
|
||||
<span v-for="skill in missingSkills" :key="skill" class="job-resume-custom-dialog__skill-tag">{{ skill }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__footer">
|
||||
<button class="job-resume-custom-dialog__primary-btn" @click="goToStep(2)">立即定制简历</button>
|
||||
<span class="job-resume-custom-dialog__skip-link" @click="handleSkip">不优化,直接投递</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 步骤2+:右侧抽屉 ===== -->
|
||||
<div v-if="currentStep >= 2" class="job-resume-custom-dialog__drawer">
|
||||
<!-- 抽屉头部 -->
|
||||
<div class="job-resume-custom-dialog__drawer-header">
|
||||
<button class="job-resume-custom-dialog__close-btn" @click="handleClose" aria-label="关闭">
|
||||
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__close-icon"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<h2 class="job-resume-custom-dialog__drawer-title">生成你的岗位专属简历</h2>
|
||||
</div>
|
||||
<!-- 返回按钮(步骤四显示) -->
|
||||
<button v-if="currentStep === 4" class="job-resume-custom-dialog__back-btn" @click="goToStep(3)">返回</button>
|
||||
<!-- 步骤指示器 -->
|
||||
<div class="job-resume-custom-dialog__steps">
|
||||
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 2 }">
|
||||
<span class="job-resume-custom-dialog__step-num">1</span><span>差距分析</span>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 3 }">
|
||||
<span class="job-resume-custom-dialog__step-num">2</span><span>定制简历</span>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 4 }">
|
||||
<span class="job-resume-custom-dialog__step-num">3</span><span>预览</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 抽屉内容区(可滚动) -->
|
||||
<div class="job-resume-custom-dialog__drawer-body">
|
||||
<!-- 步骤二:差距分析 -->
|
||||
<template v-if="currentStep === 2">
|
||||
<div class="job-resume-custom-dialog__gap-header">
|
||||
<div class="job-resume-custom-dialog__gap-left">
|
||||
<h3 class="job-resume-custom-dialog__gap-title">你的简历与该岗位的匹配度较低</h3>
|
||||
<div class="job-resume-custom-dialog__gap-warn">
|
||||
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__warn-icon"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M8 5v3M8 10.5v.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
<span>匹配度低于 6.0 分的简历,在筛选环节可能会被优先淘汰。我们会帮你快速优化提升。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__gap-score-area">
|
||||
<div class="job-resume-custom-dialog__match-ring">
|
||||
<svg viewBox="0 0 60 60" class="job-resume-custom-dialog__ring-svg"><circle cx="30" cy="30" r="24" stroke-width="4" stroke="#E8E8E8" fill="none" opacity="0.3"/><circle cx="30" cy="30" r="24" stroke-width="4" fill="none" stroke="#4FC2C9" stroke-linecap="round" :stroke-dasharray="2*Math.PI*24" :stroke-dashoffset="2*Math.PI*24*(1-jobInfo.matchScore/10)" transform="rotate(-90 30 30)"/></svg>
|
||||
<span class="job-resume-custom-dialog__match-score">{{ jobInfo.matchScore }}</span>
|
||||
</div>
|
||||
<span class="job-resume-custom-dialog__match-label">{{ matchLevelText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 对比卡片表格 -->
|
||||
<div class="job-resume-custom-dialog__gap-table">
|
||||
<!-- 第一行:概览 -->
|
||||
<div class="job-resume-custom-dialog__gap-row">
|
||||
<!-- 标签列 -->
|
||||
<div class="job-resume-custom-dialog__gap-cell job-resume-custom-dialog__gap-cell--label">
|
||||
<span class="job-resume-custom-dialog__gap-cell-title">概览</span>
|
||||
</div>
|
||||
<!-- 岗位信息列 -->
|
||||
<div class="job-resume-custom-dialog__gap-cell">
|
||||
<div class="job-resume-custom-dialog__gap-job-info">
|
||||
<div class="job-resume-custom-dialog__gap-company-icon">
|
||||
<img v-if="jobInfo.companyLogoUrl" :src="jobInfo.companyLogoUrl" :alt="jobInfo.company" class="job-resume-custom-dialog__gap-company-logo" />
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__gap-company-svg"><rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__gap-job-text">
|
||||
<span class="job-resume-custom-dialog__gap-job-title">{{ jobInfo.title }}</span>
|
||||
<span class="job-resume-custom-dialog__gap-job-sub">{{ jobInfo.location }} · {{ jobInfo.company }} 独角兽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 简历选择列 -->
|
||||
<div class="job-resume-custom-dialog__gap-cell">
|
||||
<div class="job-resume-custom-dialog__resume-selector">
|
||||
<div class="job-resume-custom-dialog__resume-info">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__resume-file-icon"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M14 2v6h6" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
|
||||
<div class="job-resume-custom-dialog__resume-text">
|
||||
<span class="job-resume-custom-dialog__resume-label">你的简历</span>
|
||||
<span class="job-resume-custom-dialog__resume-name">{{ selectedResume.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="job-resume-custom-dialog__resume-select-btn" @click="toggleResumeDropdown">选择 <svg viewBox="0 0 12 12" fill="none" class="job-resume-custom-dialog__dropdown-arrow"><path d="M3 5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
|
||||
<div v-if="showResumeDropdown" class="job-resume-custom-dialog__resume-dropdown">
|
||||
<div v-for="r in resumeList" :key="r.id" class="job-resume-custom-dialog__resume-option" :class="{'job-resume-custom-dialog__resume-option--active': r.id === selectedResume.id}" @click="selectResume(r)">{{ r.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第二行:岗位名称 -->
|
||||
<div class="job-resume-custom-dialog__gap-row">
|
||||
<div class="job-resume-custom-dialog__gap-cell job-resume-custom-dialog__gap-cell--label">
|
||||
<span>岗位名称</span>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__gap-cell">
|
||||
<span class="job-resume-custom-dialog__gap-value">{{ jobInfo.title }}</span>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__gap-cell">
|
||||
<span class="job-resume-custom-dialog__gap-value">{{ selectedResume.targetJob || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第三行:岗位关键词 -->
|
||||
<div class="job-resume-custom-dialog__gap-row">
|
||||
<div class="job-resume-custom-dialog__gap-cell job-resume-custom-dialog__gap-cell--label">
|
||||
<span>岗位关键词</span>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__gap-cell job-resume-custom-dialog__gap-cell--keywords">
|
||||
<div class="job-resume-custom-dialog__gap-keywords">
|
||||
<span v-for="kw in jobInfo.keywords" :key="kw" class="job-resume-custom-dialog__gap-kw-tag">{{ kw }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 步骤三:定制简历 -->
|
||||
<template v-if="currentStep === 3">
|
||||
<div class="job-resume-custom-dialog__custom">
|
||||
<!-- 左侧:选择要优化的部分 -->
|
||||
<div class="job-resume-custom-dialog__custom-panel">
|
||||
<h3 class="job-resume-custom-dialog__custom-panel-title">1.选择你要优化的部分</h3>
|
||||
<div class="job-resume-custom-dialog__custom-options">
|
||||
<label v-for="item in optimizeSections" :key="item.key" class="job-resume-custom-dialog__custom-checkbox">
|
||||
<input type="checkbox" v-model="item.checked" class="job-resume-custom-dialog__custom-input" />
|
||||
<span class="job-resume-custom-dialog__custom-checkmark"></span>
|
||||
<span class="job-resume-custom-dialog__custom-label">{{ item.label }}</span>
|
||||
<!-- 技能和工作经验的问号提示(使用 el-tooltip) -->
|
||||
<el-tooltip
|
||||
v-if="item.tooltip"
|
||||
:content="item.tooltip"
|
||||
placement="right"
|
||||
:show-arrow="true"
|
||||
:popper-options="{ strategy: 'fixed' }"
|
||||
effect="dark"
|
||||
>
|
||||
<span class="job-resume-custom-dialog__custom-tooltip-trigger">
|
||||
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__custom-tooltip-icon">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/>
|
||||
<path d="M6.5 6.5a1.5 1.5 0 112.12 1.37c-.42.18-.62.5-.62.88V9.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<circle cx="8" cy="11.5" r="0.6" fill="currentColor"/>
|
||||
</svg>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧:选择要新增的技能关键词 -->
|
||||
<div class="job-resume-custom-dialog__custom-panel">
|
||||
<h3 class="job-resume-custom-dialog__custom-panel-title">2.选择你要新增的技能关键词</h3>
|
||||
<div class="job-resume-custom-dialog__custom-options">
|
||||
<label v-for="skill in newSkillOptions" :key="skill.name" class="job-resume-custom-dialog__custom-checkbox">
|
||||
<input type="checkbox" v-model="skill.checked" class="job-resume-custom-dialog__custom-input" />
|
||||
<span class="job-resume-custom-dialog__custom-checkmark"></span>
|
||||
<span class="job-resume-custom-dialog__custom-label">{{ skill.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 步骤四:预览 -->
|
||||
<template v-if="currentStep === 4">
|
||||
<div class="job-resume-custom-dialog__preview">
|
||||
<!-- 左侧:简历模板预览 -->
|
||||
<div class="job-resume-custom-dialog__preview-left">
|
||||
<JobResumeTemplate :resumeData="resumeTemplateData" ref="resumeTemplateRef" />
|
||||
</div>
|
||||
<!-- 右侧:AI帮写 / 编辑 tab -->
|
||||
<div class="job-resume-custom-dialog__preview-right">
|
||||
<!-- Tab 切换 -->
|
||||
<div class="job-resume-custom-dialog__preview-tabs">
|
||||
<button
|
||||
class="job-resume-custom-dialog__preview-tab"
|
||||
:class="{ 'job-resume-custom-dialog__preview-tab--active': previewTab === 'ai' }"
|
||||
@click="previewTab = 'ai'"
|
||||
>AI帮写</button>
|
||||
<button
|
||||
class="job-resume-custom-dialog__preview-tab"
|
||||
:class="{ 'job-resume-custom-dialog__preview-tab--active': previewTab === 'edit' }"
|
||||
@click="previewTab = 'edit'"
|
||||
>编辑</button>
|
||||
</div>
|
||||
<!-- AI帮写内容 -->
|
||||
<div v-if="previewTab === 'ai'" class="job-resume-custom-dialog__preview-ai">
|
||||
<!-- 匹配度提升提示 -->
|
||||
<div class="job-resume-custom-dialog__ai-result">
|
||||
<div class="job-resume-custom-dialog__ai-result-text">
|
||||
<p class="job-resume-custom-dialog__ai-result-title">恭喜!你的简历匹配值从<br/>{{ jobInfo.matchScore }}分提升到了10分!</p>
|
||||
<div class="job-resume-custom-dialog__ai-result-detail">
|
||||
<p class="job-resume-custom-dialog__ai-result-subtitle">做了哪些优化?</p>
|
||||
<ul class="job-resume-custom-dialog__ai-result-list">
|
||||
<li v-for="(item, i) in aiOptimizeResults" :key="i">·{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="job-resume-custom-dialog__ai-result-score">
|
||||
<div class="job-resume-custom-dialog__match-ring job-resume-custom-dialog__match-ring--large">
|
||||
<svg viewBox="0 0 60 60" class="job-resume-custom-dialog__ring-svg">
|
||||
<circle cx="30" cy="30" r="24" stroke-width="4" stroke="#E8E8E8" fill="none" opacity="0.3"/>
|
||||
<circle cx="30" cy="30" r="24" stroke-width="4" fill="none" stroke="#4FC2C9" stroke-linecap="round" :stroke-dasharray="2*Math.PI*24" :stroke-dashoffset="0" transform="rotate(-90 30 30)"/>
|
||||
</svg>
|
||||
<span class="job-resume-custom-dialog__match-score">10.0</span>
|
||||
</div>
|
||||
<span class="job-resume-custom-dialog__match-label job-resume-custom-dialog__match-label--high">非常匹配</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 快捷操作按钮 -->
|
||||
<div class="job-resume-custom-dialog__ai-quick-actions">
|
||||
<button
|
||||
v-for="(action, i) in aiQuickActions"
|
||||
:key="i"
|
||||
class="job-resume-custom-dialog__ai-quick-btn"
|
||||
@click="sendAiMessage(action)"
|
||||
>{{ action }}</button>
|
||||
</div>
|
||||
<!-- AI聊天消息区域 -->
|
||||
<div class="job-resume-custom-dialog__ai-messages" ref="aiMessagesRef">
|
||||
<div
|
||||
v-for="(msg, i) in aiMessages"
|
||||
:key="i"
|
||||
class="job-resume-custom-dialog__ai-msg"
|
||||
:class="msg.role === 'ai' ? 'job-resume-custom-dialog__ai-msg--ai' : 'job-resume-custom-dialog__ai-msg--user'"
|
||||
>
|
||||
<div class="job-resume-custom-dialog__ai-msg-bubble">{{ msg.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- AI输入框 -->
|
||||
<div class="job-resume-custom-dialog__ai-input-area">
|
||||
<input
|
||||
v-model="aiInputText"
|
||||
class="job-resume-custom-dialog__ai-input"
|
||||
placeholder="你要怎么优化"
|
||||
@keyup.enter="sendAiMessage(aiInputText)"
|
||||
/>
|
||||
<button class="job-resume-custom-dialog__ai-send-btn" @click="sendAiMessage(aiInputText)">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__ai-send-icon">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 编辑内容(占位) -->
|
||||
<div v-if="previewTab === 'edit'" class="job-resume-custom-dialog__preview-edit">
|
||||
<div class="job-resume-custom-dialog__placeholder">编辑功能(待开发)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 抽屉底部按钮 -->
|
||||
<div v-if="currentStep < 4" class="job-resume-custom-dialog__drawer-footer">
|
||||
<button class="job-resume-custom-dialog__primary-btn" @click="handleDrawerNext">立即定制简历</button>
|
||||
</div>
|
||||
<!-- 步骤四专属底部:下载简历 + 立即去投递 -->
|
||||
<div v-if="currentStep === 4" class="job-resume-custom-dialog__preview-footer">
|
||||
<!-- 左侧:下载简历按钮(带下拉) -->
|
||||
<div class="job-resume-custom-dialog__download-wrap">
|
||||
<button class="job-resume-custom-dialog__download-btn" @click="toggleDownloadMenu">下载简历</button>
|
||||
<div v-if="showDownloadMenu" class="job-resume-custom-dialog__download-menu">
|
||||
<button class="job-resume-custom-dialog__download-option" @click="handleDownload('pdf')">下载PDF</button>
|
||||
<button class="job-resume-custom-dialog__download-option" @click="handleDownload('word')">下载Word</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧:立即去投递按钮 -->
|
||||
<button class="job-resume-custom-dialog__submit-btn" @click="handleSubmit">立即去投递</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
|
||||
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
|
||||
import { fetchProfile, fetchEducation, fetchWork, fetchInternship, fetchProject, fetchCompetition } from '@/api/profile'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/** 简历选项 */
|
||||
interface ResumeOption {
|
||||
id: string
|
||||
name: string
|
||||
targetJob: string
|
||||
}
|
||||
|
||||
/** 岗位信息 */
|
||||
interface JobInfo {
|
||||
title: string
|
||||
company: string
|
||||
companyLogoUrl: string
|
||||
location: string
|
||||
matchScore: number
|
||||
missingSkills: string[]
|
||||
keywords: string[]
|
||||
sourceUrl: string
|
||||
}
|
||||
|
||||
/** AI聊天消息 */
|
||||
interface AiChatMsg {
|
||||
role: 'ai' | 'user'
|
||||
content: string
|
||||
}
|
||||
|
||||
// ==================== Props & Emits ====================
|
||||
|
||||
const props = defineProps<{
|
||||
/** 控制弹窗显隐 */
|
||||
modelValue: boolean
|
||||
/** 岗位信息 */
|
||||
jobInfo: JobInfo
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'skip'): void
|
||||
(e: 'submit'): void
|
||||
}>()
|
||||
|
||||
// ==================== 步骤控制 ====================
|
||||
|
||||
/** 当前步骤:1-确认入口(居中弹窗) 2-差距分析(右侧抽屉) 3-定制简历 4-预览 */
|
||||
const currentStep = ref(1)
|
||||
|
||||
/** 缺少的技能列表 */
|
||||
const missingSkills = computed(() => props.jobInfo.missingSkills || [])
|
||||
|
||||
/** 匹配度等级文案 */
|
||||
const matchLevelText = computed(() => {
|
||||
const score = props.jobInfo.matchScore
|
||||
if (score >= 8) return '高匹配度'
|
||||
if (score >= 5) return '中匹配度'
|
||||
return '低匹配度'
|
||||
})
|
||||
|
||||
/** 跳转到指定步骤 */
|
||||
function goToStep(step: number) {
|
||||
if (step === 3) initSkillOptions()
|
||||
if (step === 4) loadResumeData()
|
||||
currentStep.value = step
|
||||
}
|
||||
|
||||
/** 抽屉模式下一步 */
|
||||
function handleDrawerNext() {
|
||||
if (currentStep.value < 4) {
|
||||
if (currentStep.value === 2) initSkillOptions()
|
||||
if (currentStep.value === 3) loadResumeData()
|
||||
currentStep.value++
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭弹窗并重置步骤 */
|
||||
function handleClose() {
|
||||
currentStep.value = 1
|
||||
showResumeDropdown.value = false
|
||||
showDownloadMenu.value = false
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
/** 不优化,直接投递 */
|
||||
function handleSkip() {
|
||||
handleClose()
|
||||
emit('skip')
|
||||
}
|
||||
|
||||
// ==================== 定制简历选项(步骤三) ====================
|
||||
|
||||
/** 优化部分选项 */
|
||||
interface OptimizeSection {
|
||||
key: string
|
||||
label: string
|
||||
checked: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
/** 技能关键词选项 */
|
||||
interface SkillOption {
|
||||
name: string
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
/** 左侧:可优化的简历部分 */
|
||||
const optimizeSections = ref<OptimizeSection[]>([
|
||||
{ key: 'summary', label: '个人概述', checked: false },
|
||||
{ key: 'skills', label: '技能', checked: false, tooltip: '我们将把您勾选的技能补充进简历的技能模块。这对于简历能否通过ATS关键词筛选至关重要。' },
|
||||
{ key: 'experience', label: '工作经验', checked: false, tooltip: '我们将把您选择的技能融入工作经历中,并对关键词进行润色,最大程度提升简历与岗位的匹配度。' },
|
||||
])
|
||||
|
||||
/** 右侧:可新增的技能关键词 */
|
||||
const newSkillOptions = ref<SkillOption[]>([])
|
||||
|
||||
/** 根据缺失技能初始化技能选项 */
|
||||
function initSkillOptions() {
|
||||
newSkillOptions.value = missingSkills.value.map(skill => ({
|
||||
name: skill,
|
||||
checked: false,
|
||||
}))
|
||||
}
|
||||
|
||||
// ==================== 简历选择(步骤二) ====================
|
||||
|
||||
/** 简历列表(模拟数据,后续对接接口) */
|
||||
const resumeList = ref<ResumeOption[]>([
|
||||
{ id: '1', name: '李华_产品经理', targetJob: '电商产品经理' },
|
||||
{ id: '2', name: '李华_数据分析', targetJob: '数据分析师' },
|
||||
])
|
||||
|
||||
/** 当前选中的简历 */
|
||||
const selectedResume = ref<ResumeOption>(resumeList.value[0])
|
||||
|
||||
/** 简历下拉是否展开 */
|
||||
const showResumeDropdown = ref(false)
|
||||
|
||||
/** 切换简历下拉 */
|
||||
function toggleResumeDropdown() {
|
||||
showResumeDropdown.value = !showResumeDropdown.value
|
||||
}
|
||||
|
||||
/** 选择简历 */
|
||||
function selectResume(r: ResumeOption) {
|
||||
selectedResume.value = r
|
||||
showResumeDropdown.value = false
|
||||
}
|
||||
|
||||
// ==================== 步骤四:预览相关 ====================
|
||||
|
||||
/** 简历模板组件引用 */
|
||||
const resumeTemplateRef = ref()
|
||||
|
||||
/** 简历模板数据 */
|
||||
const resumeTemplateData = ref<ResumeTemplateData>({
|
||||
name: '',
|
||||
email: '',
|
||||
mobileNumber: '',
|
||||
wechatNumber: '',
|
||||
summary: '',
|
||||
educations: [],
|
||||
workExperiences: [],
|
||||
internships: [],
|
||||
projects: [],
|
||||
competitions: [],
|
||||
skills: [],
|
||||
certificates: [],
|
||||
})
|
||||
|
||||
/** 从接口加载个人资料并组装简历数据 */
|
||||
async function loadResumeData() {
|
||||
try {
|
||||
// 并行请求所有个人资料数据
|
||||
const [profileRes, eduRes, workRes, internRes, projRes, compRes] = await Promise.all([
|
||||
fetchProfile(),
|
||||
fetchEducation(),
|
||||
fetchWork(),
|
||||
fetchInternship(),
|
||||
fetchProject(),
|
||||
fetchCompetition(),
|
||||
])
|
||||
|
||||
// 组装简历模板数据
|
||||
const profile = profileRes.code === '0' ? profileRes.data : null
|
||||
resumeTemplateData.value = {
|
||||
name: profile?.name || '未填写姓名',
|
||||
email: profile?.email || '',
|
||||
mobileNumber: profile?.mobileNumber || '',
|
||||
wechatNumber: profile?.wechatNumber || '',
|
||||
summary: '', // 个人概述字段,后续由AI生成或用户编辑
|
||||
educations: eduRes.code === '0' && eduRes.data ? eduRes.data.map(e => ({
|
||||
school: e.school || '',
|
||||
major: e.major || '',
|
||||
degree: e.degree || 2,
|
||||
startDate: e.startDate || '',
|
||||
endDate: e.endDate || '',
|
||||
description: e.description,
|
||||
})) : [],
|
||||
workExperiences: workRes.code === '0' && workRes.data ? workRes.data.map(w => ({
|
||||
companyName: w.companyName || '',
|
||||
position: w.position || '',
|
||||
startDate: w.startDate || '',
|
||||
endDate: w.endDate || '',
|
||||
description: w.description,
|
||||
})) : [],
|
||||
internships: internRes.code === '0' && internRes.data ? internRes.data.map(i => ({
|
||||
companyName: i.companyName || '',
|
||||
position: i.position || '',
|
||||
startDate: i.startDate || '',
|
||||
endDate: i.endDate || '',
|
||||
description: i.description,
|
||||
})) : [],
|
||||
projects: projRes.code === '0' && projRes.data ? projRes.data.map(p => ({
|
||||
projectName: p.projectName || '',
|
||||
companyName: p.companyName || '',
|
||||
role: p.role || '',
|
||||
startDate: p.startDate || '',
|
||||
endDate: p.endDate || '',
|
||||
description: p.description,
|
||||
})) : [],
|
||||
competitions: compRes.code === '0' && compRes.data ? compRes.data.map(c => ({
|
||||
competitionName: c.competitionName || '',
|
||||
award: c.award || '',
|
||||
awardDate: c.awardDate || '',
|
||||
description: c.description,
|
||||
})) : [],
|
||||
skills: profile?.skills || [],
|
||||
certificates: profile?.certificates || [],
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[JobResumeCustomDialog] 加载简历数据失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前预览右侧tab:ai-AI帮写 / edit-编辑 */
|
||||
const previewTab = ref<'ai' | 'edit'>('ai')
|
||||
|
||||
/** AI优化结果列表(模拟数据) */
|
||||
const aiOptimizeResults = ref<string[]>([
|
||||
'增加了个人概述',
|
||||
'优化了5段经历描述',
|
||||
])
|
||||
|
||||
/** AI快捷操作按钮 */
|
||||
const aiQuickActions = ref<string[]>([
|
||||
'精简一下第一段工作经历',
|
||||
'帮我强化一下简历里面的量化成果',
|
||||
'删掉和这个岗位不相关的技能',
|
||||
])
|
||||
|
||||
/** AI聊天消息列表 */
|
||||
const aiMessages = ref<AiChatMsg[]>([])
|
||||
|
||||
/** AI输入框内容 */
|
||||
const aiInputText = ref('')
|
||||
|
||||
/** AI消息区域DOM引用 */
|
||||
const aiMessagesRef = ref<HTMLElement>()
|
||||
|
||||
/** 发送AI消息 */
|
||||
function sendAiMessage(text: string) {
|
||||
if (!text.trim()) return
|
||||
aiMessages.value.push({ role: 'user', content: text.trim() })
|
||||
aiInputText.value = ''
|
||||
// TODO: 接入AI聊天接口,获取AI回复
|
||||
nextTick(() => {
|
||||
if (aiMessagesRef.value) {
|
||||
aiMessagesRef.value.scrollTop = aiMessagesRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 下载菜单是否展开 */
|
||||
const showDownloadMenu = ref(false)
|
||||
|
||||
/** 切换下载菜单 */
|
||||
function toggleDownloadMenu() {
|
||||
showDownloadMenu.value = !showDownloadMenu.value
|
||||
}
|
||||
|
||||
/** 处理下载(PDF/Word) */
|
||||
function handleDownload(type: 'pdf' | 'word') {
|
||||
showDownloadMenu.value = false
|
||||
// TODO: 实现简历HTML转PDF/Word下载
|
||||
console.log(`[下载简历] 格式: ${type}`)
|
||||
}
|
||||
|
||||
/** 立即去投递 */
|
||||
function handleSubmit() {
|
||||
handleClose()
|
||||
emit('submit')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<!-- 简历HTML模板组件:用于预览和后续导出PDF/Word -->
|
||||
<div class="job-resume-template" ref="resumeRef">
|
||||
<div class="resume-html">
|
||||
<!-- 姓名 -->
|
||||
<h1 class="resume-html__name">{{ resumeData.name }}</h1>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<div class="resume-html__contact">
|
||||
<span v-if="resumeData.email">邮箱:{{ resumeData.email }}</span>
|
||||
<div class="resume-html__contact-row">
|
||||
<span v-if="resumeData.mobileNumber">手机:{{ resumeData.mobileNumber }}</span>
|
||||
<span v-if="resumeData.wechatNumber" class="resume-html__separator">丨</span>
|
||||
<span v-if="resumeData.wechatNumber">微信号:{{ resumeData.wechatNumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 个人概述 -->
|
||||
<template v-if="resumeData.summary">
|
||||
<div class="resume-html__section-title">个人概述</div>
|
||||
<div class="resume-html__divider"></div>
|
||||
<div class="resume-html__summary">{{ resumeData.summary }}</div>
|
||||
</template>
|
||||
|
||||
<!-- 教育背景 -->
|
||||
<template v-if="resumeData.educations && resumeData.educations.length">
|
||||
<div class="resume-html__section-title">教育背景</div>
|
||||
<div class="resume-html__divider"></div>
|
||||
<div v-for="(edu, idx) in resumeData.educations" :key="'edu-' + idx" class="resume-html__item">
|
||||
<div class="resume-html__item-header">
|
||||
<div class="resume-html__item-left">
|
||||
<span class="resume-html__item-main">{{ edu.school }},{{ edu.major }},{{ degreeText(edu.degree) }}</span>
|
||||
<span v-if="edu.description && edu.description.length" class="resume-html__item-desc">
|
||||
主修课程:{{ edu.description.map(d => d.text).join('、') }}等;
|
||||
</span>
|
||||
</div>
|
||||
<div class="resume-html__item-right">
|
||||
<span class="resume-html__item-location" v-if="edu.location">{{ edu.location }}</span>
|
||||
<span class="resume-html__item-date">{{ edu.startDate }} — {{ edu.endDate || '至今' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 工作经历 -->
|
||||
<template v-if="resumeData.workExperiences && resumeData.workExperiences.length">
|
||||
<div class="resume-html__section-title">工作经历</div>
|
||||
<div class="resume-html__divider"></div>
|
||||
<div v-for="(work, idx) in resumeData.workExperiences" :key="'work-' + idx" class="resume-html__item">
|
||||
<div class="resume-html__item-header">
|
||||
<span class="resume-html__item-main">{{ work.companyName }},{{ work.position }}</span>
|
||||
<div class="resume-html__item-right">
|
||||
<span class="resume-html__item-location" v-if="work.location">{{ work.location }}</span>
|
||||
<span class="resume-html__item-date">{{ work.startDate }} — {{ work.endDate || '至今' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="work.description && work.description.length" class="resume-html__desc-list">
|
||||
<li v-for="(desc, di) in work.description" :key="'wd-' + di">{{ desc.text }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 实习经历 -->
|
||||
<template v-if="resumeData.internships && resumeData.internships.length">
|
||||
<div class="resume-html__section-title">实习经历</div>
|
||||
<div class="resume-html__divider"></div>
|
||||
<div v-for="(intern, idx) in resumeData.internships" :key="'intern-' + idx" class="resume-html__item">
|
||||
<div class="resume-html__item-header">
|
||||
<span class="resume-html__item-main">{{ intern.companyName }},{{ intern.position }}</span>
|
||||
<div class="resume-html__item-right">
|
||||
<span class="resume-html__item-location" v-if="intern.location">{{ intern.location }}</span>
|
||||
<span class="resume-html__item-date">{{ intern.startDate }} — {{ intern.endDate || '至今' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="intern.description && intern.description.length" class="resume-html__desc-list">
|
||||
<li v-for="(desc, di) in intern.description" :key="'id-' + di">{{ desc.text }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 项目经历 -->
|
||||
<template v-if="resumeData.projects && resumeData.projects.length">
|
||||
<div class="resume-html__section-title">项目经历</div>
|
||||
<div class="resume-html__divider"></div>
|
||||
<div v-for="(proj, idx) in resumeData.projects" :key="'proj-' + idx" class="resume-html__item">
|
||||
<div class="resume-html__item-header">
|
||||
<span class="resume-html__item-main">{{ proj.projectName }}{{ proj.role ? ',' + proj.role : '' }}</span>
|
||||
<div class="resume-html__item-right">
|
||||
<span class="resume-html__item-location" v-if="proj.companyName">{{ proj.companyName }}</span>
|
||||
<span class="resume-html__item-date">{{ proj.startDate }} — {{ proj.endDate || '至今' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="proj.description && proj.description.length" class="resume-html__desc-list">
|
||||
<li v-for="(desc, di) in proj.description" :key="'pd-' + di">{{ desc.text }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 竞赛/获奖经历 -->
|
||||
<template v-if="resumeData.competitions && resumeData.competitions.length">
|
||||
<div class="resume-html__section-title">获奖经历</div>
|
||||
<div class="resume-html__divider"></div>
|
||||
<div v-for="(comp, idx) in resumeData.competitions" :key="'comp-' + idx" class="resume-html__item">
|
||||
<div class="resume-html__item-header">
|
||||
<span class="resume-html__item-main">{{ comp.competitionName }}{{ comp.award ? ',' + comp.award : '' }}</span>
|
||||
<span class="resume-html__item-date" v-if="comp.awardDate">{{ comp.awardDate }}</span>
|
||||
</div>
|
||||
<ul v-if="comp.description && comp.description.length" class="resume-html__desc-list">
|
||||
<li v-for="(desc, di) in comp.description" :key="'cd-' + di">{{ desc.text }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 专业技能 -->
|
||||
<template v-if="hasSkillsSection">
|
||||
<div class="resume-html__section-title">专业技能</div>
|
||||
<div class="resume-html__divider"></div>
|
||||
<div class="resume-html__skills">
|
||||
<div v-if="resumeData.skills && resumeData.skills.length" class="resume-html__skill-row">
|
||||
<span class="resume-html__skill-label">技能:</span>
|
||||
<span>{{ resumeData.skills.join('、') }}</span>
|
||||
</div>
|
||||
<div v-if="resumeData.certificates && resumeData.certificates.length" class="resume-html__skill-row">
|
||||
<span class="resume-html__skill-label">证书:</span>
|
||||
<span>{{ resumeData.certificates.join('、') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/** 描述段落 */
|
||||
interface DescParagraph {
|
||||
id?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
/** 教育经历 */
|
||||
interface ResumeEducation {
|
||||
school: string
|
||||
major: string
|
||||
degree: number
|
||||
startDate: string
|
||||
endDate: string
|
||||
location?: string
|
||||
description?: DescParagraph[]
|
||||
}
|
||||
|
||||
/** 工作/实习经历 */
|
||||
interface ResumeWork {
|
||||
companyName: string
|
||||
position: string
|
||||
startDate: string
|
||||
endDate?: string
|
||||
location?: string
|
||||
description?: DescParagraph[]
|
||||
}
|
||||
|
||||
/** 项目经历 */
|
||||
interface ResumeProject {
|
||||
projectName: string
|
||||
companyName?: string
|
||||
role?: string
|
||||
startDate: string
|
||||
endDate?: string
|
||||
description?: DescParagraph[]
|
||||
}
|
||||
|
||||
/** 竞赛/获奖经历 */
|
||||
interface ResumeCompetition {
|
||||
competitionName: string
|
||||
award?: string
|
||||
awardDate?: string
|
||||
description?: DescParagraph[]
|
||||
}
|
||||
|
||||
/** 简历完整数据结构 */
|
||||
export interface ResumeTemplateData {
|
||||
/** 姓名 */
|
||||
name: string
|
||||
/** 邮箱 */
|
||||
email?: string
|
||||
/** 手机号 */
|
||||
mobileNumber?: string
|
||||
/** 微信号 */
|
||||
wechatNumber?: string
|
||||
/** 个人概述(新增字段) */
|
||||
summary?: string
|
||||
/** 教育背景 */
|
||||
educations?: ResumeEducation[]
|
||||
/** 工作经历 */
|
||||
workExperiences?: ResumeWork[]
|
||||
/** 实习经历 */
|
||||
internships?: ResumeWork[]
|
||||
/** 项目经历 */
|
||||
projects?: ResumeProject[]
|
||||
/** 竞赛/获奖经历 */
|
||||
competitions?: ResumeCompetition[]
|
||||
/** 技能标签 */
|
||||
skills?: string[]
|
||||
/** 证书标签 */
|
||||
certificates?: string[]
|
||||
}
|
||||
|
||||
// ==================== Props ====================
|
||||
|
||||
const props = defineProps<{
|
||||
/** 简历数据 */
|
||||
resumeData: ResumeTemplateData
|
||||
}>()
|
||||
|
||||
// ==================== DOM引用(供父组件获取HTML内容) ====================
|
||||
|
||||
const resumeRef = ref<HTMLElement>()
|
||||
|
||||
/** 暴露DOM引用,方便后续导出PDF/Word */
|
||||
defineExpose({ resumeRef })
|
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
/** 学历数字转文字 */
|
||||
function degreeText(degree: number): string {
|
||||
const map: Record<number, string> = { 1: '大专', 2: '本科', 3: '硕士', 4: '博士' }
|
||||
return map[degree] || ''
|
||||
}
|
||||
|
||||
/** 是否有技能相关内容 */
|
||||
const hasSkillsSection = computed(() => {
|
||||
return (props.resumeData.skills && props.resumeData.skills.length > 0) ||
|
||||
(props.resumeData.certificates && props.resumeData.certificates.length > 0)
|
||||
})
|
||||
</script>
|
||||
@@ -657,6 +657,19 @@
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- ========== 作品集模块 ========== -->
|
||||
<template v-else-if="module === 'portfolio'">
|
||||
<div class="profile-drawer__field">
|
||||
<label class="profile-drawer__label">作品集链接</label>
|
||||
<textarea
|
||||
class="profile-drawer__textarea"
|
||||
placeholder="请输入/粘贴作品集链接"
|
||||
v-model="portfolioUrl"
|
||||
rows="6"
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ========== 技能模块 ========== -->
|
||||
<template v-else-if="module === 'skills'">
|
||||
<!-- 技能标签列表 -->
|
||||
@@ -859,6 +872,7 @@ const moduleTitleMap: Record<string, string> = {
|
||||
work: '工作经历',
|
||||
internship: '实习经历',
|
||||
project: '项目经历',
|
||||
portfolio: '作品集',
|
||||
skills: '技能',
|
||||
competition: '竞赛',
|
||||
certificate: '证书',
|
||||
@@ -1082,6 +1096,9 @@ const removeCompetition = (index: number) => {
|
||||
/** 技能列表 — 技能模块使用 */
|
||||
const skillsList = ref<string[]>([])
|
||||
|
||||
/** 作品集链接 — 作品集模块使用 */
|
||||
const portfolioUrl = ref('')
|
||||
|
||||
/** 新技能输入框的值 */
|
||||
const newSkillInput = ref('')
|
||||
|
||||
@@ -1185,6 +1202,9 @@ watch(() => props.modelValue, (visible) => {
|
||||
} else {
|
||||
competitionList.value = [createEmptyCompetition()]
|
||||
}
|
||||
} else if (props.module === 'portfolio') {
|
||||
// 作品集:用初始数据填充链接
|
||||
portfolioUrl.value = props.initialData?.portfolioUrl || ''
|
||||
} else if (props.module === 'skills') {
|
||||
// 技能:用初始数据填充列表,若无则创建空数组
|
||||
skillsList.value = props.initialData?.skills ? [...props.initialData.skills] : []
|
||||
@@ -1234,6 +1254,8 @@ const handleSave = () => {
|
||||
...item,
|
||||
description: item.description.map(d => ({ ...d })),
|
||||
})) })
|
||||
} else if (props.module === 'portfolio') {
|
||||
emit('save', { portfolioUrl: portfolioUrl.value })
|
||||
} else if (props.module === 'skills') {
|
||||
emit('save', { skills: [...skillsList.value] })
|
||||
} else if (props.module === 'certificate') {
|
||||
|
||||
@@ -19,6 +19,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作品集 -->
|
||||
<div class="profile-page-content__card">
|
||||
<div class="profile-page-content__card-header">
|
||||
<h3 class="profile-page-content__card-title">作品集</h3>
|
||||
<button class="profile-page-content__edit-btn" @click="handleEdit('portfolio')">
|
||||
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 有链接时显示可点击链接 -->
|
||||
<a
|
||||
v-if="profile.portfolioUrl"
|
||||
:href="profile.portfolioUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="profile-page-content__portfolio-link"
|
||||
>{{ profile.portfolioUrl }}</a>
|
||||
<!-- 无链接时显示占位提示 -->
|
||||
<span v-else class="profile-page-content__portfolio-empty">暂未添加作品集链接</span>
|
||||
</div>
|
||||
|
||||
<!-- 教育经历 -->
|
||||
<div class="profile-page-content__card">
|
||||
<div class="profile-page-content__card-header">
|
||||
@@ -166,6 +186,8 @@ interface ProfileData {
|
||||
idNumber: string
|
||||
/** 所在城市编码 — 对应接口字段 regionCode */
|
||||
regionCode: string
|
||||
/** 作品集链接 — 对应接口字段 portfolioUrl */
|
||||
portfolioUrl: string
|
||||
wechat?: string
|
||||
education: Array<{
|
||||
school: string
|
||||
|
||||
@@ -94,13 +94,36 @@
|
||||
<template v-if="activeTab === 'reminder'">
|
||||
<h2 class="settings-dialog__content-title">岗位更新提醒</h2>
|
||||
<div class="settings-dialog__reminder-block">
|
||||
<div class="settings-dialog__reminder-block-title">目标岗位</div>
|
||||
<div class="settings-dialog__reminder-target">
|
||||
<div class="settings-dialog__reminder-tags">
|
||||
<span class="settings-dialog__reminder-tag" v-for="tag in targetTags" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="settings-dialog__reminder-block-title-row">
|
||||
<span class="settings-dialog__reminder-block-title">目标岗位</span>
|
||||
<button class="settings-dialog__reminder-edit-btn" @click="handleEditTarget">编辑</button>
|
||||
</div>
|
||||
<div class="settings-dialog__reminder-target">
|
||||
<div class="settings-dialog__reminder-group" v-if="intentionCategoryNames.length">
|
||||
<span class="settings-dialog__reminder-group-label">岗位</span>
|
||||
<div class="settings-dialog__reminder-tags">
|
||||
<span class="settings-dialog__reminder-tag" v-for="name in intentionCategoryNames" :key="name">{{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-dialog__reminder-group" v-if="intentionIndustryNames.length">
|
||||
<span class="settings-dialog__reminder-group-label">行业</span>
|
||||
<div class="settings-dialog__reminder-tags">
|
||||
<span class="settings-dialog__reminder-tag" v-for="name in intentionIndustryNames" :key="name">{{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-dialog__reminder-group" v-if="intentionRegionNames.length">
|
||||
<span class="settings-dialog__reminder-group-label">地区</span>
|
||||
<div class="settings-dialog__reminder-tags">
|
||||
<span class="settings-dialog__reminder-tag" v-for="name in intentionRegionNames" :key="name">{{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-dialog__reminder-group">
|
||||
<span class="settings-dialog__reminder-group-label">类型</span>
|
||||
<div class="settings-dialog__reminder-tags">
|
||||
<span class="settings-dialog__reminder-tag">{{ intentionEmploymentLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-dialog__reminder-block">
|
||||
<div class="settings-dialog__reminder-block-title">即时岗位提醒</div>
|
||||
@@ -192,21 +215,28 @@
|
||||
</div>
|
||||
|
||||
<!-- 退出登录确认弹窗 — 放在 Teleport 内部,确保层级在 overlay 之上 -->
|
||||
<el-dialog v-model="showLogout" title="退出登录" width="3.6rem" :close-on-click-modal="true" :append-to-body="false" :z-index="2100">
|
||||
<el-dialog v-model="showLogout" title="退出登录" width="3.6rem" style="line-height: 0.2rem" :close-on-click-modal="true" :append-to-body="false" :z-index="2100">
|
||||
<p style="font-size: 0.14rem; color: #555; text-align: center;">确定要退出当前账号吗?</p>
|
||||
<template #footer>
|
||||
<el-button @click="showLogout = false">取消</el-button>
|
||||
<el-button type="danger" @click="handleLogout">确认退出</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 求职目标设置弹窗 -->
|
||||
<JobGoalDialog v-model="showGoalDialog" />
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
import { logout } from '@/api/auth'
|
||||
import JobGoalDialog from './JobGoalDialog.vue'
|
||||
import { resolveRegionName } from '@/utils/region'
|
||||
import { resolveIndustryName } from '@/utils/industry'
|
||||
import { resolveJobCategoryName } from '@/utils/jobCategory'
|
||||
|
||||
/** 组件 Props — 控制弹窗显示/隐藏 */
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
@@ -242,14 +272,45 @@ const reminders = reactive({
|
||||
frequency: 'unlimited', // 提醒频率:1/2/5/unlimited
|
||||
})
|
||||
|
||||
/** 目标岗位标签列表 */
|
||||
const targetTags = ref(['产品经理', '全职', '北京'])
|
||||
/** 求职目标弹窗显示状态 */
|
||||
const showGoalDialog = ref(false)
|
||||
|
||||
/** 编辑目标岗位 */
|
||||
/** 岗位名称列表 */
|
||||
const intentionCategoryNames = computed(() => {
|
||||
const ids = store.state.jobIntention.categoryIds || []
|
||||
return ids.map((id: number) => resolveJobCategoryName(id))
|
||||
})
|
||||
|
||||
/** 行业名称列表 */
|
||||
const intentionIndustryNames = computed(() => {
|
||||
const ids = store.state.jobIntention.industryIds || []
|
||||
return ids.map((id: number) => resolveIndustryName(id))
|
||||
})
|
||||
|
||||
/** 地区名称列表 */
|
||||
const intentionRegionNames = computed(() => {
|
||||
const codes = store.state.jobIntention.regionCodes || []
|
||||
return codes.map((code: string) => resolveRegionName(code))
|
||||
})
|
||||
|
||||
/** 就业类型标签 */
|
||||
const intentionEmploymentLabel = computed(() => {
|
||||
return store.state.jobIntention.employmentType === 1 ? '实习' : '全职'
|
||||
})
|
||||
|
||||
/** 编辑目标岗位 — 打开求职目标弹窗 */
|
||||
const handleEditTarget = () => {
|
||||
ElMessage.info('编辑目标岗位功能开发中')
|
||||
showGoalDialog.value = true
|
||||
}
|
||||
|
||||
/** 弹窗打开时加载求职意向数据 */
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val && store.state.isAuthenticated) {
|
||||
store.dispatch('loadCommonData')
|
||||
store.dispatch('loadJobIntention')
|
||||
}
|
||||
})
|
||||
|
||||
/** 注销账号 — 弹出二次确认 */
|
||||
const handleDeleteAccount = () => {
|
||||
ElMessageBox.confirm('此操作将永久删除你的账号及所有数据,是否继续?', '注销账号', {
|
||||
|
||||
@@ -96,6 +96,7 @@ import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
import SettingsDialog from '@/components/SettingsDialog.vue'
|
||||
import { checkLogin } from '@/api/auth'
|
||||
import navJobsIcon from '@/assets/images/nav/nav-jobs-icon.png'
|
||||
import navResumeIcon from '@/assets/images/nav/nav-resume-icon.png'
|
||||
import navProfileIcon from '@/assets/images/nav/nav-profile-icon.png'
|
||||
@@ -200,12 +201,20 @@ const footerMenus = computed(() => [
|
||||
])
|
||||
|
||||
/**
|
||||
* 设置弹窗:需要登录,没 token 则弹登录框
|
||||
* 设置弹窗:需要登录,通过 checkLogin 接口验证
|
||||
*/
|
||||
function handleSettingsNav() {
|
||||
if (store.state.isAuthenticated) {
|
||||
async function handleSettingsNav() {
|
||||
try {
|
||||
const res = await checkLogin()
|
||||
if (res.code === '0' && res.data === true) {
|
||||
store.commit('SET_AUTHENTICATED', true)
|
||||
showSettingsDialog.value = true
|
||||
} else {
|
||||
store.commit('SET_AUTHENTICATED', false)
|
||||
store.dispatch('openLogin')
|
||||
}
|
||||
} catch {
|
||||
store.commit('SET_AUTHENTICATED', false)
|
||||
store.dispatch('openLogin')
|
||||
}
|
||||
}
|
||||
@@ -213,14 +222,26 @@ function handleSettingsNav() {
|
||||
/**
|
||||
* 导航点击处理:
|
||||
* - 静态页面(Jobs)直接跳转
|
||||
* - 动态页面需要 token,没有则弹登录框
|
||||
* - 动态页面通过 checkLogin 接口验证,未登录则弹登录框
|
||||
*/
|
||||
const staticNames = staticMenus.map(m => m.name)
|
||||
|
||||
function handleNav(item: MenuItem) {
|
||||
if (staticNames.includes(item.name) || store.state.isAuthenticated) {
|
||||
async function handleNav(item: MenuItem) {
|
||||
if (staticNames.includes(item.name)) {
|
||||
router.push(item.path)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await checkLogin()
|
||||
if (res.code === '0' && res.data === true) {
|
||||
store.commit('SET_AUTHENTICATED', true)
|
||||
router.push(item.path)
|
||||
} else {
|
||||
store.commit('SET_AUTHENTICATED', false)
|
||||
store.dispatch('openLogin', item.path)
|
||||
}
|
||||
} catch {
|
||||
store.commit('SET_AUTHENTICATED', false)
|
||||
store.dispatch('openLogin', item.path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<div class="industry-selector" ref="selectorRef">
|
||||
|
||||
<!-- 触发按钮:显示已选行业名称或默认文字"行业" -->
|
||||
<div class="industry-selector__trigger" @click="toggleDropdown">
|
||||
<span class="industry-selector__display" :title="displayText">{{ displayText }}</span>
|
||||
<div class="industry-selector__trigger" :style="triggerStyle" @click="toggleDropdown">
|
||||
<span class="industry-selector__display" :style="displayStyle" :title="displayText">{{ displayText }}</span>
|
||||
<svg
|
||||
class="industry-selector__arrow"
|
||||
:class="{ 'industry-selector__arrow--open': visible }"
|
||||
@@ -16,7 +16,12 @@
|
||||
</div>
|
||||
|
||||
<!-- 下拉面板 -->
|
||||
<div v-if="visible" class="industry-selector__panel" @click.stop>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="industry-selector__panel"
|
||||
:class="{ 'industry-selector__panel--one-col': level === 1 }"
|
||||
@click.stop
|
||||
>
|
||||
|
||||
<!-- 选中区:小方块标签展示已选中的行业名称 -->
|
||||
<div class="industry-selector__selected-area" v-if="selectedItems.length">
|
||||
@@ -47,13 +52,12 @@
|
||||
<div
|
||||
class="industry-selector__search-item"
|
||||
v-for="r in searchResults"
|
||||
:key="r.child.id"
|
||||
@click="toggleItem(r.child)"
|
||||
:key="r.node.id"
|
||||
@click="toggleItem(r.node)"
|
||||
>
|
||||
<!-- 格式:一级行业名 → 二级行业名 -->
|
||||
<span>{{ r.parentName }} → {{ r.child.name }}</span>
|
||||
<span>{{ r.path }}</span>
|
||||
<!-- 已选中项显示勾选图标 -->
|
||||
<svg v-if="isSelected(r.child.id)" class="industry-selector__check" viewBox="0 0 12 12">
|
||||
<svg v-if="isSelected(r.node.id)" class="industry-selector__check" viewBox="0 0 12 12">
|
||||
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -64,32 +68,40 @@
|
||||
<div class="industry-selector__search-empty">无匹配结果</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示:双击选中一级(仅 allowParentSelect 开启时显示) -->
|
||||
<div v-if="searchText.length < 2 && allowParentSelect" class="industry-selector__hint">双击可选中一级行业分类</div>
|
||||
|
||||
<!-- 分栏联动选择区(搜索关键词不足 2 字符时显示) -->
|
||||
<div v-if="searchText.length < 2" class="industry-selector__columns">
|
||||
<!-- 左栏:一级行业列表 -->
|
||||
<div class="industry-selector__col industry-selector__col--left">
|
||||
<div class="industry-selector__col industry-selector__col--left" :class="{ 'industry-selector__col--full': level === 1 }">
|
||||
<div
|
||||
class="industry-selector__col-item"
|
||||
:class="{ 'industry-selector__col-item--active': activeParentId === parent.id }"
|
||||
:class="{
|
||||
'industry-selector__col-item--active': level > 1 && activeParentId === parent.id,
|
||||
'industry-selector__col-item--selected': (level === 1 || allowParentSelect) && isSelected(parent.id)
|
||||
}"
|
||||
v-for="parent in industries"
|
||||
:key="parent.id"
|
||||
@click="selectParent(parent.id)"
|
||||
>
|
||||
{{ parent.name }}
|
||||
<svg v-if="(level === 1 || allowParentSelect) && isSelected(parent.id)" class="industry-selector__check" viewBox="0 0 12 12">
|
||||
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右栏:当前一级下的二级行业列表 -->
|
||||
<div class="industry-selector__col industry-selector__col--right">
|
||||
<!-- 右栏:当前一级下的二级行业列表(仅 level=2 时显示) -->
|
||||
<div v-if="level === 2" class="industry-selector__col industry-selector__col--right">
|
||||
<template v-if="activeChildren.length">
|
||||
<div
|
||||
class="industry-selector__col-item"
|
||||
:class="{ 'industry-selector__col-item--selected': isSelected(child.id) }"
|
||||
v-for="child in activeChildren"
|
||||
:key="child.id"
|
||||
@click="toggleItem(child)"
|
||||
@click="toggleItem({ id: child.id, name: child.name, level: child.level })"
|
||||
>
|
||||
{{ child.name }}
|
||||
<!-- 已选中显示勾 -->
|
||||
<svg v-if="isSelected(child.id)" class="industry-selector__check" viewBox="0 0 12 12">
|
||||
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
@@ -113,6 +125,9 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import type { IndustryChild, IndustryItem } from '@/api/common'
|
||||
|
||||
/** 统一的选中节点类型,兼容一二级 */
|
||||
type SelectedNode = { id: string; name: string; level: number }
|
||||
|
||||
// ==================== 事件与属性定义 ====================
|
||||
|
||||
/** 向父组件发送已选行业 ID 数组(integer[]) */
|
||||
@@ -126,9 +141,19 @@ const props = withDefaults(
|
||||
industryIds?: number[]
|
||||
/** 最多可选数量 */
|
||||
maxSelect?: number
|
||||
/** 是否允许双击选中一级行业 */
|
||||
allowParentSelect?: boolean
|
||||
/** 展示/选择到第几级:1=只选一级,2=选到二级 */
|
||||
level?: 1 | 2
|
||||
/** 父组件传入的触发按钮自定义样式,用于在不同场景下覆盖默认外观 */
|
||||
triggerStyle?: Record<string, string>
|
||||
/** 父组件传入的显示文字自定义样式,用于覆盖 max-width 等默认样式 */
|
||||
displayStyle?: Record<string, string>
|
||||
}>(),
|
||||
{
|
||||
maxSelect: 3,
|
||||
allowParentSelect: false,
|
||||
level: 2,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -151,8 +176,14 @@ const searchText = ref('')
|
||||
/** 当前选中的一级行业 ID(左栏高亮项) */
|
||||
const activeParentId = ref<string>('')
|
||||
|
||||
/** 已选中的末级(二级)行业列表(内部临时状态,点确认后才同步给父组件) */
|
||||
const selectedItems = ref<IndustryChild[]>([])
|
||||
/** 已选中的行业列表(支持一二级混合选择) */
|
||||
const selectedItems = ref<SelectedNode[]>([])
|
||||
|
||||
/** 一级行业上次点击时间戳,用于双击检测 */
|
||||
const level1LastClickTime = ref<Record<string, number>>({})
|
||||
|
||||
/** 双击判定间隔(毫秒) */
|
||||
const DOUBLE_CLICK_DELAY = 500
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
@@ -175,15 +206,21 @@ const activeChildren = computed<IndustryChild[]>(() => {
|
||||
return parent ? parent.children : []
|
||||
})
|
||||
|
||||
/** 搜索结果:对末级行业 name 做模糊匹配,返回带一级父名称的结果列表 */
|
||||
/** 搜索结果:根据 level 和 allowParentSelect 匹配对应级别 */
|
||||
const searchResults = computed(() => {
|
||||
if (searchText.value.length < 2) return []
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
const results: { parentName: string; child: IndustryChild }[] = []
|
||||
const results: { path: string; node: SelectedNode }[] = []
|
||||
for (const parent of industries.value) {
|
||||
// 一级:level=1 时作为末级可搜索,或 allowParentSelect 且 level=2 时可搜索
|
||||
if ((props.level === 1 || props.allowParentSelect) && parent.name.toLowerCase().includes(keyword)) {
|
||||
results.push({ path: parent.name, node: { id: parent.id, name: parent.name, level: parent.level } })
|
||||
}
|
||||
if (props.level === 2) {
|
||||
for (const child of parent.children) {
|
||||
if (child.name.toLowerCase().includes(keyword)) {
|
||||
results.push({ parentName: parent.name, child })
|
||||
results.push({ path: `${parent.name} → ${child.name}`, node: { id: child.id, name: child.name, level: child.level } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,14 +234,34 @@ function isSelected(id: string) {
|
||||
return selectedIdSet.value.has(id)
|
||||
}
|
||||
|
||||
/** 点击左栏一级行业,切换右栏显示对应二级列表 */
|
||||
/** 点击左栏一级行业 */
|
||||
function selectParent(id: string) {
|
||||
// level=1 时,一级就是末级,单击直接选中
|
||||
if (props.level === 1) {
|
||||
const l1 = industries.value.find(c => c.id === id)
|
||||
if (l1) toggleItem({ id: l1.id, name: l1.name, level: l1.level })
|
||||
return
|
||||
}
|
||||
if (props.allowParentSelect) {
|
||||
const now = Date.now()
|
||||
const lastTime = level1LastClickTime.value[id] || 0
|
||||
|
||||
if (now - lastTime < DOUBLE_CLICK_DELAY) {
|
||||
const l1 = industries.value.find(c => c.id === id)
|
||||
if (l1) toggleItem({ id: l1.id, name: l1.name, level: l1.level })
|
||||
level1LastClickTime.value[id] = 0
|
||||
} else {
|
||||
activeParentId.value = id
|
||||
level1LastClickTime.value[id] = now
|
||||
}
|
||||
} else {
|
||||
activeParentId.value = id
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换某个末级行业的选中/取消状态,超过上限时提示 */
|
||||
function toggleItem(child: IndustryChild) {
|
||||
const idx = selectedItems.value.findIndex(i => i.id === child.id)
|
||||
/** 切换某个行业节点的选中/取消状态(支持一二级),超过上限时提示 */
|
||||
function toggleItem(node: SelectedNode) {
|
||||
const idx = selectedItems.value.findIndex(i => i.id === node.id)
|
||||
if (idx >= 0) {
|
||||
selectedItems.value.splice(idx, 1)
|
||||
} else {
|
||||
@@ -212,12 +269,12 @@ function toggleItem(child: IndustryChild) {
|
||||
ElMessage.warning(`最多只能选择${props.maxSelect}个行业`)
|
||||
return
|
||||
}
|
||||
selectedItems.value.push({ ...child })
|
||||
selectedItems.value.push({ ...node })
|
||||
}
|
||||
}
|
||||
|
||||
/** 从选中区移除指定行业 */
|
||||
function removeItem(item: IndustryChild) {
|
||||
function removeItem(item: SelectedNode) {
|
||||
selectedItems.value = selectedItems.value.filter(i => i.id !== item.id)
|
||||
}
|
||||
|
||||
@@ -268,19 +325,28 @@ onBeforeUnmount(() => {
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
|
||||
/** 同步外部传入的 industryIds 到内部选中状态 */
|
||||
watch(
|
||||
() => props.industryIds,
|
||||
(ids) => {
|
||||
/** 同步外部传入的 industryIds 到内部选中状态(支持一二级) */
|
||||
function syncFromProps() {
|
||||
const ids = props.industryIds
|
||||
if (!ids || !industries.value.length) return
|
||||
const allChildren: IndustryChild[] = []
|
||||
const nodeMap = new Map<string, SelectedNode>()
|
||||
for (const p of industries.value) {
|
||||
allChildren.push(...p.children)
|
||||
nodeMap.set(p.id, { id: p.id, name: p.name, level: p.level })
|
||||
for (const c of p.children) {
|
||||
nodeMap.set(c.id, { id: c.id, name: c.name, level: c.level })
|
||||
}
|
||||
}
|
||||
selectedItems.value = ids
|
||||
.map(id => allChildren.find(c => c.id === String(id)))
|
||||
.filter(Boolean) as IndustryChild[]
|
||||
},
|
||||
.map(id => nodeMap.get(String(id)))
|
||||
.filter(Boolean) as SelectedNode[]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.industryIds,
|
||||
() => syncFromProps(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 树数据加载完成后重新同步选中项 */
|
||||
watch(industries, () => syncFromProps())
|
||||
</script>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<div class="job-category-selector" ref="selectorRef">
|
||||
|
||||
<!-- 触发按钮:显示已选岗位名称或默认文字"岗位" -->
|
||||
<div class="job-category-selector__trigger" @click="toggleDropdown">
|
||||
<span class="job-category-selector__display" :title="displayText">{{ displayText }}</span>
|
||||
<div class="job-category-selector__trigger" :style="triggerStyle" @click="toggleDropdown">
|
||||
<span class="job-category-selector__display" :style="displayStyle" :title="displayText">{{ displayText }}</span>
|
||||
<svg
|
||||
class="job-category-selector__arrow"
|
||||
:class="{ 'job-category-selector__arrow--open': visible }"
|
||||
@@ -16,7 +16,15 @@
|
||||
</div>
|
||||
|
||||
<!-- 下拉面板 -->
|
||||
<div v-if="visible" class="job-category-selector__panel" @click.stop>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="job-category-selector__panel"
|
||||
:class="{
|
||||
'job-category-selector__panel--one-col': level === 1,
|
||||
'job-category-selector__panel--two-col': level === 2
|
||||
}"
|
||||
@click.stop
|
||||
>
|
||||
|
||||
<!-- 选中区:小方块标签展示已选中的岗位名称 -->
|
||||
<div class="job-category-selector__selected-area" v-if="selectedItems.length">
|
||||
@@ -47,12 +55,11 @@
|
||||
<div
|
||||
class="job-category-selector__search-item"
|
||||
v-for="r in searchResults"
|
||||
:key="r.leaf.id"
|
||||
@click="toggleItem(r.leaf)"
|
||||
:key="r.node.id"
|
||||
@click="toggleItem(r.node)"
|
||||
>
|
||||
<!-- 格式:一级 → 二级 → 三级 -->
|
||||
<span>{{ r.level1Name }} → {{ r.level2Name }} → {{ r.leaf.name }}</span>
|
||||
<svg v-if="isSelected(r.leaf.id)" class="job-category-selector__check" viewBox="0 0 12 12">
|
||||
<span>{{ r.path }}</span>
|
||||
<svg v-if="isSelected(r.node.id)" class="job-category-selector__check" viewBox="0 0 12 12">
|
||||
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -63,44 +70,59 @@
|
||||
<div class="job-category-selector__search-empty">无匹配结果</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示:双击选中一二级(仅 allowParentSelect 开启时显示) -->
|
||||
<div v-if="searchText.length < 2 && allowParentSelect" class="job-category-selector__hint">双击可选中一级/二级岗位分类</div>
|
||||
|
||||
<!-- 三栏联动选择区(搜索关键词不足 2 字符时显示) -->
|
||||
<div v-if="searchText.length < 2" class="job-category-selector__columns">
|
||||
<!-- 左栏:一级岗位分类 -->
|
||||
<div class="job-category-selector__col job-category-selector__col--left">
|
||||
<div class="job-category-selector__col job-category-selector__col--left" :class="{ 'job-category-selector__col--full': level === 1 }">
|
||||
<div
|
||||
class="job-category-selector__col-item"
|
||||
:class="{ 'job-category-selector__col-item--active': activeLevel1Id === item.id }"
|
||||
:class="{
|
||||
'job-category-selector__col-item--active': level > 1 && activeLevel1Id === item.id,
|
||||
'job-category-selector__col-item--selected': (level === 1 || allowParentSelect) && isSelected(item.id)
|
||||
}"
|
||||
v-for="item in categories"
|
||||
:key="item.id"
|
||||
@click="selectLevel1(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
<svg v-if="(level === 1 || allowParentSelect) && isSelected(item.id)" class="job-category-selector__check" viewBox="0 0 12 12">
|
||||
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 中栏:二级岗位分类 -->
|
||||
<div class="job-category-selector__col job-category-selector__col--mid">
|
||||
<!-- 中栏:二级岗位分类(level >= 2 时显示) -->
|
||||
<div v-if="level >= 2" class="job-category-selector__col job-category-selector__col--mid" :class="{ 'job-category-selector__col--mid-end': level === 2 }">
|
||||
<template v-if="level2List.length">
|
||||
<div
|
||||
class="job-category-selector__col-item"
|
||||
:class="{ 'job-category-selector__col-item--active': activeLevel2Id === item.id }"
|
||||
:class="{
|
||||
'job-category-selector__col-item--active': level > 2 && activeLevel2Id === item.id,
|
||||
'job-category-selector__col-item--selected': (level === 2 || allowParentSelect) && isSelected(item.id)
|
||||
}"
|
||||
v-for="item in level2List"
|
||||
:key="item.id"
|
||||
@click="selectLevel2(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
<svg v-if="(level === 2 || allowParentSelect) && isSelected(item.id)" class="job-category-selector__check" viewBox="0 0 12 12">
|
||||
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="job-category-selector__col-empty">请先选择左侧分类</div>
|
||||
</div>
|
||||
<!-- 右栏:三级岗位(末级,可选中) -->
|
||||
<div class="job-category-selector__col job-category-selector__col--right">
|
||||
<!-- 右栏:三级岗位(仅 level=3 时显示) -->
|
||||
<div v-if="level === 3" class="job-category-selector__col job-category-selector__col--right">
|
||||
<template v-if="level3List.length">
|
||||
<div
|
||||
class="job-category-selector__col-item"
|
||||
:class="{ 'job-category-selector__col-item--selected': isSelected(item.id) }"
|
||||
v-for="item in level3List"
|
||||
:key="item.id"
|
||||
@click="toggleItem(item)"
|
||||
@click="toggleItem({ id: item.id, name: item.name, level: item.level })"
|
||||
>
|
||||
{{ item.name }}
|
||||
<svg v-if="isSelected(item.id)" class="job-category-selector__check" viewBox="0 0 12 12">
|
||||
@@ -126,6 +148,9 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import type { JobCategoryItem, JobCategoryChild, JobCategoryLeaf } from '@/api/common'
|
||||
|
||||
/** 统一的选中节点类型,兼容一二三级 */
|
||||
type SelectedNode = { id: string; name: string; level: number }
|
||||
|
||||
// ==================== 事件与属性定义 ====================
|
||||
|
||||
/** 向父组件发送已选岗位 ID 数组(integer[]) */
|
||||
@@ -139,9 +164,19 @@ const props = withDefaults(
|
||||
categoryIds?: number[]
|
||||
/** 最多可选数量 */
|
||||
maxSelect?: number
|
||||
/** 是否允许双击选中一二级分类 */
|
||||
allowParentSelect?: boolean
|
||||
/** 展示/选择到第几级:1=只选一级,2=选到二级,3=选到三级 */
|
||||
level?: 1 | 2 | 3
|
||||
/** 父组件传入的触发按钮自定义样式,用于在不同场景下覆盖默认外观 */
|
||||
triggerStyle?: Record<string, string>
|
||||
/** 父组件传入的显示文字自定义样式,用于覆盖 max-width 等默认样式 */
|
||||
displayStyle?: Record<string, string>
|
||||
}>(),
|
||||
{
|
||||
maxSelect: 3,
|
||||
allowParentSelect: false,
|
||||
level: 3,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -167,8 +202,17 @@ const activeLevel1Id = ref<string>('')
|
||||
/** 当前选中的二级分类 ID(中栏高亮项) */
|
||||
const activeLevel2Id = ref<string>('')
|
||||
|
||||
/** 已选中的末级(三级)岗位列表(内部临时状态,点确认后才同步给父组件) */
|
||||
const selectedItems = ref<JobCategoryLeaf[]>([])
|
||||
/** 已选中的岗位列表(支持一二三级混合选择) */
|
||||
const selectedItems = ref<SelectedNode[]>([])
|
||||
|
||||
/** 一级分类上次点击时间戳,用于双击检测 */
|
||||
const level1LastClickTime = ref<Record<string, number>>({})
|
||||
|
||||
/** 二级分类上次点击时间戳,用于双击检测 */
|
||||
const level2LastClickTime = ref<Record<string, number>>({})
|
||||
|
||||
/** 双击判定间隔(毫秒) */
|
||||
const DOUBLE_CLICK_DELAY = 500
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
@@ -198,16 +242,28 @@ const level3List = computed<JobCategoryLeaf[]>(() => {
|
||||
return mid ? mid.children : []
|
||||
})
|
||||
|
||||
/** 搜索结果:对三级岗位 name 做模糊匹配,返回带一级二级名称的完整路径 */
|
||||
/** 搜索结果:根据 level 和 allowParentSelect 匹配对应级别 */
|
||||
const searchResults = computed(() => {
|
||||
if (searchText.value.length < 2) return []
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
const results: { level1Name: string; level2Name: string; leaf: JobCategoryLeaf }[] = []
|
||||
const results: { path: string; node: SelectedNode }[] = []
|
||||
for (const l1 of categories.value) {
|
||||
// 一级:level=1 时作为末级可搜索,或 allowParentSelect 且 level>1 时可搜索
|
||||
if ((props.level === 1 || props.allowParentSelect) && l1.name.toLowerCase().includes(keyword)) {
|
||||
results.push({ path: l1.name, node: { id: l1.id, name: l1.name, level: l1.level } })
|
||||
}
|
||||
if (props.level >= 2) {
|
||||
for (const l2 of l1.children) {
|
||||
// 二级:level=2 时作为末级可搜索,或 allowParentSelect 且 level=3 时可搜索
|
||||
if ((props.level === 2 || props.allowParentSelect) && l2.name.toLowerCase().includes(keyword)) {
|
||||
results.push({ path: `${l1.name} → ${l2.name}`, node: { id: l2.id, name: l2.name, level: l2.level } })
|
||||
}
|
||||
if (props.level === 3) {
|
||||
for (const l3 of l2.children) {
|
||||
if (l3.name.toLowerCase().includes(keyword)) {
|
||||
results.push({ level1Name: l1.name, level2Name: l2.name, leaf: l3 })
|
||||
results.push({ path: `${l1.name} → ${l2.name} → ${l3.name}`, node: { id: l3.id, name: l3.name, level: l3.level } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,20 +278,61 @@ function isSelected(id: string) {
|
||||
return selectedIdSet.value.has(id)
|
||||
}
|
||||
|
||||
/** 点击左栏一级分类,切换中栏并清空右栏 */
|
||||
/** 点击左栏一级分类 */
|
||||
function selectLevel1(id: string) {
|
||||
// level=1 时,一级就是末级,单击直接选中
|
||||
if (props.level === 1) {
|
||||
const l1 = categories.value.find(c => c.id === id)
|
||||
if (l1) toggleItem({ id: l1.id, name: l1.name, level: l1.level })
|
||||
return
|
||||
}
|
||||
if (props.allowParentSelect) {
|
||||
const now = Date.now()
|
||||
const lastTime = level1LastClickTime.value[id] || 0
|
||||
|
||||
if (now - lastTime < DOUBLE_CLICK_DELAY) {
|
||||
const l1 = categories.value.find(c => c.id === id)
|
||||
if (l1) toggleItem({ id: l1.id, name: l1.name, level: l1.level })
|
||||
level1LastClickTime.value[id] = 0
|
||||
} else {
|
||||
activeLevel1Id.value = id
|
||||
activeLevel2Id.value = ''
|
||||
level1LastClickTime.value[id] = now
|
||||
}
|
||||
} else {
|
||||
activeLevel1Id.value = id
|
||||
activeLevel2Id.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击中栏二级分类,切换右栏 */
|
||||
/** 点击中栏二级分类 */
|
||||
function selectLevel2(id: string) {
|
||||
// level=2 时,二级就是末级,单击直接选中
|
||||
if (props.level === 2) {
|
||||
const l2 = level2List.value.find(c => c.id === id)
|
||||
if (l2) toggleItem({ id: l2.id, name: l2.name, level: l2.level })
|
||||
return
|
||||
}
|
||||
if (props.allowParentSelect) {
|
||||
const now = Date.now()
|
||||
const lastTime = level2LastClickTime.value[id] || 0
|
||||
|
||||
if (now - lastTime < DOUBLE_CLICK_DELAY) {
|
||||
const l2 = level2List.value.find(c => c.id === id)
|
||||
if (l2) toggleItem({ id: l2.id, name: l2.name, level: l2.level })
|
||||
level2LastClickTime.value[id] = 0
|
||||
} else {
|
||||
activeLevel2Id.value = id
|
||||
level2LastClickTime.value[id] = now
|
||||
}
|
||||
} else {
|
||||
activeLevel2Id.value = id
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换某个末级岗位的选中/取消状态,超过上限时提示 */
|
||||
function toggleItem(leaf: JobCategoryLeaf) {
|
||||
const idx = selectedItems.value.findIndex(i => i.id === leaf.id)
|
||||
/** 切换某个岗位节点的选中/取消状态(支持一二三级),超过上限时提示 */
|
||||
function toggleItem(node: SelectedNode) {
|
||||
const idx = selectedItems.value.findIndex(i => i.id === node.id)
|
||||
if (idx >= 0) {
|
||||
selectedItems.value.splice(idx, 1)
|
||||
} else {
|
||||
@@ -243,12 +340,12 @@ function toggleItem(leaf: JobCategoryLeaf) {
|
||||
ElMessage.warning(`最多只能选择${props.maxSelect}个岗位`)
|
||||
return
|
||||
}
|
||||
selectedItems.value.push({ ...leaf })
|
||||
selectedItems.value.push({ ...node })
|
||||
}
|
||||
}
|
||||
|
||||
/** 从选中区移除指定岗位 */
|
||||
function removeItem(item: JobCategoryLeaf) {
|
||||
function removeItem(item: SelectedNode) {
|
||||
selectedItems.value = selectedItems.value.filter(i => i.id !== item.id)
|
||||
}
|
||||
|
||||
@@ -298,22 +395,32 @@ onBeforeUnmount(() => {
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
|
||||
/** 同步外部传入的 categoryIds 到内部选中状态 */
|
||||
watch(
|
||||
() => props.categoryIds,
|
||||
(ids) => {
|
||||
/** 同步外部传入的 categoryIds 到内部选中状态(支持一二三级) */
|
||||
function syncFromProps() {
|
||||
const ids = props.categoryIds
|
||||
if (!ids || !categories.value.length) return
|
||||
// 收集所有三级叶子节点
|
||||
const allLeaves: JobCategoryLeaf[] = []
|
||||
// 收集所有级别节点到一个 map
|
||||
const nodeMap = new Map<string, SelectedNode>()
|
||||
for (const l1 of categories.value) {
|
||||
nodeMap.set(l1.id, { id: l1.id, name: l1.name, level: l1.level })
|
||||
for (const l2 of l1.children) {
|
||||
allLeaves.push(...l2.children)
|
||||
nodeMap.set(l2.id, { id: l2.id, name: l2.name, level: l2.level })
|
||||
for (const l3 of l2.children) {
|
||||
nodeMap.set(l3.id, { id: l3.id, name: l3.name, level: l3.level })
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedItems.value = ids
|
||||
.map(id => allLeaves.find(l => l.id === String(id)))
|
||||
.filter(Boolean) as JobCategoryLeaf[]
|
||||
},
|
||||
.map(id => nodeMap.get(String(id)))
|
||||
.filter(Boolean) as SelectedNode[]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.categoryIds,
|
||||
() => syncFromProps(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 树数据加载完成后重新同步选中项 */
|
||||
watch(categories, () => syncFromProps())
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<!-- 触发按钮:显示已选地区名称或默认文字"城市" -->
|
||||
<div class="region-selector__trigger" :style="triggerStyle" @click="toggleDropdown">
|
||||
<span class="region-selector__display" :title="displayText">{{ displayText }}</span>
|
||||
<span class="region-selector__display" :style="displayStyle" :title="displayText">{{ displayText }}</span>
|
||||
<svg
|
||||
class="region-selector__arrow"
|
||||
:class="{ 'region-selector__arrow--open': visible }"
|
||||
@@ -157,6 +157,8 @@ const props = withDefaults(
|
||||
maxSelect?: number
|
||||
/** 父组件传入的触发按钮自定义样式,用于在不同场景下覆盖默认外观 */
|
||||
triggerStyle?: Record<string, string>
|
||||
/** 父组件传入的显示文字自定义样式,用于覆盖 max-width 等默认样式 */
|
||||
displayStyle?: Record<string, string>
|
||||
}>(),
|
||||
{
|
||||
level: 2,
|
||||
@@ -356,9 +358,8 @@ onBeforeUnmount(() => {
|
||||
// ==================== 监听器 ====================
|
||||
|
||||
/** 同步外部传入的 regionCodes 到内部选中状态 */
|
||||
watch(
|
||||
() => props.regionCodes,
|
||||
(codes) => {
|
||||
function syncFromProps() {
|
||||
const codes = props.regionCodes
|
||||
if (!codes || !regions.value.length) return
|
||||
// 根据 level 收集对应级别的所有节点
|
||||
const allNodes: SelectedRegion[] = []
|
||||
@@ -380,7 +381,14 @@ watch(
|
||||
selectedItems.value = codes
|
||||
.map(code => allNodes.find(n => n.code === code))
|
||||
.filter(Boolean) as SelectedRegion[]
|
||||
},
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.regionCodes,
|
||||
() => syncFromProps(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 树数据加载完成后重新同步选中项(解决并行加载时树数据晚于 prop 到达的问题) */
|
||||
watch(regions, () => syncFromProps())
|
||||
</script>
|
||||
|
||||
+25
-5
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import store from '@/stores'
|
||||
import { checkLogin } from '@/api/auth'
|
||||
|
||||
/**
|
||||
* 静态路由 — 不需要权限,应用启动就注册
|
||||
@@ -30,8 +31,10 @@ const router = createRouter({
|
||||
* 全局前置守卫 — 核心逻辑:
|
||||
*
|
||||
* 1. 动态路由未加载 → 先拉取并注册,再重新进入当前路由
|
||||
* 2. 需要鉴权的路由没有 token → 回首页
|
||||
* 3. 其他情况 → 直接放行
|
||||
* 2. 需要鉴权的路由 → 调 checkLogin 接口验证 Cookie 是否有效
|
||||
* - 有效:同步 isAuthenticated = true,放行
|
||||
* - 无效:同步 isAuthenticated = false,弹登录框,阻止导航
|
||||
* 3. 不需要鉴权的路由 → 直接放行
|
||||
*/
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
// 动态路由只需加载一次,与登录状态无关
|
||||
@@ -46,9 +49,26 @@ router.beforeEach(async (to, _from, next) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 需要鉴权的路由,未登录则回首页
|
||||
if (to.meta?.requiresAuth && !store.state.isAuthenticated) {
|
||||
next({ name: 'Home' })
|
||||
// 需要鉴权的路由,每次都通过接口校验登录状态
|
||||
if (to.meta?.requiresAuth) {
|
||||
try {
|
||||
const res = await checkLogin()
|
||||
if (res.code === '0' && res.data === true) {
|
||||
// Cookie 有效,同步前端状态并放行
|
||||
store.commit('SET_AUTHENTICATED', true)
|
||||
next()
|
||||
} else {
|
||||
// 未登录或 Cookie 失效
|
||||
store.commit('SET_AUTHENTICATED', false)
|
||||
store.dispatch('openLogin', to.fullPath)
|
||||
next(false)
|
||||
}
|
||||
} catch {
|
||||
// 请求异常(网络错误等),也视为未登录
|
||||
store.commit('SET_AUTHENTICATED', false)
|
||||
store.dispatch('openLogin', to.fullPath)
|
||||
next(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+79
-10
@@ -5,6 +5,8 @@ import type { MenuItemRaw } from '@/api/menu'
|
||||
import { buildDynamicRoutes } from '@/router/dynamicRoutes'
|
||||
import { fetchIndustryTree, fetchJobCategoryTree, fetchRegionTree } from '@/api/common'
|
||||
import type { IndustryItem, JobCategoryItem, RegionItem } from '@/api/common'
|
||||
import { fetchJobIntention, saveJobIntention } from '@/api/jobs'
|
||||
import type { JobIntention } from '@/api/jobs'
|
||||
|
||||
/** 职位列表页缓存数据(从详情页返回时恢复用) */
|
||||
export interface JobListCache {
|
||||
@@ -71,6 +73,12 @@ export interface RootState {
|
||||
* 为 null 表示没有缓存,需要重新请求
|
||||
*/
|
||||
jobListCache: JobListCache | null
|
||||
|
||||
/**
|
||||
* 求职意向数据 — JobGoalDialog 和 Jobs 页面共享
|
||||
* 由 loadJobIntention action 从接口加载,saveJobIntention action 保存后更新
|
||||
*/
|
||||
jobIntention: JobIntention
|
||||
}
|
||||
|
||||
export default createStore<RootState>({
|
||||
@@ -86,6 +94,12 @@ export default createStore<RootState>({
|
||||
jobCategories: [],
|
||||
regions: [],
|
||||
jobListCache: null,
|
||||
jobIntention: {
|
||||
categoryIds: [],
|
||||
regionCodes: [],
|
||||
industryIds: [],
|
||||
employmentType: 0,
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
getAppName: (state) => state.appName,
|
||||
@@ -129,6 +143,14 @@ export default createStore<RootState>({
|
||||
SET_JOB_LIST_CACHE(state, cache: JobListCache | null) {
|
||||
state.jobListCache = cache
|
||||
},
|
||||
SET_JOB_INTENTION(state, data: JobIntention) {
|
||||
state.jobIntention = {
|
||||
categoryIds: data.categoryIds ?? [],
|
||||
regionCodes: data.regionCodes ?? [],
|
||||
industryIds: data.industryIds ?? [],
|
||||
employmentType: data.employmentType ?? 0,
|
||||
}
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
updateAppName({ commit }, name: string) {
|
||||
@@ -179,32 +201,79 @@ export default createStore<RootState>({
|
||||
commit('SET_AUTHENTICATED', false)
|
||||
commit('SET_SHOW_LOGIN', false)
|
||||
commit('SET_LOGIN_REDIRECT', '')
|
||||
// 清除 Jobs 页面缓存数据
|
||||
commit('SET_JOB_LIST_CACHE', null)
|
||||
commit('SET_JOB_INTENTION', {
|
||||
categoryIds: [],
|
||||
regionCodes: [],
|
||||
industryIds: [],
|
||||
employmentType: 0,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载工具类公共数据(行业分类、岗位分类等)
|
||||
* 进首页和 Jobs 页时调用,刷新全局数据
|
||||
* 进首页和 Jobs 页时调用
|
||||
* 如果 store 中已有数据则跳过请求,避免重复加载
|
||||
* 传入 force = true 可强制刷新
|
||||
*/
|
||||
async loadCommonData({ commit }) {
|
||||
async loadCommonData({ commit, state }, { force = false } = {}) {
|
||||
const needIndustry = force || state.industries.length === 0
|
||||
const needJobCategory = force || state.jobCategories.length === 0
|
||||
const needRegion = force || state.regions.length === 0
|
||||
|
||||
// 三个都已缓存,直接返回
|
||||
if (!needIndustry && !needJobCategory && !needRegion) return
|
||||
|
||||
try {
|
||||
const [industryRes, jobCategoryRes, regionRes] = await Promise.all([
|
||||
fetchIndustryTree(),
|
||||
fetchJobCategoryTree(),
|
||||
fetchRegionTree(),
|
||||
])
|
||||
if (industryRes.code === '0' && industryRes.data) {
|
||||
const promises: Promise<any>[] = []
|
||||
// 按需发起请求,保持顺序以便后续取值
|
||||
promises.push(needIndustry ? fetchIndustryTree() : Promise.resolve(null))
|
||||
promises.push(needJobCategory ? fetchJobCategoryTree() : Promise.resolve(null))
|
||||
promises.push(needRegion ? fetchRegionTree() : Promise.resolve(null))
|
||||
|
||||
const [industryRes, jobCategoryRes, regionRes] = await Promise.all(promises)
|
||||
|
||||
if (industryRes && industryRes.code === '0' && industryRes.data) {
|
||||
commit('SET_INDUSTRIES', industryRes.data)
|
||||
}
|
||||
if (jobCategoryRes.code === '0' && jobCategoryRes.data) {
|
||||
if (jobCategoryRes && jobCategoryRes.code === '0' && jobCategoryRes.data) {
|
||||
commit('SET_JOB_CATEGORIES', jobCategoryRes.data)
|
||||
}
|
||||
if (regionRes.code === '0' && regionRes.data) {
|
||||
if (regionRes && regionRes.code === '0' && regionRes.data) {
|
||||
commit('SET_REGIONS', regionRes.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[store] 加载公共分类数据失败', err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 从接口加载求职意向数据到 store
|
||||
* JobGoalDialog 和 Jobs 页面都可调用
|
||||
*/
|
||||
async loadJobIntention({ commit }) {
|
||||
try {
|
||||
const res = await fetchJobIntention()
|
||||
if (res.code === '0' && res.data) {
|
||||
commit('SET_JOB_INTENTION', res.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[store] 加载求职意向失败', err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 保存求职意向到后端,成功后同步更新 store
|
||||
* @param data 求职意向数据
|
||||
*/
|
||||
async saveJobIntention({ commit }, data: JobIntention) {
|
||||
const res = await saveJobIntention(data)
|
||||
if (res.code === '0') {
|
||||
commit('SET_JOB_INTENTION', data)
|
||||
}
|
||||
return res
|
||||
},
|
||||
},
|
||||
modules: {},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import store from '@/stores/index'
|
||||
import type { IndustryItem } from '@/api/common'
|
||||
|
||||
/**
|
||||
* 根据行业 ID 从 store 行业树中查找对应的行业名称
|
||||
* 遍历一级→二级,匹配到即返回名称,未匹配返回 ID 本身
|
||||
* @param id 行业 ID(如 12)
|
||||
* @returns 匹配到的行业名称,未匹配则返回 ID 字符串
|
||||
*/
|
||||
export function resolveIndustryName(id: number): string {
|
||||
if (!id && id !== 0) return ''
|
||||
const industries: IndustryItem[] = store.state.industries
|
||||
for (const parent of industries) {
|
||||
if (String(parent.id) === String(id)) return parent.name
|
||||
for (const child of parent.children) {
|
||||
if (String(child.id) === String(id)) return child.name
|
||||
}
|
||||
}
|
||||
return String(id)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import store from '@/stores/index'
|
||||
import type { JobCategoryItem } from '@/api/common'
|
||||
|
||||
/**
|
||||
* 根据岗位分类 ID 从 store 岗位树中查找对应的岗位名称
|
||||
* 遍历一级→二级→三级,匹配到即返回名称,未匹配返回 ID 本身
|
||||
* @param id 岗位分类 ID(如 305)
|
||||
* @returns 匹配到的岗位名称,未匹配则返回 ID 字符串
|
||||
*/
|
||||
export function resolveJobCategoryName(id: number): string {
|
||||
if (!id && id !== 0) return ''
|
||||
const categories: JobCategoryItem[] = store.state.jobCategories
|
||||
for (const lv1 of categories) {
|
||||
if (String(lv1.id) === String(id)) return lv1.name
|
||||
for (const lv2 of lv1.children) {
|
||||
if (String(lv2.id) === String(id)) return lv2.name
|
||||
if (lv2.children) {
|
||||
for (const lv3 of lv2.children) {
|
||||
if (String(lv3.id) === String(id)) return lv3.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return String(id)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import store from '@/stores'
|
||||
|
||||
/**
|
||||
* 创建 axios 实例
|
||||
@@ -21,6 +22,9 @@ service.interceptors.response.use(
|
||||
(error) => {
|
||||
const status = error.response?.status
|
||||
if (status === 401) {
|
||||
// 同步重置前端登录状态,弹出登录框
|
||||
store.commit('SET_AUTHENTICATED', false)
|
||||
store.dispatch('openLogin', window.location.pathname)
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
} else {
|
||||
ElMessage.error(error.response?.data?.msg || '请求失败')
|
||||
|
||||
+429
-4
@@ -1,20 +1,445 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<h1>首页</h1>
|
||||
<el-button type="primary" size="large" @click="router.push('/jobs')">浏览职位</el-button>
|
||||
<!-- SEO: 语义化 header -->
|
||||
<header class="home-nav">
|
||||
<div class="home-nav__inner">
|
||||
<div class="home-nav__logo">
|
||||
<span class="home-nav__logo-text">Offer派</span>
|
||||
</div>
|
||||
<button class="home-nav__btn" @click="router.push('/jobs')">免费领取 offer</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="home-hero">
|
||||
<div class="home-hero__inner">
|
||||
<div class="home-hero__left">
|
||||
<h1 class="home-hero__title">Offer派<br/>收offer就是快!</h1>
|
||||
<p class="home-hero__desc">智能匹配职位、自动填写申请、量身定制简历、推荐内部人脉——不到1分钟,统统搞定!</p>
|
||||
<button class="home-hero__cta" @click="router.push('/jobs')">免费体验</button>
|
||||
</div>
|
||||
<div class="home-hero__right">
|
||||
<div class="home-hero__card">
|
||||
<div class="home-hero__card-dots">
|
||||
<span class="dot dot--red"></span>
|
||||
<span class="dot dot--yellow"></span>
|
||||
<span class="dot dot--green"></span>
|
||||
</div>
|
||||
<div class="home-hero__card-body">
|
||||
<div class="home-hero__card-user">
|
||||
<div class="home-hero__card-avatar"></div>
|
||||
<div class="home-hero__card-info">
|
||||
<h4>求职者 Alex</h4>
|
||||
<div class="home-hero__card-tags">
|
||||
<span class="tag">React</span>
|
||||
<span class="tag">Tailwind</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-hero__card-match">98%</div>
|
||||
</div>
|
||||
<div class="home-hero__card-lines">
|
||||
<div class="line-full"></div>
|
||||
<div class="line-3q"></div>
|
||||
</div>
|
||||
<div class="home-hero__card-result">为您匹配了 5 个高星职位</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<section class="home-stats">
|
||||
<div class="home-stats__inner">
|
||||
<div class="home-stats__header">
|
||||
<h2 class="fs48">让每一次求职努力<br/>都成为看得见的成果</h2>
|
||||
<div class="home-stats__divider"></div>
|
||||
</div>
|
||||
<div class="home-stats__cards">
|
||||
<article class="stat-card">
|
||||
<div class="stat-card__num">第一</div>
|
||||
<p class="stat-card__label">80%大学生求职首选</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-card__num">3倍</div>
|
||||
<p class="stat-card__label">面试邀约率提升</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-card__num">82%</div>
|
||||
<p class="stat-card__label">用户成功拿到offer</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Jobs Showcase Section -->
|
||||
<section class="home-jobs-showcase">
|
||||
<div class="home-jobs-showcase__inner">
|
||||
<h2>海量优质校招岗位,尽在Offer派!</h2>
|
||||
<p class="home-jobs-showcase__sub">实时汇集海量超10000+名校校招职位</p>
|
||||
<div class="home-jobs-showcase__box">
|
||||
<div class="home-jobs-showcase__stats">
|
||||
<div class="showcase-stat">
|
||||
<div class="showcase-stat__num"><span class="accent">40</span>万<span class="accent">+</span></div>
|
||||
<div class="showcase-stat__label">岗位总数</div>
|
||||
</div>
|
||||
<div class="showcase-stat">
|
||||
<div class="showcase-stat__num"><span class="accent">3120</span>今日新增</div>
|
||||
<div class="showcase-stat__label">今日新增</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-jobs-showcase__scroll">
|
||||
<div class="job-ticker" v-for="(job, i) in tickerJobs" :key="i">
|
||||
<span class="job-ticker__company">{{ job.company }}·{{ job.time }}</span>
|
||||
<span class="job-ticker__title">{{ job.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature 1: 个性化岗位匹配 -->
|
||||
<section class="home-feature">
|
||||
<div class="home-feature__inner">
|
||||
<div class="home-feature__text">
|
||||
<h2>个性化<br/>岗位匹配</h2>
|
||||
<p>第一时间发现真正适合你的岗位,精准匹配你的真实技能,杜绝虚假信息。</p>
|
||||
<button class="home-feature__btn" @click="router.push('/jobs')">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="2"/><circle cx="10" cy="10" r="4" stroke="currentColor" stroke-width="2"/><circle cx="10" cy="10" r="1.5" fill="currentColor"/></svg>
|
||||
<span>立即匹配</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="home-feature__visual home-feature__visual--match">
|
||||
<div class="feature-match-card">
|
||||
<div class="feature-match-card__header">
|
||||
<div class="feature-match-card__avatar"></div>
|
||||
<div class="feature-match-card__info">
|
||||
<h4>求职者 Alex</h4>
|
||||
<div class="feature-match-card__tags">
|
||||
<span>React</span><span>Tailwind</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-match-card__score">98%</div>
|
||||
</div>
|
||||
<div class="feature-match-card__bars">
|
||||
<div class="bar bar--full"></div>
|
||||
<div class="bar bar--3q"></div>
|
||||
</div>
|
||||
<div class="feature-match-card__result">为您匹配了 5 个高星职位</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature 2: 一键自动网申 -->
|
||||
<section class="home-feature home-feature--reverse">
|
||||
<div class="home-feature__inner">
|
||||
<div class="home-feature__visual home-feature__visual--apply">
|
||||
<div class="feature-apply-card">
|
||||
<div class="feature-apply-card__header">
|
||||
<span class="feature-apply-card__status">正在执行投递程序...</span>
|
||||
<div class="feature-apply-card__dots">
|
||||
<span class="dot dot--red"></span>
|
||||
<span class="dot dot--yellow"></span>
|
||||
<span class="dot dot--green"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-apply-card__list">
|
||||
<div class="apply-item">
|
||||
<div class="apply-item__icon">✉</div>
|
||||
<div class="apply-item__info">
|
||||
<p class="apply-item__title">腾讯科技 - 校招投递成功</p>
|
||||
<p class="apply-item__sub">耗时 0.8s · 自动识别填写完毕</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="apply-item apply-item--active">
|
||||
<div class="apply-item__icon apply-item__icon--pulse">📋</div>
|
||||
<div class="apply-item__info">
|
||||
<p class="apply-item__title">京东校招 - 自动填写中...</p>
|
||||
<p class="apply-item__sub">进度 65%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-feature__text">
|
||||
<h2>一键<br/>自动网申</h2>
|
||||
<p>每日向数百岗位一键投递,覆盖各大企业网申系统。告别重复填写,节省 80% 的宝贵时间。</p>
|
||||
<button class="home-feature__btn" @click="router.push('/jobs')">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M3 10l2-8h10l2 8-7 7-7-7z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
||||
<span>开启自动网申</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature 3: 岗位定制简历 -->
|
||||
<section class="home-feature">
|
||||
<div class="home-feature__inner">
|
||||
<div class="home-feature__text">
|
||||
<h2>岗位定制简历</h2>
|
||||
<p>6秒内生成针对特定岗位优化的专业简历,通过ATS系统,突出你的核心优势。</p>
|
||||
<button class="home-feature__btn" @click="router.push('/jobs')">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M4 2h8l4 4v12H4V2z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M12 2v4h4" stroke="currentColor" stroke-width="2"/><path d="M4 11h8" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<span>优化我的简历</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="home-feature__visual home-feature__visual--resume">
|
||||
<div class="feature-resume-card">
|
||||
<div class="feature-resume-card__doc">
|
||||
<div class="doc-header">
|
||||
<div class="doc-avatar"></div>
|
||||
<div class="doc-lines">
|
||||
<div class="doc-line doc-line--half"></div>
|
||||
<div class="doc-line doc-line--third"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-bars">
|
||||
<div class="doc-bar doc-bar--1"></div>
|
||||
<div class="doc-bar doc-bar--2"></div>
|
||||
<div class="doc-bar doc-bar--3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-resume-card__badge">
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><path d="M3 2h7l3 3v8H3V2z" stroke="#4FC2C9" stroke-width="1.2"/><path d="M6 7h3" stroke="#4FC2C9" stroke-width="1.2"/></svg>
|
||||
<span>ATS OPTIMIZED</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature 4: 内推人脉 -->
|
||||
<section class="home-feature home-feature--reverse">
|
||||
<div class="home-feature__inner">
|
||||
<div class="home-feature__visual home-feature__visual--referral">
|
||||
<div class="referral-grid">
|
||||
<div class="referral-card" v-for="(ref, i) in referralCards" :key="i" :class="{ 'referral-card--offset': i % 2 === 1 }">
|
||||
<div class="referral-card__icon" :style="{ background: ref.color }">{{ ref.letter }}</div>
|
||||
<p class="referral-card__company">{{ ref.company }}</p>
|
||||
<p class="referral-card__role">{{ ref.role }}</p>
|
||||
<span class="referral-card__code">内推码: {{ ref.code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-feature__text">
|
||||
<h2>内推<br/>人脉直通</h2>
|
||||
<p>实时获取名企最新内推信息,自动填写网申内推码,简历更快到达HR。</p>
|
||||
<button class="home-feature__btn" @click="router.push('/jobs')">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M3 3h10l4 4v10H3V3z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M6 12l2 2 4-4" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<span>立即投递</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature 5: AI求职助手 -->
|
||||
<section class="home-feature">
|
||||
<div class="home-feature__inner">
|
||||
<div class="home-feature__text">
|
||||
<h2>24h全天候<br/>AI求职助手</h2>
|
||||
<p>随时提供求职指导,从岗位筛选到面试技巧,你的专属职业规划顾问。</p>
|
||||
<button class="home-feature__btn" @click="router.push('/jobs')">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M2 3h12v10H6l-4 4V3z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
||||
<span>立即咨询</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="home-feature__visual home-feature__visual--ai">
|
||||
<div class="feature-ai-chat">
|
||||
<div class="ai-bubble ai-bubble--bot">👋 哈喽!我是你的 AI 职业导师。正在为你分析即将到来的面试流程。</div>
|
||||
<div class="ai-bubble ai-bubble--user">能帮我模拟一下明天的腾讯面试吗?</div>
|
||||
<div class="ai-bubble ai-bubble--bot">
|
||||
<p class="ai-bubble__title">准备就绪!面试官画像已生成:</p>
|
||||
<div class="ai-bubble__tags">
|
||||
<span>项目深度与工程化能力</span>
|
||||
<span>计算机网络与操作系统</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Testimonials Section -->
|
||||
<section class="home-testimonials">
|
||||
<div class="home-testimonials__header">
|
||||
<h2>万千毕业生的信赖之选</h2>
|
||||
<p>REAL VOICES FROM THE COMMUNITY</p>
|
||||
</div>
|
||||
<div class="home-testimonials__founder">
|
||||
<div class="home-testimonials__founder-img">👩💼</div>
|
||||
<blockquote>"很多大学生还在用传统方式找工作,海投、反复改简历,效率很低。Offer派利用AI技术让整个校招流程更丝滑,节省了80%的繁琐过程。"</blockquote>
|
||||
<cite><span class="cite-name">创始人</span> <span class="cite-role">Offer派</span></cite>
|
||||
</div>
|
||||
<div class="home-testimonials__scroll">
|
||||
<div class="testimonial-card" v-for="(t, i) in testimonials" :key="i">
|
||||
<svg class="testimonial-card__quote" width="48" height="48" viewBox="0 0 48 48" fill="none"><path d="M6 6h14v36" stroke="rgba(82,202,209,0.2)" stroke-width="2"/><path d="M28 6h14v36" stroke="rgba(82,202,209,0.2)" stroke-width="2"/></svg>
|
||||
<p class="testimonial-card__text">"{{ t.text }}"</p>
|
||||
<div class="testimonial-card__author">
|
||||
<div class="testimonial-card__avatar"></div>
|
||||
<div>
|
||||
<p class="testimonial-card__name">{{ t.name }}</p>
|
||||
<p class="testimonial-card__school">{{ t.school }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Job Search Section -->
|
||||
<section class="home-job-search">
|
||||
<div class="home-job-search__inner">
|
||||
<h3>一键找到最适合你的工作</h3>
|
||||
<div class="home-job-search__filters">
|
||||
<div class="filter-select">
|
||||
<span>行业</span>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2"/></svg>
|
||||
</div>
|
||||
<div class="filter-select">
|
||||
<span>地区</span>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2"/></svg>
|
||||
</div>
|
||||
<div class="filter-select">
|
||||
<span>岗位</span>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2"/></svg>
|
||||
</div>
|
||||
<button class="filter-btn" @click="router.push('/jobs')">搜索职位</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<section class="home-faq">
|
||||
<div class="home-faq__inner">
|
||||
<div class="home-faq__header">
|
||||
<h2>常见问题</h2>
|
||||
<p>FREQUENTLY ASKED QUESTIONS</p>
|
||||
</div>
|
||||
<div class="home-faq__list">
|
||||
<div
|
||||
v-for="(faq, i) in faqs"
|
||||
:key="i"
|
||||
class="faq-item"
|
||||
:class="{ 'faq-item--open': faqOpen === i }"
|
||||
@click="faqOpen = faqOpen === i ? -1 : i"
|
||||
>
|
||||
<div class="faq-item__header">
|
||||
<span class="faq-item__question">{{ faq.q }}</span>
|
||||
<div class="faq-item__icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2"/>
|
||||
<line v-if="faqOpen !== i" x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="faq-item__answer" v-show="faqOpen === i">
|
||||
<p>{{ faq.a }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="home-cta">
|
||||
<div class="home-cta__inner">
|
||||
<h2>让Offer不再遥不可及<br/>大学生一站式AI求职</h2>
|
||||
<button class="home-cta__btn" @click="router.push('/jobs')">免费体验</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="home-footer">
|
||||
<div class="home-footer__inner">
|
||||
<div class="home-footer__col">
|
||||
<div class="home-footer__logo">
|
||||
<span class="home-footer__logo-text">Offer派</span>
|
||||
</div>
|
||||
<p class="home-footer__slogan">大学生AI求职平台</p>
|
||||
</div>
|
||||
<div class="home-footer__col">
|
||||
<h5>核心功能</h5>
|
||||
<ul>
|
||||
<li>智能岗位匹配</li>
|
||||
<li>AI简历优化</li>
|
||||
<li>AI求职助手</li>
|
||||
<li>一键投递</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="home-footer__col">
|
||||
<h5>求职资源</h5>
|
||||
<ul>
|
||||
<li>求职指南</li>
|
||||
<li>面试技巧</li>
|
||||
<li>简历模版</li>
|
||||
<li>行业分析</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="home-footer__col">
|
||||
<h5>其他信息</h5>
|
||||
<ul>
|
||||
<li>隐私协议</li>
|
||||
<li>服务条款</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-footer__bottom">
|
||||
<p>©2016-2026 - 广州油梨信息科技有限公司 版权所有</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
|
||||
/** 路由实例 */
|
||||
const router = useRouter()
|
||||
/** Vuex 状态管理实例 */
|
||||
const store = useStore()
|
||||
|
||||
/** 当前展开的 FAQ 索引,-1 表示全部收起 */
|
||||
const faqOpen = ref(-1)
|
||||
|
||||
/** 岗位滚动展示数据 — 模拟最新发布的校招岗位 */
|
||||
const tickerJobs = [
|
||||
{ company: '字节跳动', time: '15分钟前', title: '前端开发工程师' },
|
||||
{ company: '腾讯', time: '30分钟前', title: '后端开发工程师' },
|
||||
{ company: '阿里巴巴', time: '1小时前', title: '产品经理' },
|
||||
{ company: '华为', time: '2小时前', title: '算法工程师' },
|
||||
{ company: '京东', time: '3小时前', title: '运营管培生' },
|
||||
]
|
||||
|
||||
/** 内推人脉卡片数据 — letter: 公司首字母, color: 图标背景色 */
|
||||
const referralCards = [
|
||||
{ letter: 'b', company: '字节跳动', role: '前端工程师', code: 'r7u9xx', color: '#2563eb' },
|
||||
{ letter: 'a', company: '阿里巴巴', role: '运营管培生', code: 'alicc2', color: '#ea580c' },
|
||||
{ letter: 't', company: '腾讯', role: '后端开发工程师', code: 'tx9901', color: '#111827' },
|
||||
{ letter: 'h', company: '华为', role: '通用软件研究员', code: 'hwl92', color: '#dc2626' },
|
||||
]
|
||||
|
||||
/** 用户评价数据 — 展示真实用户的使用反馈 */
|
||||
const testimonials = [
|
||||
{ text: 'Offer派求职体验特别好!岗位信息丰富、质量高,AI岗位匹配也很精准,求职助手给的面试建议很专业,一周就拿到了Offer!', name: '李同学', school: '西安交通大学' },
|
||||
{ text: '简历优化功能太强了,针对不同岗位自动调整内容,面试邀约率直接翻倍。', name: '王同学', school: '浙江大学' },
|
||||
{ text: '一键投递省了我大量时间,再也不用一个个填网申表格了,效率提升太明显。', name: '张同学', school: '北京大学' },
|
||||
]
|
||||
|
||||
/** 常见问题列表 — q: 问题, a: 答案,点击可展开/收起 */
|
||||
const faqs = [
|
||||
{ q: '这个平台与其他求职网站有什么不同?', a: 'Offer派专注于大学生校招场景,利用AI技术实现智能岗位匹配、一键自动网申、岗位定制简历和内推人脉直通,让求职效率提升80%以上。' },
|
||||
{ q: '平台会分享我的个人信息吗?', a: '绝对不会。我们严格遵守隐私保护法规,您的个人信息仅用于岗位匹配和简历投递,不会分享给任何第三方。' },
|
||||
{ q: '如何收费?', a: '基础功能完全免费,包括岗位浏览、AI匹配和求职助手。高级功能如一键批量投递、定制简历等提供会员订阅服务。' },
|
||||
{ q: '平台的岗位来源是什么?', a: '我们的岗位信息来自各大企业官方校招渠道、合作高校就业中心以及企业HR直接发布,确保信息真实可靠。' },
|
||||
{ q: '支持哪些企业?', a: '目前已覆盖互联网、金融、制造、快消等行业的数千家企业,包括字节跳动、腾讯、阿里巴巴、华为等头部企业。' },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
// 进入首页时加载公共工具数据(行业分类等)
|
||||
// 加载公共工具数据(行业分类、岗位分类、地区分类等)
|
||||
store.dispatch('loadCommonData')
|
||||
// 触发预渲染事件 — 配合 vite.config.ts 中 PrerenderPlugin 的 renderAfterDocumentEvent 设置
|
||||
document.dispatchEvent(new Event('prerender-trigger'))
|
||||
})
|
||||
</script>
|
||||
|
||||
+82
-19
@@ -4,7 +4,8 @@
|
||||
<div class="job-detail__content">
|
||||
<!-- 页面标题 + Tab 切换 -->
|
||||
<JobPageHeader :activeTab="''" />
|
||||
|
||||
<div class="bg-white p20 border-ra20">
|
||||
<div class="bg-main p20 border-ra20">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="job-detail__toolbar">
|
||||
<button class="job-detail__close-btn" @click="goBack" aria-label="关闭">
|
||||
@@ -18,7 +19,7 @@
|
||||
<path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="jobs-page__action-icon-btn" :class="{ 'job-detail__tool-btn--liked': job.isFavorite }" aria-label="收藏">
|
||||
<button class="jobs-page__action-icon-btn" :class="{ 'job-detail__tool-btn--liked': job.isFavorite }" aria-label="收藏" @click="toggleFavorite">
|
||||
<svg viewBox="0 0 16 16" :fill="job.isFavorite ? 'currentColor' : 'none'" class="job-detail__tool-icon">
|
||||
<path d="M8 13.7l-1.1-1C3.6 9.8 1.5 7.9 1.5 5.7 1.5 3.9 2.9 2.5 4.7 2.5c1 0 2 .5 2.6 1.2h1.4c.6-.7 1.6-1.2 2.6-1.2 1.8 0 3.2 1.4 3.2 3.2 0 2.2-2.1 4.1-5.4 6.9L8 13.7z" stroke="currentColor" stroke-width="1"/>
|
||||
</svg>
|
||||
@@ -150,7 +151,7 @@
|
||||
<div class="job-detail__card">
|
||||
<p class="job-detail__desc-text">{{ job.companyInfo.summary }}</p>
|
||||
<div class="job-detail__tag-list">
|
||||
<span v-for="tag in job.tags" :key="tag" class="job-detail__tag">{{ tag }}</span>
|
||||
<span v-for="tag in job.companyInfo.tags" :key="tag" class="job-detail__tag">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -167,21 +168,19 @@
|
||||
<div class="job-detail__section-header">
|
||||
<h3 class="job-detail__section-title">任职要求</h3>
|
||||
<span class="job-detail__skill-hint">
|
||||
<svg viewBox="0 0 14 14" fill="none" class="job-detail__hint-icon">
|
||||
<circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1"/>
|
||||
<path d="M7 6.5V10" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
|
||||
<circle cx="7" cy="4.5" r="0.5" fill="currentColor"/>
|
||||
</svg>
|
||||
查看您的技能与岗位要求的匹配情况
|
||||
|
||||
<!-- 深色为你拥有的技能-->
|
||||
</span>
|
||||
</div>
|
||||
<div class="job-detail__skill-tags">
|
||||
|
||||
<!-- 先不加点击选中 @click="skill.matched = !skill.matched"-->
|
||||
<span
|
||||
v-for="skill in job.requiredSkills"
|
||||
:key="skill.name"
|
||||
class="job-detail__skill-tag cursor-po"
|
||||
:class="{ 'job-detail__skill-tag--matched': skill.matched }"
|
||||
@click="skill.matched = !skill.matched"
|
||||
|
||||
>
|
||||
{{ skill.name }}
|
||||
<svg v-if="skill.matched" viewBox="0 0 12 12" fill="none" class="job-detail__skill-close">
|
||||
@@ -217,7 +216,7 @@
|
||||
</div>
|
||||
<p class="job-detail__company-desc">{{ job.companyInfo.description }}</p>
|
||||
</div>
|
||||
<div class="job-detail__company-info-right">
|
||||
<div class="job-detail__company-info-right pr50">
|
||||
<div class="job-detail__company-meta-item">
|
||||
<span class="job-detail__meta-label">成立时间:</span>
|
||||
<span>{{ job.companyInfo.founded }}</span>
|
||||
@@ -261,6 +260,12 @@
|
||||
<!-- 公司概况 Tab 已移除,点击直接滚动到岗位详情中的公司概况部分 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<AiChat />
|
||||
|
||||
<!-- 职位不感兴趣反馈弹窗 -->
|
||||
@@ -268,24 +273,30 @@
|
||||
|
||||
<!-- 职位问题反馈弹窗 -->
|
||||
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="jobId" />
|
||||
|
||||
<!-- 岗位专属简历定制弹窗 -->
|
||||
<JobResumeCustomDialog v-model="showResumeCustomDialog" :job-info="resumeCustomJobInfo" @skip="handleSkipToApply" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, nextTick, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, nextTick, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
import SideNav from '@/components/SideNav.vue'
|
||||
import AiChat from '@/components/AiChat.vue'
|
||||
import JobPageHeader from '@/components/JobPageHeader.vue'
|
||||
import JobDislikeDialog from '@/components/JobDislikeDialog.vue'
|
||||
import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
|
||||
import { fetchJobDetail } from '@/api/jobs'
|
||||
import JobResumeCustomDialog from '@/components/JobResumeCustomDialog.vue'
|
||||
import { fetchJobDetail, toggleJobFavorite, removeJobFavorite } from '@/api/jobs'
|
||||
import type { JobDetailData } from '@/api/jobs'
|
||||
|
||||
// ==================== 路由相关 ====================
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useStore()
|
||||
|
||||
/** 当前岗位 ID,从路由参数中获取 */
|
||||
const jobId = route.params.id as string
|
||||
@@ -345,9 +356,9 @@ function scrollToCompany() {
|
||||
|
||||
/** 匹配度维度列表(岗位匹配环下方的子维度) */
|
||||
const matchItems = ref([
|
||||
{ label: '行业经验', score: 0 },
|
||||
{ label: '教育背景', score: 0 },
|
||||
{ label: '核心技能', score: 0 },
|
||||
{ label: '工作经历', score: 0 },
|
||||
{ label: '过往经历', score: 0 },
|
||||
])
|
||||
|
||||
/** 技能标签项(matched 表示用户是否具备该技能,可点击切换) */
|
||||
@@ -455,9 +466,9 @@ function fillJobData(data: JobDetailData) {
|
||||
// 匹配度详情
|
||||
if (data.matchDetail) {
|
||||
matchItems.value = [
|
||||
{ label: '行业经验', score: data.matchDetail.industryScore ?? 0 },
|
||||
{ label: '教育背景', score: data.matchDetail.educationScore ?? 0 },
|
||||
{ label: '核心技能', score: data.matchDetail.skillScore ?? 0 },
|
||||
{ label: '工作经历', score: data.matchDetail.experienceScore ?? 0 },
|
||||
{ label: '过往经历', score: data.matchDetail.experienceScore ?? 0 },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -516,7 +527,13 @@ function openDislikeDialog() {
|
||||
|
||||
/** 返回职位列表页 */
|
||||
function goBack() {
|
||||
// 使用 back() 回退,保证 Jobs 页能正确恢复缓存
|
||||
// 如果没有历史记录(直接访问详情页),则 push 到列表页
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/jobs')
|
||||
}
|
||||
}
|
||||
|
||||
/** 问题反馈 */
|
||||
@@ -532,9 +549,55 @@ function handleReport() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成岗位专属简历 */
|
||||
// ==================== 岗位专属简历定制弹窗 ====================
|
||||
|
||||
/** 简历定制弹窗显隐 */
|
||||
const showResumeCustomDialog = ref(false)
|
||||
|
||||
/** 传递给简历定制弹窗的岗位信息 */
|
||||
const resumeCustomJobInfo = computed(() => ({
|
||||
title: job.title,
|
||||
company: job.company,
|
||||
companyLogoUrl: job.companyLogoUrl,
|
||||
location: job.location,
|
||||
matchScore: +(job.matchScore / 10).toFixed(1),
|
||||
missingSkills: job.requiredSkills.filter(s => !s.matched).map(s => s.name),
|
||||
keywords: job.requiredSkills.map(s => s.name),
|
||||
sourceUrl: job.sourceUrl,
|
||||
}))
|
||||
|
||||
/** 生成岗位专属简历 — 打开定制弹窗 */
|
||||
function handleGenerateResume() {
|
||||
console.log('生成岗位专属简历')
|
||||
showResumeCustomDialog.value = true
|
||||
}
|
||||
|
||||
/** 不优化直接投递 */
|
||||
function handleSkipToApply() {
|
||||
if (job.sourceUrl) {
|
||||
window.open(job.sourceUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
/** 收藏/取消收藏 */
|
||||
async function toggleFavorite() {
|
||||
try {
|
||||
const res = job.isFavorite
|
||||
? await removeJobFavorite(job.id)
|
||||
: await toggleJobFavorite(job.id)
|
||||
if (res.code === '0') {
|
||||
const wasFavorite = job.isFavorite
|
||||
job.isFavorite = !job.isFavorite
|
||||
ElMessage.success(wasFavorite ? '已取消收藏' : '收藏成功')
|
||||
// 同步收藏状态到 Jobs 页面缓存,返回时无需刷新列表即可看到最新状态
|
||||
const cache = store.state.jobListCache
|
||||
if (cache) {
|
||||
const cached = cache.list.find((j: any) => j.id === job.id)
|
||||
if (cached) cached.isFavorite = job.isFavorite
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('收藏操作失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 去投递 — 跳转到来源链接 */
|
||||
|
||||
+493
-54
@@ -3,9 +3,28 @@
|
||||
<SideNav />
|
||||
<div class="jobs-page__content">
|
||||
<!-- 页面标题 + Tab 切换 -->
|
||||
<JobPageHeader v-model:activeTab="activeTab" />
|
||||
<JobPageHeader v-model:activeTab="activeTab" :favoriteCount="favoriteTotal" :applyCount="applyTotal" />
|
||||
<!-- 收藏统计栏 -->
|
||||
<div v-if="activeTab === 'collected'" class="jobs-page__filters-bar">
|
||||
<div class="jobs-page__fav-stats">
|
||||
<span class="jobs-page__fav-stats-item">有效({{ favoriteValidCount }})</span>
|
||||
<span class="jobs-page__fav-stats-item">失效({{ favoriteInvalidCount }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 投递状态筛选栏 -->
|
||||
<div v-if="activeTab === 'applied'" class="jobs-page__filters-bar">
|
||||
<div class="jobs-page__fav-stats">
|
||||
<span
|
||||
v-for="tab in applyStatusTabs"
|
||||
:key="tab.status"
|
||||
class="jobs-page__fav-stats-item"
|
||||
:class="{ 'jobs-page__fav-stats-item--active': applyStatusFilter === tab.status }"
|
||||
@click="switchApplyStatus(tab.status)"
|
||||
>{{ tab.label }}({{ tab.count }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 筛选条件 -->
|
||||
<div class="jobs-page__filters-bar">
|
||||
<div v-if="isRecommendTab" class="jobs-page__filters-bar">
|
||||
<div class="jobs-page__filters">
|
||||
<div class="jobs-page__filter-group">
|
||||
<!-- 筛选条件按钮列表(行业单独用组件) -->
|
||||
@@ -18,20 +37,24 @@
|
||||
:maxSelect="3"
|
||||
@update:regionCodes="onRegionChange"
|
||||
/>
|
||||
<!-- 行业筛选:使用行业选择组件 -->
|
||||
<IndustrySelector
|
||||
v-else-if="filter.key === 'industry'"
|
||||
:industryIds="selectedIndustryIds"
|
||||
:maxSelect="3"
|
||||
@update:industryIds="onIndustryChange"
|
||||
/>
|
||||
<!-- 岗位筛选:使用岗位选择组件 -->
|
||||
<JobCategorySelector
|
||||
v-else-if="filter.key === 'position'"
|
||||
:categoryIds="selectedCategoryIds"
|
||||
:maxSelect="3"
|
||||
:level="3"
|
||||
:allowParentSelect="false"
|
||||
@update:categoryIds="onCategoryChange"
|
||||
/>
|
||||
<!-- 行业筛选:使用行业选择组件 -->
|
||||
<IndustrySelector
|
||||
v-else-if="filter.key === 'industry'"
|
||||
:industryIds="selectedIndustryIds"
|
||||
:maxSelect="3"
|
||||
:level="2"
|
||||
:allowParentSelect="false"
|
||||
@update:industryIds="onIndustryChange"
|
||||
/>
|
||||
<!-- 其他筛选条件 -->
|
||||
<div
|
||||
v-else
|
||||
@@ -62,21 +85,22 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="jobs-page__search-box">
|
||||
<svg class="jobs-page__search-svg" viewBox="0 0 16 16" fill="none">
|
||||
<svg class="jobs-page__search-svg" viewBox="0 0 16 16" fill="none" @click="reloadFirstPage">
|
||||
<circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/>
|
||||
<path d="M11 11L14 14" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchText"
|
||||
v-model="keyword"
|
||||
class="jobs-page__search-input"
|
||||
placeholder="搜索职位、公司或关键词"
|
||||
@keyup.enter="reloadFirstPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 职位列表 -->
|
||||
<div ref="jobListRef" class="jobs-page__list pr5" @scroll="onListScroll">
|
||||
<div ref="jobListRef" class="jobs-page__list pr5" :style="restoring ? { visibility: 'hidden' } : {}" @scroll="onListScroll">
|
||||
<div
|
||||
v-for="(job, index) in jobList"
|
||||
:key="index"
|
||||
@@ -87,7 +111,8 @@
|
||||
<div class="jobs-page__job-main">
|
||||
<!-- 左侧:公司图标 + 职位信息 -->
|
||||
<div class="jobs-page__job-left">
|
||||
<div class="jobs-page__job-icon">
|
||||
<div class="dflex ">
|
||||
<div class="jobs-page__job-icon mr16">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="jobs-page__company-svg">
|
||||
<rect x="3" y="7" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M7 7V5a2 2 0 012-2h6a2 2 0 012 2v2" stroke="currentColor" stroke-width="1.5"/>
|
||||
@@ -98,11 +123,16 @@
|
||||
<div class="jobs-page__job-info">
|
||||
<div class="jobs-page__job-title-row">
|
||||
<span class="jobs-page__job-name">{{ job.title }}</span>
|
||||
<button class="jobs-page__job-more" aria-label="更多操作" @click.stop="toggleMenu(index)">
|
||||
<button v-if="isRecommendTab" class="jobs-page__job-more" aria-label="更多操作" @click.stop="toggleMenu(index)">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" class="jobs-page__more-svg">
|
||||
<circle cx="3" cy="8" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="13" cy="8" r="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button v-if="activeTab === 'collected'" class="jobs-page__job-remove" aria-label="取消收藏" @click.stop="removeFavoriteFromList(job, index)">
|
||||
<svg viewBox="0 0 16 16" fill="none" class="jobs-page__remove-svg">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="jobs-page__job-meta pt5">
|
||||
<span>{{ job.regionName }}</span>
|
||||
@@ -121,6 +151,9 @@
|
||||
{{ (job as any).tip }}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部操作栏 -->
|
||||
<div class="jobs-page__job-actions pt20">
|
||||
<div class="dflex fgrow2 aliite-c flex-warp" >
|
||||
@@ -129,13 +162,13 @@
|
||||
<span v-for="(tag, ti) in job.tags" :key="ti" class="jobs-page__job-tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="dflex-end mt10">
|
||||
<div class="jobs-page__job-action-left">
|
||||
<div v-if="isRecommendTab" class="jobs-page__job-action-left">
|
||||
<button class="jobs-page__action-icon-btn" aria-label="不感兴趣" @click.stop="openDislikeDialog(job)">
|
||||
<svg viewBox="0 0 16 16" fill="none" class="jobs-page__action-svg">
|
||||
<path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="jobs-page__action-icon-btn" :class="{ 'jobs-page__action-icon-btn--liked': job.isFavorite }" aria-label="收藏">
|
||||
<button class="jobs-page__action-icon-btn" :class="{ 'jobs-page__action-icon-btn--liked': job.isFavorite }" aria-label="收藏" @click.stop="toggleFavorite(job)">
|
||||
<svg viewBox="0 0 16 16" :fill="job.isFavorite ? 'currentColor' : 'none'" class="jobs-page__action-svg">
|
||||
<path d="M8 13.7l-1.1-1C3.6 9.8 1.5 7.9 1.5 5.7 1.5 3.9 2.9 2.5 4.7 2.5c1 0 2 .5 2.6 1.2h1.4c.6-.7 1.6-1.2 2.6-1.2 1.8 0 3.2 1.4 3.2 3.2 0 2.2-2.1 4.1-5.4 6.9L8 13.7z" stroke="currentColor" stroke-width="1"/>
|
||||
</svg>
|
||||
@@ -143,7 +176,7 @@
|
||||
</div>
|
||||
|
||||
<div class="jobs-page__job-action-right ">
|
||||
<button class="jobs-page__job-helper">
|
||||
<button class="jobs-page__job-helper ml5">
|
||||
<svg viewBox="0 0 14 14" fill="none" class="jobs-page__helper-svg">
|
||||
<circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1"/>
|
||||
<path d="M5.5 5.5a1.5 1.5 0 113 0c0 .8-.7 1-1.5 1.5V9" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
|
||||
@@ -151,14 +184,14 @@
|
||||
</svg>
|
||||
问助手
|
||||
</button>
|
||||
<button class="jobs-page__job-apply-btn" :class="{ 'jobs-page__job-apply-btn--active': job.applied }">
|
||||
自动投递
|
||||
<button @click="handleReport(job.sourceUrl)" class="jobs-page__job-apply-btn" :class="{ 'jobs-page__job-apply-btn--active': job.applied }">
|
||||
去投递
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 弹出菜单 -->
|
||||
<div v-if="job.showMenu" class="jobs-page__job-popup" @click.stop>
|
||||
<div v-if="isRecommendTab && job.showMenu" class="jobs-page__job-popup" @click.stop>
|
||||
<div
|
||||
class="jobs-page__job-popup-item"
|
||||
v-for="action in popupActions"
|
||||
@@ -167,10 +200,12 @@
|
||||
>{{ action }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<!-- 右侧:匹配度 -->
|
||||
<div class="jobs-page__job-match" :class="matchClass(job.matchScore)">
|
||||
<div class="jobs-page__job-match" :class="isAuthenticated ? matchClass(job.matchScore) : 'jobs-page__job-match--locked'">
|
||||
<template v-if="isAuthenticated">
|
||||
<div class="jobs-page__match-ring">
|
||||
<svg viewBox="0 0 80 80" class="jobs-page__ring-svg">
|
||||
<circle cx="40" cy="40" r="34" stroke-width="5" stroke="#E8E8E8" fill="none" opacity="0.3"/>
|
||||
@@ -186,6 +221,14 @@
|
||||
</div>
|
||||
<div class="jobs-page__match-label">匹配值</div>
|
||||
<div class="jobs-page__match-level">{{ matchLevelText(job.matchScore) }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg class="jobs-page__lock-icon" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 11V7a4 4 0 118 0v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="jobs-page__match-lock-text">登录查看匹配值</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,7 +240,7 @@
|
||||
<AiChat />
|
||||
|
||||
<!-- 职位不感兴趣反馈弹窗 -->
|
||||
<JobDislikeDialog ref="dislikeDialogRef" v-model="showDislikeDialog" :job-id="dislikeJobId" />
|
||||
<JobDislikeDialog ref="dislikeDialogRef" v-model="showDislikeDialog" :job-id="dislikeJobId" @disliked="removeDislikedJob" />
|
||||
|
||||
<!-- 职位问题反馈弹窗 -->
|
||||
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="feedbackJobId" />
|
||||
@@ -216,8 +259,8 @@ import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
|
||||
import IndustrySelector from '@/components/tools/IndustrySelector.vue'
|
||||
import JobCategorySelector from '@/components/tools/JobCategorySelector.vue'
|
||||
import RegionSelector from '@/components/tools/RegionSelector.vue'
|
||||
import { fetchJobList } from '@/api/jobs'
|
||||
import type { JobListItem, JobListParams } from '@/api/jobs'
|
||||
import { fetchJobList, fetchFavoriteList, toggleJobFavorite, removeJobFavorite, fetchFavoriteCount, fetchApplyList, fetchApplyCount, removeJobFromList } from '@/api/jobs'
|
||||
import type { JobListItem, JobListParams, FavoriteListParams, ApplyListParams, ApplyCountData } from '@/api/jobs'
|
||||
|
||||
// ==================== 路由相关 ====================
|
||||
|
||||
@@ -225,13 +268,19 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useStore()
|
||||
|
||||
/** 登录状态 — 从 store 读取 */
|
||||
const isAuthenticated = computed(() => store.state.isAuthenticated)
|
||||
|
||||
// ==================== 页面状态 ====================
|
||||
|
||||
/** 当前激活的 Tab,从 URL query 参数读取,默认"推荐" */
|
||||
const activeTab = ref((route.query.tab as string) || 'recommend')
|
||||
|
||||
/** 是否为推荐列表 Tab */
|
||||
const isRecommendTab = computed(() => activeTab.value === 'recommend')
|
||||
|
||||
/** 搜索框输入内容 */
|
||||
const searchText = ref('')
|
||||
const keyword = ref('')
|
||||
|
||||
/** 当前选中的职位卡片索引 */
|
||||
const selectedIndex = ref(0)
|
||||
@@ -248,6 +297,78 @@ const showFeedbackDialog = ref(false)
|
||||
/** 当前操作的职位 ID(用于提交问题反馈) */
|
||||
const feedbackJobId = ref<string | null>(null)
|
||||
|
||||
// ==================== 收藏统计 ====================
|
||||
|
||||
/** 收藏总数(用于 Tab 标签显示) */
|
||||
const favoriteTotal = ref(0)
|
||||
|
||||
/** 有效收藏数 */
|
||||
const favoriteValidCount = ref(0)
|
||||
|
||||
/** 失效收藏数 */
|
||||
const favoriteInvalidCount = ref(0)
|
||||
|
||||
/** 加载收藏统计 */
|
||||
async function loadFavoriteCount() {
|
||||
try {
|
||||
const res = await fetchFavoriteCount()
|
||||
if (res.code === '0' && res.data) {
|
||||
favoriteTotal.value = res.data.totalCount
|
||||
favoriteValidCount.value = res.data.validCount
|
||||
favoriteInvalidCount.value = res.data.invalidCount
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载收藏统计失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 投递统计 ====================
|
||||
|
||||
/** 投递总数(用于 Tab 标签显示) */
|
||||
const applyTotal = ref(0)
|
||||
|
||||
/** 投递统计详情 */
|
||||
const applyCountData = ref<ApplyCountData>({
|
||||
totalCount: 0,
|
||||
appliedCount: 0,
|
||||
interviewingCount: 0,
|
||||
offerCount: 0,
|
||||
rejectedCount: 0,
|
||||
closedCount: 0,
|
||||
})
|
||||
|
||||
/** 当前投递状态筛选(null=全部) */
|
||||
const applyStatusFilter = ref<number | null>(null)
|
||||
|
||||
/** 投递状态 Tab 列表 */
|
||||
const applyStatusTabs = computed(() => [
|
||||
{ status: 0, label: '已投递', count: applyCountData.value.appliedCount },
|
||||
{ status: 1, label: '面试中', count: applyCountData.value.interviewingCount },
|
||||
{ status: 2, label: '有Offer', count: applyCountData.value.offerCount },
|
||||
{ status: 3, label: '未通过', count: applyCountData.value.rejectedCount },
|
||||
{ status: 4, label: '已结束', count: applyCountData.value.closedCount },
|
||||
])
|
||||
|
||||
/** 切换投递状态筛选 */
|
||||
function switchApplyStatus(status: number) {
|
||||
applyStatusFilter.value = applyStatusFilter.value === status ? null : status
|
||||
pageNum.value = 1
|
||||
loadApplyList()
|
||||
}
|
||||
|
||||
/** 加载投递统计 */
|
||||
async function loadApplyCount() {
|
||||
try {
|
||||
const res = await fetchApplyCount()
|
||||
if (res.code === '0' && res.data) {
|
||||
applyCountData.value = res.data
|
||||
applyTotal.value = res.data.totalCount
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载投递统计失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 常量数据 ====================
|
||||
|
||||
/** 筛选条件项类型 */
|
||||
@@ -274,31 +395,48 @@ const jobTypeOptions: { label: string; value: number }[] = [
|
||||
/** 工作类型下拉菜单是否显示 */
|
||||
const showJobTypeDropdown = ref(false)
|
||||
|
||||
/** 当前选中的工作类型(integer,对应接口参数 employmentType,null 表示未选) */
|
||||
const selectedEmploymentType = ref<number | null>(null)
|
||||
/** 当前选中的工作类型 — 直接读 store.jobIntention.employmentType */
|
||||
const selectedEmploymentType = computed<number | null>(
|
||||
() => store.state.jobIntention.employmentType ?? null,
|
||||
)
|
||||
|
||||
/** 选中的行业 id 数组(integer,对应接口参数 industryIds) */
|
||||
const selectedIndustryIds = ref<number[]>([])
|
||||
/** 选中的行业 id 数组 — 直接读 store.jobIntention.industryIds */
|
||||
const selectedIndustryIds = computed<number[]>(
|
||||
() => store.state.jobIntention.industryIds || [],
|
||||
)
|
||||
|
||||
/** 选中的岗位 id 数组(integer,对应接口参数 categoryIds) */
|
||||
const selectedCategoryIds = ref<number[]>([])
|
||||
/** 选中的岗位 id 数组 — 直接读 store.jobIntention.categoryIds */
|
||||
const selectedCategoryIds = computed<number[]>(
|
||||
() => store.state.jobIntention.categoryIds || [],
|
||||
)
|
||||
|
||||
/** 选中的地区编码数组(string,对应接口参数 regionCodes) */
|
||||
const selectedRegionCodes = ref<string[]>([])
|
||||
/** 选中的地区编码数组 — 直接读 store.jobIntention.regionCodes */
|
||||
const selectedRegionCodes = computed<string[]>(
|
||||
() => store.state.jobIntention.regionCodes || [],
|
||||
)
|
||||
|
||||
/** 行业选择变更回调 */
|
||||
function onIndustryChange(ids: number[]) {
|
||||
selectedIndustryIds.value = ids
|
||||
store.dispatch('saveJobIntention', {
|
||||
...store.state.jobIntention,
|
||||
industryIds: ids,
|
||||
})
|
||||
}
|
||||
|
||||
/** 岗位选择变更回调 */
|
||||
function onCategoryChange(ids: number[]) {
|
||||
selectedCategoryIds.value = ids
|
||||
store.dispatch('saveJobIntention', {
|
||||
...store.state.jobIntention,
|
||||
categoryIds: ids,
|
||||
})
|
||||
}
|
||||
|
||||
/** 地区选择变更回调 */
|
||||
function onRegionChange(codes: string[]) {
|
||||
selectedRegionCodes.value = codes
|
||||
store.dispatch('saveJobIntention', {
|
||||
...store.state.jobIntention,
|
||||
regionCodes: codes,
|
||||
})
|
||||
}
|
||||
|
||||
/** 点击筛选条件按钮 — 仅工作类型展开下拉 */
|
||||
@@ -311,10 +449,22 @@ function handleFilterClick(filter: FilterItem) {
|
||||
/** 选中工作类型选项 */
|
||||
function selectJobType(filter: FilterItem, option: { label: string; value: number }) {
|
||||
filter.selected = option.label
|
||||
selectedEmploymentType.value = option.value
|
||||
showJobTypeDropdown.value = false
|
||||
store.dispatch('saveJobIntention', {
|
||||
...store.state.jobIntention,
|
||||
employmentType: option.value,
|
||||
})
|
||||
}
|
||||
|
||||
/** 监听 store 中 employmentType 变化,同步工作类型筛选按钮的显示文字 */
|
||||
watch(selectedEmploymentType, (val) => {
|
||||
const jobTypeFilter = filters.value.find(f => f.key === 'jobType')
|
||||
if (jobTypeFilter && val !== null) {
|
||||
const matched = jobTypeOptions.find(o => o.value === val)
|
||||
if (matched) jobTypeFilter.selected = matched.label
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
/** 点击页面其他区域时关闭下拉菜单 */
|
||||
function closeDropdownOnClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
@@ -323,30 +473,66 @@ function closeDropdownOnClickOutside(e: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
/** 跳转到原链接 */
|
||||
function handleReport( url:string ) {
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', closeDropdownOnClickOutside)
|
||||
|
||||
// 先判断是否有缓存(从详情页返回),在任何异步操作之前取出
|
||||
const cache = store.state.jobListCache
|
||||
const hasCache = !!(cache && cache.list.length > 0)
|
||||
|
||||
if (hasCache) {
|
||||
// 立即恢复缓存数据,隐藏列表避免闪烁
|
||||
restoring.value = true
|
||||
jobList.value = cache!.list
|
||||
pageNum.value = cache!.pageNum
|
||||
total.value = cache!.total
|
||||
savedScrollTop = cache!.scrollTop
|
||||
// 清除缓存,避免下次非详情页返回时误用
|
||||
store.commit('SET_JOB_LIST_CACHE', null)
|
||||
}
|
||||
|
||||
// 进入 Jobs 页时加载公共工具数据(行业分类等)
|
||||
store.dispatch('loadCommonData')
|
||||
|
||||
// 尝试从缓存恢复列表数据(从详情页返回时不重新请求)
|
||||
const cache = store.state.jobListCache
|
||||
if (cache && cache.list.length > 0) {
|
||||
jobList.value = cache.list
|
||||
pageNum.value = cache.pageNum
|
||||
total.value = cache.total
|
||||
savedScrollTop = cache.scrollTop
|
||||
// 清除缓存,避免下次非详情页返回时误用
|
||||
store.commit('SET_JOB_LIST_CACHE', null)
|
||||
// 等 DOM 渲染完成后恢复滚动位置
|
||||
// 仅登录状态下才加载需要鉴权的数据
|
||||
if (isAuthenticated.value) {
|
||||
// 加载收藏统计(用于 Tab 标签显示)
|
||||
loadFavoriteCount()
|
||||
|
||||
// 加载投递统计(用于 Tab 标签显示)
|
||||
loadApplyCount()
|
||||
|
||||
// 加载求职意向数据,computed 会自动反映到筛选条件
|
||||
await store.dispatch('loadJobIntention')
|
||||
}
|
||||
|
||||
if (hasCache) {
|
||||
// 从详情页返回,恢复滚动位置后再显示列表
|
||||
nextTick(() => {
|
||||
if (jobListRef.value) {
|
||||
jobListRef.value.scrollTop = savedScrollTop
|
||||
}
|
||||
restoring.value = false
|
||||
})
|
||||
} else {
|
||||
// 没有缓存,正常加载
|
||||
loadJobList()
|
||||
loadCurrentTab()
|
||||
}
|
||||
|
||||
// 初始化完成,允许 watcher 正常工作
|
||||
// 使用双重 nextTick 确保 loadJobIntention 引起的所有响应式更新都已 flush
|
||||
nextTick(() => {
|
||||
nextTick(() => {
|
||||
initializing = false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -394,6 +580,12 @@ const jobListRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 记录离开页面前的滚动位置 */
|
||||
let savedScrollTop = 0
|
||||
|
||||
/** 初始化标志 — onMounted 期间为 true,防止 watcher 误触发 reloadFirstPage */
|
||||
let initializing = true
|
||||
|
||||
/** 恢复缓存中 — 为 true 时列表容器 visibility:hidden,避免滚动位置闪烁 */
|
||||
const restoring = ref(false)
|
||||
// ==================== 加载岗位列表 ====================
|
||||
|
||||
/** 组装请求参数 */
|
||||
@@ -418,6 +610,10 @@ function buildParams(): JobListParams {
|
||||
if (selectedEmploymentType.value !== null) {
|
||||
params.employmentType = selectedEmploymentType.value
|
||||
}
|
||||
// 搜索关键词
|
||||
if (keyword.value.trim()) {
|
||||
params.keyword = keyword.value.trim()
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -474,7 +670,13 @@ function onListScroll() {
|
||||
// 距离底部小于 100px 时触发加载
|
||||
const threshold = 100
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < threshold) {
|
||||
if (isRecommendTab.value) {
|
||||
loadNextPage()
|
||||
} else if (activeTab.value === 'collected') {
|
||||
loadFavoriteNextPage()
|
||||
} else if (activeTab.value === 'applied') {
|
||||
loadApplyNextPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,15 +685,173 @@ function reloadFirstPage() {
|
||||
pageNum.value = 1
|
||||
// 筛选条件变化时清除缓存
|
||||
store.commit('SET_JOB_LIST_CACHE', null)
|
||||
loadCurrentTab()
|
||||
}
|
||||
|
||||
/** 根据当前 Tab 加载对应列表 */
|
||||
function loadCurrentTab() {
|
||||
if (isRecommendTab.value) {
|
||||
loadJobList()
|
||||
} else if (activeTab.value === 'collected') {
|
||||
loadFavoriteList()
|
||||
} else if (activeTab.value === 'applied') {
|
||||
loadApplyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载收藏列表 */
|
||||
async function loadFavoriteList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: FavoriteListParams = {
|
||||
pageNum: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
valid: true,
|
||||
}
|
||||
const res = await fetchFavoriteList(params)
|
||||
if (res.code === '0' && res.data) {
|
||||
jobList.value = res.data.list.map((item) => ({
|
||||
...item,
|
||||
applied: false,
|
||||
showMenu: false,
|
||||
}))
|
||||
total.value = Number(res.data.total)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载收藏列表失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载收藏列表下一页 */
|
||||
async function loadFavoriteNextPage() {
|
||||
if (loadingMore.value || noMore.value) return
|
||||
loadingMore.value = true
|
||||
pageNum.value++
|
||||
try {
|
||||
const params: FavoriteListParams = {
|
||||
pageNum: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
valid: true,
|
||||
}
|
||||
const res = await fetchFavoriteList(params)
|
||||
if (res.code === '0' && res.data) {
|
||||
const newItems = res.data.list.map((item) => ({
|
||||
...item,
|
||||
applied: false,
|
||||
showMenu: false,
|
||||
}))
|
||||
jobList.value.push(...newItems)
|
||||
total.value = Number(res.data.total)
|
||||
}
|
||||
} catch (e) {
|
||||
pageNum.value--
|
||||
console.error('加载收藏列表下一页失败', e)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 投递列表加载 ====================
|
||||
|
||||
/** 加载投递列表 */
|
||||
async function loadApplyList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: ApplyListParams = {
|
||||
pageNum: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
}
|
||||
if (applyStatusFilter.value !== null) {
|
||||
params.status = applyStatusFilter.value
|
||||
}
|
||||
const res = await fetchApplyList(params)
|
||||
if (res.code === '0' && res.data) {
|
||||
jobList.value = res.data.list.map((item) => ({
|
||||
...item,
|
||||
applied: true,
|
||||
showMenu: false,
|
||||
}))
|
||||
total.value = Number(res.data.total)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载投递列表失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载投递列表下一页 */
|
||||
async function loadApplyNextPage() {
|
||||
if (loadingMore.value || noMore.value) return
|
||||
loadingMore.value = true
|
||||
pageNum.value++
|
||||
try {
|
||||
const params: ApplyListParams = {
|
||||
pageNum: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
}
|
||||
if (applyStatusFilter.value !== null) {
|
||||
params.status = applyStatusFilter.value
|
||||
}
|
||||
const res = await fetchApplyList(params)
|
||||
if (res.code === '0' && res.data) {
|
||||
const newItems = res.data.list.map((item) => ({
|
||||
...item,
|
||||
applied: true,
|
||||
showMenu: false,
|
||||
}))
|
||||
jobList.value.push(...newItems)
|
||||
total.value = Number(res.data.total)
|
||||
}
|
||||
} catch (e) {
|
||||
pageNum.value--
|
||||
console.error('加载投递列表下一页失败', e)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听筛选条件变化,自动重新加载
|
||||
watch(
|
||||
[selectedRegionCodes, selectedCategoryIds, selectedIndustryIds, selectedEmploymentType],
|
||||
() => reloadFirstPage(),
|
||||
() => {
|
||||
// 初始化阶段(loadJobIntention 导致的变化)跳过,避免清掉缓存
|
||||
if (initializing) return
|
||||
reloadFirstPage()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 监听 Tab 切换,重置列表状态并加载对应数据
|
||||
watch(activeTab, (newTab, oldTab) => {
|
||||
if (initializing) return
|
||||
|
||||
// 收藏和投递 Tab 需要登录
|
||||
if ((newTab === 'collected' || newTab === 'applied') && !isAuthenticated.value) {
|
||||
// 回退到之前的 Tab,弹登录框
|
||||
nextTick(() => { activeTab.value = oldTab })
|
||||
store.dispatch('openLogin')
|
||||
return
|
||||
}
|
||||
|
||||
jobList.value = []
|
||||
keyword.value = ''
|
||||
pageNum.value = 1
|
||||
total.value = 0
|
||||
loadCurrentTab()
|
||||
// 切换到收藏 Tab 时刷新统计
|
||||
if (newTab === 'collected') {
|
||||
loadFavoriteCount()
|
||||
}
|
||||
// 切换到投递 Tab 时刷新统计并重置筛选
|
||||
if (newTab === 'applied') {
|
||||
applyStatusFilter.value = null
|
||||
loadApplyCount()
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
/** 切换职位卡片的弹出菜单(同时关闭其他已打开的菜单) */
|
||||
@@ -501,14 +861,46 @@ function toggleMenu(index: number) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 处理弹出菜单操作项点击 — 问题反馈打开反馈弹窗,其他直接关闭菜单 */
|
||||
/** 处理弹出菜单操作项点击 */
|
||||
function handlePopupAction(action: string, job: JobItem) {
|
||||
job.showMenu = false
|
||||
if (action === '问题反馈') {
|
||||
if (action === '从列表中移除') {
|
||||
handleRemoveFromList(job)
|
||||
} else if (action === '复制链接') {
|
||||
handleCopyLink(job)
|
||||
} else if (action === '问题反馈') {
|
||||
openFeedbackDialog(job)
|
||||
}
|
||||
}
|
||||
|
||||
/** 复制职位原链接到剪贴板 */
|
||||
async function handleCopyLink(job: JobItem) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(job.sourceUrl)
|
||||
ElMessage.success('复制链接成功')
|
||||
} catch {
|
||||
ElMessage.error('复制链接失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 从推荐列表中移除职位(前端移除 + 预留接口调用) */
|
||||
async function handleRemoveFromList(job: JobItem) {
|
||||
try {
|
||||
// TODO: 接口对接后此处会真正调用后端,当前为模拟成功
|
||||
const res = await removeJobFromList(job.id)
|
||||
if (res.code === '0') {
|
||||
const index = jobList.value.findIndex(j => j.id === job.id)
|
||||
if (index !== -1) {
|
||||
jobList.value.splice(index, 1)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
}
|
||||
ElMessage.success('已从列表中移除')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('移除职位失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据匹配分数返回对应的 CSS 类名 */
|
||||
function matchClass(score: number) {
|
||||
return score >= 80 ? 'jobs-page__job-match--high' : 'jobs-page__job-match--low'
|
||||
@@ -521,8 +913,12 @@ function matchLevelText(score: number) {
|
||||
return '匹配度偏低'
|
||||
}
|
||||
|
||||
/** 跳转到岗位详情页 — 先缓存当前列表和滚动位置 */
|
||||
/** 跳转到岗位详情页 — 未登录弹登录框,登录后再跳转 */
|
||||
function goToDetail(job: JobItem) {
|
||||
if (!isAuthenticated.value) {
|
||||
store.dispatch('openLogin', `/jobs/${job.id}`)
|
||||
return
|
||||
}
|
||||
// 保存当前列表状态到 store,返回时恢复
|
||||
store.commit('SET_JOB_LIST_CACHE', {
|
||||
list: jobList.value,
|
||||
@@ -537,6 +933,39 @@ function goToDetail(job: JobItem) {
|
||||
const dislikeDialogRef = ref<InstanceType<typeof JobDislikeDialog> | null>(null)
|
||||
const feedbackDialogRef = ref<InstanceType<typeof JobFeedbackDialog> | null>(null)
|
||||
|
||||
/** 收藏/取消收藏岗位 */
|
||||
async function toggleFavorite(job: JobItem) {
|
||||
try {
|
||||
const res = job.isFavorite
|
||||
? await removeJobFavorite(job.id)
|
||||
: await toggleJobFavorite(job.id)
|
||||
if (res.code === '0') {
|
||||
const wasFavorite = job.isFavorite
|
||||
job.isFavorite = !job.isFavorite
|
||||
ElMessage.success(wasFavorite ? '已取消收藏' : '收藏成功')
|
||||
// 刷新收藏统计
|
||||
loadFavoriteCount()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('收藏操作失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 收藏列表中取消收藏并移除该项 */
|
||||
async function removeFavoriteFromList(job: JobItem, index: number) {
|
||||
try {
|
||||
const res = await removeJobFavorite(job.id)
|
||||
if (res.code === '0') {
|
||||
jobList.value.splice(index, 1)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
ElMessage.success('已取消收藏')
|
||||
loadFavoriteCount()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('取消收藏失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开不感兴趣弹窗 — 记录当前职位 ID 并重置表单 */
|
||||
function openDislikeDialog(job: JobItem) {
|
||||
dislikeJobId.value = job.id
|
||||
@@ -544,6 +973,16 @@ function openDislikeDialog(job: JobItem) {
|
||||
showDislikeDialog.value = true
|
||||
}
|
||||
|
||||
/** 不感兴趣提交成功后,从列表中移除该职位 */
|
||||
function removeDislikedJob() {
|
||||
if (!dislikeJobId.value) return
|
||||
const index = jobList.value.findIndex(j => j.id === dislikeJobId.value)
|
||||
if (index !== -1) {
|
||||
jobList.value.splice(index, 1)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开问题反馈弹窗 — 记录当前职位 ID 并重置表单 */
|
||||
function openFeedbackDialog(job: JobItem) {
|
||||
feedbackJobId.value = job.id
|
||||
|
||||
@@ -96,6 +96,7 @@ async function loadProfile() {
|
||||
profile.value.wechat = d.wechatNumber || ''
|
||||
profile.value.skills = d.skills || []
|
||||
profile.value.certificates = d.certificates || []
|
||||
profile.value.portfolioUrl = d.portfolioUrl || ''
|
||||
}
|
||||
} catch {
|
||||
console.error('[Profile] 加载个人资料失败')
|
||||
@@ -239,6 +240,8 @@ const profile = ref({
|
||||
skills: [] as string[],
|
||||
/** 证书标签列表 — 接口字段 certificates */
|
||||
certificates: [] as string[],
|
||||
/** 作品集链接 — 接口字段 portfolioUrl */
|
||||
portfolioUrl: '',
|
||||
/** 教育经历 — 对应数据库 bg_user_profile_education */
|
||||
education: [] as Array<{
|
||||
school: string
|
||||
@@ -349,6 +352,10 @@ function handleEdit(section: string) {
|
||||
description: comp.description.map(d => ({ ...d })),
|
||||
})),
|
||||
}
|
||||
} else if (section === 'portfolio') {
|
||||
editInitialData.value = {
|
||||
portfolioUrl: profile.value.portfolioUrl,
|
||||
}
|
||||
} else if (section === 'skills') {
|
||||
editInitialData.value = {
|
||||
skills: [...profile.value.skills],
|
||||
@@ -524,6 +531,28 @@ async function handleSaveEdit(data: Record<string, any>) {
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
} else if (editModule.value === 'portfolio') {
|
||||
// ---- 作品集:调用主表接口保存,需要传递完整的个人信息数据 ----
|
||||
try {
|
||||
saving.value = true
|
||||
await saveProfile({
|
||||
name: profile.value.name,
|
||||
email: profile.value.email,
|
||||
mobileNumber: profile.value.phone,
|
||||
idCard: profile.value.idNumber,
|
||||
regionCode: profile.value.regionCode,
|
||||
wechatNumber: profile.value.wechat,
|
||||
skills: profile.value.skills,
|
||||
certificates: profile.value.certificates,
|
||||
portfolioUrl: data.portfolioUrl,
|
||||
})
|
||||
profile.value.portfolioUrl = data.portfolioUrl
|
||||
ElMessage.success('作品集保存成功')
|
||||
} catch {
|
||||
ElMessage.error('作品集保存失败,请重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
} else if (editModule.value === 'skills') {
|
||||
// ---- 技能:调用主表接口保存,需要传递完整的个人信息数据 ----
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user