简历优化和岗位简历

This commit is contained in:
2026-04-13 21:34:40 +08:00
parent a39835be01
commit 1c91818494
32 changed files with 3850 additions and 320 deletions
+446 -109
View File
@@ -10,7 +10,7 @@
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__close-icon"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
<div class="job-resume-custom-dialog__tip-bar"><span>你与该岗位的匹配度较低简历可能无法通过机筛</span></div>
<div 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">
@@ -52,9 +52,9 @@
<h2 class="job-resume-custom-dialog__drawer-title">生成你的岗位专属简历</h2>
</div>
<!-- 返回按钮步骤四显示 -->
<button v-if="currentStep === 4" class="job-resume-custom-dialog__back-btn" @click="goToStep(3)">返回</button>
<button v-if="currentStep === 4" class="job-resume-custom-dialog__back-btn mt10" @click="goToStep(3)">返回</button>
<!-- 步骤指示器 -->
<div class="job-resume-custom-dialog__steps">
<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>
@@ -71,8 +71,8 @@
<template v-if="currentStep === 2">
<div class="job-resume-custom-dialog__gap-header">
<div class="job-resume-custom-dialog__gap-left">
<h3 class="job-resume-custom-dialog__gap-title">你的简历与该岗位的匹配度较低</h3>
<div class="job-resume-custom-dialog__gap-warn">
<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>
@@ -131,9 +131,9 @@
<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">
<!-- <div class="job-resume-custom-dialog__gap-cell">
<span class="job-resume-custom-dialog__gap-value">{{ selectedResume.targetJob || '—' }}</span>
</div>
</div> -->
</div>
<!-- 第三行岗位关键词 -->
<div class="job-resume-custom-dialog__gap-row">
@@ -195,9 +195,14 @@
<!-- 步骤四预览 -->
<template v-if="currentStep === 4">
<div class="job-resume-custom-dialog__preview">
<!-- 左侧简历模板预览 -->
<!-- 左侧简历模板预览支持差异对比模式 -->
<div class="job-resume-custom-dialog__preview-left">
<JobResumeTemplate :resumeData="resumeTemplateData" ref="resumeTemplateRef" />
<JobResumeTemplate
:resumeData="resumeTemplateData"
:showDiff="isShowDiff"
:oldResumeData="oldResumeTemplateData"
ref="resumeTemplateRef"
/>
</div>
<!-- 右侧AI帮写 / 编辑 tab -->
<div class="job-resume-custom-dialog__preview-right">
@@ -252,10 +257,37 @@
<div
v-for="(msg, i) in aiMessages"
:key="i"
class="job-resume-custom-dialog__ai-msg"
:class="msg.role === 'ai' ? 'job-resume-custom-dialog__ai-msg--ai' : 'job-resume-custom-dialog__ai-msg--user'"
class="job-resume-custom-dialog__ai-msg-wrap"
>
<div class="job-resume-custom-dialog__ai-msg-bubble">{{ msg.content }}</div>
<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>
<!-- 撤销修改气泡 type=updated assistant 消息显示 -->
<div v-if="msg.canRollback" class="job-resume-custom-dialog__ai-rollback">
<button
v-if="msg.rollbackStatus !== 'done'"
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>
<span v-else 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>
</div>
<!-- AI正在回复的加载指示器 -->
<div v-if="aiLoading" class="job-resume-custom-dialog__ai-msg job-resume-custom-dialog__ai-msg--ai">
<div class="job-resume-custom-dialog__ai-msg-bubble job-resume-custom-dialog__ai-msg-bubble--loading">AI正在思考中...</div>
</div>
</div>
<!-- AI输入框 -->
@@ -273,9 +305,9 @@
</button>
</div>
</div>
<!-- 编辑内容占位 -->
<!-- 编辑内容 折叠手风琴式编辑面板 -->
<div v-if="previewTab === 'edit'" class="job-resume-custom-dialog__preview-edit">
<div class="job-resume-custom-dialog__placeholder">编辑功能待开发</div>
<JobResumeCustomEditPanel :resumeData="customResumeRawData" @update="onEditPanelUpdate" />
</div>
</div>
</div>
@@ -299,15 +331,30 @@
<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 } from 'vue'
import { ref, computed, nextTick, watch } from 'vue'
import html2pdf from 'html2pdf.js'
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
import JobResumeCustomEditPanel from '@/components/JobResumeCustomEditPanel.vue'
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
import { fetchProfile, fetchEducation, fetchWork, fetchInternship, fetchProject, fetchCompetition } from '@/api/profile'
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'
// ==================== 类型定义 ====================
@@ -328,12 +375,20 @@ interface JobInfo {
missingSkills: string[]
keywords: string[]
sourceUrl: string
/** 默认简历信息(来自 skill-gap 接口) */
defaultResume: { resumeId: string; resumeName: string; targetPosition: string } | null
}
/** AI聊天消息 */
/** AI聊天消息(用于界面展示) */
interface AiChatMsg {
role: 'ai' | 'user'
/** 角色:user-用户 assistant-AI助手 */
role: 'user' | 'assistant'
/** 消息内容 */
content: string
/** 是否可以撤销(仅 type=updated 的 assistant 消息有此标记) */
canRollback?: boolean
/** 撤销状态:idle-未操作 done-已撤销 */
rollbackStatus?: 'idle' | 'done'
}
// ==================== Props & Emits ====================
@@ -343,6 +398,8 @@ const props = defineProps<{
modelValue: boolean
/** 岗位信息 */
jobInfo: JobInfo
/** 岗位 ID(字符串,避免大整数精度丢失) */
jobId: string
}>()
const emit = defineEmits<{
@@ -359,35 +416,163 @@ const currentStep = ref(1)
/** 缺少的技能列表 */
const missingSkills = computed(() => props.jobInfo.missingSkills || [])
/** 匹配度等级文案 */
/** 匹配度等级文案6分为高低分界线) */
const matchLevelText = computed(() => {
const score = props.jobInfo.matchScore
if (score >= 8) return '高匹配度'
if (score >= 5) return '中匹配度'
if (score >= 6) return '高匹配度'
return '低匹配度'
})
/** 是否为低匹配度(低于6分) */
const isLowMatch = computed(() => props.jobInfo.matchScore < 6)
/** 跳转到指定步骤 */
function goToStep(step: number) {
if (step === 3) initSkillOptions()
if (step === 4) loadResumeData()
currentStep.value = step
if (step === 4) fetchAndLoadCustomResume()
else currentStep.value = step
}
/** 抽屉模式下一步 */
function handleDrawerNext() {
if (currentStep.value < 4) {
if (currentStep.value === 2) initSkillOptions()
if (currentStep.value === 3) loadResumeData()
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()
// 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()
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)
}
@@ -431,20 +616,71 @@ function initSkillOptions() {
}))
}
/** 获取已勾选的优化部分 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[]>([
{ id: '1', name: '李华_产品经理', targetJob: '电商产品经理' },
{ id: '2', name: '李华_数据分析', targetJob: '数据分析师' },
])
/** 简历列表(从接口获取 */
const resumeList = ref<ResumeOption[]>([])
/** 当前选中的简历 */
const selectedResume = ref<ResumeOption>(resumeList.value[0])
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
@@ -477,79 +713,41 @@ const resumeTemplateData = ref<ResumeTemplateData>({
certificates: [],
})
/** 从接口加载个人资料并组装简历数据 */
async function loadResumeData() {
try {
// 并行请求所有个人资料数据
const [profileRes, eduRes, workRes, internRes, projRes, compRes] = await Promise.all([
fetchProfile(),
fetchEducation(),
fetchWork(),
fetchInternship(),
fetchProject(),
fetchCompetition(),
])
// 组装简历模板数据
const profile = profileRes.code === '0' ? profileRes.data : null
resumeTemplateData.value = {
name: profile?.name || '未填写姓名',
email: profile?.email || '',
mobileNumber: profile?.mobileNumber || '',
wechatNumber: profile?.wechatNumber || '',
summary: '', // 个人概述字段,后续由AI生成或用户编辑
educations: eduRes.code === '0' && eduRes.data ? eduRes.data.map(e => ({
school: e.school || '',
major: e.major || '',
degree: e.degree || 2,
startDate: e.startDate || '',
endDate: e.endDate || '',
description: e.description,
})) : [],
workExperiences: workRes.code === '0' && workRes.data ? workRes.data.map(w => ({
companyName: w.companyName || '',
position: w.position || '',
startDate: w.startDate || '',
endDate: w.endDate || '',
description: w.description,
})) : [],
internships: internRes.code === '0' && internRes.data ? internRes.data.map(i => ({
companyName: i.companyName || '',
position: i.position || '',
startDate: i.startDate || '',
endDate: i.endDate || '',
description: i.description,
})) : [],
projects: projRes.code === '0' && projRes.data ? projRes.data.map(p => ({
projectName: p.projectName || '',
companyName: p.companyName || '',
role: p.role || '',
startDate: p.startDate || '',
endDate: p.endDate || '',
description: p.description,
})) : [],
competitions: compRes.code === '0' && compRes.data ? compRes.data.map(c => ({
competitionName: c.competitionName || '',
award: c.award || '',
awardDate: c.awardDate || '',
description: c.description,
})) : [],
skills: profile?.skills || [],
certificates: profile?.certificates || [],
}
} catch (err) {
console.error('[JobResumeCustomDialog] 加载简历数据失败', err)
}
}
/** 定制简历原始数据(传给编辑面板组件) */
const customResumeRawData = ref<CustomizeResumeData>({
resume: {},
})
/** 当前预览右侧tabai-AI帮写 / edit-编辑 */
const previewTab = ref<'ai' | 'edit'>('ai')
/** AI优化结果列表(模拟数据 */
const aiOptimizeResults = ref<string[]>([
'增加了个人概述',
'优化了5段经历描述',
])
/** 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
})
/** AI快捷操作按钮 */
const aiQuickActions = ref<string[]>([
@@ -558,7 +756,7 @@ const aiQuickActions = ref<string[]>([
'删掉和这个岗位不相关的技能',
])
/** AI聊天消息列表 */
/** AI聊天消息列表(界面展示用) */
const aiMessages = ref<AiChatMsg[]>([])
/** AI输入框内容 */
@@ -567,12 +765,43 @@ const aiInputText = ref('')
/** AI消息区域DOM引用 */
const aiMessagesRef = ref<HTMLElement>()
/** 发送AI消息 */
function sendAiMessage(text: string) {
if (!text.trim()) return
aiMessages.value.push({ role: 'user', content: text.trim() })
aiInputText.value = ''
// TODO: 接入AI聊天接口,获取AI回复
/** 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
@@ -580,9 +809,117 @@ function sendAiMessage(text: string) {
})
}
/**
* 发送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()
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)
/**
* 点击撤销修改按钮 — 弹出确认弹窗
* @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()
if (res.code === 0) {
// 标记该消息为已撤销
aiMessages.value[idx].rollbackStatus = 'done'
// 关闭差异对比模式
isShowDiff.value = false
// 重新查询简历数据刷新预览
const queryRes = await fetchCustomizeResume()
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
+478
View File
@@ -0,0 +1,478 @@
<template>
<!-- 定制简历编辑面板 手风琴折叠式点击模块名展开编辑失焦/选择后自动调用PUT接口保存 -->
<div class="job-resume-edit">
<!-- 遍历所有模块统一渲染折叠面板 -->
<div v-for="sec in sectionList" :key="sec.key" class="job-resume-edit__section">
<!-- 模块标题栏 -->
<div class="job-resume-edit__section-header" @click="toggle(sec.key)">
<span class="job-resume-edit__section-title">{{ sec.label }}</span>
<svg viewBox="0 0 12 12" fill="none" class="job-resume-edit__section-arrow" :class="{ 'job-resume-edit__section-arrow--open': openSections[sec.key] }">
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<!-- 展开内容区 -->
<div v-if="openSections[sec.key]" class="job-resume-edit__section-body">
<!-- ========== 基本信息 ========== -->
<template v-if="sec.key === 'info'">
<div class="job-resume-edit__field"><label class="job-resume-edit__label">姓名</label><input class="job-resume-edit__input" placeholder="请输入姓名" v-model="editData.resume.name" @blur="autoSave" /></div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">邮箱</label><input class="job-resume-edit__input" type="email" placeholder="请输入邮箱" v-model="editData.resume.email" @blur="autoSave" /></div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">电话</label><input class="job-resume-edit__input" type="tel" placeholder="请输入电话" v-model="editData.resume.mobileNumber" @blur="autoSave" /></div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">城市</label><input class="job-resume-edit__input" placeholder="请输入城市" v-model="editData.resume.city" @blur="autoSave" /></div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">微信</label><input class="job-resume-edit__input" placeholder="请输入微信号" v-model="editData.resume.wechatNumber" @blur="autoSave" /></div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">作品集链接</label><input class="job-resume-edit__input" placeholder="请输入作品集链接" v-model="editData.resume.portfolioUrl" @blur="autoSave" /></div>
</template>
<!-- ========== 个人概述 ========== -->
<template v-else-if="sec.key === 'summary'">
<textarea class="job-resume-edit__textarea" placeholder="请输入个人概述" v-model="editData.resume.summary" rows="4" @blur="autoSave"></textarea>
</template>
<!-- ========== 教育经历 ========== -->
<template v-else-if="sec.key === 'education'">
<div v-for="(edu, idx) in editData.education" :key="'edu-'+idx" class="job-resume-edit__card">
<div class="job-resume-edit__card-header">
<span class="job-resume-edit__card-index"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-index-dot"><circle cx="8" cy="8" r="3" fill="currentColor"/></svg>教育经历{{ idx + 1 }}</span>
<button v-if="editData.education.length > 1" class="job-resume-edit__card-delete" @click="removeItem('education', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-delete-icon"><path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
</div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">学校</label><input class="job-resume-edit__input" placeholder="请输入学校" v-model="edu.school" @blur="autoSave" /></div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">专业</label><input class="job-resume-edit__input" placeholder="请输入专业" v-model="edu.major" @blur="autoSave" /></div>
<div class="job-resume-edit__row">
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">学历类型</label><select class="job-resume-edit__select" v-model="edu.studyType" @change="autoSave"><option value="全日制">全日制</option><option value="非全日制">非全日制</option></select></div>
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">学历</label><select class="job-resume-edit__select" v-model="edu.degree" @change="autoSave"><option value="大专">大专</option><option value="本科">本科</option><option value="硕士">硕士</option><option value="博士">博士</option></select></div>
</div>
<div class="job-resume-edit__row">
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">入学时间</label><el-date-picker v-model="edu.startDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">毕业时间</label><el-date-picker v-model="edu.endDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
</div>
<div class="job-resume-edit__field">
<label class="job-resume-edit__label">经历描述</label>
<div v-for="(desc, dIdx) in edu.description" :key="desc.id" class="job-resume-edit__desc-item">
<textarea class="job-resume-edit__textarea job-resume-edit__textarea--with-remove" placeholder="请输入描述" v-model="desc.text" rows="3" @blur="autoSave"></textarea>
<button v-if="edu.description.length > 1" class="job-resume-edit__desc-remove" @click="removeDesc('education', idx, dIdx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__desc-remove-icon"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
</div>
<button class="job-resume-edit__add-btn" @click="addDesc('education', idx)"><span class="job-resume-edit__add-icon">+</span> 新增描述</button>
</div>
</div>
<button class="job-resume-edit__add-btn job-resume-edit__add-btn--full" @click="addItem('education')"><span class="job-resume-edit__add-icon">+</span> 新增教育经历</button>
</template>
<!-- ========== 实习经历 ========== -->
<template v-else-if="sec.key === 'internship'">
<div v-for="(item, idx) in editData.internship" :key="'intern-'+idx" class="job-resume-edit__card">
<div class="job-resume-edit__card-header">
<span class="job-resume-edit__card-index"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-index-dot"><circle cx="8" cy="8" r="3" fill="currentColor"/></svg>实习经历{{ idx + 1 }}</span>
<button v-if="editData.internship.length > 1" class="job-resume-edit__card-delete" @click="removeItem('internship', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-delete-icon"><path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
</div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">公司名称</label><input class="job-resume-edit__input" placeholder="请输入公司名称" v-model="item.companyName" @blur="autoSave" /></div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">职位</label><input class="job-resume-edit__input" placeholder="请输入职位" v-model="item.position" @blur="autoSave" /></div>
<div class="job-resume-edit__row">
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">开始时间</label><el-date-picker v-model="item.startDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">结束时间</label><el-date-picker v-model="item.endDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
</div>
<div class="job-resume-edit__field">
<label class="job-resume-edit__label">经历描述</label>
<div v-for="(desc, dIdx) in item.description" :key="desc.id" class="job-resume-edit__desc-item">
<textarea class="job-resume-edit__textarea job-resume-edit__textarea--with-remove" placeholder="请输入描述" v-model="desc.text" rows="3" @blur="autoSave"></textarea>
<button v-if="item.description.length > 1" class="job-resume-edit__desc-remove" @click="removeDesc('internship', idx, dIdx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__desc-remove-icon"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
</div>
<button class="job-resume-edit__add-btn" @click="addDesc('internship', idx)"><span class="job-resume-edit__add-icon">+</span> 新增描述</button>
</div>
</div>
<button class="job-resume-edit__add-btn job-resume-edit__add-btn--full" @click="addItem('internship')"><span class="job-resume-edit__add-icon">+</span> 新增实习经历</button>
</template>
<!-- ========== 工作经历 ========== -->
<template v-else-if="sec.key === 'work'">
<div v-for="(item, idx) in editData.work" :key="'work-'+idx" class="job-resume-edit__card">
<div class="job-resume-edit__card-header">
<span class="job-resume-edit__card-index"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-index-dot"><circle cx="8" cy="8" r="3" fill="currentColor"/></svg>工作经历{{ idx + 1 }}</span>
<button v-if="editData.work.length > 1" class="job-resume-edit__card-delete" @click="removeItem('work', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-delete-icon"><path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
</div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">公司名称</label><input class="job-resume-edit__input" placeholder="请输入公司名称" v-model="item.companyName" @blur="autoSave" /></div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">职位</label><input class="job-resume-edit__input" placeholder="请输入职位" v-model="item.position" @blur="autoSave" /></div>
<div class="job-resume-edit__row">
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">开始时间</label><el-date-picker v-model="item.startDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">结束时间</label><el-date-picker v-model="item.endDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
</div>
<div class="job-resume-edit__field">
<label class="job-resume-edit__label">经历描述</label>
<div v-for="(desc, dIdx) in item.description" :key="desc.id" class="job-resume-edit__desc-item">
<textarea class="job-resume-edit__textarea job-resume-edit__textarea--with-remove" placeholder="请输入描述" v-model="desc.text" rows="3" @blur="autoSave"></textarea>
<button v-if="item.description.length > 1" class="job-resume-edit__desc-remove" @click="removeDesc('work', idx, dIdx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__desc-remove-icon"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
</div>
<button class="job-resume-edit__add-btn" @click="addDesc('work', idx)"><span class="job-resume-edit__add-icon">+</span> 新增描述</button>
</div>
</div>
<button class="job-resume-edit__add-btn job-resume-edit__add-btn--full" @click="addItem('work')"><span class="job-resume-edit__add-icon">+</span> 新增工作经历</button>
</template>
<!-- ========== 项目经历 ========== -->
<template v-else-if="sec.key === 'project'">
<div v-for="(item, idx) in editData.project" :key="'proj-'+idx" class="job-resume-edit__card">
<div class="job-resume-edit__card-header">
<span class="job-resume-edit__card-index"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-index-dot"><circle cx="8" cy="8" r="3" fill="currentColor"/></svg>项目经历{{ idx + 1 }}</span>
<button v-if="editData.project.length > 1" class="job-resume-edit__card-delete" @click="removeItem('project', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-delete-icon"><path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
</div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">项目名称</label><input class="job-resume-edit__input" placeholder="请输入项目名称" v-model="item.projectName" @blur="autoSave" /></div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">所属公司</label><input class="job-resume-edit__input" placeholder="请输入所属公司" v-model="item.companyName" @blur="autoSave" /></div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">项目角色</label><input class="job-resume-edit__input" placeholder="请输入项目角色" v-model="item.role" @blur="autoSave" /></div>
<div class="job-resume-edit__row">
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">开始时间</label><el-date-picker v-model="item.startDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">结束时间</label><el-date-picker v-model="item.endDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
</div>
<div class="job-resume-edit__field">
<label class="job-resume-edit__label">经历描述</label>
<div v-for="(desc, dIdx) in item.description" :key="desc.id" class="job-resume-edit__desc-item">
<textarea class="job-resume-edit__textarea job-resume-edit__textarea--with-remove" placeholder="请输入描述" v-model="desc.text" rows="3" @blur="autoSave"></textarea>
<button v-if="item.description.length > 1" class="job-resume-edit__desc-remove" @click="removeDesc('project', idx, dIdx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__desc-remove-icon"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
</div>
<button class="job-resume-edit__add-btn" @click="addDesc('project', idx)"><span class="job-resume-edit__add-icon">+</span> 新增描述</button>
</div>
</div>
<button class="job-resume-edit__add-btn job-resume-edit__add-btn--full" @click="addItem('project')"><span class="job-resume-edit__add-icon">+</span> 新增项目经历</button>
</template>
<!-- ========== 竞赛 ========== -->
<template v-else-if="sec.key === 'competition'">
<div v-for="(item, idx) in editData.competition" :key="'comp-'+idx" class="job-resume-edit__card">
<div class="job-resume-edit__card-header">
<span class="job-resume-edit__card-index"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-index-dot"><circle cx="8" cy="8" r="3" fill="currentColor"/></svg>竞赛{{ idx + 1 }}</span>
<button v-if="editData.competition.length > 1" class="job-resume-edit__card-delete" @click="removeItem('competition', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-delete-icon"><path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
</div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">竞赛名称</label><input class="job-resume-edit__input" placeholder="请输入竞赛名称" v-model="item.competitionName" @blur="autoSave" /></div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">获奖名次</label><input class="job-resume-edit__input" placeholder="请输入获奖名次" v-model="item.award" @blur="autoSave" /></div>
<div class="job-resume-edit__field"><label class="job-resume-edit__label">获奖时间</label><el-date-picker v-model="item.awardDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
<div class="job-resume-edit__field">
<label class="job-resume-edit__label">获奖描述</label>
<textarea class="job-resume-edit__textarea" placeholder="请输入获奖描述" v-model="item.description[0].text" rows="3" @blur="autoSave"></textarea>
</div>
</div>
<button class="job-resume-edit__add-btn job-resume-edit__add-btn--full" @click="addItem('competition')"><span class="job-resume-edit__add-icon">+</span> 新增竞赛</button>
</template>
<!-- ========== 证书 ========== -->
<template v-else-if="sec.key === 'certificate'">
<div class="job-resume-edit__tags">
<div v-for="(cert, idx) in editData.resume.certificates" :key="'cert-'+idx" class="job-resume-edit__tag">
<span>{{ cert }}</span>
<button class="job-resume-edit__tag-remove" @click="removeTag('certificates', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__tag-remove-icon"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
</div>
</div>
<div class="job-resume-edit__field">
<input class="job-resume-edit__input" placeholder="输入证书名称后按回车添加" v-model="newCertInput" @keydown.enter="addTag('certificates')" @blur="addTag('certificates')" />
</div>
</template>
<!-- ========== 技能 ========== -->
<template v-else-if="sec.key === 'skills'">
<div class="job-resume-edit__tags">
<div v-for="(skill, idx) in editData.resume.skills" :key="'skill-'+idx" class="job-resume-edit__tag">
<span>{{ skill }}</span>
<button class="job-resume-edit__tag-remove" @click="removeTag('skills', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__tag-remove-icon"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
</div>
</div>
<div class="job-resume-edit__field">
<input class="job-resume-edit__input" placeholder="输入技能名称后按回车添加" v-model="newSkillInput" @keydown.enter="addTag('skills')" @blur="addTag('skills')" />
</div>
</template>
<!-- ========== 作品集 ========== -->
<template v-else-if="sec.key === 'portfolio'">
<div class="job-resume-edit__field">
<input class="job-resume-edit__input" placeholder="请输入作品集链接" v-model="editData.resume.portfolioUrl" @blur="autoSave" />
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { updateCustomizeResume } from '@/api/jobs'
import type { CustomizeResumeData } from '@/api/jobs'
// ==================== 类型定义 ====================
/** 描述段落 */
interface DescParagraph {
id: string
text: string
}
/** 教育经历编辑项 */
interface EduEditItem {
id: string; school: string; major: string; degree: string; studyType: string
startDate: string; endDate: string; description: DescParagraph[]
}
/** 工作/实习经历编辑项 */
interface WorkEditItem {
id: string; companyName: string; position: string
startDate: string; endDate: string; description: DescParagraph[]
}
/** 项目经历编辑项 */
interface ProjectEditItem {
id: string; companyName: string; projectName: string; role: string
startDate: string; endDate: string; description: DescParagraph[]
}
/** 竞赛经历编辑项 */
interface CompEditItem {
id: string; competitionName: string; award: string; awardDate: string
description: DescParagraph[]
}
/** 编辑数据完整结构 */
interface EditFormData {
resume: {
avatarUrl: string; name: string; email: string; mobileNumber: string
city: string; wechatNumber: string; portfolioUrl: string
skills: string[]; certificates: string[]; summary: string
}
education: EduEditItem[]
work: WorkEditItem[]
internship: WorkEditItem[]
project: ProjectEditItem[]
competition: CompEditItem[]
}
// ==================== Props ====================
const props = defineProps<{
/** 定制简历数据(从父组件传入) */
resumeData: CustomizeResumeData
}>()
/** 数据变更时通知父组件同步更新简历模板预览 */
const emit = defineEmits<{
(e: 'update', data: CustomizeResumeData): void
}>()
// ==================== 模块列表配置 ====================
/** 折叠面板模块列表 */
const sectionList = [
{ key: 'info', label: '基本信息' },
{ key: 'summary', label: '个人概述' },
{ key: 'education', label: '教育经历' },
{ key: 'internship', label: '实习经历' },
{ key: 'work', label: '工作经历' },
{ key: 'project', label: '项目经历' },
{ key: 'competition', label: '竞赛' },
{ key: 'certificate', label: '证书' },
{ key: 'skills', label: '技能' },
{ key: 'portfolio', label: '作品集' },
]
/** 各模块展开状态 */
const openSections = reactive<Record<string, boolean>>({
info: false, summary: false, education: false, internship: false,
work: false, project: false, competition: false, certificate: false,
skills: false, portfolio: false,
})
/** 切换模块展开/收起 */
function toggle(key: string) {
openSections[key] = !openSections[key]
}
// ==================== 编辑数据 ====================
/** 生成短随机ID */
function genId(): string {
return Math.random().toString(36).substring(2, 8)
}
/** 编辑表单数据(响应式) */
const editData = reactive<EditFormData>({
resume: { avatarUrl: '', name: '', email: '', mobileNumber: '', city: '', wechatNumber: '', portfolioUrl: '', skills: [], certificates: [], summary: '' },
education: [],
work: [],
internship: [],
project: [],
competition: [],
})
/** 从 props.resumeData 初始化编辑数据 */
function initEditData(data: CustomizeResumeData) {
const r = data.resume || {}
editData.resume.avatarUrl = r.avatarUrl || ''
editData.resume.name = r.name || ''
editData.resume.email = r.email || ''
editData.resume.mobileNumber = r.mobileNumber || ''
editData.resume.city = r.city || ''
editData.resume.wechatNumber = r.wechatNumber || ''
editData.resume.portfolioUrl = r.portfolioUrl || ''
editData.resume.skills = r.skills ? [...r.skills] : []
editData.resume.certificates = r.certificates ? [...r.certificates] : []
editData.resume.summary = r.summary || ''
editData.education = (data.education || []).map(e => ({
id: e.id || genId(), school: e.school || '', major: e.major || '',
degree: e.degree || '本科', studyType: e.studyType || '全日制',
startDate: e.startDate || '', endDate: e.endDate || '',
description: (e.description || []).map(d => ({ id: d.id || genId(), text: d.text || '' })),
}))
editData.work = (data.work || []).map(w => ({
id: w.id || genId(), companyName: w.companyName || '', position: w.position || '',
startDate: w.startDate || '', endDate: w.endDate || '',
description: (w.description || []).map(d => ({ id: d.id || genId(), text: d.text || '' })),
}))
editData.internship = (data.internship || []).map(i => ({
id: i.id || genId(), companyName: i.companyName || '', position: i.position || '',
startDate: i.startDate || '', endDate: i.endDate || '',
description: (i.description || []).map(d => ({ id: d.id || genId(), text: d.text || '' })),
}))
editData.project = (data.project || []).map(p => ({
id: p.id || genId(), companyName: p.companyName || '', projectName: p.projectName || '',
role: p.role || '', startDate: p.startDate || '', endDate: p.endDate || '',
description: (p.description || []).map(d => ({ id: d.id || genId(), text: d.text || '' })),
}))
editData.competition = (data.competition || []).map(c => ({
id: c.id || genId(), competitionName: c.competitionName || '', award: c.award || '',
awardDate: c.awardDate || '',
description: c.description?.length ? c.description.map(d => ({ id: d.id || genId(), text: d.text || '' })) : [{ id: genId(), text: '' }],
}))
}
/** 监听 props 变化,初始化编辑数据 */
watch(() => props.resumeData, (val) => {
if (val) initEditData(val)
}, { immediate: true, deep: true })
// ==================== 自动保存(失焦/选择后触发PUT接口) ====================
/** 防抖定时器 */
let saveTimer: ReturnType<typeof setTimeout> | null = null
/** 将编辑数据转换为接口所需的 CustomizeResumeData 格式 */
function buildSavePayload(): CustomizeResumeData {
return {
resume: {
avatarUrl: editData.resume.avatarUrl,
name: editData.resume.name,
email: editData.resume.email,
mobileNumber: editData.resume.mobileNumber,
city: editData.resume.city,
wechatNumber: editData.resume.wechatNumber,
portfolioUrl: editData.resume.portfolioUrl,
skills: [...editData.resume.skills],
certificates: [...editData.resume.certificates],
summary: editData.resume.summary,
},
education: editData.education.map(e => ({
id: e.id, school: e.school, major: e.major, degree: e.degree,
studyType: e.studyType, startDate: e.startDate, endDate: e.endDate,
description: e.description.map(d => ({ id: d.id, text: d.text })),
})),
work: editData.work.map(w => ({
id: w.id, companyName: w.companyName, position: w.position,
startDate: w.startDate, endDate: w.endDate,
description: w.description.map(d => ({ id: d.id, text: d.text })),
})),
internship: editData.internship.map(i => ({
id: i.id, companyName: i.companyName, position: i.position,
startDate: i.startDate, endDate: i.endDate,
description: i.description.map(d => ({ id: d.id, text: d.text })),
})),
project: editData.project.map(p => ({
id: p.id, companyName: p.companyName, projectName: p.projectName,
role: p.role, startDate: p.startDate, endDate: p.endDate,
description: p.description.map(d => ({ id: d.id, text: d.text })),
})),
competition: editData.competition.map(c => ({
id: c.id, competitionName: c.competitionName, award: c.award,
awardDate: c.awardDate,
description: c.description.map(d => ({ id: d.id, text: d.text })),
})),
}
}
/** 自动保存 — 防抖300ms后调用PUT接口,同时通知父组件更新预览 */
function autoSave() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(async () => {
const payload = buildSavePayload()
// 通知父组件同步更新简历模板预览
emit('update', payload)
try {
await updateCustomizeResume(payload)
} catch (e) {
console.error('[JobResumeCustomEditPanel] 自动保存失败', e)
}
}, 300)
}
// ==================== 列表操作(新增/删除条目、描述段落) ====================
/** 新增一条经历记录 */
function addItem(type: 'education' | 'work' | 'internship' | 'project' | 'competition') {
const id = genId()
const emptyDesc: DescParagraph[] = [{ id: genId(), text: '' }]
if (type === 'education') {
editData.education.push({ id, school: '', major: '', degree: '本科', studyType: '全日制', startDate: '', endDate: '', description: emptyDesc })
} else if (type === 'work') {
editData.work.push({ id, companyName: '', position: '', startDate: '', endDate: '', description: emptyDesc })
} else if (type === 'internship') {
editData.internship.push({ id, companyName: '', position: '', startDate: '', endDate: '', description: emptyDesc })
} else if (type === 'project') {
editData.project.push({ id, companyName: '', projectName: '', role: '', startDate: '', endDate: '', description: emptyDesc })
} else if (type === 'competition') {
editData.competition.push({ id, competitionName: '', award: '', awardDate: '', description: emptyDesc })
}
autoSave()
}
/** 删除一条经历记录 */
function removeItem(type: 'education' | 'work' | 'internship' | 'project' | 'competition', idx: number) {
editData[type].splice(idx, 1)
autoSave()
}
/** 新增一条描述段落 */
function addDesc(type: 'education' | 'work' | 'internship' | 'project', itemIdx: number) {
editData[type][itemIdx].description.push({ id: genId(), text: '' })
}
/** 删除一条描述段落 */
function removeDesc(type: 'education' | 'work' | 'internship' | 'project', itemIdx: number, descIdx: number) {
editData[type][itemIdx].description.splice(descIdx, 1)
autoSave()
}
// ==================== 标签操作(技能/证书) ====================
/** 新技能输入 */
const newSkillInput = ref('')
/** 新证书输入 */
const newCertInput = ref('')
/** 添加标签(技能或证书) */
function addTag(field: 'skills' | 'certificates') {
const inputRef = field === 'skills' ? newSkillInput : newCertInput
const val = inputRef.value.trim()
if (!val) { inputRef.value = ''; return }
const list = editData.resume[field]
if (!list.includes(val)) {
list.push(val)
autoSave()
}
inputRef.value = ''
}
/** 删除标签 */
function removeTag(field: 'skills' | 'certificates', idx: number) {
editData.resume[field].splice(idx, 1)
autoSave()
}
</script>
+133 -10
View File
@@ -3,7 +3,15 @@
<div class="job-resume-template" ref="resumeRef">
<div class="resume-html">
<!-- 姓名 -->
<h1 class="resume-html__name">{{ resumeData.name }}</h1>
<h1 class="resume-html__name">
<template v-if="showDiff">
<template v-for="(seg, si) in diffText(oldResumeData?.name, resumeData.name)" :key="'name-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span>
<template v-else>{{ seg.text }}</template>
</template>
</template>
<template v-else>{{ resumeData.name }}</template>
</h1>
<!-- 联系方式 -->
<div class="resume-html__contact">
@@ -19,7 +27,15 @@
<template v-if="resumeData.summary">
<div class="resume-html__section-title">个人概述</div>
<div class="resume-html__divider"></div>
<div class="resume-html__summary">{{ resumeData.summary }}</div>
<div class="resume-html__summary">
<template v-if="showDiff">
<template v-for="(seg, si) in diffText(oldResumeData?.summary, resumeData.summary)" :key="'sum-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span>
<template v-else>{{ seg.text }}</template>
</template>
</template>
<template v-else>{{ resumeData.summary }}</template>
</div>
</template>
<!-- 教育背景 -->
@@ -31,7 +47,13 @@
<div class="resume-html__item-left">
<span class="resume-html__item-main">{{ edu.school }}{{ edu.major }}{{ degreeText(edu.degree) }}</span>
<span v-if="edu.description && edu.description.length" class="resume-html__item-desc">
主修课程{{ edu.description.map(d => d.text).join('、') }}
<!-- 教育经历描述差异对比 -->
<template v-if="showDiff">
主修课程<template v-for="(desc, di) in edu.description" :key="'ed-' + di"><template v-if="di > 0">、</template><template v-for="(seg, si) in diffDescText(oldResumeData?.educations, idx, di, desc.text)" :key="'eds-' + si"><span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span><template v-else>{{ seg.text }}</template></template></template>
</template>
<template v-else>
主修课程{{ edu.description.map(d => d.text).join('、') }}
</template>
</span>
</div>
<div class="resume-html__item-right">
@@ -55,7 +77,15 @@
</div>
</div>
<ul v-if="work.description && work.description.length" class="resume-html__desc-list">
<li v-for="(desc, di) in work.description" :key="'wd-' + di">{{ desc.text }}</li>
<li v-for="(desc, di) in work.description" :key="'wd-' + di">
<template v-if="showDiff">
<template v-for="(seg, si) in diffListText(oldResumeData?.workExperiences, idx, di, desc.text)" :key="'wds-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span>
<template v-else>{{ seg.text }}</template>
</template>
</template>
<template v-else>{{ desc.text }}</template>
</li>
</ul>
</div>
</template>
@@ -73,7 +103,15 @@
</div>
</div>
<ul v-if="intern.description && intern.description.length" class="resume-html__desc-list">
<li v-for="(desc, di) in intern.description" :key="'id-' + di">{{ desc.text }}</li>
<li v-for="(desc, di) in intern.description" :key="'id-' + di">
<template v-if="showDiff">
<template v-for="(seg, si) in diffListText(oldResumeData?.internships, idx, di, desc.text)" :key="'ids-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span>
<template v-else>{{ seg.text }}</template>
</template>
</template>
<template v-else>{{ desc.text }}</template>
</li>
</ul>
</div>
</template>
@@ -91,7 +129,15 @@
</div>
</div>
<ul v-if="proj.description && proj.description.length" class="resume-html__desc-list">
<li v-for="(desc, di) in proj.description" :key="'pd-' + di">{{ desc.text }}</li>
<li v-for="(desc, di) in proj.description" :key="'pd-' + di">
<template v-if="showDiff">
<template v-for="(seg, si) in diffListText(oldResumeData?.projects, idx, di, desc.text, 'projects')" :key="'pds-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span>
<template v-else>{{ seg.text }}</template>
</template>
</template>
<template v-else>{{ desc.text }}</template>
</li>
</ul>
</div>
</template>
@@ -106,7 +152,15 @@
<span class="resume-html__item-date" v-if="comp.awardDate">{{ comp.awardDate }}</span>
</div>
<ul v-if="comp.description && comp.description.length" class="resume-html__desc-list">
<li v-for="(desc, di) in comp.description" :key="'cd-' + di">{{ desc.text }}</li>
<li v-for="(desc, di) in comp.description" :key="'cd-' + di">
<template v-if="showDiff">
<template v-for="(seg, si) in diffListText(oldResumeData?.competitions, idx, di, desc.text, 'competitions')" :key="'cds-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span>
<template v-else>{{ seg.text }}</template>
</template>
</template>
<template v-else>{{ desc.text }}</template>
</li>
</ul>
</div>
</template>
@@ -118,11 +172,27 @@
<div class="resume-html__skills">
<div v-if="resumeData.skills && resumeData.skills.length" class="resume-html__skill-row">
<span class="resume-html__skill-label">技能</span>
<span>{{ resumeData.skills.join('、') }}</span>
<template v-if="showDiff">
<span>
<template v-for="(seg, si) in diffText(oldResumeData?.skills?.join('、'), resumeData.skills.join('、'))" :key="'sk-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span>
<template v-else>{{ seg.text }}</template>
</template>
</span>
</template>
<span v-else>{{ resumeData.skills.join('') }}</span>
</div>
<div v-if="resumeData.certificates && resumeData.certificates.length" class="resume-html__skill-row">
<span class="resume-html__skill-label">证书</span>
<span>{{ resumeData.certificates.join('、') }}</span>
<template v-if="showDiff">
<span>
<template v-for="(seg, si) in diffText(oldResumeData?.certificates?.join('、'), resumeData.certificates.join('、'))" :key="'ct-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span>
<template v-else>{{ seg.text }}</template>
</template>
</span>
</template>
<span v-else>{{ resumeData.certificates.join('') }}</span>
</div>
</div>
</template>
@@ -132,6 +202,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computeDiff, type DiffSegment } from '@/utils/textDiff'
// ==================== 类型定义 ====================
@@ -190,7 +261,7 @@ export interface ResumeTemplateData {
mobileNumber?: string
/** 微信号 */
wechatNumber?: string
/** 个人概述(新增字段) */
/** 个人概述 */
summary?: string
/** 教育背景 */
educations?: ResumeEducation[]
@@ -213,6 +284,10 @@ export interface ResumeTemplateData {
const props = defineProps<{
/** 简历数据 */
resumeData: ResumeTemplateData
/** 是否显示差异对比模式,默认false显示普通模板 */
showDiff?: boolean
/** 旧简历数据(差异对比时使用,与resumeData对比) */
oldResumeData?: ResumeTemplateData
}>()
// ==================== DOM引用(供父组件获取HTML内容) ====================
@@ -235,4 +310,52 @@ const hasSkillsSection = computed(() => {
return (props.resumeData.skills && props.resumeData.skills.length > 0) ||
(props.resumeData.certificates && props.resumeData.certificates.length > 0)
})
// ==================== 差异对比工具方法 ====================
/**
* 对比两段纯文本,返回差异片段数组
* @param oldText 旧文本
* @param newText 新文本
*/
function diffText(oldText?: string, newText?: string): DiffSegment[] {
if (!newText) return []
if (!oldText) return [{ text: newText, highlight: true }]
if (oldText === newText) return [{ text: newText, highlight: false }]
return computeDiff(oldText, newText)
}
/**
* 对比带description数组的经历列表中某一项描述文本的差异
* 适用于工作经历、实习经历等有description数组的结构
* @param oldList 旧数据列表
* @param itemIdx 经历项索引
* @param descIdx 描述段落索引
* @param newText 新文本
* @param type 类型标识(用于区分projects和competitions等不同结构)
*/
function diffListText(oldList: any[] | undefined, itemIdx: number, descIdx: number, newText: string, _type?: string): DiffSegment[] {
if (!oldList || !oldList[itemIdx] || !oldList[itemIdx].description || !oldList[itemIdx].description[descIdx]) {
return [{ text: newText, highlight: true }]
}
const oldText = oldList[itemIdx].description[descIdx].text || ''
if (oldText === newText) return [{ text: newText, highlight: false }]
return computeDiff(oldText, newText)
}
/**
* 对比教育经历描述文本的差异
* @param oldEducations 旧教育经历列表
* @param eduIdx 教育经历索引
* @param descIdx 描述段落索引
* @param newText 新文本
*/
function diffDescText(oldEducations: ResumeEducation[] | undefined, eduIdx: number, descIdx: number, newText: string): DiffSegment[] {
if (!oldEducations || !oldEducations[eduIdx] || !oldEducations[eduIdx].description || !oldEducations[eduIdx].description![descIdx]) {
return [{ text: newText, highlight: true }]
}
const oldText = oldEducations[eduIdx].description![descIdx].text || ''
if (oldText === newText) return [{ text: newText, highlight: false }]
return computeDiff(oldText, newText)
}
</script>
@@ -0,0 +1,168 @@
<template>
<!-- 简历分析报告抽屉 从右侧滑入占满屏幕高度 -->
<Teleport to="body">
<!-- 遮罩层 -->
<Transition name="report-drawer-overlay">
<div
v-if="modelValue"
class="report-drawer-overlay"
@click="handleClose"
/>
</Transition>
<!-- 抽屉主体 -->
<Transition name="report-drawer-slide">
<div v-if="modelValue" class="report-drawer" @click.stop>
<!-- 顶部栏折叠按钮 + 标题 + 更新时间 -->
<div class="report-drawer__header">
<div class="report-drawer__header-left">
<button class="report-drawer__close-btn" @click="handleClose" aria-label="收起">
<svg viewBox="0 0 16 16" fill="none" class="report-drawer__close-icon">
<path d="M10 3l-5 5 5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<h3 class="report-drawer__title">简历分析报告</h3>
</div>
<span class="report-drawer__update-time">{{ updateTimeText }}</span>
</div>
<!-- 可滚动内容区 -->
<div class="report-drawer__body">
<!-- 评分概览区域 -->
<div class="report-drawer__score-section">
<div class="report-drawer__score-left">
<!-- 评级字母 -->
<span class="report-drawer__grade-avatar">{{ report.grade }}</span>
<!-- 评级标签 hover 提示 -->
<el-tooltip :content="gradeTooltip" placement="bottom" effect="dark">
<span class="report-drawer__grade-badge">{{ gradeLabel }} <span class="report-drawer__grade-dot"></span></span>
</el-tooltip>
</div>
<!-- 右侧三项计数 -->
<div class="report-drawer__score-right">
<div class="report-drawer__score-item">
<span class="report-drawer__score-num">{{ report.urgentTotal || 0 }}</span>
<span class="report-drawer__score-label">紧急修复项</span>
</div>
<div class="report-drawer__score-item">
<span class="report-drawer__score-num">{{ report.importantTotal || 0 }}</span>
<span class="report-drawer__score-label">严重问题</span>
</div>
<div class="report-drawer__score-item">
<span class="report-drawer__score-num">{{ report.expressionTotal || 0 }}</span>
<span class="report-drawer__score-label">可选修复项</span>
</div>
</div>
</div>
<!-- 评级提示语 -->
<!-- <p class="report-drawer__grade-hint">{{ gradeHintText }}</p>-->
<!-- 结论 -->
<div class="report-drawer__conclusion">
<h4 class="report-drawer__section-title">结论</h4>
<p class="report-drawer__conclusion-text">{{ report.summary || '暂无结论' }}</p>
</div>
<!-- 核心问题 -->
<div class="report-drawer__issues">
<h4 class="report-drawer__section-title">核心问题</h4>
<div
v-for="(issue, index) in issues"
:key="issue.id || index"
class="report-drawer__issue-card"
>
<!-- 问题标题序号 -->
<p class="report-drawer__issue-title">问题{{ index + 1 }}</p>
<!-- 问题内容finding -->
<p class="report-drawer__issue-detail">{{ issue.finding }}</p>
<!-- 为什么需要优化 -->
<p class="report-drawer__issue-why-label">为什么需要优化</p>
<p class="report-drawer__issue-importance">{{ issue.importance }}</p>
</div>
</div>
</div>
<!-- 底部按钮 -->
<div class="report-drawer__footer">
<button class="report-drawer__optimize-btn" @click="handleOptimize">去优化</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { DiagnosisReport, DiagnosisIssue } from '@/api/resume'
// ==================== Props ====================
const props = defineProps<{
/** 控制抽屉显示/隐藏 */
modelValue: boolean
/** 诊断报告数据 */
report: DiagnosisReport
/** 诊断问题列表 */
issues: DiagnosisIssue[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'optimize'): void
}>()
// ==================== 计算属性 ====================
/** 评级文字映射 */
const gradeLabel = computed(() => {
const map: Record<string, string> = { A: '优秀', B: '良好', C: '一般', D: '待提升' }
return map[props.report.grade || ''] || '未评级'
})
/** 评级 hover 提示文字 */
const gradeTooltip = computed(() => {
const map: Record<string, string> = {
D: '你的简历目前还有较大提升空间,建议尽快补充关键经历、完善内容表达,并优化整体结构,让简历更完整、更有竞争力',
C: '你的简历还有打磨空间,多推敲细节、补充些具体内容,整体会更出彩。',
B: '简历已经很棒了,但还有提升的潜力。再调整一下细节,会有更具有竞争力!',
A: '你的简历相当出彩,在求职市场中格外抢眼,能清晰展现您的优势与经历,已经超越绝大多数候选人了。',
}
return map[props.report.grade || ''] || ''
})
/** 评级提示语(显示在评分区域下方) */
const gradeHintText = computed(() => {
return gradeTooltip.value
})
/** 更新时间文字 */
const updateTimeText = computed(() => {
if (!props.report.createTime) return ''
try {
const createDate = new Date(props.report.createTime)
const now = new Date()
const diffMs = now.getTime() - createDate.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) return '今天更新'
if (diffDays < 30) return `更新于${diffDays}天前`
const diffMonths = Math.floor(diffDays / 30)
return `更新于${diffMonths}个月前`
} catch {
return ''
}
})
// ==================== 事件处理 ====================
/** 关闭抽屉 */
function handleClose() {
emit('update:modelValue', false)
}
/** 点击去优化 */
function handleOptimize() {
emit('optimize')
emit('update:modelValue', false)
}
</script>
+405
View File
@@ -0,0 +1,405 @@
<template>
<!-- 简历评估问题修复抽屉 从右侧滑入占满屏幕高度宽度 8rem -->
<Teleport to="body">
<!-- 遮罩层 -->
<Transition name="fix-drawer-overlay">
<div
v-if="modelValue"
class="fix-drawer-overlay"
@click="handleClose"
/>
</Transition>
<!-- 抽屉主体 -->
<Transition name="fix-drawer-slide">
<div v-if="modelValue" class="fix-drawer" @click.stop>
<!-- 顶部栏折叠按钮 + 模块标题 -->
<div class="fix-drawer__header">
<button class="fix-drawer__close-btn" @click="handleClose" aria-label="收起">
<svg viewBox="0 0 16 16" fill="none" class="fix-drawer__close-icon">
<path d="M10 3l-5 5 5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<h3 class="fix-drawer__title">{{ moduleTitle }}</h3>
</div>
<!-- 可滚动内容区 -->
<div class="fix-drawer__body">
<!-- 原文部分 -->
<div class="fix-drawer__section">
<h4 class="fix-drawer__section-title">原文</h4>
<div class="fix-drawer__card">
<p class="fix-drawer__original-text">{{ originalText }}</p>
</div>
</div>
<!-- 问题检查部分 -->
<div class="fix-drawer__section">
<h4 class="fix-drawer__section-title">问题检查</h4>
<div class="fix-drawer__card">
<!-- 发现问题 -->
<p class="fix-drawer__label">发现问题</p>
<p class="fix-drawer__text">{{ matchedIssue?.finding || '暂无' }}</p>
<!-- 重要性说明 -->
<p class="fix-drawer__label">重要性说明</p>
<p class="fix-drawer__text">{{ matchedIssue?.importance || '暂无' }}</p>
<!-- 改进建议 -->
<p class="fix-drawer__label">改进建议</p>
<p class="fix-drawer__text">{{ matchedIssue?.suggestion || '暂无' }}</p>
</div>
</div>
<!-- AI 改进后的版本 -->
<div class="fix-drawer__section">
<h4 class="fix-drawer__section-title">AI改写后的版本</h4>
<div class="fix-drawer__card">
<p class="fix-drawer__diff-text">
<span
v-for="(seg, idx) in diffSegments"
:key="idx"
:class="{ 'fix-drawer__highlight': seg.highlight }"
>{{ seg.text }}</span>
</p>
</div>
</div>
<!-- 自己改写区域 每个 optimizedContent 子元素对应一个输入框 -->
<div class="fix-drawer__section">
<h4 class="fix-drawer__section-title">自己改写</h4>
<div class="fix-drawer__card">
<textarea
v-for="(_, idx) in userRewriteList"
:key="'rewrite-' + idx"
v-model="userRewriteList[idx]"
class="fix-drawer__textarea"
:placeholder="'第 ' + (idx + 1) + ' 段改写内容…'"
:ref="(el: any) => setTextareaRef(el, idx)"
@input="autoResize(idx)"
/>
<!-- AI 优化按钮 -->
<div class="fix-drawer__textarea-footer">
<button class="fix-drawer__ai-btn" @click="handleAiPolish">AI优化</button>
</div>
</div>
</div>
<!-- AI 润色结果区域 调用接口返回后显示 -->
<div v-if="polishResult.length" class="fix-drawer__section">
<h4 class="fix-drawer__section-title">AI润色结果</h4>
<div class="fix-drawer__card">
<p
v-for="(text, idx) in polishResult"
:key="'polish-' + idx"
class="fix-drawer__polish-text"
>{{ text }}</p>
<!-- 替换按钮 -->
<div class="fix-drawer__textarea-footer">
<button class="fix-drawer__replace-btn" @click="handleReplace">替换</button>
</div>
</div>
</div>
<!-- 用户评价区域 始终显示 -->
<div class="fix-drawer__section">
<div class="fix-drawer__card fix-drawer__feedback-card">
<span class="fix-drawer__feedback-label">你认为这个修改建议有用吗</span>
<div class="fix-drawer__feedback-actions">
<!-- 有用按钮 -->
<button
class="fix-drawer__feedback-btn"
:class="{ 'fix-drawer__feedback-btn--active': feedbackValue === 1 }"
@click="handleFeedback(1)"
>
<svg viewBox="0 0 16 16" fill="none" class="fix-drawer__feedback-icon">
<path d="M4.5 7v6.5h-2a1 1 0 01-1-1V8a1 1 0 011-1h2zm1 6.5h5.3a1.5 1.5 0 001.47-1.2l1.08-5.4A1 1 0 0012.37 5.5H9V3a1.5 1.5 0 00-1.5-1.5L5.5 7z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
</svg>
有用
</button>
<!-- 无用按钮 -->
<button
class="fix-drawer__feedback-btn"
:class="{ 'fix-drawer__feedback-btn--active': feedbackValue === 2 }"
@click="handleFeedback(2)"
>
<svg viewBox="0 0 16 16" fill="none" class="fix-drawer__feedback-icon fix-drawer__feedback-icon--down">
<path d="M4.5 7v6.5h-2a1 1 0 01-1-1V8a1 1 0 011-1h2zm1 6.5h5.3a1.5 1.5 0 001.47-1.2l1.08-5.4A1 1 0 0012.37 5.5H9V3a1.5 1.5 0 00-1.5-1.5L5.5 7z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
</svg>
无用
</button>
</div>
</div>
</div>
</div>
<!-- 底部提交按钮 -->
<div class="fix-drawer__footer">
<button class="fix-drawer__submit-btn" @click="handleSubmit">提交新版本</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import type { DiagnosisIssue, DescriptionParagraph } from '@/api/resume'
import { polishDiagnosisIssue, feedbackDiagnosisIssue, resolveDiagnosisIssue } from '@/api/resume'
import { computeDiff, type DiffSegment } from '@/utils/textDiff'
// ==================== Props ====================
const props = defineProps<{
/** 控制抽屉显示/隐藏 */
modelValue: boolean
/** 模块类型:summary / education / work / internship / project / competition */
moduleType: string
/** 当前记录 ID(个人概述时为 resumeId,经历时为该段经历的 id) */
recordId: string
/** 原文内容 — 个人概述时为 summary 字符串,经历时为 description 数组 */
originalContent: string | DescriptionParagraph[]
/** 该记录对应的诊断问题 */
issue: DiagnosisIssue | undefined
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
/** 提交新版本:携带自己改写的最终内容数组 */
(e: 'submit', content: string[]): void
}>()
// ==================== 模块标题映射 ====================
/** 模块类型到中文标题的映射 */
const moduleTitleMap: Record<string, string> = {
summary: '个人概述',
education: '教育背景',
work: '工作经历',
internship: '实习经历',
project: '项目经历',
competition: '竞赛经历',
}
/** 抽屉标题 */
const moduleTitle = computed(() => moduleTitleMap[props.moduleType] || '问题修复')
// ==================== 原文处理 ====================
/** 将原文内容统一转为字符串(description 数组的 text 逐行拼接) */
const originalText = computed(() => {
if (typeof props.originalContent === 'string') {
return props.originalContent
}
if (Array.isArray(props.originalContent)) {
return props.originalContent.map(d => d.text || '').join('\n')
}
return ''
})
// ==================== 匹配的诊断问题 ====================
/** 当前记录匹配的 issue */
const matchedIssue = computed(() => props.issue)
// ==================== AI 改进内容(optimizedContent 数组) ====================
/** optimizedContent 子元素数组 — 兼容字符串(个人概述)和数组对象(经历模块) */
const optimizedItems = computed<any[]>(() => {
const oc = matchedIssue.value?.optimizedContent
if (!oc) return []
// 个人概述:optimizedContent 是字符串
if (typeof oc === 'string') return [oc]
if (Array.isArray(oc)) return oc
return []
})
/** AI 改进后的文本(统一拼接为字符串,用于差异对比) */
const optimizedText = computed(() => {
if (!optimizedItems.value.length) return ''
const oc = matchedIssue.value?.optimizedContent
// 个人概述:直接就是字符串
if (typeof oc === 'string') return oc
// 经历模块:数组对象,取每个子元素的 optimizedContent 字段拼接
return optimizedItems.value
.map((item: any) => item.optimizedContent || item.text || '')
.join('\n')
})
/** 差异对比片段 */
const diffSegments = computed<DiffSegment[]>(() => {
if (!optimizedText.value) return []
return computeDiff(originalText.value, optimizedText.value)
})
// ==================== 自己改写(多输入框) ====================
/** 用户自己改写的内容列表 — 每个 optimizedContent 子元素对应一个输入框 */
const userRewriteList = ref<string[]>([])
/** 抽屉打开时用 AI 改写内容初始化每个输入框 */
watch(() => props.modelValue, (val) => {
if (val) {
// 用 optimizedContent 初始化每个输入框(兼容字符串和数组对象)
const oc = matchedIssue.value?.optimizedContent
if (typeof oc === 'string') {
// 个人概述:单个字符串 → 单个输入框
userRewriteList.value = [oc]
} else {
userRewriteList.value = optimizedItems.value.map(
(item: any) => item.optimizedContent || item.text || ''
)
}
// 如果没有 optimizedContent,至少给一个空输入框
if (!userRewriteList.value.length) {
userRewriteList.value = ['']
}
// 清空上次的润色结果
polishResult.value = []
// 重置评价状态
feedbackValue.value = 0
// 初始化 textarea 高度
initAllTextareaHeight()
}
})
// ==================== AI 润色(调用接口) ====================
/** AI 润色返回的结果数组 */
const polishResult = ref<string[]>([])
/** 调用 AI 润色接口 */
async function handleAiPolish() {
const issueId = matchedIssue.value?.id
if (!issueId) {
ElMessage.warning('当前没有可润色的问题')
return
}
// 收集每个输入框的内容
const content = userRewriteList.value.map(s => s.trim()).filter(Boolean)
if (!content.length) {
ElMessage.warning('请先输入改写内容')
return
}
// 全屏加载提示
const loading = ElLoading.service({
lock: true,
text: 'AI 润色中,请耐心等待…',
background: 'rgba(0, 0, 0, 0.5)',
customClass: 'resume-upload-loading',
})
try {
const res = await polishDiagnosisIssue(issueId, content)
if (res.code === 0 && res.data?.content) {
polishResult.value = res.data.content
ElMessage.success('润色完成')
} else {
ElMessage.error(res.msg || '润色失败,请稍后重试')
}
} catch {
ElMessage.error('润色请求失败,请稍后重试')
} finally {
loading.close()
}
}
/** 点击替换按钮 — 以 AI 润色结果段数为准,替换自己改写的输入框 */
function handleReplace() {
userRewriteList.value = [...polishResult.value]
// 替换完成后清空润色结果区域
polishResult.value = []
ElMessage.success('已替换')
// 重新计算 textarea 高度
initAllTextareaHeight()
}
// ==================== 用户评价 ====================
/** 当前评价状态:0=未评价 1=有用 2=无用 */
const feedbackValue = ref(0)
/** 提交用户评价 */
async function handleFeedback(value: number) {
const issueId = matchedIssue.value?.id
if (!issueId) return
try {
const res = await feedbackDiagnosisIssue(issueId, value)
if (res.code === 0) {
feedbackValue.value = value
ElMessage.success('已收到,感谢您的评价')
} else {
ElMessage.error(res.msg || '评价提交失败')
}
} catch {
ElMessage.error('评价请求失败,请稍后重试')
}
}
// ==================== textarea 自适应高度 ====================
/** textarea DOM 引用数组 */
const textareaRefs: HTMLTextAreaElement[] = []
/** 收集 textarea ref */
function setTextareaRef(el: HTMLTextAreaElement | null, idx: number) {
if (el) textareaRefs[idx] = el
}
/** 根据内容自动调整 textarea 高度 */
function autoResize(idx: number) {
const el = textareaRefs[idx]
if (!el) return
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
/** 初始化所有 textarea 高度(抽屉打开后 DOM 渲染完成时调用) */
function initAllTextareaHeight() {
nextTick(() => {
userRewriteList.value.forEach((_, idx) => autoResize(idx))
})
}
// ==================== 事件处理 ====================
/** 提交新版本 — 调用 resolve 接口标记已处理,然后通知父组件更新数据 */
async function handleSubmit() {
const issueId = matchedIssue.value?.id
if (!issueId) {
ElMessage.warning('当前没有可提交的问题')
return
}
// 全屏加载提示
const loading = ElLoading.service({
lock: true,
text: '正在提交新版本…',
background: 'rgba(0, 0, 0, 0.5)',
customClass: 'resume-upload-loading',
})
try {
// 1. 调用 resolve 接口标记问题已处理
const res = await resolveDiagnosisIssue(issueId)
if (res.code !== 0) {
ElMessage.error(res.msg || '标记已处理失败')
return
}
// 2. 关闭抽屉并通知父组件携带最终改写内容
emit('update:modelValue', false)
emit('submit', [...userRewriteList.value])
} catch {
ElMessage.error('提交失败,请稍后重试')
} finally {
loading.close()
}
}
/** 关闭抽屉 */
function handleClose() {
emit('update:modelValue', false)
}
</script>