Files
offerpai_web/src/utils/resumeExport.ts
T

550 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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指令设置(单位twips1pt=20twips1英寸=1440twips
// 上下边距 2cm ≈ 1134twips,左右边距 2.5cm ≈ 1418twips
const parts: string[] = []
parts.push('<html xmlns:o="urn:schemas-microsoft-com:office:office"')
parts.push(' xmlns:w="urn:schemas-microsoft-com:office:word"')
parts.push(' xmlns="http://www.w3.org/TR/REC-html40">')
parts.push('<head>')
parts.push('<meta charset="utf-8">')
parts.push('<meta name="ProgId" content="Word.Document">')
parts.push('<meta name="Generator" content="Microsoft Word 15">')
parts.push('<!--[if gte mso 9]><xml>')
parts.push('<w:WordDocument>')
parts.push('<w:View>Print</w:View>')
parts.push('<w:Zoom>100</w:Zoom>')
parts.push('</w:WordDocument>')
parts.push('</xml><![endif]-->')
parts.push('<style>' + wordCss + '</style>')
parts.push('</head>')
parts.push('<body>')
parts.push('<!--[if gte mso 9]><xml>')
parts.push('<w:WordDocument>')
parts.push('<w:BrowserLevel>MicrosoftInternetExplorer4</w:BrowserLevel>')
parts.push('</w:WordDocument>')
parts.push('</xml><![endif]-->')
parts.push('<div class="WordSection1">' + element.outerHTML + '</div>')
parts.push('</body>')
parts.push('</html>')
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<string, number> = { '大专': 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<ResumeTemplateData | null> {
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<File> {
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('<html xmlns:o="urn:schemas-microsoft-com:office:office"')
parts.push(' xmlns:w="urn:schemas-microsoft-com:office:word"')
parts.push(' xmlns="http://www.w3.org/TR/REC-html40">')
parts.push('<head>')
parts.push('<meta charset="utf-8">')
parts.push('<meta name="ProgId" content="Word.Document">')
parts.push('<meta name="Generator" content="Microsoft Word 15">')
parts.push('<!--[if gte mso 9]><xml>')
parts.push('<w:WordDocument>')
parts.push('<w:View>Print</w:View>')
parts.push('<w:Zoom>100</w:Zoom>')
parts.push('</w:WordDocument>')
parts.push('</xml><![endif]-->')
parts.push('<style>' + wordCss + '</style>')
parts.push('</head>')
parts.push('<body>')
parts.push('<!--[if gte mso 9]><xml>')
parts.push('<w:WordDocument>')
parts.push('<w:BrowserLevel>MicrosoftInternetExplorer4</w:BrowserLevel>')
parts.push('</w:WordDocument>')
parts.push('</xml><![endif]-->')
parts.push('<div class="WordSection1">' + element.outerHTML + '</div>')
parts.push('</body>')
parts.push('</html>')
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<IDBDatabase> {
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<void> {
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<Blob | null> {
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<void> {
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<CachedResumeRecord | null> {
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<File | null> {
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)
}