Files
offerpai_web/src/components/JobResumeCustomDialog.vue
T

1033 lines
44 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>
<!-- 岗位专属简历定制弹窗步骤1居中弹窗步骤2+右侧抽屉 -->
<div v-if="modelValue" class="job-resume-custom-dialog" :class="{ 'job-resume-custom-dialog--drawer': currentStep >= 2 }">
<div class="job-resume-custom-dialog__overlay" @click="handleClose"></div>
<!-- ===== 步骤一居中弹窗 ===== -->
<div v-if="currentStep === 1" class="job-resume-custom-dialog__panel">
<div class="job-resume-custom-dialog__header">
<h2 class="job-resume-custom-dialog__title">10s快速定制岗位专属简历</h2>
<button class="job-resume-custom-dialog__close-btn" @click="handleClose" aria-label="关闭">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__close-icon"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
<div v-if="isLowMatch" class="job-resume-custom-dialog__tip-bar"><span>你与该岗位的匹配度较低简历可能无法通过机筛</span></div>
<div class="job-resume-custom-dialog__job-card">
<div class="job-resume-custom-dialog__job-left">
<div class="job-resume-custom-dialog__company-icon">
<img v-if="jobInfo.companyLogoUrl" :src="jobInfo.companyLogoUrl" :alt="jobInfo.company" class="job-resume-custom-dialog__company-logo-img" />
<svg v-else viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__company-svg"><rect x="3" y="7" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M7 7V5a2 2 0 012-2h6a2 2 0 012 2v2" stroke="currentColor" stroke-width="1.5"/><path d="M3 13h18" stroke="currentColor" stroke-width="1.5"/></svg>
</div>
<div class="job-resume-custom-dialog__job-info">
<span class="job-resume-custom-dialog__job-title">{{ jobInfo.title }}</span>
<span class="job-resume-custom-dialog__job-sub">{{ jobInfo.location }} · {{ jobInfo.company }}</span>
</div>
</div>
<div class="job-resume-custom-dialog__match-area">
<div class="job-resume-custom-dialog__match-ring">
<svg viewBox="0 0 60 60" class="job-resume-custom-dialog__ring-svg"><circle cx="30" cy="30" r="24" stroke-width="4" stroke="#E8E8E8" fill="none" opacity="0.3"/><circle cx="30" cy="30" r="24" stroke-width="4" fill="none" stroke="#4FC2C9" stroke-linecap="round" :stroke-dasharray="2*Math.PI*24" :stroke-dashoffset="2*Math.PI*24*(1-jobInfo.matchScore/10)" transform="rotate(-90 30 30)"/></svg>
<span class="job-resume-custom-dialog__match-score">{{ jobInfo.matchScore }}</span>
</div>
<span class="job-resume-custom-dialog__match-label">{{ matchLevelText }}</span>
</div>
</div>
<div class="job-resume-custom-dialog__skills-section">
<p class="job-resume-custom-dialog__skills-title">缺少{{ missingSkills.length }}项技能</p>
<div class="job-resume-custom-dialog__skills-list">
<span v-for="skill in missingSkills" :key="skill" class="job-resume-custom-dialog__skill-tag">{{ skill }}</span>
</div>
</div>
<div class="job-resume-custom-dialog__footer">
<button class="job-resume-custom-dialog__primary-btn" @click="goToStep(2)">立即定制简历</button>
<span class="job-resume-custom-dialog__skip-link" @click="handleSkip">不优化直接投递</span>
</div>
</div>
<!-- ===== 步骤2+右侧抽屉 ===== -->
<div v-if="currentStep >= 2" class="job-resume-custom-dialog__drawer">
<!-- 抽屉头部 -->
<div class="job-resume-custom-dialog__drawer-header">
<button class="job-resume-custom-dialog__close-btn" @click="handleClose" aria-label="关闭">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__close-icon"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<h2 class="job-resume-custom-dialog__drawer-title">生成你的岗位专属简历</h2>
</div>
<!-- 返回按钮步骤四显示 -->
<button v-if="currentStep === 4" class="job-resume-custom-dialog__back-btn mt10" @click="goToStep(3)">返回</button>
<!-- 步骤指示器 -->
<div class="job-resume-custom-dialog__steps pt10">
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 2 }">
<span class="job-resume-custom-dialog__step-num">1</span><span>差距分析</span>
</div>
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 3 }">
<span class="job-resume-custom-dialog__step-num">2</span><span>定制简历</span>
</div>
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 4 }">
<span class="job-resume-custom-dialog__step-num">3</span><span>预览</span>
</div>
</div>
<!-- 抽屉内容区可滚动 -->
<div class="job-resume-custom-dialog__drawer-body">
<!-- 步骤二差距分析 -->
<template v-if="currentStep === 2">
<div class="job-resume-custom-dialog__gap-header">
<div class="job-resume-custom-dialog__gap-left">
<h3 class="job-resume-custom-dialog__gap-title">你的简历与该岗位的匹配度{{ isLowMatch ? '较低' : '较高' }}</h3>
<div v-if="isLowMatch" class="job-resume-custom-dialog__gap-warn">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__warn-icon"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M8 5v3M8 10.5v.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
<span>匹配度低于 6.0 分的简历在筛选环节可能会被优先淘汰我们会帮你快速优化提升</span>
</div>
</div>
<div class="job-resume-custom-dialog__gap-score-area">
<div class="job-resume-custom-dialog__match-ring">
<svg viewBox="0 0 60 60" class="job-resume-custom-dialog__ring-svg"><circle cx="30" cy="30" r="24" stroke-width="4" stroke="#E8E8E8" fill="none" opacity="0.3"/><circle cx="30" cy="30" r="24" stroke-width="4" fill="none" stroke="#4FC2C9" stroke-linecap="round" :stroke-dasharray="2*Math.PI*24" :stroke-dashoffset="2*Math.PI*24*(1-jobInfo.matchScore/10)" transform="rotate(-90 30 30)"/></svg>
<span class="job-resume-custom-dialog__match-score">{{ jobInfo.matchScore }}</span>
</div>
<span class="job-resume-custom-dialog__match-label">{{ matchLevelText }}</span>
</div>
</div>
<!-- 对比卡片表格 -->
<div class="job-resume-custom-dialog__gap-table">
<!-- 第一行概览 -->
<div class="job-resume-custom-dialog__gap-row">
<!-- 标签列 -->
<div class="job-resume-custom-dialog__gap-cell job-resume-custom-dialog__gap-cell--label">
<span class="job-resume-custom-dialog__gap-cell-title">概览</span>
</div>
<!-- 岗位信息列 -->
<div class="job-resume-custom-dialog__gap-cell">
<div class="job-resume-custom-dialog__gap-job-info">
<div class="job-resume-custom-dialog__gap-company-icon">
<img v-if="jobInfo.companyLogoUrl" :src="jobInfo.companyLogoUrl" :alt="jobInfo.company" class="job-resume-custom-dialog__gap-company-logo" />
<svg v-else viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__gap-company-svg"><rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/></svg>
</div>
<div class="job-resume-custom-dialog__gap-job-text">
<span class="job-resume-custom-dialog__gap-job-title">{{ jobInfo.title }}</span>
<span class="job-resume-custom-dialog__gap-job-sub">{{ jobInfo.location }} · {{ jobInfo.company }}&nbsp;&nbsp;独角兽</span>
</div>
</div>
</div>
<!-- 简历选择列 -->
<div class="job-resume-custom-dialog__gap-cell">
<div class="job-resume-custom-dialog__resume-selector">
<div class="job-resume-custom-dialog__resume-info">
<svg viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__resume-file-icon"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M14 2v6h6" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
<div class="job-resume-custom-dialog__resume-text">
<span class="job-resume-custom-dialog__resume-label">你的简历</span>
<span class="job-resume-custom-dialog__resume-name">{{ selectedResume.name }}</span>
</div>
</div>
<button class="job-resume-custom-dialog__resume-select-btn" @click="toggleResumeDropdown">选择 <svg viewBox="0 0 12 12" fill="none" class="job-resume-custom-dialog__dropdown-arrow"><path d="M3 5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
<div v-if="showResumeDropdown" class="job-resume-custom-dialog__resume-dropdown">
<div v-for="r in resumeList" :key="r.id" class="job-resume-custom-dialog__resume-option" :class="{'job-resume-custom-dialog__resume-option--active': r.id === selectedResume.id}" @click="selectResume(r)">{{ r.name }}</div>
</div>
</div>
</div>
</div>
<!-- 第二行岗位名称 -->
<div class="job-resume-custom-dialog__gap-row">
<div class="job-resume-custom-dialog__gap-cell job-resume-custom-dialog__gap-cell--label">
<span>岗位名称</span>
</div>
<div class="job-resume-custom-dialog__gap-cell">
<span class="job-resume-custom-dialog__gap-value">{{ jobInfo.title }}</span>
</div>
<!-- <div class="job-resume-custom-dialog__gap-cell">
<span class="job-resume-custom-dialog__gap-value">{{ selectedResume.targetJob || '—' }}</span>
</div> -->
</div>
<!-- 第三行岗位关键词 -->
<div class="job-resume-custom-dialog__gap-row">
<div class="job-resume-custom-dialog__gap-cell job-resume-custom-dialog__gap-cell--label">
<span>岗位关键词</span>
</div>
<div class="job-resume-custom-dialog__gap-cell job-resume-custom-dialog__gap-cell--keywords">
<div class="job-resume-custom-dialog__gap-keywords">
<span v-for="kw in jobInfo.keywords" :key="kw" class="job-resume-custom-dialog__gap-kw-tag">{{ kw }}</span>
</div>
</div>
</div>
</div>
</template>
<!-- 步骤三定制简历 -->
<template v-if="currentStep === 3">
<div class="job-resume-custom-dialog__custom">
<!-- 左侧选择要优化的部分 -->
<div class="job-resume-custom-dialog__custom-panel">
<h3 class="job-resume-custom-dialog__custom-panel-title">1.选择你要优化的部分</h3>
<div class="job-resume-custom-dialog__custom-options">
<label v-for="item in optimizeSections" :key="item.key" class="job-resume-custom-dialog__custom-checkbox">
<input type="checkbox" v-model="item.checked" class="job-resume-custom-dialog__custom-input" />
<span class="job-resume-custom-dialog__custom-checkmark"></span>
<span class="job-resume-custom-dialog__custom-label">{{ item.label }}</span>
<!-- 技能和工作经验的问号提示使用 el-tooltip -->
<el-tooltip
v-if="item.tooltip"
:content="item.tooltip"
placement="right"
:show-arrow="true"
:popper-options="{ strategy: 'fixed' }"
effect="dark"
>
<span class="job-resume-custom-dialog__custom-tooltip-trigger">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__custom-tooltip-icon">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/>
<path d="M6.5 6.5a1.5 1.5 0 112.12 1.37c-.42.18-.62.5-.62.88V9.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
<circle cx="8" cy="11.5" r="0.6" fill="currentColor"/>
</svg>
</span>
</el-tooltip>
</label>
</div>
</div>
<!-- 右侧选择要新增的技能关键词 -->
<div class="job-resume-custom-dialog__custom-panel">
<h3 class="job-resume-custom-dialog__custom-panel-title">2.选择你要新增的技能关键词</h3>
<div class="job-resume-custom-dialog__custom-options">
<label v-for="skill in newSkillOptions" :key="skill.name" class="job-resume-custom-dialog__custom-checkbox">
<input type="checkbox" v-model="skill.checked" class="job-resume-custom-dialog__custom-input" />
<span class="job-resume-custom-dialog__custom-checkmark"></span>
<span class="job-resume-custom-dialog__custom-label">{{ skill.name }}</span>
</label>
</div>
</div>
</div>
</template>
<!-- 步骤四预览 -->
<template v-if="currentStep === 4">
<div class="job-resume-custom-dialog__preview">
<!-- 左侧简历模板预览支持差异对比模式 -->
<div class="job-resume-custom-dialog__preview-left">
<JobResumeTemplate
:resumeData="resumeTemplateData"
:showDiff="isShowDiff"
:oldResumeData="oldResumeTemplateData"
ref="resumeTemplateRef"
/>
</div>
<!-- 右侧AI帮写 / 编辑 tab -->
<div class="job-resume-custom-dialog__preview-right">
<!-- Tab 切换 -->
<div class="job-resume-custom-dialog__preview-tabs">
<button
class="job-resume-custom-dialog__preview-tab"
:class="{ 'job-resume-custom-dialog__preview-tab--active': previewTab === 'ai' }"
@click="previewTab = 'ai'"
>AI帮写</button>
<button
class="job-resume-custom-dialog__preview-tab"
:class="{ 'job-resume-custom-dialog__preview-tab--active': previewTab === 'edit' }"
@click="previewTab = 'edit'"
>编辑</button>
</div>
<!-- AI帮写内容 -->
<div v-if="previewTab === 'ai'" class="job-resume-custom-dialog__preview-ai">
<!-- AI聊天消息区域 -->
<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
v-for="(msg, i) in aiMessages"
:key="i"
class="job-resume-custom-dialog__ai-msg-wrap"
>
<div
class="job-resume-custom-dialog__ai-msg"
:class="msg.role === 'assistant' ? 'job-resume-custom-dialog__ai-msg--ai' : 'job-resume-custom-dialog__ai-msg--user'"
>
<div class="job-resume-custom-dialog__ai-msg-bubble">{{ msg.content }}</div>
</div>
<!-- 撤销修改气泡 -->
<!-- 已撤销状态所有历史中已撤销的消息都显示 -->
<div v-if="msg.canRollback && msg.rollbackStatus === 'done'" class="job-resume-custom-dialog__ai-rollback">
<span class="job-resume-custom-dialog__ai-rollback-done">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__ai-rollback-icon">
<path d="M3 8.5l3 3 7-7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
已撤销此次简历修改
</span>
</div>
<!-- 撤销按钮仅当该消息是列表最后一条且为AI修改简历的回答且未撤销时显示 -->
<div v-if="msg.canRollback && msg.rollbackStatus !== 'done' && isLastMessage(i)" class="job-resume-custom-dialog__ai-rollback">
<button
class="job-resume-custom-dialog__ai-rollback-btn"
@click="handleRollbackClick(i)"
>
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__ai-rollback-icon">
<path d="M3 8h7a3 3 0 010 6H8M3 8l3-3M3 8l3 3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
撤销修改
</button>
</div>
</div>
<!-- AI正在回复的加载指示器 -->
<AiThinkingIndicator v-if="aiLoading" text="AI正在思考中" />
</div>
<!-- AI输入框 -->
<div class="job-resume-custom-dialog__ai-input-area">
<input
v-model="aiInputText"
class="job-resume-custom-dialog__ai-input"
placeholder="你要怎么优化"
@keyup.enter="sendAiMessage(aiInputText)"
/>
<button class="job-resume-custom-dialog__ai-send-btn" @click="sendAiMessage(aiInputText)">
<svg viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__ai-send-icon">
<path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
<!-- 编辑内容 折叠手风琴式编辑面板 -->
<div v-if="previewTab === 'edit'" class="job-resume-custom-dialog__preview-edit">
<JobResumeCustomEditPanel :resumeData="customResumeRawData" :jobId="jobId" @update="onEditPanelUpdate" />
</div>
</div>
</div>
</template>
</div>
<!-- 抽屉底部按钮 -->
<div v-if="currentStep < 4" class="job-resume-custom-dialog__drawer-footer">
<button class="job-resume-custom-dialog__primary-btn" @click="handleDrawerNext">立即定制简历</button>
</div>
<!-- 步骤四专属底部下载简历 + 立即去投递 -->
<div v-if="currentStep === 4" class="job-resume-custom-dialog__preview-footer mt10 pt30">
<!-- 左侧下载简历按钮带下拉 -->
<div class="job-resume-custom-dialog__download-wrap">
<button class="job-resume-custom-dialog__download-btn" @click="toggleDownloadMenu">下载简历</button>
<div v-if="showDownloadMenu" class="job-resume-custom-dialog__download-menu">
<button class="job-resume-custom-dialog__download-option" @click="handleDownload('pdf')">下载PDF</button>
<button class="job-resume-custom-dialog__download-option" @click="handleDownload('word')">下载Word</button>
</div>
</div>
<!-- 右侧立即去投递按钮 -->
<button class="job-resume-custom-dialog__submit-btn" @click="handleSubmit">立即去投递</button>
</div>
</div>
<!-- 撤销修改确认弹窗 -->
<div v-if="showRollbackConfirm" class="job-resume-custom-dialog__rollback-confirm-overlay">
<div class="job-resume-custom-dialog__rollback-confirm">
<p class="job-resume-custom-dialog__rollback-confirm-text">确定要撤销AI助手对简历的修改吗</p>
<div class="job-resume-custom-dialog__rollback-confirm-actions">
<button class="job-resume-custom-dialog__rollback-confirm-cancel" @click="cancelRollback">取消</button>
<button class="job-resume-custom-dialog__rollback-confirm-ok" @click="confirmRollback">确定撤销</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
import JobResumeCustomEditPanel from '@/components/JobResumeCustomEditPanel.vue'
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
import { exportResumePdf, exportResumeWord } from '@/utils/resumeExport'
import { fetchResumeList } from '@/api/resume'
import type { ResumeListItem } from '@/api/resume'
import { fetchCustomizeResume, generateCustomizeResume, aiEditResume, rollbackCustomizeResume } from '@/api/jobs'
import type { CustomizeResumeData, AiEditChatMessage } from '@/api/jobs'
import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue'
// ==================== 类型定义 ====================
/** 简历选项 */
interface ResumeOption {
id: string
name: string
targetJob: string
}
/** 岗位信息 */
interface JobInfo {
title: string
company: string
companyLogoUrl: string
location: string
matchScore: number
missingSkills: string[]
keywords: string[]
sourceUrl: string
/** 默认简历信息(来自 skill-gap 接口) */
defaultResume: { resumeId: string; resumeName: string; targetPosition: string } | null
}
/** AI聊天消息(用于界面展示) */
interface AiChatMsg {
/** 角色:user-用户 assistant-AI助手 */
role: 'user' | 'assistant'
/** 消息内容 */
content: string
/** 是否可以撤销(仅 type=updated 的 assistant 消息有此标记) */
canRollback?: boolean
/** 撤销状态:idle-未操作 done-已撤销 */
rollbackStatus?: 'idle' | 'done'
}
// ==================== Props & Emits ====================
const props = defineProps<{
/** 控制弹窗显隐 */
modelValue: boolean
/** 岗位信息 */
jobInfo: JobInfo
/** 岗位 ID(字符串,避免大整数精度丢失) */
jobId: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'skip'): void
(e: 'submit'): void
}>()
// ==================== 步骤控制 ====================
/** 当前步骤:1-确认入口(居中弹窗) 2-差距分析(右侧抽屉) 3-定制简历 4-预览 */
const currentStep = ref(1)
/** 缺少的技能列表 */
const missingSkills = computed(() => props.jobInfo.missingSkills || [])
/** 匹配度等级文案(6分为高低分界线) */
const matchLevelText = computed(() => {
const score = props.jobInfo.matchScore
if (score >= 6) return '高匹配度'
return '低匹配度'
})
/** 是否为低匹配度(低于6分) */
const isLowMatch = computed(() => props.jobInfo.matchScore < 6)
/** 跳转到指定步骤 */
function goToStep(step: number) {
// 从步骤4返回步骤3时,清理步骤4的状态数据(避免下次进入被旧数据影响)
if (currentStep.value === 4 && step === 3) {
resetStep4State()
}
if (step === 3) initSkillOptions()
if (step === 4) fetchAndLoadCustomResume()
else currentStep.value = step
}
/** 重置步骤4(预览)的所有状态数据 */
function resetStep4State() {
aiMessages.value = []
aiInputText.value = ''
aiLoading.value = false
isShowDiff.value = false
previewTab.value = 'ai'
cachedOptimizedScore.value = 0
oldResumeTemplateData.value = {
name: '', email: '', mobileNumber: '', wechatNumber: '', summary: '',
educations: [], workExperiences: [], internships: [], projects: [],
competitions: [], skills: [], certificates: [],
}
resumeTemplateData.value = {
name: '', email: '', mobileNumber: '', wechatNumber: '', summary: '',
educations: [], workExperiences: [], internships: [], projects: [],
competitions: [], skills: [], certificates: [],
}
customResumeRawData.value = { resume: {} }
showDownloadMenu.value = false
}
/** 抽屉模式下一步 */
async function handleDrawerNext() {
if (currentStep.value >= 4) return
if (currentStep.value === 2) {
initSkillOptions()
currentStep.value++
} else if (currentStep.value === 3) {
// 步骤3 → 步骤4:先调用定制简历接口
await fetchAndLoadCustomResume()
} else {
currentStep.value++
}
}
/**
* 获取定制简历数据并跳转到预览步骤
* 流程:先 GET 查询 → 有数据直接用 → 无数据则 POST 生成 → 再 GET 查询
*/
async function fetchAndLoadCustomResume() {
const loadingInstance = ElLoading.service({
text: '正在生成定制简历...',
background: 'rgba(0, 0, 0, 0.5)',
})
try {
// 第一步:查询是否已有定制简历
let queryRes = await fetchCustomizeResume(props.jobId)
// if (queryRes.code === 0 && queryRes.data) {
// // 已有定制简历,直接填充数据并跳转预览
// fillCustomResumeData(queryRes.data)
// currentStep.value = 4
// return
// }
// 第二步:没有定制简历,调用生成接口
const genRes = await generateCustomizeResume({
jobId: props.jobId,
resumeId: selectedResume.value.id,
optimizeModules: selectedSectionKeys.value,
addSkills: selectedNewSkills.value.length > 0 ? selectedNewSkills.value : undefined,
})
if (genRes.code !== 0 || !genRes.data?.success) {
ElMessage.error('生成定制简历失败,请稍后重试')
return
}
// 第三步:生成成功后再次查询获取简历数据
queryRes = await fetchCustomizeResume(props.jobId)
if (queryRes.code === 0 && queryRes.data) {
fillCustomResumeData(queryRes.data)
currentStep.value = 4
} else {
ElMessage.error('获取定制简历数据失败')
}
} catch (e) {
console.error('[JobResumeCustomDialog] 定制简历流程失败', e)
ElMessage.error('定制简历失败,请稍后重试')
} finally {
loadingInstance.close()
}
}
/**
* 将定制简历接口返回的数据填充到简历模板
*/
function fillCustomResumeData(data: CustomizeResumeData) {
// 保存原始数据供编辑面板使用
customResumeRawData.value = JSON.parse(JSON.stringify(data))
const r = data.resume || {}
resumeTemplateData.value = {
name: r.name || '未填写姓名',
email: r.email || '',
mobileNumber: r.mobileNumber || '',
wechatNumber: r.wechatNumber || '',
summary: r.summary || '',
educations: (data.education || []).map(e => ({
school: e.school || '',
major: e.major || '',
degree: degreeToNumber(e.degree),
startDate: e.startDate || '',
endDate: e.endDate || '',
description: (e.description || []).map(d => ({ id: d.id, text: d.text || '' })),
})),
workExperiences: (data.work || []).map(w => ({
companyName: w.companyName || '',
position: w.position || '',
startDate: w.startDate || '',
endDate: w.endDate || '',
description: (w.description || []).map(d => ({ id: d.id, text: d.text || '' })),
})),
internships: (data.internship || []).map(i => ({
companyName: i.companyName || '',
position: i.position || '',
startDate: i.startDate || '',
endDate: i.endDate || '',
description: (i.description || []).map(d => ({ id: d.id, text: d.text || '' })),
})),
projects: (data.project || []).map(p => ({
projectName: p.projectName || '',
companyName: p.companyName || '',
role: p.role || '',
startDate: p.startDate || '',
endDate: p.endDate || '',
description: (p.description || []).map(d => ({ id: d.id, text: d.text || '' })),
})),
competitions: (data.competition || []).map(c => ({
competitionName: c.competitionName || '',
award: c.award || '',
awardDate: c.awardDate || '',
description: (c.description || []).map(d => ({ id: d.id, text: d.text || '' })),
})),
skills: r.skills || [],
certificates: r.certificates || [],
}
}
/** 学历文字转数字(接口返回中文,模板需要数字) */
function degreeToNumber(degree?: string): number {
const map: Record<string, number> = { '大专': 1, '本科': 2, '硕士': 3, '博士': 4 }
return map[degree || ''] || 2
}
/**
* 编辑面板数据更新回调 — 同步更新简历模板预览
* @param data 编辑面板传回的最新数据
*/
function onEditPanelUpdate(data: CustomizeResumeData) {
fillCustomResumeData(data)
}
/** 关闭弹窗并重置步骤 */
function handleClose() {
currentStep.value = 1
showResumeDropdown.value = false
showDownloadMenu.value = false
// 重置AI对话和差异对比状态
aiMessages.value = []
aiInputText.value = ''
aiLoading.value = false
isShowDiff.value = false
emit('update:modelValue', false)
}
/** 不优化,直接投递 */
function handleSkip() {
handleClose()
emit('skip')
}
// ==================== 定制简历选项(步骤三) ====================
/** 优化部分选项 */
interface OptimizeSection {
key: string
label: string
checked: boolean
tooltip?: string
}
/** 技能关键词选项 */
interface SkillOption {
name: string
checked: boolean
}
/** 左侧:可优化的简历部分 */
const optimizeSections = ref<OptimizeSection[]>([
{ key: 'summary', label: '个人概述', checked: false },
{ key: 'skills', label: '技能', checked: false, tooltip: '我们将把您勾选的技能补充进简历的技能模块。这对于简历能否通过ATS关键词筛选至关重要。' },
{ key: 'experience', label: '工作经验', checked: false, tooltip: '我们将把您选择的技能融入工作经历中,并对关键词进行润色,最大程度提升简历与岗位的匹配度。' },
])
/** 右侧:可新增的技能关键词 */
const newSkillOptions = ref<SkillOption[]>([])
/** 根据缺失技能初始化技能选项 */
function initSkillOptions() {
newSkillOptions.value = missingSkills.value.map(skill => ({
name: skill,
checked: false,
}))
}
/** 获取已勾选的优化部分 key 数组(对应 ["summary", "skills", "experience"] */
const selectedSectionKeys = computed(() =>
optimizeSections.value.filter(s => s.checked).map(s => s.key)
)
/** 获取已勾选的新增技能名称数组 */
const selectedNewSkills = computed(() =>
newSkillOptions.value.filter(s => s.checked).map(s => s.name)
)
// ==================== 简历选择(步骤二) ====================
/** 简历列表(从接口获取) */
const resumeList = ref<ResumeOption[]>([])
/** 当前选中的简历 */
const selectedResume = ref<ResumeOption>({ id: '', name: '', targetJob: '' })
/** 简历下拉是否展开 */
const showResumeDropdown = ref(false)
/** 加载简历列表(调用 /resume/list 接口) */
async function loadResumeList() {
try {
const res = await fetchResumeList()
if (res.code === '0' && res.data) {
resumeList.value = res.data.map((item: ResumeListItem) => ({
id: item.id || '',
name: item.resumeName || '未命名简历',
targetJob: item.targetPosition || '',
}))
}
} catch (e) {
console.error('[JobResumeCustomDialog] 加载简历列表失败', e)
}
}
/** 初始化简历选择(设置默认选中项) */
function initResumeSelection() {
const defaultResume = props.jobInfo.defaultResume
if (defaultResume) {
// 用 skill-gap 接口返回的简历作为默认选中
const defaultOption: ResumeOption = {
id: defaultResume.resumeId,
name: defaultResume.resumeName,
targetJob: defaultResume.targetPosition || '',
}
selectedResume.value = defaultOption
// 如果列表中没有这个简历,插入到列表头部
if (!resumeList.value.find(r => r.id === defaultOption.id)) {
resumeList.value.unshift(defaultOption)
}
} else if (resumeList.value.length > 0) {
selectedResume.value = resumeList.value[0]
}
}
/** 监听弹窗打开,加载简历列表 */
watch(() => props.modelValue, async (val) => {
if (val) {
await loadResumeList()
initResumeSelection()
}
})
/** 切换简历下拉 */
function toggleResumeDropdown() {
showResumeDropdown.value = !showResumeDropdown.value
}
/** 选择简历 */
function selectResume(r: ResumeOption) {
selectedResume.value = r
showResumeDropdown.value = false
}
// ==================== 步骤四:预览相关 ====================
/** 简历模板组件引用 */
const resumeTemplateRef = ref()
/** 简历模板数据 */
const resumeTemplateData = ref<ResumeTemplateData>({
name: '',
email: '',
mobileNumber: '',
wechatNumber: '',
summary: '',
educations: [],
workExperiences: [],
internships: [],
projects: [],
competitions: [],
skills: [],
certificates: [],
})
/** 定制简历原始数据(传给编辑面板组件) */
const customResumeRawData = ref<CustomizeResumeData>({
resume: {},
})
/** 当前预览右侧tabai-AI帮写 / edit-编辑 */
const previewTab = ref<'ai' | 'edit'>('ai')
/** AI优化结果列表(根据步骤三勾选的优化项动态生成) */
const aiOptimizeResults = computed<string[]>(() => {
const results: string[] = []
// 根据「1.选择你要优化的部分」勾选情况生成说明
const checkedSections = optimizeSections.value.filter(s => s.checked)
checkedSections.forEach(section => {
switch (section.key) {
case 'summary':
results.push('优化了个人概述,使其更贴合目标岗位要求')
break
case 'skills':
results.push('优化了技能模块,补充了与岗位匹配的关键技能词')
break
case 'experience':
results.push('润色了工作经历描述,融入岗位相关关键词以提升匹配度')
break
}
})
// 兜底:如果什么都没勾选,给一个默认提示
if (results.length === 0) {
results.push('已根据岗位要求对简历进行整体优化')
}
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快捷操作按钮 */
const aiQuickActions = ref<string[]>([
'精简一下第一段工作经历',
'帮我强化一下简历里面的量化成果',
'删掉和这个岗位不相关的技能',
])
/** AI聊天消息列表(界面展示用) */
const aiMessages = ref<AiChatMsg[]>([])
/** AI输入框内容 */
const aiInputText = ref('')
/** AI消息区域DOM引用 */
const aiMessagesRef = ref<HTMLElement>()
/** AI是否正在请求中 */
const aiLoading = ref(false)
/** 是否显示差异对比模式 */
const isShowDiff = ref(false)
/** 旧简历模板数据(AI修改前的快照,用于差异对比) */
const oldResumeTemplateData = ref<ResumeTemplateData>({
name: '',
email: '',
mobileNumber: '',
wechatNumber: '',
summary: '',
educations: [],
workExperiences: [],
internships: [],
projects: [],
competitions: [],
skills: [],
certificates: [],
})
/**
* 构建发送给接口的chatHistory格式
* 将界面展示的消息列表转换为接口需要的格式
*/
function buildChatHistory(): AiEditChatMessage[] {
return aiMessages.value.map(msg => ({
role: msg.role,
content: msg.content,
}))
}
/**
* 滚动AI消息区域到底部
*/
function scrollAiMessagesToBottom() {
nextTick(() => {
if (aiMessagesRef.value) {
aiMessagesRef.value.scrollTop = aiMessagesRef.value.scrollHeight
}
})
}
/**
* 发送AI消息 — 调用AI对话编辑简历接口
* @param text 用户输入的消息文本
*/
async function sendAiMessage(text: string) {
if (!text.trim() || aiLoading.value) return
const userMessage = text.trim()
// 添加用户消息到列表
aiMessages.value.push({ role: 'user', content: userMessage })
// 清空输入框
aiInputText.value = ''
// 滚动到底部
scrollAiMessagesToBottom()
// 开始请求
aiLoading.value = true
try {
const res = await aiEditResume({
jobId: props.jobId,
instruction: userMessage,
chatHistory: buildChatHistory().slice(0, -1), // 不包含刚发送的这条
})
if (res.code === 0 && res.data) {
const { type, message } = res.data
// 添加AI助手回复到消息列表
aiMessages.value.push({ role: 'assistant', content: message, canRollback: type === 'updated', rollbackStatus: 'idle' })
scrollAiMessagesToBottom()
if (type === 'updated') {
// 简历已更新:深拷贝当前简历数据作为旧数据快照
oldResumeTemplateData.value = JSON.parse(JSON.stringify(resumeTemplateData.value))
// 重新查询定制简历数据来刷新简历预览
const queryRes = await fetchCustomizeResume(props.jobId)
if (queryRes.code === 0 && queryRes.data) {
fillCustomResumeData(queryRes.data)
// 开启差异对比模式
isShowDiff.value = true
}
}
// type === 'message' 时只显示对话,不做额外操作
} else {
// 接口返回异常
aiMessages.value.push({ role: 'assistant', content: '抱歉,请求失败了,请稍后重试。' })
scrollAiMessagesToBottom()
}
} catch (e) {
console.error('[JobResumeCustomDialog] AI对话请求失败', e)
aiMessages.value.push({ role: 'assistant', content: '网络异常,请稍后重试。' })
scrollAiMessagesToBottom()
} finally {
aiLoading.value = false
}
}
/** 下载菜单是否展开 */
const showDownloadMenu = ref(false)
/** 撤销确认弹窗是否显示 */
const showRollbackConfirm = ref(false)
/** 当前要撤销的消息索引 */
const rollbackMsgIndex = ref(-1)
/**
* 判断该消息是否为消息列表的最后一条
* 撤销按钮只在最后一条消息恰好是AI修改简历的回答时才显示
* @param msgIndex 消息在列表中的索引
*/
function isLastMessage(msgIndex: number): boolean {
return msgIndex === aiMessages.value.length - 1
}
/**
* 点击撤销修改按钮 — 弹出确认弹窗
* @param msgIndex 消息在列表中的索引
*/
function handleRollbackClick(msgIndex: number) {
rollbackMsgIndex.value = msgIndex
showRollbackConfirm.value = true
}
/**
* 确认撤销修改 — 调用 rollback 接口并刷新简历数据
*/
async function confirmRollback() {
showRollbackConfirm.value = false
const idx = rollbackMsgIndex.value
if (idx < 0) return
try {
const res = await rollbackCustomizeResume(props.jobId)
if (res.code === 0) {
// 标记该消息为已撤销
aiMessages.value[idx].rollbackStatus = 'done'
// 关闭差异对比模式
isShowDiff.value = false
// 重新查询简历数据刷新预览
const queryRes = await fetchCustomizeResume(props.jobId)
if (queryRes.code === 0 && queryRes.data) {
fillCustomResumeData(queryRes.data)
}
} else {
ElMessage.error('撤销失败,请稍后重试')
}
} catch (e) {
console.error('[JobResumeCustomDialog] 撤销修改失败', e)
ElMessage.error('撤销失败,请稍后重试')
}
}
/** 取消撤销 */
function cancelRollback() {
showRollbackConfirm.value = false
rollbackMsgIndex.value = -1
}
/** 切换下载菜单 */
function toggleDownloadMenu() {
showDownloadMenu.value = !showDownloadMenu.value
}
/** 处理下载(PDF/Word */
async function handleDownload(type: 'pdf' | 'word') {
showDownloadMenu.value = false
const element = resumeTemplateRef.value?.resumeRef
if (!element) {
console.error('[下载简历] 无法获取简历模板DOM')
return
}
const fileName = (resumeTemplateData.value.name || '简历') + '_定制简历'
try {
if (type === 'pdf') {
await exportResumePdf(element, fileName)
} else {
exportResumeWord(element, fileName)
}
} catch (err) {
console.error('[下载简历] 导出失败', err)
}
}
/** 立即去投递 */
function handleSubmit() {
handleClose()
emit('submit')
}
</script>