简历上传,pdf简历下载

This commit is contained in:
xuxin
2026-04-03 17:52:07 +08:00
parent 821a950df2
commit a39835be01
25 changed files with 2076 additions and 301 deletions
+76 -3
View File
@@ -304,6 +304,7 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import html2pdf from 'html2pdf.js'
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
import { fetchProfile, fetchEducation, fetchWork, fetchInternship, fetchProject, fetchCompetition } from '@/api/profile'
@@ -588,10 +589,82 @@ function toggleDownloadMenu() {
}
/** 处理下载(PDF/Word */
function handleDownload(type: 'pdf' | 'word') {
async function handleDownload(type: 'pdf' | 'word') {
showDownloadMenu.value = false
// TODO: 实现简历HTML转PDF/Word下载
console.log(`[下载简历] 格式: ${type}`)
if (type === 'pdf') {
// 通过 JobResumeTemplate 组件暴露的 resumeRef 获取简历DOM
const element = resumeTemplateRef.value?.resumeRef
if (!element) {
console.error('[下载简历] 无法获取简历模板DOM')
return
}
// html2pdf 配置选项
const options = {
margin: [10, 10, 10, 10] as [number, number, number, number],
filename: `${resumeTemplateData.value.name || '简历'}_定制简历.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, logging: false },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' as const },
}
try {
await html2pdf().set(options).from(element).save()
} catch (err) {
console.error('[下载简历] PDF生成失败', err)
}
} else {
// 将简历HTML转为Word文档并下载(使用HTML格式的.doc文件,Word可正常打开)
const element = resumeTemplateRef.value?.resumeRef
if (!element) {
console.error('[下载简历] 无法获取简历模板DOM')
return
}
try {
// 获取页面样式表内容
const styleSheets = Array.from(document.styleSheets)
let cssText = ''
styleSheets.forEach((sheet) => {
try {
Array.from(sheet.cssRules).forEach((rule) => {
cssText += rule.cssText + '\n'
})
} catch {
// 跨域样式表无法读取,跳过
}
})
// 组装完整HTML文档(Word可识别的HTML格式)
const fullHtml = `
<html xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:w="urn:schemas-microsoft-com:office:word"
xmlns="http://www.w3.org/TR/REC-html40">
<head>
<meta charset="utf-8">
<meta name="ProgId" content="Word.Document">
<meta name="Generator" content="Microsoft Word 15">
<style>${cssText}</style>
</head>
<body>${element.outerHTML}</body>
</html>
`
// 生成Blob并触发下载
const blob = new Blob([fullHtml], { type: 'application/msword' })
const fileName = `${resumeTemplateData.value.name || '简历'}_定制简历.doc`
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
} catch (err) {
console.error('[下载简历] Word生成失败', err)
}
}
}
/** 立即去投递 */
+46 -16
View File
@@ -109,11 +109,9 @@
<span class="profile-drawer__required">*</span>学历类型
</label>
<div class="profile-drawer__select-wrap">
<select class="profile-drawer__input" v-model.number="edu.studyType">
<option :value="0">全日制</option>
<option :value="1">非全日制</option>
<select class="profile-drawer__input" v-model="edu.studyType">
<option v-for="opt in studyTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<!-- <svg viewBox="0 0 16 16" fill="none" class="profile-drawer__select-arrow"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> -->
</div>
</div>
<div class="profile-drawer__field profile-drawer__field--half">
@@ -121,13 +119,9 @@
<span class="profile-drawer__required">*</span>学历
</label>
<div class="profile-drawer__select-wrap">
<select class="profile-drawer__input" v-model.number="edu.degree">
<option :value="1">大专</option>
<option :value="2">本科</option>
<option :value="3">硕士</option>
<option :value="4">博士</option>
<select class="profile-drawer__input" v-model="edu.degree">
<option v-for="opt in degreeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<!-- <svg viewBox="0 0 16 16" fill="none" class="profile-drawer__select-arrow"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> -->
</div>
</div>
</div>
@@ -742,6 +736,19 @@
</div>
</template>
<!-- ========== 个人概述模块仅简历使用 ========== -->
<template v-else-if="module === 'summary'">
<div class="profile-drawer__field">
<label class="profile-drawer__label">个人概述</label>
<textarea
class="profile-drawer__textarea"
placeholder="请输入个人概述,简要介绍自己的优势和职业目标"
v-model="summaryText"
rows="8"
></textarea>
</div>
</template>
<!-- ========== 其他模块占位 ========== -->
<template v-else>
<div class="profile-drawer__empty">
@@ -770,10 +777,10 @@ interface EducationItem {
school: string
/** 专业 */
major: string
/** 学历类型(0=全日制 1=非全日制) */
studyType: number
/** 学历(1=大专 2=本科 3=硕士 4=博士) */
degree: number
/** 学历类型(数字模式:0=全日制 1=非全日制;文本模式:中文字符串 */
studyType: number | string
/** 学历(数字模式:1=大专 2=本科 3=硕士 4=博士;文本模式:中文字符串 */
degree: number | string
/** 入学时间(日期选择器使用字符串,格式:YYYY-MM) */
startDate: string
/** 毕业时间(日期选择器使用字符串,格式:YYYY-MM) */
@@ -854,8 +861,22 @@ const props = defineProps<{
module: string
/** 初始数据 — 不同模块传入不同 JSON 结构 */
initialData?: Record<string, any>
/** 教育经历是否使用中文文本作为选项值(默认 false 使用数字编码,true 则输出中文字符串) */
useEducationTextValue?: boolean
}>()
/** 学历类型选项映射 — 根据 useEducationTextValue 决定 value 类型 */
const studyTypeOptions = computed(() => props.useEducationTextValue
? [{ value: '全日制', label: '全日制' }, { value: '非全日制', label: '非全日制' }]
: [{ value: 0, label: '全日制' }, { value: 1, label: '非全日制' }]
)
/** 学历选项映射 — 根据 useEducationTextValue 决定 value 类型 */
const degreeOptions = computed(() => props.useEducationTextValue
? [{ value: '大专', label: '大专' }, { value: '本科', label: '本科' }, { value: '硕士', label: '硕士' }, { value: '博士', label: '博士' }]
: [{ value: 1, label: '大专' }, { value: 2, label: '本科' }, { value: 3, label: '硕士' }, { value: 4, label: '博士' }]
)
/** 组件 Emits */
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
@@ -868,6 +889,7 @@ const store = useStore()
/** 模块名称映射表 — 模块标识 → 中文标题 */
const moduleTitleMap: Record<string, string> = {
info: '基本信息',
summary: '个人概述',
education: '教育经历',
work: '工作经历',
internship: '实习经历',
@@ -943,8 +965,8 @@ const educationList = ref<EducationItem[]>([])
const createEmptyEducation = (): EducationItem => ({
school: '',
major: '',
studyType: 0,
degree: 2,
studyType: props.useEducationTextValue ? '全日制' : 0,
degree: props.useEducationTextValue ? '本科' : 2,
startDate: '',
endDate: '',
description: [{ id: generateId(), text: '' }],
@@ -1099,6 +1121,9 @@ const skillsList = ref<string[]>([])
/** 作品集链接 — 作品集模块使用 */
const portfolioUrl = ref('')
/** 个人概述文本 — 个人概述模块使用 */
const summaryText = ref('')
/** 新技能输入框的值 */
const newSkillInput = ref('')
@@ -1205,6 +1230,9 @@ watch(() => props.modelValue, (visible) => {
} else if (props.module === 'portfolio') {
// 作品集:用初始数据填充链接
portfolioUrl.value = props.initialData?.portfolioUrl || ''
} else if (props.module === 'summary') {
// 个人概述:用初始数据填充文本
summaryText.value = props.initialData?.summary || ''
} else if (props.module === 'skills') {
// 技能:用初始数据填充列表,若无则创建空数组
skillsList.value = props.initialData?.skills ? [...props.initialData.skills] : []
@@ -1256,6 +1284,8 @@ const handleSave = () => {
})) })
} else if (props.module === 'portfolio') {
emit('save', { portfolioUrl: portfolioUrl.value })
} else if (props.module === 'summary') {
emit('save', { summary: summaryText.value })
} else if (props.module === 'skills') {
emit('save', { skills: [...skillsList.value] })
} else if (props.module === 'certificate') {
+3 -4
View File
@@ -2,10 +2,9 @@
<div class="side-nav">
<!-- 顶部 Logo -->
<div class="side-nav__header">
<div class="side-nav__avatar">
<span class="side-nav__avatar-icon">👤</span>
</div>
<span class="side-nav__logo-text">Offer派</span>
<!-- 侧边栏Logo图片 -->
<img src="@/assets/images/logo.png" alt="Offer派" class="side-nav__logo-img" />
</div>
<!-- 主导航 -->