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
9c9e4c37
Commit
9c9e4c37
authored
Sep 01, 2026
by
李文光
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: refine JD workflow and confirmation actions
parent
966bdc6a
Hide whitespace changes
Inline
Side-by-side
Showing
16 changed files
with
979 additions
and
75 deletions
+979
-75
ai.py
backend/app/routers/ai.py
+57
-1
llm.py
backend/app/services/llm.py
+2
-2
qwenpaw_client.py
backend/app/services/qwenpaw_client.py
+13
-12
test_ai.py
backend/tests/test_ai.py
+46
-0
index.js
vue-app/src/api/index.js
+1
-1
recruitment.js
vue-app/src/api/recruitment.js
+46
-1
JdGeneratePanel.vue
vue-app/src/components/jobs/JdGeneratePanel.vue
+190
-0
JdGrillModal.vue
vue-app/src/components/jobs/JdGrillModal.vue
+9
-5
JobDetail.vue
vue-app/src/components/jobs/JobDetail.vue
+313
-31
JobEdit.vue
vue-app/src/components/jobs/JobEdit.vue
+6
-3
ResumeList.vue
vue-app/src/components/resumes/ResumeList.vue
+42
-1
recruitment.js
vue-app/src/stores/recruitment.js
+123
-2
jdMarkdown.js
vue-app/src/utils/jdMarkdown.js
+88
-0
job.js
vue-app/src/utils/job.js
+8
-0
jobFlow.js
vue-app/src/utils/jobFlow.js
+32
-16
jobStatus.js
vue-app/src/utils/jobStatus.js
+3
-0
No files found.
backend/app/routers/ai.py
View file @
9c9e4c37
...
@@ -5,12 +5,13 @@ from fastapi import APIRouter, Depends, Request
...
@@ -5,12 +5,13 @@ from fastapi import APIRouter, Depends, Request
from
fastapi.responses
import
StreamingResponse
from
fastapi.responses
import
StreamingResponse
from
sqlalchemy.orm
import
Session
from
sqlalchemy.orm
import
Session
from
backend.app.config
import
get_settings
from
backend.app.db
import
get_db
from
backend.app.db
import
get_db
from
backend.app.security
import
verify_ingest_token
from
backend.app.security
import
verify_ingest_token
from
backend.app.services.external_sync
import
sync_external_jd
from
backend.app.services.external_sync
import
sync_external_jd
from
backend.app.services.jd_grill
import
advance
as
grill_advance
from
backend.app.services.jd_grill
import
advance
as
grill_advance
from
backend.app.services.jd_grill
import
start
as
grill_start
from
backend.app.services.jd_grill
import
start
as
grill_start
from
backend.app.services.llm
import
analyze_resume
,
generate_jd_draft
from
backend.app.services.llm
import
_sanitize_job_for_external
,
analyze_resume
,
generate_jd_draft
from
backend.app.services.qwenpaw_client
import
(
from
backend.app.services.qwenpaw_client
import
(
job_info_completeness
,
job_info_completeness
,
qwenpaw_chat_stream
,
qwenpaw_chat_stream
,
...
@@ -29,6 +30,61 @@ async def generate_jd_route(payload: dict[str, Any]) -> dict[str, Any]:
...
@@ -29,6 +30,61 @@ async def generate_jd_route(payload: dict[str, Any]) -> dict[str, Any]:
return
await
generate_jd_draft
(
payload
)
return
await
generate_jd_draft
(
payload
)
@
router
.
post
(
"/generate-jd/stream"
)
async
def
generate_jd_stream_route
(
payload
:
dict
[
str
,
Any
])
->
StreamingResponse
:
"""流式生成 JD:把后端处理过程实时推给前端(SSE 事件行)。
事件序列:
- {"type":"stage","stage":"checking","message":...} 校验岗位信息
- {"type":"needs_chat","missing":[...],"message":...} 信息不全(force=False 时),前端引导补全
- {"type":"stage","stage":"generating","message":...} AI 生成中
- {"type":"done","draft":{...}} 生成完成
- {"type":"error","message":...} 失败
force=True 时跳过信息完整性检查,直接用现有信息生成草稿(缺的字段由生成器推断)。
"""
job
=
payload
.
get
(
"job"
)
or
{}
mode
=
payload
.
get
(
"mode"
)
or
"generate"
force
=
bool
(
payload
.
get
(
"force"
))
async
def
event_stream
()
->
AsyncIterator
[
str
]:
def
emit
(
event
:
dict
[
str
,
Any
])
->
str
:
return
f
"data: {_json_dumps(event)}
\n\n
"
# 1. 校验阶段
yield
emit
({
"type"
:
"stage"
,
"stage"
:
"checking"
,
"message"
:
"正在校验岗位信息完整性…"
})
# 2. 信息不全时先提示(仅 QwenPaw 启用时;force 直接跳过)
if
not
force
and
get_settings
()
.
qwenpaw_enabled
:
completeness
=
job_info_completeness
(
job
)
if
not
completeness
[
"complete"
]:
yield
emit
(
{
"type"
:
"needs_chat"
,
"missing"
:
completeness
[
"missing"
],
"message"
:
completeness
[
"message"
],
"mode"
:
mode
,
}
)
return
# 3. 生成
yield
emit
({
"type"
:
"stage"
,
"stage"
:
"generating"
,
"message"
:
"AI 正在生成 JD 草稿,请稍候…"
})
try
:
safe_payload
=
{
**
payload
,
"job"
:
_sanitize_job_for_external
(
job
)}
draft
=
await
generate_jd_draft
(
safe_payload
,
force
=
force
)
except
Exception
as
exc
:
# pragma: no cover - defensive
yield
emit
({
"type"
:
"error"
,
"message"
:
f
"JD 生成失败: {exc}"
})
return
yield
emit
({
"type"
:
"done"
,
"draft"
:
draft
})
return
StreamingResponse
(
event_stream
(),
media_type
=
"text/event-stream"
,
headers
=
{
"Cache-Control"
:
"no-cache"
,
"X-Accel-Buffering"
:
"no"
},
)
@
router
.
post
(
"/jd/completeness"
)
@
router
.
post
(
"/jd/completeness"
)
async
def
jd_completeness_route
(
payload
:
dict
[
str
,
Any
])
->
dict
[
str
,
Any
]:
async
def
jd_completeness_route
(
payload
:
dict
[
str
,
Any
])
->
dict
[
str
,
Any
]:
"""评估岗位信息是否足够直接生成 JD。信息不全时前端应切到聊天追问。"""
"""评估岗位信息是否足够直接生成 JD。信息不全时前端应切到聊天追问。"""
...
...
backend/app/services/llm.py
View file @
9c9e4c37
...
@@ -361,11 +361,11 @@ def _sanitize_job_for_external(job: dict[str, Any]) -> dict[str, Any]:
...
@@ -361,11 +361,11 @@ def _sanitize_job_for_external(job: dict[str, Any]) -> dict[str, Any]:
return
safe
return
safe
async
def
generate_jd_draft
(
payload
:
dict
[
str
,
Any
])
->
dict
[
str
,
Any
]:
async
def
generate_jd_draft
(
payload
:
dict
[
str
,
Any
]
,
*
,
force
:
bool
=
False
)
->
dict
[
str
,
Any
]:
# QwenPaw 独立智能体服务优先;未启用或失败时回退到 LLM 直连 / 本地结构化。
# QwenPaw 独立智能体服务优先;未启用或失败时回退到 LLM 直连 / 本地结构化。
# 先剥离对内字段,避免敏感信息随 job 发送到对外 LLM / 写进对外 JD。
# 先剥离对内字段,避免敏感信息随 job 发送到对外 LLM / 写进对外 JD。
safe_payload
=
{
**
payload
,
"job"
:
_sanitize_job_for_external
(
payload
.
get
(
"job"
)
or
{})}
safe_payload
=
{
**
payload
,
"job"
:
_sanitize_job_for_external
(
payload
.
get
(
"job"
)
or
{})}
qwenpaw_draft
=
await
qwenpaw_jd_draft
(
safe_payload
)
qwenpaw_draft
=
await
qwenpaw_jd_draft
(
safe_payload
,
force
=
force
)
if
qwenpaw_draft
:
if
qwenpaw_draft
:
return
qwenpaw_draft
return
qwenpaw_draft
return
await
llm_jd_draft
(
safe_payload
)
or
local_jd_draft
(
payload
)
return
await
llm_jd_draft
(
safe_payload
)
or
local_jd_draft
(
payload
)
backend/app/services/qwenpaw_client.py
View file @
9c9e4c37
...
@@ -230,12 +230,12 @@ def _extract_jd_body(reply: str) -> str:
...
@@ -230,12 +230,12 @@ def _extract_jd_body(reply: str) -> str:
return
""
return
""
async
def
qwenpaw_jd_draft
(
payload
:
dict
[
str
,
Any
])
->
dict
[
str
,
Any
]
|
None
:
async
def
qwenpaw_jd_draft
(
payload
:
dict
[
str
,
Any
]
,
*
,
force
:
bool
=
False
)
->
dict
[
str
,
Any
]
|
None
:
"""通过 QwenPaw 生成 JD 草稿。
"""通过 QwenPaw 生成 JD 草稿。
返回三种情况:
返回三种情况:
- None:未启用或服务不可用,调用方回退现有 LLM/本地逻辑。
- None:未启用或服务不可用,调用方回退现有 LLM/本地逻辑。
- {"needs_chat": True, ...}:岗位信息不全,建议前端切换到聊天追问模式。
- {"needs_chat": True, ...}:岗位信息不全
(force=False 时)
,建议前端切换到聊天追问模式。
- 完整 JD 草稿:信息足够,直接产出。
- 完整 JD 草稿:信息足够,直接产出。
"""
"""
settings
=
get_settings
()
settings
=
get_settings
()
...
@@ -244,16 +244,17 @@ async def qwenpaw_jd_draft(payload: dict[str, Any]) -> dict[str, Any] | None:
...
@@ -244,16 +244,17 @@ async def qwenpaw_jd_draft(payload: dict[str, Any]) -> dict[str, Any] | None:
job
=
payload
.
get
(
"job"
)
or
{}
job
=
payload
.
get
(
"job"
)
or
{}
mode
=
payload
.
get
(
"mode"
)
or
"generate"
mode
=
payload
.
get
(
"mode"
)
or
"generate"
# 信息不全 → 提示前端转聊天追问,而不是静默回退
# 信息不全 → 提示前端转聊天追问,而不是静默回退;force=True 时跳过(用现有信息硬生成)
completeness
=
job_info_completeness
(
job
)
if
not
force
:
if
not
completeness
[
"complete"
]:
completeness
=
job_info_completeness
(
job
)
return
{
if
not
completeness
[
"complete"
]:
"needs_chat"
:
True
,
return
{
"provider"
:
"qwenpaw"
,
"needs_chat"
:
True
,
"missing"
:
completeness
[
"missing"
],
"provider"
:
"qwenpaw"
,
"message"
:
completeness
[
"message"
],
"missing"
:
completeness
[
"missing"
],
"mode"
:
mode
,
"message"
:
completeness
[
"message"
],
}
"mode"
:
mode
,
}
mode_text
=
"基于现有JD进行版本迭代:保留仍然有效的内容,改写、细化、补强,不推翻重写、不照抄原文"
if
mode
==
"iterate"
else
"从零生成JD草稿:把零散岗位信息扩写成完整、专业、可直接发布的中文招聘JD"
mode_text
=
"基于现有JD进行版本迭代:保留仍然有效的内容,改写、细化、补强,不推翻重写、不照抄原文"
if
mode
==
"iterate"
else
"从零生成JD草稿:把零散岗位信息扩写成完整、专业、可直接发布的中文招聘JD"
prompt
=
(
prompt
=
(
...
...
backend/tests/test_ai.py
View file @
9c9e4c37
import
json
def
test_ai_fallback_routes
(
client
,
monkeypatch
):
def
test_ai_fallback_routes
(
client
,
monkeypatch
):
monkeypatch
.
setenv
(
"LLM_API_KEY"
,
""
)
monkeypatch
.
setenv
(
"LLM_API_KEY"
,
""
)
analysis
=
client
.
post
(
analysis
=
client
.
post
(
...
@@ -17,6 +20,49 @@ def test_ai_fallback_routes(client, monkeypatch):
...
@@ -17,6 +20,49 @@ def test_ai_fallback_routes(client, monkeypatch):
assert
jd
.
json
()[
"jdStatus"
]
==
"待确认"
assert
jd
.
json
()[
"jdStatus"
]
==
"待确认"
def
_stream_events
(
resp
):
"""把 SSE 响应体解析为事件列表。"""
return
[
json
.
loads
(
line
[
6
:])
for
line
in
resp
.
text
.
splitlines
()
if
line
.
startswith
(
"data:"
)
and
line
[
5
:]
.
strip
()
]
def
test_generate_jd_stream_local
(
client
,
monkeypatch
):
"""流式生成端点:无 QwenPaw 时直接生成,事件序列为 checking → generating → done。"""
monkeypatch
.
setenv
(
"LLM_API_KEY"
,
""
)
resp
=
client
.
post
(
"/api/generate-jd/stream"
,
json
=
{
"job"
:
{
"title"
:
"Java开发工程师"
,
"department"
:
"技术部"
},
"mode"
:
"generate"
},
)
assert
resp
.
status_code
==
200
assert
resp
.
headers
[
"content-type"
]
.
startswith
(
"text/event-stream"
)
events
=
_stream_events
(
resp
)
stages
=
[
e
[
"type"
]
for
e
in
events
]
assert
stages
[
0
]
==
"stage"
and
events
[
0
][
"stage"
]
==
"checking"
assert
stages
[
-
1
]
==
"done"
draft
=
events
[
-
1
][
"draft"
]
assert
draft
[
"jdStatus"
]
==
"待确认"
assert
draft
[
"provider"
]
==
"local-structured"
def
test_generate_jd_stream_force_fills_missing
(
client
,
monkeypatch
):
"""force=True 跳过完整性检查,缺 jd/department 也能生成草稿(由本地结构化补齐)。"""
monkeypatch
.
setenv
(
"LLM_API_KEY"
,
""
)
resp
=
client
.
post
(
"/api/generate-jd/stream"
,
json
=
{
"job"
:
{
"title"
:
"Java开发工程师"
},
"mode"
:
"generate"
,
"force"
:
True
},
)
assert
resp
.
status_code
==
200
events
=
_stream_events
(
resp
)
assert
events
[
-
1
][
"type"
]
==
"done"
draft
=
events
[
-
1
][
"draft"
]
assert
draft
[
"jd"
]
# 即使原始缺 jd,本地模板也会补出正文
assert
draft
[
"responsibilities"
]
assert
draft
[
"requirements"
]
def
test_grill_start_and_advance
(
client
):
def
test_grill_start_and_advance
(
client
):
"""确定式逼问访谈:start 返回第一问,逐题 advance 推进到 complete,并采集结构化字段。"""
"""确定式逼问访谈:start 返回第一问,逐题 advance 推进到 complete,并采集结构化字段。"""
start
=
client
.
post
(
start
=
client
.
post
(
...
...
vue-app/src/api/index.js
View file @
9c9e4c37
...
@@ -16,7 +16,7 @@ const http = axios.create({
...
@@ -16,7 +16,7 @@ const http = axios.create({
timeout
:
60000
,
timeout
:
60000
,
})
})
function
readToken
()
{
export
function
readToken
()
{
try
{
try
{
const
raw
=
localStorage
.
getItem
(
AUTH_STORAGE_KEY
)
const
raw
=
localStorage
.
getItem
(
AUTH_STORAGE_KEY
)
if
(
!
raw
)
return
''
if
(
!
raw
)
return
''
...
...
vue-app/src/api/recruitment.js
View file @
9c9e4c37
import
http
from
'@/api'
import
http
,
{
readToken
}
from
'@/api'
export
async
function
fetchState
(
owner
=
''
)
{
export
async
function
fetchState
(
owner
=
''
)
{
const
{
data
}
=
await
http
.
get
(
'/api/state'
,
{
params
:
owner
?
{
owner
}
:
{}
})
const
{
data
}
=
await
http
.
get
(
'/api/state'
,
{
params
:
owner
?
{
owner
}
:
{}
})
...
@@ -32,6 +32,51 @@ export async function generateJd(payload) {
...
@@ -32,6 +32,51 @@ export async function generateJd(payload) {
return
data
return
data
}
}
// 流式生成 JD:逐事件回调后端处理过程(stage / needs_chat / done / error)
export
async
function
generateJdStream
(
payload
,
handlers
=
{})
{
const
{
onStage
,
onNeedsChat
,
onDone
,
onError
}
=
handlers
let
base
=
''
if
(
typeof
window
!==
'undefined'
&&
window
.
RECRUITMENT_API_BASE_URL
)
{
base
=
String
(
window
.
RECRUITMENT_API_BASE_URL
).
replace
(
/
\/
+$/
,
''
)
}
const
token
=
readToken
()
const
response
=
await
fetch
(
`
${
base
}
/api/generate-jd/stream`
,
{
method
:
'POST'
,
headers
:
{
'Content-Type'
:
'application/json'
,
...(
token
?
{
Authorization
:
`Bearer
${
token
}
`
}
:
{}),
},
body
:
JSON
.
stringify
(
payload
),
})
if
(
!
response
.
ok
||
!
response
.
body
)
{
throw
new
Error
(
`JD 生成请求失败(HTTP
${
response
.
status
}
)`
)
}
const
reader
=
response
.
body
.
getReader
()
const
decoder
=
new
TextDecoder
(
'utf-8'
)
let
buffer
=
''
for
(;;)
{
const
{
done
,
value
}
=
await
reader
.
read
()
if
(
done
)
break
buffer
+=
decoder
.
decode
(
value
,
{
stream
:
true
})
const
lines
=
buffer
.
split
(
'
\
n'
)
buffer
=
lines
.
pop
()
||
''
for
(
const
line
of
lines
)
{
const
trimmed
=
line
.
trim
()
if
(
!
trimmed
.
startsWith
(
'data:'
))
continue
let
event
try
{
event
=
JSON
.
parse
(
trimmed
.
slice
(
5
).
trim
())
}
catch
{
continue
}
if
(
event
.
type
===
'stage'
&&
onStage
)
onStage
(
event
.
stage
,
event
.
message
)
else
if
(
event
.
type
===
'needs_chat'
&&
onNeedsChat
)
onNeedsChat
(
event
)
else
if
(
event
.
type
===
'done'
&&
onDone
)
onDone
(
event
.
draft
)
else
if
(
event
.
type
===
'error'
&&
onError
)
onError
(
event
.
message
||
'JD 生成失败'
)
}
}
}
export
async
function
jdCompleteness
(
payload
)
{
export
async
function
jdCompleteness
(
payload
)
{
const
{
data
}
=
await
http
.
post
(
'/api/jd/completeness'
,
payload
)
const
{
data
}
=
await
http
.
post
(
'/api/jd/completeness'
,
payload
)
return
data
return
data
...
...
vue-app/src/components/jobs/JdGeneratePanel.vue
0 → 100644
View file @
9c9e4c37
<
script
setup
>
import
{
computed
}
from
'vue'
import
{
useRecruitmentStore
}
from
'@/stores/recruitment'
const
store
=
useRecruitmentStore
()
const
gen
=
computed
(()
=>
store
.
jdGenerate
)
const
busy
=
computed
(()
=>
gen
.
value
?.
busy
)
const
stage
=
computed
(()
=>
gen
.
value
?.
stage
||
'starting'
)
const
message
=
computed
(()
=>
gen
.
value
?.
message
||
''
)
const
missing
=
computed
(()
=>
gen
.
value
?.
missing
||
[])
const
modeLabel
=
computed
(()
=>
{
const
mode
=
gen
.
value
?.
mode
if
(
mode
===
'iterate'
)
return
'迭代 JD 版本'
if
(
mode
===
'assist'
)
return
'AI 辅助创建'
return
'生成 JD 草稿'
})
const
stageMeta
=
computed
(()
=>
{
const
map
=
{
starting
:
{
icon
:
'⏳'
,
label
:
'准备中'
,
cls
:
''
},
checking
:
{
icon
:
'🔍'
,
label
:
'校验岗位信息'
,
cls
:
''
},
generating
:
{
icon
:
'🤖'
,
label
:
'AI 生成中'
,
cls
:
'working'
},
needs_chat
:
{
icon
:
'⚠️'
,
label
:
'岗位信息不全'
,
cls
:
'warn'
},
}
return
map
[
stage
.
value
]
||
{
icon
:
'⏳'
,
label
:
message
.
value
,
cls
:
''
}
})
</
script
>
<
template
>
<el-dialog
:model-value=
"store.jdGenerateOpen"
title=
"AI 生成 JD"
width=
"520px"
:close-on-click-modal=
"false"
:show-close=
"!busy"
@
close=
"store.jdGenerateCancel()"
>
<div
class=
"gen-head"
>
<span
class=
"gen-icon"
>
{{
stageMeta
.
icon
}}
</span>
<div>
<strong>
{{
modeLabel
}}
</strong>
<span
class=
"gen-mode"
>
{{
gen
?.
job
?.
title
||
'岗位'
}}
</span>
</div>
</div>
<div
class=
"gen-body"
:class=
"stageMeta.cls"
>
<template
v-if=
"busy"
>
<div
class=
"gen-stage"
>
<el-icon
class=
"is-loading"
><i
class=
"el-icon-loading"
/></el-icon>
<div>
<b>
{{
stageMeta
.
label
}}
</b>
<p>
{{
message
}}
</p>
</div>
</div>
<div
class=
"gen-progress"
>
<i></i>
</div>
</
template
>
<
template
v-else-if=
"stage === 'needs_chat'"
>
<p
class=
"gen-warn-text"
>
{{
message
}}
</p>
<div
v-if=
"missing.length"
class=
"gen-missing"
>
<span
v-for=
"item in missing"
:key=
"item.key"
class=
"gen-missing-tag"
>
{{
item
.
label
}}
</span>
</div>
<div
class=
"gen-actions"
>
<el-button
type=
"primary"
@
click=
"store.jdGenerateForce()"
>
用现有信息生成草稿
</el-button>
<el-button
@
click=
"store.jdGenerateGoGrill()"
>
去追问补全
</el-button>
<el-button
@
click=
"store.jdGenerateCancel()"
>
取消
</el-button>
</div>
</
template
>
</div>
<
template
#
footer
>
<el-button
v-if=
"busy"
@
click=
"store.jdGenerateCancel()"
>
取消生成
</el-button>
</
template
>
</el-dialog>
</template>
<
style
lang=
"scss"
scoped
>
.gen-head
{
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
.gen-icon
{
font-size
:
28px
;
}
strong
{
display
:
block
;
font-size
:
15px
;
}
.gen-mode
{
font-size
:
13px
;
color
:
var
(
--
muted
);
}
}
.gen-body
{
margin-top
:
16px
;
min-height
:
96px
;
.gen-stage
{
display
:
flex
;
gap
:
12px
;
align-items
:
flex-start
;
.el-icon
{
font-size
:
20px
;
margin-top
:
2px
;
}
b
{
font-size
:
14px
;
}
p
{
margin
:
4px
0
0
;
font-size
:
13px
;
color
:
var
(
--
muted
);
line-height
:
1
.6
;
}
}
.gen-progress
{
height
:
6px
;
background
:
var
(
--
bg
);
border-radius
:
999px
;
overflow
:
hidden
;
margin-top
:
16px
;
i
{
display
:
block
;
height
:
100%
;
width
:
40%
;
border-radius
:
999px
;
background
:
linear-gradient
(
90deg
,
var
(
--
blue
)
,
#7ad0ff
);
animation
:
gen-slide
1
.2s
ease-in-out
infinite
;
}
}
&
.warn
{
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
justify-content
:
center
;
}
.gen-warn-text
{
font-size
:
14px
;
color
:
#b7791f
;
margin
:
0
;
}
.gen-missing
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
8px
;
.gen-missing-tag
{
background
:
#fdf3e3
;
color
:
#b7791f
;
border
:
1px
solid
#f3d9a4
;
border-radius
:
6px
;
padding
:
3px
10px
;
font-size
:
13px
;
}
}
.gen-actions
{
display
:
flex
;
gap
:
10px
;
flex-wrap
:
wrap
;
}
}
@keyframes
gen-slide
{
0
%
{
transform
:
translateX
(
-100%
);
}
100
%
{
transform
:
translateX
(
350%
);
}
}
</
style
>
vue-app/src/components/jobs/JdGrillModal.vue
View file @
9c9e4c37
...
@@ -32,7 +32,9 @@ watch(
...
@@ -32,7 +32,9 @@ watch(
try
{
try
{
await
store
.
jdGrillStart
()
await
store
.
jdGrillStart
()
}
catch
(
error
)
{
}
catch
(
error
)
{
// 启动失败时结束 openJdGrill 的等待,避免 JobEdit 永远停在 await。
ElMessage
.
error
(
error
.
message
||
'启动追问失败'
)
ElMessage
.
error
(
error
.
message
||
'启动追问失败'
)
store
.
closeJdGrill
(
null
)
}
}
}
}
)
)
...
@@ -68,7 +70,7 @@ const finish = async () => {
...
@@ -68,7 +70,7 @@ const finish = async () => {
generating
.
value
=
true
generating
.
value
=
true
try
{
try
{
// 用 grill 累积出的结构化 job 生成 JD。后端信息完整性判断依赖 job.jd 与 job.requirements,
// 用 grill 累积出的结构化 job 生成 JD。后端信息完整性判断依赖 job.jd 与 job.requirements,
//
由生成器负责从已采集字段组织 JD 正文,避免把访谈采集的职责原文直接当 jd 回显
。
//
题单不采集 jd,故必须 force 生成(跳过完整性检查),由生成器从已采集字段推断职责/要求/JD
。
const
accumulatedJob
=
{
...
grill
.
value
.
job
}
const
accumulatedJob
=
{
...
grill
.
value
.
job
}
if
(
!
accumulatedJob
.
requirements
)
{
if
(
!
accumulatedJob
.
requirements
)
{
const
edu
=
accumulatedJob
.
education
||
''
const
edu
=
accumulatedJob
.
education
||
''
...
@@ -77,13 +79,15 @@ const finish = async () => {
...
@@ -77,13 +79,15 @@ const finish = async () => {
accumulatedJob
.
requirements
=
accumulatedJob
.
requirements
=
[
edu
,
years
,
must
].
filter
(
Boolean
).
join
(
';'
)
||
'具备相关岗位经验,能独立完成核心交付'
[
edu
,
years
,
must
].
filter
(
Boolean
).
join
(
';'
)
||
'具备相关岗位经验,能独立完成核心交付'
}
}
const
draft
=
await
store
.
generateJd
(
accumulatedJob
,
grill
.
value
.
mode
,
{
skipChat
:
true
})
// force 生成:访谈补全后直接产出 jd/职责/要求等字段,assist 与生成/迭代都适用
const
draft
=
await
store
.
generateJdOnce
({
job
:
accumulatedJob
,
mode
:
grill
.
value
.
mode
})
// 把累积的结构化 job 一并带给调用方(AI 辅助创建模式回填表单用)
// 把累积的结构化 job 一并带给调用方(AI 辅助创建模式回填表单用)
store
.
closeJdGrill
({
...
draft
,
job
:
accumulatedJob
})
store
.
closeJdGrill
({
...
draft
,
job
:
accumulatedJob
})
if
(
draft
?.
needs_chat
)
{
ElMessage
.
success
(
'岗位信息已补齐,JD 已生成'
)
ElMessage
.
warning
(
'部分岗位字段仍缺,已按已采集信息生成草稿,可继续迭代补充'
)
if
(
draft
?.
provider
?.
startsWith
(
'llm'
)
||
draft
?.
provider
?.
startsWith
(
'qwenpaw'
))
{
ElMessage
.
success
(
'AI 已生成完整 JD 草稿'
)
}
else
{
}
else
{
ElMessage
.
success
(
'
岗位信息已补齐,JD 已生成
'
)
ElMessage
.
success
(
'
已生成 JD 草稿
'
)
}
}
}
catch
(
error
)
{
}
catch
(
error
)
{
ElMessage
.
error
(
error
.
message
||
'JD 生成失败'
)
ElMessage
.
error
(
error
.
message
||
'JD 生成失败'
)
...
...
vue-app/src/components/jobs/JobDetail.vue
View file @
9c9e4c37
<
script
setup
>
<
script
setup
>
import
{
computed
,
ref
,
watch
}
from
'vue'
import
{
computed
,
nextTick
,
ref
,
watch
}
from
'vue'
import
{
useRouter
}
from
'vue-router'
import
{
useRouter
}
from
'vue-router'
import
{
ElMessageBox
}
from
'element-plus'
import
{
ElMessageBox
}
from
'element-plus'
import
{
useRecruitmentStore
}
from
'@/stores/recruitment'
import
{
useRecruitmentStore
}
from
'@/stores/recruitment'
import
{
jobActionFor
,
jobWorkflow
,
buildJobCriteria
,
jobStateBadge
}
from
'@/utils/jobFlow'
import
{
jobActionFor
,
jobWorkflow
,
buildJobCriteria
,
jobStateBadge
}
from
'@/utils/jobFlow'
import
{
resolveChannelStatus
}
from
'@/utils/job'
import
{
jobCandidates
}
from
'@/utils/links'
import
{
jobCandidates
}
from
'@/utils/links'
import
{
workflowStageClass
}
from
'@/utils/task'
import
{
workflowStageClass
}
from
'@/utils/task'
import
{
showToast
}
from
'@/utils/toast'
import
{
showToast
}
from
'@/utils/toast'
import
{
JOB_STATUS
,
JD_STATUS
,
APPROVAL_STATUS
,
CHANNEL_STATUS
,
CHANNEL_PRESETS
}
from
'@/utils/jobStatus'
import
{
buildJdMarkdown
,
buildJdPlainText
}
from
'@/utils/jdMarkdown'
import
{
JOB_STATUS
,
JD_STATUS
,
APPROVAL_STATUS
,
CHANNEL_STATUS
,
CHANNEL_ITEM_STATUS
}
from
'@/utils/jobStatus'
import
JdGrillModal
from
'./JdGrillModal.vue'
import
JdGrillModal
from
'./JdGrillModal.vue'
import
JdGeneratePanel
from
'./JdGeneratePanel.vue'
import
ChannelDialog
from
'./ChannelDialog.vue'
import
ChannelDialog
from
'./ChannelDialog.vue'
const
props
=
defineProps
({
const
props
=
defineProps
({
...
@@ -61,9 +64,9 @@ const openings = computed(() =>
...
@@ -61,9 +64,9 @@ const openings = computed(() =>
job
.
value
?
Math
.
max
(
0
,
Number
(
job
.
value
.
headcount
||
0
)
-
Number
(
job
.
value
.
hired
||
0
))
:
0
job
.
value
?
Math
.
max
(
0
,
Number
(
job
.
value
.
headcount
||
0
)
-
Number
(
job
.
value
.
hired
||
0
))
:
0
)
)
const
goResumes
=
()
=>
{
const
goResumes
=
(
jobId
=
''
)
=>
{
store
.
setPage
(
'resumes'
)
store
.
setPage
(
'resumes'
)
router
.
push
(
'/resumes'
)
router
.
push
(
jobId
?
{
path
:
'/resumes'
,
query
:
{
job
:
jobId
}
}
:
'/resumes'
)
}
}
const
statusLabel
=
(
value
)
=>
value
||
'未记录'
const
statusLabel
=
(
value
)
=>
value
||
'未记录'
...
@@ -86,6 +89,16 @@ watch(
...
@@ -86,6 +89,16 @@ watch(
const
isStaleCandidate
=
(
candidate
)
=>
flow
.
value
.
staleCandidates
.
some
((
item
)
=>
item
.
id
===
candidate
.
id
)
const
isStaleCandidate
=
(
candidate
)
=>
flow
.
value
.
staleCandidates
.
some
((
item
)
=>
item
.
id
===
candidate
.
id
)
const
hasJdContent
=
computed
(()
=>
Boolean
(
job
.
value
?.
jd
||
job
.
value
?.
responsibilities
||
job
.
value
?.
requirements
||
job
.
value
?.
mustHave
)
)
const
jdGenerationBusy
=
computed
(()
=>
Boolean
(
store
.
jdGenerateOpen
||
store
.
jdGrillOpen
))
const
generateJdLabel
=
computed
(()
=>
(
hasJdContent
.
value
?
'重新生成草稿'
:
'生成首版 JD 草稿'
))
const
jdActionHint
=
computed
(()
=>
{
if
(
!
hasJdContent
.
value
)
return
'还没有 JD:先生成首版草稿,再进入用人确认。'
return
'已有 JD:小幅调整岗位要求用“迭代”;需要按当前全部信息重写时用“重新生成”。两者都会生成新的待确认草稿。'
})
const
appendJdHistory
=
(
note
)
=>
{
const
appendJdHistory
=
(
note
)
=>
{
if
(
!
job
.
value
)
return
if
(
!
job
.
value
)
return
job
.
value
.
jdHistory
=
[
job
.
value
.
jdHistory
=
[
...
@@ -103,8 +116,17 @@ const ensureTask = (title, module, target, priority = '中', due = '今日') =>
...
@@ -103,8 +116,17 @@ const ensureTask = (title, module, target, priority = '中', due = '今日') =>
store
.
ensureTask
(
title
,
module
,
target
,
priority
,
due
,
{
jobId
:
job
.
value
?.
id
||
''
})
store
.
ensureTask
(
title
,
module
,
target
,
priority
,
due
,
{
jobId
:
job
.
value
?.
id
||
''
})
}
}
const
confirmOwner
=
()
=>
{
const
confirmOwner
=
async
()
=>
{
if
(
!
job
.
value
)
return
if
(
!
job
.
value
)
return
try
{
await
ElMessageBox
.
confirm
(
'确认岗位说明、核心职责和硬性条件已经可以交给审批流吗?确认后仍可通过“迭代 JD 版本”生成新草稿。'
,
'用人部门确认 JD'
,
{
confirmButtonText
:
'确认内容'
,
cancelButtonText
:
'继续修改'
,
type
:
'info'
}
)
}
catch
{
return
}
job
.
value
.
approvalStatus
=
APPROVAL_STATUS
.
DEPARTMENT_CONFIRMED
job
.
value
.
approvalStatus
=
APPROVAL_STATUS
.
DEPARTMENT_CONFIRMED
if
(
job
.
value
.
jdStatus
===
JD_STATUS
.
DRAFT
)
job
.
value
.
jdStatus
=
JD_STATUS
.
PENDING_CONFIRM
if
(
job
.
value
.
jdStatus
===
JD_STATUS
.
DRAFT
)
job
.
value
.
jdStatus
=
JD_STATUS
.
PENDING_CONFIRM
appendJdHistory
(
'用人部门确认JD内容'
)
appendJdHistory
(
'用人部门确认JD内容'
)
...
@@ -192,22 +214,45 @@ const refreshCandidates = async () => {
...
@@ -192,22 +214,45 @@ const refreshCandidates = async () => {
activeStep
.
value
=
4
activeStep
.
value
=
4
}
}
//
录入/迭代 JD:信息不全时 store.generateJd 会自动打开访谈面板
补全
//
生成/迭代 JD:弹出过程面板实时展示后端处理,needs_chat 时在面板内引导
补全
const
generateJd
=
async
(
mode
=
'generate'
)
=>
{
const
generateJd
=
async
(
mode
=
'generate'
)
=>
{
if
(
!
job
.
value
)
{
if
(
!
job
.
value
)
{
showToast
(
'请先选择岗位'
,
'warning'
)
showToast
(
'请先选择岗位'
,
'warning'
)
return
return
}
}
if
(
jdGenerationBusy
.
value
)
return
if
(
mode
===
'iterate'
&&
!
hasJdContent
.
value
)
{
showToast
(
'当前还没有 JD,请先生成首版草稿'
,
'warning'
)
return
}
if
(
mode
===
'iterate'
||
hasJdContent
.
value
)
{
const
title
=
mode
===
'iterate'
?
'迭代 JD 版本'
:
'重新生成 JD 草稿'
const
description
=
mode
===
'iterate'
?
`将参考当前
${
job
.
value
.
jdVersion
||
'JD'
}
的内容,结合岗位最新信息生成一个新的待确认版本。`
:
'将按当前岗位信息重新组织 JD,并生成一个新的待确认版本。'
try
{
await
ElMessageBox
.
confirm
(
`
${
description
}
当前版本不会立即生效,确认继续?`
,
title
,
{
confirmButtonText
:
'开始生成'
,
cancelButtonText
:
'取消'
,
type
:
'info'
,
})
}
catch
{
return
}
}
try
{
try
{
showToast
(
mode
===
'iterate'
?
'正在生成新版 JD...'
:
'正在生成 JD 草稿...'
,
'info'
)
const
draft
=
await
store
.
generateJdWithProgress
(
job
.
value
,
mode
)
const
draft
=
await
store
.
generateJd
(
job
.
value
,
mode
)
if
(
!
draft
)
return
// 用户在面板取消或去访谈后未带回
if
(
!
draft
)
return
// 用户在访谈中取消
store
.
applyJdDraft
(
job
.
value
,
draft
,
mode
)
store
.
applyJdDraft
(
job
.
value
,
draft
,
mode
)
store
.
completeTasksForJob
(
job
.
value
,
[
/新 JD|重评|匹配/
])
store
.
completeTasksForJob
(
job
.
value
,
[
/新 JD|重评|匹配/
])
showToast
(
draft
.
provider
?.
startsWith
(
'llm'
)
?
'AI JD 草稿已生成'
:
'本地 JD 草稿已生成'
)
showToast
(
draft
.
provider
?.
startsWith
(
'llm'
)
||
draft
.
provider
?.
startsWith
(
'qwenpaw'
)
?
'AI JD 草稿已生成'
:
'JD 草稿已生成'
)
store
.
persist
(
'JD草稿已生成'
)
store
.
persist
(
'JD草稿已生成'
)
activeStep
.
value
=
1
activeStep
.
value
=
1
}
catch
(
error
)
{
}
catch
(
error
)
{
if
(
error
?.
message
===
'已取消追问'
||
error
?.
message
===
'已取消生成'
)
return
// 用户主动取消,不提示错误
showToast
(
error
.
message
,
'error'
)
showToast
(
error
.
message
,
'error'
)
}
}
}
}
...
@@ -222,13 +267,18 @@ const onChannelsSaved = (selected = []) => {
...
@@ -222,13 +267,18 @@ const onChannelsSaved = (selected = []) => {
if
(
!
job
.
value
)
return
if
(
!
job
.
value
)
return
if
(
selected
.
length
)
{
if
(
selected
.
length
)
{
const
today
=
new
Date
().
toISOString
().
slice
(
0
,
10
)
const
today
=
new
Date
().
toISOString
().
slice
(
0
,
10
)
job
.
value
.
channels
=
selected
.
map
((
item
)
=>
({
const
currentVersion
=
job
.
value
.
jdVersion
||
'v1'
name
:
item
.
name
,
job
.
value
.
channels
=
selected
.
map
((
item
)
=>
{
link
:
item
.
link
||
''
,
const
existing
=
(
job
.
value
.
channels
||
[]).
find
((
c
)
=>
c
.
name
===
item
.
name
)
status
:
'已发布'
,
const
versionChanged
=
Boolean
(
existing
?.
jdVersion
&&
existing
.
jdVersion
!==
currentVersion
)
publishedAt
:
today
,
return
{
jdVersion
:
job
.
value
.
jdVersion
||
'v1'
,
name
:
item
.
name
,
}))
link
:
item
.
link
||
''
,
status
:
'已发布'
,
publishedAt
:
existing
&&
!
versionChanged
?
existing
.
publishedAt
||
today
:
today
,
jdVersion
:
currentVersion
,
}
})
job
.
value
.
publishedChannels
=
selected
.
map
((
item
)
=>
item
.
name
)
job
.
value
.
publishedChannels
=
selected
.
map
((
item
)
=>
item
.
name
)
job
.
value
.
channelStatus
=
CHANNEL_STATUS
.
SYNCED
job
.
value
.
channelStatus
=
CHANNEL_STATUS
.
SYNCED
if
(
job
.
value
.
jdStatus
===
JD_STATUS
.
EFFECTIVE
)
job
.
value
.
status
=
JOB_STATUS
.
RECRUITING
if
(
job
.
value
.
jdStatus
===
JD_STATUS
.
EFFECTIVE
)
job
.
value
.
status
=
JOB_STATUS
.
RECRUITING
...
@@ -243,10 +293,26 @@ const onChannelsSaved = (selected = []) => {
...
@@ -243,10 +293,26 @@ const onChannelsSaved = (selected = []) => {
store
.
persist
(
'发布渠道已同步'
)
store
.
persist
(
'发布渠道已同步'
)
}
}
// ---- 渠道台账 ----
const
channelDisplayStatus
=
(
channel
)
=>
resolveChannelStatus
(
channel
,
job
.
value
?.
jdVersion
||
''
)
const
staleChannels
=
computed
(()
=>
(
job
.
value
?.
channels
||
[]).
filter
((
channel
)
=>
channelDisplayStatus
(
channel
)
===
'待更新'
)
)
const
openChannelLink
=
(
channel
)
=>
{
if
(
!
channel
?.
link
)
return
window
.
open
(
channel
.
link
,
'_blank'
,
'noopener,noreferrer'
)
}
const
onChannelStatusChange
=
(
channel
)
=>
{
if
(
!
job
.
value
)
return
store
.
persist
(
`渠道
${
channel
.
name
}
状态已更新`
)
showToast
(
`
${
channel
.
name
}
已标记为「
${
channel
.
status
}
」`
)
}
// ---- 版本复制 ----
// ---- 版本复制 ----
const
copyVersion
=
(
version
)
=>
{
const
copyVersion
=
(
version
)
=>
{
if
(
!
job
.
value
)
return
if
(
!
job
.
value
)
return
const
vNumber
=
(
version
.
match
(
/v
(\d
+
)
/i
)
||
[])[
1
]
||
'1'
const
next
=
store
.
nextJdVersion
(
job
.
value
,
'草稿'
)
const
next
=
store
.
nextJdVersion
(
job
.
value
,
'草稿'
)
job
.
value
.
jd
=
job
.
value
.
jd
||
''
job
.
value
.
jd
=
job
.
value
.
jd
||
''
// 以该历史版本的 note 说明复制来源;实际内容是当前字段(轻量实现)
// 以该历史版本的 note 说明复制来源;实际内容是当前字段(轻量实现)
...
@@ -259,11 +325,53 @@ const copyVersion = (version) => {
...
@@ -259,11 +325,53 @@ const copyVersion = (version) => {
activeTab
.
value
=
'flow'
activeTab
.
value
=
'flow'
}
}
const
mainAction
=
()
=>
{
// ---- JD 导出(Markdown / 纯文本)----
const
jdMarkdownOpen
=
ref
(
false
)
const
jdMarkdown
=
computed
(()
=>
(
job
.
value
?
buildJdMarkdown
(
job
.
value
)
:
''
))
const
jdPlainText
=
computed
(()
=>
(
job
.
value
?
buildJdPlainText
(
job
.
value
)
:
''
))
const
jdFileName
=
computed
(()
=>
`
${(
job
.
value
?.
title
||
'岗位JD'
).
replace
(
/
[\\/
:*?"<>|
]
/g
,
'_'
)}.
md
`)
const openJdMarkdown = () => {
if (!job.value) return
if (!hasJdContent.value) {
showToast('还没有 JD 内容,先生成草稿后再导出', 'warning')
return
}
jdMarkdownOpen.value = true
}
const copyJdMarkdown = async (plain = false) => {
if (!hasJdContent.value) {
showToast('还没有 JD 内容,先生成草稿后再复制', 'warning')
return
}
const text = plain ? jdPlainText.value : jdMarkdown.value
try {
await navigator.clipboard.writeText(text)
} catch {
// 兼容非 https/localhost 等不支持 Clipboard API 的环境
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
showToast(plain ? '纯文本 JD 已复制' : 'Markdown 已复制,可直接粘贴到招聘平台')
}
const mainAction = async () => {
if (!job.value) return
if (!job.value) return
const tab = action.value.tab
const tab = action.value.tab
if (tab === 'flow') {
if (tab === 'flow') {
activeTab.value = 'flow'
activeTab.value = 'flow'
if (Number.isInteger(action.value.step)) activeStep.value = action.value.step
await nextTick()
document.querySelector('.workflow-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
if (tab === 'candidates') {
goResumes(job.value.id)
return
return
}
}
if (tab === 'publish') {
if (tab === 'publish') {
...
@@ -509,14 +617,27 @@ const mainAction = () => {
...
@@ -509,14 +617,27 @@ const mainAction = () => {
<
div
class
=
"step-pane-head"
>
<
div
class
=
"step-pane-head"
>
<
div
>
<
div
>
<
b
>
JD
草稿
<
/b
>
<
b
>
JD
草稿
<
/b
>
<
span
>
岗位说明与招聘标准,生成后可在此查看草稿内容
<
/span
>
<
span
>
首版用于把岗位信息写成
JD
;迭代用于保留历史并生成新的待确认版本
<
/span
>
<
/div
>
<
/div
>
<
div
class
=
"step-pane-actions"
>
<
div
class
=
"step-pane-actions"
>
<
el
-
button
size
=
"small"
@
click
=
"generateJd('iterate')"
>
迭代
JD
版本
<
/el-button
>
<
el
-
button
size
=
"small"
:
disabled
=
"jdGenerationBusy || !hasJdContent"
@
click
=
"generateJd('iterate')"
>
<
el
-
button
size
=
"small"
type
=
"primary"
@
click
=
"generateJd('generate')"
>
生成
JD
草稿
<
/el-button
>
迭代
JD
版本
<
/el-button
>
<
el
-
button
size
=
"small"
type
=
"primary"
:
disabled
=
"jdGenerationBusy"
@
click
=
"generateJd('generate')"
>
{{
generateJdLabel
}}
<
/el-button
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
template
v
-
if
=
"job.jd || job.responsibilities || job.requirements || job.mustHave"
>
<
div
class
=
"jd-action-guide"
>
<
span
class
=
"jd-action-guide-icon"
>
✦
<
/span
>
<
p
>
{{
jdActionHint
}}
<
/p
>
<
/div
>
<
div
v
-
if
=
"hasJdContent"
class
=
"jd-version-meta"
>
<
el
-
tag
size
=
"small"
effect
=
"plain"
>
{{
job
.
jdVersion
||
'未命名版本'
}}
<
/el-tag
>
<
span
>
{{
job
.
jdStatus
||
JD_STATUS
.
DRAFT
}}
<
/span
>
<
span
v
-
if
=
"job.jdDraftSource"
>
来源:
{{
job
.
jdDraftSource
}}
<
/span
>
<
/div
>
<
template
v
-
if
=
"hasJdContent"
>
<
div
class
=
"jd-detail"
>
<
div
class
=
"jd-detail"
>
<
div
class
=
"jd-detail-main"
>
<
div
class
=
"jd-detail-main"
>
<
span
>
岗位说明
/
JD
<
/span
>
<
span
>
岗位说明
/
JD
<
/span
>
...
@@ -567,8 +688,23 @@ const mainAction = () => {
...
@@ -567,8 +688,23 @@ const mainAction = () => {
<
span
>
用人部门确认
JD
内容后提交审批
<
/span
>
<
span
>
用人部门确认
JD
内容后提交审批
<
/span
>
<
/div
>
<
/div
>
<
div
class
=
"step-pane-actions"
>
<
div
class
=
"step-pane-actions"
>
<
el
-
button
size
=
"small"
@
click
=
"confirmOwner"
>
用人确认通过
<
/el-button
>
<
el
-
button
<
el
-
button
size
=
"small"
type
=
"primary"
@
click
=
"submitApproval"
>
提交审批
<
/el-button
>
v
-
if
=
"job.jdStatus === JD_STATUS.PENDING_CONFIRM"
size
=
"small"
type
=
"primary"
@
click
=
"confirmOwner"
>
确认
JD
内容
<
/el-button
>
<
el
-
button
v
-
if
=
"job.jdStatus !== JD_STATUS.APPROVING"
size
=
"small"
:
disabled
=
"job.approvalStatus !== APPROVAL_STATUS.DEPARTMENT_CONFIRMED"
@
click
=
"submitApproval"
>
提交审批
<
/el-button
>
<
el
-
tag
v
-
if
=
"job.jdStatus === JD_STATUS.APPROVING"
type
=
"warning"
size
=
"small"
>
审批进行中
<
/el-tag
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"step-fields"
>
<
div
class
=
"step-fields"
>
...
@@ -596,6 +732,7 @@ const mainAction = () => {
...
@@ -596,6 +732,7 @@ const mainAction = () => {
>
审批通过并生效
<
/el-butto
n
>
审批通过并生效
<
/el-butto
n
>
>
<
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
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"step-fields"
>
<
div
class
=
"step-fields"
>
...
@@ -692,6 +829,9 @@ const mainAction = () => {
...
@@ -692,6 +829,9 @@ const mainAction = () => {
<
div
>
<
div
>
<
span
>
需重评
<
/span><strong>{{ flow.staleCandidates.length
}}
人</
strong
>
<
span
>
需重评
<
/span><strong>{{ flow.staleCandidates.length
}}
人</
strong
>
<
/div
>
<
/div
>
<
div
>
<
span
>
JD
待更新渠道
<
/span><strong>{{ staleChannels.length
}}
个</
strong
>
<
/div
>
<
div
>
<
div
>
<
span
>
已发布渠道
<
/span><strong>{{ channelTags || '未同步'
}}
</
strong
>
<
span
>
已发布渠道
<
/span><strong>{{ channelTags || '未同步'
}}
</
strong
>
<
/div
>
<
/div
>
...
@@ -713,14 +853,41 @@ const mainAction = () => {
...
@@ -713,14 +853,41 @@ const mainAction = () => {
<
/div
>
<
/div
>
<
div
v
-
if
=
"job.channels && job.channels.length"
class
=
"channel-table"
>
<
div
v
-
if
=
"job.channels && job.channels.length"
class
=
"channel-table"
>
<
div
class
=
"channel-row channel-row-head"
>
<
div
class
=
"channel-row channel-row-head"
>
<
span
>
渠道
<
/span><span>链接</
span
><
span
>
状态
<
/span><span>发布时间</
span
><
span
>
JD
版本
<
/span
>
<
span
>
渠道
<
/span><span>链接</
span
><
span
>
状态
<
/span><span>发布时间</
span
><
span
>
JD
版本
<
/spa
n
><
span
>
操作
<
/span
>
<
/div
>
<
/div
>
<
div
v
-
for
=
"(channel, index) in job.channels"
:
key
=
"
index
"
class
=
"channel-row"
>
<
div
v
-
for
=
"(channel, index) in job.channels"
:
key
=
"
`${channel.name
}
-${index
}
`
"
class
=
"channel-row"
>
<
strong
>
{{
channel
.
name
}}
<
/strong
>
<
strong
>
{{
channel
.
name
}}
<
/strong
>
<
span
>
{{
channel
.
link
||
'—'
}}
<
/span
>
<
span
class
=
"channel-link-cell"
>
<
span
>
{{
channel
.
status
||
'已发布'
}}
<
/span
>
<
a
v
-
if
=
"channel.link"
:
href
=
"channel.link"
target
=
"_blank"
rel
=
"noopener noreferrer"
class
=
"channel-link"
>
{{
channel
.
link
}}
<
/
a
>
<
span
v
-
else
>
—
<
/span
>
<
/span
>
<
span
class
=
"channel-status-cell"
>
<
el
-
select
v
-
model
=
"channel.status"
size
=
"small"
class
=
"channel-status-select"
@
change
=
"onChannelStatusChange(channel)"
>
<
el
-
option
v
-
for
=
"option in CHANNEL_ITEM_STATUS"
:
key
=
"option"
:
label
=
"option"
:
value
=
"option"
/>
<
/el-select
>
<
el
-
tag
v
-
if
=
"channelDisplayStatus(channel) === '待更新'"
size
=
"small"
type
=
"warning"
>
待更新
<
/el-tag
>
<
/span
>
<
span
>
{{
channel
.
publishedAt
||
'—'
}}
<
/span
>
<
span
>
{{
channel
.
publishedAt
||
'—'
}}
<
/span
>
<
span
>
{{
channel
.
jdVersion
||
'—'
}}
<
/span
>
<
span
>
{{
channel
.
jdVersion
||
'—'
}}
<
/span
>
<
span
class
=
"channel-actions"
>
<
el
-
button
size
=
"small"
link
@
click
=
"copyJdMarkdown()"
>
复制
JD
<
/el-button
>
<
el
-
button
v
-
if
=
"channel.link"
size
=
"small"
link
type
=
"primary"
@
click
=
"openChannelLink(channel)"
>
打开
<
/el-butto
n
>
<
/span
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
div
v
-
else
class
=
"job-empty-inline"
>
还没有发布渠道,点击「同步发布渠道」选择并发布。
<
/div
>
<
div
v
-
else
class
=
"job-empty-inline"
>
还没有发布渠道,点击「同步发布渠道」选择并发布。
<
/div
>
...
@@ -754,7 +921,20 @@ const mainAction = () => {
...
@@ -754,7 +921,20 @@ const mainAction = () => {
<
/el-tabs
>
<
/el-tabs
>
<
JdGrillModal
/>
<
JdGrillModal
/>
<
ChannelDialog
v
-
model
=
"channelDialogOpen"
:
job
=
"job"
@
saved
=
"onChannelsSaved"
/>
<
JdGeneratePanel
/>
<
ChannelDialog
v
-
model
=
"channelDialogOpen"
:
job
=
"job"
@
save
=
"onChannelsSaved"
/>
<
el
-
dialog
v
-
model
=
"jdMarkdownOpen"
:
title
=
"`${job.title || '岗位'
}
· JD(Markdown)`"
width
=
"min(640px, 92vw)"
>
<
div
class
=
"md-toolbar"
>
<
span
class
=
"md-filename"
>
{{
jdFileName
}}
<
/span
>
<
div
class
=
"md-toolbar-actions"
>
<
el
-
button
size
=
"small"
@
click
=
"copyJdMarkdown(true)"
>
复制纯文本
<
/el-button
>
<
el
-
button
size
=
"small"
type
=
"primary"
@
click
=
"copyJdMarkdown()"
>
复制
Markdown
<
/el-button
>
<
/div
>
<
/div
>
<
p
class
=
"md-hint"
>
Markdown
源码,可直接粘贴到支持
Markdown
的招聘平台;平台不支持时用「复制纯文本」。
<
/p
>
<
pre
class
=
"md-source"
>
{{
jdMarkdown
}}
<
/pre
>
<
/el-dialog
>
<
/div
>
<
/div
>
<
div
v
-
else
class
=
"empty"
>
正在加载岗位
...
<
/div
>
<
div
v
-
else
class
=
"empty"
>
正在加载岗位
...
<
/div
>
<
/template
>
<
/template
>
...
@@ -1160,6 +1340,39 @@ const mainAction = () => {
...
@@ -1160,6 +1340,39 @@ const mainAction = () => {
}
}
// JD 草稿内容
// JD 草稿内容
.
jd
-
action
-
guide
{
display
:
flex
;
align
-
items
:
flex
-
start
;
gap
:
8
px
;
margin
:
4
px
0
14
px
;
padding
:
10
px
12
px
;
background
:
color
-
mix
(
in
srgb
,
var
(
--
blue
)
8
%
,
var
(
--
panel
));
border
:
1
px
solid
color
-
mix
(
in
srgb
,
var
(
--
blue
)
20
%
,
var
(
--
line
));
border
-
radius
:
8
px
;
.
jd
-
action
-
guide
-
icon
{
color
:
var
(
--
blue
);
font
-
size
:
15
px
;
line
-
height
:
1.5
;
}
p
{
margin
:
0
;
color
:
var
(
--
muted
);
font
-
size
:
13
px
;
line
-
height
:
1.6
;
}
}
.
jd
-
version
-
meta
{
display
:
flex
;
align
-
items
:
center
;
gap
:
8
px
;
margin
:
-
2
px
0
14
px
;
color
:
var
(
--
muted
);
font
-
size
:
12
px
;
}
.
jd
-
detail
{
.
jd
-
detail
{
display
:
flex
;
display
:
flex
;
flex
-
direction
:
column
;
flex
-
direction
:
column
;
...
@@ -1304,7 +1517,7 @@ const mainAction = () => {
...
@@ -1304,7 +1517,7 @@ const mainAction = () => {
.
channel
-
table
{
.
channel
-
table
{
.
channel
-
row
{
.
channel
-
row
{
display
:
grid
;
display
:
grid
;
grid
-
template
-
columns
:
1.
2f
r
1.6f
r
0.8f
r
1
fr
1
fr
;
grid
-
template
-
columns
:
1.
1f
r
1.5f
r
1
fr
0.9f
r
0.9f
r
1.
1f
r
;
gap
:
10
px
;
gap
:
10
px
;
padding
:
10
px
4
px
;
padding
:
10
px
4
px
;
border
-
bottom
:
1
px
solid
var
(
--
line
);
border
-
bottom
:
1
px
solid
var
(
--
line
);
...
@@ -1321,6 +1534,33 @@ const mainAction = () => {
...
@@ -1321,6 +1534,33 @@ const mainAction = () => {
text
-
overflow
:
ellipsis
;
text
-
overflow
:
ellipsis
;
white
-
space
:
nowrap
;
white
-
space
:
nowrap
;
}
}
.
channel
-
link
{
color
:
var
(
--
blue
);
text
-
decoration
:
none
;
&
:
hover
{
text
-
decoration
:
underline
;
}
}
.
channel
-
status
-
cell
{
display
:
flex
;
align
-
items
:
center
;
gap
:
6
px
;
.
channel
-
status
-
select
{
width
:
96
px
;
flex
:
none
;
}
}
.
channel
-
actions
{
display
:
flex
;
align
-
items
:
center
;
gap
:
2
px
;
white
-
space
:
nowrap
;
}
}
}
}
}
...
@@ -1342,4 +1582,46 @@ const mainAction = () => {
...
@@ -1342,4 +1582,46 @@ const mainAction = () => {
}
}
}
}
}
}
// JD 导出(Markdown)
.
md
-
toolbar
{
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
space
-
between
;
gap
:
12
px
;
margin
-
bottom
:
10
px
;
.
md
-
filename
{
font
-
family
:
ui
-
monospace
,
SFMono
-
Regular
,
Consolas
,
'Courier New'
,
monospace
;
font
-
size
:
12
px
;
color
:
var
(
--
muted
);
}
.
md
-
toolbar
-
actions
{
display
:
flex
;
gap
:
8
px
;
}
}
.
md
-
hint
{
margin
:
0
0
10
px
;
font
-
size
:
12
px
;
color
:
var
(
--
muted
);
}
.
md
-
source
{
margin
:
0
;
padding
:
14
px
16
px
;
max
-
height
:
56
vh
;
overflow
:
auto
;
background
:
var
(
--
bg
);
border
:
1
px
solid
var
(
--
line
);
border
-
radius
:
8
px
;
font
-
family
:
ui
-
monospace
,
SFMono
-
Regular
,
Consolas
,
'Courier New'
,
monospace
;
font
-
size
:
13
px
;
line
-
height
:
1.7
;
color
:
var
(
--
ink
);
white
-
space
:
pre
-
wrap
;
word
-
break
:
break
-
word
;
}
<
/style
>
<
/style
>
vue-app/src/components/jobs/JobEdit.vue
View file @
9c9e4c37
...
@@ -18,6 +18,7 @@ import {
...
@@ -18,6 +18,7 @@ import {
HIRE_REASON_OPTIONS
,
HIRE_REASON_OPTIONS
,
}
from
'@/utils/jobStatus'
}
from
'@/utils/jobStatus'
import
JdGrillModal
from
'./JdGrillModal.vue'
import
JdGrillModal
from
'./JdGrillModal.vue'
import
JdGeneratePanel
from
'./JdGeneratePanel.vue'
const
props
=
defineProps
({
const
props
=
defineProps
({
jobId
:
{
type
:
String
,
default
:
''
},
jobId
:
{
type
:
String
,
default
:
''
},
...
@@ -94,7 +95,8 @@ const back = () => {
...
@@ -94,7 +95,8 @@ const back = () => {
else
router
.
push
(
'/jobs'
)
else
router
.
push
(
'/jobs'
)
}
}
// AI 辅助创建:基于已填表单信息打开追问访谈,AI 一步步问清未填项,结束后自动生成 JD 草稿回填表单
// AI 辅助创建:新建岗位直接进访谈逐题问清,完成后自动把采集的结构化岗位信息回填表单。
// 这里返回的是访谈累积的 jd/职责/要求等字段,缺的字段由生成器在后续创建时推断。
const
aiAssistCreate
=
async
()
=>
{
const
aiAssistCreate
=
async
()
=>
{
if
(
!
form
.
title
.
trim
())
{
if
(
!
form
.
title
.
trim
())
{
showToast
(
'请先填写岗位名称,AI 才能基于它追问补全'
,
'warning'
)
showToast
(
'请先填写岗位名称,AI 才能基于它追问补全'
,
'warning'
)
...
@@ -110,7 +112,7 @@ const aiAssistCreate = async () => {
...
@@ -110,7 +112,7 @@ const aiAssistCreate = async () => {
priority
:
form
.
priority
,
priority
:
form
.
priority
,
}
}
try
{
try
{
//
访谈完成时 resolve 生成结果(含累积的 job);用户取消则 rejec
t
//
assist 直接进访谈逐题问清;完成后 store 内部已 force 生成完整 JD 并带回 draf
t
const
result
=
await
store
.
openJdGrill
(
baseJob
,
'assist'
)
const
result
=
await
store
.
openJdGrill
(
baseJob
,
'assist'
)
if
(
!
result
)
return
if
(
!
result
)
return
const
accumulatedJob
=
result
.
job
||
baseJob
const
accumulatedJob
=
result
.
job
||
baseJob
...
@@ -130,7 +132,7 @@ const aiAssistCreate = async () => {
...
@@ -130,7 +132,7 @@ const aiAssistCreate = async () => {
}
}
showToast
(
'AI 已补全岗位信息,可直接检查后创建'
,
'success'
)
showToast
(
'AI 已补全岗位信息,可直接检查后创建'
,
'success'
)
}
catch
(
error
)
{
}
catch
(
error
)
{
if
(
error
?.
message
===
'已取消追问'
)
return
// 用户主动取消,不提示错误
if
(
error
?.
message
===
'已取消追问'
||
error
?.
message
===
'已取消生成'
)
return
// 用户主动取消,不提示错误
showToast
(
error
.
message
||
'AI 辅助创建失败'
,
'error'
)
showToast
(
error
.
message
||
'AI 辅助创建失败'
,
'error'
)
}
}
}
}
...
@@ -411,6 +413,7 @@ const activeTab = ref('base')
...
@@ -411,6 +413,7 @@ const activeTab = ref('base')
</section>
</section>
<JdGrillModal
/>
<JdGrillModal
/>
<JdGeneratePanel
/>
</div>
</div>
</
template
>
</
template
>
...
...
vue-app/src/components/resumes/ResumeList.vue
View file @
9c9e4c37
<
script
setup
>
<
script
setup
>
import
{
computed
,
ref
}
from
'vue'
import
{
computed
,
ref
}
from
'vue'
import
{
useRoute
,
useRouter
}
from
'vue-router'
import
{
useRecruitmentStore
}
from
'@/stores/recruitment'
import
{
useRecruitmentStore
}
from
'@/stores/recruitment'
import
{
jobCandidates
}
from
'@/utils/links'
import
{
getFilteredCandidates
,
quickMatchNeedsRefresh
}
from
'@/utils/matching'
import
{
getFilteredCandidates
,
quickMatchNeedsRefresh
}
from
'@/utils/matching'
import
{
quickScore
,
aiScore
,
candidateScreeningConclusion
,
candidateRecentUpdate
}
from
'@/utils/resume'
import
{
quickScore
,
aiScore
,
candidateScreeningConclusion
,
candidateRecentUpdate
}
from
'@/utils/resume'
import
{
showToast
}
from
'@/utils/toast'
import
{
showToast
}
from
'@/utils/toast'
const
store
=
useRecruitmentStore
()
const
store
=
useRecruitmentStore
()
const
route
=
useRoute
()
const
router
=
useRouter
()
const
batchMatchRunning
=
ref
(
false
)
const
batchMatchRunning
=
ref
(
false
)
const
batchMatchResult
=
ref
(
null
)
const
batchMatchResult
=
ref
(
null
)
...
@@ -14,7 +18,16 @@ const batchMatchResult = ref(null)
...
@@ -14,7 +18,16 @@ const batchMatchResult = ref(null)
const
stageOptions
=
[
'全部阶段'
,
'未筛选'
,
'初筛通过'
,
'初筛未通过'
,
'面试中'
,
'Offer 中'
,
'待入职'
,
'已入职'
]
const
stageOptions
=
[
'全部阶段'
,
'未筛选'
,
'初筛通过'
,
'初筛未通过'
,
'面试中'
,
'Offer 中'
,
'待入职'
,
'已入职'
]
const
sourceOptions
=
[
'全部来源'
,
...
new
Set
(
store
.
candidates
.
map
((
candidate
)
=>
candidate
.
source
).
filter
(
Boolean
))]
const
sourceOptions
=
[
'全部来源'
,
...
new
Set
(
store
.
candidates
.
map
((
candidate
)
=>
candidate
.
source
).
filter
(
Boolean
))]
const
filtered
=
computed
(()
=>
getFilteredCandidates
(
store
.
candidates
,
store
.
resumeFilters
))
const
scopeJob
=
computed
(()
=>
store
.
jobs
.
find
((
job
)
=>
job
.
id
===
String
(
route
.
query
.
job
||
''
))
||
null
)
const
filtered
=
computed
(()
=>
{
const
base
=
getFilteredCandidates
(
store
.
candidates
,
store
.
resumeFilters
)
return
scopeJob
.
value
?
jobCandidates
(
scopeJob
.
value
,
base
)
:
base
})
const
clearScope
=
()
=>
{
router
.
replace
(
'/resumes'
)
}
const
highMatch
=
computed
(()
=>
store
.
candidates
.
filter
((
candidate
)
=>
quickScore
(
candidate
)
>=
85
).
length
)
const
highMatch
=
computed
(()
=>
store
.
candidates
.
filter
((
candidate
)
=>
quickScore
(
candidate
)
>=
85
).
length
)
const
pendingAnalysis
=
computed
(()
=>
store
.
candidates
.
filter
((
candidate
)
=>
!
candidate
.
analysis
).
length
)
const
pendingAnalysis
=
computed
(()
=>
store
.
candidates
.
filter
((
candidate
)
=>
!
candidate
.
analysis
).
length
)
...
@@ -155,6 +168,15 @@ const openBatch = () => {
...
@@ -155,6 +168,15 @@ const openBatch = () => {
</div>
</div>
</section>
</section>
<section
v-if=
"scopeJob"
class=
"resume-scope-card"
>
<div
class=
"resume-scope-info"
>
<el-tag
type=
"primary"
effect=
"plain"
>
岗位聚焦
</el-tag>
<strong>
{{
scopeJob
.
title
}}
</strong>
<span
class=
"muted"
>
仅显示该岗位关联候选人
</span>
</div>
<el-button
size=
"small"
@
click=
"clearScope"
>
查看全部候选人
</el-button>
</section>
<section
<section
v-if=
"batchMatchResult"
v-if=
"batchMatchResult"
class=
"batch-match-result"
class=
"batch-match-result"
...
@@ -381,4 +403,23 @@ const openBatch = () => {
...
@@ -381,4 +403,23 @@ const openBatch = () => {
.ml-6
{
.ml-6
{
margin-left
:
6px
;
margin-left
:
6px
;
}
}
.resume-scope-card
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
12px
;
margin-top
:
12px
;
padding
:
10px
14px
;
background
:
color-mix
(
in
srgb
,
var
(
--
blue
)
8%
,
var
(
--
panel
));
border
:
1px
solid
color-mix
(
in
srgb
,
var
(
--
blue
)
22%
,
var
(
--
line
));
border-radius
:
8px
;
.resume-scope-info
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-size
:
13px
;
}
}
</
style
>
</
style
>
vue-app/src/stores/recruitment.js
View file @
9c9e4c37
...
@@ -251,6 +251,8 @@ export const useRecruitmentStore = defineStore('recruitment', {
...
@@ -251,6 +251,8 @@ export const useRecruitmentStore = defineStore('recruitment', {
candidateBatchParsing
:
false
,
candidateBatchParsing
:
false
,
jdGrill
:
null
,
// { job, mode, sessionState, currentQuestion, busy, modeType, resolve, reject }
jdGrill
:
null
,
// { job, mode, sessionState, currentQuestion, busy, modeType, resolve, reject }
jdGrillOpen
:
false
,
jdGrillOpen
:
false
,
jdGenerate
:
null
,
// { job, mode, stage, message, missing, busy, resolve, reject }
jdGenerateOpen
:
false
,
}
}
},
},
...
@@ -507,6 +509,120 @@ export const useRecruitmentStore = defineStore('recruitment', {
...
@@ -507,6 +509,120 @@ export const useRecruitmentStore = defineStore('recruitment', {
else
grill
.
reject
(
new
Error
(
'已取消追问'
))
else
grill
.
reject
(
new
Error
(
'已取消追问'
))
},
},
// ---- 流式生成 JD:面板实时展示后端处理过程 ----
// assist(新建岗位)模式由 JobEdit 直接调 openJdGrill 进访谈,不走此面板
generateJdWithProgress
(
job
,
mode
=
'generate'
)
{
return
new
Promise
((
resolve
,
reject
)
=>
{
this
.
jdGenerate
=
{
job
:
{
...
job
},
mode
,
stage
:
'starting'
,
message
:
'准备生成…'
,
missing
:
[],
busy
:
true
,
resolve
,
reject
,
}
this
.
jdGenerateOpen
=
true
this
.
runJdGenerateStream
({
job
,
mode
})
})
},
// 静默一次性生成(后台用,不弹面板):force=true 跳过完整性检查,
// 用于访谈补全后把 jd/职责/要求补出来(题单不采集 jd,必须由生成器推断)
async
generateJdOnce
(
payload
)
{
return
new
Promise
((
resolve
,
reject
)
=>
{
api
.
generateJdStream
(
{
...
payload
,
force
:
true
},
{
onDone
:
(
draft
)
=>
resolve
(
draft
),
onNeedsChat
:
(
event
)
=>
reject
(
new
Error
(
event
.
message
||
'岗位信息不全'
)),
onError
:
(
message
)
=>
reject
(
new
Error
(
message
||
'JD 生成失败'
)),
}
)
.
catch
(
reject
)
})
},
async
runJdGenerateStream
(
payload
,
force
=
false
)
{
const
gen
=
this
.
jdGenerate
if
(
!
gen
)
return
gen
.
busy
=
true
gen
.
stage
=
'checking'
gen
.
message
=
'正在校验岗位信息完整性…'
try
{
await
api
.
generateJdStream
(
{
...
payload
,
force
},
{
onStage
:
(
stage
,
message
)
=>
{
if
(
this
.
jdGenerate
)
{
this
.
jdGenerate
.
stage
=
stage
this
.
jdGenerate
.
message
=
message
}
},
onNeedsChat
:
(
event
)
=>
{
if
(
!
this
.
jdGenerate
)
return
this
.
jdGenerate
.
stage
=
'needs_chat'
this
.
jdGenerate
.
message
=
event
.
message
||
'岗位信息不全,需补全后再生成。'
this
.
jdGenerate
.
missing
=
event
.
missing
||
[]
this
.
jdGenerate
.
busy
=
false
},
onDone
:
(
draft
)
=>
{
const
holder
=
this
.
jdGenerate
this
.
jdGenerate
=
null
this
.
jdGenerateOpen
=
false
if
(
holder
?.
resolve
)
holder
.
resolve
(
draft
)
},
onError
:
(
message
)
=>
{
const
holder
=
this
.
jdGenerate
this
.
jdGenerate
=
null
this
.
jdGenerateOpen
=
false
if
(
holder
?.
reject
)
holder
.
reject
(
new
Error
(
message
||
'JD 生成失败'
))
},
}
)
}
catch
(
error
)
{
const
holder
=
this
.
jdGenerate
this
.
jdGenerate
=
null
this
.
jdGenerateOpen
=
false
if
(
holder
?.
reject
)
holder
.
reject
(
error
)
}
},
// 面板操作:用现有信息强制继续生成(跳过完整性检查)
jdGenerateForce
()
{
const
gen
=
this
.
jdGenerate
if
(
!
gen
||
gen
.
busy
)
return
this
.
runJdGenerateStream
({
job
:
gen
.
job
,
mode
:
gen
.
mode
},
true
)
},
// 面板操作:去访谈补全(关闭面板打开访谈,访谈完成结果接回当前 promise)
jdGenerateGoGrill
()
{
const
gen
=
this
.
jdGenerate
if
(
!
gen
)
return
const
job
=
gen
.
job
const
mode
=
gen
.
mode
const
resolve
=
gen
.
resolve
const
reject
=
gen
.
reject
this
.
jdGenerate
=
null
this
.
jdGenerateOpen
=
false
this
.
openJdGrill
(
job
,
mode
).
then
(
(
generated
)
=>
resolve
(
generated
||
null
),
(
error
)
=>
reject
(
error
)
)
},
// 面板操作:取消
jdGenerateCancel
()
{
const
gen
=
this
.
jdGenerate
if
(
!
gen
)
return
const
reject
=
gen
.
reject
this
.
jdGenerate
=
null
this
.
jdGenerateOpen
=
false
reject
(
new
Error
(
'已取消生成'
))
},
async
jdGrillStart
()
{
async
jdGrillStart
()
{
const
grill
=
this
.
jdGrill
const
grill
=
this
.
jdGrill
if
(
!
grill
)
return
if
(
!
grill
)
return
...
@@ -575,7 +691,9 @@ export const useRecruitmentStore = defineStore('recruitment', {
...
@@ -575,7 +691,9 @@ export const useRecruitmentStore = defineStore('recruitment', {
},
},
applyJdDraft
(
job
,
draft
=
{},
mode
=
'generate'
)
{
applyJdDraft
(
job
,
draft
=
{},
mode
=
'generate'
)
{
const
nextVersion
=
draft
.
jdVersion
||
this
.
nextJdVersion
(
job
,
'待确认'
)
const
hasExistingDraft
=
Boolean
(
job
.
jd
||
job
.
responsibilities
||
job
.
requirements
||
job
.
mustHave
)
const
previousVersion
=
job
.
jdVersion
||
'当前版本'
const
nextVersion
=
hasExistingDraft
?
this
.
nextJdVersion
(
job
,
'待确认'
)
:
draft
.
jdVersion
||
'v1 待确认'
Object
.
assign
(
job
,
{
Object
.
assign
(
job
,
{
jd
:
draft
.
jd
||
job
.
jd
,
jd
:
draft
.
jd
||
job
.
jd
,
responsibilities
:
draft
.
responsibilities
||
job
.
responsibilities
,
responsibilities
:
draft
.
responsibilities
||
job
.
responsibilities
,
...
@@ -590,7 +708,10 @@ export const useRecruitmentStore = defineStore('recruitment', {
...
@@ -590,7 +708,10 @@ export const useRecruitmentStore = defineStore('recruitment', {
approvalStatus
:
draft
.
approvalStatus
||
'用人部门确认中'
,
approvalStatus
:
draft
.
approvalStatus
||
'用人部门确认中'
,
jdDraftSource
:
draft
.
provider
||
'local'
,
jdDraftSource
:
draft
.
provider
||
'local'
,
})
})
this
.
appendJdHistory
(
job
,
`
${
mode
===
'iterate'
?
'迭代'
:
'生成'
}
JD草稿`
)
const
note
=
hasExistingDraft
?
`
${
mode
===
'iterate'
?
'迭代'
:
'重新生成'
}
JD:基于
${
previousVersion
}
生成待确认版本`
:
'生成首版 JD 草稿'
this
.
appendJdHistory
(
job
,
note
)
},
},
async
parseResumeFile
(
file
)
{
async
parseResumeFile
(
file
)
{
...
...
vue-app/src/utils/jdMarkdown.js
0 → 100644
View file @
9c9e4c37
// 整份岗位 JD 导出(Markdown / 纯文本):供复制到招聘平台使用。
// 只输出对外安全字段,不包含敏感/性别/年龄/试用期/竞业等对内字段(见 AGENTS.md §4.4)。
const
SECTION_FIELDS
=
[
[
'岗位职责'
,
'responsibilities'
],
[
'任职要求'
,
'requirements'
],
[
'硬性条件'
,
'mustHave'
],
[
'加分项'
,
'niceToHave'
],
[
'淘汰项'
,
'knockout'
],
[
'胜任力模型'
,
'competency'
],
]
function
splitField
(
value
=
''
,
separators
=
/
[
;;
\n]
+/
)
{
return
String
(
value
||
''
)
.
split
(
separators
)
.
map
((
item
)
=>
item
.
trim
())
.
filter
(
Boolean
)
}
function
collect
(
job
=
{})
{
const
title
=
String
(
job
.
title
||
''
).
trim
()
||
'岗位招聘 JD'
const
summary
=
String
(
job
.
jd
||
''
).
trim
()
const
meta
=
[
[
'所属部门'
,
job
.
department
],
[
'薪资范围'
,
job
.
salaryRange
],
[
'工作地点'
,
job
.
workLocation
],
[
'工作模式'
,
job
.
workMode
],
[
'职级'
,
job
.
level
],
[
'招聘人数'
,
Number
(
job
.
headcount
)
>
0
?
`
${
job
.
headcount
}
人`
:
''
],
[
'到岗时间'
,
job
.
startDate
],
].
filter
(([,
value
])
=>
String
(
value
||
''
).
trim
())
const
sections
=
SECTION_FIELDS
.
map
(([
label
,
key
])
=>
({
label
,
items
:
splitField
(
job
[
key
])
})).
filter
(
(
section
)
=>
section
.
items
.
length
)
const
keywords
=
splitField
(
job
.
matchKeywords
,
/
[
,,、
]
+/
)
return
{
title
,
summary
,
meta
,
sections
,
keywords
}
}
export
function
buildJdMarkdown
(
job
=
{})
{
const
{
title
,
summary
,
meta
,
sections
,
keywords
}
=
collect
(
job
)
const
lines
=
[
`#
${
title
}
`
,
''
]
if
(
summary
)
lines
.
push
(
summary
,
''
)
if
(
meta
.
length
)
{
lines
.
push
(
'## 基本信息'
,
''
)
for
(
const
[
label
,
value
]
of
meta
)
lines
.
push
(
`- **
${
label
}
**:
${
value
}
`
)
lines
.
push
(
''
)
}
for
(
const
{
label
,
items
}
of
sections
)
{
lines
.
push
(
`##
${
label
}
`
,
''
)
for
(
const
item
of
items
)
lines
.
push
(
`-
${
item
}
`
)
lines
.
push
(
''
)
}
if
(
keywords
.
length
)
{
lines
.
push
(
'## 匹配关键词'
,
''
)
for
(
const
item
of
keywords
)
lines
.
push
(
`-
${
item
}
`
)
lines
.
push
(
''
)
}
return
(
lines
.
join
(
'
\
n'
)
.
replace
(
/
\n{3,}
/g
,
'
\
n
\
n'
)
.
trim
()
+
'
\
n'
)
}
export
function
buildJdPlainText
(
job
=
{})
{
const
{
title
,
summary
,
meta
,
sections
,
keywords
}
=
collect
(
job
)
const
lines
=
[
title
]
if
(
summary
)
lines
.
push
(
''
,
summary
)
if
(
meta
.
length
)
{
lines
.
push
(
''
,
'基本信息'
)
for
(
const
[
label
,
value
]
of
meta
)
lines
.
push
(
`
${
label
}
:
${
value
}
`
)
}
for
(
const
{
label
,
items
}
of
sections
)
{
lines
.
push
(
''
,
label
)
items
.
forEach
((
item
,
index
)
=>
lines
.
push
(
`
${
index
+
1
}
.
${
item
}
`
))
}
if
(
keywords
.
length
)
{
lines
.
push
(
''
,
'匹配关键词'
)
lines
.
push
(
keywords
.
join
(
'、'
))
}
return
(
lines
.
join
(
'
\
n'
)
.
replace
(
/
\n{3,}
/g
,
'
\
n
\
n'
)
.
trim
()
+
'
\
n'
)
}
vue-app/src/utils/job.js
View file @
9c9e4c37
...
@@ -50,3 +50,11 @@ export function jobSearchText(job = {}) {
...
@@ -50,3 +50,11 @@ export function jobSearchText(job = {}) {
.
filter
(
Boolean
)
.
filter
(
Boolean
)
.
join
(
' '
)
.
join
(
' '
)
}
}
// 渠道展示状态:已下架优先;JD 版本与当前不一致视为「待更新」(发布台账用)
export
function
resolveChannelStatus
(
channel
=
{},
jdVersion
=
''
)
{
const
status
=
channel
.
status
||
'已发布'
if
(
status
===
'已下架'
)
return
'已下架'
if
(
jdVersion
&&
channel
.
jdVersion
&&
channel
.
jdVersion
!==
jdVersion
)
return
'待更新'
return
status
}
vue-app/src/utils/jobFlow.js
View file @
9c9e4c37
...
@@ -89,14 +89,16 @@ export function jobActionFor(job = {}) {
...
@@ -89,14 +89,16 @@ export function jobActionFor(job = {}) {
description
:
'先明确职责与要求,后续发布和匹配才会更准确。'
,
description
:
'先明确职责与要求,后续发布和匹配才会更准确。'
,
label
:
'完善 JD'
,
label
:
'完善 JD'
,
tab
:
'flow'
,
tab
:
'flow'
,
step
:
1
,
}
}
}
}
if
([
JD_STATUS
.
PENDING_CONFIRM
,
JD_STATUS
.
APPROVING
].
includes
(
job
.
jdStatus
))
{
if
([
JD_STATUS
.
PENDING_CONFIRM
,
JD_STATUS
.
APPROVING
].
includes
(
job
.
jdStatus
))
{
return
{
return
{
title
:
'确认 JD 后发布'
,
title
:
'确认 JD 后发布'
,
description
:
'岗位说明已准备好,
完成确认即可
进入发布。'
,
description
:
'岗位说明已准备好,
先确认内容,再提交审批并
进入发布。'
,
label
:
'处理确认
'
,
label
:
job
.
jdStatus
===
JD_STATUS
.
PENDING_CONFIRM
?
'去确认 JD'
:
'查看审批
'
,
tab
:
'flow'
,
tab
:
'flow'
,
step
:
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
)
{
...
@@ -113,7 +115,7 @@ export function jobActionFor(job = {}) {
...
@@ -113,7 +115,7 @@ export function jobActionFor(job = {}) {
title
:
`还差
${
openings
}
人`
,
title
:
`还差
${
openings
}
人`
,
description
:
'岗位正在招聘中,继续从简历库匹配并推进候选人。'
,
description
:
'岗位正在招聘中,继续从简历库匹配并推进候选人。'
,
label
:
'查看候选人'
,
label
:
'查看候选人'
,
tab
:
'
overview
'
,
tab
:
'
candidates
'
,
}
}
}
}
return
{
return
{
...
@@ -169,20 +171,34 @@ export function jobWorkflow(job = {}, candidates = []) {
...
@@ -169,20 +171,34 @@ export function jobWorkflow(job = {}, candidates = []) {
?
1
?
1
:
0
:
0
const
next
=
const
next
=
status
!==
JD_STATUS
.
EFFECTIVE
status
===
JD_STATUS
.
DRAFT
?
{
title
:
'
确认 JD 生效'
,
desc
:
'JD 未生效前,简历匹配只能作为草稿依据。'
,
action
:
'确认生效
'
}
?
{
title
:
'
完善 JD 草稿'
,
desc
:
'先生成并检查岗位说明,再交给用人部门确认。'
,
action
:
'生成 / 编辑 JD
'
}
:
sta
leCandidates
.
length
:
sta
tus
===
JD_STATUS
.
PENDING_CONFIRM
?
{
?
{
title
:
'
重评关联候选人
'
,
title
:
'
用人部门确认 JD
'
,
desc
:
`
${
staleCandidates
.
length
}
位候选人的匹配/AI评估基于旧 JD,需要刷新。`
,
desc
:
'确认岗位说明、职责和硬性条件,确认后才能提交审批。'
,
action
:
'
批量重新匹配 / AI重评
'
,
action
:
'
确认 JD 内容
'
,
}
}
:
related
.
length
:
status
===
JD_STATUS
.
APPROVING
?
{
?
{
title
:
'跟进 JD 审批'
,
desc
:
'JD 已提交审批,审批通过后才能正式生效。'
,
action
:
'查看审批状态'
}
title
:
'继续推进候选人'
,
:
status
!==
JD_STATUS
.
EFFECTIVE
desc
:
`当前岗位已关联
${
related
.
length
}
位候选人,可从简历库推进面试或 Offer。`
,
?
{
title
:
'确认 JD 生效'
,
desc
:
'JD 未生效前,简历匹配只能作为草稿依据。'
,
action
:
'确认生效'
}
action
:
'查看候选人'
,
:
staleCandidates
.
length
}
?
{
:
{
title
:
'补充候选人'
,
desc
:
'JD 已生效但暂无关联候选人,下一步上传简历或从渠道导入。'
,
action
:
'上传简历'
}
title
:
'重评关联候选人'
,
desc
:
`
${
staleCandidates
.
length
}
位候选人的匹配/AI评估基于旧 JD,需要刷新。`
,
action
:
'批量重新匹配 / AI重评'
,
}
:
related
.
length
?
{
title
:
'继续推进候选人'
,
desc
:
`当前岗位已关联
${
related
.
length
}
位候选人,可从简历库推进面试或 Offer。`
,
action
:
'查看候选人'
,
}
:
{
title
:
'补充候选人'
,
desc
:
'JD 已生效但暂无关联候选人,下一步上传简历或从渠道导入。'
,
action
:
'上传简历'
,
}
return
{
related
,
staleCandidates
,
steps
,
currentIndex
,
next
}
return
{
related
,
staleCandidates
,
steps
,
currentIndex
,
next
}
}
}
vue-app/src/utils/jobStatus.js
View file @
9c9e4c37
...
@@ -51,6 +51,9 @@ export const CHANNEL_STATUS = {
...
@@ -51,6 +51,9 @@ export const CHANNEL_STATUS = {
export
const
CHANNEL_STATUS_OPTIONS
=
Object
.
values
(
CHANNEL_STATUS
)
export
const
CHANNEL_STATUS_OPTIONS
=
Object
.
values
(
CHANNEL_STATUS
)
// 单个渠道的发布状态(发布台账行内可手动切换;「待更新」由 JD 版本差异自动推导,不在此枚举)
export
const
CHANNEL_ITEM_STATUS
=
[
'已发布'
,
'已下架'
]
export
const
MATCH_RULE_STATUS
=
{
export
const
MATCH_RULE_STATUS
=
{
NOT_CONFIGURED
:
'未配置'
,
NOT_CONFIGURED
:
'未配置'
,
PENDING_CONFIRM
:
'待确认'
,
PENDING_CONFIRM
:
'待确认'
,
...
...
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