968 lines
41 KiB
TypeScript
968 lines
41 KiB
TypeScript
/**
|
||
* 侧边栏面板组件
|
||
* 插件的主操作界面,固定在浏览器右上角
|
||
* 包含:职位信息卡片、自动填写按钮、简历管理、填写进度等功能区域
|
||
*/
|
||
|
||
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>
|
||
)
|
||
}
|
||
|