550 lines
21 KiB
TypeScript
550 lines
21 KiB
TypeScript
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('<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)
|
||
}
|