新会员页面

This commit is contained in:
xuxin
2026-05-15 19:02:11 +08:00
parent f341408254
commit 14c7660770
31 changed files with 3496 additions and 396 deletions
+2
View File
@@ -11,10 +11,12 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AgentApplyProgress: typeof import('./src/components/AgentApplyProgress.vue')['default']
AgentChatJobList: typeof import('./src/components/AgentChatJobList.vue')['default'] AgentChatJobList: typeof import('./src/components/AgentChatJobList.vue')['default']
AgentMatchJobAdd: typeof import('./src/components/AgentMatchJobAdd.vue')['default'] AgentMatchJobAdd: typeof import('./src/components/AgentMatchJobAdd.vue')['default']
AgentSettingsPanel: typeof import('./src/components/AgentSettingsPanel.vue')['default'] AgentSettingsPanel: typeof import('./src/components/AgentSettingsPanel.vue')['default']
AgentSetupWizard: typeof import('./src/components/AgentSetupWizard.vue')['default'] AgentSetupWizard: typeof import('./src/components/AgentSetupWizard.vue')['default']
AgentTaskListDropdown: typeof import('./src/components/AgentTaskListDropdown.vue')['default']
AiChat: typeof import('./src/components/AiChat.vue')['default'] AiChat: typeof import('./src/components/AiChat.vue')['default']
AiThinkingIndicator: typeof import('./src/components/tools/AiThinkingIndicator.vue')['default'] AiThinkingIndicator: typeof import('./src/components/tools/AiThinkingIndicator.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
+24 -2
View File
@@ -131,8 +131,8 @@ export function fetchAgentRecommend(params: AgentRecommendParams = {}) {
/** 岗位投递请求参数 */ /** 岗位投递请求参数 */
export interface JobApplyParams { export interface JobApplyParams {
/** 岗位 ID */ /** 岗位 ID(支持字符串避免大整数精度丢失) */
jobId: number jobId: number | string
/** 投递状态:-1=待投递 0=已投递 1=面试中 2=有Offer 3=未通过 4=已结束,null=取消 */ /** 投递状态:-1=待投递 0=已投递 1=面试中 2=有Offer 3=未通过 4=已结束,null=取消 */
status: number | null status: number | null
} }
@@ -278,3 +278,25 @@ export interface AgentChatResponse {
export function sendAgentChat(params: AgentChatParams) { export function sendAgentChat(params: AgentChatParams) {
return aiService.post<any, { data: AiResult<AgentChatResponse> }>('/job-agent/chat', params).then(res => res.data) return aiService.post<any, { data: AiResult<AgentChatResponse> }>('/job-agent/chat', params).then(res => res.data)
} }
// ==================== 求职助手优化简历(Python 后端) ====================
/** 优化简历请求参数 */
export interface OptimizeResumeParams {
/** 简历 ID */
resumeId: string | number
/** 岗位 ID */
jobId: string | number
}
/**
* 求职助手岗位优化简历
* POST /job-agent/optimize-resume
*/
export function optimizeAgentResume(params: OptimizeResumeParams) {
return aiService.post('/job-agent/optimize-resume', params, {
headers: {
Token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjIwMzUyNTM4OTg5MTk4NzA0NjUsInV1SWQiOiI2MmQ5MDE2NTcyNzY0ZmNjODNjZTIyYjRjODA5ZmU5MiJ9.eE-Q5rio5J5kxkS-XPYdmk-1Tgvg6kj6NGoKWMFNU14',
},
}).then(res => res.data)
}
+27
View File
@@ -220,6 +220,33 @@ export function fetchApplyList(params: ApplyListParams = {}) {
}) })
} }
// ==================== 求职助手任务列表 ====================
/** 求职助手任务列表请求参数 */
export interface AgentTaskListParams {
/** 当前页码,从1开始,默认1 */
pageNum?: number
/** 每页条数,默认10 */
pageSize?: number
/** 搜索关键字 */
keyword?: string
/** tab类型 1=进行中(待投递) 2=已完成(已投递及之后状态),默认1 */
tab?: number
}
/**
* 获取求职助手任务列表
* POST /job/agent/task/list
*/
export function fetchAgentTaskList(params: AgentTaskListParams = {}) {
return request.post<any, ApiResult<JobPageData>>('/job/agent/task/list', {
pageNum: params.pageNum ?? 1,
pageSize: params.pageSize ?? 10,
tab: params.tab ?? 1,
...(params.keyword ? { keyword: params.keyword } : {}),
})
}
// ==================== 投递统计 ==================== // ==================== 投递统计 ====================
/** 投递统计结果 */ /** 投递统计结果 */
+61
View File
@@ -0,0 +1,61 @@
import request from '@/utils/request'
/** 站内信消息项 */
export interface MessageDto {
/** 消息ID */
id: number
/** 消息类型 1=系统消息 2=运营消息 3=订单消息 */
type: number
/** 消息标题 */
title: string
/** 消息内容 */
content: string
/** 关联业务类型 */
bizType: string
/** 关联业务ID */
bizId: number
/** 是否已读 */
read: boolean
/** 创建时间 */
createTime: { seconds: number; nanos: number }
}
/** 分页响应结构 */
export interface MessagePageResult {
pageNum: number
pageSize: number
total: number
list: MessageDto[]
}
/** 请求参数 */
export interface MessageListParams {
pageNum?: number
pageSize?: number
keyword?: string
type?: number | null
}
/**
* 获取站内信消息列表(分页)
* POST /message/list
*/
export function fetchMessageList(params: MessageListParams) {
return request.get<any, any>('/message/list', { params })
}
/**
* 获取未读消息数量
* GET /message/unread-count
*/
export function fetchUnreadCount() {
return request.get<any, any>('/message/unread-count')
}
/**
* 标记消息已读
* POST /message/read/{messageId}
*/
export function markMessageRead(messageId: number | string) {
return request.post<any, any>(`/message/read/${messageId}`)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 543 B

@@ -0,0 +1,317 @@
@use '../variables' as *;
// 申请进度消息组件
.agent-apply-progress {
background: $bg-middle;
border-radius: 0.12rem;
padding: 0.2rem;
// 顶部标题栏
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.16rem;
}
&__title {
font-size: 0.15rem;
font-weight: 600;
color: $text-dark;
// 可点击状态(暂停/恢复)
&--clickable {
cursor: pointer;
color: $accent;
&:hover {
opacity: 0.8;
}
}
}
&__cancel {
font-size: 0.13rem;
color: $text-middle;
cursor: pointer;
&:hover {
color: $text-dark;
}
}
// 岗位信息卡片
&__job-card {
display: flex;
align-items: center;
gap: 0.12rem;
background: $bg-white;
border-radius: 0.1rem;
padding: 0.14rem 0.16rem;
margin-bottom: 0.2rem;
}
&__job-icon {
width: 0.36rem;
height: 0.36rem;
border-radius: 0.08rem;
background: $bg-main;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: $text-middle;
svg {
width: 0.2rem;
height: 0.2rem;
}
}
&__job-info {
min-width: 0;
}
&__job-company {
font-size: 0.14rem;
font-weight: 600;
color: $text-dark;
line-height: 1.4;
}
&__job-title {
font-size: 0.12rem;
color: $text-middle;
line-height: 1.4;
}
// 步骤列表
&__steps {
display: flex;
flex-direction: column;
gap: 0.14rem;
}
&__step {
display: flex;
align-items: flex-start;
gap: 0.1rem;
flex-wrap: wrap;
}
// 勾选框
&__step-check {
width: 0.18rem;
height: 0.18rem;
border: 1.5px solid $text-light;
border-radius: 0.03rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 0.01rem;
svg {
width: 0.12rem;
height: 0.12rem;
}
// 已完成态
&--done {
border-color: $text-dark;
background: $text-dark;
color: $bg-white;
}
}
&__step-label {
font-size: 0.14rem;
color: $text-dark;
line-height: 0.2rem;
}
// 步骤展开内容
&__step-content {
width: 100%;
margin-top: 0.08rem;
padding-left: 0.28rem;
}
// 第2步:简历确认区域
&__resume-confirm {
display: flex;
align-items: center;
justify-content: space-between;
background: $bg-white;
border-radius: 0.08rem;
padding: 0.1rem 0.14rem;
}
&__resume-file {
display: flex;
align-items: center;
gap: 0.08rem;
}
&__file-icon {
width: 0.18rem;
height: 0.18rem;
color: $text-dark;
}
&__resume-name {
font-size: 0.13rem;
color: $text-dark;
}
&__confirm-btn {
height: 0.28rem;
padding: 0 0.16rem;
background: $btn-dark;
color: $bg-white;
border: none;
border-radius: 0.14rem;
font-size: 0.12rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: $btn-dark-hover;
}
&:disabled {
background: $text-light;
cursor: not-allowed;
}
}
// 第3步:投递操作区域
&__apply-area {
background: $bg-white;
border-radius: 0.08rem;
padding: 0.14rem 0.16rem;
}
&__apply-desc {
font-size: 0.12rem;
color: $text-middle;
line-height: 1.8;
margin: 0;
}
&__apply-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.1rem;
margin-top: 0.14rem;
}
&__skip-btn {
font-size: 0.12rem;
color: $text-middle;
cursor: pointer;
text-decoration: underline;
&:hover {
color: $text-dark;
}
&--disabled {
color: $text-light;
cursor: not-allowed;
pointer-events: none;
}
}
&__applied-btn {
height: 0.28rem;
padding: 0 0.14rem;
background: $btn-dark;
color: $bg-white;
border: none;
border-radius: 0.14rem;
font-size: 0.12rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: $btn-dark-hover;
}
&:disabled {
background: $text-light;
cursor: not-allowed;
}
}
&__goto-btn {
height: 0.28rem;
padding: 0 0.14rem;
background: $btn-dark;
color: $bg-white;
border: none;
border-radius: 0.14rem;
font-size: 0.12rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: $btn-dark-hover;
}
&:disabled {
background: $text-light;
cursor: not-allowed;
}
}
}
// 右侧面板:简历生成进度
.agent-resume-generating {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 0.4rem;
// 进度条容器
&__progress-bar {
width: 100%;
max-width: 3rem;
height: 0.04rem;
background: $border-color;
border-radius: 0.02rem;
overflow: hidden;
margin-bottom: 0.2rem;
}
&__progress-fill {
height: 100%;
background: $text-dark;
border-radius: 0.02rem;
transition: width 0.3s ease;
}
&__title {
font-size: 0.18rem;
font-weight: 700;
color: $text-dark;
margin-bottom: 0.1rem;
}
&__hint {
display: flex;
align-items: center;
gap: 0.06rem;
font-size: 0.12rem;
color: $text-middle;
}
&__hint-icon {
width: 0.14rem;
height: 0.14rem;
color: $accent;
}
}
+4 -3
View File
@@ -17,10 +17,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background: linear-gradient(90deg, #94EF9E , #53D9C8 ); background: #0F172B;
color: #fff; color: #fff;
padding: 0.1rem 0.16rem; padding: 0.12rem 0.18rem;
font-size: 0.13rem; font-size: 0.15rem;
font-weight: 600;
cursor: pointer; cursor: pointer;
border-radius: 0.2rem; border-radius: 0.2rem;
margin: 0.15rem; margin: 0.15rem;
@@ -275,7 +275,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.32rem; margin-bottom: 0.02rem;
} }
&__step { &__step {
@@ -81,17 +81,11 @@
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
margin-bottom: 4px; margin-bottom: 2px;
break-inside: avoid; break-inside: avoid;
page-break-inside: avoid; page-break-inside: avoid;
} }
&__item-left {
display: flex;
flex-direction: column;
gap: 2px;
}
&__item-main { &__item-main {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -121,7 +115,8 @@
&__item-desc { &__item-desc {
font-size: 12px; font-size: 12px;
color: $text-dark; color: $text-dark;
line-height: 1.5; line-height: 1.7;
margin-top: 2px;
} }
// 描述列表 // 描述列表
File diff suppressed because it is too large Load Diff
+113
View File
@@ -151,6 +151,119 @@
color: $text-dark; color: $text-dark;
} }
// ==================== 消息通知弹窗(左右分栏) ====================
.side-nav__message-dialog {
background: $bg-white;
border-radius: 0.12rem;
width: 8rem;
height: 6rem;
max-width: 90vw;
max-height: 90vh;
box-shadow: 0 0.04rem 0.2rem rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
}
.side-nav__message-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.16rem 0.2rem;
border-bottom: 1px solid $border-color;
font-size: 0.16rem;
font-weight: 600;
color: $text-dark;
flex-shrink: 0;
}
.side-nav__message-dialog-content {
display: flex;
flex: 1;
overflow: hidden;
}
// 左侧消息列表
.side-nav__message-list {
width: 2rem;
border-right: 1px solid $border-color;
overflow-y: auto;
flex-shrink: 0;
}
.side-nav__message-list-item {
display: flex;
align-items: center;
padding: 0.12rem 0.14rem;
cursor: pointer;
border-bottom: 1px solid $border-color;
transition: background 0.2s;
&:hover {
background: $bg-main;
}
&--active {
background: $theme-color;
}
}
.side-nav__message-list-title {
flex: 1;
font-size: 0.13rem;
color: $text-dark;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.side-nav__message-unread-dot {
width: 0.08rem;
height: 0.08rem;
border-radius: 50%;
background: $danger;
margin-left: 0.06rem;
flex-shrink: 0;
}
.side-nav__message-list-loading,
.side-nav__message-list-empty {
padding: 0.16rem;
text-align: center;
font-size: 0.12rem;
color: $text-light;
}
// 右侧消息详情
.side-nav__message-detail {
flex: 1;
padding: 0.2rem;
overflow-y: auto;
}
.side-nav__message-detail-title {
font-size: 0.16rem;
font-weight: 600;
color: $text-dark;
margin-bottom: 0.16rem;
}
.side-nav__message-detail-content {
font-size: 0.14rem;
color: $text-dark;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.side-nav__message-detail-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 0.14rem;
color: $text-light;
}
// 反馈弹窗 // 反馈弹窗
.feedback-dialog { .feedback-dialog {
padding: 0.28rem 0.24rem; padding: 0.28rem 0.24rem;
+1
View File
@@ -30,6 +30,7 @@
@use './components/resume-edit-name-dialog.scss'; @use './components/resume-edit-name-dialog.scss';
@use './components/agent-chat-job-list.scss'; @use './components/agent-chat-job-list.scss';
@use './components/agent-match-job-add.scss'; @use './components/agent-match-job-add.scss';
@use './components/agent-apply-progress.scss';
@use './components/ai-thinking-indicator.scss'; @use './components/ai-thinking-indicator.scss';
// 全局样式(优先级最高) // 全局样式(优先级最高)
+164
View File
@@ -836,6 +836,30 @@
height: calc(100vh - 0.6rem); height: calc(100vh - 0.6rem);
margin-left: 0.2rem; margin-left: 0.2rem;
max-width: 8rem; max-width: 8rem;
overflow-y: auto;
}
// 右侧简历预览容器
&__right-resume {
background: $bg-white;
border-radius: 0.12rem;
padding: 0.24rem;
height: 100%;
overflow-y: auto;
// 自定义滚动条
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: $border-color;
border-radius: 2px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
} }
// ==================== 顶部固定设置栏 ==================== // ==================== 顶部固定设置栏 ====================
@@ -909,6 +933,12 @@
&:hover { &:hover {
opacity: 0.85; opacity: 0.85;
} }
&:disabled {
background: $text-light;
cursor: not-allowed;
opacity: 0.7;
}
} }
// 右侧工具按钮区 // 右侧工具按钮区
@@ -917,6 +947,140 @@
align-items: center; align-items: center;
gap: 0.06rem; gap: 0.06rem;
flex-shrink: 0; flex-shrink: 0;
position: relative;
}
// 待投递列表下拉弹窗
&__task-dropdown {
position: absolute;
top: 0.36rem;
right: 0;
width: 5.1rem;
background: $bg-white;
border-radius: 0.12rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
z-index: 100;
padding: 0.16rem;
}
// Tab 切换
&__task-tabs {
display: flex;
gap: 0.2rem;
margin-bottom: 0.14rem;
border-bottom: 1px solid $border-color;
padding-bottom: 0.1rem;
}
&__task-tab {
font-size: 0.14rem;
color: $text-middle;
cursor: pointer;
font-weight: 500;
&--active {
color: $text-dark;
font-weight: 700;
}
}
// 列表区域
&__task-list {
max-height: 2.5rem;
overflow-y: auto;
}
&__task-empty {
font-size: 0.12rem;
color: $text-light;
text-align: center;
padding: 0.2rem 0;
}
// 单个岗位项
&__task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.1rem 0;
border-bottom: 1px solid $border-color;
&:last-child {
border-bottom: none;
}
}
&__task-item-left {
display: flex;
align-items: center;
gap: 0.08rem;
min-width: 0;
flex: 1;
}
&__task-item-name {
font-size: 0.13rem;
font-weight: 600;
color: $text-dark;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__task-item-tag {
font-size: 0.11rem;
color: $bg-white;
background: $text-middle;
padding: 0.02rem 0.08rem;
border-radius: 0.03rem;
white-space: nowrap;
flex-shrink: 0;
}
&__task-item-right {
display: flex;
align-items: center;
gap: 0.08rem;
flex-shrink: 0;
margin-left: 0.1rem;
}
&__task-item-status {
font-size: 0.12rem;
color: $text-middle;
&--done {
color: $accent;
}
}
&__task-item-remove {
font-size: 0.16rem;
color: $text-light;
cursor: pointer;
line-height: 1;
&:hover {
color: $text-dark;
}
}
// 查看全部按钮
&__task-view-all {
text-align: center;
font-size: 0.13rem;
color: $text-middle;
padding: 0.1rem 0 0;
margin-top: 0.1rem;
border-top: 1px solid $border-color;
cursor: pointer;
background: $bg-main;
border-radius: 0.06rem;
padding: 0.08rem;
&:hover {
color: $text-dark;
}
} }
// 工具按钮 // 工具按钮
+9
View File
@@ -170,6 +170,15 @@
flex-shrink: 0; flex-shrink: 0;
} }
// 视频播放器 — 16:9 比例,圆角,无控件
&__video {
width: 6.5rem;
aspect-ratio: 16 / 9;
border-radius: 0.24rem;
object-fit: cover;
display: block;
}
// 模拟匹配卡片容器 // 模拟匹配卡片容器
&__card { &__card {
width: 6.5rem; width: 6.5rem;
+8 -4
View File
@@ -41,13 +41,17 @@ $border-color: #E8E8E8;
// 遮罩层背景 // 遮罩层背景
$overlay-bg: rgba(0, 0, 0, 0.5); $overlay-bg: rgba(0, 0, 0, 0.5);
// 按钮色背景(确认提交等) // 按钮色背景(确认提交等)
// $btn-dark: #1A1A2E;
$btn-dark: #4FC2C9; $btn-dark: #4FC2C9;
// 按钮色悬停态 // 按钮色悬停态
// $btn-dark-hover: #2E3142;
$btn-dark-hover: #42A8B3; $btn-dark-hover: #42A8B3;
// 次要深色按钮背景(除非特意指定使用否则不用这个按钮色)
$btn-dark: #1A1A2E;
// 次要深色按钮悬停态(除非特意指定使用否则不用这个按钮色)
$btn-dark-hover: #2E3142;
// 渐变色背景 // 渐变色背景
$gradient-bg: linear-gradient(to right, #4FC2C9, #42A8B3); $gradient-bg: linear-gradient(to right, #4FC2C9, #42A8B3);
+177
View File
@@ -0,0 +1,177 @@
<template>
<!-- 申请进度消息组件 -->
<div class="agent-apply-progress">
<!-- 顶部标题栏 -->
<div class="agent-apply-progress__header">
<!-- 三重状态投递完成 / 暂停投递 / 开始投递 -->
<span
class="agent-apply-progress__title"
:class="{ 'agent-apply-progress__title--clickable': titleClickable }"
@click="handleTitleClick"
>{{ titleText }}</span>
<span v-if="currentStep < 4" class="agent-apply-progress__cancel" @click="handleCancel">取消</span>
</div>
<!-- 岗位信息卡片 -->
<div class="agent-apply-progress__job-card">
<div class="agent-apply-progress__job-icon">
<svg viewBox="0 0 24 24" fill="none">
<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="agent-apply-progress__job-info">
<div class="agent-apply-progress__job-company">{{ jobInfo?.companyShortName || jobInfo?.companyName || '' }}</div>
<div class="agent-apply-progress__job-title">{{ jobInfo?.title || '' }}</div>
</div>
</div>
<!-- 步骤列表 -->
<div class="agent-apply-progress__steps">
<!-- 第1步优化简历 -->
<div class="agent-apply-progress__step">
<div class="agent-apply-progress__step-check" :class="{ 'agent-apply-progress__step-check--done': currentStep > 1 }">
<svg v-if="currentStep > 1" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<span class="agent-apply-progress__step-label">优化简历</span>
</div>
<!-- 第2步确认简历 -->
<div class="agent-apply-progress__step">
<div class="agent-apply-progress__step-check" :class="{ 'agent-apply-progress__step-check--done': currentStep > 2 }">
<svg v-if="currentStep > 2" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<span class="agent-apply-progress__step-label">确认简历</span>
<!-- 第2步展开内容简历确认区域 -->
<div v-if="currentStep === 2" class="agent-apply-progress__step-content">
<div class="agent-apply-progress__resume-confirm">
<div class="agent-apply-progress__resume-file">
<svg viewBox="0 0 24 24" fill="none" class="agent-apply-progress__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>
<span class="agent-apply-progress__resume-name">{{ resumeName }}</span>
</div>
<button class="agent-apply-progress__confirm-btn" :disabled="paused" @click="handleConfirmResume">确定</button>
</div>
</div>
</div>
<!-- 第3步分析投递页面 -->
<div class="agent-apply-progress__step">
<div class="agent-apply-progress__step-check" :class="{ 'agent-apply-progress__step-check--done': currentStep > 3 }">
<svg v-if="currentStep > 3" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<span class="agent-apply-progress__step-label">分析投递页面</span>
<!-- 第3步展开内容投递操作区域 -->
<div v-if="currentStep === 3" class="agent-apply-progress__step-content">
<div class="agent-apply-progress__apply-area">
<p class="agent-apply-progress__apply-desc">该职位仅支持在申请网站原地址使用自动填充功能</p>
<p class="agent-apply-progress__apply-desc">请点击"去投递"打开申请网站我们的自动填写插件将自动弹出</p>
<p class="agent-apply-progress__apply-desc">点击"自动填写"核对信息并提交申请然后返回本页点击"我已申请"继续</p>
<div class="agent-apply-progress__apply-actions">
<span class="agent-apply-progress__skip-btn" :class="{ 'agent-apply-progress__skip-btn--disabled': paused }" @click="handleSkip">跳过</span>
<button class="agent-apply-progress__applied-btn" :disabled="paused" @click="handleApplied">我已投递</button>
<button class="agent-apply-progress__goto-btn" :disabled="paused" @click="handleGoApply">去投递</button>
</div>
</div>
</div>
</div>
<!-- 第4步提交申请 -->
<div class="agent-apply-progress__step">
<div class="agent-apply-progress__step-check" :class="{ 'agent-apply-progress__step-check--done': currentStep > 3 }">
<svg v-if="currentStep > 3" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<span class="agent-apply-progress__step-label">提交申请</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { JobListItem } from '@/api/jobs'
// ==================== Props ====================
const props = defineProps<{
/** 岗位信息 */
jobInfo: JobListItem | null
/** 当前步骤(1-4,大于该步骤表示已完成) */
currentStep: number
/** 简历名称(第2步确认时显示) */
resumeName?: string
/** 是否暂停状态 */
paused?: boolean
}>()
// ==================== 计算属性 ====================
/** 标题文字:投递完成 / 暂停投递 / 开始投递 */
const titleText = computed(() => {
if (props.currentStep >= 4) return '投递完成'
if (props.paused && props.currentStep > 1) return '开始投递'
if (!props.paused && props.currentStep > 1) return '暂停投递'
return '开始投递'
})
/** 标题是否可点击(暂停投递 或 暂停后的开始投递) */
const titleClickable = computed(() => {
if (props.currentStep >= 4) return false
return props.currentStep > 1
})
// ==================== 事件 ====================
const emit = defineEmits<{
/** 取消投递流程 */
(e: 'cancel'): void
/** 确认简历(第2步) */
(e: 'confirmResume'): void
/** 跳过投递(第3步) */
(e: 'skip'): void
/** 我已投递(第3步) */
(e: 'applied'): void
/** 去投递(第3步,打开原链接) */
(e: 'goApply'): void
/** 暂停/恢复投递 */
(e: 'togglePause'): void
}>()
// ==================== 事件处理 ====================
/** 点击标题(暂停/恢复) */
function handleTitleClick() {
if (!titleClickable.value) return
emit('togglePause')
}
/** 取消 */
function handleCancel() {
emit('cancel')
}
/** 确认简历 */
function handleConfirmResume() {
if (props.paused) return
emit('confirmResume')
}
/** 跳过 */
function handleSkip() {
if (props.paused) return
emit('skip')
}
/** 我已投递 */
function handleApplied() {
if (props.paused) return
emit('applied')
}
/** 去投递 */
function handleGoApply() {
if (props.paused) return
emit('goApply')
}
</script>
+5 -1
View File
@@ -9,6 +9,7 @@
v-for="job in displayJobs" v-for="job in displayJobs"
:key="job.id" :key="job.id"
class="agent-chat-job-list__item" class="agent-chat-job-list__item"
v-if="displayJobs.length>0"
> >
<!-- 左侧公司图标 + 岗位信息 --> <!-- 左侧公司图标 + 岗位信息 -->
<div class="agent-chat-job-list__info"> <div class="agent-chat-job-list__info">
@@ -51,9 +52,12 @@
<span class="agent-chat-job-list__score-text">{{ job.matchScore || 0 }}%</span> <span class="agent-chat-job-list__score-text">{{ job.matchScore || 0 }}%</span>
</div> </div>
</div> </div>
<div class="color-9" v-if="displayJobs.length==0">
暂无相关职位推荐
</div>
<!-- 查看全部岗位按钮 --> <!-- 查看全部岗位按钮 -->
<div class="agent-chat-job-list__footer"> <div v-if="displayJobs.length>0" class="agent-chat-job-list__footer">
<button class="agent-chat-job-list__view-all-btn" @click="handleViewAll">查看全部岗位</button> <button class="agent-chat-job-list__view-all-btn" @click="handleViewAll">查看全部岗位</button>
</div> </div>
</div> </div>
+126
View File
@@ -0,0 +1,126 @@
<template>
<!-- 待投递列表下拉弹窗 -->
<div class="agent-main__task-dropdown" @click.stop>
<!-- Tab 切换 -->
<div class="agent-main__task-tabs">
<span
class="agent-main__task-tab"
:class="{ 'agent-main__task-tab--active': activeTab === 1 }"
@click="switchTab(1)"
>进行中{{ pendingList.length }}</span>
<span
class="agent-main__task-tab"
:class="{ 'agent-main__task-tab--active': activeTab === 2 }"
@click="switchTab(2)"
>已完成{{ completedList.length }}</span>
</div>
<!-- 列表内容 -->
<div class="agent-main__task-list">
<template v-if="activeTab === 1">
<div v-if="pendingList.length === 0" class="agent-main__task-empty">暂无进行中的岗位</div>
<div v-for="job in pendingList" :key="job.id" class="agent-main__task-item">
<div class="agent-main__task-item-left">
<span class="agent-main__task-item-name">{{ (job.companyShortName || job.companyName) + '—' + job.title }}</span>
<span class="agent-main__task-item-tag">自动投递</span>
</div>
<div class="agent-main__task-item-right">
<span class="agent-main__task-item-status">未开始</span>
<span class="agent-main__task-item-remove" @click="removeJob(job)">×</span>
</div>
</div>
</template>
<template v-else>
<div v-if="loading" class="agent-main__task-empty">加载中...</div>
<div v-else-if="completedList.length === 0" class="agent-main__task-empty">暂无已完成的岗位</div>
<div v-for="job in completedList" :key="job.id" class="agent-main__task-item">
<div class="agent-main__task-item-left">
<span class="agent-main__task-item-name">{{ (job.companyShortName || job.companyName) + '—' + job.title }}</span>
<span class="agent-main__task-item-tag">自动投递</span>
</div>
<div class="agent-main__task-item-right">
<span class="agent-main__task-item-status agent-main__task-item-status--done">已完成</span>
</div>
</div>
</template>
</div>
<!-- 查看全部 -->
<div class="agent-main__task-view-all" @click="emit('viewAll')">查看全部</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { fetchAgentTaskList } from '@/api/jobs'
import { cancelApplyJob } from '@/api/agent'
import type { JobListItem } from '@/api/jobs'
// ==================== Props ====================
const props = defineProps<{
/** 进行中列表(由父组件传入,保持同步) */
pendingList: JobListItem[]
}>()
// ==================== 事件 ====================
const emit = defineEmits<{
/** 移除岗位后通知父组件刷新列表 */
(e: 'removed', jobId: string): void
/** 查看全部 */
(e: 'viewAll'): void
}>()
// ==================== 状态 ====================
/** 当前激活的 Tab(1=进行中 2=已完成) */
const activeTab = ref(1)
/** 已完成列表数据 */
const completedList = ref<JobListItem[]>([])
/** 加载状态 */
const loading = ref(false)
// ==================== 生命周期 ====================
onMounted(() => {
// 打开时同时加载已完成列表
loadCompletedList()
})
// ==================== 方法 ====================
/** 切换 Tab */
function switchTab(tab: number) {
activeTab.value = tab
if (tab === 2) {
loadCompletedList()
}
}
/** 加载已完成任务列表(tab=2) */
async function loadCompletedList() {
loading.value = true
try {
const res = await fetchAgentTaskList({ pageNum: 1, pageSize: 100, tab: 2 })
if (res.code === '0' && res.data) {
completedList.value = res.data.list || []
}
} catch {
console.error('[AgentTaskListDropdown] 加载已完成列表失败')
} finally {
loading.value = false
}
}
/** 移除岗位 — 调用 cancelApplyJob 接口并通知父组件 */
async function removeJob(job: JobListItem) {
try {
await cancelApplyJob(Number(job.id))
emit('removed', job.id)
ElMessage.success('已移除')
} catch {
ElMessage.error('移除失败')
}
}
</script>
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="ai-chat"> <div class="ai-chat">
<!-- 顶部会员横幅 点击打开会员购买弹窗 --> <!-- 顶部会员横幅 点击打开会员购买弹窗 -->
<div class="ai-chat__banner" @click="showMemberDialog = true"> <div class="ai-chat__banner" @click="showMemberDialog = true">
<span>解锁会员送快上岸!</span> <span class="dflex-start aliite-c"> <img src="@/assets/images/bule-flash.png" alt="Offer派" class="w16 h19 mr5" /> <span>升级 Pro 开启求职加速</span></span>
<span class="ai-chat__banner-arrow"></span> <span class="ai-chat__banner-arrow"></span>
</div> </div>
+70 -31
View File
@@ -221,39 +221,42 @@
</div> </div>
<!-- AI帮写内容 --> <!-- AI帮写内容 -->
<div v-if="previewTab === 'ai'" class="job-resume-custom-dialog__preview-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聊天消息区域 --> <!-- AI聊天消息区域 -->
<div class="job-resume-custom-dialog__ai-messages" ref="aiMessagesRef"> <div class="job-resume-custom-dialog__ai-messages" ref="aiMessagesRef">
<!-- 匹配度提升提示 -->
<div class="job-resume-custom-dialog__ai-result prl0">
<div class="job-resume-custom-dialog__ai-result-text">
<p class="job-resume-custom-dialog__ai-result-title">恭喜你的简历匹配值从<br/>{{ jobInfo.matchScore }}分提升到了{{ cachedOptimizedScore }}</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="2*Math.PI*24*(1-cachedOptimizedScore/10)" transform="rotate(-90 30 30)"/>
</svg>
<span class="job-resume-custom-dialog__match-score">{{ cachedOptimizedScore.toFixed(1) }}</span>
</div>
<span class="job-resume-custom-dialog__match-label job-resume-custom-dialog__match-label--high">{{ cachedOptimizedScore >= 9 ? '非常匹配' : cachedOptimizedScore >= 6 ? '高匹配度' : '低匹配度' }}</span>
</div>
</div>
<!-- 快捷操作按钮 -->
<div class="job-resume-custom-dialog__ai-quick-actions prl0">
<button
v-for="(action, i) in aiQuickActions"
:key="i"
class="job-resume-custom-dialog__ai-quick-btn"
@click="sendAiMessage(action)"
>{{ action }}</button>
</div>
<div <div
v-for="(msg, i) in aiMessages" v-for="(msg, i) in aiMessages"
:key="i" :key="i"
@@ -748,6 +751,42 @@ const aiOptimizeResults = computed<string[]>(() => {
return results return results
}) })
/**
* 根据勾选的优化部分数量计算优化后的匹配评分
* 0个勾选:3~5之间随机(一位小数)
* 1个勾选:6~7之间随机
* 2个勾选:8~9之间随机
* 3个勾选:9~10之间随机,50%概率直接为10.0
*/
const optimizedMatchScore = computed(() => {
const checkedCount = optimizeSections.value.filter(s => s.checked).length
let min: number, max: number
if (checkedCount === 0) {
min = 3; max = 5
} else if (checkedCount === 1) {
min = 6; max = 7
} else if (checkedCount === 2) {
min = 8; max = 9
} else {
// 3个勾选:50%概率直接10.0
if (Math.random() < 0.2) return 10.0
min = 9; max = 10
}
// 生成 [min, max] 之间的一位小数随机值
const raw = min + Math.random() * (max - min)
return Math.round(raw * 10) / 10
})
/** 缓存优化后评分(避免computed每次重新随机) */
const cachedOptimizedScore = ref<number>(0)
/** 进入步骤4时缓存一次评分 */
watch(currentStep, (val) => {
if (val === 4) {
cachedOptimizedScore.value = optimizedMatchScore.value
}
})
/** AI快捷操作按钮 */ /** AI快捷操作按钮 */
const aiQuickActions = ref<string[]>([ const aiQuickActions = ref<string[]>([
'精简一下第一段工作经历', '精简一下第一段工作经历',
+20 -22
View File
@@ -44,34 +44,32 @@
<div class="resume-html__divider"></div> <div class="resume-html__divider"></div>
<div v-for="(edu, idx) in resumeData.educations" :key="'edu-' + idx" class="resume-html__item"> <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-header">
<div class="resume-html__item-left"> <span class="resume-html__item-main">
<span class="resume-html__item-main"> <!-- 教育经历标题差异对比 -->
<!-- 教育经历标题差异对比 --> <template v-if="showDiff">
<template v-if="showDiff"> <template v-for="(seg, si) in diffText(
<template v-for="(seg, si) in diffText( oldResumeData?.educations?.[idx] ? (oldResumeData.educations[idx].school + '' + oldResumeData.educations[idx].major + '' + degreeText(oldResumeData.educations[idx].degree)) : '',
oldResumeData?.educations?.[idx] ? (oldResumeData.educations[idx].school + '' + oldResumeData.educations[idx].major + '' + degreeText(oldResumeData.educations[idx].degree)) : '', edu.school + '' + edu.major + '' + degreeText(edu.degree)
edu.school + '' + edu.major + '' + degreeText(edu.degree) )" :key="'eduh-' + si">
)" :key="'eduh-' + si"> <span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span><template v-else>{{ seg.text }}</template>
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span><template v-else>{{ seg.text }}</template>
</template>
</template> </template>
<template v-else>{{ edu.school }}{{ edu.major }}{{ degreeText(edu.degree) }}</template> </template>
</span> <template v-else>{{ edu.school }}{{ edu.major }}{{ degreeText(edu.degree) }}</template>
<span v-if="edu.description && edu.description.length" class="resume-html__item-desc"> </span>
<!-- 教育经历描述差异对比 -->
<template v-if="showDiff">
主修课程:<template v-for="(desc, di) in edu.description" :key="'ed-' + di"><template v-if="di > 0">、</template><template v-for="(seg, si) in diffDescText(oldResumeData?.educations, idx, di, desc.text)" :key="'eds-' + si"><span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span><template v-else>{{ seg.text }}</template></template></template>等;
</template>
<template v-else>
主修课程:{{ edu.description.map(d => d.text).join('、') }}等;
</template>
</span>
</div>
<div class="resume-html__item-right"> <div class="resume-html__item-right">
<span class="resume-html__item-location" v-if="edu.location">{{ edu.location }}</span> <span class="resume-html__item-location" v-if="edu.location">{{ edu.location }}</span>
<span class="resume-html__item-date">{{ edu.startDate }} — {{ edu.endDate || '至今' }}</span> <span class="resume-html__item-date">{{ edu.startDate }} — {{ edu.endDate || '至今' }}</span>
</div> </div>
</div> </div>
<!-- 教育经历描述独立一行,避免被header的flex布局挤压 -->
<div v-if="edu.description && edu.description.length" class="resume-html__item-desc">
<template v-if="showDiff">
主修课程:<template v-for="(desc, di) in edu.description" :key="'ed-' + di"><template v-if="di > 0">、</template><template v-for="(seg, si) in diffDescText(oldResumeData?.educations, idx, di, desc.text)" :key="'eds-' + si"><span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span><template v-else>{{ seg.text }}</template></template></template>等;
</template>
<template v-else>
主修课程:{{ edu.description.map(d => d.text).join('、') }}等;
</template>
</div>
</div> </div>
</template> </template>
+443 -92
View File
@@ -1,5 +1,5 @@
<template> <template>
<!-- 会员购买弹窗 通过 Teleport 挂载到 body --> <!-- 会员弹窗 通过 Teleport 挂载到 body -->
<Teleport to="body"> <Teleport to="body">
<!-- 遮罩层 点击关闭弹窗 --> <!-- 遮罩层 点击关闭弹窗 -->
<div v-if="modelValue" class="member-dialog-overlay" @click="$emit('update:modelValue', false)"> <div v-if="modelValue" class="member-dialog-overlay" @click="$emit('update:modelValue', false)">
@@ -8,73 +8,342 @@
<!-- 右上角关闭按钮 --> <!-- 右上角关闭按钮 -->
<span class="member-dialog__close" @click="$emit('update:modelValue', false)"></span> <span class="member-dialog__close" @click="$emit('update:modelValue', false)"></span>
<!-- 顶部标语 --> <!-- ==================== 会员介绍步骤一简介页 ==================== -->
<div class="member-dialog__slogan">每天不到一元获得三倍面试机会</div> <div v-if="currentView === 'intro1'" class="member-dialog__intro-step1">
<h1 class="member-dialog__intro-title">Offer - AI 智能求职引擎</h1>
<p class="member-dialog__intro-subtitle">全自动海投简历精修模拟面试助你轻松拿高薪</p>
<button class="member-dialog__intro-btn" @click="currentView = 'intro2'">
<span class="member-dialog__intro-btn-icon"></span>
升级 Pro 开启求职加速
</button>
</div>
<!-- 套餐卡片区域 --> <!-- ==================== 会员介绍步骤二详细介绍页可滚动 ==================== -->
<div class="member-dialog__plans"> <div v-else-if="currentView === 'intro2'" class="member-dialog__intro-step2">
<div <div class="member-dialog__step2-badge"> 求职加速会员</div>
v-for="plan in plans" <h1 class="member-dialog__step2-title">offer派 offer 就是快</h1>
:key="plan.key" <p class="member-dialog__step2-subtitle">每天不到 1 少投无效岗位把时间花在更可能拿面试的机会</p>
class="member-dialog__plan-card"
:class="{ 'member-dialog__plan-card--active': selectedPlan === plan.key }" <!-- 套餐卡片区域 -->
@click="selectedPlan = plan.key" <div class="member-dialog__plans">
> <div
<!-- 套餐名称 --> v-for="plan in plans"
<div class="member-dialog__plan-name">{{ plan.name }}</div> :key="plan.key"
<!-- 原价划线 --> class="member-dialog__plan-card"
<div class="member-dialog__plan-original">{{ plan.originalPrice }}</div> :class="{ 'member-dialog__plan-card--active': selectedPlan === plan.key }"
<!-- 现价 --> @click="selectedPlan = plan.key"
<div class="member-dialog__plan-price"> >
<span class="member-dialog__plan-price-num">¥{{ plan.price }}</span> <div v-if="plan.recommend" class="member-dialog__plan-recommend"> 推荐覆盖求职周期性价比最高</div>
<span class="member-dialog__plan-price-unit">/{{ plan.unit }}</span> <div v-if="plan.tag" class="member-dialog__plan-tag">{{ plan.tag }}</div>
<!-- 省钱标签 --> <div class="member-dialog__plan-name">{{ plan.name }}</div>
<span v-if="plan.discount" class="member-dialog__plan-discount">{{ plan.discount }}</span> <div class="member-dialog__plan-price">
<span class="member-dialog__plan-price-symbol">¥</span>
<span class="member-dialog__plan-price-num">{{ plan.price }}</span>
<span class="member-dialog__plan-price-unit">/{{ plan.unit }}</span>
</div>
<div v-if="plan.perMonth" class="member-dialog__plan-per-month">折合仅 ¥{{ plan.perMonth }}/</div>
<button class="member-dialog__plan-btn" @click.stop="handleUpgrade(plan)">{{ plan.btnText }}</button>
</div>
</div>
<!-- 提示信息 -->
<div class="member-dialog__tip">
<span class="member-dialog__tip-icon"></span>
<span class="member-dialog__tip-label">时间就是机会</span>
<span class="member-dialog__tip-text">热门岗位通常在发布后 24-48 小时内收到大量申请使用 AI 加速工具越早投递越容易被 HR 看到!</span>
</div>
<!-- 底部内容左侧能力列表 + 右侧对比表格 -->
<div class="member-dialog__content-row">
<div class="member-dialog__abilities">
<h3 class="member-dialog__abilities-title">求职加速能力</h3>
<div v-for="ability in abilities" :key="ability.text" class="member-dialog__ability-item">
<span class="member-dialog__ability-icon" :style="{ color: ability.color }">{{ ability.icon }}</span>
<span class="member-dialog__ability-text">{{ ability.text }}</span>
</div>
</div>
<div class="member-dialog__compare">
<h3 class="member-dialog__compare-title">会员能力对比</h3>
<table class="member-dialog__compare-table">
<thead>
<tr>
<th>核心能力</th>
<th>免费版</th>
<th class="member-dialog__compare-th-pro"> 会员版</th>
</tr>
</thead>
<tbody>
<tr v-for="row in compareData" :key="row.name">
<td>{{ row.name }}</td>
<td class="member-dialog__compare-free">{{ row.free }}</td>
<td class="member-dialog__compare-pro"> {{ row.pro }}</td>
</tr>
</tbody>
</table>
<div class="member-dialog__compare-footer">
<span>🔒 支付安全保障</span>
<span> 支持随时取消</span>
</div>
</div>
</div>
<!-- 用户评价 -->
<div class="member-dialog__testimonial">
<div class="member-dialog__testimonial-avatar">
<img src="@/assets/images/home/avatar-temporary.png" alt="用户头像" />
</div>
<div class="member-dialog__testimonial-content">
<div class="member-dialog__testimonial-stars"></div>
<p class="member-dialog__testimonial-text">"开通后我主要用 AI 简历优化和岗位匹配,投递效率明显提升,每次网申节省了十几分钟,终于知道该重点投哪些岗位了。"</p>
<div class="member-dialog__testimonial-author">
<span class="member-dialog__testimonial-name">张同学</span>
<span class="member-dialog__testimonial-desc">· 成功入职某大厂运营</span>
</div>
</div> </div>
<!-- 立刻升级按钮 -->
<button class="member-dialog__plan-btn" @click.stop="handleUpgrade(plan)">立刻升级</button>
</div> </div>
</div> </div>
<!-- 权益对比区域 --> <!-- ==================== 下单步骤一选择套餐 ==================== -->
<div class="member-dialog__benefits"> <div v-else-if="currentView === 'order1'" class="member-dialog__order">
<!-- 左列面试率提升工具 --> <!-- 顶部导航 -->
<div class="member-dialog__benefit-col"> <div class="member-dialog__order-header">
<div class="member-dialog__benefit-title">面试率提升工具</div> <span class="member-dialog__order-back" @click="currentView = 'intro2'"> 返回会员介绍</span>
<div <!-- 步骤条 -->
v-for="item in toolList" <div class="member-dialog__steps">
:key="item" <div class="member-dialog__step member-dialog__step--active">
class="member-dialog__benefit-item" <span class="member-dialog__step-num">1</span>
>{{ item }}</div> <span class="member-dialog__step-label">选择套餐</span>
<div class="member-dialog__benefit-other"> </div>
其他渠道购买 <span class="member-dialog__benefit-highlight">¥210/</span> <div class="member-dialog__step-line"></div>
<div class="member-dialog__step">
<span class="member-dialog__step-num">2</span>
<span class="member-dialog__step-label">支付方式</span>
</div>
<div class="member-dialog__step-line"></div>
<div class="member-dialog__step">
<span class="member-dialog__step-num">3</span>
<span class="member-dialog__step-label">完成</span>
</div>
</div> </div>
</div> </div>
<!-- 中列会员权益 --> <!-- 主内容区域可滚动 -->
<div class="member-dialog__benefit-col member-dialog__benefit-col--center"> <div class="member-dialog__order-body">
<div class="member-dialog__benefit-title member-dialog__benefit-title--accent">会员权益</div> <!-- 左侧内容 -->
<div <div class="member-dialog__order-left">
v-for="item in memberBenefits" <h2 class="member-dialog__order-title">开启你的 AI 求职加速计划</h2>
:key="item.label" <p class="member-dialog__order-desc">解锁简历优化岗位匹配投递追踪面试准备等能力让你的求职过程更高效更有方向</p>
class="member-dialog__benefit-item"
> <!-- 套餐选择卡片 -->
<span class="member-dialog__benefit-badge">{{ item.badge }}</span> <div class="member-dialog__order-plans">
<span>{{ item.label }}</span> <div
v-for="plan in plans"
:key="plan.key"
class="member-dialog__order-plan-card"
:class="{ 'member-dialog__order-plan-card--active': selectedPlan === plan.key }"
@click="selectedPlan = plan.key"
>
<div v-if="plan.recommend" class="member-dialog__order-plan-badge"> 推荐</div>
<!-- 选中圆圈 -->
<div class="member-dialog__order-plan-radio">
<div v-if="selectedPlan === plan.key" class="member-dialog__order-plan-radio-inner"></div>
</div>
<div class="member-dialog__order-plan-info">
<div class="member-dialog__order-plan-name">{{ plan.name }}</div>
<div class="member-dialog__order-plan-price">
<span class="member-dialog__order-plan-price-symbol">¥</span>
<span class="member-dialog__order-plan-price-num">{{ plan.price }}</span>
<span class="member-dialog__order-plan-price-unit">/{{ plan.unit }}</span>
</div>
<div class="member-dialog__order-plan-desc">{{ plan.orderDesc }}</div>
</div>
</div>
</div>
<!-- 支付方式 -->
<div class="member-dialog__order-payment">
<h3 class="member-dialog__order-payment-title">支付方式</h3>
<div class="member-dialog__order-payment-methods">
<div
v-for="method in paymentMethods"
:key="method.key"
class="member-dialog__order-payment-item"
:class="{ 'member-dialog__order-payment-item--active': selectedPayment === method.key }"
@click="selectedPayment = method.key"
>
<span class="member-dialog__order-payment-icon">{{ method.icon }}</span>
<span class="member-dialog__order-payment-name">{{ method.name }}</span>
</div>
</div>
<!-- 支付提示 -->
<div class="member-dialog__order-payment-hint">
点击右侧"立即开启求职加速"将弹出二维码完成支付
</div>
<div class="member-dialog__order-payment-safe">支付信息将通过安全通道加密处理请放心支付</div>
</div>
<!-- 底部保障标签 -->
<div class="member-dialog__order-guarantees">
<span class="member-dialog__order-guarantee-item">🔒 安全支付</span>
<span class="member-dialog__order-guarantee-item"> 立即解锁</span>
<span class="member-dialog__order-guarantee-item"> 支持取消续费</span>
<span class="member-dialog__order-guarantee-item">🎧 客服支持</span>
</div>
<!-- 常见问题 -->
<div class="member-dialog__order-faq">
<h3 class="member-dialog__order-faq-title">常见问题</h3>
<div
v-for="faq in faqList"
:key="faq.question"
class="member-dialog__order-faq-item"
@click="faq.open = !faq.open"
>
<div class="member-dialog__order-faq-question">
<span>{{ faq.question }}</span>
<span class="member-dialog__order-faq-arrow" :class="{ 'member-dialog__order-faq-arrow--open': faq.open }"></span>
</div>
<div v-if="faq.open" class="member-dialog__order-faq-answer">{{ faq.answer }}</div>
</div>
</div>
</div> </div>
<div class="member-dialog__benefit-other">
打包价 <span class="member-dialog__benefit-highlight">¥19.99/</span> <!-- 右侧订单摘要 -->
<div class="member-dialog__order-right">
<div class="member-dialog__order-summary">
<h3 class="member-dialog__order-summary-title">订单摘要</h3>
<!-- 套餐信息 -->
<div class="member-dialog__order-summary-row">
<div>
<div class="member-dialog__order-summary-plan">{{ currentPlan.name }}</div>
<div class="member-dialog__order-summary-duration">{{ currentPlan.unit }}</div>
</div>
<span class="member-dialog__order-summary-price">¥{{ currentPlan.price }}</span>
</div>
<!-- 分割线 -->
<div class="member-dialog__order-summary-divider"></div>
<!-- 今日支付 -->
<div class="member-dialog__order-summary-row">
<span class="member-dialog__order-summary-label">今日支付</span>
<span class="member-dialog__order-summary-total">¥{{ currentPlan.price }}</span>
</div>
<!-- 协议勾选 -->
<div class="member-dialog__order-summary-agree">
<label class="member-dialog__order-checkbox">
<input type="checkbox" v-model="agreeProtocol" />
<span class="member-dialog__order-checkbox-mark"></span>
</label>
<span>我已阅读并同意 <a href="javascript:;">会员服务协议</a> <a href="javascript:;">自动续费协议</a></span>
</div>
<!-- 立即开启按钮 -->
<button
class="member-dialog__order-submit-btn"
:disabled="!agreeProtocol"
@click="handleShowQrCode"
>立即开启求职加速</button>
<div class="member-dialog__order-submit-tip">支付成功后立即解锁全部权益可随时取消续费</div>
<!-- 你将获得 -->
<div class="member-dialog__order-benefits">
<h4 class="member-dialog__order-benefits-title">你将获得</h4>
<div v-for="item in orderBenefits" :key="item" class="member-dialog__order-benefits-item">
<span class="member-dialog__order-benefits-check"></span>
<span>{{ item }}</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div>
<!-- 右列免费权益当前 --> <!-- ==================== 下单步骤二确认支付结果 ==================== -->
<div class="member-dialog__benefit-col"> <div v-else-if="currentView === 'order2'" class="member-dialog__order">
<div class="member-dialog__benefit-title">免费权益当前</div> <div class="member-dialog__order-header">
<div <span class="member-dialog__order-back"></span>
v-for="item in freeBenefits" <div class="member-dialog__steps">
:key="item" <div class="member-dialog__step member-dialog__step--done">
class="member-dialog__benefit-item" <span class="member-dialog__step-num"></span>
>{{ item }}</div> <span class="member-dialog__step-label">选择套餐</span>
</div>
<div class="member-dialog__step-line member-dialog__step-line--done"></div>
<div class="member-dialog__step member-dialog__step--active">
<span class="member-dialog__step-num">2</span>
<span class="member-dialog__step-label">支付方式</span>
</div>
<div class="member-dialog__step-line"></div>
<div class="member-dialog__step">
<span class="member-dialog__step-num">3</span>
<span class="member-dialog__step-label">完成</span>
</div>
</div>
</div>
<!-- 确认中内容 -->
<div class="member-dialog__order-confirming">
<div class="member-dialog__order-confirming-card">
<!-- 加载动画 -->
<div class="member-dialog__order-loading-spinner"></div>
<h2 class="member-dialog__order-confirming-title">正在确认支付结果</h2>
<p class="member-dialog__order-confirming-desc">请稍候请勿关闭此页面</p>
</div>
</div>
</div>
<!-- ==================== 下单步骤三支付完成 ==================== -->
<div v-else-if="currentView === 'order3'" class="member-dialog__order">
<div class="member-dialog__order-header">
<span class="member-dialog__order-back"></span>
<div class="member-dialog__steps">
<div class="member-dialog__step member-dialog__step--done">
<span class="member-dialog__step-num"></span>
<span class="member-dialog__step-label">选择套餐</span>
</div>
<div class="member-dialog__step-line member-dialog__step-line--done"></div>
<div class="member-dialog__step member-dialog__step--done">
<span class="member-dialog__step-num"></span>
<span class="member-dialog__step-label">支付方式</span>
</div>
<div class="member-dialog__step-line member-dialog__step-line--done"></div>
<div class="member-dialog__step member-dialog__step--active">
<span class="member-dialog__step-num">3</span>
<span class="member-dialog__step-label">完成</span>
</div>
</div>
</div>
<!-- 支付成功内容 -->
<div class="member-dialog__order-success">
<div class="member-dialog__order-success-card">
<!-- 成功图标 -->
<div class="member-dialog__order-success-icon"></div>
<h2 class="member-dialog__order-success-title">支付成功求职加速已开启</h2>
<p class="member-dialog__order-success-desc">你已成功解锁 AI 求职加速权益现在可以开始优化简历匹配岗位并准备面试</p>
<button class="member-dialog__order-success-btn" @click="handleGoResume">开始优化我的简历</button>
<button class="member-dialog__order-success-btn-secondary" @click="$emit('update:modelValue', false)">查看会员权益</button>
</div>
</div>
</div>
<!-- ==================== 付款二维码遮罩层 ==================== -->
<div v-if="showQrCode" class="member-dialog__qrcode-overlay" @click="showQrCode = false">
<div class="member-dialog__qrcode-modal" @click.stop>
<!-- 关闭按钮 -->
<span class="member-dialog__qrcode-close" @click="showQrCode = false"></span>
<!-- 支付方式标题 -->
<div class="member-dialog__qrcode-title">
<span class="member-dialog__qrcode-title-icon">{{ selectedPayment === 'wechat' ? '💬' : '🔷' }}</span>
<span>{{ selectedPayment === 'wechat' ? '微信支付' : '支付宝' }}</span>
</div>
<!-- 扫码提示 -->
<h3 class="member-dialog__qrcode-subtitle">扫码完成支付</h3>
<p class="member-dialog__qrcode-desc">请使用{{ selectedPayment === 'wechat' ? '微信' : '支付宝' }} App 扫描二维码完成支付完成后此窗口会自动关闭</p>
<!-- 二维码占位 -->
<div class="member-dialog__qrcode-image">
<!-- TODO: 替换为真实二维码图片 -->
<div class="member-dialog__qrcode-placeholder"></div>
</div>
<!-- 金额 -->
<div class="member-dialog__qrcode-amount">¥{{ currentPlan.price }}</div>
<!-- 我已完成支付按钮 -->
<button class="member-dialog__qrcode-confirm-btn" @click="handlePaymentDone">我已完成支付</button>
</div> </div>
</div> </div>
</div> </div>
@@ -83,7 +352,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, computed, watch, reactive } from 'vue'
import { useRouter } from 'vue-router'
/** 组件 Props — 控制弹窗显示/隐藏 */ /** 组件 Props — 控制弹窗显示/隐藏 */
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
@@ -91,72 +361,153 @@ const props = defineProps<{ modelValue: boolean }>()
/** 组件 Emits — 通知父组件更新 modelValue */ /** 组件 Emits — 通知父组件更新 modelValue */
defineEmits<{ (e: 'update:modelValue', value: boolean): void }>() defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const router = useRouter()
// ==================== 类型定义 ==================== // ==================== 类型定义 ====================
/** 套餐项类型 */ /** 套餐项类型 */
interface PlanItem { interface PlanItem {
key: string key: string
name: string name: string
originalPrice: string
price: string price: string
unit: string unit: string
discount: string tag?: string
recommend?: boolean
perMonth?: string
btnText: string
orderDesc: string
}
/** 支付方式类型 */
interface PaymentMethod {
key: string
name: string
icon: string
}
/** FAQ 项类型 */
interface FaqItem {
question: string
answer: string
open: boolean
} }
// ==================== 状态 ==================== // ==================== 状态 ====================
/** 当前选中的套餐 */ /** 当前视图(intro1/intro2/order1/order2/order3 */
const selectedPlan = ref('monthly') const currentView = ref<'intro1' | 'intro2' | 'order1' | 'order2' | 'order3'>('intro2')
/** 监听弹窗开关 — 打开时锁定背景滚动,关闭时恢复 */ /** 当前选中的套餐 */
const selectedPlan = ref('quarterly')
/** 当前选中的支付方式 */
const selectedPayment = ref('wechat')
/** 是否同意协议 */
const agreeProtocol = ref(false)
/** 是否显示二维码弹窗 */
const showQrCode = ref(false)
/** 监听弹窗开关 — 打开时锁定背景滚动,关闭时恢复并重置状态 */
watch(() => props.modelValue, (val) => { watch(() => props.modelValue, (val) => {
document.body.style.overflow = val ? 'hidden' : '' document.body.style.overflow = val ? 'hidden' : ''
if (!val) {
currentView.value = 'intro2'
showQrCode.value = false
agreeProtocol.value = false
}
})
// ==================== 计算属性 ====================
/** 当前选中的套餐对象 */
const currentPlan = computed(() => {
return plans.find(p => p.key === selectedPlan.value) || plans[1]
}) })
// ==================== 常量数据 ==================== // ==================== 常量数据 ====================
/** 套餐列表 */ /** 套餐列表 */
const plans: PlanItem[] = [ const plans: PlanItem[] = [
{ key: 'quarterly', name: '季度会员', originalPrice: '¥149.97 / 3个月', price: '49.99', unit: '3个月', discount: '67%' }, { key: 'weekly', name: '会员', price: '17.99', unit: '周', tag: '临时体验 / 急用一次', btnText: '立即体验', orderDesc: '适合临时体验,急用一次的求职者' },
{ key: 'monthly', name: '度会员', originalPrice: '¥49.99 / 1个月', price: '19.99', unit: '', discount: '60%' }, { key: 'quarterly', name: '度会员', price: '49.99', unit: '3个月', recommend: true, perMonth: '16.66', btnText: '开启完整求职冲刺', orderDesc: '推荐正在集中投递、准备面试的求职者,性价比最高' },
{ key: 'weekly', name: '会员', originalPrice: '¥71.96 / 1个月', price: '17.99', unit: '1周', discount: '' }, { key: 'monthly', name: '月度会员', price: '19.99', unit: '月', tag: '标准求职', btnText: '开始求职加速', orderDesc: '标准求职周期,持续使用 AI 求职加速能力' },
] ]
/** 面试率提升工具列表 */ /** 求职加速能力列表 */
const toolList = [ const abilities = [
'AI 求职助手', { icon: '◉', color: '#4FC2C9', text: '个性化岗位匹配,量大极速又精准' },
'AI 针对性简历优化', { icon: '◉', color: '#4FC2C9', text: '针对岗位 JD 自动优化简历,提高简历通过率' },
'1V1真人导师', { icon: '⚡', color: '#4FC2C9', text: '自动填写申请表,每次投递节省 10-15 分钟' },
'一键自动填写网申信息', { icon: '↗', color: '#E85635', text: '内推码,提高简历被 HR 看到的概率' },
'内推码', { icon: '⏰', color: '#777777', text: '第一时间提醒高匹配岗位,抢占面试机会' },
'实时岗位更新提醒',
] ]
/** 会员权益列表 */ /** 会员能力对比数据 */
const memberBenefits = [ const compareData = [
{ badge: '无限', label: '自动化网申流程' }, { name: 'AI 岗位匹配', free: '免费 3 天', pro: '不限次数' },
{ badge: '无限', label: '简历过筛率提升' }, { name: '简历针对性优化', free: '免费 3 天', pro: '不限次数' },
{ badge: '', label: '找工作不孤单' }, { name: '自动填写网申', free: '免费 3 天', pro: '不限次数' },
{ badge: '无限', label: '每一次网申节约15Min' }, { name: '内推码获取', free: '免费 3 天', pro: '不限次数' },
{ badge: '无限', label: '自动填写最新内推码' }, { name: '高匹配岗位推送', free: '免费 3 天', pro: '不限次数' },
{ badge: '无限', label: '第一时间投递高质量岗位' },
] ]
/** 免费权益列表 */ /** 支付方式列表 */
const freeBenefits = [ const paymentMethods: PaymentMethod[] = [
'受限', { key: 'wechat', name: '微信支付', icon: '💬' },
'2次/天', { key: 'alipay', name: '支付宝', icon: '🔷' },
'不支持',
'4次/天',
'4次/天',
'1次/天',
] ]
/** 订单权益列表 */
const orderBenefits = [
'AI 简历深度优化',
'智能岗位匹配分析',
'求职信快速生成',
'面试准备指导',
'投递进度追踪',
'求职策略建议',
]
/** 常见问题列表 */
const faqList = reactive<FaqItem[]>([
{ question: '支付后如何开通?', answer: '支付成功后立即生效,无需等待,登录账号即可使用全部会员权益。', open: true },
{ question: '可以随时取消吗?', answer: '可以,您可以在设置中随时取消自动续费,当前周期内的权益仍然有效。', open: false },
{ question: '支持哪些支付方式?', answer: '目前支持微信支付和支付宝两种支付方式。', open: false },
{ question: '可以开发票吗?', answer: '支持开具电子发票,请在支付完成后联系客服申请。', open: false },
])
// ==================== 事件处理 ==================== // ==================== 事件处理 ====================
/** 点击立刻升级按钮 */ /** 从介绍页点击升级按钮 — 进入下单步骤一 */
function handleUpgrade(plan: PlanItem) { function handleUpgrade(plan: PlanItem) {
// TODO: 接入支付接口 selectedPlan.value = plan.key
ElMessage.success(`正在跳转 ${plan.name} 支付页面...`) currentView.value = 'order1'
}
/** 点击立即开启求职加速 — 显示二维码弹窗 */
function handleShowQrCode() {
if (!agreeProtocol.value) return
showQrCode.value = true
}
/** 点击我已完成支付 — 进入确认支付结果步骤 */
function handlePaymentDone() {
showQrCode.value = false
currentView.value = 'order2'
// 模拟接口确认支付结果,成功后进入步骤三
confirmPayment()
}
/** 模拟确认支付结果接口 */
async function confirmPayment() {
// TODO: 替换为真实支付确认接口
await new Promise(resolve => setTimeout(resolve, 2000))
currentView.value = 'order3'
}
/** 支付成功后跳转简历页 */
function handleGoResume() {
router.push('/resume')
} }
</script> </script>
+6 -4
View File
@@ -66,16 +66,17 @@
<div class="settings-dialog__member-card"> <div class="settings-dialog__member-card">
<div class="settings-dialog__member-header"> <div class="settings-dialog__member-header">
<div class="settings-dialog__member-title-row"> <div class="settings-dialog__member-title-row">
<span class="settings-dialog__member-name">月度会员</span> <span class="settings-dialog__member-name">会员</span>
<span class="settings-dialog__member-badge">查看详情</span> <span class="settings-dialog__member-badge">查看详情</span>
</div> </div>
<span class="settings-dialog__member-terms" @click="handleMemberTerms">会员条款</span> <span class="settings-dialog__member-terms" @click="handleMemberTerms">会员条款</span>
</div> </div>
<div class="settings-dialog__member-info-row"> <div class="settings-dialog__member-info-row">
<span class="settings-dialog__member-price"> <span class="settings-dialog__member-price">
¥19.99/<span>将于2026年3月27日续费</span> ¥19.99/
<!-- <span>将于2026年3月27日续费</span>-->
</span> </span>
<button class="settings-dialog__member-manage-btn" @click="handleManageSubscription">管理我的订阅</button> <!-- <button class="settings-dialog__member-manage-btn" @click="handleManageSubscription">管理我的订阅</button>-->
</div> </div>
</div> </div>
<div class="settings-dialog__member-issue"> <div class="settings-dialog__member-issue">
@@ -92,7 +93,8 @@
<!-- Tab: 岗位更新提醒 目标岗位即时提醒开关提醒频率 --> <!-- Tab: 岗位更新提醒 目标岗位即时提醒开关提醒频率 -->
<template v-if="activeTab === 'reminder'"> <template v-if="activeTab === 'reminder'">
<h2 class="settings-dialog__content-title">岗位更新提醒</h2> <!-- <h2 class="settings-dialog__content-title">岗位更新提醒</h2>-->
<h2 class="settings-dialog__content-title">目标岗位设置</h2>
<div class="settings-dialog__reminder-block"> <div class="settings-dialog__reminder-block">
<div class="settings-dialog__reminder-block-title-row"> <div class="settings-dialog__reminder-block-title-row">
<span class="settings-dialog__reminder-block-title">目标岗位</span> <span class="settings-dialog__reminder-block-title">目标岗位</span>
+165 -6
View File
@@ -35,13 +35,39 @@
<!-- 消息通知弹窗 --> <!-- 消息通知弹窗 -->
<Teleport to="body"> <Teleport to="body">
<div v-if="showMessageDialog" class="side-nav__dialog-overlay" @click="showMessageDialog = false"> <div v-if="showMessageDialog" class="side-nav__dialog-overlay" @click="showMessageDialog = false">
<div class="side-nav__dialog" @click.stop> <div class="side-nav__message-dialog" @click.stop>
<div class="side-nav__dialog-header"> <!-- 弹窗头部 -->
<div class="side-nav__message-dialog-header">
<span>消息通知</span> <span>消息通知</span>
<span class="side-nav__dialog-close" @click="showMessageDialog = false"></span> <span class="side-nav__dialog-close" @click="showMessageDialog = false"></span>
</div> </div>
<div class="side-nav__dialog-body"> <!-- 弹窗内容区左列表 + 右详情 -->
<p>暂无新消息</p> <div class="side-nav__message-dialog-content">
<!-- 左侧消息列表 -->
<div class="side-nav__message-list" @scroll="handleMessageListScroll">
<div
v-for="(msg, idx) in messageList"
:key="msg.id"
class="side-nav__message-list-item"
:class="{ 'side-nav__message-list-item--active': selectedMessageIdx === idx }"
@click="selectedMessageIdx = idx"
>
<span class="side-nav__message-list-title">{{ msg.title }}</span>
<span v-if="!msg.read" class="side-nav__message-unread-dot"></span>
</div>
<!-- 加载中提示 -->
<div v-if="messageLoading" class="side-nav__message-list-loading">加载中...</div>
<!-- 无消息提示 -->
<div v-if="!messageLoading && messageList.length === 0" class="side-nav__message-list-empty">暂无消息</div>
</div>
<!-- 右侧消息详情 -->
<div class="side-nav__message-detail">
<template v-if="currentMessage">
<div class="side-nav__message-detail-title">{{ currentMessage.title }}</div>
<div class="side-nav__message-detail-content">{{ currentMessage.content }}</div>
</template>
<div v-else class="side-nav__message-detail-empty">请选择一条消息查看</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -91,11 +117,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import SettingsDialog from '@/components/SettingsDialog.vue' import SettingsDialog from '@/components/SettingsDialog.vue'
import { checkLogin } from '@/api/auth' import { checkLogin } from '@/api/auth'
import { fetchMessageList, fetchUnreadCount, markMessageRead } from '@/api/message'
import type { MessageDto } from '@/api/message'
import navJobsIcon from '@/assets/images/nav/nav-jobs-icon.png' import navJobsIcon from '@/assets/images/nav/nav-jobs-icon.png'
import navResumeIcon from '@/assets/images/nav/nav-resume-icon.png' import navResumeIcon from '@/assets/images/nav/nav-resume-icon.png'
import navProfileIcon from '@/assets/images/nav/nav-profile-icon.png' import navProfileIcon from '@/assets/images/nav/nav-profile-icon.png'
@@ -167,6 +195,137 @@ const showSettingsDialog = ref(false)
const feedbackType = ref('') const feedbackType = ref('')
const feedbackDetail = ref('') const feedbackDetail = ref('')
// ==================== 站内信相关 ====================
/** 未读消息数量 */
const unreadCount = ref(0)
/** 消息列表数据 */
const messageList = ref<MessageDto[]>([])
/** 当前选中的消息索引 */
const selectedMessageIdx = ref(0)
/** 当前页码 */
const messagePageNum = ref(1)
/** 是否还有更多数据 */
const messageHasMore = ref(true)
/** 加载状态 */
const messageLoading = ref(false)
/** 当前选中的消息对象 */
const currentMessage = computed(() => {
return messageList.value[selectedMessageIdx.value] || null
})
/**
* 获取未读消息数量
*/
async function loadUnreadCount() {
try {
const res = await fetchUnreadCount()
// 接口返回结构: { code, data: number }
unreadCount.value = res.data ?? 0
} catch (e) {
console.error('获取未读消息数量失败', e)
}
}
// 组件挂载时获取未读消息数量
onMounted(() => {
loadUnreadCount()
})
/**
* 加载消息列表(分页追加)
*/
async function loadMessages() {
if (messageLoading.value || !messageHasMore.value) return
messageLoading.value = true
try {
const res = await fetchMessageList({ pageNum: messagePageNum.value, pageSize: 20 })
// 接口返回结构: { code, msg, data: { pageNum, pageSize, total, list } }
const pageData = res.data
if (pageData && pageData.list && pageData.list.length > 0) {
messageList.value.push(...pageData.list)
// 判断是否还有更多
messageHasMore.value = messageList.value.length < pageData.total
messagePageNum.value++
} else {
messageHasMore.value = false
}
} catch (e) {
console.error('加载消息失败', e)
} finally {
messageLoading.value = false
}
}
/**
* 消息列表滚动到底部时加载下一页
*/
function handleMessageListScroll(e: Event) {
const el = e.target as HTMLElement
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 10) {
loadMessages()
}
}
/**
* 刷新消息列表数据(保持当前选中的消息不变)
*/
async function refreshMessageList(keepMessageId: number | string) {
try {
// 重新请求已加载的所有页数据
const totalLoaded = messageList.value.length
const res = await fetchMessageList({ pageNum: 1, pageSize: totalLoaded })
const pageData = res.data
if (pageData && pageData.list) {
messageList.value = pageData.list
// 根据之前选中的消息ID恢复选中状态
const idx = messageList.value.findIndex((m: MessageDto) => String(m.id) === String(keepMessageId))
selectedMessageIdx.value = idx >= 0 ? idx : 0
}
} catch (e) {
console.error('刷新消息列表失败', e)
}
}
/**
* 上报消息已读:当右侧显示某条消息时,如果该消息未读则标记已读
* 标记成功后刷新列表数据和未读数量
*/
async function handleMarkRead(msg: MessageDto) {
if (msg.read) return
try {
await markMessageRead(msg.id)
// 刷新列表状态,保持当前选中
await refreshMessageList(msg.id)
// 更新未读数量
loadUnreadCount()
} catch (e) {
console.error('标记已读失败', e)
}
}
/**
* 监听当前选中消息变化,触发已读上报
*/
watch(currentMessage, (msg) => {
if (msg && !msg.read) {
handleMarkRead(msg)
}
})
/**
* 监听弹窗打开,重置并加载第一页消息
*/
watch(showMessageDialog, (val) => {
if (val) {
messageList.value = []
messagePageNum.value = 1
messageHasMore.value = true
selectedMessageIdx.value = 0
loadMessages()
}
})
const feedbackOptions = ['Bug反馈', '功能建议', '使用体验', '订阅及会员权益相关问题', '其它'] const feedbackOptions = ['Bug反馈', '功能建议', '使用体验', '订阅及会员权益相关问题', '其它']
function handleFeedbackSubmit() { function handleFeedbackSubmit() {
@@ -194,7 +353,7 @@ const settingsMenu = computed(() => {
}) })
const footerMenus = computed(() => [ const footerMenus = computed(() => [
{ iconImg: navMessageIcon, label: '消息通知', badge: 'NEW', action: () => { showMessageDialog.value = true } }, { iconImg: navMessageIcon, label: '消息通知', badge: unreadCount.value > 0 ? 'NEW' : '', action: () => { showMessageDialog.value = true } },
{ iconImg: settingsMenu.value.iconImg, label: settingsMenu.value.label, action: () => { handleSettingsNav() } }, { iconImg: settingsMenu.value.iconImg, label: settingsMenu.value.label, action: () => { handleSettingsNav() } },
{ iconImg: navFeedbackIcon, label: '反馈', action: () => { showFeedbackDialog.value = true } }, { iconImg: navFeedbackIcon, label: '反馈', action: () => { showFeedbackDialog.value = true } },
]) ])
+24 -1
View File
@@ -27,6 +27,12 @@ const router = createRouter({
routes: staticRoutes, routes: staticRoutes,
}) })
/**
* 上次静默校验登录状态的时间戳,用于节流(5 分钟内不重复请求)
*/
let lastCheckTime = 0
const CHECK_INTERVAL = 5 * 60 * 1000 // 5 分钟
/** /**
* 全局前置守卫 — 核心逻辑: * 全局前置守卫 — 核心逻辑:
* *
@@ -34,7 +40,7 @@ const router = createRouter({
* 2. 需要鉴权的路由 → 调 checkLogin 接口验证 Cookie 是否有效 * 2. 需要鉴权的路由 → 调 checkLogin 接口验证 Cookie 是否有效
* - 有效:同步 isAuthenticated = true,放行 * - 有效:同步 isAuthenticated = true,放行
* - 无效:同步 isAuthenticated = false,弹登录框,阻止导航 * - 无效:同步 isAuthenticated = false,弹登录框,阻止导航
* 3. 不需要鉴权的路由 → 直接放行 * 3. 不需要鉴权的路由 → 静默同步登录状态(不阻止导航、不弹登录框),5 分钟内节流
*/ */
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
// 动态路由只需加载一次,与登录状态无关 // 动态路由只需加载一次,与登录状态无关
@@ -53,6 +59,7 @@ router.beforeEach(async (to, _from, next) => {
if (to.meta?.requiresAuth) { if (to.meta?.requiresAuth) {
try { try {
const res = await checkLogin() const res = await checkLogin()
lastCheckTime = Date.now()
if (res.code === '0' && res.data === true) { if (res.code === '0' && res.data === true) {
// Cookie 有效,同步前端状态并放行 // Cookie 有效,同步前端状态并放行
store.commit('SET_AUTHENTICATED', true) store.commit('SET_AUTHENTICATED', true)
@@ -72,6 +79,22 @@ router.beforeEach(async (to, _from, next) => {
return return
} }
// 非鉴权路由 — 静默同步登录状态(节流:5 分钟内不重复请求)
if (Date.now() - lastCheckTime > CHECK_INTERVAL) {
checkLogin()
.then((res) => {
lastCheckTime = Date.now()
if (res.code === '0' && res.data === true) {
store.commit('SET_AUTHENTICATED', true)
} else {
store.commit('SET_AUTHENTICATED', false)
}
})
.catch(() => {
store.commit('SET_AUTHENTICATED', false)
})
}
next() next()
}) })
+57
View File
@@ -0,0 +1,57 @@
/**
* 求职意向名称解析工具
* 从 store 中的分类树数据里,根据意向 ID/Code 查找对应名称
*/
import store from '@/stores'
/**
* 获取意向岗位类型名称列表
* 根据 jobIntention.categoryIds 在 jobCategories 树中查找名称
*/
export function getIntentionCategoryNames(): string[] {
const ids = store.state.jobIntention.categoryIds || []
if (ids.length === 0) return []
const names: string[] = []
for (const group of store.state.jobCategories) {
for (const child of group.children || []) {
for (const leaf of child.children || []) {
if (ids.includes(Number(leaf.id))) names.push(leaf.name)
}
}
}
return names
}
/**
* 获取意向城市名称列表
* 根据 jobIntention.regionCodes 在 regions 树中查找名称
*/
export function getIntentionRegionNames(): string[] {
const codes = store.state.jobIntention.regionCodes || []
if (codes.length === 0) return []
const names: string[] = []
for (const province of store.state.regions) {
if (codes.includes(province.code)) { names.push(province.name); continue }
for (const city of province.children || []) {
if (codes.includes(city.code)) names.push(city.name)
}
}
return names
}
/**
* 获取意向行业名称列表
* 根据 jobIntention.industryIds 在 industries 树中查找名称
*/
export function getIntentionIndustryNames(): string[] {
const ids = store.state.jobIntention.industryIds || []
if (ids.length === 0) return []
const names: string[] = []
for (const group of store.state.industries) {
if (ids.includes(Number(group.id))) { names.push(group.name); continue }
for (const child of group.children || []) {
if (ids.includes(Number(child.id))) names.push(child.name)
}
}
return names
}
+2 -3
View File
@@ -54,13 +54,12 @@ export function exportResumeWord(element: HTMLElement, fileName: string) {
.resume-html__divider { height: 1.5px; background: #000; margin-bottom: 9.6pt; } .resume-html__divider { height: 1.5px; background: #000; margin-bottom: 9.6pt; }
.resume-html__summary { font-size: 12pt; line-height: 1.7; margin-bottom: 4.8pt; } .resume-html__summary { font-size: 12pt; line-height: 1.7; margin-bottom: 4.8pt; }
.resume-html__item { margin-bottom: 9.6pt; } .resume-html__item { margin-bottom: 9.6pt; }
.resume-html__item-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 3.6pt; } .resume-html__item-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 2pt; }
.resume-html__item-left { display: flex; flex-direction: column; gap: 1.2pt; }
.resume-html__item-main { font-size: 13.2pt; font-weight: 600; color: #000; line-height: 1.4; } .resume-html__item-main { font-size: 13.2pt; font-weight: 600; color: #000; line-height: 1.4; }
.resume-html__item-right { display: flex; align-items: center; gap: 4.8pt; flex-shrink: 0; text-align: right; } .resume-html__item-right { display: flex; align-items: center; gap: 4.8pt; flex-shrink: 0; text-align: right; }
.resume-html__item-location { font-size: 12pt; color: #000; } .resume-html__item-location { font-size: 12pt; color: #000; }
.resume-html__item-date { font-size: 12pt; color: #000; white-space: nowrap; } .resume-html__item-date { font-size: 12pt; color: #000; white-space: nowrap; }
.resume-html__item-desc { font-size: 12pt; color: #000; line-height: 1.5; } .resume-html__item-desc { font-size: 12pt; color: #000; line-height: 1.7; margin-top: 2pt; }
.resume-html__desc-list { margin: 0; padding-left: 0; list-style: none; mso-list: none; } .resume-html__desc-list { margin: 0; padding-left: 0; list-style: none; mso-list: none; }
.resume-html__desc-list li { font-size: 12pt; line-height: 1.7; color: #000; list-style: none; mso-list: none; margin-left: 0; padding-left: 24pt; text-indent: 0; } .resume-html__desc-list li { font-size: 12pt; line-height: 1.7; color: #000; list-style: none; mso-list: none; margin-left: 0; padding-left: 24pt; text-indent: 0; }
.resume-html__skills { font-size: 12pt; line-height: 1.7; } .resume-html__skills { font-size: 12pt; line-height: 1.7; }
+579 -79
View File
@@ -28,26 +28,37 @@
</svg> </svg>
</div> </div>
<!-- 状态标签 --> <!-- 状态标签 -->
<span class="agent-main__status-tag">准备就绪</span> <span v-if="applyJobList.length > 0" class="agent-main__status-tag">准备就绪</span>
<!-- 提示文字 --> <!-- 提示文字 -->
<span class="agent-main__status-text">已添加一个岗位随时可以开始投递</span> <span class="agent-main__status-text">{{ applyJobList.length > 0 ? `已添加${applyJobList.length}个岗位,随时可以开始投递` : '请添加岗位' }}</span>
<!-- 开始按钮 --> <!-- 开始按钮投递中时置灰不可点击暂停时显示继续可点击 -->
<button class="agent-main__start-btn">开始 </button> <button
class="agent-main__start-btn"
:disabled="isApplying && !isPaused"
@click="handleTopBarStartClick"
>{{ isApplying ? (isPaused ? '继续' : '投递中...') : '开始 ▸' }}</button>
<!-- 右侧工具按钮 --> <!-- 右侧工具按钮 -->
<div class="agent-main__tools"> <div class="agent-main__tools">
<!-- 设置按钮 --> <!-- 待投递列表按钮 -->
<button class="agent-main__tool-btn" title="设置"> <button class="agent-main__tool-btn" title="待投递列表" @click.stop="toggleTaskListDropdown">
<svg viewBox="0 0 24 24" fill="none"> <svg viewBox="0 0 24 24" fill="none">
<path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> <path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg> </svg>
</button> </button>
<!-- 待投递列表下拉弹窗 -->
<AgentTaskListDropdown
v-if="showTaskListDropdown"
:pending-list="applyJobList"
@removed="handleTaskRemoved"
@view-all="showTaskListDropdown = false"
/>
<!-- 齿轮按钮 --> <!-- 齿轮按钮 -->
<button class="agent-main__tool-btn" title="配置"> <!-- <button class="agent-main__tool-btn" title="配置">-->
<svg viewBox="0 0 24 24" fill="none"> <!-- <svg viewBox="0 0 24 24" fill="none">-->
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.5" /> <!-- <circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.5" />-->
<path d="M12 1v2m0 18v2m-9-11h2m18 0h2m-3.3-7.7-1.4 1.4M4.7 19.3l1.4-1.4m0-11.8L4.7 4.7m14.6 14.6-1.4-1.4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> <!-- <path d="M12 1v2m0 18v2m-9-11h2m18 0h2m-3.3-7.7-1.4 1.4M4.7 19.3l1.4-1.4m0-11.8L4.7 4.7m14.6 14.6-1.4-1.4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />-->
</svg> <!-- </svg>-->
</button> <!-- </button>-->
</div> </div>
</div> </div>
@@ -69,16 +80,26 @@
<div v-else-if="msg.type === 'recommend'" class="agent-main__chat-row"> <div v-else-if="msg.type === 'recommend'" class="agent-main__chat-row">
<!-- 如果 extra 里有岗位数据用岗位列表组件展示 --> <!-- 如果 extra 里有岗位数据用岗位列表组件展示 -->
<AgentChatJobList <AgentChatJobList
v-if="getRecommendJobsFromExtra(msg.extra).length > 0"
:summary="getRecommendSummaryFromExtra(msg.extra)" :summary="getRecommendSummaryFromExtra(msg.extra)"
:jobs="getRecommendJobsFromExtra(msg.extra)" :jobs="getRecommendJobsFromExtra(msg.extra)"
@view-all="handleOpenRecommendPanel(msg.id)" @view-all="handleOpenRecommendPanel(msg.id)"
/> />
</div> </div>
<!-- 投递进度消息 --> <!-- 投递进度消息 仅显示最新 recommend 之后的 apply_progress -->
<div v-else-if="msg.type === 'apply_progress'" class="agent-main__chat-row"> <div v-else-if="msg.type === 'apply_progress' && isVisibleApplyProgress(msg.id)" class="agent-main__chat-row">
<div class="agent-main__chat-bubble">{{ msg.content }}</div> <AgentApplyProgress
:job-info="getApplyProgressJobInfo(msg.extra)"
:current-step="getApplyProgressStep(msg.extra)"
:resume-name="getApplyProgressResumeName(msg.extra)"
:paused="isPaused"
@cancel="handleCancelApply(msg)"
@confirm-resume="handleConfirmResumeStep(msg)"
@skip="handleSkipApply(msg)"
@applied="handleAppliedStep(msg)"
@go-apply="handleGoApplyStep(msg)"
@toggle-pause="handleTogglePause"
/>
</div> </div>
</template> </template>
@@ -105,9 +126,11 @@
</div> </div>
</div> </div>
<!-- 右侧匹配岗位添加面板 --> <!-- 右侧匹配岗位添加面板 / 简历生成进度 / 简历预览 -->
<div v-if="showRightPanel" class="agent-main__right"> <div v-if="showRightPanel" class="agent-main__right">
<!-- 模式1匹配岗位添加面板 -->
<AgentMatchJobAdd <AgentMatchJobAdd
v-if="rightPanelMode === 'recommend'"
:jobs="activeRecommendJobs" :jobs="activeRecommendJobs"
:loading-job-ids="loadingJobIds" :loading-job-ids="loadingJobIds"
:panel-loading="panelLoading" :panel-loading="panelLoading"
@@ -116,6 +139,21 @@
@toggle="handleToggleJobApply" @toggle="handleToggleJobApply"
@add-all="handleAddAllJobs" @add-all="handleAddAllJobs"
/> />
<!-- 模式2简历生成进度 -->
<div v-else-if="rightPanelMode === 'generating'" class="agent-resume-generating">
<div class="agent-resume-generating__progress-bar">
<div class="agent-resume-generating__progress-fill" :style="{ width: generateProgress + '%' }"></div>
</div>
<div class="agent-resume-generating__title">正在生成岗位专属简历</div>
<div class="agent-resume-generating__hint">
<svg viewBox="0 0 16 16" fill="none" class="agent-resume-generating__hint-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>生成简历大约需要10-20</span>
</div>
</div>
<!-- 模式3简历预览 -->
<div v-else-if="rightPanelMode === 'resume'" class="agent-main__right-resume">
<JobResumeTemplate :resume-data="applyResumeData" />
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -128,12 +166,16 @@ import SideNav from '@/components/SideNav.vue'
import AgentSetupWizard from '@/components/AgentSetupWizard.vue' import AgentSetupWizard from '@/components/AgentSetupWizard.vue'
import AgentChatJobList from '@/components/AgentChatJobList.vue' import AgentChatJobList from '@/components/AgentChatJobList.vue'
import AgentMatchJobAdd from '@/components/AgentMatchJobAdd.vue' import AgentMatchJobAdd from '@/components/AgentMatchJobAdd.vue'
import { fetchAgentConfig, saveAgentConfig, fetchAgentRecommend, fetchAgentChatMessages, addAgentChatMessage, applyJob, cancelApplyJob, fetchApplyByJobIds, sendAgentChat } from '@/api/agent' import AgentApplyProgress from '@/components/AgentApplyProgress.vue'
import AgentTaskListDropdown from '@/components/AgentTaskListDropdown.vue'
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
import { fetchAgentConfig, saveAgentConfig, fetchAgentRecommend, fetchAgentChatMessages, addAgentChatMessage, applyJob, cancelApplyJob, fetchApplyByJobIds, sendAgentChat, optimizeAgentResume } from '@/api/agent'
import type { AgentConfig, AgentRecommendJob, AgentChatMessage, AgentChatHistoryItem } from '@/api/agent' import type { AgentConfig, AgentRecommendJob, AgentChatMessage, AgentChatHistoryItem } from '@/api/agent'
import { fetchApplyList } from '@/api/jobs' import { fetchAgentTaskList } from '@/api/jobs'
import type { JobListItem } from '@/api/jobs' import type { JobListItem } from '@/api/jobs'
import { fetchResumeList } from '@/api/resume' import { fetchResumeList } from '@/api/resume'
import store from '@/stores' import { getIntentionCategoryNames, getIntentionRegionNames, getIntentionIndustryNames } from '@/utils/intention'
import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue' import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue'
/** 页面初始加载状态 */ /** 页面初始加载状态 */
@@ -165,9 +207,12 @@ const activeRecommendJobs = computed(() => {
/** 聊天输入框内容 */ /** 聊天输入框内容 */
const chatInput = ref('') const chatInput = ref('')
/** 投递列表数据 */ /** 投递列表数据 */
const applyJobList = ref<JobListItem[]>([]) const applyJobList = ref<JobListItem[]>([])
/** 待投递列表下拉弹窗是否显示 */
const showTaskListDropdown = ref(false)
/** 正在请求中的岗位 ID 列表(传给子组件控制按钮 loading) */ /** 正在请求中的岗位 ID 列表(传给子组件控制按钮 loading) */
const loadingJobIds = ref<number[]>([]) const loadingJobIds = ref<number[]>([])
@@ -178,7 +223,15 @@ const panelLoading = ref(false)
const defaultResumeId = ref('') const defaultResumeId = ref('')
/** 是否正在发送消息(防止重复发送 + 显示等待状态) */ /** 是否正在发送消息(防止重复发送 + 显示等待状态) */
const isSending = ref(false)/** 聊天区域 DOM 引用 */ const isSending = ref(false)
/** 是否正在投递流程中(防止重复点击开始按钮) */
const isApplying = ref(false)
/** 是否暂停投递 */
const isPaused = ref(false)
/** 聊天区域 DOM 引用 */
const chatAreaRef = ref<HTMLElement | null>(null) const chatAreaRef = ref<HTMLElement | null>(null)
/** 滚动聊天区域到底部(平滑滚动) */ /** 滚动聊天区域到底部(平滑滚动) */
@@ -191,8 +244,15 @@ function scrollChatToBottom() {
/** 页面挂载时查询配置 */ /** 页面挂载时查询配置 */
onMounted(async () => { onMounted(async () => {
await loadAgentConfig() await loadAgentConfig()
// 点击页面其他区域关闭待投递列表下拉
document.addEventListener('click', closeTaskDropdownOnClickOutside)
}) })
/** 点击外部关闭待投递列表下拉 */
function closeTaskDropdownOnClickOutside() {
showTaskListDropdown.value = false
}
/** 查询求职助手配置 */ /** 查询求职助手配置 */
async function loadAgentConfig() { async function loadAgentConfig() {
pageLoading.value = true pageLoading.value = true
@@ -227,6 +287,20 @@ async function loadRecommendJobs() {
if (res.code === '0' && res.data && res.data.list?.length > 0) { if (res.code === '0' && res.data && res.data.list?.length > 0) {
const summary = res.data.summary || '' const summary = res.data.summary || ''
const jobList = res.data.list const jobList = res.data.list
// if(jobList.length==0){
// const aiNow = Math.floor(Date.now() / 1000)
// chatMessages.value.push({
// id: Date.now() + 1,
// type: 'assistant',
// content: '暂无相关职位推荐',
// extra: '',
// createTime: { seconds: aiNow, nanos: 0 },
// })
// try {
// await addAgentChatMessage({ type: 'assistant', content: '暂无相关职位推荐' })
// } catch { /* 静默 */ }
// }
/* 将完整岗位数据序列化为 extra JSON */ /* 将完整岗位数据序列化为 extra JSON */
const extraJson = JSON.stringify({ summary, list: jobList }) const extraJson = JSON.stringify({ summary, list: jobList })
@@ -275,15 +349,31 @@ async function loadChatMessages() {
} }
} }
/** 加载岗位投递列表 */ /** 加载求职助手任务列表(tab=1 进行中/待投递) */
async function loadApplyList() { async function loadApplyList() {
try { try {
const res = await fetchApplyList({ pageNum: 1, pageSize: 100,status:0 }) const res = await fetchAgentTaskList({ pageNum: 1, pageSize: 100, tab: 1 })
if (res.code === '0' && res.data) { if (res.code === '0' && res.data) {
applyJobList.value = res.data.list || [] applyJobList.value = res.data.list || []
} }
} catch { } catch {
console.error('[Agent] 加载投递列表失败') console.error('[Agent] 加载求职助手任务列表失败')
}
}
/** 切换待投递列表下拉弹窗显隐 */
function toggleTaskListDropdown() {
showTaskListDropdown.value = !showTaskListDropdown.value
if (showTaskListDropdown.value) {
loadApplyList()
}
}
/** 岗位从待投递列表中移除后的回调 */
function handleTaskRemoved(jobId: string) {
const idx = applyJobList.value.findIndex(j => j.id === jobId)
if (idx !== -1) {
applyJobList.value.splice(idx, 1)
} }
} }
@@ -320,58 +410,6 @@ function buildChatHistory(): AgentChatHistoryItem[] {
}) })
} }
/**
* 从 store 中获取意向岗位类型名称列表
* 根据 jobIntention.categoryIds 在 jobCategories 树中查找名称
*/
function getIntentionCategoryNames(): string[] {
const ids = store.state.jobIntention.categoryIds || []
if (ids.length === 0) return []
const names: string[] = []
for (const group of store.state.jobCategories) {
for (const child of group.children || []) {
for (const leaf of child.children || []) {
if (ids.includes(Number(leaf.id))) names.push(leaf.name)
}
}
}
return names
}
/**
* 从 store 中获取意向城市名称列表
* 根据 jobIntention.regionCodes 在 regions 树中查找名称
*/
function getIntentionRegionNames(): string[] {
const codes = store.state.jobIntention.regionCodes || []
if (codes.length === 0) return []
const names: string[] = []
for (const province of store.state.regions) {
if (codes.includes(province.code)) { names.push(province.name); continue }
for (const city of province.children || []) {
if (codes.includes(city.code)) names.push(city.name)
}
}
return names
}
/**
* 从 store 中获取意向行业名称列表
* 根据 jobIntention.industryIds 在 industries 树中查找名称
*/
function getIntentionIndustryNames(): string[] {
const ids = store.state.jobIntention.industryIds || []
if (ids.length === 0) return []
const names: string[] = []
for (const group of store.state.industries) {
if (ids.includes(Number(group.id))) { names.push(group.name); continue }
for (const child of group.children || []) {
if (ids.includes(Number(child.id))) names.push(child.name)
}
}
return names
}
/** /**
* 从消息的 extra JSON 中解析推荐岗位列表 * 从消息的 extra JSON 中解析推荐岗位列表
* extra 格式预期为 JSON 字符串,包含 list 字段 * extra 格式预期为 JSON 字符串,包含 list 字段
@@ -478,6 +516,7 @@ async function handleToggleJobApply(job: AgentRecommendJob) {
} }
/* 更新对话消息中的岗位状态 */ /* 更新对话消息中的岗位状态 */
updateJobStatusInMessages(job.id, isRemoving ? null : -1) updateJobStatusInMessages(job.id, isRemoving ? null : -1)
await loadApplyList()
ElMessage.success(isRemoving ? '岗位已从待投递移除' : '岗位已添加到待投递') ElMessage.success(isRemoving ? '岗位已从待投递移除' : '岗位已添加到待投递')
} catch { } catch {
ElMessage.error('操作失败,请重试') ElMessage.error('操作失败,请重试')
@@ -507,6 +546,7 @@ async function handleAddAllJobs() {
loadingJobIds.value = loadingJobIds.value.filter(id => id !== job.id) loadingJobIds.value = loadingJobIds.value.filter(id => id !== job.id)
} }
} }
await loadApplyList()
ElMessage.success('已全部添加到待投递') ElMessage.success('已全部添加到待投递')
} }
@@ -568,6 +608,451 @@ async function handleOpenRecommendPanel(msgId: number) {
function handleCloseRecommendPanel() { function handleCloseRecommendPanel() {
showRightPanel.value = false showRightPanel.value = false
activeRecommendMsgId.value = null activeRecommendMsgId.value = null
rightPanelMode.value = 'recommend'
}
// ==================== 投递进度流程 ====================
/** 右侧面板显示模式:recommend-岗位推荐 / generating-简历生成中 / resume-简历预览 */
const rightPanelMode = ref<'recommend' | 'generating' | 'resume'>('recommend')
/** 简历生成进度百分比(0-100) */
const generateProgress = ref(0)
/** 当前投递流程的简历数据 */
const applyResumeData = ref<ResumeTemplateData>({} as ResumeTemplateData)
/** 当前投递流程的简历名称 */
const applyResumeName = ref('')
/** 进度条定时器 */
let progressTimer: ReturnType<typeof setInterval> | null = null
/** 启动模拟进度条(慢慢增长到 90%,接口返回后快速涨满) */
function startProgressSimulation() {
generateProgress.value = 0
let elapsed = 0
progressTimer = setInterval(() => {
elapsed += 300
// 前 5 秒涨到 30%5-15 秒涨到 70%15-25 秒涨到 90%
if (generateProgress.value < 30) {
generateProgress.value += 2
} else if (generateProgress.value < 70) {
generateProgress.value += 1
} else if (generateProgress.value < 90) {
generateProgress.value += 0.5
}
// 最多到 90%
if (generateProgress.value >= 90) {
generateProgress.value = 90
}
}, 300)
}
/** 停止进度条并涨满 */
function finishProgress() {
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
generateProgress.value = 100
}
/** 学历文字转数字(接口返回中文,模板需要数字) */
function degreeToNumber(degree?: string): number {
const map: Record<string, number> = { '大专': 1, '本科': 2, '硕士': 3, '博士': 4 }
return map[degree || ''] || 2
}
/** 将优化简历接口返回数据映射为 ResumeTemplateData 格式 */
function mapOptimizeResumeToTemplate(data: any): ResumeTemplateData {
const r = data.resume || {}
return {
name: r.name || '未填写姓名',
email: r.email || '',
mobileNumber: r.mobileNumber || '',
wechatNumber: r.wechatNumber || '',
summary: r.summary || '',
educations: (data.education || []).map((e: any) => ({
school: e.school || '',
major: e.major || '',
degree: degreeToNumber(e.degree),
startDate: e.startDate || '',
endDate: e.endDate || '',
description: (e.description || []).map((d: any) => ({ id: d.id, text: d.text || '' })),
})),
workExperiences: (data.work || []).map((w: any) => ({
companyName: w.companyName || '',
position: w.position || '',
startDate: w.startDate || '',
endDate: w.endDate || '',
description: (w.description || []).map((d: any) => ({ id: d.id, text: d.text || '' })),
})),
internships: (data.internship || []).map((i: any) => ({
companyName: i.companyName || '',
position: i.position || '',
startDate: i.startDate || '',
endDate: i.endDate || '',
description: (i.description || []).map((d: any) => ({ id: d.id, text: d.text || '' })),
})),
projects: (data.project || []).map((p: any) => ({
projectName: p.projectName || '',
companyName: p.companyName || '',
role: p.role || '',
startDate: p.startDate || '',
endDate: p.endDate || '',
description: (p.description || []).map((d: any) => ({ id: d.id, text: d.text || '' })),
})),
competitions: (data.competition || []).map((c: any) => ({
competitionName: c.competitionName || '',
award: c.award || '',
awardDate: c.awardDate || '',
description: (c.description || []).map((d: any) => ({ id: d.id, text: d.text || '' })),
})),
skills: r.skills || [],
certificates: r.certificates || [],
}
}
/**
* 点击"开始"按钮 — 启动投递流程
* 1. 刷新待投递列表
* 2. 取第一个岗位
* 3. 创建 apply_progress 消息
* 4. 开始第1步:优化简历
*/
async function handleStartApply() {
// 防抖:投递中不可重复点击
if (isApplying.value) return
isApplying.value = true
// 刷新待投递列表
await loadApplyList()
if (applyJobList.value.length === 0) {
ElMessage.warning('暂无待投递岗位,请先添加岗位')
isApplying.value = false
return
}
// 取第一个岗位
const targetJob = applyJobList.value[0]
// 构建 extra 数据
const extraData = {
jobInfo: targetJob,
resumeInfo: null as any,
step: 1,
}
// 创建 apply_progress 消息并追加到对话列表
const now = Math.floor(Date.now() / 1000)
const applyMsg: AgentChatMessage = {
id: Date.now(),
type: 'apply_progress',
content: '',
extra: JSON.stringify(extraData),
createTime: { seconds: now, nanos: 0 },
}
chatMessages.value.push(applyMsg)
await nextTick()
scrollChatToBottom()
// ========== 第1步:优化简历 ==========
// 打开右侧面板,显示生成进度
rightPanelMode.value = 'generating'
showRightPanel.value = true
startProgressSimulation()
try {
const res = await optimizeAgentResume({
resumeId: defaultResumeId.value,
jobId: targetJob.id,
})
// 接口返回后涨满进度条
finishProgress()
// 等待 1 秒让用户看到 100%
await new Promise(resolve => setTimeout(resolve, 1000))
// 解析简历数据 — 映射接口返回格式到 ResumeTemplateData
const apiData = res?.data || res
if (apiData) {
const resumeResult = mapOptimizeResumeToTemplate(apiData)
// 保存简历数据
applyResumeData.value = resumeResult
applyResumeName.value = resumeResult.name || '岗位专属简历'
// 更新 extra:存入 resumeInfostep 进到 2
extraData.resumeInfo = apiData
extraData.step = 2
applyMsg.extra = JSON.stringify(extraData)
// 切换右侧面板为简历预览
rightPanelMode.value = 'resume'
}
} catch (e) {
console.error('[Agent] 优化简历失败', e)
ElMessage.error('简历优化失败,请重试')
finishProgress()
showRightPanel.value = false
rightPanelMode.value = 'recommend'
isApplying.value = false
}
}
// ==================== apply_progress 消息辅助方法 ====================
/** 从 extra 中解析岗位信息 */
function getApplyProgressJobInfo(extra: string): JobListItem | null {
try {
const parsed = JSON.parse(extra)
return parsed.jobInfo || null
} catch {
return null
}
}
/** 从 extra 中解析当前步骤 */
function getApplyProgressStep(extra: string): number {
try {
const parsed = JSON.parse(extra)
return parsed.step || 1
} catch {
return 1
}
}
/** 从 extra 中解析简历名称 */
function getApplyProgressResumeName(extra: string): string {
try {
const parsed = JSON.parse(extra)
return parsed.resumeInfo?.name || '岗位专属简历'
} catch {
return '岗位专属简历'
}
}
/**
* apply_progress 显示模式
* 'afterRecommend' — 显示最新 recommend 之后的所有 apply_progress
* 'latestOnly' — 只显示最新一条 apply_progress
* 切换此变量即可手动切换显示效果
*/
const applyProgressDisplayMode = ref<'afterRecommend' | 'latestOnly'>('latestOnly')
/**
* 判断 apply_progress 消息是否应该显示
*/
function isVisibleApplyProgress(msgId: number): boolean {
if (applyProgressDisplayMode.value === 'latestOnly') {
// 只显示最新一条 apply_progress
for (let i = chatMessages.value.length - 1; i >= 0; i--) {
if (chatMessages.value[i].type === 'apply_progress') {
return chatMessages.value[i].id === msgId
}
}
return false
}
// afterRecommend 模式:显示最新 recommend 之后的所有 apply_progress
let lastRecommendIdx = -1
for (let i = chatMessages.value.length - 1; i >= 0; i--) {
if (chatMessages.value[i].type === 'recommend') {
lastRecommendIdx = i
break
}
}
const msgIdx = chatMessages.value.findIndex(m => m.id === msgId)
if (lastRecommendIdx === -1) return true
return msgIdx > lastRecommendIdx
}
// ==================== apply_progress 步骤事件处理 ====================
/** 顶部栏按钮点击 — 未投递时开始,暂停时恢复 */
function handleTopBarStartClick() {
if (isApplying.value && isPaused.value) {
// 恢复投递
isPaused.value = false
} else if (!isApplying.value) {
// 开始新的投递
handleStartApply()
}
}
/** 暂停/恢复投递(由 AgentApplyProgress 组件触发) */
function handleTogglePause() {
isPaused.value = !isPaused.value
}
/** 取消投递流程 */
function handleCancelApply(msg: AgentChatMessage) {
// 从消息列表中移除该条 apply_progress
const idx = chatMessages.value.findIndex(m => m.id === msg.id)
if (idx !== -1) {
chatMessages.value.splice(idx, 1)
}
// 关闭右侧面板
showRightPanel.value = false
rightPanelMode.value = 'recommend'
// 投递流程结束
isApplying.value = false
}
/** 第2步:确认简历 */
function handleConfirmResumeStep(msg: AgentChatMessage) {
try {
const extraData = JSON.parse(msg.extra)
// 勾选第2步,进入第3步
extraData.step = 3
msg.extra = JSON.stringify(extraData)
} catch { /* 忽略 */ }
}
/** 第3步:跳过投递 */
function handleSkipApply(msg: AgentChatMessage) {
try {
const extraData = JSON.parse(msg.extra)
// 勾选第3步,进入第4步(自动完成)
extraData.step = 4
msg.extra = JSON.stringify(extraData)
// 持久化保存
persistApplyProgressMessage(msg)
// 自动开始下一个岗位投递(不关闭右侧面板,避免闪烁)
autoStartNextApply()
} catch { /* 忽略 */ }
}
/** 第3步:我已投递 — 调用 /job/apply 接口 */
async function handleAppliedStep(msg: AgentChatMessage) {
try {
const extraData = JSON.parse(msg.extra)
const jobInfo = extraData.jobInfo
if (!jobInfo) return
// 调用投递接口,status=0 表示已投递,jobId 传字符串避免大整数精度丢失
const res = await applyJob({ jobId: String(jobInfo.id), status: 0 })
if (res.code === '0') {
// 勾选第3步,进入第4步
extraData.step = 4
msg.extra = JSON.stringify(extraData)
ElMessage.success('投递成功')
// 持久化保存
persistApplyProgressMessage(msg)
// 自动开始下一个岗位投递(不关闭右侧面板,避免闪烁)
autoStartNextApply()
}
} catch (e) {
console.error('[Agent] 投递失败', e)
ElMessage.error('投递失败,请重试')
}
}
/** 第3步:去投递 — 打开原链接 */
function handleGoApplyStep(msg: AgentChatMessage) {
try {
const extraData = JSON.parse(msg.extra)
const sourceUrl = extraData.jobInfo?.sourceUrl
if (sourceUrl) {
window.open(sourceUrl, '_blank')
}
} catch { /* 忽略 */ }
}
/** 持久化保存 apply_progress 消息到后端 */
async function persistApplyProgressMessage(msg: AgentChatMessage) {
try {
await addAgentChatMessage({
type: 'apply_progress',
content: msg.content,
extra: msg.extra,
})
} catch {
console.error('[Agent] 保存投递进度消息失败')
}
}
/**
* 自动开始下一个岗位投递
* 当前岗位完成(step=4)后,如果未暂停,自动刷新列表并开始下一个
*/
async function autoStartNextApply() {
// 如果已暂停,不自动继续
if (isPaused.value) {
isApplying.value = false
return
}
// 刷新待投递列表
await loadApplyList()
if (applyJobList.value.length === 0) {
// 没有更多待投递岗位,结束流程
isApplying.value = false
showRightPanel.value = false
rightPanelMode.value = 'recommend'
ElMessage.success('所有待投递岗位已处理完毕')
return
}
// 取下一个岗位,开始新一轮投递
const targetJob = applyJobList.value[0]
const extraData = {
jobInfo: targetJob,
resumeInfo: null as any,
step: 1,
}
const now = Math.floor(Date.now() / 1000)
const applyMsg: AgentChatMessage = {
id: Date.now(),
type: 'apply_progress',
content: '',
extra: JSON.stringify(extraData),
createTime: { seconds: now, nanos: 0 },
}
chatMessages.value.push(applyMsg)
await nextTick()
scrollChatToBottom()
// 第1步:优化简历
rightPanelMode.value = 'generating'
showRightPanel.value = true
startProgressSimulation()
try {
const res = await optimizeAgentResume({
resumeId: defaultResumeId.value,
jobId: targetJob.id,
})
finishProgress()
await new Promise(resolve => setTimeout(resolve, 1000))
const apiData = res?.data || res
if (apiData) {
const resumeResult = mapOptimizeResumeToTemplate(apiData)
applyResumeData.value = resumeResult
applyResumeName.value = resumeResult.name || '岗位专属简历'
extraData.resumeInfo = apiData
extraData.step = 2
applyMsg.extra = JSON.stringify(extraData)
rightPanelMode.value = 'resume'
}
} catch (e) {
console.error('[Agent] 优化简历失败', e)
ElMessage.error('简历优化失败,请重试')
finishProgress()
showRightPanel.value = false
rightPanelMode.value = 'recommend'
isApplying.value = false
}
} }
/** 发送聊天消息 */ /** 发送聊天消息 */
@@ -644,11 +1129,11 @@ async function handleSendMessage() {
const excludeJobIds = collectExcludeJobIds() const excludeJobIds = collectExcludeJobIds()
try { try {
const recRes = await fetchAgentRecommend({ preference, excludeJobIds }) const recRes = await fetchAgentRecommend({ preference, excludeJobIds })
if (recRes.code === '0' && recRes.data && recRes.data.list?.length > 0) { if (recRes.code === '0' && recRes.data ) {
const summary = recRes.data.summary || '' const summary = recRes.data.summary || ''
const jobList = recRes.data.list const jobList = recRes.data.list
const extraJson = JSON.stringify({ summary, list: jobList }) const extraJson = JSON.stringify({ summary, list: jobList })
// const contentV = recRes.data.list?.length==0?'':''
/* 持久化 recommend 消息 */ /* 持久化 recommend 消息 */
try { try {
await addAgentChatMessage({ type: 'recommend', content: '', extra: extraJson }) await addAgentChatMessage({ type: 'recommend', content: '', extra: extraJson })
@@ -664,6 +1149,21 @@ async function handleSendMessage() {
createTime: { seconds: recNow, nanos: 0 }, createTime: { seconds: recNow, nanos: 0 },
}) })
// await nextTick()
// scrollChatToBottom()
// } else {
// /* 推荐列表为空 — 插入提示消息 */
// const emptyNow = Math.floor(Date.now() / 1000)
// chatMessages.value.push({
// id: Date.now() + 2,
// type: 'assistant',
// content: '暂无相关职位推荐',
// extra: '',
// createTime: { seconds: emptyNow, nanos: 0 },
// })
// try {
// await addAgentChatMessage({ type: 'assistant', content: '暂无相关职位推荐' })
// } catch { /* 静默 */ }
await nextTick() await nextTick()
scrollChatToBottom() scrollChatToBottom()
} }
+9 -25
View File
@@ -28,32 +28,16 @@
<p class="home-hero__desc">智能匹配职位自动填写申请量身定制简历推荐内部人脉不到1分钟统统搞定</p> <p class="home-hero__desc">智能匹配职位自动填写申请量身定制简历推荐内部人脉不到1分钟统统搞定</p>
<button class="home-hero__cta" @click="router.push('/jobs')">免费体验</button> <button class="home-hero__cta" @click="router.push('/jobs')">免费体验</button>
</div> </div>
<!-- 右侧视频展示区 -->
<div class="home-hero__right"> <div class="home-hero__right">
<div class="home-hero__card"> <video
<div class="home-hero__card-dots"> class="home-hero__video"
<span class="dot dot--red"></span> src="https://jsxq-image-static.oss-cn-shenzhen.aliyuncs.com/aiJob/find/open-intention-video.mp4"
<span class="dot dot--yellow"></span> autoplay
<span class="dot dot--green"></span> loop
</div> muted
<div class="home-hero__card-body"> playsinline
<div class="home-hero__card-user"> ></video>
<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>
</div> </div>
</section> </section>
+1 -1
View File
@@ -550,7 +550,7 @@ onBeforeUnmount(() => {
}) })
/** 弹出菜单操作项 */ /** 弹出菜单操作项 */
const popupActions = ['从列表中移除', '已投递', '复制链接', '问题反馈'] const popupActions = ['从列表中移除', '已投递', '复制链接']
// ==================== 职位列表项(扩展接口字段,增加前端交互状态) ==================== // ==================== 职位列表项(扩展接口字段,增加前端交互状态) ====================
+2 -1
View File
@@ -41,7 +41,8 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
}, },
'/ai-api': { '/ai-api': {
target: 'http://192.168.31.133:8000', // target: 'http://192.168.31.133:8000',
target: 'http://127.0.0.1:8000',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/ai-api/, ''), rewrite: (path) => path.replace(/^\/ai-api/, ''),
}, },