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