Commit 5f6b4553 authored by 李文光's avatar 李文光

fix: 修复快速匹配流程并优化评估报告展示

parent 8351ae19
...@@ -183,20 +183,22 @@ const importBatch = () => { ...@@ -183,20 +183,22 @@ const importBatch = () => {
tag: '新入库', tag: '新入库',
evaluation: '批量上传入库', evaluation: '批量上传入库',
}) })
store.localMatch(candidate.id)
created.push(candidate) created.push(candidate)
store.logEvent('candidate_created', candidate, { store.logEvent('candidate_created', candidate, {
stage: candidate.stage, stage: candidate.stage,
source: candidate.source || '其他', source: candidate.source || '其他',
batch: true, batch: true,
}) })
})
store.candidates.unshift(...created)
store.reconcile()
created.forEach((candidate) => {
store.localMatch(candidate.id)
store.logEvent('local_match_generated', candidate, { store.logEvent('local_match_generated', candidate, {
score: candidate.quickMatchScore || candidate.match || 0, score: candidate.quickMatchScore || candidate.match || 0,
batch: true, batch: true,
}) })
}) })
store.candidates.unshift(...created)
store.reconcile()
store.candidateBatchOpen = false store.candidateBatchOpen = false
store.candidateBatchEntries = [] store.candidateBatchEntries = []
store.candidateBatchParsing = false store.candidateBatchParsing = false
......
...@@ -151,9 +151,9 @@ const submit = async () => { ...@@ -151,9 +151,9 @@ const submit = async () => {
tag: '新入库', tag: '新入库',
evaluation: form.evaluation, evaluation: form.evaluation,
}) })
store.localMatch(candidate.id)
store.candidates.unshift(candidate) store.candidates.unshift(candidate)
store.reconcile() store.reconcile()
store.localMatch(candidate.id)
store.logEvent('candidate_created', candidate, { stage: candidate.stage, source: candidate.source || '其他' }) store.logEvent('candidate_created', candidate, { stage: candidate.stage, source: candidate.source || '其他' })
store.logEvent('local_match_generated', candidate, { score: candidate.quickMatchScore || candidate.match || 0 }) store.logEvent('local_match_generated', candidate, { score: candidate.quickMatchScore || candidate.match || 0 })
store.selectedCandidateId = candidate.id store.selectedCandidateId = candidate.id
......
<script setup> <script setup>
import { computed, reactive, ref } from 'vue' import { computed, reactive } from 'vue'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { candidateWorkflow, workflowStageClass } from '@/utils/task' import { candidateWorkflow, workflowStageClass } from '@/utils/task'
import { candidateScreeningConclusion, quickScore, aiScore } from '@/utils/resume' import { candidateScreeningConclusion, quickScore } from '@/utils/resume'
import { isAiEvaluationReport } from '@/utils/scoring' import { isAiEvaluationReport } from '@/utils/scoring'
import { analysisNeedsRefresh } from '@/utils/analysis' import { analysisNeedsRefresh } from '@/utils/analysis'
import { resumeDisplayName, candidateAge, candidateYears } from '@/utils/candidate' import { resumeDisplayName, candidateAge, candidateYears } from '@/utils/candidate'
import { uid } from '@/utils/normalize' import { uid } from '@/utils/normalize'
import { assessmentDecision, assessmentReportRows, assessmentScoreText } from '@/utils/report'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
const store = useRecruitmentStore() const store = useRecruitmentStore()
...@@ -21,6 +22,10 @@ const detailScore = computed(() => { ...@@ -21,6 +22,10 @@ const detailScore = computed(() => {
return store.resumeDetailMode === 'ai' ? Number(selected.value.analysis?.score || 0) : quickScore(selected.value) return store.resumeDetailMode === 'ai' ? Number(selected.value.analysis?.score || 0) : quickScore(selected.value)
}) })
const scoreText = computed(() => assessmentScoreText(selected.value, store.resumeDetailMode))
const decisionInfo = computed(() => assessmentDecision(selected.value, store.resumeDetailMode))
const reportRows = computed(() => assessmentReportRows(selected.value, store.resumeDetailMode))
const decisionColor = computed(() => const decisionColor = computed(() =>
detailScore.value >= 85 ? 'var(--green)' : detailScore.value >= 75 ? 'var(--yellow)' : 'var(--rose)' detailScore.value >= 85 ? 'var(--green)' : detailScore.value >= 75 ? 'var(--yellow)' : 'var(--rose)'
) )
...@@ -29,7 +34,12 @@ const back = () => { ...@@ -29,7 +34,12 @@ const back = () => {
store.resumeViewMode = 'list' store.resumeViewMode = 'list'
} }
const tabs = ref('overview') const tabs = computed({
get: () => store.resumeDetailSection,
set: (value) => {
store.resumeDetailSection = value
},
})
const generateEval = async (candidate) => { const generateEval = async (candidate) => {
try { try {
...@@ -297,23 +307,42 @@ const saveCorrection = (candidate) => { ...@@ -297,23 +307,42 @@ const saveCorrection = (candidate) => {
<div class="assessment-head"> <div class="assessment-head">
<div> <div>
<span>当前得分</span> <span>当前得分</span>
<strong :style="{ color: decisionColor }">{{ detailScore }}</strong> <strong :style="{ color: decisionColor }">{{ scoreText }}</strong>
</div> </div>
<el-tag size="small">{{ store.resumeDetailMode === 'ai' ? 'AI 评估' : '本地快速匹配' }}</el-tag> <el-tag size="small">{{ store.resumeDetailMode === 'ai' ? 'AI 评估' : '本地快速匹配' }}</el-tag>
</div> </div>
<div class="assessment-decision" :class="`tone-${decisionInfo.tone}`">
<div>
<span>初筛建议</span>
<strong>{{ decisionInfo.text }}</strong>
<p>{{ decisionInfo.note }}</p>
</div>
</div>
<div class="assessment-summary"> <div class="assessment-summary">
<p v-if="selected.analysis?.summary">{{ selected.analysis.summary }}</p> <p v-if="selected.analysis?.summary">{{ selected.analysis.summary }}</p>
<p v-else>快速匹配只做岗位关键词命中和缺口识别,不生成 HR 决策报告。</p> <p v-else>快速匹配只做岗位关键词命中和缺口识别,不生成 HR 决策报告。</p>
</div> </div>
<div v-if="selected.matchReport" class="criteria-grid"> <div v-if="selected.matchReport || selected.analysis" class="assessment-table-wrap">
<div> <el-table :data="reportRows" class="assessment-table">
<b>命中</b> <el-table-column label="维度" width="130">
<p>{{ (selected.matchReport.matched || []).join('、') || '暂无' }}</p> <template #default="{ row }">
</div> <b class="assessment-row-label">{{ row.label }}</b>
<div> </template>
<b>缺口</b> </el-table-column>
<p>{{ (selected.matchReport.missing || []).slice(0, 6).join('、') || '暂无' }}</p> <el-table-column label="内容" min-width="320">
<template #default="{ row }">
<div v-if="row.items.length" class="assessment-items">
<span v-for="(item, index) in row.items" :key="`${row.label}-${index}`">{{ item }}</span>
</div> </div>
<span v-else class="assessment-empty">暂无</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="row.tone" size="small" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
</el-table>
</div> </div>
</div> </div>
</el-tab-pane> </el-tab-pane>
...@@ -560,30 +589,76 @@ const saveCorrection = (candidate) => { ...@@ -560,30 +589,76 @@ const saveCorrection = (candidate) => {
} }
} }
.assessment-decision {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
padding: 14px 16px;
background: var(--bg);
border-left: 4px solid var(--blue);
border-radius: 8px;
span {
display: block;
font-size: 12px;
color: var(--muted);
}
strong {
display: block;
margin: 4px 0;
font-size: 18px;
}
p {
margin: 0;
color: var(--muted);
font-size: 13px;
}
&.tone-success {
border-color: var(--green);
}
&.tone-warning {
border-color: var(--yellow);
}
&.tone-danger {
border-color: var(--rose);
}
}
.assessment-summary { .assessment-summary {
margin-bottom: 12px; margin-bottom: 12px;
color: var(--muted); color: var(--muted);
} }
.criteria-grid { .assessment-table-wrap {
display: grid; margin-top: 4px;
grid-template-columns: repeat(2, 1fr); }
gap: 12px;
> div { .assessment-row-label {
background: var(--bg); font-size: 14px;
border-radius: 8px; }
padding: 12px;
.assessment-items {
display: flex;
flex-direction: column;
gap: 6px;
b { span {
color: var(--muted);
font-size: 13px; font-size: 13px;
line-height: 1.5;
} }
p { }
margin: 6px 0 0;
.assessment-empty {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
}
}
} }
.span-2 { .span-2 {
......
function listItems(value) {
return Array.isArray(value) ? value.filter((item) => String(item || '').trim()) : []
}
export function assessmentScoreText(candidate = {}, mode = 'ai') {
const analysis = mode === 'ai' ? candidate.analysis || {} : {}
const raw =
mode === 'ai' && analysis.score != null
? analysis.score
: (candidate.quickMatchScore ?? candidate.match ?? analysis.score ?? null)
if (raw === null || raw === '' || Number.isNaN(Number(raw))) return '--/100'
return `${Math.round(Number(raw))}/100`
}
export function assessmentDecision(candidate = {}, mode = 'ai') {
const analysis = mode === 'ai' ? candidate.analysis || {} : {}
const hasReport = Boolean(candidate.matchReport || (mode === 'ai' && analysis.score != null))
if (!hasReport) {
return {
text: '暂未生成评估',
tone: 'info',
note: '请先为候选人选择关联岗位,并生成快速匹配或 AI 评估报告。',
}
}
const decisionText = String(analysis.decision || '')
const score = Number(analysis.score ?? candidate.quickMatchScore ?? candidate.match ?? 0)
if (/人才池|暂缓|不建议/.test(decisionText)) {
return {
text: '暂缓,不建议进入初筛',
tone: 'danger',
note: '当前匹配证据不足或存在硬性缺口,建议进入人才池后择机复评。',
}
}
if (/复核/.test(decisionText)) {
return {
text: '建议复核后进入初筛',
tone: 'warning',
note: '总体条件可接受,但存在待确认项,建议 HR 复核后再推进。',
}
}
if (/优先/.test(decisionText)) {
return {
text: '建议进入初筛',
tone: 'success',
note: '匹配证据较充分,可优先安排初筛或面试。',
}
}
if (score >= 85) {
return {
text: '建议进入初筛',
tone: 'success',
note: '匹配度较高,可优先安排初筛或面试。',
}
}
if (score >= 72) {
return {
text: '建议复核后进入初筛',
tone: 'warning',
note: '总体条件可接受,但存在待确认项,建议 HR 复核后再推进。',
}
}
return {
text: '暂缓,不建议进入初筛',
tone: 'danger',
note: '当前匹配证据不足或存在硬性缺口,建议进入人才池后择机复评。',
}
}
export function assessmentReportRows(candidate = {}, mode = 'ai') {
const analysis = mode === 'ai' ? candidate.analysis || {} : {}
const report = candidate.matchReport || {}
const matched = listItems(report.matched)
const missing = listItems(report.missing)
const mustMatched = listItems(report.mustMatched)
const mustMissing = listItems(report.mustMissing)
const niceMatched = listItems(report.niceMatched)
const knockoutHit = listItems(report.knockoutHit)
const analysisBased = Boolean(analysis.reportVersion || analysis.hardMatch || analysis.risks)
const evalItems = []
const breakdown = analysis.scoreBreakdown || {}
const breakdownParts = []
if (breakdown.hardMatchScore != null) breakdownParts.push(`硬性匹配 ${breakdown.hardMatchScore}/35`)
if (breakdown.experienceScore != null) breakdownParts.push(`经验匹配 ${breakdown.experienceScore}/25`)
if (breakdown.evidenceScore != null) breakdownParts.push(`项目证据 ${breakdown.evidenceScore}/20`)
if (breakdown.competencyScore != null) breakdownParts.push(`胜任力 ${breakdown.competencyScore}/10`)
if (breakdown.riskAdjustment != null) breakdownParts.push(`风险调整 ${breakdown.riskAdjustment}`)
if (breakdownParts.length) evalItems.push(`得分拆解:${breakdownParts.join('、')}`)
if (analysisBased) {
evalItems.push(...listItems(analysis.hardMatch).slice(0, 6))
evalItems.push(...listItems(analysis.experienceMatch).slice(0, 3))
evalItems.push(
...listItems(analysis.projectEvidence)
.slice(0, 4)
.map((item) => `项目证据:${item}`)
)
evalItems.push(...listItems(analysis.competencyFrame).slice(0, 6))
} else {
if (matched.length) evalItems.push(`关键词命中:${matched.slice(0, 8).join('、')}`)
if (report.sourceQuality === 'filename_only') evalItems.push('仅解析到简历文件名,正文证据不足')
}
const matchItems = []
if (matched.length) matchItems.push(`岗位关键词命中(${matched.length} 项):${matched.slice(0, 8).join('、')}`)
if (mustMatched.length) matchItems.push(`硬性条件命中:${mustMatched.join('、')}`)
if (niceMatched.length) matchItems.push(`加分项命中:${niceMatched.join('、')}`)
if (report.jobTitleMatched) matchItems.push('岗位标题匹配')
if (report.sourceQuality === 'filename_only') matchItems.push('仅有简历文件名,缺少正文匹配证据')
matchItems.push(...listItems(analysis.strengths).slice(0, 4))
if (!matchItems.length) matchItems.push('暂无匹配点')
const riskItems = []
const risks = listItems(analysis.risks)
if (risks.length) {
riskItems.push(...risks.slice(0, 6))
} else {
if (knockoutHit.length) riskItems.push(`触发淘汰项:${knockoutHit.join('、')}`)
if (mustMissing.length) riskItems.push(`硬性条件缺口:${mustMissing.join('、')}`)
if (missing.length) riskItems.push(`简历暂未明确:${missing.slice(0, 6).join('、')}`)
}
if (!riskItems.length) riskItems.push('暂无明确风险或待确认项')
let questionItems = listItems(analysis.interviewQuestions).slice(0, 5)
if (!questionItems.length && missing.length) {
questionItems = [`建议初筛时确认:${missing.slice(0, 4).join('、')}`]
}
const hasRisk = Boolean(knockoutHit.length || mustMissing.length || risks.length)
return [
{
label: '评估点',
items: evalItems,
status: evalItems.length ? '评估依据' : '待补充',
tone: 'info',
},
{
label: '匹配点',
items: matchItems,
status: matched.length ? `命中 ${matched.length} 项` : '待补强',
tone: matched.length ? 'success' : 'warning',
},
{
label: '风险/待确认点',
items: riskItems,
status: hasRisk ? '需关注' : '暂无',
tone: hasRisk ? 'danger' : 'success',
},
{
label: '面试确认点',
items: questionItems,
status: questionItems.length ? '待核实' : '无需确认',
tone: questionItems.length ? 'warning' : 'info',
},
]
}
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