Files
offerpai_browser_plug/src/components/SidebarPanel.tsx
T
2026-05-12 21:21:59 +08:00

400 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } from "react"
import { fillMatchedField, delay, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill"
import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom"
import { matchFormFields, matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
import { detectPickerField } from "~lib/pickerDetector"
import { detectAndUploadResume } from "~lib/resumeUpload"
import { getMockResumeData } from "~lib/constants"
import { getResumeFieldValue } from "~lib/resumeDataHelper"
import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer } from "~lib/experienceSection"
import type { MatchedFormField, ResumeData, ExperienceSection } from "~lib/types"
import "./SidebarPanel.scss"
/** 侧边栏面板的 Props */
interface SidebarPanelProps {
/** 关闭面板的回调函数 */
onClose: () => void
}
export function SidebarPanel({ onClose }: SidebarPanelProps) {
/** 是否正在执行自动填写 */
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)
/**
* 自动填写按钮点击处理
* 流程:提取 DOM → 检测语言 → 判断是否表单页 → 检测简历上传 → 匹配字段 → 识别选择器 → 填充测试数据
*/
const handleAutoFill = async () => {
setFilling(true)
try {
// 1. 提取 DOM 结构
const domStructure = extractDomStructure()
console.log("===== OfferPie: 完整 DOM 树结构 =====")
console.log(domStructure)
console.log(`===== OfferPie: 结构总长度 ${domStructure.length} 字符 =====`)
// 2. 检测页面语言,更新到页面参数
const lang = detectPageLanguage(domStructure)
setPageLang(lang)
console.log(`===== OfferPie: 页面语言检测结果 = ${lang} =====`)
// 3. 判断是否为职位申请表单页面
const isForm = isJobApplicationForm(document.body, lang)
setIsFormPage(isForm)
console.log(`===== OfferPie: 是否为职位申请表单页面 = ${isForm} =====`)
if (isForm) {
// 4. 获取简历数据(当前使用 mock 数据,后续替换为接口调用)
// TODO: 替换为真实接口 → const res = await javaApi.get<{resume: ..., education: ..., ...}>("/resume/detail")
const currentResumeData = getMockResumeData()
setResumeData(currentResumeData)
console.log(`===== OfferPie: 已加载简历数据,教育${currentResumeData.education.length}段 工作${currentResumeData.work.length}段 实习${currentResumeData.internship.length}段 项目${currentResumeData.project.length}段 竞赛${currentResumeData.competition.length}段 =====`)
// // 4.1 检测并上传简历文件
// const resumeUrl = "https://offerpie.oss-cn-guangzhou.aliyuncs.com/%E4%BA%8E%E5%A4%A7%E6%98%A5.pdf"
// const uploaded = await detectAndUploadResume(resumeUrl)
// console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`)
// if (uploaded) await delay(1000) // 等待网站解析简历
// 4.5 定位经历区块并统计已展开段数
const sectionResults = locateExperienceSections(document.body, lang)
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
// 5. 经历数据按时间排序 + 经历区域字段匹配与填写(阶段A)
console.log("===== OfferPie: 阶段A - 经历区域填写 =====")
const usedInputs = new Set<Element>() // 全局已使用的 input 集合
const excludeRanges: { start: Element; end: Element | null }[] = [] // 经历区域范围(用于阶段B排除)
let success = 0, failed = 0, skipped = 0
let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null
let lastTextInputIsPicker = false
for (const result of expandedResults) {
if (!result.titleElement || result.expandedCount === 0) continue
const section = result.section as ExperienceSection
const sectionData = currentResumeData[section] as { startDate?: string; endDate?: string }[]
if (!sectionData || sectionData.length === 0) continue
// 5.1 对该经历数据按时间排序(最新的在前面)
const sortedIndices = sortExperienceByTime(sectionData)
console.log(` [${section}] 排序后索引: [${sortedIndices.join(",")}]`)
// 5.2 记录经历区域范围(用于阶段B排除)
// 范围:从该经历大标题到下一个大标题
const titleEl = result.titleElement
// 找下一个大标题作为范围结束
const allTitles = expandedResults.filter((r) => r.titleElement)
const currentIdx = allTitles.findIndex((r) => r.titleElement === titleEl)
const nextResult = currentIdx >= 0 && currentIdx < allTitles.length - 1 ? allTitles[currentIdx + 1] : null
excludeRanges.push({ start: titleEl, end: nextResult?.titleElement || null })
// 5.3 逐段匹配并填写
const segments = result.segmentRanges
const fillCount = Math.min(sortedIndices.length, segments.length)
for (let segIdx = 0; segIdx < fillCount; segIdx++) {
const dataIdx = sortedIndices[segIdx] // 排序后对应的简历数据索引
const segment = segments[segIdx]
// 确定该段经历的 DOM 搜索范围
// 起始:该段的 startElement(第一个输入框前的标签)
const segStartEl = segment.startElement
// 结束:下一段的 startElement,或下一个大标题
const nextSegment = segIdx < segments.length - 1 ? segments[segIdx + 1] : null
const segEndEl = nextSegment?.startElement || nextResult?.titleElement || null
// 在该段范围内匹配字段
const segFields = matchFormFieldsInRange(lang, section, dataIdx, segStartEl, segEndEl, usedInputs, segment.containerElement)
console.log(` [${section}] 第${segIdx + 1}段(数据索引${dataIdx})匹配到 ${segFields.length} 个字段`)
// 逐个填写
for (let fIdx = 0; fIdx < segFields.length; fIdx++) {
let f = segFields[fIdx]
const value = getResumeFieldValue(currentResumeData, f.section, dataIdx, f.resumeField)
if (value) f.fillValue = value
// 【核心】如果 input 已脱离 DOM(React 重新渲染导致),用 locator 重新定位
if (f.inputElement && !f.inputElement.isConnected) {
console.log(` [重新定位] "${f.labelText}" input 已脱离DOM (isConnected=false)`)
// 用 locator 重新获取容器
let activeContainer: Element | null = null
if (segment.locator) {
activeContainer = relocateSegmentContainer(segment.locator)
}
if (activeContainer) {
// 在重新定位的容器内,用 placeholder 找到对应的 input
// 【注意】排除已有值的 input(说明已被填过,可能是其他段重新渲染后的残留)
const freshInputs = activeContainer.querySelectorAll(
"input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
)
const ph = f.inputElement.getAttribute("placeholder") || ""
let found: HTMLInputElement | HTMLTextAreaElement | null = null
for (const inp of Array.from(freshInputs)) {
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
if (usedInputs.has(inp)) continue
// 跳过已有值的 input(已被填过)
if (inputEl.value && inputEl.value.length > 0) continue
if (inp.getAttribute("placeholder") === ph) {
found = inputEl
break
}
}
if (found) {
f.inputElement = found
usedInputs.add(found)
console.log(` [重新定位] "${f.labelText}" ✅ 已通过 locator 重新定位 (placeholder="${ph}")`)
} else {
console.log(` [重新定位] "${f.labelText}" ❌ 容器内未找到 placeholder="${ph}" 的空 input`)
}
} else {
console.log(` [重新定位] "${f.labelText}" ❌ locator 重新定位容器失败`)
}
}
// 【补充】如果 labelElement 也脱离了 DOMReact 重新渲染导致),重新定位
// 这对于没有 input 的纯 div 选择器(如 Ant Design Select)尤其重要
// 因为 fillMatchedField 的 picker 无 input 逻辑需要点击 labelElement
if (f.labelElement && !f.labelElement.isConnected && segment.locator) {
const activeContainer = relocateSegmentContainer(segment.locator)
if (activeContainer) {
// 在容器内用标签文字重新找到对应的标签元素
const allLabels = activeContainer.querySelectorAll("label, span, div, td, th, p, legend, dt")
for (const el of Array.from(allLabels)) {
const directText = Array.from(el.childNodes)
.filter((n) => n.nodeType === Node.TEXT_NODE)
.map((n) => n.textContent?.trim())
.filter(Boolean)
.join("")
if (directText && directText.includes(f.labelText) && directText.length < f.labelText.length + 20) {
f.labelElement = el
console.log(` [重新定位] "${f.labelText}" labelElement ✅ 已重新定位`)
break
}
}
}
}
// 时间段字段不需要 fillValue(它直接从简历数据取 startDate 和 endDate
if (isTimePeriodField(f.key)) {
const startDateVal = getResumeFieldValue(currentResumeData, f.section, dataIdx, "startDate")
const endDateVal = getResumeFieldValue(currentResumeData, f.section, dataIdx, "endDate")
let ok = await fillTimePeriodField(f, startDateVal, endDateVal, usedInputs)
if (!ok) {
// fallback 到集成式时间选择器
f.fillValue = startDateVal // 用开始时间作为 fillValue
await detectPickerField(f, lang)
ok = await fillMatchedField(f)
}
if (ok) { success++ } else { failed++ }
} else if (isTimeSingleField(f.key)) {
if (!f.fillValue) { skipped++; continue }
let ok = await fillTimeSingleField(f, f.fillValue, usedInputs)
if (!ok) {
await detectPickerField(f, lang)
ok = await fillMatchedField(f)
}
if (ok) { success++ } else { failed++ }
} else if (!f.fillValue) {
skipped++; continue
} else if (isSearchPickerField(f.key)) {
const ok = await fillSearchPickerField(f)
if (ok) { success++ } else { failed++ }
// 搜索选择器内部已处理关闭弹窗,跳过外部关闭逻辑
await delay(200)
continue
} else {
await detectPickerField(f, lang)
const ok = await fillMatchedField(f)
if (ok) { success++ } else { failed++ }
}
// 关闭残留弹窗(只点击纯文本输入框,不点击选择器类型的 input)
if (lastTextInput && !lastTextInputIsPicker) {
;(lastTextInput as HTMLElement).click()
lastTextInput.focus()
await delay(100)
lastTextInput.blur()
} else {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
)
document.activeElement.blur()
}
document.body.click()
}
await delay(300)
if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) {
lastTextInput = f.inputElement
lastTextInputIsPicker = false
} else {
lastTextInputIsPicker = true
}
}
}
}
console.log(`===== OfferPie: 阶段A完成 成功${success} 失败${failed} 跳过${skipped} =====`)
// 6. 非经历区域字段匹配与填写(阶段B)
console.log("===== OfferPie: 阶段B - 非经历区域填写 =====")
const mainFields = matchMainFields(document.body, lang, excludeRanges, usedInputs)
console.log(` 匹配到 ${mainFields.length} 个非经历字段`)
for (const f of mainFields) {
const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField)
if (value) f.fillValue = value
if (!f.fillValue) { skipped++; continue }
await detectPickerField(f, lang)
console.log(
` [${f.key}] "${f.labelText}" → type: ${f.inputType}` +
` | isPicker: ${f.isPicker}` +
` | fillValue: "${f.fillValue}"`
)
const ok = await fillMatchedField(f)
if (ok) { success++ } else { failed++ }
if (lastTextInput) {
;(lastTextInput as HTMLElement).click()
lastTextInput.focus()
await delay(100)
lastTextInput.blur()
} else {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
)
document.activeElement.blur()
}
document.body.click()
}
await delay(300)
if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement
}
setFormFields([...mainFields])
console.log(`===== OfferPie: 阶段B完成 总计成功${success} 失败${failed} 跳过${skipped} =====`)
} else {
setFormFields([])
console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====")
}
} catch (e) {
console.error("OfferPie: 获取页面结构失败", e)
}
// 1秒后恢复按钮状态
setTimeout(() => setFilling(false), 1000)
}
return (
<div className="op-container">
{/* 顶部操作栏:反馈、设置、关闭按钮 */}
<div className="op-header">
<span className="op-header-link">123456789</span>
<span className="op-header-link"></span>
{/* 关闭按钮:点击后隐藏侧边栏 */}
<button className="op-close-btn" onClick={onClose}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<circle cx="12" cy="12" r="10" />
<path d="M9 12h6M12 9l3 3-3 3" />
</svg>
</button>
</div>
{/* 插件标题 */}
<div className="op-title"></div>
{/* 职位信息卡片:展示当前页面识别到的职位信息和匹配度 */}
<div className="op-job-card">
{/* 职位图标 */}
<div className="op-job-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="#666">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
</div>
{/* 职位名称和公司信息 */}
<div className="op-job-info">
<div className="op-job-title"></div>
<div className="op-job-meta"> ·  </div>
</div>
{/* 匹配度环形进度条 */}
<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" />
{/* 进度圆环:60% 匹配度 */}
<circle cx="24" cy="24" r="20" fill="none" stroke="#000" strokeWidth="3"
strokeDasharray={`${0.6 * 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">60%</text>
</svg>
</div>
</div>
{/* 自动填写按钮:点击后获取页面结构,后续会调用 AI 接口自动填表 */}
<button className="op-autofill-btn" onClick={handleAutoFill} disabled={filling}>
{filling ? "分析中..." : "自动填写"}
</button>
{/* 使用次数信息 */}
<div className="op-credits-row">
<span className="op-credits-text">4</span>
<span className="op-credits-link"></span>
</div>
{/* 简历区域 */}
<div className="op-section-label"></div>
{/* 简历卡片:展示当前选中的简历信息 */}
<div className="op-resume-card">
{/* 简历头像 */}
<div className="op-resume-avatar">B</div>
{/* 简历名称和标签 */}
<div className="op-resume-info">
<span className="op-resume-name">-</span>
<span className="op-resume-tag"></span>
<span className="op-resume-tag"></span>
</div>
{/* 更改简历按钮 */}
<span className="op-resume-change"></span>
</div>
{/* 针对性优化简历按钮 */}
<button className="op-optimize-btn"></button>
{/* 填写进度区域 */}
<div className="op-progress-row">
<span className="op-progress-label"></span>
<span className="op-progress-value">50%</span>
</div>
{/* 进度条 */}
<div className="op-progress-bar-bg">
<div className="op-progress-bar-fill" />
</div>
</div>
)
}