个人资料和岗位列表联调,简历优化部分页面

This commit is contained in:
xuxin
2026-04-01 11:41:51 +08:00
parent 0468339d23
commit 821a950df2
42 changed files with 5935 additions and 748 deletions
+25
View File
@@ -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的常量变量和方法都要加中文注释
+3
View File
@@ -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
View File
@@ -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>
+9
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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{
+7 -3
View File
@@ -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 {
+2
View File
@@ -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
+22 -4
View File
@@ -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;
+83 -18
View File
@@ -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 {
+10 -2
View File
@@ -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);
+31 -18
View File
@@ -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,
})
ElMessage.success('反馈已提交,感谢您的反馈')
visible.value = false
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 = ''
}
+98 -175
View File
@@ -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"
/>
</el-select>
<JobCategorySelector
:categoryIds="selectedCategoryIds"
:maxSelect="3"
:level="3"
:allowParentSelect="false"
:triggerStyle="selectorTriggerStyle"
:displayStyle="selectorDisplayStyle"
@update:categoryIds="onCategoryChange"
/>
</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"
/>
</el-select>
<IndustrySelector
:industryIds="selectedIndustryIds"
:maxSelect="3"
:level="2"
:allowParentSelect="false"
:triggerStyle="selectorTriggerStyle"
:displayStyle="selectorDisplayStyle"
@update:industryIds="onIndustryChange"
/>
</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"
/>
</el-select>
<RegionSelector
:regionCodes="selectedRegionCodes"
:level="2"
:maxSelect="3"
:triggerStyle="selectorTriggerStyle"
:displayStyle="selectorDisplayStyle"
@update:regionCodes="onRegionChange"
/>
</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)
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)
}
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],
})
visible.value = false
}
/**
+12 -19
View File
@@ -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}` },
])
// ==================== ====================
+602
View File
@@ -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 }}&nbsp;&nbsp;独角兽</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)
}
}
/** 当前预览右侧tabai-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: AIAI
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: HTMLPDF/Word
console.log(`[下载简历] 格式: ${type}`)
}
/** 立即去投递 */
function handleSubmit() {
handleClose()
emit('submit')
}
</script>
+238
View File
@@ -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
}>()
// ==================== DOMHTML ====================
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>
+22
View File
@@ -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') {
+22
View File
@@ -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
+72 -11
View File
@@ -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('此操作将永久删除你的账号及所有数据,是否继续?', '注销账号', {
+30 -9
View File
@@ -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) {
showSettingsDialog.value = true
} else {
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)
} else {
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)
}
}
+105 -39
View File
@@ -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) {
for (const child of parent.children) {
if (child.name.toLowerCase().includes(keyword)) {
results.push({ parentName: parent.name, child })
// 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({ 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) {
activeParentId.value = id
// 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 到内部选中状态 */
/** 同步外部传入的 industryIds 到内部选中状态(支持一二级) */
function syncFromProps() {
const ids = props.industryIds
if (!ids || !industries.value.length) return
const nodeMap = new Map<string, SelectedNode>()
for (const p of industries.value) {
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 => nodeMap.get(String(id)))
.filter(Boolean) as SelectedNode[]
}
watch(
() => props.industryIds,
(ids) => {
if (!ids || !industries.value.length) return
const allChildren: IndustryChild[] = []
for (const p of industries.value) {
allChildren.push(...p.children)
}
selectedItems.value = ids
.map(id => allChildren.find(c => c.id === String(id)))
.filter(Boolean) as IndustryChild[]
},
() => syncFromProps(),
{ immediate: true }
)
/** 树数据加载完成后重新同步选中项 */
watch(industries, () => syncFromProps())
</script>
+155 -48
View File
@@ -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) {
for (const l2 of l1.children) {
for (const l3 of l2.children) {
if (l3.name.toLowerCase().includes(keyword)) {
results.push({ level1Name: l1.name, level2Name: l2.name, leaf: l3 })
// 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({ 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) {
activeLevel1Id.value = id
activeLevel2Id.value = ''
// 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) {
activeLevel2Id.value = id
// 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) => {
if (!ids || !categories.value.length) return
//
const allLeaves: JobCategoryLeaf[] = []
for (const l1 of categories.value) {
for (const l2 of l1.children) {
allLeaves.push(...l2.children)
/** 同步外部传入的 categoryIds 到内部选中状态(支持一二三级) */
function syncFromProps() {
const ids = props.categoryIds
if (!ids || !categories.value.length) return
// 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) {
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[]
},
}
selectedItems.value = ids
.map(id => nodeMap.get(String(id)))
.filter(Boolean) as SelectedNode[]
}
watch(
() => props.categoryIds,
() => syncFromProps(),
{ immediate: true }
)
/** 树数据加载完成后重新同步选中项 */
watch(categories, () => syncFromProps())
</script>
+30 -22
View File
@@ -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,31 +358,37 @@ onBeforeUnmount(() => {
// ==================== ====================
/** 同步外部传入的 regionCodes 到内部选中状态 */
watch(
() => props.regionCodes,
(codes) => {
if (!codes || !regions.value.length) return
// level
const allNodes: SelectedRegion[] = []
for (const province of regions.value) {
if (props.level === 2) {
for (const city of province.children) {
allNodes.push({ code: city.code, name: city.name })
}
} else {
for (const city of province.children) {
if (city.children) {
for (const district of city.children) {
allNodes.push({ code: district.code, name: district.name })
}
function syncFromProps() {
const codes = props.regionCodes
if (!codes || !regions.value.length) return
// level
const allNodes: SelectedRegion[] = []
for (const province of regions.value) {
if (props.level === 2) {
for (const city of province.children) {
allNodes.push({ code: city.code, name: city.name })
}
} else {
for (const city of province.children) {
if (city.children) {
for (const district of city.children) {
allNodes.push({ code: district.code, name: district.name })
}
}
}
}
selectedItems.value = codes
.map(code => allNodes.find(n => n.code === code))
.filter(Boolean) as SelectedRegion[]
},
}
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
View File
@@ -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
View File
@@ -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: {},
})
+20
View File
@@ -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)
}
+25
View File
@@ -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)
}
+4
View File
@@ -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
View File
@@ -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>
+270 -207
View File
@@ -4,68 +4,69 @@
<div class="job-detail__content">
<!-- 页面标题 + Tab 切换 -->
<JobPageHeader :activeTab="''" />
<!-- 顶部操作栏 -->
<div class="job-detail__toolbar">
<button class="job-detail__close-btn" @click="goBack" aria-label="关闭">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__close-icon">
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<div class="job-detail__toolbar-right">
<button class="job-detail__tool-btn" aria-label="编辑" @click="openDislikeDialog">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__tool-icon">
<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="收藏">
<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>
</button>
<button class="job-detail__apply-btn" @click="handleApply">去投递</button>
</div>
</div>
<!-- 内容区域可滚动 -->
<div class="job-detail__body">
<!-- 导航 Tab岗位详情 / 公司概况 -->
<div class="job-detail__nav-tabs">
<div
class="job-detail__nav-tab"
:class="{ 'job-detail__nav-tab--active': activeSection === 'job' }"
@click="activeSection = 'job'"
>岗位详情</div>
<div
class="job-detail__nav-tab"
:class="{ 'job-detail__nav-tab--active': activeSection === 'company' }"
@click="scrollToCompany"
>公司概况</div>
<div class="job-detail__nav-tab-right">
<span class="job-detail__link-btn" @click="handleFeedback">问题反馈</span>
<span class="job-detail__link-btn" @click="handleReport">原链接</span>
<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="关闭">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__close-icon">
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<div class="job-detail__toolbar-right">
<button class="job-detail__tool-btn" aria-label="编辑" @click="openDislikeDialog">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__tool-icon">
<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="收藏" @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>
</button>
<button class="job-detail__apply-btn" @click="handleApply">去投递</button>
</div>
</div>
</div>
<!-- 岗位详情内容 -->
<template v-if="activeSection === 'job'">
<!-- 公司 & 职位头部 -->
<div class="job-detail__card">
<div class="job-detail__card-top">
<div class="job-detail__card-left">
<div class="job-detail__company-row">
<div class="job-detail__company-icon">
<img v-if="job.companyLogoUrl" :src="job.companyLogoUrl" :alt="job.company" class="job-detail__company-logo-img" />
<svg v-else viewBox="0 0 24 24" fill="none" class="job-detail__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>
<span class="job-detail__company-name">{{ job.company }}</span>
</div>
<h3 class="job-detail__job-title">{{ job.title }}</h3>
<div class="job-detail__job-meta">
<!-- 内容区域可滚动 -->
<div class="job-detail__body">
<!-- 导航 Tab岗位详情 / 公司概况 -->
<div class="job-detail__nav-tabs">
<div
class="job-detail__nav-tab"
:class="{ 'job-detail__nav-tab--active': activeSection === 'job' }"
@click="activeSection = 'job'"
>岗位详情</div>
<div
class="job-detail__nav-tab"
:class="{ 'job-detail__nav-tab--active': activeSection === 'company' }"
@click="scrollToCompany"
>公司概况</div>
<div class="job-detail__nav-tab-right">
<span class="job-detail__link-btn" @click="handleFeedback">问题反馈</span>
<span class="job-detail__link-btn" @click="handleReport">原链接</span>
</div>
</div>
<!-- 岗位详情内容 -->
<template v-if="activeSection === 'job'">
<!-- 公司 & 职位头部 -->
<div class="job-detail__card">
<div class="job-detail__card-top">
<div class="job-detail__card-left">
<div class="job-detail__company-row">
<div class="job-detail__company-icon">
<img v-if="job.companyLogoUrl" :src="job.companyLogoUrl" :alt="job.company" class="job-detail__company-logo-img" />
<svg v-else viewBox="0 0 24 24" fill="none" class="job-detail__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>
<span class="job-detail__company-name">{{ job.company }}</span>
</div>
<h3 class="job-detail__job-title">{{ job.title }}</h3>
<div class="job-detail__job-meta">
<span class="job-detail__meta-item">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__meta-icon">
<circle cx="8" cy="6.5" r="2.5" stroke="currentColor" stroke-width="1.2"/>
@@ -73,193 +74,197 @@
</svg>
{{ job.location }}
</span>
<span class="job-detail__meta-item">
<span class="job-detail__meta-item">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__meta-icon">
<rect x="2" y="3" width="12" height="11" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
<path d="M2 6.5h12" stroke="currentColor" stroke-width="1.2"/>
</svg>
{{ job.experience }}
</span>
<span class="job-detail__meta-item">
<span class="job-detail__meta-item">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__meta-icon">
<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="1.2"/>
<path d="M5 8h6M8 5v6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
{{ job.type }}
</span>
<span v-if="job.education" class="job-detail__meta-item">
<span v-if="job.education" class="job-detail__meta-item">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__meta-icon">
<path d="M8 2L1 6l7 4 7-4-7-4z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M3 7.5v4l5 3 5-3v-4" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
</svg>
{{ job.education }}
</span>
<span v-if="job.salary" class="job-detail__meta-item">
<span v-if="job.salary" class="job-detail__meta-item">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__meta-icon">
<path d="M8 1v14M4 4h8M3 8h10M5 12h6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
{{ job.salary }}
</span>
</div>
</div>
<!-- 匹配度环 -->
<div class="job-detail__match-area">
<div class="job-detail__match-ring">
<svg viewBox="0 0 80 80" class="job-detail__ring-svg">
<circle cx="40" cy="40" r="34" stroke-width="5" stroke="#E8E8E8" fill="none" opacity="0.3"/>
<circle cx="40" cy="40" r="34" stroke-width="5" fill="none"
stroke="#4FC2C9"
stroke-linecap="round"
:stroke-dasharray="2 * Math.PI * 34"
:stroke-dashoffset="2 * Math.PI * 34 * (1 - job.matchScore / 100)"
transform="rotate(-90 40 40)"
/>
</svg>
<div class="job-detail__match-score">{{ job.matchScore }}%</div>
</div>
<div class="job-detail__match-label">岗位匹配值</div>
<div class="job-detail__match-details">
<div class="job-detail__match-item" v-for="m in matchItems" :key="m.label">
<div class="job-detail__match-mini-ring">
<svg viewBox="0 0 40 40" class="job-detail__mini-ring-svg">
<circle cx="20" cy="20" r="16" stroke-width="3" stroke="#E8E8E8" fill="none" opacity="0.3"/>
<circle cx="20" cy="20" r="16" stroke-width="3" fill="none"
stroke="#BFBFBF"
stroke-linecap="round"
:stroke-dasharray="2 * Math.PI * 16"
:stroke-dashoffset="2 * Math.PI * 16 * (1 - m.score / 100)"
transform="rotate(-90 20 20)"
</div>
</div>
<!-- 匹配度环 -->
<div class="job-detail__match-area">
<div class="job-detail__match-ring">
<svg viewBox="0 0 80 80" class="job-detail__ring-svg">
<circle cx="40" cy="40" r="34" stroke-width="5" stroke="#E8E8E8" fill="none" opacity="0.3"/>
<circle cx="40" cy="40" r="34" stroke-width="5" fill="none"
stroke="#4FC2C9"
stroke-linecap="round"
:stroke-dasharray="2 * Math.PI * 34"
:stroke-dashoffset="2 * Math.PI * 34 * (1 - job.matchScore / 100)"
transform="rotate(-90 40 40)"
/>
</svg>
<span class="job-detail__mini-score">{{ m.score }}%</span>
<div class="job-detail__match-score">{{ job.matchScore }}%</div>
</div>
<div class="job-detail__match-label">岗位匹配值</div>
<div class="job-detail__match-details">
<div class="job-detail__match-item" v-for="m in matchItems" :key="m.label">
<div class="job-detail__match-mini-ring">
<svg viewBox="0 0 40 40" class="job-detail__mini-ring-svg">
<circle cx="20" cy="20" r="16" stroke-width="3" stroke="#E8E8E8" fill="none" opacity="0.3"/>
<circle cx="20" cy="20" r="16" stroke-width="3" fill="none"
stroke="#BFBFBF"
stroke-linecap="round"
:stroke-dasharray="2 * Math.PI * 16"
:stroke-dashoffset="2 * Math.PI * 16 * (1 - m.score / 100)"
transform="rotate(-90 20 20)"
/>
</svg>
<span class="job-detail__mini-score">{{ m.score }}%</span>
</div>
<span class="job-detail__match-item-label">{{ m.label }}</span>
</div>
</div>
<span class="job-detail__match-item-label">{{ m.label }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 优化简历提示 -->
<div class="job-detail__optimize-bar">
<span>优化简历大幅提升面试成功率</span>
<button class="job-detail__optimize-btn" @click="handleGenerateResume">生成岗位专属简历</button>
</div>
<!-- 优化简历提示 -->
<div class="job-detail__optimize-bar">
<span>优化简历大幅提升面试成功率</span>
<button class="job-detail__optimize-btn" @click="handleGenerateResume">生成岗位专属简历</button>
</div>
<!-- 岗位描述 -->
<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>
</div>
</div>
<!-- 岗位描述 -->
<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.companyInfo.tags" :key="tag" class="job-detail__tag">{{ tag }}</span>
</div>
</div>
<!-- 岗位职责 -->
<div class="job-detail__card">
<h3 class="job-detail__section-title">岗位职责</h3>
<ol class="job-detail__list">
<li v-for="(item, i) in job.responsibilities" :key="i">{{ item }}</li>
</ol>
</div>
<!-- 岗位职责 -->
<div class="job-detail__card">
<h3 class="job-detail__section-title">岗位职责</h3>
<ol class="job-detail__list">
<li v-for="(item, i) in job.responsibilities" :key="i">{{ item }}</li>
</ol>
</div>
<!-- 任职要求 -->
<div class="job-detail__card">
<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">
<!-- 任职要求 -->
<div class="job-detail__card">
<div class="job-detail__section-header">
<h3 class="job-detail__section-title">任职要求</h3>
<span class="job-detail__skill-hint">
<!-- 深色为你拥有的技能-->
</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"
v-for="skill in job.requiredSkills"
:key="skill.name"
class="job-detail__skill-tag cursor-po"
:class="{ 'job-detail__skill-tag--matched': skill.matched }"
>
{{ skill.name }}
<svg v-if="skill.matched" viewBox="0 0 12 12" fill="none" class="job-detail__skill-close">
<path d="M9 3L3 9M3 3l6 6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</span>
</div>
<ol class="job-detail__list">
<li v-for="(item, i) in job.requirements" :key="i">{{ item }}</li>
</ol>
</div>
</div>
<ol class="job-detail__list">
<li v-for="(item, i) in job.requirements" :key="i">{{ item }}</li>
</ol>
</div>
<!-- 加分项 -->
<div class="job-detail__card">
<h3 class="job-detail__section-title">加分项</h3>
<p class="job-detail__desc-text">{{ job.bonus }}</p>
</div>
<!-- 加分项 -->
<div class="job-detail__card">
<h3 class="job-detail__section-title">加分项</h3>
<p class="job-detail__desc-text">{{ job.bonus }}</p>
</div>
<!-- 公司概况 -->
<div ref="companySectionRef" class="job-detail__card">
<h3 class="job-detail__section-title">公司概况</h3>
<div class="job-detail__company-info">
<div class="job-detail__company-info-left">
<div class="job-detail__company-header">
<div class="job-detail__company-logo">
<img v-if="job.companyLogoUrl" :src="job.companyLogoUrl" :alt="job.company" class="job-detail__company-logo-img" />
<svg v-else viewBox="0 0 24 24" fill="none" class="job-detail__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"/>
</svg>
<!-- 公司概况 -->
<div ref="companySectionRef" class="job-detail__card">
<h3 class="job-detail__section-title">公司概况</h3>
<div class="job-detail__company-info">
<div class="job-detail__company-info-left">
<div class="job-detail__company-header">
<div class="job-detail__company-logo">
<img v-if="job.companyLogoUrl" :src="job.companyLogoUrl" :alt="job.company" class="job-detail__company-logo-img" />
<svg v-else viewBox="0 0 24 24" fill="none" class="job-detail__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"/>
</svg>
</div>
<span class="job-detail__company-info-name">{{ job.company }}</span>
</div>
<p class="job-detail__company-desc">{{ job.companyInfo.description }}</p>
</div>
<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>
</div>
<div class="job-detail__company-meta-item">
<span class="job-detail__meta-label">公司地址</span>
<span>{{ job.companyInfo.address }}</span>
</div>
<div class="job-detail__company-meta-item">
<span class="job-detail__meta-label">企业规模</span>
<span>{{ job.companyInfo.size }}</span>
</div>
<div class="job-detail__company-meta-item">
<span class="job-detail__meta-label">官网</span>
<a :href="job.companyInfo.website" target="_blank" class="job-detail__company-link">{{ job.companyInfo.website }}</a>
</div>
</div>
<span class="job-detail__company-info-name">{{ job.company }}</span>
</div>
<p class="job-detail__company-desc">{{ job.companyInfo.description }}</p>
</div>
<div class="job-detail__company-info-right">
<div class="job-detail__company-meta-item">
<span class="job-detail__meta-label">成立时间</span>
<span>{{ job.companyInfo.founded }}</span>
</div>
<div class="job-detail__company-meta-item">
<span class="job-detail__meta-label">公司地址</span>
<span>{{ job.companyInfo.address }}</span>
</div>
<div class="job-detail__company-meta-item">
<span class="job-detail__meta-label">企业规模</span>
<span>{{ job.companyInfo.size }}</span>
</div>
<div class="job-detail__company-meta-item">
<span class="job-detail__meta-label">官网</span>
<a :href="job.companyInfo.website" target="_blank" class="job-detail__company-link">{{ job.companyInfo.website }}</a>
</div>
</div>
</div>
</div>
<!-- 融资 -->
<div class="job-detail__card">
<h3 class="job-detail__section-title">融资</h3>
<p class="job-detail__desc-text">
<span class="job-detail__funding-label">当前融资阶段</span>{{ job.companyInfo.fundingStage }}
<span class="job-detail__funding-label" style="margin-left: 0.3rem;">最新估值</span>{{ job.companyInfo.valuation }}
</p>
</div>
<!-- 最新动态 -->
<div class="job-detail__card" v-if="job.companyInfo.news.length">
<h3 class="job-detail__section-title">最新动态</h3>
<div class="job-detail__news-list">
<div v-for="(news, i) in job.companyInfo.news" :key="i" class="job-detail__news-item">
<p class="job-detail__news-desc">{{ news }}</p>
<!-- 融资 -->
<div class="job-detail__card">
<h3 class="job-detail__section-title">融资</h3>
<p class="job-detail__desc-text">
<span class="job-detail__funding-label">当前融资阶段</span>{{ job.companyInfo.fundingStage }}
<span class="job-detail__funding-label" style="margin-left: 0.3rem;">最新估值</span>{{ job.companyInfo.valuation }}
</p>
</div>
</div>
</div>
</template>
<!-- 公司概况 Tab 已移除点击直接滚动到岗位详情中的公司概况部分 -->
<!-- 最新动态 -->
<div class="job-detail__card" v-if="job.companyInfo.news.length">
<h3 class="job-detail__section-title">最新动态</h3>
<div class="job-detail__news-list">
<div v-for="(news, i) in job.companyInfo.news" :key="i" class="job-detail__news-item">
<p class="job-detail__news-desc">{{ news }}</p>
</div>
</div>
</div>
</template>
<!-- 公司概况 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() {
router.push('/jobs')
// 使 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)
}
}
/** 去投递 — 跳转到来源链接 */
+568 -129
View File
@@ -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,105 +111,124 @@
<div class="jobs-page__job-main">
<!-- 左侧公司图标 + 职位信息 -->
<div class="jobs-page__job-left">
<div class="jobs-page__job-icon">
<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"/>
<path d="M3 13h18" stroke="currentColor" stroke-width="1.5"/>
<rect x="10" y="11" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1"/>
</svg>
</div>
<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)">
<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>
</div>
<div class="jobs-page__job-meta pt5">
<span>{{ job.regionName }}</span>
<span class="jobs-page__job-dot">·</span>
<span>{{ job.companyShortName || job.companyName }}</span>
<span class="jobs-page__job-dot">·</span>
<span>{{ job.categoryName }}</span>
</div>
<!-- 提示信息 -->
<div v-if="(job as any).tip" class="jobs-page__job-tip">
<svg viewBox="0 0 14 14" fill="none" class="jobs-page__tip-svg">
<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"/>
<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"/>
<path d="M3 13h18" stroke="currentColor" stroke-width="1.5"/>
<rect x="10" y="11" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1"/>
</svg>
{{ (job as any).tip }}
</div>
<div class="jobs-page__job-info">
<div class="jobs-page__job-title-row">
<span class="jobs-page__job-name">{{ job.title }}</span>
<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>
<span class="jobs-page__job-dot">·</span>
<span>{{ job.companyShortName || job.companyName }}</span>
<span class="jobs-page__job-dot">·</span>
<span>{{ job.categoryName }}</span>
</div>
<!-- 提示信息 -->
<div v-if="(job as any).tip" class="jobs-page__job-tip">
<svg viewBox="0 0 14 14" fill="none" class="jobs-page__tip-svg">
<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>
{{ (job as any).tip }}
</div>
<!-- 底部操作栏 -->
<div class="jobs-page__job-actions pt20">
<div class="dflex fgrow2 aliite-c flex-warp" >
<!-- 标签 -->
<div class="jobs-page__job-tags mt10">
<span v-for="(tag, ti) in job.tags" :key="ti" class="jobs-page__job-tag">{{ tag }}</span>
</div>
</div>
<!-- 底部操作栏 -->
<div class="jobs-page__job-actions pt20">
<div class="dflex fgrow2 aliite-c flex-warp" >
<!-- 标签 -->
<div class="jobs-page__job-tags mt10">
<span v-for="(tag, ti) in job.tags" :key="ti" class="jobs-page__job-tag">{{ tag }}</span>
</div>
<div class="dflex-end mt10">
<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="收藏" @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>
</button>
</div>
<div class="dflex-end mt10">
<div 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="收藏">
<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>
</button>
</div>
<div class="jobs-page__job-action-right ">
<button class="jobs-page__job-helper">
<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"/>
<circle cx="7" cy="11" r="0.5" fill="currentColor"/>
</svg>
问助手
</button>
<button class="jobs-page__job-apply-btn" :class="{ 'jobs-page__job-apply-btn--active': job.applied }">
自动投递
</button>
</div>
<div class="jobs-page__job-action-right ">
<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"/>
<circle cx="7" cy="11" r="0.5" fill="currentColor"/>
</svg>
问助手
</button>
<button @click="handleReport(job.sourceUrl)" class="jobs-page__job-apply-btn" :class="{ 'jobs-page__job-apply-btn--active': job.applied }">
投递
</button>
</div>
</div>
<!-- 弹出菜单 -->
<div v-if="job.showMenu" class="jobs-page__job-popup" @click.stop>
<div
</div>
<!-- 弹出菜单 -->
<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"
:key="action"
@click="handlePopupAction(action, job)"
>{{ action }}</div>
</div>
>{{ action }}</div>
</div>
</div>
</div>
<!-- 右侧匹配度 -->
<div class="jobs-page__job-match" :class="matchClass(job.matchScore)">
<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"/>
<circle cx="40" cy="40" r="34" stroke-width="5" fill="none"
:stroke="job.matchScore >= 80 ? '#4FC2C9' : '#BFBFBF'"
stroke-linecap="round"
:stroke-dasharray="2 * Math.PI * 34"
:stroke-dashoffset="2 * Math.PI * 34 * (1 - job.matchScore / 100)"
transform="rotate(-90 40 40)"
/>
<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"/>
<circle cx="40" cy="40" r="34" stroke-width="5" fill="none"
:stroke="job.matchScore >= 80 ? '#4FC2C9' : '#BFBFBF'"
stroke-linecap="round"
:stroke-dasharray="2 * Math.PI * 34"
:stroke-dashoffset="2 * Math.PI * 34 * (1 - job.matchScore / 100)"
transform="rotate(-90 40 40)"
/>
</svg>
<div class="jobs-page__match-score">{{ job.matchScore }}%</div>
</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-score">{{ job.matchScore }}%</div>
</div>
<div class="jobs-page__match-label">匹配值</div>
<div class="jobs-page__match-level">{{ matchLevelText(job.matchScore) }}</div>
<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,对应接口参数 employmentTypenull 表示未选) */
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) {
loadNextPage()
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)
loadJobList()
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
+29
View File
@@ -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 {