bug处理,插件版本同步

This commit is contained in:
2026-07-15 16:36:20 +08:00
parent 0911760e27
commit bf84da7783
12 changed files with 707 additions and 303 deletions
+1
View File
@@ -20,6 +20,7 @@
"vuex": "^4.1.0" "vuex": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.2.2",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
+28
View File
@@ -36,6 +36,9 @@ importers:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0(vue@3.5.29(typescript@5.9.3)) version: 4.1.0(vue@3.5.29(typescript@5.9.3))
devDependencies: devDependencies:
'@types/chrome':
specifier: ^0.2.2
version: 0.2.2
'@types/markdown-it': '@types/markdown-it':
specifier: ^14.1.2 specifier: ^14.1.2
version: 14.1.2 version: 14.1.2
@@ -490,9 +493,21 @@ packages:
'@sxzz/popperjs-es@2.11.8': '@sxzz/popperjs-es@2.11.8':
resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
'@types/chrome@0.2.2':
resolution: {integrity: sha512-8rSMZ4cvo2xmaSyQg0sN5yRL7oiDkntLoiHxUhfwQnv1mvnkrdoZ25SlNrKWmYKaeP50WvrfWj1pmc02+U9KKw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/filesystem@0.0.36':
resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==}
'@types/filewriter@0.0.33':
resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==}
'@types/har-format@1.2.16':
resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==}
'@types/linkify-it@5.0.0': '@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
@@ -1365,8 +1380,21 @@ snapshots:
'@sxzz/popperjs-es@2.11.8': {} '@sxzz/popperjs-es@2.11.8': {}
'@types/chrome@0.2.2':
dependencies:
'@types/filesystem': 0.0.36
'@types/har-format': 1.2.16
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/filesystem@0.0.36':
dependencies:
'@types/filewriter': 0.0.33
'@types/filewriter@0.0.33': {}
'@types/har-format@1.2.16': {}
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
'@types/lodash-es@4.17.12': '@types/lodash-es@4.17.12':
-1
View File
@@ -279,7 +279,6 @@
border-radius: 0.12rem; border-radius: 0.12rem;
max-height: calc(var(--app-height, 100vh) - 1.4rem); max-height: calc(var(--app-height, 100vh) - 1.4rem);
overflow-y: auto; overflow-y: auto;
padding: 0.24rem;
scrollbar-width: none; // Firefox scrollbar-width: none; // Firefox
-ms-overflow-style: none; // IE/Edge -ms-overflow-style: none; // IE/Edge
+7 -4
View File
@@ -548,7 +548,10 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.08rem 0.16rem; text-align: center;
width: 0.88rem;
height: 0.37rem;
line-height: 0.37rem;
border: 1.23px solid $accent; border: 1.23px solid $accent;
border-radius: 0.08rem; border-radius: 0.08rem;
background: none; background: none;
@@ -557,7 +560,6 @@
color: $accent; color: $accent;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
line-height: 0.19rem;
white-space: nowrap; white-space: nowrap;
&:hover { &:hover {
@@ -569,7 +571,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.08rem 0.16rem; width: 0.88rem;
height: 0.37rem;
line-height: 0.35rem;
background: $accent; background: $accent;
border: none; border: none;
border-radius: 0.08rem; border-radius: 0.08rem;
@@ -578,7 +582,6 @@
color: #FFFFFF; color: #FFFFFF;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
line-height: 0.19rem;
white-space: nowrap; white-space: nowrap;
&:hover { &:hover {
+27 -2
View File
@@ -155,8 +155,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useStore } from 'vuex'
import { createChannelBridge } from '@/utils/channelBridge'
import type { JobListItem } from '@/api/jobs' import type { JobListItem } from '@/api/jobs'
const store = useStore()
// ==================== Props ==================== // ==================== Props ====================
const props = defineProps<{ const props = defineProps<{
@@ -286,8 +290,29 @@ function handleContinueApply() {
emit('continueApply') emit('continueApply')
} }
/** 前往投递 */ /** 前往投递 — 跨域 put 投递链接给插件 */
function handleGoApply() { async function handleGoApply() {
const link = props.jobInfo?.sourceUrl
if (link) {
const bridge = createChannelBridge({ domain: store.state.bridgeDomain })
try {
// 先 get 拿到已有的链接列表
const existing = await bridge.get<{ linkList: string[] }>(store.state.bridgeDomain, 'offerpaiDeliveryLinkList', true)
const linkList: string[] = existing?.linkList || []
// 如果链接不在列表中则 push
if (!linkList.includes(link)) {
linkList.push(link)
}
// 跨域 put 数据给插件
await bridge.put<{ linkList: string[] }>(store.state.bridgeDomain, 'offerpaiDeliveryLinkList', { linkList }, true)
} catch (e) {
console.error('[AgentApplyProgress] 跨域 put 投递链接失败', e)
} finally {
bridge.destroy()
}
}
emit('goApply') emit('goApply')
} }
+20 -1
View File
@@ -317,6 +317,24 @@ onMounted(async () => {
sessionStorage.removeItem('wizardStep') sessionStorage.removeItem('wizardStep')
} }
// 刷新后检测插件是否在线
const needCheck = sessionStorage.getItem('wizardCheckPlug')
if (needCheck) {
sessionStorage.removeItem('wizardCheckPlug')
// 立即打开弹窗
showInstallDialog.value = true
// 等待心跳检测完成(最多等 3.5 秒)
await new Promise(resolve => setTimeout(resolve, 3500))
if (store.state.plugIsOnline) {
// 插件在线,关闭弹窗并直接进入下一步
showInstallDialog.value = false
handleNext()
} else {
// 插件离线,弹窗保持展示 + 提示
ElMessage.warning('未检测到插件安装')
}
}
if (!store.state.regions.length) store.dispatch('loadCommonData') if (!store.state.regions.length) store.dispatch('loadCommonData')
store.dispatch('loadJobIntention') store.dispatch('loadJobIntention')
await loadProfile() await loadProfile()
@@ -665,9 +683,10 @@ function handleInstallConfirm() {
showInstallDialog.value = true showInstallDialog.value = true
} }
/** 点击"已安装,继续":保存步骤 + 刷新页面 */ /** 点击"已安装,继续":保存步骤 + 标记需要检测 + 刷新页面 */
function handleInstallConfirmReload() { function handleInstallConfirmReload() {
sessionStorage.setItem('wizardStep', String(currentStep.value)) sessionStorage.setItem('wizardStep', String(currentStep.value))
sessionStorage.setItem('wizardCheckPlug', '1')
location.reload() location.reload()
} }
+5 -1
View File
@@ -159,11 +159,15 @@ function onRegionChange(codes: string[]) {
/** 保存表单数据 */ /** 保存表单数据 */
async function handleSave() { async function handleSave() {
try { try {
const intention = store.state.jobIntention
await store.dispatch('saveJobIntention', { await store.dispatch('saveJobIntention', {
categoryIds: [...selectedCategoryIds.value], categoryIds: [...selectedCategoryIds.value],
industryIds: [...selectedIndustryIds.value], industryIds: [...selectedIndustryIds.value],
regionCodes: [...selectedRegionCodes.value], regionCodes: [...selectedRegionCodes.value],
recruitCategory: JOB_TYPE_OPTIONS.find(o => o.label === selectedJobType.value)?.value ?? 0, recruitCategory: selectedJobType.value === '全部' ? null : (JOB_TYPE_OPTIONS.find(o => o.label === selectedJobType.value)?.value ?? null),
employmentType: intention.employmentType ?? null,
aiCategoryIds: intention.aiCategoryIds || [],
aiIndustryIds: intention.aiIndustryIds || [],
}) })
visible.value = false visible.value = false
} catch (e) { } catch (e) {
+32 -9
View File
@@ -171,6 +171,15 @@ export interface RootState {
/** 浏览器插件是否在线(3秒内有 BroadcastChannel 心跳则为 true */ /** 浏览器插件是否在线(3秒内有 BroadcastChannel 心跳则为 true */
plugIsOnline: boolean plugIsOnline: boolean
/** 插件当前使用版本(从通信数据 offerpieBrowserPlugUsage.version 获取) */
plugUseVersion: string
/** 插件目标版本(手动设置,用于判断是否需要更新) */
plugTargetVersion: string
/** 插件是否是最新版本(plugUseVersion === plugTargetVersion */
isLatestPlugin: boolean
} }
export default createStore<RootState>({ export default createStore<RootState>({
@@ -206,6 +215,12 @@ export default createStore<RootState>({
bridgeDomain: 'test.offerpai.com.cn', bridgeDomain: 'test.offerpai.com.cn',
/** 浏览器插件是否在线 */ /** 浏览器插件是否在线 */
plugIsOnline: false, plugIsOnline: false,
/** 插件当前使用版本 */
plugUseVersion: '',
/** 插件目标版本 */
plugTargetVersion: '0.1.0',
/** 插件是否是最新版本 */
isLatestPlugin: true,
}, },
getters: { getters: {
getAppName: (state) => state.appName, getAppName: (state) => state.appName,
@@ -284,6 +299,11 @@ export default createStore<RootState>({
SET_PLUG_IS_ONLINE(state, online: boolean) { SET_PLUG_IS_ONLINE(state, online: boolean) {
state.plugIsOnline = online state.plugIsOnline = online
}, },
SET_PLUG_USE_VERSION(state, version: string) {
state.plugUseVersion = version
// 实时计算是否最新版本
state.isLatestPlugin = version === state.plugTargetVersion
},
}, },
actions: { actions: {
updateAppName({ commit }, name: string) { updateAppName({ commit }, name: string) {
@@ -480,31 +500,35 @@ export default createStore<RootState>({
return new Date(timeStr.replace(' ', 'T')).getTime() return new Date(timeStr.replace(' ', 'T')).getTime()
} }
/** 根据 updateTime 判断是否在线并更新状态 */ /** 根据 updateTime 判断是否在线并更新状态,同时提取 version */
function checkOnline(updateTime: string) { function checkOnline(updateTime: string, version?: string) {
const elapsed = Date.now() - parseTime(updateTime) const elapsed = Date.now() - parseTime(updateTime)
if (elapsed <= 3000) { if (elapsed <= 3000) {
lastHeartbeat = Date.now() lastHeartbeat = Date.now()
if (!state.plugIsOnline) { if (!state.plugIsOnline) {
commit('SET_PLUG_IS_ONLINE', true) commit('SET_PLUG_IS_ONLINE', true)
} }
// 更新插件版本号
if (version && version !== state.plugUseVersion) {
commit('SET_PLUG_USE_VERSION', version)
}
} }
} }
// 启动时立即 get 一次,根据 updateTime 判断初始在线状态 // 启动时立即 get 一次,根据 updateTime 判断初始在线状态
bridge.get<{ usage: string }>(state.bridgeDomain, 'offerpieBrowserPlugUsage').then((data) => { bridge.get<{ usage: string; version?: string }>(state.bridgeDomain, 'offerpieBrowserPlugUsage').then((data) => {
if (data && data.updateTime) { if (data && data.updateTime) {
checkOnline(data.updateTime) checkOnline(data.updateTime, data.version)
} }
}) })
// 方式1BroadcastChannel watch — 实时收心跳 // 方式1BroadcastChannel watch — 实时收心跳
const unwatch = bridge.watch<{ usage: string }>( const unwatch = bridge.watch<{ usage: string; version?: string }>(
state.bridgeDomain, state.bridgeDomain,
'offerpieBrowserPlugUsage', 'offerpieBrowserPlugUsage',
(data) => { (data) => {
if (data && data.updateTime) { if (data && data.updateTime) {
checkOnline(data.updateTime) checkOnline(data.updateTime, data.version)
} }
} }
) )
@@ -512,10 +536,9 @@ export default createStore<RootState>({
// 方式2:定时轮询 get IndexedDB — 兜底(覆盖 BroadcastChannel 断连场景) // 方式2:定时轮询 get IndexedDB — 兜底(覆盖 BroadcastChannel 断连场景)
const pollTimer = setInterval(async () => { const pollTimer = setInterval(async () => {
try { try {
const data = await bridge.get<{ usage: string }>(state.bridgeDomain, 'offerpieBrowserPlugUsage') const data = await bridge.get<{ usage: string; version?: string }>(state.bridgeDomain, 'offerpieBrowserPlugUsage')
// console.log('[plugHeartbeat] poll get:', data)
if (data && data.updateTime) { if (data && data.updateTime) {
checkOnline(data.updateTime) checkOnline(data.updateTime, data.version)
} }
} catch { /* 忽略读取异常 */ } } catch { /* 忽略读取异常 */ }
}, 3000) }, 3000)
+278 -8
View File
@@ -21,6 +21,12 @@
* // 取消监听 * // 取消监听
* unwatch() * unwatch()
* *
* 【跨域模式 useCsl】
* 在 put/emit/get/watch 最后一个参数传 true,数据存储走 chrome.storage.local(通过 Background 中转)
* 适用于网页与插件在不同域名标签页之间的通信场景
* 示例:bridge.put<MyData>('offerpai.com', 'someData', { ... }, true)
* 注意:useCsl 模式依赖插件 Background Service Worker 在线
*
* 6. 销毁实例(页面卸载时调用): * 6. 销毁实例(页面卸载时调用):
* bridge.destroy() * bridge.destroy()
* *
@@ -71,7 +77,7 @@ interface DBRecord {
/** BroadcastChannel 消息结构 */ /** BroadcastChannel 消息结构 */
interface ChannelMessage { interface ChannelMessage {
/** 消息类型 */ /** 消息类型 */
type: 'put' | 'emit' | 'get-request' | 'get-response' type: 'put' | 'emit' | 'get-request' | 'get-response' | 'csl-put-request' | 'csl-put-response' | 'csl-get-request' | 'csl-get-response'
/** 数据名称 */ /** 数据名称 */
name: string name: string
/** 请求唯一ID(用于 get 请求-响应匹配) */ /** 请求唯一ID(用于 get 请求-响应匹配) */
@@ -82,6 +88,8 @@ interface ChannelMessage {
createTime?: string createTime?: string
/** 更新时间 */ /** 更新时间 */
updateTime?: string updateTime?: string
/** 目标域名(csl 中转消息使用) */
domain?: string
} }
/** watch 回调函数类型 */ /** watch 回调函数类型 */
@@ -105,6 +113,10 @@ const DB_VERSION = 1
const GET_TIMEOUT_MS = 60 const GET_TIMEOUT_MS = 60
/** emit 方法节流写库间隔(毫秒) */ /** emit 方法节流写库间隔(毫秒) */
const EMIT_THROTTLE_MS = 500 const EMIT_THROTTLE_MS = 500
/** chrome.storage.local 存储总 key */
const CSL_STORAGE_KEY = 'offerpie_comm_7394028156183472'
/** Background 消息类型前缀(用于 useCsl 模式) */
const CSL_MSG_PREFIX = 'OFFERPIE_CSL'
// ============ 工具函数 ============ // ============ 工具函数 ============
@@ -120,6 +132,106 @@ function uid(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
} }
// ============ chrome.storage.local 操作(useCsl 模式) ============
/** 检测 chrome.runtime.sendMessage 是否可用(插件环境) */
function isChromeRuntimeAvailable(): boolean {
return typeof chrome !== 'undefined' && !!chrome.runtime && !!chrome.runtime.sendMessage
}
/**
* 通过 Background 写入 chrome.storage.local
* 插件环境直接发 chrome.runtime.sendMessage
* 网页环境自动走 BroadcastChannel 中转给同域 Content Script 代理
*/
function cslPut(domain: string, name: string, data: object, createTime: string, updateTime: string): Promise<void> {
return new Promise((resolve, reject) => {
if (isChromeRuntimeAvailable()) {
// 插件环境:直接发给 Background
chrome.runtime.sendMessage(
{ type: `${CSL_MSG_PREFIX}_PUT`, domain, name, data, createTime, updateTime },
(response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message))
} else {
resolve()
}
}
)
} else {
// 网页环境:通过 BroadcastChannel 请求同域 Content Script 代理
const requestId = uid()
const ch = _getCslProxyChannel(domain)
const handler = (event: MessageEvent<ChannelMessage>) => {
if (event.data.type === 'csl-put-response' && event.data.id === requestId) {
resolve()
}
}
ch.addEventListener('message', handler)
ch.postMessage({ type: 'csl-put-request', id: requestId, name, data, createTime, updateTime, domain } as ChannelMessage)
// 超时兜底
setTimeout(() => {
ch.removeEventListener('message', handler)
resolve() // 即使超时也不阻塞
}, 3000)
}
})
}
/**
* 通过 Background 从 chrome.storage.local 读取数据
* 插件环境直接发 chrome.runtime.sendMessage
* 网页环境自动走 BroadcastChannel 中转给同域 Content Script 代理
*/
function cslGet(domain: string, name: string): Promise<DBRecord | null> {
return new Promise((resolve, reject) => {
if (isChromeRuntimeAvailable()) {
// 插件环境:直接发给 Background
chrome.runtime.sendMessage(
{ type: `${CSL_MSG_PREFIX}_GET`, domain, name },
(response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message))
} else {
resolve(response?.record || null)
}
}
)
} else {
// 网页环境:通过 BroadcastChannel 请求同域 Content Script 代理
const requestId = uid()
const ch = _getCslProxyChannel(domain)
const handler = (event: MessageEvent<ChannelMessage>) => {
if (event.data.type === 'csl-get-response' && event.data.id === requestId) {
ch.removeEventListener('message', handler)
const record = event.data.data && event.data.createTime && event.data.updateTime
? { name: event.data.name, data: event.data.data, createTime: event.data.createTime, updateTime: event.data.updateTime }
: null
resolve(record)
}
}
ch.addEventListener('message', handler)
ch.postMessage({ type: 'csl-get-request', id: requestId, name, domain } as ChannelMessage)
// 超时兜底
setTimeout(() => {
ch.removeEventListener('message', handler)
resolve(null)
}, 3000)
}
})
}
/** CSL 代理频道缓存 */
const _cslProxyChannels = new Map<string, BroadcastChannel>()
/** 获取 CSL 代理用的 BroadcastChannel(与主频道分开,避免消息混淆) */
function _getCslProxyChannel(domain: string): BroadcastChannel {
if (_cslProxyChannels.has(domain)) return _cslProxyChannels.get(domain)!
const ch = new BroadcastChannel(`offerpie-bridge-csl-${domain}`)
_cslProxyChannels.set(domain, ch)
return ch
}
// ============ IndexedDB 操作 ============ // ============ IndexedDB 操作 ============
/** 打开/创建 IndexedDB 数据库,按 domain 动态创建 ObjectStore */ /** 打开/创建 IndexedDB 数据库,按 domain 动态创建 ObjectStore */
@@ -262,6 +374,46 @@ export function createChannelBridge(options: ChannelBridgeOptions) {
// 初始化默认频道监听 // 初始化默认频道监听
getChannel(domain) getChannel(domain)
// --- CSL 代理监听(插件环境下,监听网页端的 CSL 中转请求) ---
if (isPlugin && isChromeRuntimeAvailable()) {
const cslProxyCh = new BroadcastChannel(`offerpie-bridge-csl-${domain}`)
cslProxyCh.onmessage = async (event: MessageEvent<ChannelMessage>) => {
const msg = event.data
if (msg.type === 'csl-put-request' && msg.id) {
// 代理写入 chrome.storage.local
const targetDomain = msg.domain || domain
await new Promise<void>((resolve) => {
chrome.runtime.sendMessage(
{ type: `${CSL_MSG_PREFIX}_PUT`, domain: targetDomain, name: msg.name, data: msg.data, createTime: msg.createTime, updateTime: msg.updateTime },
() => resolve()
)
})
cslProxyCh.postMessage({ type: 'csl-put-response', id: msg.id, name: msg.name } as ChannelMessage)
}
if (msg.type === 'csl-get-request' && msg.id) {
// 代理读取 chrome.storage.local
const targetDomain = msg.domain || domain
const response = await new Promise<any>((resolve) => {
chrome.runtime.sendMessage(
{ type: `${CSL_MSG_PREFIX}_GET`, domain: targetDomain, name: msg.name },
(res) => resolve(res)
)
})
const record = response?.record
cslProxyCh.postMessage({
type: 'csl-get-response',
id: msg.id,
name: msg.name,
data: record?.data,
createTime: record?.createTime,
updateTime: record?.updateTime,
} as ChannelMessage)
}
}
}
// --- 公开方法 --- // --- 公开方法 ---
/** /**
@@ -269,10 +421,25 @@ export function createChannelBridge(options: ChannelBridgeOptions) {
* @param targetDomain 目标域名频道 * @param targetDomain 目标域名频道
* @param name 数据名称 * @param name 数据名称
* @param data 数据本体(需有 TS 类型定义) * @param data 数据本体(需有 TS 类型定义)
* @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转)
*/ */
async function put<T extends object>(targetDomain: string, name: string, data: T): Promise<void> { async function put<T extends object>(targetDomain: string, name: string, data: T, useCsl?: boolean): Promise<void> {
const now = nowLocalDateTime() const now = nowLocalDateTime()
// 读取已有记录判断是否为新建
if (useCsl) {
// 跨域模式:通过 Background 读写 chrome.storage.local
const existing = await cslGet(targetDomain, name).catch(() => null)
const createTime = existing?.createTime || now
const updateTime = now
await cslPut(targetDomain, name, data, createTime, updateTime)
// 同时广播(同域标签页也能收到)
const ch = getChannel(targetDomain)
const msg: ChannelMessage = { type: 'put', name, data, createTime, updateTime }
ch.postMessage(msg)
return
}
// 默认模式:IndexedDB
const existing = await dbGet(targetDomain, name) const existing = await dbGet(targetDomain, name)
const createTime = existing?.createTime || now const createTime = existing?.createTime || now
const updateTime = now const updateTime = now
@@ -291,11 +458,46 @@ export function createChannelBridge(options: ChannelBridgeOptions) {
* @param targetDomain 目标域名频道 * @param targetDomain 目标域名频道
* @param name 数据名称 * @param name 数据名称
* @param data 数据本体(需有 TS 类型定义) * @param data 数据本体(需有 TS 类型定义)
* @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转)
*/ */
async function emit<T extends object>(targetDomain: string, name: string, data: T): Promise<void> { async function emit<T extends object>(targetDomain: string, name: string, data: T, useCsl?: boolean): Promise<void> {
const now = nowLocalDateTime() const now = nowLocalDateTime()
const key = `${targetDomain}::${name}` const key = `${targetDomain}::${name}`
if (useCsl) {
// 跨域模式:每次广播,节流写 chrome.storage.local
let createTime: string
const pending = emitPending.get(key)
if (pending) {
createTime = pending.createTime
} else {
const existing = await cslGet(targetDomain, name).catch(() => null)
createTime = existing?.createTime || now
}
const updateTime = now
const record: DBRecord = { name, data, createTime, updateTime }
emitPending.set(key, record)
// BroadcastChannel 每次都广播
const ch = getChannel(targetDomain)
const msg: ChannelMessage = { type: 'emit', name, data, createTime, updateTime }
ch.postMessage(msg)
// 节流写 chrome.storage.local
if (!emitTimers.has(key)) {
emitTimers.set(key, setTimeout(async () => {
emitTimers.delete(key)
const latestRecord = emitPending.get(key)
if (latestRecord) {
emitPending.delete(key)
await cslPut(targetDomain, latestRecord.name, latestRecord.data, latestRecord.createTime, latestRecord.updateTime).catch(() => {})
}
}, EMIT_THROTTLE_MS))
}
return
}
// 默认模式:IndexedDB
// 先尝试获取 createTime(从暂存或数据库) // 先尝试获取 createTime(从暂存或数据库)
let createTime: string let createTime: string
const pending = emitPending.get(key) const pending = emitPending.get(key)
@@ -333,9 +535,38 @@ export function createChannelBridge(options: ChannelBridgeOptions) {
* get - 主动获取一次数据(先 BroadcastChannel 请求,60ms 超时转 IndexedDB * get - 主动获取一次数据(先 BroadcastChannel 请求,60ms 超时转 IndexedDB
* @param targetDomain 目标域名频道 * @param targetDomain 目标域名频道
* @param name 数据名称 * @param name 数据名称
* @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转)
* @returns 数据本体(含 createTime/updateTime),无数据返回 null * @returns 数据本体(含 createTime/updateTime),无数据返回 null
*/ */
async function get<T extends object>(targetDomain: string, name: string): Promise<BridgeData<T> | null> { async function get<T extends object>(targetDomain: string, name: string, useCsl?: boolean): Promise<BridgeData<T> | null> {
if (useCsl) {
// 跨域模式:先广播请求,超时读 chrome.storage.local
const ch = getChannel(targetDomain)
const requestId = uid()
const msg: ChannelMessage = { type: 'get-request', name, id: requestId }
ch.postMessage(msg)
const result = await new Promise<BridgeData<T> | null>((resolve) => {
getResolvers.set(requestId, resolve as any)
setTimeout(() => {
if (getResolvers.has(requestId)) {
getResolvers.delete(requestId)
resolve(null)
}
}, GET_TIMEOUT_MS)
})
if (result) return result
// 超时降级读 chrome.storage.local
const record = await cslGet(targetDomain, name).catch(() => null)
if (record) {
return { ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData<T>
}
return null
}
// 默认模式:IndexedDB
const ch = getChannel(targetDomain) const ch = getChannel(targetDomain)
const requestId = uid() const requestId = uid()
@@ -370,18 +601,57 @@ export function createChannelBridge(options: ChannelBridgeOptions) {
* @param targetDomain 目标域名频道 * @param targetDomain 目标域名频道
* @param name 数据名称 * @param name 数据名称
* @param callback 数据变化回调,参数为最新数据或 null * @param callback 数据变化回调,参数为最新数据或 null
* @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转)
* @returns 取消监听的函数 * @returns 取消监听的函数
*/ */
function watch<T extends object>(targetDomain: string, name: string, callback: WatchCallback<T>): () => void { function watch<T extends object>(targetDomain: string, name: string, callback: WatchCallback<T>, useCsl?: boolean): () => void {
const key = `${targetDomain}::${name}` const key = `${targetDomain}::${name}`
// 确保频道已初始化 // 确保频道已初始化
getChannel(targetDomain) getChannel(targetDomain)
// 注册回调 // 注册回调BroadcastChannel 广播也能触发,不管 useCsl 与否)
if (!watchers.has(key)) watchers.set(key, new Set()) if (!watchers.has(key)) watchers.set(key, new Set())
watchers.get(key)!.add(callback) watchers.get(key)!.add(callback)
// 初始读取 IndexedDB if (useCsl) {
// 跨域模式:初始读 chrome.storage.local
cslGet(targetDomain, name).then((record) => {
if (record) {
callback({ ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData<T>)
} else {
callback(null)
}
}).catch(() => callback(null))
// 监听 chrome.storage.onChanged 事件
const storageListener = (changes: { [key: string]: chrome.storage.StorageChange }, areaName: string) => {
if (areaName !== 'local' || !changes[CSL_STORAGE_KEY]) return
const newValue = changes[CSL_STORAGE_KEY].newValue as Array<{ domain: string; value: DBRecord }> | undefined
if (!newValue) return
const item = newValue.find(i => i.domain === targetDomain && i.value?.name === name)
if (item && item.value) {
callback({ ...item.value.data, createTime: item.value.createTime, updateTime: item.value.updateTime } as BridgeData<T>)
}
}
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.onChanged) {
chrome.storage.onChanged.addListener(storageListener)
}
// 返回取消监听函数
return () => {
const set = watchers.get(key)
if (set) {
set.delete(callback)
if (set.size === 0) watchers.delete(key)
}
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.onChanged) {
chrome.storage.onChanged.removeListener(storageListener)
}
}
}
// 默认模式:初始读取 IndexedDB
dbGet(targetDomain, name).then((record) => { dbGet(targetDomain, name).then((record) => {
if (record) { if (record) {
callback({ ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData<T>) callback({ ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData<T>)
+12 -7
View File
@@ -7,18 +7,18 @@
<!-- 主内容区域 --> <!-- 主内容区域 -->
<div class="agent-page__content" v-loading="pageLoading" element-loading-text="加载中..."> <div class="agent-page__content" v-loading="pageLoading" element-loading-text="加载中...">
<!-- 准备向导 第1步到第4步--> <!-- 准备向导 第1步到第4步-->
<!-- <AgentSetupWizard <AgentSetupWizard
v-if="!pageLoading && showSetupWizard" v-if="!pageLoading && showSetupWizard"
:initial-config="agentConfig" :initial-config="agentConfig"
@complete="handleSetupComplete" @complete="handleSetupComplete"
@launch="handleLaunch" @launch="handleLaunch"
/> -->
<AgentSetupWizard
v-if="true"
:initial-config="agentConfig"
@complete="handleSetupComplete"
@launch="handleLaunch"
/> />
<!-- <AgentSetupWizard-->
<!-- v-if="true"-->
<!-- :initial-config="agentConfig"-->
<!-- @complete="handleSetupComplete"-->
<!-- @launch="handleLaunch"-->
<!-- />-->
<!-- 求职助手正式内容区域 --> <!-- 求职助手正式内容区域 -->
@@ -418,6 +418,11 @@ onMounted(async () => {
loadProgressFromStorage() loadProgressFromStorage()
loadCompletedFromStorage() loadCompletedFromStorage()
await loadAgentConfig() await loadAgentConfig()
// 检查插件版本是否最新
if (store.state.plugIsOnline && !store.state.isLatestPlugin) {
ElMessage.warning(`当前安装的浏览器插件不是最新${store.state.plugTargetVersion}版,请去智能投递里下载最新版安装`)
}
}) })
/** 查询求职助手配置 */ /** 查询求职助手配置 */
+16 -1
View File
@@ -366,6 +366,7 @@
import { ref, watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue' import { ref, watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import { createChannelBridge } from '@/utils/channelBridge'
import { JOB_TYPE_OPTIONS, formatEmploymentType, formatEducation } from '@/stores/index' import { JOB_TYPE_OPTIONS, formatEmploymentType, formatEducation } from '@/stores/index'
import SideNav from '@/components/SideNav.vue' import SideNav from '@/components/SideNav.vue'
import AiChat from '@/components/AiChat.vue' import AiChat from '@/components/AiChat.vue'
@@ -903,9 +904,23 @@ async function handleApplyMethodAction() {
if (!applyMethodChoice.value) return if (!applyMethodChoice.value) return
if (applyMethodChoice.value === 'direct') { if (applyMethodChoice.value === 'direct') {
// 直接前往官网 // 直接前往官网 — 同时跨域 put 链接给插件
showAgentRemindDialog.value = false showAgentRemindDialog.value = false
if (pendingApplyUrl.value) { 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') window.open(pendingApplyUrl.value, '_blank')
} }
pendingApplyUrl.value = '' pendingApplyUrl.value = ''
+12
View File
@@ -261,6 +261,7 @@ import { useRouter, useRoute } from 'vue-router'
import { sendSmsCode, smsLogin } from '@/api/auth' import { sendSmsCode, smsLogin } from '@/api/auth'
import { fetchProfile, syncProfileFromResume } from '@/api/profile' import { fetchProfile, syncProfileFromResume } from '@/api/profile'
import { uploadResume } from '@/utils/aiRequest' import { uploadResume } from '@/utils/aiRequest'
import { fetchJobIntention, saveJobIntention } from '@/api/jobs'
import AgreementPreviewDialog from '@/components/tools/AgreementPreviewDialog.vue' import AgreementPreviewDialog from '@/components/tools/AgreementPreviewDialog.vue'
// 左侧动画面板图片资源 // 左侧动画面板图片资源
@@ -606,6 +607,17 @@ async function startUpload(file: File) {
if (res.code === 0 && res.data?.resumeId) { if (res.code === 0 && res.data?.resumeId) {
// 上传成功,同步个人资料 // 上传成功,同步个人资料
await syncProfileFromResume(String(res.data.resumeId)) await syncProfileFromResume(String(res.data.resumeId))
// 等待1秒后同步AI推荐岗位到求职意向
await new Promise(resolve => setTimeout(resolve, 1000))
const intentionRes = await fetchJobIntention()
if (intentionRes.code === '0' && intentionRes.data) {
const intention = intentionRes.data
// 把 aiCategoryIds 复制到 categoryIds 后保存
if (intention.aiCategoryIds && intention.aiCategoryIds.length) {
intention.categoryIds = intention.aiCategoryIds.map(Number)
await saveJobIntention(intention)
}
}
// 标记接口已返回 // 标记接口已返回
uploadDone = true uploadDone = true
// 完成所有步骤的动画后会自动过渡到 success 状态 // 完成所有步骤的动画后会自动过渡到 success 状态