Commit bde02970 authored by 李文光's avatar 李文光

feat: 需求25/23/11 期望薪资全链路 + 插件补抓沟通职位——expectedSalary 独立字段(解析/采集/回填/展示/手填), 采集插件 v1.3

parent 9f16374c
...@@ -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` 兜底,不覆盖已有值。
## 人才池 / 总库 ## 人才池 / 总库
......
...@@ -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,
} }
......
...@@ -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'
......
...@@ -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")),
......
...@@ -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,
......
...@@ -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"] == "高级产品经理"
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教育经历\n2017.09—2021.07 甘肃农业大学\n农林经济管理" text = "张晓燕\n本科\n细心从每一个小细节开始。\n教育经历\n2017.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元/月"
...@@ -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"] == "销售支持专员"
...@@ -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/猎聘候选人页重新采集一次,旧页面会带着新字段入库。
...@@ -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 && /^[\u4e00-\u9fa5A-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);
......
{ {
"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"],
......
...@@ -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 周报视图等小改动。*
...@@ -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>
......
...@@ -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>
......
...@@ -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))],
......
...@@ -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: 6px;
padding: 0 6px;
border: 1px solid color-mix(in srgb, var(--blue) 45%, #fff);
border-radius: 6px;
background: color-mix(in srgb, var(--blue) 10%, #fff);
color: var(--blue);
font-size: 10px;
font-weight: 600;
}
.source-chip { .source-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
......
...@@ -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 }
...@@ -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)
......
...@@ -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,
} }
} }
...@@ -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: 11px;
color: #4569c9;
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: 11px;
color: #4569c9;
font-weight: 600;
}
} }
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment