表单颜色标记,用户输入数据提交时保存
This commit is contained in:
@@ -8,6 +8,7 @@ import { useState, useEffect } from "react"
|
||||
import { getCookieValue } from "~utils/cookie"
|
||||
import { getCustomizeResume } from "~api/aiApi"
|
||||
import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon"
|
||||
import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen"
|
||||
import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types"
|
||||
import "./SidebarPanel.scss"
|
||||
|
||||
@@ -34,6 +35,8 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
const [formFields, setFormFields] = useState<MatchedFormField[]>([])
|
||||
/** 当前使用的简历数据 */
|
||||
const [resumeData, setResumeData] = useState<ResumeData | null>(null)
|
||||
/** 自动填写流程是否已完成(控制模拟提交按钮可用) */
|
||||
const [fillCompleted, setFillCompleted] = useState(false)
|
||||
|
||||
/** 页面加载时检查 Token,有岗位信息则查询定制简历 */
|
||||
useEffect(() => {
|
||||
@@ -64,24 +67,218 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
|
||||
/**
|
||||
* 自动填写按钮点击处理
|
||||
* 内部根据条件判断走 handlers/ 下具体哪个处理文件:
|
||||
* - handleAutoFillCommon:通用模式(当前默认)
|
||||
* 内部根据当前页面域名判断走哪个处理模式:
|
||||
* - handleAutoFillBeisen:北森模式(域名包含 zhiye.com,如 avicsz.zhiye.com)
|
||||
* - handleAutoFillCommon:通用模式(其他所有网站)
|
||||
* - 后续特殊网站会在此处加条件分支(如根据 domain 或 jobInfo 来源判断)
|
||||
*/
|
||||
const handleAutoFill = async () => {
|
||||
setFilling(true)
|
||||
try {
|
||||
const fillResult = await handleAutoFillCommon({ resumeData, jobInfo })
|
||||
// 检测当前页面域名,判断走哪个处理模式
|
||||
const currentHost = window.location.hostname
|
||||
const isBeisen = currentHost.includes("zhiye.com") // 北森招聘平台域名特征
|
||||
|
||||
const fillResult = isBeisen
|
||||
? await handleAutoFillBeisen({ resumeData, jobInfo })
|
||||
: await handleAutoFillCommon({ resumeData, jobInfo })
|
||||
|
||||
setPageLang(fillResult.lang)
|
||||
setIsFormPage(fillResult.isFormPage)
|
||||
if (fillResult.resumeData) setResumeData(fillResult.resumeData)
|
||||
setFormFields(fillResult.formFields)
|
||||
setFillCompleted(true)
|
||||
} catch (e) {
|
||||
console.error("OfferPie: 自动填写异常", e)
|
||||
}
|
||||
setTimeout(() => setFilling(false), 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟提交:重新读取页面上 unfilledFormData 对应字段的当前值,更新到缓存
|
||||
* 按大标题范围限定搜索,避免跨区域匹配到错误字段
|
||||
*/
|
||||
const handleSaveUserInput = () => {
|
||||
try {
|
||||
const cached = localStorage.getItem("offerpie_unfilled_form")
|
||||
if (!cached) { alert("未找到缓存数据,请先执行自动填写"); return }
|
||||
|
||||
const cacheData = JSON.parse(cached)
|
||||
const unfilledFormData = cacheData.unfilledFormData as {
|
||||
title: string
|
||||
isExperience: boolean
|
||||
formItems: { label: string; value: string }[] | { label: string; value: string }[][]
|
||||
}[]
|
||||
|
||||
const INPUT_SEL = "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 FORM_ITEM_SELS = [
|
||||
".form-item", ".form-group", ".form-field",
|
||||
".el-form-item", ".ant-form-item", ".ant-row",
|
||||
".arco-form-item", ".t-form-item", ".n-form-item",
|
||||
"[class*='form-item']", "[class*='form-group']", "[class*='formItem']",
|
||||
]
|
||||
const EXCLUDE_LABELS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"]
|
||||
|
||||
/**
|
||||
* 在页面上按文字找到大标题元素
|
||||
*/
|
||||
function findTitleElement(titleText: string): Element | null {
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT)
|
||||
let node: Node | null = walker.nextNode()
|
||||
while (node) {
|
||||
const el = node as Element
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
if (directText === titleText && el.children.length === 0) {
|
||||
return el
|
||||
}
|
||||
node = walker.nextNode()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定大标题范围内,通过标签文字查找对应 input 的值
|
||||
*/
|
||||
function findInputValueByLabelInRange(labelText: string, titleEl: Element, nextTitleEl: Element | null): string {
|
||||
const allInputs = document.querySelectorAll(INPUT_SEL)
|
||||
for (const inp of Array.from(allInputs)) {
|
||||
// 范围检查
|
||||
const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
|
||||
if (!afterTitle || !beforeNext) continue
|
||||
|
||||
// 跳过已被阶段A/B填充的简历格式字段(背景色为绿色高亮)
|
||||
// greenTwo(#b7ffc6)是阶段B2填的unfilledFormData字段,不跳过,可以更新
|
||||
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
||||
const bgColor = inputEl.style.backgroundColor
|
||||
if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") continue
|
||||
|
||||
let container: Element | null = null
|
||||
for (const sel of FORM_ITEM_SELS) {
|
||||
container = inp.closest(sel)
|
||||
if (container) break
|
||||
}
|
||||
if (!container) continue
|
||||
const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||||
for (const el of Array.from(labelEls)) {
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
if (!directText || directText.length > 30) continue
|
||||
if (EXCLUDE_LABELS.some((ex) => directText === ex)) continue
|
||||
// 非经历类型标签必须全名严格匹配,不走 includes
|
||||
if (directText === labelText &&
|
||||
(el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
||||
return inputEl.value?.trim() || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* 北森特有:在指定大标题范围内,通过标签文字查找 phoenix-radio-group 的当前选中值
|
||||
* DOM 结构:form-item > form-item__title > form-item__text(标签)
|
||||
* > form-item__control > phoenix-radio-group > phoenix-radio-group__radioItem
|
||||
* 选中判断:radioItem 子级有 phoenix-radio--checked 类名,取该 radioItem 的文字
|
||||
*/
|
||||
function findRadioGroupValueByLabelInRange(labelText: string, titleEl: Element, nextTitleEl: Element | null): string {
|
||||
const radioGroups = document.querySelectorAll(".phoenix-radio-group")
|
||||
for (const rg of Array.from(radioGroups)) {
|
||||
// 范围检查
|
||||
const afterTitle = !!(titleEl.compareDocumentPosition(rg) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(rg) & Node.DOCUMENT_POSITION_PRECEDING)
|
||||
if (!afterTitle || !beforeNext) continue
|
||||
|
||||
// 找标签文字:form-item__control 同级的 form-item__title 里的 form-item__text
|
||||
const controlEl = rg.closest(".form-item__control")
|
||||
if (!controlEl || !controlEl.parentElement) continue
|
||||
const titleDiv = controlEl.parentElement.querySelector(".form-item__title .form-item__text")
|
||||
if (!titleDiv) continue
|
||||
const directText = Array.from(titleDiv.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
// 标签严格全名匹配
|
||||
if (directText !== labelText) continue
|
||||
|
||||
// 找选中的 radioItem
|
||||
const radioItems = rg.querySelectorAll(".phoenix-radio-group__radioItem")
|
||||
for (const item of Array.from(radioItems)) {
|
||||
const checkedEl = item.querySelector(".phoenix-radio--checked")
|
||||
if (checkedEl) {
|
||||
return item.textContent?.trim() || ""
|
||||
}
|
||||
}
|
||||
// 找到了 radio group 但没有选中项,返回空
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 收集所有标题元素(按 unfilledFormData 顺序)
|
||||
const titleElements: (Element | null)[] = unfilledFormData.map((s) => findTitleElement(s.title))
|
||||
|
||||
// 遍历 unfilledFormData,按大标题范围限定搜索
|
||||
let updatedCount = 0
|
||||
for (let i = 0; i < unfilledFormData.length; i++) {
|
||||
const section = unfilledFormData[i]
|
||||
const titleEl = titleElements[i]
|
||||
if (!titleEl) continue
|
||||
|
||||
// 找下一个大标题作为范围终点
|
||||
let nextTitleEl: Element | null = null
|
||||
for (let j = i + 1; j < titleElements.length; j++) {
|
||||
if (titleElements[j]) { nextTitleEl = titleElements[j]; break }
|
||||
}
|
||||
|
||||
if (section.isExperience) {
|
||||
const segments = section.formItems as { label: string; value: string }[][]
|
||||
for (const seg of segments) {
|
||||
for (const field of seg) {
|
||||
const currentValue = findInputValueByLabelInRange(field.label, titleEl, nextTitleEl)
|
||||
if (currentValue && currentValue !== field.value) {
|
||||
field.value = currentValue
|
||||
updatedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fields = section.formItems as { label: string; value: string }[]
|
||||
for (const field of fields) {
|
||||
// 先尝试从 input/textarea 读取值
|
||||
let currentValue = findInputValueByLabelInRange(field.label, titleEl, nextTitleEl)
|
||||
// 如果没读到,尝试从北森 phoenix-radio-group 单选组读取当前选中值
|
||||
if (!currentValue) {
|
||||
currentValue = findRadioGroupValueByLabelInRange(field.label, titleEl, nextTitleEl)
|
||||
}
|
||||
if (currentValue && currentValue !== field.value) {
|
||||
field.value = currentValue
|
||||
updatedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
cacheData.unfilledFormData = unfilledFormData
|
||||
cacheData.timestamp = Date.now()
|
||||
localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData))
|
||||
alert(`已保存!更新了 ${updatedCount} 个字段的值`)
|
||||
console.log("[OfferPie] 模拟提交:已更新缓存数据", cacheData)
|
||||
} catch (e) {
|
||||
console.error("[OfferPie] 模拟提交失败:", e)
|
||||
alert("保存失败,请查看控制台")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="op-container">
|
||||
{/* 顶部操作栏:关闭按钮始终显示,反馈和设置仅登录后显示 */}
|
||||
@@ -183,8 +380,27 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
{/* <span className="op-resume-change">更改</span> */}
|
||||
</div>
|
||||
|
||||
{/* 针对性优化简历按钮 */}
|
||||
<button className="op-optimize-btn">针对性优化简历</button>
|
||||
{/* 按钮 */}
|
||||
{/* 模拟提交按钮(自动填写完成后才可点击) */}
|
||||
<button
|
||||
className="op-optimize-btn"
|
||||
disabled={!fillCompleted}
|
||||
onClick={handleSaveUserInput}
|
||||
style={{ opacity: fillCompleted ? 1 : 0.5, cursor: fillCompleted ? "pointer" : "not-allowed" }}
|
||||
>
|
||||
模拟提交(保存用户自己填输入的数据)
|
||||
</button>
|
||||
<button
|
||||
className="op-optimize-btn"
|
||||
onClick={() => {
|
||||
localStorage.removeItem("offerpie_unfilled_form")
|
||||
alert("已清空缓存数据")
|
||||
console.log("[OfferPie] 已清空 offerpie_unfilled_form 缓存")
|
||||
}}
|
||||
style={{ marginTop: "6px", opacity: 0.8 }}
|
||||
>
|
||||
清空缓存数据
|
||||
</button>
|
||||
|
||||
{/* 填写进度区域 */}
|
||||
<div className="op-progress-row">
|
||||
|
||||
Reference in New Issue
Block a user