Files
offerpai_web/src/views/Agent.vue
T

1426 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 求职助手页面 -->
<div class="agent-page dflex">
<!-- 左侧导航栏 -->
<SideNav />
<!-- 主内容区域 -->
<div class="agent-page__content" v-loading="pageLoading" element-loading-text="加载中...">
<!-- 准备向导 第1步到第4步 -->
<AgentSetupWizard
v-if="!pageLoading && showSetupWizard"
:initial-config="agentConfig"
@complete="handleSetupComplete"
@launch="handleLaunch"
/>
<!-- 求职助手正式内容区域 -->
<div v-else-if="!pageLoading" class="agent-main" :class="{ 'agent-main--with-panel': showRightPanel }">
<!-- 左侧主区域 -->
<div class="agent-main__left">
<!-- 顶部固定设置栏 -->
<div class="agent-main__top-bar">
<!-- 用户头像 -->
<div class="agent-main__avatar">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="1.5" />
<path d="M4 20c0-3.3 3.6-6 8-6s8 2.7 8 6" stroke="currentColor" stroke-width="1.5" />
</svg>
</div>
<!-- 状态标签 -->
<span v-if="applyJobList.length > 0" class="agent-main__status-tag">准备就绪</span>
<!-- 提示文字 -->
<span class="agent-main__status-text">{{ applyJobList.length > 0 ? `已添加${applyJobList.length}个岗位,随时可以开始投递` : '请添加岗位' }}</span>
<!-- 开始按钮投递中时置灰不可点击暂停时显示继续可点击 -->
<button
class="agent-main__start-btn"
:disabled="isApplying && !isPaused"
@click="handleTopBarStartClick"
>{{ isApplying ? (isPaused ? '继续' : '投递中...') : '开始 ▸' }}</button>
<!-- 右侧工具按钮 -->
<div class="agent-main__tools">
<!-- 待投递列表按钮 -->
<button class="agent-main__tool-btn" title="待投递列表" @click.stop="toggleTaskListDropdown">
<svg viewBox="0 0 24 24" fill="none">
<path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
<!-- 待投递列表下拉弹窗 -->
<AgentTaskListDropdown
v-if="showTaskListDropdown"
:pending-list="applyJobList"
@removed="handleTaskRemoved"
@view-all="handleOpenPendingListPanel"
@view-all-completed="handleOpenApplyProgressPanel"
/>
<!-- 设置按钮 -->
<button class="agent-main__tool-btn" title="配置" @click="handleOpenSettingsPanel">
设置
</button>
</div>
</div>
<!-- 中间聊天记录区域可滚动 -->
<div ref="chatAreaRef" class="agent-main__chat-area">
<!-- 遍历对话消息列表 -->
<template v-for="msg in chatMessages" :key="msg.id">
<!-- 用户消息 右对齐 -->
<div v-if="msg.type === 'user'" class="agent-main__chat-row agent-main__chat-row--user">
<div class="agent-main__chat-bubble agent-main__chat-bubble--user">{{ msg.content }}</div>
</div>
<!-- AI 助手消息 左对齐 -->
<div v-else-if="msg.type === 'assistant'" class="agent-main__chat-row">
<div class="agent-main__chat-bubble">{{ msg.content }}</div>
</div>
<!-- 推荐岗位消息 渲染岗位列表组件 -->
<div v-else-if="msg.type === 'recommend'" class="agent-main__chat-row">
<!-- 如果 extra 里有岗位数据用岗位列表组件展示 -->
<AgentChatJobList
:summary="getRecommendSummaryFromExtra(msg.extra)"
:jobs="getRecommendJobsFromExtra(msg.extra)"
@view-all="handleOpenRecommendPanel(msg.id)"
@click-job="handleChatJobClick"
/>
</div>
<!-- 投递进度消息 仅显示最新 recommend 之后的 apply_progress -->
<div v-else-if="msg.type === 'apply_progress' && isVisibleApplyProgress(msg.id)" class="agent-main__chat-row">
<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>
</template>
<!-- 会话岗位列表组件来自推荐接口非历史消息 -->
<!-- AI 正在回复的加载指示器 -->
<AiThinkingIndicator v-if="isSending" text="AI正在思考中" />
</div>
<!-- 底部固定输入框 -->
<div class="agent-main__input-bar">
<input
v-model="chatInput"
class="agent-main__input"
placeholder="告诉我你想找什么样的工作"
:disabled="isSending"
@keyup.enter="handleSendMessage"
/>
<button class="agent-main__send-btn" :disabled="isSending" @click="handleSendMessage">
<svg viewBox="0 0 24 24" fill="none">
<path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
<!-- 右侧匹配岗位添加面板 / 简历生成进度 / 简历预览 -->
<div v-if="showRightPanel" class="agent-main__right">
<!-- 模式1匹配岗位添加面板 -->
<AgentMatchJobAdd
v-if="rightPanelMode === 'recommend'"
:jobs="activeRecommendJobs"
:loading-job-ids="loadingJobIds"
:panel-loading="panelLoading"
@close="handleCloseRecommendPanel"
@view-more="handleViewMoreJobs"
@toggle="handleToggleJobApply"
@add-all="handleAddAllJobs"
@click-job="handleMatchJobClick"
/>
<!-- 模式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>
<!-- 模式4岗位预览 -->
<AgentJobPreviewPanel
v-else-if="rightPanelMode === 'jobPreview'"
:job-id="previewJobId"
:application-status="previewJobApplicationStatus"
@back="handleJobPreviewBack"
@add="handleJobPreviewAdd"
@remove="handleJobPreviewRemove"
/>
<!-- 模式5全部的待投递列表 -->
<AgentPendingJobListPanel
v-else-if="rightPanelMode === 'pendingList'"
@close="handleClosePendingListPanel"
@removed="handlePendingListRemoved"
/>
<!-- 模式6全部的已投递进度 -->
<AgentApplyProgressPanel
v-else-if="rightPanelMode === 'applyProgress'"
@close="handleCloseApplyProgressPanel"
/>
<!-- 模式7设置面板 -->
<AgentSettingPanel
v-else-if="rightPanelMode === 'settings'"
@close="handleCloseSettingsPanel"
/>
<!-- 模式7AI助手设置 -->
</div>
</div>
</div>
<!-- 求职目标设置弹窗AI 对话 editPreference 工具触发 -->
<JobGoalDialog v-model="showJobGoalDialog" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import SideNav from '@/components/SideNav.vue'
import AgentSetupWizard from '@/components/AgentSetupWizard.vue'
import AgentChatJobList from '@/components/AgentChatJobList.vue'
import AgentMatchJobAdd from '@/components/AgentMatchJobAdd.vue'
import AgentApplyProgress from '@/components/AgentApplyProgress.vue'
import AgentTaskListDropdown from '@/components/AgentTaskListDropdown.vue'
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
import AgentJobPreviewPanel from '@/components/AgentJobPreviewPanel.vue'
import AgentPendingJobListPanel from '@/components/AgentPendingJobListPanel.vue'
import AgentApplyProgressPanel from '@/components/AgentApplyProgressPanel.vue'
import AgentSettingPanel from '@/components/AgentSettingPanel.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 { fetchAgentTaskList } from '@/api/jobs'
import type { JobListItem } from '@/api/jobs'
import { fetchResumeList } from '@/api/resume'
import { getIntentionCategoryNames, getIntentionRegionNames, getIntentionIndustryNames } from '@/utils/intention'
import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue'
import JobGoalDialog from '@/components/JobGoalDialog.vue'
/** 页面初始加载状态 */
const pageLoading = ref(true)
/** 是否显示准备向导 */
const showSetupWizard = ref(true)
/** 从接口查询到的配置数据 — 传给向导组件做初始值 */
const agentConfig = ref<AgentConfig | null>(null)
/** 是否显示右侧匹配岗位面板 */
const showRightPanel = ref(false)
/** 当前右侧面板展示的 recommend 消息 ID */
const activeRecommendMsgId = ref<number | null>(null)
/** 对话消息列表 */
const chatMessages = ref<AgentChatMessage[]>([])
/** 根据当前选中的 recommend 消息 ID,解析其 extra 中的岗位列表 */
const activeRecommendJobs = computed(() => {
if (activeRecommendMsgId.value === null) return [] as AgentRecommendJob[]
const msg = chatMessages.value.find(m => m.id === activeRecommendMsgId.value)
if (!msg || !msg.extra) return [] as AgentRecommendJob[]
return getRecommendJobsFromExtra(msg.extra)
})
/** 聊天输入框内容 */
const chatInput = ref('')
/** 待投递列表数据 */
const applyJobList = ref<JobListItem[]>([])
/** 待投递列表下拉弹窗是否显示 */
const showTaskListDropdown = ref(false)
/** 正在请求中的岗位 ID 列表(传给子组件控制按钮 loading) */
const loadingJobIds = ref<number[]>([])
/** 右侧面板加载状态(查询投递记录期间显示 loading) */
const panelLoading = ref(false)
/** 默认简历 ID — 发送 AI 对话时需要(字符串避免大整数精度丢失) */
const defaultResumeId = ref('')
/** 是否正在发送消息(防止重复发送 + 显示等待状态) */
const isSending = ref(false)
/** 是否正在投递流程中(防止重复点击开始按钮) */
const isApplying = ref(false)
/** 是否显示求职目标设置弹窗(由 AI 对话 editPreference 工具触发) */
const showJobGoalDialog = ref(false)
/** 是否暂停投递 */
const isPaused = ref(false)
/** 聊天区域 DOM 引用 */
const chatAreaRef = ref<HTMLElement | null>(null)
/** 滚动聊天区域到底部(平滑滚动) */
function scrollChatToBottom() {
if (chatAreaRef.value) {
chatAreaRef.value.scrollTo({ top: chatAreaRef.value.scrollHeight, behavior: 'smooth' })
}
}
/** 页面挂载时查询配置 */
onMounted(async () => {
await loadAgentConfig()
// 点击页面其他区域关闭待投递列表下拉
document.addEventListener('click', closeTaskDropdownOnClickOutside)
})
/** 点击外部关闭待投递列表下拉 */
function closeTaskDropdownOnClickOutside() {
showTaskListDropdown.value = false
}
/** 查询求职助手配置 */
async function loadAgentConfig() {
pageLoading.value = true
try {
const res = await fetchAgentConfig()
pageLoading.value = false
if (res.code === '0' && res.data) {
agentConfig.value = res.data
/* 如果已启用(status=1),直接进入正式内容 */
if (res.data.status === 1) {
showSetupWizard.value = false
await loadChatMessages()
await loadApplyList()
await loadDefaultResumeId()
/* 没有历史消息时才自动请求推荐岗位 */
if (chatMessages.value.length === 0) {
await loadRecommendJobs()
}
}
}
} catch {
console.error('[Agent] 查询求职助手配置失败')
} finally {
}
}
/** 加载推荐岗位列表 — 请求后将结果作为 recommend 消息存入对话记录 */
async function loadRecommendJobs() {
try {
const res = await fetchAgentRecommend()
if (res.code === '0' && res.data && res.data.list?.length > 0) {
const summary = res.data.summary || ''
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 */
const extraJson = JSON.stringify({ summary, list: jobList })
/* 调接口持久化这条 recommend 消息 */
try {
await addAgentChatMessage({
type: 'recommend',
content: '',
extra: extraJson,
})
} catch {
console.error('[Agent] 保存推荐消息失败')
}
/* 追加到本地对话消息列表 */
const now = Math.floor(Date.now() / 1000)
chatMessages.value.push({
id: Date.now(),
type: 'recommend',
content: '',
extra: extraJson,
createTime: { seconds: now, nanos: 0 },
})
/* 滚动到底部 */
await nextTick()
scrollChatToBottom()
}
} catch {
console.error('[Agent] 加载推荐岗位失败')
}
}
/** 加载 Agent 对话消息列表 */
async function loadChatMessages() {
try {
const res = await fetchAgentChatMessages(50)
if (res.code === '0' && res.data) {
chatMessages.value = res.data
/* 加载完成后滚动到底部 */
await nextTick()
scrollChatToBottom()
}
} catch {
console.error('[Agent] 加载对话消息失败')
}
}
/** 加载求职助手任务列表(tab=1 进行中/待投递) */
async function loadApplyList() {
try {
const res = await fetchAgentTaskList({ pageNum: 1, pageSize: 100, tab: 1 })
if (res.code === '0' && res.data) {
applyJobList.value = res.data.list || []
}
} catch {
console.error('[Agent] 加载求职助手任务列表失败')
}
}
/** 切换待投递列表下拉弹窗显隐 */
function toggleTaskListDropdown() {
/* 投递流程进行中时禁止打开待投递列表 */
if (isApplying.value) {
ElMessage.warning('请完成或取消投递流程后再打开编辑待投递列表')
return
}
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)
}
}
/** 加载默认简历 ID */
async function loadDefaultResumeId() {
try {
const res = await fetchResumeList()
if (res.code === '0' && res.data && res.data.length > 0) {
const defaultResume = res.data.find(r => r.isDefault === 1)
const id = defaultResume?.id || res.data[0]?.id || ''
defaultResumeId.value = id
}
} catch {
console.error('[Agent] 加载默认简历失败')
}
}
/**
* 将 chatMessages 组装为 AI 对话接口需要的 history 格式
* user / assistant → content 取 chatMessages.content
* recommend / apply_progress → content 取 chatMessages.extra
*/
function buildChatHistory(): AgentChatHistoryItem[] {
return chatMessages.value.map(msg => {
const role = (msg.type === 'user') ? 'user' : 'assistant'
let content = ''
if (msg.type === 'user' || msg.type === 'assistant') {
content = msg.content || ''
} else {
/* recommend / apply_progress — 用 extra 作为内容 */
content = msg.extra || msg.content || ''
}
return { role, content }
})
}
/**
* 从消息的 extra JSON 中解析推荐岗位列表
* extra 格式预期为 JSON 字符串,包含 list 字段
*/
function getRecommendJobsFromExtra(extra: string): AgentRecommendJob[] {
if (!extra) return []
try {
const parsed = JSON.parse(extra)
return Array.isArray(parsed.list) ? parsed.list : (Array.isArray(parsed) ? parsed : [])
} catch {
return []
}
}
/** 从消息的 extra JSON 中解析推荐说明文字 */
function getRecommendSummaryFromExtra(extra: string): string {
if (!extra) return ''
try {
const parsed = JSON.parse(extra)
return parsed.summary || ''
} catch {
return ''
}
}
/**
* 收集 chatMessages 中所有 recommend 消息里的岗位 ID
* 返回字符串数组,避免大整数精度丢失
*/
function collectExcludeJobIds(): string[] {
const ids: string[] = []
for (const msg of chatMessages.value) {
if (msg.type !== 'recommend' || !msg.extra) continue
const jobs = getRecommendJobsFromExtra(msg.extra)
for (const job of jobs) {
ids.push(String(job.id))
}
}
return ids
}
/** 准备向导设置完成 — 调用接口保存配置 */
async function handleSetupComplete(data: Record<string, any>) {
try {
await saveAgentConfig(data as AgentConfig)
ElMessage.success('配置保存成功')
} catch {
ElMessage.error('配置保存失败,请重试')
}
}
/** 启用求职助手 */
async function handleLaunch() {
showSetupWizard.value = false
ElMessage.success('求职助手已启用')
/* 进入正式内容后加载对话消息和推荐岗位 */
await loadChatMessages()
await loadApplyList()
await loadDefaultResumeId()
/* 没有历史消息时才自动请求推荐岗位 */
if (chatMessages.value.length === 0) {
await loadRecommendJobs()
}
}
/**
* 更新对话消息中 recommend 消息里对应岗位的 applicationStatus
*/
function updateJobStatusInMessages(jobId: number, newStatus: number | null) {
for (const msg of chatMessages.value) {
if (msg.type !== 'recommend' || !msg.extra) continue
try {
const parsed = JSON.parse(msg.extra)
const list: AgentRecommendJob[] = Array.isArray(parsed.list) ? parsed.list : (Array.isArray(parsed) ? parsed : [])
const job = list.find(j => j.id === jobId)
if (job) {
job.applicationStatus = newStatus
if (parsed.list) {
parsed.list = list
msg.extra = JSON.stringify(parsed)
} else {
msg.extra = JSON.stringify(list)
}
}
} catch { /* 解析失败跳过 */ }
}
}
/**
* 单个岗位添加/移除 — 由子组件 toggle 事件触发
* 未投递(null) → POST 添加到待投递(-1)
* 待投递(-1) → DELETE 移除
*/
async function handleToggleJobApply(job: AgentRecommendJob) {
if (loadingJobIds.value.includes(job.id)) return
const isRemoving = job.applicationStatus === -1
loadingJobIds.value.push(job.id)
try {
if (isRemoving) {
await cancelApplyJob(job.id)
} else {
await applyJob({ jobId: job.id, status: -1 })
}
/* 更新对话消息中的岗位状态 */
updateJobStatusInMessages(job.id, isRemoving ? null : -1)
await loadApplyList()
ElMessage.success(isRemoving ? '岗位已从待投递移除' : '岗位已添加到待投递')
} catch {
ElMessage.error('操作失败,请重试')
} finally {
loadingJobIds.value = loadingJobIds.value.filter(id => id !== job.id)
}
}
/**
* 全部添加 — 由子组件 addAll 事件触发
* 将当前面板中所有未添加的岗位批量设为待投递
*/
async function handleAddAllJobs() {
const pendingJobs = activeRecommendJobs.value.filter(j => j.applicationStatus === null || j.applicationStatus === undefined)
if (pendingJobs.length === 0) {
ElMessage.info('所有岗位已添加到待投递')
return
}
for (const job of pendingJobs) {
if (loadingJobIds.value.includes(job.id)) continue
loadingJobIds.value.push(job.id)
try {
await applyJob({ jobId: job.id, status: -1 })
updateJobStatusInMessages(job.id, -1)
} catch { /* 单个失败不阻断后续 */ }
finally {
loadingJobIds.value = loadingJobIds.value.filter(id => id !== job.id)
}
}
await loadApplyList()
ElMessage.success('已全部添加到待投递')
}
/** 查看更多岗位 — 可扩展为加载下一批 */
function handleViewMoreJobs() {
// TODO: 后续可对接分页加载更多岗位
}
/** 打开右侧匹配岗位面板 — 先查询投递记录更新岗位状态再显示 */
async function handleOpenRecommendPanel(msgId: number) {
/* 投递流程进行中(模式2简历生成 / 模式3简历预览)时禁止打开推荐面板 */
if (rightPanelMode.value === 'generating' || rightPanelMode.value === 'resume') {
ElMessage.warning('请完成或退出投递流程后再添加岗位')
return
}
activeRecommendMsgId.value = msgId
showRightPanel.value = true
panelLoading.value = true
try {
/* 取出当前 recommend 消息中的岗位 ID 列表 */
const msg = chatMessages.value.find(m => m.id === msgId)
if (!msg || !msg.extra) return
const jobs = getRecommendJobsFromExtra(msg.extra)
const jobIds = jobs.map(j => j.id)
if (jobIds.length === 0) return
/* 调接口批量查询投递记录 */
const res = await fetchApplyByJobIds(jobIds)
if (res.code === '0' && res.data) {
/* 构建 jobId → status 映射 */
const statusMap = new Map<number, number>()
for (const record of res.data) {
statusMap.set(record.jobId, record.status)
}
/* 更新 extra 中每个岗位的 applicationStatus */
const parsed = JSON.parse(msg.extra)
const list: AgentRecommendJob[] = Array.isArray(parsed.list) ? parsed.list : (Array.isArray(parsed) ? parsed : [])
for (const job of list) {
if (statusMap.has(job.id)) {
job.applicationStatus = statusMap.get(job.id)!
} else {
/* 接口没有返回该岗位记录 — 说明没有投递记录,设为 null */
job.applicationStatus = null
}
}
/* 回写 extra */
if (parsed.list) {
parsed.list = list
msg.extra = JSON.stringify(parsed)
} else {
msg.extra = JSON.stringify(list)
}
}
} catch {
console.error('[Agent] 查询岗位投递记录失败')
} finally {
panelLoading.value = false
}
}
/** 关闭右侧匹配岗位面板 */
function handleCloseRecommendPanel() {
showRightPanel.value = false
activeRecommendMsgId.value = null
rightPanelMode.value = 'recommend'
}
// ==================== 岗位预览(模式4 ====================
/** 当前预览的岗位 ID */
const previewJobId = ref('')
/** 当前预览岗位的投递状态 */
const previewJobApplicationStatus = ref<number | null>(null)
/** 进入岗位预览前记录的面板模式(用于返回) */
const prevPanelMode = ref<'recommend' | 'generating' | 'resume' | 'jobPreview' | 'pendingList' | 'applyProgress' | 'settings'>('recommend')
/** 打开岗位预览面板 */
function handleOpenJobPreview(jobId: string | number, applicationStatus?: number | null) {
previewJobId.value = String(jobId)
previewJobApplicationStatus.value = applicationStatus ?? null
prevPanelMode.value = rightPanelMode.value
rightPanelMode.value = 'jobPreview'
showRightPanel.value = true
}
/** 岗位预览返回 — 回到之前的面板模式 */
function handleJobPreviewBack() {
/* 如果之前是 recommend 模式且有选中的消息,回到推荐面板 */
if (prevPanelMode.value === 'recommend' && activeRecommendMsgId.value !== null) {
rightPanelMode.value = 'recommend'
} else {
/* 否则关闭面板 */
showRightPanel.value = false
rightPanelMode.value = 'recommend'
}
}
/** 岗位预览中点击添加 — 添加到待投递 */
async function handleJobPreviewAdd(jobId: string) {
try {
await applyJob({ jobId, status: -1 })
/* 更新对话消息中的岗位状态 */
updateJobStatusInMessages(Number(jobId), -1)
previewJobApplicationStatus.value = -1
await loadApplyList()
ElMessage.success('岗位已添加到待投递')
} catch {
ElMessage.error('操作失败,请重试')
}
}
/** 岗位预览中点击移出 — 从待投递移除 */
async function handleJobPreviewRemove(jobId: string) {
try {
await cancelApplyJob(jobId)
/* 更新对话消息中的岗位状态 */
updateJobStatusInMessages(Number(jobId), null)
previewJobApplicationStatus.value = null
await loadApplyList()
ElMessage.success('岗位已从待投递移除')
} catch {
ElMessage.error('操作失败,请重试')
}
}
/** AgentMatchJobAdd 中点击岗位 — 打开岗位预览 */
function handleMatchJobClick(job: AgentRecommendJob) {
handleOpenJobPreview(job.id, job.applicationStatus)
}
/** AgentChatJobList 中点击岗位 — 打开岗位预览 */
function handleChatJobClick(job: AgentRecommendJob) {
/* 投递流程进行中时禁止打开岗位详情 */
if (isApplying.value) {
ElMessage.warning('请完成或取消投递流程后再查看岗位详情')
return
}
handleOpenJobPreview(job.id, job.applicationStatus)
}
// ==================== 待投递列表面板(模式5 ====================
/** 打开待投递列表面板 */
function handleOpenPendingListPanel() {
showTaskListDropdown.value = false
rightPanelMode.value = 'pendingList'
showRightPanel.value = true
}
/** 关闭待投递列表面板 */
function handleClosePendingListPanel() {
showRightPanel.value = false
rightPanelMode.value = 'recommend'
}
/** 待投递列表面板中移除岗位后同步父组件数据 */
function handlePendingListRemoved(jobId: string) {
const idx = applyJobList.value.findIndex(j => j.id === jobId)
if (idx !== -1) {
applyJobList.value.splice(idx, 1)
}
}
// ==================== 申请进度面板(模式6 ====================
/** 打开申请进度面板 */
function handleOpenApplyProgressPanel() {
showTaskListDropdown.value = false
rightPanelMode.value = 'applyProgress'
showRightPanel.value = true
}
/** 关闭申请进度面板 */
function handleCloseApplyProgressPanel() {
showRightPanel.value = false
rightPanelMode.value = 'recommend'
}
// ==================== 设置面板(模式7 ====================
/** 打开设置面板 */
function handleOpenSettingsPanel() {
rightPanelMode.value = 'settings'
showRightPanel.value = true
}
/** 关闭设置面板 */
function handleCloseSettingsPanel() {
showRightPanel.value = false
rightPanelMode.value = 'recommend'
}
// ==================== 投递进度流程 ====================
/** 右侧面板显示模式 */
const rightPanelMode = ref<'recommend' | 'generating' | 'resume' | 'jobPreview' | 'pendingList' | 'applyProgress' | 'settings'>('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
applyCancelled.value = false
startProgressSimulation()
try {
const res = await optimizeAgentResume({
resumeId: defaultResumeId.value,
jobId: targetJob.id,
})
// 如果在等待期间已取消,直接退出
if (applyCancelled.value) return
// 接口返回后涨满进度条
finishProgress()
// 等待 1 秒让用户看到 100%
await new Promise(resolve => setTimeout(resolve, 1000))
// 再次检查取消状态
if (applyCancelled.value) return
// 解析简历数据 — 映射接口返回格式到 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) {
// 取消导致的异常不提示
if (applyCancelled.value) return
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
}
/** 标记当前投递流程是否已被取消(防止异步回调覆盖状态) */
const applyCancelled = ref(false)
/** 取消投递流程 */
function handleCancelApply(msg: AgentChatMessage) {
// 标记已取消,阻止进行中的异步回调继续操作
applyCancelled.value = true
// 清除进度条定时器
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
// 重置暂停状态
isPaused.value = false
// 从消息列表中移除该条 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
applyCancelled.value = false
startProgressSimulation()
try {
const res = await optimizeAgentResume({
resumeId: defaultResumeId.value,
jobId: targetJob.id,
})
if (applyCancelled.value) return
finishProgress()
await new Promise(resolve => setTimeout(resolve, 1000))
if (applyCancelled.value) return
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) {
if (applyCancelled.value) return
console.error('[Agent] 优化简历失败', e)
ElMessage.error('简历优化失败,请重试')
finishProgress()
showRightPanel.value = false
rightPanelMode.value = 'recommend'
isApplying.value = false
}
}
/** 发送聊天消息 */
async function handleSendMessage() {
const text = chatInput.value.trim()
if (!text || isSending.value) return
/* 1. 先把用户消息追加到 chatMessages */
const now = Math.floor(Date.now() / 1000)
const userMsg: AgentChatMessage = {
id: Date.now(),
type: 'user',
content: text,
extra: '',
createTime: { seconds: now, nanos: 0 },
}
chatMessages.value.push(userMsg)
chatInput.value = ''
/* 滚动到底部 */
await nextTick()
scrollChatToBottom()
/* 2. 同步持久化用户消息到后端 */
try {
await addAgentChatMessage({ type: 'user', content: text })
} catch {
console.error('[Agent] 保存用户消息失败')
}
/* 3. 组装参数调 AI 对话接口 */
isSending.value = true
/* 等待 loading 指示器渲染后滚动到底部 */
await nextTick()
scrollChatToBottom()
try {
const history = buildChatHistory()
const res = await sendAgentChat({
message: text,
resumeId: defaultResumeId.value,
history,
jobCategories: getIntentionCategoryNames(),
regions: getIntentionRegionNames(),
industries: getIntentionIndustryNames(),
})
/* 处理 AI 回复 */
if (res.code === 0 && res.data?.message) {
const aiContent = res.data.message
const tool = res.data.tool
const toolParams = res.data.toolParams
if (tool === 'recommend') {
/* AI 要求推荐岗位 — 先插入 AI 的过渡消息 */
const aiNow = Math.floor(Date.now() / 1000)
chatMessages.value.push({
id: Date.now() + 1,
type: 'assistant',
content: aiContent,
extra: '',
createTime: { seconds: aiNow, nanos: 0 },
})
try {
await addAgentChatMessage({ type: 'assistant', content: aiContent })
} catch { /* 静默 */ }
await nextTick()
scrollChatToBottom()
/* 调用岗位推荐接口 */
const preference = toolParams?.preference || ''
const excludeJobIds = collectExcludeJobIds()
try {
const recRes = await fetchAgentRecommend({ preference, excludeJobIds })
if (recRes.code === '0' && recRes.data ) {
const summary = recRes.data.summary || ''
const jobList = recRes.data.list
const extraJson = JSON.stringify({ summary, list: jobList })
// const contentV = recRes.data.list?.length==0?'':''
/* 持久化 recommend 消息 */
try {
await addAgentChatMessage({ type: 'recommend', content: '', extra: extraJson })
} catch { /* 静默 */ }
/* 追加到本地对话消息列表 */
const recNow = Math.floor(Date.now() / 1000)
chatMessages.value.push({
id: Date.now() + 2,
type: 'recommend',
content: '',
extra: extraJson,
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()
scrollChatToBottom()
}
} catch {
console.error('[Agent] 推荐岗位请求失败')
}
} else if (tool === 'editPreference') {
/* AI 要求打开偏好设置弹窗 — 先插入 AI 回复消息,再弹出求职目标设置弹窗 */
const aiNow = Math.floor(Date.now() / 1000)
chatMessages.value.push({
id: Date.now() + 1,
type: 'assistant',
content: aiContent,
extra: '',
createTime: { seconds: aiNow, nanos: 0 },
})
try {
await addAgentChatMessage({ type: 'assistant', content: aiContent })
} catch { /* 静默 */ }
await nextTick()
scrollChatToBottom()
/* 打开求职目标设置弹窗 */
showJobGoalDialog.value = true
} else {
/* 普通 AI 回复 — 直接插入 assistant 消息 */
const aiNow = Math.floor(Date.now() / 1000)
chatMessages.value.push({
id: Date.now() + 1,
type: 'assistant',
content: aiContent,
extra: '',
createTime: { seconds: aiNow, nanos: 0 },
})
try {
await addAgentChatMessage({ type: 'assistant', content: aiContent })
} catch {
console.error('[Agent] 保存 AI 回复消息失败')
}
await nextTick()
scrollChatToBottom()
}
}
} catch (err) {
console.error('[Agent] AI 对话请求失败', err)
ElMessage.error('消息发送失败,请重试')
} finally {
isSending.value = false
}
}
</script>
<style scoped lang="scss">
@use '../assets/styles/pages/agent';
</style>