Commit 6b8b2e2f authored by 李文光's avatar 李文光

feat: 公司人才池(总库)与候选人标签体系,打通需求 1/12/20/22

- 人才池 = 总库:新增保留工作区 _pool + 行级 /api/pool(move/claim/read),
  跨招聘账号共享;个人工作区整包状态模型不变,池区禁止整包覆盖。
- 前端新增「人才池」页(统计/筛选/详情/认领),简历库卡片与详情抽屉
  支持一键移入;联动 pool_moved / candidate_claimed 事件并清理关联待办。
- 候选人标签升级 tags 数组(枚举+自定义,HR 手选,AI 不打标),简历库/
  人才池支持标签与「待跟进」筛选,旧单 tag 自动映射进 tags。
- 测试:新增 test_pool.py 7 例,全量 53 passed;conftest 在收集阶段即禁用
  .env,修复测试顺序导致的 qwenpaw 配置泄漏误挂。
- 文档:新增 CONTEXT.md 领域词表与 docs/adr/0001,更新需求文档标注。
- 附带提交 llm.py 解析 prompt 示例去具体化等本地改动。
parent ec9499f8
# 招聘系统领域词表(CONTEXT)
> 本文件只记录**已确认的领域术语**及其定义,不含实现细节;与代码不一致时以最新 ADR / 代码为准并回改此处。
## 账号与工作区
- **招聘账号(User)**:可登录系统的招聘人员,分管理员与招聘专员。
- **工作区(Workspace)**:单个招聘账号名下、与其他账号隔离的业务数据集合(岗位 / 候选人 / Offer / 待办 / 事件)。
- **开放模式(Open Mode)**:未启用登录时,所有数据归属本地工作区 `local`,行为与单机版一致。
## 招聘对象
- **候选人(Candidate)**:一份进入招聘流程的简历对应的人。
- **简历库(Resume Library)**:招聘专员个人工作区中的候选人集合,承载从「新入库」到「已入职」的完整流程。
- **阶段(Stage)**:候选人当前流程节点(未筛选 / 初筛通过 / 面试中 / Offer 中 / 待入职 / 已入职 等)。
- **标记(Tag)**:HR 人工给候选人贴的结构化标签,AI 不参与打标;用于重点跟进、待二面、储备、已淘汰等运营与筛选。
## 人才池 / 总库
- **人才池(Talent Pool)= 总库(Company Pool)**:公司级共享候选人池,不属于任何个人工作区,所有招聘账号可见——即需求 1 的「总库」。
- **移入人才池(Move to Pool)**:把候选人从个人工作区迁入公司人才池,原流程与待办随之结束。
- **认领 / 复活(Claim / Resurrect)**:把人才池中的候选人迁入本人工作区,重新开始招聘流程。
- **入池备注 / 入池时间 / 放入人**:记录候选人进入人才池的原因、时间与操作账号,便于复用与溯源。
## 流程与联动
- **事件日志(Event Log)**:每个关键动作留下的操作记录,用于时间线与追溯。
- **待办(Task)**:需要 HR 跟进的事项,随候选人 / 岗位 / Offer 状态自动增删。
- **下推联动**:候选人 / 岗位 / Offer 的状态变化与事件日志、待办自动保持一致。
......@@ -10,7 +10,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from backend.app.config import STATIC_ROOT, get_settings
from backend.app.db import SessionLocal, create_all
from backend.app.models import User, utc_now_iso
from backend.app.routers import ai, auth, health, ingest, resume, state
from backend.app.routers import ai, auth, health, ingest, pool, resume, state
from backend.app.security import DeploymentAuthMiddleware
from backend.app.services.auth_security import hash_password
......@@ -65,6 +65,7 @@ def create_app() -> FastAPI:
app.include_router(resume.router, prefix="/api")
app.include_router(ai.router, prefix="/api")
app.include_router(ingest.router, prefix="/api")
app.include_router(pool.router, prefix="/api")
@app.get("/", include_in_schema=False)
async def homepage() -> RedirectResponse:
......
"""公司人才池(总库)行级仓储。
人才池 = 所有招聘账号共享的公司级候选人池。池内候选人的 owner_id 固定为
保留工作区 POOL_OWNER("_pool"),不归属任何个人账号,从而实现需求 1 的「总库」:
- 移入人才池:候选人行从调用者工作区迁入池(行级操作,不整包覆盖)。
- 认领/复活:池行迁入调用者工作区,重新进入其 pipeline。
- 读取:任何已登录用户可见全公司池。
为什么不用「整包 PUT /api/state」维护池:各工作区的业务数据以客户端整包状态为
事实源,PUT 会先清空该 owner 全部业务表再重写。若池作为某个个人工作区,会出现
「A 的客户端把 B 认领走的候选人又写回」的互相踩踏;因此池用保留 owner `_pool`
+ 专用行级端点维护,state router 已拒绝 owner=_pool 的整包写入。
候选人 data JSON 内携带池标记(pool/pooledAt/pooledBy/pooledByLabel/pooledFrom/
pooledNote),便于池视图展示与审计;历史面评/AI 报告/简历引用都在 data JSON 里,
随行整体迁移,不丢失。
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from backend.app.models import AppStateMeta, Candidate, utc_now_iso
from backend.app.repositories.json_utils import dump_json, parse_json
# 公司人才池(总库)保留工作区:不作为 User 存在,禁止创建同名账号
POOL_OWNER = "_pool"
# 进入池时写入 data JSON 的池标记字段;认领时全部移除
_POOL_DATA_KEYS = ("pool", "pooledAt", "pooledBy", "pooledByLabel", "pooledFrom", "pooledNote")
class PoolError(Exception):
"""人才池操作失败,携带 HTTP 状态与面向用户的中文提示。"""
def __init__(self, status_code: int, detail: str) -> None:
super().__init__(detail)
self.status_code = status_code
self.detail = detail
def _invalidate_snapshot(session: Session, owner: str, updated_at: str) -> None:
"""失效 owner 的整包快照。
行级迁出候选人绕过了 replace_state,若不清快照,当该工作区表变空时
read_state 会回退到仍含该候选人的旧 raw_snapshot,造成「移走了还在」。
"""
meta = session.get(AppStateMeta, owner)
if meta is not None:
meta.raw_snapshot = None
meta.updated_at = updated_at
def _candidate_from_row(row: Candidate) -> dict:
data = parse_json(row.data, {})
if not data.get("createdAt"):
data = {**data, "createdAt": data.get("created_at") or row.created_at}
return data
def read_pool_candidates(session: Session) -> list[dict]:
"""读取全公司人才池候选人(跨账号共享)。"""
rows = session.scalars(
select(Candidate).where(Candidate.owner_id == POOL_OWNER).order_by(Candidate.created_at.desc(), Candidate.id.desc())
)
return [_candidate_from_row(row) for row in rows]
def move_candidate_to_pool(
session: Session,
candidate_id: str,
source_owner: str,
actor_username: str,
actor_label: str,
note: str = "",
) -> dict:
"""把调用者工作区中的候选人移入公司人才池(归属迁到 _pool)。"""
row = session.get(Candidate, candidate_id)
if row is None or row.owner_id != source_owner:
raise PoolError(404, "候选人不存在或不属于当前工作区,无法移入人才池")
data = _candidate_from_row(row)
now = utc_now_iso()
pooled = {
**data,
"pool": True,
"pooledAt": now,
"pooledBy": actor_username,
"pooledByLabel": actor_label or actor_username,
"pooledFrom": row.owner_id,
"pooledNote": note or "",
}
row.owner_id = POOL_OWNER
row.data = dump_json(pooled)
row.updated_at = now
_invalidate_snapshot(session, source_owner, now)
session.commit()
return pooled
def claim_pool_candidate(session: Session, candidate_id: str, target_owner: str) -> dict:
"""把公司人才池中的候选人认领/复活到调用者工作区(归属迁到调用者)。"""
if target_owner == POOL_OWNER:
raise PoolError(400, "总库为共享区,不能认领到自己")
row = session.get(Candidate, candidate_id)
if row is None or row.owner_id != POOL_OWNER:
raise PoolError(404, "人才池中不存在该候选人")
data = _candidate_from_row(row)
claimed = {key: value for key, value in data.items() if key not in _POOL_DATA_KEYS}
claimed["pool"] = False
now = utc_now_iso()
row.owner_id = target_owner
row.data = dump_json(claimed)
row.updated_at = now
session.commit()
return claimed
......@@ -98,6 +98,8 @@ def create_user(payload: dict[str, Any], db: Session = Depends(get_db), _: Curre
password = _validate_password(str(payload.get("password") or ""))
if not username:
raise HTTPException(status_code=400, detail="用户名不能为空")
if username in ("local", "_pool"):
raise HTTPException(status_code=400, detail="该用户名为系统保留,请更换")
if role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="角色只能是 admin 或 user")
if db.scalar(select(User).where(User.username == username)):
......@@ -168,3 +170,4 @@ def delete_user(user_id: str, db: Session = Depends(get_db), actor: CurrentUser
def _has_other_admin(db: Session, exclude: User) -> bool:
count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin", User.is_active.is_(True), User.id != exclude.id)) or 0
return count > 0
"""公司人才池(总库)路由。
- GET /api/pool 读取全公司人才池(任何已登录用户)
- POST /api/pool/move 把本人工作区候选人移入人才池
- POST /api/pool/claim 把池中候选人认领到本人工作区
所有端点均走 get_current_user,不绕过认证;池区归属不通过 ?owner= 指定他人,
只操作「本人工作区 <-> 公司池」两条边界,天然满足工作区隔离。
"""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app.dependencies import CurrentUser, get_current_user
from backend.app.repositories.pool_repository import (
POOL_OWNER,
PoolError,
claim_pool_candidate,
move_candidate_to_pool,
read_pool_candidates,
)
router = APIRouter()
def _candidate_id(payload: dict[str, Any], key: str = "candidateId") -> str:
value = str(payload.get(key) or "").strip()
if not value:
raise HTTPException(status_code=400, detail="缺少候选人 id")
return value
@router.get("/pool")
def get_pool(
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_current_user),
) -> dict[str, Any]:
candidates = read_pool_candidates(db)
return {"ok": True, "candidates": candidates, "total": len(candidates), "owner": POOL_OWNER}
@router.post("/pool/move")
def move_to_pool(
payload: dict[str, Any],
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> dict[str, Any]:
candidate = payload.get("candidate") or {}
candidate_id = _candidate_id({"candidateId": candidate.get("id")})
note = str(payload.get("note") or "").strip()[:200]
try:
pooled = move_candidate_to_pool(db, candidate_id, user.username, user.username, user.name, note)
except PoolError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
return {"ok": True, "candidate": pooled}
@router.post("/pool/claim")
def claim_from_pool(
payload: dict[str, Any],
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> dict[str, Any]:
candidate_id = _candidate_id(payload)
try:
claimed = claim_pool_candidate(db, candidate_id, user.username)
except PoolError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
return {"ok": True, "candidate": claimed}
from typing import Any
from fastapi import APIRouter, Depends, Response
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app.dependencies import CurrentUser, get_current_user, resolve_owner
from backend.app.repositories.json_utils import pretty_json
from backend.app.repositories.pool_repository import POOL_OWNER
from backend.app.repositories.state_repository import database_is_empty, read_state, replace_state
router = APIRouter()
......@@ -28,6 +29,8 @@ def put_state(
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict[str, Any]:
if owner == POOL_OWNER:
raise HTTPException(status_code=400, detail='总库(人才池)请通过人才池接口维护,禁止整包覆盖')
state = replace_state(db, owner, payload, "api-state")
return {"ok": True, "state": state, "updatedAt": state["updatedAt"], "owner": owner}
......@@ -39,6 +42,8 @@ def import_local_state(
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict[str, Any]:
if owner == POOL_OWNER:
raise HTTPException(status_code=400, detail='总库(人才池)请通过人才池接口维护,禁止整包覆盖')
state = replace_state(db, owner, payload, "localStorage-import")
return {"ok": True, "imported": True, "state": state, "updatedAt": state["updatedAt"], "owner": owner}
......@@ -56,3 +61,5 @@ def export_state(
media_type="application/json; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
......@@ -250,7 +250,7 @@ async def llm_resume_fields(text: str) -> dict[str, Any] | None:
user = (
"请从下面的简历文本中提取结构化字段,只返回一个 JSON 对象(不要 Markdown 围栏,不要多余解释)。\n\n"
"字段与规则:\n"
'- name: 姓名(2-4 个中文字符,如"薛庆霞");没有则返回 ""。\n'
'- name: 姓名(2-4 个中文字符,如"张三");没有则返回 ""。\n'
'- jobTitle: 求职意向/应聘岗位,只能是简历中明确写出的岗位名(如"销售支持专员");没有明确写出时返回 "",'
"不要根据教育/技能内容推断岗位。\n"
'- phone: 手机号(11 位,可含 +86/空格/连字符,如 13812345678);没有则返回 ""。\n'
......@@ -262,7 +262,7 @@ async def llm_resume_fields(text: str) -> dict[str, Any] | None:
'- school: 毕业院校全称(如"甘肃农业大学");没有则返回 ""。\n'
'- major: 专业名称(如"农林经济管理")。主修课程列表里的课程名不是专业,应取"专业"字段或紧邻学校/时间行后的'
'独立专业名;没有明确专业返回 ""。\n'
'- city: 现居城市或简历中的城市名(如"太原",不含"市");没有则返回 ""。\n'
'- city: 现居城市或简历中的城市名(如"太原");没有则返回 ""。\n'
"- skills: 技能数组,最多 12 项,只取简历中明确提到的技能。\n\n"
"要求:只提取简历中明确出现的信息,绝不编造或猜测。\n\n"
f"简历文本:\n{(text or '')[:16000]}"
......
import os
os.environ.setdefault("RECRUITMENT_SKIP_ENV_FILES", "1")
import pytest
from fastapi.testclient import TestClient
......
"""公司人才池(总库)测试:移入/读取/认领 + 多用户隔离 + 池区防整包覆盖。
- 开放模式(client fixture,owner=local):走完整 移入 -> 池可见 -> 认领 闭环。
- 关闭模式(auth_client fixture):两个普通用户 A/B,A 移入,B 可见并认领,
验证工作区隔离不被破坏;admin 也不能对总库整包 PUT。
"""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def auth_client(tmp_path, monkeypatch):
monkeypatch.setenv("RECRUITMENT_SKIP_ENV_FILES", "1")
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{(tmp_path / 'test-pool-auth.sqlite').as_posix()}")
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("FILES_DIR", str(tmp_path / "uploads"))
monkeypatch.setenv("ADMIN_PASSWORD", "admin-secret")
monkeypatch.setenv("ADMIN_USERNAME", "admin")
monkeypatch.setenv("SESSION_SECRET", "test-session-secret")
monkeypatch.setenv("INGEST_TOKEN", "")
monkeypatch.setenv("LLM_API_KEY", "")
monkeypatch.setenv("DEEPSEEK_API_KEY", "")
monkeypatch.setenv("OPENAI_API_KEY", "")
import backend.app.config as config
import backend.app.db as db
config.get_settings.cache_clear()
db.engine.dispose()
db.settings = config.get_settings()
db.engine = db.create_engine(
db.normalize_database_url(db.settings.database_url),
connect_args={"check_same_thread": False},
future=True,
)
db.SessionLocal.configure(bind=db.engine)
from backend.app.main import create_app
app = create_app()
with TestClient(app) as test_client:
yield test_client
db.engine.dispose()
config.get_settings.cache_clear()
def _login(client, username, password):
return client.post("/api/auth/login", json={"username": username, "password": password})
def _headers(token):
return {"Authorization": f"Bearer {token}"}
def _state_with_candidate(candidate):
return {"jobs": [], "candidates": [candidate], "offers": [], "tasks": [], "eventLog": []}
# ---- 开放模式:完整闭环 ----
def test_pool_move_read_claim_open_mode(client):
candidate = {
"id": "cand-pool-1",
"name": "张三",
"jobTitle": "Java开发工程师",
"source": "Boss",
"stage": "初筛通过",
"tags": ["重点跟进"],
"resumeText": "五年 Java 后端经验,熟悉 Spring Cloud。",
}
assert client.put("/api/state", json=_state_with_candidate(candidate)).status_code == 200
# 初始池为空
assert client.get("/api/pool").json()["total"] == 0
# 移入人才池
moved = client.post("/api/pool/move", json={"candidate": {"id": "cand-pool-1"}, "note": "面完未通过"})
assert moved.status_code == 200
pooled = moved.json()["candidate"]
assert pooled["pool"] is True
assert pooled["pooledBy"] == "local"
assert pooled["pooledNote"] == "面完未通过"
# 原工作区不再包含该候选人,池里有 1 人
state = client.get("/api/state").json()
assert [c["id"] for c in state["candidates"]] == []
pool = client.get("/api/pool").json()
assert pool["total"] == 1
assert pool["candidates"][0]["name"] == "张三"
assert pool["owner"] == "_pool"
# 认领回自己的工作区
claimed = client.post("/api/pool/claim", json={"candidateId": "cand-pool-1"})
assert claimed.status_code == 200
assert claimed.json()["candidate"]["pool"] is False
assert client.get("/api/pool").json()["total"] == 0
state = client.get("/api/state").json()
assert [c["id"] for c in state["candidates"]] == ["cand-pool-1"]
def test_pool_move_unknown_candidate_404(client):
resp = client.post("/api/pool/move", json={"candidate": {"id": "cand-nope"}})
assert resp.status_code == 404
def test_pool_claim_unknown_candidate_404(client):
resp = client.post("/api/pool/claim", json={"candidateId": "cand-nope"})
assert resp.status_code == 404
def test_pool_owner_cannot_be_whole_state_written(client):
payload = _state_with_candidate({"id": "cand-x", "name": "X"})
assert client.put("/api/state", json=payload, params={"owner": "_pool"}).status_code == 400
assert client.post("/api/import/local-state", json=payload, params={"owner": "_pool"}).status_code == 400
# ---- 关闭模式:多用户跨账号总库 ----
def test_pool_is_company_shared_across_users(auth_client):
admin_token = _login(auth_client, "admin", "admin-secret").json()["token"]
admin_headers = _headers(admin_token)
for username in ("alice", "bob"):
resp = auth_client.post(
"/api/auth/users",
json={"username": username, "name": username.title(), "role": "user", "password": f"{username}12345"},
headers=admin_headers,
)
assert resp.status_code == 200
alice_headers = _headers(_login(auth_client, "alice", "alice12345").json()["token"])
bob_headers = _headers(_login(auth_client, "bob", "bob12345").json()["token"])
# alice 在自己的工作区建候选人并移入总库
candidate = {"id": "cand-shared-1", "name": "李四", "jobTitle": "销售总监", "source": "猎聘", "stage": "初筛未通过"}
assert auth_client.put("/api/state", json=_state_with_candidate(candidate), headers=alice_headers).status_code == 200
moved = auth_client.post(
"/api/pool/move", json={"candidate": {"id": "cand-shared-1"}, "note": "面完未通过,放入总库"}, headers=alice_headers
)
assert moved.status_code == 200
assert moved.json()["candidate"]["pooledFrom"] == "alice"
# alice 工作区已移除该候选人
alice_state = auth_client.get("/api/state", headers=alice_headers).json()
assert [c["id"] for c in alice_state["candidates"]] == []
# bob 不能移走 alice 的候选人(池行不属于 bob 工作区)
forbidden = auth_client.post(
"/api/pool/move", json={"candidate": {"id": "cand-shared-1"}}, headers=bob_headers
)
assert forbidden.status_code == 404
# bob 可见公司总库(跨账号共享)
pool = auth_client.get("/api/pool", headers=bob_headers).json()
assert pool["total"] == 1
assert pool["candidates"][0]["name"] == "李四"
assert pool["candidates"][0]["pooledBy"] == "alice"
# bob 认领到自己的工作区;alice 的工作区依旧为空,池清空
claimed = auth_client.post("/api/pool/claim", json={"candidateId": "cand-shared-1"}, headers=bob_headers)
assert claimed.status_code == 200
bob_state = auth_client.get("/api/state", headers=bob_headers).json()
assert [c["id"] for c in bob_state["candidates"]] == ["cand-shared-1"]
assert auth_client.get("/api/pool", headers=alice_headers).json()["total"] == 0
alice_state = auth_client.get("/api/state", headers=alice_headers).json()
assert [c["id"] for c in alice_state["candidates"]] == []
def test_pool_requires_auth_in_closed_mode(auth_client):
assert auth_client.get("/api/pool").status_code == 401
assert auth_client.post("/api/pool/move", json={"candidate": {"id": "x"}}).status_code == 401
assert auth_client.post("/api/pool/claim", json={"candidateId": "x"}).status_code == 401
def test_reserved_username_rejected(auth_client):
admin_token = _login(auth_client, "admin", "admin-secret").json()["token"]
headers = _headers(admin_token)
for username in ("local", "_pool"):
resp = auth_client.post(
"/api/auth/users",
json={"username": username, "role": "user", "password": "pass12345"},
headers=headers,
)
assert resp.status_code == 400
# ADR 0001:人才池 = 公司共享总库(保留工作区 `_pool` + 行级接口)
- 状态:已接受(2026-09-02)
- 关联需求:需求 1(多账号 + 总库)、需求 12(候选人标记)、需求 20(下推联动)、需求 22(人才池视图)
## 背景 / Context
系统已实现「多招聘账号 + 按用户隔离工作区」:所有业务表带 `owner_id``GET/PUT /api/state`**整包 JSON** 读写单个工作区,`replace_state` 会先清空该 owner 全部业务表再重写。但公司需要一个**跨账号共享的总库(人才池)**:候选人面完没过 / 暂缓 / 储备后进入,任何 HR 都能看到并可认领到自己账号下继续推。
直接方案(把池塞进某个人工作区,或让池成为又一个可整包写的 owner)都会破坏隔离或互相踩踏:
- 若池只是某个账号工作区里的标记,别人看不到;
- 若池允许整包 `PUT /api/state`,A 的客户端(缓存了旧列表)可能在 B 认领走后又把同一候选人写回,产生跨账号覆盖。
## 决策 / Decision
1. **人才池 = 保留工作区 `_pool`**:池内候选人行的 `owner_id = "_pool"`,不建 User、禁止创建同名账号,不属于任何招聘账号。
2. **池只用行级专用接口维护**`GET /api/pool`(读全公司池)、`POST /api/pool/move`(本人工作区 → 池)、`POST /api/pool/claim`(池 → 本人工作区)。**池区禁止整包 `PUT /api/state` / 导入**(state 路由显式拒绝 `owner=_pool`)。
3. **候选人 data JSON 随行整体迁移**`pool/pooledAt/pooledBy/pooledFrom/pooledNote` 等池标记与面评/AI 报告/简历引用一起带走,不丢历史。
4. **行级迁出要失效原工作区整包快照**`read_state` 在表为空时会回退 `AppStateMeta.raw_snapshot`,移走最后一名候选人后若不清快照会出现「移走了还在」,故 move 时把源工作区 `raw_snapshot` 置空。
5. 权限:任何已登录用户可读池、可认领(只从池迁到自己区);move 只允许候选人当前归属者操作本人工作区。
6. 前端:新增「人才池」页面 / 路由;「移入人才池」「认领」走上述接口并联动事件日志(`pool_moved` / `candidate_claimed`),不再用「tag='人才池'+阶段=初筛未通过」的本地 hack。
## 影响 / Consequences
- 正向:总库可见性与个人隔离两全;整包状态模型无需改动;无数据库表结构迁移(`owner_id` 已有)。
- 代价:池内候选人不在任何个人工作区,历史「事件日志」留在原归属区(认领后新归属区以 `candidate_claimed` 起新记录);无 PDF 之外的原简历附件表行随行迁移(简历文件在磁盘、data JSON 保留引用,不受影响)。
- 后续可做:岗位级共享、批量移入/认领、认领前简历去重比对。
This diff is collapsed.
import http, { readToken } from '@/api'
export async function fetchPool() {
const { data } = await http.get('/api/pool')
if (data.error) throw new Error(data.error)
return data
}
export async function moveCandidateToPool(candidate, note = '') {
const { data } = await http.post('/api/pool/move', { candidate, note })
if (data.error) throw new Error(data.error)
return data
}
export async function claimPoolCandidate(candidateId) {
const { data } = await http.post('/api/pool/claim', { candidateId })
if (data.error) throw new Error(data.error)
return data
}
export async function fetchState(owner = '') {
const { data } = await http.get('/api/state', { params: owner ? { owner } : {} })
return data
......
......@@ -194,7 +194,7 @@ const importBatch = async () => {
city: parsed.city || '',
skills: parsed.skills?.length ? parsed.skills : extractSkills(resumeText),
parseWarning: parsed.parseWarning || '',
tag: '新入库',
tags: ['新入库'],
evaluation: '批量上传入库',
})
created.push(candidate)
......
......@@ -148,7 +148,7 @@ const submit = async () => {
: schoolTags(parsedResume.school || inferSchool(resumeText)),
city: parsedResume.city || '',
skills: parsedResume.skills?.length ? parsedResume.skills : extractSkills(resumeText),
tag: '新入库',
tags: ['新入库'],
evaluation: form.evaluation,
})
store.candidates.unshift(candidate)
......@@ -334,10 +334,14 @@ const submit = async () => {
}
}
.candidate-create-form {
position: relative;
}
.save-mask {
position: fixed;
position: absolute;
inset: 0;
z-index: 3000;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
......
......@@ -2,8 +2,11 @@
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import http from '@/api'
import { useRecruitmentStore } from '@/stores/recruitment'
import { CANDIDATE_TAG_OPTIONS, candidateTagTone } from '@/utils/constants'
import { candidateWorkflow, workflowStageClass } from '@/utils/task'
import { candidateScreeningConclusion, quickScore } from '@/utils/resume'
import { isAiEvaluationReport } from '@/utils/scoring'
......@@ -334,12 +337,30 @@ const advanceStage = (candidate, nextStage) => {
showToast('候选人阶段已更新')
}
// 移入人才池:从当前工作区迁入公司共享总库(需求 1/20),而非本地改 tag/阶段。
const poolCandidate = (candidate) => {
candidate.tag = '人才池'
candidate.interviewStatus = ''
store.setCandidateStage(candidate, '初筛未通过', '移入人才池')
store.persist('候选人已移入人才池')
showToast('候选人已移入人才池')
ElMessageBox.prompt(
'候选人将从当前工作区迁入公司人才池(总库),其他招聘账号可见并可认领,原岗位流程与待办将随之结束。可在备注中记录原因。',
`移入人才池:${candidate.name}`,
{
confirmButtonText: '确认移入',
cancelButtonText: '取消',
inputPlaceholder: '备注(可选,如:面完未通过 / 暂缓 / 储备)',
inputValue: '',
type: 'warning',
}
)
.then(async ({ value }) => {
try {
await store.moveToPoolCandidate(candidate, (value || '').trim())
showToast(`已把 ${candidate.name} 移入公司人才池`)
} catch (error) {
showToast(error.message || '移入失败', 'error')
}
})
.catch(() => {
/* 取消 */
})
}
// ---- 概览档案卡:预览 / 编辑双态 ----
......@@ -405,6 +426,7 @@ const enterEditMode = async () => {
major: c.major || '',
schoolTags: (c.schoolTags || []).join('、'),
skills: (c.skills || []).join('、'),
tags: [...(c.tags || [])],
})
editMode.value = true
store.resumeDetailSection = 'overview'
......@@ -429,6 +451,7 @@ const profileCorrection = reactive({
major: '',
schoolTags: '',
skills: '',
tags: [],
})
const saveCorrection = (candidate) => {
......@@ -445,6 +468,7 @@ const saveCorrection = (candidate) => {
major: profileCorrection.major.trim(),
schoolTags: manualSchoolTags.length ? manualSchoolTags : candidate.schoolTags || [],
skills: profileCorrection.skills.split(/[、,,\s]+/).filter(Boolean),
tags: [...new Set((profileCorrection.tags || []).map((item) => String(item).trim()).filter(Boolean))],
})
store.localMatch(candidate.id)
store.persist('候选人基础字段已修正')
......@@ -731,6 +755,48 @@ const saveInterview = (candidate) => {
</span>
</div>
</section>
<section class="ov-section ov-tags">
<div class="ov-section-title">
<span class="ov-section-mark"></span>
<span>标记</span>
</div>
<div v-if="editMode" class="ov-cell is-editing">
<span class="ov-cell-label">标记</span>
<el-select
v-model="profileCorrection.tags"
multiple
filterable
allow-create
default-first-option
size="small"
class="tag-select"
placeholder="选择或输入标记"
>
<el-option
v-for="option in CANDIDATE_TAG_OPTIONS"
:key="option"
:label="option"
:value="option"
/>
</el-select>
<div class="edit-hint">标记由 HR 人工选择/补充,AI 不参与打标。</div>
</div>
<div v-else class="skill-chips tag-chips">
<el-tag
v-for="tag in selected.tags || []"
:key="tag"
size="small"
:type="candidateTagTone(tag)"
effect="light"
>
{{ tag }}
</el-tag>
<span v-if="!(selected.tags || []).length" class="skill-empty"
>暂无标记,可点击右上角「编辑」补充</span
>
</div>
</section>
</div>
<div class="resume-text-box">
......@@ -1513,6 +1579,24 @@ const saveInterview = (candidate) => {
}
}
.tag-chips {
gap: 4px;
.el-tag {
margin-right: 0;
}
}
.tag-select {
width: 100%;
}
.edit-hint {
margin-top: 4px;
font-size: 11px;
color: var(--muted);
}
.resume-text-box {
.resume-text-head {
margin-bottom: 8px;
......
......@@ -2,7 +2,10 @@
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { useRecruitmentStore } from '@/stores/recruitment'
import { CANDIDATE_TAG_OPTIONS, candidateTagTone } from '@/utils/constants'
import { jobCandidates } from '@/utils/links'
import { getFilteredCandidates, quickMatchNeedsRefresh } from '@/utils/matching'
import { aiScore, candidateScreeningConclusion, quickScore } from '@/utils/resume'
......@@ -17,6 +20,7 @@ const batchMatchRunning = ref(false)
const batchMatchResult = ref(null)
const stageOptions = ['全部阶段', '未筛选', '初筛通过', '初筛未通过', '面试中', 'Offer 中', '待入职', '已入职']
const tagOptions = ['全部标签', '待跟进', ...CANDIDATE_TAG_OPTIONS]
const sourceOptions = computed(() => [
'全部来源',
...new Set(store.candidates.map((candidate) => candidate.source).filter(Boolean)),
......@@ -52,12 +56,13 @@ const hasFilters = computed(() => {
f.q ||
(f.stage && f.stage !== '全部阶段') ||
(f.source && f.source !== '全部来源') ||
(f.tag && f.tag !== '全部标签') ||
(f.minMatch && f.minMatch !== '全部匹配')
)
})
const clearFilters = () => {
store.resumeFilters = { q: '', stage: '全部阶段', source: '全部来源', minMatch: '全部匹配' }
store.resumeFilters = { q: '', stage: '全部阶段', source: '全部来源', tag: '全部标签', minMatch: '全部匹配' }
}
const candidateInitial = (candidate) => (candidate.name || '候选人').trim().slice(0, 1).toUpperCase()
......@@ -146,9 +151,35 @@ const runMatch = (candidate) => {
const rowCommand = (command, candidate) => {
if (command === 'view') openDetail(candidate)
if (command === 'match') runMatch(candidate)
if (command === 'pool') poolCandidate(candidate)
if (command === 'delete') deleteCandidate(candidate)
}
const poolCandidate = (candidate) => {
ElMessageBox.prompt(
'候选人将从当前工作区迁入公司人才池(总库),其他招聘账号可见并可认领。可在备注中记录原因。',
`移入人才池:${candidate.name}`,
{
confirmButtonText: '确认移入',
cancelButtonText: '取消',
inputPlaceholder: '备注(可选,如:面完未通过 / 暂缓 / 储备)',
inputValue: '',
type: 'warning',
}
)
.then(async ({ value }) => {
try {
await store.moveToPoolCandidate(candidate, (value || '').trim())
showToast(`已把 ${candidate.name} 移入公司人才池`)
} catch (error) {
showToast(error.message || '移入失败', 'error')
}
})
.catch(() => {
/* 取消 */
})
}
const matchAll = () => {
const targets = filtered.value
if (!targets.length) {
......@@ -236,6 +267,9 @@ const openBatch = () => {
<el-select v-model="store.resumeFilters.source" placeholder="来源">
<el-option v-for="option in sourceOptions" :key="option" :label="option" :value="option" />
</el-select>
<el-select v-model="store.resumeFilters.tag" placeholder="标记">
<el-option v-for="option in tagOptions" :key="option" :label="option" :value="option" />
</el-select>
<el-select v-model="store.resumeFilters.minMatch" placeholder="匹配度">
<el-option label="全部匹配" value="全部匹配" />
<el-option label="80+" value="80+" />
......@@ -314,6 +348,17 @@ const openBatch = () => {
<div class="candidate-profile" :class="{ 'is-empty': !profileSummary(candidate) }">
{{ profileSummary(candidate) || '档案字段待补全' }}
</div>
<div v-if="candidate.tags && candidate.tags.length" class="candidate-tags">
<el-tag
v-for="tag in candidate.tags"
:key="tag"
size="small"
:type="candidateTagTone(tag)"
effect="light"
>
{{ tag }}
</el-tag>
</div>
</div>
</div>
......@@ -366,6 +411,7 @@ const openBatch = () => {
<el-dropdown-menu>
<el-dropdown-item command="view">查看档案</el-dropdown-item>
<el-dropdown-item command="match">生成快速匹配</el-dropdown-item>
<el-dropdown-item command="pool">移入人才池</el-dropdown-item>
<el-dropdown-item command="delete" divided>删除候选人</el-dropdown-item>
</el-dropdown-menu>
</template>
......@@ -384,6 +430,13 @@ const openBatch = () => {
</template>
<style lang="scss" scoped>
.candidate-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.candidate-page {
display: flex;
flex-direction: column;
......
......@@ -19,6 +19,7 @@ const activeRouteKey = computed(() => {
if (name === 'dashboard') return 'dashboard'
if (['jobs', 'job-detail', 'job-edit', 'job-create'].includes(name)) return 'jobs'
if (name === 'resumes') return 'resumes'
if (name === 'pool') return 'pool'
if (name === 'offer') return 'offer'
return 'todo'
})
......@@ -46,7 +47,7 @@ const navGroups = computed(() => {
const byKey = Object.fromEntries(navItems.map((item) => [item.key, item]))
const groups = [
{ title: '工作台', items: [byKey.todo, byKey.dashboard].filter(Boolean) },
{ title: '招聘流程', items: [byKey.jobs, byKey.resumes, byKey.offer].filter(Boolean) },
{ title: '招聘流程', items: [byKey.jobs, byKey.resumes, byKey.pool, byKey.offer].filter(Boolean) },
]
if (isAdmin.value) {
groups.push({ title: '系统', items: [{ key: 'user-management', label: '用户管理', icon: 'Setting' }] })
......@@ -65,6 +66,7 @@ const navCount = (key) => {
if (key === 'dashboard') return store.jobs.length
if (key === 'jobs') return store.jobs.length
if (key === 'resumes') return store.candidates.length
if (key === 'pool') return store.poolCandidates.length
if (key === 'offer') return store.offers.length
return 0
}
......@@ -184,6 +186,7 @@ onMounted(() => {
// 先校正工作区归属(登录/换号后以当前用户为准),再加载数据库数据
store.syncOwner(userStore.workspaceOwner)
store.boot()
store.loadPool()
loadWorkspaceOptions()
})
</script>
......
......@@ -50,6 +50,11 @@ const routes = [
name: 'resumes',
component: () => import('@/views/ResumesView.vue'),
},
{
path: '/pool',
name: 'pool',
component: () => import('@/views/PoolView.vue'),
},
{
path: '/offer',
name: 'offer',
......
......@@ -9,6 +9,13 @@ export const navItems = [
},
{ key: 'jobs', label: '岗位管理', desc: '维护岗位、JD、招聘目标和岗位状态。', path: '/jobs', icon: 'Briefcase' },
{ key: 'resumes', label: '简历库', desc: '管理候选人、简历来源、匹配度和流程状态。', path: '/resumes', icon: 'User' },
{
key: 'pool',
label: '人才池',
desc: '公司共享总库:查看、认领储备与淘汰候选人。',
path: '/pool',
icon: 'Collection',
},
{ key: 'offer', label: '录用入职管理', desc: '生成 Offer 审批草稿并跟进待入职。', path: '/offer', icon: 'Stamp' },
]
......
......@@ -230,8 +230,11 @@ export const useRecruitmentStore = defineStore('recruitment', {
resumeDetailMode: 'quick',
resumeDetailSection: 'overview',
focusedTarget: null,
poolCandidates: [],
poolLoading: false,
poolLoaded: false,
jobFilters: { q: '', department: '全部部门', status: '全部状态', scope: 'all' },
resumeFilters: { q: '', stage: '全部阶段', source: '全部来源', minMatch: '全部匹配' },
resumeFilters: { q: '', stage: '全部阶段', source: '全部来源', tag: '全部标签', minMatch: '全部匹配' },
dashboardFilters: {
range: '月',
period: currentPeriodKey(),
......@@ -340,6 +343,65 @@ export const useRecruitmentStore = defineStore('recruitment', {
this.activePage = key
},
async loadPool() {
this.poolLoading = true
try {
const payload = await api.fetchPool()
this.poolCandidates = Array.isArray(payload.candidates) ? payload.candidates : []
this.poolLoaded = true
} catch {
// 人才池加载失败不阻断主流程(后端不可用时为空池)
this.poolCandidates = []
} finally {
this.poolLoading = false
}
},
// 移入人才池 = 从本人工作区迁入公司总库(后端行级迁移);本地移除候选人并清理关联 Offer/待办,
// 事件日志保留「pool_moved」作为操作记录(需求 20 联动)。
async moveToPoolCandidate(candidate, note = '') {
const pooled = (await api.moveCandidateToPool(candidate, note)).candidate
const cand = this.state.candidates.find((item) => item.id === pooled.id)
if (cand) {
const relatedOfferIds = this.state.offers.filter((item) => item.candidateId === cand.id).map((item) => item.id)
this.logEvent('pool_moved', cand, { note: note || '移入公司人才池(总库)', fromStage: cand.stage || '未筛选' })
this.state.candidates = this.state.candidates.filter((item) => item.id !== cand.id)
this.state.offers = this.state.offers.filter((item) => item.candidateId !== cand.id)
this.state.tasks = this.state.tasks.filter(
(task) =>
task.candidateId !== cand.id &&
!relatedOfferIds.includes(task.offerId) &&
!String(task.target || '').includes(cand.name || '__never__')
)
if (this.selectedCandidateId === cand.id) {
this.selectedCandidateId = this.state.candidates[0]?.id || ''
this.resumeViewMode = 'list'
}
this.reconcile()
this.persist('候选人已移入人才池')
}
this.poolCandidates = this.poolCandidates.filter((item) => item.id !== pooled.id)
await this.loadPool()
return pooled
},
// 认领 = 把公司总库候选人迁入本人工作区,重置为「未筛选」重新走流程。
async claimPoolCandidate(poolCandidate = {}) {
const claimed = (await api.claimPoolCandidate(poolCandidate.id)).candidate
const tags = claimed.tags && claimed.tags.length ? claimed.tags : ['新入库']
const candidate = normalizeCandidate({ ...claimed, stage: '未筛选', tags }, this.state.jobs)
const existing = this.state.candidates.some((item) => item.id === candidate.id)
if (!existing) {
this.state.candidates.unshift(candidate)
this.logEvent('candidate_claimed', candidate, { note: '从公司人才池认领,重新进入招聘流程' })
this.reconcile()
this.persist('候选人已认领到我的工作区')
}
this.poolCandidates = this.poolCandidates.filter((item) => item.id !== poolCandidate.id)
this.loadPool()
return candidate
},
reconcile() {
this.state = reconcileRecruitmentLinks(this.state)
},
......
import dictionaries from '../../../shared/resume-dictionaries.json'
// 候选人标记:结构化枚举,由 HR 人工选择(需求 12,AI 不写)。
export const CANDIDATE_TAG_OPTIONS = ['新入库', '重点跟进', '待二面', '待沟通', '需维护', '储备', '已淘汰']
// 旧版本单一 tag 字符串 -> 新 tags 数组 的映射(历史数据兼容)
export const LEGACY_TAG_MAP = {
重点关注: '重点跟进',
待面评: '待二面',
高优先级: '重点跟进',
人才池: '储备',
}
// 需 HR 主动沟通维护的标记:构成「本周待跟进」快捷筛选
export const FOLLOW_UP_TAGS = ['重点跟进', '待二面', '待沟通', '需维护']
// 人才池(总库)相关常量:与后端 pool_repository.POOL_OWNER 对应
export const POOL_OWNER_LABEL = '公司总库'
export function normalizeCandidateTags(candidate = {}) {
const rawTags = Array.isArray(candidate.tags) ? candidate.tags : []
const legacy = candidate.tag || ''
const list = rawTags.length ? rawTags : legacy ? [LEGACY_TAG_MAP[legacy] || legacy] : []
return [...new Set(list.map((item) => String(item).trim()).filter(Boolean))]
}
export function candidateTagTone(tag = '') {
if (tag === '已淘汰') return 'danger'
if (['重点跟进', '待二面', '待沟通', '需维护'].includes(tag)) return 'warning'
if (tag === '储备') return 'info'
return 'primary'
}
export const STORAGE_KEY = 'recruitment-system-mvp-v1'
// 兼容旧键:开放模式(未启用登录)沿用 recruitment-system-mvp-v1,避免已有浏览器数据丢失
......@@ -68,7 +95,7 @@ export const seedState = {
stage: '初筛通过',
match: 92,
resumeName: '张晓明_高级产品经理.pdf',
tag: '重点关注',
tags: ['重点跟进'],
resumeText: 'B 端 SaaS 产品经理,负责 AI 产品、需求分析、数据分析、方案设计和落地推进。',
evaluation: 'B 端产品经验完整,建议进入一面;需确认稳定性和 AI 项目实际负责范围。',
},
......@@ -80,7 +107,7 @@ export const seedState = {
stage: '面试中',
match: 88,
resumeName: '李雪梅_产品总监.pdf',
tag: '待面评',
tags: ['待二面'],
resumeText: '平台产品负责人,负责产品规划、团队管理、数据分析和跨部门协作。',
evaluation: '平台产品经验较强,管理跨度需要进一步验证。',
},
......@@ -93,7 +120,7 @@ export const seedState = {
stage: 'Offer 中',
match: 85,
resumeName: '王建国_销售总监.pdf',
tag: '高优先级',
tags: ['重点跟进'],
resumeText: '大客户销售总监,负责销售策略、重点客户拓展、团队管理和收入目标达成。',
evaluation: '大客户销售经验强,薪资方案超预算,需要审批说明。',
},
......@@ -106,7 +133,7 @@ export const seedState = {
stage: '未筛选',
match: 79,
resumeName: '陈思远_前端.pdf',
tag: '待沟通',
tags: ['待沟通'],
resumeText: '前端工程师,熟悉 React、Node、工程化、性能优化和核心业务模块开发。',
evaluation: '技术栈匹配,项目深度需要初筛确认。',
},
......@@ -118,7 +145,7 @@ export const seedState = {
stage: '初筛未通过',
match: 73,
resumeName: '刘佳宁_HRBP.pdf',
tag: '人才池',
tags: ['储备'],
resumeText: 'HRBP,熟悉招聘、员工关系和组织发展。',
evaluation: '当前岗位匹配度一般,可入人才池后续激活。',
},
......
import { extractKeywords } from './keywords.js'
import { FOLLOW_UP_TAGS, normalizeCandidateTags } from './constants.js'
import { buildJobCriteria, jobSearchText } from './job.js'
import { matchedJobForCandidate } from './links.js'
import { quickMatchScore } from './scoring.js'
......@@ -61,11 +62,19 @@ function matchingScore(matchedCount, mustMatchedCount, jobTitleMatched, resumeTe
return Math.max(45, Math.min(98, score))
}
export function candidatePassesTagFilter(candidate, tagFilter = '') {
if (!tagFilter || tagFilter === '全部标签') return true
const tags = normalizeCandidateTags(candidate)
if (tagFilter === '待跟进') return tags.some((tag) => FOLLOW_UP_TAGS.includes(tag))
return tags.includes(tagFilter)
}
export function getFilteredCandidates(candidates, filters = {}) {
const q = (filters.q || '').toLowerCase().trim()
return candidates.filter((candidate) => {
if (filters.stage && filters.stage !== '全部阶段' && candidate.stage !== filters.stage) return false
if (filters.source && filters.source !== '全部来源' && (candidate.source || '其他') !== filters.source) return false
if (filters.tag && filters.tag !== '全部标签' && !candidatePassesTagFilter(candidate, filters.tag)) return false
if (filters.minMatch && filters.minMatch !== '全部匹配') {
const min = Number(filters.minMatch.replace('≥', ''))
const score = quickMatchScore(candidate)
......@@ -73,7 +82,7 @@ export function getFilteredCandidates(candidates, filters = {}) {
}
if (q) {
const text =
`${candidate.name} ${candidate.jobTitle} ${candidate.school} ${candidate.major} ${candidate.resumeText || ''}`.toLowerCase()
`${candidate.name} ${candidate.jobTitle} ${candidate.school} ${candidate.major} ${(candidate.tags || []).join(' ')} ${candidate.resumeText || ''}`.toLowerCase()
if (!text.includes(q)) return false
}
return true
......
import { seedState, STAGE_ORDER } from './constants.js'
import { normalizeCandidateTags, seedState, STAGE_ORDER } from './constants.js'
import { enrichCandidateFields } from './candidate.js'
import { cleanCandidateName } from './naming.js'
......@@ -224,6 +224,7 @@ export function normalizeCandidate(candidate = {}, jobs = []) {
email: enriched.email,
name: cleanCandidateName(candidate.name, candidate.resumeMeta?.name || candidate.resumeName),
resumeName: candidate.resumeMeta?.name || candidate.resumeName,
tags: normalizeCandidateTags(candidate),
evaluation: /本地匹配度为|当前只有简历文件名|已匹配到/.test(candidate.evaluation || '') ? '' : candidate.evaluation,
}
}
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { useRecruitmentStore } from '@/stores/recruitment'
import { CANDIDATE_TAG_OPTIONS, FOLLOW_UP_TAGS, candidateTagTone, normalizeCandidateTags } from '@/utils/constants'
import { enrichCandidateFields, candidateAge, candidateYears } from '@/utils/candidate'
import { candidatePassesTagFilter } from '@/utils/matching'
import { showToast } from '@/utils/toast'
const store = useRecruitmentStore()
const router = useRouter()
const filters = ref({ q: '', tag: '全部标签', source: '全部来源' })
const detailOpen = ref(false)
const selectedId = ref('')
const tagOptions = ['全部标签', '待跟进', ...CANDIDATE_TAG_OPTIONS]
const sourceOptions = computed(() => [
'全部来源',
...new Set((store.poolCandidates || []).map((candidate) => candidate.source).filter(Boolean)),
])
// 池内候选人不属于当前工作区,只做轻量档案补全,不绑定本区岗位。
const poolView = computed(() =>
(store.poolCandidates || []).map((candidate) => {
const enriched = enrichCandidateFields(candidate)
return { ...enriched, tags: normalizeCandidateTags(candidate) }
})
)
const filtered = computed(() => {
const q = filters.value.q.toLowerCase().trim()
const list = poolView.value.filter((candidate) => {
if (filters.value.source !== '全部来源' && (candidate.source || '其他') !== filters.value.source) return false
if (!candidatePassesTagFilter(candidate, filters.value.tag)) return false
if (q) {
const haystack = [
candidate.name,
candidate.jobTitle,
candidate.school,
candidate.major,
(candidate.tags || []).join(' '),
candidate.resumeText || '',
candidate.pooledNote || '',
]
.filter(Boolean)
.join(' ')
.toLowerCase()
if (!haystack.includes(q)) return false
}
return true
})
return list
})
const followUpCount = computed(
() => poolView.value.filter((candidate) => (candidate.tags || []).some((tag) => FOLLOW_UP_TAGS.includes(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 selected = computed(() => poolView.value.find((candidate) => candidate.id === selectedId.value) || null)
const formatPooledAt = (iso) => {
if (!iso) return ''
const date = new Date(iso)
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())}`
}
const openDetail = (candidate) => {
selectedId.value = candidate.id
detailOpen.value = true
}
const closeDetail = () => {
detailOpen.value = false
selectedId.value = ''
}
const openResume = (candidate) => {
const url = candidate.resumeFileDataUrl || candidate.resumeMeta?.url || ''
if (!url) {
showToast('该候选人没有保存原始简历文件', 'warning')
return
}
window.open(url, '_blank')
}
const claim = async (candidate) => {
if (!candidate?.id) return
try {
await ElMessageBox.confirm(
`确认把「${candidate.name}」从公司人才池认领到你的工作区吗?认领后重置为「未筛选」,可重新绑定岗位继续推进。`,
'认领候选人',
{ confirmButtonText: '认领到我的工作区', cancelButtonText: '取消', type: 'info' }
)
} catch {
return
}
try {
const claimed = await store.claimPoolCandidate(candidate)
showToast(`已认领 ${claimed.name} 到我的工作区`)
store.selectedCandidateId = claimed.id
store.resumeViewMode = 'detail'
store.resumeDetailSection = 'overview'
router.push({ name: 'resumes' })
} catch (error) {
showToast(error.message || '认领失败,请重试', 'error')
}
}
const goResumes = () => router.push({ name: 'resumes' })
const profileSummary = (candidate) => {
const bits = [candidate.education, candidate.school, candidate.major].filter(Boolean)
const years = Number(candidateYears(candidate) || 0)
const age = Number(candidateAge(candidate) || 0)
if (years) bits.push(`${years} 年经验`)
if (age) bits.push(`${age} 岁`)
return bits.join(' · ')
}
onMounted(() => {
store.loadPool()
})
</script>
<template>
<div class="pool-page">
<div class="metric-grid">
<article class="metric-card" style="--accent: var(--blue)">
<div class="metric-copy">
<span>总库人数</span>
<strong>{{ store.poolCandidates.length }}</strong>
<small>公司共享人才池(跨账号)</small>
</div>
</article>
<article class="metric-card" style="--accent: var(--green)">
<div class="metric-copy">
<span>本周新入池</span>
<strong>{{ newThisWeek }}</strong>
<small>近 7 天放入总库</small>
</div>
</article>
<article class="metric-card" style="--accent: var(--yellow)">
<div class="metric-copy">
<span>待跟进</span>
<strong>{{ followUpCount }}</strong>
<small>重点跟进 / 待二面 / 待沟通 / 需维护</small>
</div>
</article>
</div>
<section class="card pool-card">
<header class="list-head">
<div class="list-head-copy">
<span class="section-kicker">总库 · 人才池</span>
<h2>公司共享人才池</h2>
<p>面试淘汰 / 暂缓 / 储备的候选人都汇聚到这里,任一招聘账号都可查看并认领。</p>
</div>
<div class="head-actions">
<el-button round @click="goResumes">回简历库</el-button>
</div>
</header>
<div class="filter-row">
<el-input v-model="filters.q" class="filter-search" placeholder="搜索姓名、岗位、学校、技能、备注" clearable />
<el-select v-model="filters.tag" placeholder="标记">
<el-option v-for="option in tagOptions" :key="option" :label="option" :value="option" />
</el-select>
<el-select v-model="filters.source" placeholder="来源">
<el-option v-for="option in sourceOptions" :key="option" :label="option" :value="option" />
</el-select>
<el-button v-if="store.poolLoading" round disabled>加载中…</el-button>
</div>
<div v-if="filtered.length" class="pool-grid">
<article
v-for="candidate in filtered"
:key="candidate.id"
class="pool-card-item"
tabindex="0"
@click="openDetail(candidate)"
@keydown.enter="openDetail(candidate)"
>
<div class="pool-person">
<div class="pool-avatar">{{ (candidate.name || '候').slice(0, 1).toUpperCase() }}</div>
<div class="pool-copy">
<div class="pool-name-row">
<strong>{{ candidate.name || '未命名候选人' }}</strong>
<span v-if="candidate.stage" class="stage-chip">{{ candidate.stage }}</span>
</div>
<div class="pool-sub">{{ candidate.jobTitle || '待匹配岗位' }}</div>
<div class="pool-meta">
<span v-if="candidate.source && candidate.source !== '其他'">{{ candidate.source }}</span>
<span v-if="candidate.pooledByLabel || candidate.pooledBy"
>入池:{{ candidate.pooledByLabel || candidate.pooledBy }}</span
>
<span v-if="candidate.pooledAt">{{ formatPooledAt(candidate.pooledAt) }}</span>
</div>
</div>
</div>
<div class="pool-profile" :class="{ 'is-empty': !profileSummary(candidate) }">
{{ profileSummary(candidate) || '档案字段待补全' }}
</div>
<div v-if="candidate.tags && candidate.tags.length" class="pool-tags">
<el-tag
v-for="tag in candidate.tags"
:key="tag"
size="small"
:type="candidateTagTone(tag)"
effect="light"
>{{ tag }}</el-tag
>
</div>
<div class="pool-actions" @click.stop>
<el-button size="small" round plain @click="openDetail(candidate)">查看</el-button>
<el-button size="small" round type="primary" @click="claim(candidate)">认领</el-button>
</div>
</article>
</div>
<div v-else class="empty-state">
<strong>{{ store.poolCandidates.length ? '没有找到匹配的候选人' : '人才池还是空的' }}</strong>
<p v-if="store.poolCandidates.length">换个关键词或清除筛选条件试试。</p>
<p v-else>在简历库中把「面完未通过 / 暂缓 / 储备」的候选人移入人才池,公司共享总库就会在这里展示。</p>
<el-button v-if="!store.poolCandidates.length" round type="primary" @click="goResumes"
>去简历库移入候选人</el-button
>
</div>
</section>
<el-drawer v-model="detailOpen" direction="rtl" size="min(720px, 94vw)" :with-header="false">
<div v-if="selected" class="pool-detail">
<header class="detail-head">
<div>
<h3>{{ selected.name || '未命名候选人' }}</h3>
<p>{{ selected.jobTitle || '待匹配岗位' }}</p>
</div>
<div class="detail-head-side">
<div class="detail-head-meta">
<el-tag size="small" effect="plain">{{ selected.source || '其他' }}</el-tag>
<el-tag v-if="selected.pooledByLabel || selected.pooledBy" size="small" type="info" effect="plain">
{{ selected.pooledByLabel || selected.pooledBy }} 放入
</el-tag>
</div>
<el-button text circle aria-label="关闭" title="关闭" @click="closeDetail">
<el-icon><Close /></el-icon>
</el-button>
</div>
</header>
<div class="detail-grid">
<div class="detail-item">
<span>电话</span><b>{{ selected.phone || '—' }}</b>
</div>
<div class="detail-item">
<span>邮箱</span><b>{{ selected.email || '—' }}</b>
</div>
<div class="detail-item">
<span>年龄</span><b>{{ candidateAge(selected) || '—' }}</b>
</div>
<div class="detail-item">
<span>工作年限</span><b>{{ candidateYears(selected) || '—' }}</b>
</div>
<div class="detail-item">
<span>学历</span><b>{{ selected.education || '—' }}</b>
</div>
<div class="detail-item">
<span>学校</span><b>{{ selected.school || '—' }}</b>
</div>
<div class="detail-item">
<span>专业</span><b>{{ selected.major || '—' }}</b>
</div>
<div class="detail-item">
<span>入池时间</span><b>{{ formatPooledAt(selected.pooledAt) || '—' }}</b>
</div>
<div class="detail-item">
<span>入池备注</span><b>{{ selected.pooledNote || '—' }}</b>
</div>
</div>
<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">{{
tag
}}</el-tag>
</div>
<div class="detail-resume">
<div class="detail-resume-head">
<strong>简历内容</strong>
<el-link
v-if="selected.resumeFileDataUrl || selected.resumeMeta?.url"
type="primary"
:underline="false"
@click="openResume(selected)"
>
查看原简历 ↗
</el-link>
</div>
<pre>{{ selected.resumeText || '未保存可预览的简历正文。' }}</pre>
</div>
<footer class="detail-foot">
<span class="foot-hint">认领后候选人将进入你的工作区,可绑定岗位继续推进。</span>
<el-button type="primary" round @click="claim(selected)">认领到我的工作区</el-button>
</footer>
</div>
</el-drawer>
</div>
</template>
<style lang="scss" scoped>
.pool-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.metric-card {
display: flex;
align-items: center;
min-height: 92px;
padding: 16px 18px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: 0 8px 20px rgba(39, 49, 70, 0.06);
border-top: 3px solid var(--accent);
.metric-copy {
display: flex;
flex-direction: column;
span {
font-size: 13px;
color: var(--muted);
}
strong {
font-size: 28px;
line-height: 1.2;
}
small {
font-size: 12px;
color: var(--muted);
}
}
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: 0 8px 20px rgba(39, 49, 70, 0.06);
}
.pool-card {
padding: 18px;
}
.list-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
.section-kicker {
font-size: 12px;
letter-spacing: 1px;
color: var(--blue);
font-weight: 600;
}
h2 {
margin: 2px 0 4px;
font-size: 18px;
}
p {
margin: 0;
color: var(--muted);
font-size: 13px;
}
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
.filter-search {
width: 260px;
}
}
.pool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.pool-card-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
background: #fff;
border: 1px solid var(--line);
border-radius: 12px;
cursor: pointer;
transition:
box-shadow 0.2s,
transform 0.2s;
&:hover {
box-shadow: 0 10px 24px rgba(39, 49, 70, 0.1);
transform: translateY(-1px);
}
}
.pool-person {
display: flex;
gap: 10px;
}
.pool-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
background: linear-gradient(135deg, #5b8def, #4569c9);
}
.pool-copy {
min-width: 0;
flex: 1;
}
.pool-name-row {
display: flex;
align-items: center;
gap: 6px;
strong {
font-size: 15px;
}
}
.stage-chip {
font-size: 11px;
padding: 1px 6px;
border-radius: 999px;
background: rgba(69, 105, 201, 0.1);
color: #4569c9;
}
.pool-sub {
font-size: 13px;
color: #4569c9;
margin-top: 2px;
}
.pool-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
.pool-profile {
font-size: 12px;
color: var(--ink);
&.is-empty {
color: var(--muted);
}
}
.pool-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.pool-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: auto;
}
.empty-state {
padding: 40px 16px;
text-align: center;
strong {
font-size: 16px;
}
p {
color: var(--muted);
font-size: 13px;
margin: 6px 0 14px;
}
}
.pool-detail {
padding: 18px 20px 24px;
display: flex;
flex-direction: column;
gap: 14px;
height: 100%;
overflow: auto;
}
.detail-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
h3 {
margin: 0;
font-size: 20px;
}
p {
margin: 2px 0 0;
color: var(--muted);
}
.detail-head-side {
display: flex;
align-items: center;
gap: 6px;
}
.detail-head-meta {
display: flex;
gap: 6px;
}
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 18px;
.detail-item {
display: flex;
gap: 8px;
font-size: 13px;
border-bottom: 1px dashed var(--line);
padding: 4px 0;
span {
color: var(--muted);
min-width: 64px;
}
}
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.detail-resume {
background: #f6f8fc;
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px;
.detail-resume-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.6;
max-height: 40vh;
overflow: auto;
}
}
.detail-foot {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--line);
.foot-hint {
font-size: 12px;
color: var(--muted);
margin-right: auto;
}
}
</style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment