Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
R
recruit-sys
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
李文光
recruit-sys
Commits
07310dd8
Commit
07310dd8
authored
Sep 02, 2026
by
李文光
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 重构候选人列表并保存原简历,支持档案预览与防重复提交
parent
ddc5340f
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
1048 additions
and
348 deletions
+1048
-348
resume.py
backend/app/routers/resume.py
+8
-1
recruitment.js
vue-app/src/api/recruitment.js
+10
-0
CandidateBatchModal.vue
vue-app/src/components/resumes/CandidateBatchModal.vue
+97
-80
CandidateCreateModal.vue
vue-app/src/components/resumes/CandidateCreateModal.vue
+156
-64
ResumeDetail.vue
vue-app/src/components/resumes/ResumeDetail.vue
+85
-7
ResumeList.vue
vue-app/src/components/resumes/ResumeList.vue
+685
-196
recruitment.js
vue-app/src/stores/recruitment.js
+7
-0
No files found.
backend/app/routers/resume.py
View file @
07310dd8
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"
},
)
vue-app/src/api/recruitment.js
View file @
07310dd8
...
...
@@ -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
)
...
...
vue-app/src/components/resumes/CandidateBatchModal.vue
View file @
07310dd8
...
...
@@ -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,10 +130,23 @@ const importBatch = () => {
showToast
(
'请处理解析失败项并确认每份简历的关联岗位'
,
'warning'
)
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
=
[]
let
duplicateCount
=
0
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
name
=
cleanCandidateName
(
parsed
.
name
||
''
,
entry
.
file
?.
name
)
||
'未命名候选人'
const
duplicate
=
store
.
candidates
.
some
(
...
...
@@ -155,6 +158,15 @@ const importBatch = () => {
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
,
...
...
@@ -163,10 +175,10 @@ const importBatch = () => {
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
.
resumeFileDataU
rl
||
''
,
resumeFilePreviewWarning
:
entry
.
resumeFilePreviewWarning
||
'
'
,
resumeName
:
fileMeta
?.
name
||
entry
.
file
?.
name
||
parsed
.
resumeName
||
''
,
resumeMeta
:
fileMeta
,
resumeFileDataUrl
:
upload
?.
file
?.
u
rl
||
''
,
resumeFilePreviewWarning
:
upload
?.
file
?.
url
?
''
:
'原简历未写入后端存储,仅保留解析结果。
'
,
resumeText
,
phone
:
parsed
.
phone
||
''
,
email
:
parsed
.
email
||
''
,
...
...
@@ -176,7 +188,9 @@ const importBatch = () => {
education: parsed.education || '',
school: parsed.school || inferSchool(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 || '',
skills: parsed.skills?.length ? parsed.skills : extractSkills(resumeText),
parseWarning: parsed.parseWarning || '',
...
...
@@ -206,6 +220,9 @@ const importBatch = () => {
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
>
...
...
vue-app/src/components/resumes/CandidateCreateModal.vue
View file @
07310dd8
...
...
@@ -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,6 +105,23 @@ const submit = async () => {
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
({
id
:
uid
(
'cand'
),
name
:
cleanCandidateName
(
form
.
name
||
parsedResume
.
name
||
fallbackName
,
file
?.
name
),
...
...
@@ -130,8 +130,8 @@ const submit = async () => {
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
,
resumeName
:
fileMeta
?.
name
||
file
?.
name
||
parsedResume
.
resumeName
||
''
,
resumeMeta
:
fileMeta
||
(
file
?
{
name
:
file
.
name
,
type
:
file
.
type
,
size
:
file
.
size
}
:
null
)
,
resumeFileDataUrl
,
resumeFilePreviewWarning
,
resumeText
,
...
...
@@ -163,11 +163,24 @@ const submit = async () => {
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
>
vue-app/src/components/resumes/ResumeDetail.vue
View file @
07310dd8
...
...
@@ -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=
"resume
Inline && resume
Src"
:src=
"resumeSrc"
class=
"dock-frame"
title=
"简历预览"
/>
<div
v-else
class=
"dock-text"
>
<pre>
{{
selected
.
resumeText
||
'未上传简历文件,暂无正文可预览。'
}}
</pre>
</div>
...
...
vue-app/src/components/resumes/ResumeList.vue
View file @
07310dd8
...
...
@@ -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 }">
<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,30 +242,28 @@ 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"
>
<div
v-if=
"scopeJob"
class=
"scope-strip
"
>
<div
>
<el-tag
type=
"primary"
effect=
"plain"
>
岗位聚焦
</el-tag>
<strong>
{{
scopeJob
.
title
}}
</strong>
<span
class=
"muted"
>
仅显示该岗位关联候选人
</span>
<span
>
仅显示该岗位关联候选人
</span>
</div>
<el-button
size=
"small"
@
click=
"clearScope"
>
查看全部候选人
</el-button>
</section
>
</div
>
<section
v-if=
"batchMatchResult"
class=
"batch-match-result"
:class=
"batchMatchResult.failed ? 'warning' : 'success'"
>
<div>
<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>
...
...
@@ -202,224 +280,635 @@ const openBatch = () => {
<b>
{{
batchMatchResult
.
failed
}}
</b
><span>
执行失败
</span>
</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
class=
"mini-actions"
>
<el-button
@
click=
"openCreate"
>
新增候选人
</el-button>
<el-button
type=
"primary"
@
click=
"openBatch"
>
批量上传简历
</el-button>
</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>
<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>
<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>
<div
class=
"score-cluster"
>
<div
class=
"score-cell"
>
<span>
快筛
</span>
<b
:style=
"
{
color: quickDisplay(candidate) === '--' ? 'var(--muted)' : scoreColor(quickScore(candidate)),
}"
>
{{
quickDisplay
(
candidate
)
}}
</b>
</div>
</
template
>
</el-table-column>
<el-table-column
label=
"求职岗位"
min-width=
"170"
>
<
template
#
default=
"{ row }"
>
<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=
"row
.jobId"
:model-value=
"candidate
.jobId"
size=
"small"
class=
"job-select"
placeholder=
"选择岗位"
@
change=
"(jobId) => bindJob(row
, jobId)"
@
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>
</
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
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-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>
</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
;
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
);
}
.metric-copy
{
span
{
color
:
var
(
--
muted
);
font-size
:
13
px
;
font-size
:
12
px
;
}
strong
{
display
:
block
;
margin
:
6px
0
4
px
;
margin
:
4px
0
3
px
;
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
;
}
.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
;
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
);
}
}
}
.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
;
gap
:
20px
;
align-items
:
center
;
padding
:
14px
18px
;
justify-content
:
space-between
;
gap
:
18px
;
margin
:
14px
20px
0
;
padding
:
12px
16px
;
border-radius
:
10px
;
margin-bottom
:
16px
;
&
.success
{
background
:
var
(
--
green
);
color
:
#fff
;
&
.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
));
}
&
.warning
{
background
:
var
(
--
yellow
);
color
:
#333
;
&
.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
));
}
.batch-summary
{
strong
{
display
:
block
;
font-size
:
14px
;
}
span
{
font-size
:
12px
;
opacity
:
0
.9
;
opacity
:
0
.8
;
}
}
}
.mini-action
s
{
.batch-stat
s
{
display
:
flex
;
gap
:
8px
;
}
gap
:
16px
;
.primary-cell
{
div
{
display
:
flex
;
flex-direction
:
column
;
align-items
:
baseline
;
gap
:
5px
;
opacity
:
0
.85
;
strong
{
font-size
:
14
px
;
b
{
font-size
:
16
px
;
}
small
{
color
:
var
(
--
muted
);
span
{
font-size
:
12px
;
}
}
}
}
.
next-cell
{
.
candidate-cards
{
display
:
flex
;
flex-direction
:
column
;
gap
:
2px
;
gap
:
10px
;
padding
:
14px
20px
20px
;
}
span
{
font-size
:
13px
;
.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
;
}
}
.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
;
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
;
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
;
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
;
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
;
}
.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
;
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
;
}
}
.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
>
vue-app/src/stores/recruitment.js
View file @
07310dd8
...
...
@@ -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
=
[
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment