通用模式兼容摩卡和飞书,添加通信工具组件,添加未开通会员面板
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.4 KiB |
Generated
+136
-15
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"name": "offferpie_google_extension",
|
||||
"version": "1.0.0",
|
||||
"name": "offerpie-extension",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "offferpie_google_extension",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"name": "offerpie-extension",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@arco-design/web-react": "^2.66.14",
|
||||
"plasmo": "^0.90.5",
|
||||
@@ -15,10 +14,11 @@
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.40",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^6.0.3"
|
||||
"@types/chrome": "^0.0.318",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"rimraf": "^6.1.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
@@ -5568,9 +5568,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chrome": {
|
||||
"version": "0.1.40",
|
||||
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.40.tgz",
|
||||
"integrity": "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA==",
|
||||
"version": "0.0.318",
|
||||
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.318.tgz",
|
||||
"integrity": "sha512-rrtyYQ1t+g7EyG0FejE+UXQBjSGUHGh0RIdXwUT/laPo9T724NOIgXA94ns6ewmNauwijYa5ck3+dBxWnHcynQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5915,6 +5915,16 @@
|
||||
"integrity": "sha512-iCvCkGFskbaYtfQ0a3GmcQCHl/Sv1GufXFGuUQ+FE+WJa7A/espLOuFIn09B944V8/ImPj71T4+rTASxO2PAuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/base-x": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz",
|
||||
@@ -5980,6 +5990,19 @@
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
@@ -7019,6 +7042,24 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.2.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
@@ -8138,6 +8179,22 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
@@ -8147,6 +8204,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mnemonic-id": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/mnemonic-id/-/mnemonic-id-3.2.7.tgz",
|
||||
@@ -8430,6 +8497,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -8478,6 +8552,33 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
@@ -9011,6 +9112,26 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz",
|
||||
"integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"glob": "^13.0.3",
|
||||
"package-json-from-dist": "^1.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "dist/esm/bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "3.30.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz",
|
||||
@@ -9765,9 +9886,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
+4
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "offerpie-extension",
|
||||
"displayName": "OfferPie Assistant",
|
||||
"displayName": "Offer派智能投递助手",
|
||||
"version": "0.1.0",
|
||||
"description": "智能求职助手 - 自动化投递简历",
|
||||
"description": "offer派平台旗下网申投递助手 - 自动化投递简历\n官网:https://offerpai.com.cn",
|
||||
"scripts": {
|
||||
"dev": "plasmo dev",
|
||||
"build": "plasmo build",
|
||||
"build": "rimraf .plasmo/cache build && plasmo build && node scripts/fix-manifest.js",
|
||||
"package": "plasmo package"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -18,6 +18,7 @@
|
||||
"@types/chrome": "^0.0.318",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"rimraf": "^6.1.3",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"manifest": {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 修正 Plasmo build 后 manifest.json 中 web_accessible_resources 引用不存在文件的问题
|
||||
* 逻辑:遍历 manifest 中所有引用的资源文件名,如果在 build 目录中不存在,
|
||||
* 则尝试用「同前缀 + 同后缀」的实际文件替换,找不到则移除该条目
|
||||
*/
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const buildDir = path.resolve(__dirname, "../build/chrome-mv3-prod")
|
||||
const manifestPath = path.join(buildDir, "manifest.json")
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
console.error("[fix-manifest] manifest.json 不存在,跳过")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"))
|
||||
|
||||
// 递归获取 build 目录下所有文件的相对路径
|
||||
function getAllFiles(dir, baseDir, result = []) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
getAllFiles(fullPath, baseDir, result)
|
||||
} else {
|
||||
result.push(path.relative(baseDir, fullPath).replace(/\\/g, "/"))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const actualFiles = getAllFiles(buildDir, buildDir)
|
||||
|
||||
if (!manifest.web_accessible_resources) {
|
||||
console.log("[fix-manifest] 无 web_accessible_resources,无需修正")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let modified = false
|
||||
|
||||
manifest.web_accessible_resources.forEach((entry) => {
|
||||
if (!entry.resources) return
|
||||
|
||||
const newResources = []
|
||||
for (const resource of entry.resources) {
|
||||
if (actualFiles.includes(resource)) {
|
||||
// 文件存在,保留
|
||||
newResources.push(resource)
|
||||
} else {
|
||||
// 文件不存在,尝试匹配:取文件名前缀(第一个.之前)和后缀(最后一个.之后)
|
||||
const fileName = resource.split("/").pop()
|
||||
const dirPart = resource.includes("/") ? resource.substring(0, resource.lastIndexOf("/") + 1) : ""
|
||||
const parts = fileName.split(".")
|
||||
const prefix = parts[0] // 如 sidebar
|
||||
const ext = parts[parts.length - 1] // 如 css
|
||||
|
||||
// 在实际文件中找同前缀同后缀的(考虑目录层级)
|
||||
const match = actualFiles.find((f) => {
|
||||
const fName = f.split("/").pop()
|
||||
const fParts = fName.split(".")
|
||||
return fParts[0] === prefix && fParts[fParts.length - 1] === ext
|
||||
})
|
||||
|
||||
if (match) {
|
||||
// 检查是否已经在 content_scripts.css 或其他地方被引用了(避免重复)
|
||||
console.log(`[fix-manifest] 替换: ${resource} -> ${match}`)
|
||||
newResources.push(match)
|
||||
modified = true
|
||||
} else {
|
||||
console.log(`[fix-manifest] 移除不存在的资源: ${resource}`)
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.resources = newResources
|
||||
})
|
||||
|
||||
if (modified) {
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest), "utf-8")
|
||||
console.log("[fix-manifest] manifest.json 已修正完成")
|
||||
} else {
|
||||
console.log("[fix-manifest] 所有资源文件均存在,无需修正")
|
||||
}
|
||||
@@ -14,3 +14,24 @@ export function findJobBySourceUrl(sourceUrl: string) {
|
||||
export function checkLogin() {
|
||||
return http.get('/public/checkLogin')
|
||||
}
|
||||
|
||||
// ============ 会员相关接口 ============
|
||||
|
||||
/** 会员状态响应数据 */
|
||||
export interface MemberStatusData {
|
||||
/** 是否是会员 */
|
||||
isMember: boolean
|
||||
/** 会员类型 1=正式 2=试用 */
|
||||
memberType?: number
|
||||
/** 到期时间(毫秒时间戳) */
|
||||
expireTime?: number
|
||||
/** 首次开通时间(毫秒时间戳) */
|
||||
createTime?: number
|
||||
/** 最近续费时间(毫秒时间戳) */
|
||||
updateTime?: number
|
||||
}
|
||||
|
||||
/** 查询会员状态 */
|
||||
export function getMemberStatus() {
|
||||
return http.get<MemberStatusData>('/member/status')
|
||||
}
|
||||
|
||||
@@ -30,6 +30,40 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// ============ ChannelBridge CSL 模式:chrome.storage.local 读写 ============
|
||||
const CSL_STORAGE_KEY = 'offerpie_comm_7394028156183472'
|
||||
|
||||
// CSL 写入
|
||||
if (message.type === 'OFFERPIE_CSL_PUT') {
|
||||
const { domain, name, data, createTime, updateTime } = message
|
||||
chrome.storage.local.get(CSL_STORAGE_KEY).then((result) => {
|
||||
const list: Array<{ domain: string; value: { name: string; data: object; createTime: string; updateTime: string } }> = result[CSL_STORAGE_KEY] || []
|
||||
// 查找是否已有该条目
|
||||
const idx = list.findIndex(i => i.domain === domain && i.value?.name === name)
|
||||
const record = { name, data, createTime, updateTime }
|
||||
if (idx >= 0) {
|
||||
list[idx].value = record
|
||||
} else {
|
||||
list.push({ domain, value: record })
|
||||
}
|
||||
chrome.storage.local.set({ [CSL_STORAGE_KEY]: list }).then(() => {
|
||||
sendResponse({ success: true })
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// CSL 读取
|
||||
if (message.type === 'OFFERPIE_CSL_GET') {
|
||||
const { domain, name } = message
|
||||
chrome.storage.local.get(CSL_STORAGE_KEY).then((result) => {
|
||||
const list: Array<{ domain: string; value: { name: string; data: object; createTime: string; updateTime: string } }> = result[CSL_STORAGE_KEY] || []
|
||||
const item = list.find(i => i.domain === domain && i.value?.name === name)
|
||||
sendResponse({ record: item?.value || null })
|
||||
})
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
/** 插件安装事件 */
|
||||
|
||||
@@ -9,7 +9,7 @@ $radius-sm: 12px;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 380px;
|
||||
min-height: 470px;
|
||||
//min-height: 470px;
|
||||
max-height: calc(100vh - 20px);
|
||||
background: #fff;
|
||||
border-radius: $radius-lg;
|
||||
@@ -24,26 +24,27 @@ $radius-sm: 12px;
|
||||
|
||||
/* 收起状态 */
|
||||
&--collapsed {
|
||||
width: auto;
|
||||
width: 380px;
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border-radius: 50%;
|
||||
border-radius: $radius-lg;
|
||||
}
|
||||
}
|
||||
|
||||
/* 收起状态的 logo 圆圈 */
|
||||
.op-collapsed-logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
|
||||
/* 收起状态:横条样式(logo + 文字 + 向下箭头) */
|
||||
.op-collapsed-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: grab;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
@@ -51,6 +52,27 @@ $radius-sm: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.op-collapsed-text {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.op-collapsed-arrow {
|
||||
flex-shrink: 0;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 拖拽提示气泡 */
|
||||
.op-drag-tooltip {
|
||||
position: absolute;
|
||||
@@ -70,7 +92,7 @@ $radius-sm: 12px;
|
||||
|
||||
/* 内层容器:承载 padding 和滚动 */
|
||||
.op-inner {
|
||||
padding: 20px 20px 24px;
|
||||
padding: 20px 20px 10px 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -95,6 +117,7 @@ $radius-sm: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
cursor: grab;
|
||||
|
||||
&-left {
|
||||
display: flex;
|
||||
@@ -189,19 +212,62 @@ $radius-sm: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
margin-bottom: 6px;
|
||||
transition: opacity 0.2s;
|
||||
transition: opacity 0.2s, background 0.2s;
|
||||
|
||||
&:hover { opacity: 0.85; }
|
||||
&--filling {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) { opacity: 0.85; }
|
||||
&:disabled { opacity: 0.7; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
/* 填写完成状态:重置按钮 + 完成提示 */
|
||||
.op-fill-done-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.op-reset-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f0fafa;
|
||||
border: 1px solid #e0f0f0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #ddf5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.op-fill-done-label {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #6dd4a0;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.op-credits {
|
||||
&-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 20px;
|
||||
padding: 0 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&-text {
|
||||
@@ -330,39 +396,6 @@ $radius-sm: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 未登录提示区域 */
|
||||
.op-login-prompt {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.op-login-text {
|
||||
font-size: 15px;
|
||||
color: #555;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.op-login-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover { opacity: 0.85; }
|
||||
}
|
||||
|
||||
|
||||
/* ============ 字段列表区域(待填写 / 已填写) ============ */
|
||||
.op-field-section {
|
||||
margin-top: 16px;
|
||||
@@ -481,6 +514,15 @@ $radius-sm: 12px;
|
||||
}
|
||||
|
||||
|
||||
/* ============ 版本号 ============ */
|
||||
.op-version {
|
||||
text-align: right;
|
||||
font-size: 10px;
|
||||
color: #bbb;
|
||||
padding: 4px 10px 6px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ============ 助手已记住的表单 弹窗 ============ */
|
||||
.op-cache-modal-mask {
|
||||
position: fixed;
|
||||
@@ -674,3 +716,66 @@ $radius-sm: 12px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 非会员状态 ============ */
|
||||
.op-nonmember {
|
||||
padding: 0 20px 20px;
|
||||
|
||||
&-divider {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
border-top: 0.5px solid rgba(100, 116, 139, 0.4);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 19px;
|
||||
color: #132034;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&-desc {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 15px;
|
||||
color: #64748B;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background: #52CAD1;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
color: #FFFFFF;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
}
|
||||
|
||||
&-refresh {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
text-decoration-line: underline;
|
||||
color: #64748B;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+279
-101
@@ -6,15 +6,27 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { getCookieValue } from "~utils/cookie"
|
||||
import { get as storageGet, set as storageSet, remove as storageRemove } from "~utils/storage"
|
||||
import { getCustomizeResume } from "~api/aiApi"
|
||||
import { getMemberStatus } from "~api/dataApi"
|
||||
import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon"
|
||||
import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen"
|
||||
import { scanPageFields, extractNonResumeFields, printFieldStats } from "~lib/fillStats"
|
||||
import type { TitleStat } from "~lib/fillStats"
|
||||
import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types"
|
||||
import { createChannelBridge } from "~lib/channelBridge"
|
||||
import logoImg from "data-base64:~/../assets/logo-offerpai.png"
|
||||
import { config as appConfig } from "~config"
|
||||
import "./SidebarPanel.scss"
|
||||
|
||||
/** 投递链接列表数据类型(跨域通信用) */
|
||||
interface OfferpaieDeliveryLinkListData {
|
||||
linkList: string[]
|
||||
}
|
||||
|
||||
/** 创建通信桥接实例(用于投递链接列表的跨域读写) */
|
||||
const panelBridge = createChannelBridge({ domain: appConfig.bridgeDomain, isPlugin: true })
|
||||
|
||||
/** 侧边栏面板的 Props */
|
||||
interface SidebarPanelProps {
|
||||
/** 当前标签页的来源链接 */
|
||||
@@ -28,6 +40,8 @@ interface SidebarPanelProps {
|
||||
export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) {
|
||||
/** 是否已登录(有 Token) */
|
||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null)
|
||||
/** 是否为会员(null=未检测,true=会员,false=非会员) */
|
||||
const [isMember, setIsMember] = useState<boolean | null>(null)
|
||||
/** 是否正在执行自动填写 */
|
||||
const [filling, setFilling] = useState(false)
|
||||
/** 页面语言类型:中文 / 英文 */
|
||||
@@ -43,7 +57,7 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
/** 页面字段扫描统计结果(用于面板底部展示已填/待填字段列表) */
|
||||
const [fieldStats, setFieldStats] = useState<TitleStat[]>([])
|
||||
/** 顶部状态文字 */
|
||||
const [headerStatus, setHeaderStatus] = useState<string>("")
|
||||
const [headerStatus, setHeaderStatus] = useState<string>("准备中, 请登录到投递表单页~")
|
||||
|
||||
/** 是否启用字段更新时自动滚动到该字段,模拟跟踪(常量开关,方便调试关闭) */
|
||||
const ENABLE_AUTO_SCROLL_TO_UPDATED = true
|
||||
@@ -139,11 +153,12 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
return () => { stopScanLoop() }
|
||||
}, [])
|
||||
|
||||
/** 页面加载时检测是否有足够的输入框,显示"准备就绪" */
|
||||
/** 页面加载 & SPA 路由变化时检测是否有足够的输入框,显示"准备就绪" */
|
||||
useEffect(() => {
|
||||
const hostDoc = document.getRootNode() === document ? document : (document.getRootNode() as ShadowRoot).ownerDocument
|
||||
|
||||
/** 检测页面是否有足够的表单输入框 */
|
||||
const checkInputs = () => {
|
||||
// 注意:需要查询宿主页面的 DOM,而不是 Shadow DOM 内部
|
||||
const hostDoc = document.getRootNode() === document ? document : (document.getRootNode() as ShadowRoot).ownerDocument
|
||||
const inputs = hostDoc.querySelectorAll("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='file']), textarea")
|
||||
if (inputs.length > 2) {
|
||||
setHeaderStatus("准备就绪,请点击开始填写")
|
||||
@@ -151,16 +166,35 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
}
|
||||
return false
|
||||
}
|
||||
// 立即检测一次
|
||||
if (checkInputs()) return
|
||||
// 页面可能还在加载,延迟重试几次
|
||||
const timer1 = setTimeout(checkInputs, 500)
|
||||
const timer2 = setTimeout(checkInputs, 1500)
|
||||
const timer3 = setTimeout(checkInputs, 3000)
|
||||
|
||||
/** 路由变化后延迟多次重试检测(等待新页面 DOM 渲染) */
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
const scheduleCheck = () => {
|
||||
setHeaderStatus("准备中, 请登录到投递表单页~")
|
||||
if (checkInputs()) return
|
||||
timers.push(setTimeout(checkInputs, 500))
|
||||
timers.push(setTimeout(checkInputs, 1500))
|
||||
timers.push(setTimeout(checkInputs, 3000))
|
||||
timers.push(setTimeout(checkInputs, 5000))
|
||||
}
|
||||
|
||||
// 初次挂载时检测
|
||||
scheduleCheck()
|
||||
|
||||
// URL 轮询:每秒检查一次 URL 是否变化(兼容所有 SPA 路由方式)
|
||||
let lastUrl = window.location.href
|
||||
const urlPollInterval = setInterval(() => {
|
||||
const currentUrl = window.location.href
|
||||
if (currentUrl !== lastUrl) {
|
||||
lastUrl = currentUrl
|
||||
console.log("[OfferPie] 检测到 URL 变化:", currentUrl)
|
||||
scheduleCheck()
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer1)
|
||||
clearTimeout(timer2)
|
||||
clearTimeout(timer3)
|
||||
clearInterval(urlPollInterval)
|
||||
timers.forEach(clearTimeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -203,14 +237,32 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
setLastUpdatedFieldEl(null)
|
||||
}, [lastUpdatedFieldEl])
|
||||
|
||||
/** 页面加载时检查 Token,有岗位信息则查询定制简历 */
|
||||
/** 页面加载时检查 Token,有岗位信息则查询定制简历,并检查会员状态 */
|
||||
useEffect(() => {
|
||||
getCookieValue("Token").then((token) => {
|
||||
console.log("[OfferPie] SidebarPanel 获取到的 Token:", token)
|
||||
setIsLoggedIn(!!token)
|
||||
|
||||
// 有 Token 且有岗位 ID 时,查询定制简历
|
||||
if (token && jobInfo?.id) {
|
||||
if (!token) {
|
||||
setHeaderStatus("请登录后继续使用")
|
||||
return
|
||||
}
|
||||
|
||||
// 查询会员状态
|
||||
getMemberStatus().then((memberData) => {
|
||||
console.log("[OfferPie] 会员状态:", memberData)
|
||||
setIsMember(memberData.isMember)
|
||||
if (!memberData.isMember) {
|
||||
setHeaderStatus("请开通会员后继续使用")
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn("[OfferPie] 查询会员状态失败:", err)
|
||||
setIsMember(false)
|
||||
setHeaderStatus("请开通会员后继续使用")
|
||||
})
|
||||
|
||||
// 有岗位 ID 时,查询定制简历
|
||||
if (jobInfo?.id) {
|
||||
getCustomizeResume(jobInfo.id).then((resumeRes: any) => {
|
||||
console.log("[OfferPie] 定制简历数据:", resumeRes)
|
||||
// 接口返回的 resume 字段映射为 main
|
||||
@@ -355,6 +407,26 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
* 8. matchMainFields 匹配非经历区域字段(姓名/手机/邮箱等)并填充
|
||||
* 9. 收集剩余未匹配的空白字段(unmatchedFields),后续交给 AI 生成答案
|
||||
*
|
||||
/** 重置按钮:恢复到开始填写状态,重新检测页面 */
|
||||
const handleReset = () => {
|
||||
setFillCompleted(false)
|
||||
// 重新检测页面输入框状态
|
||||
setHeaderStatus("准备中, 请登录到投递表单页~")
|
||||
const hostDoc = document.getRootNode() === document ? document : (document.getRootNode() as ShadowRoot).ownerDocument
|
||||
const inputs = hostDoc.querySelectorAll("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='file']), textarea")
|
||||
if (inputs.length > 2) {
|
||||
setHeaderStatus("准备就绪,请点击开始填写")
|
||||
} else {
|
||||
// 延迟重试
|
||||
setTimeout(() => {
|
||||
const inputs2 = hostDoc.querySelectorAll("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='file']), textarea")
|
||||
if (inputs2.length > 2) setHeaderStatus("准备就绪,请点击开始填写")
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动填写主流程
|
||||
* 填写完成后更新组件状态,供后续"保存用户修改"和"AI补填"使用
|
||||
*/
|
||||
const handleAutoFill = async () => {
|
||||
@@ -404,14 +476,12 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
* 模拟提交:通过 fillStats 扫描页面字段,过滤出 B2 阶段字段数据,更新到缓存
|
||||
* 【注意】弹窗打开时不执行,避免覆盖用户正在编辑的数据
|
||||
*/
|
||||
const handleSaveUserInput = () => {
|
||||
const handleSaveUserInput = async () => {
|
||||
// 弹窗打开时直接跳过,不更新缓存
|
||||
if (showCacheModal) return
|
||||
try {
|
||||
const cached = localStorage.getItem("offerpie_unfilled_form")
|
||||
if (!cached) { alert("未找到缓存数据,请先执行自动填写"); return }
|
||||
|
||||
const cacheData = JSON.parse(cached)
|
||||
const cacheData = await storageGet<any>("offerpie_unfilled_form")
|
||||
if (!cacheData) { return }
|
||||
|
||||
// 检测当前网站模式
|
||||
const currentHost = window.location.hostname
|
||||
@@ -424,81 +494,72 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
const nonResumeData = extractNonResumeFields(titleStats)
|
||||
|
||||
// 更新缓存中的 unfilledFormData
|
||||
// 策略:新数据中有的字段用新值替换,新数据中没有但旧数据中有的字段保留
|
||||
const oldSections = cacheData.unfilledFormData as any[] || []
|
||||
for (const newSec of nonResumeData) {
|
||||
const oldSec = oldSections.find((s: any) => s.title === newSec.title)
|
||||
if (!oldSec) {
|
||||
const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title)
|
||||
if (oldSecIdx < 0) {
|
||||
oldSections.push(newSec)
|
||||
continue
|
||||
}
|
||||
|
||||
if (newSec.isExperience) {
|
||||
const oldSegments = oldSec.formItems as any[][]
|
||||
const newSegments = newSec.formItems as any[][]
|
||||
for (let sIdx = 0; sIdx < newSegments.length; sIdx++) {
|
||||
if (sIdx >= oldSegments.length) {
|
||||
oldSegments.push(newSegments[sIdx])
|
||||
continue
|
||||
}
|
||||
const oldFields = oldSegments[sIdx]
|
||||
const newFields = newSegments[sIdx]
|
||||
for (const newField of newFields) {
|
||||
const oldField = oldFields.find((f: any) => f.label === newField.label)
|
||||
if (oldField) {
|
||||
if (newField.value) oldField.value = newField.value
|
||||
} else {
|
||||
const oldSec = oldSections[oldSecIdx]
|
||||
if (newSec.isExperience && oldSec.isExperience) {
|
||||
// 经历类型:按段合并,保留旧字段
|
||||
const oldSegments = oldSec.formItems as any[][]
|
||||
const newSegments = newSec.formItems as any[][]
|
||||
for (let sIdx = 0; sIdx < newSegments.length; sIdx++) {
|
||||
if (sIdx >= oldSegments.length) {
|
||||
oldSegments.push(newSegments[sIdx])
|
||||
} else {
|
||||
oldFields.push(newField)
|
||||
// 新字段更新值,旧字段中有但新字段中没有的保留
|
||||
const oldFields = oldSegments[sIdx]
|
||||
const newFields = newSegments[sIdx]
|
||||
for (const nf of newFields) {
|
||||
const of_ = oldFields.find((f: any) => f.label === nf.label)
|
||||
if (of_) {
|
||||
if (nf.value) of_.value = nf.value
|
||||
} else {
|
||||
oldFields.push(nf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const oldFields = oldSec.formItems as any[]
|
||||
const newFields = newSec.formItems as any[]
|
||||
for (const newField of newFields) {
|
||||
const oldField = oldFields.find((f: any) => f.label === newField.label)
|
||||
if (oldField) {
|
||||
if (newField.value) oldField.value = newField.value
|
||||
} else {
|
||||
oldFields.push(newField)
|
||||
} else if (!newSec.isExperience && !oldSec.isExperience) {
|
||||
// 非经历类型:按字段合并,保留旧字段
|
||||
const oldFields = oldSec.formItems as any[]
|
||||
const newFields = newSec.formItems as any[]
|
||||
for (const nf of newFields) {
|
||||
const of_ = oldFields.find((f: any) => f.label === nf.label)
|
||||
if (of_) {
|
||||
if (nf.value) of_.value = nf.value
|
||||
} else {
|
||||
oldFields.push(nf)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 类型变化,直接替换
|
||||
oldSections[oldSecIdx] = newSec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cacheData.unfilledFormData = oldSections
|
||||
cacheData.timestamp = Date.now()
|
||||
localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData))
|
||||
await storageSet("offerpie_unfilled_form", cacheData)
|
||||
|
||||
// 打印所有字段数据
|
||||
// console.log("===== OfferPie: handleSaveUserInput 全量字段数据 =====")
|
||||
printFieldStats(titleStats)
|
||||
|
||||
// 打印 B2 字段数据
|
||||
// console.log("===== OfferPie: handleSaveUserInput B2阶段字段数据 =====")
|
||||
// console.log(JSON.stringify(nonResumeData, null, 2))
|
||||
// console.log("===== OfferPie: B2字段数据打印完毕 =====")
|
||||
|
||||
const updatedCount = nonResumeData.reduce((sum, sec) => {
|
||||
if (sec.isExperience) {
|
||||
return sum + (sec.formItems as any[][]).reduce((s, seg) => s + seg.filter((f: any) => f.value).length, 0)
|
||||
}
|
||||
return sum + (sec.formItems as any[]).filter((f: any) => f.value).length
|
||||
}, 0)
|
||||
// console.log(`已保存!B2字段中有值的共 ${updatedCount} 个`)
|
||||
} catch (e) {
|
||||
console.error("[OfferPie] 模拟提交失败:", e)
|
||||
console.log("保存失败,请查看控制台")
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开"助手已记住的表单"弹窗,从 localStorage 加载数据 */
|
||||
const openCacheModal = () => {
|
||||
/** 打开"助手已记住的表单"弹窗,从 chrome.storage 加载数据 */
|
||||
const openCacheModal = async () => {
|
||||
try {
|
||||
const cached = localStorage.getItem("offerpie_unfilled_form")
|
||||
if (!cached) {
|
||||
const cacheData = await storageGet<any>("offerpie_unfilled_form")
|
||||
if (!cacheData) {
|
||||
setCacheModalData([])
|
||||
} else {
|
||||
const cacheData = JSON.parse(cached)
|
||||
setCacheModalData(cacheData.unfilledFormData || [])
|
||||
}
|
||||
} catch {
|
||||
@@ -528,14 +589,13 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
setCacheModalData(updated)
|
||||
}
|
||||
|
||||
/** 弹窗内保存数据到 localStorage */
|
||||
const saveCacheModalData = () => {
|
||||
/** 弹窗内保存数据到 chrome.storage */
|
||||
const saveCacheModalData = async () => {
|
||||
try {
|
||||
const cached = localStorage.getItem("offerpie_unfilled_form")
|
||||
const cacheData = cached ? JSON.parse(cached) : {}
|
||||
const cacheData = await storageGet<any>("offerpie_unfilled_form") || {}
|
||||
cacheData.unfilledFormData = cacheModalData || []
|
||||
cacheData.timestamp = Date.now()
|
||||
localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData))
|
||||
await storageSet("offerpie_unfilled_form", cacheData)
|
||||
alert("已保存表单数据")
|
||||
} catch (e) {
|
||||
console.error("[OfferPie] 保存缓存数据失败:", e)
|
||||
@@ -544,10 +604,12 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
}
|
||||
|
||||
/** 弹窗内清空缓存数据(带确认) */
|
||||
const clearCacheFromModal = () => {
|
||||
const clearCacheFromModal = async () => {
|
||||
const confirmed = window.confirm("是否清空已记录的表单数据?清空后下次填简历那些数据需要您一个个手动填。")
|
||||
if (!confirmed) return
|
||||
localStorage.removeItem("offerpie_unfilled_form")
|
||||
// 先停止循环扫描,防止 handleSaveUserInput 又把数据写回去
|
||||
stopScanLoop()
|
||||
await storageRemove("offerpie_unfilled_form")
|
||||
setCacheModalData([])
|
||||
alert("已清空缓存数据")
|
||||
console.log("[OfferPie] 已清空 offerpie_unfilled_form 缓存")
|
||||
@@ -555,11 +617,50 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
|
||||
// ============ 拖拽逻辑 ============
|
||||
|
||||
/** 鼠标按下:启动长按计时 */
|
||||
/** 即时拖拽:收起状态横条 / 展开状态顶部 header,按住即可拖动(无需长按) */
|
||||
const handleInstantDragDown = useCallback((e: React.MouseEvent) => {
|
||||
// 如果点击的是箭头按钮,不启动拖拽
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest("button, .op-collapsed-arrow, .op-collapse-btn")) return
|
||||
|
||||
e.preventDefault()
|
||||
const state = dragStateRef.current
|
||||
state.startX = e.clientX
|
||||
state.startY = e.clientY
|
||||
state.startOffsetX = dragOffset.x
|
||||
state.startOffsetY = dragOffset.y
|
||||
state.isDragging = true
|
||||
state.isLongPress = true // 标记为已激活拖拽
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.cursor = "grabbing"
|
||||
}
|
||||
|
||||
const handleMouseMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX - state.startX
|
||||
const dy = ev.clientY - state.startY
|
||||
setDragOffset({ x: state.startOffsetX + dx, y: state.startOffsetY + dy })
|
||||
if (!hasDragged) setHasDragged(true)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
state.isDragging = false
|
||||
state.isLongPress = false
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.cursor = ""
|
||||
}
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}, [dragOffset, hasDragged])
|
||||
|
||||
/** 鼠标按下(非 header 区域):启动长按计时,300ms 后才可拖拽 */
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// 如果点击的是交互元素(按钮、链接、输入框等),不启动拖拽
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest("button, a, input, textarea, select, [role='button'], .op-credits-link, .op-field-item, .op-cache-modal-mask")) return
|
||||
if (target.closest("button, a, input, textarea, select, [role='button'], .op-credits-link, .op-field-item, .op-cache-modal-mask, .op-header, .op-collapsed-bar")) return
|
||||
|
||||
e.preventDefault()
|
||||
const state = dragStateRef.current
|
||||
@@ -646,50 +747,84 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
>
|
||||
{/* 拖拽提示气泡 */}
|
||||
{showDragTooltip && (
|
||||
<div className="op-drag-tooltip">长按可以拖动助手位置</div>
|
||||
<div className="op-drag-tooltip">助手位置可以拖动</div>
|
||||
)}
|
||||
|
||||
{/* 收起状态:只显示 logo 圆圈 */}
|
||||
{/* 收起状态:显示 logo + 提示文字 + 向下箭头 */}
|
||||
{collapsed ? (
|
||||
<div className="op-collapsed-logo" onClick={() => setCollapsed(false)}>
|
||||
<img src={logoImg} alt="OfferPie" width={28} height={31} />
|
||||
<div className="op-collapsed-bar" onMouseDown={handleInstantDragDown}>
|
||||
<img src={logoImg} alt="OfferPie" width={28} height={31} className="op-header-logo" />
|
||||
<span className="op-collapsed-text">{headerStatus || "Offer派助手"}</span>
|
||||
<svg className="op-collapsed-arrow" onClick={() => setCollapsed(false)} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 填写中遮罩:覆盖整个面板,阻止用户操作 */}
|
||||
{filling && <div className="op-filling-overlay" />}
|
||||
<div className="op-inner">
|
||||
{/* 新顶部:logo + 提示文字 + 收起箭头 */}
|
||||
<div className="op-header">
|
||||
{/* 新顶部:logo + 提示文字 + 收起箭头(按住即可拖动) */}
|
||||
<div className="op-header" onMouseDown={handleInstantDragDown}>
|
||||
<div className="op-header-left">
|
||||
<img src={logoImg} alt="OfferPie" width={28} height={31} className="op-header-logo" />
|
||||
<span className="op-header-status">{headerStatus}</span>
|
||||
<span className="op-header-status">{headerStatus || "Offer派助手"}</span>
|
||||
</div>
|
||||
<button className="op-collapse-btn" onClick={() => setCollapsed(true)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 未登录状态:显示登录提示 */}
|
||||
{!isLoggedIn && isLoggedIn !== null && (
|
||||
<div className="op-login-prompt">
|
||||
<p className="op-login-text">请先前往 Offer派 官网进行登录</p>
|
||||
<div className="op-nonmember">
|
||||
<div className="op-nonmember-divider" />
|
||||
<div className="op-nonmember-title">登录 Offer 派,开启智能投递</div>
|
||||
<div className="op-nonmember-desc">登录后将自动同步你的会员状态、简历和求职信息</div>
|
||||
<button
|
||||
className="op-login-btn"
|
||||
onClick={() => {
|
||||
window.open("http://localhost:5173/jobs", "_blank")
|
||||
}}
|
||||
className="op-nonmember-btn"
|
||||
onClick={() => { window.open("https://test.offerpai.com.cn/jobs", "_blank") }}
|
||||
>
|
||||
前往 Offer派
|
||||
前往登录
|
||||
</button>
|
||||
<div
|
||||
className="op-nonmember-refresh"
|
||||
onClick={() => { window.location.reload() }}
|
||||
>
|
||||
已完成登录?刷新登录状态
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已登录状态:显示完整功能 */}
|
||||
{isLoggedIn && (
|
||||
<>
|
||||
{/* 非会员状态:显示开通会员提示 */}
|
||||
{isMember === false && (
|
||||
<div className="op-nonmember">
|
||||
<div className="op-nonmember-divider" />
|
||||
<div className="op-nonmember-title">智能投递助手为会员专享功能</div>
|
||||
<div className="op-nonmember-desc">开通会员后,即可自动识别并填写网申表单,减少重复操作</div>
|
||||
<button
|
||||
className="op-nonmember-btn"
|
||||
onClick={() => { window.open("https://test.offerpai.com.cn/jobs", "_blank") }}
|
||||
>
|
||||
开通会员,立即使用
|
||||
</button>
|
||||
<div
|
||||
className="op-nonmember-refresh"
|
||||
onClick={() => { window.location.reload() }}
|
||||
>
|
||||
已开通会员?刷新会员状态
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 会员状态:显示完整功能 */}
|
||||
{isMember === true && (
|
||||
<>
|
||||
{/* 职位信息卡片 */}
|
||||
<div className="op-job-card">
|
||||
|
||||
@@ -718,13 +853,50 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 自动填写按钮 */}
|
||||
<button className="op-autofill-btn" onClick={handleAutoFill} disabled={filling}>
|
||||
{filling ? "填写中..." : "开始填写"}
|
||||
</button>
|
||||
{/* 自动填写按钮区域 */}
|
||||
{fillCompleted ? (
|
||||
<div className="op-fill-done-row">
|
||||
<button className="op-reset-btn" onClick={handleReset}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#52CAD1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<polyline points="1 20 1 14 7 14" />
|
||||
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="op-fill-done-label">填写完成,请检查</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className={`op-autofill-btn${filling ? " op-autofill-btn--filling" : ""}`} onClick={handleAutoFill} disabled={filling || headerStatus !== "准备就绪,请点击开始填写"}>
|
||||
{filling ? "填写中...." : "开始填写"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 使用次数信息 */}
|
||||
{/* 操作链接 */}
|
||||
<div className="op-credits-row">
|
||||
<span className="op-credits-link" onClick={async () => {
|
||||
const cachedUrl = sessionStorage.getItem("offerpie_source_url") || sourceUrl
|
||||
if (!cachedUrl) return
|
||||
|
||||
try {
|
||||
// 通过跨域 CSL 模式读取投递链接列表
|
||||
const result = await panelBridge.get<OfferpaieDeliveryLinkListData>(appConfig.bridgeDomain, "offerpaiDeliveryLinkList", true)
|
||||
const linkList = result?.linkList || []
|
||||
|
||||
// 检查当前投递链接是否已在列表中
|
||||
if (!linkList.includes(cachedUrl)) {
|
||||
linkList.push(cachedUrl)
|
||||
}
|
||||
|
||||
// 写回跨域通信(让网页端能读到)
|
||||
await panelBridge.put<OfferpaieDeliveryLinkListData>(appConfig.bridgeDomain, "offerpaiDeliveryLinkList", { linkList }, true)
|
||||
console.log("[OfferPie] 已更新投递链接列表:", linkList)
|
||||
} catch (err) {
|
||||
console.warn("[OfferPie] 更新投递链接列表失败:", err)
|
||||
}
|
||||
|
||||
// 跳转到投递链接
|
||||
window.location.href = cachedUrl
|
||||
}}>打开投递链接</span>
|
||||
<span className="op-credits-link" onClick={openCacheModal}>助手已记住的表单</span>
|
||||
</div>
|
||||
|
||||
@@ -864,6 +1036,12 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 版本号(未登录或非会员时隐藏) */}
|
||||
{isLoggedIn && isMember !== false && (
|
||||
<div className="op-version">ver: {appConfig.pluginVersion}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,17 +10,25 @@ const envConfigs: Record<string, {
|
||||
dataBaseApi: string
|
||||
aiBaseApi: string
|
||||
cookieSourceUrl: string
|
||||
/** 通信桥接组件的域名标识(用于 BroadcastChannel 频道隔离) */
|
||||
bridgeDomain: string
|
||||
/** 插件版本号 */
|
||||
pluginVersion: string
|
||||
}> = {
|
||||
dev: {
|
||||
dataBaseApi: 'http://127.0.0.1:8080/api',
|
||||
// aiBaseApi: 'http://192.168.31.133:8000',
|
||||
aiBaseApi: 'http://127.0.0.1:8000',
|
||||
cookieSourceUrl: 'http://127.0.0.1:5173',
|
||||
bridgeDomain: 'test.offerpai.com.cn',
|
||||
pluginVersion: '0.1.1',
|
||||
},
|
||||
prod: {
|
||||
dataBaseApi: 'https://www.offerpai.com.cn/api',
|
||||
aiBaseApi: 'https://www.offerpai.com.cn/ai-api',
|
||||
cookieSourceUrl: 'https://www.offerpai.com.cn',
|
||||
bridgeDomain: 'www.offerpai.com.cn',
|
||||
pluginVersion: '0.1.1',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+97
-18
@@ -10,8 +10,77 @@ import { useEffect, useState, useRef } from "react"
|
||||
import { SidebarPanel } from "~components/SidebarPanel"
|
||||
import { getCookieValue } from "~utils/cookie"
|
||||
import { findJobBySourceUrl } from "~api/dataApi"
|
||||
import { createChannelBridge } from "~lib/channelBridge"
|
||||
import { config as appConfig } from "~config"
|
||||
import type { JobInfo } from "~lib/types"
|
||||
|
||||
// ============ 投递链接列表数据类型 ============
|
||||
|
||||
/** 投递链接列表数据(跨域通信用) */
|
||||
interface OfferpaieDeliveryLinkListData {
|
||||
linkList: string[]
|
||||
}
|
||||
|
||||
// ============ 通信桥接:插件运行即激活 ============
|
||||
|
||||
/** 插件使用状态数据类型 */
|
||||
interface OfferpieBrowserPlugUsage {
|
||||
usage: string
|
||||
version: string
|
||||
}
|
||||
|
||||
/** 创建通信桥接实例(插件端,不启用 Web Lock) */
|
||||
const bridge = createChannelBridge({ domain: appConfig.bridgeDomain, isPlugin: true })
|
||||
|
||||
/** 插件运行时立即广播使用状态,并每1秒持续广播(让 watch 端能实时感知插件在线) */
|
||||
bridge.emit<OfferpieBrowserPlugUsage>(appConfig.bridgeDomain, 'offerpieBrowserPlugUsage', { usage: 'isUsed', version: appConfig.pluginVersion })
|
||||
setInterval(() => {
|
||||
bridge.emit<OfferpieBrowserPlugUsage>(appConfig.bridgeDomain, 'offerpieBrowserPlugUsage', { usage: 'isUsed', version: appConfig.pluginVersion })
|
||||
}, 1000)
|
||||
|
||||
/**
|
||||
* 摩卡平台重定向链接处理
|
||||
* mokahr.com 平台的页面会重定向,导致当前 URL 和数据库存的原始来源链接不一致
|
||||
* 但岗位 ID(URL 最后一段,如 2381af5e-4285-4eac-815b-ba40bf586389)在两种链接里是一样的
|
||||
* 通过跨域通信从 offerpaiDeliveryLinkList 中匹配含相同 ID 的原始投递链接
|
||||
*/
|
||||
async function resolveMokahrRedirectUrl(url: string): Promise<string> {
|
||||
// 仅对 mokahr.com 域名的链接生效
|
||||
if (!url.includes("mokahr.com")) return url
|
||||
|
||||
// 从 URL 末尾提取岗位 ID(最后一个 / 后面的部分,去掉可能的查询参数和 hash)
|
||||
const cleanUrl = url.split("?")[0].split("#").pop() || url.split("?")[0]
|
||||
const segments = cleanUrl.split("/").filter(Boolean)
|
||||
const mokaJobId = segments[segments.length - 1]
|
||||
if (!mokaJobId) return url
|
||||
|
||||
console.log("[OfferPie] 摩卡平台重定向链接处理,提取岗位ID:", mokaJobId)
|
||||
|
||||
try {
|
||||
// 通过跨域 CSL 模式读取投递链接列表
|
||||
const result = await bridge.get<OfferpaieDeliveryLinkListData>(appConfig.bridgeDomain, "offerpaiDeliveryLinkList", true)
|
||||
const linkList = result?.linkList || []
|
||||
if (linkList.length === 0) {
|
||||
console.log("[OfferPie] offerpaiDeliveryLinkList 为空,无法匹配原始链接")
|
||||
return url
|
||||
}
|
||||
|
||||
// 从后往前遍历 linkList,找到第一个包含该岗位 ID 的链接
|
||||
for (let i = linkList.length - 1; i >= 0; i--) {
|
||||
if (linkList[i].includes(mokaJobId)) {
|
||||
console.log("[OfferPie] 匹配到摩卡平台原始来源链接:", linkList[i])
|
||||
return linkList[i]
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[OfferPie] linkList 中未找到包含该岗位ID的链接,使用当前URL")
|
||||
} catch (err) {
|
||||
console.warn("[OfferPie] 摩卡平台重定向链接处理失败:", err)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/** Content Script 配置:匹配所有网页 */
|
||||
export const config: PlasmoCSConfig = {
|
||||
matches: ["<all_urls>"]
|
||||
@@ -54,26 +123,36 @@ function Sidebar() {
|
||||
// 确定用于查询岗位信息的 URL:优先用 sessionStorage 缓存的(同标签页跳转后保留)
|
||||
const STORAGE_KEY = "offerpie_source_url"
|
||||
const cachedUrl = sessionStorage.getItem(STORAGE_KEY)
|
||||
const queryUrl = cachedUrl || currentUrl
|
||||
let queryUrl = cachedUrl || currentUrl
|
||||
|
||||
// 查询岗位信息
|
||||
findJobBySourceUrl(queryUrl).then((data) => {
|
||||
console.log("[OfferPie] 岗位信息:", data)
|
||||
if (data && data.id) {
|
||||
setJobInfo(data)
|
||||
// 查到岗位后缓存当前查询成功的 URL
|
||||
sessionStorage.setItem(STORAGE_KEY, queryUrl)
|
||||
// 自动打开面板
|
||||
if (!autoOpenedRef.current) {
|
||||
autoOpenedRef.current = true
|
||||
setVisible(true)
|
||||
}
|
||||
} else if (!cachedUrl) {
|
||||
// 当前 URL 和缓存都没查到,不自动打开
|
||||
console.log("[OfferPie] 当前页面未匹配到岗位信息")
|
||||
// 摩卡平台重定向链接处理:替换为原始来源链接
|
||||
resolveMokahrRedirectUrl(queryUrl).then((resolvedUrl) => {
|
||||
if (resolvedUrl !== queryUrl) {
|
||||
queryUrl = resolvedUrl
|
||||
// 同步更新 sessionStorage 缓存
|
||||
sessionStorage.setItem(STORAGE_KEY, resolvedUrl)
|
||||
console.log("[OfferPie] 摩卡平台链接已替换为原始来源:", resolvedUrl)
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn("[OfferPie] 查询岗位信息失败:", err)
|
||||
|
||||
// 查询岗位信息
|
||||
findJobBySourceUrl(queryUrl).then((data) => {
|
||||
console.log("[OfferPie] 岗位信息:", data)
|
||||
if (data && data.id) {
|
||||
setJobInfo(data)
|
||||
// 查到岗位后缓存当前查询成功的 URL
|
||||
sessionStorage.setItem(STORAGE_KEY, queryUrl)
|
||||
// 自动打开面板
|
||||
if (!autoOpenedRef.current) {
|
||||
autoOpenedRef.current = true
|
||||
setVisible(true)
|
||||
}
|
||||
} else if (!cachedUrl) {
|
||||
// 当前 URL 和缓存都没查到,不自动打开
|
||||
console.log("[OfferPie] 当前页面未匹配到岗位信息")
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn("[OfferPie] 查询岗位信息失败:", err)
|
||||
})
|
||||
})
|
||||
|
||||
// 如果有缓存 URL 且当前 URL 不同(跳转到了表单页),也自动打开面板
|
||||
|
||||
@@ -19,6 +19,8 @@ import { locateExperienceSections, expandExperienceSections, sortExperienceByTim
|
||||
import type { ExperienceSectionLocateResult } from "~lib/experienceSection"
|
||||
import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types"
|
||||
import { setFieldHighlight, isRequiredField } from "~lib/formStyle"
|
||||
import { get as storageGet, set as storageSet } from "~utils/storage"
|
||||
import { findLabelForInput } from "~lib/labelFinder"
|
||||
|
||||
/** 北森模式自动填写的参数 */
|
||||
export interface AutoFillBeisenParams {
|
||||
@@ -957,41 +959,10 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
||||
if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") continue
|
||||
}
|
||||
|
||||
// 查找标签
|
||||
let labelText = ""
|
||||
let container: Element | null = null
|
||||
for (const sel of FORM_ITEM_SELS_JSON) {
|
||||
container = inp.closest(sel)
|
||||
if (container) break
|
||||
}
|
||||
if (container) {
|
||||
const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||||
for (const el of Array.from(labelEls)) {
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
if (!directText || directText.length > 30) continue
|
||||
if (JSON_EXCLUDE_LABELS.some((ex) => directText === ex)) continue
|
||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
labelText = directText
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!labelText) {
|
||||
let prev: Element | null = inp.previousElementSibling
|
||||
for (let i = 0; i < 3 && prev; i++) {
|
||||
const text = prev.textContent?.trim() || ""
|
||||
if (text && text.length <= 20 && !JSON_EXCLUDE_LABELS.some((ex) => text === ex)) {
|
||||
labelText = text
|
||||
break
|
||||
}
|
||||
prev = prev.previousElementSibling
|
||||
}
|
||||
}
|
||||
if (!labelText) labelText = inputEl.getAttribute("placeholder") || "(未知字段)"
|
||||
// 查找标签(使用统一封装的 labelFinder)
|
||||
const titleElementSet = new Set<Element>(allTitles.map((t) => t.element))
|
||||
const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet)
|
||||
const labelText = detectedLabel
|
||||
|
||||
// 非经历类型:跳过简历格式字段
|
||||
// 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过
|
||||
@@ -1114,72 +1085,29 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
||||
console.log(JSON.stringify(unfilledFormData, null, 2))
|
||||
console.log("===== OfferPie: JSON 输出完毕 =====")
|
||||
|
||||
// 存 localStorage 缓存,包含简历名字和未填字段数据
|
||||
// 更新规则:以第一次存的版本为基准,可以添加字段、更新 value,但不删除已有字段
|
||||
// 存 chrome.storage 缓存,包含简历名字和未填字段数据(跨域名共享,按简历名区分)
|
||||
const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || ""
|
||||
try {
|
||||
const existingRaw = localStorage.getItem("offerpie_unfilled_form")
|
||||
const existing = await storageGet<any>("offerpie_unfilled_form")
|
||||
let cacheData: any = null
|
||||
|
||||
if (existingRaw) {
|
||||
const existing = JSON.parse(existingRaw)
|
||||
if (existing.resumeName === resumeName && existing.unfilledFormData) {
|
||||
// 同一份简历,合并更新(不删除已有字段,只添加新字段和更新 value)
|
||||
const oldSections = existing.unfilledFormData as any[]
|
||||
const newSections = unfilledFormData as any[]
|
||||
if (existing && existing.resumeName === resumeName && existing.unfilledFormData) {
|
||||
// 同一份简历,用新数据替换同名 section(保留其他平台的 section)
|
||||
const oldSections = existing.unfilledFormData as any[]
|
||||
const newSections = unfilledFormData as any[]
|
||||
|
||||
// 遍历新数据,对每个 section 做合并
|
||||
for (const newSec of newSections) {
|
||||
const oldSec = oldSections.find((s: any) => s.title === newSec.title)
|
||||
if (!oldSec) {
|
||||
// 新增的 section,直接追加
|
||||
oldSections.push(newSec)
|
||||
continue
|
||||
}
|
||||
|
||||
if (newSec.isExperience) {
|
||||
// 经历类型:按段合并
|
||||
const oldSegments = oldSec.formItems as any[][]
|
||||
const newSegments = newSec.formItems as any[][]
|
||||
for (let sIdx = 0; sIdx < newSegments.length; sIdx++) {
|
||||
if (sIdx >= oldSegments.length) {
|
||||
// 新增的段,直接追加
|
||||
oldSegments.push(newSegments[sIdx])
|
||||
continue
|
||||
}
|
||||
// 已有的段:合并字段
|
||||
const oldFields = oldSegments[sIdx]
|
||||
const newFields = newSegments[sIdx]
|
||||
for (const newField of newFields) {
|
||||
const oldField = oldFields.find((f: any) => f.label === newField.label)
|
||||
if (oldField) {
|
||||
// 已有字段:如果新值非空则更新
|
||||
if (newField.value) oldField.value = newField.value
|
||||
} else {
|
||||
// 新字段:追加
|
||||
oldFields.push(newField)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 非经历类型:按字段合并
|
||||
const oldFields = oldSec.formItems as any[]
|
||||
const newFields = newSec.formItems as any[]
|
||||
for (const newField of newFields) {
|
||||
const oldField = oldFields.find((f: any) => f.label === newField.label)
|
||||
if (oldField) {
|
||||
if (newField.value) oldField.value = newField.value
|
||||
} else {
|
||||
oldFields.push(newField)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const newSec of newSections) {
|
||||
const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title)
|
||||
if (oldSecIdx < 0) {
|
||||
oldSections.push(newSec)
|
||||
} else {
|
||||
oldSections[oldSecIdx] = newSec
|
||||
}
|
||||
|
||||
existing.unfilledFormData = oldSections
|
||||
existing.timestamp = Date.now()
|
||||
cacheData = existing
|
||||
}
|
||||
|
||||
existing.unfilledFormData = oldSections
|
||||
existing.timestamp = Date.now()
|
||||
cacheData = existing
|
||||
}
|
||||
|
||||
// 没有已有缓存或不是同一份简历 → 新建
|
||||
@@ -1191,10 +1119,10 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData))
|
||||
await storageSet("offerpie_unfilled_form", cacheData)
|
||||
console.log(`===== OfferPie: 已缓存未填字段数据(简历: "${resumeName}") =====`)
|
||||
} catch (e) {
|
||||
console.warn("OfferPie: localStorage 缓存失败", e)
|
||||
console.warn("OfferPie: chrome.storage 缓存失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1503,15 +1431,12 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
|
||||
const { lang, resumeName, usedInputs, expandedResults, sectionResults } = params
|
||||
const result: FillCachedDataResult = { success: 0, failed: 0, skipped: 0 }
|
||||
|
||||
const cachedRaw = localStorage.getItem("offerpie_unfilled_form")
|
||||
if (!cachedRaw) {
|
||||
const cacheData = await storageGet<any>("offerpie_unfilled_form")
|
||||
if (!cacheData) {
|
||||
console.log("===== OfferPie: 阶段B2 - 无缓存数据,跳过 =====")
|
||||
return result
|
||||
}
|
||||
|
||||
let cacheData: any
|
||||
try { cacheData = JSON.parse(cachedRaw) } catch { return result }
|
||||
|
||||
if (cacheData.resumeName !== resumeName) {
|
||||
console.log(`===== OfferPie: 阶段B2 - 缓存简历名"${cacheData.resumeName}"与当前"${resumeName}"不匹配,跳过 =====`)
|
||||
return result
|
||||
|
||||
@@ -19,6 +19,8 @@ import { locateExperienceSections, expandExperienceSections, sortExperienceByTim
|
||||
import type { ExperienceSectionLocateResult } from "~lib/experienceSection"
|
||||
import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types"
|
||||
import { setFieldHighlight, isRequiredField } from "~lib/formStyle"
|
||||
import { get as storageGet, set as storageSet } from "~utils/storage"
|
||||
import { findLabelForInput } from "~lib/labelFinder"
|
||||
|
||||
/** 通用自动填写的参数 */
|
||||
export interface AutoFillCommonParams {
|
||||
@@ -941,41 +943,10 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
||||
if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") continue
|
||||
}
|
||||
|
||||
// 查找标签
|
||||
let labelText = ""
|
||||
let container: Element | null = null
|
||||
for (const sel of FORM_ITEM_SELS_JSON) {
|
||||
container = inp.closest(sel)
|
||||
if (container) break
|
||||
}
|
||||
if (container) {
|
||||
const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||||
for (const el of Array.from(labelEls)) {
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
if (!directText || directText.length > 30) continue
|
||||
if (JSON_EXCLUDE_LABELS.some((ex) => directText === ex)) continue
|
||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
labelText = directText
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!labelText) {
|
||||
let prev: Element | null = inp.previousElementSibling
|
||||
for (let i = 0; i < 3 && prev; i++) {
|
||||
const text = prev.textContent?.trim() || ""
|
||||
if (text && text.length <= 20 && !JSON_EXCLUDE_LABELS.some((ex) => text === ex)) {
|
||||
labelText = text
|
||||
break
|
||||
}
|
||||
prev = prev.previousElementSibling
|
||||
}
|
||||
}
|
||||
if (!labelText) labelText = inputEl.getAttribute("placeholder") || "(未知字段)"
|
||||
// 查找标签(使用统一封装的 labelFinder)
|
||||
const titleElementSet = new Set<Element>(allTitles.map((t) => t.element))
|
||||
const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet)
|
||||
const labelText = detectedLabel
|
||||
|
||||
// 非经历类型:跳过简历格式字段
|
||||
// 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过
|
||||
@@ -1049,72 +1020,29 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
||||
// console.log(JSON.stringify(unfilledFormData, null, 2))
|
||||
console.log("===== OfferPie: JSON 输出完毕 =====")
|
||||
|
||||
// 存 localStorage 缓存,包含简历名字和未填字段数据
|
||||
// 更新规则:以第一次存的版本为基准,可以添加字段、更新 value,但不删除已有字段
|
||||
// 存 chrome.storage 缓存,包含简历名字和未填字段数据(跨域名共享,按简历名区分)
|
||||
const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || ""
|
||||
try {
|
||||
const existingRaw = localStorage.getItem("offerpie_unfilled_form")
|
||||
const existing = await storageGet<any>("offerpie_unfilled_form")
|
||||
let cacheData: any = null
|
||||
|
||||
if (existingRaw) {
|
||||
const existing = JSON.parse(existingRaw)
|
||||
if (existing.resumeName === resumeName && existing.unfilledFormData) {
|
||||
// 同一份简历,合并更新(不删除已有字段,只添加新字段和更新 value)
|
||||
const oldSections = existing.unfilledFormData as any[]
|
||||
const newSections = unfilledFormData as any[]
|
||||
if (existing && existing.resumeName === resumeName && existing.unfilledFormData) {
|
||||
// 同一份简历,用新数据替换同名 section(保留其他平台的 section)
|
||||
const oldSections = existing.unfilledFormData as any[]
|
||||
const newSections = unfilledFormData as any[]
|
||||
|
||||
// 遍历新数据,对每个 section 做合并
|
||||
for (const newSec of newSections) {
|
||||
const oldSec = oldSections.find((s: any) => s.title === newSec.title)
|
||||
if (!oldSec) {
|
||||
// 新增的 section,直接追加
|
||||
oldSections.push(newSec)
|
||||
continue
|
||||
}
|
||||
|
||||
if (newSec.isExperience) {
|
||||
// 经历类型:按段合并
|
||||
const oldSegments = oldSec.formItems as any[][]
|
||||
const newSegments = newSec.formItems as any[][]
|
||||
for (let sIdx = 0; sIdx < newSegments.length; sIdx++) {
|
||||
if (sIdx >= oldSegments.length) {
|
||||
// 新增的段,直接追加
|
||||
oldSegments.push(newSegments[sIdx])
|
||||
continue
|
||||
}
|
||||
// 已有的段:合并字段
|
||||
const oldFields = oldSegments[sIdx]
|
||||
const newFields = newSegments[sIdx]
|
||||
for (const newField of newFields) {
|
||||
const oldField = oldFields.find((f: any) => f.label === newField.label)
|
||||
if (oldField) {
|
||||
// 已有字段:如果新值非空则更新
|
||||
if (newField.value) oldField.value = newField.value
|
||||
} else {
|
||||
// 新字段:追加
|
||||
oldFields.push(newField)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 非经历类型:按字段合并
|
||||
const oldFields = oldSec.formItems as any[]
|
||||
const newFields = newSec.formItems as any[]
|
||||
for (const newField of newFields) {
|
||||
const oldField = oldFields.find((f: any) => f.label === newField.label)
|
||||
if (oldField) {
|
||||
if (newField.value) oldField.value = newField.value
|
||||
} else {
|
||||
oldFields.push(newField)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const newSec of newSections) {
|
||||
const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title)
|
||||
if (oldSecIdx < 0) {
|
||||
oldSections.push(newSec)
|
||||
} else {
|
||||
oldSections[oldSecIdx] = newSec
|
||||
}
|
||||
|
||||
existing.unfilledFormData = oldSections
|
||||
existing.timestamp = Date.now()
|
||||
cacheData = existing
|
||||
}
|
||||
|
||||
existing.unfilledFormData = oldSections
|
||||
existing.timestamp = Date.now()
|
||||
cacheData = existing
|
||||
}
|
||||
|
||||
// 没有已有缓存或不是同一份简历 → 新建
|
||||
@@ -1126,10 +1054,10 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData))
|
||||
await storageSet("offerpie_unfilled_form", cacheData)
|
||||
console.log(`===== OfferPie: 已缓存未填字段数据(简历: "${resumeName}") =====`)
|
||||
} catch (e) {
|
||||
console.warn("OfferPie: localStorage 缓存失败", e)
|
||||
console.warn("OfferPie: chrome.storage 缓存失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1347,15 +1275,12 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
|
||||
const { lang, resumeName, usedInputs, expandedResults, sectionResults } = params
|
||||
const result: FillCachedDataResult = { success: 0, failed: 0, skipped: 0 }
|
||||
|
||||
const cachedRaw = localStorage.getItem("offerpie_unfilled_form")
|
||||
if (!cachedRaw) {
|
||||
const cacheData = await storageGet<any>("offerpie_unfilled_form")
|
||||
if (!cacheData) {
|
||||
console.log("===== OfferPie: 阶段B2 - 无缓存数据,跳过 =====")
|
||||
return result
|
||||
}
|
||||
|
||||
let cacheData: any
|
||||
try { cacheData = JSON.parse(cachedRaw) } catch { return result }
|
||||
|
||||
if (cacheData.resumeName !== resumeName) {
|
||||
console.log(`===== OfferPie: 阶段B2 - 缓存简历名"${cacheData.resumeName}"与当前"${resumeName}"不匹配,跳过 =====`)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
/**
|
||||
* OfferPie 跨标签页通信桥接组件
|
||||
* 用于浏览器插件与网站页面之间的纯本地通信(不依赖任何后端接口)
|
||||
*
|
||||
* 【使用说明】
|
||||
* 1. 引入并创建实例:
|
||||
* import { createChannelBridge } from './channelBridge'
|
||||
* const bridge = createChannelBridge({ domain: 'offerpai.com', isPlugin: true })
|
||||
*
|
||||
* 2. 发送数据(单次,同时写缓存 + 广播):
|
||||
* bridge.put<MyDataType>('offerpai.com', 'resumeStatus', { ... })
|
||||
*
|
||||
* 3. 高频发送(节流写缓存,每次都广播):
|
||||
* bridge.emit<MyDataType>('offerpai.com', 'cursorPosition', { ... })
|
||||
*
|
||||
* 4. 主动获取一次数据(先广播问对方,60ms超时转IndexedDB):
|
||||
* const result = await bridge.get<MyDataType>('offerpai.com', 'resumeStatus')
|
||||
*
|
||||
* 5. 持续监听数据变化(初始读一次缓存,后续实时接收 put/emit):
|
||||
* const unwatch = bridge.watch<MyDataType>('offerpai.com', 'resumeStatus', (data) => { ... })
|
||||
* // 取消监听
|
||||
* unwatch()
|
||||
*
|
||||
* 【跨域模式 useCsl】
|
||||
* 在 put/emit/get/watch 最后一个参数传 true,数据存储走 chrome.storage.local(通过 Background 中转)
|
||||
* 适用于网页与插件在不同域名标签页之间的通信场景
|
||||
* 示例:bridge.put<MyData>('offerpai.com', 'someData', { ... }, true)
|
||||
* 注意:useCsl 模式依赖插件 Background Service Worker 在线
|
||||
*
|
||||
* 6. 销毁实例(页面卸载时调用):
|
||||
* bridge.destroy()
|
||||
*
|
||||
* 【参数说明】
|
||||
* - domain: 频道域名标识,不同网站项目用不同域名隔离
|
||||
* - isPlugin: 是否为插件环境,默认 false(非插件时会启用 Web Lock 保活)
|
||||
*
|
||||
* 【数据类型要求】
|
||||
* - 传递的数据必须有 TypeScript 类型定义(泛型约束 extends object)
|
||||
* - 组件自动为每条数据添加 createTime 和 updateTime 字段
|
||||
*
|
||||
* ============================================================
|
||||
* 【通信数据名称注册表】
|
||||
* 在此区域记录所有通信数据的名称、类型和用途说明
|
||||
* 新增数据时在此补充,删除时移除对应行
|
||||
* ------------------------------------------------------------
|
||||
* | 数据名(name) | 中文名称 | 说明 |
|
||||
* | ----------------------------- | -------------- | --------------------------------- |
|
||||
* | offerpieBrowserPlugUsage | 插件使用状态 | 标识插件正在运行 { usage: string, version: string } |
|
||||
* | offerpaiDeliveryLinkList | 投递链接列表 | 记录要投递职位的来源链接,目前用于解决投递网站的重定向 { linkList: string[] } |
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
/** 组件自动附加的时间戳字段 */
|
||||
export interface BridgeTimestamp {
|
||||
/** 数据首次创建时间(格式:yyyy-MM-dd HH:mm:ss) */
|
||||
createTime: string
|
||||
/** 数据最近更新时间(格式:yyyy-MM-dd HH:mm:ss) */
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
/** 带时间戳的完整数据包装 */
|
||||
export type BridgeData<T extends object> = T & BridgeTimestamp
|
||||
|
||||
/** IndexedDB 存储记录结构 */
|
||||
interface DBRecord {
|
||||
/** 数据名称(唯一索引) */
|
||||
name: string
|
||||
/** JSON 数据本体 */
|
||||
data: object
|
||||
/** 创建时间 */
|
||||
createTime: string
|
||||
/** 更新时间 */
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
/** BroadcastChannel 消息结构 */
|
||||
interface ChannelMessage {
|
||||
/** 消息类型 */
|
||||
type: 'put' | 'emit' | 'get-request' | 'get-response' | 'csl-put-request' | 'csl-put-response' | 'csl-get-request' | 'csl-get-response'
|
||||
/** 数据名称 */
|
||||
name: string
|
||||
/** 请求唯一ID(用于 get 请求-响应匹配) */
|
||||
id?: string
|
||||
/** 数据本体(put/emit/get-response 携带) */
|
||||
data?: object
|
||||
/** 创建时间 */
|
||||
createTime?: string
|
||||
/** 更新时间 */
|
||||
updateTime?: string
|
||||
/** 目标域名(csl 中转消息使用) */
|
||||
domain?: string
|
||||
}
|
||||
|
||||
/** watch 回调函数类型 */
|
||||
type WatchCallback<T extends object> = (data: BridgeData<T> | null) => void
|
||||
|
||||
/** 创建实例的配置参数 */
|
||||
export interface ChannelBridgeOptions {
|
||||
/** 默认域名(可在方法调用时覆盖) */
|
||||
domain: string
|
||||
/** 是否为插件环境,默认 false。非插件时启用 Web Lock 保活 */
|
||||
isPlugin?: boolean
|
||||
}
|
||||
|
||||
// ============ 常量 ============
|
||||
|
||||
/** IndexedDB 数据库名称(含固定雪花ID保证唯一性) */
|
||||
const DB_NAME = 'offerpie_comm_7394028156183472'
|
||||
/** get 方法 BroadcastChannel 超时时间(毫秒) */
|
||||
const GET_TIMEOUT_MS = 60
|
||||
/** emit 方法节流写库间隔(毫秒) */
|
||||
const EMIT_THROTTLE_MS = 500
|
||||
/** chrome.storage.local 存储总 key */
|
||||
const CSL_STORAGE_KEY = 'offerpie_comm_7394028156183472'
|
||||
/** Background 消息类型前缀(用于 useCsl 模式) */
|
||||
const CSL_MSG_PREFIX = 'OFFERPIE_CSL'
|
||||
|
||||
// ============ 工具函数 ============
|
||||
|
||||
/** 获取当前时间的 localDateTime 格式字符串:yyyy-MM-dd HH:mm:ss */
|
||||
function nowLocalDateTime(): string {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
/** 生成简单唯一ID(用于 get 请求匹配) */
|
||||
function uid(): string {
|
||||
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 操作 ============
|
||||
|
||||
/** 数据库连接缓存(避免重复打开和版本冲突) */
|
||||
const _dbCache = new Map<string, Promise<IDBDatabase>>()
|
||||
|
||||
/** 打开/创建 IndexedDB 数据库,按 domain 动态创建 ObjectStore,带连接缓存防并发冲突 */
|
||||
function openDB(storeName: string): Promise<IDBDatabase> {
|
||||
// 如果已有进行中或已完成的连接,直接复用
|
||||
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<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME)
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
const store = db.createObjectStore(storeName, { keyPath: 'name' })
|
||||
store.createIndex('name', 'name', { unique: true })
|
||||
}
|
||||
}
|
||||
request.onsuccess = () => {
|
||||
const db = request.result
|
||||
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 = () => {
|
||||
const db2 = req2.result
|
||||
if (!db2.objectStoreNames.contains(storeName)) {
|
||||
const store = db2.createObjectStore(storeName, { keyPath: 'name' })
|
||||
store.createIndex('name', 'name', { unique: true })
|
||||
}
|
||||
}
|
||||
req2.onsuccess = () => resolve(req2.result)
|
||||
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 = () => { _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<void> {
|
||||
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<DBRecord | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 核心:创建通信桥接实例 ============
|
||||
|
||||
export function createChannelBridge(options: ChannelBridgeOptions) {
|
||||
const { domain, isPlugin = false } = options
|
||||
|
||||
/** BroadcastChannel 实例缓存(按 domain 隔离频道) */
|
||||
const channels = new Map<string, BroadcastChannel>()
|
||||
|
||||
/** watch 订阅回调注册表:key = `${domain}::${name}` */
|
||||
const watchers = new Map<string, Set<WatchCallback<any>>>()
|
||||
|
||||
/** emit 节流定时器:key = `${domain}::${name}` */
|
||||
const emitTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/** emit 节流暂存最新数据:key = `${domain}::${name}` */
|
||||
const emitPending = new Map<string, DBRecord>()
|
||||
|
||||
/** get 请求等待队列:key = requestId */
|
||||
const getResolvers = new Map<string, (data: BridgeData<any> | null) => void>()
|
||||
|
||||
// --- Web Lock 保活(非插件环境) ---
|
||||
if (!isPlugin && typeof navigator !== 'undefined' && navigator.locks) {
|
||||
navigator.locks.request('offerpie-keep-alive', () => new Promise(() => {}))
|
||||
}
|
||||
|
||||
/** 获取或创建指定 domain 的 BroadcastChannel */
|
||||
function getChannel(channelDomain: string): BroadcastChannel {
|
||||
if (channels.has(channelDomain)) return channels.get(channelDomain)!
|
||||
const ch = new BroadcastChannel(`offerpie-bridge-${channelDomain}`)
|
||||
ch.onmessage = (event: MessageEvent<ChannelMessage>) => handleMessage(channelDomain, event.data)
|
||||
channels.set(channelDomain, ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
/** 处理收到的 BroadcastChannel 消息 */
|
||||
async function handleMessage(channelDomain: string, msg: ChannelMessage) {
|
||||
const watchKey = `${channelDomain}::${msg.name}`
|
||||
|
||||
if (msg.type === 'put' || msg.type === 'emit') {
|
||||
// 收到对方的数据推送,触发本地 watch 回调
|
||||
const wrapped = msg.data && msg.createTime && msg.updateTime
|
||||
? { ...msg.data, createTime: msg.createTime, updateTime: msg.updateTime } as BridgeData<any>
|
||||
: null
|
||||
const callbacks = watchers.get(watchKey)
|
||||
if (callbacks) {
|
||||
callbacks.forEach(cb => cb(wrapped))
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'get-request' && msg.id) {
|
||||
// 收到对方的 get 请求,从本地 IndexedDB 读取数据并回复
|
||||
const record = await dbGet(channelDomain, msg.name)
|
||||
const ch = getChannel(channelDomain)
|
||||
const response: ChannelMessage = {
|
||||
type: 'get-response',
|
||||
name: msg.name,
|
||||
id: msg.id,
|
||||
data: record?.data,
|
||||
createTime: record?.createTime,
|
||||
updateTime: record?.updateTime,
|
||||
}
|
||||
ch.postMessage(response)
|
||||
}
|
||||
|
||||
if (msg.type === 'get-response' && msg.id) {
|
||||
// 收到对方对 get 请求的回复
|
||||
const resolver = getResolvers.get(msg.id)
|
||||
if (resolver) {
|
||||
getResolvers.delete(msg.id)
|
||||
if (msg.data && msg.createTime && msg.updateTime) {
|
||||
resolver({ ...msg.data, createTime: msg.createTime, updateTime: msg.updateTime } as BridgeData<any>)
|
||||
} else {
|
||||
resolver(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化默认频道监听
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 公开方法 ---
|
||||
|
||||
/**
|
||||
* put - 单次发送数据(写入 IndexedDB + BroadcastChannel 广播)
|
||||
* @param targetDomain 目标域名频道
|
||||
* @param name 数据名称
|
||||
* @param data 数据本体(需有 TS 类型定义)
|
||||
* @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转)
|
||||
*/
|
||||
async function put<T extends object>(targetDomain: string, name: string, data: T, useCsl?: boolean): Promise<void> {
|
||||
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 createTime = existing?.createTime || now
|
||||
const updateTime = now
|
||||
|
||||
const record: DBRecord = { name, data, createTime, updateTime }
|
||||
// 写入 IndexedDB
|
||||
await dbPut(targetDomain, record)
|
||||
// BroadcastChannel 广播
|
||||
const ch = getChannel(targetDomain)
|
||||
const msg: ChannelMessage = { type: 'put', name, data, createTime, updateTime }
|
||||
ch.postMessage(msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* emit - 高频发送数据(每次都广播,节流 500ms 写一次 IndexedDB)
|
||||
* @param targetDomain 目标域名频道
|
||||
* @param name 数据名称
|
||||
* @param data 数据本体(需有 TS 类型定义)
|
||||
* @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转)
|
||||
*/
|
||||
async function emit<T extends object>(targetDomain: string, name: string, data: T, useCsl?: boolean): Promise<void> {
|
||||
const now = nowLocalDateTime()
|
||||
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(从暂存或内存缓存)
|
||||
let createTime: string
|
||||
const pending = emitPending.get(key)
|
||||
if (pending) {
|
||||
createTime = pending.createTime
|
||||
} else {
|
||||
// 只在首次查库获取 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 实时性)
|
||||
const ch = getChannel(targetDomain)
|
||||
const msg: ChannelMessage = { type: 'emit', name, data, createTime, updateTime }
|
||||
ch.postMessage(msg)
|
||||
|
||||
// 节流写库
|
||||
if (!emitTimers.has(key)) {
|
||||
emitTimers.set(key, setTimeout(async () => {
|
||||
emitTimers.delete(key)
|
||||
const latestRecord = emitPending.get(key)
|
||||
if (latestRecord) {
|
||||
// 注意:不再 delete emitPending,让下次 emit 继续命中缓存
|
||||
await dbPut(targetDomain, latestRecord)
|
||||
}
|
||||
}, EMIT_THROTTLE_MS))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get - 主动获取一次数据(先 BroadcastChannel 请求,60ms 超时转 IndexedDB)
|
||||
* @param targetDomain 目标域名频道
|
||||
* @param name 数据名称
|
||||
* @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转)
|
||||
* @returns 数据本体(含 createTime/updateTime),无数据返回 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 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
|
||||
|
||||
// 超时降级读 IndexedDB
|
||||
const record = await dbGet(targetDomain, name)
|
||||
if (record) {
|
||||
return { ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData<T>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* watch - 持续监听数据变化(初始读一次缓存,后续实时接收 put/emit 推送)
|
||||
* @param targetDomain 目标域名频道
|
||||
* @param name 数据名称
|
||||
* @param callback 数据变化回调,参数为最新数据或 null
|
||||
* @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转)
|
||||
* @returns 取消监听的函数
|
||||
*/
|
||||
function watch<T extends object>(targetDomain: string, name: string, callback: WatchCallback<T>, useCsl?: boolean): () => void {
|
||||
const key = `${targetDomain}::${name}`
|
||||
// 确保频道已初始化
|
||||
getChannel(targetDomain)
|
||||
|
||||
// 注册回调(BroadcastChannel 广播也能触发,不管 useCsl 与否)
|
||||
if (!watchers.has(key)) watchers.set(key, new Set())
|
||||
watchers.get(key)!.add(callback)
|
||||
|
||||
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) => {
|
||||
if (record) {
|
||||
callback({ ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData<T>)
|
||||
} else {
|
||||
callback(null)
|
||||
}
|
||||
})
|
||||
|
||||
// 返回取消监听函数
|
||||
return () => {
|
||||
const set = watchers.get(key)
|
||||
if (set) {
|
||||
set.delete(callback)
|
||||
if (set.size === 0) watchers.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* destroy - 销毁实例,关闭所有频道,清除定时器
|
||||
*/
|
||||
function destroy(): void {
|
||||
channels.forEach(ch => ch.close())
|
||||
channels.clear()
|
||||
watchers.clear()
|
||||
emitTimers.forEach(timer => clearTimeout(timer))
|
||||
emitTimers.clear()
|
||||
emitPending.clear()
|
||||
getResolvers.clear()
|
||||
}
|
||||
|
||||
return { put, emit, get, watch, destroy }
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export const JOB_FORM_LABELS: FormLabelItem[] = [
|
||||
{ key: "eduPeriod", zh: ["就读时间", "就读期间", "起止时间"], en: ["Study Period", "Duration", "Period"], section: "education", resumeField: "startDate,endDate" },
|
||||
{ key: "eduStartDate", zh: ["入学时间", "开始时间"], en: ["Start Date", "Enrollment Date", "From"], section: "education", resumeField: "startDate" },
|
||||
{ key: "eduEndDate", zh: ["结束时间", "毕业时间"], en: ["End Date", "To", "Until"], section: "education", resumeField: "endDate" },
|
||||
{ key: "eduDescription", zh: ["在校经历", "教育描述", "在校描述"], en: ["Education Description", "Academic Description"], section: "education", resumeField: "description" },
|
||||
{ key: "eduDescription", zh: ["在校经历", "教育描述", "在校描述", "描述"], en: ["Education Description", "Academic Description"], section: "education", resumeField: "description" },
|
||||
{ key: "gpa", zh: ["GPA", "绩点", "成绩", "平均分"], en: ["GPA", "Grade Point Average", "Academic Score"], section: "education", resumeField: "" },
|
||||
{ key: "graduationThesis", zh: ["毕业论文", "毕业设计", "毕业论文/设计题目"], en: ["Thesis", "Graduation Thesis", "Dissertation"], section: "education", resumeField: "" },
|
||||
// ---- 求职意向(主表) ----
|
||||
@@ -68,27 +68,27 @@ export const JOB_FORM_LABELS: FormLabelItem[] = [
|
||||
{ key: "workPeriod", zh: ["起止时间", "在职时间", "就职时间"], en: ["Period", "Duration", "Employment Period"], section: "work", resumeField: "startDate,endDate" },
|
||||
{ key: "workStartDate", zh: ["开始时间", "入职时间"], en: ["Start Date", "From"], section: "work", resumeField: "startDate" },
|
||||
{ key: "workEndDate", zh: ["结束时间", "离职时间"], en: ["End Date", "To"], section: "work", resumeField: "endDate" },
|
||||
{ key: "workDescription", zh: ["工作描述", "工作内容", "职责描述", "岗位职责", "职位描述"], en: ["Job Description", "Responsibilities", "Description", "Duties"], section: "work", resumeField: "description" },
|
||||
{ key: "workDescription", zh: ["工作描述", "工作内容", "职责描述", "岗位职责", "职位描述", "工作职责", "描述"], en: ["Job Description", "Responsibilities", "Description", "Duties"], section: "work", resumeField: "description" },
|
||||
// ---- 实习经历(数组) ----
|
||||
{ key: "internCompany", zh: ["实习公司", "实习单位"], en: ["Intern Company", "Internship Company"], section: "internship", resumeField: "companyName" },
|
||||
{ key: "internPosition", zh: ["实习职位", "实习岗位"], en: ["Intern Position", "Internship Role"], section: "internship", resumeField: "position" },
|
||||
{ key: "internPeriod", zh: ["起止时间", "实习时间", "就职时间"], en: ["Period", "Duration", "Employment Period"], section: "internship", resumeField: "startDate,endDate" },
|
||||
{ key: "internStartDate", zh: ["实习开始时间", "开始时间"], en: ["Internship Start Date", "Start Date", "From"], section: "internship", resumeField: "startDate" },
|
||||
{ key: "internEndDate", zh: ["实习结束时间", "结束时间"], en: ["Internship End Date", "End Date", "To"], section: "internship", resumeField: "endDate" },
|
||||
{ key: "internDescription", zh: ["实习描述", "实习内容","工作描述", "工作职责", "工作内容", "职责描述", "岗位职责", "职位描述"], en: ["Internship Description", "Intern Duties"], section: "internship", resumeField: "description" },
|
||||
{ key: "internDescription", zh: ["实习描述", "实习内容","工作描述", "工作职责", "工作内容", "职责描述", "岗位职责", "职位描述", "描述"], en: ["Internship Description", "Intern Duties"], section: "internship", resumeField: "description" },
|
||||
// ---- 项目经历(数组) ----
|
||||
{ key: "projectName", zh: ["项目名称", "项目名", "项目", "项目名称"], en: ["Project Name", "Project", "Project Title"], section: "project", resumeField: "projectName" },
|
||||
{ key: "projectName", zh: ["项目名称", "项目名", "项目名称"], en: ["Project Name", "Project", "Project Title"], section: "project", resumeField: "projectName" },
|
||||
{ key: "projectCompany", zh: ["所属公司", "项目所属公司", "项目公司"], en: ["Project Company", "Company"], section: "project", resumeField: "companyName" },
|
||||
{ key: "projectRole", zh: ["担任角色", "项目角色", "角色"], en: ["Role", "Project Role", "Your Role"], section: "project", resumeField: "role" },
|
||||
{ key: "projectPeriod", zh: ["起止时间", "项目时间"], en: ["Period", "Duration", "Project Period"], section: "project", resumeField: "startDate,endDate" },
|
||||
{ key: "projectStartDate", zh: ["项目开始时间", "开始时间"], en: ["Project Start Date", "Start Date", "From"], section: "project", resumeField: "startDate" },
|
||||
{ key: "projectEndDate", zh: ["项目结束时间", "结束时间"], en: ["Project End Date", "End Date", "To"], section: "project", resumeField: "endDate" },
|
||||
{ key: "projectDescription", zh: ["项目描述", "项目内容", "项目职责"], en: ["Project Description", "Project Details", "Project Responsibilities"], section: "project", resumeField: "description" },
|
||||
{ key: "projectDescription", zh: ["项目描述", "项目内容", "项目职责", "描述"], en: ["Project Description", "Project Details", "Project Responsibilities"], section: "project", resumeField: "description" },
|
||||
// ---- 竞赛经历(数组) ----
|
||||
{ key: "competitionName", zh: ["竞赛名称", "比赛名称", "竞赛"], en: ["Competition Name", "Contest Name", "Competition"], section: "competition", resumeField: "competitionName" },
|
||||
{ key: "competitionAward", zh: ["获奖情况", "奖项", "获奖"], en: ["Award", "Prize", "Achievement"], section: "competition", resumeField: "award" },
|
||||
{ key: "competitionDate", zh: ["获奖时间", "比赛时间"], en: ["Award Date", "Competition Date"], section: "competition", resumeField: "awardDate" },
|
||||
{ key: "competitionDescription", zh: ["竞赛描述", "比赛描述"], en: ["Competition Description", "Contest Details"], section: "competition", resumeField: "description" },
|
||||
{ key: "competitionDescription", zh: ["竞赛描述", "比赛描述", "描述"], en: ["Competition Description", "Contest Details"], section: "competition", resumeField: "description" },
|
||||
// ---- 技能与证书(主表) ----
|
||||
{ key: "skills", zh: ["技能", "专业技能", "技能特长", "专业特长"], en: ["Skills", "Technical Skills", "Competencies"], section: "main", resumeField: "skills" },
|
||||
{ key: "language", zh: ["语言水平", "语言能力", "外语能力", "英语等级", "英语等级成绩", "英语水平", "外语水平", "语言考试"], en: ["Language Proficiency", "English Level", "Language Skills", "Foreign Language", "Language Level"], section: "main", resumeField: "" },
|
||||
|
||||
@@ -19,6 +19,12 @@ import { delay } from "./autofill"
|
||||
import { snapshotElementsInRange, diffSnapshots, findTopLevelNewElements } from "./dom"
|
||||
import type { ExperienceSection, ExperienceSectionConfig, ResumeData } from "./types"
|
||||
|
||||
/** 大标题排除关键词:包含这些文字的标签不作为大标题(它们是子标题/说明文字) */
|
||||
const TITLE_EXCLUDE_KEYWORDS = ["高中教育经历", "本科教育经历", "本科及以上教育经历", "填写高中", "填写本科"]
|
||||
|
||||
/** 基本信息大标题关键词(精确全称匹配,用于分步表单页面识别) */
|
||||
const BASIC_INFO_TITLE_KEYWORDS = ["个人信息", "基本信息", "基础信息", "个人基本信息"]
|
||||
|
||||
// ====================================================================
|
||||
// 一、类型定义
|
||||
// ====================================================================
|
||||
@@ -105,6 +111,46 @@ export interface ExperienceSectionLocateResult {
|
||||
* 【注意】优先找正文区域的标题,跳过导航/锚点/菜单区域
|
||||
* 如果正文区域没找到,再 fallback 到全页面搜索
|
||||
*/
|
||||
/**
|
||||
* 查找页面中所有匹配指定经历类型关键词的标题元素(按 DOM 顺序)
|
||||
* 用于处理同名标题(如两个"教育经历")时逐个验证哪个有添加按钮
|
||||
*/
|
||||
function findAllSectionTitles(
|
||||
rootEl: Element,
|
||||
lang: "zh" | "en",
|
||||
config: ExperienceSectionConfig
|
||||
): { titleElement: Element; titleText: string }[] {
|
||||
const keywords = lang === "zh" ? config.zh : config.en
|
||||
const results: { titleElement: Element; titleText: string }[] = []
|
||||
|
||||
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_ELEMENT)
|
||||
let node: Node | null = walker.nextNode()
|
||||
while (node) {
|
||||
const el = node as Element
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((child) => child.nodeType === Node.TEXT_NODE)
|
||||
.map((child) => child.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
|
||||
if (directText) {
|
||||
// 排除子标题/说明文字
|
||||
if (TITLE_EXCLUDE_KEYWORDS.some((kw) => directText.includes(kw))) {
|
||||
node = walker.nextNode()
|
||||
continue
|
||||
}
|
||||
for (const keyword of keywords) {
|
||||
if (directText.includes(keyword) && directText.length < keyword.length + 10) {
|
||||
results.push({ titleElement: el, titleText: directText })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
node = walker.nextNode()
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
function findSectionTitle(
|
||||
rootEl: Element,
|
||||
lang: "zh" | "en",
|
||||
@@ -147,6 +193,11 @@ function findSectionTitle(
|
||||
.join("")
|
||||
|
||||
if (directText) {
|
||||
// 排除子标题/说明文字
|
||||
if (TITLE_EXCLUDE_KEYWORDS.some((kw) => directText.includes(kw))) {
|
||||
node = walker1.nextNode()
|
||||
continue
|
||||
}
|
||||
for (const keyword of keywords) {
|
||||
if (directText.includes(keyword) && directText.length < keyword.length + 10) {
|
||||
if (!isInNavArea(el)) {
|
||||
@@ -170,6 +221,11 @@ function findSectionTitle(
|
||||
.join("")
|
||||
|
||||
if (directText) {
|
||||
// 排除子标题/说明文字
|
||||
if (TITLE_EXCLUDE_KEYWORDS.some((kw) => directText.includes(kw))) {
|
||||
node = walker2.nextNode()
|
||||
continue
|
||||
}
|
||||
for (const keyword of keywords) {
|
||||
if (directText.includes(keyword) && directText.length < keyword.length + 10) {
|
||||
return { titleElement: el, titleText: directText }
|
||||
@@ -264,6 +320,11 @@ function findAllTitlesWithSameSignature(rootEl: Element, refSignature: TitleSign
|
||||
|
||||
if (directText && directText.length > 0 && directText.length < 20 && el.children.length === 0) {
|
||||
if (!isInNavArea(el)) {
|
||||
// 排除子标题/说明文字(如"填写高中教育经历"、"填写本科及以上教育经历")
|
||||
if (TITLE_EXCLUDE_KEYWORDS.some((kw) => directText.includes(kw))) {
|
||||
node = walker.nextNode()
|
||||
continue
|
||||
}
|
||||
const sig = extractTitleSignature(el)
|
||||
if (isSameSignature(sig, refSignature)) {
|
||||
// 【注意】如果有参考元素的 className,额外验证候选元素自身 className 必须一致
|
||||
@@ -735,16 +796,40 @@ export function locateExperienceSections(
|
||||
): ExperienceSectionLocateResult[] {
|
||||
// console.log("===== OfferPie: 开始定位经历区块 =====")
|
||||
|
||||
// 定位5种经历的大标题
|
||||
// 定位5种经历的大标题(需要验证范围内有添加按钮,否则不算可扩展的5大经历区块)
|
||||
const located: { config: ExperienceSectionConfig; titleElement: Element; titleText: string }[] = []
|
||||
const notFound: ExperienceSectionConfig[] = []
|
||||
|
||||
for (const config of EXPERIENCE_SECTION_CONFIGS) {
|
||||
const result = findSectionTitle(rootEl, lang, config)
|
||||
if (result) {
|
||||
located.push({ config, titleElement: result.titleElement, titleText: result.titleText })
|
||||
} else {
|
||||
if (!result) {
|
||||
notFound.push(config)
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证该标题范围内是否有添加按钮
|
||||
// 如果没有(如"高中教育经历"这种固定区块),跳过它继续找下一个同名标题
|
||||
let validResult = result
|
||||
let validated = false
|
||||
const allTitleCandidates = findAllSectionTitles(rootEl, lang, config)
|
||||
|
||||
for (const candidate of allTitleCandidates) {
|
||||
// 找到该标题在页面中的下一个同结构标题作为范围边界(简单用 DOM 后续第一个同 signature 标题)
|
||||
const candidateIdx = allTitleCandidates.indexOf(candidate)
|
||||
const nextCandidate = candidateIdx < allTitleCandidates.length - 1 ? allTitleCandidates[candidateIdx + 1] : null
|
||||
const addBtn = findAddButton(candidate.titleElement, nextCandidate?.titleElement || null, config, lang)
|
||||
if (addBtn) {
|
||||
validResult = candidate
|
||||
validated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (validated) {
|
||||
located.push({ config, titleElement: validResult.titleElement, titleText: validResult.titleText })
|
||||
} else {
|
||||
// 所有同名标题都没有添加按钮,取第一个作为定位结果(expandedCount=1 固定段)
|
||||
located.push({ config, titleElement: result.titleElement, titleText: result.titleText })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,6 +907,55 @@ export function locateExperienceSections(
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 【补充逻辑】检测分步表单页面的"个人基本信息"大标题
|
||||
* 触发条件:locateExperienceSections 没有找到任何5大经历大标题
|
||||
* 判断逻辑:
|
||||
* 1. 页面中存在 BASIC_INFO_TITLE_KEYWORDS 中的精确全称文字标签
|
||||
* 2. 该标签后面有 ≥2 个输入框
|
||||
* 3. 页面中没有5大经历的大标题关键词
|
||||
* 满足则返回该标签作为唯一大标题(代表分步填写的个人基本信息页)
|
||||
*/
|
||||
function detectBasicInfoPageTitle(): { element: Element; text: string } | null {
|
||||
const INPUT_SEL = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
|
||||
|
||||
// 先确认页面中没有5大经历大标题(教育经历/工作经历/实习经历/项目经历/竞赛经历)
|
||||
const expKeywords = ["教育经历", "工作经历", "实习经历", "项目经历", "竞赛经历"]
|
||||
const pageText = document.body.innerText || ""
|
||||
for (const kw of expKeywords) {
|
||||
if (pageText.includes(kw)) return null
|
||||
}
|
||||
|
||||
// 遍历 DOM 找精确匹配的基本信息标题
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT)
|
||||
let node: Node | null = walker.nextNode()
|
||||
while (node) {
|
||||
const el = node as Element
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((child) => child.nodeType === Node.TEXT_NODE)
|
||||
.map((child) => child.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
|
||||
if (directText && BASIC_INFO_TITLE_KEYWORDS.includes(directText)) {
|
||||
// 检查该标签后面是否有 ≥2 个输入框
|
||||
const allInputs = document.body.querySelectorAll(INPUT_SEL)
|
||||
let inputCountAfter = 0
|
||||
for (const inp of Array.from(allInputs)) {
|
||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
inputCountAfter++
|
||||
if (inputCountAfter >= 2) break
|
||||
}
|
||||
}
|
||||
if (inputCountAfter >= 2) {
|
||||
return { element: el, text: directText }
|
||||
}
|
||||
}
|
||||
node = walker.nextNode()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 【工具方法】获取页面所有大标题列表(复用 locateExperienceSections 内部的 TitleSignature 匹配逻辑)
|
||||
* 用于填写结束后按大标题分组统计字段处理结果
|
||||
@@ -833,7 +967,12 @@ export function getAllPageTitles(
|
||||
locateResults: ExperienceSectionLocateResult[]
|
||||
): { element: Element; text: string }[] {
|
||||
const firstLocated = locateResults.find((r) => r.titleElement)
|
||||
if (!firstLocated || !firstLocated.titleElement) return []
|
||||
if (!firstLocated || !firstLocated.titleElement) {
|
||||
// 【补充分支】没有任何5大经历大标题被定位时,检测是否为分步表单页面(只有个人基本信息)
|
||||
const fallbackTitle = detectBasicInfoPageTitle()
|
||||
if (fallbackTitle) return [fallbackTitle]
|
||||
return []
|
||||
}
|
||||
|
||||
const refSignature = extractTitleSignature(firstLocated.titleElement)
|
||||
let allPageTitles = findAllTitlesWithSameSignature(document.body, refSignature, firstLocated.titleElement)
|
||||
@@ -1047,6 +1186,23 @@ export async function expandExperienceSections(
|
||||
})
|
||||
result.expandedCount = result.segmentRanges.length
|
||||
console.log(` [重建] 用段容器特征重建 segmentRanges: ${result.segmentRanges.length} 段`)
|
||||
} else if (matchingChildren.length === 1) {
|
||||
// 【修复】从0段展开到1段时,父级下只有1个匹配容器,也应作为唯一的 segmentRange
|
||||
const el = matchingChildren[0]
|
||||
const inputs = el.querySelectorAll(INPUT_SEL)
|
||||
const firstInput = inputs.length > 0 ? inputs[0] : el
|
||||
const startLabel = findLabelBeforeInput(firstInput, el)
|
||||
const childIdx = Array.from(parentEl.children).indexOf(el)
|
||||
result.segmentRanges = [{
|
||||
startElement: startLabel || firstInput,
|
||||
endElement: inputs.length > 0 ? inputs[inputs.length - 1] : el,
|
||||
isNewlyAdded: true,
|
||||
containerElement: el,
|
||||
nthChildIndex: childIdx + 1,
|
||||
locator: buildLocator(el, parentEl, childIdx + 1, result.titleText),
|
||||
}]
|
||||
result.expandedCount = 1
|
||||
console.log(` [重建] 从0段展开到1段,用新增容器构建 segmentRanges: 1 段`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-56
@@ -13,6 +13,7 @@ import { getAllPageTitles, locateExperienceSections } from "./experienceSection"
|
||||
import type { ExperienceSectionLocateResult } from "./experienceSection"
|
||||
import type { MatchedFormField, UnmatchedFormField } from "./types"
|
||||
import { isRequiredField, setFieldHighlight } from "./formStyle"
|
||||
import { findLabelForInput } from "./labelFinder"
|
||||
|
||||
// ====================================================================
|
||||
// 一、类型定义
|
||||
@@ -303,67 +304,12 @@ function scanBeisenCascadePickers(
|
||||
// 三、通用辅助方法
|
||||
// ====================================================================
|
||||
|
||||
/** 排除的标签文字(不视为有效标签) */
|
||||
const STAT_EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"]
|
||||
|
||||
/** input 选择器(排除 hidden/submit/button/checkbox/radio/file/disabled) */
|
||||
const INPUT_SEL_STAT = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
|
||||
|
||||
/** 表单项容器选择器集合 */
|
||||
const FORM_ITEM_SELS = [
|
||||
".form-item", ".form-group", ".form-field",
|
||||
".el-form-item", ".ant-form-item", ".ant-row",
|
||||
".arco-form-item", ".t-form-item", ".n-form-item",
|
||||
"[class*='form-item']", "[class*='form-group']", "[class*='formItem']",
|
||||
]
|
||||
|
||||
/** 5大经历 section 名集合 */
|
||||
const FIVE_EXP_SECTIONS = new Set(["education", "work", "internship", "project", "competition"])
|
||||
|
||||
/**
|
||||
* 查找 input 元素对应的标签文字
|
||||
*/
|
||||
function findLabelForInput(inp: Element): { labelText: string; labelElement: Element | null } {
|
||||
let labelText = ""
|
||||
let labelElement: Element | null = null
|
||||
let container: Element | null = null
|
||||
for (const sel of FORM_ITEM_SELS) {
|
||||
container = inp.closest(sel)
|
||||
if (container) break
|
||||
}
|
||||
if (container) {
|
||||
const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||||
for (const el of Array.from(labelEls)) {
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
if (!directText || directText.length > 30) continue
|
||||
if (STAT_EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue
|
||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
labelText = directText
|
||||
labelElement = el
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!labelText) {
|
||||
let prev: Element | null = inp.previousElementSibling
|
||||
for (let i = 0; i < 3 && prev; i++) {
|
||||
const text = prev.textContent?.trim() || ""
|
||||
if (text && text.length <= 20 && !STAT_EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) {
|
||||
labelText = text
|
||||
labelElement = prev
|
||||
break
|
||||
}
|
||||
prev = prev.previousElementSibling
|
||||
}
|
||||
}
|
||||
if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)"
|
||||
return { labelText, labelElement }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 对非5大经历的其他经历类型区块,通过标签重复次数检测段数
|
||||
@@ -474,6 +420,9 @@ export function scanPageFields(params: ScanPageFieldsParams): TitleStat[] {
|
||||
const allTitles = getAllPageTitles(sectionResults)
|
||||
if (allTitles.length === 0) return []
|
||||
|
||||
// 构建大标题元素集合(用于 findLabelForInput 第二遍策略的停止边界)
|
||||
const titleElementSet = new Set<Element>(allTitles.map((t) => t.element))
|
||||
|
||||
const processedInputs = new Set<Element>()
|
||||
|
||||
// 收集各阶段字段到统一格式
|
||||
@@ -579,7 +528,7 @@ export function scanPageFields(params: ScanPageFieldsParams): TitleStat[] {
|
||||
|
||||
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
||||
const alreadyHasValue = !!(inputEl.value && inputEl.value.trim().length > 0)
|
||||
const { labelText, labelElement } = findLabelForInput(inp)
|
||||
const { labelText, labelElement } = findLabelForInput(inp, titleElementSet)
|
||||
|
||||
// 根据背景色判断来源和颜色
|
||||
let color: FieldStatColor
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 表单字段标签定位模块(统一封装)
|
||||
* 负责:根据 input/textarea 元素在 DOM 中定位其对应的标签文字
|
||||
*
|
||||
* 两遍策略:
|
||||
* 第一遍:通过 form-item 容器选择器定位 → 逐级向上修复 → 在容器内找标签
|
||||
* 第二遍:如果第一遍失败,沿 DOM 文档流逆向遍历找到有效中文标签
|
||||
*
|
||||
* 所有需要"识别输入框前面的标签名字"的地方统一调用此模块
|
||||
*/
|
||||
|
||||
// ============ 常量 ============
|
||||
|
||||
/** 排除的标签文字(不视为有效标签) */
|
||||
const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填", "确定", "取消", "搜索", "无准确的毕业时间可填写预计毕业时间"]
|
||||
|
||||
/** 表单项容器选择器集合(用于第一遍策略的 closest 查找) */
|
||||
const FORM_ITEM_SELS = [
|
||||
".form-item", ".form-group", ".form-field",
|
||||
".el-form-item", ".ant-form-item", ".ant-row",
|
||||
".arco-form-item", ".t-form-item", ".n-form-item",
|
||||
"[class*='form-item']", "[class*='form-group']", "[class*='formItem']",
|
||||
]
|
||||
|
||||
// ============ 工具方法 ============
|
||||
|
||||
/**
|
||||
* 判断文本是否为有效的表单字段标签
|
||||
* 必须包含至少2个汉字,排除纯符号/纯英文/单字符
|
||||
*/
|
||||
export function isValidLabelText(text: string): boolean {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return false
|
||||
if (trimmed.length > 30) return false
|
||||
// 必须包含至少2个汉字
|
||||
const chineseChars = trimmed.match(/[\u4e00-\u9fff]/g)
|
||||
if (!chineseChars || chineseChars.length < 2) return false
|
||||
// 排除已知无意义标签
|
||||
if (EXCLUDE_LABEL_TEXTS.some((ex) => trimmed === ex)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// ============ 第二遍策略:DOM 逆向遍历 ============
|
||||
|
||||
/**
|
||||
* 【第二遍策略】沿 DOM 文档流往前遍历,查找 input 对应的标签文字
|
||||
* 从 input 出发逆向走,逐个检查文本节点
|
||||
*
|
||||
* 停止条件:
|
||||
* - 碰到另一个 input/textarea/select 元素
|
||||
* - 碰到大标题元素(titleElements 集合中的元素)
|
||||
* - 碰到包含 input 的非祖先元素(前一个字段区域)
|
||||
* - 最多走 80 个节点
|
||||
*
|
||||
* 过滤条件:跳过不含至少2个汉字的文本
|
||||
*/
|
||||
function walkBackwardForLabel(inp: Element, titleElements?: Set<Element>): { text: string; element: Element } | null {
|
||||
const INPUT_TAG_SET = new Set(["INPUT", "TEXTAREA", "SELECT"])
|
||||
let maxSteps = 80
|
||||
let current: Node | null = inp
|
||||
|
||||
while (maxSteps-- > 0) {
|
||||
let prev: Node | null = null
|
||||
|
||||
if (current.previousSibling) {
|
||||
prev = current.previousSibling
|
||||
while (prev.lastChild) {
|
||||
prev = prev.lastChild
|
||||
}
|
||||
} else {
|
||||
prev = current.parentNode
|
||||
}
|
||||
|
||||
if (!prev || prev === document.body || prev === document.documentElement) break
|
||||
current = prev
|
||||
|
||||
if (current.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = current as Element
|
||||
if (INPUT_TAG_SET.has(el.tagName)) break
|
||||
if (titleElements && titleElements.has(el)) break
|
||||
if (!el.contains(inp) && el.querySelector("input, textarea, select")) {
|
||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) break
|
||||
}
|
||||
}
|
||||
|
||||
if (current.nodeType === Node.TEXT_NODE) {
|
||||
const text = current.textContent?.trim() || ""
|
||||
if (isValidLabelText(text)) {
|
||||
const parentEl = current.parentElement
|
||||
if (parentEl) {
|
||||
return { text, element: parentEl }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ============ 主方法:查找 input 对应的标签文字 ============
|
||||
|
||||
/**
|
||||
* 查找 input/textarea 元素对应的标签文字
|
||||
*
|
||||
* @param inp - 输入框元素
|
||||
* @param titleElements - 大标题元素集合(用于第二遍策略的停止边界,可选)
|
||||
* @returns { labelText, labelElement } 标签文字和标签元素
|
||||
*
|
||||
* 策略流程:
|
||||
* 1. closest(FORM_ITEM_SELS) 找容器
|
||||
* 2. 验证容器内是否同时有标签和输入框,不满足则逐级向上找(最多4层)
|
||||
* 3. 在容器内 querySelectorAll 找 input 前面的第一个有效文字元素
|
||||
* 4. fallback: previousElementSibling 查找
|
||||
* 5. 第二遍:如果以上都未得到含≥2个汉字的标签,启动 walkBackwardForLabel 逆向遍历
|
||||
* 6. 都找不到则返回 placeholder 或 "(未知字段)"
|
||||
*/
|
||||
export function findLabelForInput(inp: Element, titleElements?: Set<Element>): { labelText: string; labelElement: Element | null } {
|
||||
let labelText = ""
|
||||
let labelElement: Element | null = null
|
||||
let container: Element | null = null
|
||||
for (const sel of FORM_ITEM_SELS) {
|
||||
container = inp.closest(sel)
|
||||
if (container) break
|
||||
}
|
||||
|
||||
// 【验证】container 应同时包含"标签文字"和"输入框",否则匹配层级太浅
|
||||
if (container) {
|
||||
const hasInput = container.querySelector("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']), textarea")
|
||||
let hasLabelText = false
|
||||
if (hasInput) {
|
||||
const candidates = container.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||||
for (const el of Array.from(candidates)) {
|
||||
if (el === inp || el.contains(inp)) continue
|
||||
const dt = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
if (dt && dt.length <= 30 && !EXCLUDE_LABEL_TEXTS.some((ex) => dt === ex)) {
|
||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
hasLabelText = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 逐级向上找,最多 4 层
|
||||
if (!hasLabelText) {
|
||||
let parent = container.parentElement
|
||||
for (let up = 0; up < 4 && parent; up++) {
|
||||
const parentCandidates = parent.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||||
let found = false
|
||||
for (const el of Array.from(parentCandidates)) {
|
||||
if (el === inp || el.contains(inp)) continue
|
||||
const dt = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
if (dt && dt.length <= 30 && !EXCLUDE_LABEL_TEXTS.some((ex) => dt === ex)) {
|
||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
container = parent
|
||||
break
|
||||
}
|
||||
parent = parent.parentElement
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 在容器内找标签
|
||||
if (container) {
|
||||
const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||||
for (const el of Array.from(labelEls)) {
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
if (!directText || directText.length > 30) continue
|
||||
if (EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue
|
||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
labelText = directText
|
||||
labelElement = el
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: previousElementSibling
|
||||
if (!labelText) {
|
||||
let prev: Element | null = inp.previousElementSibling
|
||||
for (let i = 0; i < 3 && prev; i++) {
|
||||
const text = prev.textContent?.trim() || ""
|
||||
if (text && text.length <= 20 && !EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) {
|
||||
labelText = text
|
||||
labelElement = prev
|
||||
break
|
||||
}
|
||||
prev = prev.previousElementSibling
|
||||
}
|
||||
}
|
||||
|
||||
// 【第二遍策略】如果第一遍未识别出有效标签(空或不含2个汉字),逆向遍历 DOM
|
||||
if (!labelText || !isValidLabelText(labelText)) {
|
||||
const foundLabel = walkBackwardForLabel(inp, titleElements)
|
||||
if (foundLabel) {
|
||||
labelText = foundLabel.text
|
||||
labelElement = foundLabel.element
|
||||
}
|
||||
}
|
||||
|
||||
// 最终 fallback:都找不到才用 placeholder 或返回"(未知字段)"
|
||||
if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)"
|
||||
return { labelText, labelElement }
|
||||
}
|
||||
Reference in New Issue
Block a user