/** * 侧边栏面板组件 * 插件的主操作界面,固定在浏览器右上角 * 包含:职位信息卡片、自动填写按钮、简历管理、填写进度等功能区域 */ 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, preloadRegionTree } from "~api/dataApi" import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon" import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen" import { handleAutoFillMoka } from "~handlers/handleAutoFillMoka" import { handleAutoFillFeishu } from "~handlers/handleAutoFillFeishu" import { handleAutoFillHotjob } from "~handlers/handleAutoFillHotjob" import { scanPageFields, extractNonResumeFields, printFieldStats, collectPickerOptionTexts, getPickerDisplayValue } from "~lib/fillStats" import type { TitleStat } from "~lib/fillStats" import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types" import { createChannelBridge } from "~lib/channelBridge" import { buildResumeExcludeTexts } from "~lib/resumeDataHelper" 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 { /** 当前标签页的来源链接 */ sourceUrl: string /** 岗位信息(由 sidebar 层查询后传入) */ jobInfo: JobInfo | null /** 关闭面板的回调函数 */ onClose: () => void } export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) { /** 是否已登录(有 Token) */ const [isLoggedIn, setIsLoggedIn] = useState(null) /** 是否为会员(null=未检测,true=会员,false=非会员) */ const [isMember, setIsMember] = useState(null) /** 是否正在执行自动填写 */ const [filling, setFilling] = useState(false) /** 页面语言类型:中文 / 英文 */ const [pageLang, setPageLang] = useState<"zh" | "en">("zh") /** 是否为职位申请表单页面 */ const [isFormPage, setIsFormPage] = useState(false) /** 匹配到的表单字段列表 */ const [formFields, setFormFields] = useState([]) /** 当前使用的简历数据 */ const [resumeData, setResumeData] = useState(null) /** 自动填写流程是否已完成(控制模拟提交按钮可用) */ const [fillCompleted, setFillCompleted] = useState(false) /** 页面字段扫描统计结果(用于面板底部展示已填/待填字段列表) */ const [fieldStats, setFieldStats] = useState([]) /** 顶部状态文字 */ const [headerStatus, setHeaderStatus] = useState("准备中, 请登录到投递表单页~") /** 是否启用字段更新时自动滚动到该字段,模拟跟踪(常量开关,方便调试关闭) */ const ENABLE_AUTO_SCROLL_TO_UPDATED = true /** 循环扫描定时器引用 */ const scanIntervalRef = useRef | null>(null) /** 上一次扫描结果快照(用于对比哪些字段刚刚更新) */ const prevFieldSnapshotRef = useRef>(new Map()) /** 字段列表容器 ref(用于滚动) */ const fieldListRef = useRef(null) /** 最新一次更新的字段 inputElement(用于定位滚动目标) */ const [lastUpdatedFieldEl, setLastUpdatedFieldEl] = useState(null) /** 是否显示"助手已记住的表单"弹窗 */ const [showCacheModal, setShowCacheModal] = useState(false) /** 弹窗中编辑的表单数据(从 localStorage 读取后在弹窗内编辑) */ const [cacheModalData, setCacheModalData] = useState(null) /** 面板是否收起(收起时只显示 logo 圆圈) */ const [collapsed, setCollapsed] = useState(false) /** 拖拽位置偏移量(相对于初始位置) */ const [dragOffset, setDragOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) /** 是否已经被拖动过(用于判断是否显示拖拽提示气泡) */ const [hasDragged, setHasDragged] = useState(false) /** 拖拽提示:收起状态已提示过 */ const collapsedTooltipShownRef = useRef(false) /** 拖拽提示:展开状态已提示过 */ const expandedTooltipShownRef = useRef(false) /** 是否正在显示拖拽提示气泡 */ const [showDragTooltip, setShowDragTooltip] = useState(false) /** 拖拽提示气泡定时器 */ const dragTooltipTimerRef = useRef | null>(null) /** 面板容器 ref(用于拖拽) */ const containerRef = useRef(null) /** 拖拽状态 ref */ const dragStateRef = useRef<{ isDragging: boolean; startX: number; startY: number; startOffsetX: number; startOffsetY: number; longPressTimer: ReturnType | null; isLongPress: boolean }>({ isDragging: false, startX: 0, startY: 0, startOffsetX: 0, startOffsetY: 0, longPressTimer: null, isLongPress: false }) /** 选择器选项文字缓存(collectPickerOptionTexts 收集后存入,handleSaveUserInput 用于排除) */ const pickerOptionTextsRef = useRef>(new Set()) /** 停止当前循环扫描 */ const stopScanLoop = () => { if (scanIntervalRef.current) { clearInterval(scanIntervalRef.current) scanIntervalRef.current = null } } /** 对比前后快照,找出刚刚变为已填写的字段 */ const findUpdatedField = (newStats: TitleStat[]): Element | null => { const prevMap = prevFieldSnapshotRef.current let updatedEl: Element | null = null const newMap = new Map() for (const ts of newStats) { for (const f of ts.fields) { if (!f.inputElement) continue const newColor = f.color newMap.set(f.inputElement, newColor) const prevColor = prevMap.get(f.inputElement) // 从非绿变成绿/greenTwo → 刚刚被填写(取最后一个变化的) if (prevColor && prevColor !== "green" && prevColor !== "greenTwo" && (newColor === "green" || newColor === "greenTwo")) { updatedEl = f.inputElement } } } prevFieldSnapshotRef.current = newMap return updatedEl } /** 开启循环扫描(每秒执行一次) */ const startScanLoop = (params: { siteMode?: "beisen" | "moka" | "feishu" | "hotjob"; sectionResults?: any[]; expandedResults?: any[]; excludeTexts?: Set }) => { stopScanLoop() const hasFullData = !!(params.sectionResults && params.expandedResults) /** * 同步构建排除集合:合并 params.excludeTexts + 当前 resumeData 中的所有值 * 避免将已填入表单的简历值(如"汉族"、"广州"等)误认为表单标签 */ const buildExcludeTexts = (): Set => { const baseSet = buildResumeExcludeTexts(resumeData) // 合并外部传入的静态排除集合(第二次调用时由 fillResult.resumeData 构建) if (params.excludeTexts) { for (const t of params.excludeTexts) baseSet.add(t) } return baseSet } /** * 补充选择器展示值:遍历已识别字段,对 value 为空的字段调用 getPickerDisplayValue * 从 DOM 中提取展示文字与已收集的弹出层选项比对,匹配成功则更新字段 value */ const fillPickerDisplayValues = (fieldStats: TitleStat[]) => { const options = pickerOptionTextsRef.current if (options.size === 0) return // 北森模式(zhiye.com)的选择器值直接写在输入框里,不需要额外提取展示值 if (window.location.hostname.includes("zhiye.com")) return for (const ts of fieldStats) { for (let fIdx = 0; fIdx < ts.fields.length; fIdx++) { const f = ts.fields[fIdx] // 已有值的跳过 if (f.value) continue if (!f.inputElement) continue const inputEl = f.inputElement as HTMLElement // input.value 有值的跳过 if ((inputEl as HTMLInputElement).value?.trim()) continue // 找同大标题内下一个字段的标签文字作为边界 const nextField = ts.fields[fIdx + 1] const nextLabelText = nextField?.labelText || null // 调用 getPickerDisplayValue 比对 const displayVal = getPickerDisplayValue(inputEl, f.labelText, nextLabelText, options) if (displayVal) { f.value = displayVal f.filled = true f.color = "green" } } } } // 立即执行一次 const stats = scanPageFields({ ...params, excludeTexts: buildExcludeTexts() }) // 补充选择器展示值:对 value 为空的字段,从 DOM 中提取展示文字与选项比对 fillPickerDisplayValues(stats) setFieldStats(stats) findUpdatedField(stats) // 初始化快照,不滚动 if (hasFullData) handleSaveUserInput() // 每秒循环 scanIntervalRef.current = setInterval(() => { const s = scanPageFields({ ...params, excludeTexts: buildExcludeTexts() }) // 补充选择器展示值 fillPickerDisplayValues(s) setFieldStats(s) if (ENABLE_AUTO_SCROLL_TO_UPDATED) { const updatedEl = findUpdatedField(s) if (updatedEl) setLastUpdatedFieldEl(updatedEl) } if (hasFullData) handleSaveUserInput() }, 1000) } /** 组件卸载时清理定时器 */ useEffect(() => { return () => { stopScanLoop() } }, []) /** 页面加载 & SPA 路由变化时检测是否有足够的输入框,显示"准备就绪" */ useEffect(() => { const hostDoc = document.getRootNode() === document ? document : (document.getRootNode() as ShadowRoot).ownerDocument /** 检测页面是否有足够的表单输入框 */ const checkInputs = () => { const inputs = hostDoc.querySelectorAll("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='file']), textarea") if (inputs.length > 2) { setHeaderStatus("准备就绪,请点击开始填写") return true } return false } /** 路由变化后延迟多次重试检测(等待新页面 DOM 渲染) */ const timers: ReturnType[] = [] 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 () => { clearInterval(urlPollInterval) timers.forEach(clearTimeout) } }, []) /** 字段更新时自动滚动字段列表到对应项 */ useEffect(() => { if (!ENABLE_AUTO_SCROLL_TO_UPDATED || !lastUpdatedFieldEl || !fieldListRef.current) return // 计算该字段在渲染中的全局索引(按渲染顺序:按大标题 → 按段 → 按字段) let globalIdx = 0 let targetIdx = -1 for (const ts of fieldStats) { if (ts.fields.length === 0) continue if (ts.isExpType && ts.segmentCount > 1) { for (let sIdx = 0; sIdx < ts.segmentCount; sIdx++) { const segFields = ts.fields.filter((f) => f.segmentIndex === sIdx) for (const f of segFields) { if (f.inputElement === lastUpdatedFieldEl) { targetIdx = globalIdx } globalIdx++ } } } else { for (const f of ts.fields) { if (f.inputElement === lastUpdatedFieldEl) { targetIdx = globalIdx } globalIdx++ } } if (targetIdx >= 0) break } if (targetIdx >= 0) { const targetItem = fieldListRef.current.querySelector(`[data-field-idx="${targetIdx}"]`) if (targetItem) { // 滚动到居中位置再往上偏移30px,让下一个字段更接近视口中心(模拟实时跟踪效果) const container = fieldListRef.current const itemRect = targetItem.getBoundingClientRect() const containerRect = container.getBoundingClientRect() const offsetTop = itemRect.top - containerRect.top + container.scrollTop const targetScroll = offsetTop - container.clientHeight / 2 + itemRect.height / 2 + 50 container.scrollTo({ top: Math.max(0, targetScroll), behavior: "smooth" }) } } setLastUpdatedFieldEl(null) }, [lastUpdatedFieldEl]) /** 页面加载时检查 Token,有岗位信息则查询定制简历,并检查会员状态 */ useEffect(() => { getCookieValue("Token").then((token) => { console.log("[OfferPie] SidebarPanel 获取到的 Token:", token) setIsLoggedIn(!!token) if (!token) { setHeaderStatus("请登录后继续使用") return } // 预加载省市区行政区划数据(供摩卡级联选择器使用) preloadRegionTree() // 查询会员状态 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 const mappedData: ResumeData = { main: resumeRes.resume || {}, education: resumeRes.education || [], work: resumeRes.work || [], internship: resumeRes.internship || [], project: resumeRes.project || [], competition: resumeRes.competition || [], } setResumeData(mappedData) }).catch((err) => { console.warn("[OfferPie] 查询定制简历失败:", err) }) } }) }, [jobInfo?.id]) /** * 自动填写按钮点击处理 * 内部根据当前页面域名判断走哪个处理模式: * - handleAutoFillBeisen:北森模式(域名包含 zhiye.com,如 avicsz.zhiye.com) * - handleAutoFillCommon:通用模式(其他所有网站) * - 后续特殊网站会在此处加条件分支(如根据 domain 或 jobInfo 来源判断) */ /** 显示全页面填写遮罩层(黑色半透明 + 提示文字 + 闪烁圆点动画) */ function showFillingOverlay() { // 防止重复创建 if (document.getElementById("offerpie-filling-overlay")) return const overlay = document.createElement("div") overlay.id = "offerpie-filling-overlay" Object.assign(overlay.style, { position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh", backgroundColor: "rgba(0, 0, 0, 0.05)", zIndex: "2147483646", display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "auto", }) // 提示文字容器 const content = document.createElement("div") Object.assign(content.style, { display: "flex", alignItems: "center", gap: "4px", userSelect: "none", }) // 文字 const text = document.createElement("span") text.textContent = "填写中..." Object.assign(text.style, { color: "rgba(255,255,255, 1)", background:"rgba(107, 114, 128, 0.5)", height:"40px", "line-height":"40px", width:"370px", "text-align":"center", "border-radius":"4px", fontSize: "18px", fontWeight: "500", letterSpacing: "1px", }) content.appendChild(text) // 5个按顺序消失闪烁的圆点 for (let i = 0; i < 5; i++) { const dot = document.createElement("span") Object.assign(dot.style, { display: "inline-block", width: "6px", height: "6px", borderRadius: "50%", backgroundColor: "rgba(82, 202, 209, 0.3)", marginLeft: i === 0 ? "6px" : "3px", animation: `offerpie-dot-blink 1.5s ${i * 0.3}s infinite`, }) content.appendChild(dot) } overlay.appendChild(content) // 注入关键帧动画样式(按顺序消失闪烁) if (!document.getElementById("offerpie-dot-style")) { const style = document.createElement("style") style.id = "offerpie-dot-style" style.textContent = ` @keyframes offerpie-dot-blink { 0%, 20% { opacity: 1; } 40%, 100% { opacity: 0; } } ` document.head.appendChild(style) } document.body.appendChild(overlay) } /** 移除全页面填写遮罩层(先显示完成提示,2秒后再移除) */ function hideFillingOverlay() { const overlay = document.getElementById("offerpie-filling-overlay") if (!overlay) return // 找到提示文字元素,更新为完成状态 const text = overlay.querySelector("span") as HTMLElement | null if (text) { text.textContent = "填写完成,请检查" text.style.background = "rgba(118, 213, 132, 0.5)" } // 隐藏闪烁圆点 const dots = overlay.querySelectorAll("span:not(:first-child)") dots.forEach((dot) => { ;(dot as HTMLElement).style.display = "none" }) // 2秒后移除遮罩层 setTimeout(() => { overlay.remove() }, 2000) } /** * 自动填写入口 * 根据当前页面域名分发到不同的处理器: * - 北森系统(zhiye.com)→ handleAutoFillBeisen:适配北森 Phoenix UI 组件库的特殊 DOM 结构 * - 其他招聘网站 → handleAutoFillCommon:通用模式,基于 DOM 差异对比适配各种 UI 框架 * * 两个处理器内部流程一致: * 1. extractDomStructure 提取页面 DOM 树结构 * 2. detectPageLanguage 检测中/英文 * 3. isJobApplicationForm 判断是否为求职表单页(非表单页直接跳过) * 4. 获取简历数据(接口优先,无接口时 fallback 到 mock 数据) * 5. locateExperienceSections 定位5大经历区块(教育/工作/实习/项目/竞赛) * 6. expandExperienceSections 对比简历段数,点击添加按钮补足经历段 * 7. 逐段匹配字段 + 填充(走 fillMatchedField 统一入口) * 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 () => { setFilling(true) setHeaderStatus("正在填写表单,请勿跳转页面") // 停止之前的循环扫描(如果有) stopScanLoop() // 操作中打开遮罩 showFillingOverlay() try { // 检测当前页面域名,判断走哪个处理模式 const currentHost = window.location.hostname const isBeisen = currentHost.includes("zhiye.com") // 北森招聘平台域名特征 const isMoka = currentHost.includes("mokahr.com") // 摩卡招聘平台域名特征 const isFeishu = currentHost.includes("feishu.cn") // 飞书招聘平台域名特征 const isHotjob = currentHost.includes("hotjob.cn") // Hotjob招聘平台域名特征 const scanSiteMode = isBeisen ? "beisen" as const : isMoka ? "moka" as const : isFeishu ? "feishu" as const : isHotjob ? "hotjob" as const : undefined // 第一次开启循环扫描(不传 sectionResults,阶段A之前就开始展示) startScanLoop({ siteMode: scanSiteMode }) const fillResult = isBeisen ? await handleAutoFillBeisen({ resumeData, jobInfo }) : isMoka ? await handleAutoFillMoka({ resumeData, jobInfo }) : isFeishu ? await handleAutoFillFeishu({ resumeData, jobInfo }) : isHotjob ? await handleAutoFillHotjob({ resumeData, jobInfo }) : await handleAutoFillCommon({ resumeData, jobInfo }) // 将处理器返回的结果同步到组件状态 setPageLang(fillResult.lang) // 页面语言(影响后续标签匹配用中文还是英文) setIsFormPage(fillResult.isFormPage) // 是否为表单页(非表单页不显示填写相关 UI) if (fillResult.resumeData) setResumeData(fillResult.resumeData) // 更新简历数据(可能从 mock 切换为接口数据) setFormFields(fillResult.formFields) // 已匹配的字段列表(用于后续高亮/状态展示) setFillCompleted(true) // 标记填写流程已完成,解锁"保存修改"等按钮 setHeaderStatus("表单填写完成,请检查后提交") hideFillingOverlay() // 填写完成,移除遮罩 // 停止第一次循环扫描,开启第二次循环扫描(传入 sectionResults + expandedResults,分段精确) // 构建排除集合:从简历数据中提取所有值,避免将已填值误认为标签 const scanExcludeTexts = new Set() const scanResumeData = fillResult.resumeData || resumeData if (scanResumeData) { const main = scanResumeData.main if (main) { for (const val of Object.values(main)) { if (typeof val === "string" && val.trim()) scanExcludeTexts.add(val.trim()) if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) scanExcludeTexts.add(v.trim()) }) } } const expKeys: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"] for (const sec of expKeys) { const items = scanResumeData[sec] if (!Array.isArray(items)) continue for (const item of items) { for (const val of Object.values(item as Record)) { if (typeof val === "string" && val.trim()) scanExcludeTexts.add(val.trim()) } } } } // 收集页面上所有选择器字段的下拉选项文字,合并到排除集合 // 避免选择器已选值(如"维吾尔族")被误认为表单标签 // 北森模式(zhiye.com)选择器值直接写在输入框里,跳过整套弹出层收集逻辑 if (!isBeisen) { const preFieldStats = scanPageFields({ siteMode: scanSiteMode, sectionResults: fillResult.sectionResults, expandedResults: fillResult.expandedResults, excludeTexts: scanExcludeTexts, }) const pickerOptionTexts = await collectPickerOptionTexts(preFieldStats) for (const t of pickerOptionTexts) scanExcludeTexts.add(t) // 存入 ref,供 handleSaveUserInput 使用 pickerOptionTextsRef.current = pickerOptionTexts } startScanLoop({ siteMode: scanSiteMode, sectionResults: fillResult.sectionResults, expandedResults: fillResult.expandedResults, excludeTexts: scanExcludeTexts, }) } catch (e) { console.error("OfferPie: 自动填写异常", e) hideFillingOverlay() // 异常时也移除遮罩 } setTimeout(() => setFilling(false), 1000) } /** * 模拟提交:通过 fillStats 扫描页面字段,过滤出 B2 阶段字段数据,更新到缓存 * 【注意】弹窗打开时不执行,避免覆盖用户正在编辑的数据 */ const handleSaveUserInput = async () => { // 弹窗打开时直接跳过,不更新缓存 if (showCacheModal) return try { const cacheData = await storageGet("offerpie_unfilled_form") if (!cacheData) { return } // 检测当前网站模式 const currentHost = window.location.hostname const siteMode = currentHost.includes("zhiye.com") ? "beisen" as const : currentHost.includes("mokahr.com") ? "moka" as const : currentHost.includes("feishu.cn") ? "feishu" as const : currentHost.includes("hotjob.cn") ? "hotjob" as const : undefined // 从简历数据和缓存数据中提取所有已填值,构建排除集合 // 避免 findLabelForInput 把这些值文字误认为是表单字段标签 const excludeTexts = new Set() if (resumeData) { // 主表字段值 const main = resumeData.main if (main) { for (const val of Object.values(main)) { if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim()) if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) excludeTexts.add(v.trim()) }) } } // 5大经历字段值 const expSections: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"] for (const sec of expSections) { const items = resumeData[sec] if (!Array.isArray(items)) continue for (const item of items) { for (const val of Object.values(item as Record)) { if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim()) } } } } // 缓存中已记住的表单字段值 if (cacheData.unfilledFormData) { for (const sec of cacheData.unfilledFormData as any[]) { const items = sec.formItems if (!items) continue if (sec.isExperience && Array.isArray(items)) { for (const seg of items) { if (Array.isArray(seg)) { for (const f of seg) { if (f.value && typeof f.value === "string") excludeTexts.add(f.value.trim()) } } } } else if (Array.isArray(items)) { for (const f of items) { if (f.value && typeof f.value === "string") excludeTexts.add(f.value.trim()) } } } } // 合并选择器选项文字(collectPickerOptionTexts 收集的下拉选项) for (const t of pickerOptionTextsRef.current) excludeTexts.add(t) // 调用 fillStats 扫描页面所有字段 const titleStats = scanPageFields({ siteMode, excludeTexts }) // 补充选择器展示值(与 startScanLoop 逻辑一致) const pickerOpts = pickerOptionTextsRef.current if (pickerOpts.size > 0) { for (const ts of titleStats) { for (let fIdx = 0; fIdx < ts.fields.length; fIdx++) { const f = ts.fields[fIdx] if (f.value) continue if (!f.inputElement) continue if ((f.inputElement as HTMLInputElement).value?.trim()) continue const nextField = ts.fields[fIdx + 1] const nextLabelText = nextField?.labelText || null const displayVal = getPickerDisplayValue(f.inputElement as HTMLElement, f.labelText, nextLabelText, pickerOpts) if (displayVal) { f.value = displayVal f.filled = true } } } } /** * 过滤日期/时间字段值中的年龄后缀,如 "1997-05 (29岁)" → "1997-05" * 匹配中英文括号包裹的"数字+岁/years old"格式 */ const stripAgeSuffix = (label: string, value: string): string => { if (!value) return value const dateKeywords = ["日期", "时间", "年月", "生日", "出生", "date", "birth", "年龄"] const isDateField = dateKeywords.some((k) => label.toLowerCase().includes(k.toLowerCase())) if (!isDateField) return value // 去掉中英文括号包裹的年龄信息:(29岁)(29岁)(29 years old) 等 return value.replace(/\s*[(\uff08]\s*\d+\s*(岁|years?\s*old)\s*[)\uff09]/gi, "").trim() } /** * 地区类字段空格转斜杠:如 "山西 大同市" → "山西/大同市" * 适用于:籍贯、户口、所在地、出生地、工作地等地区相关字段 * @param label - 字段标签名(用于判断是否为地区字段) * @param value - 字段当前值 * @returns 处理后的值(非地区字段原样返回) */ const formatRegionValue = (label: string, value: string): string => { if (!value) return value const regionKeywords = ["籍贯", "户口", "所在地", "出生地", "工作地", "居住地", "家庭地址", "户籍", "地区", "城市", "省市", "location", "birthplace", "hometown", "residence"] const isRegionField = regionKeywords.some((k) => label.toLowerCase().includes(k.toLowerCase())) if (!isRegionField) return value // 将空格替换为 /(如 "山西 大同市" → "山西/大同市") return value.replace(/\s+/g, "/") } // 提取非简历格式字段(B2阶段字段) const nonResumeData = extractNonResumeFields(titleStats) // 更新缓存中的 unfilledFormData // 策略:新数据中有的字段用新值替换,新数据中没有但旧数据中有的字段保留 const oldSections = cacheData.unfilledFormData as any[] || [] for (const newSec of nonResumeData) { const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title) if (oldSecIdx < 0) { oldSections.push(newSec) } 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 { // 新字段更新值,旧字段中有但新字段中没有的保留 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 = formatRegionValue(nf.label || "", stripAgeSuffix(nf.label || "", nf.value)) } else { oldFields.push(nf) } } } } } 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 = formatRegionValue(nf.label || "", stripAgeSuffix(nf.label || "", nf.value)) } else { oldFields.push(nf) } } } else { // 类型变化,直接替换 oldSections[oldSecIdx] = newSec } } } cacheData.unfilledFormData = oldSections cacheData.timestamp = Date.now() await storageSet("offerpie_unfilled_form", cacheData) // 打印所有字段数据 printFieldStats(titleStats) } catch (e) { console.error("[OfferPie] 模拟提交失败:", e) } } /** 打开"助手已记住的表单"弹窗,从 chrome.storage 加载数据 */ const openCacheModal = async () => { try { const cacheData = await storageGet("offerpie_unfilled_form") if (!cacheData) { setCacheModalData([]) } else { setCacheModalData(cacheData.unfilledFormData || []) } } catch { setCacheModalData([]) } setShowCacheModal(true) } /** 关闭弹窗 */ const closeCacheModal = () => { setShowCacheModal(false) setCacheModalData(null) } /** 弹窗内更新某个字段的值 */ const updateCacheFieldValue = (sectionIdx: number, segIdx: number | null, fieldIdx: number, newValue: string) => { if (!cacheModalData) return const updated = [...cacheModalData] const section = updated[sectionIdx] if (segIdx !== null) { // 经历类型:formItems 是二维数组 ;(section.formItems as any[][])[segIdx][fieldIdx].value = newValue } else { // 普通类型:formItems 是一维数组 ;(section.formItems as any[])[fieldIdx].value = newValue } setCacheModalData(updated) } /** 弹窗内保存数据到 chrome.storage */ const saveCacheModalData = async () => { try { const cacheData = await storageGet("offerpie_unfilled_form") || {} cacheData.unfilledFormData = cacheModalData || [] cacheData.timestamp = Date.now() await storageSet("offerpie_unfilled_form", cacheData) alert("已保存表单数据") } catch (e) { console.error("[OfferPie] 保存缓存数据失败:", e) alert("保存失败,请查看控制台") } } /** 弹窗内清空缓存数据(带确认) */ const clearCacheFromModal = async () => { const confirmed = window.confirm("是否清空已记录的表单数据?清空后下次填简历那些数据需要您一个个手动填。") if (!confirmed) return // 先停止循环扫描,防止 handleSaveUserInput 又把数据写回去 stopScanLoop() await storageRemove("offerpie_unfilled_form") setCacheModalData([]) alert("已清空缓存数据") console.log("[OfferPie] 已清空 offerpie_unfilled_form 缓存") } // ============ 拖拽逻辑 ============ /** 即时拖拽:收起状态横条 / 展开状态顶部 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, .op-header, .op-collapsed-bar")) return e.preventDefault() const state = dragStateRef.current state.startX = e.clientX state.startY = e.clientY state.startOffsetX = dragOffset.x state.startOffsetY = dragOffset.y state.isLongPress = false // 300ms 后认为是长按,开始拖拽 state.longPressTimer = setTimeout(() => { state.isLongPress = true state.isDragging = true if (containerRef.current) { containerRef.current.style.cursor = "grabbing" } }, 300) const handleMouseMove = (ev: MouseEvent) => { if (!state.isLongPress) { // 还没触发长按,如果移动太远就取消(避免误触) const dist = Math.abs(ev.clientX - state.startX) + Math.abs(ev.clientY - state.startY) if (dist > 5 && state.longPressTimer) { clearTimeout(state.longPressTimer) state.longPressTimer = null } return } // 长按中拖拽 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 = () => { if (state.longPressTimer) { clearTimeout(state.longPressTimer) state.longPressTimer = null } state.isDragging = 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]) /** 鼠标移入:显示拖拽提示气泡(收起状态提示1次,展开状态提示1次,拖动过后不再提示) */ const handleMouseEnter = useCallback(() => { if (hasDragged) return if (collapsed) { if (collapsedTooltipShownRef.current) return collapsedTooltipShownRef.current = true } else { if (expandedTooltipShownRef.current) return expandedTooltipShownRef.current = true } setShowDragTooltip(true) if (dragTooltipTimerRef.current) clearTimeout(dragTooltipTimerRef.current) dragTooltipTimerRef.current = setTimeout(() => { setShowDragTooltip(false) }, 3000) }, [hasDragged, collapsed]) /** 组件卸载时清理拖拽提示定时器 */ useEffect(() => { return () => { if (dragTooltipTimerRef.current) clearTimeout(dragTooltipTimerRef.current) } }, []) return (
{/* 拖拽提示气泡 */} {showDragTooltip && (
助手位置可以拖动
)} {/* 收起状态:显示 logo + 提示文字 + 向下箭头 */} {collapsed ? (
OfferPie {headerStatus || "Offer派助手"} setCollapsed(false)} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
) : ( <> {/* 填写中遮罩:覆盖整个面板,阻止用户操作 */} {filling &&
}
{/* 新顶部:logo + 提示文字 + 收起箭头(按住即可拖动) */}
OfferPie {headerStatus || "Offer派助手"}
{/* 未登录状态:显示登录提示 */} {!isLoggedIn && isLoggedIn !== null && (
登录 Offer 派,开启智能投递
登录后将自动同步你的会员状态、简历和求职信息
{ window.location.reload() }} > 已完成登录?刷新登录状态
)} {/* 已登录状态:显示完整功能 */} {isLoggedIn && ( <> {/* 非会员状态:显示开通会员提示 */} {isMember === false && (
智能投递助手为会员专享功能
开通会员后,即可自动识别并填写网申表单,减少重复操作
{ window.location.reload() }} > 已开通会员?刷新会员状态
)} {/* 会员状态:显示完整功能 */} {isMember === true && ( <> {/* 职位信息卡片 */}
{jobInfo?.title || "未匹配到岗位信息"}
{jobInfo ? [jobInfo.companyName || jobInfo.companyShortName] .filter(Boolean) .join(" · ") || "—" : "当前页面暂未关联岗位"}
{jobInfo?.matchScore != null && (
{jobInfo.matchScore}%
)}
{/* 自动填写按钮区域 */} {fillCompleted ? (
填写完成,请检查
) : ( )} {/* 操作链接 */}
{ const cachedUrl = sessionStorage.getItem("offerpie_source_url") || sourceUrl if (!cachedUrl) return try { // 通过跨域 CSL 模式读取投递链接列表 const result = await panelBridge.get(appConfig.bridgeDomain, "offerpaiDeliveryLinkList", true) const linkList = result?.linkList || [] // 检查当前投递链接是否已在列表中 if (!linkList.includes(cachedUrl)) { linkList.push(cachedUrl) } // 写回跨域通信(让网页端能读到) await panelBridge.put(appConfig.bridgeDomain, "offerpaiDeliveryLinkList", { linkList }, true) console.log("[OfferPie] 已更新投递链接列表:", linkList) } catch (err) { console.warn("[OfferPie] 更新投递链接列表失败:", err) } // 跳转到投递链接 window.location.href = cachedUrl }}>打开投递链接 助手已记住的表单
{/* 简历区域 */}
简历
{resumeData?.main?.name ? '已优化' : ""}
{resumeData?.main?.name || "暂无简历"}.pdf
{/* 按钮 */} {/* 填写进度区域 */}
填写进度 {fieldStats.length > 0 ? `${fieldStats.reduce((s, t) => s + t.fields.filter((f) => f.color === "green" || f.color === "greenTwo").length, 0)}/${fieldStats.reduce((s, t) => s + t.fields.length, 0)}` : "0/0"}
0 ? `${Math.round(fieldStats.reduce((s, t) => s + t.fields.filter((f) => f.color === "green" || f.color === "greenTwo").length, 0) / Math.max(fieldStats.reduce((s, t) => s + t.fields.length, 0), 1) * 100)}%` : "0%" }} />
{/* 已识别字段列表 */} {fieldStats.length > 0 && (() => { /** 点击字段名时滚动到对应输入框 */ const scrollToField = (el: Element | null) => { if (!el || !(el instanceof HTMLElement)) return el.scrollIntoView({ behavior: "smooth", block: "center" }) // 闪烁高亮提示 const origOutline = el.style.outline el.style.outline = "2px solid #52cad1" setTimeout(() => { el.style.outline = origOutline }, 1500) } return ( <> {/* 已识别字段区域(合并展示所有字段) */}
已识别字段 (剩余必填:{fieldStats.reduce((s, t) => s + t.fields.filter((f) => f.color === "red").length, 0)})
{(() => { let globalIdx = 0 return fieldStats.map((ts) => { if (ts.fields.length === 0) return null return (
{ts.titleText}
{ts.isExpType && ts.segmentCount > 1 ? Array.from({ length: ts.segmentCount }, (_, sIdx) => { const segFields = ts.fields.filter((f) => f.segmentIndex === sIdx) if (segFields.length === 0) return null return (
经历{sIdx + 1}
{segFields.map((f, idx) => { const isFilled = f.color === "green" || f.color === "greenTwo" const fieldIdx = globalIdx++ return (
scrollToField(f.inputElement)} > {isFilled ? ( ) : ( )} {f.labelText} {f.color === "red" && 必填}
) })}
) }) : ts.fields.map((f, idx) => { const isFilled = f.color === "green" || f.color === "greenTwo" const fieldIdx = globalIdx++ return (
scrollToField(f.inputElement)} > {isFilled ? ( ) : ( )} {f.labelText} {f.color === "red" && 必填}
) }) }
) }) })()}
) })()} )} )} {/* 版本号(未登录或非会员时隐藏) */} {isLoggedIn && isMember !== false && (
ver: {appConfig.pluginVersion}
)}
{/* 助手已记住的表单 弹窗 */} {showCacheModal && (() => { // 因为 op-container 有 transform,内部的 fixed 定位会相对于容器而非视口 // 需要计算反向偏移让弹窗遮罩覆盖整个视口 const containerEl = containerRef.current const rect = containerEl?.getBoundingClientRect() const maskStyle: React.CSSProperties = rect ? { position: "fixed" as const, top: -rect.top, left: -rect.left, width: "100vw", height: "100vh", } : {} return (
e.stopPropagation()}>
助手已记住的表单
{(!cacheModalData || cacheModalData.length === 0) ? (
暂无缓存数据
) : (
{/* 左侧竖直 tab 导航 */}
{cacheModalData.map((section: any, sIdx: number) => (
{ const contentEl = containerRef.current?.querySelector(".op-cache-modal-content") const targetEl = contentEl?.querySelector(`[data-cache-section-idx="${sIdx}"]`) if (targetEl) targetEl.scrollIntoView({ behavior: "smooth", block: "start" }) }} > {section.title}
))}
{/* 右侧内容区域 */}
{cacheModalData.map((section: any, sIdx: number) => (
{section.title}
{section.isExperience ? ( // 经历类型:formItems 是二维数组,每段经历分开展示 (section.formItems as any[][]).map((segFields: any[], segIdx: number) => (
经历{segIdx + 1}
{segFields.map((field: any, fIdx: number) => { // 标签名包含"内容"二字的字段用 textarea 单独一行 const isLongText = field.label && field.label.includes("内容") if (isLongText) { return (