Commit 9f16374c authored by 李文光's avatar 李文光

feat: 需求22 补全——简历库/人才池批量移入认领与打标签, 批量入库按 file_hash 库级去重

parent 22f1e111
...@@ -18,8 +18,9 @@ ...@@ -18,8 +18,9 @@
## 人才池 / 总库 ## 人才池 / 总库
- **人才池(Talent Pool)= 总库(Company Pool)**:公司级共享候选人池,不属于任何个人工作区,所有招聘账号可见——即需求 1 的「总库」。 - **人才池(Talent Pool)= 总库(Company Pool)**:公司级共享候选人池,不属于任何个人工作区,所有招聘账号可见——即需求 1 的「总库」。
- **移入人才池(Move to Pool)**:把候选人从个人工作区迁入公司人才池,原流程与待办随之结束。 - **移入人才池(Move to Pool)**:把候选人从个人工作区迁入公司人才池,原流程与待办随之结束;支持单条或**批量移入**(简历库多选)。
- **认领 / 复活(Claim / Resurrect)**:把人才池中的候选人迁入本人工作区,重新开始招聘流程。 - **认领 / 复活(Claim / Resurrect)**:把人才池中的候选人迁入本人工作区,重新开始招聘流程;支持单条或**批量认领**(人才池多选)。
- **简历库级去重(Resume Dedup)**:批量入库/上传时按文件哈希(file hash)+ 姓名比对——本人简历库已存在 → 跳过并计数;同一份简历在公司人才池 → 不重复建档,引导去人才池认领;不同账号工作区之间不互斥。
- **入池备注 / 入池时间 / 放入人**:记录候选人进入人才池的原因、时间与操作账号,便于复用与溯源。 - **入池备注 / 入池时间 / 放入人**:记录候选人进入人才池的原因、时间与操作账号,便于复用与溯源。
## 岗位与 JD ## 岗位与 JD
......
...@@ -10,6 +10,8 @@ ...@@ -10,6 +10,8 @@
> >
> **2026-09-04 增量(需求 9)**:粘贴 JD 自动结构化 + 招聘需求模板已实现——新增 backend/app/services/jd_import.py(需求模板生成 + LLM 优先/本地小节切分解析),接口 /api/jd/parse-pasted 与 /api/jd/requirement-template,JobEdit「JD 与匹配标准」页新增「粘贴 JD 自动结构化 / 生成需求模板」入口;模板含对内约束(性别/年龄/试用期/竞业/验证),解析后回填岗位对内字段,绝不上对外 JD。详见需求 9 条目。 > **2026-09-04 增量(需求 9)**:粘贴 JD 自动结构化 + 招聘需求模板已实现——新增 backend/app/services/jd_import.py(需求模板生成 + LLM 优先/本地小节切分解析),接口 /api/jd/parse-pasted 与 /api/jd/requirement-template,JobEdit「JD 与匹配标准」页新增「粘贴 JD 自动结构化 / 生成需求模板」入口;模板含对内约束(性别/年龄/试用期/竞业/验证),解析后回填岗位对内字段,绝不上对外 JD。详见需求 9 条目。
> >
> **2026-09-04 增量(需求 22 补全)**:批量移入人才池 / 批量打标签 / 批量认领(复活)已实现(简历库与人才池页多选 + 批量工具条),批量入库/上传增加 **file_hash 库级去重**(命中本人简历库→跳过计数;命中公司人才池→不重复建档、引导认领;跨账号不互斥);候选人 resumeMeta 落 hash 供后续去重。详见需求 22 条目。
>
> 本次复核相对上一版(810e280)新增提交:`ddc5340`(候选人详情抽屉+档案编辑)、`07310dd`(简历库重构+保存原简历+防重复)、`6248f27`/`fc0aa5c`(简历解析真正 LLM 优先并移除脱敏)、`ec9499f`(弹窗蒙层/ESC 不关闭)。 > 本次复核相对上一版(810e280)新增提交:`ddc5340`(候选人详情抽屉+档案编辑)、`07310dd`(简历库重构+保存原简历+防重复)、`6248f27`/`fc0aa5c`(简历解析真正 LLM 优先并移除脱敏)、`ec9499f`(弹窗蒙层/ESC 不关闭)。
> >
> 依据代码现状核实: > 依据代码现状核实:
...@@ -32,7 +34,7 @@ HR 原话要点: ...@@ -32,7 +34,7 @@ HR 原话要点:
**结论**:延续现有系统架构(整包状态 + 前后端分离),按模块迭代。 **结论**:延续现有系统架构(整包状态 + 前后端分离),按模块迭代。
**核实情况(2026-09,最新)**:「下推联动」痛点已基本解决——候选人和岗位的每个状态动作都会同时写事件日志、增删待办(见需求 20);企微智能体产出的 JD 可经 `/api/jd/sync-external` 自动落为岗位并置为「待确认」状态(见需求 2)。**候选人详情已于近期改为「右侧抽屉 + 可收缩 PDF 档案预览」**(提交 `ddc5340`/`07310dd`,见需求 10),HR「弹窗/页面级收敛、简历可收缩预览」的交互诉求已落地。**候选人标记/人才池体系已大部落地(需求 1 总库=人才池 + 需求 12 标签筛选 + 需求 22 人才池独立视图,2026-09-02 工作区补充)**,需求 14(自述优势约束)已实现(2026-09-03,见条目);需求 17(风险点固定维度)亦已实现(2026-09-03,见条目);余下缺口为批量移入/复活(需求 22);岗位维简历量统计已于 2026-09-03 落地;另简历解析自 `fc0aa5c` 起改为**原文直传大模型提取字段(不再本地脱敏)**,采集插件仍不抓「沟通职位/期望薪资」。 **核实情况(2026-09,最新)**:「下推联动」痛点已基本解决——候选人和岗位的每个状态动作都会同时写事件日志、增删待办(见需求 20);企微智能体产出的 JD 可经 `/api/jd/sync-external` 自动落为岗位并置为「待确认」状态(见需求 2)。**候选人详情已于近期改为「右侧抽屉 + 可收缩 PDF 档案预览」**(提交 `ddc5340`/`07310dd`,见需求 10),HR「弹窗/页面级收敛、简历可收缩预览」的交互诉求已落地。**候选人标记/人才池体系已大部落地(需求 1 总库=人才池 + 需求 12 标签筛选 + 需求 22 人才池独立视图,2026-09-02 工作区补充)**,需求 14(自述优势约束)已实现(2026-09-03,见条目);需求 17(风险点固定维度)亦已实现(2026-09-03,见条目);需求 22 批量移入/复活与 file_hash 库级去重已实现(2026-09-04,见条目);岗位维简历量统计已于 2026-09-03 落地;另简历解析自 `fc0aa5c` 起改为**原文直传大模型提取字段(不再本地脱敏)**,采集插件仍不抓「沟通职位/期望薪资」。
--- ---
...@@ -197,8 +199,8 @@ HR 原话要点: ...@@ -197,8 +199,8 @@ HR 原话要点:
**需求 22:简历库 = 简历存储 + 标签 + 统计** **需求 22:简历库 = 简历存储 + 标签 + 统计**
- 原话:「简历库涵盖所有简历,几十上百份可打标签;要看总量、通过面试人数、各岗位简历量(如 java 岗多少份、人力岗多少份);面完没过的放人才池」(P19) - 原话:「简历库涵盖所有简历,几十上百份可打标签;要看总量、通过面试人数、各岗位简历量(如 java 岗多少份、人力岗多少份);面完没过的放人才池」(P19)
- 现状:🔄 近期重构(提交 `07310dd`):简历库为**卡片网格列表**(头像/姓名/阶段/来源/快筛与 AI 分/求职岗位下拉/「查看档案·快速匹配·更多」操作),顶部 4 张卡(简历总数 / 高匹配=快筛≥85 / 待 AI 评估 / 待补全),筛选为 关键词/阶段/来源/匹配档位——**仍无标签筛选**;漏斗/来源/趋势/ROI 看板在**独立 `/dashboard` 路由**(可按岗位范围过滤),当时的「各岗位简历量」统计缺口已于 2026-09-03 补齐(见下)。**新增「保存原简历 + 档案预览/防重复」**:后端 `/api/upload-resume` 把原文件落库(`ResumeFile` 表含 `file_hash` 唯一字段,原始文本存 `ResumeText`,结构化档案存 `ResumeProfile`),候选人带 `resumeFileDataUrl`(`/api/resumes/{name}`)与 `resumeMeta`,详情抽屉左栏可预览/下载;**批量入库增加前端防重复**(按 简历文件名+清洗后姓名 跳过并计数,后端 `IngestItem` 仍以唯一 `idempotency_key` 兜底)。**人才池(总库)独立视图已实现(2026-09-02 补充)**:左侧「人才池」页 = 公司共享总库(跨账号),卡片列表 + 关键词/标签/来源筛选 + 统计卡(总库人数/本周新入池/待跟进)+ 详情抽屉(档案/简历正文/原简历链接)+「认领到我的工作区」;简历库卡片与详情抽屉均可一键「移入人才池」。**岗位维简历量统计已落地(2026-09-03)**:简历库页顶部新增「各岗位简历量」chips,按岗位显示 简历量 / 已入职 / 高匹配(快筛≥85),点击聚焦该岗位候选人(复用 `?job=` 聚焦),未绑定岗位单列提示。**残余**:批量移入/复活仍未做 - 现状:🔄 近期重构(提交 `07310dd`):简历库为**卡片网格列表**(头像/姓名/阶段/来源/快筛与 AI 分/求职岗位下拉/「查看档案·快速匹配·更多」操作),顶部 4 张卡(简历总数 / 高匹配=快筛≥85 / 待 AI 评估 / 待补全),筛选为 关键词/阶段/来源/匹配档位——**仍无标签筛选**;漏斗/来源/趋势/ROI 看板在**独立 `/dashboard` 路由**(可按岗位范围过滤),当时的「各岗位简历量」统计缺口已于 2026-09-03 补齐(见下)。**新增「保存原简历 + 档案预览/防重复」**:后端 `/api/upload-resume` 把原文件落库(`ResumeFile` 表含 `file_hash` 唯一字段,原始文本存 `ResumeText`,结构化档案存 `ResumeProfile`),候选人带 `resumeFileDataUrl`(`/api/resumes/{name}`)与 `resumeMeta`,详情抽屉左栏可预览/下载;**批量入库增加前端防重复**(按 简历文件名+清洗后姓名 跳过并计数,后端 `IngestItem` 仍以唯一 `idempotency_key` 兜底)。**人才池(总库)独立视图已实现(2026-09-02 补充)**:左侧「人才池」页 = 公司共享总库(跨账号),卡片列表 + 关键词/标签/来源筛选 + 统计卡(总库人数/本周新入池/待跟进)+ 详情抽屉(档案/简历正文/原简历链接)+「认领到我的工作区」;简历库卡片与详情抽屉均可一键「移入人才池」。**岗位维简历量统计已落地(2026-09-03)**:简历库页顶部新增「各岗位简历量」chips,按岗位显示 简历量 / 已入职 / 高匹配(快筛≥85),点击聚焦该岗位候选人(复用 `?job=` 聚焦),未绑定岗位单列提示。**批量操作已实现(2026-09-04)**:简历库卡片多选(勾选框)→ 批量工具条「批量移入人才池(可统一备注)/批量打标签」;人才池页列表/卡片多选 →「批量认领到我的工作区」(=批量复活),均复用单条逻辑循环并汇总成功/失败。**file_hash 库级去重已实现(2026-09-04)**:批量入库先上传取 hash,再与「本人简历库 + 公司人才池」比对——命中本人库跳过计数「工作区已存在」,命中人才池不重复建档并提示「可去人才池认领」,跨账号不互斥;候选人 `resumeMeta` 新增 `hash` 落档供后续去重
- 建议(🔄):标签筛选、人才池独立视图、岗位维简历量统计均已落地;待做:批量放入/复活、后端按 `file_hash` 库级去重 - 建议(✅ 已落地):标签筛选、人才池独立视图、岗位维简历量统计、批量移入/复活、file_hash 库级去重均已实现。可选增强:人才池批量「重新绑定岗位/批量打标」、跨账号重复提示(当前按隔离不互斥)、把去重下沉到后端行级建档(当前在入库/上传环节按 hash 比对)
--- ---
...@@ -234,7 +236,7 @@ HR 原话要点: ...@@ -234,7 +236,7 @@ HR 原话要点:
| P1 | 需求 12 候选人标记 | 🔄 基本落地 | tags 枚举 + 详情编辑 + 简历库/人才池标签筛选 + 待跟进快捷筛选;独立周报视图待做 | | P1 | 需求 12 候选人标记 | 🔄 基本落地 | tags 枚举 + 详情编辑 + 简历库/人才池标签筛选 + 待跟进快捷筛选;独立周报视图待做 |
| P2 | 需求 4 岗位自动关闭 | ✅ 已实现(2026-09-03) | hired>=headcount 自动置「已招满」并记事件日志 | | P2 | 需求 4 岗位自动关闭 | ✅ 已实现(2026-09-03) | hired>=headcount 自动置「已招满」并记事件日志 |
| P2 | 需求 9 粘贴 JD 自动结构化 + 需求模板 | ✅ 已实现(2026-09-04) | 粘贴解析(LLM/本地)+ 需求模板(含对内约束)闭环 | | P2 | 需求 9 粘贴 JD 自动结构化 + 需求模板 | ✅ 已实现(2026-09-04) | 粘贴解析(LLM/本地)+ 需求模板(含对内约束)闭环 |
| P2 | 需求 22 简历库标签/统计 | 🔄 大部分落地 | 标签筛选/人才池视图/岗位维简历量已落地;批量移入待做 | | P2 | 需求 22 简历库标签/统计/批量 | ✅ 已实现(2026-09-04) | 批量移入/打标签/批量认领 + file_hash 库级去重 |
| P3 | 需求 23 插件字段修正 | 🔄 部分 | 补抓沟通职位/期望薪资 | | P3 | 需求 23 插件字段修正 | 🔄 部分 | 补抓沟通职位/期望薪资 |
| P3 | 需求 21 入职材料三件套 | 🔄 部分 | 三张模板化 | | P3 | 需求 21 入职材料三件套 | 🔄 部分 | 三张模板化 |
| P3 | 需求 3 岗位类型枚举 / 需求 25 期望薪资展示 | 🆕 | 小改动,按需做 | | P3 | 需求 3 岗位类型枚举 / 需求 25 期望薪资展示 | 🆕 | 小改动,按需做 |
...@@ -254,4 +256,4 @@ HR 原话要点: ...@@ -254,4 +256,4 @@ HR 原话要点:
--- ---
*整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。需求 14/17(自述优势约束、风险点固定维度)、需求 4 自动招满、岗位维简历量统计已于 2026-09-03 落地;需求 9(粘贴 JD 自动结构化 + 招聘需求模板,含对内约束)已于 2026-09-04 落地;建议优先补齐:批量移入/复活(需求 22),并按需做需求 3/25 等小改动。* *整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。需求 14/17(自述优势约束、风险点固定维度)、需求 4 自动招满、岗位维简历量统计已于 2026-09-03 落地;需求 9(粘贴 JD 自动结构化 + 招聘需求模板)与需求 22 补全(批量移入/复活、file_hash 库级去重)已于 2026-09-04 落地;后续按需做需求 3/25 等小改动(岗位类型枚举、期望薪资展示)。*
...@@ -142,18 +142,32 @@ const importBatch = async () => { ...@@ -142,18 +142,32 @@ const importBatch = async () => {
return return
} }
} }
// 需求 22:按 file_hash 库级去重——对比本人简历库与公司人才池已有简历的 hash
await store.loadPool()
const workspaceHashes = new Set(store.candidates.map((candidate) => candidate.resumeMeta?.hash).filter(Boolean))
const poolHashes = new Set(store.poolCandidates.map((candidate) => candidate.resumeMeta?.hash).filter(Boolean))
const created = [] const created = []
let duplicateCount = 0 let duplicateCount = 0
let poolDuplicateCount = 0
importable.forEach((entry) => { importable.forEach((entry) => {
const upload = entry.upload || {} const upload = entry.upload || {}
const parsed = entry.parsed || upload.parsed || {} const parsed = entry.parsed || upload.parsed || {}
const job = store.jobs.find((item) => item.id === entry.jobId) const job = store.jobs.find((item) => item.id === entry.jobId)
const name = cleanCandidateName(parsed.name || '', entry.file?.name) || '未命名候选人' const name = cleanCandidateName(parsed.name || '', entry.file?.name) || '未命名候选人'
const duplicate = store.candidates.some( const hash = String(upload.file?.fileHash || '')
const poolDuplicate = Boolean(hash && poolHashes.has(hash))
const nameDuplicate = store.candidates.some(
(candidate) => (candidate) =>
candidate.resumeName === entry.file?.name && cleanCandidateName(candidate.name, candidate.resumeName) === name candidate.resumeName === entry.file?.name && cleanCandidateName(candidate.name, candidate.resumeName) === name
) )
if (duplicate) { const workspaceDuplicate = Boolean(hash && workspaceHashes.has(hash))
if (poolDuplicate) {
// 同一份简历已在公司人才池:不重复建档,引导去人才池认领
poolDuplicateCount += 1
return
}
if (workspaceDuplicate || nameDuplicate) {
duplicateCount += 1 duplicateCount += 1
return return
} }
...@@ -163,6 +177,7 @@ const importBatch = async () => { ...@@ -163,6 +177,7 @@ const importBatch = async () => {
name: upload.file.originalName || entry.file?.name, name: upload.file.originalName || entry.file?.name,
type: upload.file.fileType || entry.file?.type || '', type: upload.file.fileType || entry.file?.type || '',
size: upload.file.sizeBytes || entry.file?.size || 0, size: upload.file.sizeBytes || entry.file?.size || 0,
hash: upload.file.fileHash || '',
} }
: entry.file : entry.file
? { name: entry.file.name, type: entry.file.type, size: entry.file.size } ? { name: entry.file.name, type: entry.file.type, size: entry.file.size }
...@@ -219,7 +234,10 @@ const importBatch = async () => { ...@@ -219,7 +234,10 @@ const importBatch = async () => {
if (created[0]) store.selectedCandidateId = created[0].id if (created[0]) store.selectedCandidateId = created[0].id
store.resumeViewMode = 'list' store.resumeViewMode = 'list'
store.persist('批量简历已入库') store.persist('批量简历已入库')
showToast(`已批量入库 ${created.length} ${duplicateCount ? `,跳过重复 ${duplicateCount} 份` : ''}`) let message = `已批量入库 ${created.length} `
if (duplicateCount) message += `,工作区已存在跳过 ${duplicateCount} `
if (poolDuplicateCount) message += `,人才池已有 ${poolDuplicateCount} 份(可去人才池认领)`
showToast(message, 'success')
} finally { } finally {
importing.value = false importing.value = false
} }
......
...@@ -19,6 +19,69 @@ const router = useRouter() ...@@ -19,6 +19,69 @@ const router = useRouter()
const batchMatchRunning = ref(false) const batchMatchRunning = ref(false)
const batchMatchResult = ref(null) const batchMatchResult = ref(null)
// 需求 22 残余:批量移入人才池 / 批量打标签(多选卡片)
const selectedIds = ref([])
const selectedCandidates = computed(() =>
store.candidates.filter((candidate) => selectedIds.value.includes(candidate.id))
)
const isSelected = (candidate) => selectedIds.value.includes(candidate.id)
const toggleSelect = (candidate) => {
const index = selectedIds.value.indexOf(candidate.id)
if (index >= 0) selectedIds.value.splice(index, 1)
else selectedIds.value.push(candidate.id)
}
const clearSelection = () => {
selectedIds.value = []
}
const batchMoveToPool = async () => {
if (!selectedCandidates.value.length) return
let note = ''
try {
const { value } = await ElMessageBox.prompt('可统一备注移入原因(留空也可)', '批量移入人才池', {
confirmButtonText: '移入人才池',
cancelButtonText: '取消',
inputPlaceholder: '如:二面未通过 / 暂缓 / 储备',
inputValue: '',
})
note = (value || '').trim()
} catch {
return
}
const list = [...selectedCandidates.value]
let moved = 0
let failed = 0
for (const candidate of list) {
try {
await store.moveToPoolCandidate(candidate, note)
moved += 1
} catch {
failed += 1
}
}
clearSelection()
showToast(failed ? `已移入 ${moved} 人,失败 ${failed} 人` : `已批量移入人才池 ${moved} 人`, 'success')
}
const batchTagOpen = ref(false)
const batchTagValue = ref([])
const openBatchTag = () => {
const first = selectedCandidates.value[0]
batchTagValue.value = first?.tags ? [...first.tags] : []
batchTagOpen.value = true
}
const applyBatchTag = () => {
const tags = [...batchTagValue.value]
const count = selectedCandidates.value.length
selectedCandidates.value.forEach((candidate) => {
candidate.tags = tags
})
batchTagOpen.value = false
clearSelection()
store.persist('已批量更新候选人标记')
showToast(tags.length ? `已为 ${count} 人更新标记:${tags.join('、')}` : `已清除 ${count} 人的标记`, 'success')
}
const stageOptions = ['全部阶段', '未筛选', '初筛通过', '初筛未通过', '面试中', 'Offer 中', '待入职', '已入职'] const stageOptions = ['全部阶段', '未筛选', '初筛通过', '初筛未通过', '面试中', 'Offer 中', '待入职', '已入职']
const tagOptions = ['全部标签', '待跟进', ...CANDIDATE_TAG_OPTIONS] const tagOptions = ['全部标签', '待跟进', ...CANDIDATE_TAG_OPTIONS]
const sourceOptions = computed(() => [ const sourceOptions = computed(() => [
...@@ -311,6 +374,15 @@ const openBatch = () => { ...@@ -311,6 +374,15 @@ const openBatch = () => {
</div> </div>
</header> </header>
<div v-if="selectedIds.length" class="selection-bar">
<span class="selection-count"
>已选 <b>{{ selectedIds.length }}</b> 位候选人</span
>
<el-button size="small" type="primary" plain @click="batchMoveToPool">批量移入人才池</el-button>
<el-button size="small" plain @click="openBatchTag">批量打标签</el-button>
<el-button size="small" text @click="clearSelection">取消选择</el-button>
</div>
<div class="filter-row"> <div class="filter-row">
<el-input <el-input
v-model="store.resumeFilters.q" v-model="store.resumeFilters.q"
...@@ -380,10 +452,18 @@ const openBatch = () => { ...@@ -380,10 +452,18 @@ const openBatch = () => {
v-for="candidate in filtered" v-for="candidate in filtered"
:key="candidate.id" :key="candidate.id"
class="candidate-card" class="candidate-card"
:class="{ 'is-selected': isSelected(candidate) }"
tabindex="0" tabindex="0"
@click="openDetail(candidate)" @click="openDetail(candidate)"
@keydown.enter="openDetail(candidate)" @keydown.enter="openDetail(candidate)"
> >
<div class="candidate-check-cell" @click.stop>
<el-checkbox
:model-value="isSelected(candidate)"
aria-label="选择候选人"
@change="toggleSelect(candidate)"
/>
</div>
<div class="candidate-person"> <div class="candidate-person">
<div class="candidate-avatar" :style="{ '--avatar-accent': scoreColor(quickScore(candidate)) }"> <div class="candidate-avatar" :style="{ '--avatar-accent': scoreColor(quickScore(candidate)) }">
{{ candidateInitial(candidate) }} {{ candidateInitial(candidate) }}
...@@ -483,6 +563,17 @@ const openBatch = () => { ...@@ -483,6 +563,17 @@ const openBatch = () => {
<el-button v-if="hasFilters" round @click="clearFilters">清除筛选</el-button> <el-button v-if="hasFilters" round @click="clearFilters">清除筛选</el-button>
</div> </div>
</section> </section>
<el-dialog v-model="batchTagOpen" title="批量打标签" width="440px">
<p class="batch-tag-tip">将覆盖所选候选人的现有标记;全部取消勾选 = 清除标记。</p>
<el-checkbox-group v-model="batchTagValue" class="batch-tag-group">
<el-checkbox v-for="tag in CANDIDATE_TAG_OPTIONS" :key="tag" :label="tag">{{ tag }}</el-checkbox>
</el-checkbox-group>
<template #footer>
<el-button @click="batchTagOpen = false">取消</el-button>
<el-button type="primary" @click="applyBatchTag">应用到已选 {{ selectedIds.length }} </el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
...@@ -716,10 +807,11 @@ const openBatch = () => { ...@@ -716,10 +807,11 @@ const openBatch = () => {
} }
.candidate-card { .candidate-card {
position: relative;
display: grid; display: grid;
grid-template-columns: minmax(240px, 1.5fr) minmax(150px, 0.7fr) minmax(220px, 0.95fr) auto; grid-template-columns: 24px minmax(240px, 1.5fr) minmax(150px, 0.7fr) minmax(220px, 0.95fr) auto;
align-items: center; align-items: center;
gap: 16px; gap: 12px;
padding: 13px 14px; padding: 13px 14px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 12px;
...@@ -1103,4 +1195,46 @@ const openBatch = () => { ...@@ -1103,4 +1195,46 @@ const openBatch = () => {
transition: none; transition: none;
} }
} }
.candidate-card.is-selected {
border-color: var(--blue);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--blue) 30%, transparent);
}
.candidate-check-cell {
display: flex;
align-items: center;
}
.selection-bar {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
padding: 8px 12px;
background: color-mix(in srgb, var(--blue) 10%, #fff);
border: 1px solid color-mix(in srgb, var(--blue) 35%, var(--line));
border-radius: 10px;
.selection-count {
margin-right: auto;
font-size: 13px;
b {
color: var(--blue);
}
}
}
.batch-tag-tip {
margin-bottom: 10px;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.batch-tag-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
</style> </style>
...@@ -22,6 +22,45 @@ const selectedId = ref('') ...@@ -22,6 +22,45 @@ const selectedId = ref('')
const LOAD_STEP = 60 const LOAD_STEP = 60
const visibleCount = ref(LOAD_STEP) const visibleCount = ref(LOAD_STEP)
// 需求 22:批量认领(复活)——多选人才池候选人认领到我的工作区
const selectedPoolIds = ref([])
const selectedPool = computed(() => poolView.value.filter((candidate) => selectedPoolIds.value.includes(candidate.id)))
const poolIsSelected = (candidate) => selectedPoolIds.value.includes(candidate.id)
const togglePoolSelect = (candidate) => {
const index = selectedPoolIds.value.indexOf(candidate.id)
if (index >= 0) selectedPoolIds.value.splice(index, 1)
else selectedPoolIds.value.push(candidate.id)
}
const clearPoolSelection = () => {
selectedPoolIds.value = []
}
const batchClaim = async () => {
const count = selectedPool.value.length
if (!count) return
try {
await ElMessageBox.confirm(
`确认把选中的 ${count} 位候选人认领到你的工作区?认领后重置为「未筛选」,可重新绑定岗位继续推进。`,
'批量认领到我的工作区',
{ confirmButtonText: '认领到我的工作区', cancelButtonText: '取消', type: 'info' }
)
} catch {
return
}
const list = [...selectedPool.value]
let ok = 0
let failed = 0
for (const candidate of list) {
try {
await store.claimPoolCandidate(candidate)
ok += 1
} catch {
failed += 1
}
}
clearPoolSelection()
showToast(failed ? `已认领 ${ok} 人,失败 ${failed} 人` : `已批量认领 ${ok} 人到我的工作区`, 'success')
}
function readDensity() { function readDensity() {
try { try {
return localStorage.getItem('pool-density-v1') === 'grid' ? 'grid' : 'list' return localStorage.getItem('pool-density-v1') === 'grid' ? 'grid' : 'list'
...@@ -444,6 +483,15 @@ onMounted(() => { ...@@ -444,6 +483,15 @@ onMounted(() => {
</button> </button>
</div> </div>
</div> </div>
<div v-if="selectedPoolIds.length" class="pool-selection-bar">
<span class="pool-selection-count"
>已选 <b>{{ selectedPoolIds.length }}</b> 人</span
>
<el-button size="small" round type="primary" plain @click="batchClaim">批量认领到我的工作区</el-button>
<el-button size="small" round text @click="clearPoolSelection">取消选择</el-button>
</div>
<!-- 结果区 --> <!-- 结果区 -->
<div class="results"> <div class="results">
<!-- 骨架屏 --> <!-- 骨架屏 -->
...@@ -464,12 +512,20 @@ onMounted(() => { ...@@ -464,12 +512,20 @@ onMounted(() => {
v-for="candidate in visible" v-for="candidate in visible"
:key="candidate.id" :key="candidate.id"
class="ledger-row" class="ledger-row"
:class="{ 'is-selected': poolIsSelected(candidate) }"
:style="{ '--row-tone': firstTone(candidate) }" :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="lr-person"> <div class="lr-person">
<el-checkbox
class="pool-check"
:model-value="poolIsSelected(candidate)"
aria-label="选择认领"
@click.stop
@change="togglePoolSelect(candidate)"
/>
<span class="person-tile">{{ avatarText(candidate) }}</span> <span class="person-tile">{{ avatarText(candidate) }}</span>
<span class="person-copy"> <span class="person-copy">
<strong class="person-name">{{ candidate.name || '未命名候选人' }}</strong> <strong class="person-name">{{ candidate.name || '未命名候选人' }}</strong>
...@@ -515,12 +571,20 @@ onMounted(() => { ...@@ -515,12 +571,20 @@ onMounted(() => {
v-for="candidate in visible" v-for="candidate in visible"
:key="candidate.id" :key="candidate.id"
class="pool-card" class="pool-card"
:class="{ 'is-selected': poolIsSelected(candidate) }"
:style="{ '--row-tone': firstTone(candidate) }" :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="pc-head"> <div class="pc-head">
<el-checkbox
class="pool-check"
:model-value="poolIsSelected(candidate)"
aria-label="选择认领"
@click.stop
@change="togglePoolSelect(candidate)"
/>
<span class="person-tile">{{ avatarText(candidate) }}</span> <span class="person-tile">{{ avatarText(candidate) }}</span>
<span class="pc-head-copy"> <span class="pc-head-copy">
<strong>{{ candidate.name || '未命名候选人' }}</strong> <strong>{{ candidate.name || '未命名候选人' }}</strong>
...@@ -1625,4 +1689,35 @@ onMounted(() => { ...@@ -1625,4 +1689,35 @@ onMounted(() => {
background: #f3f5f9; background: #f3f5f9;
} }
} }
.pool-check {
flex-shrink: 0;
margin-right: 4px;
}
.ledger-row.is-selected,
.pool-card.is-selected {
outline: 2px solid var(--blue);
outline-offset: -2px;
}
.pool-selection-bar {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0 4px;
padding: 8px 12px;
background: color-mix(in srgb, var(--blue) 10%, #fff);
border: 1px solid color-mix(in srgb, var(--blue) 35%, var(--line));
border-radius: 10px;
.pool-selection-count {
margin-right: auto;
font-size: 13px;
b {
color: var(--blue);
}
}
}
</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