通用模式兼容摩卡和飞书,添加通信工具组件,添加未开通会员面板

This commit is contained in:
2026-07-17 20:04:29 +08:00
parent bca1ca3182
commit bfe13d300b
17 changed files with 1997 additions and 456 deletions
+279 -101
View File
@@ -6,15 +6,27 @@
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 } from "~api/dataApi"
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 { createChannelBridge } from "~lib/channelBridge"
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 {
/** 当前标签页的来源链接 */
@@ -28,6 +40,8 @@ interface SidebarPanelProps {
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)
/** 页面语言类型:中文 / 英文 */
@@ -43,7 +57,7 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
/** 页面字段扫描统计结果(用于面板底部展示已填/待填字段列表) */
const [fieldStats, setFieldStats] = useState<TitleStat[]>([])
/** 顶部状态文字 */
const [headerStatus, setHeaderStatus] = useState<string>("")
const [headerStatus, setHeaderStatus] = useState<string>("准备中, 请登录到投递表单页~")
/** 是否启用字段更新时自动滚动到该字段,模拟跟踪(常量开关,方便调试关闭) */
const ENABLE_AUTO_SCROLL_TO_UPDATED = true
@@ -139,11 +153,12 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
return () => { stopScanLoop() }
}, [])
/** 页面加载时检测是否有足够的输入框,显示"准备就绪" */
/** 页面加载 & SPA 路由变化时检测是否有足够的输入框,显示"准备就绪" */
useEffect(() => {
const hostDoc = document.getRootNode() === document ? document : (document.getRootNode() as ShadowRoot).ownerDocument
/** 检测页面是否有足够的表单输入框 */
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("准备就绪,请点击开始填写")
@@ -151,16 +166,35 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
}
return false
}
// 立即检测一次
if (checkInputs()) return
// 页面可能还在加载,延迟重试几次
const timer1 = setTimeout(checkInputs, 500)
const timer2 = setTimeout(checkInputs, 1500)
const timer3 = setTimeout(checkInputs, 3000)
/** 路由变化后延迟多次重试检测(等待新页面 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 () => {
clearTimeout(timer1)
clearTimeout(timer2)
clearTimeout(timer3)
clearInterval(urlPollInterval)
timers.forEach(clearTimeout)
}
}, [])
@@ -203,14 +237,32 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
setLastUpdatedFieldEl(null)
}, [lastUpdatedFieldEl])
/** 页面加载时检查 Token,有岗位信息则查询定制简历 */
/** 页面加载时检查 Token,有岗位信息则查询定制简历,并检查会员状态 */
useEffect(() => {
getCookieValue("Token").then((token) => {
console.log("[OfferPie] SidebarPanel 获取到的 Token:", token)
setIsLoggedIn(!!token)
// 有 Token 且有岗位 ID 时,查询定制简历
if (token && jobInfo?.id) {
if (!token) {
setHeaderStatus("请登录后继续使用")
return
}
// 查询会员状态
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
@@ -355,6 +407,26 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
* 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 () => {
@@ -404,14 +476,12 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
* 模拟提交:通过 fillStats 扫描页面字段,过滤出 B2 阶段字段数据,更新到缓存
* 【注意】弹窗打开时不执行,避免覆盖用户正在编辑的数据
*/
const handleSaveUserInput = () => {
const handleSaveUserInput = async () => {
// 弹窗打开时直接跳过,不更新缓存
if (showCacheModal) return
try {
const cached = localStorage.getItem("offerpie_unfilled_form")
if (!cached) { alert("未找到缓存数据,请先执行自动填写"); return }
const cacheData = JSON.parse(cached)
const cacheData = await storageGet<any>("offerpie_unfilled_form")
if (!cacheData) { return }
// 检测当前网站模式
const currentHost = window.location.hostname
@@ -424,81 +494,72 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
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) {
const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title)
if (oldSecIdx < 0) {
oldSections.push(newSec)
continue
}
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
}
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 {
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 {
oldFields.push(newField)
// 新字段更新值,旧字段中有但新字段中没有的保留
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 = nf.value
} else {
oldFields.push(nf)
}
}
}
}
}
} else {
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)
} 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 = nf.value
} else {
oldFields.push(nf)
}
}
} else {
// 类型变化,直接替换
oldSections[oldSecIdx] = newSec
}
}
}
cacheData.unfilledFormData = oldSections
cacheData.timestamp = Date.now()
localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData))
await storageSet("offerpie_unfilled_form", 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 = () => {
/** 打开"助手已记住的表单"弹窗,从 chrome.storage 加载数据 */
const openCacheModal = async () => {
try {
const cached = localStorage.getItem("offerpie_unfilled_form")
if (!cached) {
const cacheData = await storageGet<any>("offerpie_unfilled_form")
if (!cacheData) {
setCacheModalData([])
} else {
const cacheData = JSON.parse(cached)
setCacheModalData(cacheData.unfilledFormData || [])
}
} catch {
@@ -528,14 +589,13 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
setCacheModalData(updated)
}
/** 弹窗内保存数据到 localStorage */
const saveCacheModalData = () => {
/** 弹窗内保存数据到 chrome.storage */
const saveCacheModalData = async () => {
try {
const cached = localStorage.getItem("offerpie_unfilled_form")
const cacheData = cached ? JSON.parse(cached) : {}
const cacheData = await storageGet<any>("offerpie_unfilled_form") || {}
cacheData.unfilledFormData = cacheModalData || []
cacheData.timestamp = Date.now()
localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData))
await storageSet("offerpie_unfilled_form", cacheData)
alert("已保存表单数据")
} catch (e) {
console.error("[OfferPie] 保存缓存数据失败:", e)
@@ -544,10 +604,12 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
}
/** 弹窗内清空缓存数据(带确认) */
const clearCacheFromModal = () => {
const clearCacheFromModal = async () => {
const confirmed = window.confirm("是否清空已记录的表单数据?清空后下次填简历那些数据需要您一个个手动填。")
if (!confirmed) return
localStorage.removeItem("offerpie_unfilled_form")
// 先停止循环扫描,防止 handleSaveUserInput 又把数据写回去
stopScanLoop()
await storageRemove("offerpie_unfilled_form")
setCacheModalData([])
alert("已清空缓存数据")
console.log("[OfferPie] 已清空 offerpie_unfilled_form 缓存")
@@ -555,11 +617,50 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
// ============ 拖拽逻辑 ============
/** 鼠标按下:启动长按计时 */
/** 即时拖拽:收起状态横条 / 展开状态顶部 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")) return
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
@@ -646,50 +747,84 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
>
{/* 拖拽提示气泡 */}
{showDragTooltip && (
<div className="op-drag-tooltip"></div>
<div className="op-drag-tooltip"></div>
)}
{/* 收起状态:显示 logo 圆圈 */}
{/* 收起状态:显示 logo + 提示文字 + 向下箭头 */}
{collapsed ? (
<div className="op-collapsed-logo" onClick={() => setCollapsed(false)}>
<img src={logoImg} alt="OfferPie" width={28} height={31} />
<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">
{/* 新顶部: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}</span>
<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="6 9 12 15 18 9" />
<polyline points="9 6 15 12 9 18" />
</svg>
</button>
</div>
{/* 未登录状态:显示登录提示 */}
{!isLoggedIn && isLoggedIn !== null && (
<div className="op-login-prompt">
<p className="op-login-text"> Offer派 </p>
<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-login-btn"
onClick={() => {
window.open("http://localhost:5173/jobs", "_blank")
}}
className="op-nonmember-btn"
onClick={() => { window.open("https://test.offerpai.com.cn/jobs", "_blank") }}
>
Offer派
</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">
@@ -718,13 +853,50 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
)}
</div>
{/* 自动填写按钮 */}
<button className="op-autofill-btn" onClick={handleAutoFill} disabled={filling}>
{filling ? "填写中..." : "开始填写"}
</button>
{/* 自动填写按钮区域 */}
{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>
@@ -864,6 +1036,12 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
)
})()}
</>
)}
</>
)}
{/* 版本号(未登录或非会员时隐藏) */}
{isLoggedIn && isMember !== false && (
<div className="op-version">ver: {appConfig.pluginVersion}</div>
)}
</div>