Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
R
recruit-sys
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
李文光
recruit-sys
Commits
0c4c8cd3
Commit
0c4c8cd3
authored
Sep 09, 2026
by
李文光
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(jobs): 优化岗位表单薪资交互并新增招聘类型
parent
2ccdf0e5
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
688 additions
and
250 deletions
+688
-250
jd_import.py
backend/app/services/jd_import.py
+22
-3
test_jd_import.py
backend/tests/test_jd_import.py
+13
-1
JdImportModal.vue
vue-app/src/components/jobs/JdImportModal.vue
+271
-81
JobDetail.vue
vue-app/src/components/jobs/JobDetail.vue
+6
-3
JobEdit.vue
vue-app/src/components/jobs/JobEdit.vue
+259
-128
JobList.vue
vue-app/src/components/jobs/JobList.vue
+42
-19
jdMarkdown.js
vue-app/src/utils/jdMarkdown.js
+1
-1
jobStatus.js
vue-app/src/utils/jobStatus.js
+56
-1
normalize.js
vue-app/src/utils/normalize.js
+18
-13
No files found.
backend/app/services/jd_import.py
View file @
0c4c8cd3
...
...
@@ -30,7 +30,7 @@ EXTERNAL_KEYS = (
"competency"
,
)
# 基本信息字段(粘贴 JD 里常见)
BASE_KEYS
=
(
"title"
,
"department"
,
"headcount"
,
"workLocation"
,
"salaryRange"
)
BASE_KEYS
=
(
"title"
,
"department"
,
"
recruitType"
,
"
headcount"
,
"workLocation"
,
"salaryRange"
)
# 对内约束字段(用人部门需求,但绝不上对外 JD / LLM)
INTERNAL_KEYS
=
(
"genderRestriction"
,
...
...
@@ -59,11 +59,14 @@ _TEMPLATE_SECTIONS: list[tuple[str, str]] = [
_TEMPLATE_BASE_LABELS
=
{
"title"
:
"岗位名称"
,
"department"
:
"所属部门"
,
"recruitType"
:
"招聘类型"
,
"headcount"
:
"招聘人数"
,
"workLocation"
:
"工作地点"
,
"salaryRange"
:
"薪资范围"
,
}
_RECRUIT_TYPE_OPTIONS
=
(
"社招"
,
"校招"
,
"实习"
,
"兼职"
)
_ALIASES
:
dict
[
str
,
list
[
str
]]
=
{
"jd"
:
[
"岗位说明"
,
"职位说明"
,
"职位概述"
,
"岗位概述"
,
"JD 简介"
,
"jd简介"
,
"职位简介"
,
"招聘背景"
,
"背景介绍"
],
"responsibilities"
:
[
"工作职责"
,
"岗位职责"
,
"职位职责"
,
"职责描述"
,
"主要职责"
,
"工作内容"
,
"岗位描述"
,
"职位描述"
],
...
...
@@ -112,6 +115,7 @@ def build_requirement_template(job: dict[str, Any] | None = None) -> str:
base
=
{
"title"
:
str
(
job
.
get
(
"title"
)
or
""
),
"department"
:
str
(
job
.
get
(
"department"
)
or
""
),
"recruitType"
:
str
(
job
.
get
(
"recruitType"
)
or
""
),
"headcount"
:
str
(
job
.
get
(
"headcount"
)
or
""
),
"workLocation"
:
str
(
job
.
get
(
"workLocation"
)
or
job
.
get
(
"location"
)
or
""
),
"salaryRange"
:
str
(
job
.
get
(
"salaryRange"
)
or
""
),
...
...
@@ -198,11 +202,26 @@ def _parse_base_info(text: str) -> dict[str, str]:
continue
value
=
_label_line_value
(
line
,
label
)
if
value
:
if
key
==
"recruitType"
:
value
=
next
((
item
for
item
in
_RECRUIT_TYPE_OPTIONS
if
item
in
value
),
value
[:
20
])
result
[
key
]
=
value
if
"headcount"
not
in
result
:
match
=
re
.
search
(
r"(?:招聘|招)\s*(\d+)\s*人"
,
line
)
if
match
:
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
...
...
@@ -287,8 +306,8 @@ async def _parse_pasted_jd_llm(text: str, settings: Any) -> dict[str, Any] | Non
按原文对应小节逐条要点抽取,多条用换行分隔;保留原文的序号与列表符号
(如 1、2、3、或 -、•),原文没有序号或列表符号时就按换行分隔,不要自行添加。
4. matchKeywords:6-10 个岗位关键词,用中文逗号分隔。
5. title/department/workLocation/salaryRange/headcount:从基本信息或正文提取;
headcount 只输出数字
,
没有则空字符串。
5. title/department/
recruitType/
workLocation/salaryRange/headcount:从基本信息或正文提取;
headcount 只输出数字
;recruitType 只从 社招/校招/实习/兼职 中选,原文
没有则空字符串。
6. genderRestriction / ageRestriction / probationPeriod / probationCriteria /
nonCompete / verification 是对内约束:只在原文**明确出现**(如「性别要求:男」、
「年龄 35 以下」「试用期 3 个月」「需签竞业」「背调」)时提取;原文没提就空字符串,
...
...
backend/tests/test_jd_import.py
View file @
0c4c8cd3
...
...
@@ -16,6 +16,7 @@ from backend.app.services.jd_import import (
TEMPLATE_FILLED
=
"""## 岗位基本信息(可留空让 HR 补)
- 岗位名称:大客户销售经理
- 所属部门:市场部
- 招聘类型:校招
- 招聘人数:1
- 工作地点:深圳
- 薪资范围:20-35k
...
...
@@ -73,7 +74,14 @@ GENERIC_JD = """高级前端工程师(杭州)
def
test_template_contains_external_and_internal_sections
():
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
(
"岗位基本信息"
,
...
...
@@ -93,6 +101,7 @@ def test_template_contains_external_and_internal_sections():
assert
heading
in
tpl
,
f
"模板缺少章节:{heading}"
assert
"高级前端"
not
in
tpl
# 不预填无关岗位
assert
"Java开发工程师"
in
tpl
assert
"招聘类型:校招"
in
tpl
def
test_local_parse_template_roundtrip
():
...
...
@@ -100,6 +109,7 @@ def test_local_parse_template_roundtrip():
assert
parsed
[
"provider"
]
==
"local-structured"
assert
parsed
[
"title"
]
==
"大客户销售经理"
assert
parsed
[
"department"
]
==
"市场部"
assert
parsed
[
"recruitType"
]
==
"校招"
assert
parsed
[
"headcount"
]
==
"1"
assert
parsed
[
"workLocation"
]
==
"深圳"
assert
parsed
[
"salaryRange"
]
==
"20-35k"
...
...
@@ -163,6 +173,7 @@ def test_parse_pasted_jd_llm_first(llm_enabled, monkeypatch):
return
{
"title"
:
"大客户销售经理"
,
"department"
:
"市场部"
,
"recruitType"
:
"校招"
,
"headcount"
:
"1"
,
"workLocation"
:
"深圳"
,
"salaryRange"
:
"20-35k"
,
...
...
@@ -188,6 +199,7 @@ def test_parse_pasted_jd_llm_first(llm_enabled, monkeypatch):
result
=
asyncio
.
run
(
parse_pasted_jd
(
"一份招聘需求文本"
))
assert
result
[
"provider"
]
.
startswith
(
"llm:"
)
assert
result
[
"title"
]
==
"大客户销售经理"
assert
result
[
"recruitType"
]
==
"校招"
assert
result
[
"mustHave"
]
==
"独立成交 500 万项目"
assert
result
[
"nonCompete"
]
==
"需签"
assert
result
[
"genderRestriction"
]
==
"不限"
...
...
vue-app/src/components/jobs/JdImportModal.vue
View file @
0c4c8cd3
...
...
@@ -14,7 +14,9 @@ const props = defineProps({
})
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
applyMode
=
ref
(
'fill'
)
// fill = 仅补空字段;overwrite = 覆盖已填字段
const
parsing
=
ref
(
false
)
...
...
@@ -33,13 +35,15 @@ watch(
if
(
!
open
)
return
tab
.
value
=
props
.
initialMode
===
'template'
?
'template'
:
'paste'
parsed
.
value
=
null
}
},
{
immediate
:
true
}
)
// 可回填字段元数据(与后端 jd_import 的 ALL_KEYS 对应;kind 用于预览标签)
const
FIELD_META
=
[
{
key
:
'title'
,
label
:
'岗位名称'
,
kind
:
'base'
},
{
key
:
'department'
,
label
:
'所属部门'
,
kind
:
'base'
},
{
key
:
'recruitType'
,
label
:
'招聘类型'
,
kind
:
'base'
},
{
key
:
'headcount'
,
label
:
'招聘人数'
,
kind
:
'base'
},
{
key
:
'workLocation'
,
label
:
'工作地点'
,
kind
:
'base'
},
{
key
:
'salaryRange'
,
label
:
'薪资范围'
,
kind
:
'base'
},
...
...
@@ -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
kindText
=
(
kind
)
=>
(
kind
===
'internal'
?
'对内不对外'
:
kind
===
'content'
?
'对外 JD 内容'
:
'基本信息'
)
...
...
@@ -104,6 +113,7 @@ const buildTemplate = async () => {
const
job
=
{
title
:
props
.
job
.
title
||
''
,
department
:
props
.
job
.
department
||
''
,
recruitType
:
props
.
job
.
recruitType
||
''
,
headcount
:
props
.
job
.
headcount
||
''
,
workLocation
:
props
.
job
.
workLocation
||
''
,
salaryRange
:
props
.
job
.
salaryRange
||
''
,
...
...
@@ -137,137 +147,317 @@ const close = () => {
<
template
>
<el-dialog
:model-value=
"dialogOpen"
title=
"JD 导入 · 粘贴结构化 / 需求模板
"
width=
"
780px
"
class=
"jd-import-dialog
"
width=
"
min(800px, 94vw)
"
top=
"6vh"
:close-on-click-modal=
"false"
@
update:model-value=
"dialogOpen = $event"
@
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-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
class=
"jd-steps"
>
<div
class=
"jd-step"
>
<div
class=
"jd-step-head"
>
<span
class=
"jd-step-num"
>
1
</span>
<div
class=
"jd-step-copy"
>
<b>
粘贴 JD / 需求模板原文
</b>
<span
>
整段粘贴用人部门填写的模板或现有
JD,自动拆出职责、要求、硬性、加分、排除、关键词、胜任力与对内约束
</span
>
</div>
</div>
<el-input
v-model=
"pasteText"
type=
"textarea"
:rows=
"9"
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-parse-preview-head"
>
<b>
解析结果预览
</b>
<span>
回填时
{{
applyMode
===
'fill'
?
'只补空字段,不覆盖手填内容'
:
'会覆盖同名已填字段'
}}
</span>
<div
class=
"jd-step"
>
<div
class=
"jd-step-head"
>
<span
class=
"jd-step-num"
>
2
</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
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
v-if=
"parsed"
class=
"jd-step"
>
<div
class=
"jd-step-head"
>
<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>
</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
class=
"jd-steps"
>
<div
class=
"jd-step"
>
<div
class=
"jd-step-head"
>
<span
class=
"jd-step-num"
>
1
</span>
<div
class=
"jd-step-copy"
>
<b>
生成需求模板
</b>
<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>
<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>
<div
class=
"jd-footer"
>
<span
v-if=
"tab === 'paste' && parsed"
class=
"jd-footer-note"
>
{{
applyMode
===
'fill'
?
'仅补空字段,不覆盖手填内容'
:
'将覆盖岗位表单同名已填字段'
}}
</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
>
</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-dialog
{
:deep
(
.el-dialog__header
)
{
padding
:
20px
24px
6px
;
}
: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
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
10px
;
margin
:
10px
0
4px
;
flex-direction
:
column
;
gap
:
18px
;
}
.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-step-head
{
display
:
flex
;
gap
:
10px
;
align-items
:
flex-start
;
margin-bottom
:
10px
;
.jd-parse-preview-head
{
display
:
flex
;
.jd-step-num
{
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
;
justify-content
:
space-between
;
gap
:
12px
;
margin-bottom
:
8px
;
justify-content
:
center
;
font-size
:
12px
;
font-weight
:
600
;
line-height
:
1
;
}
.jd-step-copy
{
min-width
:
0
;
b
{
font-size
:
13px
;
display
:
block
;
font-size
:
14px
;
font-weight
:
600
;
color
:
var
(
--
ink
);
}
span
{
font-size
:
12px
;
display
:
block
;
margin-top
:
2px
;
font-size
:
12
.5px
;
line-height
:
1
.55
;
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
{
display
:
grid
;
grid-template-columns
:
130px
1fr
;
align-items
:
baseline
;
gap
:
8px
;
padding
:
5
px
0
;
grid-template-columns
:
auto
minmax
(
96px
,
140px
)
minmax
(
0
,
1fr
)
;
gap
:
10px
14px
;
align-items
:
start
;
padding
:
8
px
0
;
&
+
.jd-parse-row
{
border-top
:
1px
dashed
rgba
(
24
,
32
,
51
,
0
.06
);
border-top
:
1px
solid
rgba
(
24
,
32
,
51
,
0
.07
);
}
b
{
font-size
:
13
px
;
.jd-row-tag
{
margin-top
:
1
px
;
}
span
{
font-size
:
1
2
px
;
color
:
var
(
--
muted
)
;
line-height
:
1
.5
;
.jd-row-label
{
font-size
:
1
3
px
;
font-weight
:
600
;
color
:
var
(
--
ink
)
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
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
>
vue-app/src/components/jobs/JobDetail.vue
View file @
0c4c8cd3
...
...
@@ -485,7 +485,7 @@ const mainAction = async () => {
<section
class=
"job-focus-hero"
>
<div>
<span
class=
"section-kicker"
>
{{
job
.
department
||
'未设置部门'
}}
</span>
<span
class=
"section-kicker"
>
{{
job
.
recruitType
||
'社招'
}}
·
{{
job
.
department
||
'未设置部门'
}}
</span>
<div
class=
"job-focus-title"
>
<h2>
{{
job
.
title
}}
</h2>
<el-tag
v-if=
"job.priority === '高'"
type=
"danger"
size=
"small"
>
高优先级
</el-tag>
...
...
@@ -575,8 +575,8 @@ const mainAction = async () => {
<
div
v
-
if
=
"job.workLocation"
>
<
span
>
工作地点
<
/span><strong>{{ job.workLocation
}}
</
strong
>
<
/div
>
<
div
v
-
if
=
"job.
workMod
e"
>
<
span
>
工作模式
<
/span><strong>{{ job.workMod
e
}}
</
strong
>
<
div
v
-
if
=
"job.
recruitTyp
e"
>
<
span
>
招聘类型
<
/span><strong>{{ job.recruitTyp
e
}}
</
strong
>
<
/div
>
<
div
v
-
if
=
"job.hireReason"
>
<
span
>
招聘原因
<
/span><strong>{{ job.hireReason
}}
</
strong
>
...
...
@@ -672,6 +672,9 @@ const mainAction = async () => {
<
div
>
<
span
>
岗位名称
<
/span><strong>{{ job.title || '未填写'
}}
</
strong
>
<
/div
>
<
div
>
<
span
>
招聘类型
<
/span><strong>{{ job.recruitType || '社招'
}}
</
strong
>
<
/div
>
<
div
>
<
span
>
所属部门
<
/span><strong>{{ job.department || '未填写'
}}
</
strong
>
<
/div
>
...
...
vue-app/src/components/jobs/JobEdit.vue
View file @
0c4c8cd3
<
script
setup
>
import
{
computed
,
reactive
,
ref
}
from
'vue'
import
{
computed
,
reactive
,
ref
,
watch
}
from
'vue'
import
{
useRoute
,
useRouter
}
from
'vue-router'
import
{
ElMessageBox
}
from
'element-plus'
...
...
@@ -15,8 +15,12 @@ import {
JD_STATUS_EDITABLE_OPTIONS
,
APPROVAL_STATUS
,
PRIORITY_OPTIONS
,
WORK_MODE_OPTIONS
,
HIRE_REASON_OPTIONS
,
RECRUIT_TYPE
,
RECRUIT_TYPE_OPTIONS
,
DEPARTMENT_OPTIONS
,
LEVEL_OPTIONS
,
LOCATION_OPTIONS
,
}
from
'@/utils/jobStatus'
import
JdGrillModal
from
'./JdGrillModal.vue'
import
JdGeneratePanel
from
'./JdGeneratePanel.vue'
...
...
@@ -42,10 +46,37 @@ const jobTypeSelectOptions = computed(() => {
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
({
jobId
:
''
,
title
:
''
,
department
:
''
,
recruitType
:
RECRUIT_TYPE
.
SOCIAL
,
jobType
:
JOB_TYPE_UNCLASSIFIED
,
headcount
:
1
,
owner
:
'Charie'
,
...
...
@@ -64,7 +95,6 @@ const form = reactive({
competency
:
'专业能力
\
n业务理解
\
n数据分析
\
n沟通协同
\
n抗压推进'
,
level
:
''
,
workLocation
:
''
,
workMode
:
''
,
hireReason
:
''
,
startDate
:
''
,
reportTo
:
''
,
...
...
@@ -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
const
REQUIRED_CREATE_FIELDS
=
[
{
key
:
'title'
,
label
:
'岗位名称'
,
tab
:
'base'
},
...
...
@@ -94,6 +189,7 @@ const syncForm = () => {
form
.
jobId
=
job
?.
id
||
''
form
.
title
=
job
?.
title
||
''
form
.
department
=
job
?.
department
||
''
form
.
recruitType
=
job
?.
recruitType
||
RECRUIT_TYPE
.
SOCIAL
form
.
jobType
=
job
?.
jobType
||
JOB_TYPE_UNCLASSIFIED
form
.
headcount
=
Number
(
job
?.
headcount
||
1
)
form
.
owner
=
job
?.
owner
||
'Charie'
...
...
@@ -112,7 +208,6 @@ const syncForm = () => {
form
.
competency
=
job
?.
competency
||
'专业能力
\
n业务理解
\
n数据分析
\
n沟通协同
\
n抗压推进'
form
.
level
=
job
?.
level
||
''
form
.
workLocation
=
job
?.
workLocation
||
''
form
.
workMode
=
job
?.
workMode
||
''
form
.
hireReason
=
job
?.
hireReason
||
''
form
.
startDate
=
job
?.
startDate
||
''
form
.
reportTo
=
job
?.
reportTo
||
''
...
...
@@ -124,9 +219,11 @@ const syncForm = () => {
nonCompete
:
job
?.
nonCompete
||
''
,
verification
:
job
?.
verification
||
''
,
}
syncSalaryPair
()
}
syncForm
()
watch
(()
=>
form
.
salaryRange
,
syncSalaryPair
)
const
back
=
()
=>
{
if
(
existing
.
value
)
router
.
push
(
`/jobs/
${
existing
.
value
.
id
}
`
)
...
...
@@ -143,6 +240,7 @@ const aiAssistCreate = async () => {
const
baseJob
=
{
title
:
form
.
title
,
department
:
form
.
department
,
recruitType
:
form
.
recruitType
,
jobType
:
form
.
jobType
,
headcount
:
form
.
headcount
,
owner
:
form
.
owner
,
...
...
@@ -241,6 +339,7 @@ const submit = async (saveMode = 'update') => {
createdAt
:
existing
.
value
?.
createdAt
||
new
Date
().
toISOString
(),
title
:
form
.
title
,
department
:
form
.
department
,
recruitType
:
form
.
recruitType
||
RECRUIT_TYPE
.
SOCIAL
,
jobType
:
form
.
jobType
||
JOB_TYPE_UNCLASSIFIED
,
headcount
:
Number
(
form
.
headcount
||
1
),
owner
:
form
.
owner
||
'Charie'
,
...
...
@@ -259,7 +358,6 @@ const submit = async (saveMode = 'update') => {
competency
:
form
.
competency
||
''
,
level
:
form
.
level
||
''
,
workLocation
:
form
.
workLocation
||
''
,
workMode
:
form
.
workMode
||
''
,
hireReason
:
form
.
hireReason
||
''
,
startDate
:
form
.
startDate
||
''
,
reportTo
:
form
.
reportTo
||
''
,
...
...
@@ -326,7 +424,7 @@ const applyParsed = ({ fields = {}, mode = 'fill' }) => {
'matchKeywords'
,
'competency'
,
]
const
baseKeys
=
[
'title'
,
'department'
,
'workLocation'
,
'salaryRange'
]
const
baseKeys
=
[
'title'
,
'department'
,
'
recruitType'
,
'
workLocation'
,
'salaryRange'
]
const
internalKeys
=
[
'genderRestriction'
,
'ageRestriction'
,
...
...
@@ -388,33 +486,6 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
</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 基础信息 ============ -->
...
...
@@ -423,8 +494,21 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
<el-form-item
label=
"岗位名称"
required
>
<el-input
v-model=
"form.title"
/>
</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-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
label=
"岗位类型"
>
<el-select
v-model=
"form.jobType"
placeholder=
"选择岗位类型"
style=
"width: 100%"
>
...
...
@@ -432,14 +516,31 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
</el-select>
</el-form-item>
<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
label=
"工作地点"
required
>
<el-input
v-model=
"form.workLocation"
placeholder=
"如 上海"
/>
</el-form-item>
<el-form-item
label=
"工作模式"
>
<el-select
v-model=
"form.workMode"
>
<el-option
v-for=
"mode in WORK_MODE_OPTIONS"
:key=
"mode"
:label=
"mode"
:value=
"mode"
/>
<el-select
v-model=
"form.workLocation"
placeholder=
"选择或输入工作地点"
filterable
allow-create
default-first-option
>
<el-option
v-for=
"location in locationSelectOptions"
:key=
"location"
:label=
"location"
:value=
"location"
/>
</el-select>
</el-form-item>
<el-form-item
label=
"招聘人数"
required
>
...
...
@@ -449,7 +550,59 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
<el-input
v-model=
"form.owner"
/>
</el-form-item>
<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
label=
"优先级"
>
<el-select
v-model=
"form.priority"
>
...
...
@@ -675,116 +828,94 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
line-height
:
1
.5
;
}
.jd-tools
{
.salary-range-field
{
width
:
100%
;
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
);
}
flex-direction
:
column
;
gap
:
8px
;
.
jd-tools-btns
{
.
salary-range-pair
{
display
:
flex
;
gap
:
8px
;
flex-shrink
:
0
;
align-items
:
center
;
gap
:
10px
;
}
}
.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
);
.salary-pick
{
flex
:
1
;
min-width
:
0
;
.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
;
.salary-pick-label
{
display
:
block
;
margin-bottom
:
6px
;
font-size
:
12px
;
line-height
:
1
;
color
:
var
(
--
muted
);
}
p
{
margin
:
0
;
color
:
var
(
--
muted
);
font-size
:
13px
;
line-height
:
1
.6
;
.salary-pick-select
{
width
:
100%
;
}
}
.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
;
}
.salary-range-sep
{
flex-shrink
:
0
;
padding-bottom
:
1px
;
color
:
var
(
--
muted
);
font-size
:
13px
;
}
.
jd-entry-step
{
.
salary-range-readout
{
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
;
}
align-items
:
center
;
gap
:
8px
;
min-height
:
22px
;
font-size
:
13px
;
}
.jd-entry-body
{
min-width
:
0
;
.salary-range-value
{
color
:
var
(
--
blue
);
font-weight
:
600
;
}
b
{
display
:
block
;
font-size
:
14px
;
margin-bottom
:
4px
;
}
.salary-range-clear
{
padding
:
0
;
font-size
:
12px
;
}
p
{
margin
:
0
0
8px
;
color
:
var
(
--
muted
);
font-size
:
12px
;
line-height
:
1
.6
;
}
@media
(
max-width
:
420px
)
{
.salary-range-pair
{
align-items
:
stretch
;
flex-direction
:
column
;
gap
:
8px
;
}
.salary-range-sep
{
display
:
none
;
}
}
}
.jd-entry-foot
{
margin
:
10px
0
0
;
.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
;
}
}
.internal-card
{
...
...
vue-app/src/components/jobs/JobList.vue
View file @
0c4c8cd3
...
...
@@ -21,7 +21,15 @@ const filteredJobs = computed(() => {
const
q
=
(
filters
.
q
||
''
).
trim
().
toLowerCase
()
return
jobs
.
value
.
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
(
' '
)
.
toLowerCase
()
if
(
q
&&
!
fields
.
includes
(
q
))
return
false
...
...
@@ -129,9 +137,17 @@ const newJob = () => {
router
.
push
(
'/jobs/new'
)
}
// 需求 9 快捷入口:从用人部门填好的 JD/模板直接进入粘贴创建
const
createFromPasted
=
()
=>
{
router
.
push
({
path
:
'/jobs/new'
,
query
:
{
import
:
'paste'
}
})
// 新建岗位入口:在列表页选择创建方式,由 JobEdit 根据 query 自动打开对应导入弹窗
const
onCreateCommand
=
(
command
)
=>
{
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
=
()
=>
{
...
...
@@ -219,8 +235,25 @@ const deleteJob = async (job) => {
</div>
</div>
<div
class=
"job-command-actions"
>
<el-button
class=
"job-command-add"
@
click=
"createFromPasted"
>
📋 从 JD / 需求模板创建
</el-button>
<el-button
type=
"primary"
@
click=
"newJob"
>
新建岗位
</el-button>
<el-dropdown
split-button
type=
"primary"
trigger=
"click"
@
click=
"newJob"
@
command=
"onCreateCommand"
>
新建岗位
<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>
</section>
...
...
@@ -312,6 +345,9 @@ const deleteJob = async (job) => {
{{ job.salaryRange || '薪资待补充' }}
</p>
<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-state-badge"
:class=
"jobStateBadge(job.status).tone"
>
{{
jobStateBadge(job.status).text
...
...
@@ -731,18 +767,5 @@ const deleteJob = async (job) => {
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
>
vue-app/src/utils/jdMarkdown.js
View file @
0c4c8cd3
...
...
@@ -29,10 +29,10 @@ function collect(job = {}) {
const
title
=
String
(
job
.
title
||
''
).
trim
()
||
'岗位招聘 JD'
const
summary
=
String
(
job
.
jd
||
''
).
trim
()
const
meta
=
[
[
'招聘类型'
,
job
.
recruitType
],
[
'所属部门'
,
job
.
department
],
[
'薪资范围'
,
job
.
salaryRange
],
[
'工作地点'
,
job
.
workLocation
],
[
'工作模式'
,
job
.
workMode
],
[
'职级'
,
job
.
level
],
[
'招聘人数'
,
Number
(
job
.
headcount
)
>
0
?
`
${
job
.
headcount
}
人`
:
''
],
[
'到岗时间'
,
job
.
startDate
],
...
...
vue-app/src/utils/jobStatus.js
View file @
0c4c8cd3
...
...
@@ -66,9 +66,64 @@ export const MATCH_RULE_STATUS_OPTIONS = Object.values(MATCH_RULE_STATUS)
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
=
[
'新增'
,
'替补'
,
'项目扩张'
,
'人才储备'
]
// 基础信息里「一般不变」的字段用下拉:部门 / 职级 / 工作地点。
// 走 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'
,
'猎聘'
,
'智联'
,
'官网'
,
'内推'
]
vue-app/src/utils/normalize.js
View file @
0c4c8cd3
import
{
normalizeCandidateTags
,
seedState
,
STAGE_ORDER
,
JOB_TYPE_UNCLASSIFIED
}
from
'./constants.js'
import
{
enrichCandidateFields
}
from
'./candidate.js'
import
{
cleanCandidateName
}
from
'./naming.js'
import
{
RECRUIT_TYPE
}
from
'./jobStatus.js'
export
function
uid
(
prefix
)
{
return
`
${
prefix
}
-
${
Date
.
now
()}
-
${
Math
.
floor
(
Math
.
random
()
*
1000
)}
`
...
...
@@ -114,31 +115,34 @@ const LEGACY_CATEGORY_TO_JOB_TYPE = {
}
export
function
normalizeJob
(
job
=
{})
{
const
jd
=
job
.
jd
||
''
// 旧版「工作模式」已由「招聘类型」替代:实习模式迁移为实习招聘,其余回退社招
const
legacyWorkMode
=
job
.
workMode
const
normalized
=
{
...
job
}
delete
normalized
.
workMode
const
jd
=
normalized
.
jd
||
''
// 兼容旧数据:无 channels 明细时,由 publishedChannels 反推
const
legacyChannels
=
Array
.
isArray
(
job
.
channels
)
&&
job
.
channels
.
length
?
job
.
channels
:
(
job
.
publishedChannels
||
[]).
map
((
name
)
=>
({
Array
.
isArray
(
normalized
.
channels
)
&&
normalized
.
channels
.
length
?
normalized
.
channels
:
(
normalized
.
publishedChannels
||
[]).
map
((
name
)
=>
({
name
,
link
:
''
,
status
:
'已发布'
,
publishedAt
:
job
.
channelSyncedAt
||
''
,
jdVersion
:
job
.
jdStatus
===
'已生效'
?
job
.
jdVersion
||
'v1'
:
''
,
publishedAt
:
normalized
.
channelSyncedAt
||
''
,
jdVersion
:
normalized
.
jdStatus
===
'已生效'
?
normalized
.
jdVersion
||
'v1'
:
''
,
}))
return
{
jobType
:
job
.
jobType
||
LEGACY_CATEGORY_TO_JOB_TYPE
[
job
.
category
]
||
JOB_TYPE_UNCLASSIFIED
,
createdAt
:
job
.
createdAt
||
job
.
created_at
||
''
,
jobType
:
normalized
.
jobType
||
LEGACY_CATEGORY_TO_JOB_TYPE
[
normalized
.
category
]
||
JOB_TYPE_UNCLASSIFIED
,
createdAt
:
normalized
.
createdAt
||
normalized
.
created_at
||
''
,
salaryRange
:
''
,
jdVersion
:
job
.
jdStatus
===
'已生效'
?
'v1 当前生效'
:
'v1 草稿'
,
jdVersion
:
normalized
.
jdStatus
===
'已生效'
?
'v1 当前生效'
:
'v1 草稿'
,
approvalStatus
:
'未发起'
,
matchRuleStatus
:
job
.
matchKeywords
||
job
.
mustHave
||
job
.
competency
?
'已配置'
:
'未配置'
,
matchRuleStatus
:
normalized
.
matchKeywords
||
normalized
.
mustHave
||
normalized
.
competency
?
'已配置'
:
'未配置'
,
channelStatus
:
'未发布'
,
publishedChannels
:
[],
channels
:
legacyChannels
,
level
:
''
,
workLocation
:
''
,
workMode
:
''
,
hireReason
:
''
,
startDate
:
''
,
reportTo
:
''
,
...
...
@@ -154,8 +158,9 @@ export function normalizeJob(job = {}) {
knockout
:
''
,
matchKeywords
:
''
,
competency
:
'专业能力
\
n业务理解
\
n数据分析
\
n沟通协同
\
n抗压推进'
,
jdHistory
:
[{
version
:
'v1'
,
status
:
job
.
jdStatus
||
'草稿'
,
note
:
jd
||
'初始版本'
,
date
:
'2026-05-29'
}],
...
job
,
jdHistory
:
[{
version
:
'v1'
,
status
:
normalized
.
jdStatus
||
'草稿'
,
note
:
jd
||
'初始版本'
,
date
:
'2026-05-29'
}],
...
normalized
,
recruitType
:
normalized
.
recruitType
||
(
legacyWorkMode
===
'实习'
?
'实习'
:
RECRUIT_TYPE
.
SOCIAL
),
}
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment