/** * 侧边栏面板组件 * 插件的主操作界面,固定在浏览器右上角 * 包含:职位信息卡片、自动填写按钮、简历管理、填写进度等功能区域 */ import { useState, useEffect, useRef, useCallback } from "react" import { getCookieValue } from "~utils/cookie" import { getCustomizeResume } from "~api/aiApi" 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 logoImg from "data-base64:~/../assets/logo-offerpai.png" import "./SidebarPanel.scss" /** 侧边栏面板的 Props */ interface SidebarPanelProps { /** 当前标签页的来源链接 */ sourceUrl: string /** 岗位信息(由 sidebar 层查询后传入) */ jobInfo: JobInfo | null /** 关闭面板的回调函数 */ onClose: () => void } export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) { /** 是否已登录(有 Token) */ const [isLoggedIn, setIsLoggedIn] = 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 }) /** 停止当前循环扫描 */ 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(() => { getCookieValue("Token").then((token) => { console.log("[OfferPie] SidebarPanel 获取到的 Token:", token) setIsLoggedIn(!!token) // 有 Token 且有岗位 ID 时,查询定制简历 if (token && 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 生成答案 * * 填写完成后更新组件状态,供后续"保存用户修改"和"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) // 是否为表单页(非表单页不显示填写相关 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) } /** * 模拟提交:通过 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 currentHost = window.location.hostname const siteMode = currentHost.includes("zhiye.com") ? "beisen" as const : undefined // 调用 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 } 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) } } } } cacheData.unfilledFormData = oldSections cacheData.timestamp = Date.now() localStorage.setItem("offerpie_unfilled_form", JSON.stringify(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 + 提示文字 + 收起箭头 */}
OfferPie {headerStatus}
{/* 未登录状态:显示登录提示 */} {!isLoggedIn && isLoggedIn !== null && (

请先前往 Offer派 官网进行登录

)} {/* 已登录状态:显示完整功能 */} {isLoggedIn && ( <> {/* 职位信息卡片 */}
{jobInfo?.title || "未匹配到岗位信息"}
{jobInfo ? [jobInfo.companyName || jobInfo.companyShortName] .filter(Boolean) .join(" · ") || "—" : "当前页面暂未关联岗位"}
{jobInfo?.matchScore != null && (
{jobInfo.matchScore}%
)}
{/* 自动填写按钮 */} {/* 使用次数信息 */}
助手已记住的表单
{/* 简历区域 */}
简历
{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" && 必填}
) }) }
) }) })()}
) })()} )}
{/* 助手已记住的表单 弹窗 */} {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 (