初始化
This commit is contained in:
+32
@@ -0,0 +1,32 @@
|
||||
# 依赖
|
||||
node_modules
|
||||
|
||||
# Plasmo 构建缓存
|
||||
.plasmo
|
||||
|
||||
# 构建产物
|
||||
build
|
||||
dist
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
|
||||
# 编辑器/IDE
|
||||
.idea
|
||||
.vscode
|
||||
.kiro
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# 打包产物
|
||||
*.zip
|
||||
*.crx
|
||||
*.pem
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: "none",
|
||||
printWidth: 100
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Generated
+9986
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "offerpie-extension",
|
||||
"displayName": "OfferPie Assistant",
|
||||
"version": "0.1.0",
|
||||
"description": "智能求职助手 - 自动化投递简历",
|
||||
"scripts": {
|
||||
"dev": "plasmo dev",
|
||||
"build": "plasmo build",
|
||||
"package": "plasmo package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-react": "^2.66.14",
|
||||
"plasmo": "^0.90.5",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.318",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"manifest": {
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"storage",
|
||||
"tabs"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"action": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Background Service Worker
|
||||
* 插件的后台服务,负责:
|
||||
* 1. 监听插件图标点击事件,通知 Content Script 切换侧边栏
|
||||
* 2. 后续可扩展:定时任务调度、跨页面状态管理、与后端的长连接等
|
||||
*/
|
||||
|
||||
export {}
|
||||
|
||||
/**
|
||||
* 监听插件图标点击事件
|
||||
* 由于 manifest 中 action 没有设置 default_popup,
|
||||
* 点击图标会触发此事件,向当前活动标签页发送切换侧边栏的消息
|
||||
*/
|
||||
chrome.action.onClicked.addListener(async (tab) => {
|
||||
if (tab.id) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: "TOGGLE_SIDEBAR" })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
$primary: #000;
|
||||
$bg-card: #fafafa;
|
||||
$radius-lg: 20px;
|
||||
$radius-md: 14px;
|
||||
$radius-sm: 12px;
|
||||
|
||||
.op-container {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 384px;
|
||||
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;
|
||||
}
|
||||
|
||||
.op-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
gap: 12px;
|
||||
|
||||
&-link {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.op-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.op-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.op-job {
|
||||
&-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: $bg-card;
|
||||
border-radius: $radius-md;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&-meta {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.op-match-score {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.op-autofill-btn {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover { opacity: 0.85; }
|
||||
&:disabled { opacity: 0.7; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.op-credits {
|
||||
&-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-link {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.op-section-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.op-resume {
|
||||
&-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: $bg-card;
|
||||
border-radius: $radius-sm;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #e8e8e8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-tag {
|
||||
font-size: 11px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-change {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.op-optimize-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: #f0faf5;
|
||||
color: $primary;
|
||||
border: none;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: 20px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #e0f5ec; }
|
||||
}
|
||||
|
||||
.op-progress {
|
||||
&-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&-bar-bg {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&-bar-fill {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: $primary;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 侧边栏面板组件
|
||||
* 插件的主操作界面,固定在浏览器右上角
|
||||
* 包含:职位信息卡片、自动填写按钮、简历管理、填写进度等功能区域
|
||||
*/
|
||||
|
||||
import { useState } from "react"
|
||||
import { fillMatchedField, delay } from "~lib/autofill"
|
||||
import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom"
|
||||
import { matchFormFields } from "~lib/formMatcher"
|
||||
import { detectPickerField } from "~lib/pickerDetector"
|
||||
import { detectAndUploadResume } from "~lib/resumeUpload"
|
||||
import { getMockResumeData } from "~lib/constants"
|
||||
import { getResumeFieldValue } from "~lib/resumeDataHelper"
|
||||
import type { MatchedFormField, ResumeData } from "~lib/types"
|
||||
import "./SidebarPanel.scss"
|
||||
|
||||
/** 侧边栏面板的 Props */
|
||||
interface SidebarPanelProps {
|
||||
/** 关闭面板的回调函数 */
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SidebarPanel({ onClose }: SidebarPanelProps) {
|
||||
/** 是否正在执行自动填写 */
|
||||
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)
|
||||
|
||||
/**
|
||||
* 自动填写按钮点击处理
|
||||
* 流程:提取 DOM → 检测语言 → 判断是否表单页 → 检测简历上传 → 匹配字段 → 识别选择器 → 填充测试数据
|
||||
*/
|
||||
const handleAutoFill = async () => {
|
||||
setFilling(true)
|
||||
try {
|
||||
// 1. 提取 DOM 结构
|
||||
const domStructure = extractDomStructure()
|
||||
console.log("===== OfferPie: 完整 DOM 树结构 =====")
|
||||
console.log(domStructure)
|
||||
console.log(`===== OfferPie: 结构总长度 ${domStructure.length} 字符 =====`)
|
||||
|
||||
// 2. 检测页面语言,更新到页面参数
|
||||
const lang = detectPageLanguage(domStructure)
|
||||
setPageLang(lang)
|
||||
console.log(`===== OfferPie: 页面语言检测结果 = ${lang} =====`)
|
||||
|
||||
// 3. 判断是否为职位申请表单页面
|
||||
const isForm = isJobApplicationForm(document.body, lang)
|
||||
setIsFormPage(isForm)
|
||||
console.log(`===== OfferPie: 是否为职位申请表单页面 = ${isForm} =====`)
|
||||
|
||||
if (isForm) {
|
||||
// 4. 获取简历数据(当前使用 mock 数据,后续替换为接口调用)
|
||||
// TODO: 替换为真实接口 → const res = await javaApi.get<{resume: ..., education: ..., ...}>("/resume/detail")
|
||||
const currentResumeData = getMockResumeData()
|
||||
setResumeData(currentResumeData)
|
||||
console.log(`===== OfferPie: 已加载简历数据,教育${currentResumeData.education.length}段 工作${currentResumeData.work.length}段 实习${currentResumeData.internship.length}段 项目${currentResumeData.project.length}段 竞赛${currentResumeData.competition.length}段 =====`)
|
||||
|
||||
// 4.1 检测并上传简历文件
|
||||
const resumeUrl = "https://offerpie.oss-cn-guangzhou.aliyuncs.com/%E4%BA%8E%E5%A4%A7%E6%98%A5.pdf"
|
||||
const uploaded = await detectAndUploadResume(resumeUrl)
|
||||
console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`)
|
||||
if (uploaded) await delay(1000) // 等待网站解析简历
|
||||
|
||||
// 4.5 查找教育背景(教育经历)、实习经历、工作经历、项目经历、竞赛经历位置和添加按钮,根据要填的简历数据添加相应的经历条数
|
||||
// TODO: 后续实现步骤:
|
||||
// 1. 遍历 EXPERIENCE_SECTION_CONFIGS,在 DOM 中查找每种经历的区块标题元素
|
||||
// 2. 对比简历数据中该经历的段数 vs 页面已展开的段数
|
||||
// 3. 如果简历数据段数 > 页面已展开段数,查找并点击"添加"按钮补足差额
|
||||
// 4. 每次点击添加按钮后等待 DOM 更新,重新计数确认
|
||||
// 5. 对5种经历的标签关系数据做特殊标记(后续完善)
|
||||
// 6. 所有经历段数添加完毕后,再进入统一的字段匹配和填写流程
|
||||
|
||||
// 5. 匹配表单字段
|
||||
const fields = matchFormFields(document.body, lang)
|
||||
console.log(`===== OfferPie: 匹配到 ${fields.length} 个表单字段 =====`)
|
||||
|
||||
// 6. 逐个:检测选择器 → 填充数据 → 立即填写(一个个来,避免多个选择器同时打开)
|
||||
let success = 0, failed = 0, skipped = 0
|
||||
// 记录上一个非 picker 的输入框,用于每次填完后点击它来关闭残留弹窗
|
||||
let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||
|
||||
for (const f of fields) {
|
||||
// 从简历数据中获取填写值(根据 section + sectionIndex + resumeField 定位)
|
||||
if (currentResumeData) {
|
||||
const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField)
|
||||
if (value) f.fillValue = value
|
||||
}
|
||||
if (!f.fillValue) { skipped++; continue }
|
||||
|
||||
// 检测选择器类型(会点击展开再关闭)
|
||||
await detectPickerField(f, lang)
|
||||
|
||||
console.log(
|
||||
` [${f.key}] "${f.labelText}" → input: ${f.inputSelector || "未找到"}` +
|
||||
` | type: ${f.inputType}` +
|
||||
` | isPicker: ${f.isPicker}` +
|
||||
` | pickerSelector: ${f.pickerDropdownSelector || "无"}` +
|
||||
` | fillValue: "${f.fillValue}"`
|
||||
)
|
||||
|
||||
// 立即填写这个字段
|
||||
const ok = await fillMatchedField(f)
|
||||
if (ok) { success++ } else { failed++ }
|
||||
|
||||
// 每个字段填完后,点击上一个非 picker 输入框来关闭残留弹窗
|
||||
if (lastTextInput) {
|
||||
;(lastTextInput as HTMLElement).click()
|
||||
lastTextInput.focus()
|
||||
await delay(100)
|
||||
lastTextInput.blur()
|
||||
} else {
|
||||
// 兜底:只按 Escape 关闭弹窗,不点击任何元素(避免误触链接导致页面跳转)
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
|
||||
)
|
||||
document.activeElement.blur()
|
||||
}
|
||||
document.body.click()
|
||||
}
|
||||
await delay(300)
|
||||
|
||||
// 更新上一个非 picker 输入框的记录
|
||||
if (!f.isPicker && f.inputElement) {
|
||||
lastTextInput = f.inputElement
|
||||
}
|
||||
}
|
||||
|
||||
setFormFields(fields)
|
||||
console.log(`===== OfferPie: 填写结果 成功${success} 失败${failed} 跳过${skipped} =====`)
|
||||
} else {
|
||||
setFormFields([])
|
||||
console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====")
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("OfferPie: 获取页面结构失败", e)
|
||||
}
|
||||
// 1秒后恢复按钮状态
|
||||
setTimeout(() => setFilling(false), 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="op-container">
|
||||
{/* 顶部操作栏:反馈、设置、关闭按钮 */}
|
||||
<div className="op-header">
|
||||
<span className="op-header-link">反馈</span>
|
||||
<span className="op-header-link">设置</span>
|
||||
{/* 关闭按钮:点击后隐藏侧边栏 */}
|
||||
<button className="op-close-btn" onClick={onClose}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9 12h6M12 9l3 3-3 3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 插件标题 */}
|
||||
<div className="op-title">大学生求职助手</div>
|
||||
|
||||
{/* 职位信息卡片:展示当前页面识别到的职位信息和匹配度 */}
|
||||
<div className="op-job-card">
|
||||
{/* 职位图标 */}
|
||||
<div className="op-job-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#666">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* 职位名称和公司信息 */}
|
||||
<div className="op-job-info">
|
||||
<div className="op-job-title">数据产品经理</div>
|
||||
<div className="op-job-meta">北京 · 字节跳动 独角兽</div>
|
||||
</div>
|
||||
{/* 匹配度环形进度条 */}
|
||||
<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" />
|
||||
{/* 进度圆环:60% 匹配度 */}
|
||||
<circle cx="24" cy="24" r="20" fill="none" stroke="#000" strokeWidth="3"
|
||||
strokeDasharray={`${0.6 * 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">60%</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自动填写按钮:点击后获取页面结构,后续会调用 AI 接口自动填表 */}
|
||||
<button className="op-autofill-btn" onClick={handleAutoFill} disabled={filling}>
|
||||
{filling ? "分析中..." : "自动填写"}
|
||||
</button>
|
||||
|
||||
{/* 使用次数信息 */}
|
||||
<div className="op-credits-row">
|
||||
<span className="op-credits-text">剩余4次</span>
|
||||
<span className="op-credits-link">立即获得无限次数</span>
|
||||
</div>
|
||||
|
||||
{/* 简历区域 */}
|
||||
<div className="op-section-label">简历</div>
|
||||
{/* 简历卡片:展示当前选中的简历信息 */}
|
||||
<div className="op-resume-card">
|
||||
{/* 简历头像 */}
|
||||
<div className="op-resume-avatar">B</div>
|
||||
{/* 简历名称和标签 */}
|
||||
<div className="op-resume-info">
|
||||
<span className="op-resume-name">李华-产品经理</span>
|
||||
<span className="op-resume-tag">默认</span>
|
||||
<span className="op-resume-tag">竞争力分析</span>
|
||||
</div>
|
||||
{/* 更改简历按钮 */}
|
||||
<span className="op-resume-change">更改</span>
|
||||
</div>
|
||||
|
||||
{/* 针对性优化简历按钮 */}
|
||||
<button className="op-optimize-btn">针对性优化简历</button>
|
||||
|
||||
{/* 填写进度区域 */}
|
||||
<div className="op-progress-row">
|
||||
<span className="op-progress-label">填写进度</span>
|
||||
<span className="op-progress-value">50%</span>
|
||||
</div>
|
||||
{/* 进度条 */}
|
||||
<div className="op-progress-bar-bg">
|
||||
<div className="op-progress-bar-fill" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Content Script - 侧边栏入口
|
||||
* 该脚本会被注入到所有网页中,负责渲染侧边栏面板
|
||||
* 使用 Plasmo 的 Content Script UI 功能,通过 Shadow DOM 实现样式隔离
|
||||
*/
|
||||
|
||||
import styleText from "data-text:~components/SidebarPanel.scss"
|
||||
import type { PlasmoCSConfig, PlasmoGetStyle } from "plasmo"
|
||||
import { useEffect, useState } from "react"
|
||||
import { SidebarPanel } from "~components/SidebarPanel"
|
||||
|
||||
/** Content Script 配置:匹配所有网页 */
|
||||
export const config: PlasmoCSConfig = {
|
||||
matches: ["<all_urls>"]
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 SCSS 编译后的样式注入到 Shadow DOM 中
|
||||
* 这样插件的样式不会影响宿主页面,也不会被宿主页面的样式影响
|
||||
*/
|
||||
export const getStyle: PlasmoGetStyle = () => {
|
||||
const style = document.createElement("style")
|
||||
style.textContent = styleText
|
||||
return style
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧边栏根组件
|
||||
* 监听来自 Background Service Worker 的消息,控制面板的显示/隐藏
|
||||
*/
|
||||
function Sidebar() {
|
||||
/** 侧边栏是否可见 */
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* 消息监听器:接收来自 background 或 popup 的消息
|
||||
* 当收到 TOGGLE_SIDEBAR 消息时,切换侧边栏的显示状态
|
||||
*/
|
||||
const handler = (message: any) => {
|
||||
if (message.type === "TOGGLE_SIDEBAR") {
|
||||
setVisible((prev) => !prev)
|
||||
}
|
||||
}
|
||||
chrome.runtime.onMessage.addListener(handler)
|
||||
// 组件卸载时移除监听器,防止内存泄漏
|
||||
return () => chrome.runtime.onMessage.removeListener(handler)
|
||||
}, [])
|
||||
|
||||
// 不可见时不渲染任何内容
|
||||
if (!visible) return null
|
||||
|
||||
return <SidebarPanel onClose={() => setVisible(false)} />
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 后端 API 通信模块
|
||||
* 封装了与 Java 后端和 Python AI 后端的 REST API 请求方法
|
||||
* 注意:项目有两个后端,新增接口时务必确认是哪个后端的
|
||||
*/
|
||||
|
||||
/** Java 后端基础地址 */
|
||||
const BASE_URL = "http://localhost:8080/api"
|
||||
|
||||
/** Python AI 后端基础地址 */
|
||||
const AI_BASE_URL = "http://localhost:5000/api"
|
||||
|
||||
/** 请求配置选项 */
|
||||
interface ApiOptions {
|
||||
/** 请求方法,默认 GET */
|
||||
method?: string
|
||||
/** 请求体数据 */
|
||||
body?: unknown
|
||||
/** 自定义请求头 */
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用请求方法
|
||||
* @param baseUrl - 后端基础地址
|
||||
* @param path - 接口路径,如 /user/info
|
||||
* @param options - 请求配置
|
||||
* @returns 解析后的 JSON 响应数据
|
||||
* @throws 当响应状态码非 2xx 时抛出错误
|
||||
*/
|
||||
async function request<T>(baseUrl: string, path: string, options: ApiOptions = {}): Promise<T> {
|
||||
const { method = "GET", body, headers = {} } = options
|
||||
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API Error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Java 后端接口
|
||||
* 用于用户管理、简历数据、投递记录等业务接口
|
||||
*/
|
||||
export const javaApi = {
|
||||
/** 发送 GET 请求到 Java 后端 */
|
||||
get: <T>(path: string) => request<T>(BASE_URL, path),
|
||||
/** 发送 POST 请求到 Java 后端 */
|
||||
post: <T>(path: string, body: unknown) => request<T>(BASE_URL, path, { method: "POST", body })
|
||||
}
|
||||
|
||||
/**
|
||||
* Python AI 后端接口
|
||||
* 用于页面结构分析、智能填表、简历优化等 AI 功能接口
|
||||
*/
|
||||
export const aiApi = {
|
||||
/** 发送 GET 请求到 Python AI 后端 */
|
||||
get: <T>(path: string) => request<T>(AI_BASE_URL, path),
|
||||
/** 发送 POST 请求到 Python AI 后端 */
|
||||
post: <T>(path: string, body: unknown) => request<T>(AI_BASE_URL, path, { method: "POST", body })
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 自动填写核心模块
|
||||
* 负责:字段填写入口、值写入、单选填写、选择器填写、弹窗关闭、批量填写
|
||||
*/
|
||||
|
||||
import type { MatchedFormField } from "./types"
|
||||
import { findAndClickOptionInVisiblePopups, clickBestOptionInDropdown } from "./pickerFill"
|
||||
import { fillDatePicker } from "./datePicker"
|
||||
|
||||
// ============ 工具函数 ============
|
||||
|
||||
/** 延时工具函数 */
|
||||
export function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的值写入方法(适配各种前端框架的受控组件)
|
||||
* 优先用 execCommand 模拟真实输入,失败则 fallback 到原生 setter
|
||||
*/
|
||||
export function forceSetValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||
if (el instanceof HTMLInputElement && el.type === "file") {
|
||||
console.warn("OfferPie: 跳过 file 类型 input")
|
||||
return
|
||||
}
|
||||
el.focus()
|
||||
if (el.value) el.select()
|
||||
const inserted = document.execCommand("insertText", false, value)
|
||||
if (!inserted || el.value !== value) {
|
||||
const isTextarea = el instanceof HTMLTextAreaElement
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
isTextarea ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, "value"
|
||||
)?.set
|
||||
setter?.call(el, value)
|
||||
el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: value }))
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }))
|
||||
}
|
||||
el.dispatchEvent(new Event("blur", { bubbles: true }))
|
||||
}
|
||||
|
||||
/** 关闭弹出层 */
|
||||
export async function closePopup(inputEl: HTMLElement) {
|
||||
inputEl.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true }))
|
||||
await delay(100)
|
||||
inputEl.blur()
|
||||
inputEl.dispatchEvent(new Event("blur", { bubbles: true }))
|
||||
await delay(100)
|
||||
document.body.click()
|
||||
await delay(200)
|
||||
}
|
||||
|
||||
// ============ 字段填写 ============
|
||||
|
||||
/** 填写单个匹配到的表单字段 */
|
||||
export async function fillMatchedField(field: MatchedFormField): Promise<boolean> {
|
||||
const { labelText, inputElement, isPicker, fillValue, inputType, radioContainer } = field
|
||||
if (!fillValue) { console.warn(`OfferPie: ⏭ "${labelText}" 无填写值,跳过`); return false }
|
||||
try {
|
||||
if (inputType === "radio" && radioContainer) return fillRadioField(radioContainer, labelText, fillValue)
|
||||
if (!inputElement) { console.warn(`OfferPie: ❌ "${labelText}" 未找到 input 元素,跳过`); return false }
|
||||
if (inputElement instanceof HTMLInputElement && inputElement.type === "file") { console.warn(`OfferPie: ⏭ "${labelText}" 是文件上传框,跳过`); return false }
|
||||
if (isPicker) return await fillPickerField(field)
|
||||
inputElement.focus()
|
||||
await delay(50)
|
||||
forceSetValue(inputElement, fillValue)
|
||||
console.log(`OfferPie: ✅ [输入框] 已填写 "${labelText}" = "${fillValue}"`)
|
||||
return true
|
||||
} catch (err) { console.error(`OfferPie: ❌ 填写 "${labelText}" 出错`, err); return false }
|
||||
}
|
||||
|
||||
/** 填写单选按钮组 */
|
||||
function fillRadioField(container: Element, labelText: string, fillValue: string): boolean {
|
||||
const radioSelectors = ['[class*="radio"]', 'label', '[role="radio"]', 'input[type="radio"]']
|
||||
for (const sel of radioSelectors) {
|
||||
for (const option of Array.from(container.querySelectorAll(sel))) {
|
||||
const text = option.textContent?.trim() || ""
|
||||
if (text === fillValue || text.includes(fillValue)) {
|
||||
const clickTarget = (option.closest('[class*="radio"]') || option) as HTMLElement
|
||||
clickTarget.click()
|
||||
console.log(`OfferPie: ✅ [单选] 已选择 "${labelText}" = "${fillValue}" (点击 "${text}")`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const el of Array.from(container.querySelectorAll("*"))) {
|
||||
const text = el.textContent?.trim() || ""
|
||||
if (text === fillValue && (el as HTMLElement).offsetHeight > 0) {
|
||||
;(el as HTMLElement).click()
|
||||
console.log(`OfferPie: ✅ [单选] 已选择 "${labelText}" = "${fillValue}"`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
console.warn(`OfferPie: ❌ [单选] "${labelText}" 未找到选项 "${fillValue}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 【重要】填写选择器类型字段
|
||||
* 核心流程:
|
||||
* 1. 记录点击前的 DOM 快照(body 子元素 + 所有可见弹出层)
|
||||
* 2. 点击 input 及其单子标签父级链来展开选择器
|
||||
* 3. 对比点击前后 DOM 差异,找到新增的弹出层
|
||||
* 4. 在弹出层中用模糊匹配找到目标选项并点击
|
||||
* 5. 找不到弹出层时 fallback 到全局可见弹出层搜索和 input 父级搜索
|
||||
* 6. 最终兜底:写入值 + Enter 确认
|
||||
*
|
||||
* 【注意】不要删除 DOM 差异对比逻辑,这是自建组件选择器识别的核心机制
|
||||
*/
|
||||
async function fillPickerField(field: MatchedFormField): Promise<boolean> {
|
||||
const { labelText, inputElement, fillValue } = field
|
||||
if (!inputElement) return false
|
||||
const isDateValue = /^\d{4}-\d{2}-\d{2}$/.test(fillValue)
|
||||
|
||||
// ---- 步骤1:记录点击前的 DOM 快照 ----
|
||||
const POPUP_SELECTORS = [
|
||||
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
|
||||
'[class*="picker-panel"]', '[class*="overlay"]', '[class*="popover"]',
|
||||
'[class*="select-dropdown"]', '[class*="cascader"]',
|
||||
'[class*="menu"][class*="content"]',
|
||||
'[role="listbox"]', '[role="menu"]',
|
||||
]
|
||||
const beforeBodyChildren = new Set(Array.from(document.body.children))
|
||||
const beforeVisiblePopups = new Set<Element>()
|
||||
for (const sel of POPUP_SELECTORS) {
|
||||
document.querySelectorAll(sel).forEach((el) => {
|
||||
if ((el as HTMLElement).offsetHeight > 0 && (el as HTMLElement).offsetWidth > 0) beforeVisiblePopups.add(el)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 步骤2:点击展开选择器(input + 单子标签父级链) ----
|
||||
const clickTargets: HTMLElement[] = [inputElement as HTMLElement]
|
||||
let current: HTMLElement | null = inputElement as HTMLElement
|
||||
for (let i = 0; i < 5 && current?.parentElement; i++) {
|
||||
const parentEl = current.parentElement
|
||||
if (parentEl.children.length <= 2) {
|
||||
clickTargets.push(parentEl)
|
||||
current = parentEl
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
// 从外到内逐个点击(外层容器可能绑了事件)
|
||||
for (let i = clickTargets.length - 1; i >= 0; i--) {
|
||||
clickTargets[i].click()
|
||||
}
|
||||
inputElement.focus()
|
||||
await delay(300)
|
||||
|
||||
if (isDateValue) return await fillDatePicker(field)
|
||||
|
||||
// ---- 步骤3:对比 DOM 差异,找到新增的弹出层 ----
|
||||
let dropdownEl: HTMLElement | null = null
|
||||
|
||||
// 策略A:body 下新增的直接子元素
|
||||
for (const child of Array.from(document.body.children)) {
|
||||
if (!beforeBodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) {
|
||||
dropdownEl = child as HTMLElement
|
||||
break
|
||||
}
|
||||
}
|
||||
// 策略B:新变为可见的弹出层
|
||||
if (!dropdownEl) {
|
||||
for (const sel of POPUP_SELECTORS) {
|
||||
for (const el of Array.from(document.querySelectorAll(sel))) {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0 && !beforeVisiblePopups.has(el)) {
|
||||
dropdownEl = htmlEl
|
||||
break
|
||||
}
|
||||
}
|
||||
if (dropdownEl) break
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 步骤4:在弹出层中查找匹配选项 ----
|
||||
if (dropdownEl) {
|
||||
console.log(`OfferPie: [fillPicker] "${labelText}" 找到弹出层: ${dropdownEl.tagName}.${(dropdownEl.className || "").toString().split(" ")[0]}`)
|
||||
if (clickBestOptionInDropdown(dropdownEl, fillValue)) {
|
||||
await delay(300); await closePopup(inputElement)
|
||||
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 步骤5:fallback - 全局可见弹出层搜索 ----
|
||||
if (findAndClickOptionInVisiblePopups(fillValue, labelText)) {
|
||||
await delay(300); await closePopup(inputElement)
|
||||
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`)
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- 步骤5b:fallback - input 父级附近搜索弹出层 ----
|
||||
let parentEl: Element | null = inputElement.parentElement
|
||||
for (let i = 0; i < 8 && parentEl; i++) {
|
||||
const dropdowns = parentEl.querySelectorAll('[class*="dropdown"], [class*="select-dropdown"], [class*="option"], [role="listbox"]')
|
||||
for (const dd of Array.from(dropdowns)) {
|
||||
const htmlDd = dd as HTMLElement
|
||||
if (htmlDd.offsetHeight > 10 && htmlDd.offsetWidth > 0) {
|
||||
if (clickBestOptionInDropdown(htmlDd, fillValue)) {
|
||||
await delay(300); await closePopup(inputElement)
|
||||
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
parentEl = parentEl.parentElement
|
||||
}
|
||||
|
||||
// ---- 步骤6:写入值触发搜索,再尝试一次 ----
|
||||
forceSetValue(inputElement, fillValue)
|
||||
await delay(500)
|
||||
if (findAndClickOptionInVisiblePopups(fillValue, labelText)) {
|
||||
await delay(300); await closePopup(inputElement)
|
||||
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`)
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- 步骤7:兜底 Enter 确认 ----
|
||||
inputElement.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true }))
|
||||
await delay(300)
|
||||
await closePopup(inputElement)
|
||||
console.log(`OfferPie: ⚠️ [选择器] 未找到匹配选项,已兜底填写 "${labelText}" = "${fillValue}"`)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 批量填写所有匹配到的表单字段 */
|
||||
export async function fillAllFields(fields: MatchedFormField[]): Promise<{ total: number; success: number; failed: number; skipped: number }> {
|
||||
const result = { total: fields.length, success: 0, failed: 0, skipped: 0 }
|
||||
console.log(`===== OfferPie: 开始自动填写,共 ${fields.length} 个字段 =====`)
|
||||
for (const field of fields) {
|
||||
if (!field.fillValue) { result.skipped++; continue }
|
||||
if (field.inputType !== "radio" && !field.inputElement) { result.skipped++; continue }
|
||||
const ok = await fillMatchedField(field)
|
||||
if (ok) result.success++; else result.failed++
|
||||
await delay(200)
|
||||
}
|
||||
console.log(`===== OfferPie: 自动填写完毕 总计 ${result.total} | 成功 ${result.success} | 失败 ${result.failed} | 跳过 ${result.skipped} =====`)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 常量数据模块
|
||||
* 包含表单标签数组、测试填写数据、UI组件库选择器配置、特殊标签修正配置等所有常量
|
||||
*/
|
||||
|
||||
import type { FormLabelItem, UILibPickerConfig, ExperienceSectionConfig, ResumeData } from "./types"
|
||||
|
||||
// ============ 职位申请常用信息标签(中英文) ============
|
||||
|
||||
/**
|
||||
* 职位申请表单常用信息标签数组
|
||||
* 包含个人信息、联系方式、教育背景、求职意向等常见字段
|
||||
*/
|
||||
export const JOB_FORM_LABELS: FormLabelItem[] = [
|
||||
// ---- 个人基本信息(主表) ----
|
||||
{ key: "name", zh: ["姓名", "姓 名", "真实姓名", "名字"], en: ["Name", "Full Name", "Your Name", "Legal Name"], section: "main", resumeField: "name" },
|
||||
{ key: "gender", zh: ["性别"], en: ["Gender", "Sex"], section: "main", resumeField: "" },
|
||||
{ key: "birthday", zh: ["出生日期", "出生年月", "生日", "出生日期"], en: ["Date of Birth", "Birthday", "Birth Date", "DOB"], section: "main", resumeField: "" },
|
||||
{ key: "idType", zh: ["证件类型"], en: ["ID Type", "Document Type"], section: "main", resumeField: "" },
|
||||
{ key: "idNumber", zh: ["身份证号", "身份证", "证件号码", "证件号"], en: ["ID Number", "National ID", "ID Card Number"], section: "main", resumeField: "" },
|
||||
{ key: "nationality", zh: ["国籍"], en: ["Nationality", "Country of Citizenship"], section: "main", resumeField: "" },
|
||||
{ key: "ethnicity", zh: ["民族"], en: ["Ethnicity", "Race"], section: "main", resumeField: "" },
|
||||
{ key: "maritalStatus", zh: ["婚姻状况", "婚姻"], en: ["Marital Status", "Marriage Status"], section: "main", resumeField: "" },
|
||||
{ key: "hasChildren", zh: ["有无子女", "子女"], en: ["Children", "Has Children"], section: "main", resumeField: "" },
|
||||
{ key: "politicalStatus", zh: ["政治面貌"], en: ["Political Status", "Political Affiliation"], section: "main", resumeField: "" },
|
||||
{ key: "nativePlace", zh: ["籍贯", "户籍", "户口所在地"], en: ["Native Place", "Hometown", "Place of Origin"], section: "main", resumeField: "" },
|
||||
{ key: "height", zh: ["身高", "身高(cm)"], en: ["Height"], section: "main", resumeField: "" },
|
||||
{ key: "weight", zh: ["体重", "体重(kg)"], en: ["Weight"], section: "main", resumeField: "" },
|
||||
{ key: "avatarUrl", zh: ["头像", "照片", "个人照片"], en: ["Avatar", "Photo", "Profile Photo"], section: "main", resumeField: "avatarUrl" },
|
||||
{ key: "freshGraduate", zh: ["应届生", "应届生标识", "是否应届"], en: ["Fresh Graduate", "New Graduate", "Recent Graduate"], section: "main", resumeField: "" },
|
||||
// ---- 联系方式(主表) ----
|
||||
{ key: "phone", zh: ["手机号", "手机", "电话", "联系电话", "手机号码", "移动电话", "手机类型"], en: ["Phone", "Mobile", "Phone Number", "Cell Phone", "Telephone", "Contact Number"], section: "main", resumeField: "mobileNumber" },
|
||||
{ key: "email", zh: ["邮箱", "电子邮箱", "邮件", "电子邮件"], en: ["Email", "E-mail", "Email Address"], section: "main", resumeField: "email" },
|
||||
{ key: "address", zh: ["地址", "通讯地址", "联系地址", "现居住地", "家庭住址", "现住址"], en: ["Address", "Current Address", "Mailing Address", "Location", "Home Address"], section: "main", resumeField: "city" },
|
||||
{ key: "zipCode", zh: ["邮编", "邮政编码"], en: ["Zip Code", "Postal Code", "ZIP"], section: "main", resumeField: "" },
|
||||
{ key: "wechatNumber", zh: ["微信号", "微信", "微信账号"], en: ["WeChat", "WeChat ID", "WeChat Number"], section: "main", resumeField: "wechatNumber" },
|
||||
{ key: "qq", zh: ["QQ", "QQ号", "QQ号码"], en: ["QQ", "QQ Number"], section: "main", resumeField: "" },
|
||||
{ key: "emergencyContact", zh: ["紧急联系人", "紧急联系人姓名"], en: ["Emergency Contact", "Emergency Contact Name"], section: "main", resumeField: "" },
|
||||
{ key: "emergencyPhone", zh: ["紧急联系方式", "紧急联系电话", "紧急联系人电话"], en: ["Emergency Phone", "Emergency Contact Number"], section: "main", resumeField: "" },
|
||||
{ key: "portfolioUrl", zh: ["作品集", "作品链接", "个人作品", "作品集链接"], en: ["Portfolio", "Portfolio URL", "Portfolio Link", "Work Samples"], section: "main", resumeField: "portfolioUrl" },
|
||||
// ---- 教育经历(数组) ----
|
||||
{ key: "school", zh: ["学校", "学校名称", "毕业院校", "院校", "毕业学校"], en: ["School", "University", "College", "Institution", "School Name"], section: "education", resumeField: "school" },
|
||||
{ key: "schoolLocation", zh: ["学校所在地", "学校所在城市"], en: ["School Location", "Campus Location"], section: "education", resumeField: "" },
|
||||
{ key: "major", zh: ["专业", "专业名称", "所学专业", "主修课程"], en: ["Major", "Field of Study", "Specialization", "Program"], section: "education", resumeField: "major" },
|
||||
{ key: "secondMajor", zh: ["第二专业", "辅修专业", "辅修"], en: ["Second Major", "Minor", "Double Major"], section: "education", resumeField: "" },
|
||||
{ key: "degree", zh: ["学历", "学位", "最高学历", "最高学位"], en: ["Degree", "Education Level", "Qualification", "Education"], section: "education", resumeField: "degree" },
|
||||
{ key: "studyType", zh: ["学习形式", "就读方式", "全日制/非全日制", "学习方式"], en: ["Study Type", "Study Mode", "Full-time/Part-time"], section: "education", resumeField: "studyType" },
|
||||
{ key: "graduationDate", zh: ["毕业时间", "毕业日期", "毕业年份"], en: ["Graduation Date", "Graduation Year", "Expected Graduation"], section: "education", resumeField: "endDate" },
|
||||
{ key: "eduStartDate", zh: ["入学时间", "开始时间"], en: ["Start Date", "Enrollment Date", "From"], section: "education", resumeField: "startDate" },
|
||||
{ key: "eduEndDate", zh: ["结束时间", "毕业时间"], en: ["End Date", "To", "Until"], section: "education", resumeField: "endDate" },
|
||||
{ key: "eduDescription", zh: ["在校经历", "教育描述", "在校描述"], en: ["Education Description", "Academic Description"], section: "education", resumeField: "description" },
|
||||
{ key: "gpa", zh: ["GPA", "绩点", "成绩", "平均分"], en: ["GPA", "Grade Point Average", "Academic Score"], section: "education", resumeField: "" },
|
||||
{ key: "graduationThesis", zh: ["毕业论文", "毕业设计", "毕业论文/设计题目"], en: ["Thesis", "Graduation Thesis", "Dissertation"], section: "education", resumeField: "" },
|
||||
// ---- 求职意向(主表) ----
|
||||
{ key: "expectedSalary", zh: ["期望薪资", "期望月薪", "薪资要求", "期望薪酬", "薪酬", "薪资", "年薪", "月薪", "薪酬待遇", "期望年薪"], en: ["Expected Salary", "Desired Salary", "Salary Expectation", "Compensation"], section: "main", resumeField: "" },
|
||||
{ key: "expectedPosition", zh: ["期望职位", "意向岗位", "应聘职位", "期望月薪/年薪"], en: ["Expected Position", "Desired Position", "Job Title", "Position Applied"], section: "main", resumeField: "targetPosition" },
|
||||
{ key: "expectedCity", zh: ["期望城市", "工作城市", "意向城市", "期望工作地点", "期望工作地"], en: ["Preferred City", "Work Location", "Desired Location", "Preferred Location"], section: "main", resumeField: "city" },
|
||||
{ key: "availableDate", zh: ["到岗时间", "入职时间", "可到岗日期"], en: ["Available Date", "Start Date", "Earliest Start Date", "Availability"], section: "main", resumeField: "" },
|
||||
{ key: "workYears", zh: ["工作年限", "工作经验", "工作年限(年)", "期望工作地"], en: ["Work Experience", "Years of Experience", "Work Years"], section: "main", resumeField: "" },
|
||||
{ key: "jobAcceptAdjust", zh: ["是否接受岗位调剂", "接受调剂", "服从调剂"], en: ["Accept Job Adjustment", "Open to Relocation", "Flexible Position"], section: "main", resumeField: "" },
|
||||
{ key: "idCardNumber", zh: ["证件号", "证件号码"], en: ["ID Card Number", "Certificate Number"], section: "main", resumeField: "" },
|
||||
// ---- 工作经历(数组) ----
|
||||
{ key: "workCompany", zh: ["公司", "公司名称", "单位名称", "工作单位", "公司全称"], en: ["Company", "Company Name", "Employer", "Organization"], section: "work", resumeField: "companyName" },
|
||||
{ key: "workCompanyShort", zh: ["公司简称"], en: ["Company Abbreviation", "Short Name"], section: "work", resumeField: "" },
|
||||
{ key: "workCompanyScale", zh: ["公司规模"], en: ["Company Size", "Company Scale"], section: "work", resumeField: "" },
|
||||
{ key: "workPosition", zh: ["职位", "职位名称", "岗位", "担任职务", "职位名称"], en: ["Job Title", "Position", "Title", "Role"], section: "work", resumeField: "position" },
|
||||
{ key: "workStartDate", zh: ["开始时间", "入职时间"], en: ["Start Date", "From"], section: "work", resumeField: "startDate" },
|
||||
{ key: "workEndDate", zh: ["结束时间", "离职时间"], en: ["End Date", "To"], section: "work", resumeField: "endDate" },
|
||||
{ key: "workDescription", zh: ["工作描述", "工作内容", "职责描述", "岗位职责", "职位描述"], en: ["Job Description", "Responsibilities", "Description", "Duties"], section: "work", resumeField: "description" },
|
||||
// ---- 实习经历(数组) ----
|
||||
{ key: "internCompany", zh: ["实习公司", "实习单位"], en: ["Intern Company", "Internship Company"], section: "internship", resumeField: "companyName" },
|
||||
{ key: "internPosition", zh: ["实习职位", "实习岗位"], en: ["Intern Position", "Internship Role"], section: "internship", resumeField: "position" },
|
||||
{ key: "internStartDate", zh: ["实习开始时间"], en: ["Internship Start Date"], section: "internship", resumeField: "startDate" },
|
||||
{ key: "internEndDate", zh: ["实习结束时间"], en: ["Internship End Date"], section: "internship", resumeField: "endDate" },
|
||||
{ key: "internDescription", zh: ["实习描述", "实习内容"], en: ["Internship Description", "Intern Duties"], section: "internship", resumeField: "description" },
|
||||
// ---- 项目经历(数组) ----
|
||||
{ key: "projectName", zh: ["项目名称", "项目名", "项目", "项目名称"], en: ["Project Name", "Project", "Project Title"], section: "project", resumeField: "projectName" },
|
||||
{ key: "projectCompany", zh: ["所属公司", "项目所属公司", "项目公司"], en: ["Project Company", "Company"], section: "project", resumeField: "companyName" },
|
||||
{ key: "projectRole", zh: ["担任角色", "项目角色", "角色"], en: ["Role", "Project Role", "Your Role"], section: "project", resumeField: "role" },
|
||||
{ key: "projectStartDate", zh: ["项目开始时间"], en: ["Project Start Date"], section: "project", resumeField: "startDate" },
|
||||
{ key: "projectEndDate", zh: ["项目结束时间"], en: ["Project End Date"], section: "project", resumeField: "endDate" },
|
||||
{ key: "projectDescription", zh: ["项目描述", "项目内容", "项目职责"], en: ["Project Description", "Project Details", "Project Responsibilities"], section: "project", resumeField: "description" },
|
||||
// ---- 竞赛经历(数组) ----
|
||||
{ key: "competitionName", zh: ["竞赛名称", "比赛名称", "竞赛"], en: ["Competition Name", "Contest Name", "Competition"], section: "competition", resumeField: "competitionName" },
|
||||
{ key: "competitionAward", zh: ["获奖情况", "奖项", "获奖"], en: ["Award", "Prize", "Achievement"], section: "competition", resumeField: "award" },
|
||||
{ key: "competitionDate", zh: ["获奖时间", "比赛时间"], en: ["Award Date", "Competition Date"], section: "competition", resumeField: "awardDate" },
|
||||
{ key: "competitionDescription", zh: ["竞赛描述", "比赛描述"], en: ["Competition Description", "Contest Details"], section: "competition", resumeField: "description" },
|
||||
// ---- 技能与证书(主表) ----
|
||||
{ key: "skills", zh: ["技能", "专业技能", "技能特长", "专业特长"], en: ["Skills", "Technical Skills", "Competencies"], section: "main", resumeField: "skills" },
|
||||
{ key: "language", zh: ["语言水平", "语言能力", "外语能力", "英语等级", "英语等级成绩", "英语水平", "外语水平", "语言考试"], en: ["Language Proficiency", "English Level", "Language Skills", "Foreign Language", "Language Level"], section: "main", resumeField: "" },
|
||||
{ key: "certificate", zh: ["证书", "资格证书", "获得证书"], en: ["Certificate", "Certification", "License", "Credentials"], section: "main", resumeField: "certificates" },
|
||||
// ---- 自我评价与附加信息(主表) ----
|
||||
{ key: "selfIntro", zh: ["自我评价", "自我介绍", "个人简介", "个人总结", "自我描述"], en: ["Self Introduction", "About Me", "Summary", "Personal Statement", "Profile"], section: "main", resumeField: "summary" },
|
||||
{ key: "hobby", zh: ["兴趣爱好", "爱好", "健身爱好", "个人爱好"], en: ["Hobbies", "Interests", "Hobbies & Interests"], section: "main", resumeField: "" },
|
||||
{ key: "specialty", zh: ["特长", "个人特长"], en: ["Specialty", "Strengths", "Personal Strengths"], section: "main", resumeField: "" },
|
||||
{ key: "rewardPunishment", zh: ["奖惩情况", "奖惩", "奖励情况"], en: ["Rewards & Punishments", "Awards & Disciplinary"], section: "main", resumeField: "" },
|
||||
{ key: "academicWork", zh: ["学术专著", "学术著作"], en: ["Academic Works", "Publications"], section: "main", resumeField: "" },
|
||||
{ key: "academicPaper", zh: ["学术论文", "论文发表"], en: ["Academic Papers", "Research Papers", "Thesis"], section: "main", resumeField: "" },
|
||||
{ key: "patent", zh: ["专利发明", "专利", "发明专利"], en: ["Patents", "Inventions", "Patent"], section: "main", resumeField: "" },
|
||||
{ key: "coverLetter", zh: ["求职信", "自荐信"], en: ["Cover Letter", "Application Letter"], section: "main", resumeField: "" },
|
||||
{ key: "referral", zh: ["推荐人", "内推人", "推荐渠道", "渠道"], en: ["Referral", "Referred By", "How did you hear", "Source"], section: "main", resumeField: "" },
|
||||
{ key: "agreement", zh: ["本人保证", "同意条款", "隐私协议", "声明"], en: ["Agreement", "I agree", "Terms", "Declaration", "Consent"], section: "main", resumeField: "" },
|
||||
]
|
||||
|
||||
// ============ 测试数据 ============
|
||||
|
||||
/** 根据标签 key 生成的测试填写数据(中英文各一份),用于开发调试阶段 */
|
||||
export const TEST_FILL_DATA: Record<string, { zh: string; en: string }> = {
|
||||
name: { zh: "李华", en: "Hua Li" },
|
||||
gender: { zh: "男", en: "Male" },
|
||||
birthday: { zh: "2002-03-15", en: "2002-03-15" },
|
||||
idType: { zh: "身份证", en: "ID Card" },
|
||||
idNumber: { zh: "110101200203150011", en: "110101200203150011" },
|
||||
nationality: { zh: "中国", en: "China" },
|
||||
ethnicity: { zh: "汉族", en: "Han" },
|
||||
maritalStatus: { zh: "未婚", en: "Single" },
|
||||
hasChildren: { zh: "无", en: "No" },
|
||||
politicalStatus: { zh: "共青团员", en: "Communist Youth League" },
|
||||
nativePlace: { zh: "北京", en: "Beijing" },
|
||||
height: { zh: "175", en: "175" },
|
||||
weight: { zh: "70", en: "70" },
|
||||
avatarUrl: { zh: "", en: "" },
|
||||
freshGraduate: { zh: "是", en: "Yes" },
|
||||
phone: { zh: "13800138000", en: "13800138000" },
|
||||
email: { zh: "lihua@example.com", en: "lihua@example.com" },
|
||||
address: { zh: "北京市海淀区中关村大街1号", en: "No.1 Zhongguancun Street, Haidian, Beijing" },
|
||||
zipCode: { zh: "100080", en: "100080" },
|
||||
wechatNumber: { zh: "lihua_wx", en: "lihua_wx" },
|
||||
qq: { zh: "123456789", en: "123456789" },
|
||||
emergencyContact: { zh: "李明", en: "Ming Li" },
|
||||
emergencyPhone: { zh: "13900139000", en: "13900139000" },
|
||||
portfolioUrl: { zh: "https://portfolio.example.com", en: "https://portfolio.example.com" },
|
||||
school: { zh: "北京大学", en: "Peking University" },
|
||||
schoolLocation: { zh: "北京", en: "Beijing" },
|
||||
major: { zh: "计算机科学与技术", en: "Computer Science" },
|
||||
secondMajor: { zh: "数学与应用数学", en: "Mathematics" },
|
||||
degree: { zh: "硕士", en: "Master" },
|
||||
studyType: { zh: "全日制", en: "Full-time" },
|
||||
graduationDate: { zh: "2024-07-01", en: "2024-07-01" },
|
||||
eduStartDate: { zh: "2020-09-01", en: "2020-09-01" },
|
||||
eduEndDate: { zh: "2024-07-01", en: "2024-07-01" },
|
||||
eduDescription: { zh: "主修计算机科学核心课程", en: "Majored in core CS courses" },
|
||||
gpa: { zh: "3.8", en: "3.8" },
|
||||
graduationThesis: { zh: "基于深度学习的文本分类研究", en: "Text Classification Based on Deep Learning" },
|
||||
expectedSalary: { zh: "15000", en: "15000" },
|
||||
expectedPosition: { zh: "前端开发工程师", en: "Frontend Developer" },
|
||||
expectedCity: { zh: "北京", en: "Beijing" },
|
||||
availableDate: { zh: "2024-08-01", en: "2024-08-01" },
|
||||
workYears: { zh: "3", en: "3" },
|
||||
jobAcceptAdjust: { zh: "是", en: "Yes" },
|
||||
idCardNumber: { zh: "110101200203150011", en: "110101200203150011" },
|
||||
workCompany: { zh: "字节跳动", en: "ByteDance" },
|
||||
workCompanyShort: { zh: "字节", en: "ByteDance" },
|
||||
workCompanyScale: { zh: "10000人以上", en: "10000+" },
|
||||
workPosition: { zh: "前端开发工程师", en: "Frontend Developer" },
|
||||
workStartDate: { zh: "2023-06-01", en: "2023-06-01" },
|
||||
workEndDate: { zh: "2023-09-01", en: "2023-09-01" },
|
||||
workDescription: { zh: "负责活动页面开发与性能优化", en: "Developed campaign pages and optimized performance" },
|
||||
internCompany: { zh: "腾讯", en: "Tencent" },
|
||||
internPosition: { zh: "前端实习生", en: "Frontend Intern" },
|
||||
internStartDate: { zh: "2022-07-01", en: "2022-07-01" },
|
||||
internEndDate: { zh: "2022-09-30", en: "2022-09-30" },
|
||||
internDescription: { zh: "参与小程序开发", en: "Participated in mini-program development" },
|
||||
projectName: { zh: "智能简历填写助手", en: "Smart Resume Filler" },
|
||||
projectCompany: { zh: "字节跳动", en: "ByteDance" },
|
||||
projectRole: { zh: "前端负责人", en: "Frontend Lead" },
|
||||
projectStartDate: { zh: "2023-03-01", en: "2023-03-01" },
|
||||
projectEndDate: { zh: "2023-06-01", en: "2023-06-01" },
|
||||
projectDescription: { zh: "负责项目前端架构设计与开发", en: "Led frontend architecture design and development" },
|
||||
competitionName: { zh: "ACM程序设计大赛", en: "ACM Programming Contest" },
|
||||
competitionAward: { zh: "金奖", en: "Gold Award" },
|
||||
competitionDate: { zh: "2023-07-01", en: "2023-07-01" },
|
||||
competitionDescription:{ zh: "团队协作完成算法竞赛", en: "Team collaboration in algorithm competition" },
|
||||
skills: { zh: "React, TypeScript, Node.js", en: "React, TypeScript, Node.js" },
|
||||
language: { zh: "CET-6 550分", en: "CET-6 550" },
|
||||
certificate: { zh: "软件设计师", en: "Software Designer Certificate" },
|
||||
selfIntro: { zh: "热爱前端开发,有丰富的项目经验", en: "Passionate frontend developer with solid project experience" },
|
||||
hobby: { zh: "篮球、游泳、阅读", en: "Basketball, Swimming, Reading" },
|
||||
specialty: { zh: "全栈开发、算法竞赛", en: "Full-stack Development, Algorithm Competition" },
|
||||
rewardPunishment: { zh: "校级优秀学生", en: "Outstanding Student Award" },
|
||||
academicWork: { zh: "", en: "" },
|
||||
academicPaper: { zh: "", en: "" },
|
||||
patent: { zh: "", en: "" },
|
||||
coverLetter: { zh: "您好,我对贵公司的岗位非常感兴趣", en: "Dear Hiring Manager, I am very interested in this position" },
|
||||
referral: { zh: "学校招聘会", en: "Campus Job Fair" },
|
||||
agreement: { zh: "同意", en: "I agree" },
|
||||
}
|
||||
|
||||
// ============ 特殊标签 input 修正配置 ============
|
||||
|
||||
/**
|
||||
* 特殊标签的 placeholder 关键词配置
|
||||
* key: 标签的 key,placeholders: 该标签对应 input 的 placeholder 应包含的关键词
|
||||
*/
|
||||
export const SPECIAL_LABEL_PLACEHOLDERS: Record<string, string[]> = {
|
||||
/** 证件号码:前面常有证件类型 select,findNearestInput 容易找到 select 的 input */
|
||||
idNumber: ["证件号码", "证件号", "身份证号", "身份证", "ID Number", "ID Card"],
|
||||
}
|
||||
|
||||
// ============ UI 组件库选择器类名数据 ============
|
||||
|
||||
/** 常用 PC 端 UI 组件库的各类选择器类名数据 */
|
||||
export const UI_LIB_PICKER_CONFIGS: UILibPickerConfig[] = [
|
||||
{ libName: "Element UI", dropdownClasses: ["el-select-dropdown", "el-date-picker", "el-picker-panel", "el-cascader-panel", "el-time-panel", "el-date-range-picker", "el-popper", "el-autocomplete-suggestion"], optionItemClasses: ["el-select-dropdown__item", "el-date-table td", "el-cascader-node", "el-time-spinner__item"], triggerClasses: ["el-select", "el-date-editor", "el-cascader", "el-time-select", "el-autocomplete"] },
|
||||
{ libName: "Ant Design", dropdownClasses: ["ant-select-dropdown", "ant-picker-dropdown", "ant-cascader-dropdown", "ant-picker-panel", "ant-select-tree-dropdown", "ant-dropdown"], optionItemClasses: ["ant-select-item-option", "ant-picker-cell-inner", "ant-cascader-menu-item", "ant-select-tree-treenode"], triggerClasses: ["ant-select", "ant-picker", "ant-cascader", "ant-select-selector"] },
|
||||
{ libName: "Arco Design", dropdownClasses: ["arco-select-popup", "arco-picker-container", "arco-trigger-popup", "arco-cascader-popup", "arco-datepicker-container"], optionItemClasses: ["arco-select-option", "arco-picker-cell-inner", "arco-cascader-option"], triggerClasses: ["arco-select", "arco-picker", "arco-cascader", "arco-select-view"] },
|
||||
{ libName: "TDesign", dropdownClasses: ["t-select__dropdown", "t-date-picker__panel", "t-popup", "t-cascader__popup", "t-time-picker__panel"], optionItemClasses: ["t-select-option", "t-date-picker__cell-inner", "t-cascader__item"], triggerClasses: ["t-select", "t-date-picker", "t-cascader", "t-input__wrap"] },
|
||||
{ libName: "Naive UI", dropdownClasses: ["n-base-select-menu", "n-date-panel", "n-cascader-menu", "n-time-picker-panel", "n-auto-complete-menu"], optionItemClasses: ["n-base-select-option", "n-date-panel-date", "n-cascader-option"], triggerClasses: ["n-select", "n-date-picker", "n-cascader", "n-auto-complete"] },
|
||||
{ libName: "iView / View UI", dropdownClasses: ["ivu-select-dropdown", "ivu-date-picker-transfer", "ivu-cascader-transfer", "ivu-time-picker-transfer"], optionItemClasses: ["ivu-select-item", "ivu-date-picker-cells-cell", "ivu-cascader-menu-item"], triggerClasses: ["ivu-select", "ivu-date-picker", "ivu-cascader"] },
|
||||
{ libName: "Vuetify", dropdownClasses: ["v-menu__content", "v-picker", "v-date-picker-table", "v-select-list", "v-autocomplete__content"], optionItemClasses: ["v-list-item", "v-date-picker-table__current", "v-btn--active"], triggerClasses: ["v-select", "v-text-field", "v-autocomplete", "v-input"] },
|
||||
{ libName: "MUI", dropdownClasses: ["MuiPopover-root", "MuiMenu-paper", "MuiAutocomplete-popper", "MuiPickersPopper-root", "MuiPaper-root"], optionItemClasses: ["MuiMenuItem-root", "MuiAutocomplete-option", "MuiPickersDay-root"], triggerClasses: ["MuiSelect-select", "MuiAutocomplete-root", "MuiInputBase-root"] },
|
||||
{ libName: "Semi Design", dropdownClasses: ["semi-select-option-list", "semi-datepicker-panel-container", "semi-cascader-option-lists", "semi-popover-wrapper"], optionItemClasses: ["semi-select-option", "semi-datepicker-day", "semi-cascader-option"], triggerClasses: ["semi-select", "semi-datepicker", "semi-cascader", "semi-input-wrapper"] },
|
||||
]
|
||||
|
||||
// ============ 经历区块标题关键词配置 ============
|
||||
|
||||
/**
|
||||
* 5大经历区块的标题关键词配置
|
||||
* 用于在页面 DOM 中识别经历区块位置、统计已展开段数、查找添加按钮
|
||||
*/
|
||||
export const EXPERIENCE_SECTION_CONFIGS: ExperienceSectionConfig[] = [
|
||||
{
|
||||
section: "education",
|
||||
zh: ["教育背景", "教育经历", "学历信息", "教育信息"],
|
||||
en: ["Education", "Education Background", "Academic Background", "Education History"],
|
||||
coreFieldKey: "school",
|
||||
addButtonZh: ["添加", "新增", "添加教育", "添加教育经历", "新增教育经历", "+ 添加"],
|
||||
addButtonEn: ["Add", "Add Education", "Add More", "+ Add"],
|
||||
},
|
||||
{
|
||||
section: "work",
|
||||
zh: ["工作经历", "工作经验", "工作信息"],
|
||||
en: ["Work Experience", "Employment History", "Work History", "Professional Experience"],
|
||||
coreFieldKey: "workCompany",
|
||||
addButtonZh: ["添加", "新增", "添加工作", "添加工作经历", "新增工作经历", "+ 添加"],
|
||||
addButtonEn: ["Add", "Add Work", "Add Experience", "Add More", "+ Add"],
|
||||
},
|
||||
{
|
||||
section: "internship",
|
||||
zh: ["实习经历", "实习经验", "实习信息"],
|
||||
en: ["Internship", "Internship Experience", "Intern Experience"],
|
||||
coreFieldKey: "internCompany",
|
||||
addButtonZh: ["添加", "新增", "添加实习", "添加实习经历", "新增实习经历", "+ 添加"],
|
||||
addButtonEn: ["Add", "Add Internship", "Add More", "+ Add"],
|
||||
},
|
||||
{
|
||||
section: "project",
|
||||
zh: ["项目经历", "项目经验", "项目信息"],
|
||||
en: ["Project", "Project Experience", "Projects"],
|
||||
coreFieldKey: "projectName",
|
||||
addButtonZh: ["添加", "新增", "添加项目", "添加项目经历", "新增项目经历", "+ 添加"],
|
||||
addButtonEn: ["Add", "Add Project", "Add More", "+ Add"],
|
||||
},
|
||||
{
|
||||
section: "competition",
|
||||
zh: ["竞赛经历", "获奖经历", "竞赛信息", "获奖情况"],
|
||||
en: ["Competition", "Awards", "Competitions & Awards", "Contest Experience"],
|
||||
coreFieldKey: "competitionName",
|
||||
addButtonZh: ["添加", "新增", "添加竞赛", "添加竞赛经历", "新增竞赛经历", "+ 添加"],
|
||||
addButtonEn: ["Add", "Add Competition", "Add Award", "Add More", "+ Add"],
|
||||
},
|
||||
]
|
||||
|
||||
// ============ Mock 简历数据(模拟接口返回) ============
|
||||
|
||||
/**
|
||||
* 模拟后端接口返回的完整简历数据
|
||||
* 后续会替换为真实接口调用:javaApi.get<ResumeData>("/resume/detail")
|
||||
*/
|
||||
export function getMockResumeData(): ResumeData {
|
||||
return {
|
||||
main: {
|
||||
avatarUrl: "",
|
||||
name: "洪赫",
|
||||
email: "1908796496@qq.com",
|
||||
mobileNumber: "13713539828",
|
||||
city: "",
|
||||
wechatNumber: "13713539828",
|
||||
portfolioUrl: "",
|
||||
skills: ["数据分析", "模型训练", "模型测试", "算法部署", "图像识别", "目标识别", "目标检测", "Python"],
|
||||
certificates: ["英语四级", "雅思6分"],
|
||||
summary: "专业能力:掌握药学、医学相关理论知识,熟练使用Office办公软件(Word、Excel、PPT)进行数据整理和文档撰写,python熟练,了解数据库搭建和管理(sql),具备医学文献检索和数据分析能力。语言能力:英语四级,雅思6分,中英文书面与口头表达能力较强,能够进行跨部门沟通。普通话标准、流利。性格特质:工作态度认真,注重细节,有强烈的好奇心和学习能力,专业课排前20%,具备较强的逻辑分析能力,责任心强,团队合作意识强,能适应阶段性高强度工作。兴趣爱好:篮球、游戏、音乐等,参加院系篮球队、校院音乐节以及歌唱比赛并取得不错名次。",
|
||||
},
|
||||
education: [
|
||||
{
|
||||
id: "qLVFVDxh",
|
||||
school: "香港理工大学",
|
||||
major: "数据科学与分析",
|
||||
degree: "硕士",
|
||||
studyType: "全日制",
|
||||
startDate: "2025.09",
|
||||
endDate: "2026.12",
|
||||
description: [
|
||||
{ type: "text", content: "系统学习数据分析、统计建模、数据挖掘等核心课程,具备扎实的数据整理与逻辑分析能力" },
|
||||
{ type: "text", content: "掌握数据库管理、医学文献检索工具使用方法,能够高效处理和分析大规模数据" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "AntTOPmb",
|
||||
school: "华南师范大学",
|
||||
major: "人工智能",
|
||||
degree: "本科",
|
||||
studyType: "统招",
|
||||
startDate: "2021.09",
|
||||
endDate: "2025.07",
|
||||
description: [
|
||||
{ type: "text", content: "大一:高等数学、Python程序设计、面向对象程序设计、计算机体系结构、线性代数、离散数学等,培养严谨的逻辑思维能力" },
|
||||
{ type: "text", content: "大二:Java、软件工程导论、概率论与数理统计、数据结构与算法、Web开发、数据库原理、操作系统等,具备数据管理与分析基础" },
|
||||
{ type: "text", content: "大三:人工智能基础、计算机网络、计算机语言等,掌握数据挖掘与模式识别方法" },
|
||||
{ type: "text", content: "熟练使用Office办公软件(Word、Excel、PPT)进行数据整理、报告撰写与汇报展示,注重细节与准确性" },
|
||||
],
|
||||
},
|
||||
],
|
||||
work: [],
|
||||
internship: [
|
||||
{
|
||||
id: "EoNkjAF1",
|
||||
companyName: "深圳乐动机器人科技有限公司",
|
||||
position: "机器人感知算法工程师",
|
||||
startDate: "2024.05",
|
||||
endDate: "2024.08",
|
||||
description: [
|
||||
{ type: "text", content: "负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
|
||||
{ type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" },
|
||||
{ type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cX5EGRDD",
|
||||
companyName: "江西卓云深圳研发中心",
|
||||
position: "图像算法工程师",
|
||||
startDate: "2023.12",
|
||||
endDate: "2024.02",
|
||||
description: [
|
||||
{ type: "text", content: "运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
|
||||
{ type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" },
|
||||
{ type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" },
|
||||
],
|
||||
},
|
||||
],
|
||||
project: [
|
||||
{
|
||||
id: "q966EOx2",
|
||||
companyName: "",
|
||||
projectName: "微信小程序开发与测试",
|
||||
role: "",
|
||||
startDate: "2022.01",
|
||||
endDate: "",
|
||||
description: [
|
||||
{ type: "text", content: "负责小程序功能模块的开发与测试,严格遵循开发规范和质量标准,确保系统稳定运行" },
|
||||
{ type: "text", content: "参与需求分析与文档撰写工作,具备良好的书面表达能力和跨部门沟通协调能力" },
|
||||
{ type: "text", content: "注重细节管理,对测试数据进行系统整理与分析,保证项目交付的准确性" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "KVSEaywu",
|
||||
companyName: "",
|
||||
projectName: "多人游戏聊天网页开发",
|
||||
role: "",
|
||||
startDate: "2022.08",
|
||||
endDate: "",
|
||||
description: [
|
||||
{ type: "text", content: "负责网页功能开发与用户交互模块设计,具备快速学习新技术和解决问题的能力" },
|
||||
{ type: "text", content: "熟练使用Office等办公软件进行项目文档管理和数据整理工作" },
|
||||
{ type: "text", content: "具备团队合作精神,能够配合完成阶段性高强度工作任务" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "KQtH64TE",
|
||||
companyName: "",
|
||||
projectName: "基于SEIR模型的疫情大数据追踪预测应用研究",
|
||||
role: "",
|
||||
startDate: "2022.12",
|
||||
endDate: "",
|
||||
description: [
|
||||
{ type: "text", content: "参与疫情数据监测与分析项目,运用数据模型进行风险评估和趋势预测" },
|
||||
{ type: "text", content: "负责大量数据的收集、整理与分析工作,具备较强的逻辑分析能力和数据处理能力" },
|
||||
{ type: "text", content: "撰写项目研究报告,具备文献检索和专业资料查阅能力,工作态度认真负责" },
|
||||
{ type: "text", content: "对公共卫生安全监测工作有初步认知,理解数据监测在风险管理中的重要性" },
|
||||
],
|
||||
},
|
||||
],
|
||||
competition: [],
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* DOM 工具模块
|
||||
* 负责:DOM 结构提取、页面语言检测、表单页面判断、CSS 选择器生成
|
||||
*/
|
||||
|
||||
import { JOB_FORM_LABELS } from "./constants"
|
||||
|
||||
/** 只保留的属性白名单 */
|
||||
const KEEP_ATTRS = ["id", "class", "placeholder", "disabled"]
|
||||
|
||||
/**
|
||||
* 提取当前页面的精简 DOM 树结构
|
||||
* 只保留 id、class、placeholder 属性和标签内部的直接文字内容
|
||||
*/
|
||||
export function extractDomStructure(rootEl: Element = document.body): string {
|
||||
const walk = (el: Element, depth: number = 0): string => {
|
||||
const indent = " ".repeat(depth)
|
||||
const tag = el.tagName.toLowerCase()
|
||||
const attrs = KEEP_ATTRS
|
||||
.filter((name) => el.hasAttribute(name))
|
||||
.map((name) => ` ${name}="${el.getAttribute(name)}"`)
|
||||
.join("")
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
||||
.map((node) => node.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
const selfClosing = ["input", "img", "br", "hr", "meta", "link"].includes(tag)
|
||||
if (selfClosing) return `${indent}<${tag}${attrs} />\n`
|
||||
if (el.children.length === 0 && directText) return `${indent}<${tag}${attrs}>${directText}</${tag}>\n`
|
||||
let result = `${indent}<${tag}${attrs}>`
|
||||
if (directText) result += directText
|
||||
result += "\n"
|
||||
for (const child of Array.from(el.children)) result += walk(child, depth + 1)
|
||||
result += `${indent}</${tag}>\n`
|
||||
return result
|
||||
}
|
||||
return walk(rootEl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过页面可见文字内容判断页面是中文还是英文
|
||||
*/
|
||||
export function detectPageLanguage(domStructure: string): "zh" | "en" {
|
||||
const pageText = document.body.innerText || ""
|
||||
const chineseChars = (pageText.match(/[\u4e00-\u9fff]/g) || []).length
|
||||
const englishChars = (pageText.match(/[a-zA-Z]/g) || []).length
|
||||
return chineseChars >= englishChars ? "zh" : "en"
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前页面是否为职位申请表单页面
|
||||
* 条件:DOM 中匹配到 ≥2 个标签数组中的标签文字,且页面 input 元素 ≥2 个
|
||||
*/
|
||||
export function isJobApplicationForm(rootEl: Element = document.body, lang: "zh" | "en"): boolean {
|
||||
const pageText = (rootEl as HTMLElement).innerText || rootEl.textContent || ""
|
||||
const inputCount = rootEl.querySelectorAll("input, textarea, select").length
|
||||
if (inputCount < 2) return false
|
||||
let matchedCount = 0
|
||||
for (const item of JOB_FORM_LABELS) {
|
||||
const labels = lang === "zh" ? item.zh : item.en
|
||||
if (labels.some((label) => pageText.includes(label))) matchedCount++
|
||||
if (matchedCount >= 2) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成元素的唯一 CSS 选择器路径
|
||||
*/
|
||||
export function buildSelector(el: Element): string {
|
||||
if (el.id) return `#${CSS.escape(el.id)}`
|
||||
const parts: string[] = []
|
||||
let current: Element | null = el
|
||||
while (current && current !== document.body && current !== document.documentElement) {
|
||||
let selector = current.tagName.toLowerCase()
|
||||
if (current.className && typeof current.className === "string") {
|
||||
const classes = current.className.trim().split(/\s+/).slice(0, 2)
|
||||
if (classes.length > 0 && classes[0]) {
|
||||
selector += "." + classes.map((c) => CSS.escape(c)).join(".")
|
||||
}
|
||||
}
|
||||
const parent = current.parentElement
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children).filter((s) => s.tagName === current!.tagName)
|
||||
if (siblings.length > 1) {
|
||||
const index = siblings.indexOf(current) + 1
|
||||
selector += `:nth-child(${index})`
|
||||
}
|
||||
}
|
||||
parts.unshift(selector)
|
||||
current = current.parentElement
|
||||
}
|
||||
return parts.join(" > ")
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 表单字段匹配模块
|
||||
* 负责:在页面 DOM 中匹配标签数组对应的表单字段,查找关联的 input 元素
|
||||
* 包含:标签匹配、input 查找、button 查找、特殊标签修正
|
||||
*/
|
||||
|
||||
import type { MatchedFormField } from "./types"
|
||||
import { JOB_FORM_LABELS, SPECIAL_LABEL_PLACEHOLDERS } from "./constants"
|
||||
import { buildSelector } from "./dom"
|
||||
|
||||
// ============ 特殊标签 input 修正 ============
|
||||
|
||||
/**
|
||||
* 对特殊标签进行 input 修正
|
||||
* 当 findNearestInput 找到的 input 的 placeholder 和预期不匹配时,
|
||||
* 从标签元素逐级向上查找 placeholder 匹配的 input
|
||||
*/
|
||||
function fixSpecialLabelInput(
|
||||
key: string,
|
||||
currentInput: HTMLInputElement | HTMLTextAreaElement,
|
||||
labelEl: Element,
|
||||
usedInputs: Set<Element>
|
||||
): HTMLInputElement | HTMLTextAreaElement {
|
||||
const placeholders = SPECIAL_LABEL_PLACEHOLDERS[key]
|
||||
if (!placeholders) return currentInput
|
||||
|
||||
const ph = currentInput.getAttribute("placeholder")?.trim() || ""
|
||||
if (placeholders.some((p) => ph.includes(p))) return currentInput
|
||||
|
||||
const INPUT_SEL = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled])"
|
||||
let searchParent: Element | null = labelEl.parentElement
|
||||
for (let depth = 0; depth < 6 && searchParent; depth++) {
|
||||
const candidates = searchParent.querySelectorAll(INPUT_SEL)
|
||||
for (const candidate of Array.from(candidates)) {
|
||||
if (usedInputs.has(candidate)) continue
|
||||
const cph = candidate.getAttribute("placeholder")?.trim() || ""
|
||||
if (placeholders.some((p) => cph.includes(p))) {
|
||||
return candidate as HTMLInputElement
|
||||
}
|
||||
}
|
||||
searchParent = searchParent.parentElement
|
||||
}
|
||||
return currentInput
|
||||
}
|
||||
|
||||
// ============ input 查找 ============
|
||||
|
||||
/** 常见的表单项容器选择器 */
|
||||
const FORM_ITEM_SELECTORS = [
|
||||
".form-item", ".form-group", ".form-field",
|
||||
".el-form-item", ".ant-form-item", ".ant-row",
|
||||
".arco-form-item", ".t-form-item", ".n-form-item",
|
||||
".ivu-form-item", ".v-input", ".MuiFormControl-root",
|
||||
"[class*='form-item']", "[class*='form-group']", "[class*='formItem']",
|
||||
]
|
||||
|
||||
/** input 选择器(排除隐藏、提交、按钮、复选、单选、文件类型) */
|
||||
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])"
|
||||
|
||||
/**
|
||||
* 在标签元素附近查找最近的、属于同一表单项的 input/textarea 输入框
|
||||
* 策略:1.表单项容器内查找 → 2.逐级向上查找 → 3.兜底查兄弟节点
|
||||
*/
|
||||
function findNearestInput(labelEl: Element): HTMLInputElement | HTMLTextAreaElement | null {
|
||||
// 策略1:找标签所在的最近表单项容器
|
||||
for (const sel of FORM_ITEM_SELECTORS) {
|
||||
const container = labelEl.closest(sel)
|
||||
if (container) {
|
||||
const input = container.querySelector(INPUT_SEL) as HTMLInputElement | HTMLTextAreaElement | null
|
||||
if (input) return input
|
||||
}
|
||||
}
|
||||
|
||||
// 策略2:逐级向上查找
|
||||
let parent: Element | null = labelEl.parentElement
|
||||
for (let i = 0; i < 5 && parent; i++) {
|
||||
const inputs = parent.querySelectorAll(INPUT_SEL)
|
||||
if (inputs.length === 1) return inputs[0] as HTMLInputElement | HTMLTextAreaElement
|
||||
if (inputs.length > 1) return findClosestInputAfterLabel(labelEl, parent)
|
||||
parent = parent.parentElement
|
||||
}
|
||||
|
||||
// 策略3:兜底查兄弟节点
|
||||
let sibling = labelEl.nextElementSibling
|
||||
while (sibling) {
|
||||
if ((sibling.tagName === "INPUT" || sibling.tagName === "TEXTAREA") &&
|
||||
!(sibling as HTMLInputElement).type?.match(/hidden|submit|button|checkbox|radio|file/)) {
|
||||
return sibling as HTMLInputElement | HTMLTextAreaElement
|
||||
}
|
||||
const innerInput = sibling.querySelector(INPUT_SEL) as HTMLInputElement | HTMLTextAreaElement | null
|
||||
if (innerInput) return innerInput
|
||||
sibling = sibling.nextElementSibling
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 在包含多个 input 的父容器中,找到 DOM 顺序上紧跟在标签后面的第一个 input */
|
||||
function findClosestInputAfterLabel(labelEl: Element, container: Element): HTMLInputElement | HTMLTextAreaElement | null {
|
||||
const allInputs = Array.from(container.querySelectorAll(INPUT_SEL)) as (HTMLInputElement | HTMLTextAreaElement)[]
|
||||
for (const input of allInputs) {
|
||||
const position = labelEl.compareDocumentPosition(input)
|
||||
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return input
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 在 input 元素的同级或父级查找关联的 button */
|
||||
function findNearbyButton(inputEl: Element): HTMLElement | null {
|
||||
const BTN_SELECTOR = 'button, [role="button"], [class*="select"][class*="suffix"], [class*="select"][class*="arrow"], [class*="picker"][class*="icon"], [class*="calendar"][class*="icon"]'
|
||||
// 查找同级兄弟元素(前后各 2 个)
|
||||
let sibling = inputEl.nextElementSibling
|
||||
let count = 0
|
||||
while (sibling && count < 2) {
|
||||
if (sibling.matches(BTN_SELECTOR)) return sibling as HTMLElement
|
||||
const inner = sibling.querySelector(BTN_SELECTOR) as HTMLElement | null
|
||||
if (inner) return inner
|
||||
sibling = sibling.nextElementSibling
|
||||
count++
|
||||
}
|
||||
sibling = inputEl.previousElementSibling
|
||||
count = 0
|
||||
while (sibling && count < 2) {
|
||||
if (sibling.matches(BTN_SELECTOR)) return sibling as HTMLElement
|
||||
const inner = sibling.querySelector(BTN_SELECTOR) as HTMLElement | null
|
||||
if (inner) return inner
|
||||
sibling = sibling.previousElementSibling
|
||||
count++
|
||||
}
|
||||
// 向上查找父元素内的 button
|
||||
let parent: Element | null = inputEl.parentElement
|
||||
for (let i = 0; i < 2 && parent; i++) {
|
||||
const btn = parent.querySelector('button, [role="button"]') as HTMLElement | null
|
||||
if (btn && btn !== inputEl) return btn
|
||||
parent = parent.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ============ 表单字段匹配主方法 ============
|
||||
|
||||
/**
|
||||
* 匹配页面中的表单字段
|
||||
* 遍历标签数组,在 DOM 中查找每个标签对应的元素和紧跟的 input 输入框
|
||||
* 返回结果按 DOM 顺序排序
|
||||
*/
|
||||
export function matchFormFields(
|
||||
rootEl: Element = document.body,
|
||||
lang: "zh" | "en"
|
||||
): MatchedFormField[] {
|
||||
const results: MatchedFormField[] = []
|
||||
const usedInputs = new Set<Element>()
|
||||
const candidateEls = rootEl.querySelectorAll(
|
||||
"label, span, div, td, th, p, legend, dt, h1, h2, h3, h4, h5, h6"
|
||||
)
|
||||
|
||||
for (const item of JOB_FORM_LABELS) {
|
||||
const labels = lang === "zh" ? item.zh : item.en
|
||||
const sortedLabels = [...labels].sort((a, b) => b.length - a.length)
|
||||
let matchCount = 0
|
||||
|
||||
for (const candidateEl of Array.from(candidateEls)) {
|
||||
const directText = Array.from(candidateEl.childNodes)
|
||||
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
||||
.map((node) => node.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
const fullText = candidateEl.textContent?.trim() || ""
|
||||
|
||||
const matchLabel = sortedLabels.find(
|
||||
(label) =>
|
||||
(directText && directText.includes(label) && directText.length < label.length + 20) ||
|
||||
(fullText.includes(label) && fullText.length < label.length + 20)
|
||||
)
|
||||
|
||||
if (matchLabel) {
|
||||
// 检测单选按钮组
|
||||
let isRadioType = false
|
||||
let radioContainer: Element | null = null
|
||||
const closestFormItem = candidateEl.closest(".form-item, .form-group, .el-form-item, .ant-form-item")
|
||||
if (closestFormItem) {
|
||||
const radioInItem = closestFormItem.querySelector('input[type="radio"]')
|
||||
if (radioInItem) { isRadioType = true; radioContainer = closestFormItem }
|
||||
}
|
||||
|
||||
// 非 radio 类型,正常找 input
|
||||
let inputEl: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||
let inputType: "text" | "radio" | "picker" | "textarea" = isRadioType ? "radio" : "text"
|
||||
|
||||
if (!isRadioType) {
|
||||
inputEl = findNearestInput(candidateEl)
|
||||
if (inputEl) inputEl = fixSpecialLabelInput(item.key, inputEl, candidateEl, usedInputs)
|
||||
if (inputEl && usedInputs.has(inputEl)) continue
|
||||
if (inputEl) {
|
||||
usedInputs.add(inputEl)
|
||||
if (inputEl.tagName === "TEXTAREA") inputType = "textarea"
|
||||
}
|
||||
}
|
||||
|
||||
const btnEl = inputEl ? findNearbyButton(inputEl) : null
|
||||
results.push({
|
||||
key: item.key, section: item.section, resumeField: item.resumeField,
|
||||
sectionIndex: matchCount, labelText: matchLabel,
|
||||
labelElement: candidateEl, labelSelector: buildSelector(candidateEl),
|
||||
inputElement: inputEl, inputSelector: inputEl ? buildSelector(inputEl) : "",
|
||||
buttonElement: btnEl, buttonSelector: btnEl ? buildSelector(btnEl) : "",
|
||||
inputType, radioContainer: isRadioType ? radioContainer : null,
|
||||
isPicker: false, pickerDropdownElement: null, pickerDropdownSelector: "", fillValue: "",
|
||||
})
|
||||
matchCount++
|
||||
if (item.section === "main") break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按 DOM 顺序排序
|
||||
results.sort((a, b) => {
|
||||
const pos = a.labelElement.compareDocumentPosition(b.labelElement)
|
||||
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1
|
||||
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 选择器识别模块
|
||||
* 负责:检测表单字段是否为选择器类型(下拉、日期、级联等)
|
||||
* 检测方式:
|
||||
* 1. UI组件库类名匹配(快速路径)
|
||||
* 2. 提示文字检测("请选择"等)
|
||||
* 3. 主动点击检测:点击 input 及其单子标签父级链,对比点击前后 DOM 变化判断是否有弹出层
|
||||
*/
|
||||
|
||||
import type { MatchedFormField, UILibPickerConfig } from "./types"
|
||||
import { UI_LIB_PICKER_CONFIGS } from "./constants"
|
||||
import { delay } from "./autofill"
|
||||
|
||||
/** 弹出层相关的 CSS 选择器 */
|
||||
const POPUP_SELECTORS = [
|
||||
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
|
||||
'[class*="picker-panel"]', '[class*="overlay"]', '[class*="popover"]',
|
||||
'[class*="select-dropdown"]', '[class*="cascader"]',
|
||||
'[class*="menu"][class*="content"]',
|
||||
'[role="listbox"]', '[role="menu"]',
|
||||
]
|
||||
|
||||
/** 通过提示文字判断是否为选择器 */
|
||||
function hasPickerHintText(field: MatchedFormField, lang: "zh" | "en"): boolean {
|
||||
const hints = lang === "zh"
|
||||
? ["请选择", "请输入并选择", "点击选择"]
|
||||
: ["Please select", "Choose", "Click to select", "Pick a"]
|
||||
|
||||
if (field.inputElement) {
|
||||
const placeholder = field.inputElement.getAttribute("placeholder") || ""
|
||||
if (hints.some((h) => placeholder.includes(h))) return true
|
||||
if (field.inputElement.hasAttribute("readonly")) {
|
||||
let parent: Element | null = field.inputElement.parentElement
|
||||
for (let i = 0; i < 3 && parent; i++) {
|
||||
const cls = parent.className || ""
|
||||
if (typeof cls === "string" && (
|
||||
cls.includes("select") || cls.includes("picker") || cls.includes("date") ||
|
||||
cls.includes("cascader") || cls.includes("dropdown") || cls.includes("calendar")
|
||||
)) return true
|
||||
parent = parent.parentElement
|
||||
}
|
||||
}
|
||||
// 检查紧邻兄弟元素的文字
|
||||
for (const sib of [field.inputElement.nextElementSibling, field.inputElement.previousElementSibling]) {
|
||||
if (sib) {
|
||||
const text = sib.textContent?.trim() || ""
|
||||
if (text.length < 30 && hints.some((h) => text.includes(h))) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 检查 input 附近是否有已知 UI 组件库的选择器触发区域类名 */
|
||||
function matchUILibTrigger(field: MatchedFormField): UILibPickerConfig | null {
|
||||
const classSet = new Set<string>()
|
||||
let el: Element | null = field.inputElement || field.labelElement
|
||||
for (let i = 0; i < 5 && el; i++) {
|
||||
if (el.className && typeof el.className === "string") {
|
||||
el.className.trim().split(/\s+/).forEach((c) => classSet.add(c))
|
||||
}
|
||||
el = el.parentElement
|
||||
}
|
||||
if (field.buttonElement?.className && typeof field.buttonElement.className === "string") {
|
||||
field.buttonElement.className.trim().split(/\s+/).forEach((c) => classSet.add(c))
|
||||
}
|
||||
for (const lib of UI_LIB_PICKER_CONFIGS) {
|
||||
if (lib.triggerClasses.some((cls) => classSet.has(cls))) return lib
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 记录当前页面所有可见弹出层和 body 直接子元素 */
|
||||
function snapshotDOM(): { bodyChildren: Set<Element>; visiblePopups: Set<Element> } {
|
||||
const bodyChildren = new Set(Array.from(document.body.children))
|
||||
const visiblePopups = new Set<Element>()
|
||||
for (const sel of POPUP_SELECTORS) {
|
||||
document.querySelectorAll(sel).forEach((el) => {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0) visiblePopups.add(el)
|
||||
})
|
||||
}
|
||||
return { bodyChildren, visiblePopups }
|
||||
}
|
||||
|
||||
/** 对比 DOM 快照,检测是否有新增弹出层 */
|
||||
function hasNewPopup(before: ReturnType<typeof snapshotDOM>): boolean {
|
||||
// 检查 body 下新增的直接子元素
|
||||
for (const child of Array.from(document.body.children)) {
|
||||
if (!before.bodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) return true
|
||||
}
|
||||
// 检查新变为可见的弹出层
|
||||
for (const sel of POPUP_SELECTORS) {
|
||||
const els = document.querySelectorAll(sel)
|
||||
for (const el of Array.from(els)) {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0 && !before.visiblePopups.has(el)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 关闭可能打开的弹出层 */
|
||||
async function dismissPopup() {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
|
||||
)
|
||||
document.activeElement.blur()
|
||||
}
|
||||
document.body.click()
|
||||
await delay(200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动点击检测:点击 input 及其单子标签父级链,对比 DOM 变化判断是否有弹出层
|
||||
* 如果检测到弹出层,关闭后返回 true
|
||||
*/
|
||||
async function detectByClick(field: MatchedFormField): Promise<boolean> {
|
||||
if (!field.inputElement) return false
|
||||
|
||||
// 收集要点击的元素:input 本身 + 单子标签父级链(最多5层)
|
||||
const clickTargets: HTMLElement[] = [field.inputElement as HTMLElement]
|
||||
let current: HTMLElement | null = field.inputElement as HTMLElement
|
||||
for (let i = 0; i < 5 && current?.parentElement; i++) {
|
||||
const parentEl = current.parentElement
|
||||
if (parentEl.children.length <= 2) {
|
||||
clickTargets.push(parentEl)
|
||||
current = parentEl
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 逐个尝试点击,检测 DOM 变化
|
||||
for (const target of clickTargets) {
|
||||
const before = snapshotDOM()
|
||||
target.click()
|
||||
await delay(200)
|
||||
|
||||
if (hasNewPopup(before)) {
|
||||
console.log(`OfferPie: [${field.key}] 点击 ${target.tagName}.${(target.className || "").toString().split(" ")[0]} 后检测到弹出层`)
|
||||
await dismissPopup()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 没检测到弹出层,确保关闭焦点状态
|
||||
await dismissPopup()
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测单个字段是否为数据选择器类型
|
||||
* 优先级:1.UI组件库类名 → 2.提示文字 → 3.主动点击检测DOM变化
|
||||
*/
|
||||
export async function detectPickerField(field: MatchedFormField, lang: "zh" | "en"): Promise<void> {
|
||||
if (field.inputType === "radio") return
|
||||
|
||||
// 方式1:UI 组件库类名匹配
|
||||
const matchedLib = matchUILibTrigger(field)
|
||||
if (matchedLib) {
|
||||
console.log(`OfferPie: [${field.key}] 匹配到 UI 组件库: ${matchedLib.libName}`)
|
||||
field.isPicker = true
|
||||
field.inputType = "picker"
|
||||
field.pickerDropdownSelector = matchedLib.optionItemClasses.map((cls) => "." + CSS.escape(cls)).join(", ")
|
||||
return
|
||||
}
|
||||
|
||||
// 方式2:提示文字检测
|
||||
if (hasPickerHintText(field, lang)) {
|
||||
console.log(`OfferPie: [${field.key}] 有选择器提示文字,标记为 picker`)
|
||||
field.isPicker = true
|
||||
field.inputType = "picker"
|
||||
return
|
||||
}
|
||||
|
||||
// 方式3:主动点击检测 DOM 变化
|
||||
const detected = await detectByClick(field)
|
||||
if (detected) {
|
||||
console.log(`OfferPie: [${field.key}] 通过点击检测到弹出层,标记为 picker`)
|
||||
field.isPicker = true
|
||||
field.inputType = "picker"
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 选择器选项匹配与点击模块
|
||||
* 负责:在弹出层中查找匹配选项并点击、模糊匹配评分、下拉列表检测
|
||||
*/
|
||||
|
||||
import type { MatchedFormField } from "./types"
|
||||
|
||||
// ============ 模糊匹配 ============
|
||||
|
||||
/** 模糊匹配评分:两个字符串的相似度 */
|
||||
export function fuzzyMatchScore(text: string, target: string): number {
|
||||
const a = text.toLowerCase().trim()
|
||||
const b = target.toLowerCase().trim()
|
||||
if (!a || !b) return 0
|
||||
if (a === b) return 1
|
||||
if (a.includes(b)) return 0.8
|
||||
if (b.includes(a)) return 0.8
|
||||
// 数字范围匹配
|
||||
const rangeMatch = a.match(/^(\d+)\s*[~\-—~至到]\s*(\d+)/)
|
||||
if (rangeMatch && /^\d+$/.test(b)) {
|
||||
const targetNum = Number(b)
|
||||
if (targetNum >= Number(rangeMatch[1]) && targetNum <= Number(rangeMatch[2])) return 0.7
|
||||
}
|
||||
// 字符重叠率
|
||||
let overlap = 0
|
||||
for (const ch of b) { if (a.includes(ch)) overlap++ }
|
||||
return overlap / Math.max(a.length, b.length)
|
||||
}
|
||||
|
||||
// ============ 弹出层选项查找与点击 ============
|
||||
|
||||
/**
|
||||
* 在弹出层 DOM 中找到最佳匹配选项并点击
|
||||
* 递归遍历弹出层内所有可见的叶子文字节点做模糊匹配
|
||||
*/
|
||||
export function clickBestOptionInDropdown(dropdownEl: HTMLElement, fillValue: string): boolean {
|
||||
const candidates: { el: HTMLElement; text: string }[] = []
|
||||
|
||||
const collectCandidates = (el: Element) => {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight === 0 || htmlEl.offsetWidth === 0) return
|
||||
if (htmlEl.classList?.contains("disabled") || htmlEl.classList?.contains("is-disabled") ||
|
||||
htmlEl.getAttribute("aria-disabled") === "true") return
|
||||
const children = Array.from(el.children)
|
||||
const text = el.textContent?.trim() || ""
|
||||
if (text && text.length <= 50) {
|
||||
if (/^\d+\/\d+$/.test(text)) return
|
||||
const hasBlockChild = children.some((c) => {
|
||||
const tag = c.tagName?.toLowerCase()
|
||||
return tag && !["span", "em", "i", "b", "strong", "small", "a", "img", "svg"].includes(tag)
|
||||
})
|
||||
if (!hasBlockChild) { candidates.push({ el: htmlEl, text }); return }
|
||||
}
|
||||
for (const child of children) collectCandidates(child)
|
||||
}
|
||||
collectCandidates(dropdownEl)
|
||||
|
||||
if (candidates.length === 0) return false
|
||||
console.log(`OfferPie: [clickBestOption] 找到 ${candidates.length} 个候选选项,fillValue="${fillValue}"`)
|
||||
candidates.forEach((c, i) => console.log(`OfferPie: 候选[${i}] "${c.text}"`))
|
||||
|
||||
let bestMatch: { el: HTMLElement; text: string; score: number } | null = null
|
||||
for (const { el, text } of candidates) {
|
||||
if (text.toLowerCase() === fillValue.toLowerCase()) {
|
||||
el.click()
|
||||
console.log(`OfferPie: ✅ [弹出层] 点击选项 "${text}" (精确匹配)`)
|
||||
return true
|
||||
}
|
||||
const score = fuzzyMatchScore(text, fillValue)
|
||||
if (score > 0.2 && (!bestMatch || score > bestMatch.score)) bestMatch = { el, text, score }
|
||||
}
|
||||
if (bestMatch) {
|
||||
bestMatch.el.click()
|
||||
console.log(`OfferPie: ✅ [弹出层] 点击选项 "${bestMatch.text}" (模糊匹配 score=${bestMatch.score.toFixed(2)})`)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前页面所有可见的弹出层中查找并点击匹配选项
|
||||
*/
|
||||
export function findAndClickOptionInVisiblePopups(fillValue: string, labelText: string): boolean {
|
||||
const popupSelectors = [
|
||||
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
|
||||
'[class*="picker-panel"]', '[class*="overlay"]', '[class*="popover"]',
|
||||
'[class*="select-dropdown"]', '[class*="cascader"]',
|
||||
'[class*="menu"][class*="content"]',
|
||||
'[class*="phoenix-select-dropdown"]', '[class*="phoenix-calendar"]',
|
||||
'[role="listbox"]', '[role="menu"]',
|
||||
]
|
||||
const visiblePopups: HTMLElement[] = []
|
||||
for (const sel of popupSelectors) {
|
||||
document.querySelectorAll(sel).forEach((el) => {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0) {
|
||||
const isDuplicate = visiblePopups.some((existing) => existing.contains(htmlEl) || htmlEl.contains(existing))
|
||||
if (!isDuplicate) visiblePopups.push(htmlEl)
|
||||
}
|
||||
})
|
||||
}
|
||||
console.log(`OfferPie: [findOption] "${labelText}" 找到 ${visiblePopups.length} 个可见弹出层`)
|
||||
for (const popup of visiblePopups) {
|
||||
if (clickBestOptionInDropdown(popup, fillValue)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ============ 通用下拉列表检测与点击 ============
|
||||
|
||||
/** 在页面中查找所有可见的"同规格标签组" */
|
||||
function findVisibleListGroups(): Element[][] {
|
||||
const results: Element[][] = []
|
||||
const visited = new Set<Element>()
|
||||
const containerSelectors = [
|
||||
"ul", "ol", "[role='listbox']", "[class*='list']", "[class*='dropdown']",
|
||||
"[class*='select']", "[class*='menu']", "[class*='popup']", "[class*='popper']",
|
||||
"[class*='virtuallist']", "[class*='virtual-list']", "div",
|
||||
]
|
||||
for (const sel of containerSelectors) {
|
||||
const containers = document.querySelectorAll(sel)
|
||||
for (const container of Array.from(containers)) {
|
||||
if (visited.has(container)) continue
|
||||
const htmlContainer = container as HTMLElement
|
||||
if (htmlContainer.offsetHeight === 0 || htmlContainer.offsetWidth === 0) continue
|
||||
const group = findSameSpecChildren(container)
|
||||
if (group && group.length >= 3) { visited.add(container); results.push(group) }
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => b.length - a.length)
|
||||
return results
|
||||
}
|
||||
|
||||
function findSameSpecChildren(container: Element): Element[] | null {
|
||||
const directGroup = groupBySpec(Array.from(container.children))
|
||||
if (directGroup) return directGroup
|
||||
for (const child of Array.from(container.children)) {
|
||||
const subGroup = groupBySpec(Array.from(child.children))
|
||||
if (subGroup) return subGroup
|
||||
for (const grandChild of Array.from(child.children)) {
|
||||
const subSubGroup = groupBySpec(Array.from(grandChild.children))
|
||||
if (subSubGroup) return subSubGroup
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function groupBySpec(elements: Element[]): Element[] | null {
|
||||
if (elements.length < 3) return null
|
||||
const groups = new Map<string, Element[]>()
|
||||
for (const el of elements) {
|
||||
if ((el as HTMLElement).offsetHeight === 0 || (el as HTMLElement).offsetWidth === 0) continue
|
||||
const tag = el.tagName
|
||||
const firstClass = (el.className && typeof el.className === "string") ? el.className.trim().split(/\s+/)[0] || "" : ""
|
||||
const key = `${tag}|${firstClass}`
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(el)
|
||||
}
|
||||
let best: Element[] | null = null
|
||||
for (const [, group] of groups) {
|
||||
if (group.length >= 3 && (!best || group.length > best.length)) best = group
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function findDeepestTextNode(el: Element): HTMLElement | null {
|
||||
if (el.children.length === 0 && el.textContent?.trim()) return el as HTMLElement
|
||||
const allLeaves: HTMLElement[] = []
|
||||
const walk = (node: Element) => {
|
||||
if (node.children.length === 0) { if (node.textContent?.trim()) allLeaves.push(node as HTMLElement); return }
|
||||
for (const child of Array.from(node.children)) walk(child)
|
||||
}
|
||||
walk(el)
|
||||
return allLeaves.length > 0 ? allLeaves[allLeaves.length - 1] : null
|
||||
}
|
||||
|
||||
function clickItemAndParent(deepest: HTMLElement, item: Element) {
|
||||
const path: HTMLElement[] = []
|
||||
let current: HTMLElement | null = deepest
|
||||
while (current && current !== item.parentElement) { path.unshift(current); current = current.parentElement }
|
||||
for (const el of path) el.click()
|
||||
}
|
||||
|
||||
/** 通用下拉列表检测与点击 */
|
||||
export function tryClickDropdownListItem(fillValue: string): boolean {
|
||||
const listGroups = findVisibleListGroups()
|
||||
for (const group of listGroups) {
|
||||
let bestMatch: { item: Element; text: string; deepest: HTMLElement; score: number } | null = null
|
||||
for (const item of group) {
|
||||
const deepest = findDeepestTextNode(item)
|
||||
const text = deepest ? deepest.textContent?.trim() || "" : item.textContent?.trim() || ""
|
||||
if (!text || /^\d+\/\d+$/.test(text) || text.length > 100) continue
|
||||
if (text === fillValue) {
|
||||
clickItemAndParent(deepest || (item as HTMLElement), item)
|
||||
console.log(`OfferPie: ✅ [下拉列表] 点击选项 "${text}" (精确匹配)`)
|
||||
return true
|
||||
}
|
||||
const score = fuzzyMatchScore(text, fillValue)
|
||||
if (score > 0.2 && (!bestMatch || score > bestMatch.score)) {
|
||||
bestMatch = { item, text, deepest: deepest || (item as HTMLElement), score }
|
||||
}
|
||||
}
|
||||
if (bestMatch) {
|
||||
clickItemAndParent(bestMatch.deepest, bestMatch.item)
|
||||
console.log(`OfferPie: ✅ [下拉列表] 点击选项 "${bestMatch.text}" (模糊匹配 score=${bestMatch.score.toFixed(2)})`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 在选择器弹出层中尝试点击与 fillValue 匹配的选项(基于 pickerDropdownSelector) */
|
||||
export function tryClickMatchingOption(field: MatchedFormField): boolean {
|
||||
const { fillValue, pickerDropdownElement, pickerDropdownSelector } = field
|
||||
let optionEls: Element[] = []
|
||||
if (pickerDropdownElement && pickerDropdownSelector) {
|
||||
optionEls = Array.from(pickerDropdownElement.querySelectorAll(pickerDropdownSelector))
|
||||
}
|
||||
if (optionEls.length === 0 && pickerDropdownSelector) {
|
||||
optionEls = Array.from(document.querySelectorAll(pickerDropdownSelector)).filter((el) => (el as HTMLElement).offsetHeight > 0 && (el as HTMLElement).offsetWidth > 0)
|
||||
}
|
||||
if (optionEls.length === 0) {
|
||||
const dropdownSelectors = [
|
||||
'[class*="select-dropdown"] [class*="item"]:not([class*="disabled"])',
|
||||
'[class*="dropdown"] [class*="option"]:not([class*="disabled"])',
|
||||
'[class*="dropdown"] [class*="item"]:not([class*="disabled"])',
|
||||
'[class*="select"] [class*="option"]:not([class*="disabled"])',
|
||||
'[class*="popup"] [class*="option"]', '[class*="popper"] [class*="option"]',
|
||||
'[class*="menu"] [class*="item"]', '[role="option"]', '[role="listbox"] > *',
|
||||
]
|
||||
for (const sel of dropdownSelectors) {
|
||||
const visibleEls = Array.from(document.querySelectorAll(sel)).filter((el) => (el as HTMLElement).offsetHeight > 0 && (el as HTMLElement).offsetWidth > 0)
|
||||
if (visibleEls.length > 0) { optionEls = visibleEls; break }
|
||||
}
|
||||
}
|
||||
if (optionEls.length === 0) return false
|
||||
|
||||
let bestMatch: { el: Element; text: string; score: number } | null = null
|
||||
for (const optionEl of optionEls) {
|
||||
const text = optionEl.textContent?.trim() || ""
|
||||
if (!text || /^\d+\/\d+$/.test(text) || text.length > 100) continue
|
||||
if (text.toLowerCase() === fillValue.toLowerCase()) {
|
||||
;(optionEl as HTMLElement).click()
|
||||
console.log(`OfferPie: ✅ 点击选项 "${text}" (精确匹配)`)
|
||||
return true
|
||||
}
|
||||
const score = fuzzyMatchScore(text, fillValue)
|
||||
if (score > 0.2 && (!bestMatch || score > bestMatch.score)) bestMatch = { el: optionEl, text, score }
|
||||
}
|
||||
if (bestMatch) {
|
||||
;(bestMatch.el as HTMLElement).click()
|
||||
console.log(`OfferPie: ✅ 点击选项 "${bestMatch.text}" (模糊匹配 score=${bestMatch.score.toFixed(2)})`)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 简历数据取值辅助模块
|
||||
* 负责:根据 section + sectionIndex + resumeField 从 ResumeData 中提取填写值
|
||||
*/
|
||||
|
||||
import type { ResumeData, ResumeSection, DescriptionParagraph, ExperienceSection } from "./types"
|
||||
|
||||
/**
|
||||
* 从简历数据中获取指定字段的填写值
|
||||
* @param resumeData - 完整简历数据
|
||||
* @param section - 数据分区(main / education / work / internship / project / competition)
|
||||
* @param sectionIndex - 经历数组中的索引(section 为经历类型时使用)
|
||||
* @param resumeField - 分区内的字段名
|
||||
* @returns 要填入的字符串值,取不到返回空字符串
|
||||
*/
|
||||
export function getResumeFieldValue(
|
||||
resumeData: ResumeData,
|
||||
section: ResumeSection,
|
||||
sectionIndex: number,
|
||||
resumeField: string
|
||||
): string {
|
||||
if (!resumeField) return ""
|
||||
|
||||
// 主表字段
|
||||
if (section === "main") {
|
||||
const mainData = resumeData.main
|
||||
const value = (mainData as Record<string, unknown>)[resumeField]
|
||||
if (value === undefined || value === null) return ""
|
||||
// 数组类型字段(skills、certificates)转为逗号分隔字符串
|
||||
if (Array.isArray(value)) return value.join("、")
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// 经历类型字段
|
||||
const sectionData = resumeData[section as ExperienceSection]
|
||||
if (!Array.isArray(sectionData) || sectionIndex >= sectionData.length) return ""
|
||||
|
||||
const item = sectionData[sectionIndex] as Record<string, unknown>
|
||||
if (!item) return ""
|
||||
|
||||
const value = item[resumeField]
|
||||
if (value === undefined || value === null) return ""
|
||||
|
||||
// description 字段:将段落数组拼接为文本
|
||||
if (resumeField === "description" && Array.isArray(value)) {
|
||||
return (value as DescriptionParagraph[])
|
||||
.map((p) => p.text || p.content || "")
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) return value.join("、")
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取简历数据中某个经历分区的总段数
|
||||
* @param resumeData - 完整简历数据
|
||||
* @param section - 经历分区
|
||||
* @returns 该经历类型的总段数
|
||||
*/
|
||||
export function getResumeSectionCount(resumeData: ResumeData, section: ExperienceSection): number {
|
||||
const sectionData = resumeData[section]
|
||||
return Array.isArray(sectionData) ? sectionData.length : 0
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 简历文件自动上传模块
|
||||
* 负责:检测页面上的简历上传按钮,从 OSS 下载简历文件并注入到 file input
|
||||
*/
|
||||
|
||||
import { delay } from "./autofill"
|
||||
|
||||
/**
|
||||
* 检测页面上的简历上传标签,并自动上传简历文件
|
||||
* 查找逻辑:找到文字同时包含"上传"和"简历"的标签元素
|
||||
* 上传逻辑:找到关联的 input[type="file"],从 OSS 下载文件,构造 File 对象注入
|
||||
*
|
||||
* @param resumeUrl - 简历文件的 OSS 地址
|
||||
* @returns 是否上传成功
|
||||
*/
|
||||
export async function detectAndUploadResume(resumeUrl: string): Promise<boolean> {
|
||||
// 1. 查找简历上传标签
|
||||
const allEls = document.querySelectorAll("button, div, span, a, label, p, [role='button']")
|
||||
let uploadEl: HTMLElement | null = null
|
||||
for (const el of Array.from(allEls)) {
|
||||
const text = el.textContent?.trim() || ""
|
||||
if (text.length < 20 && (
|
||||
(text.includes("上传") && text.includes("简历")) ||
|
||||
(text.includes("简历") && text.includes("附件")) ||
|
||||
(text.includes("附件") && text.includes("简历"))
|
||||
)) {
|
||||
uploadEl = el as HTMLElement
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!uploadEl) { console.log("OfferPie: 未找到简历上传标签"); return false }
|
||||
console.log(`OfferPie: 找到简历上传标签: "${uploadEl.textContent?.trim()}" tag=${uploadEl.tagName}`)
|
||||
|
||||
// 2. 查找关联的 input[type="file"]
|
||||
let fileInput: HTMLInputElement | null = null
|
||||
|
||||
// 策略1:从上传标签逐级向上找
|
||||
let parent: Element | null = uploadEl
|
||||
for (let i = 0; i < 5 && parent; i++) {
|
||||
fileInput = parent.querySelector('input[type="file"]') as HTMLInputElement | null
|
||||
if (fileInput) break
|
||||
const nextSib = parent.nextElementSibling
|
||||
const prevSib = parent.previousElementSibling
|
||||
if (nextSib) {
|
||||
fileInput = nextSib.tagName === "INPUT" && (nextSib as HTMLInputElement).type === "file"
|
||||
? nextSib as HTMLInputElement : nextSib.querySelector('input[type="file"]') as HTMLInputElement | null
|
||||
}
|
||||
if (!fileInput && prevSib) {
|
||||
fileInput = prevSib.tagName === "INPUT" && (prevSib as HTMLInputElement).type === "file"
|
||||
? prevSib as HTMLInputElement : prevSib.querySelector('input[type="file"]') as HTMLInputElement | null
|
||||
}
|
||||
if (fileInput) break
|
||||
parent = parent.parentElement
|
||||
}
|
||||
|
||||
// 策略2:按 id/name 含 resume 的 file input
|
||||
if (!fileInput) {
|
||||
for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
|
||||
const id = input.id?.toLowerCase() || ""
|
||||
const name = input.getAttribute("name")?.toLowerCase() || ""
|
||||
if (id.includes("resume") || name.includes("resume")) { fileInput = input as HTMLInputElement; break }
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3:点击上传标签及其单子标签父级来触发
|
||||
if (!fileInput) {
|
||||
console.log("OfferPie: 未直接找到 file input,尝试点击触发...")
|
||||
const clickTargets: HTMLElement[] = [uploadEl]
|
||||
let current: HTMLElement | null = uploadEl
|
||||
for (let i = 0; i < 5 && current?.parentElement; i++) {
|
||||
const parentEl = current.parentElement
|
||||
if (parentEl.children.length === 1) { clickTargets.push(parentEl); current = parentEl } else break
|
||||
}
|
||||
for (const target of clickTargets) {
|
||||
target.click()
|
||||
await delay(300)
|
||||
for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
|
||||
const id = input.id?.toLowerCase() || ""
|
||||
const name = input.getAttribute("name")?.toLowerCase() || ""
|
||||
if (id.includes("portrait") || id.includes("avatar") || id.includes("photo") ||
|
||||
name.includes("portrait") || name.includes("avatar") || name.includes("photo")) continue
|
||||
fileInput = input as HTMLInputElement
|
||||
break
|
||||
}
|
||||
if (fileInput) { console.log(`OfferPie: 点击 ${target.tagName} 后找到 file input`); break }
|
||||
}
|
||||
}
|
||||
|
||||
// 策略4:兜底取第一个非头像的 file input
|
||||
if (!fileInput) {
|
||||
for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
|
||||
const id = input.id?.toLowerCase() || ""
|
||||
const name = input.getAttribute("name")?.toLowerCase() || ""
|
||||
if (id.includes("portrait") || id.includes("avatar") || id.includes("photo") ||
|
||||
name.includes("portrait") || name.includes("avatar") || name.includes("photo")) continue
|
||||
fileInput = input as HTMLInputElement
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileInput) { console.log("OfferPie: 未找到简历文件上传的 input[type='file']"); return false }
|
||||
console.log(`OfferPie: 找到 file input: id="${fileInput.id}" name="${fileInput.getAttribute("name") || ""}"`)
|
||||
|
||||
// 3. 从 OSS 下载并注入
|
||||
try {
|
||||
const response = await fetch(resumeUrl)
|
||||
if (!response.ok) { console.error(`OfferPie: 下载简历失败: ${response.status}`); return false }
|
||||
const blob = await response.blob()
|
||||
const urlPath = new URL(resumeUrl).pathname
|
||||
const fileName = decodeURIComponent(urlPath.split("/").pop() || "resume.pdf")
|
||||
const ext = fileName.split(".").pop()?.toLowerCase()
|
||||
const mimeMap: Record<string, string> = {
|
||||
pdf: "application/pdf", doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
ppt: "application/vnd.ms-powerpoint",
|
||||
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
}
|
||||
const mimeType = mimeMap[ext || ""] || blob.type || "application/octet-stream"
|
||||
const file = new File([blob], fileName, { type: mimeType })
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
fileInput.files = dataTransfer.files
|
||||
fileInput.dispatchEvent(new Event("change", { bubbles: true }))
|
||||
fileInput.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
console.log(`OfferPie: ✅ 简历文件已上传: "${fileName}" (${(blob.size / 1024).toFixed(1)}KB)`)
|
||||
return true
|
||||
} catch (err) { console.error("OfferPie: 简历上传出错", err); return false }
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 类型定义模块
|
||||
* 包含简历数据接口、表单标签接口、匹配字段接口、UI组件库配置接口等所有共享类型
|
||||
*/
|
||||
|
||||
// ============ 简历数据类型 ============
|
||||
|
||||
/** 描述段落(富文本段落) */
|
||||
export interface DescriptionParagraph {
|
||||
/** 段落 ID */
|
||||
id?: string
|
||||
/** 段落类型 */
|
||||
type?: string
|
||||
/** 段落内容(旧格式) */
|
||||
content?: string
|
||||
/** 段落文本(接口返回格式) */
|
||||
text?: string
|
||||
}
|
||||
|
||||
/** 简历主表数据 */
|
||||
export interface ResumeMainData {
|
||||
/** 简历 ID */
|
||||
id?: string
|
||||
/** 简历名称 */
|
||||
resumeName?: string
|
||||
/** 目标岗位 */
|
||||
targetPosition?: string
|
||||
/** 是否默认简历 0=否 1=是 */
|
||||
isDefault?: number
|
||||
/** 头像URL */
|
||||
avatarUrl?: string
|
||||
/** 真实姓名 */
|
||||
name?: string
|
||||
/** 邮箱 */
|
||||
email?: string
|
||||
/** 手机号码 */
|
||||
mobileNumber?: string
|
||||
/** 所在城市 */
|
||||
city?: string
|
||||
/** 微信号 */
|
||||
wechatNumber?: string
|
||||
/** 作品集链接 */
|
||||
portfolioUrl?: string
|
||||
/** 技能标签列表 */
|
||||
skills?: string[]
|
||||
/** 证书标签列表 */
|
||||
certificates?: string[]
|
||||
/** 个人概述 */
|
||||
summary?: string
|
||||
}
|
||||
|
||||
/** 教育经历项 */
|
||||
export interface ResumeEducation {
|
||||
id?: string
|
||||
school?: string
|
||||
major?: string
|
||||
degree?: string
|
||||
studyType?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
description?: DescriptionParagraph[]
|
||||
}
|
||||
|
||||
/** 工作经历项 */
|
||||
export interface ResumeWork {
|
||||
id?: string
|
||||
companyName?: string
|
||||
position?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
description?: DescriptionParagraph[]
|
||||
}
|
||||
|
||||
/** 实习经历项 */
|
||||
export interface ResumeInternship {
|
||||
id?: string
|
||||
companyName?: string
|
||||
position?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
description?: DescriptionParagraph[]
|
||||
}
|
||||
|
||||
/** 项目经历项 */
|
||||
export interface ResumeProject {
|
||||
id?: string
|
||||
companyName?: string
|
||||
projectName?: string
|
||||
role?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
description?: DescriptionParagraph[]
|
||||
}
|
||||
|
||||
/** 竞赛经历项 */
|
||||
export interface ResumeCompetition {
|
||||
id?: string
|
||||
competitionName?: string
|
||||
award?: string
|
||||
awardDate?: string
|
||||
description?: DescriptionParagraph[]
|
||||
}
|
||||
|
||||
/** 完整简历数据(主表 + 5大经历) */
|
||||
export interface ResumeData {
|
||||
main: ResumeMainData
|
||||
education: ResumeEducation[]
|
||||
work: ResumeWork[]
|
||||
internship: ResumeInternship[]
|
||||
project: ResumeProject[]
|
||||
competition: ResumeCompetition[]
|
||||
}
|
||||
|
||||
/** 简历数据分区类型 */
|
||||
export type ResumeSection = "main" | "education" | "work" | "internship" | "project" | "competition"
|
||||
|
||||
/** 经历类型分区(不含 main) */
|
||||
export type ExperienceSection = Exclude<ResumeSection, "main">
|
||||
|
||||
// ============ 经历区块配置类型 ============
|
||||
|
||||
/** 经历区块标题配置(用于识别页面中的经历区块和添加按钮) */
|
||||
export interface ExperienceSectionConfig {
|
||||
/** 经历分区标识 */
|
||||
section: ExperienceSection
|
||||
/** 中文区块标题关键词(用于在 DOM 中匹配区块标题) */
|
||||
zh: string[]
|
||||
/** 英文区块标题关键词 */
|
||||
en: string[]
|
||||
/** 该经历类型的核心字段 key(用于统计已展开的段数) */
|
||||
coreFieldKey: string
|
||||
/** 添加按钮的文字关键词(中文) */
|
||||
addButtonZh: string[]
|
||||
/** 添加按钮的文字关键词(英文) */
|
||||
addButtonEn: string[]
|
||||
}
|
||||
|
||||
// ============ 表单标签类型 ============
|
||||
|
||||
/** 标签分类接口 */
|
||||
export interface FormLabelItem {
|
||||
/** 标签分类名(内部标识) */
|
||||
key: string
|
||||
/** 中文标签(可能出现的多种写法) */
|
||||
zh: string[]
|
||||
/** 英文标签(可能出现的多种写法) */
|
||||
en: string[]
|
||||
/** 对应简历数据的分区 */
|
||||
section: ResumeSection
|
||||
/** 对应简历数据分区内的字段名 */
|
||||
resumeField: string
|
||||
}
|
||||
|
||||
// ============ UI 组件库配置类型 ============
|
||||
|
||||
/** UI 组件库选择器配置 */
|
||||
export interface UILibPickerConfig {
|
||||
/** 组件库名称 */
|
||||
libName: string
|
||||
/** 选择器容器/弹出层的 CSS 类名关键词 */
|
||||
dropdownClasses: string[]
|
||||
/** 最终可点击选择结果项的 CSS 类名 */
|
||||
optionItemClasses: string[]
|
||||
/** input 附近可能出现的选择器触发区域类名 */
|
||||
triggerClasses: string[]
|
||||
}
|
||||
|
||||
// ============ 匹配字段类型 ============
|
||||
|
||||
/** 匹配到的表单字段信息 */
|
||||
export interface MatchedFormField {
|
||||
/** 标签分类 key(如 name、phone、email) */
|
||||
key: string
|
||||
/** 对应简历数据的分区(main / education / work 等) */
|
||||
section: ResumeSection
|
||||
/** 对应简历数据分区内的字段名 */
|
||||
resumeField: string
|
||||
/** 经历数组中的索引(section 为经历类型时使用,从 0 开始) */
|
||||
sectionIndex: number
|
||||
/** 匹配到的标签文字内容 */
|
||||
labelText: string
|
||||
/** 标签所在的 HTML 元素 */
|
||||
labelElement: Element
|
||||
/** 标签元素的 CSS 选择器路径 */
|
||||
labelSelector: string
|
||||
/** 紧跟标签后的第一个 input 输入框元素(radio 类型时为 null) */
|
||||
inputElement: HTMLInputElement | HTMLTextAreaElement | null
|
||||
/** input 元素的 CSS 选择器路径 */
|
||||
inputSelector: string
|
||||
/** input 同级或父级的点击按钮元素 */
|
||||
buttonElement: HTMLElement | null
|
||||
/** button 元素的 CSS 选择器路径 */
|
||||
buttonSelector: string
|
||||
/** 表单项的输入类型 */
|
||||
inputType: "text" | "radio" | "picker" | "textarea"
|
||||
/** 单选按钮组的容器元素(inputType 为 radio 时使用) */
|
||||
radioContainer: Element | null
|
||||
/** 是否为数据选择器类型 */
|
||||
isPicker: boolean
|
||||
/** 数据选择器弹出层的 DOM 元素 */
|
||||
pickerDropdownElement: HTMLElement | null
|
||||
/** 数据选择器弹出层的 CSS 选择器路径 */
|
||||
pickerDropdownSelector: string
|
||||
/** 要填入的值 */
|
||||
fillValue: string
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "plasmo/templates/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user