Commit a730e3ab authored by 李文光's avatar 李文光

feat(jobs): 简化岗位编辑流程并优化 JD 与内约束表单

parent 3e6bc5e4
......@@ -12,8 +12,6 @@ import {
JOB_STATUS,
JOB_STATUS_OPTIONS,
JD_STATUS,
JD_STATUS_EDITABLE_OPTIONS,
APPROVAL_STATUS,
PRIORITY_OPTIONS,
HIRE_REASON_OPTIONS,
RECRUIT_TYPE,
......@@ -22,7 +20,6 @@ import {
LEVEL_OPTIONS,
LOCATION_OPTIONS,
} from '@/utils/jobStatus'
import JdGrillModal from './JdGrillModal.vue'
import JdGeneratePanel from './JdGeneratePanel.vue'
import JdImportModal from './JdImportModal.vue'
......@@ -72,6 +69,19 @@ function parseSalaryRange(text) {
return { min: null, max: null }
}
function parseAgeRange(text) {
const raw = String(text || '').trim()
const range = raw.match(/^(\d{1,2})\s*[-~至到]\s*(\d{1,2})/)
if (range) return { min: Number(range[1]), max: Number(range[2]) }
const above = raw.match(/^(\d{1,2})\s*岁?(?:及以上|以上|起)/)
if (above) return { min: Number(above[1]), max: null }
const below = raw.match(/^(\d{1,2})\s*岁?(?:及以下|以下)/)
if (below) return { min: null, max: Number(below[1]) }
const single = raw.match(/^(\d{1,2})\s*岁?$/)
if (single) return { min: Number(single[1]), max: Number(single[1]) }
return { min: null, max: null }
}
const form = reactive({
jobId: '',
title: '',
......@@ -173,6 +183,81 @@ const clearSalaryRange = () => {
form.salaryRange = ''
}
// 年龄要求同样用「最低/最高」成对选择,单位为岁;空值表示不限
const AGE_YEAR_OPTIONS = Array.from({ length: 43 }, (_, index) => 18 + index)
const ageLow = ref(null)
const ageHigh = ref(null)
const ageRangeSet = computed(() => Boolean(String(form.internal.ageRestriction || '').trim()))
const syncAgePair = () => {
const { min, max } = parseAgeRange(form.internal.ageRestriction)
ageLow.value = min
ageHigh.value = max ?? min
}
const writeAgeRange = () => {
const low = ageLow.value
const high = ageHigh.value
if (low === null && high === null) {
form.internal.ageRestriction = ''
return
}
if (low !== null && high === null) {
form.internal.ageRestriction = `${low}岁及以上`
return
}
if (low === null && high !== null) {
form.internal.ageRestriction = `${high}岁及以下`
return
}
form.internal.ageRestriction = low === high ? `${low}岁` : `${low}-${high}岁`
}
const onAgeLowChange = () => {
if (ageLow.value === null || ageLow.value === '') {
ageLow.value = null
ageHigh.value = null
form.internal.ageRestriction = ''
return
}
if (ageHigh.value !== null && Number(ageHigh.value) < Number(ageLow.value)) {
ageHigh.value = ageLow.value
}
writeAgeRange()
}
const onAgeHighChange = () => {
if (ageHigh.value === null || ageHigh.value === '') return
writeAgeRange()
}
const clearAgeRange = () => {
ageLow.value = null
ageHigh.value = null
form.internal.ageRestriction = ''
}
const PROBATION_MONTH_OPTIONS = [3, 4, 5, 6]
const normalizeGenderValue = (value) => {
const raw = String(value || '').trim()
if (['不限', '男', '女'].includes(raw)) return raw
if (raw === '男女不限' || raw === '不限性别') return '不限'
return raw
}
const normalizeProbationPeriod = (value) => {
const raw = String(value || '').trim()
const match = raw.match(/^(\d{1,2})\s*个月?$/)
return match ? `${Number(match[1])}个月` : raw
}
const normalizeNonCompeteValue = (value) => {
const raw = String(value || '').trim()
return raw === '不需要' ? '无需签' : raw
}
// 新建岗位必填项:与表单里 required 标记保持一致,缺失时阻止提交并跳到对应 Tab
const REQUIRED_CREATE_FIELDS = [
{ key: 'title', label: '岗位名称', tab: 'base' },
......@@ -212,67 +297,26 @@ const syncForm = () => {
form.startDate = job?.startDate || ''
form.reportTo = job?.reportTo || ''
form.internal = {
genderRestriction: job?.genderRestriction || '',
genderRestriction: normalizeGenderValue(job?.genderRestriction),
ageRestriction: job?.ageRestriction || '',
probationPeriod: job?.probationPeriod || '',
probationPeriod: normalizeProbationPeriod(job?.probationPeriod),
probationCriteria: job?.probationCriteria || '',
nonCompete: job?.nonCompete || '',
nonCompete: normalizeNonCompeteValue(job?.nonCompete),
verification: job?.verification || '',
}
syncSalaryPair()
syncAgePair()
}
syncForm()
watch(() => form.salaryRange, syncSalaryPair)
watch(() => form.internal.ageRestriction, syncAgePair)
const back = () => {
if (existing.value) router.push(`/jobs/${existing.value.id}`)
else router.push('/jobs')
}
// AI 辅助创建:新建岗位直接进访谈逐题问清,完成后自动把采集的结构化岗位信息回填表单。
// 这里返回的是访谈累积的 jd/职责/要求等字段,缺的字段由生成器在后续创建时推断。
const aiAssistCreate = async () => {
if (!form.title.trim()) {
showToast('请先填写岗位名称,AI 才能基于它追问补全', 'warning')
return
}
const baseJob = {
title: form.title,
department: form.department,
recruitType: form.recruitType,
jobType: form.jobType,
headcount: form.headcount,
owner: form.owner,
salaryRange: form.salaryRange,
priority: form.priority,
}
try {
// assist 直接进访谈逐题问清;完成后 store 内部已 force 生成完整 JD 并带回 draft
const result = await store.openJdGrill(baseJob, 'assist')
if (!result) return
const accumulatedJob = result.job || baseJob
const fields = [
['jd', 'jd'],
['responsibilities', 'responsibilities'],
['requirements', 'requirements'],
['mustHave', 'mustHave'],
['niceToHave', 'niceToHave'],
['knockout', 'knockout'],
['matchKeywords', 'matchKeywords'],
['competency', 'competency'],
]
for (const [formKey, draftKey] of fields) {
const value = result[draftKey] ?? accumulatedJob[draftKey]
if (value && !form[formKey]) form[formKey] = value
}
showToast('AI 已补全岗位信息,可直接检查后创建', 'success')
} catch (error) {
if (error?.message === '已取消追问' || error?.message === '已取消生成') return // 用户主动取消,不提示错误
showToast(error.message || 'AI 辅助创建失败', 'error')
}
}
const submit = async (saveMode = 'update') => {
if (!form.title.trim()) {
showToast('请填写岗位名称', 'warning')
......@@ -404,6 +448,16 @@ const copyVersion = (version) => {
const activeTab = ref('base')
const nextStep = () => {
if (activeTab.value === 'base') {
activeTab.value = 'jd'
} else if (activeTab.value === 'jd') {
activeTab.value = 'status'
} else {
submit('update')
}
}
// 需求 9:粘贴 JD 自动结构化 / 需求模板(JdImportModal)
const importOpen = ref(false)
const importMode = ref('paste')
......@@ -459,7 +513,10 @@ const applyParsed = ({ fields = {}, mode = 'fill' }) => {
if (!value) return
const current = String(form.internal[key] || '').trim()
if (mode === 'overwrite' || !current) {
form.internal[key] = value
if (key === 'genderRestriction') form.internal[key] = normalizeGenderValue(value)
else if (key === 'probationPeriod') form.internal[key] = normalizeProbationPeriod(value)
else if (key === 'nonCompete') form.internal[key] = normalizeNonCompeteValue(value)
else form.internal[key] = value
applied += 1
}
})
......@@ -644,14 +701,14 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
</div>
<el-form label-position="top">
<el-form-item label="岗位说明 / JD" required>
<el-input v-model="form.jd" type="textarea" :rows="4" />
<el-input v-model="form.jd" type="textarea" :autosize="{ minRows: 5, maxRows: 28 }" />
</el-form-item>
<div class="form two">
<el-form-item label="核心职责" required>
<el-input v-model="form.responsibilities" type="textarea" :rows="3" />
<el-form-item label="核心职责" required class="jd-long-text">
<el-input v-model="form.responsibilities" type="textarea" :autosize="{ minRows: 9, maxRows: 40 }" />
</el-form-item>
<el-form-item label="任职要求" required>
<el-input v-model="form.requirements" type="textarea" :rows="3" />
<el-form-item label="任职要求" required class="jd-long-text">
<el-input v-model="form.requirements" type="textarea" :autosize="{ minRows: 9, maxRows: 40 }" />
</el-form-item>
<el-form-item label="硬性条件(逗号分隔)">
<el-input v-model="form.mustHave" placeholder="如 本科, 5年经验, 电气专业" />
......@@ -665,8 +722,8 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
<el-form-item label="匹配关键词(逗号分隔)">
<el-input v-model="form.matchKeywords" />
</el-form-item>
<el-form-item label="胜任力框架">
<el-input v-model="form.competency" type="textarea" :rows="3" />
<el-form-item label="胜任力框架" class="jd-span-full">
<el-input v-model="form.competency" type="textarea" :autosize="{ minRows: 4, maxRows: 18 }" />
</el-form-item>
</div>
</el-form>
......@@ -674,27 +731,74 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
<div class="internal-card">
<div class="internal-card-head">
<b>对内约束(不对外发布)</b>
<span>性别 / 年龄 / 试用期 / 竞业 / 验证只做对内执行,对外 JD 由系统自动剥离</span>
<span>性别 / 年龄 / 试用期 / 竞业 / 转正 / 验证只做对内执行,对外 JD 由系统自动剥离</span>
</div>
<el-form label-position="top" class="form two">
<el-form-item label="性别要求">
<el-input v-model="form.internal.genderRestriction" placeholder="不限 / 男 / 女" />
<el-select v-model="form.internal.genderRestriction" clearable placeholder="不限">
<el-option label="不限" value="不限" />
<el-option label="男" value="男" />
<el-option label="女" value="女" />
</el-select>
</el-form-item>
<el-form-item label="年龄要求">
<el-input v-model="form.internal.ageRestriction" placeholder="不限 或 如 30-40" />
<div class="salary-range-field">
<div class="salary-range-pair">
<div class="salary-pick">
<el-select
v-model="ageLow"
class="salary-pick-select"
placeholder="选择下限"
clearable
@change="onAgeLowChange"
>
<el-option v-for="year in AGE_YEAR_OPTIONS" :key="year" :label="`${year}岁`" :value="year" />
</el-select>
</div>
<span class="salary-range-sep"></span>
<div class="salary-pick">
<el-select
v-model="ageHigh"
class="salary-pick-select"
placeholder="选择上限"
clearable
:disabled="ageLow === null"
@change="onAgeHighChange"
>
<el-option v-for="year in AGE_YEAR_OPTIONS" :key="year" :label="`${year}岁`" :value="year" />
</el-select>
</div>
</div>
<div v-if="ageRangeSet" class="salary-range-readout">
<span class="salary-range-value">{{ form.internal.ageRestriction }}</span>
<el-button class="salary-range-clear" link type="primary" size="small" @click="clearAgeRange">
清除
</el-button>
</div>
</div>
</el-form-item>
<el-form-item label="试用期">
<el-input v-model="form.internal.probationPeriod" placeholder="如 3 个月" />
</el-form-item>
<el-form-item label="转正标准">
<el-input v-model="form.internal.probationCriteria" placeholder="转正看什么 / 交付什么" />
<el-select v-model="form.internal.probationPeriod" clearable placeholder="请选择">
<el-option
v-for="month in PROBATION_MONTH_OPTIONS"
:key="month"
:label="`${month}个月`"
:value="`${month}个月`"
/>
</el-select>
</el-form-item>
<el-form-item label="竞业协议">
<el-input v-model="form.internal.nonCompete" placeholder="需签 / 不需要 / 竞对范围说明" />
<el-select v-model="form.internal.nonCompete" clearable placeholder="请选择">
<el-option label="需签" value="需签" />
<el-option label="无需签" value="无需签" />
</el-select>
</el-form-item>
<el-form-item label="入职验证方式">
<el-input v-model="form.internal.verification" placeholder="简历看什么 / 面试怎么验 / 是否背调" />
</el-form-item>
<el-form-item label="转正标准">
<el-input v-model="form.internal.probationCriteria" placeholder="转正看什么 / 交付什么" />
</el-form-item>
</el-form>
</div>
</el-tab-pane>
......@@ -707,16 +811,6 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
<el-option v-for="option in JOB_STATUS_OPTIONS" :key="option" :label="option" :value="option" />
</el-select>
</el-form-item>
<el-form-item label="JD 状态">
<el-select v-model="form.jdStatus">
<el-option v-for="option in JD_STATUS_EDITABLE_OPTIONS" :key="option" :label="option" :value="option" />
</el-select>
<div class="field-hint">「已生效」需在岗位详情页走审批流程,此处不可直选。</div>
</el-form-item>
<el-form-item label="审批状态">
<el-input :model-value="existing?.approvalStatus || APPROVAL_STATUS.NOT_STARTED" readonly />
<div class="field-hint">在岗位详情页「JD 流程」中推进确认与审批。</div>
</el-form-item>
<el-form-item label="当前 JD 版本">
<el-input v-model="form.jdVersion" readonly />
</el-form-item>
......@@ -748,16 +842,14 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
<span>{{ existing ? '需要单独保留修改记录时,再创建新的 JD 版本。' : '' }}</span>
</div>
<div class="actions-btns">
<el-button v-if="!existing" type="primary" plain @click="aiAssistCreate">
<span class="ai-spark"></span> AI 辅助创建
<el-button type="primary" @click="isNew ? nextStep() : submit('update')">
{{ existing ? '保存修改' : activeTab === 'status' ? '创建岗位' : '下一步' }}
</el-button>
<el-button type="primary" @click="submit('update')">{{ existing ? '保存修改' : '创建岗位' }}</el-button>
<el-button v-if="existing" @click="submit('iterate')">保存为新 JD 版本</el-button>
</div>
</div>
</section>
<JdGrillModal />
<JdGeneratePanel />
<JdImportModal v-model="importOpen" :initial-mode="importMode" :job="form" @applied="applyParsed" />
</div>
......@@ -809,6 +901,37 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
}
}
// JD / 对内约束等表单的左右两列布局;内容较长时在窄屏折回单列
.form.two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 18px;
align-items: start;
:deep(.el-form-item) {
min-width: 0;
}
:deep(.el-select),
:deep(.el-input-number) {
width: 100%;
}
:deep(.jd-span-full) {
grid-column: 1 / -1;
}
@media (max-width: 960px) {
grid-template-columns: minmax(0, 1fr);
}
}
.jd-long-text {
:deep(.el-textarea__inner) {
line-height: 1.7;
}
}
.form-note {
margin-top: 16px;
padding: 12px 14px;
......@@ -994,9 +1117,4 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
gap: 8px;
}
}
.ai-spark {
color: var(--violet, #8b5cf6);
margin-right: 2px;
}
</style>
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