Commit 1249554d authored by 李文光's avatar 李文光

feat: 需求14 自述优势约束、需求4/22 岗位联动与简历量统计、看板图表重构

- 需求14: 评估/解析 prompt 加入「候选人自述优势不作匹配依据、只作面试待验证线索」铁律, 同步 CONTEXT.md 术语与 test_ai.py 单测
- 需求4: 确认入职后 hired>=headcount 自动置「已招满」并记 job_filled 事件, 待办关注/徽标/动作卡口径同步
- 需求22: 简历库顶部「各岗位简历量」统计 chips, 点击按岗位聚焦筛选候选人
- 看板: 修复 dashboardScope 漏返回 filters 导致的 TypeError; 漏斗改按「已达阶段累计」梯形展示(与 KPI 口径一致); 来源环图改数据驱动(扇区=真实占比); 日历跟随筛选月份; 趋势/ROI 明细与转化展示优化
parent f3f85b35
......@@ -22,6 +22,11 @@
- **认领 / 复活(Claim / Resurrect)**:把人才池中的候选人迁入本人工作区,重新开始招聘流程。
- **入池备注 / 入池时间 / 放入人**:记录候选人进入人才池的原因、时间与操作账号,便于复用与溯源。
## 评估与匹配
- **自述优势(Self-Claimed Strengths / 自我评价)**:候选人在简历中对自己能力、性格、经历的主观自述(如「自我评价」「核心竞争力」「个人优势」段落)。规则:不作为 AI 匹配 / 亮点 / 证据依据,只能作为面试待验证线索。
- **简历事实(Resume Facts)**:可从简历客观内容核实或引用的匹配依据——工作 / 项目 / 实习经历的职责、产出与结果数据、教育背景、证书与明确列出的技能。AI 评估只以此类内容作为匹配与亮点证据。
## 流程与联动
- **事件日志(Event Log)**:每个关键动作留下的操作记录,用于时间线与追溯。
......
......@@ -8,6 +8,19 @@ from langchain_openai import ChatOpenAI
from backend.app.config import get_settings
from backend.app.services.qwenpaw_client import qwenpaw_jd_draft
# 需求 14:AI 评估铁律——候选人自述优势/主观表述不作为匹配依据(只作面试待验证线索)。
# 详见 docs/requirements_from_interview.md 需求 14。
_SELF_CLAIM_RULE = (
"评估铁律(必须遵守):\n"
"1. 候选人在简历「自我评价 / 核心竞争力 / 个人优势 / 主观描述」中自述的内容"
"(如“抗压能力强”“沟通能力突出”“责任心强”“热爱学习”等主观表述)不作为匹配依据,"
"不得用于 score、strengths、hardMatch、experienceMatch、projectEvidence 的正面判定;\n"
"2. 匹配与亮点依据只能来自简历中的客观事实:工作/项目/实习经历的职责、产出与可验证成果数字、"
"教育背景、证书及明确列出的技能;\n"
"3. 候选人自述的软性优点最多作为「面试待验证线索」写进 interviewQuestions 请 HR 核实,"
"严禁写成已验证的优势或证据。"
)
def split_rule_lines(value: str | list[str] | None) -> list[str]:
if isinstance(value, list):
......@@ -263,8 +276,11 @@ async def llm_resume_fields(text: str) -> dict[str, Any] | None:
'- major: 专业名称(如"农林经济管理")。主修课程列表里的课程名不是专业,应取"专业"字段或紧邻学校/时间行后的'
'独立专业名;没有明确专业返回 ""。\n'
'- city: 现居城市或简历中的城市名(如"太原");没有则返回 ""。\n'
"- skills: 技能数组,最多 12 项,只取简历中明确提到的技能。\n\n"
"要求:只提取简历中明确出现的信息,绝不编造或猜测。\n\n"
"- skills: 技能数组,最多 12 项,只取简历中明确提到的硬技能/工具/语言/证书名称"
"(如 Java、SQL、Power BI、Python);「自我评价 / 个人优势」段落里的主观品质词"
"(如“沟通能力强”“抗压性好”“学习能力强”)不属于技能,不要收录。\n\n"
"要求:只提取简历中明确出现的信息,绝不编造或猜测;"
"自我评价 / 个人优势段落的主观表述仅作参考,不得用来推断求职岗位、工作年限、学历等硬字段。\n\n"
f"简历文本:\n{(text or '')[:16000]}"
)
try:
......@@ -290,6 +306,7 @@ async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None:
user = (
"请分析候选人与岗位的匹配度,并返回 JSON。字段必须为:score, decision, summary, hardMatch, "
"experienceMatch, projectEvidence, competencyFrame, risks, interviewQuestions, nextAction, scoreBreakdown。\n\n"
f"{_SELF_CLAIM_RULE}\n\n"
f"岗位:{json.dumps(payload.get('job') or {}, ensure_ascii=False, indent=2)}\n\n"
f"岗位结构化规则:{json.dumps(criteria, ensure_ascii=False, indent=2)}\n\n"
f"候选人脱敏结构:{json.dumps({**safe_candidate, 'resumeText': None}, ensure_ascii=False, indent=2)}\n\n"
......@@ -297,7 +314,7 @@ async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None:
)
parsed = await _chat_json(
settings,
"你是企业招聘系统中的资深HR初筛分析助手。只返回JSON,不要输出Markdown。",
"你是企业招聘系统中的资深HR初筛分析助手。只返回JSON,不要输出Markdown。评估必须以岗位硬性指标与简历客观事实为依据,候选人的自我评价/自述优势不作为匹配依据。",
user,
temperature=0,
)
......
import asyncio
import json
import pytest
def test_ai_fallback_routes(client, monkeypatch):
monkeypatch.setenv("LLM_API_KEY", "")
......@@ -208,3 +211,89 @@ def test_grill_never_overwrites_prefilled_fields(client):
assert "responsibilities" not in asked_ids
assert "salary_location" not in asked_ids
assert "tech_stack" not in asked_ids
@pytest.fixture()
def llm_enabled(monkeypatch):
"""隔离环境 + 配置假 LLM Key:让 llm_* 直连函数走到 _chat_json(本测试中会被打桩)。"""
monkeypatch.setenv("RECRUITMENT_SKIP_ENV_FILES", "1")
monkeypatch.setenv("LLM_API_KEY", "test-key")
monkeypatch.setenv("LLM_MODEL", "test-model")
monkeypatch.setenv("LLM_BASE_URL", "http://llm.test/v1")
import backend.app.config as config
config.get_settings.cache_clear()
yield
config.get_settings.cache_clear()
def test_analysis_prompt_bans_self_claimed_strengths(llm_enabled, monkeypatch):
"""需求14:评估链路 prompt 必须声明「候选人自述优势不作为匹配依据、只作面试待验证线索」。"""
captured = {}
async def fake_chat_json(settings, system, user, *, temperature=0):
captured["system"] = system
captured["user"] = user
return {
"score": 80,
"decision": "建议优先推进面试",
"summary": "测试摘要",
"hardMatch": [],
"experienceMatch": [],
"projectEvidence": [],
"competencyFrame": [],
"risks": [],
"interviewQuestions": [],
"nextAction": "建议推进面试",
"scoreBreakdown": {"finalScore": 80},
}
monkeypatch.setattr("backend.app.services.llm._chat_json", fake_chat_json)
from backend.app.services.llm import llm_resume_analysis
result = asyncio.run(
llm_resume_analysis(
{
"candidate": {"name": "王五", "resumeText": "自我评价:抗压能力强、沟通能力突出,热爱学习。"},
"job": {"title": "大客户销售", "mustHave": "大客户", "matchKeywords": "沟通"},
}
)
)
assert result["provider"].startswith("llm:")
# 评估铁律进入 user prompt:自述不是匹配依据,只能进 interviewQuestions 待验证
assert "不作为匹配依据" in captured["user"]
assert "自我评价" in captured["user"]
assert "interviewQuestions" in captured["user"]
# system prompt 同步声明同一原则
assert "不作为匹配依据" in captured["system"]
def test_fields_prompt_excludes_subjective_traits_from_skills(llm_enabled, monkeypatch):
"""需求14(解析链路):skills 只收硬技能,不收自我评价里的主观品质词;硬字段不被自述带偏。"""
captured = {}
async def fake_chat_json(settings, system, user, *, temperature=0):
captured["user"] = user
return {
"name": "王五",
"jobTitle": "",
"phone": "",
"email": "",
"birthDate": "",
"age": "",
"years": "",
"education": "",
"school": "",
"major": "",
"city": "",
"skills": ["Java", "SQL"],
}
monkeypatch.setattr("backend.app.services.llm._chat_json", fake_chat_json)
from backend.app.services.llm import llm_resume_fields
result = asyncio.run(llm_resume_fields("自我评价:沟通能力强,抗压性好。\n技能:Java、SQL"))
assert result["skills"] == ["Java", "SQL"]
# 解析 prompt 声明:主观品质词不属于 skills、自述不得推断硬字段
assert "主观品质" in captured["user"]
assert "自我评价 / 个人优势段落的主观表述仅作参考" in captured["user"]
......@@ -4,6 +4,8 @@
>
> **最近一次对照代码核实:2026-09-02(main @ ec9499f,工作区另含同日未提交的「总库/人才池 + 候选人标签」实现)**。核实结论以代码为准,标注"已实现"的需求均可在源码中找到对应实现。
>
> **2026-09-03 增量(基于 main @ f3f85b3)**:需求 14「AI 不得引用候选人自述优势」已实现——评估 prompt 加入「评估铁律」(自述不作为匹配依据、只作面试待验证线索),解析 prompt 同步约束主观品质词不进 skills;详见需求 14 条目。同日,需求 4(招聘人数联动自动关闭)与需求 22(岗位维简历量统计)亦已一并实现,见对应条目。
>
> 本次复核相对上一版(810e280)新增提交:`ddc5340`(候选人详情抽屉+档案编辑)、`07310dd`(简历库重构+保存原简历+防重复)、`6248f27`/`fc0aa5c`(简历解析真正 LLM 优先并移除脱敏)、`ec9499f`(弹窗蒙层/ESC 不关闭)。
>
> 依据代码现状核实:
......@@ -26,7 +28,7 @@ HR 原话要点:
**结论**:延续现有系统架构(整包状态 + 前后端分离),按模块迭代。
**核实情况(2026-09,最新)**:「下推联动」痛点已基本解决——候选人和岗位的每个状态动作都会同时写事件日志、增删待办(见需求 20);企微智能体产出的 JD 可经 `/api/jd/sync-external` 自动落为岗位并置为「待确认」状态(见需求 2)。**候选人详情已于近期改为「右侧抽屉 + 可收缩 PDF 档案预览」**(提交 `ddc5340`/`07310dd`,见需求 10),HR「弹窗/页面级收敛、简历可收缩预览」的交互诉求已落地。**候选人标记/人才池体系已大部落地(需求 1 总库=人才池 + 需求 12 标签筛选 + 需求 22 人才池独立视图,2026-09-02 工作区补充)**,余下缺口为 AI 评估规则化的自述优势约束(需求 14)与风险固定维度(需求 17)、岗位维简历量统计(需求 22);另简历解析自 `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)、批量移入/复活(需求 22);岗位维简历量统计已于 2026-09-03 落地;另简历解析自 `fc0aa5c` 起改为**原文直传大模型提取字段(不再本地脱敏)**,采集插件仍不抓「沟通职位/期望薪资」。
---
......@@ -49,9 +51,9 @@ HR 原话要点:
**需求 4:招聘人数联动自动关闭**
- 原话:「目标招 2 个,已录用 2 个,数值相等就自动已关闭;招 1 个就够也能手动关闭」(P2)
- 现状:🔄 `headcount`/`hired` 字段已有,且 `hired`**自动统计**:`store.reconcileRecruitmentLinks` 按"岗位下已入职候选人"实时回写 `job.hired`;`jobProgressSteps` 有"招聘完成"步骤(`hired>=headcount` 即 done)、`jobActionFor` 在缺口为 0 时提示「招聘目标已完成」;状态枚举含「已招满」「已关闭」。**但没有任何代码在 `hired>=headcount` 时自动把 `job.status` 置为「已关闭」**——目前仍需手动改状态
- 建议(🆕):规则触发:`hired >= headcount && headcount > 0` 时自动置 `status=已招满/已关闭`(可在 reconcile 里做,顺带写事件日志),同时保留手动关闭
- 现状:**已实现(2026-09-03)**`store.reconcileRecruitmentLinks` 在回写 `job.hired` 后执行规则:当岗位处于「招聘中」且 `hired >= headcount > 0` 时,自动把 `job.status` 置为「已招满」(`job.autoFilledAt` 记时间),并向事件日志写 `job_filled`(招聘目标已达成);「确认入职」动作(offer 已入职)现在会先 `reconcile` 再保存,最后一人入职即触发自动招满
- 联动:`jobNeedsAttention`/`jobActionFor`/`jobStateBadge` 已把「已招满」作为终态处理(不再提示需处理、主按钮显示「岗位已招满·查看状态」、徽章 success 色)。手动关闭/暂停/重开仍保留:HR 可在编辑页选「已关闭」;若把已招满岗位手动重开为「招聘中」而未上调 `headcount`,下一次 reconcile 会再次自动置回「已招满」(需先上调招聘人数)
- 测试:前端无自动化用例,按构建验证(`npm run build` 通过);规则逻辑集中在 `reconcileRecruitmentLinks`,加载/创建/删除/移池/入职等所有会触发 reconcile 的路径统一生效。
**需求 5:JD 状态 + 版本历史自动迭代**
- 原话:「JD 状态保留(已生效等);版本号自动识别,确认生效后自动变 V2;改一个字也算小改也要记版本;可查历史版本(以前 JD 什么样、什么时候发布的)」(P2/P9)
- 现状:✅ 完整实现:`jdStatus/jdVersion/jdHistory` 三个字段 + `jd_versions` 表。版本自动递增:`store.nextJdVersion` 扫描当前版本与历史的最大 vN +1;"保存为新 JD 版本"/"迭代 JD"自动生成 vN 待确认;`approveJd` 确认生效时把版本后缀改为「当前生效」。每次生成/编辑/确认都会 `appendJdHistory` 记录版本、日期、发布人、备注,JobDetail 与 JobEdit 均可查看历史并可「复制为新草稿」。小改大改都记版本(编辑保存即记)。
......@@ -127,8 +129,10 @@ HR 原话要点:
**需求 14:AI 不得引用候选人自述优势**
- 原话:「不能参考候选人自己写的核心竞争力;简历里大家都写自己优势,要加约束规则」(P16)
- 建议(🆕):**未实现**。复核 `llm_resume_analysis`(评估)与 `llm_resume_fields`(解析)的 system/user prompt,均未显式声明"候选人自述优势/核心竞争力/自我评价 不作为匹配依据";脱敏简历正文 `resumeText`(含自我评价段落)原样进 LLM,存在被"核心竞争力"段落带偏的风险。需在两条链路的 prompt 中加入硬规则(如"候选人自述优势/主观表述不作为匹配依据,只作为面试待验证线索"),本地规则天然不读自述,但评估侧要防 LLM 被带偏。
- 建议实施点(一行即可生效):`llm_resume_analysis``llm_resume_fields` 的 system prompt 追加该约束,评估权重以岗位硬性指标 + 简历事实(项目/经历数据)为准。
- 现状:✅ **已实现(2026-09-03)**`llm_resume_analysis`(评估)的 system/user prompt 已加入「评估铁律」:候选人自述优势/核心竞争力/自我评价/主观表述不作为匹配依据;匹配与亮点只依据简历客观事实(工作/项目/实习经历的职责、产出与结果数据、教育背景、证书与明确技能);候选人自述的软性优点最多作为「面试待验证线索」写进 `interviewQuestions`,严禁写成已验证优势/证据。
- 同步约束:`llm_resume_fields`(解析)prompt 明确 skills 只收硬技能/工具/语言/证书名称,不收"沟通能力强""抗压性好"等主观品质词;自我评价/个人优势段落的主观表述仅作参考,不得用来推断求职岗位/工作年限/学历等硬字段。
- 测试:`test_ai.py` 新增 `test_analysis_prompt_bans_self_claimed_strengths``test_fields_prompt_excludes_subjective_traits_from_skills` 两条 prompt 断言(全量 55 passed)。
- 说明:本地关键词规则仍按整篇简历命中出证据、不读自述语义,保持不变;前端 `analysis` 按候选人+岗位 `inputKey` 复用缓存,已生成的旧报告不会自动重生成——需按新规则重评的候选人在详情抽屉改输入后重新生成(「按新规则重评」按钮可作后续增强)。
**需求 15:匹配分析 = 结合对内 JD 找"哪里匹配"**
- 原话:「匹配分析结合对内版 JD 分析,给我找人做参考(如找 SAP/金蝶/用友做 ERP 大客户销售的人)」(P16)
......@@ -183,8 +187,8 @@ HR 原话要点:
**需求 22:简历库 = 简历存储 + 标签 + 统计**
- 原话:「简历库涵盖所有简历,几十上百份可打标签;要看总量、通过面试人数、各岗位简历量(如 java 岗多少份、人力岗多少份);面完没过的放人才池」(P19)
- 现状:🔄 近期重构(提交 `07310dd`):简历库为**卡片网格列表**(头像/姓名/阶段/来源/快筛与 AI 分/求职岗位下拉/「查看档案·快速匹配·更多」操作),顶部 4 张卡(简历总数 / 高匹配=快筛≥85 / 待 AI 评估 / 待补全),筛选为 关键词/阶段/来源/匹配档位——**仍无标签筛选**;漏斗/来源/趋势/ROI 看板在**独立 `/dashboard` 路由**(可按岗位范围过滤),**仍无「各岗位简历量」统计****新增「保存原简历 + 档案预览/防重复」**:后端 `/api/upload-resume` 把原文件落库(`ResumeFile` 表含 `file_hash` 唯一字段,原始文本存 `ResumeText`,结构化档案存 `ResumeProfile`),候选人带 `resumeFileDataUrl`(`/api/resumes/{name}`)与 `resumeMeta`,详情抽屉左栏可预览/下载;**批量入库增加前端防重复**(按 简历文件名+清洗后姓名 跳过并计数,后端 `IngestItem` 仍以唯一 `idempotency_key` 兜底)。**人才池(总库)独立视图已实现(2026-09-02 补充)**:左侧「人才池」页 = 公司共享总库(跨账号),卡片列表 + 关键词/标签/来源筛选 + 统计卡(总库人数/本周新入池/待跟进)+ 详情抽屉(档案/简历正文/原简历链接)+「认领到我的工作区」;简历库卡片与详情抽屉均可一键「移入人才池」。**残余**:岗位维简历量统计、批量移入/复活仍未做。
- 建议(🔄):标签筛选与人才池独立视图已落地;待做:岗位维简历量统计(表格或看板)、批量放入/复活、后端按 `file_hash` 库级去重。
- 现状:🔄 近期重构(提交 `07310dd`):简历库为**卡片网格列表**(头像/姓名/阶段/来源/快筛与 AI 分/求职岗位下拉/「查看档案·快速匹配·更多」操作),顶部 4 张卡(简历总数 / 高匹配=快筛≥85 / 待 AI 评估 / 待补全),筛选为 关键词/阶段/来源/匹配档位——**仍无标签筛选**;漏斗/来源/趋势/ROI 看板在**独立 `/dashboard` 路由**(可按岗位范围过滤),当时的「各岗位简历量」统计缺口已于 2026-09-03 补齐(见下)。**新增「保存原简历 + 档案预览/防重复」**:后端 `/api/upload-resume` 把原文件落库(`ResumeFile` 表含 `file_hash` 唯一字段,原始文本存 `ResumeText`,结构化档案存 `ResumeProfile`),候选人带 `resumeFileDataUrl`(`/api/resumes/{name}`)与 `resumeMeta`,详情抽屉左栏可预览/下载;**批量入库增加前端防重复**(按 简历文件名+清洗后姓名 跳过并计数,后端 `IngestItem` 仍以唯一 `idempotency_key` 兜底)。**人才池(总库)独立视图已实现(2026-09-02 补充)**:左侧「人才池」页 = 公司共享总库(跨账号),卡片列表 + 关键词/标签/来源筛选 + 统计卡(总库人数/本周新入池/待跟进)+ 详情抽屉(档案/简历正文/原简历链接)+「认领到我的工作区」;简历库卡片与详情抽屉均可一键「移入人才池」。**岗位维简历量统计已落地(2026-09-03)**:简历库页顶部新增「各岗位简历量」chips,按岗位显示 简历量 / 已入职 / 高匹配(快筛≥85),点击聚焦该岗位候选人(复用 `?job=` 聚焦),未绑定岗位单列提示。**残余**:批量移入/复活仍未做。
- 建议(🔄):标签筛选、人才池独立视图、岗位维简历量统计均已落地;待做:批量放入/复活、后端按 `file_hash` 库级去重。
---
......@@ -215,12 +219,12 @@ HR 原话要点:
| --- | --- | --- | --- |
| P0 | 需求 13 AI 评估规则化(稳定可复现) | ✅ 已大幅落地 | inputKey 缓存 + 固定 schema + temp=0 + 本地回退;残余为 LLM 重生成时表述波动 |
| P0 | 需求 20 下推流程联动 | ✅ 已落地 | 事件日志 + 待办自动增删已贯穿候选人/JD/Offer;人才池移入/认领联动已补 |
| P0 | 需求 14 AI 不得引用自述优势 | 🆕 未实现 | 两条 prompt 加一行硬规则即可,直接影响评估可信度 |
| P0 | 需求 14 AI 不得引用自述优势 | ✅ 已实现(2026-09-03) | 评估/解析 prompt 已加「自述不作为匹配依据」硬规则 |
| P1 | 需求 17 风险点固定维度 | 🆕 未实现 | 跳槽/竞业/gap/职业道德四项规则,风控刚需 |
| P1 | 需求 12 候选人标记 | 🔄 基本落地 | tags 枚举 + 详情编辑 + 简历库/人才池标签筛选 + 待跟进快捷筛选;独立周报视图待做 |
| P2 | 需求 4 岗位自动关闭 | 🔄 部分 | reconcile 里加一行规则触发即可 |
| P2 | 需求 4 岗位自动关闭 | ✅ 已实现(2026-09-03) | hired>=headcount 自动置「已招满」并记事件日志 |
| P2 | 需求 9 粘贴 JD 自动结构化 | 🔄 缺入口 | 复用现有 LLM 解析加一个按钮 |
| P2 | 需求 22 简历库标签/统计 | 🔄 部分 | 人才池独立视图 + 标签筛选已落地;岗位维简历量统计与批量移入待做 |
| P2 | 需求 22 简历库标签/统计 | 🔄 大部分落地 | 标签筛选/人才池视图/岗位维简历量已落地;批量移入待做 |
| P3 | 需求 23 插件字段修正 | 🔄 部分 | 补抓沟通职位/期望薪资 |
| P3 | 需求 21 入职材料三件套 | 🔄 部分 | 三张模板化 |
| P3 | 需求 3 岗位类型枚举 / 需求 25 期望薪资展示 | 🆕 | 小改动,按需做 |
......@@ -240,4 +244,4 @@ HR 原话要点:
---
*整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。建议优先补齐缺口项:AI 自述优势约束(需求 14)、风险固定维度(需求 17)、候选人标记/标签筛选与人才池视图(需求 12/22)。*
*整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。需求 14(AI 自述优势约束)已于 2026-09-03 实现;建议优先补齐:风险固定维度(需求 17)、批量移入/复活(需求 22),并按需做需求 9/3/25 等小改动(需求 4 自动招满与岗位维简历量统计已于 2026-09-03 落地)。*
......@@ -3,23 +3,54 @@ import { computed } from 'vue'
const props = defineProps({
events: { type: Array, default: () => [] },
// 要展示的月份 YYYY-MM(默认当月);与筛选月份保持一致
period: { type: String, default: '' },
})
const days = computed(() => {
// 事件类型中文标签:新事件(如 job_filled)也走这里,未知类型回退原始 type
const EVENT_TYPE_LABELS = {
candidate_created: '新候选人入库',
stage_changed: '阶段流转',
local_match_generated: '本地匹配生成',
ai_evaluation_generated: 'AI 评估生成',
interview_feedback_saved: '面试反馈保存',
offer_created: 'Offer 创建',
offer_approved: 'Offer 审批通过',
candidate_offer_sent: 'Offer 已发送',
onboarded: '确认入职',
pool_moved: '移入人才池',
candidate_claimed: '从人才池认领',
job_filled: '岗位自动招满',
}
const eventColor = (type) =>
type === 'candidate_created'
? 'var(--blue)'
: type === 'onboarded' || type === 'job_filled'
? 'var(--green)'
: type === 'pool_moved' || type === 'candidate_claimed'
? 'var(--rose)'
: 'var(--violet)'
const eventLabel = (event) => EVENT_TYPE_LABELS[event.type] || event.type
const eventSubject = (event) => event.candidateName || event.jobTitle || '候选人'
const { year, month } = (() => {
if (/^\d{4}-\d{2}$/.test(props.period || '')) {
const [y, m] = props.period.split('-').map(Number)
return { year: y, month: m - 1 }
}
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth()
return { year: now.getFullYear(), month: now.getMonth() }
})()
const monthLabel = `${year}${month + 1}月`
const days = computed(() => {
const firstDay = new Date(year, month, 1)
const startWeekday = firstDay.getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const cells = []
for (let index = 0; index < startWeekday; index += 1) {
cells.push(null)
}
for (let day = 1; day <= daysInMonth; day += 1) {
cells.push(new Date(year, month, day))
}
for (let index = 0; index < startWeekday; index += 1) cells.push(null)
for (let day = 1; day <= daysInMonth; day += 1) cells.push(new Date(year, month, day))
return cells
})
......@@ -28,6 +59,7 @@ const eventsByDay = computed(() => {
props.events.forEach((event) => {
const date = new Date(event.at)
if (Number.isNaN(date.getTime())) return
if (date.getFullYear() !== year || date.getMonth() !== month) return
const key = date.toDateString()
if (!map.has(key)) map.set(key, [])
map.get(key).push(event)
......@@ -35,47 +67,58 @@ const eventsByDay = computed(() => {
return map
})
const eventTotalInMonth = computed(() => [...eventsByDay.value.values()].reduce((sum, list) => sum + list.length, 0))
const isToday = (date) => date && date.toDateString() === new Date().toDateString()
const dayEvents = (date) => (date ? eventsByDay.value.get(date.toDateString()) || [] : [])
const dayTip = (events) => events.map((event) => `${eventSubject(event)} · ${eventLabel(event)}`).join('\n')
// 事件明细只列当月最近 6 条(与日历红点同月,避免跨月错位)
const listEvents = computed(() =>
[...props.events]
.filter((event) => {
const date = new Date(event.at)
return !Number.isNaN(date.getTime()) && date.getFullYear() === year && date.getMonth() === month
})
.sort((a, b) => new Date(b.at) - new Date(a.at))
.slice(0, 6)
)
</script>
<template>
<div class="calendar">
<div class="calendar-head">
<strong>{{ monthLabel }}</strong>
<span v-if="eventTotalInMonth">本月 {{ eventTotalInMonth }} 条动态</span>
<span v-else>本月暂无动态</span>
</div>
<div class="calendar-grid">
<span v-for="week in ['日', '一', '二', '三', '四', '五', '六']" :key="week" class="week-head">{{ week }}</span>
<template v-for="(date, index) in days" :key="index">
<div class="day-cell" :class="{ today: isToday(date), empty: !date }">
<template v-if="date">
<span class="day-num">{{ date.getDate() }}</span>
<span
v-if="dayEvents(date).length"
class="dot-mark"
:title="
dayEvents(date)
.map((event) => event.type)
.join(', ')
"
<span v-if="dayEvents(date).length" class="dot-mark" :title="dayTip(dayEvents(date))">
<i
v-for="event in dayEvents(date).slice(0, 3)"
:key="event.id"
:style="{ background: eventColor(event.type) }"
/>
</span>
</template>
</div>
</template>
</div>
<div class="calendar-events">
<div v-for="event in events" :key="event.id" class="event-chip">
<i
:style="{
background:
event.type === 'candidate_created'
? 'var(--blue)'
: event.type === 'onboarded'
? 'var(--green)'
: 'var(--violet)',
}"
/>
<span>{{ event.candidateName || '候选人' }} · {{ event.type }}</span>
<div v-if="listEvents.length" class="calendar-events">
<div v-for="event in listEvents" :key="event.id" class="event-chip">
<i :style="{ background: eventColor(event.type) }" />
<span class="event-date">{{ new Date(event.at).getDate() }}日</span>
<span class="event-text">{{ eventSubject(event) }} · {{ eventLabel(event) }}</span>
</div>
</div>
<div v-else class="empty-inline">当前月份暂无事件</div>
</div>
</template>
......@@ -83,7 +126,24 @@ const dayEvents = (date) => (date ? eventsByDay.value.get(date.toDateString()) |
.calendar {
display: flex;
flex-direction: column;
gap: 10px;
}
.calendar-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
strong {
font-size: 14px;
color: var(--ink);
}
span {
font-size: 12px;
color: var(--muted);
}
}
.calendar-grid {
......@@ -96,7 +156,7 @@ const dayEvents = (date) => (date ? eventsByDay.value.get(date.toDateString()) |
text-align: center;
font-size: 12px;
color: var(--muted);
padding: 4px 0;
padding: 2px 0;
}
.day-cell {
......@@ -106,12 +166,13 @@ const dayEvents = (date) => (date ? eventsByDay.value.get(date.toDateString()) |
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 13px;
font-size: 12px;
background: var(--bg);
color: var(--ink);
&.today {
outline: 2px solid var(--blue);
outline-offset: 0;
}
&.empty {
......@@ -120,11 +181,16 @@ const dayEvents = (date) => (date ? eventsByDay.value.get(date.toDateString()) |
.dot-mark {
position: absolute;
bottom: 6px;
bottom: 5px;
display: flex;
gap: 2px;
i {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--rose);
display: block;
}
}
}
......@@ -132,19 +198,42 @@ const dayEvents = (date) => (date ? eventsByDay.value.get(date.toDateString()) |
display: flex;
flex-direction: column;
gap: 6px;
max-height: 132px;
overflow-y: auto;
.event-chip {
display: flex;
align-items: center;
gap: 8px;
gap: 7px;
font-size: 12px;
color: var(--muted);
min-width: 0;
i {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.event-date {
flex-shrink: 0;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.event-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.empty-inline {
padding: 10px 0 2px;
text-align: center;
color: var(--muted);
font-size: 13px;
}
</style>
......@@ -2,90 +2,153 @@
import { computed } from 'vue'
const props = defineProps({
analytics: { type: Object, default: () => ({ stageCounts: {}, candidatesInScope: [] }) },
metrics: { type: Object, default: () => ({ candidates: 0 }) },
analytics: { type: Object, default: () => ({ funnel: [] }) },
})
const MAX = 100
const stages = computed(() => {
const counts = props.analytics.stageCounts || {}
const order = ['简历进入', '未筛选', '初筛通过', '面试中', 'Offer 中', '待入职', '已入职']
const total = props.metrics.candidates || Math.max(1, ...Object.values(counts))
return order
.filter((stage) => stage in counts || stage === '简历进入' || stage === '已入职' || stage === '面试中')
.map((stage) => ({
name: stage,
value: counts[stage] || 0,
percent: total ? Math.round(((counts[stage] || 0) / total) * 100) : 0,
// 由「深蓝(简历) → 青 → 绿(入职)」的色带,传达候选人一路收敛到入职
const FUNNEL_COLORS = ['#4e6fc0', '#648fc8', '#6fb6cd', '#5cc4a4', '#3fa97f']
// 阶段行间转换率文案(每阶段 = 相对上一阶段通过的比率)
const STEP_LABELS = {
初筛通过: '初筛通过率',
进入面试: '进入面试率',
Offer: 'Offer 转化',
入职: '入职转化',
}
const rows = computed(() =>
(props.analytics.funnel || []).map((row, index) => ({
...row,
color: FUNNEL_COLORS[index % FUNNEL_COLORS.length],
stepLabel: index === 0 ? '' : STEP_LABELS[row.name] || '阶段转化率',
}))
)
const maxValue = computed(() => {
const first = rows.value[0]?.value || 0
return Math.max(1, first, ...rows.value.map((row) => row.value || 0))
})
const maxValue = computed(() => Math.max(1, ...stages.value.map((stage) => stage.value)))
const isEmpty = computed(() => !rows.value.length || !(rows.value[0]?.value > 0))
// 梯形几何:每一层上边 = 本阶段人数宽度,下边 = 下一阶段人数宽度,
// 相邻层共享边界宽度 → 整体轮廓平滑收敛,才是真正的漏斗形态。
const shapeStyle = (row, index) => {
const topHalf = ((row.value || 0) / maxValue.value) * 50
const next = rows.value[index + 1]
// 末层收窄 60% 留一个小出口(避免收成 0 造成「入职=0」错觉)
const bottomHalf = next ? ((next.value || 0) / maxValue.value) * 50 : Math.max(0.4, topHalf * 0.6)
const leftTop = 50 - topHalf
const rightTop = 50 + topHalf
const leftBottom = 50 - bottomHalf
const rightBottom = 50 + bottomHalf
return {
background: row.color,
clipPath: `polygon(${leftTop}% 0, ${rightTop}% 0, ${rightBottom}% 100%, ${leftBottom}% 100%)`,
}
}
</script>
<template>
<div class="funnel">
<div v-for="(stage, index) in stages" :key="stage.name" class="funnel-row">
<span class="funnel-name">{{ stage.name }}</span>
<div class="funnel-track">
<i
class="funnel-bar"
:style="{
width: `${(stage.value / maxValue) * 100}%`,
'--accent': index === 0 ? 'var(--blue)' : index === stages.length - 1 ? 'var(--green)' : 'var(--cyan)',
}"
/>
<div v-if="!isEmpty" class="funnel">
<div v-for="(row, index) in rows" :key="row.name">
<div class="funnel-row">
<span class="funnel-name">{{ row.name }}</span>
<div class="funnel-shape" :style="shapeStyle(row, index)" />
<div class="funnel-meta">
<strong>{{ row.value }}</strong>
<em :title="`占首层(简历进入) ${row.pct}`">占简历 {{ row.pct }}</em>
</div>
</div>
<strong>{{ stage.value }}</strong>
<em>{{ stage.percent }}%</em>
<div v-if="index < rows.length - 1" class="funnel-step">
<span class="step-inner">
<span class="step-line" />
<span class="step-text"
>{{ rows[index + 1].stepLabel }} <b>{{ rows[index + 1].stepRate }}</b></span
>
<span class="step-line" />
</span>
</div>
</div>
</div>
<div v-else class="empty">当前筛选暂无数据</div>
</template>
<style lang="scss" scoped>
.funnel {
display: flex;
flex-direction: column;
gap: 12px;
gap: 4px;
}
.funnel-row {
display: grid;
grid-template-columns: 72px 1fr 44px 44px;
grid-template-columns: 84px 1fr 72px;
align-items: center;
gap: 12px;
gap: 10px;
height: 40px;
}
.funnel-name {
color: var(--muted);
font-size: 13px;
color: var(--ink);
}
.funnel-track {
height: 12px;
background: var(--bg);
border-radius: 999px;
overflow: hidden;
.funnel-shape {
height: 40px;
width: 100%;
transition:
clip-path 0.35s ease,
background 0.35s ease;
}
.funnel-bar {
.funnel-meta {
text-align: right;
line-height: 1.15;
strong {
display: block;
height: 100%;
min-width: 4px;
border-radius: 999px;
background: var(--accent);
transition: width 0.3s ease;
}
font-size: 16px;
font-variant-numeric: tabular-nums;
color: var(--ink);
}
strong {
text-align: right;
font-size: 14px;
em {
font-style: normal;
font-size: 11px;
color: var(--muted);
}
}
em {
.funnel-step {
display: grid;
grid-template-columns: 84px 1fr 72px;
gap: 10px;
height: 22px;
.step-inner {
grid-column: 2;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.step-line {
flex: 1;
height: 1px;
background: var(--line);
}
.step-text {
font-size: 11px;
color: var(--muted);
font-size: 13px;
font-style: normal;
white-space: nowrap;
b {
color: var(--ink);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
}
}
</style>
......@@ -5,6 +5,11 @@ const props = defineProps({
rows: { type: Array, default: () => [] },
})
const toNum = (value) => {
const num = Number.parseInt(String(value || '0'), 10)
return Number.isFinite(num) ? Math.max(0, Math.min(100, num)) : 0
}
const tableRows = computed(() =>
props.rows.map((row) => ({
source: row.source,
......@@ -14,30 +19,80 @@ const tableRows = computed(() =>
onboarded: row.onboarded || 0,
offerRate: row.offerRate || '0%',
onboardRate: row.onboardRate || '0%',
offerRateNum: toNum(row.offerRate),
onboardRateNum: toNum(row.onboardRate),
}))
)
</script>
<template>
<el-table :data="tableRows" empty-text="当前筛选暂无数据">
<el-table-column prop="source" label="渠道" min-width="90" />
<el-table-column label="简历" width="70">
<el-table :data="tableRows" empty-text="当前筛选暂无数据" size="small">
<el-table-column prop="source" label="渠道" min-width="88" />
<el-table-column label="简历" width="62" align="right">
<template #default="{ row }">{{ row.candidates }}</template>
</el-table-column>
<el-table-column label="面试" width="70">
<el-table-column label="面试" width="62" align="right">
<template #default="{ row }">{{ row.interviews }}</template>
</el-table-column>
<el-table-column label="Offer" width="70">
<el-table-column label="Offer" width="62" align="right">
<template #default="{ row }">{{ row.offers }}</template>
</el-table-column>
<el-table-column label="入职" width="70">
<el-table-column label="入职" width="62" align="right">
<template #default="{ row }">{{ row.onboarded }}</template>
</el-table-column>
<el-table-column label="Offer率" width="80">
<template #default="{ row }">{{ row.offerRate }}</template>
<el-table-column label="Offer率" min-width="96">
<template #default="{ row }">
<div class="rate-cell" :title="`Offer 率 ${row.offerRate}`">
<i class="rate-track"><b :style="{ '--w': `${row.offerRateNum}%` }" /></i>
<span>{{ row.offerRate }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="入职率" width="80">
<template #default="{ row }">{{ row.onboardRate }}</template>
<el-table-column label="入职率" min-width="96">
<template #default="{ row }">
<div class="rate-cell" :title="`入职率 ${row.onboardRate}`">
<i class="rate-track green"><b :style="{ '--w': `${row.onboardRateNum}%` }" /></i>
<span>{{ row.onboardRate }}</span>
</div>
</template>
</el-table-column>
</el-table>
</template>
<style lang="scss" scoped>
.rate-cell {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
.rate-track {
flex: 1;
min-width: 34px;
height: 6px;
border-radius: 999px;
background: var(--bg);
overflow: hidden;
b {
display: block;
height: 100%;
width: var(--w);
min-width: 0;
border-radius: 999px;
background: var(--violet);
}
&.green b {
background: var(--green);
}
}
span {
flex-shrink: 0;
color: var(--muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
}
</style>
......@@ -5,21 +5,42 @@ const props = defineProps({
rows: { type: Array, default: () => [] },
})
const PALETTE = ['var(--blue)', 'var(--cyan)', 'var(--violet)', 'var(--green)', 'var(--yellow)', 'var(--rose)']
const totalCandidates = computed(() => props.rows.reduce((sum, row) => sum + (row.candidates || 0), 0))
const colored = computed(() => {
const palette = ['var(--blue)', 'var(--cyan)', 'var(--violet)', 'var(--green)', 'var(--yellow)', 'var(--rose)']
return props.rows.map((row, index) => ({
const colored = computed(() =>
props.rows.map((row, index) => ({
...row,
color: palette[index % palette.length],
color: PALETTE[index % PALETTE.length],
percent: totalCandidates.value ? Math.round(((row.candidates || 0) / totalCandidates.value) * 100) : 0,
}))
)
// 按真实占比生成 conic-gradient 分段;扇区之间留 1% 白缝,扇区与图例百分比严格一致。
const conicStyle = computed(() => {
if (!totalCandidates.value) return ''
let cursor = 0
const parts = []
colored.value.forEach((row, index) => {
const slice = ((row.candidates || 0) / totalCandidates.value) * 100
if (slice <= 0) return
const start = cursor
const end = cursor + slice
const isLast = index === colored.value.length - 1
const gap = colored.value.length > 1 && slice > 1.6 ? 1 : 0
const innerEnd = isLast || !gap ? end : end - gap
parts.push(`${row.color} ${start.toFixed(2)}% ${innerEnd.toFixed(2)}%`)
if (!isLast && gap) parts.push(`transparent ${innerEnd.toFixed(2)}% ${end.toFixed(2)}%`)
cursor = end
})
return `conic-gradient(${parts.join(', ')})`
})
</script>
<template>
<div v-if="colored.length" class="sources">
<div class="donut" :style="{ '--total': totalCandidates }">
<div class="donut" :style="{ background: conicStyle }">
<div class="donut-hole">
<strong>{{ totalCandidates }}</strong>
<span>候选人</span>
......@@ -45,33 +66,30 @@ const colored = computed(() => {
}
.donut {
width: 120px;
height: 120px;
width: 132px;
height: 132px;
border-radius: 50%;
background: conic-gradient(
var(--blue) 0% 25%,
var(--cyan) 25% 55%,
var(--violet) 55% 75%,
var(--green) 75% 90%,
var(--yellow) 90% 100%
);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(24, 32, 51, 0.04);
transition: background 0.3s ease;
.donut-hole {
width: 72px;
height: 72px;
width: 84px;
height: 84px;
border-radius: 50%;
background: var(--panel);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 1px var(--line);
strong {
font-size: 20px;
font-size: 22px;
font-variant-numeric: tabular-nums;
}
span {
......@@ -88,7 +106,7 @@ const colored = computed(() => {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
gap: 9px;
li {
display: grid;
......@@ -110,13 +128,15 @@ const colored = computed(() => {
.value {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
em {
color: var(--muted);
font-style: normal;
min-width: 40px;
min-width: 42px;
text-align: right;
font-variant-numeric: tabular-nums;
}
}
</style>
......@@ -6,8 +6,14 @@ const props = defineProps({
range: { type: String, default: '月' },
})
const SERIES = [
{ key: 'resumes', label: '简历', color: 'var(--blue)' },
{ key: 'offers', label: 'Offer', color: 'var(--violet)' },
{ key: 'onboarded', label: '入职', color: 'var(--green)' },
]
const maxValue = computed(() => {
const all = props.rows.flatMap((row) => [row.resumes, row.offers, row.onboarded])
const all = props.rows.flatMap((row) => SERIES.map((series) => Number(row[series.key]) || 0))
return Math.max(1, ...all)
})
......@@ -17,22 +23,25 @@ const periodLabel = (row) => row.period || row.label
<template>
<div v-if="rows.length" class="trend">
<div class="trend-legend">
<span><i class="dot resumes" />简历</span>
<span><i class="dot offers" />Offer</span>
<span><i class="dot onboarded" />入职</span>
<span v-for="series in SERIES" :key="series.key">
<i class="dot" :style="{ background: series.color }" />{{ series.label }}
</span>
</div>
<div class="trend-chart">
<div class="trend-grid" aria-hidden="true">
<i v-for="step in 4" :key="step" />
</div>
<div v-for="row in rows" :key="periodLabel(row)" class="trend-col">
<div class="trend-bars">
<i
v-for="series in ['resumes', 'offers', 'onboarded']"
:key="series"
v-for="series in SERIES"
:key="series.key"
class="trend-bar"
:class="series"
:style="{
height: `${(row[series] / maxValue) * 100}%`,
'--accent': series === 'resumes' ? 'var(--blue)' : series === 'offers' ? 'var(--violet)' : 'var(--green)',
height: `${((Number(row[series.key]) || 0) / maxValue) * 100}%`,
'--accent': series.color,
}"
:title="`${periodLabel(row)} ${series.label}:${row[series.key] || 0}`"
/>
</div>
<span class="trend-period">{{ periodLabel(row) }}</span>
......@@ -60,34 +69,40 @@ const periodLabel = (row) => row.period || row.label
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
&.resumes {
background: var(--blue);
}
&.offers {
background: var(--violet);
}
&.onboarded {
background: var(--green);
}
margin-right: 5px;
}
}
.trend-chart {
position: relative;
display: flex;
align-items: stretch;
gap: 10px;
height: 150px;
height: 160px;
}
.trend-grid {
position: absolute;
inset: 0 0 20px;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
i {
border-top: 1px dashed var(--line);
display: block;
}
}
.trend-col {
position: relative;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 0;
}
.trend-bars {
......@@ -100,16 +115,25 @@ const periodLabel = (row) => row.period || row.label
}
.trend-bar {
width: 14px;
flex: 0 1 14px;
min-width: 4px;
max-width: 14px;
min-height: 2px;
border-radius: 3px 3px 0 0;
background: var(--accent);
opacity: 0.9;
cursor: default;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}
.trend-period {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
</style>
......@@ -50,6 +50,36 @@ const metrics = computed(() => [
{ label: '待补全', value: needParseReview.value, hint: '解析字段或正文缺失', color: 'var(--yellow)' },
])
// 需求 22:岗位维简历量统计——按 jobId 归属、不重复计数;未绑定岗位单列
const jobResumeStats = computed(() => {
const rows = new Map(
store.jobs.map((job) => [job.id, { id: job.id, title: job.title, count: 0, hired: 0, highMatch: 0 }])
)
let unbound = 0
store.candidates.forEach((candidate) => {
const row = rows.get(candidate.jobId || '')
if (!row) {
unbound += 1
return
}
row.count += 1
if (candidate.stage === '已入职') row.hired += 1
if (quickScore(candidate) >= 85) row.highMatch += 1
})
return {
rows: [...rows.values()]
.filter((row) => row.count > 0)
.sort((a, b) => b.count - a.count)
.slice(0, 12),
unbound,
}
})
const focusJobScope = (jobId) => {
store.resumeFilters = { q: '', stage: '全部阶段', source: '全部来源', tag: '全部标签', minMatch: '全部匹配' }
router.replace({ path: '/resumes', query: jobId ? { job: jobId } : {} })
}
const hasFilters = computed(() => {
const f = store.resumeFilters || {}
return Boolean(
......@@ -241,6 +271,33 @@ const openBatch = () => {
</article>
</div>
<section class="job-resume-stats">
<header class="stats-head">
<div>
<span class="section-kicker">简历库统计</span>
<h3>各岗位简历量</h3>
</div>
<span class="stats-hint">
{{ jobResumeStats.unbound ? `未绑定岗位 ${jobResumeStats.unbound} 份 · ` : '' }}点击岗位聚焦查看
</span>
</header>
<div v-if="jobResumeStats.rows.length" class="job-stat-chips">
<button
v-for="row in jobResumeStats.rows"
:key="row.id"
type="button"
class="job-stat-chip"
:class="{ active: scopeJob?.id === row.id }"
@click="focusJobScope(scopeJob?.id === row.id ? '' : row.id)"
>
<strong>{{ row.title }}</strong>
<b>{{ row.count }} </b>
<small>已入职 {{ row.hired }} · 高匹配 {{ row.highMatch }}</small>
</button>
</div>
<div v-else class="job-stat-empty">暂无岗位关联简历,上传或新建候选人后自动统计</div>
</section>
<section class="card candidate-list-card">
<header class="list-head">
<div class="list-head-copy">
......@@ -915,6 +972,88 @@ const openBatch = () => {
transform: translateY(-4px);
}
.job-resume-stats {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 16px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: 0 8px 20px rgba(39, 49, 70, 0.06);
}
.stats-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
h3 {
margin: 2px 0 0;
font-size: 15px;
color: var(--ink);
}
}
.stats-hint {
color: var(--muted);
font-size: 12px;
}
.job-stat-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.job-stat-chip {
display: inline-flex;
align-items: baseline;
gap: 8px;
padding: 7px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg);
color: var(--ink);
font-size: 12px;
cursor: pointer;
transition:
border-color 0.15s ease,
background 0.15s ease,
transform 0.15s ease;
strong {
font-size: 13px;
}
b {
color: var(--blue);
font-variant-numeric: tabular-nums;
}
small {
color: var(--muted);
font-size: 11px;
}
&:hover {
border-color: color-mix(in srgb, var(--blue) 55%, var(--line));
transform: translateY(-1px);
}
&.active {
border-color: var(--blue);
background: color-mix(in srgb, var(--blue) 8%, #fff);
}
}
.job-stat-empty {
padding: 10px 2px;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 1280px) {
.candidate-card {
grid-template-columns: minmax(220px, 1.4fr) minmax(120px, 0.6fr) minmax(220px, 1fr) auto;
......
import { defineStore } from 'pinia'
import { AUTH_STORAGE_KEY, seedState } from '@/utils/constants'
import { JOB_STATUS } from '@/utils/jobStatus'
import * as api from '@/api/recruitment'
import {
enrichCandidateFields,
......@@ -177,6 +178,23 @@ function reconcileRecruitmentLinks(targetState = {}) {
job.hired = (targetState.candidates || []).filter(
(candidate) => candidate.jobId === job.id && candidate.stage === '已入职'
).length
// 需求 4:招聘目标达成(hired >= headcount > 0)时自动置「已招满」并记事件日志;手动关闭/重开仍保留。
if (
job.status === JOB_STATUS.RECRUITING &&
Number(job.headcount || 0) > 0 &&
Number(job.hired || 0) >= Number(job.headcount || 0)
) {
job.status = JOB_STATUS.FILLED
job.autoFilledAt = new Date().toISOString()
if (!Array.isArray(targetState.eventLog)) targetState.eventLog = []
targetState.eventLog.push(
buildRecruitmentEvent('job_filled', {}, {
jobId: job.id,
jobTitle: job.title || '岗位',
note: '招聘目标已达成,岗位自动置为已招满',
})
)
}
})
return targetState
}
......@@ -868,6 +886,7 @@ export const useRecruitmentStore = defineStore('recruitment', {
this.completeTasksForCandidate(candidate, [/入职|材料|Offer|背调|体检/])
this.logEvent('onboarded', candidate, { offerId, note: '候选人已入职' })
}
this.reconcile()
this.persist('候选人已确认入职')
},
......
......@@ -48,10 +48,10 @@ export function dashboardScope(filters, state) {
const at = new Date(event.at)
return !Number.isNaN(at.getTime()) && at >= start && at < end && eventMatchesDashboardFilters(event, filters, state)
})
return { start, end, events, period: filters.period, range: filters.range }
return { start, end, events, period: filters.period, range: filters.range, filters }
}
export function dashboardSelectedJob(filters, state) {
export function dashboardSelectedJob(filters = {}, state) {
if (!filters.jobId || filters.jobId === 'all') return null
return state.jobs.find((job) => job.id === filters.jobId) || null
}
......@@ -67,7 +67,7 @@ export function dashboardCandidateJob(candidate, state) {
)
}
export function candidateMatchesDashboardFilters(candidate, filters, state) {
export function candidateMatchesDashboardFilters(candidate, filters = {}, state) {
const selectedJob = dashboardSelectedJob(filters, state)
if (selectedJob && !jobCandidatesForFilter(selectedJob, state.candidates).some((item) => item.id === candidate.id))
return false
......@@ -205,6 +205,35 @@ export function averageRecruitmentCycle(events = []) {
return `${Math.round(cycles.reduce((sum, day) => sum + day, 0) / cycles.length)}天`
}
// 招聘漏斗口径:与 KPI 一致,按「已推进到该阶段或之后」累计(候选人当前阶段隐含已通过前置阶段),
// 因此逐级人数单调不增,漏斗形态真实;未筛选/淘汰只计入「简历进入」。
const FUNNEL_GATES = [
{ name: '简历进入', reach: null },
{ name: '初筛通过', reach: new Set(['初筛通过', '面试中', 'Offer 中', '待入职', '已入职']) },
{ name: '进入面试', reach: new Set(['面试中', 'Offer 中', '待入职', '已入职']) },
{ name: 'Offer', reach: new Set(['Offer 中', '待入职', '已入职']) },
{ name: '入职', reach: new Set(['已入职']) },
]
export function funnelRows(candidatesInScope = []) {
const total = candidatesInScope.length
const counts = candidatesInScope.reduce((acc, candidate) => {
const stage = candidate.stage || '未筛选'
acc[stage] = (acc[stage] || 0) + 1
return acc
}, {})
const values = FUNNEL_GATES.map((gate) => {
if (!gate.reach) return total
return Object.entries(counts).reduce((sum, [stage, count]) => (gate.reach.has(stage) ? sum + count : sum), 0)
})
return FUNNEL_GATES.map((gate, index) => ({
name: gate.name,
value: values[index],
pct: percent(values[index], total),
stepRate: index === 0 ? null : percent(values[index], values[index - 1]),
}))
}
export function pageMetrics(filters, state) {
const scope = dashboardScope(filters, state)
const candidatesInScope = scopedCandidates(scope, state)
......@@ -227,11 +256,7 @@ export function dashboardAnalytics(filters, state) {
const scope = dashboardScope(filters, state)
const candidatesInScope = scopedCandidates(scope, state)
const candidateIds = new Set(candidatesInScope.map((candidate) => candidate.id))
const stageOrder = ['未筛选', '初筛通过', '面试中', 'Offer 中', '待入职', '已入职']
const stageCounts = Object.fromEntries(
stageOrder.map((stage) => [stage, candidatesInScope.filter((candidate) => candidate.stage === stage).length])
)
stageCounts['简历进入'] = candidatesInScope.length
const funnel = funnelRows(candidatesInScope)
const sourceRows = Object.entries(
candidatesInScope.reduce((acc, candidate) => {
const source = candidate.source || '其他'
......@@ -250,7 +275,7 @@ export function dashboardAnalytics(filters, state) {
const trend = scopedTrendRows(scope, state)
const cycleDays = averageRecruitmentCycle(scope.events)
const recentEvents = [...scope.events].sort((a, b) => new Date(b.at) - new Date(a.at)).slice(0, 8)
return { stageOrder, stageCounts, sourceRows, trend, cycleDays, recentEvents, scope, candidatesInScope, candidateIds }
return { sourceRows, funnel, trend, cycleDays, recentEvents, scope, candidatesInScope, candidateIds }
}
export function dashboardScopeLabel(filters = {}, state = {}) {
......
......@@ -54,7 +54,7 @@ export function jobSearchText(job = {}) {
}
export function jobNeedsAttention(job = {}) {
if (job.status === JOB_STATUS.CLOSED || job.status === JOB_STATUS.PAUSED) return false
if ([JOB_STATUS.CLOSED, JOB_STATUS.PAUSED, JOB_STATUS.FILLED].includes(job.status)) return false
if (job.jdStatus !== JD_STATUS.EFFECTIVE) return true
// 统一口径:channelStatus 只要不是「已同步」都视为待同步
if (job.channelStatus && job.channelStatus !== CHANNEL_STATUS.SYNCED) return true
......@@ -79,6 +79,14 @@ export function jobActionFor(job = {}) {
tab: 'overview',
}
}
if (job.status === JOB_STATUS.FILLED) {
return {
title: '岗位已招满',
description: '招聘目标已达成(已入职 = 招聘人数),岗位已自动关闭;如需继续招人请先上调招聘人数或手动重开。',
label: '查看状态',
tab: 'overview',
}
}
if (job.status === JOB_STATUS.CLOSED) {
return {
title: '招聘已关闭',
......@@ -151,7 +159,7 @@ export function jobProgressSteps(job = {}) {
}
export function jobStateBadge(text = '') {
const tone = /已生效|已同步|招聘中|已完成/.test(text)
const tone = /已生效|已同步|招聘中|已完成|已招满/.test(text)
? 'success'
: /审批中|待确认|待同步|草稿/.test(text)
? 'warning'
......
......@@ -25,6 +25,13 @@ const scopeItems = computed(() => dashboardScopeLabel(store.dashboardFilters, st
const m = computed(() => pageMetrics(store.dashboardFilters, store.state))
const analytics = computed(() => dashboardAnalytics(store.dashboardFilters, store.state))
// 日历跟随筛选控件里的月份(自定义范围则取开始日期所在月),避免筛选后仍显示「当前月」
const calendarMonth = computed(() => {
const filters = store.dashboardFilters
if (filters.range === '自定义') return (filters.customStart || '').slice(0, 7)
return filters.period || ''
})
</script>
<template>
......@@ -44,11 +51,11 @@ const analytics = computed(() => dashboardAnalytics(store.dashboardFilters, stor
<div class="card-head">
<div>
<div class="card-title">招聘漏斗</div>
<div class="card-note">简历库 / 录用入职联动</div>
<div class="card-note">简历 → 初筛 → 面试 → Offer → 入职</div>
</div>
<span class="card-hint">阶段转化</span>
<span class="card-hint">按已推进阶段累计</span>
</div>
<FunnelCard :analytics="analytics" :metrics="m" />
<FunnelCard :analytics="analytics" />
</section>
<section class="card dashboard-card">
......@@ -77,18 +84,18 @@ const analytics = computed(() => dashboardAnalytics(store.dashboardFilters, stor
<div class="card-head">
<div>
<div class="card-title">招聘日历</div>
<div class="card-note">面试 / Offer / 入职节点</div>
<div class="card-note">候选人 / Offer / 入职动态</div>
</div>
<span class="card-hint">红点悬停查看</span>
<span class="card-hint">跟随筛选月份</span>
</div>
<CalendarCard :events="analytics.recentEvents" />
<CalendarCard :events="analytics.scope.events" :period="calendarMonth" />
</section>
<section class="card dashboard-card">
<div class="card-head">
<div>
<div class="card-title">渠道效果与 ROI</div>
<div class="card-note">投入 / 转化 / 单入职成本</div>
<div class="card-note">渠道简历量 / 面试 / Offer / 入职转化</div>
</div>
<span class="card-hint">渠道效果</span>
</div>
......
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