Files
offerpai_web/src/components/JobResumeCustomDialog.vue
T
2026-07-08 18:58:27 +08:00

1109 lines
45 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 v-if="modelValue" class="job-resume-custom-dialog job-resume-custom-dialog--drawer">
<div class="job-resume-custom-dialog__overlay" @click="handleClose"></div>
<!-- ===== 右侧抽屉 ===== -->
<div 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>
<!-- 下载简历按钮步骤三显示位于步骤指示器上方 -->
<div v-if="currentStep === 4" class="job-resume-custom-dialog__top-download mt10 ml12">
<button class="job-resume-custom-dialog__top-download-btn" @click="toggleDownloadMenu">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__top-download-icon">
<path d="M8 2v8M8 10l-3-3M8 10l3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 12h10" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
下载简历
</button>
<!-- 下载下拉菜单 -->
<div v-if="showDownloadMenu" class="job-resume-custom-dialog__top-download-menu">
<button class="job-resume-custom-dialog__top-download-option" @click="handleDownload('pdf')">下载PDF</button>
<button class="job-resume-custom-dialog__top-download-option" @click="handleDownload('word')">下载Word</button>
</div>
</div>
<!-- 步骤指示器 -->
<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-line"></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-line"></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">你的简历与该岗位的<span class="job-resume-custom-dialog__gap-title-highlight">匹配度{{ isLowMatch ? '较低' : '较高' }}</span></h3>
<p class="job-resume-custom-dialog__gap-subtitle">匹配度低于 6.0 分的简历在筛选环节可能会被优先淘汰我们会帮你快速优化提升</p>
</div>
<!-- 右侧匹配度分数 -->
<div class="job-resume-custom-dialog__gap-score-area">
<div class="job-resume-custom-dialog__score-circle">
<svg viewBox="0 0 72 72" class="job-resume-custom-dialog__score-svg">
<circle cx="36" cy="36" r="32" stroke-width="5" stroke="rgba(82,202,209,0.2)" fill="none"/>
<circle cx="36" cy="36" r="32" stroke-width="5" fill="none" stroke="#52CAD1" stroke-linecap="round" :stroke-dasharray="2*Math.PI*32" :stroke-dashoffset="2*Math.PI*32*(1-jobInfo.matchScore/10)" transform="rotate(-90 36 36)"/>
</svg>
<span class="job-resume-custom-dialog__score-num">{{ jobInfo.matchScore }}</span>
</div>
<span class="job-resume-custom-dialog__score-tag">{{ matchLevelText }}</span>
</div>
</div>
<!-- 简历选择 -->
<div class="job-resume-custom-dialog__section">
<h4 class="job-resume-custom-dialog__section-title">简历选择</h4>
<div class="job-resume-custom-dialog__resume-select-box" @click="toggleResumeDropdown">
<span class="job-resume-custom-dialog__resume-select-name">{{ selectedResume.name }}</span>
<svg viewBox="0 0 14 14" fill="none" class="job-resume-custom-dialog__resume-select-arrow"><path d="M4 6l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<!-- 简历下拉 -->
<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.stop="selectResume(r)">{{ r.name }}</div>
</div>
</div>
</div>
<!-- 岗位概览 -->
<div class="job-resume-custom-dialog__section">
<h4 class="job-resume-custom-dialog__section-title">岗位概览</h4>
<div class="job-resume-custom-dialog__overview-card">
<div class="job-resume-custom-dialog__overview-title">{{ jobInfo.company }} · {{ jobInfo.title }}</div>
<div class="job-resume-custom-dialog__overview-sub">{{ jobInfo.location }} · {{ jobInfo.company }}</div>
<div class="job-resume-custom-dialog__overview-tags">
<span v-for="kw in jobInfo.keywords" :key="kw" class="job-resume-custom-dialog__overview-tag">{{ kw }}</span>
</div>
</div>
</div>
<!-- 差距分析列表 -->
<div class="job-resume-custom-dialog__section">
<h4 class="job-resume-custom-dialog__section-title">差距分析</h4>
<div class="job-resume-custom-dialog__gap-list">
<div v-for="(gap, idx) in gapItems" :key="idx" class="job-resume-custom-dialog__gap-item">
<div class="job-resume-custom-dialog__gap-item-num">
<span>{{ idx + 1 }}</span>
</div>
<div class="job-resume-custom-dialog__gap-item-content">
<div class="job-resume-custom-dialog__gap-item-title">{{ gap.title }}</div>
<div class="job-resume-custom-dialog__gap-item-desc">{{ gap.description }}</div>
</div>
</div>
</div>
</div>
</template>
<!-- 步骤二定制简历 -->
<template v-if="currentStep === 3">
<div class="job-resume-custom-dialog__custom-v2">
<!-- 第一部分选择你要优化的部分 -->
<div class="job-resume-custom-dialog__custom-section">
<div class="job-resume-custom-dialog__custom-section-header">
<h3 class="job-resume-custom-dialog__custom-section-title">选择你要优化的部分</h3>
<p class="job-resume-custom-dialog__custom-section-desc">选择后系统会基于岗位要求自动重写对应模块提升简历通过初筛的概率</p>
</div>
<div class="job-resume-custom-dialog__custom-cards">
<label
v-for="item in optimizeSections"
:key="item.key"
class="job-resume-custom-dialog__custom-card"
:class="{ 'job-resume-custom-dialog__custom-card--checked': item.checked }"
>
<input type="checkbox" v-model="item.checked" class="job-resume-custom-dialog__custom-card-input" />
<!-- 自定义复选框圆形 -->
<span class="job-resume-custom-dialog__custom-card-check">
<svg v-if="item.checked" viewBox="0 0 20 20" fill="none" class="job-resume-custom-dialog__custom-card-check-svg">
<circle cx="10" cy="10" r="10" fill="#52CAD1"/>
<path d="M6 10.5l2.5 2.5 5.5-5.5" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg v-else viewBox="0 0 20 20" fill="none" class="job-resume-custom-dialog__custom-card-check-svg">
<circle cx="10" cy="10" r="9.33" stroke="#6A7282" stroke-width="1.33"/>
</svg>
</span>
<!-- 文字内容 -->
<span class="job-resume-custom-dialog__custom-card-text">
<span class="job-resume-custom-dialog__custom-card-label">{{ item.label }}</span>
<span v-if="item.tooltip" class="job-resume-custom-dialog__custom-card-tip">{{ item.tooltip }}</span>
</span>
</label>
</div>
</div>
<!-- 第二部分选择你要新增的技能关键词 -->
<div class="job-resume-custom-dialog__custom-section">
<div class="job-resume-custom-dialog__custom-section-header">
<h3 class="job-resume-custom-dialog__custom-section-title">选择你要新增的技能关键词</h3>
<p class="job-resume-custom-dialog__custom-section-desc">关键词会被自然融入经历描述中避免生硬堆砌</p>
</div>
<div class="job-resume-custom-dialog__custom-cards">
<label
v-for="skill in newSkillOptions"
:key="skill.name"
class="job-resume-custom-dialog__custom-card"
:class="{ 'job-resume-custom-dialog__custom-card--checked': skill.checked }"
>
<input type="checkbox" v-model="skill.checked" class="job-resume-custom-dialog__custom-card-input" />
<!-- 自定义复选框圆形 -->
<span class="job-resume-custom-dialog__custom-card-check">
<svg v-if="skill.checked" viewBox="0 0 20 20" fill="none" class="job-resume-custom-dialog__custom-card-check-svg">
<circle cx="10" cy="10" r="10" fill="#52CAD1"/>
<path d="M6 10.5l2.5 2.5 5.5-5.5" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg v-else viewBox="0 0 20 20" fill="none" class="job-resume-custom-dialog__custom-card-check-svg">
<circle cx="10" cy="10" r="9.33" stroke="#6A7282" stroke-width="1.33"/>
</svg>
</span>
<!-- 文字内容 -->
<span class="job-resume-custom-dialog__custom-card-text">
<span class="job-resume-custom-dialog__custom-card-label">{{ skill.name }}</span>
</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"
:mode="resumeTemplateMode"
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-congrats-wrap">
<!-- 左侧绿色边框卡片文字部分 -->
<div class="job-resume-custom-dialog__ai-congrats">
<p class="job-resume-custom-dialog__ai-congrats-title">🎉 恭喜你的简历匹配值<br/> <span class="job-resume-custom-dialog__ai-congrats-bold">{{ jobInfo.matchScore }}</span> 提升到了 <span class="job-resume-custom-dialog__ai-congrats-bold">{{ cachedOptimizedScore }}</span></p>
</div>
<!-- 右侧圆环+标签卡片外面 -->
<div class="job-resume-custom-dialog__ai-congrats-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="5" stroke="#E8E8E8" fill="none" opacity="0.3"/>
<circle cx="30" cy="30" r="24" stroke-width="5" 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-optimize-detail">
<p class="job-resume-custom-dialog__ai-optimize-subtitle">改写说明·{{ aiOptimizeResults.length }}</p>
<div class="job-resume-custom-dialog__ai-optimize-list">
<p v-for="(item, i) in aiOptimizeResults" :key="i" class="job-resume-custom-dialog__ai-optimize-item">{{ i + 1 }}{{ item }}</p>
</div>
</div>
<!-- 快捷操作按钮 -->
<div v-if="false" 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 width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_118_2831)">
<path d="M16.8621 4.73833C17.2221 3.7425 16.2571 2.7775 15.2613 3.13833L3.09046 7.54C2.09129 7.90167 1.97046 9.265 2.88963 9.7975L6.77463 12.0467L10.2438 8.5775C10.401 8.4257 10.6115 8.34171 10.83 8.34361C11.0485 8.34551 11.2575 8.43315 11.412 8.58765C11.5665 8.74216 11.6541 8.95117 11.656 9.16967C11.6579 9.38816 11.5739 9.59867 11.4221 9.75583L7.95296 13.225L10.203 17.11C10.7346 18.0292 12.098 17.9075 12.4596 16.9092L16.8621 4.73833Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_118_2831">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</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">
<!-- 步骤二定制简历):上一步 + 下一步 -->
<template v-if="currentStep === 3">
<div class="job-resume-custom-dialog__footer-btns">
<button class="job-resume-custom-dialog__prev-btn" @click="goToStep(2)">上一步</button>
<button class="job-resume-custom-dialog__next-btn" @click="handleDrawerNext">下一步</button>
</div>
</template>
<!-- 步骤一差距分析):立即定制简历 -->
<template v-else>
<button class="job-resume-custom-dialog__primary-btn" @click="handleDrawerNext">立即定制简历</button>
</template>
</div>
<!-- 步骤三专属底部下载简历 + 立即去投递 -->
<div v-if="currentStep === 4" class="job-resume-custom-dialog__preview-footer mt10 pt30">
<!-- 左侧上一步按钮 -->
<button class="job-resume-custom-dialog__footer-prev-btn" @click="goToStep(3)">上一步</button>
<!-- 右侧立即去投递按钮 -->
<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>
<!-- 全屏加载遮罩 -->
<FullscreenLoading :visible="fullLoading" :text="fullLoadingText" title="Nova 正在帮您努力处理~" />
<!-- 简历定制步骤进度遮罩 -->
<StepProgressOverlay
v-if="showCustomStepProgress"
:title="customStepTitle"
:subtitle="customStepSubtitle"
:steps="customStepNames"
:durations="customStepDurations"
:done="customStepDone"
@finished="handleCustomStepFinished"
@close="handleCustomStepClose"
/>
</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, setDefaultResume } 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'
import FullscreenLoading from '@/components/FullscreenLoading.vue'
import StepProgressOverlay from '@/components/StepProgressOverlay.vue'
// ==================== 类型定义 ====================
/** 简历选项 */
interface ResumeOption {
id: string
name: string
targetJob: string
}
/** 差距分析项 */
interface GapItem {
keyword: string
title: string
description: string
}
/** 岗位信息 */
interface JobInfo {
title: string
company: string
companyLogoUrl: string
location: string
matchScore: number
missingSkills: string[]
keywords: string[]
sourceUrl: string
/** 差距分析列表(来自 skill-gap 接口) */
gaps: GapItem[]
/** 默认简历信息(来自 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
}>()
// ==================== 步骤控制 ====================
/** 全屏加载是否显示 */
const fullLoading = ref(false)
/** 全屏加载文案 */
const fullLoadingText = ref('')
// ==================== 简历定制步骤进度遮罩 ====================
/** 步骤进度遮罩是否显示 */
const showCustomStepProgress = ref(false)
/** 接口是否已完成 */
const customStepDone = ref(false)
/** 大标题 */
const customStepTitle = '简历定制中,请耐心等待'
/** 副标题 */
const customStepSubtitle = '根据STAR法则,快速定制补齐你的简历缺失部分'
/** 4个步骤名称 */
const customStepNames = ['个人概述优化...', '岗位技能优化...', '工作经验优化...', '最终简历生成中...']
/** 每步时长 */
const customStepDurations = [2000, 2000, 2000, 2000]
/** 缓存的定制简历数据(等进度走完后填充) */
const pendingCustomResumeData = ref<any>(null)
/** 步骤进度走完后填充数据并跳转预览 */
function handleCustomStepFinished() {
showCustomStepProgress.value = false
if (pendingCustomResumeData.value) {
fillCustomResumeData(pendingCustomResumeData.value)
currentStep.value = 4
pendingCustomResumeData.value = null
}
}
/** 关闭步骤进度遮罩 */
function handleCustomStepClose() {
showCustomStepProgress.value = false
}
/** 当前步骤:2-差距分析(右侧抽屉) 3-定制简历 4-预览 */
const currentStep = ref(2)
/** 缺少的技能列表 */
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)
/** 差距分析列表(来自 jobInfo.gaps */
const gapItems = computed(() => props.jobInfo.gaps || [])
/** 跳转到指定步骤 */
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) {
// 校验:至少勾选一个优化部分
if (selectedSectionKeys.value.length === 0) {
ElMessage.warning('请选择你要优化的部分')
return
}
// 步骤3 → 步骤4:先调用定制简历接口
await fetchAndLoadCustomResume()
} else {
currentStep.value++
}
}
/**
* 获取定制简历数据并跳转到预览步骤
* 流程:先 GET 查询 → 有数据直接用 → 无数据则 POST 生成 → 再 GET 查询
*/
async function fetchAndLoadCustomResume() {
// 显示步骤进度遮罩
showCustomStepProgress.value = true
customStepDone.value = false
// 缓存数据用于进度完成后填充
let resumeData: CustomizeResumeData | null = null
try {
// 第一步:查询是否已有定制简历
let queryRes = await fetchCustomizeResume(props.jobId)
// 第二步:没有定制简历,调用生成接口
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) {
showCustomStepProgress.value = false
ElMessage.error('生成定制简历失败,请稍后重试')
return
}
// 第三步:生成成功后再次查询获取简历数据
queryRes = await fetchCustomizeResume(props.jobId)
if (queryRes.code === 0 && queryRes.data) {
resumeData = queryRes.data
// 通知步骤进度组件接口已完成
customStepDone.value = true
// 缓存数据,等进度走完后再填充
pendingCustomResumeData.value = resumeData
} else {
showCustomStepProgress.value = false
ElMessage.error('获取定制简历数据失败')
}
} catch (e) {
console.error('[JobResumeCustomDialog] 定制简历流程失败', e)
showCustomStepProgress.value = false
ElMessage.error('定制简历失败,请稍后重试')
}
}
/**
* 将定制简历接口返回的数据填充到简历模板
*/
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 = 2
showResumeDropdown.value = false
showDownloadMenu.value = false
// 重置AI对话和差异对比状态
aiMessages.value = []
aiInputText.value = ''
aiLoading.value = false
isShowDiff.value = false
emit('update:modelValue', false)
}
// ==================== 定制简历选项(步骤一) ====================
/** 优化部分选项 */
interface OptimizeSection {
key: string
label: string
checked: boolean
tooltip?: string
}
/** 技能关键词选项 */
interface SkillOption {
name: string
checked: boolean
}
/** 左侧:可优化的简历部分 */
const optimizeSections = ref<OptimizeSection[]>([
{ key: 'summary', label: '个人概述', checked: true },
{ key: 'skills', label: '技能', checked: true, tooltip: '我们将把您勾选的技能补充进简历的技能模块。这对于简历能否通过ATS关键词筛选至关重要。' },
{ key: 'experience', label: '工作经验', checked: true, tooltip: '我们将把您选择的技能融入工作经历中,并对关键词进行润色,最大程度提升简历与岗位的匹配度。' },
])
/** 右侧:可新增的技能关键词 */
const newSkillOptions = ref<SkillOption[]>([])
/** 根据缺失技能初始化技能选项(默认全选) */
function initSkillOptions() {
newSkillOptions.value = missingSkills.value.map(skill => ({
name: skill,
checked: true,
}))
}
/** 获取已勾选的优化部分 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
// 调用设为默认简历接口
setDefaultResume(r.id).catch(e => {
console.error('[JobResumeCustomDialog] 设为默认简历失败', e)
})
}
// ==================== 步骤一:预览相关 ====================
/** 简历模板组件引用 */
const resumeTemplateRef = ref()
/** 简历模板模式:preview=预览,export=导出 */
const resumeTemplateMode = ref<'preview' | 'export'>('preview')
/** 简历模板数据 */
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
// 切换到导出模式(大padding)
resumeTemplateMode.value = 'export'
await nextTick()
const element = resumeTemplateRef.value?.resumeRef
if (!element) {
console.error('[下载简历] 无法获取简历模板DOM')
resumeTemplateMode.value = 'preview'
return
}
const fileName = (resumeTemplateData.value.name || '简历') + '_定制简历'
try {
if (type === 'pdf') {
await exportResumePdf(element, fileName)
} else {
exportResumeWord(element, fileName)
}
} catch (err) {
console.error('[下载简历] 导出失败', err)
} finally {
// 导出完成后恢复预览模式
resumeTemplateMode.value = 'preview'
}
}
/** 立即去投递 */
function handleSubmit() {
handleClose()
emit('submit')
}
</script>