初始化

This commit is contained in:
zk
2026-05-09 10:12:21 +08:00
commit 4e36c82bc4
22 changed files with 13508 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
/**
* 侧边栏面板组件
* 插件的主操作界面,固定在浏览器右上角
* 包含:职位信息卡片、自动填写按钮、简历管理、填写进度等功能区域
*/
import { useState } from "react"
import { fillMatchedField, delay } from "~lib/autofill"
import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom"
import { matchFormFields } from "~lib/formMatcher"
import { detectPickerField } from "~lib/pickerDetector"
import { detectAndUploadResume } from "~lib/resumeUpload"
import { getMockResumeData } from "~lib/constants"
import { getResumeFieldValue } from "~lib/resumeDataHelper"
import type { MatchedFormField, ResumeData } 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 查找教育背景(教育经历)、实习经历、工作经历、项目经历、竞赛经历位置和添加按钮,根据要填的简历数据添加相应的经历条数
// TODO: 后续实现步骤:
// 1. 遍历 EXPERIENCE_SECTION_CONFIGS,在 DOM 中查找每种经历的区块标题元素
// 2. 对比简历数据中该经历的段数 vs 页面已展开的段数
// 3. 如果简历数据段数 > 页面已展开段数,查找并点击"添加"按钮补足差额
// 4. 每次点击添加按钮后等待 DOM 更新,重新计数确认
// 5. 对5种经历的标签关系数据做特殊标记(后续完善)
// 6. 所有经历段数添加完毕后,再进入统一的字段匹配和填写流程
// 5. 匹配表单字段
const fields = matchFormFields(document.body, lang)
console.log(`===== OfferPie: 匹配到 ${fields.length} 个表单字段 =====`)
// 6. 逐个:检测选择器 → 填充数据 → 立即填写(一个个来,避免多个选择器同时打开)
let success = 0, failed = 0, skipped = 0
// 记录上一个非 picker 的输入框,用于每次填完后点击它来关闭残留弹窗
let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null
for (const f of fields) {
// 从简历数据中获取填写值(根据 section + sectionIndex + resumeField 定位)
if (currentResumeData) {
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}" → input: ${f.inputSelector || "未找到"}` +
` | type: ${f.inputType}` +
` | isPicker: ${f.isPicker}` +
` | pickerSelector: ${f.pickerDropdownSelector || "无"}` +
` | fillValue: "${f.fillValue}"`
)
// 立即填写这个字段
const ok = await fillMatchedField(f)
if (ok) { success++ } else { failed++ }
// 每个字段填完后,点击上一个非 picker 输入框来关闭残留弹窗
if (lastTextInput) {
;(lastTextInput as HTMLElement).click()
lastTextInput.focus()
await delay(100)
lastTextInput.blur()
} else {
// 兜底:只按 Escape 关闭弹窗,不点击任何元素(避免误触链接导致页面跳转)
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)
// 更新上一个非 picker 输入框的记录
if (!f.isPicker && f.inputElement) {
lastTextInput = f.inputElement
}
}
setFormFields(fields)
console.log(`===== OfferPie: 填写结果 成功${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"></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>
)
}