优化业务逻辑和基础库的大标题区间和字段的识别精度
This commit is contained in:
@@ -14,38 +14,6 @@ Plasmo 是一个 Chrome 扩展开发框架,核心价值是:**让你用写 Re
|
|||||||
- Content Script 样式隔离(Shadow DOM)
|
- Content Script 样式隔离(Shadow DOM)
|
||||||
- 开发时热更新(改代码自动刷新扩展)
|
- 开发时热更新(改代码自动刷新扩展)
|
||||||
|
|
||||||
## 2️⃣ 约定式文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── popup.tsx # 点击插件图标弹出的小窗口
|
|
||||||
├── options.tsx # 插件设置页(右键图标→选项)
|
|
||||||
├── newtab.tsx # 覆盖浏览器新标签页
|
|
||||||
├── sidepanel.tsx # 浏览器侧边栏面板
|
|
||||||
│
|
|
||||||
├── background/
|
|
||||||
│ └── index.ts # 后台 Service Worker,监听事件、做中转
|
|
||||||
│
|
|
||||||
├── contents/
|
|
||||||
│ └── xxx.tsx / xxx.ts # 注入到目标网页里的脚本(可以有多个)
|
|
||||||
│
|
|
||||||
├── components/ # 自己的 UI 组件(非框架约定)
|
|
||||||
│ └── Button.tsx
|
|
||||||
│
|
|
||||||
└── lib/ # 工具函数(非框架约定)
|
|
||||||
└── api.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
**核心规则:文件放对位置就自动生效,不需要任何注册或配置。**
|
|
||||||
|
|
||||||
| 文件 | 有就生效,没有就没这功能 |
|
|
||||||
|------|------------------------|
|
|
||||||
| `popup.tsx` | 有 → 点图标弹窗;没有 → 点图标触发 background 事件 |
|
|
||||||
| `options.tsx` | 有 → 有设置页;没有 → 没设置页 |
|
|
||||||
| `newtab.tsx` | 有 → 新标签页被接管;没有 → 正常新标签页 |
|
|
||||||
| `sidepanel.tsx` | 有 → 有浏览器侧边栏;没有 → 没有 |
|
|
||||||
| `background/index.ts` | 有 → 有后台服务;没有 → 没后台逻辑 |
|
|
||||||
| `contents/任意名.tsx` | 有几个就注入几个脚本到网页里 |
|
|
||||||
|
|
||||||
## 3️⃣ 文件后缀规则
|
## 3️⃣ 文件后缀规则
|
||||||
|
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
---
|
|
||||||
inclusion: manual
|
|
||||||
---
|
|
||||||
|
|
||||||
# OfferPie Chrome Extension 项目结构说明
|
|
||||||
|
|
||||||
## 1️⃣ 项目整体层次
|
|
||||||
```
|
|
||||||
offferpie_google_extension/
|
|
||||||
│
|
|
||||||
├─ package.json # 依赖 + manifest 权限配置
|
|
||||||
├─ tsconfig.json # TypeScript 配置(继承 Plasmo 模板)
|
|
||||||
├─ .prettierrc.cjs # 代码格式化配置
|
|
||||||
│
|
|
||||||
├─ assets/ # 静态资源(图标等)
|
|
||||||
│ ├─ icon.png
|
|
||||||
│ └─ icon1.png
|
|
||||||
│
|
|
||||||
└─ src/ # 源码主目录
|
|
||||||
├─ config.ts # **环境配置**(多环境切换:dev/prod)
|
|
||||||
├─ constants.ts # **全局常量**(消息类型、存储 Key、缓存时间等)
|
|
||||||
│
|
|
||||||
├─ background/ # **Background Service Worker**
|
|
||||||
│ └─ index.ts # 后台服务(监听图标点击、处理 Cookie 读取消息、安装事件)
|
|
||||||
│
|
|
||||||
├─ contents/ # **Content Scripts**(注入到目标网页的脚本)
|
|
||||||
│ └─ sidebar.tsx # 侧边栏入口(Shadow DOM 样式隔离,监听消息控制显隐)
|
|
||||||
│
|
|
||||||
├─ components/ # **UI 组件**
|
|
||||||
│ ├─ SidebarPanel.tsx # 侧边栏面板组件
|
|
||||||
│ └─ SidebarPanel.scss # 侧边栏样式
|
|
||||||
│
|
|
||||||
├─ api/ # **接口层**(REST API 请求封装)
|
|
||||||
│ ├─ request.ts # 通用请求方法(自动带 Token、统一错误处理、支持路径/query/body 参数)
|
|
||||||
│ ├─ dataApi.ts # Java 后端接口(业务 CRUD)
|
|
||||||
│ └─ aiApi.ts # Python AI 后端接口(AI 能力)
|
|
||||||
│
|
|
||||||
├─ utils/ # **工具层**(无业务依赖的通用工具)
|
|
||||||
│ ├─ cookie.ts # Cookie 读取(通过消息中转 Background 获取)
|
|
||||||
│ ├─ storage.ts # Chrome Storage 封装(chrome.storage.local 读写删)
|
|
||||||
│ └─ auth.ts # 登录状态检查(带缓存,全局共享)
|
|
||||||
│
|
|
||||||
└─ lib/ # **业务逻辑层**(具体业务功能实现)
|
|
||||||
├─ autofill.ts # 自动填表逻辑
|
|
||||||
├─ dom.ts # DOM 操作工具
|
|
||||||
├─ formMatcher.ts # 表单匹配器
|
|
||||||
├─ pickerDetector.ts # 选择器检测
|
|
||||||
├─ pickerFill.ts # 选择器填充
|
|
||||||
├─ datePicker.ts # 日期选择器处理
|
|
||||||
├─ resumeDataHelper.ts # 简历数据辅助
|
|
||||||
├─ resumeUpload.ts # 简历上传
|
|
||||||
├─ constants.ts # 业务常量
|
|
||||||
└─ types.ts # 业务类型定义
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2️⃣ 各层模块职责
|
|
||||||
| 层级 | 主要职责 | 关键文件 |
|
|
||||||
|------|----------|----------|
|
|
||||||
| **config** | 环境配置管理,支持 dev/prod 切换 | `config.ts`(dataBaseApi、aiBaseApi、cookieSourceUrl) |
|
|
||||||
| **constants** | 全局常量定义 | `constants.ts`(MSG_TYPES、STORAGE_KEYS、LOGIN_CHECK_INTERVAL) |
|
|
||||||
| **background** | 后台事件处理:图标点击、Cookie 读取中转、安装事件 | `background/index.ts` |
|
|
||||||
| **contents** | 注入目标网页的脚本,负责 UI 渲染和页面交互 | `contents/sidebar.tsx`(侧边栏面板注入) |
|
|
||||||
| **components** | 可复用 React UI 组件 | `SidebarPanel.tsx` |
|
|
||||||
| **api** | 接口请求封装,自动携带 Token,统一错误处理 | `request.ts`(通用封装)、`dataApi.ts`(Java 端)、`aiApi.ts`(Python 端) |
|
|
||||||
| **utils** | 无业务依赖的通用工具 | `cookie.ts`(Cookie 获取)、`storage.ts`(持久化存储)、`auth.ts`(登录检查) |
|
|
||||||
| **lib** | 具体业务功能实现(自动填表、DOM 操作、简历处理等) | `autofill.ts`、`formMatcher.ts`、`resumeUpload.ts` 等 |
|
|
||||||
|
|
||||||
## 3️⃣ 技术栈
|
|
||||||
| 类别 | 技术选型 | 说明 |
|
|
||||||
|------|----------|------|
|
|
||||||
| **扩展框架** | Plasmo 0.90 | Chrome MV3 扩展开发框架,约定式文件结构 |
|
|
||||||
| **UI 框架** | React 19 | 组件化 UI 开发 |
|
|
||||||
| **UI 组件库** | Arco Design (web-react) | 字节跳动 React 组件库 |
|
|
||||||
| **语言** | TypeScript 5.8 | 类型安全 |
|
|
||||||
| **样式** | SCSS | 组件样式,通过 Shadow DOM 隔离 |
|
|
||||||
|
|
||||||
## 4️⃣ Plasmo 框架约定
|
|
||||||
| 文件/目录 | 框架行为 |
|
|
||||||
|-----------|----------|
|
|
||||||
| `src/background/index.ts` | 自动注册为 Background Service Worker |
|
|
||||||
| `src/contents/*.tsx` | 自动注册为 Content Script(带 UI,Shadow DOM 隔离) |
|
|
||||||
| `src/contents/*.ts` | 自动注册为 Content Script(纯逻辑,无 UI) |
|
|
||||||
| `popup.tsx`(未使用) | 点击图标弹出的小窗口 |
|
|
||||||
| `options.tsx`(未使用) | 扩展设置页 |
|
|
||||||
| `sidepanel.tsx`(未使用) | 浏览器侧边栏面板 |
|
|
||||||
| `package.json → manifest` | 自动合并生成 manifest.json |
|
|
||||||
|
|
||||||
## 5️⃣ 通信架构
|
|
||||||
```
|
|
||||||
Content Script(页面内)
|
|
||||||
│
|
|
||||||
│ chrome.runtime.sendMessage
|
|
||||||
│ ← GET_TOKEN / GET_COOKIES →
|
|
||||||
▼
|
|
||||||
Background Service Worker(后台)
|
|
||||||
│
|
|
||||||
│ chrome.cookies.get / getAll
|
|
||||||
▼
|
|
||||||
Chrome Cookie Store(公司网站域名下的 Cookie)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Content Script 不直接读 Cookie,统一通过 Background 中转
|
|
||||||
- Background 按需唤醒,处理完消息后自动休眠
|
|
||||||
- Token 从公司网站 Cookie 中获取,复用页面登录状态
|
|
||||||
|
|
||||||
## 6️⃣ 鉴权机制
|
|
||||||
- **Token 来源**:通过 `chrome.cookies.get` 从公司网站域名(cookieSourceUrl)读取名为 `Token` 的 Cookie
|
|
||||||
- **请求携带**:`api/request.ts` 每次请求前自动获取 Token,塞入请求头 `Token` 字段
|
|
||||||
- **登录检查**:`utils/auth.ts` 提供 `ensureLogin()` 方法,调用后端 `checkLogin` 接口验证 Token 有效性
|
|
||||||
- **缓存策略**:登录检查结果缓存到 `chrome.storage.local`,5 分钟内全局不重复请求;失败立即清缓存
|
|
||||||
|
|
||||||
## 7️⃣ 与后端的关系
|
|
||||||
| 后端 | 基础地址 | 用途 |
|
|
||||||
|------|----------|------|
|
|
||||||
| **Java 端**(back-end) | `http://localhost:8080/api` | 业务 CRUD:用户、简历、岗位、投递等 |
|
|
||||||
| **Python AI 端**(offerpie_python_ai) | `http://localhost:8000` | AI 能力:智能分析、简历解析、对话等 |
|
|
||||||
|
|
||||||
- 两个后端共享同一套 Token 鉴权体系
|
|
||||||
- 插件通过读取公司网站 Cookie 复用登录状态,无需单独登录
|
|
||||||
|
|
||||||
## 8️⃣ Manifest 权限
|
|
||||||
| 权限 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `activeTab` | 访问当前活动标签页 |
|
|
||||||
| `storage` | 使用 chrome.storage.local 持久化数据 |
|
|
||||||
| `tabs` | 操作标签页(发送消息等) |
|
|
||||||
| `cookies` | 读取公司网站域名下的 Cookie(获取 Token) |
|
|
||||||
| `host_permissions: <all_urls>` | Content Script 注入所有页面 + 跨域请求 |
|
|
||||||
|
|
||||||
## 9️⃣ 构建与运行
|
|
||||||
- **安装依赖**:`npm install`
|
|
||||||
- **开发模式**:`npm run dev`(热更新,产物在 `build/chrome-mv3-dev`)
|
|
||||||
- **生产构建**:`npm run build`(产物在 `build/chrome-mv3-prod`)
|
|
||||||
- **打包发布**:`npm run package`(生成 zip)
|
|
||||||
- **加载到 Chrome**:`chrome://extensions` → 开发者模式 → 加载已解压的扩展程序 → 选择 build 目录
|
|
||||||
@@ -128,7 +128,7 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 开启循环扫描(每秒执行一次) */
|
/** 开启循环扫描(每秒执行一次) */
|
||||||
const startScanLoop = (params: { siteMode?: "beisen"; sectionResults?: any[]; expandedResults?: any[] }) => {
|
const startScanLoop = (params: { siteMode?: "beisen"; sectionResults?: any[]; expandedResults?: any[]; excludeTexts?: Set<string> }) => {
|
||||||
stopScanLoop()
|
stopScanLoop()
|
||||||
const hasFullData = !!(params.sectionResults && params.expandedResults)
|
const hasFullData = !!(params.sectionResults && params.expandedResults)
|
||||||
// 立即执行一次
|
// 立即执行一次
|
||||||
@@ -459,10 +459,33 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
|||||||
hideFillingOverlay() // 填写完成,移除遮罩
|
hideFillingOverlay() // 填写完成,移除遮罩
|
||||||
|
|
||||||
// 停止第一次循环扫描,开启第二次循环扫描(传入 sectionResults + expandedResults,分段精确)
|
// 停止第一次循环扫描,开启第二次循环扫描(传入 sectionResults + expandedResults,分段精确)
|
||||||
|
// 构建排除集合:从简历数据中提取所有值,避免将已填值误认为标签
|
||||||
|
const scanExcludeTexts = new Set<string>()
|
||||||
|
const scanResumeData = fillResult.resumeData || resumeData
|
||||||
|
if (scanResumeData) {
|
||||||
|
const main = scanResumeData.main
|
||||||
|
if (main) {
|
||||||
|
for (const val of Object.values(main)) {
|
||||||
|
if (typeof val === "string" && val.trim()) scanExcludeTexts.add(val.trim())
|
||||||
|
if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) scanExcludeTexts.add(v.trim()) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const expKeys: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"]
|
||||||
|
for (const sec of expKeys) {
|
||||||
|
const items = scanResumeData[sec]
|
||||||
|
if (!Array.isArray(items)) continue
|
||||||
|
for (const item of items) {
|
||||||
|
for (const val of Object.values(item as Record<string, any>)) {
|
||||||
|
if (typeof val === "string" && val.trim()) scanExcludeTexts.add(val.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
startScanLoop({
|
startScanLoop({
|
||||||
siteMode: scanSiteMode,
|
siteMode: scanSiteMode,
|
||||||
sectionResults: fillResult.sectionResults,
|
sectionResults: fillResult.sectionResults,
|
||||||
expandedResults: fillResult.expandedResults,
|
expandedResults: fillResult.expandedResults,
|
||||||
|
excludeTexts: scanExcludeTexts,
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -487,8 +510,49 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
|||||||
const currentHost = window.location.hostname
|
const currentHost = window.location.hostname
|
||||||
const siteMode = currentHost.includes("zhiye.com") ? "beisen" as const : undefined
|
const siteMode = currentHost.includes("zhiye.com") ? "beisen" as const : undefined
|
||||||
|
|
||||||
|
// 从简历数据和缓存数据中提取所有已填值,构建排除集合
|
||||||
|
// 避免 findLabelForInput 把这些值文字误认为是表单字段标签
|
||||||
|
const excludeTexts = new Set<string>()
|
||||||
|
if (resumeData) {
|
||||||
|
// 主表字段值
|
||||||
|
const main = resumeData.main
|
||||||
|
if (main) {
|
||||||
|
for (const val of Object.values(main)) {
|
||||||
|
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
|
||||||
|
if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) excludeTexts.add(v.trim()) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 5大经历字段值
|
||||||
|
const expSections: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"]
|
||||||
|
for (const sec of expSections) {
|
||||||
|
const items = resumeData[sec]
|
||||||
|
if (!Array.isArray(items)) continue
|
||||||
|
for (const item of items) {
|
||||||
|
for (const val of Object.values(item as Record<string, any>)) {
|
||||||
|
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 缓存中已记住的表单字段值
|
||||||
|
if (cacheData.unfilledFormData) {
|
||||||
|
for (const sec of cacheData.unfilledFormData as any[]) {
|
||||||
|
const items = sec.formItems
|
||||||
|
if (!items) continue
|
||||||
|
if (sec.isExperience && Array.isArray(items)) {
|
||||||
|
for (const seg of items) {
|
||||||
|
if (Array.isArray(seg)) {
|
||||||
|
for (const f of seg) { if (f.value && typeof f.value === "string") excludeTexts.add(f.value.trim()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(items)) {
|
||||||
|
for (const f of items) { if (f.value && typeof f.value === "string") excludeTexts.add(f.value.trim()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 调用 fillStats 扫描页面所有字段
|
// 调用 fillStats 扫描页面所有字段
|
||||||
const titleStats = scanPageFields({ siteMode })
|
const titleStats = scanPageFields({ siteMode, excludeTexts })
|
||||||
|
|
||||||
// 提取非简历格式字段(B2阶段字段)
|
// 提取非简历格式字段(B2阶段字段)
|
||||||
const nonResumeData = extractNonResumeFields(titleStats)
|
const nonResumeData = extractNonResumeFields(titleStats)
|
||||||
|
|||||||
@@ -881,6 +881,28 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
|||||||
const allTitles = getAllPageTitles(sectionResults)
|
const allTitles = getAllPageTitles(sectionResults)
|
||||||
const FIVE_EXP_SECTIONS_JSON = new Set(["education", "work", "internship", "project", "competition"])
|
const FIVE_EXP_SECTIONS_JSON = new Set(["education", "work", "internship", "project", "competition"])
|
||||||
|
|
||||||
|
// 从简历数据中提取所有值,构建排除集合(避免将已填值误认为标签)
|
||||||
|
const excludeTexts = new Set<string>()
|
||||||
|
if (currentResumeData) {
|
||||||
|
const main = currentResumeData.main
|
||||||
|
if (main) {
|
||||||
|
for (const val of Object.values(main)) {
|
||||||
|
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
|
||||||
|
if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) excludeTexts.add(v.trim()) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const expKeys: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"]
|
||||||
|
for (const sec of expKeys) {
|
||||||
|
const items = currentResumeData[sec]
|
||||||
|
if (!Array.isArray(items)) continue
|
||||||
|
for (const item of items) {
|
||||||
|
for (const val of Object.values(item as Record<string, any>)) {
|
||||||
|
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 判断某个字段是否属于简历数据格式(JOB_FORM_LABELS 里 resumeField 非空的) */
|
/** 判断某个字段是否属于简历数据格式(JOB_FORM_LABELS 里 resumeField 非空的) */
|
||||||
const resumeFormatInputs = new Set<Element>()
|
const resumeFormatInputs = new Set<Element>()
|
||||||
// 阶段A和B的所有 input 都是简历格式字段
|
// 阶段A和B的所有 input 都是简历格式字段
|
||||||
@@ -944,6 +966,17 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
|||||||
type FieldItem = { label: string; value: string; inputEl: Element }
|
type FieldItem = { label: string; value: string; inputEl: Element }
|
||||||
const collectedFields: FieldItem[] = []
|
const collectedFields: FieldItem[] = []
|
||||||
|
|
||||||
|
// 构建当前大标题范围内所有已填 input 的值集合
|
||||||
|
// 用于排除误将已填值(如"香港理工大学"、"硕士"、"2026"等)当作标签的情况
|
||||||
|
const filledInputValues = new Set<string>()
|
||||||
|
for (const inp of Array.from(allInputs)) {
|
||||||
|
const afterT = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
const beforeN = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
|
||||||
|
if (!afterT || !beforeN) continue
|
||||||
|
const val = (inp as HTMLInputElement | HTMLTextAreaElement).value?.trim()
|
||||||
|
if (val) filledInputValues.add(val)
|
||||||
|
}
|
||||||
|
|
||||||
for (const inp of Array.from(allInputs)) {
|
for (const inp of Array.from(allInputs)) {
|
||||||
// 范围检查
|
// 范围检查
|
||||||
const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
@@ -962,8 +995,12 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
|||||||
|
|
||||||
// 查找标签(使用统一封装的 labelFinder)
|
// 查找标签(使用统一封装的 labelFinder)
|
||||||
const titleElementSet = new Set<Element>(allTitles.map((t) => t.element))
|
const titleElementSet = new Set<Element>(allTitles.map((t) => t.element))
|
||||||
const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet)
|
const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet, excludeTexts)
|
||||||
const labelText = detectedLabel
|
const labelText = detectedLabel?.trim()
|
||||||
|
|
||||||
|
// 跳过无效标签:空、纯数字、已知排除词、或检测到的"标签"实际是某个已填 input 的值
|
||||||
|
if (!labelText || /^\d+$/.test(labelText) || JSON_EXCLUDE_LABELS.some((ex) => labelText === ex)) continue
|
||||||
|
if (filledInputValues.has(labelText)) continue
|
||||||
|
|
||||||
// 非经历类型:跳过简历格式字段
|
// 非经历类型:跳过简历格式字段
|
||||||
// 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过
|
// 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过
|
||||||
|
|||||||
@@ -865,6 +865,28 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
|||||||
const allTitles = getAllPageTitles(sectionResults)
|
const allTitles = getAllPageTitles(sectionResults)
|
||||||
const FIVE_EXP_SECTIONS_JSON = new Set(["education", "work", "internship", "project", "competition"])
|
const FIVE_EXP_SECTIONS_JSON = new Set(["education", "work", "internship", "project", "competition"])
|
||||||
|
|
||||||
|
// 从简历数据中提取所有值,构建排除集合(避免将已填值误认为标签)
|
||||||
|
const excludeTexts = new Set<string>()
|
||||||
|
if (currentResumeData) {
|
||||||
|
const main = currentResumeData.main
|
||||||
|
if (main) {
|
||||||
|
for (const val of Object.values(main)) {
|
||||||
|
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
|
||||||
|
if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) excludeTexts.add(v.trim()) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const expKeys: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"]
|
||||||
|
for (const sec of expKeys) {
|
||||||
|
const items = currentResumeData[sec]
|
||||||
|
if (!Array.isArray(items)) continue
|
||||||
|
for (const item of items) {
|
||||||
|
for (const val of Object.values(item as Record<string, any>)) {
|
||||||
|
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 判断某个字段是否属于简历数据格式(JOB_FORM_LABELS 里 resumeField 非空的) */
|
/** 判断某个字段是否属于简历数据格式(JOB_FORM_LABELS 里 resumeField 非空的) */
|
||||||
const resumeFormatInputs = new Set<Element>()
|
const resumeFormatInputs = new Set<Element>()
|
||||||
// 阶段A和B的所有 input 都是简历格式字段
|
// 阶段A和B的所有 input 都是简历格式字段
|
||||||
@@ -928,6 +950,17 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
|||||||
type FieldItem = { label: string; value: string; inputEl: Element }
|
type FieldItem = { label: string; value: string; inputEl: Element }
|
||||||
const collectedFields: FieldItem[] = []
|
const collectedFields: FieldItem[] = []
|
||||||
|
|
||||||
|
// 构建当前大标题范围内所有已填 input 的值集合
|
||||||
|
// 用于排除误将已填值(如"香港理工大学"、"硕士"、"2026"等)当作标签的情况
|
||||||
|
const filledInputValues = new Set<string>()
|
||||||
|
for (const inp of Array.from(allInputs)) {
|
||||||
|
const afterT = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
const beforeN = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
|
||||||
|
if (!afterT || !beforeN) continue
|
||||||
|
const val = (inp as HTMLInputElement | HTMLTextAreaElement).value?.trim()
|
||||||
|
if (val) filledInputValues.add(val)
|
||||||
|
}
|
||||||
|
|
||||||
for (const inp of Array.from(allInputs)) {
|
for (const inp of Array.from(allInputs)) {
|
||||||
// 范围检查
|
// 范围检查
|
||||||
const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
@@ -946,8 +979,12 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
|||||||
|
|
||||||
// 查找标签(使用统一封装的 labelFinder)
|
// 查找标签(使用统一封装的 labelFinder)
|
||||||
const titleElementSet = new Set<Element>(allTitles.map((t) => t.element))
|
const titleElementSet = new Set<Element>(allTitles.map((t) => t.element))
|
||||||
const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet)
|
const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet, excludeTexts)
|
||||||
const labelText = detectedLabel
|
const labelText = detectedLabel?.trim()
|
||||||
|
|
||||||
|
// 跳过无效标签:空、纯数字、已知排除词、或检测到的"标签"实际是某个已填 input 的值
|
||||||
|
if (!labelText || /^\d+$/.test(labelText) || JSON_EXCLUDE_LABELS.some((ex) => labelText === ex)) continue
|
||||||
|
if (filledInputValues.has(labelText)) continue
|
||||||
|
|
||||||
// 非经历类型:跳过简历格式字段
|
// 非经历类型:跳过简历格式字段
|
||||||
// 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过
|
// 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { findNearestInput } from "./formMatcher"
|
|||||||
import type { ExperienceSection, ExperienceSectionConfig, ResumeData } from "./types"
|
import type { ExperienceSection, ExperienceSectionConfig, ResumeData } from "./types"
|
||||||
|
|
||||||
/** 大标题排除关键词:包含这些文字的标签不作为大标题(它们是子标题/说明文字) */
|
/** 大标题排除关键词:包含这些文字的标签不作为大标题(它们是子标题/说明文字) */
|
||||||
const TITLE_EXCLUDE_KEYWORDS = ["高中教育经历", "本科教育经历", "本科及以上教育经历", "填写高中", "填写本科"]
|
const TITLE_EXCLUDE_KEYWORDS = ["高中教育经历", "本科教育经历", "本科及以上教育经历", "填写高中", "填写本科", "必填", "选填"]
|
||||||
|
|
||||||
/** 基本信息大标题关键词(精确全称匹配,用于分步表单页面识别) */
|
/** 基本信息大标题关键词(精确全称匹配,用于分步表单页面识别) */
|
||||||
const BASIC_INFO_TITLE_KEYWORDS = ["个人信息", "基本信息", "基础信息", "个人基本信息"]
|
const BASIC_INFO_TITLE_KEYWORDS = ["个人信息", "基本信息", "基础信息", "个人基本信息"]
|
||||||
|
|||||||
+30
-2
@@ -97,6 +97,8 @@ export interface ScanPageFieldsParams {
|
|||||||
siteMode?: "beisen"
|
siteMode?: "beisen"
|
||||||
/** 是否设置高亮背景色(默认false,统计模式下可设为true) */
|
/** 是否设置高亮背景色(默认false,统计模式下可设为true) */
|
||||||
applyHighlight?: boolean
|
applyHighlight?: boolean
|
||||||
|
/** 需要排除的文字集合(简历数据/缓存中的已填值,传给 findLabelForInput 避免将已填值误认为标签) */
|
||||||
|
excludeTexts?: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
@@ -405,6 +407,7 @@ export function scanPageFields(params: ScanPageFieldsParams): TitleStat[] {
|
|||||||
unmatchedFields,
|
unmatchedFields,
|
||||||
siteMode,
|
siteMode,
|
||||||
applyHighlight = false,
|
applyHighlight = false,
|
||||||
|
excludeTexts,
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
// 如果没传 sectionResults,自动检测语言并重新定位大标题
|
// 如果没传 sectionResults,自动检测语言并重新定位大标题
|
||||||
@@ -520,6 +523,18 @@ export function scanPageFields(params: ScanPageFieldsParams): TitleStat[] {
|
|||||||
|
|
||||||
// 【补扫阶段D】在此标题范围内查找未被任何阶段处理的 input
|
// 【补扫阶段D】在此标题范围内查找未被任何阶段处理的 input
|
||||||
const allInputsInRange = document.body.querySelectorAll(INPUT_SEL_STAT)
|
const allInputsInRange = document.body.querySelectorAll(INPUT_SEL_STAT)
|
||||||
|
|
||||||
|
// 构建当前大标题范围内所有已填 input 的值集合
|
||||||
|
// 用于排除误将已填值(如"香港理工大学"、"硕士"、"2026"等)当作标签的情况
|
||||||
|
const filledInputValuesInRange = new Set<string>()
|
||||||
|
for (const inp of Array.from(allInputsInRange)) {
|
||||||
|
const afterT = titleEl === inp || !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
const beforeN = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
|
||||||
|
if (!afterT || !beforeN) continue
|
||||||
|
const val = (inp as HTMLInputElement | HTMLTextAreaElement).value?.trim()
|
||||||
|
if (val) filledInputValuesInRange.add(val)
|
||||||
|
}
|
||||||
|
|
||||||
for (const inp of Array.from(allInputsInRange)) {
|
for (const inp of Array.from(allInputsInRange)) {
|
||||||
if (processedInputs.has(inp)) continue
|
if (processedInputs.has(inp)) continue
|
||||||
const afterTitle = titleEl === inp || !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
const afterTitle = titleEl === inp || !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
@@ -528,7 +543,14 @@ export function scanPageFields(params: ScanPageFieldsParams): TitleStat[] {
|
|||||||
|
|
||||||
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
||||||
const alreadyHasValue = !!(inputEl.value && inputEl.value.trim().length > 0)
|
const alreadyHasValue = !!(inputEl.value && inputEl.value.trim().length > 0)
|
||||||
const { labelText, labelElement } = findLabelForInput(inp, titleElementSet)
|
const { labelText, labelElement } = findLabelForInput(inp, titleElementSet, excludeTexts)
|
||||||
|
|
||||||
|
// 跳过无效标签:纯数字、或检测到的"标签"实际是某个已填 input 的值
|
||||||
|
const trimmedLabel = labelText?.trim() || ""
|
||||||
|
if (!trimmedLabel || /^\d+$/.test(trimmedLabel) || filledInputValuesInRange.has(trimmedLabel)) {
|
||||||
|
processedInputs.add(inp)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 根据背景色判断来源和颜色
|
// 根据背景色判断来源和颜色
|
||||||
let color: FieldStatColor
|
let color: FieldStatColor
|
||||||
@@ -750,7 +772,13 @@ export function extractNonResumeFields(titleStats: TitleStat[]): {
|
|||||||
|
|
||||||
for (const ts of titleStats) {
|
for (const ts of titleStats) {
|
||||||
// 只取非简历格式字段
|
// 只取非简历格式字段
|
||||||
const nonResumeFields = ts.fields.filter((f) => !f.isResumeFormat)
|
const nonResumeFields = ts.fields.filter((f) => {
|
||||||
|
if (f.isResumeFormat) return false
|
||||||
|
// 跳过无效标签:纯数字、或(未知字段)
|
||||||
|
const label = f.labelText?.trim()
|
||||||
|
if (!label || /^\d+$/.test(label) || label === "(未知字段)") return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
if (nonResumeFields.length === 0) continue
|
if (nonResumeFields.length === 0) continue
|
||||||
|
|
||||||
if (ts.isExpType && ts.segmentCount > 0) {
|
if (ts.isExpType && ts.segmentCount > 0) {
|
||||||
|
|||||||
+35
-7
@@ -12,7 +12,10 @@
|
|||||||
// ============ 常量 ============
|
// ============ 常量 ============
|
||||||
|
|
||||||
/** 排除的标签文字(不视为有效标签) */
|
/** 排除的标签文字(不视为有效标签) */
|
||||||
const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填", "确定", "取消", "搜索", "无准确的毕业时间可填写预计毕业时间"]
|
const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "添加", "*", "必填", "确定", "取消", "搜索", "无准确的毕业时间可填写预计毕业时间","暂无选项"]
|
||||||
|
|
||||||
|
/** 排除的标签文字片段(包含这些片段的文本也不视为有效标签) */
|
||||||
|
const EXCLUDE_LABEL_INCLUDES = ["请输入","请选择"]
|
||||||
|
|
||||||
/** 表单项容器选择器集合(用于第一遍策略的 closest 查找) */
|
/** 表单项容器选择器集合(用于第一遍策略的 closest 查找) */
|
||||||
const FORM_ITEM_SELS = [
|
const FORM_ITEM_SELS = [
|
||||||
@@ -37,6 +40,8 @@ export function isValidLabelText(text: string): boolean {
|
|||||||
if (!chineseChars || chineseChars.length < 2) return false
|
if (!chineseChars || chineseChars.length < 2) return false
|
||||||
// 排除已知无意义标签
|
// 排除已知无意义标签
|
||||||
if (EXCLUDE_LABEL_TEXTS.some((ex) => trimmed === ex)) return false
|
if (EXCLUDE_LABEL_TEXTS.some((ex) => trimmed === ex)) return false
|
||||||
|
// 排除包含特定片段的文字
|
||||||
|
if (EXCLUDE_LABEL_INCLUDES.some((inc) => trimmed.includes(inc))) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +57,9 @@ export function isValidLabelText(text: string): boolean {
|
|||||||
* - 碰到包含 input 的非祖先元素(前一个字段区域)
|
* - 碰到包含 input 的非祖先元素(前一个字段区域)
|
||||||
* - 最多走 80 个节点
|
* - 最多走 80 个节点
|
||||||
*
|
*
|
||||||
* 过滤条件:跳过不含至少2个汉字的文本
|
* 过滤条件:跳过不含至少2个汉字的文本,跳过 excludeTexts 中的文本
|
||||||
*/
|
*/
|
||||||
function walkBackwardForLabel(inp: Element, titleElements?: Set<Element>): { text: string; element: Element } | null {
|
function walkBackwardForLabel(inp: Element, titleElements?: Set<Element>, excludeTexts?: Set<string>): { text: string; element: Element } | null {
|
||||||
const INPUT_TAG_SET = new Set(["INPUT", "TEXTAREA", "SELECT"])
|
const INPUT_TAG_SET = new Set(["INPUT", "TEXTAREA", "SELECT"])
|
||||||
let maxSteps = 80
|
let maxSteps = 80
|
||||||
let current: Node | null = inp
|
let current: Node | null = inp
|
||||||
@@ -86,6 +91,8 @@ function walkBackwardForLabel(inp: Element, titleElements?: Set<Element>): { tex
|
|||||||
if (current.nodeType === Node.TEXT_NODE) {
|
if (current.nodeType === Node.TEXT_NODE) {
|
||||||
const text = current.textContent?.trim() || ""
|
const text = current.textContent?.trim() || ""
|
||||||
if (isValidLabelText(text)) {
|
if (isValidLabelText(text)) {
|
||||||
|
// 跳过已填值(简历数据/缓存数据中的值不应作为标签)
|
||||||
|
if (excludeTexts && excludeTexts.has(text)) continue
|
||||||
const parentEl = current.parentElement
|
const parentEl = current.parentElement
|
||||||
if (parentEl) {
|
if (parentEl) {
|
||||||
return { text, element: parentEl }
|
return { text, element: parentEl }
|
||||||
@@ -104,6 +111,7 @@ function walkBackwardForLabel(inp: Element, titleElements?: Set<Element>): { tex
|
|||||||
*
|
*
|
||||||
* @param inp - 输入框元素
|
* @param inp - 输入框元素
|
||||||
* @param titleElements - 大标题元素集合(用于第二遍策略的停止边界,可选)
|
* @param titleElements - 大标题元素集合(用于第二遍策略的停止边界,可选)
|
||||||
|
* @param excludeTexts - 需要排除的文字集合(简历数据/缓存中的已填值,遇到时跳过继续找,可选)
|
||||||
* @returns { labelText, labelElement } 标签文字和标签元素
|
* @returns { labelText, labelElement } 标签文字和标签元素
|
||||||
*
|
*
|
||||||
* 策略流程:
|
* 策略流程:
|
||||||
@@ -114,7 +122,7 @@ function walkBackwardForLabel(inp: Element, titleElements?: Set<Element>): { tex
|
|||||||
* 5. 第二遍:如果以上都未得到含≥2个汉字的标签,启动 walkBackwardForLabel 逆向遍历
|
* 5. 第二遍:如果以上都未得到含≥2个汉字的标签,启动 walkBackwardForLabel 逆向遍历
|
||||||
* 6. 都找不到则返回 placeholder 或 "(未知字段)"
|
* 6. 都找不到则返回 placeholder 或 "(未知字段)"
|
||||||
*/
|
*/
|
||||||
export function findLabelForInput(inp: Element, titleElements?: Set<Element>): { labelText: string; labelElement: Element | null } {
|
export function findLabelForInput(inp: Element, titleElements?: Set<Element>, excludeTexts?: Set<string>): { labelText: string; labelElement: Element | null } {
|
||||||
let labelText = ""
|
let labelText = ""
|
||||||
let labelElement: Element | null = null
|
let labelElement: Element | null = null
|
||||||
let container: Element | null = null
|
let container: Element | null = null
|
||||||
@@ -137,6 +145,8 @@ export function findLabelForInput(inp: Element, titleElements?: Set<Element>): {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("")
|
.join("")
|
||||||
if (dt && dt.length <= 30 && !EXCLUDE_LABEL_TEXTS.some((ex) => dt === ex)) {
|
if (dt && dt.length <= 30 && !EXCLUDE_LABEL_TEXTS.some((ex) => dt === ex)) {
|
||||||
|
if (EXCLUDE_LABEL_INCLUDES.some((inc) => dt.includes(inc))) continue
|
||||||
|
if (excludeTexts && excludeTexts.has(dt)) continue
|
||||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||||
hasLabelText = true
|
hasLabelText = true
|
||||||
break
|
break
|
||||||
@@ -158,6 +168,8 @@ export function findLabelForInput(inp: Element, titleElements?: Set<Element>): {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("")
|
.join("")
|
||||||
if (dt && dt.length <= 30 && !EXCLUDE_LABEL_TEXTS.some((ex) => dt === ex)) {
|
if (dt && dt.length <= 30 && !EXCLUDE_LABEL_TEXTS.some((ex) => dt === ex)) {
|
||||||
|
if (EXCLUDE_LABEL_INCLUDES.some((inc) => dt.includes(inc))) continue
|
||||||
|
if (excludeTexts && excludeTexts.has(dt)) continue
|
||||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
@@ -184,6 +196,9 @@ export function findLabelForInput(inp: Element, titleElements?: Set<Element>): {
|
|||||||
.join("")
|
.join("")
|
||||||
if (!directText || directText.length > 30) continue
|
if (!directText || directText.length > 30) continue
|
||||||
if (EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue
|
if (EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue
|
||||||
|
if (EXCLUDE_LABEL_INCLUDES.some((inc) => directText.includes(inc))) continue
|
||||||
|
// 跳过已填值(简历数据/缓存数据中的值不应作为标签)
|
||||||
|
if (excludeTexts && excludeTexts.has(directText)) continue
|
||||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||||
labelText = directText
|
labelText = directText
|
||||||
labelElement = el
|
labelElement = el
|
||||||
@@ -197,7 +212,12 @@ export function findLabelForInput(inp: Element, titleElements?: Set<Element>): {
|
|||||||
let prev: Element | null = inp.previousElementSibling
|
let prev: Element | null = inp.previousElementSibling
|
||||||
for (let i = 0; i < 3 && prev; i++) {
|
for (let i = 0; i < 3 && prev; i++) {
|
||||||
const text = prev.textContent?.trim() || ""
|
const text = prev.textContent?.trim() || ""
|
||||||
if (text && text.length <= 20 && !EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) {
|
if (text && text.length <= 20 && !EXCLUDE_LABEL_TEXTS.some((ex) => text === ex) && !EXCLUDE_LABEL_INCLUDES.some((inc) => text.includes(inc))) {
|
||||||
|
// 跳过已填值
|
||||||
|
if (excludeTexts && excludeTexts.has(text)) {
|
||||||
|
prev = prev.previousElementSibling
|
||||||
|
continue
|
||||||
|
}
|
||||||
labelText = text
|
labelText = text
|
||||||
labelElement = prev
|
labelElement = prev
|
||||||
break
|
break
|
||||||
@@ -208,7 +228,7 @@ export function findLabelForInput(inp: Element, titleElements?: Set<Element>): {
|
|||||||
|
|
||||||
// 【第二遍策略】如果第一遍未识别出有效标签(空或不含2个汉字),逆向遍历 DOM
|
// 【第二遍策略】如果第一遍未识别出有效标签(空或不含2个汉字),逆向遍历 DOM
|
||||||
if (!labelText || !isValidLabelText(labelText)) {
|
if (!labelText || !isValidLabelText(labelText)) {
|
||||||
const foundLabel = walkBackwardForLabel(inp, titleElements)
|
const foundLabel = walkBackwardForLabel(inp, titleElements, excludeTexts)
|
||||||
if (foundLabel) {
|
if (foundLabel) {
|
||||||
labelText = foundLabel.text
|
labelText = foundLabel.text
|
||||||
labelElement = foundLabel.element
|
labelElement = foundLabel.element
|
||||||
@@ -216,6 +236,14 @@ export function findLabelForInput(inp: Element, titleElements?: Set<Element>): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 最终 fallback:都找不到才用 placeholder 或返回"(未知字段)"
|
// 最终 fallback:都找不到才用 placeholder 或返回"(未知字段)"
|
||||||
if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)"
|
if (!labelText) {
|
||||||
|
const placeholder = (inp as HTMLInputElement).getAttribute("placeholder") || ""
|
||||||
|
// placeholder 如果包含排除片段(如"请输入"、"请选择"),不作为标签
|
||||||
|
if (placeholder && !EXCLUDE_LABEL_INCLUDES.some((inc) => placeholder.includes(inc)) && !EXCLUDE_LABEL_TEXTS.some((ex) => placeholder === ex)) {
|
||||||
|
labelText = placeholder
|
||||||
|
} else {
|
||||||
|
labelText = "(未知字段)"
|
||||||
|
}
|
||||||
|
}
|
||||||
return { labelText, labelElement }
|
return { labelText, labelElement }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user