400 lines
19 KiB
TypeScript
400 lines
19 KiB
TypeScript
/**
|
||
* 侧边栏面板组件
|
||
* 插件的主操作界面,固定在浏览器右上角
|
||
* 包含:职位信息卡片、自动填写按钮、简历管理、填写进度等功能区域
|
||
*/
|
||
|
||
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 也脱离了 DOM(React 重新渲染导致),重新定位
|
||
// 这对于没有 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>
|
||
)
|
||
}
|
||
|