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

style: 人才池改为档案导卡筛选与台账列表,支撑大库浏览

parent 6b8b2e2f
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { CANDIDATE_TAG_OPTIONS, FOLLOW_UP_TAGS, candidateTagTone, normalizeCandidateTags } from '@/utils/constants' import { CANDIDATE_TAG_OPTIONS, FOLLOW_UP_TAGS, normalizeCandidateTags } from '@/utils/constants'
import { enrichCandidateFields, candidateAge, candidateYears } from '@/utils/candidate' import { enrichCandidateFields, candidateAge, candidateYears } from '@/utils/candidate'
import { candidatePassesTagFilter } from '@/utils/matching'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
const store = useRecruitmentStore() const store = useRecruitmentStore()
const router = useRouter() const router = useRouter()
const filters = ref({ q: '', tag: '全部标签', source: '全部来源' }) const ALL_TAG = '全部标签'
const FOLLOW_TAG = '待跟进'
const NONE_TAG = '__none__'
const filters = ref({ q: '', tag: ALL_TAG, source: '全部来源', sort: 'recent' })
const density = ref(readDensity())
const detailOpen = ref(false) const detailOpen = ref(false)
const selectedId = ref('') const selectedId = ref('')
const LOAD_STEP = 60
const visibleCount = ref(LOAD_STEP)
const tagOptions = ['全部标签', '待跟进', ...CANDIDATE_TAG_OPTIONS] function readDensity() {
try {
return localStorage.getItem('pool-density-v1') === 'grid' ? 'grid' : 'list'
} catch {
return 'list'
}
}
const sourceOptions = computed(() => [ function persistDensity(value) {
'全部来源', density.value = value
...new Set((store.poolCandidates || []).map((candidate) => candidate.source).filter(Boolean)), try {
]) localStorage.setItem('pool-density-v1', value)
} catch {
/* 忽略隐私模式下的写入失败 */
}
}
// 池内候选人不属于当前工作区,只做轻量档案补全,不绑定本区岗位。 // 池内候选人不属于当前工作区,只做轻量档案补全,不绑定本区岗位。
const poolView = computed(() => const poolView = computed(() =>
...@@ -31,11 +47,94 @@ const poolView = computed(() => ...@@ -31,11 +47,94 @@ const poolView = computed(() =>
}) })
) )
const sourceOptions = computed(() => [
'全部来源',
...new Set(poolView.value.map((candidate) => candidate.source).filter(Boolean)),
])
// ---- 导卡(guide-tab) 标签过滤体系 ----
// 两个检索区:视图(全部/待跟进) 与 标记(预设+自定义+未标记),单点切换、再点取消。
const guideSections = computed(() => {
const all = poolView.value
const tagSet = (candidate) => candidate.tags || []
const countBy = (predicate) => all.filter(predicate).length
const preset = CANDIDATE_TAG_OPTIONS
const custom = new Map()
all.forEach((candidate) => {
tagSet(candidate).forEach((tag) => {
if (!preset.includes(tag)) custom.set(tag, (custom.get(tag) || 0) + 1)
})
})
return [
{
caption: '视图',
tabs: [
{ key: ALL_TAG, label: '全部', tone: '#4569c9', count: all.length },
{
key: FOLLOW_TAG,
label: FOLLOW_TAG,
tone: '#9a6a12',
count: countBy((candidate) => tagSet(candidate).some((tag) => FOLLOW_UP_TAGS.includes(tag))),
hint: '重点跟进 · 待二面 · 待沟通 · 需维护',
},
],
},
{
caption: '标记',
tabs: [
...preset.map((tag) => ({
key: tag,
label: tag,
tone: tagColor(tag),
count: countBy((candidate) => tagSet(candidate).includes(tag)),
})),
...[...custom.entries()]
.sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]), 'zh-CN'))
.map(([label, count]) => ({ key: label, label, tone: tagColor(label), count })),
{
key: NONE_TAG,
label: '未标记',
tone: '#9aa3b2',
count: countBy((candidate) => tagSet(candidate).length === 0),
hint: '还没有任何标记的档案',
},
],
},
]
})
function passesTag(candidate, key) {
const tags = normalizeCandidateTags(candidate)
if (key === ALL_TAG) return true
if (key === FOLLOW_TAG) return tags.some((tag) => FOLLOW_UP_TAGS.includes(tag))
if (key === NONE_TAG) return tags.length === 0
return tags.includes(key)
}
function selectTag(tab) {
filters.value.tag = filters.value.tag === tab.key ? ALL_TAG : tab.key
}
function isTagActive(tab) {
return filters.value.tag === tab.key
}
// ---- 统计 ----
const followUpCount = computed(() => poolView.value.filter((candidate) => passesTag(candidate, FOLLOW_TAG)).length)
const unmarkedCount = computed(() => poolView.value.filter((candidate) => passesTag(candidate, NONE_TAG)).length)
const newThisWeek = computed(() => {
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000
return poolView.value.filter((candidate) => Date.parse(candidate.pooledAt || '') >= weekAgo).length
})
// ---- 检索与排序 ----
const filtered = computed(() => { const filtered = computed(() => {
const q = filters.value.q.toLowerCase().trim() const q = filters.value.q.toLowerCase().trim()
const list = poolView.value.filter((candidate) => { return poolView.value.filter((candidate) => {
if (filters.value.source !== '全部来源' && (candidate.source || '其他') !== filters.value.source) return false if (filters.value.source !== '全部来源' && (candidate.source || '其他') !== filters.value.source) return false
if (!candidatePassesTagFilter(candidate, filters.value.tag)) return false if (!passesTag(candidate, filters.value.tag)) return false
if (q) { if (q) {
const haystack = [ const haystack = [
candidate.name, candidate.name,
...@@ -53,28 +152,113 @@ const filtered = computed(() => { ...@@ -53,28 +152,113 @@ const filtered = computed(() => {
} }
return true return true
}) })
})
const sorted = computed(() => {
const list = [...filtered.value]
const poolTs = (candidate) => Date.parse(candidate.pooledAt || '') || 0
switch (filters.value.sort) {
case 'oldest':
list.sort((a, b) => poolTs(a) - poolTs(b) || String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN'))
break
case 'name':
list.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN'))
break
case 'years':
list.sort((a, b) => Number(b.years || 0) - Number(a.years || 0) || poolTs(b) - poolTs(a))
break
default:
list.sort((a, b) => poolTs(b) - poolTs(a) || String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN'))
}
return list return list
}) })
const followUpCount = computed( const visible = computed(() => sorted.value.slice(0, visibleCount.value))
() => poolView.value.filter((candidate) => (candidate.tags || []).some((tag) => FOLLOW_UP_TAGS.includes(tag))).length const hasFilters = computed(
() => Boolean(filters.value.q.trim()) || filters.value.tag !== ALL_TAG || filters.value.source !== '全部来源'
) )
const newThisWeek = computed(() => { function resetFilters() {
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000 filters.value = { q: '', tag: ALL_TAG, source: '全部来源', sort: filters.value.sort }
return poolView.value.filter((candidate) => Date.parse(candidate.pooledAt || '') >= weekAgo).length }
})
watch(
[
() => filters.value.q,
() => filters.value.tag,
() => filters.value.source,
() => filters.value.sort,
() => poolView.value.length,
],
() => {
visibleCount.value = LOAD_STEP
}
)
// ---- 展示 ----
const sortOptions = [
{ value: 'recent', label: '最近入池' },
{ value: 'oldest', label: '最早入池' },
{ value: 'name', label: '姓名 A-Z' },
{ value: 'years', label: '经验年限' },
]
const selected = computed(() => poolView.value.find((candidate) => candidate.id === selectedId.value) || null) const selected = computed(() => poolView.value.find((candidate) => candidate.id === selectedId.value) || null)
const pad = (num) => String(num).padStart(2, '0')
const formatPooledAt = (iso) => { const formatPooledAt = (iso) => {
if (!iso) return '' if (!iso) return ''
const date = new Date(iso) const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '' if (Number.isNaN(date.getTime())) return ''
const pad = (num) => String(num).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
} }
const formatPooledShort = (iso) => {
const date = new Date(iso || '')
if (Number.isNaN(date.getTime())) return ''
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function avatarText(candidate) {
return String(candidate.name || '候')
.trim()
.slice(0, 1)
.toUpperCase()
}
function roleLine(candidate) {
const role = candidate.jobTitle || '待匹配岗位'
const stage = candidate.stage && candidate.stage !== '未筛选' ? candidate.stage : ''
return stage ? `${role} · ${stage}` : role
}
function pooledByText(candidate) {
return candidate.pooledByLabel || candidate.pooledBy || '未知'
}
function firstTone(candidate) {
const tags = candidate.tags || []
return tags.length ? tagColor(tags[0]) : '#a6aebf'
}
const tagColor = (tag = '') => {
const map = {
新入库: '#1f7a5a',
重点跟进: '#9a6a12',
待二面: '#9a6a12',
待沟通: '#9a6a12',
需维护: '#9a6a12',
储备: '#4d5c75',
已淘汰: '#b24848',
}
return map[tag] || '#3d4759'
}
function visibleTags(candidate) {
const tags = candidate.tags || []
return { shown: tags.slice(0, 2), extra: Math.max(0, tags.length - 2) }
}
// ---- 操作 ----
const openDetail = (candidate) => { const openDetail = (candidate) => {
selectedId.value = candidate.id selectedId.value = candidate.id
detailOpen.value = true detailOpen.value = true
...@@ -132,112 +316,266 @@ onMounted(() => { ...@@ -132,112 +316,266 @@ onMounted(() => {
store.loadPool() store.loadPool()
}) })
</script> </script>
<template> <template>
<div class="pool-page"> <div class="pool-page">
<div class="metric-grid"> <!-- 台账头:池况一句话 + 关键数字 -->
<article class="metric-card" style="--accent: var(--blue)"> <section class="hero-strip">
<div class="metric-copy"> <div class="hero-copy">
<span>总库人数</span> <p class="hero-eyebrow">COMPANY POOL · 共享总库</p>
<strong>{{ store.poolCandidates.length }}</strong> <h2>公司人才池</h2>
<small>公司共享人才池(跨账号)</small> <p class="hero-sub">淘汰、暂缓、储备的候选档案在这里沉淀——跨账号可见,检索到谁,谁认领回自己的流程。</p>
</div> </div>
</article> <dl class="hero-stats">
<article class="metric-card" style="--accent: var(--green)"> <div class="stat-cell">
<div class="metric-copy"> <dt>在库</dt>
<span>本周新入池</span> <dd class="stat-num" :class="{ 'is-empty': store.poolLoading }">
<strong>{{ newThisWeek }}</strong> {{ store.poolLoading ? '—' : store.poolCandidates.length }}
<small>近 7 天放入总库</small> </dd>
</div> </div>
</article> <div class="stat-cell">
<article class="metric-card" style="--accent: var(--yellow)"> <dt>本周入池</dt>
<div class="metric-copy"> <dd class="stat-num accent-up" :class="{ 'is-empty': store.poolLoading }">
<span>待跟进</span> {{ store.poolLoading ? '—' : `+${newThisWeek}` }}
<strong>{{ followUpCount }}</strong> </dd>
<small>重点跟进 / 待二面 / 待沟通 / 需维护</small>
</div> </div>
</article> <div class="stat-cell">
<dt>待跟进</dt>
<dd class="stat-num" :class="{ 'is-empty': store.poolLoading }">
{{ store.poolLoading ? '—' : followUpCount }}
</dd>
</div> </div>
<div class="stat-cell">
<dt>未标记</dt>
<dd class="stat-num" :class="{ 'is-empty': store.poolLoading }">
{{ store.poolLoading ? '—' : unmarkedCount }}
</dd>
</div>
</dl>
<div class="hero-action">
<el-button round class="ghost-btn" @click="goResumes">回简历库</el-button>
</div>
</section>
<section class="card pool-card"> <section class="pool-panel">
<header class="list-head"> <!-- 检索导卡带(签名元素):像档案目录的导卡,一张卡一种检索口径 -->
<div class="list-head-copy"> <div class="guide-rail">
<span class="section-kicker">总库 · 人才池</span> <div
<h2>公司共享人才池</h2> v-for="section in guideSections"
<p>面试淘汰 / 暂缓 / 储备的候选人都汇聚到这里,任一招聘账号都可查看并认领。</p> :key="section.caption"
class="guide-zone"
:class="{ 'is-divider': section.caption === '标记' }"
>
<span class="guide-caption">{{ section.caption }}</span>
<div class="guide-tabs">
<button
v-for="tab in section.tabs"
:key="tab.key"
class="guide-tab"
:class="{ 'is-active': isTagActive(tab) }"
type="button"
:title="tab.hint || tab.label"
:aria-pressed="isTagActive(tab)"
@click="selectTag(tab)"
>
<span class="gt-line">
<i class="gt-dot" :style="{ background: tab.tone }"></i>
<span class="gt-label">{{ tab.label }}</span>
<b class="gt-count" :style="{ color: tab.tone }">{{ tab.count }}</b>
</span>
<span class="gt-gauge" :class="{ 'is-none': tab.key === NONE_TAG }">
<i
class="gt-gauge-fill"
:style="{
width: `${Math.max(3, Math.round((tab.count / Math.max(1, poolView.length)) * 100))}%`,
background: tab.tone,
}"
></i>
</span>
</button>
</div>
</div> </div>
<div class="head-actions">
<el-button round @click="goResumes">回简历库</el-button>
</div> </div>
</header>
<div class="filter-row"> <!-- 检索工具行 -->
<el-input v-model="filters.q" class="filter-search" placeholder="搜索姓名、岗位、学校、技能、备注" clearable /> <div class="toolbar">
<el-select v-model="filters.tag" placeholder="标记"> <el-input v-model="filters.q" class="tool-search" placeholder="姓名、岗位、学校、技能、备注" clearable>
<el-option v-for="option in tagOptions" :key="option" :label="option" :value="option" /> <template #prefix
</el-select> ><el-icon class="tool-search-icon"><Search /></el-icon
<el-select v-model="filters.source" placeholder="来源"> ></template>
</el-input>
<span class="ctl">
<span class="ctl-label">来源</span>
<el-select v-model="filters.source" class="ctl-select" placeholder="来源">
<el-option v-for="option in sourceOptions" :key="option" :label="option" :value="option" /> <el-option v-for="option in sourceOptions" :key="option" :label="option" :value="option" />
</el-select> </el-select>
<el-button v-if="store.poolLoading" round disabled>加载中…</el-button> </span>
<span class="ctl">
<span class="ctl-label">排序</span>
<el-select v-model="filters.sort" class="ctl-select" placeholder="排序">
<el-option v-for="option in sortOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</span>
<span class="toolbar-spacer"></span>
<span v-if="hasFilters" class="hit-meta">
命中 <b>{{ sorted.length }}</b> / {{ poolView.length }}
<button type="button" class="clear-filters" @click="resetFilters">清除筛选</button>
</span>
<span v-else class="hit-meta"
> <b>{{ poolView.length }}</b> 人</span
>
<div class="density" role="group" aria-label="展示密度">
<button
type="button"
class="density-btn"
:class="{ 'is-active': density === 'list' }"
title="目录列表"
@click="persistDensity('list')"
>
<el-icon><List /></el-icon>
</button>
<button
type="button"
class="density-btn"
:class="{ 'is-active': density === 'grid' }"
title="卡片视图"
@click="persistDensity('grid')"
>
<el-icon><Grid /></el-icon>
</button>
</div>
</div>
<!-- 结果区 -->
<div class="results">
<!-- 骨架屏 -->
<div v-if="store.poolLoading" class="skeleton" aria-hidden="true">
<div v-for="i in 5" :key="i" class="skel-row"></div>
</div> </div>
<div v-if="filtered.length" class="pool-grid"> <!-- 列表(台账目录,适合大库) -->
<div v-else-if="visible.length && density === 'list'" class="ledger">
<div class="ledger-head">
<span>候选人</span>
<span>档案速览</span>
<span>标记</span>
<span>来源 · 入池</span>
<span class="col-actions">操作</span>
</div>
<article <article
v-for="candidate in filtered" v-for="candidate in visible"
:key="candidate.id" :key="candidate.id"
class="pool-card-item" class="ledger-row"
:style="{ '--row-tone': firstTone(candidate) }"
tabindex="0" tabindex="0"
@click="openDetail(candidate)" @click="openDetail(candidate)"
@keydown.enter="openDetail(candidate)" @keydown.enter="openDetail(candidate)"
> >
<div class="pool-person"> <div class="lr-person">
<div class="pool-avatar">{{ (candidate.name || '候').slice(0, 1).toUpperCase() }}</div> <span class="person-tile">{{ avatarText(candidate) }}</span>
<div class="pool-copy"> <span class="person-copy">
<div class="pool-name-row"> <strong class="person-name">{{ candidate.name || '未命名候选人' }}</strong>
<strong>{{ candidate.name || '未命名候选人' }}</strong> <span class="person-role">{{ roleLine(candidate) }}</span>
<span v-if="candidate.stage" class="stage-chip">{{ candidate.stage }}</span> </span>
</div> </div>
<div class="pool-sub">{{ candidate.jobTitle || '待匹配岗位' }}</div> <div
<div class="pool-meta"> class="lr-profile"
<span v-if="candidate.source && candidate.source !== '其他'">{{ candidate.source }}</span> :class="{ 'is-empty': !profileSummary(candidate) }"
<span v-if="candidate.pooledByLabel || candidate.pooledBy" :title="profileSummary(candidate)"
>入池:{{ candidate.pooledByLabel || candidate.pooledBy }}</span
> >
<span v-if="candidate.pooledAt">{{ formatPooledAt(candidate.pooledAt) }}</span> {{ profileSummary(candidate) || '档案待补全' }}
</div> </div>
<div class="lr-tags">
<template v-if="candidate.tags && candidate.tags.length">
<span
v-for="tag in visibleTags(candidate).shown"
:key="tag"
class="tag-chip"
:style="{ '--tone': tagColor(tag) }"
>{{ tag }}</span
>
<span v-if="visibleTags(candidate).extra" class="tag-more">+{{ visibleTags(candidate).extra }}</span>
</template>
<span v-else class="tag-chip is-none">未标记</span>
</div> </div>
<div class="lr-meta">
<span class="lr-source">{{ candidate.source || '其他' }}</span>
<span class="lr-by" :title="`${pooledByText(candidate)} 放入 · ${formatPooledAt(candidate.pooledAt)}`">
<b>{{ pooledByText(candidate) }}</b> · {{ formatPooledShort(candidate.pooledAt) || '—' }}
</span>
</div> </div>
<div class="lr-actions col-actions" @click.stop>
<div class="pool-profile" :class="{ 'is-empty': !profileSummary(candidate) }"> <button type="button" class="row-link" @click="openDetail(candidate)">查看</button>
{{ profileSummary(candidate) || '档案字段待补全' }} <el-button size="small" round type="primary" @click="claim(candidate)">认领</el-button>
</div>
</article>
</div> </div>
<div v-if="candidate.tags && candidate.tags.length" class="pool-tags"> <!-- 卡片视图(小库浏览) -->
<el-tag <div v-else-if="visible.length && density === 'grid'" class="pool-grid">
v-for="tag in candidate.tags" <article
v-for="candidate in visible"
:key="candidate.id"
class="pool-card"
:style="{ '--row-tone': firstTone(candidate) }"
tabindex="0"
@click="openDetail(candidate)"
@keydown.enter="openDetail(candidate)"
>
<div class="pc-head">
<span class="person-tile">{{ avatarText(candidate) }}</span>
<span class="pc-head-copy">
<strong>{{ candidate.name || '未命名候选人' }}</strong>
<span class="pc-role">{{ roleLine(candidate) }}</span>
</span>
</div>
<div class="pc-tags">
<template v-if="candidate.tags && candidate.tags.length">
<span
v-for="tag in candidate.tags.slice(0, 3)"
:key="tag" :key="tag"
size="small" class="tag-chip"
:type="candidateTagTone(tag)" :style="{ '--tone': tagColor(tag) }"
effect="light" >{{ tag }}</span
>{{ tag }}</el-tag
> >
<span v-if="candidate.tags.length > 3" class="tag-more">+{{ candidate.tags.length - 3 }}</span>
</template>
<span v-else class="tag-chip is-none">未标记</span>
</div> </div>
<p class="pc-profile" :class="{ 'is-empty': !profileSummary(candidate) }">
<div class="pool-actions" @click.stop> {{ profileSummary(candidate) || '档案待补全' }}
</p>
<footer class="pc-foot">
<span class="lr-source">{{ candidate.source || '其他' }}</span>
<span class="lr-by" :title="`${pooledByText(candidate)} 放入 · ${formatPooledAt(candidate.pooledAt)}`">
{{ pooledByText(candidate) }} · {{ formatPooledShort(candidate.pooledAt) || '—' }}
</span>
<span class="pc-actions" @click.stop>
<el-button size="small" round plain @click="openDetail(candidate)">查看</el-button> <el-button size="small" round plain @click="openDetail(candidate)">查看</el-button>
<el-button size="small" round type="primary" @click="claim(candidate)">认领</el-button> <el-button size="small" round type="primary" @click="claim(candidate)">认领</el-button>
</div> </span>
</footer>
</article> </article>
</div> </div>
<!-- 空态 -->
<div v-else class="empty-state"> <div v-else class="empty-state">
<strong>{{ store.poolCandidates.length ? '没有找到匹配的候选人' : '人才池还是空的' }}</strong> <template v-if="poolView.length">
<p v-if="store.poolCandidates.length">换个关键词或清除筛选条件试试。</p> <strong>没有命中结果</strong>
<p v-else>在简历库中把「面完未通过 / 暂缓 / 储备」的候选人移入人才池,公司共享总库就会在这里展示。</p> <p>换一个关键词、来源或导卡试试;池子里的其他人可能更适合你现在的岗位。</p>
<el-button v-if="!store.poolCandidates.length" round type="primary" @click="goResumes" <button v-if="hasFilters" type="button" class="empty-btn" @click="resetFilters">清除全部筛选</button>
>去简历库移入候选人</el-button </template>
> <template v-else>
<strong>人才池还没有档案</strong>
<p>在简历库中把「面完未通过 / 暂缓 / 储备」的候选人移入人才池,公司共享总库就会在这里展示。</p>
<button type="button" class="empty-btn" @click="goResumes">去简历库移入候选人</button>
</template>
</div>
<!-- 渐进加载:数量大时按批渲染,保持滚动顺畅 -->
<div v-if="!store.poolLoading && visible.length < sorted.length" class="load-more">
<span class="load-caption">已显示 {{ visible.length }} / {{ sorted.length }}</span>
<button type="button" class="load-more-btn" @click="visibleCount += LOAD_STEP">
再显示 {{ Math.min(LOAD_STEP, sorted.length - visible.length) }}
</button>
</div>
</div> </div>
</section> </section>
...@@ -246,14 +584,14 @@ onMounted(() => { ...@@ -246,14 +584,14 @@ onMounted(() => {
<header class="detail-head"> <header class="detail-head">
<div> <div>
<h3>{{ selected.name || '未命名候选人' }}</h3> <h3>{{ selected.name || '未命名候选人' }}</h3>
<p>{{ selected.jobTitle || '待匹配岗位' }}</p> <p>{{ roleLine(selected) }}</p>
</div> </div>
<div class="detail-head-side"> <div class="detail-head-side">
<div class="detail-head-meta"> <div class="detail-head-meta">
<el-tag size="small" effect="plain">{{ selected.source || '其他' }}</el-tag> <span class="tag-chip is-source">{{ selected.source || '其他' }}</span>
<el-tag v-if="selected.pooledByLabel || selected.pooledBy" size="small" type="info" effect="plain"> <span v-if="selected.pooledByLabel || selected.pooledBy" class="tag-chip is-put">
{{ selected.pooledByLabel || selected.pooledBy }} 放入 {{ pooledByText(selected) }} 放入
</el-tag> </span>
</div> </div>
<el-button text circle aria-label="关闭" title="关闭" @click="closeDetail"> <el-button text circle aria-label="关闭" title="关闭" @click="closeDetail">
<el-icon><Close /></el-icon> <el-icon><Close /></el-icon>
...@@ -292,9 +630,9 @@ onMounted(() => { ...@@ -292,9 +630,9 @@ onMounted(() => {
</div> </div>
<div v-if="selected.tags && selected.tags.length" class="detail-tags"> <div v-if="selected.tags && selected.tags.length" class="detail-tags">
<el-tag v-for="tag in selected.tags" :key="tag" size="small" :type="candidateTagTone(tag)" effect="light">{{ <span v-for="tag in selected.tags" :key="tag" class="tag-chip" :style="{ '--tone': tagColor(tag) }">{{
tag tag
}}</el-tag> }}</span>
</div> </div>
<div class="detail-resume"> <div class="detail-resume">
...@@ -320,217 +658,784 @@ onMounted(() => { ...@@ -320,217 +658,784 @@ onMounted(() => {
</el-drawer> </el-drawer>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.pool-page { .pool-page {
--p-ink: #16203a;
--p-muted: #667187;
--p-line: #e4e9f2;
--p-rail: #f1f5fa;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px; gap: 14px;
} }
.metric-card { /* ---------- 台账头 ---------- */
.hero-strip {
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 92px; gap: 22px;
padding: 16px 18px; padding: 18px 20px;
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--p-line);
border-radius: 14px; border-radius: 16px;
box-shadow: 0 8px 20px rgba(39, 49, 70, 0.06); box-shadow:
border-top: 3px solid var(--accent); 0 1px 2px rgba(16, 26, 48, 0.04),
0 14px 34px -22px rgba(16, 26, 48, 0.22);
.hero-copy {
min-width: 0;
flex: 1 1 auto;
.hero-eyebrow {
margin: 0 0 5px;
font-size: 10px;
font-weight: 700;
letter-spacing: 2.5px;
color: var(--blue);
}
.metric-copy { h2 {
margin: 0 0 4px;
font-size: 21px;
letter-spacing: 0.3px;
color: var(--p-ink);
}
.hero-sub {
margin: 0;
font-size: 12.5px;
color: var(--p-muted);
line-height: 1.6;
}
}
.hero-stats {
display: flex; display: flex;
flex-direction: column; margin: 0;
flex: 0 0 auto;
.stat-cell {
min-width: 86px;
padding: 2px 20px 0;
border-left: 1px solid var(--p-line);
dt {
font-size: 10.5px;
letter-spacing: 1.5px;
color: var(--p-muted);
margin-bottom: 5px;
}
span { .stat-num {
font-size: 13px; margin: 0;
color: var(--muted); font-size: 25px;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
color: var(--p-ink);
&.accent-up {
color: #1f7a5a;
} }
strong { &.is-empty {
font-size: 28px; color: var(--p-muted);
line-height: 1.2; }
}
}
} }
small { .hero-action {
font-size: 12px; flex: 0 0 auto;
color: var(--muted); padding-left: 4px;
.ghost-btn {
border-color: var(--p-line);
color: var(--p-muted);
background: #fff;
font-size: 12.5px;
&:hover {
border-color: var(--blue);
color: var(--blue);
}
} }
} }
} }
.card { /* ---------- 面板 ---------- */
.pool-panel {
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--p-line);
border-radius: 14px; border-radius: 16px;
box-shadow: 0 8px 20px rgba(39, 49, 70, 0.06); box-shadow:
0 1px 2px rgba(16, 26, 48, 0.04),
0 14px 34px -22px rgba(16, 26, 48, 0.22);
overflow: hidden;
} }
.pool-card { /* ---------- 检索导卡带(签名元素) ---------- */
padding: 18px; .guide-rail {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
background: var(--p-rail);
border-bottom: 1px solid var(--p-line);
} }
.list-head { .guide-zone {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; gap: 6px;
gap: 12px; padding-top: 2px;
margin-bottom: 14px;
.section-kicker {
font-size: 12px;
letter-spacing: 1px;
color: var(--blue);
font-weight: 600;
}
h2 { &.is-divider {
margin: 2px 0 4px; border-top: 1px dashed var(--p-line);
font-size: 18px; padding-top: 10px;
} }
p { .guide-caption {
margin: 0; flex: 0 0 auto;
color: var(--muted); width: 34px;
font-size: 13px; padding-top: 9px;
font-size: 10px;
letter-spacing: 2px;
color: var(--p-muted);
user-select: none;
} }
}
.filter-row { .guide-tabs {
flex: 1;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 6px;
margin-bottom: 14px;
.filter-search {
width: 260px;
} }
} }
.pool-grid { .guide-tab {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.pool-card-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 4px;
padding: 14px; min-width: 64px;
background: #fff; padding: 7px 11px 6px;
border: 1px solid var(--line); border: 1px solid transparent;
border-radius: 12px; border-radius: 10px;
background: transparent;
color: var(--p-muted);
cursor: pointer; cursor: pointer;
text-align: left;
transition: transition:
box-shadow 0.2s, background 0.15s ease,
transform 0.2s; border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover { &:hover {
box-shadow: 0 10px 24px rgba(39, 49, 70, 0.1); background: rgba(255, 255, 255, 0.72);
transform: translateY(-1px);
} }
}
.pool-person { &:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 1px;
}
&.is-active {
background: #fff;
border-color: var(--p-line);
box-shadow: 0 3px 10px -3px rgba(16, 26, 48, 0.14);
color: var(--p-ink);
.gt-label {
font-weight: 700;
}
}
.gt-line {
display: flex; display: flex;
gap: 10px; align-items: center;
} gap: 6px;
line-height: 1;
.pool-avatar { .gt-dot {
width: 40px; width: 6px;
height: 40px; height: 6px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0;
}
.gt-label {
font-size: 12.5px;
}
.gt-count {
margin-left: auto;
font-size: 12px;
font-variant-numeric: tabular-nums;
font-weight: 700;
}
}
.gt-gauge {
display: block;
height: 3px;
border-radius: 2px;
background: rgba(16, 26, 48, 0.08);
overflow: hidden;
.gt-gauge-fill {
display: block;
height: 100%;
border-radius: 2px;
}
&.is-none {
background: repeating-linear-gradient(90deg, rgba(16, 26, 48, 0.08) 0 3px, transparent 3px 6px);
.gt-gauge-fill {
display: none;
}
}
}
}
/* ---------- 工具行 ---------- */
.toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--p-line);
.tool-search {
width: 300px;
max-width: 40vw;
}
.tool-search-icon {
color: var(--p-muted);
}
.ctl {
display: inline-flex;
align-items: center;
gap: 6px;
.ctl-label {
font-size: 12px;
color: var(--p-muted);
}
.ctl-select {
width: 128px;
}
}
.toolbar-spacer {
flex: 1 1 auto;
}
.hit-meta {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--p-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
b {
color: var(--p-ink);
}
.clear-filters {
border: none;
background: none;
color: var(--blue);
font-size: 12px;
cursor: pointer;
padding: 2px 4px;
border-radius: 6px;
&:hover {
background: rgba(69, 105, 201, 0.08);
}
&:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 1px;
}
}
}
.density {
display: inline-flex;
padding: 2px;
background: var(--p-rail);
border: 1px solid var(--p-line);
border-radius: 9px;
.density-btn {
display: inline-flex;
align-items: center;
justify-content: center; justify-content: center;
color: #fff; width: 26px;
font-weight: 700; height: 24px;
background: linear-gradient(135deg, #5b8def, #4569c9); border: none;
border-radius: 7px;
background: transparent;
color: var(--p-muted);
cursor: pointer;
font-size: 14px;
&:hover {
color: var(--p-ink);
}
&.is-active {
background: #fff;
color: var(--blue);
box-shadow: 0 1px 4px rgba(16, 26, 48, 0.12);
}
&:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 1px;
}
}
}
} }
.pool-copy { /* ---------- 通用标记 ---------- */
min-width: 0; .tag-chip {
flex: 1; display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 6px;
font-size: 11px;
line-height: 1;
white-space: nowrap;
background: color-mix(in srgb, var(--tone, #4569c9) 10%, #fff);
color: var(--tone, #4569c9);
border: 1px solid color-mix(in srgb, var(--tone, #4569c9) 26%, transparent);
&.is-none {
--tone: #9aa3b2;
border-style: dashed;
background: transparent;
}
&.is-source {
--tone: #4d5c75;
}
&.is-put {
--tone: #5f6b80;
}
} }
.pool-name-row { .tag-more {
font-size: 11px;
color: var(--p-muted);
}
/* ---------- 台账目录列表 ---------- */
.ledger {
display: flex; display: flex;
flex-direction: column;
}
.ledger-head,
.ledger-row {
display: grid;
grid-template-columns: minmax(210px, 1.55fr) minmax(150px, 1.05fr) minmax(108px, auto) minmax(150px, 0.9fr) minmax(
132px,
auto
);
gap: 14px;
align-items: center; align-items: center;
gap: 6px; padding: 0 16px;
}
strong { .ledger-head {
font-size: 15px; min-height: 32px;
background: #f8fafc;
border-bottom: 1px solid var(--p-line);
font-size: 10px;
letter-spacing: 1.5px;
color: var(--p-muted);
.col-actions {
text-align: right;
} }
} }
.stage-chip { .ledger-row {
font-size: 11px; position: relative;
padding: 1px 6px; min-height: 62px;
border-radius: 999px; border-bottom: 1px solid #eef1f6;
background: rgba(69, 105, 201, 0.1); cursor: pointer;
color: #4569c9; transition: background 0.15s ease;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: transparent;
transition: background 0.15s ease;
}
&:hover {
background: #f7faff;
&::before {
background: var(--row-tone, #4569c9);
}
}
&:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: -2px;
}
&:last-child {
border-bottom: none;
}
}
.lr-person {
display: flex;
align-items: center;
gap: 11px;
min-width: 0;
} }
.pool-sub { .person-tile {
font-size: 13px; display: inline-flex;
color: #4569c9; align-items: center;
margin-top: 2px; justify-content: center;
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 10px;
background: color-mix(in srgb, var(--row-tone, #4569c9) 11%, #fff);
color: var(--row-tone, #4569c9);
border: 1px solid color-mix(in srgb, var(--row-tone, #4569c9) 24%, transparent);
font-size: 14px;
font-weight: 700;
} }
.pool-meta { .person-copy {
min-width: 0;
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 8px; gap: 2px;
.person-name {
font-size: 13.5px;
color: var(--p-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.person-role {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--p-muted);
margin-top: 4px; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
.pool-profile { .lr-profile {
font-size: 12px; font-size: 12px;
color: var(--ink); color: color-mix(in srgb, var(--p-ink) 82%, #4569c9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.is-empty { &.is-empty {
color: var(--muted); color: var(--p-muted);
} }
} }
.pool-tags { .lr-tags {
display: flex; display: flex;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
min-width: 0;
} }
.pool-actions { .lr-meta {
display: flex; display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
.lr-source {
align-self: flex-start;
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 7px;
border-radius: 5px;
background: #eef3fa;
color: #4d5c75;
font-size: 10.5px;
letter-spacing: 0.5px;
}
.lr-by {
font-size: 11.5px;
color: var(--p-muted);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
b {
color: #44506a;
font-weight: 600;
}
}
}
.lr-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 6px;
margin-top: auto;
.row-link {
border: none;
background: none;
color: var(--p-muted);
font-size: 12.5px;
cursor: pointer;
padding: 4px 2px;
border-radius: 6px;
&:hover {
color: var(--blue);
}
&:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 1px;
}
}
} }
/* ---------- 卡片视图 ---------- */
.pool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
padding: 16px;
}
.pool-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--p-line);
border-radius: 13px;
background: #fff;
cursor: pointer;
transition:
box-shadow 0.18s ease,
transform 0.18s ease,
border-color 0.18s ease;
&:hover {
border-color: color-mix(in srgb, var(--row-tone, #4569c9) 34%, var(--p-line));
box-shadow: 0 10px 26px -14px rgba(16, 26, 48, 0.22);
transform: translateY(-2px);
}
&:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 1px;
}
.pc-head {
display: flex;
align-items: center;
gap: 10px;
.pc-head-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
font-size: 14px;
color: var(--p-ink);
}
.pc-role {
font-size: 12px;
color: var(--p-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.pc-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.pc-profile {
margin: 0;
font-size: 12px;
color: color-mix(in srgb, var(--p-ink) 82%, #4569c9);
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
&.is-empty {
color: var(--p-muted);
}
}
.pc-foot {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
margin-top: auto;
padding-top: 10px;
border-top: 1px dashed var(--p-line);
.lr-by {
font-size: 11px;
color: var(--p-muted);
font-variant-numeric: tabular-nums;
}
.pc-actions {
margin-left: auto;
display: inline-flex;
gap: 6px;
}
}
}
/* ---------- 空态 / 加载 ---------- */
.empty-state { .empty-state {
padding: 40px 16px; padding: 64px 20px;
text-align: center; text-align: center;
strong { strong {
display: block;
font-size: 16px; font-size: 16px;
color: var(--p-ink);
margin-bottom: 7px;
} }
p { p {
color: var(--muted); margin: 0 auto 18px;
max-width: 460px;
color: var(--p-muted);
font-size: 13px; font-size: 13px;
margin: 6px 0 14px; line-height: 1.7;
}
.empty-btn {
border: 1px solid var(--p-line);
background: #fff;
border-radius: 999px;
padding: 8px 18px;
font-size: 13px;
color: var(--p-ink);
cursor: pointer;
transition:
border-color 0.15s ease,
color 0.15s ease;
&:hover {
border-color: var(--blue);
color: var(--blue);
}
&:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 1px;
}
}
}
.skeleton {
padding: 8px 16px 18px;
.skel-row {
height: 60px;
border-bottom: 1px solid #eef1f6;
background: linear-gradient(100deg, #f3f5f9 40%, #fafbfd 50%, #f3f5f9 60%);
background-size: 200% 100%;
animation: skel-shimmer 1.4s ease-in-out infinite;
}
}
@keyframes skel-shimmer {
from {
background-position: 120% 0;
}
to {
background-position: -80% 0;
}
}
.load-more {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
padding: 14px 16px 18px;
border-top: 1px solid #eef1f6;
.load-caption {
font-size: 12px;
color: var(--p-muted);
font-variant-numeric: tabular-nums;
}
.load-more-btn {
border: 1px solid var(--p-line);
background: #fff;
border-radius: 999px;
padding: 7px 18px;
font-size: 12.5px;
color: var(--p-ink);
cursor: pointer;
transition:
border-color 0.15s ease,
color 0.15s ease;
&:hover {
border-color: var(--blue);
color: var(--blue);
}
&:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 1px;
}
} }
} }
/* ---------- 详情抽屉 ---------- */
.pool-detail { .pool-detail {
padding: 18px 20px 24px; padding: 18px 20px 24px;
display: flex; display: flex;
...@@ -548,11 +1453,12 @@ onMounted(() => { ...@@ -548,11 +1453,12 @@ onMounted(() => {
h3 { h3 {
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
color: var(--p-ink);
} }
p { p {
margin: 2px 0 0; margin: 3px 0 0;
color: var(--muted); color: var(--p-muted);
} }
.detail-head-side { .detail-head-side {
...@@ -576,11 +1482,11 @@ onMounted(() => { ...@@ -576,11 +1482,11 @@ onMounted(() => {
display: flex; display: flex;
gap: 8px; gap: 8px;
font-size: 13px; font-size: 13px;
border-bottom: 1px dashed var(--line); border-bottom: 1px dashed var(--p-line);
padding: 4px 0; padding: 5px 0;
span { span {
color: var(--muted); color: var(--p-muted);
min-width: 64px; min-width: 64px;
} }
} }
...@@ -589,13 +1495,13 @@ onMounted(() => { ...@@ -589,13 +1495,13 @@ onMounted(() => {
.detail-tags { .detail-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 5px;
} }
.detail-resume { .detail-resume {
background: #f6f8fc; background: #f7f9fc;
border: 1px solid var(--line); border: 1px solid var(--p-line);
border-radius: 10px; border-radius: 11px;
padding: 12px; padding: 12px;
.detail-resume-head { .detail-resume-head {
...@@ -610,7 +1516,8 @@ onMounted(() => { ...@@ -610,7 +1516,8 @@ onMounted(() => {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.65;
color: color-mix(in srgb, var(--p-ink) 90%, #4569c9);
max-height: 40vh; max-height: 40vh;
overflow: auto; overflow: auto;
} }
...@@ -623,12 +1530,99 @@ onMounted(() => { ...@@ -623,12 +1530,99 @@ onMounted(() => {
gap: 12px; gap: 12px;
margin-top: auto; margin-top: auto;
padding-top: 12px; padding-top: 12px;
border-top: 1px solid var(--line); border-top: 1px solid var(--p-line);
.foot-hint { .foot-hint {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--p-muted);
margin-right: auto; margin-right: auto;
} }
} }
/* ---------- 响应式 ---------- */
@media (max-width: 1320px) {
.hero-strip {
flex-wrap: wrap;
}
.hero-action {
margin-left: auto;
}
.hero-stats {
order: 3;
width: 100%;
padding-top: 12px;
border-top: 1px solid var(--p-line);
.stat-cell {
flex: 1;
border-left: none;
padding: 0 20px 0 0;
&:not(:first-child) {
padding-left: 20px;
border-left: 1px solid var(--p-line);
}
}
}
}
@media (max-width: 1180px) {
.ledger-head,
.ledger-row {
grid-template-columns: minmax(200px, 1.5fr) minmax(96px, auto) minmax(150px, 0.9fr) minmax(132px, auto);
}
.ledger-row .lr-profile,
.ledger-head span:nth-child(2) {
display: none;
}
}
@media (max-width: 940px) {
.ledger-head,
.ledger-row {
grid-template-columns: minmax(180px, 1.4fr) minmax(132px, auto) minmax(132px, auto);
}
.ledger-row .lr-meta,
.ledger-head span:nth-child(4) {
display: none;
}
}
@media (max-width: 720px) {
.ledger-head,
.ledger-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.ledger-row .lr-tags,
.ledger-head span:nth-child(3) {
display: none;
}
.toolbar .tool-search {
width: 100%;
max-width: none;
}
.hit-meta {
order: 5;
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.ledger-row,
.pool-card,
.guide-tab {
transition: none;
}
.skel-row {
animation: none;
background: #f3f5f9;
}
}
</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