Commit 9c9e4c37 authored by 李文光's avatar 李文光

feat: refine JD workflow and confirmation actions

parent 966bdc6a
......@@ -5,12 +5,13 @@ from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from backend.app.config import get_settings
from backend.app.db import get_db
from backend.app.security import verify_ingest_token
from backend.app.services.external_sync import sync_external_jd
from backend.app.services.jd_grill import advance as grill_advance
from backend.app.services.jd_grill import start as grill_start
from backend.app.services.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 (
job_info_completeness,
qwenpaw_chat_stream,
......@@ -29,6 +30,61 @@ async def generate_jd_route(payload: dict[str, Any]) -> dict[str, Any]:
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")
async def jd_completeness_route(payload: dict[str, Any]) -> dict[str, Any]:
"""评估岗位信息是否足够直接生成 JD。信息不全时前端应切到聊天追问。"""
......
......@@ -361,11 +361,11 @@ def _sanitize_job_for_external(job: dict[str, Any]) -> dict[str, Any]:
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 直连 / 本地结构化。
# 先剥离对内字段,避免敏感信息随 job 发送到对外 LLM / 写进对外 JD。
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:
return qwenpaw_draft
return await llm_jd_draft(safe_payload) or local_jd_draft(payload)
......@@ -230,12 +230,12 @@ def _extract_jd_body(reply: str) -> str:
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 草稿。
返回三种情况:
- None:未启用或服务不可用,调用方回退现有 LLM/本地逻辑。
- {"needs_chat": True, ...}:岗位信息不全,建议前端切换到聊天追问模式。
- {"needs_chat": True, ...}:岗位信息不全(force=False 时),建议前端切换到聊天追问模式。
- 完整 JD 草稿:信息足够,直接产出。
"""
settings = get_settings()
......@@ -244,7 +244,8 @@ async def qwenpaw_jd_draft(payload: dict[str, Any]) -> dict[str, Any] | None:
job = payload.get("job") or {}
mode = payload.get("mode") or "generate"
# 信息不全 → 提示前端转聊天追问,而不是静默回退
# 信息不全 → 提示前端转聊天追问,而不是静默回退;force=True 时跳过(用现有信息硬生成)
if not force:
completeness = job_info_completeness(job)
if not completeness["complete"]:
return {
......
import json
def test_ai_fallback_routes(client, monkeypatch):
monkeypatch.setenv("LLM_API_KEY", "")
analysis = client.post(
......@@ -17,6 +20,49 @@ def test_ai_fallback_routes(client, monkeypatch):
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):
"""确定式逼问访谈:start 返回第一问,逐题 advance 推进到 complete,并采集结构化字段。"""
start = client.post(
......
......@@ -16,7 +16,7 @@ const http = axios.create({
timeout: 60000,
})
function readToken() {
export function readToken() {
try {
const raw = localStorage.getItem(AUTH_STORAGE_KEY)
if (!raw) return ''
......
import http from '@/api'
import http, { readToken } from '@/api'
export async function fetchState(owner = '') {
const { data } = await http.get('/api/state', { params: owner ? { owner } : {} })
......@@ -32,6 +32,51 @@ export async function generateJd(payload) {
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) {
const { data } = await http.post('/api/jd/completeness', payload)
return data
......
<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>
......@@ -32,7 +32,9 @@ watch(
try {
await store.jdGrillStart()
} catch (error) {
// 启动失败时结束 openJdGrill 的等待,避免 JobEdit 永远停在 await。
ElMessage.error(error.message || '启动追问失败')
store.closeJdGrill(null)
}
}
)
......@@ -68,7 +70,7 @@ const finish = async () => {
generating.value = true
try {
// 用 grill 累积出的结构化 job 生成 JD。后端信息完整性判断依赖 job.jd 与 job.requirements,
// 由生成器负责从已采集字段组织 JD 正文,避免把访谈采集的职责原文直接当 jd 回显
// 题单不采集 jd,故必须 force 生成(跳过完整性检查),由生成器从已采集字段推断职责/要求/JD
const accumulatedJob = { ...grill.value.job }
if (!accumulatedJob.requirements) {
const edu = accumulatedJob.education || ''
......@@ -77,13 +79,15 @@ const finish = async () => {
accumulatedJob.requirements =
[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 辅助创建模式回填表单用)
store.closeJdGrill({ ...draft, job: accumulatedJob })
if (draft?.needs_chat) {
ElMessage.warning('部分岗位字段仍缺,已按已采集信息生成草稿,可继续迭代补充')
} else {
ElMessage.success('岗位信息已补齐,JD 已生成')
if (draft?.provider?.startsWith('llm') || draft?.provider?.startsWith('qwenpaw')) {
ElMessage.success('AI 已生成完整 JD 草稿')
} else {
ElMessage.success('已生成 JD 草稿')
}
} catch (error) {
ElMessage.error(error.message || 'JD 生成失败')
......
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { useRecruitmentStore } from '@/stores/recruitment'
import { jobActionFor, jobWorkflow, buildJobCriteria, jobStateBadge } from '@/utils/jobFlow'
import { resolveChannelStatus } from '@/utils/job'
import { jobCandidates } from '@/utils/links'
import { workflowStageClass } from '@/utils/task'
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 JdGeneratePanel from './JdGeneratePanel.vue'
import ChannelDialog from './ChannelDialog.vue'
const props = defineProps({
......@@ -61,9 +64,9 @@ const openings = computed(() =>
job.value ? Math.max(0, Number(job.value.headcount || 0) - Number(job.value.hired || 0)) : 0
)
const goResumes = () => {
const goResumes = (jobId = '') => {
store.setPage('resumes')
router.push('/resumes')
router.push(jobId ? { path: '/resumes', query: { job: jobId } } : '/resumes')
}
const statusLabel = (value) => value || '未记录'
......@@ -86,6 +89,16 @@ watch(
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) => {
if (!job.value) return
job.value.jdHistory = [
......@@ -103,8 +116,17 @@ const ensureTask = (title, module, target, priority = '中', due = '今日') =>
store.ensureTask(title, module, target, priority, due, { jobId: job.value?.id || '' })
}
const confirmOwner = () => {
const confirmOwner = async () => {
if (!job.value) return
try {
await ElMessageBox.confirm(
'确认岗位说明、核心职责和硬性条件已经可以交给审批流吗?确认后仍可通过“迭代 JD 版本”生成新草稿。',
'用人部门确认 JD',
{ confirmButtonText: '确认内容', cancelButtonText: '继续修改', type: 'info' }
)
} catch {
return
}
job.value.approvalStatus = APPROVAL_STATUS.DEPARTMENT_CONFIRMED
if (job.value.jdStatus === JD_STATUS.DRAFT) job.value.jdStatus = JD_STATUS.PENDING_CONFIRM
appendJdHistory('用人部门确认JD内容')
......@@ -192,22 +214,45 @@ const refreshCandidates = async () => {
activeStep.value = 4
}
// 录入/迭代 JD:信息不全时 store.generateJd 会自动打开访谈面板补全
// 生成/迭代 JD:弹出过程面板实时展示后端处理,needs_chat 时在面板内引导补全
const generateJd = async (mode = 'generate') => {
if (!job.value) {
showToast('请先选择岗位', 'warning')
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 {
showToast(mode === 'iterate' ? '正在生成新版 JD...' : '正在生成 JD 草稿...', 'info')
const draft = await store.generateJd(job.value, mode)
if (!draft) return // 用户在访谈中取消
const draft = await store.generateJdWithProgress(job.value, mode)
if (!draft) return // 用户在面板取消或去访谈后未带回
store.applyJdDraft(job.value, draft, mode)
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草稿已生成')
activeStep.value = 1
} catch (error) {
if (error?.message === '已取消追问' || error?.message === '已取消生成') return // 用户主动取消,不提示错误
showToast(error.message, 'error')
}
}
......@@ -222,13 +267,18 @@ const onChannelsSaved = (selected = []) => {
if (!job.value) return
if (selected.length) {
const today = new Date().toISOString().slice(0, 10)
job.value.channels = selected.map((item) => ({
const currentVersion = job.value.jdVersion || 'v1'
job.value.channels = selected.map((item) => {
const existing = (job.value.channels || []).find((c) => c.name === item.name)
const versionChanged = Boolean(existing?.jdVersion && existing.jdVersion !== currentVersion)
return {
name: item.name,
link: item.link || '',
status: '已发布',
publishedAt: today,
jdVersion: job.value.jdVersion || 'v1',
}))
publishedAt: existing && !versionChanged ? existing.publishedAt || today : today,
jdVersion: currentVersion,
}
})
job.value.publishedChannels = selected.map((item) => item.name)
job.value.channelStatus = CHANNEL_STATUS.SYNCED
if (job.value.jdStatus === JD_STATUS.EFFECTIVE) job.value.status = JOB_STATUS.RECRUITING
......@@ -243,10 +293,26 @@ const onChannelsSaved = (selected = []) => {
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) => {
if (!job.value) return
const vNumber = (version.match(/v(\d+)/i) || [])[1] || '1'
const next = store.nextJdVersion(job.value, '草稿')
job.value.jd = job.value.jd || ''
// 以该历史版本的 note 说明复制来源;实际内容是当前字段(轻量实现)
......@@ -259,11 +325,53 @@ const copyVersion = (version) => {
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
const tab = action.value.tab
if (tab === '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
}
if (tab === 'publish') {
......@@ -509,14 +617,27 @@ const mainAction = () => {
<div class="step-pane-head">
<div>
<b>JD 草稿</b>
<span>岗位说明与招聘标准,生成后可在此查看草稿内容</span>
<span>首版用于把岗位信息写成 JD;迭代用于保留历史并生成新的待确认版本</span>
</div>
<div class="step-pane-actions">
<el-button size="small" @click="generateJd('iterate')">迭代 JD 版本</el-button>
<el-button size="small" type="primary" @click="generateJd('generate')">生成 JD 草稿</el-button>
<el-button size="small" :disabled="jdGenerationBusy || !hasJdContent" @click="generateJd('iterate')">
迭代 JD 版本
</el-button>
<el-button size="small" type="primary" :disabled="jdGenerationBusy" @click="generateJd('generate')">
{{ generateJdLabel }}
</el-button>
</div>
</div>
<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="job.jd || job.responsibilities || job.requirements || job.mustHave">
<template v-if="hasJdContent">
<div class="jd-detail">
<div class="jd-detail-main">
<span>岗位说明 / JD</span>
......@@ -567,8 +688,23 @@ const mainAction = () => {
<span>用人部门确认 JD 内容后提交审批</span>
</div>
<div class="step-pane-actions">
<el-button size="small" @click="confirmOwner">用人确认通过</el-button>
<el-button size="small" type="primary" @click="submitApproval">提交审批</el-button>
<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 class="step-fields">
......@@ -596,6 +732,7 @@ const mainAction = () => {
>审批通过并生效</el-button
>
<el-button size="small" @click="openChannelDialog">同步发布渠道</el-button>
<el-button size="small" :disabled="!hasJdContent" @click="openJdMarkdown">查看 Markdown</el-button>
</div>
</div>
<div class="step-fields">
......@@ -692,6 +829,9 @@ const mainAction = () => {
<div>
<span>需重评</span><strong>{{ flow.staleCandidates.length }} 人</strong>
</div>
<div>
<span>JD 待更新渠道</span><strong>{{ staleChannels.length }} 个</strong>
</div>
<div>
<span>已发布渠道</span><strong>{{ channelTags || '未同步' }}</strong>
</div>
......@@ -713,14 +853,41 @@ const mainAction = () => {
</div>
<div v-if="job.channels && job.channels.length" class="channel-table">
<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 版本</span
><span>操作</span>
</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>
<span>{{ channel.link || '—' }}</span>
<span>{{ channel.status || '已发布' }}</span>
<span class="channel-link-cell">
<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.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-button
>
</span>
</div>
</div>
<div v-else class="job-empty-inline">还没有发布渠道,点击「同步发布渠道」选择并发布。</div>
......@@ -754,7 +921,20 @@ const mainAction = () => {
</el-tabs>
<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 v-else class="empty">正在加载岗位...</div>
</template>
......@@ -1160,6 +1340,39 @@ const mainAction = () => {
}
// JD 草稿内容
.jd-action-guide {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 4px 0 14px;
padding: 10px 12px;
background: color-mix(in srgb, var(--blue) 8%, var(--panel));
border: 1px solid color-mix(in srgb, var(--blue) 20%, var(--line));
border-radius: 8px;
.jd-action-guide-icon {
color: var(--blue);
font-size: 15px;
line-height: 1.5;
}
p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
}
.jd-version-meta {
display: flex;
align-items: center;
gap: 8px;
margin: -2px 0 14px;
color: var(--muted);
font-size: 12px;
}
.jd-detail {
display: flex;
flex-direction: column;
......@@ -1304,7 +1517,7 @@ const mainAction = () => {
.channel-table {
.channel-row {
display: grid;
grid-template-columns: 1.2fr 1.6fr 0.8fr 1fr 1fr;
grid-template-columns: 1.1fr 1.5fr 1fr 0.9fr 0.9fr 1.1fr;
gap: 10px;
padding: 10px 4px;
border-bottom: 1px solid var(--line);
......@@ -1321,6 +1534,33 @@ const mainAction = () => {
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-link {
color: var(--blue);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.channel-status-cell {
display: flex;
align-items: center;
gap: 6px;
.channel-status-select {
width: 96px;
flex: none;
}
}
.channel-actions {
display: flex;
align-items: center;
gap: 2px;
white-space: nowrap;
}
}
}
......@@ -1342,4 +1582,46 @@ const mainAction = () => {
}
}
}
// JD 导出(Markdown)
.md-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
.md-filename {
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--muted);
}
.md-toolbar-actions {
display: flex;
gap: 8px;
}
}
.md-hint {
margin: 0 0 10px;
font-size: 12px;
color: var(--muted);
}
.md-source {
margin: 0;
padding: 14px 16px;
max-height: 56vh;
overflow: auto;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
color: var(--ink);
white-space: pre-wrap;
word-break: break-word;
}
</style>
......@@ -18,6 +18,7 @@ import {
HIRE_REASON_OPTIONS,
} from '@/utils/jobStatus'
import JdGrillModal from './JdGrillModal.vue'
import JdGeneratePanel from './JdGeneratePanel.vue'
const props = defineProps({
jobId: { type: String, default: '' },
......@@ -94,7 +95,8 @@ const back = () => {
else router.push('/jobs')
}
// AI 辅助创建:基于已填表单信息打开追问访谈,AI 一步步问清未填项,结束后自动生成 JD 草稿回填表单
// AI 辅助创建:新建岗位直接进访谈逐题问清,完成后自动把采集的结构化岗位信息回填表单。
// 这里返回的是访谈累积的 jd/职责/要求等字段,缺的字段由生成器在后续创建时推断。
const aiAssistCreate = async () => {
if (!form.title.trim()) {
showToast('请先填写岗位名称,AI 才能基于它追问补全', 'warning')
......@@ -110,7 +112,7 @@ const aiAssistCreate = async () => {
priority: form.priority,
}
try {
// 访谈完成时 resolve 生成结果(含累积的 job);用户取消则 reject
// assist 直接进访谈逐题问清;完成后 store 内部已 force 生成完整 JD 并带回 draft
const result = await store.openJdGrill(baseJob, 'assist')
if (!result) return
const accumulatedJob = result.job || baseJob
......@@ -130,7 +132,7 @@ const aiAssistCreate = async () => {
}
showToast('AI 已补全岗位信息,可直接检查后创建', 'success')
} catch (error) {
if (error?.message === '已取消追问') return // 用户主动取消,不提示错误
if (error?.message === '已取消追问' || error?.message === '已取消生成') return // 用户主动取消,不提示错误
showToast(error.message || 'AI 辅助创建失败', 'error')
}
}
......@@ -411,6 +413,7 @@ const activeTab = ref('base')
</section>
<JdGrillModal />
<JdGeneratePanel />
</div>
</template>
......
<script setup>
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRecruitmentStore } from '@/stores/recruitment'
import { jobCandidates } from '@/utils/links'
import { getFilteredCandidates, quickMatchNeedsRefresh } from '@/utils/matching'
import { quickScore, aiScore, candidateScreeningConclusion, candidateRecentUpdate } from '@/utils/resume'
import { showToast } from '@/utils/toast'
const store = useRecruitmentStore()
const route = useRoute()
const router = useRouter()
const batchMatchRunning = ref(false)
const batchMatchResult = ref(null)
......@@ -14,7 +18,16 @@ const batchMatchResult = ref(null)
const stageOptions = ['全部阶段', '未筛选', '初筛通过', '初筛未通过', '面试中', 'Offer 中', '待入职', '已入职']
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 pendingAnalysis = computed(() => store.candidates.filter((candidate) => !candidate.analysis).length)
......@@ -155,6 +168,15 @@ const openBatch = () => {
</div>
</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
v-if="batchMatchResult"
class="batch-match-result"
......@@ -381,4 +403,23 @@ const openBatch = () => {
.ml-6 {
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>
......@@ -251,6 +251,8 @@ export const useRecruitmentStore = defineStore('recruitment', {
candidateBatchParsing: false,
jdGrill: null, // { job, mode, sessionState, currentQuestion, busy, modeType, resolve, reject }
jdGrillOpen: false,
jdGenerate: null, // { job, mode, stage, message, missing, busy, resolve, reject }
jdGenerateOpen: false,
}
},
......@@ -507,6 +509,120 @@ export const useRecruitmentStore = defineStore('recruitment', {
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() {
const grill = this.jdGrill
if (!grill) return
......@@ -575,7 +691,9 @@ export const useRecruitmentStore = defineStore('recruitment', {
},
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, {
jd: draft.jd || job.jd,
responsibilities: draft.responsibilities || job.responsibilities,
......@@ -590,7 +708,10 @@ export const useRecruitmentStore = defineStore('recruitment', {
approvalStatus: draft.approvalStatus || '用人部门确认中',
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) {
......
// 整份岗位 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'
)
}
......@@ -50,3 +50,11 @@ export function jobSearchText(job = {}) {
.filter(Boolean)
.join(' ')
}
// 渠道展示状态:已下架优先;JD 版本与当前不一致视为「待更新」(发布台账用)
export function resolveChannelStatus(channel = {}, jdVersion = '') {
const status = channel.status || '已发布'
if (status === '已下架') return '已下架'
if (jdVersion && channel.jdVersion && channel.jdVersion !== jdVersion) return '待更新'
return status
}
......@@ -89,14 +89,16 @@ export function jobActionFor(job = {}) {
description: '先明确职责与要求,后续发布和匹配才会更准确。',
label: '完善 JD',
tab: 'flow',
step: 1,
}
}
if ([JD_STATUS.PENDING_CONFIRM, JD_STATUS.APPROVING].includes(job.jdStatus)) {
return {
title: '确认 JD 后发布',
description: '岗位说明已准备好,完成确认即可进入发布。',
label: '处理确认',
description: '岗位说明已准备好,先确认内容,再提交审批并进入发布。',
label: job.jdStatus === JD_STATUS.PENDING_CONFIRM ? '去确认 JD' : '查看审批',
tab: 'flow',
step: 2,
}
}
if (job.jdStatus === JD_STATUS.EFFECTIVE && job.channelStatus && job.channelStatus !== CHANNEL_STATUS.SYNCED) {
......@@ -113,7 +115,7 @@ export function jobActionFor(job = {}) {
title: `还差 ${openings} 人`,
description: '岗位正在招聘中,继续从简历库匹配并推进候选人。',
label: '查看候选人',
tab: 'overview',
tab: 'candidates',
}
}
return {
......@@ -169,7 +171,17 @@ export function jobWorkflow(job = {}, candidates = []) {
? 1
: 0
const next =
status !== JD_STATUS.EFFECTIVE
status === JD_STATUS.DRAFT
? { title: '完善 JD 草稿', desc: '先生成并检查岗位说明,再交给用人部门确认。', action: '生成 / 编辑 JD' }
: status === JD_STATUS.PENDING_CONFIRM
? {
title: '用人部门确认 JD',
desc: '确认岗位说明、职责和硬性条件,确认后才能提交审批。',
action: '确认 JD 内容',
}
: status === JD_STATUS.APPROVING
? { title: '跟进 JD 审批', desc: 'JD 已提交审批,审批通过后才能正式生效。', action: '查看审批状态' }
: status !== JD_STATUS.EFFECTIVE
? { title: '确认 JD 生效', desc: 'JD 未生效前,简历匹配只能作为草稿依据。', action: '确认生效' }
: staleCandidates.length
? {
......@@ -183,6 +195,10 @@ export function jobWorkflow(job = {}, candidates = []) {
desc: `当前岗位已关联 ${related.length} 位候选人,可从简历库推进面试或 Offer。`,
action: '查看候选人',
}
: { title: '补充候选人', desc: 'JD 已生效但暂无关联候选人,下一步上传简历或从渠道导入。', action: '上传简历' }
: {
title: '补充候选人',
desc: 'JD 已生效但暂无关联候选人,下一步上传简历或从渠道导入。',
action: '上传简历',
}
return { related, staleCandidates, steps, currentIndex, next }
}
......@@ -51,6 +51,9 @@ export const CHANNEL_STATUS = {
export const CHANNEL_STATUS_OPTIONS = Object.values(CHANNEL_STATUS)
// 单个渠道的发布状态(发布台账行内可手动切换;「待更新」由 JD 版本差异自动推导,不在此枚举)
export const CHANNEL_ITEM_STATUS = ['已发布', '已下架']
export const MATCH_RULE_STATUS = {
NOT_CONFIGURED: '未配置',
PENDING_CONFIRM: '待确认',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment