From bca1ca3182a1746826476ebb878301a3f849d1b6 Mon Sep 17 00:00:00 2001 From: xuxin <15279969124@163.com> Date: Thu, 18 Jun 2026 16:42:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=8C=97=E6=A3=AE=E7=BA=A7?= =?UTF-8?q?=E8=81=94=E9=80=89=E6=8B=A9=E5=99=A8=E6=93=8D=E4=BD=9Cbug?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=94=B9=E6=8F=92=E4=BB=B6=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=A1=AB=E5=86=99?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=A1=AB=E5=86=99=E5=8A=A8=E6=80=81=E8=B7=9F?= =?UTF-8?q?=E8=B8=AA=E8=BF=9B=E5=BA=A6=E5=8C=BA=E3=80=81=E7=AE=80=E5=8E=86?= =?UTF-8?q?=E5=A4=96=E8=A1=A8=E5=8D=95=E4=BF=A1=E6=81=AF=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=8A=9F=E8=83=BD=E5=92=8C=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=9D=A2=E6=9D=BF=E3=80=81=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=94=B6=E8=B5=B7=E3=80=81=E6=8F=92=E4=BB=B6=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icon1.png | Bin 2326 -> 0 bytes assets/logo-offerpai.png | Bin 0 -> 6101 bytes src/components/SidebarPanel.scss | 466 +++++++++++-- src/components/SidebarPanel.tsx | 981 +++++++++++++++++++++------ src/config.ts | 6 +- src/handlers/handleAutoFillBeisen.ts | 380 ++++++++++- src/handlers/handleAutoFillCommon.ts | 118 +++- src/lib/experienceSection.ts | 28 +- src/lib/fillStats.ts | 830 ++++++++++++++++++++++ 9 files changed, 2499 insertions(+), 310 deletions(-) delete mode 100644 assets/icon1.png create mode 100644 assets/logo-offerpai.png create mode 100644 src/lib/fillStats.ts diff --git a/assets/icon1.png b/assets/icon1.png deleted file mode 100644 index 6ae0bc4328bb318ff7edef86804fe8fd82aa48d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2326 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 z(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 + }) + + /** 停止当前循环扫描 */ + 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"; sectionResults?: any[]; expandedResults?: any[] }) => { + stopScanLoop() + const hasFullData = !!(params.sectionResults && params.expandedResults) + // 立即执行一次 + const stats = scanPageFields(params) + setFieldStats(stats) + findUpdatedField(stats) // 初始化快照,不滚动 + if (hasFullData) handleSaveUserInput() + // 每秒循环 + scanIntervalRef.current = setInterval(() => { + const s = scanPageFields(params) + setFieldStats(s) + if (ENABLE_AUTO_SCROLL_TO_UPDATED) { + const updatedEl = findUpdatedField(s) + if (updatedEl) setLastUpdatedFieldEl(updatedEl) + } + if (hasFullData) handleSaveUserInput() + }, 1000) + } + + /** 组件卸载时清理定时器 */ + useEffect(() => { + return () => { stopScanLoop() } + }, []) + + /** 页面加载时检测是否有足够的输入框,显示"准备就绪" */ + useEffect(() => { + 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("准备就绪,请点击开始填写") + return true + } + return false + } + // 立即检测一次 + if (checkInputs()) return + // 页面可能还在加载,延迟重试几次 + const timer1 = setTimeout(checkInputs, 500) + const timer2 = setTimeout(checkInputs, 1500) + const timer3 = setTimeout(checkInputs, 3000) + return () => { + clearTimeout(timer1) + clearTimeout(timer2) + clearTimeout(timer3) + } + }, []) + + /** 字段更新时自动滚动字段列表到对应项 */ + 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(() => { @@ -72,230 +237,441 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) * - 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 生成答案 + * + * 填写完成后更新组件状态,供后续"保存用户修改"和"AI补填"使用 + */ const handleAutoFill = async () => { setFilling(true) + setHeaderStatus("正在填写表单,请勿跳转页面") + // 停止之前的循环扫描(如果有) + stopScanLoop() + // 操作中打开遮罩 + showFillingOverlay() try { // 检测当前页面域名,判断走哪个处理模式 const currentHost = window.location.hostname const isBeisen = currentHost.includes("zhiye.com") // 北森招聘平台域名特征 + const scanSiteMode = isBeisen ? "beisen" as const : undefined + + // 第一次开启循环扫描(不传 sectionResults,阶段A之前就开始展示) + startScanLoop({ siteMode: scanSiteMode }) const fillResult = isBeisen ? await handleAutoFillBeisen({ resumeData, jobInfo }) : await handleAutoFillCommon({ resumeData, jobInfo }) - setPageLang(fillResult.lang) - setIsFormPage(fillResult.isFormPage) - if (fillResult.resumeData) setResumeData(fillResult.resumeData) - setFormFields(fillResult.formFields) - setFillCompleted(true) + // 将处理器返回的结果同步到组件状态 + setPageLang(fillResult.lang) // 页面语言(影响后续标签匹配用中文还是英文) + setIsFormPage(fillResult.isFormPage) // 是否为表单页(非表单页不显示填写相关 UI) + if (fillResult.resumeData) setResumeData(fillResult.resumeData) // 更新简历数据(可能从 mock 切换为接口数据) + setFormFields(fillResult.formFields) // 已匹配的字段列表(用于后续高亮/状态展示) + setFillCompleted(true) // 标记填写流程已完成,解锁"保存修改"等按钮 + setHeaderStatus("表单填写完成,请检查后提交") + hideFillingOverlay() // 填写完成,移除遮罩 + + // 停止第一次循环扫描,开启第二次循环扫描(传入 sectionResults + expandedResults,分段精确) + startScanLoop({ + siteMode: scanSiteMode, + sectionResults: fillResult.sectionResults, + expandedResults: fillResult.expandedResults, + }) + } catch (e) { console.error("OfferPie: 自动填写异常", e) + hideFillingOverlay() // 异常时也移除遮罩 } setTimeout(() => setFilling(false), 1000) } /** - * 模拟提交:重新读取页面上 unfilledFormData 对应字段的当前值,更新到缓存 - * 按大标题范围限定搜索,避免跨区域匹配到错误字段 + * 模拟提交:通过 fillStats 扫描页面字段,过滤出 B2 阶段字段数据,更新到缓存 + * 【注意】弹窗打开时不执行,避免覆盖用户正在编辑的数据 */ const handleSaveUserInput = () => { + // 弹窗打开时直接跳过,不更新缓存 + if (showCacheModal) return try { const cached = localStorage.getItem("offerpie_unfilled_form") if (!cached) { alert("未找到缓存数据,请先执行自动填写"); return } const cacheData = JSON.parse(cached) - const unfilledFormData = cacheData.unfilledFormData as { - title: string - isExperience: boolean - formItems: { label: string; value: string }[] | { label: string; value: string }[][] - }[] - 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])" - 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']", - ] - const EXCLUDE_LABELS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] + // 检测当前网站模式 + const currentHost = window.location.hostname + const siteMode = currentHost.includes("zhiye.com") ? "beisen" as const : undefined - /** - * 在页面上按文字找到大标题元素 - */ - function findTitleElement(titleText: string): Element | null { - 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((n) => n.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent?.trim()) - .filter(Boolean) - .join("") - if (directText === titleText && el.children.length === 0) { - return el - } - node = walker.nextNode() + // 调用 fillStats 扫描页面所有字段 + const titleStats = scanPageFields({ siteMode }) + + // 提取非简历格式字段(B2阶段字段) + 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) { + oldSections.push(newSec) + continue } - return null - } - /** - * 在指定大标题范围内,通过标签文字查找对应 input 的值 - */ - function findInputValueByLabelInRange(labelText: string, titleEl: Element, nextTitleEl: Element | null): string { - const allInputs = document.querySelectorAll(INPUT_SEL) - for (const inp of Array.from(allInputs)) { - // 范围检查 - const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) - const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) - if (!afterTitle || !beforeNext) continue - - // 跳过已被阶段A/B填充的简历格式字段(背景色为绿色高亮) - // greenTwo(#b7ffc6)是阶段B2填的unfilledFormData字段,不跳过,可以更新 - const inputEl = inp as HTMLInputElement | HTMLTextAreaElement - const bgColor = inputEl.style.backgroundColor - if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") continue - - let container: Element | null = null - for (const sel of FORM_ITEM_SELS) { - container = inp.closest(sel) - if (container) break - } - if (!container) continue - 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_LABELS.some((ex) => directText === ex)) continue - // 非经历类型标签必须全名严格匹配,不走 includes - if (directText === labelText && - (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) { - return inputEl.value?.trim() || "" + 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 } - } - } - return "" - } - - /** - * 北森特有:在指定大标题范围内,通过标签文字查找 phoenix-radio-group 的当前选中值 - * DOM 结构:form-item > form-item__title > form-item__text(标签) - * > form-item__control > phoenix-radio-group > phoenix-radio-group__radioItem - * 选中判断:radioItem 子级有 phoenix-radio--checked 类名,取该 radioItem 的文字 - */ - function findRadioGroupValueByLabelInRange(labelText: string, titleEl: Element, nextTitleEl: Element | null): string { - const radioGroups = document.querySelectorAll(".phoenix-radio-group") - for (const rg of Array.from(radioGroups)) { - // 范围检查 - const afterTitle = !!(titleEl.compareDocumentPosition(rg) & Node.DOCUMENT_POSITION_FOLLOWING) - const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(rg) & Node.DOCUMENT_POSITION_PRECEDING) - if (!afterTitle || !beforeNext) continue - - // 找标签文字:form-item__control 同级的 form-item__title 里的 form-item__text - const controlEl = rg.closest(".form-item__control") - if (!controlEl || !controlEl.parentElement) continue - const titleDiv = controlEl.parentElement.querySelector(".form-item__title .form-item__text") - if (!titleDiv) continue - const directText = Array.from(titleDiv.childNodes) - .filter((n) => n.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent?.trim()) - .filter(Boolean) - .join("") - // 标签严格全名匹配 - if (directText !== labelText) continue - - // 找选中的 radioItem - const radioItems = rg.querySelectorAll(".phoenix-radio-group__radioItem") - for (const item of Array.from(radioItems)) { - const checkedEl = item.querySelector(".phoenix-radio--checked") - if (checkedEl) { - return item.textContent?.trim() || "" - } - } - // 找到了 radio group 但没有选中项,返回空 - return "" - } - return "" - } - - // 收集所有标题元素(按 unfilledFormData 顺序) - const titleElements: (Element | null)[] = unfilledFormData.map((s) => findTitleElement(s.title)) - - // 遍历 unfilledFormData,按大标题范围限定搜索 - let updatedCount = 0 - for (let i = 0; i < unfilledFormData.length; i++) { - const section = unfilledFormData[i] - const titleEl = titleElements[i] - if (!titleEl) continue - - // 找下一个大标题作为范围终点 - let nextTitleEl: Element | null = null - for (let j = i + 1; j < titleElements.length; j++) { - if (titleElements[j]) { nextTitleEl = titleElements[j]; break } - } - - if (section.isExperience) { - const segments = section.formItems as { label: string; value: string }[][] - for (const seg of segments) { - for (const field of seg) { - const currentValue = findInputValueByLabelInRange(field.label, titleEl, nextTitleEl) - if (currentValue && currentValue !== field.value) { - field.value = currentValue - updatedCount++ + 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 fields = section.formItems as { label: string; value: string }[] - for (const field of fields) { - // 先尝试从 input/textarea 读取值 - let currentValue = findInputValueByLabelInRange(field.label, titleEl, nextTitleEl) - // 如果没读到,尝试从北森 phoenix-radio-group 单选组读取当前选中值 - if (!currentValue) { - currentValue = findRadioGroupValueByLabelInRange(field.label, titleEl, nextTitleEl) - } - if (currentValue && currentValue !== field.value) { - field.value = currentValue - updatedCount++ + 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) } } } } - // 更新缓存 - cacheData.unfilledFormData = unfilledFormData + cacheData.unfilledFormData = oldSections cacheData.timestamp = Date.now() localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData)) - alert(`已保存!更新了 ${updatedCount} 个字段的值`) - console.log("[OfferPie] 模拟提交:已更新缓存数据", 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 = () => { + try { + const cached = localStorage.getItem("offerpie_unfilled_form") + if (!cached) { + setCacheModalData([]) + } else { + const cacheData = JSON.parse(cached) + 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) + } + + /** 弹窗内保存数据到 localStorage */ + const saveCacheModalData = () => { + try { + const cached = localStorage.getItem("offerpie_unfilled_form") + const cacheData = cached ? JSON.parse(cached) : {} + cacheData.unfilledFormData = cacheModalData || [] + cacheData.timestamp = Date.now() + localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData)) + alert("已保存表单数据") + } catch (e) { + console.error("[OfferPie] 保存缓存数据失败:", e) alert("保存失败,请查看控制台") } } + /** 弹窗内清空缓存数据(带确认) */ + const clearCacheFromModal = () => { + const confirmed = window.confirm("是否清空已记录的表单数据?清空后下次填简历那些数据需要您一个个手动填。") + if (!confirmed) return + localStorage.removeItem("offerpie_unfilled_form") + setCacheModalData([]) + alert("已清空缓存数据") + console.log("[OfferPie] 已清空 offerpie_unfilled_form 缓存") + } + + // ============ 拖拽逻辑 ============ + + /** 鼠标按下:启动长按计时 */ + 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 + + 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 ? ( +
setCollapsed(false)}> + OfferPie +
+ ) : ( + <> + {/* 填写中遮罩:覆盖整个面板,阻止用户操作 */} + {filling &&
} +
+ {/* 新顶部:logo + 提示文字 + 收起箭头 */}
- {isLoggedIn && 反馈} - {/*{isLoggedIn && 设置}*/} -
- {/* 插件标题 */} -
大学生求职助手
- {/* 未登录状态:显示登录提示 */} {!isLoggedIn && isLoggedIn !== null && (
@@ -303,7 +679,6 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) - {/* 填写进度区域 */}
填写进度 - 50% + + {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" && 必填} +
+ ) + }) + } +
+ ) + }) + })()} +
+
+ + ) + })()} + + )} +
+ + {/* 助手已记住的表单 弹窗 */} + {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) ? ( +
暂无缓存数据
+ ) : ( + 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 ( +
+ +