Commit 3fdbebc5 authored by 李文光's avatar 李文光

feat(dashboard): 重做招聘数据看板配色与图表方案

- 看板级语义配色(蓝/青/紫/琥珀/绿)映射招聘漏斗,提升白底对比度

- KPI 卡片加图标色块与语义色调;新增关键洞察条(周期/转化)

- 漏斗/趋势/来源/ROI 统一看板色;趋势补 Y 轴刻度,漏斗内嵌数值自适应文字色

- 招聘日历改为左侧月格+右侧事件卡片双栏,宽屏自适应多列,加图例与数量角标
parent 3605d466
......@@ -22,14 +22,33 @@ const EVENT_TYPE_LABELS = {
candidate_claimed: '从人才池认领',
job_filled: '岗位自动招满',
}
const eventColor = (type) =>
type === 'candidate_created'
? 'var(--blue)'
: type === 'onboarded' || type === 'job_filled'
? 'var(--green)'
: type === 'pool_moved' || type === 'candidate_claimed'
? 'var(--rose)'
: 'var(--violet)'
// 事件分为四类语义,颜色与图例/日格/动态列表共用,避免逐个类型散落颜色
const CATEGORIES = [
{ key: 'entry', color: 'var(--dash-blue)', label: '入库' },
{ key: 'progress', color: 'var(--dash-violet)', label: '匹配 / 推进' },
{ key: 'hire', color: 'var(--dash-green)', label: '入职 / 招满' },
{ key: 'pool', color: 'var(--dash-rose)', label: '人才池' },
]
const EVENT_CATEGORY = {
candidate_created: 'entry',
stage_changed: 'progress',
local_match_generated: 'progress',
ai_evaluation_generated: 'progress',
interview_feedback_saved: 'progress',
offer_created: 'progress',
offer_approved: 'progress',
candidate_offer_sent: 'progress',
onboarded: 'hire',
job_filled: 'hire',
pool_moved: 'pool',
candidate_claimed: 'pool',
}
const eventCategory = (type) => EVENT_CATEGORY[type] || 'progress'
const categoryOf = (key) => CATEGORIES.find((item) => item.key === key) || CATEGORIES[1]
const eventColor = (type) => categoryOf(eventCategory(type)).color
const eventLabel = (event) => EVENT_TYPE_LABELS[event.type] || event.type
const eventSubject = (event) => event.candidateName || event.jobTitle || '候选人'
......@@ -70,12 +89,14 @@ const eventsByDay = computed(() => {
const eventTotalInMonth = computed(() => [...eventsByDay.value.values()].reduce((sum, list) => sum + list.length, 0))
const isToday = (date) => date && date.toDateString() === new Date().toDateString()
const isWeekend = (date) => !!date && [0, 6].includes(date.getDay())
const dayEvents = (date) => (date ? eventsByDay.value.get(date.toDateString()) || [] : [])
const dayColors = (date) =>
[...new Set(dayEvents(date).map((event) => eventCategory(event.type)))].map((key) => categoryOf(key).color)
const dayTip = (events) => events.map((event) => `${eventSubject(event)} · ${eventLabel(event)}`).join('\n')
// 事件明细只列当月最近 6 条(与日历红点同月,避免跨月错位)
// 事件明细只列当月最近 6 条(与日同月,避免跨月错位)
const listEvents = computed(() =>
[...props.events]
.filter((event) => {
......@@ -83,42 +104,72 @@ const listEvents = computed(() =>
return !Number.isNaN(date.getTime()) && date.getFullYear() === year && date.getMonth() === month
})
.sort((a, b) => new Date(b.at) - new Date(a.at))
.slice(0, 6)
.slice(0, 10)
)
</script>
<template>
<div class="calendar">
<div class="calendar-head">
<strong>{{ monthLabel }}</strong>
<span v-if="eventTotalInMonth">本月 {{ eventTotalInMonth }} 条动态</span>
<span v-else>本月暂无动态</span>
</div>
<div class="calendar-grid">
<span v-for="week in ['日', '一', '二', '三', '四', '五', '六']" :key="week" class="week-head">{{ week }}</span>
<template v-for="(date, index) in days" :key="index">
<div class="day-cell" :class="{ today: isToday(date), empty: !date }">
<template v-if="date">
<span class="day-num">{{ date.getDate() }}</span>
<span v-if="dayEvents(date).length" class="dot-mark" :title="dayTip(dayEvents(date))">
<i
v-for="event in dayEvents(date).slice(0, 3)"
:key="event.id"
:style="{ background: eventColor(event.type) }"
/>
</span>
<div class="calendar-layout">
<div class="calendar-left">
<div class="calendar-head">
<strong>{{ monthLabel }}</strong>
<span v-if="eventTotalInMonth">本月 {{ eventTotalInMonth }} 条动态</span>
<span v-else>本月暂无动态</span>
</div>
<div class="calendar-grid">
<span
v-for="(week, index) in ['日', '一', '二', '三', '四', '五', '六']"
:key="week"
class="week-head"
:class="{ weekend: index === 0 || index === 6 }"
>
{{ week }}
</span>
<template v-for="(date, index) in days" :key="index">
<div
class="day-cell"
:class="{
today: isToday(date),
weekend: isWeekend(date),
empty: !date,
hasEvents: !!(date && dayEvents(date).length),
}"
>
<template v-if="date">
<span class="day-num">{{ date.getDate() }}</span>
<span v-if="dayEvents(date).length" class="day-count">{{ dayEvents(date).length }}</span>
<span v-if="dayEvents(date).length" class="dot-mark" :title="dayTip(dayEvents(date))">
<i v-for="color in dayColors(date)" :key="color" :style="{ background: color }" />
</span>
</template>
</div>
</template>
</div>
</template>
</div>
<div v-if="listEvents.length" class="calendar-events">
<div v-for="event in listEvents" :key="event.id" class="event-chip">
<i :style="{ background: eventColor(event.type) }" />
<span class="event-date">{{ new Date(event.at).getDate() }}日</span>
<span class="event-text">{{ eventSubject(event) }} · {{ eventLabel(event) }}</span>
<div class="calendar-legend">
<span v-for="item in CATEGORIES" :key="item.key">
<i :style="{ background: item.color }" />{{ item.label }}
</span>
</div>
</div>
<div class="calendar-right">
<div class="events-title">
<span>最近动态</span>
<em v-if="eventTotalInMonth">最多 10 条</em>
</div>
<div v-if="listEvents.length" class="calendar-events">
<div v-for="event in listEvents" :key="event.id" class="event-chip">
<i :style="{ background: eventColor(event.type) }" />
<span class="event-date">{{ new Date(event.at).getDate() }}日</span>
<span class="event-text">{{ eventSubject(event) }} · {{ eventLabel(event) }}</span>
</div>
</div>
<div v-else class="empty-inline">当前月份暂无事件</div>
</div>
</div>
<div v-else class="empty-inline">当前月份暂无事件</div>
</div>
</template>
......@@ -126,7 +177,19 @@ const listEvents = computed(() =>
.calendar {
display: flex;
flex-direction: column;
gap: 10px;
}
.calendar-layout {
display: grid;
grid-template-columns: minmax(300px, 440px) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.calendar-left {
display: flex;
flex-direction: column;
gap: 12px;
}
.calendar-head {
......@@ -136,13 +199,14 @@ const listEvents = computed(() =>
gap: 12px;
strong {
font-size: 14px;
color: var(--ink);
font-size: 15px;
color: var(--dash-ink);
}
span {
font-size: 12px;
color: var(--muted);
color: var(--dash-muted);
font-variant-numeric: tabular-nums;
}
}
......@@ -154,9 +218,13 @@ const listEvents = computed(() =>
.week-head {
text-align: center;
font-size: 12px;
color: var(--muted);
font-size: 11px;
color: var(--dash-muted);
padding: 2px 0;
&.weekend {
color: var(--dash-amber);
}
}
.day-cell {
......@@ -165,18 +233,56 @@ const listEvents = computed(() =>
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border-radius: 8px;
font-size: 12px;
background: var(--bg);
color: var(--ink);
background: var(--panel);
color: var(--dash-ink);
box-shadow: inset 0 0 0 1px var(--dash-line);
font-variant-numeric: tabular-nums;
transition:
box-shadow 0.15s ease,
background 0.15s ease;
&:hover {
box-shadow: inset 0 0 0 1px var(--dash-muted);
}
&.weekend {
background: var(--dash-grid);
}
&.today {
outline: 2px solid var(--blue);
outline-offset: 0;
box-shadow:
inset 0 0 0 1px var(--dash-blue),
0 0 0 2px var(--dash-blue-soft);
color: var(--dash-blue);
font-weight: 700;
}
&.hasEvents .day-num {
font-weight: 600;
}
&.empty {
background: transparent;
box-shadow: none;
}
.day-count {
position: absolute;
top: 4px;
right: 4px;
min-width: 15px;
height: 15px;
padding: 0 4px;
border-radius: 999px;
background: var(--dash-blue-soft);
color: var(--dash-blue);
font-size: 10px;
font-weight: 600;
line-height: 15px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.dot-mark {
......@@ -190,23 +296,77 @@ const listEvents = computed(() =>
height: 5px;
border-radius: 50%;
display: block;
box-shadow: 0 0 0 1px var(--dash-grid);
}
}
}
.calendar-events {
.calendar-legend {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
font-size: 11px;
color: var(--dash-muted);
span {
display: inline-flex;
align-items: center;
gap: 5px;
}
i {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
}
.calendar-right {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 132px;
overflow-y: auto;
gap: 8px;
min-width: 0;
}
.events-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--dash-line);
span {
font-size: 13px;
color: var(--dash-ink);
font-weight: 600;
}
em {
font-style: normal;
font-size: 11px;
color: var(--dash-muted);
}
}
.calendar-events {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 8px;
.event-chip {
display: flex;
display: grid;
grid-template-columns: 8px 28px minmax(0, 1fr);
align-items: center;
gap: 7px;
gap: 8px;
min-height: 34px;
padding: 6px 10px;
border: 1px solid var(--dash-line);
border-radius: 8px;
background: var(--panel);
font-size: 12px;
color: var(--muted);
color: var(--dash-muted);
min-width: 0;
i {
......@@ -214,26 +374,36 @@ const listEvents = computed(() =>
height: 8px;
border-radius: 50%;
flex-shrink: 0;
justify-self: center;
}
.event-date {
flex-shrink: 0;
color: var(--ink);
text-align: center;
color: var(--dash-ink);
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.event-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
}
}
.empty-inline {
padding: 10px 0 2px;
padding: 20px 0;
text-align: center;
color: var(--muted);
color: var(--dash-muted);
font-size: 13px;
}
@media (max-width: 900px) {
.calendar-layout {
grid-template-columns: 1fr;
gap: 18px;
}
}
</style>
......@@ -5,8 +5,23 @@ const props = defineProps({
analytics: { type: Object, default: () => ({ funnel: [] }) },
})
// 由「深蓝(简历) → 青 → 绿(入职)」的色带,传达候选人一路收敛到入职
const FUNNEL_COLORS = ['#4e6fc0', '#648fc8', '#6fb6cd', '#5cc4a4', '#3fa97f']
// 语义色带:蓝(简历) → 青(初筛) → 紫(面试) → 琥珀(Offer) → 绿(入职),与 KPI/洞察同源
const FUNNEL_TONES = ['blue', 'cyan', 'violet', 'amber', 'green']
const FUNNEL_COLORS = {
blue: 'var(--dash-blue)',
cyan: 'var(--dash-cyan)',
violet: 'var(--dash-violet)',
amber: 'var(--dash-amber)',
green: 'var(--dash-green)',
}
// 内嵌数值的对比度适配:深色填充用白字,亮色填充用深字(保证小字号 4.5:1)
const FUNNEL_TEXT = {
blue: '#ffffff',
cyan: '#0f3a46',
violet: '#ffffff',
amber: '#3a2c05',
green: '#123f2b',
}
// 阶段行间转换率文案(每阶段 = 相对上一阶段通过的比率)
const STEP_LABELS = {
初筛通过: '初筛通过率',
......@@ -18,7 +33,8 @@ const STEP_LABELS = {
const rows = computed(() =>
(props.analytics.funnel || []).map((row, index) => ({
...row,
color: FUNNEL_COLORS[index % FUNNEL_COLORS.length],
color: FUNNEL_COLORS[FUNNEL_TONES[index % FUNNEL_TONES.length]],
textColor: FUNNEL_TEXT[FUNNEL_TONES[index % FUNNEL_TONES.length]],
stepLabel: index === 0 ? '' : STEP_LABELS[row.name] || '阶段转化率',
}))
)
......@@ -46,14 +62,24 @@ const shapeStyle = (row, index) => {
clipPath: `polygon(${leftTop}% 0, ${rightTop}% 0, ${rightBottom}% 100%, ${leftBottom}% 100%)`,
}
}
const cycleHint = computed(() => {
const value = props.analytics.cycleDays
return value && value !== '暂无' ? `平均周期 ${value}` : ''
})
</script>
<template>
<div v-if="!isEmpty" class="funnel">
<div v-if="cycleHint" class="funnel-cycle">{{ cycleHint }}</div>
<div v-for="(row, index) in rows" :key="row.name">
<div class="funnel-row">
<span class="funnel-name">{{ row.name }}</span>
<div class="funnel-shape" :style="shapeStyle(row, index)" />
<span class="funnel-name">
{{ row.name }}
</span>
<div class="funnel-shape" :style="shapeStyle(row, index)">
<span class="funnel-center" :style="{ color: row.textColor }">{{ row.value }}</span>
</div>
<div class="funnel-meta">
<strong>{{ row.value }}</strong>
<em :title="`占首层(简历进入) ${row.pct}`">占简历 {{ row.pct }}</em>
......@@ -80,6 +106,19 @@ const shapeStyle = (row, index) => {
gap: 4px;
}
.funnel-cycle {
margin-bottom: 10px;
font-size: 12px;
color: var(--dash-muted);
&::before {
content: '◎';
margin-right: 6px;
color: var(--dash-violet);
font-size: 11px;
}
}
.funnel-row {
display: grid;
grid-template-columns: 84px 1fr 72px;
......@@ -90,15 +129,29 @@ const shapeStyle = (row, index) => {
.funnel-name {
font-size: 13px;
color: var(--ink);
color: var(--dash-ink);
font-weight: 500;
}
.funnel-shape {
position: relative;
height: 40px;
width: 100%;
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0));
transition:
clip-path 0.35s ease,
background 0.35s ease;
.funnel-center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
}
.funnel-meta {
......@@ -109,13 +162,13 @@ const shapeStyle = (row, index) => {
display: block;
font-size: 16px;
font-variant-numeric: tabular-nums;
color: var(--ink);
color: var(--dash-ink);
}
em {
font-style: normal;
font-size: 11px;
color: var(--muted);
color: var(--dash-muted);
}
}
......@@ -136,16 +189,16 @@ const shapeStyle = (row, index) => {
.step-line {
flex: 1;
height: 1px;
background: var(--line);
background: var(--dash-line);
}
.step-text {
font-size: 11px;
color: var(--muted);
color: var(--dash-muted);
white-space: nowrap;
b {
color: var(--ink);
color: var(--dash-ink);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
......
<script setup>
defineProps({
import { computed } from 'vue'
const props = defineProps({
label: { type: String, required: true },
value: { type: Number, default: 0 },
hint: { type: String, default: '' },
color: { type: String, default: 'var(--blue)' },
icon: { type: String, default: 'DataLine' },
tone: { type: String, default: 'blue' },
})
// 看板级语义色:数值/图标必须保证白底可读(≥4.5:1),故用暗色变体而非全局亮色
const TONES = {
blue: { accent: 'var(--dash-blue)', soft: 'var(--dash-blue-soft)' },
cyan: { accent: 'var(--dash-cyan)', soft: 'var(--dash-cyan-soft)' },
violet: { accent: 'var(--dash-violet)', soft: 'var(--dash-violet-soft)' },
amber: { accent: 'var(--dash-amber)', soft: 'var(--dash-amber-soft)' },
green: { accent: 'var(--dash-green)', soft: 'var(--dash-green-soft)' },
rose: { accent: 'var(--dash-rose)', soft: 'var(--dash-rose-soft)' },
}
const tone = computed(() => TONES[props.tone] || TONES.blue)
</script>
<template>
<article class="dashboard-kpi" :style="{ '--accent': color }">
<span>{{ label }}</span>
<strong>{{ value }}</strong>
<em>{{ hint }}</em>
<article class="dashboard-kpi" :style="{ '--accent': tone.accent, '--soft': tone.soft }">
<div class="kpi-top">
<span class="kpi-label">{{ label }}</span>
<el-icon class="kpi-icon"><component :is="icon" /></el-icon>
</div>
<strong class="kpi-value">{{ value }}</strong>
<em class="kpi-hint">{{ hint }}</em>
</article>
</template>
<style lang="scss" scoped>
.dashboard-kpi {
position: relative;
overflow: hidden;
background: var(--panel);
border: 1px solid var(--line);
border: 1px solid var(--dash-line);
border-radius: 12px;
padding: 16px 18px;
padding: 16px 16px 15px;
span {
color: var(--muted);
font-size: 13px;
// 顶部细色带:区分语义,不喧宾夺主
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent), transparent 70%);
opacity: 0.55;
}
strong {
.kpi-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
.kpi-label {
color: var(--dash-muted);
font-size: 13px;
font-weight: 500;
}
.kpi-icon {
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--soft);
color: var(--accent);
font-size: 17px;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
.kpi-value {
display: block;
margin: 6px 0 4px;
font-size: 28px;
margin: 10px 0 3px;
font-size: 30px;
line-height: 1.1;
color: var(--accent);
font-variant-numeric: tabular-nums;
font-weight: 700;
letter-spacing: 0;
}
em {
color: var(--muted);
.kpi-hint {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--dash-muted);
font-size: 12px;
font-style: normal;
&::before {
content: '';
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--dash-grid);
}
}
}
</style>
......@@ -26,7 +26,7 @@ const tableRows = computed(() =>
</script>
<template>
<el-table :data="tableRows" empty-text="当前筛选暂无数据" size="small">
<el-table class="rate-sheet" :data="tableRows" empty-text="当前筛选暂无数据" size="small">
<el-table-column prop="source" label="渠道" min-width="88" />
<el-table-column label="简历" width="62" align="right">
<template #default="{ row }">{{ row.candidates }}</template>
......@@ -60,6 +60,14 @@ const tableRows = computed(() =>
</template>
<style lang="scss" scoped>
.rate-sheet {
--el-table-border-color: var(--dash-line);
--el-table-header-bg-color: var(--dash-grid);
--el-table-row-hover-bg-color: var(--dash-grid);
--el-table-text-color: var(--dash-ink);
--el-table-header-text-color: var(--dash-muted);
}
.rate-cell {
display: flex;
align-items: center;
......@@ -71,7 +79,7 @@ const tableRows = computed(() =>
min-width: 34px;
height: 6px;
border-radius: 999px;
background: var(--bg);
background: var(--dash-grid);
overflow: hidden;
b {
......@@ -80,17 +88,19 @@ const tableRows = computed(() =>
width: var(--w);
min-width: 0;
border-radius: 999px;
background: var(--violet);
background-color: var(--dash-violet);
background-image: linear-gradient(90deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.18));
}
&.green b {
background: var(--green);
background-color: var(--dash-green);
background-image: linear-gradient(90deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.18));
}
}
span {
flex-shrink: 0;
color: var(--muted);
color: var(--dash-muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
......
......@@ -5,7 +5,14 @@ const props = defineProps({
rows: { type: Array, default: () => [] },
})
const PALETTE = ['var(--blue)', 'var(--cyan)', 'var(--violet)', 'var(--green)', 'var(--yellow)', 'var(--rose)']
const PALETTE = [
'var(--dash-blue)',
'var(--dash-cyan)',
'var(--dash-violet)',
'var(--dash-green)',
'var(--dash-amber)',
'var(--dash-rose)',
]
const totalCandidates = computed(() => props.rows.reduce((sum, row) => sum + (row.candidates || 0), 0))
......@@ -66,19 +73,21 @@ const conicStyle = computed(() => {
}
.donut {
width: 132px;
height: 132px;
width: 136px;
height: 136px;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(24, 32, 51, 0.04);
box-shadow:
inset 0 0 0 1px var(--dash-line),
0 6px 18px rgba(39, 49, 70, 0.06);
transition: background 0.3s ease;
.donut-hole {
width: 84px;
height: 84px;
width: 78px;
height: 78px;
border-radius: 50%;
background: var(--panel);
display: flex;
......@@ -90,11 +99,12 @@ const conicStyle = computed(() => {
strong {
font-size: 22px;
font-variant-numeric: tabular-nums;
color: var(--dash-ink);
}
span {
font-size: 12px;
color: var(--muted);
color: var(--dash-muted);
}
}
}
......@@ -120,19 +130,21 @@ const conicStyle = computed(() => {
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 0 2px var(--dash-grid);
}
.name {
color: var(--ink);
color: var(--dash-ink);
}
.value {
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--dash-ink);
}
em {
color: var(--muted);
color: var(--dash-muted);
font-style: normal;
min-width: 42px;
text-align: right;
......
......@@ -7,14 +7,31 @@ const props = defineProps({
})
const SERIES = [
{ key: 'resumes', label: '简历', color: 'var(--blue)' },
{ key: 'offers', label: 'Offer', color: 'var(--violet)' },
{ key: 'onboarded', label: '入职', color: 'var(--green)' },
{ key: 'resumes', label: '简历', color: 'var(--dash-blue)' },
{ key: 'offers', label: 'Offer', color: 'var(--dash-violet)' },
{ key: 'onboarded', label: '入职', color: 'var(--dash-green)' },
]
const maxValue = computed(() => {
const rawMax = computed(() => {
const all = props.rows.flatMap((row) => SERIES.map((series) => Number(row[series.key]) || 0))
return Math.max(1, ...all)
return Math.max(0, ...all)
})
// 自适应坐标轴:把顶端刻度收成一档,避免 max=1 时柱子过矮/刻度松散
const axisTop = computed(() => {
const max = rawMax.value
if (max <= 0) return 4
const intervals = max <= 2 ? 2 : 4
const step = Math.max(1, Math.ceil(max / intervals))
return step * intervals
})
const gridCount = computed(() => (rawMax.value <= 2 ? 2 : 4))
const ticks = computed(() => {
const top = axisTop.value
const count = gridCount.value
return Array.from({ length: count + 1 }, (_, index) => Math.round((top * (count - index)) / count))
})
const periodLabel = (row) => row.period || row.label
......@@ -28,23 +45,28 @@ const periodLabel = (row) => row.period || row.label
</span>
</div>
<div class="trend-chart">
<div class="trend-grid" aria-hidden="true">
<i v-for="step in 4" :key="step" />
<div class="trend-y">
<span v-for="tick in ticks" :key="tick">{{ tick }}</span>
</div>
<div v-for="row in rows" :key="periodLabel(row)" class="trend-col">
<div class="trend-bars">
<i
v-for="series in SERIES"
:key="series.key"
class="trend-bar"
:style="{
height: `${((Number(row[series.key]) || 0) / maxValue) * 100}%`,
'--accent': series.color,
}"
:title="`${periodLabel(row)} ${series.label}:${row[series.key] || 0}`"
/>
<div class="trend-plot">
<div class="trend-grid" aria-hidden="true">
<i v-for="index in gridCount + 1" :key="index" />
</div>
<div v-for="row in rows" :key="periodLabel(row)" class="trend-col">
<div class="trend-bars">
<i
v-for="series in SERIES"
:key="series.key"
class="trend-bar"
:style="{
height: `${((Number(row[series.key]) || 0) / axisTop) * 100}%`,
'--accent': series.color,
}"
:title="`${periodLabel(row)} ${series.label}:${row[series.key] || 0}`"
/>
</div>
<span class="trend-period">{{ periodLabel(row) }}</span>
</div>
<span class="trend-period">{{ periodLabel(row) }}</span>
</div>
</div>
</div>
......@@ -62,41 +84,66 @@ const periodLabel = (row) => row.period || row.label
display: flex;
gap: 16px;
font-size: 12px;
color: var(--muted);
color: var(--dash-muted);
flex-wrap: wrap;
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
margin-right: 6px;
box-shadow: 0 0 0 3px var(--dash-grid);
}
}
.trend-chart {
display: flex;
gap: 8px;
height: 176px;
}
.trend-y {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 26px;
padding-bottom: 22px;
flex-shrink: 0;
color: var(--dash-muted);
font-size: 10px;
text-align: right;
font-variant-numeric: tabular-nums;
span {
line-height: 0;
}
}
.trend-plot {
position: relative;
display: flex;
align-items: stretch;
gap: 10px;
height: 160px;
flex: 1;
min-width: 0;
gap: 12px;
}
.trend-grid {
position: absolute;
inset: 0 0 20px;
inset: 0 0 22px;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
i {
border-top: 1px dashed var(--line);
border-top: 1px dashed var(--dash-grid);
display: block;
}
}
.trend-col {
position: relative;
z-index: 1;
flex: 1;
min-width: 0;
display: flex;
......@@ -119,20 +166,28 @@ const periodLabel = (row) => row.period || row.label
min-width: 4px;
max-width: 14px;
min-height: 2px;
border-radius: 3px 3px 0 0;
background: var(--accent);
opacity: 0.9;
border-radius: 4px 4px 1px 1px;
background-color: var(--accent);
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0));
opacity: 0.94;
cursor: default;
transition: opacity 0.15s ease;
transition:
opacity 0.15s ease,
transform 0.15s ease;
&:hover {
opacity: 1;
transform: translateY(-2px);
}
}
.trend-period {
font-size: 12px;
color: var(--muted);
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--dash-muted);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
......
......@@ -2,6 +2,7 @@
import { computed } from 'vue'
import { useRecruitmentStore } from '@/stores/recruitment'
import { percent } from '@/utils/format'
import { dashboardAnalytics, dashboardScopeLabel, pageMetrics } from '@/utils/dashboard'
import DashboardFilterBar from '@/components/dashboard/DashboardFilterBar.vue'
import KpiCard from '@/components/dashboard/KpiCard.vue'
......@@ -26,6 +27,23 @@ const scopeItems = computed(() => dashboardScopeLabel(store.dashboardFilters, st
const m = computed(() => pageMetrics(store.dashboardFilters, store.state))
const analytics = computed(() => dashboardAnalytics(store.dashboardFilters, store.state))
// 关键洞察:复用已算好的漏斗/周期数据,不再重复遍历业务表
const funnelStage = computed(() => Object.fromEntries((analytics.value.funnel || []).map((row) => [row.name, row])))
const offerFromResume = computed(() => funnelStage.value['Offer']?.pct || '0%')
const offerToHired = computed(() => {
const offer = funnelStage.value['Offer']?.value || 0
const hired = funnelStage.value['入职']?.value || 0
if (!offer) return '—'
return percent(hired, offer)
})
const insights = computed(() => [
{ label: '平均招聘周期', value: analytics.value.cycleDays || '暂无', icon: 'Timer', tone: 'violet' },
{ label: '简历 → Offer 转化', value: offerFromResume.value, icon: 'TrendCharts', tone: 'blue' },
{ label: 'Offer → 入职 转化', value: offerToHired.value, icon: 'CircleCheck', tone: 'green' },
{ label: '当前筛选动态', value: String(m.value.events || 0), icon: 'DataAnalysis', tone: 'cyan' },
])
// 日历跟随筛选控件里的月份(自定义范围则取开始日期所在月),避免筛选后仍显示「当前月」
const calendarMonth = computed(() => {
const filters = store.dashboardFilters
......@@ -38,110 +56,276 @@ const calendarMonth = computed(() => {
<div class="dashboard-workbench">
<DashboardFilterBar />
<div class="dashboard-scope-strip">
<span v-for="item in scopeItems" :key="item">{{ item }}</span>
</div>
<div class="dashboard-kpi-grid">
<KpiCard label="候选人数" :value="m.candidates" hint="简历库主数据" color="var(--blue)" />
<KpiCard label="面试人数" :value="m.interview" hint="面试中及后续阶段" color="var(--cyan)" />
<KpiCard label="Offer 人数" :value="m.offers" hint="录用入职回写" color="var(--violet)" />
<KpiCard label="待入职" :value="m.onboarding" hint="Offer 已发待到岗" color="var(--yellow)" />
<KpiCard label="入职人数" :value="m.hired" hint="岗位已入职合计" color="var(--green)" />
<KpiCard label="候选人数" :value="m.candidates" hint="简历库主数据" icon="User" tone="blue" />
<KpiCard label="面试人数" :value="m.interview" hint="面试中及后续阶段" icon="ChatDotRound" tone="cyan" />
<KpiCard label="Offer 人数" :value="m.offers" hint="录用入职回写" icon="Money" tone="violet" />
<KpiCard label="待入职" :value="m.onboarding" hint="Offer 已发待到岗" icon="Timer" tone="amber" />
<KpiCard label="入职人数" :value="m.hired" hint="岗位已入职合计" icon="CircleCheck" tone="green" />
</div>
<div class="dashboard-insight-grid">
<article v-for="item in insights" :key="item.label" class="dashboard-insight" :class="`tone-${item.tone}`">
<el-icon class="insight-icon"><component :is="item.icon" /></el-icon>
<div>
<span class="insight-label">{{ item.label }}</span>
<strong class="insight-value">{{ item.value }}</strong>
</div>
</article>
</div>
<div class="dashboard-main-grid">
<section class="card dashboard-card">
<section class="card dashboard-card card-funnel">
<div class="card-head">
<div>
<div class="card-title">招聘漏斗</div>
<div class="card-note">简历 → 初筛 → 面试 → Offer → 入职</div>
<div class="card-note">简历沉淀到入职的逐级转化</div>
</div>
<span class="card-hint">已推进阶段累计</span>
<span class="card-hint">按阶段累计</span>
</div>
<FunnelCard :analytics="analytics" />
</section>
<section class="card dashboard-card">
<section class="card dashboard-card card-trend">
<div class="card-head">
<div>
<div class="card-title">{{ periodLabel }} 趋势</div>
<div class="card-note">累计候选人 / Offer / 入职</div>
<div class="card-note">候选人 / Offer / 入职 走势</div>
</div>
<span class="card-hint">{{ store.dashboardFilters.range }}</span>
</div>
<TrendCard :rows="analytics.trend" :range="store.dashboardFilters.range" />
</section>
<section class="card dashboard-card">
<section class="card dashboard-card card-source">
<div class="card-head">
<div>
<div class="card-title">候选人来源占比</div>
<div class="card-note">来源结构 / 入职转化</div>
<div class="card-note">各渠道简历结构</div>
</div>
<span class="card-hint">渠道结构</span>
<span class="card-hint">来源结构</span>
</div>
<SourceCard :rows="analytics.sourceRows" />
</section>
<section class="card dashboard-card">
<section class="card dashboard-card card-roi">
<div class="card-head">
<div>
<div class="card-title">招聘日历</div>
<div class="card-note">候选人 / Offer / 入职动态</div>
<div class="card-title">渠道效果与 ROI</div>
<div class="card-note">简历量 / 面试 / Offer / 入职转化</div>
</div>
<span class="card-hint">跟随筛选月份</span>
<span class="card-hint">渠道效果</span>
</div>
<CalendarCard :events="analytics.scope.events" :period="calendarMonth" />
<RoiCard :rows="analytics.sourceRows" />
</section>
<section class="card dashboard-card">
<section class="card dashboard-card card-calendar">
<div class="card-head">
<div>
<div class="card-title">渠道效果与 ROI</div>
<div class="card-note">渠道简历量 / 面试 / Offer / 入职转化</div>
<div class="card-title">招聘日历</div>
<div class="card-note">候选人 / Offer / 入职动态</div>
</div>
<span class="card-hint">渠道效果</span>
<span class="card-hint">跟随筛选月份</span>
</div>
<RoiCard :rows="analytics.sourceRows" />
<CalendarCard :events="analytics.scope.events" :period="calendarMonth" />
</section>
</div>
<div class="dashboard-scope-strip">
<span v-for="item in scopeItems" :key="item">{{ item }}</span>
</div>
</div>
</template>
<style lang="scss" scoped>
// 看板级语义配色:与全局 token 同源,但按数据可视化调优明度/对比度,
// 仅在 dashboard 根元素注入,不影响其他页面。
.dashboard-workbench {
--dash-blue: #3f62bd;
--dash-cyan: #2a83a0;
--dash-violet: #6a57d6;
--dash-amber: #bd7a14;
--dash-green: #2e9a6b;
--dash-rose: #d35165;
--dash-ink: #1a2333;
--dash-muted: #5f6b7d;
--dash-line: #e6ebf3;
--dash-grid: #eef2f7;
--dash-blue-soft: rgba(63, 98, 189, 0.12);
--dash-cyan-soft: rgba(42, 131, 160, 0.14);
--dash-violet-soft: rgba(106, 87, 214, 0.13);
--dash-amber-soft: rgba(189, 122, 20, 0.14);
--dash-green-soft: rgba(46, 154, 107, 0.14);
--dash-rose-soft: rgba(211, 81, 101, 0.13);
display: flex;
flex-direction: column;
gap: 16px;
}
.dashboard-scope-strip {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
padding: 9px 14px;
border: 1px solid var(--dash-line);
border-radius: 10px;
background: var(--panel);
color: var(--dash-muted);
font-size: 12px;
span {
position: relative;
padding-left: 12px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--dash-grid);
transform: translateY(-50%);
}
}
}
.dashboard-kpi-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.dashboard-insight-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
.dashboard-insight {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--panel);
border: 1px solid var(--dash-line);
border-radius: 12px;
.insight-icon {
width: 34px;
height: 34px;
border-radius: 9px;
flex-shrink: 0;
color: var(--insight-accent);
background: var(--insight-soft);
font-size: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.insight-label {
display: block;
color: var(--dash-muted);
font-size: 12px;
margin-bottom: 2px;
}
.insight-value {
display: block;
color: var(--dash-ink);
font-size: 20px;
font-variant-numeric: tabular-nums;
font-weight: 650;
}
}
.tone-blue {
--insight-accent: var(--dash-blue);
--insight-soft: var(--dash-blue-soft);
}
.tone-cyan {
--insight-accent: var(--dash-cyan);
--insight-soft: var(--dash-cyan-soft);
}
.tone-violet {
--insight-accent: var(--dash-violet);
--insight-soft: var(--dash-violet-soft);
}
.tone-green {
--insight-accent: var(--dash-green);
--insight-soft: var(--dash-green-soft);
}
}
.dashboard-main-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 16px;
.card-funnel {
grid-column: span 5;
}
.card-trend {
grid-column: span 7;
}
.card-source {
grid-column: span 4;
}
.card-roi {
grid-column: span 8;
}
.card-calendar {
grid-column: span 12;
}
}
.dashboard-scope-strip {
display: flex;
flex-wrap: wrap;
gap: 12px;
color: var(--muted);
font-size: 13px;
.dashboard-card {
.card-hint {
color: var(--dash-muted);
font-size: 12px;
flex-shrink: 0;
}
}
@media (max-width: 1100px) {
.dashboard-kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dashboard-insight-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dashboard-main-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
.card-funnel,
.card-trend,
.card-source,
.card-roi {
grid-column: span 1;
}
.card-calendar {
grid-column: span 2;
}
}
}
@media (max-width: 680px) {
.dashboard-kpi-grid,
.dashboard-insight-grid,
.dashboard-main-grid {
grid-template-columns: 1fr;
}
.dashboard-main-grid {
.card-funnel,
.card-trend,
.card-source,
.card-roi,
.card-calendar {
grid-column: auto;
}
}
}
</style>
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