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步:跳过投递 */