diff --git a/assets/icon1.png b/assets/icon1.png deleted file mode 100644 index 6ae0bc4..0000000 Binary files a/assets/icon1.png and /dev/null differ diff --git a/assets/logo-offerpai.png b/assets/logo-offerpai.png new file mode 100644 index 0000000..36ab9b3 Binary files /dev/null and b/assets/logo-offerpai.png differ diff --git a/src/components/SidebarPanel.scss b/src/components/SidebarPanel.scss index dc3f750..8b19bda 100644 --- a/src/components/SidebarPanel.scss +++ b/src/components/SidebarPanel.scss @@ -8,46 +8,125 @@ $radius-sm: 12px; position: fixed; top: 10px; right: 10px; - width: 384px; - min-height: 480px; + width: 380px; + min-height: 470px; max-height: calc(100vh - 20px); background: #fff; border-radius: $radius-lg; - padding: 20px 20px 24px; - font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.06); z-index: 2147483647; display: flex; flex-direction: column; - overflow-y: auto; color: $primary; font-size: 14px; + font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif; + user-select: none; + + /* 收起状态 */ + &--collapsed { + width: auto; + min-height: auto; + max-height: none; + background: transparent; + box-shadow: none; + border-radius: 50%; + } +} + +/* 收起状态的 logo 圆圈 */ +.op-collapsed-logo { + width: 60px; + height: 60px; + border-radius: 50%; + background: #fff; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 6px 28px rgba(0, 0, 0, 0.18); + } +} + +/* 拖拽提示气泡 */ +.op-drag-tooltip { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: #fff; + color: #333; + font-size: 13px; + padding: 8px 16px; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + white-space: nowrap; + z-index: 10000; + pointer-events: none; +} + +/* 内层容器:承载 padding 和滚动 */ +.op-inner { + padding: 20px 20px 24px; + overflow-y: auto; + flex: 1; + display: flex; + flex-direction: column; +} + +/* 面板填写遮罩:和 op-inner 同级,覆盖整个 op-container */ +.op-filling-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.05); + border-radius: $radius-lg; + z-index: 9999; } .op-header { display: flex; - justify-content: flex-end; + justify-content: space-between; align-items: center; - margin-bottom: 4px; + margin-bottom: 12px; gap: 12px; - &-link { + &-left { + display: flex; + align-items: center; + gap: 8px; + } + + &-logo { + flex-shrink: 0; + } + + &-status { font-size: 13px; - color: #333; - cursor: pointer; - text-decoration: underline; - text-underline-offset: 3px; + color: #555; + font-weight: 500; } } -.op-close-btn { +.op-collapse-btn { background: none; border: none; cursor: pointer; - padding: 2px; + padding: 4px; display: flex; align-items: center; - color: #333; + color: #666; + border-radius: 4px; + transition: background 0.15s; + + &:hover { + background: #f0f0f0; + } } .op-title { @@ -61,8 +140,6 @@ $radius-sm: 12px; display: flex; align-items: center; gap: 12px; - padding: 14px 16px; - background: $bg-card; border-radius: $radius-md; margin-bottom: 24px; } @@ -103,15 +180,15 @@ $radius-sm: 12px; .op-autofill-btn { width: 100%; - height: 52px; - background: $primary; + height: 40px; + background: #52CAD1; color: #fff; border: none; - border-radius: $radius-md; - font-size: 20px; + border-radius: 4px; + font-size: 16px; font-weight: 700; cursor: pointer; - margin-bottom: 12px; + margin-bottom: 6px; transition: opacity 0.2s; &:hover { opacity: 0.85; } @@ -120,11 +197,11 @@ $radius-sm: 12px; .op-credits { &-row { - display: flex; justify-content: space-between; align-items: center; - margin-bottom: 20px; + margin-bottom: 2px; padding: 0 2px; + text-align: center; } &-text { @@ -133,11 +210,12 @@ $radius-sm: 12px; } &-link { - font-size: 13px; + font-size: 12px; font-weight: 500; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; + color: #666; } } @@ -152,23 +230,21 @@ $radius-sm: 12px; display: flex; align-items: center; gap: 10px; - padding: 12px 14px; - background: $bg-card; + padding: 2px 0px; border-radius: $radius-sm; margin-bottom: 12px; } &-avatar { - width: 32px; - height: 32px; - border-radius: 50%; - background: #e8e8e8; + width: 36px; + height: 16px; + border-radius: 2px; + background: #E7F8F9; display: flex; align-items: center; justify-content: center; - font-size: 14px; - font-weight: 600; - color: #555; + font-size: 8px; + color: #52CAD1; flex-shrink: 0; } @@ -184,6 +260,7 @@ $radius-sm: 12px; &-name { font-size: 13px; font-weight: 600; + color:#666; } &-tag { @@ -233,21 +310,21 @@ $radius-sm: 12px; } &-value { - font-size: 14px; - font-weight: 700; + font-size: 12px; + color:#666; } &-bar-bg { width: 100%; height: 6px; - background: #f0f0f0; + background: #D7F3F4; border-radius: 3px; } &-bar-fill { width: 50%; height: 100%; - background: $primary; + background: #52CAD1; border-radius: 3px; transition: width 0.3s; } @@ -284,3 +361,316 @@ $radius-sm: 12px; &:hover { opacity: 0.85; } } + + +/* ============ 字段列表区域(待填写 / 已填写) ============ */ +.op-field-section { + margin-top: 16px; + + &-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + margin-bottom: 8px; + color: #333; + } + + &-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + &--red { background: #ff4d4f; } + &--green { background: #34c759; } + } + + &-count { + font-size: 11px; + font-weight: 500; + color: #999; + margin-left: 2px; + } + + &-required-count { + font-size: 11px; + font-weight: 500; + color: #ff4d4f; + margin-left: 4px; + } +} + +.op-field-list { + max-height: 301px; + overflow-y: auto; + border: 1px solid #f0f0f0; + border-radius: 10px; + padding: 6px 0; + + /* 自定义滚动条 */ + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-thumb { + background: #ddd; + border-radius: 2px; + } +} + +.op-field-group { + &-title { + font-size: 12.5px; + font-weight: 600; + color: #222; + padding: 10px 14px 4px; + } + + &-segment { + font-size: 11px; + font-weight: 500; + color: #888; + padding: 6px 14px 2px 22px; + border-top: 1px dashed #f0f0f0; + margin-top: 2px; + } +} + +.op-field-item { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + cursor: pointer; + transition: background 0.15s; + user-select: none; + + &:hover { + background: #f5f8fa; + } + + &-check { + flex-shrink: 0; + } + + &-icon { + flex-shrink: 0; + } + + &-name { + font-size: 12.5px; + color: #333; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &-required { + font-size: 10px; + color: #ff4d4f; + font-weight: 500; + flex-shrink: 0; + padding: 1px 5px; + border: 1px solid #ffccc7; + border-radius: 4px; + line-height: 14px; + } +} + + +/* ============ 助手已记住的表单 弹窗 ============ */ +.op-cache-modal-mask { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.4); + z-index: 2147483647; + display: flex; + align-items: center; + justify-content: center; +} + +.op-cache-modal { + width: 700px; + height: 500px; + background: #fff; + border-radius: 12px; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.op-cache-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #f0f0f0; + flex-shrink: 0; +} + +.op-cache-modal-title { + font-size: 16px; + font-weight: 700; + color: #222; +} + +.op-cache-modal-close { + background: none; + border: none; + font-size: 18px; + color: #999; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: background 0.15s; + + &:hover { + background: #f5f5f5; + color: #333; + } +} + +.op-cache-modal-body { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + + &::-webkit-scrollbar { + width: 5px; + } + &::-webkit-scrollbar-thumb { + background: #ddd; + border-radius: 3px; + } +} + +.op-cache-modal-empty { + text-align: center; + color: #999; + font-size: 14px; + padding: 60px 0; +} + +.op-cache-modal-section { + margin-bottom: 20px; + + &-title { + font-size: 15px; + font-weight: 700; + color: #111; + margin-bottom: 12px; + padding-bottom: 6px; + border-bottom: 1px solid #eee; + } +} + +.op-cache-modal-segment { + margin-bottom: 14px; + + &-title { + font-size: 13px; + font-weight: 600; + color: #555; + margin-bottom: 8px; + padding-left: 4px; + } +} + +.op-cache-modal-fields { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.op-cache-modal-field { + display: inline-flex; + align-items: center; + gap: 6px; + + /* 长文本 textarea 独占一行 */ + &--full { + width: 100%; + flex-direction: column; + align-items: flex-start; + } +} + +.op-cache-modal-label { + font-size: 12px; + color: #555; + white-space: nowrap; + flex-shrink: 0; +} + +.op-cache-modal-input { + width: 200px; + height: 30px; + border: 1px solid #ddd; + border-radius: 4px; + padding: 0 8px; + font-size: 13px; + color: #333; + outline: none; + transition: border-color 0.2s; + + &:focus { + border-color: #52CAD1; + } +} + +.op-cache-modal-textarea { + width: 100%; + border: 1px solid #ddd; + border-radius: 4px; + padding: 6px 8px; + font-size: 13px; + color: #333; + outline: none; + resize: vertical; + font-family: inherit; + transition: border-color 0.2s; + + &:focus { + border-color: #52CAD1; + } +} + +.op-cache-modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + border-top: 1px solid #f0f0f0; + flex-shrink: 0; +} + +.op-cache-modal-btn { + height: 34px; + padding: 0 20px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { opacity: 0.85; } + + &--clear { + background: #fff1f0; + color: #ff4d4f; + border: 1px solid #ffccc7; + } + + &--save { + background: #52CAD1; + color: #fff; + } +} diff --git a/src/components/SidebarPanel.tsx b/src/components/SidebarPanel.tsx index f151be8..47f52d0 100644 --- a/src/components/SidebarPanel.tsx +++ b/src/components/SidebarPanel.tsx @@ -4,12 +4,15 @@ * 包含:职位信息卡片、自动填写按钮、简历管理、填写进度等功能区域 */ -import { useState, useEffect } from "react" +import { useState, useEffect, useRef, useCallback } from "react" import { getCookieValue } from "~utils/cookie" import { getCustomizeResume } from "~api/aiApi" import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon" import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen" +import { scanPageFields, extractNonResumeFields, printFieldStats } from "~lib/fillStats" +import type { TitleStat } from "~lib/fillStats" import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types" +import logoImg from "data-base64:~/../assets/logo-offerpai.png" import "./SidebarPanel.scss" /** 侧边栏面板的 Props */ @@ -37,6 +40,168 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) const [resumeData, setResumeData] = useState(null) /** 自动填写流程是否已完成(控制模拟提交按钮可用) */ const [fillCompleted, setFillCompleted] = useState(false) + /** 页面字段扫描统计结果(用于面板底部展示已填/待填字段列表) */ + const [fieldStats, setFieldStats] = useState([]) + /** 顶部状态文字 */ + const [headerStatus, setHeaderStatus] = useState("") + + /** 是否启用字段更新时自动滚动到该字段,模拟跟踪(常量开关,方便调试关闭) */ + const ENABLE_AUTO_SCROLL_TO_UPDATED = true + + /** 循环扫描定时器引用 */ + const scanIntervalRef = useRef | null>(null) + /** 上一次扫描结果快照(用于对比哪些字段刚刚更新) */ + const prevFieldSnapshotRef = useRef>(new Map()) + /** 字段列表容器 ref(用于滚动) */ + const fieldListRef = useRef(null) + /** 最新一次更新的字段 inputElement(用于定位滚动目标) */ + const [lastUpdatedFieldEl, setLastUpdatedFieldEl] = useState(null) + /** 是否显示"助手已记住的表单"弹窗 */ + const [showCacheModal, setShowCacheModal] = useState(false) + /** 弹窗中编辑的表单数据(从 localStorage 读取后在弹窗内编辑) */ + const [cacheModalData, setCacheModalData] = useState(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 | null>(null) + /** 面板容器 ref(用于拖拽) */ + const containerRef = useRef(null) + /** 拖拽状态 ref */ + const dragStateRef = useRef<{ isDragging: boolean; startX: number; startY: number; startOffsetX: number; startOffsetY: number; longPressTimer: ReturnType | null; isLongPress: boolean }>({ + isDragging: false, startX: 0, startY: 0, startOffsetX: 0, startOffsetY: 0, longPressTimer: null, isLongPress: false + }) + + /** 停止当前循环扫描 */ + 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() + + 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"; sectionResults?: any[]; expandedResults?: any[] }) => { + stopScanLoop() + const hasFullData = !!(params.sectionResults && params.expandedResults) + // 立即执行一次 + const stats = scanPageFields(params) + setFieldStats(stats) + findUpdatedField(stats) // 初始化快照,不滚动 + if (hasFullData) handleSaveUserInput() + // 每秒循环 + scanIntervalRef.current = setInterval(() => { + const s = scanPageFields(params) + setFieldStats(s) + if (ENABLE_AUTO_SCROLL_TO_UPDATED) { + const updatedEl = findUpdatedField(s) + if (updatedEl) setLastUpdatedFieldEl(updatedEl) + } + if (hasFullData) handleSaveUserInput() + }, 1000) + } + + /** 组件卸载时清理定时器 */ + useEffect(() => { + return () => { stopScanLoop() } + }, []) + + /** 页面加载时检测是否有足够的输入框,显示"准备就绪" */ + useEffect(() => { + const checkInputs = () => { + // 注意:需要查询宿主页面的 DOM,而不是 Shadow DOM 内部 + 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("准备就绪,请点击开始填写") + return true + } + return false + } + // 立即检测一次 + if (checkInputs()) return + // 页面可能还在加载,延迟重试几次 + const timer1 = setTimeout(checkInputs, 500) + const timer2 = setTimeout(checkInputs, 1500) + const timer3 = setTimeout(checkInputs, 3000) + return () => { + clearTimeout(timer1) + clearTimeout(timer2) + clearTimeout(timer3) + } + }, []) + + /** 字段更新时自动滚动字段列表到对应项 */ + 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(() => { @@ -72,230 +237,441 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) * - 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 生成答案 + * + * 填写完成后更新组件状态,供后续"保存用户修改"和"AI补填"使用 + */ const handleAutoFill = async () => { setFilling(true) + setHeaderStatus("正在填写表单,请勿跳转页面") + // 停止之前的循环扫描(如果有) + stopScanLoop() + // 操作中打开遮罩 + showFillingOverlay() try { // 检测当前页面域名,判断走哪个处理模式 const currentHost = window.location.hostname const isBeisen = currentHost.includes("zhiye.com") // 北森招聘平台域名特征 + const scanSiteMode = isBeisen ? "beisen" as const : undefined + + // 第一次开启循环扫描(不传 sectionResults,阶段A之前就开始展示) + startScanLoop({ siteMode: scanSiteMode }) 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) + // 将处理器返回的结果同步到组件状态 + setPageLang(fillResult.lang) // 页面语言(影响后续标签匹配用中文还是英文) + setIsFormPage(fillResult.isFormPage) // 是否为表单页(非表单页不显示填写相关 UI) + if (fillResult.resumeData) setResumeData(fillResult.resumeData) // 更新简历数据(可能从 mock 切换为接口数据) + setFormFields(fillResult.formFields) // 已匹配的字段列表(用于后续高亮/状态展示) + setFillCompleted(true) // 标记填写流程已完成,解锁"保存修改"等按钮 + setHeaderStatus("表单填写完成,请检查后提交") + hideFillingOverlay() // 填写完成,移除遮罩 + + // 停止第一次循环扫描,开启第二次循环扫描(传入 sectionResults + expandedResults,分段精确) + startScanLoop({ + siteMode: scanSiteMode, + sectionResults: fillResult.sectionResults, + expandedResults: fillResult.expandedResults, + }) + } catch (e) { console.error("OfferPie: 自动填写异常", e) + hideFillingOverlay() // 异常时也移除遮罩 } setTimeout(() => setFilling(false), 1000) } /** - * 模拟提交:重新读取页面上 unfilledFormData 对应字段的当前值,更新到缓存 - * 按大标题范围限定搜索,避免跨区域匹配到错误字段 + * 模拟提交:通过 fillStats 扫描页面字段,过滤出 B2 阶段字段数据,更新到缓存 + * 【注意】弹窗打开时不执行,避免覆盖用户正在编辑的数据 */ const handleSaveUserInput = () => { + // 弹窗打开时直接跳过,不更新缓存 + if (showCacheModal) return 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 = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] + // 检测当前网站模式 + const currentHost = window.location.hostname + const siteMode = currentHost.includes("zhiye.com") ? "beisen" as const : undefined - /** - * 在页面上按文字找到大标题元素 - */ - 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() + // 调用 fillStats 扫描页面所有字段 + const titleStats = scanPageFields({ siteMode }) + + // 提取非简历格式字段(B2阶段字段) + const nonResumeData = extractNonResumeFields(titleStats) + + // 更新缓存中的 unfilledFormData + const oldSections = cacheData.unfilledFormData as any[] || [] + for (const newSec of nonResumeData) { + const oldSec = oldSections.find((s: any) => s.title === newSec.title) + if (!oldSec) { + oldSections.push(newSec) + continue } - 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() || "" + if (newSec.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]) + continue } - } - } - 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++ + const oldFields = oldSegments[sIdx] + const newFields = newSegments[sIdx] + for (const newField of newFields) { + const oldField = oldFields.find((f: any) => f.label === newField.label) + if (oldField) { + if (newField.value) oldField.value = newField.value + } else { + oldFields.push(newField) } } } } 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++ + const oldFields = oldSec.formItems as any[] + const newFields = newSec.formItems as any[] + for (const newField of newFields) { + const oldField = oldFields.find((f: any) => f.label === newField.label) + if (oldField) { + if (newField.value) oldField.value = newField.value + } else { + oldFields.push(newField) } } } } - // 更新缓存 - cacheData.unfilledFormData = unfilledFormData + cacheData.unfilledFormData = oldSections cacheData.timestamp = Date.now() localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData)) - alert(`已保存!更新了 ${updatedCount} 个字段的值`) - console.log("[OfferPie] 模拟提交:已更新缓存数据", cacheData) + + // 打印所有字段数据 + // console.log("===== OfferPie: handleSaveUserInput 全量字段数据 =====") + printFieldStats(titleStats) + + // 打印 B2 字段数据 + // console.log("===== OfferPie: handleSaveUserInput B2阶段字段数据 =====") + // console.log(JSON.stringify(nonResumeData, null, 2)) + // console.log("===== OfferPie: B2字段数据打印完毕 =====") + + const updatedCount = nonResumeData.reduce((sum, sec) => { + if (sec.isExperience) { + return sum + (sec.formItems as any[][]).reduce((s, seg) => s + seg.filter((f: any) => f.value).length, 0) + } + return sum + (sec.formItems as any[]).filter((f: any) => f.value).length + }, 0) + // console.log(`已保存!B2字段中有值的共 ${updatedCount} 个`) } catch (e) { console.error("[OfferPie] 模拟提交失败:", e) + console.log("保存失败,请查看控制台") + } + } + + /** 打开"助手已记住的表单"弹窗,从 localStorage 加载数据 */ + const openCacheModal = () => { + try { + const cached = localStorage.getItem("offerpie_unfilled_form") + if (!cached) { + setCacheModalData([]) + } else { + const cacheData = JSON.parse(cached) + 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) + } + + /** 弹窗内保存数据到 localStorage */ + const saveCacheModalData = () => { + try { + const cached = localStorage.getItem("offerpie_unfilled_form") + const cacheData = cached ? JSON.parse(cached) : {} + cacheData.unfilledFormData = cacheModalData || [] + cacheData.timestamp = Date.now() + localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData)) + alert("已保存表单数据") + } catch (e) { + console.error("[OfferPie] 保存缓存数据失败:", e) alert("保存失败,请查看控制台") } } + /** 弹窗内清空缓存数据(带确认) */ + const clearCacheFromModal = () => { + const confirmed = window.confirm("是否清空已记录的表单数据?清空后下次填简历那些数据需要您一个个手动填。") + if (!confirmed) return + localStorage.removeItem("offerpie_unfilled_form") + setCacheModalData([]) + alert("已清空缓存数据") + console.log("[OfferPie] 已清空 offerpie_unfilled_form 缓存") + } + + // ============ 拖拽逻辑 ============ + + /** 鼠标按下:启动长按计时 */ + 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")) 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 ( -
- {/* 顶部操作栏:关闭按钮始终显示,反馈和设置仅登录后显示 */} +
+ {/* 拖拽提示气泡 */} + {showDragTooltip && ( +
长按可以拖动助手位置
+ )} + + {/* 收起状态:只显示 logo 圆圈 */} + {collapsed ? ( +
setCollapsed(false)}> + OfferPie +
+ ) : ( + <> + {/* 填写中遮罩:覆盖整个面板,阻止用户操作 */} + {filling &&
} +
+ {/* 新顶部:logo + 提示文字 + 收起箭头 */}
- {isLoggedIn && 反馈} - {/*{isLoggedIn && 设置}*/} -
- {/* 插件标题 */} -
大学生求职助手
- {/* 未登录状态:显示登录提示 */} {!isLoggedIn && isLoggedIn !== null && (
@@ -303,7 +679,6 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) - {/* 填写进度区域 */}
填写进度 - 50% + + {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"} +
-
+
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%" + }} + />
+ + {/* 已识别字段列表 */} + {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 ( + <> + {/* 已识别字段区域(合并展示所有字段) */} +
+
+ + 已识别字段 + (剩余必填:{fieldStats.reduce((s, t) => s + t.fields.filter((f) => f.color === "red").length, 0)}) +
+
+ {(() => { + let globalIdx = 0 + return fieldStats.map((ts) => { + if (ts.fields.length === 0) return null + return ( +
+
{ts.titleText}
+ {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 ( +
+
经历{sIdx + 1}
+ {segFields.map((f, idx) => { + const isFilled = f.color === "green" || f.color === "greenTwo" + const fieldIdx = globalIdx++ + return ( +
scrollToField(f.inputElement)} + > + {isFilled ? ( + + + + + ) : ( + + + + )} + {f.labelText} + {f.color === "red" && 必填} +
+ ) + })} +
+ ) + }) + : ts.fields.map((f, idx) => { + const isFilled = f.color === "green" || f.color === "greenTwo" + const fieldIdx = globalIdx++ + return ( +
scrollToField(f.inputElement)} + > + {isFilled ? ( + + + + + ) : ( + + + + )} + {f.labelText} + {f.color === "red" && 必填} +
+ ) + }) + } +
+ ) + }) + })()} +
+
+ + ) + })()} + + )} +
+ + {/* 助手已记住的表单 弹窗 */} + {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 ( +
+
e.stopPropagation()}> +
+ 助手已记住的表单 + +
+
+ {(!cacheModalData || cacheModalData.length === 0) ? ( +
暂无缓存数据
+ ) : ( + cacheModalData.map((section: any, sIdx: number) => ( +
+
{section.title}
+ {section.isExperience ? ( + // 经历类型:formItems 是二维数组,每段经历分开展示 + (section.formItems as any[][]).map((segFields: any[], segIdx: number) => ( +
+
经历{segIdx + 1}
+
+ {segFields.map((field: any, fIdx: number) => { + // 标签名包含"内容"二字的字段用 textarea 单独一行 + const isLongText = field.label && field.label.includes("内容") + if (isLongText) { + return ( +
+ +