1375 lines
61 KiB
TypeScript
1375 lines
61 KiB
TypeScript
/**
|
||
* 侧边栏面板组件
|
||
* 插件的主操作界面,固定在浏览器右上角
|
||
* 包含:职位信息卡片、自动填写按钮、简历管理、填写进度等功能区域
|
||
*/
|
||
|
||
import { useState, useEffect, useRef, useCallback } from "react"
|
||
import { getCookieValue } from "~utils/cookie"
|
||
import { get as storageGet, set as storageSet, remove as storageRemove } from "~utils/storage"
|
||
import { getCustomizeResume } from "~api/aiApi"
|
||
import { getMemberStatus, preloadRegionTree } from "~api/dataApi"
|
||
import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon"
|
||
import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen"
|
||
import { handleAutoFillMoka } from "~handlers/handleAutoFillMoka"
|
||
import { handleAutoFillFeishu } from "~handlers/handleAutoFillFeishu"
|
||
import { handleAutoFillHotjob } from "~handlers/handleAutoFillHotjob"
|
||
import { scanPageFields, extractNonResumeFields, printFieldStats, collectPickerOptionTexts, getPickerDisplayValue } from "~lib/fillStats"
|
||
import type { TitleStat } from "~lib/fillStats"
|
||
import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types"
|
||
import { createChannelBridge } from "~lib/channelBridge"
|
||
import { buildResumeExcludeTexts } from "~lib/resumeDataHelper"
|
||
import logoImg from "data-base64:~/../assets/logo-offerpai.png"
|
||
import { config as appConfig } from "~config"
|
||
import "./SidebarPanel.scss"
|
||
|
||
/** 投递链接列表数据类型(跨域通信用) */
|
||
interface OfferpaieDeliveryLinkListData {
|
||
linkList: string[]
|
||
}
|
||
|
||
/** 创建通信桥接实例(用于投递链接列表的跨域读写) */
|
||
const panelBridge = createChannelBridge({ domain: appConfig.bridgeDomain, isPlugin: true })
|
||
|
||
/** 侧边栏面板的 Props */
|
||
interface SidebarPanelProps {
|
||
/** 当前标签页的来源链接 */
|
||
sourceUrl: string
|
||
/** 岗位信息(由 sidebar 层查询后传入) */
|
||
jobInfo: JobInfo | null
|
||
/** 关闭面板的回调函数 */
|
||
onClose: () => void
|
||
}
|
||
|
||
export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) {
|
||
/** 是否已登录(有 Token) */
|
||
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null)
|
||
/** 是否为会员(null=未检测,true=会员,false=非会员) */
|
||
const [isMember, setIsMember] = useState<boolean | null>(null)
|
||
/** 是否正在执行自动填写 */
|
||
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)
|
||
/** 自动填写流程是否已完成(控制模拟提交按钮可用) */
|
||
const [fillCompleted, setFillCompleted] = useState(false)
|
||
/** 页面字段扫描统计结果(用于面板底部展示已填/待填字段列表) */
|
||
const [fieldStats, setFieldStats] = useState<TitleStat[]>([])
|
||
/** 顶部状态文字 */
|
||
const [headerStatus, setHeaderStatus] = useState<string>("准备中, 请登录到投递表单页~")
|
||
|
||
/** 是否启用字段更新时自动滚动到该字段,模拟跟踪(常量开关,方便调试关闭) */
|
||
const ENABLE_AUTO_SCROLL_TO_UPDATED = true
|
||
|
||
/** 循环扫描定时器引用 */
|
||
const scanIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||
/** 上一次扫描结果快照(用于对比哪些字段刚刚更新) */
|
||
const prevFieldSnapshotRef = useRef<Map<Element, string>>(new Map())
|
||
/** 字段列表容器 ref(用于滚动) */
|
||
const fieldListRef = useRef<HTMLDivElement | null>(null)
|
||
/** 最新一次更新的字段 inputElement(用于定位滚动目标) */
|
||
const [lastUpdatedFieldEl, setLastUpdatedFieldEl] = useState<Element | null>(null)
|
||
/** 是否显示"助手已记住的表单"弹窗 */
|
||
const [showCacheModal, setShowCacheModal] = useState(false)
|
||
/** 弹窗中编辑的表单数据(从 localStorage 读取后在弹窗内编辑) */
|
||
const [cacheModalData, setCacheModalData] = useState<any[] | null>(null)
|
||
|
||
/** 面板是否收起(收起时只显示 logo 圆圈) */
|
||
const [collapsed, setCollapsed] = useState(false)
|
||
/** 拖拽位置偏移量(相对于初始位置) */
|
||
const [dragOffset, setDragOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
|
||
/** 是否已经被拖动过(用于判断是否显示拖拽提示气泡) */
|
||
const [hasDragged, setHasDragged] = useState(false)
|
||
/** 拖拽提示:收起状态已提示过 */
|
||
const collapsedTooltipShownRef = useRef(false)
|
||
/** 拖拽提示:展开状态已提示过 */
|
||
const expandedTooltipShownRef = useRef(false)
|
||
/** 是否正在显示拖拽提示气泡 */
|
||
const [showDragTooltip, setShowDragTooltip] = useState(false)
|
||
/** 拖拽提示气泡定时器 */
|
||
const dragTooltipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
/** 面板容器 ref(用于拖拽) */
|
||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||
/** 拖拽状态 ref */
|
||
const dragStateRef = useRef<{ isDragging: boolean; startX: number; startY: number; startOffsetX: number; startOffsetY: number; longPressTimer: ReturnType<typeof setTimeout> | null; isLongPress: boolean }>({
|
||
isDragging: false, startX: 0, startY: 0, startOffsetX: 0, startOffsetY: 0, longPressTimer: null, isLongPress: false
|
||
})
|
||
|
||
/** 选择器选项文字缓存(collectPickerOptionTexts 收集后存入,handleSaveUserInput 用于排除) */
|
||
const pickerOptionTextsRef = useRef<Set<string>>(new Set())
|
||
|
||
/** 停止当前循环扫描 */
|
||
const stopScanLoop = () => {
|
||
if (scanIntervalRef.current) {
|
||
clearInterval(scanIntervalRef.current)
|
||
scanIntervalRef.current = null
|
||
}
|
||
}
|
||
|
||
/** 对比前后快照,找出刚刚变为已填写的字段 */
|
||
const findUpdatedField = (newStats: TitleStat[]): Element | null => {
|
||
const prevMap = prevFieldSnapshotRef.current
|
||
let updatedEl: Element | null = null
|
||
const newMap = new Map<Element, string>()
|
||
|
||
for (const ts of newStats) {
|
||
for (const f of ts.fields) {
|
||
if (!f.inputElement) continue
|
||
const newColor = f.color
|
||
newMap.set(f.inputElement, newColor)
|
||
const prevColor = prevMap.get(f.inputElement)
|
||
// 从非绿变成绿/greenTwo → 刚刚被填写(取最后一个变化的)
|
||
if (prevColor && prevColor !== "green" && prevColor !== "greenTwo" && (newColor === "green" || newColor === "greenTwo")) {
|
||
updatedEl = f.inputElement
|
||
}
|
||
}
|
||
}
|
||
|
||
prevFieldSnapshotRef.current = newMap
|
||
return updatedEl
|
||
}
|
||
|
||
/** 开启循环扫描(每秒执行一次) */
|
||
const startScanLoop = (params: { siteMode?: "beisen" | "moka" | "feishu" | "hotjob"; sectionResults?: any[]; expandedResults?: any[]; excludeTexts?: Set<string> }) => {
|
||
stopScanLoop()
|
||
const hasFullData = !!(params.sectionResults && params.expandedResults)
|
||
|
||
/**
|
||
* 同步构建排除集合:合并 params.excludeTexts + 当前 resumeData 中的所有值
|
||
* 避免将已填入表单的简历值(如"汉族"、"广州"等)误认为表单标签
|
||
*/
|
||
const buildExcludeTexts = (): Set<string> => {
|
||
const baseSet = buildResumeExcludeTexts(resumeData)
|
||
// 合并外部传入的静态排除集合(第二次调用时由 fillResult.resumeData 构建)
|
||
if (params.excludeTexts) {
|
||
for (const t of params.excludeTexts) baseSet.add(t)
|
||
}
|
||
return baseSet
|
||
}
|
||
|
||
/**
|
||
* 补充选择器展示值:遍历已识别字段,对 value 为空的字段调用 getPickerDisplayValue
|
||
* 从 DOM 中提取展示文字与已收集的弹出层选项比对,匹配成功则更新字段 value
|
||
*/
|
||
const fillPickerDisplayValues = (fieldStats: TitleStat[]) => {
|
||
const options = pickerOptionTextsRef.current
|
||
if (options.size === 0) return
|
||
// 北森模式(zhiye.com)的选择器值直接写在输入框里,不需要额外提取展示值
|
||
if (window.location.hostname.includes("zhiye.com")) return
|
||
for (const ts of fieldStats) {
|
||
for (let fIdx = 0; fIdx < ts.fields.length; fIdx++) {
|
||
const f = ts.fields[fIdx]
|
||
// 已有值的跳过
|
||
if (f.value) continue
|
||
if (!f.inputElement) continue
|
||
const inputEl = f.inputElement as HTMLElement
|
||
// input.value 有值的跳过
|
||
if ((inputEl as HTMLInputElement).value?.trim()) continue
|
||
// 找同大标题内下一个字段的标签文字作为边界
|
||
const nextField = ts.fields[fIdx + 1]
|
||
const nextLabelText = nextField?.labelText || null
|
||
// 调用 getPickerDisplayValue 比对
|
||
const displayVal = getPickerDisplayValue(inputEl, f.labelText, nextLabelText, options)
|
||
if (displayVal) {
|
||
f.value = displayVal
|
||
f.filled = true
|
||
f.color = "green"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 立即执行一次
|
||
const stats = scanPageFields({ ...params, excludeTexts: buildExcludeTexts() })
|
||
// 补充选择器展示值:对 value 为空的字段,从 DOM 中提取展示文字与选项比对
|
||
fillPickerDisplayValues(stats)
|
||
setFieldStats(stats)
|
||
findUpdatedField(stats) // 初始化快照,不滚动
|
||
if (hasFullData) handleSaveUserInput()
|
||
// 每秒循环
|
||
scanIntervalRef.current = setInterval(() => {
|
||
const s = scanPageFields({ ...params, excludeTexts: buildExcludeTexts() })
|
||
// 补充选择器展示值
|
||
fillPickerDisplayValues(s)
|
||
setFieldStats(s)
|
||
if (ENABLE_AUTO_SCROLL_TO_UPDATED) {
|
||
const updatedEl = findUpdatedField(s)
|
||
if (updatedEl) setLastUpdatedFieldEl(updatedEl)
|
||
}
|
||
if (hasFullData) handleSaveUserInput()
|
||
}, 1000)
|
||
}
|
||
|
||
/** 组件卸载时清理定时器 */
|
||
useEffect(() => {
|
||
return () => { stopScanLoop() }
|
||
}, [])
|
||
|
||
/** 页面加载 & SPA 路由变化时检测是否有足够的输入框,显示"准备就绪" */
|
||
useEffect(() => {
|
||
const hostDoc = document.getRootNode() === document ? document : (document.getRootNode() as ShadowRoot).ownerDocument
|
||
|
||
/** 检测页面是否有足够的表单输入框 */
|
||
const checkInputs = () => {
|
||
const inputs = hostDoc.querySelectorAll("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='file']), textarea")
|
||
if (inputs.length > 2) {
|
||
setHeaderStatus("准备就绪,请点击开始填写")
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
/** 路由变化后延迟多次重试检测(等待新页面 DOM 渲染) */
|
||
const timers: ReturnType<typeof setTimeout>[] = []
|
||
const scheduleCheck = () => {
|
||
setHeaderStatus("准备中, 请登录到投递表单页~")
|
||
if (checkInputs()) return
|
||
timers.push(setTimeout(checkInputs, 500))
|
||
timers.push(setTimeout(checkInputs, 1500))
|
||
timers.push(setTimeout(checkInputs, 3000))
|
||
timers.push(setTimeout(checkInputs, 5000))
|
||
}
|
||
|
||
// 初次挂载时检测
|
||
scheduleCheck()
|
||
|
||
// URL 轮询:每秒检查一次 URL 是否变化(兼容所有 SPA 路由方式)
|
||
let lastUrl = window.location.href
|
||
const urlPollInterval = setInterval(() => {
|
||
const currentUrl = window.location.href
|
||
if (currentUrl !== lastUrl) {
|
||
lastUrl = currentUrl
|
||
console.log("[OfferPie] 检测到 URL 变化:", currentUrl)
|
||
scheduleCheck()
|
||
}
|
||
}, 1000)
|
||
|
||
return () => {
|
||
clearInterval(urlPollInterval)
|
||
timers.forEach(clearTimeout)
|
||
}
|
||
}, [])
|
||
|
||
/** 字段更新时自动滚动字段列表到对应项 */
|
||
useEffect(() => {
|
||
if (!ENABLE_AUTO_SCROLL_TO_UPDATED || !lastUpdatedFieldEl || !fieldListRef.current) return
|
||
// 计算该字段在渲染中的全局索引(按渲染顺序:按大标题 → 按段 → 按字段)
|
||
let globalIdx = 0
|
||
let targetIdx = -1
|
||
for (const ts of fieldStats) {
|
||
if (ts.fields.length === 0) continue
|
||
if (ts.isExpType && ts.segmentCount > 1) {
|
||
for (let sIdx = 0; sIdx < ts.segmentCount; sIdx++) {
|
||
const segFields = ts.fields.filter((f) => f.segmentIndex === sIdx)
|
||
for (const f of segFields) {
|
||
if (f.inputElement === lastUpdatedFieldEl) { targetIdx = globalIdx }
|
||
globalIdx++
|
||
}
|
||
}
|
||
} else {
|
||
for (const f of ts.fields) {
|
||
if (f.inputElement === lastUpdatedFieldEl) { targetIdx = globalIdx }
|
||
globalIdx++
|
||
}
|
||
}
|
||
if (targetIdx >= 0) break
|
||
}
|
||
if (targetIdx >= 0) {
|
||
const targetItem = fieldListRef.current.querySelector(`[data-field-idx="${targetIdx}"]`)
|
||
if (targetItem) {
|
||
// 滚动到居中位置再往上偏移30px,让下一个字段更接近视口中心(模拟实时跟踪效果)
|
||
const container = fieldListRef.current
|
||
const itemRect = targetItem.getBoundingClientRect()
|
||
const containerRect = container.getBoundingClientRect()
|
||
const offsetTop = itemRect.top - containerRect.top + container.scrollTop
|
||
const targetScroll = offsetTop - container.clientHeight / 2 + itemRect.height / 2 + 50
|
||
container.scrollTo({ top: Math.max(0, targetScroll), behavior: "smooth" })
|
||
}
|
||
}
|
||
setLastUpdatedFieldEl(null)
|
||
}, [lastUpdatedFieldEl])
|
||
|
||
/** 页面加载时检查 Token,有岗位信息则查询定制简历,并检查会员状态 */
|
||
useEffect(() => {
|
||
getCookieValue("Token").then((token) => {
|
||
console.log("[OfferPie] SidebarPanel 获取到的 Token:", token)
|
||
setIsLoggedIn(!!token)
|
||
|
||
if (!token) {
|
||
setHeaderStatus("请登录后继续使用")
|
||
return
|
||
}
|
||
|
||
// 预加载省市区行政区划数据(供摩卡级联选择器使用)
|
||
preloadRegionTree()
|
||
|
||
// 查询会员状态
|
||
getMemberStatus().then((memberData) => {
|
||
console.log("[OfferPie] 会员状态:", memberData)
|
||
setIsMember(memberData.isMember)
|
||
if (!memberData.isMember) {
|
||
setHeaderStatus("请开通会员后继续使用")
|
||
}
|
||
}).catch((err) => {
|
||
console.warn("[OfferPie] 查询会员状态失败:", err)
|
||
setIsMember(false)
|
||
setHeaderStatus("请开通会员后继续使用")
|
||
})
|
||
|
||
// 有岗位 ID 时,查询定制简历
|
||
if (jobInfo?.id) {
|
||
getCustomizeResume(jobInfo.id).then((resumeRes: any) => {
|
||
console.log("[OfferPie] 定制简历数据:", resumeRes)
|
||
// 接口返回的 resume 字段映射为 main
|
||
const mappedData: ResumeData = {
|
||
main: resumeRes.resume || {},
|
||
education: resumeRes.education || [],
|
||
work: resumeRes.work || [],
|
||
internship: resumeRes.internship || [],
|
||
project: resumeRes.project || [],
|
||
competition: resumeRes.competition || [],
|
||
}
|
||
setResumeData(mappedData)
|
||
}).catch((err) => {
|
||
console.warn("[OfferPie] 查询定制简历失败:", err)
|
||
})
|
||
}
|
||
})
|
||
}, [jobInfo?.id])
|
||
|
||
/**
|
||
* 自动填写按钮点击处理
|
||
* 内部根据当前页面域名判断走哪个处理模式:
|
||
* - handleAutoFillBeisen:北森模式(域名包含 zhiye.com,如 avicsz.zhiye.com)
|
||
* - handleAutoFillCommon:通用模式(其他所有网站)
|
||
* - 后续特殊网站会在此处加条件分支(如根据 domain 或 jobInfo 来源判断)
|
||
*/
|
||
/** 显示全页面填写遮罩层(黑色半透明 + 提示文字 + 闪烁圆点动画) */
|
||
function showFillingOverlay() {
|
||
// 防止重复创建
|
||
if (document.getElementById("offerpie-filling-overlay")) return
|
||
|
||
const overlay = document.createElement("div")
|
||
overlay.id = "offerpie-filling-overlay"
|
||
Object.assign(overlay.style, {
|
||
position: "fixed",
|
||
top: "0",
|
||
left: "0",
|
||
width: "100vw",
|
||
height: "100vh",
|
||
backgroundColor: "rgba(0, 0, 0, 0.05)",
|
||
zIndex: "2147483646",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
pointerEvents: "auto",
|
||
})
|
||
|
||
// 提示文字容器
|
||
const content = document.createElement("div")
|
||
Object.assign(content.style, {
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "4px",
|
||
userSelect: "none",
|
||
})
|
||
|
||
// 文字
|
||
const text = document.createElement("span")
|
||
text.textContent = "填写中..."
|
||
Object.assign(text.style, {
|
||
color: "rgba(255,255,255, 1)",
|
||
background:"rgba(107, 114, 128, 0.5)",
|
||
height:"40px",
|
||
"line-height":"40px",
|
||
width:"370px",
|
||
"text-align":"center",
|
||
"border-radius":"4px",
|
||
fontSize: "18px",
|
||
fontWeight: "500",
|
||
letterSpacing: "1px",
|
||
})
|
||
content.appendChild(text)
|
||
|
||
// 5个按顺序消失闪烁的圆点
|
||
for (let i = 0; i < 5; i++) {
|
||
const dot = document.createElement("span")
|
||
Object.assign(dot.style, {
|
||
display: "inline-block",
|
||
width: "6px",
|
||
height: "6px",
|
||
borderRadius: "50%",
|
||
backgroundColor: "rgba(82, 202, 209, 0.3)",
|
||
marginLeft: i === 0 ? "6px" : "3px",
|
||
animation: `offerpie-dot-blink 1.5s ${i * 0.3}s infinite`,
|
||
})
|
||
content.appendChild(dot)
|
||
}
|
||
|
||
overlay.appendChild(content)
|
||
|
||
// 注入关键帧动画样式(按顺序消失闪烁)
|
||
if (!document.getElementById("offerpie-dot-style")) {
|
||
const style = document.createElement("style")
|
||
style.id = "offerpie-dot-style"
|
||
style.textContent = `
|
||
@keyframes offerpie-dot-blink {
|
||
0%, 20% { opacity: 1; }
|
||
40%, 100% { opacity: 0; }
|
||
}
|
||
`
|
||
document.head.appendChild(style)
|
||
}
|
||
|
||
document.body.appendChild(overlay)
|
||
}
|
||
|
||
/** 移除全页面填写遮罩层(先显示完成提示,2秒后再移除) */
|
||
function hideFillingOverlay() {
|
||
const overlay = document.getElementById("offerpie-filling-overlay")
|
||
if (!overlay) return
|
||
// 找到提示文字元素,更新为完成状态
|
||
const text = overlay.querySelector("span") as HTMLElement | null
|
||
if (text) {
|
||
text.textContent = "填写完成,请检查"
|
||
text.style.background = "rgba(118, 213, 132, 0.5)"
|
||
}
|
||
// 隐藏闪烁圆点
|
||
const dots = overlay.querySelectorAll("span:not(:first-child)")
|
||
dots.forEach((dot) => {
|
||
;(dot as HTMLElement).style.display = "none"
|
||
})
|
||
// 2秒后移除遮罩层
|
||
setTimeout(() => {
|
||
overlay.remove()
|
||
}, 2000)
|
||
}
|
||
|
||
/**
|
||
* 自动填写入口
|
||
* 根据当前页面域名分发到不同的处理器:
|
||
* - 北森系统(zhiye.com)→ handleAutoFillBeisen:适配北森 Phoenix UI 组件库的特殊 DOM 结构
|
||
* - 其他招聘网站 → handleAutoFillCommon:通用模式,基于 DOM 差异对比适配各种 UI 框架
|
||
*
|
||
* 两个处理器内部流程一致:
|
||
* 1. extractDomStructure 提取页面 DOM 树结构
|
||
* 2. detectPageLanguage 检测中/英文
|
||
* 3. isJobApplicationForm 判断是否为求职表单页(非表单页直接跳过)
|
||
* 4. 获取简历数据(接口优先,无接口时 fallback 到 mock 数据)
|
||
* 5. locateExperienceSections 定位5大经历区块(教育/工作/实习/项目/竞赛)
|
||
* 6. expandExperienceSections 对比简历段数,点击添加按钮补足经历段
|
||
* 7. 逐段匹配字段 + 填充(走 fillMatchedField 统一入口)
|
||
* 8. matchMainFields 匹配非经历区域字段(姓名/手机/邮箱等)并填充
|
||
* 9. 收集剩余未匹配的空白字段(unmatchedFields),后续交给 AI 生成答案
|
||
*
|
||
/** 重置按钮:恢复到开始填写状态,重新检测页面 */
|
||
const handleReset = () => {
|
||
setFillCompleted(false)
|
||
// 重新检测页面输入框状态
|
||
setHeaderStatus("准备中, 请登录到投递表单页~")
|
||
const hostDoc = document.getRootNode() === document ? document : (document.getRootNode() as ShadowRoot).ownerDocument
|
||
const inputs = hostDoc.querySelectorAll("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='file']), textarea")
|
||
if (inputs.length > 2) {
|
||
setHeaderStatus("准备就绪,请点击开始填写")
|
||
} else {
|
||
// 延迟重试
|
||
setTimeout(() => {
|
||
const inputs2 = hostDoc.querySelectorAll("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='file']), textarea")
|
||
if (inputs2.length > 2) setHeaderStatus("准备就绪,请点击开始填写")
|
||
}, 1000)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 自动填写主流程
|
||
* 填写完成后更新组件状态,供后续"保存用户修改"和"AI补填"使用
|
||
*/
|
||
const handleAutoFill = async () => {
|
||
setFilling(true)
|
||
setHeaderStatus("正在填写表单,请勿跳转页面")
|
||
// 停止之前的循环扫描(如果有)
|
||
stopScanLoop()
|
||
// 操作中打开遮罩
|
||
showFillingOverlay()
|
||
try {
|
||
// 检测当前页面域名,判断走哪个处理模式
|
||
const currentHost = window.location.hostname
|
||
const isBeisen = currentHost.includes("zhiye.com") // 北森招聘平台域名特征
|
||
const isMoka = currentHost.includes("mokahr.com") // 摩卡招聘平台域名特征
|
||
const isFeishu = currentHost.includes("feishu.cn") // 飞书招聘平台域名特征
|
||
const isHotjob = currentHost.includes("hotjob.cn") // Hotjob招聘平台域名特征
|
||
const scanSiteMode = isBeisen ? "beisen" as const
|
||
: isMoka ? "moka" as const
|
||
: isFeishu ? "feishu" as const
|
||
: isHotjob ? "hotjob" as const
|
||
: undefined
|
||
|
||
// 第一次开启循环扫描(不传 sectionResults,阶段A之前就开始展示)
|
||
startScanLoop({ siteMode: scanSiteMode })
|
||
|
||
const fillResult = isBeisen
|
||
? await handleAutoFillBeisen({ resumeData, jobInfo })
|
||
: isMoka
|
||
? await handleAutoFillMoka({ resumeData, jobInfo })
|
||
: isFeishu
|
||
? await handleAutoFillFeishu({ resumeData, jobInfo })
|
||
: isHotjob
|
||
? await handleAutoFillHotjob({ resumeData, jobInfo })
|
||
: await handleAutoFillCommon({ resumeData, jobInfo })
|
||
|
||
// 将处理器返回的结果同步到组件状态
|
||
setPageLang(fillResult.lang) // 页面语言(影响后续标签匹配用中文还是英文)
|
||
setIsFormPage(fillResult.isFormPage) // 是否为表单页(非表单页不显示填写相关 UI)
|
||
if (fillResult.resumeData) setResumeData(fillResult.resumeData) // 更新简历数据(可能从 mock 切换为接口数据)
|
||
setFormFields(fillResult.formFields) // 已匹配的字段列表(用于后续高亮/状态展示)
|
||
setFillCompleted(true) // 标记填写流程已完成,解锁"保存修改"等按钮
|
||
setHeaderStatus("表单填写完成,请检查后提交")
|
||
hideFillingOverlay() // 填写完成,移除遮罩
|
||
|
||
// 停止第一次循环扫描,开启第二次循环扫描(传入 sectionResults + expandedResults,分段精确)
|
||
// 构建排除集合:从简历数据中提取所有值,避免将已填值误认为标签
|
||
const scanExcludeTexts = new Set<string>()
|
||
const scanResumeData = fillResult.resumeData || resumeData
|
||
if (scanResumeData) {
|
||
const main = scanResumeData.main
|
||
if (main) {
|
||
for (const val of Object.values(main)) {
|
||
if (typeof val === "string" && val.trim()) scanExcludeTexts.add(val.trim())
|
||
if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) scanExcludeTexts.add(v.trim()) })
|
||
}
|
||
}
|
||
const expKeys: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"]
|
||
for (const sec of expKeys) {
|
||
const items = scanResumeData[sec]
|
||
if (!Array.isArray(items)) continue
|
||
for (const item of items) {
|
||
for (const val of Object.values(item as Record<string, any>)) {
|
||
if (typeof val === "string" && val.trim()) scanExcludeTexts.add(val.trim())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 收集页面上所有选择器字段的下拉选项文字,合并到排除集合
|
||
// 避免选择器已选值(如"维吾尔族")被误认为表单标签
|
||
// 北森模式(zhiye.com)选择器值直接写在输入框里,跳过整套弹出层收集逻辑
|
||
if (!isBeisen) {
|
||
const preFieldStats = scanPageFields({
|
||
siteMode: scanSiteMode,
|
||
sectionResults: fillResult.sectionResults,
|
||
expandedResults: fillResult.expandedResults,
|
||
excludeTexts: scanExcludeTexts,
|
||
})
|
||
const pickerOptionTexts = await collectPickerOptionTexts(preFieldStats)
|
||
for (const t of pickerOptionTexts) scanExcludeTexts.add(t)
|
||
// 存入 ref,供 handleSaveUserInput 使用
|
||
pickerOptionTextsRef.current = pickerOptionTexts
|
||
}
|
||
|
||
startScanLoop({
|
||
siteMode: scanSiteMode,
|
||
sectionResults: fillResult.sectionResults,
|
||
expandedResults: fillResult.expandedResults,
|
||
excludeTexts: scanExcludeTexts,
|
||
})
|
||
|
||
} catch (e) {
|
||
console.error("OfferPie: 自动填写异常", e)
|
||
hideFillingOverlay() // 异常时也移除遮罩
|
||
}
|
||
setTimeout(() => setFilling(false), 1000)
|
||
}
|
||
|
||
/**
|
||
* 模拟提交:通过 fillStats 扫描页面字段,过滤出 B2 阶段字段数据,更新到缓存
|
||
* 【注意】弹窗打开时不执行,避免覆盖用户正在编辑的数据
|
||
*/
|
||
const handleSaveUserInput = async () => {
|
||
// 弹窗打开时直接跳过,不更新缓存
|
||
if (showCacheModal) return
|
||
try {
|
||
const cacheData = await storageGet<any>("offerpie_unfilled_form")
|
||
if (!cacheData) { return }
|
||
|
||
// 检测当前网站模式
|
||
const currentHost = window.location.hostname
|
||
const siteMode = currentHost.includes("zhiye.com") ? "beisen" as const
|
||
: currentHost.includes("mokahr.com") ? "moka" as const
|
||
: currentHost.includes("feishu.cn") ? "feishu" as const
|
||
: currentHost.includes("hotjob.cn") ? "hotjob" as const
|
||
: undefined
|
||
|
||
// 从简历数据和缓存数据中提取所有已填值,构建排除集合
|
||
// 避免 findLabelForInput 把这些值文字误认为是表单字段标签
|
||
const excludeTexts = new Set<string>()
|
||
if (resumeData) {
|
||
// 主表字段值
|
||
const main = resumeData.main
|
||
if (main) {
|
||
for (const val of Object.values(main)) {
|
||
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
|
||
if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) excludeTexts.add(v.trim()) })
|
||
}
|
||
}
|
||
// 5大经历字段值
|
||
const expSections: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"]
|
||
for (const sec of expSections) {
|
||
const items = resumeData[sec]
|
||
if (!Array.isArray(items)) continue
|
||
for (const item of items) {
|
||
for (const val of Object.values(item as Record<string, any>)) {
|
||
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 缓存中已记住的表单字段值
|
||
if (cacheData.unfilledFormData) {
|
||
for (const sec of cacheData.unfilledFormData as any[]) {
|
||
const items = sec.formItems
|
||
if (!items) continue
|
||
if (sec.isExperience && Array.isArray(items)) {
|
||
for (const seg of items) {
|
||
if (Array.isArray(seg)) {
|
||
for (const f of seg) { if (f.value && typeof f.value === "string") excludeTexts.add(f.value.trim()) }
|
||
}
|
||
}
|
||
} else if (Array.isArray(items)) {
|
||
for (const f of items) { if (f.value && typeof f.value === "string") excludeTexts.add(f.value.trim()) }
|
||
}
|
||
}
|
||
}
|
||
// 合并选择器选项文字(collectPickerOptionTexts 收集的下拉选项)
|
||
for (const t of pickerOptionTextsRef.current) excludeTexts.add(t)
|
||
|
||
// 调用 fillStats 扫描页面所有字段
|
||
const titleStats = scanPageFields({ siteMode, excludeTexts })
|
||
|
||
// 补充选择器展示值(与 startScanLoop 逻辑一致)
|
||
const pickerOpts = pickerOptionTextsRef.current
|
||
if (pickerOpts.size > 0) {
|
||
for (const ts of titleStats) {
|
||
for (let fIdx = 0; fIdx < ts.fields.length; fIdx++) {
|
||
const f = ts.fields[fIdx]
|
||
if (f.value) continue
|
||
if (!f.inputElement) continue
|
||
if ((f.inputElement as HTMLInputElement).value?.trim()) continue
|
||
const nextField = ts.fields[fIdx + 1]
|
||
const nextLabelText = nextField?.labelText || null
|
||
const displayVal = getPickerDisplayValue(f.inputElement as HTMLElement, f.labelText, nextLabelText, pickerOpts)
|
||
if (displayVal) {
|
||
f.value = displayVal
|
||
f.filled = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 过滤日期/时间字段值中的年龄后缀,如 "1997-05 (29岁)" → "1997-05"
|
||
* 匹配中英文括号包裹的"数字+岁/years old"格式
|
||
*/
|
||
const stripAgeSuffix = (label: string, value: string): string => {
|
||
if (!value) return value
|
||
const dateKeywords = ["日期", "时间", "年月", "生日", "出生", "date", "birth", "年龄"]
|
||
const isDateField = dateKeywords.some((k) => label.toLowerCase().includes(k.toLowerCase()))
|
||
if (!isDateField) return value
|
||
// 去掉中英文括号包裹的年龄信息:(29岁)(29岁)(29 years old) 等
|
||
return value.replace(/\s*[(\uff08]\s*\d+\s*(岁|years?\s*old)\s*[)\uff09]/gi, "").trim()
|
||
}
|
||
|
||
/**
|
||
* 地区类字段空格转斜杠:如 "山西 大同市" → "山西/大同市"
|
||
* 适用于:籍贯、户口、所在地、出生地、工作地等地区相关字段
|
||
* @param label - 字段标签名(用于判断是否为地区字段)
|
||
* @param value - 字段当前值
|
||
* @returns 处理后的值(非地区字段原样返回)
|
||
*/
|
||
const formatRegionValue = (label: string, value: string): string => {
|
||
if (!value) return value
|
||
const regionKeywords = ["籍贯", "户口", "所在地", "出生地", "工作地", "居住地", "家庭地址", "户籍", "地区", "城市", "省市", "location", "birthplace", "hometown", "residence"]
|
||
const isRegionField = regionKeywords.some((k) => label.toLowerCase().includes(k.toLowerCase()))
|
||
if (!isRegionField) return value
|
||
// 将空格替换为 /(如 "山西 大同市" → "山西/大同市")
|
||
return value.replace(/\s+/g, "/")
|
||
}
|
||
|
||
// 提取非简历格式字段(B2阶段字段)
|
||
const nonResumeData = extractNonResumeFields(titleStats)
|
||
|
||
// 更新缓存中的 unfilledFormData
|
||
// 策略:新数据中有的字段用新值替换,新数据中没有但旧数据中有的字段保留
|
||
const oldSections = cacheData.unfilledFormData as any[] || []
|
||
for (const newSec of nonResumeData) {
|
||
const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title)
|
||
if (oldSecIdx < 0) {
|
||
oldSections.push(newSec)
|
||
} else {
|
||
const oldSec = oldSections[oldSecIdx]
|
||
if (newSec.isExperience && oldSec.isExperience) {
|
||
// 经历类型:按段合并,保留旧字段
|
||
const oldSegments = oldSec.formItems as any[][]
|
||
const newSegments = newSec.formItems as any[][]
|
||
for (let sIdx = 0; sIdx < newSegments.length; sIdx++) {
|
||
if (sIdx >= oldSegments.length) {
|
||
oldSegments.push(newSegments[sIdx])
|
||
} else {
|
||
// 新字段更新值,旧字段中有但新字段中没有的保留
|
||
const oldFields = oldSegments[sIdx]
|
||
const newFields = newSegments[sIdx]
|
||
for (const nf of newFields) {
|
||
const of_ = oldFields.find((f: any) => f.label === nf.label)
|
||
if (of_) {
|
||
if (nf.value) of_.value = formatRegionValue(nf.label || "", stripAgeSuffix(nf.label || "", nf.value))
|
||
} else {
|
||
oldFields.push(nf)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if (!newSec.isExperience && !oldSec.isExperience) {
|
||
// 非经历类型:按字段合并,保留旧字段
|
||
const oldFields = oldSec.formItems as any[]
|
||
const newFields = newSec.formItems as any[]
|
||
for (const nf of newFields) {
|
||
const of_ = oldFields.find((f: any) => f.label === nf.label)
|
||
if (of_) {
|
||
if (nf.value) of_.value = formatRegionValue(nf.label || "", stripAgeSuffix(nf.label || "", nf.value))
|
||
} else {
|
||
oldFields.push(nf)
|
||
}
|
||
}
|
||
} else {
|
||
// 类型变化,直接替换
|
||
oldSections[oldSecIdx] = newSec
|
||
}
|
||
}
|
||
}
|
||
|
||
cacheData.unfilledFormData = oldSections
|
||
cacheData.timestamp = Date.now()
|
||
await storageSet("offerpie_unfilled_form", cacheData)
|
||
|
||
// 打印所有字段数据
|
||
printFieldStats(titleStats)
|
||
} catch (e) {
|
||
console.error("[OfferPie] 模拟提交失败:", e)
|
||
}
|
||
}
|
||
|
||
/** 打开"助手已记住的表单"弹窗,从 chrome.storage 加载数据 */
|
||
const openCacheModal = async () => {
|
||
try {
|
||
const cacheData = await storageGet<any>("offerpie_unfilled_form")
|
||
if (!cacheData) {
|
||
setCacheModalData([])
|
||
} else {
|
||
setCacheModalData(cacheData.unfilledFormData || [])
|
||
}
|
||
} catch {
|
||
setCacheModalData([])
|
||
}
|
||
setShowCacheModal(true)
|
||
}
|
||
|
||
/** 关闭弹窗 */
|
||
const closeCacheModal = () => {
|
||
setShowCacheModal(false)
|
||
setCacheModalData(null)
|
||
}
|
||
|
||
/** 弹窗内更新某个字段的值 */
|
||
const updateCacheFieldValue = (sectionIdx: number, segIdx: number | null, fieldIdx: number, newValue: string) => {
|
||
if (!cacheModalData) return
|
||
const updated = [...cacheModalData]
|
||
const section = updated[sectionIdx]
|
||
if (segIdx !== null) {
|
||
// 经历类型:formItems 是二维数组
|
||
;(section.formItems as any[][])[segIdx][fieldIdx].value = newValue
|
||
} else {
|
||
// 普通类型:formItems 是一维数组
|
||
;(section.formItems as any[])[fieldIdx].value = newValue
|
||
}
|
||
setCacheModalData(updated)
|
||
}
|
||
|
||
/** 弹窗内保存数据到 chrome.storage */
|
||
const saveCacheModalData = async () => {
|
||
try {
|
||
const cacheData = await storageGet<any>("offerpie_unfilled_form") || {}
|
||
cacheData.unfilledFormData = cacheModalData || []
|
||
cacheData.timestamp = Date.now()
|
||
await storageSet("offerpie_unfilled_form", cacheData)
|
||
alert("已保存表单数据")
|
||
} catch (e) {
|
||
console.error("[OfferPie] 保存缓存数据失败:", e)
|
||
alert("保存失败,请查看控制台")
|
||
}
|
||
}
|
||
|
||
/** 弹窗内清空缓存数据(带确认) */
|
||
const clearCacheFromModal = async () => {
|
||
const confirmed = window.confirm("是否清空已记录的表单数据?清空后下次填简历那些数据需要您一个个手动填。")
|
||
if (!confirmed) return
|
||
// 先停止循环扫描,防止 handleSaveUserInput 又把数据写回去
|
||
stopScanLoop()
|
||
await storageRemove("offerpie_unfilled_form")
|
||
setCacheModalData([])
|
||
alert("已清空缓存数据")
|
||
console.log("[OfferPie] 已清空 offerpie_unfilled_form 缓存")
|
||
}
|
||
|
||
// ============ 拖拽逻辑 ============
|
||
|
||
/** 即时拖拽:收起状态横条 / 展开状态顶部 header,按住即可拖动(无需长按) */
|
||
const handleInstantDragDown = useCallback((e: React.MouseEvent) => {
|
||
// 如果点击的是箭头按钮,不启动拖拽
|
||
const target = e.target as HTMLElement
|
||
if (target.closest("button, .op-collapsed-arrow, .op-collapse-btn")) return
|
||
|
||
e.preventDefault()
|
||
const state = dragStateRef.current
|
||
state.startX = e.clientX
|
||
state.startY = e.clientY
|
||
state.startOffsetX = dragOffset.x
|
||
state.startOffsetY = dragOffset.y
|
||
state.isDragging = true
|
||
state.isLongPress = true // 标记为已激活拖拽
|
||
if (containerRef.current) {
|
||
containerRef.current.style.cursor = "grabbing"
|
||
}
|
||
|
||
const handleMouseMove = (ev: MouseEvent) => {
|
||
const dx = ev.clientX - state.startX
|
||
const dy = ev.clientY - state.startY
|
||
setDragOffset({ x: state.startOffsetX + dx, y: state.startOffsetY + dy })
|
||
if (!hasDragged) setHasDragged(true)
|
||
}
|
||
|
||
const handleMouseUp = () => {
|
||
state.isDragging = false
|
||
state.isLongPress = false
|
||
if (containerRef.current) {
|
||
containerRef.current.style.cursor = ""
|
||
}
|
||
document.removeEventListener("mousemove", handleMouseMove)
|
||
document.removeEventListener("mouseup", handleMouseUp)
|
||
}
|
||
|
||
document.addEventListener("mousemove", handleMouseMove)
|
||
document.addEventListener("mouseup", handleMouseUp)
|
||
}, [dragOffset, hasDragged])
|
||
|
||
/** 鼠标按下(非 header 区域):启动长按计时,300ms 后才可拖拽 */
|
||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||
// 如果点击的是交互元素(按钮、链接、输入框等),不启动拖拽
|
||
const target = e.target as HTMLElement
|
||
if (target.closest("button, a, input, textarea, select, [role='button'], .op-credits-link, .op-field-item, .op-cache-modal-mask, .op-header, .op-collapsed-bar")) return
|
||
|
||
e.preventDefault()
|
||
const state = dragStateRef.current
|
||
state.startX = e.clientX
|
||
state.startY = e.clientY
|
||
state.startOffsetX = dragOffset.x
|
||
state.startOffsetY = dragOffset.y
|
||
state.isLongPress = false
|
||
|
||
// 300ms 后认为是长按,开始拖拽
|
||
state.longPressTimer = setTimeout(() => {
|
||
state.isLongPress = true
|
||
state.isDragging = true
|
||
if (containerRef.current) {
|
||
containerRef.current.style.cursor = "grabbing"
|
||
}
|
||
}, 300)
|
||
|
||
const handleMouseMove = (ev: MouseEvent) => {
|
||
if (!state.isLongPress) {
|
||
// 还没触发长按,如果移动太远就取消(避免误触)
|
||
const dist = Math.abs(ev.clientX - state.startX) + Math.abs(ev.clientY - state.startY)
|
||
if (dist > 5 && state.longPressTimer) {
|
||
clearTimeout(state.longPressTimer)
|
||
state.longPressTimer = null
|
||
}
|
||
return
|
||
}
|
||
// 长按中拖拽
|
||
const dx = ev.clientX - state.startX
|
||
const dy = ev.clientY - state.startY
|
||
setDragOffset({ x: state.startOffsetX + dx, y: state.startOffsetY + dy })
|
||
if (!hasDragged) setHasDragged(true)
|
||
}
|
||
|
||
const handleMouseUp = () => {
|
||
if (state.longPressTimer) {
|
||
clearTimeout(state.longPressTimer)
|
||
state.longPressTimer = null
|
||
}
|
||
state.isDragging = false
|
||
if (containerRef.current) {
|
||
containerRef.current.style.cursor = ""
|
||
}
|
||
document.removeEventListener("mousemove", handleMouseMove)
|
||
document.removeEventListener("mouseup", handleMouseUp)
|
||
}
|
||
|
||
document.addEventListener("mousemove", handleMouseMove)
|
||
document.addEventListener("mouseup", handleMouseUp)
|
||
}, [dragOffset, hasDragged])
|
||
|
||
/** 鼠标移入:显示拖拽提示气泡(收起状态提示1次,展开状态提示1次,拖动过后不再提示) */
|
||
const handleMouseEnter = useCallback(() => {
|
||
if (hasDragged) return
|
||
if (collapsed) {
|
||
if (collapsedTooltipShownRef.current) return
|
||
collapsedTooltipShownRef.current = true
|
||
} else {
|
||
if (expandedTooltipShownRef.current) return
|
||
expandedTooltipShownRef.current = true
|
||
}
|
||
setShowDragTooltip(true)
|
||
if (dragTooltipTimerRef.current) clearTimeout(dragTooltipTimerRef.current)
|
||
dragTooltipTimerRef.current = setTimeout(() => {
|
||
setShowDragTooltip(false)
|
||
}, 3000)
|
||
}, [hasDragged, collapsed])
|
||
|
||
/** 组件卸载时清理拖拽提示定时器 */
|
||
useEffect(() => {
|
||
return () => {
|
||
if (dragTooltipTimerRef.current) clearTimeout(dragTooltipTimerRef.current)
|
||
}
|
||
}, [])
|
||
|
||
return (
|
||
<div
|
||
className={`op-container ${collapsed ? "op-container--collapsed" : ""}`}
|
||
ref={containerRef}
|
||
style={{ transform: `translate(${dragOffset.x}px, ${dragOffset.y}px)` }}
|
||
onMouseDown={handleMouseDown}
|
||
onMouseEnter={handleMouseEnter}
|
||
>
|
||
{/* 拖拽提示气泡 */}
|
||
{showDragTooltip && (
|
||
<div className="op-drag-tooltip">助手位置可以拖动</div>
|
||
)}
|
||
|
||
{/* 收起状态:显示 logo + 提示文字 + 向下箭头 */}
|
||
{collapsed ? (
|
||
<div className="op-collapsed-bar" onMouseDown={handleInstantDragDown}>
|
||
<img src={logoImg} alt="OfferPie" width={28} height={31} className="op-header-logo" />
|
||
<span className="op-collapsed-text">{headerStatus || "Offer派助手"}</span>
|
||
<svg className="op-collapsed-arrow" onClick={() => setCollapsed(false)} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="6 9 12 15 18 9" />
|
||
</svg>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* 填写中遮罩:覆盖整个面板,阻止用户操作 */}
|
||
{filling && <div className="op-filling-overlay" />}
|
||
<div className="op-inner">
|
||
{/* 新顶部:logo + 提示文字 + 收起箭头(按住即可拖动) */}
|
||
<div className="op-header" onMouseDown={handleInstantDragDown}>
|
||
<div className="op-header-left">
|
||
<img src={logoImg} alt="OfferPie" width={28} height={31} className="op-header-logo" />
|
||
<span className="op-header-status">{headerStatus || "Offer派助手"}</span>
|
||
</div>
|
||
<button className="op-collapse-btn" onClick={() => setCollapsed(true)}>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="9 6 15 12 9 18" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* 未登录状态:显示登录提示 */}
|
||
{!isLoggedIn && isLoggedIn !== null && (
|
||
<div className="op-nonmember">
|
||
<div className="op-nonmember-divider" />
|
||
<div className="op-nonmember-title">登录 Offer 派,开启智能投递</div>
|
||
<div className="op-nonmember-desc">登录后将自动同步你的会员状态、简历和求职信息</div>
|
||
<button
|
||
className="op-nonmember-btn"
|
||
onClick={() => { window.open("https://test.offerpai.com.cn/jobs", "_blank") }}
|
||
>
|
||
前往登录
|
||
</button>
|
||
<div
|
||
className="op-nonmember-refresh"
|
||
onClick={() => { window.location.reload() }}
|
||
>
|
||
已完成登录?刷新登录状态
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 已登录状态:显示完整功能 */}
|
||
{isLoggedIn && (
|
||
<>
|
||
{/* 非会员状态:显示开通会员提示 */}
|
||
{isMember === false && (
|
||
<div className="op-nonmember">
|
||
<div className="op-nonmember-divider" />
|
||
<div className="op-nonmember-title">智能投递助手为会员专享功能</div>
|
||
<div className="op-nonmember-desc">开通会员后,即可自动识别并填写网申表单,减少重复操作</div>
|
||
<button
|
||
className="op-nonmember-btn"
|
||
onClick={() => { window.open("https://test.offerpai.com.cn/jobs", "_blank") }}
|
||
>
|
||
开通会员,立即使用
|
||
</button>
|
||
<div
|
||
className="op-nonmember-refresh"
|
||
onClick={() => { window.location.reload() }}
|
||
>
|
||
已开通会员?刷新会员状态
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 会员状态:显示完整功能 */}
|
||
{isMember === true && (
|
||
<>
|
||
{/* 职位信息卡片 */}
|
||
<div className="op-job-card">
|
||
|
||
<div className="op-job-info">
|
||
<div className="op-job-title">{jobInfo?.title || "未匹配到岗位信息"}</div>
|
||
<div className="op-job-meta">
|
||
{jobInfo
|
||
? [jobInfo.companyName || jobInfo.companyShortName]
|
||
.filter(Boolean)
|
||
.join(" · ") || "—"
|
||
: "当前页面暂未关联岗位"}
|
||
</div>
|
||
</div>
|
||
{jobInfo?.matchScore != null && (
|
||
<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" />
|
||
<circle cx="24" cy="24" r="20" fill="none" stroke="#52CAD1" strokeWidth="3"
|
||
strokeDasharray={`${(jobInfo.matchScore) / 100 * 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">
|
||
{jobInfo.matchScore}%
|
||
</text>
|
||
</svg>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 自动填写按钮区域 */}
|
||
{fillCompleted ? (
|
||
<div className="op-fill-done-row">
|
||
<button className="op-reset-btn" onClick={handleReset}>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#52CAD1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="23 4 23 10 17 10" />
|
||
<polyline points="1 20 1 14 7 14" />
|
||
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" />
|
||
</svg>
|
||
</button>
|
||
<div className="op-fill-done-label">填写完成,请检查</div>
|
||
</div>
|
||
) : (
|
||
<button className={`op-autofill-btn${filling ? " op-autofill-btn--filling" : ""}`} onClick={handleAutoFill} disabled={filling || headerStatus !== "准备就绪,请点击开始填写"}>
|
||
{filling ? "填写中...." : "开始填写"}
|
||
</button>
|
||
)}
|
||
|
||
{/* 操作链接 */}
|
||
<div className="op-credits-row">
|
||
<span className="op-credits-link" onClick={async () => {
|
||
const cachedUrl = sessionStorage.getItem("offerpie_source_url") || sourceUrl
|
||
if (!cachedUrl) return
|
||
|
||
try {
|
||
// 通过跨域 CSL 模式读取投递链接列表
|
||
const result = await panelBridge.get<OfferpaieDeliveryLinkListData>(appConfig.bridgeDomain, "offerpaiDeliveryLinkList", true)
|
||
const linkList = result?.linkList || []
|
||
|
||
// 检查当前投递链接是否已在列表中
|
||
if (!linkList.includes(cachedUrl)) {
|
||
linkList.push(cachedUrl)
|
||
}
|
||
|
||
// 写回跨域通信(让网页端能读到)
|
||
await panelBridge.put<OfferpaieDeliveryLinkListData>(appConfig.bridgeDomain, "offerpaiDeliveryLinkList", { linkList }, true)
|
||
console.log("[OfferPie] 已更新投递链接列表:", linkList)
|
||
} catch (err) {
|
||
console.warn("[OfferPie] 更新投递链接列表失败:", err)
|
||
}
|
||
|
||
// 跳转到投递链接
|
||
window.location.href = cachedUrl
|
||
}}>打开投递链接</span>
|
||
<span className="op-credits-link" onClick={openCacheModal}>助手已记住的表单</span>
|
||
</div>
|
||
|
||
{/* 简历区域 */}
|
||
<div className="op-section-label">简历</div>
|
||
<div className="op-resume-card">
|
||
<div className="op-resume-avatar">
|
||
{resumeData?.main?.name ? '已优化' : ""}
|
||
|
||
</div>
|
||
<div className="op-resume-info">
|
||
<span className="op-resume-name">
|
||
{resumeData?.main?.name || "暂无简历"}.pdf
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 按钮 */}
|
||
|
||
{/* 填写进度区域 */}
|
||
<div className="op-progress-row">
|
||
<span className="op-progress-label">填写进度</span>
|
||
<span className="op-progress-value">
|
||
{fieldStats.length > 0
|
||
? `${fieldStats.reduce((s, t) => s + t.fields.filter((f) => f.color === "green" || f.color === "greenTwo").length, 0)}/${fieldStats.reduce((s, t) => s + t.fields.length, 0)}`
|
||
: "0/0"}
|
||
</span>
|
||
</div>
|
||
<div className="op-progress-bar-bg">
|
||
<div
|
||
className="op-progress-bar-fill"
|
||
style={{
|
||
width: fieldStats.length > 0
|
||
? `${Math.round(fieldStats.reduce((s, t) => s + t.fields.filter((f) => f.color === "green" || f.color === "greenTwo").length, 0) / Math.max(fieldStats.reduce((s, t) => s + t.fields.length, 0), 1) * 100)}%`
|
||
: "0%"
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* 已识别字段列表 */}
|
||
{fieldStats.length > 0 && (() => {
|
||
/** 点击字段名时滚动到对应输入框 */
|
||
const scrollToField = (el: Element | null) => {
|
||
if (!el || !(el instanceof HTMLElement)) return
|
||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
||
// 闪烁高亮提示
|
||
const origOutline = el.style.outline
|
||
el.style.outline = "2px solid #52cad1"
|
||
setTimeout(() => { el.style.outline = origOutline }, 1500)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* 已识别字段区域(合并展示所有字段) */}
|
||
<div className="op-field-section">
|
||
<div className="op-field-section-title">
|
||
<span className="op-field-section-dot op-field-section-dot--green" />
|
||
已识别字段
|
||
<span className="op-field-section-required-count">(剩余必填:{fieldStats.reduce((s, t) => s + t.fields.filter((f) => f.color === "red").length, 0)})</span>
|
||
</div>
|
||
<div className="op-field-list" ref={fieldListRef}>
|
||
{(() => {
|
||
let globalIdx = 0
|
||
return fieldStats.map((ts) => {
|
||
if (ts.fields.length === 0) return null
|
||
return (
|
||
<div key={ts.titleText} className="op-field-group">
|
||
<div className="op-field-group-title">{ts.titleText}</div>
|
||
{ts.isExpType && ts.segmentCount > 1
|
||
? Array.from({ length: ts.segmentCount }, (_, sIdx) => {
|
||
const segFields = ts.fields.filter((f) => f.segmentIndex === sIdx)
|
||
if (segFields.length === 0) return null
|
||
return (
|
||
<div key={sIdx}>
|
||
<div className="op-field-group-segment">经历{sIdx + 1}</div>
|
||
{segFields.map((f, idx) => {
|
||
const isFilled = f.color === "green" || f.color === "greenTwo"
|
||
const fieldIdx = globalIdx++
|
||
return (
|
||
<div
|
||
key={`${f.labelText}-${sIdx}-${idx}`}
|
||
className="op-field-item"
|
||
data-field-idx={fieldIdx}
|
||
onClick={() => scrollToField(f.inputElement)}
|
||
>
|
||
{isFilled ? (
|
||
<svg className="op-field-item-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||
<circle cx="12" cy="12" r="11" fill="#52cad1" />
|
||
<path d="M7 12.5l3 3 7-7" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
) : (
|
||
<svg className="op-field-item-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||
<circle cx="12" cy="12" r="10" stroke="#999" strokeWidth="2.5" />
|
||
</svg>
|
||
)}
|
||
<span className="op-field-item-name">{f.labelText}</span>
|
||
{f.color === "red" && <span className="op-field-item-required">必填</span>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
})
|
||
: ts.fields.map((f, idx) => {
|
||
const isFilled = f.color === "green" || f.color === "greenTwo"
|
||
const fieldIdx = globalIdx++
|
||
return (
|
||
<div
|
||
key={`${f.labelText}-${idx}`}
|
||
className="op-field-item"
|
||
data-field-idx={fieldIdx}
|
||
onClick={() => scrollToField(f.inputElement)}
|
||
>
|
||
{isFilled ? (
|
||
<svg className="op-field-item-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||
<circle cx="12" cy="12" r="11" fill="#52cad1" />
|
||
<path d="M7 12.5l3 3 7-7" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
) : (
|
||
<svg className="op-field-item-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||
<circle cx="12" cy="12" r="10" stroke="#999" strokeWidth="2.5" />
|
||
</svg>
|
||
)}
|
||
<span className="op-field-item-name">{f.labelText}</span>
|
||
{f.color === "red" && <span className="op-field-item-required">必填</span>}
|
||
</div>
|
||
)
|
||
})
|
||
}
|
||
</div>
|
||
)
|
||
})
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
})()}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
{/* 版本号(未登录或非会员时隐藏) */}
|
||
{isLoggedIn && isMember !== false && (
|
||
<div className="op-version">ver: {appConfig.pluginVersion}</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 助手已记住的表单 弹窗 */}
|
||
{showCacheModal && (() => {
|
||
// 因为 op-container 有 transform,内部的 fixed 定位会相对于容器而非视口
|
||
// 需要计算反向偏移让弹窗遮罩覆盖整个视口
|
||
const containerEl = containerRef.current
|
||
const rect = containerEl?.getBoundingClientRect()
|
||
const maskStyle: React.CSSProperties = rect ? {
|
||
position: "fixed" as const,
|
||
top: -rect.top,
|
||
left: -rect.left,
|
||
width: "100vw",
|
||
height: "100vh",
|
||
} : {}
|
||
return (
|
||
<div className="op-cache-modal-mask" style={maskStyle} onClick={closeCacheModal}>
|
||
<div className="op-cache-modal" onClick={(e) => e.stopPropagation()}>
|
||
<div className="op-cache-modal-header">
|
||
<span className="op-cache-modal-title">助手已记住的表单</span>
|
||
<button className="op-cache-modal-close" onClick={closeCacheModal}>✕</button>
|
||
</div>
|
||
<div className="op-cache-modal-body">
|
||
{(!cacheModalData || cacheModalData.length === 0) ? (
|
||
<div className="op-cache-modal-empty">暂无缓存数据</div>
|
||
) : (
|
||
<div className="op-cache-modal-body-inner">
|
||
{/* 左侧竖直 tab 导航 */}
|
||
<div className="op-cache-modal-tabs">
|
||
{cacheModalData.map((section: any, sIdx: number) => (
|
||
<div
|
||
key={sIdx}
|
||
className="op-cache-modal-tab"
|
||
onClick={() => {
|
||
const contentEl = containerRef.current?.querySelector(".op-cache-modal-content")
|
||
const targetEl = contentEl?.querySelector(`[data-cache-section-idx="${sIdx}"]`)
|
||
if (targetEl) targetEl.scrollIntoView({ behavior: "smooth", block: "start" })
|
||
}}
|
||
>
|
||
{section.title}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* 右侧内容区域 */}
|
||
<div className="op-cache-modal-content">
|
||
{cacheModalData.map((section: any, sIdx: number) => (
|
||
<div key={sIdx} className="op-cache-modal-section" data-cache-section-idx={sIdx}>
|
||
<div className="op-cache-modal-section-title">{section.title}</div>
|
||
{section.isExperience ? (
|
||
// 经历类型:formItems 是二维数组,每段经历分开展示
|
||
(section.formItems as any[][]).map((segFields: any[], segIdx: number) => (
|
||
<div key={segIdx} className="op-cache-modal-segment">
|
||
<div className="op-cache-modal-segment-title">经历{segIdx + 1}</div>
|
||
<div className="op-cache-modal-fields">
|
||
{segFields.map((field: any, fIdx: number) => {
|
||
// 标签名包含"内容"二字的字段用 textarea 单独一行
|
||
const isLongText = field.label && field.label.includes("内容")
|
||
if (isLongText) {
|
||
return (
|
||
<div key={fIdx} className="op-cache-modal-field op-cache-modal-field--full">
|
||
<label className="op-cache-modal-label">{field.label}</label>
|
||
<textarea
|
||
className="op-cache-modal-textarea"
|
||
rows={3}
|
||
value={field.value || ""}
|
||
onChange={(e) => updateCacheFieldValue(sIdx, segIdx, fIdx, e.target.value)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div key={fIdx} className="op-cache-modal-field">
|
||
<label className="op-cache-modal-label">{field.label}</label>
|
||
<input
|
||
className="op-cache-modal-input"
|
||
type="text"
|
||
value={field.value || ""}
|
||
onChange={(e) => updateCacheFieldValue(sIdx, segIdx, fIdx, e.target.value)}
|
||
/>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
// 普通类型:formItems 是一维数组
|
||
<div className="op-cache-modal-fields">
|
||
{(section.formItems as any[]).map((field: any, fIdx: number) => (
|
||
<div key={fIdx} className="op-cache-modal-field">
|
||
<label className="op-cache-modal-label">{field.label}</label>
|
||
<input
|
||
className="op-cache-modal-input"
|
||
type="text"
|
||
value={field.value || ""}
|
||
onChange={(e) => updateCacheFieldValue(sIdx, null, fIdx, e.target.value)}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="op-cache-modal-footer">
|
||
<button className="op-cache-modal-btn op-cache-modal-btn--clear" onClick={clearCacheFromModal}>清空缓存</button>
|
||
<button className="op-cache-modal-btn op-cache-modal-btn--save" onClick={saveCacheModalData}>确认保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|