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

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

parent ddc5340f
import mimetypes
from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
......@@ -32,4 +34,9 @@ def get_resume(filename: str) -> FileResponse:
path = resolve_resume_path(filename)
if not path:
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) {
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) {
const { data } = await http.post('/api/analyze-resume', payload)
if (data.error) throw new Error(data.error)
......
......@@ -13,12 +13,12 @@ import {
extractSkills,
schoolTags,
} from '@/utils/inference'
import { fileToDataUrl } from '@/utils/file'
import { showToast } from '@/utils/toast'
const store = useRecruitmentStore()
const parsing = ref(false)
const importing = ref(false)
const ready = computed(
() => store.candidateBatchEntries.filter((entry) => entry.status === 'ready' && entry.jobId).length
......@@ -62,8 +62,6 @@ const setBatchSource = (source) => {
const handleFiles = async (uploadFile) => {
const file = uploadFile.raw
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 = {
id: uid('batch-resume'),
file,
......@@ -99,15 +97,7 @@ const handleFiles = async (uploadFile) => {
entry.parseWarning = parsed.parseWarning || ''
entry.status = 'ready'
entry.jobId = store.candidateBatchMode === 'job' ? store.candidateBatchJobId : store.smartBatchJobId(entry)
if (cacheOriginalFiles && entry.file.size <= 2 * 1024 * 1024) {
try {
entry.resumeFileDataUrl = await fileToDataUrl(entry.file)
} catch (error) {
entry.resumeFilePreviewWarning = error.message
}
} else {
entry.resumeFilePreviewWarning = '批次文件较大,仅保存解析结果,未缓存原文件预览。'
}
entry.resumeFilePreviewWarning = ''
} catch (error) {
entry.status = 'error'
entry.error = error.message || '解析失败'
......@@ -130,9 +120,9 @@ const entryStatusView = (entry) => {
return { text: entry.parseWarning ? '字段待核对' : '可入库', type: 'success' }
}
const importBatch = () => {
if (store.candidateBatchParsing) {
showToast('简历仍在解析,请稍候', 'warning')
const importBatch = async () => {
if (store.candidateBatchParsing || importing.value) {
showToast('简历仍在解析或入库中,请稍候', 'warning')
return
}
const importable = store.candidateBatchEntries.filter((entry) => entry.status === 'ready' && entry.jobId)
......@@ -140,72 +130,99 @@ const importBatch = () => {
showToast('请处理解析失败项并确认每份简历的关联岗位', 'warning')
return
}
const created = []
let duplicateCount = 0
importable.forEach((entry) => {
const parsed = entry.parsed || {}
const job = store.jobs.find((item) => item.id === entry.jobId)
const name = cleanCandidateName(parsed.name || '', entry.file?.name) || '未命名候选人'
const duplicate = store.candidates.some(
(candidate) =>
candidate.resumeName === entry.file?.name && cleanCandidateName(candidate.name, candidate.resumeName) === name
)
if (duplicate) {
duplicateCount += 1
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 resumeText = parsed.resumeText || ''
const candidate = enrichCandidateFields({
id: uid('cand'),
name,
jobId: job.id,
jobTitle: job.title,
source: parsed.source || store.candidateBatchSource,
stage: '未筛选',
match: 0,
resumeName: entry.file?.name || parsed.resumeName || '',
resumeMeta: entry.file ? { name: entry.file.name, type: entry.file.type, size: entry.file.size } : null,
resumeFileDataUrl: entry.resumeFileDataUrl || '',
resumeFilePreviewWarning: entry.resumeFilePreviewWarning || '',
resumeText,
phone: parsed.phone || '',
email: parsed.email || '',
birthDate: parsed.birthDate || inferBirthDate(resumeText),
age: parsed.age || ageFromBirthDate(parsed.birthDate || inferBirthDate(resumeText)),
years: parsed.years || inferWorkYears(`${resumeText}\n${entry.file?.name || ''}`),
education: parsed.education || '',
school: parsed.school || inferSchool(resumeText),
major: parsed.major || inferMajor(resumeText),
schoolTags: parsed.schoolTags?.length ? parsed.schoolTags : schoolTags(parsed.school || inferSchool(resumeText)),
city: parsed.city || '',
skills: parsed.skills?.length ? parsed.skills : extractSkills(resumeText),
parseWarning: parsed.parseWarning || '',
tag: '新入库',
evaluation: '批量上传入库',
})
created.push(candidate)
store.logEvent('candidate_created', candidate, {
stage: candidate.stage,
source: candidate.source || '其他',
batch: true,
const created = []
let duplicateCount = 0
importable.forEach((entry) => {
const upload = entry.upload || {}
const parsed = entry.parsed || upload.parsed || {}
const job = store.jobs.find((item) => item.id === entry.jobId)
const name = cleanCandidateName(parsed.name || '', entry.file?.name) || '未命名候选人'
const duplicate = store.candidates.some(
(candidate) =>
candidate.resumeName === entry.file?.name && cleanCandidateName(candidate.name, candidate.resumeName) === name
)
if (duplicate) {
duplicateCount += 1
return
}
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({
id: uid('cand'),
name,
jobId: job.id,
jobTitle: job.title,
source: parsed.source || store.candidateBatchSource,
stage: '未筛选',
match: 0,
resumeName: fileMeta?.name || entry.file?.name || parsed.resumeName || '',
resumeMeta: fileMeta,
resumeFileDataUrl: upload?.file?.url || '',
resumeFilePreviewWarning: upload?.file?.url ? '' : '原简历未写入后端存储,仅保留解析结果。',
resumeText,
phone: parsed.phone || '',
email: parsed.email || '',
birthDate: parsed.birthDate || inferBirthDate(resumeText),
age: parsed.age || ageFromBirthDate(parsed.birthDate || inferBirthDate(resumeText)),
years: parsed.years || inferWorkYears(`${resumeText}\n${entry.file?.name || ''}`),
education: parsed.education || '',
school: parsed.school || inferSchool(resumeText),
major: parsed.major || inferMajor(resumeText),
schoolTags: parsed.schoolTags?.length
? parsed.schoolTags
: schoolTags(parsed.school || inferSchool(resumeText)),
city: parsed.city || '',
skills: parsed.skills?.length ? parsed.skills : extractSkills(resumeText),
parseWarning: parsed.parseWarning || '',
tag: '新入库',
evaluation: '批量上传入库',
})
created.push(candidate)
store.logEvent('candidate_created', candidate, {
stage: candidate.stage,
source: candidate.source || '其他',
batch: true,
})
})
})
store.candidates.unshift(...created)
store.reconcile()
created.forEach((candidate) => {
store.localMatch(candidate.id)
store.logEvent('local_match_generated', candidate, {
score: candidate.quickMatchScore || candidate.match || 0,
batch: true,
store.candidates.unshift(...created)
store.reconcile()
created.forEach((candidate) => {
store.localMatch(candidate.id)
store.logEvent('local_match_generated', candidate, {
score: candidate.quickMatchScore || candidate.match || 0,
batch: true,
})
})
})
store.candidateBatchOpen = false
store.candidateBatchEntries = []
store.candidateBatchParsing = false
if (created[0]) store.selectedCandidateId = created[0].id
store.resumeViewMode = 'list'
store.persist('批量简历已入库')
showToast(`已批量入库 ${created.length} ${duplicateCount ? `,跳过重复 ${duplicateCount} 份` : ''}`)
store.candidateBatchOpen = false
store.candidateBatchEntries = []
store.candidateBatchParsing = false
if (created[0]) store.selectedCandidateId = created[0].id
store.resumeViewMode = 'list'
store.persist('批量简历已入库')
showToast(`已批量入库 ${created.length} ${duplicateCount ? `,跳过重复 ${duplicateCount} 份` : ''}`)
} finally {
importing.value = false
}
}
</script>
......@@ -305,8 +322,8 @@ const importBatch = () => {
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :disabled="!canImport" @click="importBatch">
确认入库 {{ ready ? `(${ready})` : '' }}
<el-button type="primary" :disabled="importing || !canImport" @click="importBatch">
{{ importing ? '正在保存原简历...' : `确认入库 ${ready ? `(${ready})` : ''}` }}
</el-button>
</template>
</el-dialog>
......
......@@ -13,7 +13,6 @@ import {
extractSkills,
schoolTags,
} from '@/utils/inference'
import { fileToDataUrl } from '@/utils/file'
import { showToast } from '@/utils/toast'
const store = useRecruitmentStore()
......@@ -30,6 +29,7 @@ const form = reactive({
const resumeFile = ref(null)
const parsing = ref(false)
const saving = ref(false)
const suggestions = computed(() => {
if (!form.resumeText && !form.jobTitle) return []
......@@ -85,36 +85,19 @@ const pickJob = (jobId) => {
}
const submit = async () => {
if (saving.value) return
const file = resumeFile.value
let parsedResume = {}
let resumeText = form.resumeText
let resumeFileDataUrl = ''
let resumeFilePreviewWarning = ''
let fileMeta = null
if (!file && !resumeText.trim()) {
showToast('请先上传简历文件,或在补充区填写简历文本', 'warning')
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 selectedJob = store.jobs.find((job) => job.id === form.jobId)
if (!selectedJob) {
......@@ -122,52 +105,82 @@ const submit = async () => {
return
}
const candidate = enrichCandidateFields({
id: uid('cand'),
name: cleanCandidateName(form.name || parsedResume.name || fallbackName, file?.name),
jobId: selectedJob?.id || '',
jobTitle: selectedJob?.title || form.jobTitle || parsedResume.jobTitle || '待匹配岗位',
source: parsedResume.source || form.source,
stage: form.stage,
match: 0,
resumeName: file?.name || parsedResume.resumeName || '',
resumeMeta: file ? { name: file.name, type: file.type, size: file.size } : null,
resumeFileDataUrl,
resumeFilePreviewWarning,
resumeText,
phone: parsedResume.phone || '',
email: parsedResume.email || '',
birthDate: parsedResume.birthDate || inferBirthDate(resumeText),
age: parsedResume.age || ageFromBirthDate(parsedResume.birthDate || inferBirthDate(resumeText)),
years: parsedResume.years || inferWorkYears(`${resumeText}\n${file?.name || ''}`),
education: parsedResume.education || '',
school: parsedResume.school || inferSchool(resumeText),
major: parsedResume.major || inferMajor(resumeText),
schoolTags: parsedResume.schoolTags?.length
? parsedResume.schoolTags
: schoolTags(parsedResume.school || inferSchool(resumeText)),
city: parsedResume.city || '',
skills: parsedResume.skills?.length ? parsedResume.skills : extractSkills(resumeText),
tag: '新入库',
evaluation: form.evaluation,
})
store.candidates.unshift(candidate)
store.reconcile()
store.localMatch(candidate.id)
store.logEvent('candidate_created', candidate, { stage: candidate.stage, source: candidate.source || '其他' })
store.logEvent('local_match_generated', candidate, { score: candidate.quickMatchScore || candidate.match || 0 })
store.selectedCandidateId = candidate.id
store.candidateCreateDraft = null
store.candidateCreateOpen = false
store.resumeViewMode = 'detail'
store.resumeDetailSection = 'overview'
store.persist()
showToast('候选人已保存并完成本地匹配')
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({
id: uid('cand'),
name: cleanCandidateName(form.name || parsedResume.name || fallbackName, file?.name),
jobId: selectedJob?.id || '',
jobTitle: selectedJob?.title || form.jobTitle || parsedResume.jobTitle || '待匹配岗位',
source: parsedResume.source || form.source,
stage: form.stage,
match: 0,
resumeName: fileMeta?.name || file?.name || parsedResume.resumeName || '',
resumeMeta: fileMeta || (file ? { name: file.name, type: file.type, size: file.size } : null),
resumeFileDataUrl,
resumeFilePreviewWarning,
resumeText,
phone: parsedResume.phone || '',
email: parsedResume.email || '',
birthDate: parsedResume.birthDate || inferBirthDate(resumeText),
age: parsedResume.age || ageFromBirthDate(parsedResume.birthDate || inferBirthDate(resumeText)),
years: parsedResume.years || inferWorkYears(`${resumeText}\n${file?.name || ''}`),
education: parsedResume.education || '',
school: parsedResume.school || inferSchool(resumeText),
major: parsedResume.major || inferMajor(resumeText),
schoolTags: parsedResume.schoolTags?.length
? parsedResume.schoolTags
: schoolTags(parsedResume.school || inferSchool(resumeText)),
city: parsedResume.city || '',
skills: parsedResume.skills?.length ? parsedResume.skills : extractSkills(resumeText),
tag: '新入库',
evaluation: form.evaluation,
})
store.candidates.unshift(candidate)
store.reconcile()
store.localMatch(candidate.id)
store.logEvent('candidate_created', candidate, { stage: candidate.stage, source: candidate.source || '其他' })
store.logEvent('local_match_generated', candidate, { score: candidate.quickMatchScore || candidate.match || 0 })
store.selectedCandidateId = candidate.id
store.candidateCreateDraft = null
store.candidateCreateOpen = false
store.resumeViewMode = 'detail'
store.resumeDetailSection = 'overview'
store.persist()
showToast('候选人已保存并完成本地匹配')
} catch (error) {
showToast(`保存失败:${error.message}`, 'error')
} finally {
saving.value = false
}
}
</script>
<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">
<section class="candidate-create-block">
<div class="block-head">
......@@ -250,11 +263,21 @@ const submit = async () => {
/>
<el-input v-model="form.evaluation" type="textarea" :rows="2" placeholder="可选:初筛备注、来源说明、关注点" />
</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>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" @click="submit">保存并匹配</el-button>
<el-button :disabled="saving" @click="close">取消</el-button>
<el-button type="primary" :loading="saving" :disabled="saving" @click="submit">保存并匹配</el-button>
</template>
</el-dialog>
</template>
......@@ -310,4 +333,73 @@ const submit = async () => {
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>
......@@ -140,11 +140,43 @@ watch(drawerModel, (open) => {
if (!open) ringDrawn.value = false
})
// ---- 简历 PDF 预览(带鉴权拉取 → blob URL)----
// ---- 原始简历:格式判断 + 带鉴权拉取预览 ----
const dockOpen = ref(false)
const resumeSrc = ref('')
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() {
if (resumeObjectUrl) {
URL.revokeObjectURL(resumeObjectUrl)
......@@ -155,15 +187,14 @@ function revokeResumeUrl() {
async function syncResumeSrc(url) {
revokeResumeUrl()
if (!url) return
if (!url || !resumeInline.value) 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' })
const blob = response.data instanceof Blob ? response.data : new Blob([response.data])
resumeObjectUrl = URL.createObjectURL(blob)
}
resumeSrc.value = resumeObjectUrl
......@@ -182,7 +213,7 @@ watch(
const fileMetaText = computed(() => {
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 ? '原始简历文件' : '未上传简历文件'
})
......@@ -194,6 +225,41 @@ function formatFileSize(bytes) {
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 = () => {
if (resumeSrc.value) window.open(resumeSrc.value, '_blank')
}
......@@ -508,7 +574,19 @@ const saveInterview = (candidate) => {
</div>
</div>
<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
text
circle
......@@ -527,7 +605,7 @@ const saveInterview = (candidate) => {
</div>
</div>
<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">
<pre>{{ selected.resumeText || '未上传简历文件,暂无正文可预览。' }}</pre>
</div>
......
......@@ -5,7 +5,8 @@ import { useRoute, useRouter } from 'vue-router'
import { useRecruitmentStore } from '@/stores/recruitment'
import { jobCandidates } from '@/utils/links'
import { getFilteredCandidates, quickMatchNeedsRefresh } from '@/utils/matching'
import { quickScore, aiScore, candidateScreeningConclusion, candidateRecentUpdate } from '@/utils/resume'
import { aiScore, candidateScreeningConclusion, quickScore } from '@/utils/resume'
import { candidateWorkflow } from '@/utils/task'
import { showToast } from '@/utils/toast'
const store = useRecruitmentStore()
......@@ -16,7 +17,10 @@ const batchMatchRunning = ref(false)
const batchMatchResult = ref(null)
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)
......@@ -42,11 +46,62 @@ const metrics = computed(() => [
{ label: '待补全', value: needParseReview.value, hint: '解析字段或正文缺失', color: 'var(--yellow)' },
])
const scoreTag = (score) => ({
text: score,
type: score >= 85 ? 'success' : score >= 72 ? 'warning' : 'danger',
const hasFilters = computed(() => {
const f = store.resumeFilters || {}
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) => {
store.selectedCandidateId = candidate.id
store.resumeViewMode = 'detail'
......@@ -88,6 +143,12 @@ const runMatch = (candidate) => {
showToast('已生成快速匹配')
}
const rowCommand = (command, candidate) => {
if (command === 'view') openDetail(candidate)
if (command === 'match') runMatch(candidate)
if (command === 'delete') deleteCandidate(candidate)
}
const matchAll = () => {
const targets = filtered.value
if (!targets.length) {
......@@ -138,18 +199,37 @@ const openBatch = () => {
</script>
<template>
<div>
<div class="cards grid-4">
<article v-for="m in metrics" :key="m.label" class="metric" :style="{ '--accent': m.color }">
<span>{{ m.label }}</span>
<strong>{{ m.value }}</strong>
<small>{{ m.hint }}</small>
<div class="candidate-page">
<div class="metric-grid">
<article v-for="m in metrics" :key="m.label" class="metric-card" :style="{ '--accent': m.color }">
<div class="metric-copy">
<span>{{ m.label }}</span>
<strong>{{ m.value }}</strong>
<small>{{ m.hint }}</small>
</div>
</article>
</div>
<section class="card resume-filter-card">
<div class="resume-filter-row">
<el-input v-model="store.resumeFilters.q" placeholder="搜索姓名、岗位、来源、标签、技能" clearable />
<section class="card candidate-list-card">
<header class="list-head">
<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-option v-for="option in stageOptions" :key="option" :label="option" :value="option" />
</el-select>
......@@ -162,264 +242,673 @@ const openBatch = () => {
<el-option label="90+" value="90+" />
<el-option label="待分析" value="待分析" />
</el-select>
<el-button :disabled="batchMatchRunning" @click="matchAll">
<el-button :disabled="batchMatchRunning" class="batch-btn" @click="matchAll">
{{ batchMatchRunning ? '匹配中...' : '批量重新匹配' }}
</el-button>
<button v-if="hasFilters" type="button" class="clear-filters" @click="clearFilters">清除筛选</button>
</div>
</section>
<section v-if="scopeJob" class="resume-scope-card">
<div class="resume-scope-info">
<el-tag type="primary" effect="plain">岗位聚焦</el-tag>
<strong>{{ scopeJob.title }}</strong>
<span class="muted">仅显示该岗位关联候选人</span>
</div>
<el-button size="small" @click="clearScope">查看全部候选人</el-button>
</section>
<section
v-if="batchMatchResult"
class="batch-match-result"
:class="batchMatchResult.failed ? 'warning' : 'success'"
>
<div>
<strong>批量匹配已完成</strong>
<span>{{ batchMatchResult.scope }} · {{ batchMatchResult.time }}</span>
</div>
<div>
<b>{{ batchMatchResult.matched }}</b
><span>完成匹配</span>
</div>
<div>
<b>{{ batchMatchResult.noText }}</b
><span>缺少正文</span>
</div>
<div>
<b>{{ batchMatchResult.noJob }}</b
><span>未绑岗位</span>
</div>
<div>
<b>{{ batchMatchResult.failed }}</b
><span>执行失败</span>
</div>
</section>
<section class="card candidate-list-card">
<div class="card-head">
<div v-if="scopeJob" class="scope-strip">
<div>
<div class="card-title">候选人列表</div>
<div class="card-note">已筛选 {{ filtered.length }} / {{ store.candidates.length }}</div>
</div>
<div class="mini-actions">
<el-button @click="openCreate">新增候选人</el-button>
<el-button type="primary" @click="openBatch">批量上传简历</el-button>
<el-tag type="primary" effect="plain">岗位聚焦</el-tag>
<strong>{{ scopeJob.title }}</strong>
<span>仅显示该岗位关联候选人</span>
</div>
<el-button size="small" @click="clearScope">查看全部候选人</el-button>
</div>
<el-table :data="filtered" row-key="id" empty-text="没有符合筛选条件的候选人">
<el-table-column label="候选人" min-width="160">
<template #default="{ row }">
<div class="primary-cell">
<strong>{{ row.name }}</strong>
<small>{{ row.phone || row.email || '字段待补全' }}</small>
<transition name="fade">
<div v-if="batchMatchResult" class="batch-strip" :class="batchMatchResult.failed ? 'is-warning' : 'is-success'">
<div class="batch-summary">
<strong>批量匹配已完成</strong>
<span>{{ batchMatchResult.scope }} · {{ batchMatchResult.time }}</span>
</div>
<div class="batch-stats">
<div>
<b>{{ batchMatchResult.matched }}</b
><span>完成匹配</span>
</div>
<div>
<b>{{ batchMatchResult.noText }}</b
><span>缺少正文</span>
</div>
<div>
<b>{{ batchMatchResult.noJob }}</b
><span>未绑岗位</span>
</div>
<div>
<b>{{ batchMatchResult.failed }}</b
><span>执行失败</span>
</div>
</div>
</div>
</transition>
<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>
</template>
</el-table-column>
<el-table-column label="求职岗位" min-width="170">
<template #default="{ row }">
<el-select
:model-value="row.jobId"
size="small"
placeholder="选择岗位"
@change="(jobId) => bindJob(row, jobId)"
>
<el-option value="">未绑定</el-option>
<el-option v-for="job in store.jobs" :key="job.id" :label="job.title" :value="job.id" />
</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 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>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">查看</el-button>
<el-button link @click="runMatch(row)">匹配</el-button>
<el-button link type="danger" @click="deleteCandidate(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="score-cluster">
<div class="score-cell">
<span>快筛</span>
<b
:style="{
color: quickDisplay(candidate) === '--' ? 'var(--muted)' : scoreColor(quickScore(candidate)),
}"
>
{{ quickDisplay(candidate) }}
</b>
</div>
<div class="score-cell">
<span>AI</span>
<b :style="{ color: aiDisplay(candidate) === '--' ? 'var(--muted)' : scoreColor(aiScore(candidate)) }">
{{ aiDisplay(candidate) }}
</b>
</div>
</div>
<div class="candidate-flow" @click.stop>
<div class="flow-job">
<span class="cell-label">求职岗位</span>
<el-select
:model-value="candidate.jobId"
size="small"
class="job-select"
placeholder="选择岗位"
@change="(jobId) => bindJob(candidate, jobId)"
>
<el-option value="">未绑定</el-option>
<el-option v-for="job in store.jobs" :key="job.id" :label="job.title" :value="job.id" />
</el-select>
</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>
</el-dropdown>
</div>
</article>
</div>
<div v-else class="empty-state">
<strong>没有找到匹配的候选人</strong>
<p>换个关键词或清除筛选条件,也可以批量上传新简历。</p>
<el-button v-if="hasFilters" round @click="clearFilters">清除筛选</el-button>
</div>
</section>
</div>
</template>
<style lang="scss" scoped>
.grid-4 {
.candidate-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
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);
border: 1px solid var(--line);
border-radius: 12px;
padding: 16px 18px;
span {
color: var(--muted);
font-size: 13px;
border-radius: 14px;
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);
}
strong {
display: block;
margin: 6px 0 4px;
font-size: 26px;
color: var(--accent);
}
.metric-copy {
span {
color: var(--muted);
font-size: 12px;
}
small {
color: var(--muted);
font-size: 12px;
strong {
display: block;
margin: 4px 0 3px;
font-size: 26px;
line-height: 1;
color: var(--accent);
font-variant-numeric: tabular-nums;
}
small {
color: var(--muted);
font-size: 12px;
}
}
}
.resume-filter-card {
padding: 16px 18px;
margin-bottom: 16px;
.candidate-list-card {
padding: 0;
overflow: hidden;
}
.resume-filter-row {
.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;
}
}
.filter-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 12px 20px 16px;
border-bottom: 1px solid var(--line);
.el-input {
width: 240px;
.filter-search {
width: min(300px, 100%);
}
.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);
}
}
}
.batch-match-result {
.scope-strip {
display: flex;
gap: 20px;
align-items: center;
padding: 14px 18px;
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;
margin-bottom: 16px;
&.success {
background: var(--green);
color: #fff;
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);
}
}
}
&.warning {
background: var(--yellow);
color: #333;
.batch-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin: 14px 20px 0;
padding: 12px 16px;
border-radius: 10px;
&.is-success {
background: color-mix(in srgb, var(--green) 14%, #fff);
border: 1px solid color-mix(in srgb, var(--green) 30%, var(--line));
color: color-mix(in srgb, var(--green) 70%, var(--ink));
}
strong {
font-size: 14px;
&.is-warning {
background: color-mix(in srgb, var(--yellow) 18%, #fff);
border: 1px solid color-mix(in srgb, var(--yellow) 42%, var(--line));
color: color-mix(in srgb, var(--yellow) 55%, var(--ink));
}
span {
font-size: 12px;
opacity: 0.9;
.batch-summary {
strong {
display: block;
font-size: 14px;
}
span {
font-size: 12px;
opacity: 0.8;
}
}
.batch-stats {
display: flex;
gap: 16px;
div {
display: flex;
align-items: baseline;
gap: 5px;
opacity: 0.85;
b {
font-size: 16px;
}
span {
font-size: 12px;
}
}
}
}
.candidate-cards {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 20px 20px;
}
.candidate-card {
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;
}
}
.mini-actions {
.candidate-person {
display: flex;
gap: 8px;
align-items: center;
gap: 12px;
min-width: 0;
}
.primary-cell {
.candidate-avatar {
display: flex;
flex-direction: column;
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: 14px;
font-size: 15px;
}
small {
}
.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);
font-size: 12px;
}
}
.next-cell {
.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;
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;
}
.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;
flex-direction: column;
gap: 2px;
align-items: center;
gap: 10px;
}
.score-cell {
flex: 1;
display: flex;
align-items: baseline;
gap: 6px;
min-width: 60px;
span {
font-size: 13px;
color: var(--muted);
font-size: 11px;
}
b {
font-size: 21px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
}
.ml-6 {
margin-left: 6px;
.candidate-flow {
display: grid;
grid-template-columns: minmax(120px, 0.9fr) minmax(130px, 1.1fr);
gap: 12px;
align-items: start;
}
.flow-job,
.flow-next {
min-width: 0;
.job-select {
width: 100%;
}
}
.cell-label {
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 4px;
}
.resume-scope-card {
.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;
}
}
.candidate-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
padding: 10px 14px;
background: color-mix(in srgb, var(--blue) 8%, var(--panel));
border: 1px solid color-mix(in srgb, var(--blue) 22%, var(--line));
border-radius: 8px;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
.resume-scope-info {
display: flex;
align-items: center;
gap: 8px;
.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;
}
}
.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>
......@@ -721,6 +721,13 @@ export const useRecruitmentStore = defineStore('recruitment', {
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 = {}) {
if (offer.salary && offer.salary !== '待确认') return offer.salary
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