Commit 22f1e111 authored by 李文光's avatar 李文光

feat: 需求9 粘贴JD自动结构化 + 招聘需求模板(含对内约束), 入口重排至新建流程

新增 backend/app/services/jd_import.py: build_requirement_template 生成《招聘需求模板》(发给用人部门填写,含对内约束:性别/年龄/试用期/竞业/验证), parse_pasted_jd 把填好的模板或整段 JD 拆成结构化岗位字段(LLM 优先,无 Key/失败回退本地小节切分); 新增接口 /api/jd/parse-pasted 与 /api/jd/requirement-template。前端: 岗位管理列表 hero 新增「从 JD / 需求模板创建」直达(自动打开粘贴), 新建岗位页顶部两步引导卡(01 生成需求模板 / 02 粘贴自动创建), JobEdit JD 页保留编辑老岗位的小入口, 对内约束回填岗位字段持久化(不上对外 JD)。测试: test_jd_import.py, 全量 103 passed。文档 requirements/CONTEXT 同步。
parent be7f8224
......@@ -22,6 +22,14 @@
- **认领 / 复活(Claim / Resurrect)**:把人才池中的候选人迁入本人工作区,重新开始招聘流程。
- **入池备注 / 入池时间 / 放入人**:记录候选人进入人才池的原因、时间与操作账号,便于复用与溯源。
## 岗位与 JD
- **招聘需求(Job Requirement)**:用人部门发起的岗位招聘要求总称。有完整 JD 时直接粘贴结构化;没有 JD 时用「需求模板」向用人部门收集,填完再粘回结构化。
- **需求模板(Requirement Template)**:系统生成的《招聘需求模板》(Markdown),发给用人部门填写——既含对外 JD 内容(岗位说明 / 职责 / 要求 / 硬性 / 加分 / 排除 / 关键词 / 胜任力),也含「对内约束」;填完/改完后粘回系统,由「粘贴 JD 结构化」还原成岗位字段。
- **粘贴 JD 结构化(Paste-to-Structured)**:把整段 JD 或填好的需求模板粘进岗位编辑页,自动拆成结构化岗位字段并回填(可仅补空或覆盖);AI 优先,无 AI 时按小节标题本地切分,不依赖手工逐段粘贴。
- **对内约束(Internal Constraints)**:岗位对内执行的要求——性别 / 年龄 / 试用期与转正标准 / 竞业协议 / 入职验证方式。属于用人部门需求,但严禁进入对外 JD 或 LLM prompt。
- **对外 JD(External JD)**:仅供招聘平台展示、面向候选人的岗位说明;由系统从对外字段组装并自动剥离一切对内约束(同「对内约束」)。
## 外部岗位与审批
- **外部岗位(External Job)**:用人部门经企微智能体(QwenPaw + recruit-grill)产出并经 OA 审批的招聘需求,不经招聘系统先行创建;区别于 HR 在系统内直接创建的岗位。
......
......@@ -11,6 +11,7 @@ from backend.app.security import verify_ingest_token
from backend.app.services.external_sync import sync_external_jd
from backend.app.services.jd_grill import advance as grill_advance
from backend.app.services.jd_grill import start as grill_start
from backend.app.services.jd_import import build_requirement_template, parse_pasted_jd
from backend.app.services.llm import _sanitize_job_for_external, analyze_resume, generate_jd_draft
from backend.app.services.qwenpaw_client import (
job_info_completeness,
......@@ -85,6 +86,29 @@ async def generate_jd_stream_route(payload: dict[str, Any]) -> StreamingResponse
)
@router.post("/jd/parse-pasted")
async def jd_parse_pasted_route(payload: dict[str, Any]) -> dict[str, Any]:
"""需求 9:粘贴 JD 自动结构化——LLM 优先,失败/无 Key 回退本地小节切分。
请求体: {"text": "用人部门填写的招聘需求模板或整段 JD 文本"}
响应: 结构化 job 字段(title/department/.../jd/职责/要求/硬性/加分/排除/关键词/
胜任力 + 对内约束 genderRestriction/ageRestriction/probation*/nonCompete/verification)
+ provider(llm:* 或 local-structured)。
"""
return await parse_pasted_jd(str(payload.get("text") or ""))
@router.post("/jd/requirement-template")
async def jd_requirement_template_route(payload: dict[str, Any]) -> dict[str, Any]:
"""需求 9:生成《招聘需求模板》(发给用人部门填,填完粘回 parse-pasted)。
请求体: {"job": {可选 title/department/headcount/workLocation/salaryRange 预填}}
响应: {"template": Markdown 文本}
"""
return {"template": build_requirement_template(payload.get("job") or {})}
@router.post("/jd/completeness")
async def jd_completeness_route(payload: dict[str, Any]) -> dict[str, Any]:
"""评估岗位信息是否足够直接生成 JD。信息不全时前端应切到聊天追问。"""
......
"""需求 9:粘贴 JD 自动结构化 + 招聘需求模板生成。
HR 访谈原话(P9/P11):「模板两个用途:已有 JD 直接粘进去生成;没有 JD 用人部门
要模板,把模板发他」——对应 docs/requirements_from_interview.md 需求 9。
约定:
- **需求模板**:发给用人部门填写的《招聘需求模板》,既采对外 JD 内容,也采对内约束
(性别/年龄/试用期/竞业/验证——这些也是用人部门的需求);对外 JD 只是发布给平台展示用。
- **粘贴解析**:把填好的模板或整段 JD 粘回来,LLM 优先拆成结构化 job 字段;
无 Key / 失败时回退**本地小节切分**(确定性,不 500)。
- 对内字段(genderRestriction/ageRestriction/probationPeriod/...)与对外字段分开存放,
与 `_INTERNAL_ONLY_JOB_KEYS` 约定一致,绝不上对外 JD / LLM prompt。
"""
import re
from typing import Any
from backend.app.config import get_settings
from backend.app.services.llm import _chat_json
# 对外 JD 内容字段(也是 /api/generate-jd 的 schema 子集)
EXTERNAL_KEYS = (
"jd",
"responsibilities",
"requirements",
"mustHave",
"niceToHave",
"knockout",
"matchKeywords",
"competency",
)
# 基本信息字段(粘贴 JD 里常见)
BASE_KEYS = ("title", "department", "headcount", "workLocation", "salaryRange")
# 对内约束字段(用人部门需求,但绝不上对外 JD / LLM)
INTERNAL_KEYS = (
"genderRestriction",
"ageRestriction",
"probationPeriod",
"probationCriteria",
"nonCompete",
"verification",
)
ALL_KEYS = BASE_KEYS + EXTERNAL_KEYS + INTERNAL_KEYS
# 模板章节(解析器按同一组标题回退切分,保证模板填完能被本地解析精确还原)
_TEMPLATE_SECTIONS: list[tuple[str, str]] = [
("base", "岗位基本信息(可留空让 HR 补)"),
("jd", "岗位说明(1-2 句定位 + 为什么招 / 解决什么问题)"),
("responsibilities", "核心职责(4-6 条)"),
("requirements", "任职要求(硬性门槛 / 核心能力 / 软素质)"),
("mustHave", "硬性条件(一票否决,缺一不可)"),
("niceToHave", "加分项"),
("knockout", "排除信号(简历看着像其实不对的画像)"),
("matchKeywords", "匹配关键词(6-10 个,逗号分隔)"),
("competency", "胜任力 / 素质模型(5-6 项)"),
("internal", "对内约束(HR 与用人部门确认用,绝不上对外 JD)"),
]
_TEMPLATE_BASE_LABELS = {
"title": "岗位名称",
"department": "所属部门",
"headcount": "招聘人数",
"workLocation": "工作地点",
"salaryRange": "薪资范围",
}
_ALIASES: dict[str, list[str]] = {
"jd": ["岗位说明", "职位说明", "职位概述", "岗位概述", "JD 简介", "jd简介", "职位简介", "招聘背景", "背景介绍"],
"responsibilities": ["工作职责", "岗位职责", "职位职责", "职责描述", "主要职责", "工作内容", "岗位描述", "职位描述"],
"requirements": ["任职要求", "职位要求", "岗位要求", "任职资格", "资格要求", "基本要求", "应聘要求"],
"mustHave": ["硬性条件", "硬性要求", "一票否决", "必备条件", "命脉"],
"niceToHave": ["加分项", "优先条件", "优先考虑", "加分"],
"knockout": ["排除信号", "淘汰信号", "淘汰项", "排除项"],
"matchKeywords": ["匹配关键词", "关键词"],
"competency": ["胜任力", "素质模型", "能力模型", "软素质"],
"internal": ["对内约束", "内部约束"],
}
_HEADING_PREFIX = re.compile(r"^\s*(?:#{1,6}\s*)?(?:\d+\s*[.、.))]?\s*)?")
def _clean_heading(line: str) -> str:
text = _HEADING_PREFIX.sub("", line).strip()
text = re.sub(r"^[\u26a0\ufe0f!!☆★\s]+", "", text)
return text.strip("【】[]()() \t").strip()
def _clean_bullet(line: str) -> str:
text = line.strip()
text = re.sub(r"^[-•·*▪◦>]+\s*", "", text)
text = re.sub(r"^(\d+\s*[.、).)]|\(\d+\)|(\d+)|[①②③④⑤⑥⑦⑧⑨⑩⑪⑫]+)\s*", "", text)
return text.strip()
def _label_line_value(line: str, label: str) -> str | None:
pattern = re.compile(rf"^\s*[-•·*#>\d.、\s]*{re.escape(label)}\s*[::]\s*(.+)$")
match = pattern.match(line)
if not match:
return None
value = _clean_bullet(match.group(1)).strip()
return value or None
# ---------------------------------------------------------------------------
# 需求模板生成
# ---------------------------------------------------------------------------
def build_requirement_template(job: dict[str, Any] | None = None) -> str:
"""生成发给用人部门的《招聘需求模板》Markdown 文本(纯本地,不调 LLM)。"""
job = job or {}
base = {
"title": str(job.get("title") or ""),
"department": str(job.get("department") or ""),
"headcount": str(job.get("headcount") or ""),
"workLocation": str(job.get("workLocation") or job.get("location") or ""),
"salaryRange": str(job.get("salaryRange") or ""),
}
lines = [
"# 招聘需求模板",
"",
"> 用途:给用人部门填写招聘需求用。填完/改完后,把整份内容粘回招聘系统",
"> 「粘贴 JD 自动结构化」,系统会自动拆成岗位各字段。带 ⚠️ 的内部约束只会留作对内执行,不会出现在对外 JD。",
"",
"## 岗位基本信息(可留空让 HR 补)",
]
for key, label in _TEMPLATE_BASE_LABELS.items():
lines.append(f"- {label}:{base.get(key, '')}")
for _, heading in _TEMPLATE_SECTIONS[1:]:
lines.extend(["", f"## {heading}", "", ""])
lines.extend(
[
"- 性别要求:不限",
"- 年龄要求:不限",
"- 试用期(时长 + 转正标准):",
"- 竞业协议:",
"- 入职验证方式:",
]
)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# 粘贴 JD 结构化解析
# ---------------------------------------------------------------------------
def _match_section_heading(line: str) -> str | None:
"""识别某行是否是 JD 小节标题,返回目标 key;非标题返回 None。"""
cleaned = _clean_heading(line)
if not cleaned or len(cleaned) > 36:
return None
low = cleaned.lower()
# 模板编号标题(别名直接开头,如「核心职责(4-6 条)」)
for key, label in _TEMPLATE_SECTIONS:
if key in ("base", "internal"):
continue
short = label.split("(")[0].strip()
if short and low.startswith(short.lower()):
return key
# 通用 JD 标题(如「岗位职责:」「【任职要求】」「1、工作内容」)
for key, aliases in _ALIASES.items():
for alias in aliases:
if low.startswith(alias.lower()):
return key
return None
def _split_by_headings(text: str) -> dict[str, list[str]]:
sections: dict[str, list[str]] = {}
current: str | None = None
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
key = _match_section_heading(line)
if key:
current = key
sections.setdefault(current, [])
# 「加分项:有微前端经验」这类标题与内容同行的行,冒号后内容并入该节
rest = re.split(r"[::]", line, maxsplit=1)[1].strip() if re.search(r"[::]", line) else ""
if rest:
sections[current].append(rest)
continue
if current:
sections[current].append(line)
return sections
def _parse_base_info(text: str) -> dict[str, str]:
result: dict[str, str] = {}
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
for key, label in _TEMPLATE_BASE_LABELS.items():
if key in result:
continue
value = _label_line_value(line, label)
if value:
result[key] = value
if "headcount" not in result:
match = re.search(r"(?:招聘|招)\s*(\d+)\s*人", line)
if match:
result["headcount"] = match.group(1)
return result
def _parse_internal(lines: list[str]) -> dict[str, str]:
result: dict[str, str] = {}
for line in lines:
value = _label_line_value(line, "性别要求") or _label_line_value(line, "性别")
if value:
result["genderRestriction"] = value
value = _label_line_value(line, "年龄要求") or _label_line_value(line, "年龄")
if value:
result["ageRestriction"] = value
value = _label_line_value(line, "试用期(时长 + 转正标准)") or _label_line_value(line, "试用期")
if value:
months = re.search(r"(\d+(?:\.\d+)?)\s*个月", value)
if months:
result["probationPeriod"] = f"{months.group(1)}个月"
result["probationCriteria"] = value
value = _label_line_value(line, "竞业协议") or _label_line_value(line, "竞业")
if value:
result["nonCompete"] = value[:80]
value = (
_label_line_value(line, "入职验证方式")
or _label_line_value(line, "验证方式")
or _label_line_value(line, "背调")
)
if value:
result["verification"] = value
return result
def _join_section(lines: list[str], limit: int = 3000) -> str:
cleaned: list[str] = []
for line in lines:
text = _clean_bullet(line)
if text and text not in cleaned:
cleaned.append(text)
return "\n".join(cleaned)[:limit]
def parse_pasted_jd_local(text: str) -> dict[str, Any]:
"""无 LLM 时的本地回退:按小节标题切分 + 标签行取值,确定性输出。"""
result: dict[str, Any] = {key: "" for key in ALL_KEYS}
result["provider"] = "local-structured"
text = str(text or "").strip()
if not text:
return result
sections = _split_by_headings(text)
result.update(_parse_base_info(text))
result.update(_parse_internal(sections.get("internal") or []))
if sections.get("jd"):
result["jd"] = _join_section(sections["jd"], limit=500)
for key in ("responsibilities", "requirements", "mustHave", "niceToHave", "knockout", "competency"):
if sections.get(key):
result[key] = _join_section(sections[key])
if sections.get("matchKeywords"):
result["matchKeywords"] = _join_section(sections["matchKeywords"], limit=500)
return result
async def _parse_pasted_jd_llm(text: str, settings: Any) -> dict[str, Any] | None:
if not settings.effective_llm_api_key:
return None
user = f"""你是资深招聘 JD 结构化助手。把用人部门填写的招聘需求模板或整段 JD 文本,
拆成结构化 JSON 字段(只返回 JSON,不要 Markdown 围栏、不要多余解释)。键必须为:
{", ".join(ALL_KEYS)}
拆解规则:
1. 不编造:原文没有的字段一律返回空字符串;不确定的宁可留空,也不要猜测。
2. jd = 岗位说明 1-2 句(定位 + 解决什么问题);原文没有概述段则留空。
3. responsibilities / requirements / mustHave / niceToHave / knockout / competency:
按原文对应小节逐条要点抽取,多条用换行分隔,去掉序号与列表符号。
4. matchKeywords:6-10 个岗位关键词,用中文逗号分隔。
5. title/department/workLocation/salaryRange/headcount:从基本信息或正文提取;
headcount 只输出数字,没有则空字符串。
6. genderRestriction / ageRestriction / probationPeriod / probationCriteria /
nonCompete / verification 是对内约束:只在原文**明确出现**(如「性别要求:男」、
「年龄 35 以下」「试用期 3 个月」「需签竞业」「背调」)时提取;原文没提就空字符串,
绝不猜测成「不限」。
7. 不输出候选人或无关隐私信息。
待解析文本:
{str(text or "")[:20000]}"""
parsed = await _chat_json(
settings,
"你是企业招聘系统中的资深JD结构化助手。只返回JSON,不要输出Markdown。",
user,
temperature=0,
)
if not parsed:
return None
result = {key: str(parsed.get(key) or "").strip() for key in ALL_KEYS}
result["provider"] = f"llm:{settings.effective_llm_model}"
return result
async def parse_pasted_jd(text: str) -> dict[str, Any]:
"""粘贴 JD 结构化解析:LLM 优先,失败 / 无 Key 回退本地小节切分。"""
settings = get_settings()
parsed = await _parse_pasted_jd_llm(text, settings)
if parsed is None:
parsed = parse_pasted_jd_local(text)
return parsed
"""需求 9:粘贴 JD 自动结构化 + 招聘需求模板生成的单测。
覆盖:模板章节(含对内约束)、本地小节切分解析(模板式与通用 JD 式)、
无 Key 接口回退、LLM 优先路径字段保留。
"""
import asyncio
import pytest
from backend.app.services.jd_import import (
ALL_KEYS,
build_requirement_template,
parse_pasted_jd_local,
)
TEMPLATE_FILLED = """## 岗位基本信息(可留空让 HR 补)
- 岗位名称:大客户销售经理
- 所属部门:市场部
- 招聘人数:1
- 工作地点:深圳
- 薪资范围:20-35k
## 岗位说明(1-2 句定位 + 为什么招 / 解决什么问题)
负责华南区域电力大客户的开发与成交,支撑新能源业务增长。
## 核心职责(4-6 条)
1、制定区域客户开拓计划并推动落地
2、主导大客户投标、报价与合同谈判
## 任职要求(硬性门槛 / 核心能力 / 软素质)
本科及以上,5 年以上大客户销售经验
具备电力行业客户资源优先
## 硬性条件(一票否决,缺一不可)
有独立成交 500 万以上项目的记录
## 加分项
有售电公司经验
## 排除信号(简历看着像其实不对的画像)
只有渠道分销经验没有直销大客户经验
## 匹配关键词(6-10 个,逗号分隔)
大客户, 售电, 投标, 电力
## 胜任力 / 素质模型(5-6 项)
专业能力
业务理解
沟通协同
抗压推进
## 对内约束(HR 与用人部门确认用,绝不上对外 JD)
- 性别要求:不限
- 年龄要求:30-40
- 试用期(时长 + 转正标准):3 个月,独立拿下首个大客户
- 竞业协议:需签
- 入职验证方式:背调 + 前雇主电话核实
"""
GENERIC_JD = """高级前端工程师(杭州)
岗位职责:
1、负责公司 Web 平台前端架构与工程化建设;
2、与产品、后端协作推进需求落地。
任职要求:
1、本科及以上,5 年以上前端经验;
2、熟悉 Vue3 / TypeScript,有大型项目经验优先。
加分项:有微前端或低代码平台经验。
"""
def test_template_contains_external_and_internal_sections():
tpl = build_requirement_template(
{"title": "Java开发工程师", "department": "技术部", "headcount": 2, "workLocation": "深圳", "salaryRange": "25-40k"}
)
for heading in (
"岗位基本信息",
"岗位说明",
"核心职责",
"任职要求",
"硬性条件",
"加分项",
"排除信号",
"匹配关键词",
"胜任力",
"对内约束",
"性别要求",
"竞业协议",
"入职验证方式",
):
assert heading in tpl, f"模板缺少章节:{heading}"
assert "高级前端" not in tpl # 不预填无关岗位
assert "Java开发工程师" in tpl
def test_local_parse_template_roundtrip():
parsed = parse_pasted_jd_local(TEMPLATE_FILLED)
assert parsed["provider"] == "local-structured"
assert parsed["title"] == "大客户销售经理"
assert parsed["department"] == "市场部"
assert parsed["headcount"] == "1"
assert parsed["workLocation"] == "深圳"
assert parsed["salaryRange"] == "20-35k"
assert "开发与成交" in parsed["jd"]
assert "合同谈判" in parsed["responsibilities"]
assert "5 年以上大客户销售经验" in parsed["requirements"]
assert "500 万" in parsed["mustHave"]
assert "售电公司经验" in parsed["niceToHave"]
assert "渠道分销" in parsed["knockout"]
assert "售电" in parsed["matchKeywords"]
assert "抗压推进" in parsed["competency"]
assert parsed["genderRestriction"] == "不限"
assert parsed["ageRestriction"] == "30-40"
assert parsed["probationPeriod"] == "3个月"
assert "独立拿下首个大客户" in parsed["probationCriteria"]
assert parsed["nonCompete"] == "需签"
assert "背调" in parsed["verification"]
def test_local_parse_generic_jd_without_internal():
parsed = parse_pasted_jd_local(GENERIC_JD)
assert parsed["title"] == ""
assert "工程化" in parsed["responsibilities"]
assert "Vue3" in parsed["requirements"]
assert "微前端" in parsed["niceToHave"]
for key in ("genderRestriction", "ageRestriction", "probationCriteria", "nonCompete", "verification"):
assert parsed[key] == "", f"通用 JD 不应猜出对内字段:{key}"
assert parsed["provider"] == "local-structured"
def test_local_parse_empty_text_returns_empty_defaults():
parsed = parse_pasted_jd_local("")
for key in ALL_KEYS:
assert parsed[key] == ""
assert parsed["provider"] == "local-structured"
@pytest.fixture()
def llm_enabled(monkeypatch):
"""隔离环境 + 假 LLM Key:让 jd_import 走到 _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_parse_pasted_jd_llm_first(llm_enabled, monkeypatch):
"""LLM 可用时走 llm 路径且保留对内字段;同时落在 _chat_json 的请求里。"""
captured = {}
async def fake_chat_json(settings, system, user, *, temperature=0):
captured["user"] = user
return {
"title": "大客户销售经理",
"department": "市场部",
"headcount": "1",
"workLocation": "深圳",
"salaryRange": "20-35k",
"jd": "负责电力大客户开发。",
"responsibilities": "制定客户计划\n推动投标",
"requirements": "本科,5 年经验",
"mustHave": "独立成交 500 万项目",
"niceToHave": "售电经验",
"knockout": "无直销经验",
"matchKeywords": "大客户, 售电",
"competency": "专业能力\n抗压推进",
"genderRestriction": "不限",
"ageRestriction": "",
"probationPeriod": "3个月",
"probationCriteria": "3 个月,独立拿下首个客户",
"nonCompete": "需签",
"verification": "背调",
}
monkeypatch.setattr("backend.app.services.jd_import._chat_json", fake_chat_json)
from backend.app.services.jd_import import parse_pasted_jd
result = asyncio.run(parse_pasted_jd("一份招聘需求文本"))
assert result["provider"].startswith("llm:")
assert result["title"] == "大客户销售经理"
assert result["mustHave"] == "独立成交 500 万项目"
assert result["nonCompete"] == "需签"
assert result["genderRestriction"] == "不限"
# prompt 约束:不编造、对内字段只在明确出现时提取
assert "不编造" in captured["user"]
assert "genderRestriction" in captured["user"]
assert "对内约束" in captured["user"] or "背调" in captured["user"]
def test_parse_pasted_route_falls_back_to_local(client):
"""无 LLM Key 时接口回退本地小节切分,不 500。"""
resp = client.post("/api/jd/parse-pasted", json={"text": TEMPLATE_FILLED})
assert resp.status_code == 200
data = resp.json()
assert data["provider"] == "local-structured"
assert data["title"] == "大客户销售经理"
assert data["nonCompete"] == "需签"
assert data["genderRestriction"] == "不限"
def test_requirement_template_route(client):
resp = client.post("/api/jd/requirement-template", json={"job": {"title": "售电交易员", "department": "交易部"}})
assert resp.status_code == 200
template = resp.json()["template"]
assert "售电交易员" in template
assert "对内约束" in template
assert "粘贴 JD 自动结构化" in template
......@@ -8,6 +8,8 @@
>
> **2026-09-03(工作区,需求 17)**:风险点固定维度已实现——新增 backend/app/services/risk_profile.py(跳槽频率/竞业协议/职业空窗/高层职业道德四维本地规则),local-structured 与 LLM 评估统一合并 riskProfile,LLM prompt 限定 risks 固定四维,前端「评估」Tab 新增固定维度面板;详见需求 17 条目。
>
> **2026-09-04 增量(需求 9)**:粘贴 JD 自动结构化 + 招聘需求模板已实现——新增 backend/app/services/jd_import.py(需求模板生成 + LLM 优先/本地小节切分解析),接口 /api/jd/parse-pasted 与 /api/jd/requirement-template,JobEdit「JD 与匹配标准」页新增「粘贴 JD 自动结构化 / 生成需求模板」入口;模板含对内约束(性别/年龄/试用期/竞业/验证),解析后回填岗位对内字段,绝不上对外 JD。详见需求 9 条目。
>
> 本次复核相对上一版(810e280)新增提交:`ddc5340`(候选人详情抽屉+档案编辑)、`07310dd`(简历库重构+保存原简历+防重复)、`6248f27`/`fc0aa5c`(简历解析真正 LLM 优先并移除脱敏)、`ec9499f`(弹窗蒙层/ESC 不关闭)。
>
> 依据代码现状核实:
......@@ -82,8 +84,14 @@ HR 原话要点:
**需求 9:需求模板(有 JD 粘贴 / 无 JD 用模板 / AI 调研)**
- 原话:「模板两个用途:已有 JD 直接粘进去生成;没有 JD 用人部门要模板,把模板发他;还有 AI 调研场景」(P9/P11)
- 现状:✅「逼问式访谈(AI 调研)」已实现:`jd_grill.py` 确定式状态机逐题采集(岗位定位/职责/要求/敏感/竞业等),新建岗位的「AI 辅助创建」直接进访谈;生成完自动补齐 JD 各段。🔄 **「粘贴完整 JD → 自动结构化」入口缺失**:JD 与匹配标准 Tab 是手填 textarea,没有"粘贴整段 JD,自动拆出职责/要求/硬性/加分/关键词"的入口。
- 建议(🆕):加"粘贴 JD 自动结构化"按钮(走现有 LLM 结构化解析,复用 `generate-jd` 的 JSON schema),满足"有 JD 直接粘"场景。
- 现状:✅ **已实现(2026-09-04)**。「粘贴 JD 自动结构化」+「招聘需求模板」闭环落地:
- 新增 `backend/app/services/jd_import.py`:`build_requirement_template` 生成《招聘需求模板》(纯本地),`parse_pasted_jd` 解析填好的模板或整段 JD——**LLM 优先**输出固定 schema,无 Key/失败时回退**本地小节切分**(确定性,不 500);
- 模板与解析共用同一组小节标题(岗位说明/核心职责/任职要求/硬性条件/加分项/排除信号/匹配关键词/胜任力/对内约束),用人部门填完粘回即可精确还原;
- **模板含对内约束**(性别/年龄/试用期与转正标准/竞业协议/入职验证方式——用人部门需求),解析后存入岗位对内字段(`genderRestriction/ageRestriction/probationPeriod/probationCriteria/nonCompete/verification`),沿用 `_INTERNAL_ONLY_JOB_KEYS` 约定,对外 JD 只用于平台展示并自动剥离;
- 入口:JobEdit「JD 与匹配标准」Tab 顶部「📋 粘贴 JD 自动结构化 / 📄 生成需求模板」,新建与老岗位均可用;解析结果先预览(基本信息/对外内容/对内约束),可「仅补空」或「覆盖」回填表单,老岗位仍由 HR 决定保存方式,不自动升版;
- 接口:`POST /api/jd/parse-pasted``POST /api/jd/requirement-template`
- 测试:`backend/tests/test_jd_import.py`(模板章节/模板往返解析/通用 JD 解析/LLM 优先/接口回退),全量 103 passed。
- 建议(✅ 已落地):粘贴入口 + 模板生成(含对内约束)+ LLM/本地双解析已实现;可选增强:解析结果与现有岗位做差异比对后一键"迭代为 JD 新版本"、模板按岗位类型预置示例。
---
......@@ -225,7 +233,7 @@ HR 原话要点:
| P1 | 需求 17 风险点固定维度 | ✅ 已实现(2026-09-03) | 四维本地规则 + LLM prompt 对齐 + 前端「风险点评估」面板 |
| P1 | 需求 12 候选人标记 | 🔄 基本落地 | tags 枚举 + 详情编辑 + 简历库/人才池标签筛选 + 待跟进快捷筛选;独立周报视图待做 |
| P2 | 需求 4 岗位自动关闭 | ✅ 已实现(2026-09-03) | hired>=headcount 自动置「已招满」并记事件日志 |
| P2 | 需求 9 粘贴 JD 自动结构化 | 🔄 缺入口 | 复用现有 LLM 解析加一个按钮 |
| P2 | 需求 9 粘贴 JD 自动结构化 + 需求模板 | ✅ 已实现(2026-09-04) | 粘贴解析(LLM/本地)+ 需求模板(含对内约束)闭环 |
| P2 | 需求 22 简历库标签/统计 | 🔄 大部分落地 | 标签筛选/人才池视图/岗位维简历量已落地;批量移入待做 |
| P3 | 需求 23 插件字段修正 | 🔄 部分 | 补抓沟通职位/期望薪资 |
| P3 | 需求 21 入职材料三件套 | 🔄 部分 | 三张模板化 |
......@@ -246,4 +254,4 @@ HR 原话要点:
---
*整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。需求 14/17(自述优势约束、风险点固定维度)、需求 4 自动招满与岗位维简历量统计已于 2026-09-03 落地;建议优先补齐:批量移入/复活(需求 22),并按需做需求 9/3/25 等小改动。*
*整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。需求 14/17(自述优势约束、风险点固定维度)、需求 4 自动招满、岗位维简历量统计已于 2026-09-03 落地;需求 9(粘贴 JD 自动结构化 + 招聘需求模板,含对内约束)已于 2026-09-04 落地;建议优先补齐:批量移入/复活(需求 22),并按需做需求 3/25 等小改动。*
......@@ -122,6 +122,20 @@ export async function jdCompleteness(payload) {
return data
}
// 需求 9:粘贴 JD 自动结构化(LLM 优先,本地小节切分兜底)
export async function jdParsePasted(text) {
const { data } = await http.post('/api/jd/parse-pasted', { text })
if (data.error) throw new Error(data.error)
return data
}
// 需求 9:生成《招聘需求模板》(发给用人部门填,填完粘回 jdParsePasted)
export async function jdRequirementTemplate(job = {}) {
const { data } = await http.post('/api/jd/requirement-template', { job })
if (data.error) throw new Error(data.error)
return data
}
// 确定式「逼问式访谈」问答状态机:一次一问 + 推荐答案。
export async function jdGrillStart(payload) {
const { data } = await http.post('/api/jd/grill/start', payload)
......
<script setup>
// 需求 9:粘贴 JD 自动结构化 + 招聘需求模板生成(对话框,供 JobEdit JD 页使用)
import { computed, ref, watch } from 'vue'
import { jdParsePasted, jdRequirementTemplate } from '@/api/recruitment'
import { showToast } from '@/utils/toast'
const props = defineProps({
modelValue: { type: Boolean, default: false },
// 打开时默认激活哪个页签:paste | template
initialMode: { type: String, default: 'paste' },
// 当前表单的岗位基本信息快照,用于生成模板时预填
job: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:modelValue', 'applied'])
const tab = ref('paste')
const pasteText = ref('')
const applyMode = ref('fill') // fill = 仅补空字段;overwrite = 覆盖已填字段
const parsing = ref(false)
const parsed = ref(null)
const building = ref(false)
const templateText = ref('')
const dialogOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
watch(
() => props.modelValue,
(open) => {
if (!open) return
tab.value = props.initialMode === 'template' ? 'template' : 'paste'
parsed.value = null
}
)
// 可回填字段元数据(与后端 jd_import 的 ALL_KEYS 对应;kind 用于预览标签)
const FIELD_META = [
{ key: 'title', label: '岗位名称', kind: 'base' },
{ key: 'department', label: '所属部门', kind: 'base' },
{ key: 'headcount', label: '招聘人数', kind: 'base' },
{ key: 'workLocation', label: '工作地点', kind: 'base' },
{ key: 'salaryRange', label: '薪资范围', kind: 'base' },
{ key: 'jd', label: '岗位说明 / JD 概述', kind: 'content' },
{ key: 'responsibilities', label: '核心职责', kind: 'content' },
{ key: 'requirements', label: '任职要求', kind: 'content' },
{ key: 'mustHave', label: '硬性条件', kind: 'content' },
{ key: 'niceToHave', label: '加分项', kind: 'content' },
{ key: 'knockout', label: '排除信号', kind: 'content' },
{ key: 'matchKeywords', label: '匹配关键词', kind: 'content' },
{ key: 'competency', label: '胜任力 / 素质模型', kind: 'content' },
{ key: 'genderRestriction', label: '性别要求(对内)', kind: 'internal' },
{ key: 'ageRestriction', label: '年龄要求(对内)', kind: 'internal' },
{ key: 'probationPeriod', label: '试用期(对内)', kind: 'internal' },
{ key: 'probationCriteria', label: '转正标准(对内)', kind: 'internal' },
{ key: 'nonCompete', label: '竞业协议(对内)', kind: 'internal' },
{ key: 'verification', label: '入职验证(对内)', kind: 'internal' },
]
const previewRows = computed(() => {
const data = parsed.value || {}
return FIELD_META.filter((meta) => String(data[meta.key] || '').trim()).map((meta) => ({
...meta,
snippet: String(data[meta.key]).replace(/\s+/g, ' ').slice(0, 60),
}))
})
const kindTag = (kind) => (kind === 'internal' ? 'warning' : kind === 'content' ? '' : 'info')
const kindText = (kind) => (kind === 'internal' ? '对内不对外' : kind === 'content' ? '对外 JD 内容' : '基本信息')
const runParse = async () => {
const text = pasteText.value.trim()
if (!text) {
showToast('请先粘贴需求模板或 JD 文本', 'warning')
return
}
parsing.value = true
try {
const data = await jdParsePasted(text)
parsed.value = data
const count = FIELD_META.filter((meta) => String(data[meta.key] || '').trim()).length
const provider = String(data.provider || '').startsWith('llm:') ? 'AI 解析' : '本地规则解析'
showToast(`${provider}完成,识别到 ${count} 个字段,请核对后回填`, 'success')
} catch (error) {
showToast(error.message || '解析失败', 'error')
} finally {
parsing.value = false
}
}
const apply = () => {
if (!parsed.value) return
emit('applied', { fields: parsed.value, mode: applyMode.value })
dialogOpen.value = false
pasteText.value = ''
parsed.value = null
}
const buildTemplate = async () => {
building.value = true
try {
const job = {
title: props.job.title || '',
department: props.job.department || '',
headcount: props.job.headcount || '',
workLocation: props.job.workLocation || '',
salaryRange: props.job.salaryRange || '',
}
const data = await jdRequirementTemplate(job)
templateText.value = data.template || ''
showToast('需求模板已生成,可复制发给用人部门', 'success')
} catch (error) {
showToast(error.message || '模板生成失败', 'error')
} finally {
building.value = false
}
}
const copyTemplate = async () => {
if (!templateText.value) return
try {
await navigator.clipboard.writeText(templateText.value)
showToast('模板已复制,请发给用人部门填写', 'success')
} catch {
showToast('复制失败,请手动全选复制', 'error')
}
}
const close = () => {
dialogOpen.value = false
parsed.value = null
}
</script>
<template>
<el-dialog
:model-value="dialogOpen"
title="JD 导入 · 粘贴结构化 / 需求模板"
width="780px"
top="6vh"
:close-on-click-modal="false"
@update:model-value="dialogOpen = $event"
@close="close"
>
<el-tabs v-model="tab">
<el-tab-pane label="粘贴 JD 自动结构化" name="paste">
<p class="jd-import-hint">
把用人部门填好的《招聘需求模板》或整段 JD 粘进来,自动拆出 岗位说明 / 职责 / 要求 / 硬性 / 加分 / 排除 /
关键词 / 胜任力 与 对内约束(性别年龄试用期竞业验证,只对内执行)。
</p>
<el-input
v-model="pasteText"
type="textarea"
:rows="10"
placeholder="粘贴招聘需求模板或 JD 全文…(无 AI Key 时按小节标题本地切分,推荐使用系统模板填写)"
/>
<div class="jd-import-toolbar">
<el-radio-group v-model="applyMode" size="small">
<el-radio-button label="fill">仅补空字段</el-radio-button>
<el-radio-button label="overwrite">覆盖已填字段</el-radio-button>
</el-radio-group>
<el-button type="primary" :loading="parsing" @click="runParse">解析</el-button>
</div>
<div v-if="previewRows.length" class="jd-parse-preview">
<div class="jd-parse-preview-head">
<b>解析结果预览</b>
<span>回填时{{ applyMode === 'fill' ? '只补空字段,不覆盖手填内容' : '会覆盖同名已填字段' }}</span>
</div>
<div v-for="row in previewRows" :key="row.key" class="jd-parse-row">
<el-tag :type="kindTag(row.kind)" size="small" effect="light">{{ kindText(row.kind) }}</el-tag>
<b>{{ row.label }}</b>
<span>{{ row.snippet }}</span>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="生成需求模板" name="template">
<p class="jd-import-hint">
没有现成 JD 时,用系统模板向用人部门收集需求(含对内约束,不会出现在对外 JD);
用人部门填完/改完后,粘回左侧「粘贴 JD 自动结构化」即可一键回填岗位。
</p>
<div class="jd-import-toolbar">
<el-button type="primary" :loading="building" @click="buildTemplate">生成模板</el-button>
<el-button :disabled="!templateText" @click="copyTemplate">复制模板</el-button>
</div>
<el-input
v-model="templateText"
type="textarea"
:rows="16"
readonly
placeholder="点击「生成模板」后,这里会展示可发给用人部门填写的模板…"
/>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button v-if="tab === 'paste' && previewRows.length" type="primary" @click="apply">
回填到岗位表单({{ previewRows.length }} 项)
</el-button>
<el-button @click="close">关闭</el-button>
</template>
</el-dialog>
</template>
<style lang="scss" scoped>
.jd-import-hint {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.6;
color: var(--muted);
}
.jd-import-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 10px 0 4px;
}
.jd-parse-preview {
margin-top: 10px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid var(--line);
border-radius: 10px;
.jd-parse-preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
b {
font-size: 13px;
}
span {
font-size: 12px;
color: var(--muted);
}
}
.jd-parse-row {
display: grid;
grid-template-columns: 130px 1fr;
align-items: baseline;
gap: 8px;
padding: 5px 0;
& + .jd-parse-row {
border-top: 1px dashed rgba(24, 32, 51, 0.06);
}
b {
font-size: 13px;
}
span {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>
<script setup>
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { useRecruitmentStore } from '@/stores/recruitment'
......@@ -19,6 +19,7 @@ import {
} from '@/utils/jobStatus'
import JdGrillModal from './JdGrillModal.vue'
import JdGeneratePanel from './JdGeneratePanel.vue'
import JdImportModal from './JdImportModal.vue'
const props = defineProps({
jobId: { type: String, default: '' },
......@@ -26,6 +27,7 @@ const props = defineProps({
const store = useRecruitmentStore()
const router = useRouter()
const route = useRoute()
const existing = computed(() => (props.jobId ? store.state.jobs.find((item) => item.id === props.jobId) || null : null))
......@@ -57,6 +59,14 @@ const form = reactive({
hireReason: '',
startDate: '',
reportTo: '',
internal: {
genderRestriction: '',
ageRestriction: '',
probationPeriod: '',
probationCriteria: '',
nonCompete: '',
verification: '',
},
})
// 新建岗位必填项:与表单里 required 标记保持一致,缺失时阻止提交并跳到对应 Tab
......@@ -97,6 +107,14 @@ const syncForm = () => {
form.hireReason = job?.hireReason || ''
form.startDate = job?.startDate || ''
form.reportTo = job?.reportTo || ''
form.internal = {
genderRestriction: job?.genderRestriction || '',
ageRestriction: job?.ageRestriction || '',
probationPeriod: job?.probationPeriod || '',
probationCriteria: job?.probationCriteria || '',
nonCompete: job?.nonCompete || '',
verification: job?.verification || '',
}
}
syncForm()
......@@ -236,6 +254,12 @@ const submit = async (saveMode = 'update') => {
hireReason: form.hireReason || '',
startDate: form.startDate || '',
reportTo: form.reportTo || '',
genderRestriction: form.internal.genderRestriction,
ageRestriction: form.internal.ageRestriction,
probationPeriod: form.internal.probationPeriod,
probationCriteria: form.internal.probationCriteria,
nonCompete: form.internal.nonCompete,
verification: form.internal.verification,
matchRuleStatus: shouldIterate
? '待确认'
: form.matchKeywords || form.mustHave || form.competency
......@@ -272,6 +296,77 @@ const copyVersion = (version) => {
}
const activeTab = ref('base')
// 需求 9:粘贴 JD 自动结构化 / 需求模板(JdImportModal)
const importOpen = ref(false)
const importMode = ref('paste')
const openImport = (mode) => {
importMode.value = mode
importOpen.value = true
}
const applyParsed = ({ fields = {}, mode = 'fill' }) => {
const contentKeys = [
'jd',
'responsibilities',
'requirements',
'mustHave',
'niceToHave',
'knockout',
'matchKeywords',
'competency',
]
const baseKeys = ['title', 'department', 'workLocation', 'salaryRange']
const internalKeys = [
'genderRestriction',
'ageRestriction',
'probationPeriod',
'probationCriteria',
'nonCompete',
'verification',
]
let applied = 0
const setIf = (key, value) => {
if (value === undefined || value === null) return false
const text = String(value).trim()
if (!text) return false
const current = String(form[key] ?? '').trim()
if (mode === 'overwrite' || !current) {
form[key] = text
return true
}
return false
}
;[...baseKeys, ...contentKeys].forEach((key) => {
if (setIf(key, fields[key])) applied += 1
})
const hc = String(fields.headcount || '').trim()
const hcNum = Number(hc)
if (hc && Number.isFinite(hcNum) && hcNum >= 1 && (mode === 'overwrite' || !form.headcount)) {
form.headcount = hcNum
applied += 1
}
internalKeys.forEach((key) => {
const value = String(fields[key] || '').trim()
if (!value) return
const current = String(form.internal[key] || '').trim()
if (mode === 'overwrite' || !current) {
form.internal[key] = value
applied += 1
}
})
showToast(
`已回填 ${applied} 个字段${mode === 'fill' ? '(仅补空,未覆盖手填内容)' : ''};对内约束已存入岗位,不会出现在对外 JD`,
'success'
)
}
// 从岗位列表「从 JD / 需求模板创建」进入新建页时,自动打开对应功能
if (isNew.value && ['paste', 'template'].includes(String(route.query.import || ''))) {
openImport(String(route.query.import))
router.replace({ query: {} })
}
</script>
<template>
......@@ -284,6 +379,33 @@ const activeTab = ref('base')
</div>
</div>
<section v-if="isNew" class="jd-entry">
<div class="jd-entry-head">
<span class="jd-entry-kicker">用人部门想招人?</span>
<h3>把他们的需求变成岗位,两步搞定</h3>
<p>有现成 JD 就直接粘贴自动生成;没有 JD 就先发《需求模板》让用人部门填写,填好再粘回来。</p>
</div>
<div class="jd-entry-steps">
<div class="jd-entry-step">
<span class="jd-entry-no">01</span>
<div class="jd-entry-body">
<b>还没有 JD?先发模板给用人部门收集</b>
<p>生成《招聘需求模板》→ 复制发给用人部门 → 对方按章节填写后交回。</p>
<el-button size="small" plain @click="openImport('template')">📄 生成需求模板发给用人部门</el-button>
</div>
</div>
<div class="jd-entry-step">
<span class="jd-entry-no">02</span>
<div class="jd-entry-body">
<b>有 JD / 模板已填好?粘贴自动生成</b>
<p>整段粘进来 → 自动拆出基本信息、JD、匹配规则与对内约束 → 核对后即可创建岗位。</p>
<el-button size="small" type="primary" @click="openImport('paste')">📋 粘贴 JD / 需求,自动创建</el-button>
</div>
</div>
</div>
<p class="jd-entry-foot">也可以直接到下方「基础信息 / JD 与匹配标准」标签页手动填写。</p>
</section>
<section class="card">
<el-tabs v-model="activeTab" class="edit-tabs">
<!-- ============ Tab 1 基础信息 ============ -->
......@@ -349,6 +471,13 @@ const activeTab = ref('base')
<!-- ============ Tab 2 JD 与匹配标准 ============ -->
<el-tab-pane label="JD 与匹配标准" name="jd">
<div v-if="!isNew" class="jd-tools">
<span class="jd-tools-hint">用人部门改了 JD?粘贴整段更新,或重发模板收集</span>
<div class="jd-tools-btns">
<el-button size="small" plain @click="openImport('paste')">📋 粘贴 JD 更新字段</el-button>
<el-button size="small" plain @click="openImport('template')">📄 重发需求模板</el-button>
</div>
</div>
<el-form label-position="top">
<el-form-item label="岗位说明 / JD" required>
<el-input v-model="form.jd" type="textarea" :rows="4" />
......@@ -377,6 +506,33 @@ const activeTab = ref('base')
</el-form-item>
</div>
</el-form>
<div class="internal-card">
<div class="internal-card-head">
<b>对内约束(不对外发布)</b>
<span>性别 / 年龄 / 试用期 / 竞业 / 验证只做对内执行,对外 JD 由系统自动剥离</span>
</div>
<el-form label-position="top" class="form two">
<el-form-item label="性别要求">
<el-input v-model="form.internal.genderRestriction" placeholder="不限 / 男 / 女" />
</el-form-item>
<el-form-item label="年龄要求">
<el-input v-model="form.internal.ageRestriction" placeholder="不限 或 如 30-40" />
</el-form-item>
<el-form-item label="试用期">
<el-input v-model="form.internal.probationPeriod" placeholder="如 3 个月" />
</el-form-item>
<el-form-item label="转正标准">
<el-input v-model="form.internal.probationCriteria" placeholder="转正看什么 / 交付什么" />
</el-form-item>
<el-form-item label="竞业协议">
<el-input v-model="form.internal.nonCompete" placeholder="需签 / 不需要 / 竞对范围说明" />
</el-form-item>
<el-form-item label="入职验证方式">
<el-input v-model="form.internal.verification" placeholder="简历看什么 / 面试怎么验 / 是否背调" />
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<!-- ============ Tab 3 状态与版本 ============ -->
......@@ -439,6 +595,7 @@ const activeTab = ref('base')
<JdGrillModal />
<JdGeneratePanel />
<JdImportModal v-model="importOpen" :initial-mode="importMode" :job="form" @applied="applyParsed" />
</div>
</template>
......@@ -507,6 +664,143 @@ const activeTab = ref('base')
line-height: 1.5;
}
.jd-tools {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid var(--line);
border-radius: 10px;
.jd-tools-hint {
font-size: 12px;
color: var(--muted);
}
.jd-tools-btns {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
.jd-entry {
margin-bottom: 14px;
padding: 14px 16px;
background: linear-gradient(120deg, rgba(255, 255, 255, 0.94), rgba(255, 255, 255, 0.78));
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: 0 8px 24px rgba(24, 32, 51, 0.05);
.jd-entry-kicker {
display: block;
font-size: 11px;
letter-spacing: 1px;
color: var(--blue);
font-weight: 600;
margin-bottom: 4px;
}
.jd-entry-head {
h3 {
margin: 0 0 4px;
font-size: 16px;
}
p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
}
.jd-entry-steps {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 12px;
@media (max-width: 760px) {
grid-template-columns: 1fr;
}
}
.jd-entry-step {
display: flex;
gap: 12px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--line);
border-radius: 12px;
.jd-entry-no {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--blue);
color: #fff;
font-size: 12px;
font-weight: 700;
}
.jd-entry-body {
min-width: 0;
b {
display: block;
font-size: 14px;
margin-bottom: 4px;
}
p {
margin: 0 0 8px;
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
}
}
.jd-entry-foot {
margin: 10px 0 0;
font-size: 12px;
color: var(--muted);
}
}
.internal-card {
margin-top: 16px;
padding: 12px 14px;
background: color-mix(in srgb, var(--yellow) 7%, #fff);
border: 1px dashed color-mix(in srgb, var(--yellow) 55%, var(--line));
border-radius: 10px;
.internal-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
b {
font-size: 13px;
}
span {
font-size: 12px;
color: var(--muted);
}
}
}
.edit-history {
display: flex;
flex-direction: column;
......
......@@ -127,6 +127,11 @@ const newJob = () => {
router.push('/jobs/new')
}
// 需求 9 快捷入口:从用人部门填好的 JD/模板直接进入粘贴创建
const createFromPasted = () => {
router.push({ path: '/jobs/new', query: { import: 'paste' } })
}
const clearFilter = () => {
store.jobFilters = { q: '', department: '全部部门', status: '全部状态', scope: 'all' }
}
......@@ -211,7 +216,10 @@ const deleteJob = async (job) => {
><span>需要处理</span>
</div>
</div>
<el-button type="primary" @click="newJob">新建岗位</el-button>
<div class="job-command-actions">
<el-button class="job-command-add" @click="createFromPasted">📋 从 JD / 需求模板创建</el-button>
<el-button type="primary" @click="newJob">新建岗位</el-button>
</div>
</section>
<div class="job-metrics grid-4">
......@@ -698,4 +706,23 @@ const deleteJob = async (job) => {
margin-bottom: 14px;
}
}
.job-command-hero .job-command-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
.job-command-add {
background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.45);
color: #fff;
&:hover,
&:focus {
background: rgba(255, 255, 255, 0.24);
border-color: rgba(255, 255, 255, 0.7);
color: #fff;
}
}
}
</style>
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