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