Commit 0c4c8cd3 authored by 李文光's avatar 李文光

feat(jobs): 优化岗位表单薪资交互并新增招聘类型

parent 2ccdf0e5
...@@ -30,7 +30,7 @@ EXTERNAL_KEYS = ( ...@@ -30,7 +30,7 @@ EXTERNAL_KEYS = (
"competency", "competency",
) )
# 基本信息字段(粘贴 JD 里常见) # 基本信息字段(粘贴 JD 里常见)
BASE_KEYS = ("title", "department", "headcount", "workLocation", "salaryRange") BASE_KEYS = ("title", "department", "recruitType", "headcount", "workLocation", "salaryRange")
# 对内约束字段(用人部门需求,但绝不上对外 JD / LLM) # 对内约束字段(用人部门需求,但绝不上对外 JD / LLM)
INTERNAL_KEYS = ( INTERNAL_KEYS = (
"genderRestriction", "genderRestriction",
...@@ -59,11 +59,14 @@ _TEMPLATE_SECTIONS: list[tuple[str, str]] = [ ...@@ -59,11 +59,14 @@ _TEMPLATE_SECTIONS: list[tuple[str, str]] = [
_TEMPLATE_BASE_LABELS = { _TEMPLATE_BASE_LABELS = {
"title": "岗位名称", "title": "岗位名称",
"department": "所属部门", "department": "所属部门",
"recruitType": "招聘类型",
"headcount": "招聘人数", "headcount": "招聘人数",
"workLocation": "工作地点", "workLocation": "工作地点",
"salaryRange": "薪资范围", "salaryRange": "薪资范围",
} }
_RECRUIT_TYPE_OPTIONS = ("社招", "校招", "实习", "兼职")
_ALIASES: dict[str, list[str]] = { _ALIASES: dict[str, list[str]] = {
"jd": ["岗位说明", "职位说明", "职位概述", "岗位概述", "JD 简介", "jd简介", "职位简介", "招聘背景", "背景介绍"], "jd": ["岗位说明", "职位说明", "职位概述", "岗位概述", "JD 简介", "jd简介", "职位简介", "招聘背景", "背景介绍"],
"responsibilities": ["工作职责", "岗位职责", "职位职责", "职责描述", "主要职责", "工作内容", "岗位描述", "职位描述"], "responsibilities": ["工作职责", "岗位职责", "职位职责", "职责描述", "主要职责", "工作内容", "岗位描述", "职位描述"],
...@@ -112,6 +115,7 @@ def build_requirement_template(job: dict[str, Any] | None = None) -> str: ...@@ -112,6 +115,7 @@ def build_requirement_template(job: dict[str, Any] | None = None) -> str:
base = { base = {
"title": str(job.get("title") or ""), "title": str(job.get("title") or ""),
"department": str(job.get("department") or ""), "department": str(job.get("department") or ""),
"recruitType": str(job.get("recruitType") or ""),
"headcount": str(job.get("headcount") or ""), "headcount": str(job.get("headcount") or ""),
"workLocation": str(job.get("workLocation") or job.get("location") or ""), "workLocation": str(job.get("workLocation") or job.get("location") or ""),
"salaryRange": str(job.get("salaryRange") or ""), "salaryRange": str(job.get("salaryRange") or ""),
...@@ -198,11 +202,26 @@ def _parse_base_info(text: str) -> dict[str, str]: ...@@ -198,11 +202,26 @@ def _parse_base_info(text: str) -> dict[str, str]:
continue continue
value = _label_line_value(line, label) value = _label_line_value(line, label)
if value: if value:
if key == "recruitType":
value = next((item for item in _RECRUIT_TYPE_OPTIONS if item in value), value[:20])
result[key] = value result[key] = value
if "headcount" not in result: if "headcount" not in result:
match = re.search(r"(?:招聘|招)\s*(\d+)\s*人", line) match = re.search(r"(?:招聘|招)\s*(\d+)\s*人", line)
if match: if match:
result["headcount"] = match.group(1) result["headcount"] = match.group(1)
if not result.get("recruitType"):
for line in text.splitlines()[:10]:
for recruit_type in _RECRUIT_TYPE_OPTIONS:
if (
f"【{recruit_type}】" in line
or f"[{recruit_type}]" in line
or f"({recruit_type})" in line
or f"({recruit_type})" in line
):
result["recruitType"] = recruit_type
break
if result.get("recruitType"):
break
return result return result
...@@ -287,8 +306,8 @@ async def _parse_pasted_jd_llm(text: str, settings: Any) -> dict[str, Any] | Non ...@@ -287,8 +306,8 @@ async def _parse_pasted_jd_llm(text: str, settings: Any) -> dict[str, Any] | Non
按原文对应小节逐条要点抽取,多条用换行分隔;保留原文的序号与列表符号 按原文对应小节逐条要点抽取,多条用换行分隔;保留原文的序号与列表符号
(如 1、2、3、或 -、•),原文没有序号或列表符号时就按换行分隔,不要自行添加。 (如 1、2、3、或 -、•),原文没有序号或列表符号时就按换行分隔,不要自行添加。
4. matchKeywords:6-10 个岗位关键词,用中文逗号分隔。 4. matchKeywords:6-10 个岗位关键词,用中文逗号分隔。
5. title/department/workLocation/salaryRange/headcount:从基本信息或正文提取; 5. title/department/recruitType/workLocation/salaryRange/headcount:从基本信息或正文提取;
headcount 只输出数字没有则空字符串。 headcount 只输出数字;recruitType 只从 社招/校招/实习/兼职 中选,原文没有则空字符串。
6. genderRestriction / ageRestriction / probationPeriod / probationCriteria / 6. genderRestriction / ageRestriction / probationPeriod / probationCriteria /
nonCompete / verification 是对内约束:只在原文**明确出现**(如「性别要求:男」、 nonCompete / verification 是对内约束:只在原文**明确出现**(如「性别要求:男」、
「年龄 35 以下」「试用期 3 个月」「需签竞业」「背调」)时提取;原文没提就空字符串, 「年龄 35 以下」「试用期 3 个月」「需签竞业」「背调」)时提取;原文没提就空字符串,
......
...@@ -16,6 +16,7 @@ from backend.app.services.jd_import import ( ...@@ -16,6 +16,7 @@ from backend.app.services.jd_import import (
TEMPLATE_FILLED = """## 岗位基本信息(可留空让 HR 补) TEMPLATE_FILLED = """## 岗位基本信息(可留空让 HR 补)
- 岗位名称:大客户销售经理 - 岗位名称:大客户销售经理
- 所属部门:市场部 - 所属部门:市场部
- 招聘类型:校招
- 招聘人数:1 - 招聘人数:1
- 工作地点:深圳 - 工作地点:深圳
- 薪资范围:20-35k - 薪资范围:20-35k
...@@ -73,7 +74,14 @@ GENERIC_JD = """高级前端工程师(杭州) ...@@ -73,7 +74,14 @@ GENERIC_JD = """高级前端工程师(杭州)
def test_template_contains_external_and_internal_sections(): def test_template_contains_external_and_internal_sections():
tpl = build_requirement_template( tpl = build_requirement_template(
{"title": "Java开发工程师", "department": "技术部", "headcount": 2, "workLocation": "深圳", "salaryRange": "25-40k"} {
"title": "Java开发工程师",
"department": "技术部",
"recruitType": "校招",
"headcount": 2,
"workLocation": "深圳",
"salaryRange": "25-40k",
}
) )
for heading in ( for heading in (
"岗位基本信息", "岗位基本信息",
...@@ -93,6 +101,7 @@ def test_template_contains_external_and_internal_sections(): ...@@ -93,6 +101,7 @@ def test_template_contains_external_and_internal_sections():
assert heading in tpl, f"模板缺少章节:{heading}" assert heading in tpl, f"模板缺少章节:{heading}"
assert "高级前端" not in tpl # 不预填无关岗位 assert "高级前端" not in tpl # 不预填无关岗位
assert "Java开发工程师" in tpl assert "Java开发工程师" in tpl
assert "招聘类型:校招" in tpl
def test_local_parse_template_roundtrip(): def test_local_parse_template_roundtrip():
...@@ -100,6 +109,7 @@ def test_local_parse_template_roundtrip(): ...@@ -100,6 +109,7 @@ def test_local_parse_template_roundtrip():
assert parsed["provider"] == "local-structured" assert parsed["provider"] == "local-structured"
assert parsed["title"] == "大客户销售经理" assert parsed["title"] == "大客户销售经理"
assert parsed["department"] == "市场部" assert parsed["department"] == "市场部"
assert parsed["recruitType"] == "校招"
assert parsed["headcount"] == "1" assert parsed["headcount"] == "1"
assert parsed["workLocation"] == "深圳" assert parsed["workLocation"] == "深圳"
assert parsed["salaryRange"] == "20-35k" assert parsed["salaryRange"] == "20-35k"
...@@ -163,6 +173,7 @@ def test_parse_pasted_jd_llm_first(llm_enabled, monkeypatch): ...@@ -163,6 +173,7 @@ def test_parse_pasted_jd_llm_first(llm_enabled, monkeypatch):
return { return {
"title": "大客户销售经理", "title": "大客户销售经理",
"department": "市场部", "department": "市场部",
"recruitType": "校招",
"headcount": "1", "headcount": "1",
"workLocation": "深圳", "workLocation": "深圳",
"salaryRange": "20-35k", "salaryRange": "20-35k",
...@@ -188,6 +199,7 @@ def test_parse_pasted_jd_llm_first(llm_enabled, monkeypatch): ...@@ -188,6 +199,7 @@ def test_parse_pasted_jd_llm_first(llm_enabled, monkeypatch):
result = asyncio.run(parse_pasted_jd("一份招聘需求文本")) result = asyncio.run(parse_pasted_jd("一份招聘需求文本"))
assert result["provider"].startswith("llm:") assert result["provider"].startswith("llm:")
assert result["title"] == "大客户销售经理" assert result["title"] == "大客户销售经理"
assert result["recruitType"] == "校招"
assert result["mustHave"] == "独立成交 500 万项目" assert result["mustHave"] == "独立成交 500 万项目"
assert result["nonCompete"] == "需签" assert result["nonCompete"] == "需签"
assert result["genderRestriction"] == "不限" assert result["genderRestriction"] == "不限"
......
...@@ -14,7 +14,9 @@ const props = defineProps({ ...@@ -14,7 +14,9 @@ const props = defineProps({
}) })
const emit = defineEmits(['update:modelValue', 'applied']) const emit = defineEmits(['update:modelValue', 'applied'])
const tab = ref('paste') // 打开即可能已为 true(如列表入口带 ?import= 直达),初始 tab 直接取自 initialMode,
// 避免首次挂载时 watcher 不触发而停留在默认 paste 页签。
const tab = ref(props.initialMode === 'template' ? 'template' : 'paste')
const pasteText = ref('') const pasteText = ref('')
const applyMode = ref('fill') // fill = 仅补空字段;overwrite = 覆盖已填字段 const applyMode = ref('fill') // fill = 仅补空字段;overwrite = 覆盖已填字段
const parsing = ref(false) const parsing = ref(false)
...@@ -33,13 +35,15 @@ watch( ...@@ -33,13 +35,15 @@ watch(
if (!open) return if (!open) return
tab.value = props.initialMode === 'template' ? 'template' : 'paste' tab.value = props.initialMode === 'template' ? 'template' : 'paste'
parsed.value = null parsed.value = null
} },
{ immediate: true }
) )
// 可回填字段元数据(与后端 jd_import 的 ALL_KEYS 对应;kind 用于预览标签) // 可回填字段元数据(与后端 jd_import 的 ALL_KEYS 对应;kind 用于预览标签)
const FIELD_META = [ const FIELD_META = [
{ key: 'title', label: '岗位名称', kind: 'base' }, { key: 'title', label: '岗位名称', kind: 'base' },
{ key: 'department', label: '所属部门', kind: 'base' }, { key: 'department', label: '所属部门', kind: 'base' },
{ key: 'recruitType', label: '招聘类型', kind: 'base' },
{ key: 'headcount', label: '招聘人数', kind: 'base' }, { key: 'headcount', label: '招聘人数', kind: 'base' },
{ key: 'workLocation', label: '工作地点', kind: 'base' }, { key: 'workLocation', label: '工作地点', kind: 'base' },
{ key: 'salaryRange', label: '薪资范围', kind: 'base' }, { key: 'salaryRange', label: '薪资范围', kind: 'base' },
...@@ -67,6 +71,11 @@ const previewRows = computed(() => { ...@@ -67,6 +71,11 @@ const previewRows = computed(() => {
})) }))
}) })
const previewSummary = computed(() => {
const provider = String(parsed.value?.provider || '').startsWith('llm:') ? 'AI 解析' : '本地规则解析'
return `已识别 ${previewRows.value.length} 个字段(${provider}),确认后回填岗位表单;对内约束不会进入对外 JD`
})
const kindTag = (kind) => (kind === 'internal' ? 'warning' : kind === 'content' ? '' : 'info') const kindTag = (kind) => (kind === 'internal' ? 'warning' : kind === 'content' ? '' : 'info')
const kindText = (kind) => (kind === 'internal' ? '对内不对外' : kind === 'content' ? '对外 JD 内容' : '基本信息') const kindText = (kind) => (kind === 'internal' ? '对内不对外' : kind === 'content' ? '对外 JD 内容' : '基本信息')
...@@ -104,6 +113,7 @@ const buildTemplate = async () => { ...@@ -104,6 +113,7 @@ const buildTemplate = async () => {
const job = { const job = {
title: props.job.title || '', title: props.job.title || '',
department: props.job.department || '', department: props.job.department || '',
recruitType: props.job.recruitType || '',
headcount: props.job.headcount || '', headcount: props.job.headcount || '',
workLocation: props.job.workLocation || '', workLocation: props.job.workLocation || '',
salaryRange: props.job.salaryRange || '', salaryRange: props.job.salaryRange || '',
...@@ -137,137 +147,317 @@ const close = () => { ...@@ -137,137 +147,317 @@ const close = () => {
<template> <template>
<el-dialog <el-dialog
:model-value="dialogOpen" :model-value="dialogOpen"
title="JD 导入 · 粘贴结构化 / 需求模板" class="jd-import-dialog"
width="780px" width="min(800px, 94vw)"
top="6vh" top="6vh"
:close-on-click-modal="false" :close-on-click-modal="false"
@update:model-value="dialogOpen = $event" @update:model-value="dialogOpen = $event"
@close="close" @close="close"
> >
<template #header>
<div class="jd-import-header">
<div class="jd-import-title">JD 导入</div>
<div class="jd-import-subtitle">粘贴结构化或生成需求模板,核对后回填岗位表单</div>
</div>
</template>
<el-tabs v-model="tab"> <el-tabs v-model="tab">
<el-tab-pane label="粘贴 JD 自动结构化" name="paste"> <el-tab-pane label="粘贴 JD 自动结构化" name="paste">
<p class="jd-import-hint"> <div class="jd-steps">
把用人部门填好的《招聘需求模板》或整段 JD 粘进来,自动拆出 岗位说明 / 职责 / 要求 / 硬性 / 加分 / 排除 / <div class="jd-step">
关键词 / 胜任力 与 对内约束(性别年龄试用期竞业验证,只对内执行)。 <div class="jd-step-head">
</p> <span class="jd-step-num">1</span>
<el-input <div class="jd-step-copy">
v-model="pasteText" <b>粘贴 JD / 需求模板原文</b>
type="textarea" <span
:rows="10" >整段粘贴用人部门填写的模板或现有
placeholder="粘贴招聘需求模板或 JD 全文…(无 AI Key 时按小节标题本地切分,推荐使用系统模板填写)" JD,自动拆出职责、要求、硬性、加分、排除、关键词、胜任力与对内约束</span
/> >
<div class="jd-import-toolbar"> </div>
<el-radio-group v-model="applyMode" size="small"> </div>
<el-radio-button label="fill">仅补空字段</el-radio-button> <el-input
<el-radio-button label="overwrite">覆盖已填字段</el-radio-button> v-model="pasteText"
</el-radio-group> type="textarea"
<el-button type="primary" :loading="parsing" @click="runParse">解析</el-button> :rows="9"
</div> aria-label="粘贴 JD 或需求模板原文"
placeholder="粘贴招聘需求模板或 JD 全文…(无 AI Key 时按小节标题本地切分,推荐使用系统模板填写)"
/>
<div class="jd-field-hint">无 AI Key 时会按小节标题本地切分字段,推荐用系统模板填写后粘贴。</div>
</div>
<div v-if="previewRows.length" class="jd-parse-preview"> <div class="jd-step">
<div class="jd-parse-preview-head"> <div class="jd-step-head">
<b>解析结果预览</b> <span class="jd-step-num">2</span>
<span>回填时{{ applyMode === 'fill' ? '只补空字段,不覆盖手填内容' : '会覆盖同名已填字段' }}</span> <div class="jd-step-copy">
<b>选择回填方式并解析</b>
<span>决定解析结果是否覆盖岗位表单里已手填的同名字段</span>
</div>
</div>
<div class="jd-parse-bar">
<el-radio-group v-model="applyMode" class="jd-mode-group">
<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-icon><MagicStick /></el-icon>
<span>解析</span>
</el-button>
</div>
</div> </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> <div v-if="parsed" class="jd-step">
<b>{{ row.label }}</b> <div class="jd-step-head">
<span>{{ row.snippet }}</span> <span class="jd-step-num">3</span>
<div class="jd-step-copy">
<b>核对解析结果</b>
<span>{{ previewSummary }}</span>
</div>
</div>
<div v-if="previewRows.length" class="jd-parse-preview">
<div v-for="row in previewRows" :key="row.key" class="jd-parse-row">
<el-tag :type="kindTag(row.kind)" size="small" effect="light" class="jd-row-tag">
{{ kindText(row.kind) }}
</el-tag>
<b class="jd-row-label" :title="row.label">{{ row.label }}</b>
<span class="jd-row-snippet">{{ row.snippet }}</span>
</div>
</div>
<div v-else class="jd-parse-empty">没有识别到可回填的字段,可调整原文后重试。</div>
</div> </div>
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="生成需求模板" name="template"> <el-tab-pane label="生成需求模板" name="template">
<p class="jd-import-hint"> <div class="jd-steps">
没有现成 JD 时,用系统模板向用人部门收集需求(含对内约束,不会出现在对外 JD); <div class="jd-step">
用人部门填完/改完后,粘回左侧「粘贴 JD 自动结构化」即可一键回填岗位。 <div class="jd-step-head">
</p> <span class="jd-step-num">1</span>
<div class="jd-import-toolbar"> <div class="jd-step-copy">
<el-button type="primary" :loading="building" @click="buildTemplate">生成模板</el-button> <b>生成需求模板</b>
<el-button :disabled="!templateText" @click="copyTemplate">复制模板</el-button> <span>基于岗位基本信息生成模板发给用人部门填写,含对内约束,不会出现在对外 JD</span>
</div>
</div>
<div class="jd-template-bar">
<el-button type="primary" :loading="building" @click="buildTemplate">
<el-icon><MagicStick /></el-icon>
<span>生成模板</span>
</el-button>
<el-button :disabled="!templateText" @click="copyTemplate">
<el-icon><CopyDocument /></el-icon>
<span>复制模板</span>
</el-button>
</div>
</div>
<div class="jd-step">
<div class="jd-step-head">
<span class="jd-step-num">2</span>
<div class="jd-step-copy">
<b>复制下发</b>
<span>用人部门填完或改完后,粘回「粘贴 JD」页签即可一键回填</span>
</div>
</div>
<el-input
v-model="templateText"
type="textarea"
:rows="14"
readonly
aria-label="生成的需求模板"
placeholder="点击「生成模板」后,这里会展示可发给用人部门填写的模板…"
/>
</div>
</div> </div>
<el-input
v-model="templateText"
type="textarea"
:rows="16"
readonly
placeholder="点击「生成模板」后,这里会展示可发给用人部门填写的模板…"
/>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<template #footer> <template #footer>
<el-button v-if="tab === 'paste' && previewRows.length" type="primary" @click="apply"> <div class="jd-footer">
回填到岗位表单({{ previewRows.length }} 项) <span v-if="tab === 'paste' && parsed" class="jd-footer-note">
</el-button> {{ applyMode === 'fill' ? '仅补空字段,不覆盖手填内容' : '将覆盖岗位表单同名已填字段' }}
<el-button @click="close">关闭</el-button> </span>
<div class="jd-footer-btns">
<el-button @click="close">关闭</el-button>
<el-button v-if="tab === 'paste' && previewRows.length" type="primary" @click="apply">
回填到岗位表单({{ previewRows.length }} 项)
</el-button>
</div>
</div>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.jd-import-hint { .jd-import-dialog {
margin: 0 0 10px; :deep(.el-dialog__header) {
font-size: 13px; padding: 20px 24px 6px;
line-height: 1.6; }
color: var(--muted);
:deep(.el-dialog__body) {
padding: 2px 24px 16px;
max-height: calc(100vh - 236px);
overflow-y: auto;
}
:deep(.el-dialog__footer) {
padding: 12px 24px 20px;
border-top: 1px solid var(--line);
}
} }
.jd-import-toolbar { .jd-import-header {
.jd-import-title {
font-size: 16px;
font-weight: 600;
color: var(--ink);
}
.jd-import-subtitle {
margin-top: 2px;
font-size: 13px;
line-height: 1.5;
color: var(--muted);
}
}
.jd-steps {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between; gap: 18px;
gap: 10px;
margin: 10px 0 4px;
} }
.jd-parse-preview { .jd-step-head {
margin-top: 10px; display: flex;
padding: 10px 12px; gap: 10px;
background: rgba(255, 255, 255, 0.72); align-items: flex-start;
border: 1px solid var(--line); margin-bottom: 10px;
border-radius: 10px;
.jd-parse-preview-head { .jd-step-num {
display: flex; flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--line);
background: var(--bg);
color: var(--blue);
display: inline-flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 12px; font-size: 12px;
margin-bottom: 8px; font-weight: 600;
line-height: 1;
}
.jd-step-copy {
min-width: 0;
b { b {
font-size: 13px; display: block;
font-size: 14px;
font-weight: 600;
color: var(--ink);
} }
span { span {
font-size: 12px; display: block;
margin-top: 2px;
font-size: 12.5px;
line-height: 1.55;
color: var(--muted); color: var(--muted);
} }
} }
}
.jd-field-hint {
margin-top: 6px;
font-size: 12px;
line-height: 1.5;
color: var(--muted);
}
.jd-parse-bar,
.jd-template-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.jd-template-bar {
justify-content: flex-start;
}
.jd-parse-preview {
margin-top: 2px;
padding: 4px 14px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 12px;
.jd-parse-row { .jd-parse-row {
display: grid; display: grid;
grid-template-columns: 130px 1fr; grid-template-columns: auto minmax(96px, 140px) minmax(0, 1fr);
align-items: baseline; gap: 10px 14px;
gap: 8px; align-items: start;
padding: 5px 0; padding: 8px 0;
& + .jd-parse-row { & + .jd-parse-row {
border-top: 1px dashed rgba(24, 32, 51, 0.06); border-top: 1px solid rgba(24, 32, 51, 0.07);
} }
b { .jd-row-tag {
font-size: 13px; margin-top: 1px;
} }
span { .jd-row-label {
font-size: 12px; font-size: 13px;
color: var(--muted); font-weight: 600;
line-height: 1.5; color: var(--ink);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.jd-row-snippet {
min-width: 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}
.jd-parse-empty {
padding: 14px;
text-align: center;
font-size: 13px;
color: var(--muted);
background: var(--bg);
border: 1px dashed var(--line);
border-radius: 10px;
}
.jd-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
.jd-footer-note {
font-size: 12.5px;
line-height: 1.5;
color: var(--muted);
}
.jd-footer-btns {
display: flex;
gap: 8px;
margin-left: auto;
} }
} }
</style> </style>
...@@ -485,7 +485,7 @@ const mainAction = async () => { ...@@ -485,7 +485,7 @@ const mainAction = async () => {
<section class="job-focus-hero"> <section class="job-focus-hero">
<div> <div>
<span class="section-kicker">{{ job.department || '未设置部门' }}</span> <span class="section-kicker">{{ job.recruitType || '社招' }} · {{ job.department || '未设置部门' }}</span>
<div class="job-focus-title"> <div class="job-focus-title">
<h2>{{ job.title }}</h2> <h2>{{ job.title }}</h2>
<el-tag v-if="job.priority === '高'" type="danger" size="small">高优先级</el-tag> <el-tag v-if="job.priority === '高'" type="danger" size="small">高优先级</el-tag>
...@@ -575,8 +575,8 @@ const mainAction = async () => { ...@@ -575,8 +575,8 @@ const mainAction = async () => {
<div v-if="job.workLocation"> <div v-if="job.workLocation">
<span>工作地点</span><strong>{{ job.workLocation }}</strong> <span>工作地点</span><strong>{{ job.workLocation }}</strong>
</div> </div>
<div v-if="job.workMode"> <div v-if="job.recruitType">
<span>工作模式</span><strong>{{ job.workMode }}</strong> <span>招聘类型</span><strong>{{ job.recruitType }}</strong>
</div> </div>
<div v-if="job.hireReason"> <div v-if="job.hireReason">
<span>招聘原因</span><strong>{{ job.hireReason }}</strong> <span>招聘原因</span><strong>{{ job.hireReason }}</strong>
...@@ -672,6 +672,9 @@ const mainAction = async () => { ...@@ -672,6 +672,9 @@ const mainAction = async () => {
<div> <div>
<span>岗位名称</span><strong>{{ job.title || '未填写' }}</strong> <span>岗位名称</span><strong>{{ job.title || '未填写' }}</strong>
</div> </div>
<div>
<span>招聘类型</span><strong>{{ job.recruitType || '社招' }}</strong>
</div>
<div> <div>
<span>所属部门</span><strong>{{ job.department || '未填写' }}</strong> <span>所属部门</span><strong>{{ job.department || '未填写' }}</strong>
</div> </div>
......
<script setup> <script setup>
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
...@@ -15,8 +15,12 @@ import { ...@@ -15,8 +15,12 @@ import {
JD_STATUS_EDITABLE_OPTIONS, JD_STATUS_EDITABLE_OPTIONS,
APPROVAL_STATUS, APPROVAL_STATUS,
PRIORITY_OPTIONS, PRIORITY_OPTIONS,
WORK_MODE_OPTIONS,
HIRE_REASON_OPTIONS, HIRE_REASON_OPTIONS,
RECRUIT_TYPE,
RECRUIT_TYPE_OPTIONS,
DEPARTMENT_OPTIONS,
LEVEL_OPTIONS,
LOCATION_OPTIONS,
} from '@/utils/jobStatus' } from '@/utils/jobStatus'
import JdGrillModal from './JdGrillModal.vue' import JdGrillModal from './JdGrillModal.vue'
import JdGeneratePanel from './JdGeneratePanel.vue' import JdGeneratePanel from './JdGeneratePanel.vue'
...@@ -42,10 +46,37 @@ const jobTypeSelectOptions = computed(() => { ...@@ -42,10 +46,37 @@ const jobTypeSelectOptions = computed(() => {
return current ? [...active, { ...current, active: true }] : active return current ? [...active, { ...current, active: true }] : active
}) })
// 部门 / 职级 / 工作地点下拉:预设项 + 岗位列表里已出现过的值 + 当前表单值去重,
// 保证「新建」时能快捷选择,同时编辑历史数据时不会因值不在预设里而丢标签。
const departmentSelectOptions = computed(() => {
const used = store.jobs.map((job) => job.department).filter(Boolean)
return [...new Set([...DEPARTMENT_OPTIONS, ...used, form.department].filter(Boolean))]
})
const levelSelectOptions = computed(() => {
const used = store.jobs.map((job) => job.level).filter(Boolean)
return [...new Set([...LEVEL_OPTIONS, ...used, form.level].filter(Boolean))]
})
const locationSelectOptions = computed(() => {
const used = store.jobs.map((job) => job.workLocation).filter(Boolean)
return [...new Set([...LOCATION_OPTIONS, ...used, form.workLocation].filter(Boolean))]
})
function parseSalaryRange(text) {
const raw = String(text || '').trim()
const range = raw.match(/^(\d+)\s*[-~到]\s*(\d+)\s*k/i)
if (range) return { min: Number(range[1]), max: Number(range[2]) }
const single = raw.match(/^(\d+)\s*k/i)
if (single) return { min: Number(single[1]), max: null }
return { min: null, max: null }
}
const form = reactive({ const form = reactive({
jobId: '', jobId: '',
title: '', title: '',
department: '', department: '',
recruitType: RECRUIT_TYPE.SOCIAL,
jobType: JOB_TYPE_UNCLASSIFIED, jobType: JOB_TYPE_UNCLASSIFIED,
headcount: 1, headcount: 1,
owner: 'Charie', owner: 'Charie',
...@@ -64,7 +95,6 @@ const form = reactive({ ...@@ -64,7 +95,6 @@ const form = reactive({
competency: '专业能力\n业务理解\n数据分析\n沟通协同\n抗压推进', competency: '专业能力\n业务理解\n数据分析\n沟通协同\n抗压推进',
level: '', level: '',
workLocation: '', workLocation: '',
workMode: '',
hireReason: '', hireReason: '',
startDate: '', startDate: '',
reportTo: '', reportTo: '',
...@@ -78,6 +108,71 @@ const form = reactive({ ...@@ -78,6 +108,71 @@ const form = reactive({
}, },
}) })
// 薪资范围:岗位列表/详情/JD 里仍以字符串存储(如 30-45K/月)。
// 编辑页用「最低/最高」下拉生成;5-20K 步进 1K,之后步进 5K,上限 100K。
const SALARY_K_OPTIONS = [
...Array.from({ length: 16 }, (_, index) => 5 + index),
...Array.from({ length: 16 }, (_, index) => 25 + index * 5),
]
const salaryLow = ref(null)
const salaryHigh = ref(null)
const salaryHighOptions = computed(() => {
const low = Number(salaryLow.value)
return low ? SALARY_K_OPTIONS.filter((value) => value >= low) : SALARY_K_OPTIONS
})
const salarySet = computed(() => Boolean(String(form.salaryRange || '').trim()))
const salaryLabel = computed(() => {
const raw = String(form.salaryRange || '').trim()
const { min, max } = parseSalaryRange(raw)
if (min === null && max === null) return raw
if (min !== null && max !== null) return `${min} - ${max}K/月`
const exact = raw.match(/^\d+\s*k\/月/i)
return exact ? raw : `${min}K/月起`
})
// 用表单字符串回显两个下拉;无法解析的自由文本不清除,等用户重选时再覆盖。
const syncSalaryPair = () => {
const { min, max } = parseSalaryRange(form.salaryRange)
salaryLow.value = min
salaryHigh.value = max ?? min
}
const writeSalaryRange = () => {
const low = salaryLow.value
const high = salaryHigh.value
if (low === null && high === null) form.salaryRange = ''
if (low === null || high === null) return
form.salaryRange = low === high ? `${low}K/月` : `${low}-${high}K/月`
}
const onSalaryLowChange = () => {
if (salaryLow.value === null || salaryLow.value === '') {
salaryLow.value = null
salaryHigh.value = null
form.salaryRange = ''
return
}
if (salaryHigh.value !== null && Number(salaryHigh.value) < Number(salaryLow.value)) {
salaryHigh.value = salaryLow.value
}
writeSalaryRange()
}
const onSalaryHighChange = () => {
if (salaryHigh.value === null || salaryHigh.value === '') return
writeSalaryRange()
}
const clearSalaryRange = () => {
salaryLow.value = null
salaryHigh.value = null
form.salaryRange = ''
}
// 新建岗位必填项:与表单里 required 标记保持一致,缺失时阻止提交并跳到对应 Tab // 新建岗位必填项:与表单里 required 标记保持一致,缺失时阻止提交并跳到对应 Tab
const REQUIRED_CREATE_FIELDS = [ const REQUIRED_CREATE_FIELDS = [
{ key: 'title', label: '岗位名称', tab: 'base' }, { key: 'title', label: '岗位名称', tab: 'base' },
...@@ -94,6 +189,7 @@ const syncForm = () => { ...@@ -94,6 +189,7 @@ const syncForm = () => {
form.jobId = job?.id || '' form.jobId = job?.id || ''
form.title = job?.title || '' form.title = job?.title || ''
form.department = job?.department || '' form.department = job?.department || ''
form.recruitType = job?.recruitType || RECRUIT_TYPE.SOCIAL
form.jobType = job?.jobType || JOB_TYPE_UNCLASSIFIED form.jobType = job?.jobType || JOB_TYPE_UNCLASSIFIED
form.headcount = Number(job?.headcount || 1) form.headcount = Number(job?.headcount || 1)
form.owner = job?.owner || 'Charie' form.owner = job?.owner || 'Charie'
...@@ -112,7 +208,6 @@ const syncForm = () => { ...@@ -112,7 +208,6 @@ const syncForm = () => {
form.competency = job?.competency || '专业能力\n业务理解\n数据分析\n沟通协同\n抗压推进' form.competency = job?.competency || '专业能力\n业务理解\n数据分析\n沟通协同\n抗压推进'
form.level = job?.level || '' form.level = job?.level || ''
form.workLocation = job?.workLocation || '' form.workLocation = job?.workLocation || ''
form.workMode = job?.workMode || ''
form.hireReason = job?.hireReason || '' form.hireReason = job?.hireReason || ''
form.startDate = job?.startDate || '' form.startDate = job?.startDate || ''
form.reportTo = job?.reportTo || '' form.reportTo = job?.reportTo || ''
...@@ -124,9 +219,11 @@ const syncForm = () => { ...@@ -124,9 +219,11 @@ const syncForm = () => {
nonCompete: job?.nonCompete || '', nonCompete: job?.nonCompete || '',
verification: job?.verification || '', verification: job?.verification || '',
} }
syncSalaryPair()
} }
syncForm() syncForm()
watch(() => form.salaryRange, syncSalaryPair)
const back = () => { const back = () => {
if (existing.value) router.push(`/jobs/${existing.value.id}`) if (existing.value) router.push(`/jobs/${existing.value.id}`)
...@@ -143,6 +240,7 @@ const aiAssistCreate = async () => { ...@@ -143,6 +240,7 @@ const aiAssistCreate = async () => {
const baseJob = { const baseJob = {
title: form.title, title: form.title,
department: form.department, department: form.department,
recruitType: form.recruitType,
jobType: form.jobType, jobType: form.jobType,
headcount: form.headcount, headcount: form.headcount,
owner: form.owner, owner: form.owner,
...@@ -241,6 +339,7 @@ const submit = async (saveMode = 'update') => { ...@@ -241,6 +339,7 @@ const submit = async (saveMode = 'update') => {
createdAt: existing.value?.createdAt || new Date().toISOString(), createdAt: existing.value?.createdAt || new Date().toISOString(),
title: form.title, title: form.title,
department: form.department, department: form.department,
recruitType: form.recruitType || RECRUIT_TYPE.SOCIAL,
jobType: form.jobType || JOB_TYPE_UNCLASSIFIED, jobType: form.jobType || JOB_TYPE_UNCLASSIFIED,
headcount: Number(form.headcount || 1), headcount: Number(form.headcount || 1),
owner: form.owner || 'Charie', owner: form.owner || 'Charie',
...@@ -259,7 +358,6 @@ const submit = async (saveMode = 'update') => { ...@@ -259,7 +358,6 @@ const submit = async (saveMode = 'update') => {
competency: form.competency || '', competency: form.competency || '',
level: form.level || '', level: form.level || '',
workLocation: form.workLocation || '', workLocation: form.workLocation || '',
workMode: form.workMode || '',
hireReason: form.hireReason || '', hireReason: form.hireReason || '',
startDate: form.startDate || '', startDate: form.startDate || '',
reportTo: form.reportTo || '', reportTo: form.reportTo || '',
...@@ -326,7 +424,7 @@ const applyParsed = ({ fields = {}, mode = 'fill' }) => { ...@@ -326,7 +424,7 @@ const applyParsed = ({ fields = {}, mode = 'fill' }) => {
'matchKeywords', 'matchKeywords',
'competency', 'competency',
] ]
const baseKeys = ['title', 'department', 'workLocation', 'salaryRange'] const baseKeys = ['title', 'department', 'recruitType', 'workLocation', 'salaryRange']
const internalKeys = [ const internalKeys = [
'genderRestriction', 'genderRestriction',
'ageRestriction', 'ageRestriction',
...@@ -388,33 +486,6 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || ' ...@@ -388,33 +486,6 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
</div> </div>
</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"> <section class="card">
<el-tabs v-model="activeTab" class="edit-tabs"> <el-tabs v-model="activeTab" class="edit-tabs">
<!-- ============ Tab 1 基础信息 ============ --> <!-- ============ Tab 1 基础信息 ============ -->
...@@ -423,8 +494,21 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || ' ...@@ -423,8 +494,21 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
<el-form-item label="岗位名称" required> <el-form-item label="岗位名称" required>
<el-input v-model="form.title" /> <el-input v-model="form.title" />
</el-form-item> </el-form-item>
<el-form-item label="招聘类型">
<el-select v-model="form.recruitType" placeholder="选择招聘类型">
<el-option v-for="type in RECRUIT_TYPE_OPTIONS" :key="type" :label="type" :value="type" />
</el-select>
</el-form-item>
<el-form-item label="所属部门" required> <el-form-item label="所属部门" required>
<el-input v-model="form.department" /> <el-select
v-model="form.department"
placeholder="选择或输入部门"
filterable
allow-create
default-first-option
>
<el-option v-for="dept in departmentSelectOptions" :key="dept" :label="dept" :value="dept" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="岗位类型"> <el-form-item label="岗位类型">
<el-select v-model="form.jobType" placeholder="选择岗位类型" style="width: 100%"> <el-select v-model="form.jobType" placeholder="选择岗位类型" style="width: 100%">
...@@ -432,14 +516,31 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || ' ...@@ -432,14 +516,31 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="职级"> <el-form-item label="职级">
<el-input v-model="form.level" placeholder="如 P7、总监" /> <el-select
v-model="form.level"
placeholder="选择或输入职级"
filterable
allow-create
default-first-option
clearable
>
<el-option v-for="level in levelSelectOptions" :key="level" :label="level" :value="level" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="工作地点" required> <el-form-item label="工作地点" required>
<el-input v-model="form.workLocation" placeholder="如 上海" /> <el-select
</el-form-item> v-model="form.workLocation"
<el-form-item label="工作模式"> placeholder="选择或输入工作地点"
<el-select v-model="form.workMode"> filterable
<el-option v-for="mode in WORK_MODE_OPTIONS" :key="mode" :label="mode" :value="mode" /> allow-create
default-first-option
>
<el-option
v-for="location in locationSelectOptions"
:key="location"
:label="location"
:value="location"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="招聘人数" required> <el-form-item label="招聘人数" required>
...@@ -449,7 +550,59 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || ' ...@@ -449,7 +550,59 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
<el-input v-model="form.owner" /> <el-input v-model="form.owner" />
</el-form-item> </el-form-item>
<el-form-item label="薪资范围"> <el-form-item label="薪资范围">
<el-input v-model="form.salaryRange" placeholder="如 30-45k/月" /> <div class="salary-range-field">
<div class="salary-range-pair">
<div class="salary-pick">
<el-select
v-model="salaryLow"
class="salary-pick-select"
placeholder="选择下限"
clearable
@change="onSalaryLowChange"
>
<el-option-group v-if="salaryLow === null || salaryLow <= 20" label="5K - 20K(每 1K 一档)">
<el-option
v-for="value in SALARY_K_OPTIONS.filter((item) => item <= 20)"
:key="value"
:label="`${value}K/月`"
:value="value"
/>
</el-option-group>
<el-option-group v-if="salaryLow === null || salaryLow > 20" label="25K - 100K(每 5K 一档)">
<el-option
v-for="value in SALARY_K_OPTIONS.filter((item) => item > 20)"
:key="value"
:label="`${value}K/月`"
:value="value"
/>
</el-option-group>
</el-select>
</div>
<span class="salary-range-sep"></span>
<div class="salary-pick">
<el-select
v-model="salaryHigh"
class="salary-pick-select"
placeholder="选择上限"
:disabled="salaryLow === null"
@change="onSalaryHighChange"
>
<el-option
v-for="value in salaryHighOptions"
:key="value"
:label="`${value}K/月`"
:value="value"
/>
</el-select>
</div>
</div>
<div v-if="salarySet" class="salary-range-readout">
<span class="salary-range-value">{{ salaryLabel }}</span>
<el-button class="salary-range-clear" link type="primary" size="small" @click="clearSalaryRange">
清除
</el-button>
</div>
</div>
</el-form-item> </el-form-item>
<el-form-item label="优先级"> <el-form-item label="优先级">
<el-select v-model="form.priority"> <el-select v-model="form.priority">
...@@ -675,116 +828,94 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || ' ...@@ -675,116 +828,94 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
line-height: 1.5; line-height: 1.5;
} }
.jd-tools { .salary-range-field {
width: 100%;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between; gap: 8px;
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 { .salary-range-pair {
display: flex; display: flex;
gap: 8px; align-items: center;
flex-shrink: 0; gap: 10px;
} }
}
.jd-entry { .salary-pick {
margin-bottom: 14px; flex: 1;
padding: 14px 16px; min-width: 0;
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 { .salary-pick-label {
display: block; display: block;
font-size: 11px; margin-bottom: 6px;
letter-spacing: 1px; font-size: 12px;
color: var(--blue); line-height: 1;
font-weight: 600; color: var(--muted);
margin-bottom: 4px;
}
.jd-entry-head {
h3 {
margin: 0 0 4px;
font-size: 16px;
} }
p { .salary-pick-select {
margin: 0; width: 100%;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
} }
} }
.jd-entry-steps { .salary-range-sep {
display: grid; flex-shrink: 0;
grid-template-columns: repeat(2, minmax(0, 1fr)); padding-bottom: 1px;
gap: 12px; color: var(--muted);
margin-top: 12px; font-size: 13px;
@media (max-width: 760px) {
grid-template-columns: 1fr;
}
} }
.jd-entry-step { .salary-range-readout {
display: flex; display: flex;
gap: 12px; align-items: center;
padding: 12px 14px; gap: 8px;
background: rgba(255, 255, 255, 0.6); min-height: 22px;
border: 1px solid var(--line); font-size: 13px;
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 { .salary-range-value {
min-width: 0; color: var(--blue);
font-weight: 600;
}
b { .salary-range-clear {
display: block; padding: 0;
font-size: 14px; font-size: 12px;
margin-bottom: 4px; }
}
p { @media (max-width: 420px) {
margin: 0 0 8px; .salary-range-pair {
color: var(--muted); align-items: stretch;
font-size: 12px; flex-direction: column;
line-height: 1.6; gap: 8px;
} }
.salary-range-sep {
display: none;
} }
} }
}
.jd-entry-foot { .jd-tools {
margin: 10px 0 0; 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; font-size: 12px;
color: var(--muted); color: var(--muted);
} }
.jd-tools-btns {
display: flex;
gap: 8px;
flex-shrink: 0;
}
} }
.internal-card { .internal-card {
......
...@@ -21,7 +21,15 @@ const filteredJobs = computed(() => { ...@@ -21,7 +21,15 @@ const filteredJobs = computed(() => {
const q = (filters.q || '').trim().toLowerCase() const q = (filters.q || '').trim().toLowerCase()
return jobs.value return jobs.value
.filter((job) => { .filter((job) => {
const fields = [job.title, job.department, job.owner, jobTypeLabel(job.jobType), job.jd, job.matchKeywords] const fields = [
job.title,
job.department,
job.owner,
job.recruitType,
jobTypeLabel(job.jobType),
job.jd,
job.matchKeywords,
]
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()
if (q && !fields.includes(q)) return false if (q && !fields.includes(q)) return false
...@@ -129,9 +137,17 @@ const newJob = () => { ...@@ -129,9 +137,17 @@ const newJob = () => {
router.push('/jobs/new') router.push('/jobs/new')
} }
// 需求 9 快捷入口:从用人部门填好的 JD/模板直接进入粘贴创建 // 新建岗位入口:在列表页选择创建方式,由 JobEdit 根据 query 自动打开对应导入弹窗
const createFromPasted = () => { const onCreateCommand = (command) => {
router.push({ path: '/jobs/new', query: { import: 'paste' } }) if (command === 'paste') {
router.push({ path: '/jobs/new', query: { import: 'paste' } })
return
}
if (command === 'template') {
router.push({ path: '/jobs/new', query: { import: 'template' } })
return
}
router.push('/jobs/new')
} }
const clearFilter = () => { const clearFilter = () => {
...@@ -219,8 +235,25 @@ const deleteJob = async (job) => { ...@@ -219,8 +235,25 @@ const deleteJob = async (job) => {
</div> </div>
</div> </div>
<div class="job-command-actions"> <div class="job-command-actions">
<el-button class="job-command-add" @click="createFromPasted">📋 从 JD / 需求模板创建</el-button> <el-dropdown split-button type="primary" trigger="click" @click="newJob" @command="onCreateCommand">
<el-button type="primary" @click="newJob">新建岗位</el-button> 新建岗位
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="paste">
<el-icon><MagicStick /></el-icon>
粘贴 JD / 需求自动创建
</el-dropdown-item>
<el-dropdown-item command="template">
<el-icon><Document /></el-icon>
生成需求模板给用人部门
</el-dropdown-item>
<el-dropdown-item command="blank" divided>
<el-icon><EditPen /></el-icon>
空白表单手动填写
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div> </div>
</section> </section>
...@@ -312,6 +345,9 @@ const deleteJob = async (job) => { ...@@ -312,6 +345,9 @@ const deleteJob = async (job) => {
{{ job.salaryRange || '薪资待补充' }} {{ job.salaryRange || '薪资待补充' }}
</p> </p>
<div class="job-list-item-tags"> <div class="job-list-item-tags">
<span v-if="job.recruitType && job.recruitType !== '社招'" class="job-type-badge">{{
job.recruitType
}}</span>
<span class="job-type-badge">{{ jobTypeLabel(job.jobType, store.jobTypeOptions) }}</span> <span class="job-type-badge">{{ jobTypeLabel(job.jobType, store.jobTypeOptions) }}</span>
<span class="job-state-badge" :class="jobStateBadge(job.status).tone">{{ <span class="job-state-badge" :class="jobStateBadge(job.status).tone">{{
jobStateBadge(job.status).text jobStateBadge(job.status).text
...@@ -731,18 +767,5 @@ const deleteJob = async (job) => { ...@@ -731,18 +767,5 @@ const deleteJob = async (job) => {
display: flex; display: flex;
gap: 8px; gap: 8px;
flex-shrink: 0; 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> </style>
...@@ -29,10 +29,10 @@ function collect(job = {}) { ...@@ -29,10 +29,10 @@ function collect(job = {}) {
const title = String(job.title || '').trim() || '岗位招聘 JD' const title = String(job.title || '').trim() || '岗位招聘 JD'
const summary = String(job.jd || '').trim() const summary = String(job.jd || '').trim()
const meta = [ const meta = [
['招聘类型', job.recruitType],
['所属部门', job.department], ['所属部门', job.department],
['薪资范围', job.salaryRange], ['薪资范围', job.salaryRange],
['工作地点', job.workLocation], ['工作地点', job.workLocation],
['工作模式', job.workMode],
['职级', job.level], ['职级', job.level],
['招聘人数', Number(job.headcount) > 0 ? `${job.headcount} 人` : ''], ['招聘人数', Number(job.headcount) > 0 ? `${job.headcount} 人` : ''],
['到岗时间', job.startDate], ['到岗时间', job.startDate],
......
...@@ -66,9 +66,64 @@ export const MATCH_RULE_STATUS_OPTIONS = Object.values(MATCH_RULE_STATUS) ...@@ -66,9 +66,64 @@ export const MATCH_RULE_STATUS_OPTIONS = Object.values(MATCH_RULE_STATUS)
export const PRIORITY_OPTIONS = ['高', '中', '低'] export const PRIORITY_OPTIONS = ['高', '中', '低']
export const WORK_MODE_OPTIONS = ['全职', '实习', '远程', '混合'] // 招聘类型:社招为默认值,校招/实习/兼职按用人场景选择
export const RECRUIT_TYPE = {
SOCIAL: '社招',
CAMPUS: '校招',
INTERN: '实习',
PART_TIME: '兼职',
}
export const RECRUIT_TYPE_OPTIONS = Object.values(RECRUIT_TYPE)
export const HIRE_REASON_OPTIONS = ['新增', '替补', '项目扩张', '人才储备'] export const HIRE_REASON_OPTIONS = ['新增', '替补', '项目扩张', '人才储备']
// 基础信息里「一般不变」的字段用下拉:部门 / 职级 / 工作地点。
// 走 filterable + allow-create 的 el-select,既给出常用项,也允许录入列表外的自定义值。
export const DEPARTMENT_OPTIONS = [
'产品部',
'技术部',
'研发部',
'解决方案部',
'销售部',
'商务运营部',
'市场部',
'人力资源部',
'财务部',
'行政部',
]
export const LEVEL_OPTIONS = [
'P5',
'P6',
'P7',
'P8',
'P9',
'P10',
'专员',
'高级专员',
'主管',
'经理',
'高级经理',
'总监',
'高级总监',
'副总裁',
]
export const LOCATION_OPTIONS = [
'北京',
'上海',
'深圳',
'广州',
'杭州',
'成都',
'武汉',
'南京',
'苏州',
'西安',
'长沙',
'重庆',
]
// 预置发布渠道 // 预置发布渠道
export const CHANNEL_PRESETS = ['Boss', '猎聘', '智联', '官网', '内推'] export const CHANNEL_PRESETS = ['Boss', '猎聘', '智联', '官网', '内推']
import { normalizeCandidateTags, seedState, STAGE_ORDER, JOB_TYPE_UNCLASSIFIED } from './constants.js' import { normalizeCandidateTags, seedState, STAGE_ORDER, JOB_TYPE_UNCLASSIFIED } from './constants.js'
import { enrichCandidateFields } from './candidate.js' import { enrichCandidateFields } from './candidate.js'
import { cleanCandidateName } from './naming.js' import { cleanCandidateName } from './naming.js'
import { RECRUIT_TYPE } from './jobStatus.js'
export function uid(prefix) { export function uid(prefix) {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}` return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`
...@@ -114,31 +115,34 @@ const LEGACY_CATEGORY_TO_JOB_TYPE = { ...@@ -114,31 +115,34 @@ const LEGACY_CATEGORY_TO_JOB_TYPE = {
} }
export function normalizeJob(job = {}) { export function normalizeJob(job = {}) {
const jd = job.jd || '' // 旧版「工作模式」已由「招聘类型」替代:实习模式迁移为实习招聘,其余回退社招
const legacyWorkMode = job.workMode
const normalized = { ...job }
delete normalized.workMode
const jd = normalized.jd || ''
// 兼容旧数据:无 channels 明细时,由 publishedChannels 反推 // 兼容旧数据:无 channels 明细时,由 publishedChannels 反推
const legacyChannels = const legacyChannels =
Array.isArray(job.channels) && job.channels.length Array.isArray(normalized.channels) && normalized.channels.length
? job.channels ? normalized.channels
: (job.publishedChannels || []).map((name) => ({ : (normalized.publishedChannels || []).map((name) => ({
name, name,
link: '', link: '',
status: '已发布', status: '已发布',
publishedAt: job.channelSyncedAt || '', publishedAt: normalized.channelSyncedAt || '',
jdVersion: job.jdStatus === '已生效' ? job.jdVersion || 'v1' : '', jdVersion: normalized.jdStatus === '已生效' ? normalized.jdVersion || 'v1' : '',
})) }))
return { return {
jobType: job.jobType || LEGACY_CATEGORY_TO_JOB_TYPE[job.category] || JOB_TYPE_UNCLASSIFIED, jobType: normalized.jobType || LEGACY_CATEGORY_TO_JOB_TYPE[normalized.category] || JOB_TYPE_UNCLASSIFIED,
createdAt: job.createdAt || job.created_at || '', createdAt: normalized.createdAt || normalized.created_at || '',
salaryRange: '', salaryRange: '',
jdVersion: job.jdStatus === '已生效' ? 'v1 当前生效' : 'v1 草稿', jdVersion: normalized.jdStatus === '已生效' ? 'v1 当前生效' : 'v1 草稿',
approvalStatus: '未发起', approvalStatus: '未发起',
matchRuleStatus: job.matchKeywords || job.mustHave || job.competency ? '已配置' : '未配置', matchRuleStatus: normalized.matchKeywords || normalized.mustHave || normalized.competency ? '已配置' : '未配置',
channelStatus: '未发布', channelStatus: '未发布',
publishedChannels: [], publishedChannels: [],
channels: legacyChannels, channels: legacyChannels,
level: '', level: '',
workLocation: '', workLocation: '',
workMode: '',
hireReason: '', hireReason: '',
startDate: '', startDate: '',
reportTo: '', reportTo: '',
...@@ -154,8 +158,9 @@ export function normalizeJob(job = {}) { ...@@ -154,8 +158,9 @@ export function normalizeJob(job = {}) {
knockout: '', knockout: '',
matchKeywords: '', matchKeywords: '',
competency: '专业能力\n业务理解\n数据分析\n沟通协同\n抗压推进', competency: '专业能力\n业务理解\n数据分析\n沟通协同\n抗压推进',
jdHistory: [{ version: 'v1', status: job.jdStatus || '草稿', note: jd || '初始版本', date: '2026-05-29' }], jdHistory: [{ version: 'v1', status: normalized.jdStatus || '草稿', note: jd || '初始版本', date: '2026-05-29' }],
...job, ...normalized,
recruitType: normalized.recruitType || (legacyWorkMode === '实习' ? '实习' : RECRUIT_TYPE.SOCIAL),
} }
} }
......
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