表单颜色标记,用户输入数据提交时保存

This commit is contained in:
2026-06-15 18:24:58 +08:00
parent e3a4ae2381
commit 6be840a416
8 changed files with 3127 additions and 15 deletions
+221 -5
View File
@@ -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">