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

feat: 候选人详情抽屉与档案编辑,重构概览及导航侧栏

parent 810e2805
...@@ -47,65 +47,139 @@ textarea { ...@@ -47,65 +47,139 @@ textarea {
position: sticky; position: sticky;
top: 0; top: 0;
height: 100vh; height: 100vh;
padding: 18px 14px; padding: 16px 12px;
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
gap: 18px; gap: 14px;
background: #111722; background: linear-gradient(180deg, #1a2340 0%, #0f1526 100%);
color: #fff; color: #fff;
.brand { .brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px; padding: 6px 8px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
.brand-mark { .brand-mark {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 34px; width: 38px;
height: 34px; height: 38px;
border-radius: 9px; border-radius: 11px;
background: var(--blue); background: linear-gradient(135deg, var(--blue), var(--cyan));
color: #fff; color: #fff;
font-weight: 700; font-weight: 700;
font-size: 15px; font-size: 15px;
box-shadow: 0 6px 16px rgba(69, 105, 201, 0.4);
letter-spacing: 0.5px;
} }
strong { .brand-copy {
display: block; min-width: 0;
font-size: 16px;
}
span { strong {
display: block; display: block;
font-size: 12px; font-size: 16px;
color: rgba(255, 255, 255, 0.5); font-weight: 600;
letter-spacing: 0.5px;
}
.brand-sub {
display: flex;
align-items: center;
gap: 6px;
margin-top: 3px;
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
}
} }
} }
.nav { .nav {
min-height: 0;
overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 14px;
padding-right: 2px;
.nav-group {
display: flex;
flex-direction: column;
gap: 3px;
}
.nav-group-title {
font-size: 10px;
letter-spacing: 2px;
color: rgba(255, 255, 255, 0.4);
padding: 0 8px;
margin-bottom: 4px;
}
} }
.side-actions { .side-foot {
display: flex; border-top: 1px solid rgba(255, 255, 255, 0.08);
flex-direction: column; padding-top: 12px;
gap: 8px;
align-items: stretch;
.el-button { .side-foot-title {
margin-left: 0; font-size: 10px;
width: 100%; letter-spacing: 2px;
color: rgba(255, 255, 255, 0.4);
padding: 0 8px;
margin-bottom: 8px;
} }
.import-input { .data-actions {
display: none; display: flex;
flex-direction: column;
gap: 6px;
.data-btn {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 9px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease;
.el-icon {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
}
&:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(117, 195, 221, 0.4);
color: #fff;
.el-icon {
color: var(--cyan);
}
}
&:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 1px;
}
}
} }
} }
.import-input {
display: none;
}
} }
.main { .main {
...@@ -116,60 +190,113 @@ textarea { ...@@ -116,60 +190,113 @@ textarea {
.top { .top {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
padding: 20px 28px; padding: 14px 28px;
background: var(--panel); background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(14px) saturate(1.2);
-webkit-backdrop-filter: blur(14px) saturate(1.2);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
h1 { h1 {
margin: 0 0 4px; margin: 0 0 3px;
font-size: 22px; font-size: 20px;
} }
p { p {
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);
font-size: 14px; font-size: 13px;
} }
.top-meta { .top-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 14px;
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 12px;
strong { .save-status {
color: var(--ink); display: inline-flex;
align-items: center;
gap: 7px;
}
.today {
font-variant-numeric: tabular-nums;
} }
.workspace-switch { .workspace-switch {
width: 130px; width: 132px;
} }
.user-entry { .user-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
cursor: pointer; padding: 5px 10px 5px 6px;
padding: 4px 8px; border: 1px solid var(--line);
border-radius: 8px; border-radius: 999px;
background: #fff;
color: var(--ink); color: var(--ink);
cursor: pointer;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease;
&:hover { &:hover {
background: var(--line); border-color: var(--blue);
box-shadow: 0 4px 14px rgba(88, 116, 200, 0.18);
} }
.user-name { &:focus-visible {
max-width: 140px; outline: 2px solid var(--cyan);
overflow: hidden; outline-offset: 1px;
text-overflow: ellipsis; }
white-space: nowrap;
.user-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, var(--blue), var(--cyan));
color: #fff;
font-size: 13px;
font-weight: 600;
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.55);
}
.user-chip-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.2;
strong {
font-size: 13px;
color: var(--ink);
max-width: 130px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
em {
font-style: normal;
font-size: 10px;
color: var(--muted);
}
}
.user-chevron {
font-size: 12px;
color: var(--muted);
} }
} }
} }
...@@ -181,6 +308,17 @@ textarea { ...@@ -181,6 +308,17 @@ textarea {
min-height: 0; min-height: 0;
} }
// 通用状态圆点(侧栏品牌 / 顶栏保存状态)
.status-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 0 3px rgba(99, 200, 155, 0.18);
flex-shrink: 0;
}
// 通用卡片式容器 // 通用卡片式容器
.panel { .panel {
padding: 22px; padding: 22px;
......
...@@ -4,40 +4,102 @@ defineProps({ ...@@ -4,40 +4,102 @@ defineProps({
active: { type: Boolean, default: false }, active: { type: Boolean, default: false },
count: { type: Number, default: 0 }, count: { type: Number, default: 0 },
}) })
defineEmits(['click'])
</script> </script>
<template> <template>
<button type="button" class="nav-item" :class="{ active }" @click="$emit('click')"> <button type="button" class="nav-item" :class="{ active }" @click="$emit('click')">
<span>{{ item.label }}</span> <span class="nav-icon">
<span class="count">{{ count }}</span> <el-icon><component :is="item.icon" /></el-icon>
</span>
<span class="nav-label">{{ item.label }}</span>
<span v-if="count" class="nav-count">{{ count }}</span>
</button> </button>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.nav-item { .nav-item {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 10px;
gap: 8px;
width: 100%; width: 100%;
padding: 10px 12px; padding: 9px 11px;
border-radius: 8px; border: 0;
border-radius: 10px;
background: transparent; background: transparent;
color: rgba(255, 255, 255, 0.72); color: rgba(255, 255, 255, 0.66);
text-align: left; text-align: left;
cursor: pointer;
transition:
background 0.18s ease,
color 0.18s ease,
transform 0.12s ease;
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
font-size: 17px;
flex-shrink: 0;
color: rgba(255, 255, 255, 0.78);
transition: color 0.18s ease;
}
.nav-label {
flex: 1;
min-width: 0;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-count {
min-width: 22px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: rgba(255, 255, 255, 0.85);
font-size: 11px;
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&:hover { &:hover {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
color: #fff;
.nav-icon {
color: #fff;
}
}
&:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 1px;
} }
&.active { &.active {
background: var(--blue); background: linear-gradient(120deg, rgba(88, 116, 200, 0.95), rgba(117, 195, 221, 0.82));
color: #fff; color: #fff;
} box-shadow: 0 6px 18px rgba(69, 105, 201, 0.35);
.nav-icon {
color: #fff;
}
.count { .nav-count {
font-size: 12px; background: rgba(255, 255, 255, 0.24);
opacity: 0.8; color: #fff;
}
} }
} }
</style> </style>
<script setup> <script setup>
import { computed, reactive } from 'vue' import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import http from '@/api'
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 } from '@/utils/resume' import { candidateScreeningConclusion, quickScore } from '@/utils/resume'
...@@ -9,9 +11,12 @@ import { analysisNeedsRefresh } from '@/utils/analysis' ...@@ -9,9 +11,12 @@ 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 { assessmentDecision, assessmentReportRows, assessmentScoreText } from '@/utils/report'
import { getFilteredCandidates } from '@/utils/matching'
import { jobCandidates } from '@/utils/links'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
const store = useRecruitmentStore() const store = useRecruitmentStore()
const route = useRoute()
const selected = computed(() => store.selectedCandidate || store.candidates[0] || null) const selected = computed(() => store.selectedCandidate || store.candidates[0] || null)
...@@ -26,14 +31,174 @@ const scoreText = computed(() => assessmentScoreText(selected.value, store.resum ...@@ -26,14 +31,174 @@ const scoreText = computed(() => assessmentScoreText(selected.value, store.resum
const decisionInfo = computed(() => assessmentDecision(selected.value, store.resumeDetailMode)) const decisionInfo = computed(() => assessmentDecision(selected.value, store.resumeDetailMode))
const reportRows = computed(() => assessmentReportRows(selected.value, store.resumeDetailMode)) const reportRows = computed(() => assessmentReportRows(selected.value, store.resumeDetailMode))
const scoreNumber = computed(() => {
const value = detailScore.value
return value ? Math.round(value) : '--'
})
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)'
) )
const back = () => { const stageTone = computed(() => {
const stage = selected.value?.stage || ''
if (['已入职', '待入职'].includes(stage)) return 'success'
if (['面试中', 'Offer 中'].includes(stage)) return 'warning'
if (['初筛未通过'].includes(stage)) return 'danger'
return 'info'
})
// ---- 抽屉开关 ----
const drawerModel = computed({
get: () => store.resumeViewMode === 'detail',
set: (value) => {
if (!value) store.resumeViewMode = 'list'
},
})
const close = () => {
store.resumeViewMode = 'list' store.resumeViewMode = 'list'
} }
// ---- 上一个 / 下一个候选人(跟随列表当前筛选范围)----
const drawerCandidates = computed(() => {
const base = getFilteredCandidates(store.candidates, store.resumeFilters)
const scopeJob = store.jobs.find((job) => job.id === String(route.query.job || '')) || null
return scopeJob ? jobCandidates(scopeJob, base) : base
})
const currentIndex = computed(() => drawerCandidates.value.findIndex((c) => c.id === selected.value?.id))
const canPrev = computed(() => drawerCandidates.value.length > 1 && currentIndex.value > 0)
const canNext = computed(
() =>
drawerCandidates.value.length > 1 &&
currentIndex.value >= 0 &&
currentIndex.value < drawerCandidates.value.length - 1
)
const positionText = computed(() => {
const total = drawerCandidates.value.length
const index = currentIndex.value
return total ? `${index + 1} / ${total}` : ''
})
const stepCandidate = (dir) => {
const list = drawerCandidates.value
if (!list.length) return
const index = currentIndex.value
const nextIndex = (index + dir + list.length) % list.length
const candidate = list[nextIndex]
if (!candidate) return
store.selectedCandidateId = candidate.id
showToast(`${dir > 0 ? '下一个' : '上一个'}${candidate.name}`, 'info')
}
const onKeydown = (event) => {
const tag = document.activeElement?.tagName?.toLowerCase() || ''
if (['input', 'textarea', 'select'].includes(tag)) return
if (event.key === 'ArrowLeft') {
event.preventDefault()
stepCandidate(-1)
} else if (event.key === 'ArrowRight') {
event.preventDefault()
stepCandidate(1)
}
}
watch(
() => store.resumeViewMode === 'detail',
(open) => {
if (open) window.addEventListener('keydown', onKeydown)
else window.removeEventListener('keydown', onKeydown)
}
)
watch(selected, (candidate) => {
if (!candidate && store.resumeViewMode === 'detail') store.resumeViewMode = 'list'
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
revokeResumeUrl()
})
// ---- 评分圆环 ----
const RING_RADIUS = 38
const ringCircumference = 2 * Math.PI * RING_RADIUS
const ringDrawn = ref(false)
const ringOffset = computed(() => {
const score = Math.max(0, Math.min(100, detailScore.value || 0))
return ringDrawn.value ? ringCircumference * (1 - score / 100) : ringCircumference
})
const onDrawerOpened = () => {
ringDrawn.value = true
}
watch(drawerModel, (open) => {
if (!open) ringDrawn.value = false
})
// ---- 简历 PDF 预览(带鉴权拉取 → blob URL)----
const dockOpen = ref(false)
const resumeSrc = ref('')
let resumeObjectUrl = ''
function revokeResumeUrl() {
if (resumeObjectUrl) {
URL.revokeObjectURL(resumeObjectUrl)
resumeObjectUrl = ''
}
resumeSrc.value = ''
}
async function syncResumeSrc(url) {
revokeResumeUrl()
if (!url) return
try {
if (url.startsWith('data:')) {
const response = await fetch(url)
resumeObjectUrl = URL.createObjectURL(await response.blob())
} else {
const response = await http.get(url, { responseType: 'blob' })
const blob =
response.data instanceof Blob ? response.data : new Blob([response.data], { type: 'application/pdf' })
resumeObjectUrl = URL.createObjectURL(blob)
}
resumeSrc.value = resumeObjectUrl
} catch {
resumeSrc.value = ''
}
}
watch(
() => selected.value?.resumeFileDataUrl || '',
(url) => {
syncResumeSrc(url)
},
{ immediate: true }
)
const fileMetaText = computed(() => {
const meta = selected.value?.resumeMeta
if (meta?.size) return `${meta.type || '简历文件'} · ${formatFileSize(meta.size)}`
return selected.value?.resumeName ? '原始简历文件' : '未上传简历文件'
})
function formatFileSize(bytes) {
const value = Number(bytes || 0)
if (!value) return ''
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
const openResumeInNewTab = () => {
if (resumeSrc.value) window.open(resumeSrc.value, '_blank')
}
// ---- 标签页 ----
const tabs = computed({ const tabs = computed({
get: () => store.resumeDetailSection, get: () => store.resumeDetailSection,
set: (value) => { set: (value) => {
...@@ -41,7 +206,12 @@ const tabs = computed({ ...@@ -41,7 +206,12 @@ const tabs = computed({
}, },
}) })
// ---- 动作 ----
const evaluating = ref(false)
const generateEval = async (candidate) => { const generateEval = async (candidate) => {
if (evaluating.value) return
evaluating.value = true
try { try {
if (!store.localMatch(candidate.id)) throw new Error('请先为候选人选择并确认关联岗位') if (!store.localMatch(candidate.id)) throw new Error('请先为候选人选择并确认关联岗位')
const previousAnalysis = candidate.analysis const previousAnalysis = candidate.analysis
...@@ -61,6 +231,8 @@ const generateEval = async (candidate) => { ...@@ -61,6 +231,8 @@ const generateEval = async (candidate) => {
showToast('AI 评估报告已生成') showToast('AI 评估报告已生成')
} catch (error) { } catch (error) {
showToast(error.message, 'error') showToast(error.message, 'error')
} finally {
evaluating.value = false
} }
} }
...@@ -104,6 +276,117 @@ const poolCandidate = (candidate) => { ...@@ -104,6 +276,117 @@ const poolCandidate = (candidate) => {
showToast('候选人已移入人才池') showToast('候选人已移入人才池')
} }
// ---- 概览档案卡:预览 / 编辑双态 ----
const editMode = ref(false)
const overviewSections = computed(() => {
const c = selected.value
if (!c) return { groups: [], skills: [] }
const val = (v) => String(v || '').trim()
const groups = [
{
key: 'contact',
title: '联系',
rows: [
{ label: '姓名', value: val(c.name), model: 'name', editable: true, preview: false },
{ label: '电话', value: val(c.phone), model: 'phone', editable: true },
{ label: '邮箱', value: val(c.email), model: 'email', editable: true },
],
},
{
key: 'basic',
title: '基础条件',
rows: [
{ label: '出生日期', value: val(c.birthDate), model: '', editable: false },
{ label: '年龄', value: val(candidateAge(c)), model: 'age', editable: true },
{ label: '工作年限', value: val(candidateYears(c)), model: 'years', editable: true },
{ label: '学历', value: val(c.education), model: 'education', editable: true },
],
},
{
key: 'education',
title: '教育背景',
rows: [
{ label: '学校', value: val(c.school), model: 'school', editable: true },
{ label: '专业', value: val(c.major), model: 'major', editable: true },
{
label: '学校标签',
value: (c.schoolTags || []).join('、'),
model: 'schoolTags',
editable: true,
preview: false,
},
],
},
]
return { groups, skills: (c.skills || []).filter(Boolean) }
})
const overviewVisibleRows = (section) =>
editMode.value ? section.rows.filter((row) => row.editable) : section.rows.filter((row) => row.preview !== false)
const enterEditMode = async () => {
const c = selected.value
if (!c) return
Object.assign(profileCorrection, {
name: c.name || '',
phone: c.phone || '',
email: c.email || '',
age: c.age || '',
years: c.years || '',
education: c.education || '',
school: c.school || '',
major: c.major || '',
schoolTags: (c.schoolTags || []).join('、'),
skills: (c.skills || []).join('、'),
})
editMode.value = true
store.resumeDetailSection = 'overview'
await nextTick()
const firstInput = document.querySelector('.overview-card .el-input input')
firstInput?.focus()
showToast('正在编辑基础信息,保存后自动重算快筛', 'info')
}
const cancelEdit = () => {
editMode.value = false
}
const profileCorrection = reactive({
name: '',
phone: '',
email: '',
age: '',
years: '',
education: '',
school: '',
major: '',
schoolTags: '',
skills: '',
})
const saveCorrection = (candidate) => {
const school = profileCorrection.school.trim()
const manualSchoolTags = profileCorrection.schoolTags.split(/[、,,\s]+/).filter(Boolean)
Object.assign(candidate, {
name: profileCorrection.name || candidate.name,
phone: profileCorrection.phone.trim(),
email: profileCorrection.email.trim(),
age: profileCorrection.age.trim(),
years: profileCorrection.years.trim(),
education: profileCorrection.education.trim(),
school,
major: profileCorrection.major.trim(),
schoolTags: manualSchoolTags.length ? manualSchoolTags : candidate.schoolTags || [],
skills: profileCorrection.skills.split(/[、,,\s]+/).filter(Boolean),
})
store.localMatch(candidate.id)
store.persist('候选人基础字段已修正')
editMode.value = false
showToast('字段已保存,并已重新快筛')
}
// ---- 面试记录 ----
const interviewForm = reactive({ const interviewForm = reactive({
round: '一面', round: '一面',
interviewer: '', interviewer: '',
...@@ -153,427 +436,1022 @@ const saveInterview = (candidate) => { ...@@ -153,427 +436,1022 @@ const saveInterview = (candidate) => {
store.persist('面试评价已保存') store.persist('面试评价已保存')
showToast('面试评价已保存') showToast('面试评价已保存')
} }
</script>
<template>
<el-drawer
v-model="drawerModel"
class="candidate-drawer"
direction="rtl"
:size="'min(1180px, 96vw)'"
:with-header="false"
:close-on-click-modal="true"
@opened="onDrawerOpened"
>
<div v-if="selected" class="dossier">
<header class="dossier-head">
<div class="head-left">
<el-button class="head-back" text circle aria-label="返回候选人列表" title="返回候选人列表" @click="close">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<div class="head-identity">
<div class="head-title">
<h3>{{ selected.name || '未命名候选人' }}</h3>
<span class="head-job">{{ selected.jobTitle || '待匹配岗位' }}</span>
</div>
<div class="head-meta">
<el-tag size="small" type="info" effect="plain">{{ selected.source || '其他' }}</el-tag>
<el-tag size="small" :type="stageTone" effect="plain">{{ selected.stage || '未筛选' }}</el-tag>
<span class="head-filename">{{ resumeDisplayName(selected) }}</span>
</div>
</div>
</div>
<div class="head-actions">
<div class="nav-stack">
<el-button
text
circle
:disabled="!canPrev"
aria-label="上一个候选人"
title="上一个候选人(←)"
@click="stepCandidate(-1)"
>
<el-icon><ArrowLeft /></el-icon>
</el-button>
<span class="nav-position">{{ positionText }}</span>
<el-button
text
circle
:disabled="!canNext"
aria-label="下一个候选人"
title="下一个候选人(→)"
@click="stepCandidate(1)"
>
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
<el-button size="small" @click="enterEditMode">修正字段</el-button>
<el-button size="small" type="primary" circle aria-label="关闭" title="关闭(Esc)" @click="close">
<el-icon><Close /></el-icon>
</el-button>
</div>
</header>
<div class="dossier-body">
<aside class="resume-dock" :class="{ collapsed: !dockOpen }">
<div v-if="dockOpen" class="dock-panel">
<div class="dock-head">
<div class="dock-file">
<el-icon class="dock-file-icon"><Document /></el-icon>
<div class="dock-file-copy">
<strong :title="resumeDisplayName(selected)">{{ resumeDisplayName(selected) }}</strong>
<span>{{ fileMetaText }}</span>
</div>
</div>
<div class="dock-tools">
<el-tooltip content="在新标签打开简历" placement="top" :disabled="!resumeSrc">
<el-button
text
circle
:disabled="!resumeSrc"
aria-label="在新标签打开简历"
@click="openResumeInNewTab"
>
<el-icon><FullScreen /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="收起简历" placement="top">
<el-button text circle aria-label="收起简历" @click="dockOpen = false">
<el-icon><Fold /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
<div class="dock-body">
<iframe v-if="resumeSrc" :src="resumeSrc" class="dock-frame" title="简历预览" />
<div v-else class="dock-text">
<pre>{{ selected.resumeText || '未上传简历文件,暂无正文可预览。' }}</pre>
</div>
</div>
</div>
<button v-else type="button" class="dock-rail" @click="dockOpen = true">
<el-icon class="rail-icon"><Document /></el-icon>
<span class="rail-label">{{ resumeDisplayName(selected) }}</span>
<span class="rail-hint">展开预览</span>
</button>
</aside>
<section class="dossier-content">
<div class="summary-strip" :class="`tone-${decisionInfo.tone}`">
<div class="score-block">
<svg class="score-ring" viewBox="0 0 88 88" :style="{ '--ring': decisionColor }" aria-hidden="true">
<circle class="ring-track" cx="44" cy="44" r="38" />
<circle
class="ring-value"
cx="44"
cy="44"
r="38"
:stroke-dasharray="ringCircumference"
:stroke-dashoffset="ringOffset"
/>
</svg>
<div class="score-copy">
<span class="score-label">当前得分</span>
<strong class="score-value">{{ scoreNumber }}<small>/100</small></strong>
<span class="score-mode">{{ store.resumeDetailMode === 'ai' ? 'AI 评估' : '本地快筛' }}</span>
</div>
</div>
<div class="decision-block">
<span class="eyebrow">初筛建议</span>
<strong class="decision-text">{{ decisionInfo.text }}</strong>
<p class="note-text">{{ decisionInfo.note }}</p>
</div>
<div class="next-block">
<span class="eyebrow">当前节点</span>
<strong class="next-title">{{ candidateScreeningConclusion(selected, store.state) }}</strong>
<p class="note-text">{{ flow?.next?.desc || '查看候选人当前流程状态。' }}</p>
</div>
</div>
<div v-if="flow" class="workflow-steps">
<div
v-for="(stage, index) in flow.stages"
:key="stage"
class="workflow-step"
:class="workflowStageClass(index, flow.currentIndex)"
>
<span class="step-dot">{{ index + 1 }}</span>
<b>{{ stage }}</b>
</div>
</div>
const profileFields = computed(() => { <div class="dossier-tabs">
if (!selected.value) return [] <el-tabs v-model="tabs">
return [ <el-tab-pane label="概览" name="overview">
{ label: '出生日期', value: selected.value.birthDate || '未识别' }, <div class="overview-card" :class="{ editing: editMode }">
{ label: '年龄', value: candidateAge(selected.value) || '未识别' }, <div class="overview-head">
{ label: '年限', value: candidateYears(selected.value) || '未识别' }, <div class="overview-title">
{ label: '学历', value: selected.value.education || '未识别' }, <span class="ov-eyebrow">候选人档案 · 已提取字段</span>
{ label: '学校', value: selected.value.school || '未识别' }, <h4>基本信息</h4>
{ label: '专业', value: selected.value.major || '未识别' }, </div>
{ label: '电话', value: selected.value.phone || '未识别' }, <div class="overview-actions">
{ label: '邮箱', value: selected.value.email || '未识别' }, <template v-if="editMode">
{ label: '技能', value: (selected.value.skills || []).join('、') || '未识别' }, <el-button size="small" @click="cancelEdit">取消</el-button>
] <el-button size="small" type="primary" @click="saveCorrection(selected)">
}) <el-icon class="btn-icon"><Check /></el-icon>
保存修正
</el-button>
</template>
<el-button v-else size="small" plain @click="enterEditMode">
<el-icon class="btn-icon"><EditPen /></el-icon>
编辑
</el-button>
</div>
</div>
const profileCorrection = reactive({ <div v-if="editMode" class="edit-banner">
name: '', <el-icon><EditPen /></el-icon>
phone: '', <span>正在编辑基础信息,保存后将自动重新计算本地快筛。</span>
email: '', </div>
age: '',
years: '',
education: '',
school: '',
major: '',
schoolTags: '',
skills: '',
})
const openCorrection = () => { <section v-for="section in overviewSections.groups" :key="section.key" class="ov-section">
const c = selected.value <div class="ov-section-title">
if (!c) return <span class="ov-section-mark"></span>
Object.assign(profileCorrection, { <span>{{ section.title }}</span>
name: c.name || '', </div>
phone: c.phone || '', <div class="ov-grid">
email: c.email || '', <div
age: c.age || '', v-for="row in overviewVisibleRows(section)"
years: c.years || '', :key="row.label"
education: c.education || '', class="ov-cell"
school: c.school || '', :class="{ 'is-empty': !row.value, 'is-editing': editMode }"
major: c.major || '', >
schoolTags: (c.schoolTags || []).join('、'), <span class="ov-cell-label">{{ row.label }}</span>
skills: (c.skills || []).join('、'), <el-input
}) v-if="editMode"
showToast('可在下方修正候选人基础字段', 'info') v-model="profileCorrection[row.model]"
size="small"
:placeholder="`填写${row.label}`"
/>
<strong v-else class="ov-cell-value">{{ row.value || '未识别' }}</strong>
</div>
</div>
</section>
<section class="ov-section ov-skills">
<div class="ov-section-title">
<span class="ov-section-mark"></span>
<span>技能栈</span>
</div>
<div v-if="editMode" class="ov-cell is-editing">
<span class="ov-cell-label">技能</span>
<el-input v-model="profileCorrection.skills" size="small" placeholder="技能(、或空格分隔)" />
</div>
<div v-else class="skill-chips">
<span v-for="skill in overviewSections.skills" :key="skill" class="skill-chip">{{ skill }}</span>
<span v-if="!overviewSections.skills.length" class="skill-empty">
暂无技能信息,可点击右上角「编辑」补充
</span>
</div>
</section>
</div>
<div class="resume-text-box">
<div class="resume-text-head">
<span class="ov-eyebrow">正文来源</span>
<h4>简历正文</h4>
</div>
<el-input :model-value="selected.resumeText || '暂无正文'" type="textarea" :rows="5" readonly />
</div>
</el-tab-pane>
<el-tab-pane label="评估" name="assessment">
<div class="assessment-panel">
<div class="assessment-head">
<div>
<span>当前得分</span>
<strong :style="{ color: decisionColor }">{{ scoreText }}</strong>
</div>
<el-tag size="small">{{ store.resumeDetailMode === 'ai' ? 'AI 评估' : '本地快速匹配' }}</el-tag>
</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">
<p v-if="selected.analysis?.summary">{{ selected.analysis.summary }}</p>
<p v-else>快速匹配只做岗位关键词命中和缺口识别,不生成 HR 决策报告。</p>
</div>
<div v-if="selected.matchReport || selected.analysis" class="assessment-table-wrap">
<el-table :data="reportRows" class="assessment-table">
<el-table-column label="维度" width="130">
<template #default="{ row }">
<b class="assessment-row-label">{{ row.label }}</b>
</template>
</el-table-column>
<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>
<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>
</el-tab-pane>
<el-tab-pane label="面试" name="interview">
<div class="interview-panel">
<el-form label-position="top" class="form two">
<el-form-item label="轮次">
<el-select v-model="interviewForm.round">
<el-option label="一面" value="一面" />
<el-option label="二面" value="二面" />
</el-select>
</el-form-item>
<el-form-item label="面试官">
<el-input v-model="interviewForm.interviewer" />
</el-form-item>
<el-form-item label="时间">
<el-input v-model="interviewForm.scheduledAt" placeholder="如 06-20 14:00" />
</el-form-item>
<el-form-item label="形式">
<el-select v-model="interviewForm.format">
<el-option label="线上" value="线上" />
<el-option label="线下" value="线下" />
</el-select>
</el-form-item>
<el-form-item label="结果">
<el-select v-model="interviewForm.result">
<el-option label="待反馈" value="待反馈" />
<el-option label="通过" value="通过" />
<el-option label="不通过" value="不通过" />
</el-select>
</el-form-item>
<el-form-item label="下一步">
<el-select v-model="interviewForm.nextStep">
<el-option label="继续面试" value="继续面试" />
<el-option label="推进 Offer" value="推进 Offer" />
</el-select>
</el-form-item>
<el-form-item label="面试评价" class="span-2">
<el-input v-model="interviewForm.feedback" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<el-button type="primary" @click="saveInterview(selected)">保存面试记录</el-button>
<div v-if="selected.interviews && selected.interviews.length" class="interview-list">
<div v-for="interview in selected.interviews" :key="interview.id" class="interview-item">
<strong>{{ interview.round }} · {{ interview.interviewer || '待定' }}</strong>
<span>{{ interview.scheduledAt || '时间待定' }} · {{ interview.format }}</span>
<el-tag size="small">{{ interview.result || '待反馈' }}</el-tag>
<p>{{ interview.feedback || '' }}</p>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="简历文本" name="resume">
<div class="resume-text-box">
<el-input :model-value="selected.resumeText || '暂无正文'" type="textarea" :rows="10" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</section>
</div>
<footer class="dossier-footer">
<div class="footer-note">
<span class="footer-dot"></span>
<span>下一步建议:{{ flow?.next?.title || '查看候选人详情' }}</span>
</div>
<div class="footer-actions">
<el-button @click="runLocalMatchAction(selected)">快速匹配</el-button>
<el-button type="primary" :loading="evaluating" @click="generateEval(selected)">生成 AI 评估报告</el-button>
<el-button @click="advanceStage(selected, '面试中')">推进面试</el-button>
<el-button class="pool-btn" @click="poolCandidate(selected)">移入人才池</el-button>
</div>
</footer>
</div>
</el-drawer>
</template>
<style lang="scss" scoped>
// el-drawer 根节点由子组件渲染,不带本组件 data-v 属性,需用 :global() 覆盖
:global(.candidate-drawer) {
--dossier-bg: #f4f7fc;
--dossier-accent: #4569c9;
--dossier-accent-soft: rgba(69, 105, 201, 0.1);
--dossier-line: rgba(24, 32, 51, 0.08);
display: flex;
flex-direction: column;
background: var(--dossier-bg);
box-shadow: -24px 0 60px rgba(24, 32, 51, 0.16);
} }
const saveCorrection = (candidate) => { :global(.candidate-drawer .el-drawer__body) {
const school = profileCorrection.school.trim() padding: 0;
const manualSchoolTags = profileCorrection.schoolTags.split(/[、,,\s]+/).filter(Boolean) flex: 1;
Object.assign(candidate, { min-height: 0;
name: profileCorrection.name || candidate.name, display: flex;
phone: profileCorrection.phone.trim(), flex-direction: column;
email: profileCorrection.email.trim(), overflow: hidden;
age: profileCorrection.age.trim(),
years: profileCorrection.years.trim(),
education: profileCorrection.education.trim(),
school,
major: profileCorrection.major.trim(),
schoolTags: manualSchoolTags.length ? manualSchoolTags : candidate.schoolTags || [],
skills: profileCorrection.skills.split(/[、,,\s]+/).filter(Boolean),
})
store.localMatch(candidate.id)
store.persist('候选人基础字段已修正')
showToast('字段已保存,并已重新快筛')
} }
</script>
<template> .dossier {
<div class="candidate-detail-wrap"> display: flex;
<div class="subpage-head"> flex-direction: column;
<el-button @click="back">返回候选人列表</el-button> height: 100%;
<div> min-height: 0;
<h3>{{ selected?.name || '候选人' }} · {{ selected?.jobTitle || '待匹配岗位' }}</h3> background: var(--dossier-bg);
<p>{{ selected?.source || '其他' }} · {{ selected?.stage || '未筛选' }}</p> }
</div>
<el-button @click="openCorrection">修正字段</el-button>
</div>
<section v-if="selected && flow" class="card candidate-detail-card"> // ---- 头部(玻璃拟态)----
<div class="profile-head"> .dossier-head {
<div class="profile-avatar">{{ selected.name.slice(0, 1) || '候' }}</div> display: flex;
<div> align-items: center;
<div class="profile-meta"> justify-content: space-between;
<el-tag type="info" size="small">{{ selected.source || '其他' }}</el-tag> gap: 16px;
<el-tag size="small">{{ selected.stage || '未筛选' }}</el-tag> padding: 14px 18px;
<span class="muted">{{ resumeDisplayName(selected) }}</span> background: rgba(255, 255, 255, 0.72);
</div> backdrop-filter: blur(18px) saturate(1.4);
</div> -webkit-backdrop-filter: blur(18px) saturate(1.4);
</div> border-bottom: 1px solid var(--dossier-line);
<div class="workflow-steps"> .head-left {
<div display: flex;
v-for="(stage, index) in flow.stages" align-items: center;
:key="stage" gap: 12px;
class="workflow-step" min-width: 0;
:class="workflowStageClass(index, flow.currentIndex)" }
>
<span>{{ index + 1 }}</span> .head-back {
<b>{{ stage }}</b> flex-shrink: 0;
</div> }
</div>
.head-identity {
min-width: 0;
}
.head-title {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
h3 {
margin: 0;
font-size: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.head-job {
color: var(--muted);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.head-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
.head-filename {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 260px;
}
}
.head-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.nav-stack {
display: flex;
align-items: center;
gap: 2px;
margin-right: 6px;
padding-right: 10px;
border-right: 1px solid var(--dossier-line);
.nav-position {
font-size: 12px;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-width: 44px;
text-align: center;
}
}
}
// ---- 主体双栏 ----
.dossier-body {
flex: 1;
min-height: 0;
display: flex;
gap: 14px;
padding: 14px;
overflow: hidden;
}
// 左侧:简历预览区(可收缩)
.resume-dock {
flex: 0 0 46%;
min-width: 0;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(14px) saturate(1.2);
-webkit-backdrop-filter: blur(14px) saturate(1.2);
border: 1px solid var(--dossier-line);
border-radius: 14px;
overflow: hidden;
transition: flex-basis 0.32s cubic-bezier(0.22, 1, 0.36, 1);
&.collapsed {
flex-basis: 62px;
}
.dock-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.dock-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--dossier-line);
.dock-file {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.dock-file-icon {
font-size: 22px;
color: var(--dossier-accent);
flex-shrink: 0;
}
.dock-file-copy {
min-width: 0;
strong {
display: block;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
span {
display: block;
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
}
.dock-tools {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
}
<div class="resume-decision-bar"> .dock-body {
<div> flex: 1;
<span>当前节点</span> min-height: 0;
<strong>{{ candidateScreeningConclusion(selected, store.state) }}</strong> position: relative;
<p>{{ flow.next.desc }}</p> background: #eef2f8;
</div>
<div class="workflow-next-actions"> .dock-frame {
<el-button type="primary" @click="runLocalMatchAction(selected)">快速匹配</el-button> position: absolute;
<el-button type="primary" @click="generateEval(selected)">生成 AI 评估报告</el-button> inset: 0;
<el-button @click="advanceStage(selected, '面试中')">推进面试</el-button> width: 100%;
<el-button @click="poolCandidate(selected)">移入人才池</el-button> height: 100%;
</div> border: 0;
</div> background: #fff;
}
<el-tabs v-model="tabs"> .dock-text {
<el-tab-pane label="概览" name="overview"> position: absolute;
<div class="profile-grid"> inset: 0;
<div v-for="field in profileFields" :key="field.label" class="profile-field"> overflow: auto;
<span>{{ field.label }}</span> padding: 14px;
<strong>{{ field.value }}</strong>
</div> pre {
</div> margin: 0;
font-family: inherit;
font-size: 13px;
line-height: 1.7;
color: var(--ink);
white-space: pre-wrap;
word-break: break-word;
}
}
}
<div v-if="profileCorrection.name !== '' || selected.phone !== ''" class="correction-box"> .dock-rail {
<h4>字段修正</h4> display: flex;
<div class="form two"> flex-direction: column;
<el-input v-model="profileCorrection.name" placeholder="姓名" /> align-items: center;
<el-input v-model="profileCorrection.phone" placeholder="电话" /> justify-content: center;
<el-input v-model="profileCorrection.email" placeholder="邮箱" /> gap: 14px;
<el-input v-model="profileCorrection.age" placeholder="年龄" /> height: 100%;
<el-input v-model="profileCorrection.years" placeholder="年限" /> width: 100%;
<el-input v-model="profileCorrection.education" placeholder="学历" /> padding: 12px 6px;
<el-input v-model="profileCorrection.school" placeholder="学校" /> border: 0;
<el-input v-model="profileCorrection.major" placeholder="专业" /> background: linear-gradient(180deg, rgba(69, 105, 201, 0.08), rgba(117, 195, 221, 0.14));
<el-input v-model="profileCorrection.schoolTags" placeholder="标签(、分隔)" /> cursor: pointer;
<el-input v-model="profileCorrection.skills" placeholder="技能(、分隔)" /> color: var(--dossier-accent);
</div> transition: background 0.2s ease;
<el-button type="primary" @click="saveCorrection(selected)">保存字段修正</el-button>
</div> &:hover {
background: linear-gradient(180deg, rgba(69, 105, 201, 0.16), rgba(117, 195, 221, 0.22));
}
<div class="resume-text-box"> .rail-icon {
<h4>简历正文</h4> font-size: 22px;
<el-input :model-value="selected.resumeText || '暂无正文'" type="textarea" :rows="4" readonly /> }
</div>
</el-tab-pane>
<el-tab-pane label="评估" name="assessment">
<div class="assessment-panel">
<div class="assessment-head">
<div>
<span>当前得分</span>
<strong :style="{ color: decisionColor }">{{ scoreText }}</strong>
</div>
<el-tag size="small">{{ store.resumeDetailMode === 'ai' ? 'AI 评估' : '本地快速匹配' }}</el-tag>
</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">
<p v-if="selected.analysis?.summary">{{ selected.analysis.summary }}</p>
<p v-else>快速匹配只做岗位关键词命中和缺口识别,不生成 HR 决策报告。</p>
</div>
<div v-if="selected.matchReport || selected.analysis" class="assessment-table-wrap">
<el-table :data="reportRows" class="assessment-table">
<el-table-column label="维度" width="130">
<template #default="{ row }">
<b class="assessment-row-label">{{ row.label }}</b>
</template>
</el-table-column>
<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>
<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>
</el-tab-pane>
<el-tab-pane label="面试" name="interview">
<div class="interview-panel">
<el-form label-position="top" class="form two">
<el-form-item label="轮次">
<el-select v-model="interviewForm.round">
<el-option label="一面" value="一面" />
<el-option label="二面" value="二面" />
</el-select>
</el-form-item>
<el-form-item label="面试官">
<el-input v-model="interviewForm.interviewer" />
</el-form-item>
<el-form-item label="时间">
<el-input v-model="interviewForm.scheduledAt" placeholder="如 06-20 14:00" />
</el-form-item>
<el-form-item label="形式">
<el-select v-model="interviewForm.format">
<el-option label="线上" value="线上" />
<el-option label="线下" value="线下" />
</el-select>
</el-form-item>
<el-form-item label="结果">
<el-select v-model="interviewForm.result">
<el-option label="待反馈" value="待反馈" />
<el-option label="通过" value="通过" />
<el-option label="不通过" value="不通过" />
</el-select>
</el-form-item>
<el-form-item label="下一步">
<el-select v-model="interviewForm.nextStep">
<el-option label="继续面试" value="继续面试" />
<el-option label="推进 Offer" value="推进 Offer" />
</el-select>
</el-form-item>
<el-form-item label="面试评价" class="span-2">
<el-input v-model="interviewForm.feedback" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<el-button type="primary" @click="saveInterview(selected)">保存面试记录</el-button>
<div v-if="selected.interviews && selected.interviews.length" class="interview-list">
<div v-for="interview in selected.interviews" :key="interview.id" class="interview-item">
<strong>{{ interview.round }} · {{ interview.interviewer || '待定' }}</strong>
<span>{{ interview.scheduledAt || '时间待定' }} · {{ interview.format }}</span>
<el-tag size="small">{{ interview.result || '待反馈' }}</el-tag>
<p>{{ interview.feedback || '' }}</p>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="简历文本" name="resume"> .rail-label {
<div class="resume-text-box"> writing-mode: vertical-rl;
<el-input :model-value="selected.resumeText || '暂无正文'" type="textarea" :rows="10" /> text-orientation: mixed;
</div> font-size: 12px;
</el-tab-pane> letter-spacing: 2px;
</el-tabs> color: var(--ink);
</section> max-width: 100%;
</div> overflow: hidden;
</template> text-overflow: ellipsis;
white-space: nowrap;
}
<style lang="scss" scoped> .rail-hint {
.candidate-detail-wrap { font-size: 10px;
color: var(--muted);
writing-mode: vertical-rl;
}
}
}
// 右侧:档案区
.dossier-content {
flex: 1;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 12px;
overflow: auto;
} }
.subpage-head { // 顶部速览条
.summary-strip {
display: flex; display: flex;
align-items: center; align-items: stretch;
gap: 14px; gap: 18px;
padding: 16px 18px;
border-radius: 14px;
background: linear-gradient(120deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.72));
border: 1px solid var(--dossier-line);
box-shadow: 0 8px 24px rgba(24, 32, 51, 0.06);
.score-block {
display: flex;
align-items: center;
gap: 14px;
min-width: 168px;
}
h3 { .score-ring {
margin: 0; width: 78px;
height: 78px;
flex-shrink: 0;
transform: rotate(-90deg);
.ring-track {
fill: none;
stroke: rgba(24, 32, 51, 0.08);
stroke-width: 9;
}
.ring-value {
fill: none;
stroke: var(--ring, var(--dossier-accent));
stroke-width: 9;
stroke-linecap: round;
transition: stroke-dashoffset 0.9s cubic-bezier(0.22, 1, 0.36, 1);
}
} }
p {
margin: 4px 0 0; .score-copy {
color: var(--muted); display: flex;
font-size: 13px; flex-direction: column;
min-width: 0;
.score-label {
font-size: 12px;
color: var(--muted);
}
.score-value {
font-size: 32px;
line-height: 1.1;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--ink);
small {
font-size: 14px;
font-weight: 500;
color: var(--muted);
margin-left: 2px;
}
}
.score-mode {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
} }
.el-button:last-child { .decision-block,
margin-left: auto; .next-block {
flex: 1;
min-width: 0;
padding-left: 18px;
border-left: 1px solid var(--dossier-line);
} }
}
.profile-head { .eyebrow {
display: flex; display: block;
align-items: center; font-size: 11px;
gap: 16px; letter-spacing: 1px;
margin-bottom: 16px; color: var(--muted);
margin-bottom: 4px;
}
.profile-avatar { .decision-text,
width: 52px; .next-title {
height: 52px; display: block;
border-radius: 50%; font-size: 16px;
background: var(--blue);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600; font-weight: 600;
color: var(--ink);
line-height: 1.35;
} }
}
.profile-meta { .note-text {
display: flex; margin: 6px 0 0;
align-items: center; font-size: 12px;
gap: 8px; line-height: 1.6;
color: var(--muted);
}
&.tone-success {
border-left: 4px solid var(--green);
}
&.tone-warning {
border-left: 4px solid var(--yellow);
}
&.tone-danger {
border-left: 4px solid var(--rose);
}
&.tone-info {
border-left: 4px solid var(--blue);
}
} }
// 流程步骤条
.workflow-steps { .workflow-steps {
display: flex; display: flex;
gap: 8px; gap: 6px;
margin-bottom: 16px; padding: 4px 2px;
.workflow-step { .workflow-step {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 7px;
padding: 8px 10px; padding: 8px 10px;
background: var(--bg); background: rgba(255, 255, 255, 0.7);
border-radius: 8px; border: 1px solid var(--dossier-line);
border-radius: 10px;
font-size: 12px; font-size: 12px;
color: var(--muted);
transition:
transform 0.15s ease,
border-color 0.15s ease;
span { &:hover {
transform: translateY(-1px);
}
.step-dot {
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 50%; border-radius: 50%;
background: var(--muted); background: rgba(24, 32, 51, 0.14);
color: #fff; color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 11px; font-size: 11px;
flex-shrink: 0;
}
b {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
&.done { &.done {
span { color: var(--ink);
.step-dot {
background: var(--green); background: var(--green);
} }
} }
&.active { &.active {
border: 1px solid var(--blue); border-color: var(--blue);
span { color: var(--ink);
box-shadow: 0 4px 12px rgba(88, 116, 200, 0.18);
.step-dot {
background: var(--blue); background: var(--blue);
} }
} }
} }
} }
.resume-decision-bar { // 标签内容
.dossier-tabs {
flex: 1;
min-height: 0;
:deep(.el-tabs__header) {
margin-bottom: 12px;
}
}
// ---- 概览:候选人档案卡(预览 / 编辑双态)----
.overview-card {
position: relative;
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--dossier-line);
border-left: 3px solid var(--dossier-accent);
border-radius: 14px;
padding: 16px 18px 6px;
margin-bottom: 16px;
box-shadow: 0 8px 24px rgba(24, 32, 51, 0.05);
transition: border-color 0.25s ease;
&.editing {
border-left-color: var(--yellow);
border-color: color-mix(in srgb, var(--yellow) 55%, var(--dossier-line));
.overview-head {
border-bottom-color: color-mix(in srgb, var(--yellow) 40%, var(--dossier-line));
}
}
}
.overview-head {
display: flex; display: flex;
align-items: flex-start;
justify-content: space-between; justify-content: space-between;
align-items: center; gap: 12px;
gap: 16px; padding-bottom: 12px;
background: var(--bg); margin-bottom: 6px;
padding: 16px; border-bottom: 1px solid var(--dossier-line);
border-radius: 8px;
margin-bottom: 16px;
span { .overview-title {
font-size: 12px; min-width: 0;
color: var(--muted);
} }
strong {
.ov-eyebrow {
display: block; display: block;
margin: 4px 0; font-size: 11px;
letter-spacing: 1px;
color: var(--muted);
margin-bottom: 3px;
} }
p {
h4 {
margin: 0; margin: 0;
color: var(--muted); font-size: 15px;
font-size: 13px; font-weight: 600;
}
.overview-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.btn-icon {
margin-right: 2px;
}
} }
} }
.workflow-next-actions { .edit-banner {
display: flex; display: flex;
flex-wrap: wrap; align-items: center;
gap: 8px; gap: 8px;
justify-content: flex-end; margin: 10px 0 6px;
padding: 8px 12px;
border-radius: 9px;
background: color-mix(in srgb, var(--yellow) 14%, #fff);
border: 1px dashed color-mix(in srgb, var(--yellow) 60%, var(--dossier-line));
color: color-mix(in srgb, #8a6d1a 80%, var(--ink));
font-size: 12px;
}
.ov-section {
margin: 10px 0 4px;
}
.ov-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
.ov-section-mark {
width: 6px;
height: 6px;
border-radius: 2px;
background: var(--dossier-accent);
flex-shrink: 0;
}
} }
.profile-grid { .ov-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; gap: 8px 24px;
margin-bottom: 16px; }
.profile-field { .ov-cell {
background: var(--bg); min-width: 0;
border-radius: 8px; padding: 7px 10px;
padding: 12px; border-radius: 9px;
background: rgba(244, 247, 252, 0.72);
border: 1px solid transparent;
transition:
background 0.2s ease,
border-color 0.2s ease;
.ov-cell-label {
display: block;
font-size: 11px;
color: var(--muted);
margin-bottom: 3px;
letter-spacing: 0.3px;
}
span { .ov-cell-value {
display: block; display: block;
color: var(--muted); font-size: 14px;
font-size: 12px; font-weight: 500;
} color: var(--ink);
strong { font-variant-numeric: tabular-nums;
display: block; line-height: 1.4;
margin-top: 4px; overflow-wrap: anywhere;
font-size: 14px; }
&.is-empty {
.ov-cell-value {
color: color-mix(in srgb, var(--muted) 62%, #fff);
font-weight: 400;
} }
} }
&.is-editing {
background: #fff;
border-color: color-mix(in srgb, var(--yellow) 45%, var(--dossier-line));
}
} }
.correction-box { .skill-chips {
background: var(--bg); display: flex;
border-radius: 8px; flex-wrap: wrap;
padding: 16px; gap: 6px;
margin-bottom: 16px; padding: 2px 0 10px;
h4 { .skill-chip {
margin: 0 0 12px; display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--cyan) 14%, #fff);
border: 1px solid color-mix(in srgb, var(--cyan) 30%, var(--dossier-line));
color: color-mix(in srgb, var(--dossier-accent) 80%, var(--ink));
font-size: 12px;
}
.skill-empty {
font-size: 12px;
color: var(--muted);
} }
} }
.resume-text-box { .resume-text-box {
h4 { .resume-text-head {
margin-bottom: 8px; margin-bottom: 8px;
.ov-eyebrow {
display: block;
font-size: 11px;
letter-spacing: 1px;
color: var(--muted);
margin-bottom: 2px;
}
h4 {
margin: 0;
font-size: 14px;
}
} }
} }
.assessment-head { .assessment-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
...@@ -584,8 +1462,9 @@ const saveCorrection = (candidate) => { ...@@ -584,8 +1462,9 @@ const saveCorrection = (candidate) => {
font-size: 13px; font-size: 13px;
color: var(--muted); color: var(--muted);
} }
strong { strong {
font-size: 32px; font-size: 30px;
} }
} }
...@@ -596,9 +1475,9 @@ const saveCorrection = (candidate) => { ...@@ -596,9 +1475,9 @@ const saveCorrection = (candidate) => {
gap: 16px; gap: 16px;
margin-bottom: 12px; margin-bottom: 12px;
padding: 14px 16px; padding: 14px 16px;
background: var(--bg); background: rgba(255, 255, 255, 0.8);
border-left: 4px solid var(--blue); border-left: 4px solid var(--blue);
border-radius: 8px; border-radius: 10px;
span { span {
display: block; display: block;
...@@ -634,6 +1513,8 @@ const saveCorrection = (candidate) => { ...@@ -634,6 +1513,8 @@ const saveCorrection = (candidate) => {
.assessment-summary { .assessment-summary {
margin-bottom: 12px; margin-bottom: 12px;
color: var(--muted); color: var(--muted);
font-size: 13px;
line-height: 1.6;
} }
.assessment-table-wrap { .assessment-table-wrap {
...@@ -672,8 +1553,9 @@ const saveCorrection = (candidate) => { ...@@ -672,8 +1553,9 @@ const saveCorrection = (candidate) => {
gap: 10px; gap: 10px;
.interview-item { .interview-item {
background: var(--bg); background: rgba(255, 255, 255, 0.8);
border-radius: 8px; border: 1px solid var(--dossier-line);
border-radius: 10px;
padding: 12px; padding: 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
...@@ -683,6 +1565,7 @@ const saveCorrection = (candidate) => { ...@@ -683,6 +1565,7 @@ const saveCorrection = (candidate) => {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
} }
p { p {
margin: 4px 0 0; margin: 4px 0 0;
color: var(--muted); color: var(--muted);
...@@ -690,4 +1573,109 @@ const saveCorrection = (candidate) => { ...@@ -690,4 +1573,109 @@ const saveCorrection = (candidate) => {
} }
} }
} }
// 底部操作栏
.dossier-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 18px;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(14px) saturate(1.2);
-webkit-backdrop-filter: blur(14px) saturate(1.2);
border-top: 1px solid var(--dossier-line);
.footer-note {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
min-width: 0;
.footer-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--blue);
flex-shrink: 0;
}
}
.footer-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.pool-btn {
color: var(--violet);
border-color: color-mix(in srgb, var(--violet) 45%, var(--line));
}
}
}
@media (max-width: 1024px) {
.dossier-body {
flex-direction: column;
overflow: auto;
}
.resume-dock {
flex: 0 0 42%;
max-height: 42%;
&.collapsed {
flex-basis: 56px;
max-height: 56px;
}
}
.dossier-content {
overflow: visible;
}
}
@media (max-width: 760px) {
.dossier-head {
.head-actions .nav-stack {
display: none;
}
}
.summary-strip {
flex-direction: column;
gap: 12px;
.decision-block,
.next-block {
padding-left: 0;
border-left: 0;
padding-top: 10px;
border-top: 1px solid var(--dossier-line);
}
}
.workflow-steps {
flex-wrap: wrap;
}
.dossier-footer {
flex-direction: column;
align-items: stretch;
.footer-actions {
flex-wrap: wrap;
}
}
}
@media (prefers-reduced-motion: reduce) {
.resume-dock,
.ring-value,
.workflow-step {
transition: none;
}
}
</style> </style>
...@@ -27,6 +27,8 @@ watch( ...@@ -27,6 +27,8 @@ watch(
activeRouteKey, activeRouteKey,
(key) => { (key) => {
store.activePage = key store.activePage = key
// 离开简历库时关闭候选人抽屉,避免返回时残留弹层
if (key !== 'resumes') store.resumeViewMode = 'list'
}, },
{ immediate: true } { immediate: true }
) )
...@@ -39,6 +41,19 @@ const todayText = computed(() => ...@@ -39,6 +41,19 @@ const todayText = computed(() =>
const displayName = computed(() => userStore.user?.name || userStore.user?.username || '未登录') const displayName = computed(() => userStore.user?.name || userStore.user?.username || '未登录')
const isAdmin = computed(() => userStore.isAdmin) const isAdmin = computed(() => userStore.isAdmin)
// 侧栏导航按工作流分组
const navGroups = computed(() => {
const byKey = Object.fromEntries(navItems.map((item) => [item.key, item]))
const groups = [
{ title: '工作台', items: [byKey.todo, byKey.dashboard].filter(Boolean) },
{ title: '招聘流程', items: [byKey.jobs, byKey.resumes, byKey.offer].filter(Boolean) },
]
if (isAdmin.value) {
groups.push({ title: '系统', items: [{ key: 'user-management', label: '用户管理', icon: 'Setting' }] })
}
return groups
})
const navigate = (key) => { const navigate = (key) => {
store.setPage(key) store.setPage(key)
if (key === 'user-management') router.push({ name: 'user-management' }) if (key === 'user-management') router.push({ name: 'user-management' })
...@@ -81,6 +96,12 @@ const handleReset = () => { ...@@ -81,6 +96,12 @@ const handleReset = () => {
if (ok) store.resetData() if (ok) store.resetData()
} }
// ---- 侧栏数据工具 ----
const importInput = ref(null)
const triggerImport = () => {
importInput.value?.click()
}
// ---- 用户菜单 ---- // ---- 用户菜单 ----
const changePwdOpen = ref(false) const changePwdOpen = ref(false)
const pwdForm = ref({ oldPassword: '', newPassword: '', confirm: '' }) const pwdForm = ref({ oldPassword: '', newPassword: '', confirm: '' })
...@@ -172,33 +193,43 @@ onMounted(() => { ...@@ -172,33 +193,43 @@ onMounted(() => {
<aside class="side"> <aside class="side">
<div class="brand"> <div class="brand">
<div class="brand-mark">HR</div> <div class="brand-mark">HR</div>
<div><strong>招聘系统</strong><span>本地可用版</span></div> <div class="brand-copy">
<strong>招聘系统</strong>
<span class="brand-sub"><i class="status-dot"></i>本地可用版</span>
</div>
</div> </div>
<nav class="nav"> <nav class="nav">
<NavItem <div v-for="group in navGroups" :key="group.title" class="nav-group">
v-for="item in navItems" <div class="nav-group-title">{{ group.title }}</div>
:key="item.key" <NavItem
:item="item" v-for="item in group.items"
:active="store.activePage === item.key" :key="item.key"
:count="navCount(item.key)" :item="item"
@click="navigate(item.key)" :active="store.activePage === item.key"
/> :count="navCount(item.key)"
<NavItem @click="navigate(item.key)"
v-if="isAdmin" />
key="user-management" </div>
:item="{ key: 'user-management', label: '用户管理' }"
:active="store.activePage === 'user-management'"
:count="0"
@click="navigate('user-management')"
/>
</nav> </nav>
<div class="side-actions">
<el-button type="primary" size="small" @click="handleExport">导出备份</el-button> <div class="side-foot">
<el-button size="small"> <div class="side-foot-title">数据</div>
导入备份 <div class="data-actions">
<input class="import-input" type="file" accept="application/json" @change="handleImport" /> <button type="button" class="data-btn" @click="handleExport">
</el-button> <el-icon><Download /></el-icon>
<el-button size="small" @click="handleReset">重置示例</el-button> <span>导出备份</span>
</button>
<button type="button" class="data-btn" @click="triggerImport">
<el-icon><Upload /></el-icon>
<span>导入备份</span>
</button>
<input ref="importInput" class="import-input" type="file" accept="application/json" @change="handleImport" />
<button type="button" class="data-btn" @click="handleReset">
<el-icon><Refresh /></el-icon>
<span>重置示例</span>
</button>
</div>
</div> </div>
</aside> </aside>
...@@ -209,8 +240,11 @@ onMounted(() => { ...@@ -209,8 +240,11 @@ onMounted(() => {
<p>{{ pageMeta.desc }}</p> <p>{{ pageMeta.desc }}</p>
</div> </div>
<div class="top-meta"> <div class="top-meta">
<span>{{ store.saveStateText }}</span> <span class="save-status">
<strong>{{ todayText }}</strong> <i class="status-dot"></i>
<span>{{ store.saveStateText }}</span>
</span>
<span class="today">{{ todayText }}</span>
<el-select v-if="isAdmin" v-model="workspaceValue" class="workspace-switch" size="small" placeholder="工作区"> <el-select v-if="isAdmin" v-model="workspaceValue" class="workspace-switch" size="small" placeholder="工作区">
<el-option <el-option
v-for="option in workspaceOptions" v-for="option in workspaceOptions"
...@@ -220,12 +254,14 @@ onMounted(() => { ...@@ -220,12 +254,14 @@ onMounted(() => {
/> />
</el-select> </el-select>
<el-dropdown trigger="click" @command="handleUserCommand"> <el-dropdown trigger="click" @command="handleUserCommand">
<span class="user-entry"> <button type="button" class="user-chip">
<el-avatar :size="26">{{ displayName.slice(0, 1).toUpperCase() }}</el-avatar> <span class="user-avatar">{{ displayName.slice(0, 1).toUpperCase() }}</span>
<span class="user-name">{{ displayName }}</span> <span class="user-chip-copy">
<el-tag v-if="isAdmin" size="small" type="danger">管理员</el-tag> <strong>{{ displayName }}</strong>
<el-icon><arrow-down /></el-icon> <em>{{ isAdmin ? '管理员' : '招聘专员' }}</em>
</span> </span>
<el-icon class="user-chevron"><ArrowDown /></el-icon>
</button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="change-pwd">修改密码</el-dropdown-item> <el-dropdown-item command="change-pwd">修改密码</el-dropdown-item>
......
export const navItems = [ export const navItems = [
{ key: 'todo', label: '待办项', desc: '处理需要 HR 跟进的事项。', path: '/' }, { key: 'todo', label: '待办项', desc: '处理需要 HR 跟进的事项。', path: '/', icon: 'Bell' },
{ key: 'dashboard', label: '招聘数据看板', desc: '用本地数据查看招聘漏斗、来源和趋势。', path: '/dashboard' }, {
{ key: 'jobs', label: '岗位管理', desc: '维护岗位、JD、招聘目标和岗位状态。', path: '/jobs' }, key: 'dashboard',
{ key: 'resumes', label: '简历库', desc: '管理候选人、简历来源、匹配度和流程状态。', path: '/resumes' }, label: '招聘数据看板',
{ key: 'offer', label: '录用入职管理', desc: '生成 Offer 审批草稿并跟进待入职。', path: '/offer' }, desc: '用本地数据查看招聘漏斗、来源和趋势。',
path: '/dashboard',
icon: 'DataAnalysis',
},
{ key: 'jobs', label: '岗位管理', desc: '维护岗位、JD、招聘目标和岗位状态。', path: '/jobs', icon: 'Briefcase' },
{ key: 'resumes', label: '简历库', desc: '管理候选人、简历来源、匹配度和流程状态。', path: '/resumes', icon: 'User' },
{ key: 'offer', label: '录用入职管理', desc: '生成 Offer 审批草稿并跟进待入职。', path: '/offer', icon: 'Stamp' },
] ]
export const navPageMeta = Object.fromEntries( export const navPageMeta = Object.fromEntries(
......
<script setup> <script setup>
import { computed } from 'vue' import { computed, watch } from 'vue'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import ResumeList from '@/components/resumes/ResumeList.vue' import ResumeList from '@/components/resumes/ResumeList.vue'
...@@ -9,16 +9,27 @@ import CandidateBatchModal from '@/components/resumes/CandidateBatchModal.vue' ...@@ -9,16 +9,27 @@ import CandidateBatchModal from '@/components/resumes/CandidateBatchModal.vue'
const store = useRecruitmentStore() const store = useRecruitmentStore()
const mode = computed(() => store.resumeViewMode)
const createOpen = computed(() => store.candidateCreateOpen) const createOpen = computed(() => store.candidateCreateOpen)
const batchOpen = computed(() => store.candidateBatchOpen) const batchOpen = computed(() => store.candidateBatchOpen)
// 待办「定位候选人」:自动打开候选人抽屉
watch(
() => store.focusedTarget?.type,
(type) => {
if (type === 'candidate') {
store.resumeViewMode = 'detail'
store.resumeDetailSection = 'overview'
store.focusedTarget = null
}
},
{ immediate: true }
)
</script> </script>
<template> <template>
<div class="resume-workbench"> <div class="resume-workbench">
<ResumeList v-show="mode !== 'detail'" /> <ResumeList />
<ResumeDetail v-if="mode === 'detail'" /> <ResumeDetail />
<CandidateCreateModal v-if="createOpen" /> <CandidateCreateModal v-if="createOpen" />
<CandidateBatchModal v-if="batchOpen" /> <CandidateBatchModal v-if="batchOpen" />
......
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