Commit 07310dd8 authored by 李文光's avatar 李文光

feat: 重构候选人列表并保存原简历,支持档案预览与防重复提交

parent ddc5340f
import mimetypes
from fastapi import APIRouter, File, HTTPException, UploadFile from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
...@@ -32,4 +34,9 @@ def get_resume(filename: str) -> FileResponse: ...@@ -32,4 +34,9 @@ def get_resume(filename: str) -> FileResponse:
path = resolve_resume_path(filename) path = resolve_resume_path(filename)
if not path: if not path:
raise HTTPException(status_code=404, detail="文件未找到") raise HTTPException(status_code=404, detail="文件未找到")
return FileResponse(path, media_type="application/pdf", headers={"Cache-Control": "private, max-age=3600"}) media_type, _ = mimetypes.guess_type(path.name) or (None, None)
return FileResponse(
path,
media_type=media_type or "application/octet-stream",
headers={"Cache-Control": "private, max-age=3600"},
)
...@@ -20,6 +20,16 @@ export async function parseResume(file) { ...@@ -20,6 +20,16 @@ export async function parseResume(file) {
return data return data
} }
export async function uploadResumeFile(file) {
const formData = new FormData()
formData.append('resume', file)
const { data } = await http.post('/api/upload-resume', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
if (data.error) throw new Error(data.error)
return data
}
export async function analyzeResume(payload) { export async function analyzeResume(payload) {
const { data } = await http.post('/api/analyze-resume', payload) const { data } = await http.post('/api/analyze-resume', payload)
if (data.error) throw new Error(data.error) if (data.error) throw new Error(data.error)
......
...@@ -13,12 +13,12 @@ import { ...@@ -13,12 +13,12 @@ import {
extractSkills, extractSkills,
schoolTags, schoolTags,
} from '@/utils/inference' } from '@/utils/inference'
import { fileToDataUrl } from '@/utils/file'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
const store = useRecruitmentStore() const store = useRecruitmentStore()
const parsing = ref(false) const parsing = ref(false)
const importing = ref(false)
const ready = computed( const ready = computed(
() => store.candidateBatchEntries.filter((entry) => entry.status === 'ready' && entry.jobId).length () => store.candidateBatchEntries.filter((entry) => entry.status === 'ready' && entry.jobId).length
...@@ -62,8 +62,6 @@ const setBatchSource = (source) => { ...@@ -62,8 +62,6 @@ const setBatchSource = (source) => {
const handleFiles = async (uploadFile) => { const handleFiles = async (uploadFile) => {
const file = uploadFile.raw const file = uploadFile.raw
if (!file || !store.candidateBatchOpen) return if (!file || !store.candidateBatchOpen) return
const totalSize = store.candidateBatchEntries.reduce((total, entry) => total + (entry.file?.size || 0), 0)
const cacheOriginalFiles = totalSize + file.size <= 2 * 1024 * 1024
const entry = { const entry = {
id: uid('batch-resume'), id: uid('batch-resume'),
file, file,
...@@ -99,15 +97,7 @@ const handleFiles = async (uploadFile) => { ...@@ -99,15 +97,7 @@ const handleFiles = async (uploadFile) => {
entry.parseWarning = parsed.parseWarning || '' entry.parseWarning = parsed.parseWarning || ''
entry.status = 'ready' entry.status = 'ready'
entry.jobId = store.candidateBatchMode === 'job' ? store.candidateBatchJobId : store.smartBatchJobId(entry) entry.jobId = store.candidateBatchMode === 'job' ? store.candidateBatchJobId : store.smartBatchJobId(entry)
if (cacheOriginalFiles && entry.file.size <= 2 * 1024 * 1024) { entry.resumeFilePreviewWarning = ''
try {
entry.resumeFileDataUrl = await fileToDataUrl(entry.file)
} catch (error) {
entry.resumeFilePreviewWarning = error.message
}
} else {
entry.resumeFilePreviewWarning = '批次文件较大,仅保存解析结果,未缓存原文件预览。'
}
} catch (error) { } catch (error) {
entry.status = 'error' entry.status = 'error'
entry.error = error.message || '解析失败' entry.error = error.message || '解析失败'
...@@ -130,9 +120,9 @@ const entryStatusView = (entry) => { ...@@ -130,9 +120,9 @@ const entryStatusView = (entry) => {
return { text: entry.parseWarning ? '字段待核对' : '可入库', type: 'success' } return { text: entry.parseWarning ? '字段待核对' : '可入库', type: 'success' }
} }
const importBatch = () => { const importBatch = async () => {
if (store.candidateBatchParsing) { if (store.candidateBatchParsing || importing.value) {
showToast('简历仍在解析,请稍候', 'warning') showToast('简历仍在解析或入库中,请稍候', 'warning')
return return
} }
const importable = store.candidateBatchEntries.filter((entry) => entry.status === 'ready' && entry.jobId) const importable = store.candidateBatchEntries.filter((entry) => entry.status === 'ready' && entry.jobId)
...@@ -140,10 +130,23 @@ const importBatch = () => { ...@@ -140,10 +130,23 @@ const importBatch = () => {
showToast('请处理解析失败项并确认每份简历的关联岗位', 'warning') showToast('请处理解析失败项并确认每份简历的关联岗位', 'warning')
return return
} }
importing.value = true
try {
for (const entry of importable) {
if (!entry.file) continue
try {
entry.upload = await store.uploadResumeFile(entry.file)
} catch {
showToast(`上传原简历失败:${entry.file.name}`, 'error')
importing.value = false
return
}
}
const created = [] const created = []
let duplicateCount = 0 let duplicateCount = 0
importable.forEach((entry) => { importable.forEach((entry) => {
const parsed = entry.parsed || {} const upload = entry.upload || {}
const parsed = entry.parsed || upload.parsed || {}
const job = store.jobs.find((item) => item.id === entry.jobId) const job = store.jobs.find((item) => item.id === entry.jobId)
const name = cleanCandidateName(parsed.name || '', entry.file?.name) || '未命名候选人' const name = cleanCandidateName(parsed.name || '', entry.file?.name) || '未命名候选人'
const duplicate = store.candidates.some( const duplicate = store.candidates.some(
...@@ -155,6 +158,15 @@ const importBatch = () => { ...@@ -155,6 +158,15 @@ const importBatch = () => {
return return
} }
const resumeText = parsed.resumeText || '' const resumeText = parsed.resumeText || ''
const fileMeta = upload.file
? {
name: upload.file.originalName || entry.file?.name,
type: upload.file.fileType || entry.file?.type || '',
size: upload.file.sizeBytes || entry.file?.size || 0,
}
: entry.file
? { name: entry.file.name, type: entry.file.type, size: entry.file.size }
: null
const candidate = enrichCandidateFields({ const candidate = enrichCandidateFields({
id: uid('cand'), id: uid('cand'),
name, name,
...@@ -163,10 +175,10 @@ const importBatch = () => { ...@@ -163,10 +175,10 @@ const importBatch = () => {
source: parsed.source || store.candidateBatchSource, source: parsed.source || store.candidateBatchSource,
stage: '未筛选', stage: '未筛选',
match: 0, match: 0,
resumeName: entry.file?.name || parsed.resumeName || '', resumeName: fileMeta?.name || entry.file?.name || parsed.resumeName || '',
resumeMeta: entry.file ? { name: entry.file.name, type: entry.file.type, size: entry.file.size } : null, resumeMeta: fileMeta,
resumeFileDataUrl: entry.resumeFileDataUrl || '', resumeFileDataUrl: upload?.file?.url || '',
resumeFilePreviewWarning: entry.resumeFilePreviewWarning || '', resumeFilePreviewWarning: upload?.file?.url ? '' : '原简历未写入后端存储,仅保留解析结果。',
resumeText, resumeText,
phone: parsed.phone || '', phone: parsed.phone || '',
email: parsed.email || '', email: parsed.email || '',
...@@ -176,7 +188,9 @@ const importBatch = () => { ...@@ -176,7 +188,9 @@ const importBatch = () => {
education: parsed.education || '', education: parsed.education || '',
school: parsed.school || inferSchool(resumeText), school: parsed.school || inferSchool(resumeText),
major: parsed.major || inferMajor(resumeText), major: parsed.major || inferMajor(resumeText),
schoolTags: parsed.schoolTags?.length ? parsed.schoolTags : schoolTags(parsed.school || inferSchool(resumeText)), schoolTags: parsed.schoolTags?.length
? parsed.schoolTags
: schoolTags(parsed.school || inferSchool(resumeText)),
city: parsed.city || '', city: parsed.city || '',
skills: parsed.skills?.length ? parsed.skills : extractSkills(resumeText), skills: parsed.skills?.length ? parsed.skills : extractSkills(resumeText),
parseWarning: parsed.parseWarning || '', parseWarning: parsed.parseWarning || '',
...@@ -206,6 +220,9 @@ const importBatch = () => { ...@@ -206,6 +220,9 @@ const importBatch = () => {
store.resumeViewMode = 'list' store.resumeViewMode = 'list'
store.persist('批量简历已入库') store.persist('批量简历已入库')
showToast(`已批量入库 ${created.length} ${duplicateCount ? `,跳过重复 ${duplicateCount} 份` : ''}`) showToast(`已批量入库 ${created.length} ${duplicateCount ? `,跳过重复 ${duplicateCount} 份` : ''}`)
} finally {
importing.value = false
}
} }
</script> </script>
...@@ -305,8 +322,8 @@ const importBatch = () => { ...@@ -305,8 +322,8 @@ const importBatch = () => {
<template #footer> <template #footer>
<el-button @click="close">取消</el-button> <el-button @click="close">取消</el-button>
<el-button type="primary" :disabled="!canImport" @click="importBatch"> <el-button type="primary" :disabled="importing || !canImport" @click="importBatch">
确认入库 {{ ready ? `(${ready})` : '' }} {{ importing ? '正在保存原简历...' : `确认入库 ${ready ? `(${ready})` : ''}` }}
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
......
...@@ -13,7 +13,6 @@ import { ...@@ -13,7 +13,6 @@ import {
extractSkills, extractSkills,
schoolTags, schoolTags,
} from '@/utils/inference' } from '@/utils/inference'
import { fileToDataUrl } from '@/utils/file'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
const store = useRecruitmentStore() const store = useRecruitmentStore()
...@@ -30,6 +29,7 @@ const form = reactive({ ...@@ -30,6 +29,7 @@ const form = reactive({
const resumeFile = ref(null) const resumeFile = ref(null)
const parsing = ref(false) const parsing = ref(false)
const saving = ref(false)
const suggestions = computed(() => { const suggestions = computed(() => {
if (!form.resumeText && !form.jobTitle) return [] if (!form.resumeText && !form.jobTitle) return []
...@@ -85,36 +85,19 @@ const pickJob = (jobId) => { ...@@ -85,36 +85,19 @@ const pickJob = (jobId) => {
} }
const submit = async () => { const submit = async () => {
if (saving.value) return
const file = resumeFile.value const file = resumeFile.value
let parsedResume = {} let parsedResume = {}
let resumeText = form.resumeText let resumeText = form.resumeText
let resumeFileDataUrl = '' let resumeFileDataUrl = ''
let resumeFilePreviewWarning = '' let resumeFilePreviewWarning = ''
let fileMeta = null
if (!file && !resumeText.trim()) { if (!file && !resumeText.trim()) {
showToast('请先上传简历文件,或在补充区填写简历文本', 'warning') showToast('请先上传简历文件,或在补充区填写简历文本', 'warning')
return return
} }
if (file) {
if (file.size <= 6 * 1024 * 1024) {
try {
resumeFileDataUrl = await fileToDataUrl(file)
} catch (error) {
resumeFilePreviewWarning = error.message
}
} else {
resumeFilePreviewWarning = '文件超过 6MB,未缓存原文件预览。'
}
try {
parsedResume = await store.parseResumeFile(file)
resumeText = parsedResume.resumeText || resumeText
} catch (error) {
showToast(error.message, 'error')
if (/\.(txt|md)$/i.test(file.name)) resumeText = await file.text()
}
}
const fallbackName = cleanCandidateName('', file?.name) || '未命名候选人' const fallbackName = cleanCandidateName('', file?.name) || '未命名候选人'
const selectedJob = store.jobs.find((job) => job.id === form.jobId) const selectedJob = store.jobs.find((job) => job.id === form.jobId)
if (!selectedJob) { if (!selectedJob) {
...@@ -122,6 +105,23 @@ const submit = async () => { ...@@ -122,6 +105,23 @@ const submit = async () => {
return return
} }
saving.value = true
try {
if (file) {
const upload = await store.uploadResumeFile(file)
parsedResume = upload?.parsed || parsedResume
resumeFileDataUrl = upload?.file?.url || ''
resumeFilePreviewWarning = upload?.file?.url ? '' : '原简历未写入后端存储,仅保留解析结果。'
if (upload?.file) {
fileMeta = {
name: upload.file.originalName || file.name,
type: upload.file.fileType || file.type,
size: upload.file.sizeBytes || file.size,
}
}
resumeText = parsedResume.resumeText || resumeText
}
const candidate = enrichCandidateFields({ const candidate = enrichCandidateFields({
id: uid('cand'), id: uid('cand'),
name: cleanCandidateName(form.name || parsedResume.name || fallbackName, file?.name), name: cleanCandidateName(form.name || parsedResume.name || fallbackName, file?.name),
...@@ -130,8 +130,8 @@ const submit = async () => { ...@@ -130,8 +130,8 @@ const submit = async () => {
source: parsedResume.source || form.source, source: parsedResume.source || form.source,
stage: form.stage, stage: form.stage,
match: 0, match: 0,
resumeName: file?.name || parsedResume.resumeName || '', resumeName: fileMeta?.name || file?.name || parsedResume.resumeName || '',
resumeMeta: file ? { name: file.name, type: file.type, size: file.size } : null, resumeMeta: fileMeta || (file ? { name: file.name, type: file.type, size: file.size } : null),
resumeFileDataUrl, resumeFileDataUrl,
resumeFilePreviewWarning, resumeFilePreviewWarning,
resumeText, resumeText,
...@@ -163,11 +163,24 @@ const submit = async () => { ...@@ -163,11 +163,24 @@ const submit = async () => {
store.resumeDetailSection = 'overview' store.resumeDetailSection = 'overview'
store.persist() store.persist()
showToast('候选人已保存并完成本地匹配') showToast('候选人已保存并完成本地匹配')
} catch (error) {
showToast(`保存失败:${error.message}`, 'error')
} finally {
saving.value = false
}
} }
</script> </script>
<template> <template>
<el-dialog :model-value="store.candidateCreateOpen" title="新增候选人" width="640px" @close="close"> <el-dialog
:model-value="store.candidateCreateOpen"
title="新增候选人"
width="640px"
:close-on-click-modal="!saving"
:close-on-press-escape="!saving"
:show-close="!saving"
@close="close"
>
<div class="candidate-create-form"> <div class="candidate-create-form">
<section class="candidate-create-block"> <section class="candidate-create-block">
<div class="block-head"> <div class="block-head">
...@@ -250,11 +263,21 @@ const submit = async () => { ...@@ -250,11 +263,21 @@ const submit = async () => {
/> />
<el-input v-model="form.evaluation" type="textarea" :rows="2" placeholder="可选:初筛备注、来源说明、关注点" /> <el-input v-model="form.evaluation" type="textarea" :rows="2" placeholder="可选:初筛备注、来源说明、关注点" />
</details> </details>
<transition name="save-fade">
<div v-if="saving" class="save-mask" role="status" aria-live="polite">
<div class="save-card">
<div class="save-spinner" aria-hidden="true"></div>
<strong>正在保存并匹配</strong>
<span>正在上传原简历并生成快速匹配,请勿关闭或重复点击。</span>
</div>
</div>
</transition>
</div> </div>
<template #footer> <template #footer>
<el-button @click="close">取消</el-button> <el-button :disabled="saving" @click="close">取消</el-button>
<el-button type="primary" @click="submit">保存并匹配</el-button> <el-button type="primary" :loading="saving" :disabled="saving" @click="submit">保存并匹配</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
...@@ -310,4 +333,73 @@ const submit = async () => { ...@@ -310,4 +333,73 @@ const submit = async () => {
margin-bottom: 8px; margin-bottom: 8px;
} }
} }
.save-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(233, 237, 243, 0.6);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.save-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-width: 260px;
padding: 30px 38px;
background: #fff;
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: 0 24px 60px rgba(39, 49, 70, 0.18);
text-align: center;
strong {
font-size: 15px;
color: var(--ink);
}
span {
font-size: 12px;
color: var(--muted);
line-height: 1.6;
}
}
.save-spinner {
width: 34px;
height: 34px;
border: 3px solid color-mix(in srgb, var(--cyan) 28%, #fff);
border-top-color: var(--blue);
border-radius: 50%;
animation: save-spin 0.8s linear infinite;
margin-bottom: 4px;
}
@keyframes save-spin {
to {
transform: rotate(360deg);
}
}
.save-fade-enter-active,
.save-fade-leave-active {
transition: opacity 0.18s ease;
}
.save-fade-enter-from,
.save-fade-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.save-spinner {
animation-duration: 2.4s;
}
}
</style> </style>
...@@ -140,11 +140,43 @@ watch(drawerModel, (open) => { ...@@ -140,11 +140,43 @@ watch(drawerModel, (open) => {
if (!open) ringDrawn.value = false if (!open) ringDrawn.value = false
}) })
// ---- 简历 PDF 预览(带鉴权拉取 → blob URL)---- // ---- 原始简历:格式判断 + 带鉴权拉取预览 ----
const dockOpen = ref(false) const dockOpen = ref(false)
const resumeSrc = ref('') const resumeSrc = ref('')
let resumeObjectUrl = '' let resumeObjectUrl = ''
const resumeKind = computed(() => {
const metaType = String(selected.value?.resumeMeta?.type || '').toLowerCase()
const name = String(selected.value?.resumeName || selected.value?.resumeMeta?.name || '').toLowerCase()
const url = String(selected.value?.resumeFileDataUrl || '').toLowerCase()
const dataMime = url.startsWith('data:') ? url.slice(5).split(';')[0].toLowerCase() : ''
if (metaType.includes('pdf') || dataMime.includes('pdf') || name.endsWith('.pdf') || url.includes('.pdf'))
return 'pdf'
if (
metaType.startsWith('text/') ||
['txt', 'md', 'markdown'].includes(metaType) ||
dataMime.startsWith('text/') ||
name.endsWith('.txt') ||
name.endsWith('.md') ||
name.endsWith('.markdown') ||
url.includes('.txt') ||
url.includes('.md')
)
return 'text'
return 'file'
})
const resumeInline = computed(() => ['pdf', 'text'].includes(resumeKind.value))
const resumeTypeLabel = computed(() => {
const type = String(selected.value?.resumeMeta?.type || '')
const lower = type.toLowerCase()
if (lower.includes('pdf')) return 'PDF'
if (lower.includes('wordprocessing') || ['docx', 'doc'].includes(lower)) return 'Word'
if (lower.startsWith('text/') || ['txt', 'md', 'markdown'].includes(lower)) return '文本'
return type || '简历文件'
})
function revokeResumeUrl() { function revokeResumeUrl() {
if (resumeObjectUrl) { if (resumeObjectUrl) {
URL.revokeObjectURL(resumeObjectUrl) URL.revokeObjectURL(resumeObjectUrl)
...@@ -155,15 +187,14 @@ function revokeResumeUrl() { ...@@ -155,15 +187,14 @@ function revokeResumeUrl() {
async function syncResumeSrc(url) { async function syncResumeSrc(url) {
revokeResumeUrl() revokeResumeUrl()
if (!url) return if (!url || !resumeInline.value) return
try { try {
if (url.startsWith('data:')) { if (url.startsWith('data:')) {
const response = await fetch(url) const response = await fetch(url)
resumeObjectUrl = URL.createObjectURL(await response.blob()) resumeObjectUrl = URL.createObjectURL(await response.blob())
} else { } else {
const response = await http.get(url, { responseType: 'blob' }) const response = await http.get(url, { responseType: 'blob' })
const blob = const blob = response.data instanceof Blob ? response.data : new Blob([response.data])
response.data instanceof Blob ? response.data : new Blob([response.data], { type: 'application/pdf' })
resumeObjectUrl = URL.createObjectURL(blob) resumeObjectUrl = URL.createObjectURL(blob)
} }
resumeSrc.value = resumeObjectUrl resumeSrc.value = resumeObjectUrl
...@@ -182,7 +213,7 @@ watch( ...@@ -182,7 +213,7 @@ watch(
const fileMetaText = computed(() => { const fileMetaText = computed(() => {
const meta = selected.value?.resumeMeta const meta = selected.value?.resumeMeta
if (meta?.size) return `${meta.type || '简历文件'} · ${formatFileSize(meta.size)}` if (meta?.size) return `${resumeTypeLabel.value} · ${formatFileSize(meta.size)}`
return selected.value?.resumeName ? '原始简历文件' : '未上传简历文件' return selected.value?.resumeName ? '原始简历文件' : '未上传简历文件'
}) })
...@@ -194,6 +225,41 @@ function formatFileSize(bytes) { ...@@ -194,6 +225,41 @@ function formatFileSize(bytes) {
return `${(value / 1024 / 1024).toFixed(1)} MB` return `${(value / 1024 / 1024).toFixed(1)} MB`
} }
const resumeDownloading = ref(false)
async function downloadOriginalResume() {
const candidate = selected.value
const url = candidate?.resumeFileDataUrl || ''
if (!url) {
showToast('该候选人没有保存原始简历文件', 'warning')
return
}
resumeDownloading.value = true
try {
let blob
if (url.startsWith('data:')) {
const response = await fetch(url)
blob = await response.blob()
} else {
const response = await http.get(url, { responseType: 'blob' })
blob = response.data instanceof Blob ? response.data : new Blob([response.data])
}
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectUrl
link.download = resumeDisplayName(candidate) || '原始简历'
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(objectUrl)
showToast('已开始下载原始简历')
} catch {
showToast('原简历下载失败,请稍后重试', 'error')
} finally {
resumeDownloading.value = false
}
}
const openResumeInNewTab = () => { const openResumeInNewTab = () => {
if (resumeSrc.value) window.open(resumeSrc.value, '_blank') if (resumeSrc.value) window.open(resumeSrc.value, '_blank')
} }
...@@ -508,7 +574,19 @@ const saveInterview = (candidate) => { ...@@ -508,7 +574,19 @@ const saveInterview = (candidate) => {
</div> </div>
</div> </div>
<div class="dock-tools"> <div class="dock-tools">
<el-tooltip content="在新标签打开简历" placement="top" :disabled="!resumeSrc"> <el-tooltip content="下载原简历" placement="top" :disabled="!selected?.resumeFileDataUrl">
<el-button
text
circle
:loading="resumeDownloading"
:disabled="!selected?.resumeFileDataUrl"
aria-label="下载原简历"
@click="downloadOriginalResume"
>
<el-icon><Download /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="resumeInline" content="在新标签打开简历" placement="top" :disabled="!resumeSrc">
<el-button <el-button
text text
circle circle
...@@ -527,7 +605,7 @@ const saveInterview = (candidate) => { ...@@ -527,7 +605,7 @@ const saveInterview = (candidate) => {
</div> </div>
</div> </div>
<div class="dock-body"> <div class="dock-body">
<iframe v-if="resumeSrc" :src="resumeSrc" class="dock-frame" title="简历预览" /> <iframe v-if="resumeInline && resumeSrc" :src="resumeSrc" class="dock-frame" title="简历预览" />
<div v-else class="dock-text"> <div v-else class="dock-text">
<pre>{{ selected.resumeText || '未上传简历文件,暂无正文可预览。' }}</pre> <pre>{{ selected.resumeText || '未上传简历文件,暂无正文可预览。' }}</pre>
</div> </div>
......
...@@ -5,7 +5,8 @@ import { useRoute, useRouter } from 'vue-router' ...@@ -5,7 +5,8 @@ import { useRoute, useRouter } from 'vue-router'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { jobCandidates } from '@/utils/links' import { jobCandidates } from '@/utils/links'
import { getFilteredCandidates, quickMatchNeedsRefresh } from '@/utils/matching' import { getFilteredCandidates, quickMatchNeedsRefresh } from '@/utils/matching'
import { quickScore, aiScore, candidateScreeningConclusion, candidateRecentUpdate } from '@/utils/resume' import { aiScore, candidateScreeningConclusion, quickScore } from '@/utils/resume'
import { candidateWorkflow } from '@/utils/task'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
const store = useRecruitmentStore() const store = useRecruitmentStore()
...@@ -16,7 +17,10 @@ const batchMatchRunning = ref(false) ...@@ -16,7 +17,10 @@ const batchMatchRunning = ref(false)
const batchMatchResult = ref(null) const batchMatchResult = ref(null)
const stageOptions = ['全部阶段', '未筛选', '初筛通过', '初筛未通过', '面试中', 'Offer 中', '待入职', '已入职'] const stageOptions = ['全部阶段', '未筛选', '初筛通过', '初筛未通过', '面试中', 'Offer 中', '待入职', '已入职']
const sourceOptions = ['全部来源', ...new Set(store.candidates.map((candidate) => candidate.source).filter(Boolean))] const sourceOptions = computed(() => [
'全部来源',
...new Set(store.candidates.map((candidate) => candidate.source).filter(Boolean)),
])
const scopeJob = computed(() => store.jobs.find((job) => job.id === String(route.query.job || '')) || null) const scopeJob = computed(() => store.jobs.find((job) => job.id === String(route.query.job || '')) || null)
...@@ -42,11 +46,62 @@ const metrics = computed(() => [ ...@@ -42,11 +46,62 @@ const metrics = computed(() => [
{ label: '待补全', value: needParseReview.value, hint: '解析字段或正文缺失', color: 'var(--yellow)' }, { label: '待补全', value: needParseReview.value, hint: '解析字段或正文缺失', color: 'var(--yellow)' },
]) ])
const scoreTag = (score) => ({ const hasFilters = computed(() => {
text: score, const f = store.resumeFilters || {}
type: score >= 85 ? 'success' : score >= 72 ? 'warning' : 'danger', return Boolean(
f.q ||
(f.stage && f.stage !== '全部阶段') ||
(f.source && f.source !== '全部来源') ||
(f.minMatch && f.minMatch !== '全部匹配')
)
}) })
const clearFilters = () => {
store.resumeFilters = { q: '', stage: '全部阶段', source: '全部来源', minMatch: '全部匹配' }
}
const candidateInitial = (candidate) => (candidate.name || '候选人').trim().slice(0, 1).toUpperCase()
const scoreColor = (score) => {
const value = Number(score || 0)
if (!value) return 'var(--muted)'
if (value >= 85) return 'var(--green)'
if (value >= 72) return 'var(--yellow)'
return 'var(--rose)'
}
const quickDisplay = (candidate) => {
const value = quickScore(candidate)
return value ? Math.round(value) : '--'
}
const aiDisplay = (candidate) => {
const value = aiScore(candidate)
if (value === null || value === undefined) return '--'
if (value === '需重评') return '需重评'
const numeric = Number(value)
return Number.isFinite(numeric) ? Math.round(numeric) : '--'
}
const stageToneClass = (stage) => {
if (['已入职', '待入职'].includes(stage)) return 'tone-success'
if (['面试中', 'Offer 中'].includes(stage)) return 'tone-warning'
if (['初筛未通过'].includes(stage)) return 'tone-danger'
if (['初筛通过'].includes(stage)) return 'tone-cyan'
return 'tone-neutral'
}
const profileSummary = (candidate) => {
const bits = [candidate.education, candidate.school, candidate.major].filter(Boolean)
const years = Number(candidate.years || 0)
const age = Number(candidate.age || 0)
if (years) bits.push(`${years} 年经验`)
if (age) bits.push(`${age} 岁`)
return bits.join(' · ')
}
const workflowFor = (candidate) => candidateWorkflow(candidate, store.state)
const openDetail = (candidate) => { const openDetail = (candidate) => {
store.selectedCandidateId = candidate.id store.selectedCandidateId = candidate.id
store.resumeViewMode = 'detail' store.resumeViewMode = 'detail'
...@@ -88,6 +143,12 @@ const runMatch = (candidate) => { ...@@ -88,6 +143,12 @@ const runMatch = (candidate) => {
showToast('已生成快速匹配') showToast('已生成快速匹配')
} }
const rowCommand = (command, candidate) => {
if (command === 'view') openDetail(candidate)
if (command === 'match') runMatch(candidate)
if (command === 'delete') deleteCandidate(candidate)
}
const matchAll = () => { const matchAll = () => {
const targets = filtered.value const targets = filtered.value
if (!targets.length) { if (!targets.length) {
...@@ -138,18 +199,37 @@ const openBatch = () => { ...@@ -138,18 +199,37 @@ const openBatch = () => {
</script> </script>
<template> <template>
<div> <div class="candidate-page">
<div class="cards grid-4"> <div class="metric-grid">
<article v-for="m in metrics" :key="m.label" class="metric" :style="{ '--accent': m.color }"> <article v-for="m in metrics" :key="m.label" class="metric-card" :style="{ '--accent': m.color }">
<div class="metric-copy">
<span>{{ m.label }}</span> <span>{{ m.label }}</span>
<strong>{{ m.value }}</strong> <strong>{{ m.value }}</strong>
<small>{{ m.hint }}</small> <small>{{ m.hint }}</small>
</div>
</article> </article>
</div> </div>
<section class="card resume-filter-card"> <section class="card candidate-list-card">
<div class="resume-filter-row"> <header class="list-head">
<el-input v-model="store.resumeFilters.q" placeholder="搜索姓名、岗位、来源、标签、技能" clearable /> <div class="list-head-copy">
<span class="section-kicker">人才库</span>
<h2>候选人列表</h2>
<p>已筛选 {{ filtered.length }} / {{ store.candidates.length }} 位候选人</p>
</div>
<div class="head-actions">
<el-button round @click="openCreate">新增候选人</el-button>
<el-button round type="primary" @click="openBatch">批量上传简历</el-button>
</div>
</header>
<div class="filter-row">
<el-input
v-model="store.resumeFilters.q"
class="filter-search"
placeholder="搜索姓名、岗位、学校、技能"
clearable
/>
<el-select v-model="store.resumeFilters.stage" placeholder="阶段"> <el-select v-model="store.resumeFilters.stage" placeholder="阶段">
<el-option v-for="option in stageOptions" :key="option" :label="option" :value="option" /> <el-option v-for="option in stageOptions" :key="option" :label="option" :value="option" />
</el-select> </el-select>
...@@ -162,30 +242,28 @@ const openBatch = () => { ...@@ -162,30 +242,28 @@ const openBatch = () => {
<el-option label="90+" value="90+" /> <el-option label="90+" value="90+" />
<el-option label="待分析" value="待分析" /> <el-option label="待分析" value="待分析" />
</el-select> </el-select>
<el-button :disabled="batchMatchRunning" @click="matchAll"> <el-button :disabled="batchMatchRunning" class="batch-btn" @click="matchAll">
{{ batchMatchRunning ? '匹配中...' : '批量重新匹配' }} {{ batchMatchRunning ? '匹配中...' : '批量重新匹配' }}
</el-button> </el-button>
<button v-if="hasFilters" type="button" class="clear-filters" @click="clearFilters">清除筛选</button>
</div> </div>
</section>
<section v-if="scopeJob" class="resume-scope-card"> <div v-if="scopeJob" class="scope-strip">
<div class="resume-scope-info"> <div>
<el-tag type="primary" effect="plain">岗位聚焦</el-tag> <el-tag type="primary" effect="plain">岗位聚焦</el-tag>
<strong>{{ scopeJob.title }}</strong> <strong>{{ scopeJob.title }}</strong>
<span class="muted">仅显示该岗位关联候选人</span> <span>仅显示该岗位关联候选人</span>
</div> </div>
<el-button size="small" @click="clearScope">查看全部候选人</el-button> <el-button size="small" @click="clearScope">查看全部候选人</el-button>
</section> </div>
<section <transition name="fade">
v-if="batchMatchResult" <div v-if="batchMatchResult" class="batch-strip" :class="batchMatchResult.failed ? 'is-warning' : 'is-success'">
class="batch-match-result" <div class="batch-summary">
:class="batchMatchResult.failed ? 'warning' : 'success'"
>
<div>
<strong>批量匹配已完成</strong> <strong>批量匹配已完成</strong>
<span>{{ batchMatchResult.scope }} · {{ batchMatchResult.time }}</span> <span>{{ batchMatchResult.scope }} · {{ batchMatchResult.time }}</span>
</div> </div>
<div class="batch-stats">
<div> <div>
<b>{{ batchMatchResult.matched }}</b <b>{{ batchMatchResult.matched }}</b
><span>完成匹配</span> ><span>完成匹配</span>
...@@ -202,224 +280,635 @@ const openBatch = () => { ...@@ -202,224 +280,635 @@ const openBatch = () => {
<b>{{ batchMatchResult.failed }}</b <b>{{ batchMatchResult.failed }}</b
><span>执行失败</span> ><span>执行失败</span>
</div> </div>
</section>
<section class="card candidate-list-card">
<div class="card-head">
<div>
<div class="card-title">候选人列表</div>
<div class="card-note">已筛选 {{ filtered.length }} / {{ store.candidates.length }}</div>
</div> </div>
<div class="mini-actions"> </div>
<el-button @click="openCreate">新增候选人</el-button> </transition>
<el-button type="primary" @click="openBatch">批量上传简历</el-button>
<div v-if="filtered.length" class="candidate-cards">
<article
v-for="candidate in filtered"
:key="candidate.id"
class="candidate-card"
tabindex="0"
@click="openDetail(candidate)"
@keydown.enter="openDetail(candidate)"
>
<div class="candidate-person">
<div class="candidate-avatar" :style="{ '--avatar-accent': scoreColor(quickScore(candidate)) }">
{{ candidateInitial(candidate) }}
</div>
<div class="candidate-copy">
<div class="candidate-name-row">
<strong>{{ candidate.name }}</strong>
<span class="stage-chip" :class="stageToneClass(candidate.stage)">{{
candidate.stage || '未筛选'
}}</span>
<span v-if="quickMatchNeedsRefresh(candidate)" class="refresh-chip">快筛待更新</span>
</div>
<div class="candidate-contact">
{{ candidate.phone || candidate.email || '联系方式待补全' }}
<span v-if="candidate.source && candidate.source !== '其他'" class="source-chip">{{
candidate.source
}}</span>
</div>
<div class="candidate-profile" :class="{ 'is-empty': !profileSummary(candidate) }">
{{ profileSummary(candidate) || '档案字段待补全' }}
</div>
</div> </div>
</div> </div>
<el-table :data="filtered" row-key="id" empty-text="没有符合筛选条件的候选人"> <div class="score-cluster">
<el-table-column label="候选人" min-width="160"> <div class="score-cell">
<template #default="{ row }"> <span>快筛</span>
<div class="primary-cell"> <b
<strong>{{ row.name }}</strong> :style="{
<small>{{ row.phone || row.email || '字段待补全' }}</small> color: quickDisplay(candidate) === '--' ? 'var(--muted)' : scoreColor(quickScore(candidate)),
}"
>
{{ quickDisplay(candidate) }}
</b>
</div> </div>
</template> <div class="score-cell">
</el-table-column> <span>AI</span>
<el-table-column label="求职岗位" min-width="170"> <b :style="{ color: aiDisplay(candidate) === '--' ? 'var(--muted)' : scoreColor(aiScore(candidate)) }">
<template #default="{ row }"> {{ aiDisplay(candidate) }}
</b>
</div>
</div>
<div class="candidate-flow" @click.stop>
<div class="flow-job">
<span class="cell-label">求职岗位</span>
<el-select <el-select
:model-value="row.jobId" :model-value="candidate.jobId"
size="small" size="small"
class="job-select"
placeholder="选择岗位" placeholder="选择岗位"
@change="(jobId) => bindJob(row, jobId)" @change="(jobId) => bindJob(candidate, jobId)"
> >
<el-option value="">未绑定</el-option> <el-option value="">未绑定</el-option>
<el-option v-for="job in store.jobs" :key="job.id" :label="job.title" :value="job.id" /> <el-option v-for="job in store.jobs" :key="job.id" :label="job.title" :value="job.id" />
</el-select> </el-select>
</template>
</el-table-column>
<el-table-column label="简历文件" min-width="150">
<template #default="{ row }">{{ row.resumeName || '未上传' }}</template>
</el-table-column>
<el-table-column label="快筛" width="90">
<template #default="{ row }">
<el-tag size="small" :type="scoreTag(quickScore(row)).type">{{ quickScore(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="AI" width="90">
<template #default="{ row }">
<el-tag
v-if="aiScore(row) !== null"
size="small"
:type="aiScore(row) === '需重评' ? 'danger' : scoreTag(aiScore(row)).type"
>
{{ aiScore(row) }}
</el-tag>
<span v-else class="muted">-</span>
</template>
</el-table-column>
<el-table-column label="来源 / 状态" min-width="140">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.source || '其他' }}</el-tag>
<el-tag size="small" class="ml-6">{{ row.stage || '未筛选' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="初筛结论" min-width="120">
<template #default="{ row }">{{ candidateScreeningConclusion(row, store.state) }}</template>
</el-table-column>
<el-table-column label="标记" min-width="120">
<template #default="{ row }">
<el-tag size="small">{{ row.tag || '无' }}</el-tag>
<small class="muted ml-6">{{ candidateRecentUpdate(row) }}</small>
</template>
</el-table-column>
<el-table-column label="下一步" min-width="160">
<template #default="{ row }">
<div class="next-cell">
<span>{{ row.workflow?.next?.title || '查看详情' }}</span>
<el-button link type="primary" @click="openDetail(row)">查看详情</el-button>
</div> </div>
<div class="flow-next">
<span class="cell-label">下一步</span>
<b>{{ workflowFor(candidate).next.title }}</b>
<small>{{ candidateScreeningConclusion(candidate, store.state) }}</small>
</div>
</div>
<div class="candidate-actions" @click.stop>
<el-button size="small" round type="primary" plain @click="openDetail(candidate)">查看档案</el-button>
<el-button size="small" round @click="runMatch(candidate)">快速匹配</el-button>
<el-dropdown trigger="click" @command="(command) => rowCommand(command, candidate)">
<el-button link class="more-btn">更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">查看档案</el-dropdown-item>
<el-dropdown-item command="match">生成快速匹配</el-dropdown-item>
<el-dropdown-item command="delete" divided>删除候选人</el-dropdown-item>
</el-dropdown-menu>
</template> </template>
</el-table-column> </el-dropdown>
<el-table-column label="操作" width="120" fixed="right"> </div>
<template #default="{ row }"> </article>
<el-button link type="primary" @click="openDetail(row)">查看</el-button> </div>
<el-button link @click="runMatch(row)">匹配</el-button>
<el-button link type="danger" @click="deleteCandidate(row)">删除</el-button> <div v-else class="empty-state">
</template> <strong>没有找到匹配的候选人</strong>
</el-table-column> <p>换个关键词或清除筛选条件,也可以批量上传新简历。</p>
</el-table> <el-button v-if="hasFilters" round @click="clearFilters">清除筛选</el-button>
</div>
</section> </section>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.grid-4 { .candidate-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.metric-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px; gap: 14px;
margin-bottom: 16px;
} }
.metric { .metric-card {
position: relative;
display: flex;
align-items: center;
min-height: 92px;
padding: 16px 18px 16px 20px;
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 14px;
padding: 16px 18px; box-shadow: 0 8px 20px rgba(39, 49, 70, 0.06);
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent);
}
.metric-copy {
span { span {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 12px;
} }
strong { strong {
display: block; display: block;
margin: 6px 0 4px; margin: 4px 0 3px;
font-size: 26px; font-size: 26px;
line-height: 1;
color: var(--accent); color: var(--accent);
font-variant-numeric: tabular-nums;
} }
small { small {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
} }
}
} }
.resume-filter-card { .candidate-list-card {
padding: 16px 18px; padding: 0;
margin-bottom: 16px; overflow: hidden;
}
.list-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 20px 20px 12px;
.list-head-copy {
.section-kicker {
display: block;
font-size: 11px;
letter-spacing: 2px;
color: var(--muted);
margin-bottom: 4px;
}
h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
}
p {
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
}
}
.head-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
} }
.resume-filter-row { .filter-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
gap: 10px; gap: 10px;
padding: 12px 20px 16px;
border-bottom: 1px solid var(--line);
.el-input { .filter-search {
width: 240px; width: min(300px, 100%);
} }
.el-select { .el-select {
width: 160px; width: 148px;
}
.batch-btn {
margin-left: auto;
}
.clear-filters {
border: 0;
background: transparent;
color: var(--blue);
cursor: pointer;
font-size: 13px;
&:hover {
color: var(--cyan);
}
}
}
.scope-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 14px 20px 0;
padding: 10px 14px;
background: color-mix(in srgb, var(--blue) 8%, #fff);
border: 1px solid color-mix(in srgb, var(--blue) 22%, var(--line));
border-radius: 10px;
div {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
min-width: 0;
strong {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
span {
color: var(--muted);
}
} }
} }
.batch-match-result { .batch-strip {
display: flex; display: flex;
gap: 20px;
align-items: center; align-items: center;
padding: 14px 18px; justify-content: space-between;
gap: 18px;
margin: 14px 20px 0;
padding: 12px 16px;
border-radius: 10px; border-radius: 10px;
margin-bottom: 16px;
&.success { &.is-success {
background: var(--green); background: color-mix(in srgb, var(--green) 14%, #fff);
color: #fff; border: 1px solid color-mix(in srgb, var(--green) 30%, var(--line));
color: color-mix(in srgb, var(--green) 70%, var(--ink));
} }
&.warning { &.is-warning {
background: var(--yellow); background: color-mix(in srgb, var(--yellow) 18%, #fff);
color: #333; border: 1px solid color-mix(in srgb, var(--yellow) 42%, var(--line));
color: color-mix(in srgb, var(--yellow) 55%, var(--ink));
} }
.batch-summary {
strong { strong {
display: block;
font-size: 14px; font-size: 14px;
} }
span { span {
font-size: 12px; font-size: 12px;
opacity: 0.9; opacity: 0.8;
}
} }
}
.mini-actions { .batch-stats {
display: flex; display: flex;
gap: 8px; gap: 16px;
}
.primary-cell { div {
display: flex; display: flex;
flex-direction: column; align-items: baseline;
gap: 5px;
opacity: 0.85;
strong { b {
font-size: 14px; font-size: 16px;
} }
small {
color: var(--muted); span {
font-size: 12px; font-size: 12px;
} }
}
}
} }
.next-cell { .candidate-cards {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 10px;
padding: 14px 20px 20px;
}
span { .candidate-card {
font-size: 13px; display: grid;
grid-template-columns: minmax(240px, 1.5fr) minmax(150px, 0.7fr) minmax(220px, 0.95fr) auto;
align-items: center;
gap: 16px;
padding: 13px 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel);
cursor: pointer;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
&:hover {
border-color: color-mix(in srgb, var(--blue) 40%, var(--line));
box-shadow: 0 10px 24px rgba(39, 49, 70, 0.08);
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 1px;
}
}
.candidate-person {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.candidate-avatar {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 42px;
height: 42px;
border-radius: 13px;
background: color-mix(in srgb, var(--avatar-accent, var(--blue)) 14%, #fff);
color: var(--avatar-accent, var(--blue));
font-size: 16px;
font-weight: 700;
border: 1px solid color-mix(in srgb, var(--avatar-accent, var(--blue)) 26%, transparent);
}
.candidate-copy {
min-width: 0;
}
.candidate-name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
strong {
font-size: 15px;
}
}
.stage-chip {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
white-space: nowrap;
&.tone-success {
background: color-mix(in srgb, var(--green) 13%, #fff);
color: color-mix(in srgb, var(--green) 78%, var(--ink));
}
&.tone-warning {
background: color-mix(in srgb, var(--yellow) 18%, #fff);
color: color-mix(in srgb, var(--yellow) 55%, var(--ink));
}
&.tone-danger {
background: color-mix(in srgb, var(--rose) 12%, #fff);
color: color-mix(in srgb, var(--rose) 80%, var(--ink));
}
&.tone-cyan {
background: color-mix(in srgb, var(--cyan) 15%, #fff);
color: color-mix(in srgb, var(--blue) 70%, var(--cyan));
}
&.tone-neutral {
background: var(--bg);
color: var(--muted);
} }
} }
.ml-6 { .refresh-chip {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 6px;
background: color-mix(in srgb, var(--yellow) 18%, #fff);
color: color-mix(in srgb, var(--yellow) 50%, var(--ink));
font-size: 10px;
white-space: nowrap;
}
.source-chip {
display: inline-flex;
align-items: center;
margin-left: 6px; margin-left: 6px;
padding: 0 6px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--bg);
color: var(--muted);
font-size: 10px;
}
.candidate-contact {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.resume-scope-card { .candidate-profile {
margin-top: 3px;
color: color-mix(in srgb, var(--ink) 72%, var(--blue));
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.is-empty {
color: var(--muted);
}
}
.score-cluster {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 10px;
}
.score-cell {
flex: 1;
display: flex;
align-items: baseline;
gap: 6px;
min-width: 60px;
span {
color: var(--muted);
font-size: 11px;
}
b {
font-size: 21px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
}
.candidate-flow {
display: grid;
grid-template-columns: minmax(120px, 0.9fr) minmax(130px, 1.1fr);
gap: 12px; gap: 12px;
margin-top: 12px; align-items: start;
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)); .flow-job,
border-radius: 8px; .flow-next {
min-width: 0;
.job-select {
width: 100%;
}
}
.cell-label {
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 4px;
}
.flow-next {
b {
display: block;
font-size: 13px;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
display: block;
margin-top: 3px;
color: var(--muted);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.resume-scope-info { .candidate-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
.more-btn {
color: var(--muted);
}
}
.empty-state {
padding: 40px 20px;
text-align: center;
color: var(--muted);
strong {
display: block;
font-size: 16px;
color: var(--ink);
margin-bottom: 6px;
}
p {
margin: 0 0 14px;
font-size: 13px; font-size: 13px;
} }
} }
.fade-enter-active,
.fade-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
@media (max-width: 1280px) {
.candidate-card {
grid-template-columns: minmax(220px, 1.4fr) minmax(120px, 0.6fr) minmax(220px, 1fr) auto;
gap: 12px;
}
}
@media (max-width: 1120px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.candidate-card {
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
row-gap: 12px;
.score-cluster,
.candidate-actions {
justify-content: flex-start;
}
}
}
@media (max-width: 720px) {
.metric-grid {
grid-template-columns: 1fr;
}
.candidate-card {
grid-template-columns: 1fr;
.candidate-flow {
grid-template-columns: 1fr;
}
}
.batch-strip {
flex-direction: column;
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.candidate-card,
.fade-enter-active,
.fade-leave-active {
transition: none;
}
}
</style> </style>
...@@ -721,6 +721,13 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -721,6 +721,13 @@ export const useRecruitmentStore = defineStore('recruitment', {
return api.parseResume(file) return api.parseResume(file)
}, },
async uploadResumeFile(file) {
if (window.location.protocol === 'file:') {
return { file: null, parsed: { resumeName: file.name, parseWarning: '当前为 file 模式,未保存原简历。' } }
}
return api.uploadResumeFile(file)
},
offerSalarySummary(offer = {}) { offerSalarySummary(offer = {}) {
if (offer.salary && offer.salary !== '待确认') return offer.salary if (offer.salary && offer.salary !== '待确认') return offer.salary
const parts = [ const parts = [
......
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