通用模式兼容摩卡和飞书,添加通信工具组件,添加未开通会员面板

This commit is contained in:
2026-07-17 20:04:29 +08:00
parent bca1ca3182
commit bfe13d300b
17 changed files with 1997 additions and 456 deletions
+21
View File
@@ -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')
}
+34
View File
@@ -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
}
})
/** 插件安装事件 */
+155 -50
View File
@@ -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
View File
@@ -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>
+8
View File
@@ -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
View File
@@ -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 和数据库存的原始来源链接不一致
* 但岗位 IDURL 最后一段,如 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 不同(跳转到了表单页),也自动打开面板
+26 -101
View File
@@ -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
+26 -101
View File
@@ -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
+734
View File
@@ -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 }
}
+6 -6
View File
@@ -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: "" },
+161 -5
View File
@@ -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
View File
@@ -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
+221
View File
@@ -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 }
}