import html2pdf from 'html2pdf.js' import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue' import { fetchResumeMain, fetchResumeEducation, fetchResumeWork, fetchResumeInternship, fetchResumeProject, fetchResumeCompetition, } from '@/api/resume' /** * 将简历DOM导出为PDF文件 * @param element 简历DOM元素 * @param fileName 文件名(不含扩展名) */ export async function exportResumePdf(element: HTMLElement, fileName: string) { const options = { margin: [10, 10, 10, 10] as [number, number, number, number], filename: `${fileName}.pdf`, image: { type: 'jpeg' as const, quality: 0.98 }, html2canvas: { scale: 2, useCORS: true, logging: false }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' as const }, pagebreak: { mode: ['css', 'legacy'] }, } await html2pdf().set(options).from(element).save() } /** * 将简历DOM导出为Word文件 * @param element 简历DOM元素 * @param fileName 文件名(不含扩展名) */ export function exportResumeWord(element: HTMLElement, fileName: string) { // Word专用内联样式(不使用页面全局样式,避免 rem/100px 基准干扰) // 字体在第一版基础上放大50%,边距通过Word XML指令设置 const wordCss = ` @page WordSection1 { size: 595.3pt 841.9pt; mso-page-orientation: portrait; mso-header-margin: 0pt; mso-footer-margin: 0pt; mso-paper-source: 0; margin-top: 72pt; margin-right: 54pt; margin-bottom: 72pt; margin-left: 54pt; } div.WordSection1 { page: WordSection1; } body { font-family: 'SimSun', 'Songti SC', serif; color: #000; line-height: 1.6; margin: 0; padding: 0; } .job-resume-template { width: 100%; background: #fff; box-sizing: border-box; } .resume-html { padding: 0; font-family: 'SimSun', 'Songti SC', serif; color: #000; line-height: 1.6; } .resume-html__name { font-size: 24pt; font-weight: 700; margin: 0 0 4.8pt 0; line-height: 1.3; } .resume-html__contact { font-size: 12pt; color: #000; margin-bottom: 3.6pt; line-height: 1.5; } .resume-html__contact-row { display: flex; align-items: center; gap: 0; } .resume-html__separator { margin: 0 4.8pt; color: #777; } .resume-html__section-title { font-size: 17.3pt; font-weight: 700; color: #000; margin-top: 20pt; margin-bottom: 10pt; line-height: 1.3; } .resume-html__divider { height: 1.5px; background: #000; margin-bottom: 9.6pt; } .resume-html__summary { font-size: 12pt; line-height: 1.7; margin-bottom: 4.8pt; } .resume-html__item { margin-bottom: 9.6pt; } .resume-html__item-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 2pt; } .resume-html__item-main { font-size: 13.2pt; font-weight: 600; color: #000; line-height: 1.4; } .resume-html__item-right { display: flex; align-items: center; gap: 4.8pt; flex-shrink: 0; text-align: right; } .resume-html__item-location { font-size: 12pt; color: #000; } .resume-html__item-date { font-size: 12pt; color: #000; white-space: nowrap; } .resume-html__item-desc { font-size: 12pt; color: #000; line-height: 1.7; margin-top: 2pt; } .resume-html__desc-list { margin: 0; padding-left: 0; list-style: none; mso-list: none; } .resume-html__desc-list li { font-size: 12pt; line-height: 1.7; color: #000; list-style: none; mso-list: none; margin-left: 0; padding-left: 24pt; text-indent: 0; } .resume-html__skills { font-size: 12pt; line-height: 1.7; } .resume-html__skill-row { margin-bottom: 2.4pt; } .resume-html__skill-label { font-weight: 600; } .resume-html__diff-highlight { background-color: #D4EDDA; color: #155724; border-radius: 2px; padding: 0 1px; } ` // 组装Word可识别的HTML文档 // Word页面边距通过XML指令设置(单位twips,1pt=20twips,1英寸=1440twips) // 上下边距 2cm ≈ 1134twips,左右边距 2.5cm ≈ 1418twips const parts: string[] = [] parts.push('') parts.push('') parts.push('') parts.push('') parts.push('') parts.push('') parts.push('') parts.push('') parts.push('') parts.push('') parts.push('
' + element.outerHTML + '
') parts.push('') parts.push('') const fullHtml = parts.join('\n') // 生成Blob并触发下载 const blob = new Blob([fullHtml], { type: 'application/msword' }) const link = document.createElement('a') link.href = URL.createObjectURL(blob) link.download = `${fileName}.doc` document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(link.href) } /** 学历文字转数字 */ function degreeToNumber(degree?: string): number { const map: Record = { '大专': 1, '本科': 2, '硕士': 3, '博士': 4 } return map[degree || ''] || 2 } /** 描述段落映射辅助函数 */ function mapDesc(list?: { id?: string; text?: string }[]) { return (list || []).map(d => ({ id: d.id, text: d.text || '' })) } /** * 加载简历完整数据并组装为模板格式 * @param resumeId 简历ID */ export async function loadResumeTemplateData(resumeId: string): Promise { const [mainRes, eduRes, workRes, internRes, projRes, compRes] = await Promise.all([ fetchResumeMain(resumeId), fetchResumeEducation(resumeId), fetchResumeWork(resumeId), fetchResumeInternship(resumeId), fetchResumeProject(resumeId), fetchResumeCompetition(resumeId), ]) if (mainRes.code !== '0' || !mainRes.data) return null const r = mainRes.data return { name: r.name || '未填写姓名', email: r.email || '', mobileNumber: r.mobileNumber || '', wechatNumber: r.wechatNumber || '', summary: r.summary || '', educations: (eduRes.data || []).map(e => ({ school: e.school || '', major: e.major || '', degree: degreeToNumber(e.degree), startDate: e.startDate || '', endDate: e.endDate || '', description: mapDesc(e.description), })), workExperiences: (workRes.data || []).map(w => ({ companyName: w.companyName || '', position: w.position || '', startDate: w.startDate || '', endDate: w.endDate || '', description: mapDesc(w.description), })), internships: (internRes.data || []).map(i => ({ companyName: i.companyName || '', position: i.position || '', startDate: i.startDate || '', endDate: i.endDate || '', description: mapDesc(i.description), })), projects: (projRes.data || []).map(p => ({ projectName: p.projectName || '', companyName: p.companyName || '', role: p.role || '', startDate: p.startDate || '', endDate: p.endDate || '', description: mapDesc(p.description), })), competitions: (compRes.data || []).map(c => ({ competitionName: c.competitionName || '', award: c.award || '', awardDate: c.awardDate || '', description: mapDesc(c.description), })), skills: r.skills || [], certificates: r.certificates || [], } } // ==================== 生成文件对象(不触发下载,用于上传到服务器) ==================== /** * 生成简历PDF的File对象(不触发浏览器下载) * @param element 简历DOM元素 * @param fileName 文件名(不含扩展名) * @returns PDF格式的File对象 */ export async function generateResumePdfFile(element: HTMLElement, fileName: string): Promise { const options = { margin: [10, 10, 10, 10] as [number, number, number, number], filename: `${fileName}.pdf`, image: { type: 'jpeg' as const, quality: 0.98 }, html2canvas: { scale: 2, useCORS: true, logging: false }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' as const }, pagebreak: { mode: ['css', 'legacy'] }, } // 使用 outputPdf('blob') 获取 Blob,不触发下载 const blob: Blob = await html2pdf().set(options).from(element).outputPdf('blob') return new File([blob], `${fileName}.pdf`, { type: 'application/pdf' }) } /** * 生成简历Word的File对象(不触发浏览器下载) * @param element 简历DOM元素 * @param fileName 文件名(不含扩展名) * @returns Word格式的File对象 */ export function generateResumeWordFile(element: HTMLElement, fileName: string): File { // 复用与 exportResumeWord 相同的 Word HTML 组装逻辑 const wordCss = ` @page WordSection1 { size: 595.3pt 841.9pt; mso-page-orientation: portrait; mso-header-margin: 0pt; mso-footer-margin: 0pt; mso-paper-source: 0; margin-top: 72pt; margin-right: 54pt; margin-bottom: 72pt; margin-left: 54pt; } div.WordSection1 { page: WordSection1; } body { font-family: 'SimSun', 'Songti SC', serif; color: #000; line-height: 1.6; margin: 0; padding: 0; } .job-resume-template { width: 100%; background: #fff; box-sizing: border-box; } .resume-html { padding: 0; font-family: 'SimSun', 'Songti SC', serif; color: #000; line-height: 1.6; } .resume-html__name { font-size: 24pt; font-weight: 700; margin: 0 0 4.8pt 0; line-height: 1.3; } .resume-html__contact { font-size: 12pt; color: #000; margin-bottom: 3.6pt; line-height: 1.5; } .resume-html__contact-row { display: flex; align-items: center; gap: 0; } .resume-html__separator { margin: 0 4.8pt; color: #777; } .resume-html__section-title { font-size: 17.3pt; font-weight: 700; color: #000; margin-top: 20pt; margin-bottom: 10pt; line-height: 1.3; } .resume-html__divider { height: 1.5px; background: #000; margin-bottom: 9.6pt; } .resume-html__summary { font-size: 12pt; line-height: 1.7; margin-bottom: 4.8pt; } .resume-html__item { margin-bottom: 9.6pt; } .resume-html__item-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 2pt; } .resume-html__item-main { font-size: 13.2pt; font-weight: 600; color: #000; line-height: 1.4; } .resume-html__item-right { display: flex; align-items: center; gap: 4.8pt; flex-shrink: 0; text-align: right; } .resume-html__item-location { font-size: 12pt; color: #000; } .resume-html__item-date { font-size: 12pt; color: #000; white-space: nowrap; } .resume-html__item-desc { font-size: 12pt; color: #000; line-height: 1.7; margin-top: 2pt; } .resume-html__desc-list { margin: 0; padding-left: 0; list-style: none; mso-list: none; } .resume-html__desc-list li { font-size: 12pt; line-height: 1.7; color: #000; list-style: none; mso-list: none; margin-left: 0; padding-left: 24pt; text-indent: 0; } .resume-html__skills { font-size: 12pt; line-height: 1.7; } .resume-html__skill-row { margin-bottom: 2.4pt; } .resume-html__skill-label { font-weight: 600; } .resume-html__diff-highlight { background-color: #D4EDDA; color: #155724; border-radius: 2px; padding: 0 1px; } ` const parts: string[] = [] parts.push('') parts.push('') parts.push('') parts.push('') parts.push('') parts.push('') parts.push('') parts.push('') parts.push('') parts.push('') parts.push('
' + element.outerHTML + '
') parts.push('') parts.push('') const fullHtml = parts.join('\n') // 生成File对象,不触发下载 const blob = new Blob([fullHtml], { type: 'application/msword' }) return new File([blob], `${fileName}.doc`, { type: 'application/msword' }) } // ==================== 定制简历本地缓存(IndexedDB) ==================== /** * 【定制简历缓存机制说明】 * * 由于服务器资源有限,投递流程中生成的岗位专属简历PDF不直接上传到OSS, * 而是以 Blob 形式缓存到浏览器 IndexedDB 中(空间可达几百MB,远大于 localStorage 的 5~10MB)。 * * 存储结构: * - 索引记录数组:localStorage key = "CUSTOM_RESUME_CACHE_INDEX" * 存储 CachedResumeRecord[] 数组的 JSON 字符串(几百字节,不占空间),每条记录包含: * userId(用户ID)、jobId(岗位ID)、fileName(雪花ID文件名)、 * storageKey(简历文件在 IndexedDB 中的 key)、localDateTime(存储时间) * * - 简历文件数据:IndexedDB 数据库名 = "ResumeFileCache",对象仓库名 = "files" * 以 storageKey 为主键,直接存储 PDF 的 Blob 对象(无需 Base64 编码,不膨胀体积) * * 使用方式: * 1. 存储:调用 cacheResumePdfToLocal(element, userId, jobId) 生成PDF并缓存到IndexedDB * 2. 查询:调用 getCachedResumeRecord(userId, jobId) 通过用户ID+岗位ID获取缓存记录 * 3. 取文件:调用 getCachedResumeFile(storageKey) 通过记录中的 storageKey 从IndexedDB获取 File 对象 * 4. 清理:调用 clearExpiredResumeCache(maxAgeDays) 清理过期缓存 * * 优势: * - IndexedDB 直接存 Blob,不需要 Base64 编码,文件体积不膨胀 * - 空间充足(几百MB~GB级),不会像 localStorage 那样容易满 * - 取出来就是 Blob,直接 new File([blob], name) 即可当文件使用 */ /** 定制简历缓存索引记录 */ export interface CachedResumeRecord { /** 系统用户ID */ userId: string /** 对应岗位ID */ jobId: string /** 缓存的简历文件名(雪花ID格式,不含扩展名) */ fileName: string /** 简历文件在 IndexedDB 中的存储 key */ storageKey: string /** 存储时间(ISO 8601 格式) */ localDateTime: string } /** 缓存索引在 localStorage 中的 key(仅存索引JSON,体积极小) */ const CACHE_INDEX_KEY = 'CUSTOM_RESUME_CACHE_INDEX' /** IndexedDB 数据库名 */ const IDB_NAME = 'ResumeFileCache' /** IndexedDB 对象仓库名 */ const IDB_STORE = 'files' /** IndexedDB 版本号 */ const IDB_VERSION = 1 /** * 打开 IndexedDB 数据库连接 * @returns IDBDatabase 实例 */ function openResumeDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(IDB_NAME, IDB_VERSION) request.onupgradeneeded = () => { const db = request.result // 创建对象仓库(如果不存在) if (!db.objectStoreNames.contains(IDB_STORE)) { db.createObjectStore(IDB_STORE) } } request.onsuccess = () => resolve(request.result) request.onerror = () => reject(request.error) }) } /** * 向 IndexedDB 写入 Blob 数据 * @param key 存储键 * @param blob 文件 Blob */ async function idbPut(key: string, blob: Blob): Promise { const db = await openResumeDB() return new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, 'readwrite') const store = tx.objectStore(IDB_STORE) const request = store.put(blob, key) request.onsuccess = () => resolve() request.onerror = () => reject(request.error) tx.oncomplete = () => db.close() }) } /** * 从 IndexedDB 读取 Blob 数据 * @param key 存储键 * @returns Blob 对象,未找到返回 null */ async function idbGet(key: string): Promise { const db = await openResumeDB() return new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, 'readonly') const store = tx.objectStore(IDB_STORE) const request = store.get(key) request.onsuccess = () => resolve(request.result || null) request.onerror = () => reject(request.error) tx.oncomplete = () => db.close() }) } /** * 从 IndexedDB 删除指定 key 的数据 * @param key 存储键 */ async function idbDelete(key: string): Promise { const db = await openResumeDB() return new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, 'readwrite') const store = tx.objectStore(IDB_STORE) const request = store.delete(key) request.onsuccess = () => resolve() request.onerror = () => reject(request.error) tx.oncomplete = () => db.close() }) } /** * 生成简单的雪花ID(基于时间戳 + 随机数,保证不重复) * @returns 雪花ID字符串 */ function generateSnowflakeId(): string { const timestamp = Date.now().toString(36) const random = Math.random().toString(36).substring(2, 10) return `${timestamp}${random}` } /** * 获取缓存索引记录数组(从 localStorage 读取) * @returns 缓存记录数组 */ function getCacheIndex(): CachedResumeRecord[] { try { const raw = localStorage.getItem(CACHE_INDEX_KEY) return raw ? JSON.parse(raw) : [] } catch { return [] } } /** * 保存缓存索引记录数组(写入 localStorage) * @param records 缓存记录数组 */ function saveCacheIndex(records: CachedResumeRecord[]) { localStorage.setItem(CACHE_INDEX_KEY, JSON.stringify(records)) } /** * 将简历PDF生成并缓存到浏览器 IndexedDB * @param element 简历DOM元素 * @param userId 当前用户ID * @param jobId 对应岗位ID * @returns 缓存记录(包含 storageKey 等信息),存储失败返回 null */ export async function cacheResumePdfToLocal( element: HTMLElement, userId: string, jobId: string, ): Promise { try { // 生成雪花ID作为文件名 const snowflakeId = generateSnowflakeId() const storageKey = `resume_${snowflakeId}` // 生成PDF Blob const options = { margin: [10, 10, 10, 10] as [number, number, number, number], filename: `${snowflakeId}.pdf`, image: { type: 'jpeg' as const, quality: 0.98 }, html2canvas: { scale: 2, useCORS: true, logging: false }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' as const }, pagebreak: { mode: ['css', 'legacy'] }, } const blob: Blob = await html2pdf().set(options).from(element).outputPdf('blob') // 将 Blob 直接存入 IndexedDB(无需 Base64 编码) await idbPut(storageKey, blob) // 构建缓存记录 const record: CachedResumeRecord = { userId, jobId, fileName: snowflakeId, storageKey, localDateTime: new Date().toISOString(), } // 更新索引(如果同一用户+岗位已有记录,先移除旧的) const index = getCacheIndex() const existIdx = index.findIndex(r => r.userId === userId && r.jobId === jobId) if (existIdx !== -1) { // 删除旧的 IndexedDB 文件缓存 await idbDelete(index[existIdx].storageKey) index.splice(existIdx, 1) } index.push(record) saveCacheIndex(index) return record } catch (e) { console.error('[resumeExport] 缓存简历到IndexedDB失败', e) return null } } /** * 通过用户ID和岗位ID查询缓存记录 * @param userId 用户ID * @param jobId 岗位ID * @returns 缓存记录,未找到返回 null */ export function getCachedResumeRecord(userId: string, jobId: string): CachedResumeRecord | null { const index = getCacheIndex() return index.find(r => r.userId === userId && r.jobId === jobId) || null } /** * 通过 storageKey 从 IndexedDB 获取缓存的简历 File 对象 * @param storageKey 缓存记录中的 storageKey * @param fileName 可选,指定输出文件名(不含扩展名),默认用 storageKey 中的雪花ID * @returns PDF格式的 File 对象,未找到返回 null */ export async function getCachedResumeFile(storageKey: string, fileName?: string): Promise { try { const blob = await idbGet(storageKey) if (!blob) return null const name = fileName || storageKey.replace('resume_', '') return new File([blob], `${name}.pdf`, { type: 'application/pdf' }) } catch { return null } } /** * 清理过期的简历缓存 * @param maxAgeDays 最大保留天数,默认7天 */ export async function clearExpiredResumeCache(maxAgeDays = 7) { const index = getCacheIndex() const now = Date.now() const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000 const validRecords: CachedResumeRecord[] = [] for (const record of index) { const recordTime = new Date(record.localDateTime).getTime() if (now - recordTime > maxAgeMs) { // 过期,删除 IndexedDB 中的文件缓存 await idbDelete(record.storageKey) } else { validRecords.push(record) } } saveCacheIndex(validRecords) }