Files
offerpai_browser_plug/src/components/SidebarPanel.tsx
T

968 lines
41 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 { 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<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
})
/** 停止当前循环扫描 */
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"; 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 (
<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-logo" onClick={() => setCollapsed(false)}>
<img src={logoImg} alt="OfferPie" width={28} height={31} />
</div>
) : (
<>
{/* 填写中遮罩:覆盖整个面板,阻止用户操作 */}
{filling && <div className="op-filling-overlay" />}
<div className="op-inner">
{/* 新顶部:logo + 提示文字 + 收起箭头 */}
<div className="op-header">
<div className="op-header-left">
<img src={logoImg} alt="OfferPie" width={28} height={31} className="op-header-logo" />
<span className="op-header-status">{headerStatus}</span>
</div>
<button className="op-collapse-btn" onClick={() => setCollapsed(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
{/* 未登录状态:显示登录提示 */}
{!isLoggedIn && isLoggedIn !== null && (
<div className="op-login-prompt">
<p className="op-login-text">请先前往 Offer派 官网进行登录</p>
<button
className="op-login-btn"
onClick={() => {
window.open("http://localhost:5173/jobs", "_blank")
}}
>
前往 Offer派
</button>
</div>
)}
{/* 已登录状态:显示完整功能 */}
{isLoggedIn && (
<>
{/* 职位信息卡片 */}
<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>
{/* 自动填写按钮 */}
<button className="op-autofill-btn" onClick={handleAutoFill} disabled={filling}>
{filling ? "填写中..." : "开始填写"}
</button>
{/* 使用次数信息 */}
<div className="op-credits-row">
<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>
</>
)
})()}
</>
)}
</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>
) : (
cacheModalData.map((section: any, sIdx: number) => (
<div key={sIdx} className="op-cache-modal-section">
<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 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>
)
}