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

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

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

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

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

- 招聘日历改为左侧月格+右侧事件卡片双栏,宽屏自适应多列,加图例与数量角标
parent 3605d466
...@@ -22,14 +22,33 @@ const EVENT_TYPE_LABELS = { ...@@ -22,14 +22,33 @@ const EVENT_TYPE_LABELS = {
candidate_claimed: '从人才池认领', candidate_claimed: '从人才池认领',
job_filled: '岗位自动招满', job_filled: '岗位自动招满',
} }
const eventColor = (type) =>
type === 'candidate_created' // 事件分为四类语义,颜色与图例/日格/动态列表共用,避免逐个类型散落颜色
? 'var(--blue)' const CATEGORIES = [
: type === 'onboarded' || type === 'job_filled' { key: 'entry', color: 'var(--dash-blue)', label: '入库' },
? 'var(--green)' { key: 'progress', color: 'var(--dash-violet)', label: '匹配 / 推进' },
: type === 'pool_moved' || type === 'candidate_claimed' { key: 'hire', color: 'var(--dash-green)', label: '入职 / 招满' },
? 'var(--rose)' { key: 'pool', color: 'var(--dash-rose)', label: '人才池' },
: 'var(--violet)' ]
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 eventLabel = (event) => EVENT_TYPE_LABELS[event.type] || event.type
const eventSubject = (event) => event.candidateName || event.jobTitle || '候选人' const eventSubject = (event) => event.candidateName || event.jobTitle || '候选人'
...@@ -70,12 +89,14 @@ const eventsByDay = computed(() => { ...@@ -70,12 +89,14 @@ const eventsByDay = computed(() => {
const eventTotalInMonth = computed(() => [...eventsByDay.value.values()].reduce((sum, list) => sum + list.length, 0)) const eventTotalInMonth = computed(() => [...eventsByDay.value.values()].reduce((sum, list) => sum + list.length, 0))
const isToday = (date) => date && date.toDateString() === new Date().toDateString() 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 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') const dayTip = (events) => events.map((event) => `${eventSubject(event)} · ${eventLabel(event)}`).join('\n')
// 事件明细只列当月最近 6 条(与日历红点同月,避免跨月错位) // 事件明细只列当月最近 6 条(与日同月,避免跨月错位)
const listEvents = computed(() => const listEvents = computed(() =>
[...props.events] [...props.events]
.filter((event) => { .filter((event) => {
...@@ -83,34 +104,62 @@ const listEvents = computed(() => ...@@ -83,34 +104,62 @@ const listEvents = computed(() =>
return !Number.isNaN(date.getTime()) && date.getFullYear() === year && date.getMonth() === month return !Number.isNaN(date.getTime()) && date.getFullYear() === year && date.getMonth() === month
}) })
.sort((a, b) => new Date(b.at) - new Date(a.at)) .sort((a, b) => new Date(b.at) - new Date(a.at))
.slice(0, 6) .slice(0, 10)
) )
</script> </script>
<template> <template>
<div class="calendar"> <div class="calendar">
<div class="calendar-layout">
<div class="calendar-left">
<div class="calendar-head"> <div class="calendar-head">
<strong>{{ monthLabel }}</strong> <strong>{{ monthLabel }}</strong>
<span v-if="eventTotalInMonth">本月 {{ eventTotalInMonth }} 条动态</span> <span v-if="eventTotalInMonth">本月 {{ eventTotalInMonth }} 条动态</span>
<span v-else>本月暂无动态</span> <span v-else>本月暂无动态</span>
</div> </div>
<div class="calendar-grid"> <div class="calendar-grid">
<span v-for="week in ['日', '一', '二', '三', '四', '五', '六']" :key="week" class="week-head">{{ week }}</span> <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"> <template v-for="(date, index) in days" :key="index">
<div class="day-cell" :class="{ today: isToday(date), empty: !date }"> <div
class="day-cell"
:class="{
today: isToday(date),
weekend: isWeekend(date),
empty: !date,
hasEvents: !!(date && dayEvents(date).length),
}"
>
<template v-if="date"> <template v-if="date">
<span class="day-num">{{ date.getDate() }}</span> <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))"> <span v-if="dayEvents(date).length" class="dot-mark" :title="dayTip(dayEvents(date))">
<i <i v-for="color in dayColors(date)" :key="color" :style="{ background: color }" />
v-for="event in dayEvents(date).slice(0, 3)"
:key="event.id"
:style="{ background: eventColor(event.type) }"
/>
</span> </span>
</template> </template>
</div> </div>
</template> </template>
</div> </div>
<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-if="listEvents.length" class="calendar-events">
<div v-for="event in listEvents" :key="event.id" class="event-chip"> <div v-for="event in listEvents" :key="event.id" class="event-chip">
<i :style="{ background: eventColor(event.type) }" /> <i :style="{ background: eventColor(event.type) }" />
...@@ -120,13 +169,27 @@ const listEvents = computed(() => ...@@ -120,13 +169,27 @@ const listEvents = computed(() =>
</div> </div>
<div v-else class="empty-inline">当前月份暂无事件</div> <div v-else class="empty-inline">当前月份暂无事件</div>
</div> </div>
</div>
</div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.calendar { .calendar {
display: flex; display: flex;
flex-direction: column; 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 { .calendar-head {
...@@ -136,13 +199,14 @@ const listEvents = computed(() => ...@@ -136,13 +199,14 @@ const listEvents = computed(() =>
gap: 12px; gap: 12px;
strong { strong {
font-size: 14px; font-size: 15px;
color: var(--ink); color: var(--dash-ink);
} }
span { span {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--dash-muted);
font-variant-numeric: tabular-nums;
} }
} }
...@@ -154,9 +218,13 @@ const listEvents = computed(() => ...@@ -154,9 +218,13 @@ const listEvents = computed(() =>
.week-head { .week-head {
text-align: center; text-align: center;
font-size: 12px; font-size: 11px;
color: var(--muted); color: var(--dash-muted);
padding: 2px 0; padding: 2px 0;
&.weekend {
color: var(--dash-amber);
}
} }
.day-cell { .day-cell {
...@@ -165,18 +233,56 @@ const listEvents = computed(() => ...@@ -165,18 +233,56 @@ const listEvents = computed(() =>
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 6px; border-radius: 8px;
font-size: 12px; font-size: 12px;
background: var(--bg); background: var(--panel);
color: var(--ink); 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 { &.today {
outline: 2px solid var(--blue); box-shadow:
outline-offset: 0; 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 { &.empty {
background: transparent; 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 { .dot-mark {
...@@ -190,23 +296,77 @@ const listEvents = computed(() => ...@@ -190,23 +296,77 @@ const listEvents = computed(() =>
height: 5px; height: 5px;
border-radius: 50%; border-radius: 50%;
display: block; 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
max-height: 132px; min-width: 0;
overflow-y: auto; }
.event-chip { .events-title {
display: flex; 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: grid;
grid-template-columns: 8px 28px minmax(0, 1fr);
align-items: center; 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; font-size: 12px;
color: var(--muted); color: var(--dash-muted);
min-width: 0; min-width: 0;
i { i {
...@@ -214,26 +374,36 @@ const listEvents = computed(() => ...@@ -214,26 +374,36 @@ const listEvents = computed(() =>
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
justify-self: center;
} }
.event-date { .event-date {
flex-shrink: 0; text-align: center;
color: var(--ink); color: var(--dash-ink);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-weight: 500;
} }
.event-text { .event-text {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-width: 0;
} }
} }
} }
.empty-inline { .empty-inline {
padding: 10px 0 2px; padding: 20px 0;
text-align: center; text-align: center;
color: var(--muted); color: var(--dash-muted);
font-size: 13px; font-size: 13px;
} }
@media (max-width: 900px) {
.calendar-layout {
grid-template-columns: 1fr;
gap: 18px;
}
}
</style> </style>
...@@ -5,8 +5,23 @@ const props = defineProps({ ...@@ -5,8 +5,23 @@ const props = defineProps({
analytics: { type: Object, default: () => ({ funnel: [] }) }, analytics: { type: Object, default: () => ({ funnel: [] }) },
}) })
// 由「深蓝(简历) → 青 → 绿(入职)」的色带,传达候选人一路收敛到入职 // 语义色带:蓝(简历) → 青(初筛) → 紫(面试) → 琥珀(Offer) → 绿(入职),与 KPI/洞察同源
const FUNNEL_COLORS = ['#4e6fc0', '#648fc8', '#6fb6cd', '#5cc4a4', '#3fa97f'] 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 = { const STEP_LABELS = {
初筛通过: '初筛通过率', 初筛通过: '初筛通过率',
...@@ -18,7 +33,8 @@ const STEP_LABELS = { ...@@ -18,7 +33,8 @@ const STEP_LABELS = {
const rows = computed(() => const rows = computed(() =>
(props.analytics.funnel || []).map((row, index) => ({ (props.analytics.funnel || []).map((row, index) => ({
...row, ...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] || '阶段转化率', stepLabel: index === 0 ? '' : STEP_LABELS[row.name] || '阶段转化率',
})) }))
) )
...@@ -46,14 +62,24 @@ const shapeStyle = (row, index) => { ...@@ -46,14 +62,24 @@ const shapeStyle = (row, index) => {
clipPath: `polygon(${leftTop}% 0, ${rightTop}% 0, ${rightBottom}% 100%, ${leftBottom}% 100%)`, clipPath: `polygon(${leftTop}% 0, ${rightTop}% 0, ${rightBottom}% 100%, ${leftBottom}% 100%)`,
} }
} }
const cycleHint = computed(() => {
const value = props.analytics.cycleDays
return value && value !== '暂无' ? `平均周期 ${value}` : ''
})
</script> </script>
<template> <template>
<div v-if="!isEmpty" class="funnel"> <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 v-for="(row, index) in rows" :key="row.name">
<div class="funnel-row"> <div class="funnel-row">
<span class="funnel-name">{{ row.name }}</span> <span class="funnel-name">
<div class="funnel-shape" :style="shapeStyle(row, index)" /> {{ 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"> <div class="funnel-meta">
<strong>{{ row.value }}</strong> <strong>{{ row.value }}</strong>
<em :title="`占首层(简历进入) ${row.pct}`">占简历 {{ row.pct }}</em> <em :title="`占首层(简历进入) ${row.pct}`">占简历 {{ row.pct }}</em>
...@@ -80,6 +106,19 @@ const shapeStyle = (row, index) => { ...@@ -80,6 +106,19 @@ const shapeStyle = (row, index) => {
gap: 4px; 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 { .funnel-row {
display: grid; display: grid;
grid-template-columns: 84px 1fr 72px; grid-template-columns: 84px 1fr 72px;
...@@ -90,15 +129,29 @@ const shapeStyle = (row, index) => { ...@@ -90,15 +129,29 @@ const shapeStyle = (row, index) => {
.funnel-name { .funnel-name {
font-size: 13px; font-size: 13px;
color: var(--ink); color: var(--dash-ink);
font-weight: 500;
} }
.funnel-shape { .funnel-shape {
position: relative;
height: 40px; height: 40px;
width: 100%; width: 100%;
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0));
transition: transition:
clip-path 0.35s ease, clip-path 0.35s ease,
background 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 { .funnel-meta {
...@@ -109,13 +162,13 @@ const shapeStyle = (row, index) => { ...@@ -109,13 +162,13 @@ const shapeStyle = (row, index) => {
display: block; display: block;
font-size: 16px; font-size: 16px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--ink); color: var(--dash-ink);
} }
em { em {
font-style: normal; font-style: normal;
font-size: 11px; font-size: 11px;
color: var(--muted); color: var(--dash-muted);
} }
} }
...@@ -136,16 +189,16 @@ const shapeStyle = (row, index) => { ...@@ -136,16 +189,16 @@ const shapeStyle = (row, index) => {
.step-line { .step-line {
flex: 1; flex: 1;
height: 1px; height: 1px;
background: var(--line); background: var(--dash-line);
} }
.step-text { .step-text {
font-size: 11px; font-size: 11px;
color: var(--muted); color: var(--dash-muted);
white-space: nowrap; white-space: nowrap;
b { b {
color: var(--ink); color: var(--dash-ink);
font-weight: 600; font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
......
<script setup> <script setup>
defineProps({ import { computed } from 'vue'
const props = defineProps({
label: { type: String, required: true }, label: { type: String, required: true },
value: { type: Number, default: 0 }, value: { type: Number, default: 0 },
hint: { type: String, default: '' }, 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> </script>
<template> <template>
<article class="dashboard-kpi" :style="{ '--accent': color }"> <article class="dashboard-kpi" :style="{ '--accent': tone.accent, '--soft': tone.soft }">
<span>{{ label }}</span> <div class="kpi-top">
<strong>{{ value }}</strong> <span class="kpi-label">{{ label }}</span>
<em>{{ hint }}</em> <el-icon class="kpi-icon"><component :is="icon" /></el-icon>
</div>
<strong class="kpi-value">{{ value }}</strong>
<em class="kpi-hint">{{ hint }}</em>
</article> </article>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.dashboard-kpi { .dashboard-kpi {
position: relative;
overflow: hidden;
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--dash-line);
border-radius: 12px; border-radius: 12px;
padding: 16px 18px; padding: 16px 16px 15px;
// 顶部细色带:区分语义,不喧宾夺主
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent), transparent 70%);
opacity: 0.55;
}
.kpi-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
span { .kpi-label {
color: var(--muted); color: var(--dash-muted);
font-size: 13px; font-size: 13px;
font-weight: 500;
} }
strong { .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; display: block;
margin: 6px 0 4px; margin: 10px 0 3px;
font-size: 28px; font-size: 30px;
line-height: 1.1;
color: var(--accent); color: var(--accent);
font-variant-numeric: tabular-nums;
font-weight: 700;
letter-spacing: 0;
} }
em { .kpi-hint {
color: var(--muted); display: inline-flex;
align-items: center;
gap: 5px;
color: var(--dash-muted);
font-size: 12px; font-size: 12px;
font-style: normal; font-style: normal;
&::before {
content: '';
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--dash-grid);
}
} }
} }
</style> </style>
...@@ -26,7 +26,7 @@ const tableRows = computed(() => ...@@ -26,7 +26,7 @@ const tableRows = computed(() =>
</script> </script>
<template> <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 prop="source" label="渠道" min-width="88" />
<el-table-column label="简历" width="62" align="right"> <el-table-column label="简历" width="62" align="right">
<template #default="{ row }">{{ row.candidates }}</template> <template #default="{ row }">{{ row.candidates }}</template>
...@@ -60,6 +60,14 @@ const tableRows = computed(() => ...@@ -60,6 +60,14 @@ const tableRows = computed(() =>
</template> </template>
<style lang="scss" scoped> <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 { .rate-cell {
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -71,7 +79,7 @@ const tableRows = computed(() => ...@@ -71,7 +79,7 @@ const tableRows = computed(() =>
min-width: 34px; min-width: 34px;
height: 6px; height: 6px;
border-radius: 999px; border-radius: 999px;
background: var(--bg); background: var(--dash-grid);
overflow: hidden; overflow: hidden;
b { b {
...@@ -80,17 +88,19 @@ const tableRows = computed(() => ...@@ -80,17 +88,19 @@ const tableRows = computed(() =>
width: var(--w); width: var(--w);
min-width: 0; min-width: 0;
border-radius: 999px; 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 { &.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 { span {
flex-shrink: 0; flex-shrink: 0;
color: var(--muted); color: var(--dash-muted);
font-size: 12px; font-size: 12px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
......
...@@ -5,7 +5,14 @@ const props = defineProps({ ...@@ -5,7 +5,14 @@ const props = defineProps({
rows: { type: Array, default: () => [] }, 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)) const totalCandidates = computed(() => props.rows.reduce((sum, row) => sum + (row.candidates || 0), 0))
...@@ -66,19 +73,21 @@ const conicStyle = computed(() => { ...@@ -66,19 +73,21 @@ const conicStyle = computed(() => {
} }
.donut { .donut {
width: 132px; width: 136px;
height: 132px; height: 136px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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; transition: background 0.3s ease;
.donut-hole { .donut-hole {
width: 84px; width: 78px;
height: 84px; height: 78px;
border-radius: 50%; border-radius: 50%;
background: var(--panel); background: var(--panel);
display: flex; display: flex;
...@@ -90,11 +99,12 @@ const conicStyle = computed(() => { ...@@ -90,11 +99,12 @@ const conicStyle = computed(() => {
strong { strong {
font-size: 22px; font-size: 22px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--dash-ink);
} }
span { span {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--dash-muted);
} }
} }
} }
...@@ -120,19 +130,21 @@ const conicStyle = computed(() => { ...@@ -120,19 +130,21 @@ const conicStyle = computed(() => {
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 0 2px var(--dash-grid);
} }
.name { .name {
color: var(--ink); color: var(--dash-ink);
} }
.value { .value {
font-weight: 600; font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--dash-ink);
} }
em { em {
color: var(--muted); color: var(--dash-muted);
font-style: normal; font-style: normal;
min-width: 42px; min-width: 42px;
text-align: right; text-align: right;
......
...@@ -7,14 +7,31 @@ const props = defineProps({ ...@@ -7,14 +7,31 @@ const props = defineProps({
}) })
const SERIES = [ const SERIES = [
{ key: 'resumes', label: '简历', color: 'var(--blue)' }, { key: 'resumes', label: '简历', color: 'var(--dash-blue)' },
{ key: 'offers', label: 'Offer', color: 'var(--violet)' }, { key: 'offers', label: 'Offer', color: 'var(--dash-violet)' },
{ key: 'onboarded', label: '入职', color: 'var(--green)' }, { 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)) 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 const periodLabel = (row) => row.period || row.label
...@@ -28,8 +45,12 @@ const periodLabel = (row) => row.period || row.label ...@@ -28,8 +45,12 @@ const periodLabel = (row) => row.period || row.label
</span> </span>
</div> </div>
<div class="trend-chart"> <div class="trend-chart">
<div class="trend-y">
<span v-for="tick in ticks" :key="tick">{{ tick }}</span>
</div>
<div class="trend-plot">
<div class="trend-grid" aria-hidden="true"> <div class="trend-grid" aria-hidden="true">
<i v-for="step in 4" :key="step" /> <i v-for="index in gridCount + 1" :key="index" />
</div> </div>
<div v-for="row in rows" :key="periodLabel(row)" class="trend-col"> <div v-for="row in rows" :key="periodLabel(row)" class="trend-col">
<div class="trend-bars"> <div class="trend-bars">
...@@ -38,7 +59,7 @@ const periodLabel = (row) => row.period || row.label ...@@ -38,7 +59,7 @@ const periodLabel = (row) => row.period || row.label
:key="series.key" :key="series.key"
class="trend-bar" class="trend-bar"
:style="{ :style="{
height: `${((Number(row[series.key]) || 0) / maxValue) * 100}%`, height: `${((Number(row[series.key]) || 0) / axisTop) * 100}%`,
'--accent': series.color, '--accent': series.color,
}" }"
:title="`${periodLabel(row)} ${series.label}:${row[series.key] || 0}`" :title="`${periodLabel(row)} ${series.label}:${row[series.key] || 0}`"
...@@ -48,6 +69,7 @@ const periodLabel = (row) => row.period || row.label ...@@ -48,6 +69,7 @@ const periodLabel = (row) => row.period || row.label
</div> </div>
</div> </div>
</div> </div>
</div>
<div v-else class="empty">当前筛选暂无数据</div> <div v-else class="empty">当前筛选暂无数据</div>
</template> </template>
...@@ -62,41 +84,66 @@ const periodLabel = (row) => row.period || row.label ...@@ -62,41 +84,66 @@ const periodLabel = (row) => row.period || row.label
display: flex; display: flex;
gap: 16px; gap: 16px;
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--dash-muted);
flex-wrap: wrap;
.dot { .dot {
display: inline-block; display: inline-block;
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
margin-right: 5px; margin-right: 6px;
box-shadow: 0 0 0 3px var(--dash-grid);
} }
} }
.trend-chart { .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; position: relative;
display: flex; display: flex;
align-items: stretch; flex: 1;
gap: 10px; min-width: 0;
height: 160px; gap: 12px;
} }
.trend-grid { .trend-grid {
position: absolute; position: absolute;
inset: 0 0 20px; inset: 0 0 22px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
pointer-events: none; pointer-events: none;
i { i {
border-top: 1px dashed var(--line); border-top: 1px dashed var(--dash-grid);
display: block; display: block;
} }
} }
.trend-col { .trend-col {
position: relative; z-index: 1;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: flex; display: flex;
...@@ -119,20 +166,28 @@ const periodLabel = (row) => row.period || row.label ...@@ -119,20 +166,28 @@ const periodLabel = (row) => row.period || row.label
min-width: 4px; min-width: 4px;
max-width: 14px; max-width: 14px;
min-height: 2px; min-height: 2px;
border-radius: 3px 3px 0 0; border-radius: 4px 4px 1px 1px;
background: var(--accent); background-color: var(--accent);
opacity: 0.9; background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0));
opacity: 0.94;
cursor: default; cursor: default;
transition: opacity 0.15s ease; transition:
opacity 0.15s ease,
transform 0.15s ease;
&:hover { &:hover {
opacity: 1; opacity: 1;
transform: translateY(-2px);
} }
} }
.trend-period { .trend-period {
font-size: 12px; height: 22px;
color: var(--muted); display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--dash-muted);
white-space: nowrap; white-space: nowrap;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { percent } from '@/utils/format'
import { dashboardAnalytics, dashboardScopeLabel, pageMetrics } from '@/utils/dashboard' import { dashboardAnalytics, dashboardScopeLabel, pageMetrics } from '@/utils/dashboard'
import DashboardFilterBar from '@/components/dashboard/DashboardFilterBar.vue' import DashboardFilterBar from '@/components/dashboard/DashboardFilterBar.vue'
import KpiCard from '@/components/dashboard/KpiCard.vue' import KpiCard from '@/components/dashboard/KpiCard.vue'
...@@ -26,6 +27,23 @@ const scopeItems = computed(() => dashboardScopeLabel(store.dashboardFilters, st ...@@ -26,6 +27,23 @@ const scopeItems = computed(() => dashboardScopeLabel(store.dashboardFilters, st
const m = computed(() => pageMetrics(store.dashboardFilters, store.state)) const m = computed(() => pageMetrics(store.dashboardFilters, store.state))
const analytics = computed(() => dashboardAnalytics(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 calendarMonth = computed(() => {
const filters = store.dashboardFilters const filters = store.dashboardFilters
...@@ -38,110 +56,276 @@ const calendarMonth = computed(() => { ...@@ -38,110 +56,276 @@ const calendarMonth = computed(() => {
<div class="dashboard-workbench"> <div class="dashboard-workbench">
<DashboardFilterBar /> <DashboardFilterBar />
<div class="dashboard-scope-strip">
<span v-for="item in scopeItems" :key="item">{{ item }}</span>
</div>
<div class="dashboard-kpi-grid"> <div class="dashboard-kpi-grid">
<KpiCard label="候选人数" :value="m.candidates" hint="简历库主数据" color="var(--blue)" /> <KpiCard label="候选人数" :value="m.candidates" hint="简历库主数据" icon="User" tone="blue" />
<KpiCard label="面试人数" :value="m.interview" hint="面试中及后续阶段" color="var(--cyan)" /> <KpiCard label="面试人数" :value="m.interview" hint="面试中及后续阶段" icon="ChatDotRound" tone="cyan" />
<KpiCard label="Offer 人数" :value="m.offers" hint="录用入职回写" color="var(--violet)" /> <KpiCard label="Offer 人数" :value="m.offers" hint="录用入职回写" icon="Money" tone="violet" />
<KpiCard label="待入职" :value="m.onboarding" hint="Offer 已发待到岗" color="var(--yellow)" /> <KpiCard label="待入职" :value="m.onboarding" hint="Offer 已发待到岗" icon="Timer" tone="amber" />
<KpiCard label="入职人数" :value="m.hired" hint="岗位已入职合计" color="var(--green)" /> <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>
<div class="dashboard-main-grid"> <div class="dashboard-main-grid">
<section class="card dashboard-card"> <section class="card dashboard-card card-funnel">
<div class="card-head"> <div class="card-head">
<div> <div>
<div class="card-title">招聘漏斗</div> <div class="card-title">招聘漏斗</div>
<div class="card-note">简历 → 初筛 → 面试 → Offer → 入职</div> <div class="card-note">简历沉淀到入职的逐级转化</div>
</div> </div>
<span class="card-hint">已推进阶段累计</span> <span class="card-hint">按阶段累计</span>
</div> </div>
<FunnelCard :analytics="analytics" /> <FunnelCard :analytics="analytics" />
</section> </section>
<section class="card dashboard-card"> <section class="card dashboard-card card-trend">
<div class="card-head"> <div class="card-head">
<div> <div>
<div class="card-title">{{ periodLabel }} 趋势</div> <div class="card-title">{{ periodLabel }} 趋势</div>
<div class="card-note">累计候选人 / Offer / 入职</div> <div class="card-note">候选人 / Offer / 入职 走势</div>
</div> </div>
<span class="card-hint">{{ store.dashboardFilters.range }}</span> <span class="card-hint">{{ store.dashboardFilters.range }}</span>
</div> </div>
<TrendCard :rows="analytics.trend" :range="store.dashboardFilters.range" /> <TrendCard :rows="analytics.trend" :range="store.dashboardFilters.range" />
</section> </section>
<section class="card dashboard-card"> <section class="card dashboard-card card-source">
<div class="card-head"> <div class="card-head">
<div> <div>
<div class="card-title">候选人来源占比</div> <div class="card-title">候选人来源占比</div>
<div class="card-note">来源结构 / 入职转化</div> <div class="card-note">各渠道简历结构</div>
</div> </div>
<span class="card-hint">渠道结构</span> <span class="card-hint">来源结构</span>
</div> </div>
<SourceCard :rows="analytics.sourceRows" /> <SourceCard :rows="analytics.sourceRows" />
</section> </section>
<section class="card dashboard-card"> <section class="card dashboard-card card-roi">
<div class="card-head"> <div class="card-head">
<div> <div>
<div class="card-title">招聘日历</div> <div class="card-title">渠道效果与 ROI</div>
<div class="card-note">候选人 / Offer / 入职动态</div> <div class="card-note">简历量 / 面试 / Offer / 入职转化</div>
</div> </div>
<span class="card-hint">跟随筛选月份</span> <span class="card-hint">渠道效果</span>
</div> </div>
<CalendarCard :events="analytics.scope.events" :period="calendarMonth" /> <RoiCard :rows="analytics.sourceRows" />
</section> </section>
<section class="card dashboard-card"> <section class="card dashboard-card card-calendar">
<div class="card-head"> <div class="card-head">
<div> <div>
<div class="card-title">渠道效果与 ROI</div> <div class="card-title">招聘日历</div>
<div class="card-note">渠道简历量 / 面试 / Offer / 入职转化</div> <div class="card-note">候选人 / Offer / 入职动态</div>
</div> </div>
<span class="card-hint">渠道效果</span> <span class="card-hint">跟随筛选月份</span>
</div> </div>
<RoiCard :rows="analytics.sourceRows" /> <CalendarCard :events="analytics.scope.events" :period="calendarMonth" />
</section> </section>
</div> </div>
<div class="dashboard-scope-strip">
<span v-for="item in scopeItems" :key="item">{{ item }}</span>
</div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
// 看板级语义配色:与全局 token 同源,但按数据可视化调优明度/对比度,
// 仅在 dashboard 根元素注入,不影响其他页面。
.dashboard-workbench { .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; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; 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 { .dashboard-kpi-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px; 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 { .dashboard-main-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 16px; 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 { .dashboard-card {
display: flex; .card-hint {
flex-wrap: wrap; color: var(--dash-muted);
gap: 12px; font-size: 12px;
color: var(--muted); flex-shrink: 0;
font-size: 13px; }
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.dashboard-kpi-grid { .dashboard-kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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 { .dashboard-main-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dashboard-main-grid {
.card-funnel,
.card-trend,
.card-source,
.card-roi,
.card-calendar {
grid-column: auto;
}
}
} }
</style> </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