Files
offerpai_browser_plug/src/components/SidebarPanel.tsx
T

1375 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 侧边栏面板组件
* 插件的主操作界面,固定在浏览器右上角
* 包含:职位信息卡片、自动填写按钮、简历管理、填写进度等功能区域
*/
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<boolean | null>(null)
/** 是否为会员(null=未检测,true=会员,false=非会员) */
const [isMember, setIsMember] = useState<boolean | null>(null)
/** 是否正在执行自动填写 */
const [filling, setFilling] = useState(false)
/** 页面语言类型:中文 / 英文 */
const [pageLang, setPageLang] = useState<"zh" | "en">("zh")
/** 是否为职位申请表单页面 */
const [isFormPage, setIsFormPage] = useState(false)
/** 匹配到的表单字段列表 */
const [formFields, setFormFields] = useState<MatchedFormField[]>([])
/** 当前使用的简历数据 */
const [resumeData, setResumeData] = useState<ResumeData | null>(null)
/** 自动填写流程是否已完成(控制模拟提交按钮可用) */
const [fillCompleted, setFillCompleted] = useState(false)
/** 页面字段扫描统计结果(用于面板底部展示已填/待填字段列表) */
const [fieldStats, setFieldStats] = useState<TitleStat[]>([])
/** 顶部状态文字 */
const [headerStatus, setHeaderStatus] = useState<string>("准备中, 请登录到投递表单页~")
/** 是否启用字段更新时自动滚动到该字段,模拟跟踪(常量开关,方便调试关闭) */
const ENABLE_AUTO_SCROLL_TO_UPDATED = true
/** 循环扫描定时器引用 */
const scanIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
/** 上一次扫描结果快照(用于对比哪些字段刚刚更新) */
const prevFieldSnapshotRef = useRef<Map<Element, string>>(new Map())
/** 字段列表容器 ref(用于滚动) */
const fieldListRef = useRef<HTMLDivElement | null>(null)
/** 最新一次更新的字段 inputElement(用于定位滚动目标) */
const [lastUpdatedFieldEl, setLastUpdatedFieldEl] = useState<Element | null>(null)
/** 是否显示"助手已记住的表单"弹窗 */
const [showCacheModal, setShowCacheModal] = useState(false)
/** 弹窗中编辑的表单数据(从 localStorage 读取后在弹窗内编辑) */
const [cacheModalData, setCacheModalData] = useState<any[] | null>(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<ReturnType<typeof setTimeout> | null>(null)
/** 面板容器 ref(用于拖拽) */
const containerRef = useRef<HTMLDivElement | null>(null)
/** 拖拽状态 ref */
const dragStateRef = useRef<{ isDragging: boolean; startX: number; startY: number; startOffsetX: number; startOffsetY: number; longPressTimer: ReturnType<typeof setTimeout> | null; isLongPress: boolean }>({
isDragging: false, startX: 0, startY: 0, startOffsetX: 0, startOffsetY: 0, longPressTimer: null, isLongPress: false
})
/** 选择器选项文字缓存(collectPickerOptionTexts 收集后存入,handleSaveUserInput 用于排除) */
const pickerOptionTextsRef = useRef<Set<string>>(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<Element, string>()
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<string> }) => {
stopScanLoop()
const hasFullData = !!(params.sectionResults && params.expandedResults)
/**
* 同步构建排除集合:合并 params.excludeTexts + 当前 resumeData 中的所有值
* 避免将已填入表单的简历值(如"汉族"、"广州"等)误认为表单标签
*/
const buildExcludeTexts = (): Set<string> => {
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<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 () => {
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<string>()
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<string, any>)) {
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<any>("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<string>()
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<string, any>)) {
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<any>("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<any>("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 (
<div
className={`op-container ${collapsed ? "op-container--collapsed" : ""}`}
ref={containerRef}
style={{ transform: `translate(${dragOffset.x}px, ${dragOffset.y}px)` }}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
>
{/* 拖拽提示气泡 */}
{showDragTooltip && (
<div className="op-drag-tooltip">助手位置可以拖动</div>
)}
{/* 收起状态:显示 logo + 提示文字 + 向下箭头 */}
{collapsed ? (
<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" 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 || "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="9 6 15 12 9 18" />
</svg>
</button>
</div>
{/* 未登录状态:显示登录提示 */}
{!isLoggedIn && isLoggedIn !== null && (
<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-nonmember-btn"
onClick={() => { window.open("https://test.offerpai.com.cn/jobs", "_blank") }}
>
前往登录
</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">
<div className="op-job-info">
<div className="op-job-title">{jobInfo?.title || "未匹配到岗位信息"}</div>
<div className="op-job-meta">
{jobInfo
? [jobInfo.companyName || jobInfo.companyShortName]
.filter(Boolean)
.join(" · ") || "—"
: "当前页面暂未关联岗位"}
</div>
</div>
{jobInfo?.matchScore != null && (
<div className="op-match-score">
<svg width="48" height="48" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" fill="none" stroke="#f0f0f0" strokeWidth="3" />
<circle cx="24" cy="24" r="20" fill="none" stroke="#52CAD1" strokeWidth="3"
strokeDasharray={`${(jobInfo.matchScore) / 100 * 2 * Math.PI * 20} ${2 * Math.PI * 20}`}
strokeLinecap="round" transform="rotate(-90 24 24)" />
<text x="24" y="26" textAnchor="middle" fontSize="13" fontWeight="700" fill="#000">
{jobInfo.matchScore}%
</text>
</svg>
</div>
)}
</div>
{/* 自动填写按钮区域 */}
{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>
{/* 简历区域 */}
<div className="op-section-label">简历</div>
<div className="op-resume-card">
<div className="op-resume-avatar">
{resumeData?.main?.name ? '已优化' : ""}
</div>
<div className="op-resume-info">
<span className="op-resume-name">
{resumeData?.main?.name || "暂无简历"}.pdf
</span>
</div>
</div>
{/* 按钮 */}
{/* 填写进度区域 */}
<div className="op-progress-row">
<span className="op-progress-label">填写进度</span>
<span className="op-progress-value">
{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"}
</span>
</div>
<div className="op-progress-bar-bg">
<div
className="op-progress-bar-fill"
style={{
width: fieldStats.length > 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%"
}}
/>
</div>
{/* 已识别字段列表 */}
{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 (
<>
{/* 已识别字段区域(合并展示所有字段) */}
<div className="op-field-section">
<div className="op-field-section-title">
<span className="op-field-section-dot op-field-section-dot--green" />
已识别字段
<span className="op-field-section-required-count">(剩余必填:{fieldStats.reduce((s, t) => s + t.fields.filter((f) => f.color === "red").length, 0)}</span>
</div>
<div className="op-field-list" ref={fieldListRef}>
{(() => {
let globalIdx = 0
return fieldStats.map((ts) => {
if (ts.fields.length === 0) return null
return (
<div key={ts.titleText} className="op-field-group">
<div className="op-field-group-title">{ts.titleText}</div>
{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 (
<div key={sIdx}>
<div className="op-field-group-segment">经历{sIdx + 1}</div>
{segFields.map((f, idx) => {
const isFilled = f.color === "green" || f.color === "greenTwo"
const fieldIdx = globalIdx++
return (
<div
key={`${f.labelText}-${sIdx}-${idx}`}
className="op-field-item"
data-field-idx={fieldIdx}
onClick={() => scrollToField(f.inputElement)}
>
{isFilled ? (
<svg className="op-field-item-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#52cad1" />
<path d="M7 12.5l3 3 7-7" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg className="op-field-item-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="#999" strokeWidth="2.5" />
</svg>
)}
<span className="op-field-item-name">{f.labelText}</span>
{f.color === "red" && <span className="op-field-item-required">必填</span>}
</div>
)
})}
</div>
)
})
: ts.fields.map((f, idx) => {
const isFilled = f.color === "green" || f.color === "greenTwo"
const fieldIdx = globalIdx++
return (
<div
key={`${f.labelText}-${idx}`}
className="op-field-item"
data-field-idx={fieldIdx}
onClick={() => scrollToField(f.inputElement)}
>
{isFilled ? (
<svg className="op-field-item-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#52cad1" />
<path d="M7 12.5l3 3 7-7" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg className="op-field-item-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="#999" strokeWidth="2.5" />
</svg>
)}
<span className="op-field-item-name">{f.labelText}</span>
{f.color === "red" && <span className="op-field-item-required">必填</span>}
</div>
)
})
}
</div>
)
})
})()}
</div>
</div>
</>
)
})()}
</>
)}
</>
)}
{/* 版本号(未登录或非会员时隐藏) */}
{isLoggedIn && isMember !== false && (
<div className="op-version">ver: {appConfig.pluginVersion}</div>
)}
</div>
{/* 助手已记住的表单 弹窗 */}
{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 (
<div className="op-cache-modal-mask" style={maskStyle} onClick={closeCacheModal}>
<div className="op-cache-modal" onClick={(e) => e.stopPropagation()}>
<div className="op-cache-modal-header">
<span className="op-cache-modal-title">助手已记住的表单</span>
<button className="op-cache-modal-close" onClick={closeCacheModal}></button>
</div>
<div className="op-cache-modal-body">
{(!cacheModalData || cacheModalData.length === 0) ? (
<div className="op-cache-modal-empty">暂无缓存数据</div>
) : (
<div className="op-cache-modal-body-inner">
{/* 左侧竖直 tab 导航 */}
<div className="op-cache-modal-tabs">
{cacheModalData.map((section: any, sIdx: number) => (
<div
key={sIdx}
className="op-cache-modal-tab"
onClick={() => {
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}
</div>
))}
</div>
{/* 右侧内容区域 */}
<div className="op-cache-modal-content">
{cacheModalData.map((section: any, sIdx: number) => (
<div key={sIdx} className="op-cache-modal-section" data-cache-section-idx={sIdx}>
<div className="op-cache-modal-section-title">{section.title}</div>
{section.isExperience ? (
// 经历类型:formItems 是二维数组,每段经历分开展示
(section.formItems as any[][]).map((segFields: any[], segIdx: number) => (
<div key={segIdx} className="op-cache-modal-segment">
<div className="op-cache-modal-segment-title">经历{segIdx + 1}</div>
<div className="op-cache-modal-fields">
{segFields.map((field: any, fIdx: number) => {
// 标签名包含"内容"二字的字段用 textarea 单独一行
const isLongText = field.label && field.label.includes("内容")
if (isLongText) {
return (
<div key={fIdx} className="op-cache-modal-field op-cache-modal-field--full">
<label className="op-cache-modal-label">{field.label}</label>
<textarea
className="op-cache-modal-textarea"
rows={3}
value={field.value || ""}
onChange={(e) => updateCacheFieldValue(sIdx, segIdx, fIdx, e.target.value)}
/>
</div>
)
}
return (
<div key={fIdx} className="op-cache-modal-field">
<label className="op-cache-modal-label">{field.label}</label>
<input
className="op-cache-modal-input"
type="text"
value={field.value || ""}
onChange={(e) => updateCacheFieldValue(sIdx, segIdx, fIdx, e.target.value)}
/>
</div>
)
})}
</div>
</div>
))
) : (
// 普通类型:formItems 是一维数组
<div className="op-cache-modal-fields">
{(section.formItems as any[]).map((field: any, fIdx: number) => (
<div key={fIdx} className="op-cache-modal-field">
<label className="op-cache-modal-label">{field.label}</label>
<input
className="op-cache-modal-input"
type="text"
value={field.value || ""}
onChange={(e) => updateCacheFieldValue(sIdx, null, fIdx, e.target.value)}
/>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
<div className="op-cache-modal-footer">
<button className="op-cache-modal-btn op-cache-modal-btn--clear" onClick={clearCacheFromModal}>清空缓存</button>
<button className="op-cache-modal-btn op-cache-modal-btn--save" onClick={saveCacheModalData}>确认保存</button>
</div>
</div>
</div>
)
})()}
</>
)}
</div>
)
}