Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
R
recruit-sys
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
李文光
recruit-sys
Commits
bde02970
Commit
bde02970
authored
Sep 04, 2026
by
李文光
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 需求25/23/11 期望薪资全链路 + 插件补抓沟通职位——expectedSalary 独立字段(解析/采集/回填/展示/手填), 采集插件 v1.3
parent
9f16374c
Show whitespace changes
Inline
Side-by-side
Showing
20 changed files
with
269 additions
and
12 deletions
+269
-12
CONTEXT.md
CONTEXT.md
+2
-0
ingest_repository.py
backend/app/repositories/ingest_repository.py
+3
-2
llm.py
backend/app/services/llm.py
+3
-0
resume_parser.py
backend/app/services/resume_parser.py
+1
-0
resume_parser_core.py
backend/app/services/resume_parser_core.py
+9
-0
test_ingest.py
backend/tests/test_ingest.py
+67
-0
test_resume_parser.py
backend/tests/test_resume_parser.py
+21
-0
test_resume_parser_llm.py
backend/tests/test_resume_parser_llm.py
+8
-0
README.md
browser-extension/README.md
+8
-0
content.js
browser-extension/content.js
+58
-0
manifest.json
browser-extension/manifest.json
+1
-1
requirements_from_interview.md
docs/requirements_from_interview.md
+10
-8
CandidateBatchModal.vue
vue-app/src/components/resumes/CandidateBatchModal.vue
+13
-0
CandidateCreateModal.vue
vue-app/src/components/resumes/CandidateCreateModal.vue
+4
-0
ResumeDetail.vue
vue-app/src/components/resumes/ResumeDetail.vue
+4
-0
ResumeList.vue
vue-app/src/components/resumes/ResumeList.vue
+17
-0
candidate.js
vue-app/src/utils/candidate.js
+9
-0
inference.js
vue-app/src/utils/inference.js
+8
-0
normalize.js
vue-app/src/utils/normalize.js
+1
-0
PoolView.vue
vue-app/src/views/PoolView.vue
+22
-1
No files found.
CONTEXT.md
View file @
bde02970
...
@@ -14,6 +14,8 @@
...
@@ -14,6 +14,8 @@
-
**简历库(Resume Library)**
:招聘专员个人工作区中的候选人集合,承载从「新入库」到「已入职」的完整流程。
-
**简历库(Resume Library)**
:招聘专员个人工作区中的候选人集合,承载从「新入库」到「已入职」的完整流程。
-
**阶段(Stage)**
:候选人当前流程节点(未筛选 / 初筛通过 / 面试中 / Offer 中 / 待入职 / 已入职 等)。
-
**阶段(Stage)**
:候选人当前流程节点(未筛选 / 初筛通过 / 面试中 / Offer 中 / 待入职 / 已入职 等)。
-
**标记(Tag)**
:HR 人工给候选人贴的结构化标签,AI 不参与打标;用于重点跟进、待二面、储备、已淘汰等运营与筛选。
-
**标记(Tag)**
:HR 人工给候选人贴的结构化标签,AI 不参与打标;用于重点跟进、待二面、储备、已淘汰等运营与筛选。
-
**期望薪资(Expected Salary)**
:候选人对薪酬的期望值,自由文本(如
`20-30K`
/
`15k`
/
`面议`
);来源为简历解析、平台采集或 HR 手填,旧数据可从入库备注回填。_Avoid_: 岗位薪资范围(岗位
`salaryRange`
)、Offer 薪资(Offer
`salary`
)。
-
**沟通职位(Communicated Job)**
:招聘平台对话中 HR 与候选人实际沟通的岗位,区别于候选人在平台填写的「期望岗位 / 求职意向」;由采集插件上传,仅在候选人自身求职岗位为空时作为初始
`jobTitle`
兜底,不覆盖已有值。
## 人才池 / 总库
## 人才池 / 总库
...
...
backend/app/repositories/ingest_repository.py
View file @
bde02970
...
@@ -54,7 +54,8 @@ def upsert_ingest_candidate(
...
@@ -54,7 +54,8 @@ def upsert_ingest_candidate(
candidate
=
{
candidate
=
{
"id"
:
candidate_id
,
"id"
:
candidate_id
,
"name"
:
str
(
parsed
.
get
(
"name"
)
or
raw
.
get
(
"name"
)
or
"未识别"
)[:
60
],
"name"
:
str
(
parsed
.
get
(
"name"
)
or
raw
.
get
(
"name"
)
or
"未识别"
)[:
60
],
"jobTitle"
:
str
(
parsed
.
get
(
"jobTitle"
)
or
raw
.
get
(
"jobTitle"
)
or
""
)[:
100
],
"jobTitle"
:
str
(
parsed
.
get
(
"jobTitle"
)
or
raw
.
get
(
"jobTitle"
)
or
raw
.
get
(
"communicatedJob"
)
or
""
)[:
100
],
"expectedSalary"
:
str
(
parsed
.
get
(
"expectedSalary"
)
or
raw
.
get
(
"expectedSalary"
)
or
raw
.
get
(
"salary"
)
or
""
)[:
100
],
"source"
:
source
,
"source"
:
source
,
"sourceUrl"
:
raw
.
get
(
"sourceUrl"
)
or
""
,
"sourceUrl"
:
raw
.
get
(
"sourceUrl"
)
or
""
,
"stage"
:
"未筛选"
,
"stage"
:
"未筛选"
,
...
@@ -81,7 +82,7 @@ def upsert_ingest_candidate(
...
@@ -81,7 +82,7 @@ def upsert_ingest_candidate(
if
saved_file
if
saved_file
else
None
,
else
None
,
"tag"
:
"新入库"
,
"tag"
:
"新入库"
,
"evaluation"
:
f
"期望薪资:{raw.get('salary')}"
if
raw
.
get
(
"salary"
)
else
"平台采集入库"
,
"evaluation"
:
"平台采集入库"
,
"createdAt"
:
at
,
"createdAt"
:
at
,
"updatedAt"
:
at
,
"updatedAt"
:
at
,
}
}
...
...
backend/app/services/llm.py
View file @
bde02970
...
@@ -250,6 +250,7 @@ def sanitize_candidate_for_llm(candidate: dict[str, Any]) -> dict[str, Any]:
...
@@ -250,6 +250,7 @@ def sanitize_candidate_for_llm(candidate: dict[str, Any]) -> dict[str, Any]:
LLM_RESUME_FIELDS
=
(
LLM_RESUME_FIELDS
=
(
"name"
,
"name"
,
"jobTitle"
,
"jobTitle"
,
"expectedSalary"
,
"phone"
,
"phone"
,
"email"
,
"email"
,
"birthDate"
,
"birthDate"
,
...
@@ -280,6 +281,8 @@ async def llm_resume_fields(text: str) -> dict[str, Any] | None:
...
@@ -280,6 +281,8 @@ async def llm_resume_fields(text: str) -> dict[str, Any] | None:
'- name: 姓名(2-4 个中文字符,如"张三");没有则返回 ""。
\n
'
'- name: 姓名(2-4 个中文字符,如"张三");没有则返回 ""。
\n
'
'- jobTitle: 求职意向/应聘岗位,只能是简历中明确写出的岗位名(如"销售支持专员");没有明确写出时返回 "",'
'- jobTitle: 求职意向/应聘岗位,只能是简历中明确写出的岗位名(如"销售支持专员");没有明确写出时返回 "",'
"不要根据教育/技能内容推断岗位。
\n
"
"不要根据教育/技能内容推断岗位。
\n
"
'- expectedSalary: 期望薪资(候选人的期望薪酬,如 "20-30K"、"15k"、"面议");简历明确写出期望薪资/薪酬/待遇时'
"提取原文,没有则返回
\"\"
,不要用招聘岗位的薪资范围猜测。
\n
"
'- phone: 手机号(11 位,可含 +86/空格/连字符,如 13812345678);没有则返回 ""。
\n
'
'- phone: 手机号(11 位,可含 +86/空格/连字符,如 13812345678);没有则返回 ""。
\n
'
'- email: 邮箱;没有则返回 ""。
\n
'
'- email: 邮箱;没有则返回 ""。
\n
'
'- birthDate: 出生日期,格式 YYYY-MM-DD;没有则返回 ""。
\n
'
'- birthDate: 出生日期,格式 YYYY-MM-DD;没有则返回 ""。
\n
'
...
...
backend/app/services/resume_parser.py
View file @
bde02970
...
@@ -78,6 +78,7 @@ def _compose_llm_result(text: str, filename: str, llm_result: dict) -> dict:
...
@@ -78,6 +78,7 @@ def _compose_llm_result(text: str, filename: str, llm_result: dict) -> dict:
return
{
return
{
"name"
:
_clean_text_value
(
llm_result
.
get
(
"name"
)),
"name"
:
_clean_text_value
(
llm_result
.
get
(
"name"
)),
"jobTitle"
:
_clean_text_value
(
llm_result
.
get
(
"jobTitle"
)),
"jobTitle"
:
_clean_text_value
(
llm_result
.
get
(
"jobTitle"
)),
"expectedSalary"
:
_clean_text_value
(
llm_result
.
get
(
"expectedSalary"
)),
"phone"
:
_clean_text_value
(
llm_result
.
get
(
"phone"
)),
"phone"
:
_clean_text_value
(
llm_result
.
get
(
"phone"
)),
"email"
:
_clean_text_value
(
llm_result
.
get
(
"email"
)),
"email"
:
_clean_text_value
(
llm_result
.
get
(
"email"
)),
"birthDate"
:
_clean_text_value
(
llm_result
.
get
(
"birthDate"
)),
"birthDate"
:
_clean_text_value
(
llm_result
.
get
(
"birthDate"
)),
...
...
backend/app/services/resume_parser_core.py
View file @
bde02970
...
@@ -286,6 +286,14 @@ def infer_job(text: str) -> str:
...
@@ -286,6 +286,14 @@ def infer_job(text: str) -> str:
return
_infer_job_from_standalone_line
(
text
)
return
_infer_job_from_standalone_line
(
text
)
def
infer_expected_salary
(
text
:
str
)
->
str
:
"""从简历文本识别期望薪资(如 15-25K / 8000-12000元/月 / 面议);没有明确写出返回空。"""
return
first_match
([
r"(?:期望薪资|期望薪酬|期望月薪|期望待遇|期望薪金|目标薪资)[::\s]*([^\n,,。;;||]{1,24})"
,
r"(?:薪资期望|薪酬期望)[::\s]*([^\n,,。;;||]{1,24})"
,
],
text
)
def
infer_source
(
text
:
str
,
filename
:
str
)
->
str
:
def
infer_source
(
text
:
str
,
filename
:
str
)
->
str
:
source_text
=
f
"{text} {filename}"
.
lower
()
source_text
=
f
"{text} {filename}"
.
lower
()
mapping
=
{
mapping
=
{
...
@@ -472,6 +480,7 @@ def parse_resume_text(text: str, filename: str) -> dict:
...
@@ -472,6 +480,7 @@ def parse_resume_text(text: str, filename: str) -> dict:
return
{
return
{
**
pii
,
**
pii
,
"jobTitle"
:
infer_job
(
text
),
"jobTitle"
:
infer_job
(
text
),
"expectedSalary"
:
infer_expected_salary
(
text
),
"years"
:
infer_work_years
(
f
"{text}
\n
{filename}"
),
"years"
:
infer_work_years
(
f
"{text}
\n
{filename}"
),
"education"
:
infer_education
(
text
),
"education"
:
infer_education
(
text
),
"school"
:
school
,
"school"
:
school
,
...
...
backend/tests/test_ingest.py
View file @
bde02970
...
@@ -16,3 +16,70 @@ def test_ingest_deduplicates_payload(client):
...
@@ -16,3 +16,70 @@ def test_ingest_deduplicates_payload(client):
assert
queue
[
"count"
]
==
1
assert
queue
[
"count"
]
==
1
client
.
post
(
f
"/api/ingest-queue/clear?ids={queue['queue'][0]['id']}"
)
client
.
post
(
f
"/api/ingest-queue/clear?ids={queue['queue'][0]['id']}"
)
assert
client
.
get
(
"/api/ingest-queue"
)
.
json
()[
"count"
]
==
0
assert
client
.
get
(
"/api/ingest-queue"
)
.
json
()[
"count"
]
==
0
def
test_ingest_expected_salary_stored_as_field
(
client
):
"""需求 25:插件/采集带上 salary 或 expectedSalary 时落候选人独立字段,不再塞进 evaluation 备注。"""
payload
=
{
"name"
:
"王五"
,
"source"
:
"Boss"
,
"sourceUrl"
:
"https://example.test/resume/2"
,
"summary"
:
"React Node 工程化"
,
"expectedSalary"
:
"25-35K"
,
}
resp
=
client
.
post
(
"/api/ingest"
,
json
=
payload
)
assert
resp
.
status_code
==
200
assert
resp
.
json
()[
"ok"
]
is
True
candidate
=
client
.
get
(
"/api/state"
)
.
json
()[
"candidates"
][
0
]
assert
candidate
[
"name"
]
==
"王五"
assert
candidate
[
"expectedSalary"
]
==
"25-35K"
assert
"期望薪资"
not
in
(
candidate
.
get
(
"evaluation"
)
or
""
)
def
test_ingest_legacy_salary_raw_maps_to_expected_salary
(
client
):
"""向后兼容:raw.salary(旧插件字段名)也映射到 expectedSalary。"""
payload
=
{
"name"
:
"赵六"
,
"source"
:
"猎聘"
,
"sourceUrl"
:
"https://example.test/resume/3"
,
"summary"
:
"销售管理"
,
"salary"
:
"30k"
,
}
resp
=
client
.
post
(
"/api/ingest"
,
json
=
payload
)
assert
resp
.
status_code
==
200
assert
resp
.
json
()[
"ok"
]
is
True
candidate
=
client
.
get
(
"/api/state"
)
.
json
()[
"candidates"
][
0
]
assert
candidate
[
"expectedSalary"
]
==
"30k"
def
test_ingest_communicated_job_fills_job_title_when_empty
(
client
):
"""需求 23/11:jobTitle 为空时用平台沟通职位兜底,避免候选人显示待匹配岗位。"""
payload
=
{
"name"
:
"钱七"
,
"source"
:
"Boss"
,
"sourceUrl"
:
"https://example.test/resume/4"
,
"summary"
:
"前端架构 React"
,
"communicatedJob"
:
"高级前端工程师"
,
}
resp
=
client
.
post
(
"/api/ingest"
,
json
=
payload
)
assert
resp
.
status_code
==
200
assert
resp
.
json
()[
"ok"
]
is
True
candidate
=
client
.
get
(
"/api/state"
)
.
json
()[
"candidates"
][
0
]
assert
candidate
[
"jobTitle"
]
==
"高级前端工程师"
def
test_ingest_communicated_job_does_not_override_job_title
(
client
):
"""Q2 修订:jobTitle 已有值(简历求职意向/上游字段)时,沟通职位不得覆盖。"""
payload
=
{
"name"
:
"孙八"
,
"source"
:
"Boss"
,
"sourceUrl"
:
"https://example.test/resume/5"
,
"summary"
:
"产品规划"
,
"jobTitle"
:
"高级产品经理"
,
"communicatedJob"
:
"产品总监"
,
}
resp
=
client
.
post
(
"/api/ingest"
,
json
=
payload
)
assert
resp
.
status_code
==
200
assert
resp
.
json
()[
"ok"
]
is
True
candidate
=
client
.
get
(
"/api/state"
)
.
json
()[
"candidates"
][
0
]
assert
candidate
[
"jobTitle"
]
==
"高级产品经理"
backend/tests/test_resume_parser.py
View file @
bde02970
from
backend.app.services.resume_parser_core
import
(
from
backend.app.services.resume_parser_core
import
(
infer_expected_salary
,
infer_job
,
infer_job
,
infer_major
,
infer_major
,
main
,
main
,
...
@@ -109,3 +110,23 @@ def test_job_title_keyword_rule_still_works():
...
@@ -109,3 +110,23 @@ def test_job_title_keyword_rule_still_works():
def
test_job_title_ignores_name_degree_and_motto_lines
():
def
test_job_title_ignores_name_degree_and_motto_lines
():
text
=
"张晓燕
\n
本科
\n
细心从每一个小细节开始。
\n
教育经历
\n
2017.09—2021.07 甘肃农业大学
\n
农林经济管理"
text
=
"张晓燕
\n
本科
\n
细心从每一个小细节开始。
\n
教育经历
\n
2017.09—2021.07 甘肃农业大学
\n
农林经济管理"
assert
infer_job
(
text
)
==
""
assert
infer_job
(
text
)
==
""
def
test_expected_salary_from_resume_text
():
text
=
"期望职位:销售支持专员
\n
期望薪资:15-25K
\n
期望城市:太原
\n
工作经历..."
assert
infer_expected_salary
(
text
)
==
"15-25K"
def
test_expected_salary_without_label_returns_empty
():
text
=
"销售支持专员
\n
负责客户管理与市场拓展。"
assert
infer_expected_salary
(
text
)
==
""
def
test_expected_salary_parse_resume
(
tmp_path
):
resume
=
tmp_path
/
"张晓燕.txt"
resume
.
write_text
(
"张晓燕
\n
电话:18734915261
\n
期望薪资:8000-12000元/月
\n
销售支持专员
\n
教育经历
\n
甘肃农业大学 农林经济管理"
,
encoding
=
"utf-8"
,
)
parsed
=
parse_resume
(
resume
)
assert
parsed
[
"expectedSalary"
]
==
"8000-12000元/月"
backend/tests/test_resume_parser_llm.py
View file @
bde02970
...
@@ -142,3 +142,11 @@ def test_async_parse_falls_back_without_llm(tmp_path, llm_disabled):
...
@@ -142,3 +142,11 @@ def test_async_parse_falls_back_without_llm(tmp_path, llm_disabled):
assert
parsed
[
"phone"
]
==
"13912345678"
assert
parsed
[
"phone"
]
==
"13912345678"
assert
parsed
[
"school"
]
==
"北京大学"
assert
parsed
[
"school"
]
==
"北京大学"
assert
parsed
[
"major"
]
==
"软件工程"
assert
parsed
[
"major"
]
==
"软件工程"
def
test_compose_llm_result_keeps_expected_salary
():
"""LLM 提取到期望薪资时原样落入结构化结果(Q3:解析链路补 expectedSalary)。"""
llm
=
{
"name"
:
"张晓燕"
,
"jobTitle"
:
"销售支持专员"
,
"expectedSalary"
:
"15-25K"
,
"school"
:
"甘肃农业大学"
}
merged
=
_compose_llm_result
(
"简历原文"
,
"张晓燕.txt"
,
llm
)
assert
merged
[
"expectedSalary"
]
==
"15-25K"
assert
merged
[
"jobTitle"
]
==
"销售支持专员"
browser-extension/README.md
View file @
bde02970
...
@@ -10,3 +10,11 @@
...
@@ -10,3 +10,11 @@
4.
在扩展弹窗中确认 API 地址为
`http://127.0.0.1:4177/api/ingest`
;部署到其他地址时可直接修改并保存。
4.
在扩展弹窗中确认 API 地址为
`http://127.0.0.1:4177/api/ingest`
;部署到其他地址时可直接修改并保存。
本扩展从 BOSS 直聘和猎聘页面采集候选人简历并提交给后端。
`reference/`
是外部方案参考副本,不属于运行时扩展。
本扩展从 BOSS 直聘和猎聘页面采集候选人简历并提交给后端。
`reference/`
是外部方案参考副本,不属于运行时扩展。
## v1.3(需求 23 / 25)
采集时补抓两项字段并随 payload 上传(抓不到则留空,不影响原有采集):
-
`expectedSalary`
:候选人页面上的「期望薪资」(DOM 选择器 + 页面文本兜底)。
-
`communicatedJob`
:平台「沟通职位」文本;仅在 DOM/明确标签命中且通过岗位名校验时才上传,避免把简历里的期望岗位误当沟通职位。
升级后请在扩展管理页点击刷新(重新加载扩展),再在 BOSS/猎聘候选人页重新采集一次,旧页面会带着新字段入库。
browser-extension/content.js
View file @
bde02970
...
@@ -73,6 +73,60 @@
...
@@ -73,6 +73,60 @@
function
extractPanelText
(
panel
)
{
return
panel
?
clean
(
panel
.
textContent
).
slice
(
0
,
5000
)
:
''
;
}
function
extractPanelText
(
panel
)
{
return
panel
?
clean
(
panel
.
textContent
).
slice
(
0
,
5000
)
:
''
;
}
// ── 需求 25 / 23:补抓期望薪资 + 平台沟通职位(best-effort,抓不到留空不阻断)──
function
looksLikeSalary
(
s
)
{
if
(
!
s
)
return
false
;
s
=
String
(
s
).
trim
();
if
(
!
s
||
s
.
length
>
30
)
return
false
;
return
/^面议/
.
test
(
s
)
||
/
(?:\d
+
(?:\.\d
+
)?\s
*
[
kKwW
]\b
|
\d
+
(?:\.\d
+
)?\s
*万|
\d
+
(?:\.\d
+
)?\s
*千|
\d{4,}
|
\d{1,3}\s
*
[
-—~至到
]\s
*
\d)
/
.
test
(
s
);
}
function
looksLikeJobTitle
(
s
)
{
if
(
!
s
)
return
false
;
s
=
String
(
s
).
trim
();
if
(
s
.
length
<
2
||
s
.
length
>
30
)
return
false
;
if
(
/
[
。,、;:()()
]
/
.
test
(
s
))
return
false
;
if
(
/^
(
期望|沟通|求职|招聘|简历|薪资|职位|岗位|已读|在线|上午|下午
)
/
.
test
(
s
))
return
false
;
if
(
/期望薪资|沟通职位|求职意向|岗位名称|招聘职位|工作年限/
.
test
(
s
))
return
false
;
if
(
/
(
专员|工程师|经理|总监|顾问|助理|主管|运营|设计师|分析师|代表|主任|编辑|记者|律师|医生|护士|会计|出纳|采购|市场|人事|行政|文员|客服|讲师|教练|专家|规划师|架构师|测试|开发|负责人|研究员|助教|产品
)
$/
.
test
(
s
))
return
true
;
return
s
.
length
<=
16
&&
/^
[\u
4e00-
\u
9fa5A-Za-z0-9·
\-\s]
+$/
.
test
(
s
)
&&
/
[
A-Za-z
]
|
\d
/
.
test
(
s
);
}
function
extractExpectedSalary
()
{
var
sels
=
[
'.resume-detail .salary'
,
'[class*="expect"] [class*="salary"]'
,
'[class*="salary"] [class*="expect"]'
,
'[class*="expectedSalary"]'
,
'[class*="expect-salary"]'
,
'.salary-line'
,
'.expect-salary'
];
for
(
var
i
=
0
;
i
<
sels
.
length
;
i
++
)
{
var
els
=
document
.
querySelectorAll
(
sels
[
i
]);
for
(
var
j
=
0
;
j
<
els
.
length
;
j
++
)
{
var
t
=
clean
(
els
[
j
].
textContent
);
if
(
looksLikeSalary
(
t
))
return
t
.
slice
(
0
,
30
);
}
}
if
(
!
document
.
body
)
return
''
;
var
text
=
clean
(
document
.
body
.
innerText
||
''
);
var
m
=
text
.
match
(
/期望
(?:
薪资|薪酬|月薪|待遇|薪金
)[
::
]?\s
*
([^\n
,,。;;||
]{1,24})
/
);
return
m
&&
looksLikeSalary
(
m
[
1
])
?
m
[
1
].
trim
().
slice
(
0
,
30
)
:
''
;
}
function
extractCommunicatedJob
()
{
var
sels
=
[
'[class*="job-name"]'
,
'[class*="jobName"]'
,
'[class*="job-title"]'
,
'[class*="positionName"]'
,
'[class*="position-name"]'
,
'[class*="chat-title"] [class*="job"]'
,
'.chat-header .job-info .name'
,
'[class*="communicate"] [class*="job"]'
,
'.talk-job-name'
];
for
(
var
i
=
0
;
i
<
sels
.
length
;
i
++
)
{
var
els
=
document
.
querySelectorAll
(
sels
[
i
]);
for
(
var
j
=
0
;
j
<
els
.
length
;
j
++
)
{
var
t
=
clean
(
els
[
j
].
textContent
);
if
(
looksLikeJobTitle
(
t
))
return
t
.
slice
(
0
,
30
);
}
}
// 页面文本中的明确「沟通职位」/「正在沟通」标签兜底(抓不到不强猜,避免把简历里的期望岗位当沟通职位)
if
(
!
document
.
body
)
return
''
;
var
text
=
clean
(
document
.
body
.
innerText
||
''
);
var
m
=
text
.
match
(
/
(?:
沟通职位|正在沟通的职位|本职位|沟通中的职位
)[
::
]?\s
*
([^\n
,,。;;||
]{2,30})
/
);
return
m
&&
looksLikeJobTitle
(
m
[
1
])
?
m
[
1
].
trim
().
slice
(
0
,
30
)
:
''
;
}
async
function
doSend
()
{
async
function
doSend
()
{
if
(
sending
)
return
;
if
(
sending
)
return
;
sending
=
true
;
sending
=
true
;
...
@@ -89,6 +143,10 @@
...
@@ -89,6 +143,10 @@
if
(
!
name
&&
!
text
)
throw
new
Error
(
'简历无内容'
);
if
(
!
name
&&
!
text
)
throw
new
Error
(
'简历无内容'
);
payload
.
name
=
name
;
payload
.
summary
=
text
;
payload
.
name
=
name
;
payload
.
summary
=
text
;
}
}
var
expectedSalary
=
extractExpectedSalary
();
if
(
expectedSalary
)
payload
.
expectedSalary
=
expectedSalary
;
var
communicatedJob
=
extractCommunicatedJob
();
if
(
communicatedJob
)
payload
.
communicatedJob
=
communicatedJob
;
var
hash
=
(
payload
.
name
||
''
)
+
'|'
+
(
payload
.
pdfUrl
||
pdfUrl
||
''
)
+
'|'
+
platform
;
var
hash
=
(
payload
.
name
||
''
)
+
'|'
+
(
payload
.
pdfUrl
||
pdfUrl
||
''
)
+
'|'
+
platform
;
if
(
hash
===
lastSentHash
&&
lastSentHash
)
{
showBubble
(
'已采集过'
);
sending
=
false
;
setTimeout
(
hideBubble
,
1500
);
return
;
}
if
(
hash
===
lastSentHash
&&
lastSentHash
)
{
showBubble
(
'已采集过'
);
sending
=
false
;
setTimeout
(
hideBubble
,
1500
);
return
;
}
var
result
=
await
sendToLocal
(
payload
);
var
result
=
await
sendToLocal
(
payload
);
...
...
browser-extension/manifest.json
View file @
bde02970
{
{
"manifest_version"
:
3
,
"manifest_version"
:
3
,
"name"
:
"简历采集器"
,
"name"
:
"简历采集器"
,
"version"
:
"1.
2
"
,
"version"
:
"1.
3
"
,
"description"
:
"从 BOSS 直聘 / 猎聘自动采集简历 PDF,同步到本地招聘系统"
,
"description"
:
"从 BOSS 直聘 / 猎聘自动采集简历 PDF,同步到本地招聘系统"
,
"permissions"
:
[
"activeTab"
,
"storage"
,
"cookies"
,
"scripting"
],
"permissions"
:
[
"activeTab"
,
"storage"
,
"cookies"
,
"scripting"
],
...
...
docs/requirements_from_interview.md
View file @
bde02970
...
@@ -12,6 +12,8 @@
...
@@ -12,6 +12,8 @@
>
>
> **2026-09-04 增量(需求 22 补全)**:批量移入人才池 / 批量打标签 / 批量认领(复活)已实现(简历库与人才池页多选 + 批量工具条),批量入库/上传增加 **file_hash 库级去重**(命中本人简历库→跳过计数;命中公司人才池→不重复建档、引导认领;跨账号不互斥);候选人 resumeMeta 落 hash 供后续去重。详见需求 22 条目。
> **2026-09-04 增量(需求 22 补全)**:批量移入人才池 / 批量打标签 / 批量认领(复活)已实现(简历库与人才池页多选 + 批量工具条),批量入库/上传增加 **file_hash 库级去重**(命中本人简历库→跳过计数;命中公司人才池→不重复建档、引导认领;跨账号不互斥);候选人 resumeMeta 落 hash 供后续去重。详见需求 22 条目。
>
>
> **2026-09-04 增量(需求 25/23/11:期望薪资 + 沟通职位)**:候选人新增独立 **`expectedSalary`(期望薪资)** 字段——简历解析(LLM+正则)、采集入库、新建/批量表单与详情抽屉均可写入,旧数据从入库备注「期望薪资:…」自动回填;简历库卡片/人才池/详情/新建与批量导入全链路展示与手填。采集插件升 **v1.3** 补抓「期望薪资」与平台「沟通职位」文本上传;沟通职位仅在候选人自身求职岗位为空时兜底为 `jobTitle`,不覆盖已有值。详见需求 11/23/25 条目。
>
> 本次复核相对上一版(810e280)新增提交:`ddc5340`(候选人详情抽屉+档案编辑)、`07310dd`(简历库重构+保存原简历+防重复)、`6248f27`/`fc0aa5c`(简历解析真正 LLM 优先并移除脱敏)、`ec9499f`(弹窗蒙层/ESC 不关闭)。
> 本次复核相对上一版(810e280)新增提交:`ddc5340`(候选人详情抽屉+档案编辑)、`07310dd`(简历库重构+保存原简历+防重复)、`6248f27`/`fc0aa5c`(简历解析真正 LLM 优先并移除脱敏)、`ec9499f`(弹窗蒙层/ESC 不关闭)。
>
>
> 依据代码现状核实:
> 依据代码现状核实:
...
@@ -34,7 +36,7 @@ HR 原话要点:
...
@@ -34,7 +36,7 @@ HR 原话要点:
**结论**
:延续现有系统架构(整包状态 + 前后端分离),按模块迭代。
**结论**
:延续现有系统架构(整包状态 + 前后端分离),按模块迭代。
**核实情况(2026-09,最新)**
:「下推联动」痛点已基本解决——候选人和岗位的每个状态动作都会同时写事件日志、增删待办(见需求 20);企微智能体产出的 JD 可经
`/api/jd/sync-external`
自动落为岗位并置为「待确认」状态(见需求 2)。
**候选人详情已于近期改为「右侧抽屉 + 可收缩 PDF 档案预览」**
(提交
`ddc5340`
/
`07310dd`
,见需求 10),HR「弹窗/页面级收敛、简历可收缩预览」的交互诉求已落地。
**候选人标记/人才池体系已大部落地(需求 1 总库=人才池 + 需求 12 标签筛选 + 需求 22 人才池独立视图,2026-09-02 工作区补充)**
,需求 14(自述优势约束)已实现(2026-09-03,见条目);需求 17(风险点固定维度)亦已实现(2026-09-03,见条目);需求 22 批量移入/复活与 file_hash 库级去重已实现(2026-09-04,见条目);岗位维简历量统计已于 2026-09-03 落地;另简历解析自
`fc0aa5c`
起改为
**原文直传大模型提取字段(不再本地脱敏)**
,采集插件仍不抓「沟通职位/期望薪资」
。
**核实情况(2026-09,最新)**
:「下推联动」痛点已基本解决——候选人和岗位的每个状态动作都会同时写事件日志、增删待办(见需求 20);企微智能体产出的 JD 可经
`/api/jd/sync-external`
自动落为岗位并置为「待确认」状态(见需求 2)。
**候选人详情已于近期改为「右侧抽屉 + 可收缩 PDF 档案预览」**
(提交
`ddc5340`
/
`07310dd`
,见需求 10),HR「弹窗/页面级收敛、简历可收缩预览」的交互诉求已落地。
**候选人标记/人才池体系已大部落地(需求 1 总库=人才池 + 需求 12 标签筛选 + 需求 22 人才池独立视图,2026-09-02 工作区补充)**
,需求 14(自述优势约束)已实现(2026-09-03,见条目);需求 17(风险点固定维度)亦已实现(2026-09-03,见条目);需求 22 批量移入/复活与 file_hash 库级去重已实现(2026-09-04,见条目);岗位维简历量统计已于 2026-09-03 落地;另简历解析自
`fc0aa5c`
起改为
**原文直传大模型提取字段(不再本地脱敏)**
;采集插件已自
**v1.3**
起补抓「沟通职位/期望薪资」(需求 23,2026-09-04)
。
---
---
...
@@ -114,7 +116,7 @@ HR 原话要点:
...
@@ -114,7 +116,7 @@ HR 原话要点:
**需求 11:候选人与岗位「重新匹配」**
**需求 11:候选人与岗位「重新匹配」**
-
原话:「插件抓的岗位是候选人在平台选的期望岗位,实际匹配要用我们的沟通职位;可能抓错,要能改匹配岗位重新匹配」(P12)
-
原话:「插件抓的岗位是候选人在平台选的期望岗位,实际匹配要用我们的沟通职位;可能抓错,要能改匹配岗位重新匹配」(P12)
-
现状:✅ 简历库卡片「求职岗位」列为岗位下拉,改选后
`bindJob`
立即回写
`jobId/jobTitle`
并自动
`localMatch`
重新快筛;另有「批量重新匹配」可整批刷新(完成后显示 完成/缺少正文/未绑岗位/执行失败 汇总);详情抽屉「快速匹配/AI 评估」也可重跑。改岗后 AI 评估会因
`analysisInputKey`
变化而提示"需重评"(生成时若已有有效报告会提示复用)。
-
现状:✅ 简历库卡片「求职岗位」列为岗位下拉,改选后
`bindJob`
立即回写
`jobId/jobTitle`
并自动
`localMatch`
重新快筛;另有「批量重新匹配」可整批刷新(完成后显示 完成/缺少正文/未绑岗位/执行失败 汇总);详情抽屉「快速匹配/AI 评估」也可重跑。改岗后 AI 评估会因
`analysisInputKey`
变化而提示"需重评"(生成时若已有有效报告会提示复用)。
-
建议(
🔄):已达标;
可选增强:改岗后自动提示并一键重生成 AI 评估(当前需手动点)。
-
建议(
✅ 已达标,2026-09-04 补强):采集插件 v1.3 起上传平台「沟通职位」文本(
`communicatedJob`
),仅在候选人自身求职岗位(jobTitle)为空时兜底为初始岗位,不覆盖已有值;简历库「求职岗位」下拉仍可随时改绑并自动重快筛。
可选增强:改岗后自动提示并一键重生成 AI 评估(当前需手动点)。
**需求 12:候选人标记(重点/待跟进)**
**需求 12:候选人标记(重点/待跟进)**
-
原话:「通过一面待二面的人重点标记;很匹配但难找的人标记重点关注;未来每周看提醒沟通维护;标记人工选,AI 不做」(P12)
-
原话:「通过一面待二面的人重点标记;很匹配但难找的人标记重点关注;未来每周看提醒沟通维护;标记人工选,AI 不做」(P12)
...
@@ -211,15 +213,15 @@ HR 原话要点:
...
@@ -211,15 +213,15 @@ HR 原话要点:
-
「狗抓的岗位不准,抓的是平台期望岗位,要改成匹配我们的沟通职位」(P12)
-
「狗抓的岗位不准,抓的是平台期望岗位,要改成匹配我们的沟通职位」(P12)
-
「狗抓到名字是错的(余德宝 888 抓成童童真),但 PDF 抓得准」(P12)
-
「狗抓到名字是错的(余德宝 888 抓成童童真),但 PDF 抓得准」(P12)
-
「期望薪资没抓」(P14)
-
「期望薪资没抓」(P14)
-
现状:
🔄 插件(Boss/猎聘)采集(本核对周期内
`browser-extension/`
无新提交):优先抓
`pdfUrl + cookies`
(后端下载 PDF 并 LLM 优先解析,名字/字段准);抓不到 PDF 时回退
`name + summary`
(面板文本);幂等去重(
`lastSentHash`
+ 后端
`idempotency_key`
)。
**缺口**
:① 插件 payload 不含平台"沟通职位/求职岗位"字段(
`jobTitle`
为空),岗位匹配依赖 HR 在系统内改绑;② 不抓期望薪资(
`raw.salary`
从未被发送,仅 ingest 入库时把
`期望薪资:{salary}`
写进
`evaluation`
备注);③ 无 PDF 时 name 启发式(
`looksLikePersonName`
)仍可能抓错。系统侧另在批量入库加了前端防重复(按文件名+姓名
,见需求 22)。
-
现状:
✅
**已补(2026-09-04,插件 v1.3)**
。采集仍优先
`pdfUrl + cookies`
(后端下载 PDF 并 LLM 优先解析,名字/字段准);抓不到 PDF 时回退
`name + summary`
;幂等去重不变。
**本轮已补**
:① 插件补抓平台「沟通职位」文本(
`communicatedJob`
)与「期望薪资」(
`expectedSalary`
)一并上传;② 后端 ingest 落候选人独立
`expectedSalary`
字段(不再塞进 evaluation 备注,旧备注数据由前端自动回填),沟通职位仅在 jobTitle 为空时兜底;③ 简历解析(LLM+正则)同步提取期望薪资。
**残余**
:无 PDF 时 name 启发式(
`looksLikePersonName`
)仍可能抓错;BOSS/猎聘 DOM 选择器为 best-effort,需在真站重新加载扩展后人工验收。系统侧另有前端防重复(文件名+姓名/file_hash
,见需求 22)。
-
建议(
🆕):插件修正:抓取平台页面上的"沟通职位"文本一并上传;尝试抓期望薪资字段(如实名/薪资区块);PDF 优先策略保持;无 PDF 时把名字/正文解析交给后端 LLM 修正(现有 ingest 已在 PDF 路径做了)
。
-
建议(
✅ 已落地):PDF 优先策略保持;插件字段抓取已上传,选择器命中不了时留空,不阻断采集
。
---
---
## 九、其他与交互细节
## 九、其他与交互细节
-
**需求 24:候选人概览抓取准确度**
:✅
**解析链路已重构为「LLM 优先、纯正则兜底」,且不再对 PII 本地脱敏**
(提交
`6248f27`
/
`fc0aa5c`
)。
`parse_resume_path_async`
读取简历文本后把
**原文(含姓名/手机/邮箱等 PII)直接交给 `llm_resume_fields`**
提取全部字段(
`LLM_RESUME_FIELDS`
:name/jobTitle/phone/email/birthDate/age/years/education/school/major/city/skills);仅当大模型不可用或返回全空(
`_has_llm_content`
)时
**整体回退**
`parse_resume_text`
纯正则。提交
`fc0aa5c`
移除了此前"本地脱敏给 LLM、PII 字段以正则结果为准"的双轨合并(
`_merge_parse_result`
已不存在),现在结构化字段
**直接采用 LLM 返回值**
(
`_compose_llm_result`
,schoolTags 按学校本地打标),靠 prompt「只提取明确出现、绝不编造、没有返回空」约束。
**注意取舍**
:解析路径现会把候选人 PII 明文发送给大模型提供方(代码注释已注明"如需脱敏请走 analyze_resume 链路");AI
**综合评估**
路径仍走
`sanitize_candidate_for_llm`
脱敏。此前修复的"换行粘连/专业误判/名字抓成工作经历"等仍在(对应单测
`test_resume_parser_llm.py`
随新链路更新)。
-
**需求 24:候选人概览抓取准确度**
:✅
**解析链路已重构为「LLM 优先、纯正则兜底」,且不再对 PII 本地脱敏**
(提交
`6248f27`
/
`fc0aa5c`
)。
`parse_resume_path_async`
读取简历文本后把
**原文(含姓名/手机/邮箱等 PII)直接交给 `llm_resume_fields`**
提取全部字段(
`LLM_RESUME_FIELDS`
:name/jobTitle/phone/email/birthDate/age/years/education/school/major/city/skills);仅当大模型不可用或返回全空(
`_has_llm_content`
)时
**整体回退**
`parse_resume_text`
纯正则。提交
`fc0aa5c`
移除了此前"本地脱敏给 LLM、PII 字段以正则结果为准"的双轨合并(
`_merge_parse_result`
已不存在),现在结构化字段
**直接采用 LLM 返回值**
(
`_compose_llm_result`
,schoolTags 按学校本地打标),靠 prompt「只提取明确出现、绝不编造、没有返回空」约束。
**注意取舍**
:解析路径现会把候选人 PII 明文发送给大模型提供方(代码注释已注明"如需脱敏请走 analyze_resume 链路");AI
**综合评估**
路径仍走
`sanitize_candidate_for_llm`
脱敏。此前修复的"换行粘连/专业误判/名字抓成工作经历"等仍在(对应单测
`test_resume_parser_llm.py`
随新链路更新)。
-
**需求 25:期望薪资展示**
:
🆕 无独立
`expectedSalary`
字段/展示(全库无此字段,候选人卡片/抽屉/新建表单均无薪资输入)。仅 ingest 入库时把
`raw.salary`
写进
`evaluation`
备注(
`期望薪资:{salary}`
),插件实际不传。建议候选人详情加"期望薪资"字段(人工可补,插件抓不到)
。
-
**需求 25:期望薪资展示**
:
✅
**已实现(2026-09-04)**
。候选人新增独立
`expectedSalary`
(期望薪资,自由文本如
`20-30K`
/
`15k`
/
`面议`
):① 简历解析(LLM 字段+本地正则)自动提取;② 采集入库
`raw.expectedSalary/salary`
落独立字段(不再写进 evaluation 备注);③ 新建表单/批量导入每行可手填;④ 抽屉「基础条件」可编辑;⑤ 简历库卡片、人才池列表/卡片/详情有值即展示。旧数据从入库备注「期望薪资:…」自动回填,零成本过渡
。
-
**需求 26:UI 风格**
:🔄 蓝色系多色 +
**已引入玻璃拟态元素**
:顶部栏(
`main.scss .top`
)已用半透明
`rgba(255,255,255,0.86)`
+
`backdrop-filter: blur(14px) saturate(1.2)`
毛玻璃(sticky 顶栏,提交
`ddc5340`
一并改入);卡片/抽屉主体仍为白底实心,与 HR 整体"玻璃拟态"偏好尚有差距。待 HR 提供风格截图后对齐。
-
**需求 26:UI 风格**
:🔄 蓝色系多色 +
**已引入玻璃拟态元素**
:顶部栏(
`main.scss .top`
)已用半透明
`rgba(255,255,255,0.86)`
+
`backdrop-filter: blur(14px) saturate(1.2)`
毛玻璃(sticky 顶栏,提交
`ddc5340`
一并改入);卡片/抽屉主体仍为白底实心,与 HR 整体"玻璃拟态"偏好尚有差距。待 HR 提供风格截图后对齐。
-
**需求 27:伯乐帮对照**
:未集成,也无相关代码。本系统做完整档案与评估;如需对照可后续评估对接(外部工具只做履历合格参考)。
-
**需求 27:伯乐帮对照**
:未集成,也无相关代码。本系统做完整档案与评估;如需对照可后续评估对接(外部工具只做履历合格参考)。
...
@@ -237,9 +239,9 @@ HR 原话要点:
...
@@ -237,9 +239,9 @@ HR 原话要点:
| P2 | 需求 4 岗位自动关闭 | ✅ 已实现(2026-09-03) | hired>=headcount 自动置「已招满」并记事件日志 |
| P2 | 需求 4 岗位自动关闭 | ✅ 已实现(2026-09-03) | hired>=headcount 自动置「已招满」并记事件日志 |
| P2 | 需求 9 粘贴 JD 自动结构化 + 需求模板 | ✅ 已实现(2026-09-04) | 粘贴解析(LLM/本地)+ 需求模板(含对内约束)闭环 |
| P2 | 需求 9 粘贴 JD 自动结构化 + 需求模板 | ✅ 已实现(2026-09-04) | 粘贴解析(LLM/本地)+ 需求模板(含对内约束)闭环 |
| P2 | 需求 22 简历库标签/统计/批量 | ✅ 已实现(2026-09-04) | 批量移入/打标签/批量认领 + file_hash 库级去重 |
| P2 | 需求 22 简历库标签/统计/批量 | ✅ 已实现(2026-09-04) | 批量移入/打标签/批量认领 + file_hash 库级去重 |
| P3 | 需求 23 插件字段修正 |
🔄 部分 | 补抓沟通职位/期望薪资
|
| P3 | 需求 23 插件字段修正 |
✅ 已补(2026-09-04, v1.3) | 补抓沟通职位/期望薪资;DOM 选择器待真站人工验收
|
| P3 | 需求 21 入职材料三件套 | 🔄 部分 | 三张模板化 |
| P3 | 需求 21 入职材料三件套 | 🔄 部分 | 三张模板化 |
| P3 | 需求 3 岗位类型枚举 / 需求 25 期望薪资展示 |
🆕 | 小改动,按需
做 |
| P3 | 需求 3 岗位类型枚举 / 需求 25 期望薪资展示 |
✅ 25 已实现(2026-09-04) | 3 待 HR 定义岗位类型枚举后再
做 |
| P3 | 需求 26 UI 玻璃拟态 | 🔄 部分 | 顶栏已毛玻璃,整体待 HR 截图后统一 |
| P3 | 需求 26 UI 玻璃拟态 | 🔄 部分 | 顶栏已毛玻璃,整体待 HR 截图后统一 |
**近期已补(2026-09,不再列入待办)**
:需求 10 候选人详情抽屉 + PDF 档案预览(
`ddc5340`
/
`07310dd`
/
`ec9499f`
);需求 24 简历解析改 LLM 优先、
`fc0aa5c`
移除解析链路脱敏(评估链路仍脱敏)。
**同日(工作区未提交)再补**
:需求 1「总库=人才池」(跨账号共享池 + 移入/认领)、需求 12 标签结构化与筛选、需求 20 人才池联动、需求 22 人才池独立视图——详见各需求条目与 docs/adr/0001-company-talent-pool.md。
**同日(工作区)续补**
:需求 17 风险点固定维度亦已实现(risk_profile.py 四维本地规则 + LLM prompt 限定 + 前端「风险点评估」面板),见需求 17 条目。
**近期已补(2026-09,不再列入待办)**
:需求 10 候选人详情抽屉 + PDF 档案预览(
`ddc5340`
/
`07310dd`
/
`ec9499f`
);需求 24 简历解析改 LLM 优先、
`fc0aa5c`
移除解析链路脱敏(评估链路仍脱敏)。
**同日(工作区未提交)再补**
:需求 1「总库=人才池」(跨账号共享池 + 移入/认领)、需求 12 标签结构化与筛选、需求 20 人才池联动、需求 22 人才池独立视图——详见各需求条目与 docs/adr/0001-company-talent-pool.md。
**同日(工作区)续补**
:需求 17 风险点固定维度亦已实现(risk_profile.py 四维本地规则 + LLM prompt 限定 + 前端「风险点评估」面板),见需求 17 条目。
...
@@ -256,4 +258,4 @@ HR 原话要点:
...
@@ -256,4 +258,4 @@ HR 原话要点:
---
---
*整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。需求 14/17(自述优势约束、风险点固定维度)、需求 4 自动招满、岗位维简历量统计已于 2026-09-03 落地;需求 9(粘贴 JD 自动结构化 + 招聘需求模板)
与需求 22 补全(批量移入/复活、file_hash 库级去重)已于 2026-09-04 落地;后续按需做需求 3/25 等小改动(岗位类型枚举、期望薪资展示)
。*
*整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。需求 14/17(自述优势约束、风险点固定维度)、需求 4 自动招满、岗位维简历量统计已于 2026-09-03 落地;需求 9(粘贴 JD 自动结构化 + 招聘需求模板)
、需求 22 补全(批量移入/复活、file_hash 库级去重)与需求 25/23(期望薪资独立字段+插件补抓沟通职位、插件 v1.3)均已于 2026-09-04 落地;后续按需做需求 3(岗位类型枚举,待 HR 定义)与需求 12 周报视图等小改动
。*
vue-app/src/components/resumes/CandidateBatchModal.vue
View file @
bde02970
...
@@ -70,6 +70,7 @@ const handleFiles = async (uploadFile) => {
...
@@ -70,6 +70,7 @@ const handleFiles = async (uploadFile) => {
jobId
:
store
.
candidateBatchMode
===
'job'
?
store
.
candidateBatchJobId
:
''
,
jobId
:
store
.
candidateBatchMode
===
'job'
?
store
.
candidateBatchJobId
:
''
,
recommendedJobId
:
''
,
recommendedJobId
:
''
,
suggestions
:
[],
suggestions
:
[],
expectedSalary
:
''
,
error
:
''
,
error
:
''
,
parseWarning
:
''
,
parseWarning
:
''
,
resumeFileDataUrl
:
''
,
resumeFileDataUrl
:
''
,
...
@@ -92,6 +93,7 @@ const handleFiles = async (uploadFile) => {
...
@@ -92,6 +93,7 @@ const handleFiles = async (uploadFile) => {
skills
:
parsed
.
skills
||
[],
skills
:
parsed
.
skills
||
[],
})
})
entry
.
parsed
=
parsed
entry
.
parsed
=
parsed
entry
.
expectedSalary
=
parsed
.
expectedSalary
||
entry
.
expectedSalary
||
''
entry
.
suggestions
=
suggestions
entry
.
suggestions
=
suggestions
entry
.
recommendedJobId
=
suggestions
[
0
]?.
jobId
||
''
entry
.
recommendedJobId
=
suggestions
[
0
]?.
jobId
||
''
entry
.
parseWarning
=
parsed
.
parseWarning
||
''
entry
.
parseWarning
=
parsed
.
parseWarning
||
''
...
@@ -208,6 +210,7 @@ const importBatch = async () => {
...
@@ -208,6 +210,7 @@ const importBatch = async () => {
: schoolTags(parsed.school || inferSchool(resumeText)),
: schoolTags(parsed.school || inferSchool(resumeText)),
city: parsed.city || '',
city: parsed.city || '',
skills: parsed.skills?.length ? parsed.skills : extractSkills(resumeText),
skills: parsed.skills?.length ? parsed.skills : extractSkills(resumeText),
expectedSalary: parsed.expectedSalary || entry.expectedSalary || '',
parseWarning: parsed.parseWarning || '',
parseWarning: parsed.parseWarning || '',
tags: ['新入库'],
tags: ['新入库'],
evaluation: '批量上传入库',
evaluation: '批量上传入库',
...
@@ -332,6 +335,16 @@ const importBatch = async () => {
...
@@ -332,6 +335,16 @@ const importBatch = async () => {
</el-select>
</el-select>
</
template
>
</
template
>
</el-table-column>
</el-table-column>
<el-table-column
label=
"期望薪资"
width=
"150"
>
<
template
#
default=
"{ row }"
>
<el-input
v-model=
"row.expectedSalary"
size=
"small"
:disabled=
"row.status !== 'ready'"
placeholder=
"如 20-30K(可手填)"
/>
</
template
>
</el-table-column>
<el-table-column
label=
"状态"
width=
"120"
>
<el-table-column
label=
"状态"
width=
"120"
>
<
template
#
default=
"{ row }"
>
<
template
#
default=
"{ row }"
>
<el-tag
size=
"small"
:type=
"entryStatusView(row).type"
>
{{
entryStatusView
(
row
).
text
}}
</el-tag>
<el-tag
size=
"small"
:type=
"entryStatusView(row).type"
>
{{
entryStatusView
(
row
).
text
}}
</el-tag>
...
...
vue-app/src/components/resumes/CandidateCreateModal.vue
View file @
bde02970
...
@@ -25,6 +25,7 @@ const form = reactive({
...
@@ -25,6 +25,7 @@ const form = reactive({
jobTitle
:
''
,
jobTitle
:
''
,
resumeText
:
''
,
resumeText
:
''
,
evaluation
:
''
,
evaluation
:
''
,
expectedSalary
:
''
,
})
})
const
resumeFile
=
ref
(
null
)
const
resumeFile
=
ref
(
null
)
...
@@ -52,6 +53,7 @@ const onFileChange = async (uploadFile) => {
...
@@ -52,6 +53,7 @@ const onFileChange = async (uploadFile) => {
try
{
try
{
const
parsed
=
await
store
.
parseResumeFile
(
file
)
const
parsed
=
await
store
.
parseResumeFile
(
file
)
if
(
parsed
.
name
)
form
.
name
=
cleanCandidateName
(
parsed
.
name
,
file
.
name
)
if
(
parsed
.
name
)
form
.
name
=
cleanCandidateName
(
parsed
.
name
,
file
.
name
)
if
(
parsed
.
expectedSalary
)
form
.
expectedSalary
=
parsed
.
expectedSalary
if
(
parsed
.
jobTitle
)
{
if
(
parsed
.
jobTitle
)
{
form
.
jobTitle
=
parsed
.
jobTitle
form
.
jobTitle
=
parsed
.
jobTitle
const
job
=
store
.
jobs
.
find
((
j
)
=>
j
.
title
===
parsed
.
jobTitle
)
const
job
=
store
.
jobs
.
find
((
j
)
=>
j
.
title
===
parsed
.
jobTitle
)
...
@@ -148,6 +150,7 @@ const submit = async () => {
...
@@ -148,6 +150,7 @@ const submit = async () => {
: schoolTags(parsedResume.school || inferSchool(resumeText)),
: schoolTags(parsedResume.school || inferSchool(resumeText)),
city: parsedResume.city || '',
city: parsedResume.city || '',
skills: parsedResume.skills?.length ? parsedResume.skills : extractSkills(resumeText),
skills: parsedResume.skills?.length ? parsedResume.skills : extractSkills(resumeText),
expectedSalary: parsedResume.expectedSalary || form.expectedSalary,
tags: ['新入库'],
tags: ['新入库'],
evaluation: form.evaluation,
evaluation: form.evaluation,
})
})
...
@@ -221,6 +224,7 @@ const submit = async () => {
...
@@ -221,6 +224,7 @@ const submit = async () => {
:value=
"s"
:value=
"s"
/>
/>
</el-select>
</el-select>
<el-input
v-model=
"form.expectedSalary"
placeholder=
"期望薪资(自动识别 / 手填,如 20-30K)"
/>
</div>
</div>
</section>
</section>
...
...
vue-app/src/components/resumes/ResumeDetail.vue
View file @
bde02970
...
@@ -560,6 +560,7 @@ const overviewSections = computed(() => {
...
@@ -560,6 +560,7 @@ const overviewSections = computed(() => {
{
label
:
'年龄'
,
value
:
val
(
candidateAge
(
c
)),
model
:
'age'
,
editable
:
true
},
{
label
:
'年龄'
,
value
:
val
(
candidateAge
(
c
)),
model
:
'age'
,
editable
:
true
},
{
label
:
'工作年限'
,
value
:
val
(
candidateYears
(
c
)),
model
:
'years'
,
editable
:
true
},
{
label
:
'工作年限'
,
value
:
val
(
candidateYears
(
c
)),
model
:
'years'
,
editable
:
true
},
{
label
:
'学历'
,
value
:
val
(
c
.
education
),
model
:
'education'
,
editable
:
true
},
{
label
:
'学历'
,
value
:
val
(
c
.
education
),
model
:
'education'
,
editable
:
true
},
{
label
:
'期望薪资'
,
value
:
val
(
c
.
expectedSalary
),
model
:
'expectedSalary'
,
editable
:
true
},
],
],
},
},
{
{
...
@@ -596,6 +597,7 @@ const enterEditMode = async () => {
...
@@ -596,6 +597,7 @@ const enterEditMode = async () => {
education
:
c
.
education
||
''
,
education
:
c
.
education
||
''
,
school
:
c
.
school
||
''
,
school
:
c
.
school
||
''
,
major
:
c
.
major
||
''
,
major
:
c
.
major
||
''
,
expectedSalary
:
c
.
expectedSalary
||
''
,
schoolTags
:
(
c
.
schoolTags
||
[]).
join
(
'、'
),
schoolTags
:
(
c
.
schoolTags
||
[]).
join
(
'、'
),
skills
:
(
c
.
skills
||
[]).
join
(
'、'
),
skills
:
(
c
.
skills
||
[]).
join
(
'、'
),
tags
:
[...(
c
.
tags
||
[])],
tags
:
[...(
c
.
tags
||
[])],
...
@@ -621,6 +623,7 @@ const profileCorrection = reactive({
...
@@ -621,6 +623,7 @@ const profileCorrection = reactive({
education
:
''
,
education
:
''
,
school
:
''
,
school
:
''
,
major
:
''
,
major
:
''
,
expectedSalary
:
''
,
schoolTags
:
''
,
schoolTags
:
''
,
skills
:
''
,
skills
:
''
,
tags
:
[],
tags
:
[],
...
@@ -638,6 +641,7 @@ const saveCorrection = (candidate) => {
...
@@ -638,6 +641,7 @@ const saveCorrection = (candidate) => {
education
:
profileCorrection
.
education
.
trim
(),
education
:
profileCorrection
.
education
.
trim
(),
school
,
school
,
major
:
profileCorrection
.
major
.
trim
(),
major
:
profileCorrection
.
major
.
trim
(),
expectedSalary
:
profileCorrection
.
expectedSalary
.
trim
(),
schoolTags
:
manualSchoolTags
.
length
?
manualSchoolTags
:
candidate
.
schoolTags
||
[],
schoolTags
:
manualSchoolTags
.
length
?
manualSchoolTags
:
candidate
.
schoolTags
||
[],
skills
:
profileCorrection
.
skills
.
split
(
/
[
、,,
\s]
+/
).
filter
(
Boolean
),
skills
:
profileCorrection
.
skills
.
split
(
/
[
、,,
\s]
+/
).
filter
(
Boolean
),
tags
:
[...
new
Set
((
profileCorrection
.
tags
||
[]).
map
((
item
)
=>
String
(
item
).
trim
()).
filter
(
Boolean
))],
tags
:
[...
new
Set
((
profileCorrection
.
tags
||
[]).
map
((
item
)
=>
String
(
item
).
trim
()).
filter
(
Boolean
))],
...
...
vue-app/src/components/resumes/ResumeList.vue
View file @
bde02970
...
@@ -5,6 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
...
@@ -5,6 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
import
{
ElMessageBox
}
from
'element-plus'
import
{
ElMessageBox
}
from
'element-plus'
import
{
useRecruitmentStore
}
from
'@/stores/recruitment'
import
{
useRecruitmentStore
}
from
'@/stores/recruitment'
import
{
expectedSalaryText
}
from
'@/utils/candidate'
import
{
CANDIDATE_TAG_OPTIONS
,
candidateTagTone
}
from
'@/utils/constants'
import
{
CANDIDATE_TAG_OPTIONS
,
candidateTagTone
}
from
'@/utils/constants'
import
{
jobCandidates
}
from
'@/utils/links'
import
{
jobCandidates
}
from
'@/utils/links'
import
{
getFilteredCandidates
,
quickMatchNeedsRefresh
}
from
'@/utils/matching'
import
{
getFilteredCandidates
,
quickMatchNeedsRefresh
}
from
'@/utils/matching'
...
@@ -478,6 +479,9 @@ const openBatch = () => {
...
@@ -478,6 +479,9 @@ const openBatch = () => {
<
/div
>
<
/div
>
<
div
class
=
"candidate-contact"
>
<
div
class
=
"candidate-contact"
>
{{
candidate
.
phone
||
candidate
.
email
||
'联系方式待补全'
}}
{{
candidate
.
phone
||
candidate
.
email
||
'联系方式待补全'
}}
<
span
v
-
if
=
"expectedSalaryText(candidate)"
class
=
"salary-chip"
>
{{
expectedSalaryText
(
candidate
)
}}
<
/span
>
<
span
v
-
if
=
"candidate.source && candidate.source !== '其他'"
class
=
"source-chip"
>
{{
<
span
v
-
if
=
"candidate.source && candidate.source !== '其他'"
class
=
"source-chip"
>
{{
candidate
.
source
candidate
.
source
}}
<
/span
>
}}
<
/span
>
...
@@ -918,6 +922,19 @@ const openBatch = () => {
...
@@ -918,6 +922,19 @@ const openBatch = () => {
white
-
space
:
nowrap
;
white
-
space
:
nowrap
;
}
}
.
salary
-
chip
{
display
:
inline
-
flex
;
align
-
items
:
center
;
margin
-
left
:
6
px
;
padding
:
0
6
px
;
border
:
1
px
solid
color
-
mix
(
in
srgb
,
var
(
--
blue
)
45
%
,
#
fff
);
border
-
radius
:
6
px
;
background
:
color
-
mix
(
in
srgb
,
var
(
--
blue
)
10
%
,
#
fff
);
color
:
var
(
--
blue
);
font
-
size
:
10
px
;
font
-
weight
:
600
;
}
.
source
-
chip
{
.
source
-
chip
{
display
:
inline
-
flex
;
display
:
inline
-
flex
;
align
-
items
:
center
;
align
-
items
:
center
;
...
...
vue-app/src/utils/candidate.js
View file @
bde02970
...
@@ -8,6 +8,7 @@ import {
...
@@ -8,6 +8,7 @@ import {
inferBirthDate
,
inferBirthDate
,
inferEducation
,
inferEducation
,
inferEmail
,
inferEmail
,
inferExpectedSalary
,
inferMajor
,
inferMajor
,
inferPhone
,
inferPhone
,
inferSchool
,
inferSchool
,
...
@@ -41,6 +42,9 @@ export function enrichCandidateFields(candidate = {}) {
...
@@ -41,6 +42,9 @@ export function enrichCandidateFields(candidate = {}) {
education
:
hasProfileValue
(
candidate
.
education
)
?
candidate
.
education
:
inferEducation
(
resumeText
),
education
:
hasProfileValue
(
candidate
.
education
)
?
candidate
.
education
:
inferEducation
(
resumeText
),
phone
:
hasProfileValue
(
candidate
.
phone
)
?
candidate
.
phone
:
inferPhone
(
resumeText
),
phone
:
hasProfileValue
(
candidate
.
phone
)
?
candidate
.
phone
:
inferPhone
(
resumeText
),
email
:
hasProfileValue
(
candidate
.
email
)
?
candidate
.
email
:
inferEmail
(
resumeText
),
email
:
hasProfileValue
(
candidate
.
email
)
?
candidate
.
email
:
inferEmail
(
resumeText
),
expectedSalary
:
hasProfileValue
(
candidate
.
expectedSalary
)
?
candidate
.
expectedSalary
:
inferExpectedSalary
(
resumeText
)
||
inferExpectedSalary
(
candidate
.
evaluation
||
''
),
school
,
school
,
major
:
hasProfileValue
(
candidate
.
major
)
?
candidate
.
major
:
inferMajor
(
resumeText
),
major
:
hasProfileValue
(
candidate
.
major
)
?
candidate
.
major
:
inferMajor
(
resumeText
),
schoolTags
:
hasProfileValue
(
candidate
.
schoolTags
)
?
candidate
.
schoolTags
:
inferredTags
,
schoolTags
:
hasProfileValue
(
candidate
.
schoolTags
)
?
candidate
.
schoolTags
:
inferredTags
,
...
@@ -71,4 +75,9 @@ export function reparseCandidateProfile(candidate = {}) {
...
@@ -71,4 +75,9 @@ export function reparseCandidateProfile(candidate = {}) {
return
fields
return
fields
}
}
export
function
expectedSalaryText
(
candidate
=
{})
{
const
value
=
candidate
.
expectedSalary
return
hasProfileValue
(
value
)
?
`期望
${
value
}
`
:
''
}
export
{
candidateAge
,
candidateYears
,
cleanCandidateName
,
normalizeSchoolTagsInput
,
resumeDisplayName
,
schoolTags
}
export
{
candidateAge
,
candidateYears
,
cleanCandidateName
,
normalizeSchoolTagsInput
,
resumeDisplayName
,
schoolTags
}
vue-app/src/utils/inference.js
View file @
bde02970
...
@@ -245,6 +245,14 @@ export function extractSkills(text = '') {
...
@@ -245,6 +245,14 @@ export function extractSkills(text = '') {
return
SKILL_DICTIONARY
.
filter
((
skill
)
=>
lower
.
includes
(
skill
.
toLowerCase
()))
return
SKILL_DICTIONARY
.
filter
((
skill
)
=>
lower
.
includes
(
skill
.
toLowerCase
()))
}
}
export
function
inferExpectedSalary
(
text
=
''
)
{
const
value
=
compactChineseText
(
text
)
const
match
=
value
.
match
(
/
(?:
期望薪资|期望薪酬|期望月薪|期望待遇|期望薪金|目标薪资
)[
::
\s]
*
([^\n
,,。;;||
]{1,24})
|
(?:
薪资期望|薪酬期望
)[
::
\s]
*
([^\n
,,。;;||
]{1,24})
/i
)
return
(
match
?.[
1
]
||
match
?.[
2
]
||
''
).
trim
()
}
export
function
educationProfile
(
candidate
=
{})
{
export
function
educationProfile
(
candidate
=
{})
{
return
[
candidate
.
school
,
candidate
.
education
,
candidate
.
major
,
...(
candidate
.
schoolTags
||
[])]
return
[
candidate
.
school
,
candidate
.
education
,
candidate
.
major
,
...(
candidate
.
schoolTags
||
[])]
.
filter
(
Boolean
)
.
filter
(
Boolean
)
...
...
vue-app/src/utils/normalize.js
View file @
bde02970
...
@@ -225,6 +225,7 @@ export function normalizeCandidate(candidate = {}, jobs = []) {
...
@@ -225,6 +225,7 @@ export function normalizeCandidate(candidate = {}, jobs = []) {
name
:
cleanCandidateName
(
candidate
.
name
,
candidate
.
resumeMeta
?.
name
||
candidate
.
resumeName
),
name
:
cleanCandidateName
(
candidate
.
name
,
candidate
.
resumeMeta
?.
name
||
candidate
.
resumeName
),
resumeName
:
candidate
.
resumeMeta
?.
name
||
candidate
.
resumeName
,
resumeName
:
candidate
.
resumeMeta
?.
name
||
candidate
.
resumeName
,
tags
:
normalizeCandidateTags
(
candidate
),
tags
:
normalizeCandidateTags
(
candidate
),
expectedSalary
:
enriched
.
expectedSalary
,
evaluation
:
/本地匹配度为|当前只有简历文件名|已匹配到/
.
test
(
candidate
.
evaluation
||
''
)
?
''
:
candidate
.
evaluation
,
evaluation
:
/本地匹配度为|当前只有简历文件名|已匹配到/
.
test
(
candidate
.
evaluation
||
''
)
?
''
:
candidate
.
evaluation
,
}
}
}
}
vue-app/src/views/PoolView.vue
View file @
bde02970
...
@@ -5,7 +5,7 @@ import { ElMessageBox } from 'element-plus'
...
@@ -5,7 +5,7 @@ import { ElMessageBox } from 'element-plus'
import
{
useRecruitmentStore
}
from
'@/stores/recruitment'
import
{
useRecruitmentStore
}
from
'@/stores/recruitment'
import
{
CANDIDATE_TAG_OPTIONS
,
FOLLOW_UP_TAGS
,
normalizeCandidateTags
}
from
'@/utils/constants'
import
{
CANDIDATE_TAG_OPTIONS
,
FOLLOW_UP_TAGS
,
normalizeCandidateTags
}
from
'@/utils/constants'
import
{
enrichCandidateFields
,
candidateAge
,
candidateYears
}
from
'@/utils/candidate'
import
{
enrichCandidateFields
,
expectedSalaryText
,
candidateAge
,
candidateYears
}
from
'@/utils/candidate'
import
{
showToast
}
from
'@/utils/toast'
import
{
showToast
}
from
'@/utils/toast'
const
store
=
useRecruitmentStore
()
const
store
=
useRecruitmentStore
()
...
@@ -530,6 +530,9 @@ onMounted(() => {
...
@@ -530,6 +530,9 @@ onMounted(() => {
<
span
class
=
"person-copy"
>
<
span
class
=
"person-copy"
>
<
strong
class
=
"person-name"
>
{{
candidate
.
name
||
'未命名候选人'
}}
<
/strong
>
<
strong
class
=
"person-name"
>
{{
candidate
.
name
||
'未命名候选人'
}}
<
/strong
>
<
span
class
=
"person-role"
>
{{
roleLine
(
candidate
)
}}
<
/span
>
<
span
class
=
"person-role"
>
{{
roleLine
(
candidate
)
}}
<
/span
>
<
span
v
-
if
=
"expectedSalaryText(candidate)"
class
=
"person-salary"
>
{{
expectedSalaryText
(
candidate
)
}}
<
/span
>
<
/span
>
<
/span
>
<
/div
>
<
/div
>
<
div
<
div
...
@@ -589,6 +592,9 @@ onMounted(() => {
...
@@ -589,6 +592,9 @@ onMounted(() => {
<
span
class
=
"pc-head-copy"
>
<
span
class
=
"pc-head-copy"
>
<
strong
>
{{
candidate
.
name
||
'未命名候选人'
}}
<
/strong
>
<
strong
>
{{
candidate
.
name
||
'未命名候选人'
}}
<
/strong
>
<
span
class
=
"pc-role"
>
{{
roleLine
(
candidate
)
}}
<
/span
>
<
span
class
=
"pc-role"
>
{{
roleLine
(
candidate
)
}}
<
/span
>
<
span
v
-
if
=
"expectedSalaryText(candidate)"
class
=
"pc-salary"
>
{{
expectedSalaryText
(
candidate
)
}}
<
/span
>
<
/span
>
<
/span
>
<
/div
>
<
/div
>
<
div
class
=
"pc-tags"
>
<
div
class
=
"pc-tags"
>
...
@@ -676,6 +682,9 @@ onMounted(() => {
...
@@ -676,6 +682,9 @@ onMounted(() => {
<
div
class
=
"detail-item"
>
<
div
class
=
"detail-item"
>
<
span
>
工作年限
<
/span><b>{{ candidateYears
(
selected
)
|| '—'
}}
</
b
>
<
span
>
工作年限
<
/span><b>{{ candidateYears
(
selected
)
|| '—'
}}
</
b
>
<
/div
>
<
/div
>
<
div
class
=
"detail-item"
>
<
span
>
期望薪资
<
/span><b>{{ selected.expectedSalary || '—'
}}
</
b
>
<
/div
>
<
div
class
=
"detail-item"
>
<
div
class
=
"detail-item"
>
<
span
>
学历
<
/span><b>{{ selected.education || '—'
}}
</
b
>
<
span
>
学历
<
/span><b>{{ selected.education || '—'
}}
</
b
>
<
/div
>
<
/div
>
...
@@ -1212,6 +1221,12 @@ onMounted(() => {
...
@@ -1212,6 +1221,12 @@ onMounted(() => {
text
-
overflow
:
ellipsis
;
text
-
overflow
:
ellipsis
;
white
-
space
:
nowrap
;
white
-
space
:
nowrap
;
}
}
.
person
-
salary
{
font
-
size
:
11
px
;
color
:
#
4569
c9
;
font
-
weight
:
600
;
}
}
}
.
lr
-
profile
{
.
lr
-
profile
{
...
@@ -1350,6 +1365,12 @@ onMounted(() => {
...
@@ -1350,6 +1365,12 @@ onMounted(() => {
text
-
overflow
:
ellipsis
;
text
-
overflow
:
ellipsis
;
white
-
space
:
nowrap
;
white
-
space
:
nowrap
;
}
}
.
pc
-
salary
{
font
-
size
:
11
px
;
color
:
#
4569
c9
;
font
-
weight
:
600
;
}
}
}
}
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment