diff --git a/components.d.ts b/components.d.ts index 48180a1..b45469d 100644 --- a/components.d.ts +++ b/components.d.ts @@ -42,6 +42,7 @@ declare module 'vue' { FullscreenLoading: typeof import('./src/components/FullscreenLoading.vue')['default'] HelloWorld: typeof import('./src/components/HelloWorld.vue')['default'] IndustrySelector: typeof import('./src/components/tools/IndustrySelector.vue')['default'] + JobApplyMethodDialog: typeof import('./src/components/JobApplyMethodDialog.vue')['default'] JobCategorySelector: typeof import('./src/components/tools/JobCategorySelector.vue')['default'] JobDislikeDialog: typeof import('./src/components/JobDislikeDialog.vue')['default'] JobFeedbackDialog: typeof import('./src/components/JobFeedbackDialog.vue')['default'] diff --git a/src/assets/styles/pages/job-detail.scss b/src/assets/styles/pages/job-detail.scss index d8c13aa..92de6c0 100644 --- a/src/assets/styles/pages/job-detail.scss +++ b/src/assets/styles/pages/job-detail.scss @@ -167,14 +167,15 @@ display: flex; align-items: center; justify-content: center; - padding: 0.08rem 0.16rem; + width: 0.88rem; + height: 0.37rem; + line-height: 0.37rem; background: $accent; border: 1px solid $accent; border-radius: 0.08rem; color: $bg-white; font-size: 0.14rem; font-weight: 600; - line-height: 0.19rem; cursor: pointer; transition: background 0.2s; diff --git a/src/business.config.ts b/src/business.config.ts index 664ae3f..af51896 100644 --- a/src/business.config.ts +++ b/src/business.config.ts @@ -6,4 +6,4 @@ export const ENABLE_FORCE_RESUME_UPLOAD = true /** 浏览器插件目标版本(用于判断用户是否需要更新插件) */ -export const PLUGIN_TARGET_VERSION = '0.1.0' +export const PLUGIN_TARGET_VERSION = '0.1.1' diff --git a/src/components/JobApplyMethodDialog.vue b/src/components/JobApplyMethodDialog.vue new file mode 100644 index 0000000..ba62fa2 --- /dev/null +++ b/src/components/JobApplyMethodDialog.vue @@ -0,0 +1,420 @@ + + + + + diff --git a/src/components/JobResumeCustomDialog.vue b/src/components/JobResumeCustomDialog.vue index 7467566..25c8b75 100644 --- a/src/components/JobResumeCustomDialog.vue +++ b/src/components/JobResumeCustomDialog.vue @@ -48,8 +48,8 @@
-

你的简历与该岗位的匹配度{{ isLowMatch ? '较低' : '较高' }}

-

匹配度低于 6.0 分的简历,在筛选环节可能会被优先淘汰。我们会帮你快速优化提升。

+

你的简历与该岗位任职要求差距{{ isLowMatch ? '较大' : '较小' }}

+

竞争力低于 6.0 分的简历,在筛选环节可能会被优先淘汰。我们会帮你快速优化提升。

diff --git a/src/stores/index.ts b/src/stores/index.ts index cd7af47..9e31eb0 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -213,7 +213,7 @@ export default createStore({ inviteCode: '', showMemberAccessDialog: false, /** 通信桥接组件的域名标识(用于 BroadcastChannel 频道隔离) */ - bridgeDomain: 'test.offerpai.com.cn', + bridgeDomain: 'www.offerpai.com.cn', /** 浏览器插件是否在线 */ plugIsOnline: false, /** 插件当前使用版本 */ diff --git a/src/utils/channelBridge.ts b/src/utils/channelBridge.ts index 63817d4..df0179e 100644 --- a/src/utils/channelBridge.ts +++ b/src/utils/channelBridge.ts @@ -45,8 +45,8 @@ * ------------------------------------------------------------ * | 数据名(name) | 中文名称 | 说明 | * | ----------------------------- | -------------- | --------------------------------- | - * * | offerpieBrowserPlugUsage | 插件使用状态 | 标识插件正在运行 { usage: string, version: string } | - * * | offerpaiDeliveryLinkList | 投递链接列表 | 记录要投递职位的来源链接,目前用于解决投递网站的重定向 { linkList: string[] },useCsl:true | + * | offerpieBrowserPlugUsage | 插件使用状态 | 标识插件正在运行 { usage: string, version: string } | + * | offerpaiDeliveryLinkList | 投递链接列表 | 记录要投递职位的来源链接,目前用于解决投递网站的重定向 { linkList: string[] } | * ============================================================ */ @@ -108,8 +108,6 @@ export interface ChannelBridgeOptions { /** IndexedDB 数据库名称(含固定雪花ID保证唯一性) */ const DB_NAME = 'offerpie_comm_7394028156183472' -/** IndexedDB 版本号 */ -const DB_VERSION = 1 /** get 方法 BroadcastChannel 超时时间(毫秒) */ const GET_TIMEOUT_MS = 60 /** emit 方法节流写库间隔(毫秒) */ @@ -235,10 +233,30 @@ function _getCslProxyChannel(domain: string): BroadcastChannel { // ============ IndexedDB 操作 ============ -/** 打开/创建 IndexedDB 数据库,按 domain 动态创建 ObjectStore */ +/** 数据库连接缓存(避免重复打开和版本冲突) */ +const _dbCache = new Map>() + +/** 打开/创建 IndexedDB 数据库,按 domain 动态创建 ObjectStore,带连接缓存防并发冲突 */ function openDB(storeName: string): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION) + // 如果已有进行中或已完成的连接,直接复用 + if (_dbCache.has(storeName)) { + return _dbCache.get(storeName)!.then(db => { + // 检查连接是否还有效(可能被 close 了) + try { + // 尝试创建事务验证连接有效性 + if (db.objectStoreNames.contains(storeName)) { + return db + } + } catch { + // 连接已关闭,清除缓存重新打开 + _dbCache.delete(storeName) + } + return openDB(storeName) + }) + } + + const promise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME) request.onupgradeneeded = () => { const db = request.result if (!db.objectStoreNames.contains(storeName)) { @@ -248,9 +266,10 @@ function openDB(storeName: string): Promise { } request.onsuccess = () => { const db = request.result - // 如果 store 不存在(版本没升级的情况),关闭后升版本重建 if (!db.objectStoreNames.contains(storeName)) { + // store 不存在,需要升版本。关闭当前连接再重开 db.close() + _dbCache.delete(storeName) const version = db.version + 1 const req2 = indexedDB.open(DB_NAME, version) req2.onupgradeneeded = () => { @@ -261,37 +280,60 @@ function openDB(storeName: string): Promise { } } req2.onsuccess = () => resolve(req2.result) - req2.onerror = () => reject(req2.error) + req2.onerror = () => { _dbCache.delete(storeName); reject(req2.error) } + req2.onblocked = () => { + // 其他标签页占用数据库导致升级被阻塞,超时后放弃 + console.warn('[ChannelBridge] IndexedDB upgrade blocked, retrying...') + _dbCache.delete(storeName) + reject(new Error('IndexedDB upgrade blocked')) + } } else { resolve(db) } } - request.onerror = () => reject(request.error) + request.onerror = () => { _dbCache.delete(storeName); reject(request.error) } + request.onblocked = () => { + console.warn('[ChannelBridge] IndexedDB open blocked') + _dbCache.delete(storeName) + reject(new Error('IndexedDB open blocked')) + } }) + + _dbCache.set(storeName, promise) + return promise } /** 写入或更新一条记录到 IndexedDB */ async function dbPut(storeName: string, record: DBRecord): Promise { - const db = await openDB(storeName) - return new Promise((resolve, reject) => { - const tx = db.transaction(storeName, 'readwrite') - const store = tx.objectStore(storeName) - store.put(record) - tx.oncomplete = () => { db.close(); resolve() } - tx.onerror = () => { db.close(); reject(tx.error) } - }) + try { + const db = await openDB(storeName) + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readwrite') + const store = tx.objectStore(storeName) + store.put(record) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } catch (err) { + console.warn('[ChannelBridge] dbPut failed:', err) + } } /** 从 IndexedDB 读取一条记录 */ async function dbGet(storeName: string, name: string): Promise { - const db = await openDB(storeName) - return new Promise((resolve, reject) => { - const tx = db.transaction(storeName, 'readonly') - const store = tx.objectStore(storeName) - const req = store.get(name) - req.onsuccess = () => { db.close(); resolve(req.result || null) } - req.onerror = () => { db.close(); reject(req.error) } - }) + try { + const db = await openDB(storeName) + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readonly') + const store = tx.objectStore(storeName) + const req = store.get(name) + req.onsuccess = () => resolve(req.result || null) + req.onerror = () => reject(req.error) + }) + } catch (err) { + console.warn('[ChannelBridge] dbGet failed:', err) + return null + } } // ============ 核心:创建通信桥接实例 ============ @@ -499,19 +541,23 @@ export function createChannelBridge(options: ChannelBridgeOptions) { } // 默认模式:IndexedDB - // 先尝试获取 createTime(从暂存或数据库) + // 先尝试获取 createTime(从暂存或内存缓存) let createTime: string const pending = emitPending.get(key) if (pending) { createTime = pending.createTime } else { - const existing = await dbGet(targetDomain, name) + // 只在首次查库获取 createTime,失败就用当前时间(不阻塞后续 emit) + let existing: DBRecord | null = null + try { + existing = await dbGet(targetDomain, name) + } catch {} createTime = existing?.createTime || now } const updateTime = now const record: DBRecord = { name, data, createTime, updateTime } - // 暂存最新数据 + // 暂存最新数据(不再 delete,保证后续 emit 总能命中缓存不走 dbGet) emitPending.set(key, record) // BroadcastChannel 每次都广播(保证 watch 实时性) @@ -525,7 +571,7 @@ export function createChannelBridge(options: ChannelBridgeOptions) { emitTimers.delete(key) const latestRecord = emitPending.get(key) if (latestRecord) { - emitPending.delete(key) + // 注意:不再 delete emitPending,让下次 emit 继续命中缓存 await dbPut(targetDomain, latestRecord) } }, EMIT_THROTTLE_MS)) diff --git a/src/views/JobDetail.vue b/src/views/JobDetail.vue index 9493626..9664945 100644 --- a/src/views/JobDetail.vue +++ b/src/views/JobDetail.vue @@ -169,6 +169,15 @@ + + + @@ -187,7 +196,7 @@ diff --git a/src/views/Jobs.vue b/src/views/Jobs.vue index 494ee50..f437390 100644 --- a/src/views/Jobs.vue +++ b/src/views/Jobs.vue @@ -310,50 +310,13 @@ -
-
- -
- -

选择投递方式

- - - - - -
- -
-

直接前往官网

-

手动填写网申信息
自行记录投递进度

-
- -
- -
-
- - 智能投递助手 -
-

{{ agentCardDesc }}

-
-
- - -
-
+ @@ -394,7 +357,6 @@ import { ref, watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useStore } from 'vuex' -import { createChannelBridge } from '@/utils/channelBridge' import { JOB_TYPE_OPTIONS, formatEmploymentType, formatEducation } from '@/stores/index' import SideNav from '@/components/SideNav.vue' import AiChat from '@/components/AiChat.vue' @@ -402,6 +364,7 @@ import JobPageHeader from '@/components/JobPageHeader.vue' import JobDislikeDialog from '@/components/JobDislikeDialog.vue' import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue' import MemberDialog from '@/components/MemberDialog.vue' +import JobApplyMethodDialog from '@/components/JobApplyMethodDialog.vue' import IndustrySelector from '@/components/tools/IndustrySelector.vue' import JobCategorySelector from '@/components/tools/JobCategorySelector.vue' import RegionSelector from '@/components/tools/RegionSelector.vue' @@ -412,7 +375,6 @@ import type { JobListItem, JobListParams, FavoriteListParams, ApplyListParams, A import { getTimeEvent, setTimeEvent, timestampToLocalDateTime } from '@/utils/time' import { fetchIndustryTree, fetchJobCategoryTree } from '@/api/common' import type { IndustryItem, JobCategoryItem } from '@/api/common' -import { fetchAgentConfig, applyJob } from '@/api/agent' import { ElMessage } from 'element-plus' import JobResumeTemplate from '@/components/JobResumeTemplate.vue' import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue' @@ -901,16 +863,18 @@ function handleReport(job: JobListItem) { // 暂存当前岗位信息 pendingApplyUrl.value = job.sourceUrl pendingApplyJobId.value = job.id - // 重置选项状态 - applyMethodChoice.value = null - // 每次触发都展示弹窗 + // 展示弹窗并调用组件 open 方法初始化 showAgentRemindDialog.value = true - // 异步加载 AI 助手配置状态 - loadAgentConfigStatus() + nextTick(() => { + applyMethodDialogRef.value?.open() + }) } // ==================== 选择投递方式弹窗状态 ==================== +/** 弹窗组件 ref */ +const applyMethodDialogRef = ref | null>(null) + /** 弹窗是否显示 */ const showAgentRemindDialog = ref(false) @@ -920,147 +884,10 @@ const pendingApplyUrl = ref('') /** 暂存当前触发弹窗的岗位 ID */ const pendingApplyJobId = ref('') -/** 用户在弹窗中选择的投递方式:null=未选择, 'direct'=直接前往官网, 'agent'=智能投递助手 */ -const applyMethodChoice = ref<'direct' | 'agent' | null>(null) - -/** AI助手是否已完成配置(status === 1) */ -const agentConfigReady = ref(false) - -/** 是否正在加载 AI 助手配置 */ -const agentConfigLoading = ref(false) - /** 会员购买弹窗显示状态 */ const showMemberDialog = ref(false) -/** 用户会员状态 — 从 store 读取 */ -const memberStatus = computed(() => store.state.memberStatus) -/** 用户是否是会员(正式或试用) */ -const isMember = computed(() => !!memberStatus.value?.isMember) - -/** 智能投递助手选项卡内的描述文字 — 根据会员和配置状态动态显示 */ -const agentCardDesc = computed(() => { - if (!isMember.value) { - return '自动填写网申信息\n统一管理投递进度' - } - if (!agentConfigReady.value) { - return '完成助手设置后\n可使用智能投递' - } - return '将该岗位加入待投递列表\n在投递助手中统一处理' -}) - -/** 底部操作按钮文字 */ -const applyMethodActionText = computed(() => { - if (!applyMethodChoice.value) return '请选择投递方式' - if (applyMethodChoice.value === 'direct') return '去官网投递' - // agent 分支 - if (!isMember.value) return '开通会员使用' - if (!agentConfigReady.value) return '去完成设置' - return '加入待投递列表' -}) - -/** 底部操作按钮是否显示 icon */ -const applyMethodActionIcon = computed(() => { - if (!applyMethodChoice.value) return false - if (applyMethodChoice.value === 'direct') return false - // agent 分支都有 icon - return true -}) - -/** 底部操作按钮样式 class */ -const applyMethodActionClass = computed(() => { - if (!applyMethodChoice.value) return 'jobs-page__apply-method-action--disabled' - return 'jobs-page__apply-method-action--active' -}) - -/** 加载 AI 助手配置状态 */ -async function loadAgentConfigStatus() { - if (agentConfigLoading.value) return - agentConfigLoading.value = true - try { - const res = await fetchAgentConfig() - if (res.code === '0' && res.data && res.data.status === 1) { - agentConfigReady.value = true - } else { - agentConfigReady.value = false - } - } catch { - agentConfigReady.value = false - } finally { - agentConfigLoading.value = false - } -} - -/** 关闭选择投递方式弹窗 */ -function closeAgentRemind() { - showAgentRemindDialog.value = false - pendingApplyUrl.value = '' - pendingApplyJobId.value = '' -} - -/** 底部操作按钮点击处理 */ -async function handleApplyMethodAction() { - if (!applyMethodChoice.value) return - - if (applyMethodChoice.value === 'direct') { - // 直接前往官网 — 同时跨域 put 链接给插件 - showAgentRemindDialog.value = false - if (pendingApplyUrl.value) { - // 跨域推送投递链接到插件 - const bridge = createChannelBridge({ domain: store.state.bridgeDomain }) - try { - const existing = await bridge.get<{ linkList: string[] }>(store.state.bridgeDomain, 'offerpaiDeliveryLinkList', true) - const linkList: string[] = existing?.linkList || [] - if (!linkList.includes(pendingApplyUrl.value)) { - linkList.push(pendingApplyUrl.value) - } - await bridge.put<{ linkList: string[] }>(store.state.bridgeDomain, 'offerpaiDeliveryLinkList', { linkList }, true) - } catch (e) { - console.error('[Jobs] 跨域 put 投递链接失败', e) - } finally { - bridge.destroy() - } - window.open(pendingApplyUrl.value, '_blank') - } - pendingApplyUrl.value = '' - pendingApplyJobId.value = '' - return - } - - // 智能投递助手分支 - if (!isMember.value) { - // 非会员 — 打开会员购买弹窗 - showAgentRemindDialog.value = false - showMemberDialog.value = true - pendingApplyUrl.value = '' - pendingApplyJobId.value = '' - return - } - - if (!agentConfigReady.value) { - // 有会员但没完成 AI 助手配置 — 跳转 Agent 页面 - showAgentRemindDialog.value = false - pendingApplyUrl.value = '' - pendingApplyJobId.value = '' - router.push('/agent') - return - } - - // 有会员且配置完成 — 加入待投递列表 - try { - const res = await applyJob({ jobId: pendingApplyJobId.value, status: -1 }) - if (res.code === '0') { - ElMessage.success('已加入待投递列表') - } else { - ElMessage.error(res.msg || '加入待投递列表失败') - } - } catch { - ElMessage.error('加入待投递列表失败') - } - showAgentRemindDialog.value = false - pendingApplyUrl.value = '' - pendingApplyJobId.value = '' -} onMounted(async () => { document.addEventListener('click', closeDropdownOnClickOutside)