From 59ac8ab78361e2ed0d33535c3b09d76a9c6af065 Mon Sep 17 00:00:00 2001 From: xuxin <15279969124@163.com> Date: Fri, 29 May 2026 15:45:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9A=E5=88=B6=E7=AE=80=E5=8E=86=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E8=AE=B0=E5=BD=95=E7=BC=93=E5=AD=98=E5=8A=A0?= =?UTF-8?q?IndexDB=E5=AD=98=E5=82=A8=E5=B7=A5=E5=85=B7=E5=B0=81=E8=A3=85?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=90=8C=E4=B8=80=E4=B8=AA=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E7=9A=84=E4=B8=8D=E5=90=8C=E5=8D=8F=E8=AE=AE=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tools/AgreementPreviewDialog.vue | 40 +-- src/utils/resumeExport.ts | 264 ++++++++++++++++++ src/views/Agent.vue | 59 +++- 3 files changed, 342 insertions(+), 21 deletions(-) diff --git a/src/components/tools/AgreementPreviewDialog.vue b/src/components/tools/AgreementPreviewDialog.vue index 6f5e391..da560dc 100644 --- a/src/components/tools/AgreementPreviewDialog.vue +++ b/src/components/tools/AgreementPreviewDialog.vue @@ -23,7 +23,7 @@ diff --git a/src/utils/resumeExport.ts b/src/utils/resumeExport.ts index cec8fe2..31cd3de 100644 --- a/src/utils/resumeExport.ts +++ b/src/utils/resumeExport.ts @@ -283,3 +283,267 @@ export function generateResumeWordFile(element: HTMLElement, fileName: string): 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) +} diff --git a/src/views/Agent.vue b/src/views/Agent.vue index 412fe98..aacbf38 100644 --- a/src/views/Agent.vue +++ b/src/views/Agent.vue @@ -152,7 +152,7 @@
- +
- @@ -209,6 +208,8 @@ import { fetchAgentTaskList } from '@/api/jobs' import type { JobListItem } from '@/api/jobs' import { fetchResumeList } from '@/api/resume' import { getIntentionCategoryNames, getIntentionRegionNames, getIntentionIndustryNames } from '@/utils/intention' +import { cacheResumePdfToLocal, getCachedResumeRecord, getCachedResumeFile } from '@/utils/resumeExport' +import store from '@/stores' import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue' import JobGoalDialog from '@/components/JobGoalDialog.vue' @@ -796,6 +797,9 @@ const generateProgress = ref(0) /** 当前投递流程的简历数据 */ const applyResumeData = ref({} as ResumeTemplateData) +/** 右侧面板简历模板组件引用(用于生成PDF上传) */ +const applyResumeTemplateRef = ref | null>(null) + /** 当前投递流程的简历名称 */ const applyResumeName = ref('') @@ -1098,13 +1102,60 @@ function handleCancelApply(msg: AgentChatMessage) { } /** 第2步:确认简历 */ -function handleConfirmResumeStep(msg: AgentChatMessage) { +async function handleConfirmResumeStep(msg: AgentChatMessage) { try { const extraData = JSON.parse(msg.extra) // 勾选第2步,进入第3步 extraData.step = 3 msg.extra = JSON.stringify(extraData) - } catch { /* 忽略 */ } + + // 确认简历后,自动生成PDF并缓存到浏览器localStorage + await nextTick() + const element = applyResumeTemplateRef.value?.resumeRef + if (element) { + const userId = String(store.state.userInfo?.id || '') + const jobId = String(extraData.jobInfo?.id || '') + if (userId && jobId) { + const cacheRecord = await cacheResumePdfToLocal(element, userId, jobId) + if (cacheRecord) { + // 将缓存记录的 storageKey 存入 extraData,方便后续读取 + extraData.resumeCacheKey = cacheRecord.storageKey + msg.extra = JSON.stringify(extraData) + + //测试一下通过userId和jobId拿到storageKey,再用storageKey拿到简历文件并下载到浏览器测试一下(测试成功,先注释) + const testRecord = getCachedResumeRecord(userId, jobId) + // if (testRecord) { + // const testFile = await getCachedResumeFile(testRecord.storageKey, '测试缓存简历') + // if (testFile) { + // const url = URL.createObjectURL(testFile) + // const a = document.createElement('a') + // a.href = url + // a.download = testFile.name + // document.body.appendChild(a) + // a.click() + // document.body.removeChild(a) + // URL.revokeObjectURL(url) + // console.log('[Agent] 测试:从IndexedDB取出简历并下载成功', testRecord) + // } + // } + } + } + } + + // // 【暂时注释】上传到服务器方案(服务器资源压力大,暂用localStorage缓存替代) + // const element = applyResumeTemplateRef.value?.resumeRef + // if (element) { + // const fileName = applyResumeName.value || '岗位专属简历' + // const pdfFile = await generateResumePdfFile(element, fileName) + // const uploadRes = await uploadFileToOss(pdfFile, 'ResumeFile') + // if (uploadRes.code === '0' && uploadRes.data) { + // extraData.resumeFileUrl = uploadRes.data.downloadUrl + // msg.extra = JSON.stringify(extraData) + // } + // } + } catch (e) { + console.error('[Agent] 简历PDF缓存失败', e) + } } /** 第3步:跳过投递 */