Commit 542a4008 authored by 李文光's avatar 李文光

feat: 优化 JD 流程并展示岗位创建时间

parent 1b8f3ddd
...@@ -89,10 +89,12 @@ def workspace_has_data(session: Session, owner: str) -> bool: ...@@ -89,10 +89,12 @@ def workspace_has_data(session: Session, owner: str) -> bool:
def read_state(session: Session, owner: str = "local") -> dict[str, Any]: def read_state(session: Session, owner: str = "local") -> dict[str, Any]:
jobs = [ jobs = []
parse_json(item.data, {}) for item in session.scalars(select(Job).where(Job.owner_id == owner).order_by(Job.created_at, Job.id)):
for item in session.scalars(select(Job).where(Job.owner_id == owner).order_by(Job.created_at, Job.id)) data = parse_json(item.data, {})
] if not data.get("createdAt"):
data = {**data, "createdAt": data.get("created_at") or item.created_at}
jobs.append(data)
candidates = [ candidates = [
parse_json(item.data, {}) parse_json(item.data, {})
for item in session.scalars( for item in session.scalars(
...@@ -143,8 +145,8 @@ def replace_state(session: Session, owner: str, input_state: dict[str, Any], imp ...@@ -143,8 +145,8 @@ def replace_state(session: Session, owner: str, input_state: dict[str, Any], imp
for job in state["jobs"]: for job in state["jobs"]:
job_id = job.get("id") or _id("job") job_id = job.get("id") or _id("job")
job = {**job, "id": job_id}
created_at = job.get("createdAt") or job.get("created_at") or state["updatedAt"] created_at = job.get("createdAt") or job.get("created_at") or state["updatedAt"]
job = {**job, "id": job_id, "createdAt": created_at}
session.add( session.add(
Job( Job(
id=job_id, id=job_id,
......
...@@ -23,7 +23,8 @@ from collections.abc import Callable ...@@ -23,7 +23,8 @@ from collections.abc import Callable
from typing import Any from typing import Any
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 题单(question-bank v2.2;研发/技术岗额外插入 9a 技术栈问) # 题单(question-bank v2.3;技术岗额外插入 9a 技术栈问,
# 且技术向推荐答案只对 tech_role=True 展示)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 每条可选答案:label 展示给用户,value 入库,fields 是选中时写入 job 的键值。 # 每条可选答案:label 展示给用户,value 入库,fields 是选中时写入 job 的键值。
...@@ -31,8 +32,8 @@ from typing import Any ...@@ -31,8 +32,8 @@ from typing import Any
_Option = dict[str, Any] _Option = dict[str, Any]
def _opt(label: str, value: str, fields: dict[str, Any] | None = None) -> _Option: def _opt(label: str, value: str, fields: dict[str, Any] | None = None, **extra: Any) -> _Option:
return {"label": label, "value": value, "fields": fields or {}} return {"label": label, "value": value, "fields": fields or {}, **extra}
# 每个问题的定义。parse_text(s, job) 可自由输入做字段推断,用于开放式回答。 # 每个问题的定义。parse_text(s, job) 可自由输入做字段推断,用于开放式回答。
...@@ -126,7 +127,7 @@ QUESTION_BANK: list[dict[str, Any]] = [ ...@@ -126,7 +127,7 @@ QUESTION_BANK: list[dict[str, Any]] = [
"负责核心模块的设计与编码;参加代码评审与方案评审;" "负责核心模块的设计与编码;参加代码评审与方案评审;"
"参与联调与上线,负责线上问题值班与处置" "参与联调与上线,负责线上问题值班与处置"
), ),
}), }, tech_only=True),
_opt("业务岗:拓客/方案/谈判/交付/客户经营", "sales", { _opt("业务岗:拓客/方案/谈判/交付/客户经营", "sales", {
"responsibilities": ( "responsibilities": (
"负责客户开拓与需求对接;输出方案与商务谈判;推进招投标与回款;" "负责客户开拓与需求对接;输出方案与商务谈判;推进招投标与回款;"
...@@ -224,12 +225,15 @@ QUESTION_BANK: list[dict[str, Any]] = [ ...@@ -224,12 +225,15 @@ QUESTION_BANK: list[dict[str, Any]] = [
"options": [ "options": [
_opt("技术栈能上手 + 独立交付", "tech-lifeline", { _opt("技术栈能上手 + 独立交付", "tech-lifeline", {
"mustHave": "核心技能能直接上手;端到端独立交付能力", "mustHave": "核心技能能直接上手;端到端独立交付能力",
}), }, tech_only=True),
_opt("业务理解 + 客户资源", "business-lifeline", { _opt("业务理解 + 客户资源", "business-lifeline", {
"mustHave": "业务理解力;客户资源或成交能力", "mustHave": "业务理解力;客户资源或成交能力",
}), }),
_opt("线上问题处置 + 抗压", "ops-lifeline", { _opt("线上问题处置 + 抗压", "ops-lifeline", {
"mustHave": "线上问题定位与处置经验;抗压推进能力", "mustHave": "线上问题定位与处置经验;抗压推进能力",
}, tech_only=True),
_opt("可迁移行业经验 + 稳定成交/交付", "field-lifeline", {
"mustHave": "目标行业可迁移经验;稳定的成交或交付记录",
}), }),
], ],
"free_text": True, "free_text": True,
...@@ -246,7 +250,7 @@ QUESTION_BANK: list[dict[str, Any]] = [ ...@@ -246,7 +250,7 @@ QUESTION_BANK: list[dict[str, Any]] = [
"options": [ "options": [
_opt("简历看项目 + 面试做设计题", "resume+design", { _opt("简历看项目 + 面试做设计题", "resume+design", {
"verification": "简历看真实项目落地;面试给真实场景现场设计追问。", "verification": "简历看真实项目落地;面试给真实场景现场设计追问。",
}), }, tech_only=True),
_opt("简历看关键词 + 面试问案例", "resume+case", { _opt("简历看关键词 + 面试问案例", "resume+case", {
"verification": "简历看相关经历关键词;面试问经典案例 STAR 追问。", "verification": "简历看相关经历关键词;面试问经典案例 STAR 追问。",
}), }),
...@@ -291,16 +295,25 @@ QUESTION_BANK: list[dict[str, Any]] = [ ...@@ -291,16 +295,25 @@ QUESTION_BANK: list[dict[str, Any]] = [
"options": [ "options": [
_opt("纯 CRUD / 只会调轮子", "crud", { _opt("纯 CRUD / 只会调轮子", "crud", {
"knockout": "纯 CRUD 无架构封装;只会调轮子不懂原理", "knockout": "纯 CRUD 无架构封装;只会调轮子不懂原理",
}), }, tech_only=True),
_opt("无线上问题经验", "no-ops", { _opt("无线上问题经验", "no-ops", {
"knockout": "纯功能开发,无生产环境排查处置经验", "knockout": "纯功能开发,无生产环境排查处置经验",
}), }, tech_only=True),
_opt("技术炫技、无业务 sense", "show-off", { _opt("技术炫技、无业务 sense", "show-off", {
"knockout": "技术炫技,缺少业务落地能力", "knockout": "技术炫技,缺少业务落地能力",
}), }, tech_only=True),
_opt("关键词全命中但无成果/无深度", "shallow", { _opt("关键词全命中但无成果/无深度", "shallow", {
"knockout": "简历关键词全命中但缺真实成果与深度", "knockout": "简历关键词全命中但缺真实成果与深度",
}), }),
_opt("客户资源看着多,但成交/回款无证据", "no-proof", {
"knockout": "客户资源或线索多,但缺少实际成交、回款与复购证据",
}),
_opt("只会跟单/陪跑,推不动决策链", "no-close", {
"knockout": "只会跟单与关系维护,缺少推进决策链和临门成交能力",
}),
_opt("只能低价成交,无方案/谈判溢价能力", "price-only", {
"knockout": "成交依赖低价或政策,缺少方案价值与商务谈判能力",
}),
], ],
"free_text": True, "free_text": True,
"parse_text": lambda text: {"knockout": text.strip()[:200]}, "parse_text": lambda text: {"knockout": text.strip()[:200]},
...@@ -319,6 +332,10 @@ QUESTION_BANK: list[dict[str, Any]] = [ ...@@ -319,6 +332,10 @@ QUESTION_BANK: list[dict[str, Any]] = [
_opt("3 个月,能否在指导下独立完成一次迭代", "3m-iterate", { _opt("3 个月,能否在指导下独立完成一次迭代", "3m-iterate", {
"probationPeriod": "3个月", "probationPeriod": "3个月",
"probationCriteria": "在指导下独立完成一次需求迭代", "probationCriteria": "在指导下独立完成一次需求迭代",
}, tech_only=True),
_opt("3 个月,能否独立拿下首个客户/首单", "3m-first-deal", {
"probationPeriod": "3个月",
"probationCriteria": "独立跟进并成交首个目标客户,或跑通首个项目交付",
}), }),
_opt("6 个月,看是否能独立交付关键任务", "6m-deliver", { _opt("6 个月,看是否能独立交付关键任务", "6m-deliver", {
"probationPeriod": "6个月", "probationPeriod": "6个月",
...@@ -365,7 +382,9 @@ def _completed() -> dict[str, Any]: ...@@ -365,7 +382,9 @@ def _completed() -> dict[str, Any]:
return {"complete": True} return {"complete": True}
def _next_question(question_ids: list[str], job: dict[str, Any], answered: list[str] | None = None) -> dict[str, Any] | None: def _next_question(
question_ids: list[str], job: dict[str, Any], answered: list[str] | None = None, tech_role: bool = False
) -> dict[str, Any] | None:
"""按 id 顺序找到第一个需要问的问题(返回渲染数据),都答过则完成。 """按 id 顺序找到第一个需要问的问题(返回渲染数据),都答过则完成。
会跳过已作答(answered)或满足 skip_when 的问题。 会跳过已作答(answered)或满足 skip_when 的问题。
...@@ -379,7 +398,7 @@ def _next_question(question_ids: list[str], job: dict[str, Any], answered: list[ ...@@ -379,7 +398,7 @@ def _next_question(question_ids: list[str], job: dict[str, Any], answered: list[
continue continue
if q.get("skip_when") and q["skip_when"](job): if q.get("skip_when") and q["skip_when"](job):
continue continue
return _render(q) return _render(q, tech_role=tech_role)
return _completed() return _completed()
...@@ -402,10 +421,11 @@ def _question_ids(tech_role: bool) -> list[str]: ...@@ -402,10 +421,11 @@ def _question_ids(tech_role: bool) -> list[str]:
return ids return ids
def _render(q: dict[str, Any]) -> dict[str, Any]: def _render(q: dict[str, Any], *, tech_role: bool = False) -> dict[str, Any]:
options = [ options = [
{"label": o["label"], "value": o["value"], "fields": o.get("fields", {})} {"label": o["label"], "value": o["value"], "fields": o.get("fields", {})}
for o in q.get("options", []) for o in q.get("options", [])
if not (o.get("tech_only") and not tech_role)
] ]
return { return {
"question_id": q["id"], "question_id": q["id"],
...@@ -465,7 +485,7 @@ def start(state: dict[str, Any] | None, job: dict[str, Any]) -> dict[str, Any]: ...@@ -465,7 +485,7 @@ def start(state: dict[str, Any] | None, job: dict[str, Any]) -> dict[str, Any]:
tech_role = bool(state.get("tech_role", _guess_tech_role(job))) tech_role = bool(state.get("tech_role", _guess_tech_role(job)))
job = _start_job(job or {}) job = _start_job(job or {})
ids = _question_ids(tech_role) ids = _question_ids(tech_role)
nxt = _next_question(ids, job, answered=[]) nxt = _next_question(ids, job, answered=[], tech_role=tech_role)
# 记录这道题是「从哪一题开始问」的进度 # 记录这道题是「从哪一题开始问」的进度
return { return {
"session_id": state.get("session_id") or "", "session_id": state.get("session_id") or "",
...@@ -497,7 +517,7 @@ def advance(state: dict[str, Any], answer: dict[str, Any]) -> dict[str, Any]: ...@@ -497,7 +517,7 @@ def advance(state: dict[str, Any], answer: dict[str, Any]) -> dict[str, Any]:
answered.append(qid) answered.append(qid)
ids = state.get("question_ids") or _question_ids(bool(state.get("tech_role"))) ids = state.get("question_ids") or _question_ids(bool(state.get("tech_role")))
nxt = _next_question(ids, job, answered=answered) nxt = _next_question(ids, job, answered=answered, tech_role=bool(state.get("tech_role")))
if isinstance(nxt, dict) and nxt.get("complete"): if isinstance(nxt, dict) and nxt.get("complete"):
return { return {
......
...@@ -2,7 +2,8 @@ import json ...@@ -2,7 +2,8 @@ import json
import re import re
from typing import Any from typing import Any
import httpx from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from backend.app.config import get_settings from backend.app.config import get_settings
from backend.app.services.qwenpaw_client import qwenpaw_jd_draft from backend.app.services.qwenpaw_client import qwenpaw_jd_draft
...@@ -159,6 +160,32 @@ def parse_json_from_text(text: str) -> dict[str, Any]: ...@@ -159,6 +160,32 @@ def parse_json_from_text(text: str) -> dict[str, Any]:
raise ValueError("模型未返回可解析 JSON") from None raise ValueError("模型未返回可解析 JSON") from None
def _message_text(message: Any) -> str:
content = message.content
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(
str(part.get("text") or part.get("content") or "") if isinstance(part, dict) else str(part)
for part in content
)
return str(content or "")
async def _chat_json(settings: Any, system: str, user: str, *, temperature: float = 0) -> dict[str, Any]:
"""用 langchain-openai 的 ChatOpenAI 走 OpenAI 兼容接口,固定 JSON 模式。"""
chat = ChatOpenAI(
model=settings.effective_llm_model,
api_key=settings.effective_llm_api_key,
base_url=settings.effective_llm_base_url,
temperature=temperature,
timeout=60,
max_retries=1,
).bind(response_format={"type": "json_object"})
response = await chat.ainvoke([SystemMessage(content=system), HumanMessage(content=user)])
return parse_json_from_text(_message_text(response))
def sanitize_resume_for_llm(text: str, candidate: dict[str, Any]) -> str: def sanitize_resume_for_llm(text: str, candidate: dict[str, Any]) -> str:
sanitized = str(text or "") sanitized = str(text or "")
for value in [candidate.get("name"), candidate.get("phone"), candidate.get("email"), candidate.get("city"), candidate.get("resumeName")]: for value in [candidate.get("name"), candidate.get("phone"), candidate.get("email"), candidate.get("city"), candidate.get("resumeName")]:
...@@ -204,24 +231,12 @@ async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None: ...@@ -204,24 +231,12 @@ async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None:
f"候选人脱敏结构:{json.dumps({**safe_candidate, 'resumeText': None}, ensure_ascii=False, indent=2)}\n\n" f"候选人脱敏结构:{json.dumps({**safe_candidate, 'resumeText': None}, ensure_ascii=False, indent=2)}\n\n"
f"脱敏简历正文:\n{(safe_candidate.get('resumeText') or '')[:16000]}" f"脱敏简历正文:\n{(safe_candidate.get('resumeText') or '')[:16000]}"
) )
async with httpx.AsyncClient(timeout=60) as client: parsed = await _chat_json(
response = await client.post( settings,
f"{settings.effective_llm_base_url}/chat/completions", "你是企业招聘系统中的资深HR初筛分析助手。只返回JSON,不要输出Markdown。",
headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.effective_llm_api_key}"}, user,
json={ temperature=0,
"model": settings.effective_llm_model, )
"temperature": 0,
"messages": [
{"role": "system", "content": "你是企业招聘系统中的资深HR初筛分析助手。只返回JSON,不要输出Markdown。"},
{"role": "user", "content": user},
],
"response_format": {"type": "json_object"},
},
)
data = response.json()
if response.status_code >= 400:
raise RuntimeError(data.get("error", {}).get("message") or "LLM 分析失败")
parsed = parse_json_from_text(data.get("choices", [{}])[0].get("message", {}).get("content") or "{}")
return {**parsed, "reportVersion": "ai-evaluation-v2", "provider": f"llm:{settings.effective_llm_model}"} return {**parsed, "reportVersion": "ai-evaluation-v2", "provider": f"llm:{settings.effective_llm_model}"}
...@@ -326,24 +341,13 @@ jdVersion, jdStatus, approvalStatus, jd, responsibilities, requirements, mustHav ...@@ -326,24 +341,13 @@ jdVersion, jdStatus, approvalStatus, jd, responsibilities, requirements, mustHav
- 整体语气专业、具体、可执行,让候选人读完知道"来了做什么、需要什么"。 - 整体语气专业、具体、可执行,让候选人读完知道"来了做什么、需要什么"。
岗位信息:{json.dumps(payload.get('job') or {}, ensure_ascii=False, indent=2)}""" 岗位信息:{json.dumps(payload.get('job') or {}, ensure_ascii=False, indent=2)}"""
async with httpx.AsyncClient(timeout=60) as client: parsed = await _chat_json(
response = await client.post( settings,
f"{settings.effective_llm_base_url}/chat/completions", "你是企业招聘系统中的资深招聘JD顾问。只返回JSON,不要输出Markdown。",
headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.effective_llm_api_key}"}, user,
json={ temperature=0.35,
"model": settings.effective_llm_model, )
"temperature": 0.35, return {**parsed, "provider": f"llm:{settings.effective_llm_model}"}
"messages": [
{"role": "system", "content": "你是企业招聘系统中的资深招聘JD顾问。只返回JSON,不要输出Markdown。"},
{"role": "user", "content": user},
],
"response_format": {"type": "json_object"},
},
)
data = response.json()
if response.status_code >= 400:
raise RuntimeError(data.get("error", {}).get("message") or "JD 生成失败")
return {**parse_json_from_text(data.get("choices", [{}])[0].get("message", {}).get("content") or "{}"), "provider": f"llm:{settings.effective_llm_model}"}
# 对内字段:用于内部访谈/寻源,严禁随岗位信息发送到对外 LLM 或写进对外 JD。 # 对内字段:用于内部访谈/寻源,严禁随岗位信息发送到对外 LLM 或写进对外 JD。
......
...@@ -16,6 +16,8 @@ dependencies = [ ...@@ -16,6 +16,8 @@ dependencies = [
"pydantic-settings>=2.4,<3", "pydantic-settings>=2.4,<3",
"python-multipart>=0.0.9,<1", "python-multipart>=0.0.9,<1",
"httpx>=0.27,<1", "httpx>=0.27,<1",
"langchain-core>=1.0,<2",
"langchain-openai>=1.0,<2",
"pypdf>=5.1,<6", "pypdf>=5.1,<6",
"python-docx>=1.1,<2" "python-docx>=1.1,<2"
] ]
......
...@@ -95,4 +95,6 @@ def main(argv: list[str] | None = None) -> int: ...@@ -95,4 +95,6 @@ def main(argv: list[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
import sys
sys.argv.append("--reload")
raise SystemExit(main()) raise SystemExit(main())
...@@ -112,6 +112,58 @@ def test_grill_start_and_advance(client): ...@@ -112,6 +112,58 @@ def test_grill_start_and_advance(client):
assert state["progress"]["percent"] == 100 assert state["progress"]["percent"] == 100
def test_grill_sales_role_uses_business_options(client):
"""非技术岗(销售经理)不出现技术向推荐答案,试用期/排除信号用业务岗口径。"""
start = client.post(
"/api/jd/grill/start",
json={"job": {"title": "销售经理", "department": "市场部"}, "tech_role": False},
)
assert start.status_code == 200
s = start.json()
assert s["total_steps"] == 13
assert "tech_stack" not in s["question_ids"]
answers = {
"entry_path": "new",
"input_form": "one-sentence",
"internal_digest": "must-hire",
"positioning": "backfill",
"responsibilities": "sales",
"hard_gate": "bachelor-plus",
"sensitive": "none",
"salary_location": "shenzhen-onsite",
"lifeline": "business-lifeline",
"verification": "resume+case",
"knockout": "no-proof",
"probation": "3m-first-deal",
"noncompete": "no",
}
state = s
asked = []
while not state.get("complete"):
qid = state["next"]["question_id"]
asked.append(qid)
option_values = [opt["value"] for opt in state["next"]["options"]]
if qid == "knockout":
assert "crud" not in option_values
assert "no-proof" in option_values
if qid == "probation":
assert "3m-iterate" not in option_values
assert "3m-first-deal" in option_values
resp = client.post(
"/api/jd/grill/advance",
json={"state": state, "answer": {"question_id": qid, "type": "option", "option": answers[qid]}},
)
assert resp.status_code == 200
state = resp.json()
assert state["complete"] is True
assert len(asked) == 13
assert "tech_stack" not in asked
assert state["job"]["knockout"] == "客户资源或线索多,但缺少实际成交、回款与复购证据"
assert state["job"]["probationCriteria"] == "独立跟进并成交首个目标客户,或跑通首个项目交付"
def test_grill_skips_existing_fields(client): def test_grill_skips_existing_fields(client):
"""老岗位迭代:已有 jd/mustHave/knockout/education 的题自动跳过,只问变化点。""" """老岗位迭代:已有 jd/mustHave/knockout/education 的题自动跳过,只问变化点。"""
start = client.post( start = client.post(
......
...@@ -23,6 +23,7 @@ def test_state_api_roundtrip(client): ...@@ -23,6 +23,7 @@ def test_state_api_roundtrip(client):
loaded = client.get("/api/state").json() loaded = client.get("/api/state").json()
assert loaded["meta"]["persisted"] is True assert loaded["meta"]["persisted"] is True
assert loaded["jobs"][0]["title"] == "高级前端工程师" assert loaded["jobs"][0]["title"] == "高级前端工程师"
assert loaded["jobs"][0]["createdAt"]
assert loaded["candidates"][0]["name"] == "张三" assert loaded["candidates"][0]["name"] == "张三"
assert loaded["tasks"][0]["title"] == "安排面试" assert loaded["tasks"][0]["title"] == "安排面试"
......
...@@ -92,10 +92,12 @@ const isStaleCandidate = (candidate) => flow.value.staleCandidates.some((item) = ...@@ -92,10 +92,12 @@ const isStaleCandidate = (candidate) => flow.value.staleCandidates.some((item) =
const hasJdContent = computed(() => const hasJdContent = computed(() =>
Boolean(job.value?.jd || job.value?.responsibilities || job.value?.requirements || job.value?.mustHave) Boolean(job.value?.jd || job.value?.responsibilities || job.value?.requirements || job.value?.mustHave)
) )
const jdPendingConfirm = computed(() => [JD_STATUS.DRAFT, JD_STATUS.PENDING_CONFIRM].includes(job.value?.jdStatus))
const jdConfirmed = computed(() => job.value?.approvalStatus === APPROVAL_STATUS.DEPARTMENT_CONFIRMED)
const jdGenerationBusy = computed(() => Boolean(store.jdGenerateOpen || store.jdGrillOpen)) const jdGenerationBusy = computed(() => Boolean(store.jdGenerateOpen || store.jdGrillOpen))
const generateJdLabel = computed(() => (hasJdContent.value ? '重新生成草稿' : '生成首版 JD 草稿')) const generateJdLabel = computed(() => (hasJdContent.value ? '重新生成草稿' : '生成首版 JD 草稿'))
const jdActionHint = computed(() => { const jdActionHint = computed(() => {
if (!hasJdContent.value) return '还没有 JD:先生成首版草稿,再进入用人确认。' if (!hasJdContent.value) return '还没有 JD:顶部主按钮会直接生成首版草稿,也可以在这里手动调整后再生成。'
return '已有 JD:小幅调整岗位要求用“迭代”;需要按当前全部信息重写时用“重新生成”。两者都会生成新的待确认草稿。' return '已有 JD:小幅调整岗位要求用“迭代”;需要按当前全部信息重写时用“重新生成”。两者都会生成新的待确认草稿。'
}) })
...@@ -153,7 +155,7 @@ const submitApproval = async () => { ...@@ -153,7 +155,7 @@ const submitApproval = async () => {
ensureTask('跟进JD审批结果', '岗位管理', job.value.title, '中', '今日') ensureTask('跟进JD审批结果', '岗位管理', job.value.title, '中', '今日')
store.persist('JD已提交审批') store.persist('JD已提交审批')
showToast('JD 已提交审批,已生成跟进待办') showToast('JD 已提交审批,已生成跟进待办')
activeStep.value = 2 activeStep.value = 3
} }
const confirmMatchRules = () => { const confirmMatchRules = () => {
...@@ -166,6 +168,11 @@ const confirmMatchRules = () => { ...@@ -166,6 +168,11 @@ const confirmMatchRules = () => {
const approveJd = async () => { const approveJd = async () => {
if (!job.value) return if (!job.value) return
if (job.value.jdStatus !== JD_STATUS.APPROVING) {
showToast('请先在「用人确认」提交审批', 'warning')
activeStep.value = 2
return
}
try { try {
await ElMessageBox.confirm('审批通过后 JD 将正式生效,并进入发布流程。确认生效?', 'JD 生效', { await ElMessageBox.confirm('审批通过后 JD 将正式生效,并进入发布流程。确认生效?', 'JD 生效', {
confirmButtonText: '确认生效', confirmButtonText: '确认生效',
...@@ -365,6 +372,10 @@ const mainAction = async () => { ...@@ -365,6 +372,10 @@ const mainAction = async () => {
const tab = action.value.tab const tab = action.value.tab
if (tab === 'flow') { if (tab === 'flow') {
activeTab.value = 'flow' activeTab.value = 'flow'
if (!hasJdContent.value) {
await generateJd('generate')
return
}
if (Number.isInteger(action.value.step)) activeStep.value = action.value.step if (Number.isInteger(action.value.step)) activeStep.value = action.value.step
await nextTick() await nextTick()
document.querySelector('.workflow-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) document.querySelector('.workflow-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
...@@ -689,24 +700,23 @@ const mainAction = async () => { ...@@ -689,24 +700,23 @@ const mainAction = async () => {
</div> </div>
<div class="step-pane-actions"> <div class="step-pane-actions">
<el-button <el-button
v-if="job.jdStatus === JD_STATUS.PENDING_CONFIRM" v-if="jdPendingConfirm"
size="small" size="small"
type="primary" type="primary"
:disabled="!hasJdContent"
@click="confirmOwner" @click="confirmOwner"
> >
确认 JD 内容 确认 JD 内容
</el-button> </el-button>
<el-button <el-button v-if="jdPendingConfirm" size="small" :disabled="!jdConfirmed" @click="submitApproval">
v-if="job.jdStatus !== JD_STATUS.APPROVING"
size="small"
:disabled="job.approvalStatus !== APPROVAL_STATUS.DEPARTMENT_CONFIRMED"
@click="submitApproval"
>
提交审批 提交审批
</el-button> </el-button>
<el-tag v-if="job.jdStatus === JD_STATUS.APPROVING" type="warning" size="small">审批进行中</el-tag> <el-tag v-if="job.jdStatus === JD_STATUS.APPROVING" type="warning" size="small">审批进行中</el-tag>
</div> </div>
</div> </div>
<div v-if="jdPendingConfirm && !jdConfirmed" class="step-action-hint">
{{ hasJdContent ? '先点击「确认 JD 内容」,确认后即可提交审批。' : '先生成 JD 草稿,再回来确认内容。' }}
</div>
<div class="step-fields"> <div class="step-fields">
<div> <div>
<span>审批状态</span><strong>{{ job.approvalStatus || APPROVAL_STATUS.NOT_STARTED }}</strong> <span>审批状态</span><strong>{{ job.approvalStatus || APPROVAL_STATUS.NOT_STARTED }}</strong>
...@@ -728,13 +738,20 @@ const mainAction = async () => { ...@@ -728,13 +738,20 @@ const mainAction = async () => {
<span>审批通过后 JD 生效,并同步招聘渠道</span> <span>审批通过后 JD 生效,并同步招聘渠道</span>
</div> </div>
<div class="step-pane-actions"> <div class="step-pane-actions">
<el-button v-if="job.jdStatus !== JD_STATUS.EFFECTIVE" size="small" type="primary" @click="approveJd" <el-button v-if="job.jdStatus === JD_STATUS.APPROVING" size="small" type="primary" @click="approveJd"
>审批通过并生效</el-button >审批通过并生效</el-button
> >
<el-tag v-if="job.jdStatus === JD_STATUS.EFFECTIVE" size="small" type="success">JD 已生效</el-tag>
<el-button size="small" @click="openChannelDialog">同步发布渠道</el-button> <el-button size="small" @click="openChannelDialog">同步发布渠道</el-button>
<el-button size="small" :disabled="!hasJdContent" @click="openJdMarkdown">查看 Markdown</el-button> <el-button size="small" :disabled="!hasJdContent" @click="openJdMarkdown">查看 Markdown</el-button>
</div> </div>
</div> </div>
<div
v-if="job.jdStatus !== JD_STATUS.APPROVING && job.jdStatus !== JD_STATUS.EFFECTIVE"
class="step-action-hint"
>
先在「用人确认」确认 JD 内容并提交审批,审批中才能在这里通过并生效。
</div>
<div class="step-fields"> <div class="step-fields">
<div> <div>
<span>JD 状态</span><strong>{{ job.jdStatus || JD_STATUS.DRAFT }}</strong> <span>JD 状态</span><strong>{{ job.jdStatus || JD_STATUS.DRAFT }}</strong>
...@@ -811,10 +828,14 @@ const mainAction = async () => { ...@@ -811,10 +828,14 @@ const mainAction = async () => {
</div> </div>
<div class="actions"> <div class="actions">
<el-button @click="openChannelDialog">同步发布渠道</el-button> <el-button @click="openChannelDialog">同步发布渠道</el-button>
<el-button v-if="job.jdStatus !== JD_STATUS.EFFECTIVE" type="primary" @click="approveJd" <el-button v-if="job.jdStatus === JD_STATUS.APPROVING" type="primary" @click="approveJd"
>审批通过并生效</el-button >审批通过并生效</el-button
> >
<el-tag v-if="job.jdStatus === JD_STATUS.EFFECTIVE" type="success">JD 已生效</el-tag>
</div> </div>
<p v-if="job.jdStatus !== JD_STATUS.APPROVING && job.jdStatus !== JD_STATUS.EFFECTIVE" class="publish-hint">
先在「JD 流程」完成用人确认并提交审批,才能在这里审批生效。
</p>
</div> </div>
<div class="publish-box"> <div class="publish-box">
...@@ -1319,6 +1340,12 @@ const mainAction = async () => { ...@@ -1319,6 +1340,12 @@ const mainAction = async () => {
} }
} }
.step-action-hint {
margin: -4px 0 12px;
color: var(--muted);
font-size: 12px;
}
.step-fields { .step-fields {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
...@@ -1511,6 +1538,12 @@ const mainAction = async () => { ...@@ -1511,6 +1538,12 @@ const mainAction = async () => {
.actions { .actions {
margin-top: 12px; margin-top: 12px;
.publish-hint {
margin: 10px 0 0;
color: var(--muted);
font-size: 12px;
}
} }
} }
......
...@@ -40,7 +40,7 @@ const form = reactive({ ...@@ -40,7 +40,7 @@ const form = reactive({
owner: 'Charie', owner: 'Charie',
salaryRange: '', salaryRange: '',
priority: '中', priority: '中',
status: '招聘中', status: JOB_STATUS.PENDING_CONFIRM,
jdStatus: '草稿', jdStatus: '草稿',
jdVersion: 'v1 草稿', jdVersion: 'v1 草稿',
jd: '', jd: '',
...@@ -211,6 +211,7 @@ const submit = async (saveMode = 'update') => { ...@@ -211,6 +211,7 @@ const submit = async (saveMode = 'update') => {
const nextJob = normalizeJob({ const nextJob = normalizeJob({
...(existing.value || {}), ...(existing.value || {}),
id: existing.value?.id || uid('job'), id: existing.value?.id || uid('job'),
createdAt: existing.value?.createdAt || new Date().toISOString(),
title: form.title, title: form.title,
department: form.department, department: form.department,
category: form.category || '默认岗位', category: form.category || '默认岗位',
......
...@@ -104,6 +104,20 @@ const openingsFor = (job) => { ...@@ -104,6 +104,20 @@ const openingsFor = (job) => {
return diff > 0 ? `还差 ${diff} 人` : '目标已完成' return diff > 0 ? `还差 ${diff} 人` : '目标已完成'
} }
const formatCreatedAt = (value) => {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime())
? '—'
: date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
// 主按钮:进入岗位详情并定位到对应 Tab // 主按钮:进入岗位详情并定位到对应 Tab
const openDetail = (job, tab = 'overview') => { const openDetail = (job, tab = 'overview') => {
router.push({ path: `/jobs/${job.id}`, query: tab && tab !== 'overview' ? { tab } : {} }) router.push({ path: `/jobs/${job.id}`, query: tab && tab !== 'overview' ? { tab } : {} })
...@@ -133,6 +147,7 @@ const duplicateJob = (job) => { ...@@ -133,6 +147,7 @@ const duplicateJob = (job) => {
...job, ...job,
id: uid('job'), id: uid('job'),
title: `${job.title}(副本)`, title: `${job.title}(副本)`,
createdAt: new Date().toISOString(),
status: '草稿', status: '草稿',
jdStatus: JD_STATUS.DRAFT, jdStatus: JD_STATUS.DRAFT,
approvalStatus: '未发起', approvalStatus: '未发起',
...@@ -285,6 +300,7 @@ const deleteJob = async (job) => { ...@@ -285,6 +300,7 @@ const deleteJob = async (job) => {
{{ jobStateBadge(`JD · ${job.jdStatus || '草稿'}`).text }} {{ jobStateBadge(`JD · ${job.jdStatus || '草稿'}`).text }}
</span> </span>
</div> </div>
<div class="job-created-at">创建于 {{ formatCreatedAt(job.createdAt) }}</div>
</div> </div>
<div class="job-list-progress"> <div class="job-list-progress">
<div> <div>
...@@ -563,6 +579,12 @@ const deleteJob = async (job) => { ...@@ -563,6 +579,12 @@ const deleteJob = async (job) => {
display: flex; display: flex;
gap: 6px; gap: 6px;
} }
.job-created-at {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
}
} }
.job-state-badge { .job-state-badge {
......
import { extractKeywords } from './keywords.js' import { extractKeywords } from './keywords.js'
import { jobCandidates } from './links.js' import { jobCandidates } from './links.js'
import { analysisNeedsRefresh, quickMatchNeedsRefresh } from './analysis.js' import { analysisNeedsRefresh, quickMatchNeedsRefresh } from './analysis.js'
import { JOB_STATUS, JD_STATUS, APPROVAL_STATUS, CHANNEL_STATUS, MATCH_RULE_STATUS } from './jobStatus.js' import { JOB_STATUS, JD_STATUS, APPROVAL_STATUS, CHANNEL_STATUS } from './jobStatus.js'
export function buildJobCriteria(job = {}) { export function buildJobCriteria(job = {}) {
const split = (value) => const split = (value) =>
...@@ -66,6 +66,10 @@ export function jobPriority(job = {}) { ...@@ -66,6 +66,10 @@ export function jobPriority(job = {}) {
return job.priority === '高' ? 1 : 0 return job.priority === '高' ? 1 : 0
} }
export function hasJdContent(job = {}) {
return Boolean(job.jd || job.responsibilities || job.requirements || job.mustHave)
}
export function jobActionFor(job = {}) { export function jobActionFor(job = {}) {
if (job.status === JOB_STATUS.PAUSED) { if (job.status === JOB_STATUS.PAUSED) {
return { return {
...@@ -83,22 +87,28 @@ export function jobActionFor(job = {}) { ...@@ -83,22 +87,28 @@ export function jobActionFor(job = {}) {
tab: 'overview', tab: 'overview',
} }
} }
if (!job.jd || job.jdStatus === JD_STATUS.DRAFT) { if (!hasJdContent(job)) {
return { return {
title: '补齐岗位说明', title: '生成 JD 草稿',
description: '先明确职责与要求,后续发布和匹配才会更准确。', description: '先生成首版岗位说明和招聘标准,再进入用人确认。',
label: '完善 JD', label: '生成 JD 草稿',
tab: 'flow', tab: 'flow',
step: 1, step: 1,
} }
} }
if ([JD_STATUS.PENDING_CONFIRM, JD_STATUS.APPROVING].includes(job.jdStatus)) { if ([JD_STATUS.DRAFT, JD_STATUS.PENDING_CONFIRM, JD_STATUS.APPROVING].includes(job.jdStatus)) {
const approving = job.jdStatus === JD_STATUS.APPROVING
const confirmed = job.approvalStatus === APPROVAL_STATUS.DEPARTMENT_CONFIRMED
return { return {
title: '确认 JD 后发布', title: approving ? '确认 JD 生效' : confirmed ? '提交 JD 审批' : '确认 JD 后发布',
description: '岗位说明已准备好,先确认内容,再提交审批并进入发布。', description: approving
label: job.jdStatus === JD_STATUS.PENDING_CONFIRM ? '去确认 JD' : '查看审批', ? 'JD 已提交审批,审批通过后正式生效并进入发布。'
: confirmed
? '用人部门已确认 JD,下一步提交审批。'
: '草稿已生成,先确认内容,再提交审批并进入发布。',
label: approving ? '去确认生效' : confirmed ? '提交审批' : '去确认 JD',
tab: 'flow', tab: 'flow',
step: 2, step: approving ? 3 : 2,
} }
} }
if (job.jdStatus === JD_STATUS.EFFECTIVE && job.channelStatus && job.channelStatus !== CHANNEL_STATUS.SYNCED) { if (job.jdStatus === JD_STATUS.EFFECTIVE && job.channelStatus && job.channelStatus !== CHANNEL_STATUS.SYNCED) {
...@@ -164,41 +174,53 @@ export function jobWorkflow(job = {}, candidates = []) { ...@@ -164,41 +174,53 @@ export function jobWorkflow(job = {}, candidates = []) {
? 4 ? 4
: 3 : 3
: status === JD_STATUS.APPROVING : status === JD_STATUS.APPROVING
? 2 ? 3
: status === JD_STATUS.PENDING_CONFIRM : status === JD_STATUS.PENDING_CONFIRM
? 2 ? 2
: status === JD_STATUS.DRAFT : status === JD_STATUS.DRAFT
? 1 ? hasJdContent(job)
? 2
: 1
: 0 : 0
const next = let next
status === JD_STATUS.DRAFT if (!hasJdContent(job)) {
? { title: '完善 JD 草稿', desc: '先生成并检查岗位说明,再交给用人部门确认。', action: '生成 / 编辑 JD' } next = { title: '生成 JD 草稿', desc: '先生成岗位说明,再交给用人部门确认。', action: '生成 JD 草稿' }
: status === JD_STATUS.PENDING_CONFIRM } else if (status === JD_STATUS.DRAFT) {
? { const confirmed = job.approvalStatus === APPROVAL_STATUS.DEPARTMENT_CONFIRMED
title: '用人部门确认 JD', next = confirmed
desc: '确认岗位说明、职责和硬性条件,确认后才能提交审批。', ? { title: '提交 JD 审批', desc: '用人部门已确认 JD,下一步提交审批。', action: '提交审批' }
action: '确认 JD 内容', : { title: '用人部门确认 JD', desc: '草稿已生成,先确认内容,再提交审批。', action: '确认 JD 内容' }
} } else if (status === JD_STATUS.PENDING_CONFIRM) {
: status === JD_STATUS.APPROVING const confirmed = job.approvalStatus === APPROVAL_STATUS.DEPARTMENT_CONFIRMED
? { title: '跟进 JD 审批', desc: 'JD 已提交审批,审批通过后才能正式生效。', action: '查看审批状态' } next = confirmed
: status !== JD_STATUS.EFFECTIVE ? { title: '提交 JD 审批', desc: '用人部门已确认 JD,下一步提交审批。', action: '提交审批' }
? { title: '确认 JD 生效', desc: 'JD 未生效前,简历匹配只能作为草稿依据。', action: '确认生效' } : {
: staleCandidates.length title: '用人部门确认 JD',
? { desc: '确认岗位说明、职责和硬性条件,确认后才能提交审批。',
title: '重评关联候选人', action: '确认 JD 内容',
desc: `${staleCandidates.length} 位候选人的匹配/AI评估基于旧 JD,需要刷新。`, }
action: '批量重新匹配 / AI重评', } else if (status === JD_STATUS.APPROVING) {
} next = { title: '确认 JD 生效', desc: 'JD 已提交审批,审批通过后正式生效并进入发布。', action: '去确认生效' }
: related.length } else if (status !== JD_STATUS.EFFECTIVE) {
? { next = { title: '确认 JD 生效', desc: 'JD 未生效前,简历匹配只能作为草稿依据。', action: '确认生效' }
title: '继续推进候选人', } else if (staleCandidates.length) {
desc: `当前岗位已关联 ${related.length} 位候选人,可从简历库推进面试或 Offer。`, next = {
action: '查看候选人', title: '重评关联候选人',
} desc: `${staleCandidates.length} 位候选人的匹配/AI评估基于旧 JD,需要刷新。`,
: { action: '批量重新匹配 / AI重评',
title: '补充候选人', }
desc: 'JD 已生效但暂无关联候选人,下一步上传简历或从渠道导入。', } else if (related.length) {
action: '上传简历', next = {
} title: '继续推进候选人',
desc: `当前岗位已关联 ${related.length} 位候选人,可从简历库推进面试或 Offer。`,
action: '查看候选人',
}
} else {
next = {
title: '补充候选人',
desc: 'JD 已生效但暂无关联候选人,下一步上传简历或从渠道导入。',
action: '上传简历',
}
}
return { related, staleCandidates, steps, currentIndex, next } return { related, staleCandidates, steps, currentIndex, next }
} }
...@@ -114,6 +114,7 @@ export function normalizeJob(job = {}) { ...@@ -114,6 +114,7 @@ export function normalizeJob(job = {}) {
})) }))
return { return {
category: '默认岗位', category: '默认岗位',
createdAt: job.createdAt || job.created_at || '',
salaryRange: '', salaryRange: '',
jdVersion: job.jdStatus === '已生效' ? 'v1 当前生效' : 'v1 草稿', jdVersion: job.jdStatus === '已生效' ? 'v1 当前生效' : 'v1 草稿',
approvalStatus: '未发起', approvalStatus: '未发起',
......
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