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

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

parent 810e2805
......@@ -47,65 +47,139 @@ textarea {
position: sticky;
top: 0;
height: 100vh;
padding: 18px 14px;
padding: 16px 12px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 18px;
background: #111722;
gap: 14px;
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: 34px;
height: 34px;
border-radius: 9px;
background: var(--blue);
width: 38px;
height: 38px;
border-radius: 11px;
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 4px;
font-size: 22px;
margin: 0 0 3px;
font-size: 20px;
}
p {
margin: 0;
color: var(--muted);
font-size: 14px;
font-size: 13px;
}
.top-meta {
display: flex;
align-items: center;
gap: 12px;
gap: 14px;
color: var(--muted);
font-size: 13px;
font-size: 12px;
strong {
color: var(--ink);
.save-status {
display: inline-flex;
align-items: center;
gap: 7px;
}
.today {
font-variant-numeric: tabular-nums;
}
.workspace-switch {
width: 130px;
width: 132px;
}
.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;
......
......@@ -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>
......@@ -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 navItems"
v-for="item in group.items"
: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-tag>
<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 }}</strong>
<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>
......
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(
......
<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" />
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment