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 保留引用,不受影响)。
- 后续可做:岗位级共享、批量移入/认领、认领前简历去重比对。
# 招聘系统需求说明
> 本文档由《原文_招聘系统功能盘点与AI评估规则讨论.pdf》(20 页 HR 与研发访谈转写)整理而来,并结合当前系统实际能力标注了"现有/缺失"状态。格式:每项需求先给出 HR 原话要点,再给出需求描述与实现建议;标注「✅ 已有」「🔄 部分」「🆕 新增」。
> 本文档由《原文_招聘系统功能盘点与AI评估规则讨论.pdf》(20 页 HR 与研发访谈转写)整理而来,并结合当前系统实际能力标注了「现有/缺失」状态。格式:每项需求先给出 HR 原话要点,再给出需求描述与实现建议;标注「✅ 已有」「🔄 部分」「🆕 新增」。
>
> 依据代码现状核实:`backend/app/services/llm.py`(AI 评估/脱敏)、`backend/app/repositories/state_repository.py`(整包状态)、`vue-app/src/utils/normalize.js`(候选人/岗位字段)、`backend/app/models.py`(表结构)、`browser-extension/`(采集插件)。
> **最近一次对照代码核实:2026-09-02(main @ ec9499f,工作区另含同日未提交的「总库/人才池 + 候选人标签」实现)**。核实结论以代码为准,标注"已实现"的需求均可在源码中找到对应实现。
>
> 本次复核相对上一版(810e280)新增提交:`ddc5340`(候选人详情抽屉+档案编辑)、`07310dd`(简历库重构+保存原简历+防重复)、`6248f27`/`fc0aa5c`(简历解析真正 LLM 优先并移除脱敏)、`ec9499f`(弹窗蒙层/ESC 不关闭)。
>
> 依据代码现状核实:
> - 后端:`backend/app/models.py`(表结构,含 `ResumeFile/ResumeText/ResumeProfile/CandidateMatchReport/IngestItem`)、`backend/app/repositories/state_repository.py`(整包状态)、`backend/app/services/llm.py`(AI 评估/脱敏/JD 生成/简历字段提取)、`backend/app/services/resume_parser.py` + `resume_parser_core.py`(LLM 优先解析)、`backend/app/services/jd_grill.py`(逼问式访谈)、`backend/app/services/external_sync.py`(JD 同步)、`backend/app/repositories/ingest_repository.py`(采集入库)、`backend/app/routers/resume.py`(`/api/upload-resume` 存原简历 + `/api/resumes/{name}` 档案取回)、`backend/app/routers/*`
> - 前端:`vue-app/src/stores/recruitment.js`(下推联动/状态机/缓存)、`vue-app/src/utils/normalize.js`(候选人/岗位字段)、`jobFlow.js`(岗位流程)、`task.js`(候选人流程)、`matching.js`/`scoring.js`(匹配评分)、`report.js`(评估报告)、`vue-app/src/components/resumes/ResumeDetail.vue`(候选人抽屉+PDF 预览)、`ResumeList.vue`(简历库卡片列表+档案)、`CandidateCreateModal.vue`/`CandidateBatchModal.vue`(新建/批量入库+防重复)、`vue-app/src/components/jobs/JobDetail.vue`(岗位详情)、`vue-app/src/utils/jobStatus.js`(状态枚举)
> - 采集插件:`browser-extension/content.js`、`background.js`(Boss/猎聘)
---
......@@ -17,7 +24,9 @@ HR 原话要点:
- 「他所有的下推逻辑是要绑定的…我做完以后发现他有些板块不会同步,代办里面也不新增」(P19)——**下推联动是全局痛点**
- 开发方式:「基于你现在弄的那个系统,按模块(简历库/岗位管理)逐个完善」(P20)
**结论**:延续现有系统架构(整包状态 + 前后端分离),按模块迭代,优先补齐「下推联动」。
**结论**:延续现有系统架构(整包状态 + 前后端分离),按模块迭代。
**核实情况(2026-09,最新)**:「下推联动」痛点已基本解决——候选人和岗位的每个状态动作都会同时写事件日志、增删待办(见需求 20);企微智能体产出的 JD 可经 `/api/jd/sync-external` 自动落为岗位并置为「待确认」状态(见需求 2)。**候选人详情已于近期改为「右侧抽屉 + 可收缩 PDF 档案预览」**(提交 `ddc5340`/`07310dd`,见需求 10),HR「弹窗/页面级收敛、简历可收缩预览」的交互诉求已落地。**候选人标记/人才池体系已大部落地(需求 1 总库=人才池 + 需求 12 标签筛选 + 需求 22 人才池独立视图,2026-09-02 工作区补充)**,余下缺口为 AI 评估规则化的自述优势约束(需求 14)与风险固定维度(需求 17)、岗位维简历量统计(需求 22);另简历解析自 `fc0aa5c` 起改为**原文直传大模型提取字段(不再本地脱敏)**,采集插件仍不抓「沟通职位/期望薪资」。
---
......@@ -25,33 +34,33 @@ HR 原话要点:
**需求 1:多招聘账号 + 总库**
- 原话:「我们两个的账号要隔离开,他的候选人在他系统里,我的候选人在我系统里,但公司还会有一个总库」(P1)
- 现状:✅ 多用户登录 + 按用户隔离工作区已实现(`owner_id`),admin 可切换工作区。**「总库」尚未实现**——普通用户只能看自己工作区,没有"所有人可见的共享池"
- 建议(🔄):可选加"总库"概念:admin 可将候选人标记为共享(owner 置为特殊值或加 `shared` 标志),普通用户可见共享池;或保持现状,由 admin 作为总库管理员
- 现状:✅ 多用户登录 + 按用户隔离工作区已实现(`User` 表 + 全部业务表带 `owner_id`,`dependencies.resolve_owner` 按用户解析,admin 通过 `?owner=` 或 AppShell 顶部工作区下拉切换)。**「总库」已落地(2026-09-02 补充,对应实现即人才池)**:任一 HR 可把候选人「移入人才池」迁出个人工作区进公司共享池;任何登录用户可在左侧「人才池」页查看全公司池并「认领」到自己的工作区继续推。后端用保留工作区 `_pool` + 行级接口(`GET/POST /api/pool`)维护,池区禁止整包 `PUT /api/state` 覆盖(详见 docs/adr/0001-company-talent-pool.md)
- 建议(🔄):已落地(共享覆盖候选人池;岗位级共享未做)。可选增强:批量移入/认领、认领前与本人池比对、岗位共享
**需求 2:OA 同步岗位 + 创建岗位双入口**
- 原话:「OA 审批完同步过来,已是待发布状态;系统内也要保留手动创建岗位入口」(P2)
- 现状:✅ 手动创建岗位已有;OA 同步入口部分存在(`/api/jd/sync-external` 接收企微 QwenPaw JD 建岗)。审批流程明确「不在本系统做,走 OA」(P1: 审批回到 OA,此处不存在审批流程)
- 建议(🔄):明确 OA 同步字段映射(岗位名/部门/类型/招聘人数/目标),同步后状态为「待发布」
- 现状:✅ 手动创建岗位已有(JobEditView/`/jobs/new`)。✅ 外部同步已实现:`/api/jd/sync-external` 接收企微 QwenPaw JD,`sync_external_jd``external_id`/`title` upsert 岗位,同步后 `jdStatus=待确认``approvalStatus=企微智能体产出,待用人部门确认`,即"待发布"语义。审批流程明确「不在本系统做,走 OA」
- 建议(🔄):同步字段映射较基础(标题/部门/招聘人数/JD 各段),如需联动部门/岗位类型/目标可扩;核心链路已通
**需求 3:岗位类型细分**
- 原话:「岗位类型我还没细分;有研发、解决方案(售前)、销售、商务(两种定义)、产品(暂无)」(P2)
- 现状:🔄 无岗位类型枚举字段。
- 建议(🆕):岗位加 `jobType` 枚举(研发/解决方案/销售/商务-销售口径/商务-投标口径/产品/职能/技术),可配置。
- 现状:🔄 无岗位类型枚举字段,仅 `category` 自由文本(默认"默认岗位",placeholder"例如:技术岗、职能岗"),不参与筛选(代码中无 `jobType` 字段)
- 建议(🆕):岗位加 `jobType` 枚举(研发/解决方案/销售/商务-销售口径/商务-投标口径/产品/职能/技术),可配置,并进列表筛选
**需求 4:招聘人数联动自动关闭**
- 原话:「目标招 2 个,已录用 2 个,数值相等就自动已关闭;招 1 个就够也能手动关闭」(P2)
- 现状:🔄 岗位有 headcount/hired 字段,状态流转部分支持(录用入职后 candidate 改已入职)
- 建议(🆕):**hired ≥ headcount 时自动置岗位状态「已关闭」**(规则触发,非手动)
- 现状:🔄 `headcount`/`hired` 字段已有,且 `hired`**自动统计**:`store.reconcileRecruitmentLinks` 按"岗位下已入职候选人"实时回写 `job.hired`;`jobProgressSteps` 有"招聘完成"步骤(`hired>=headcount` 即 done)、`jobActionFor` 在缺口为 0 时提示「招聘目标已完成」;状态枚举含「已招满」「已关闭」。**但没有任何代码在 `hired>=headcount` 时自动把 `job.status` 置为「已关闭」**——目前仍需手动改状态
- 建议(🆕):规则触发:`hired >= headcount && headcount > 0` 时自动置 `status=已招满/已关闭`(可在 reconcile 里做,顺带写事件日志),同时保留手动关闭
**需求 5:JD 状态 + 版本历史自动迭代**
- 原话:「JD 状态保留(已生效等);版本号自动识别,确认生效后自动变 V2;改一个字也算小改也要记版本;可查历史版本(以前 JD 什么样、什么时候发布的)」(P2/P9)
- 现状:✅ JD 状态/版本/历史已有(`jdStatus/jdVersion/jdHistory`);「确认生效自动 +1 版本」为部分(需确认当前是否自动)。
- 建议(🔄):确认生效时自动生成新版本记录(日期、发布人),历史版本可查看/回溯;小改大改都记版本
- 现状:✅ 完整实现:`jdStatus/jdVersion/jdHistory` 三个字段 + `jd_versions` 表。版本自动递增:`store.nextJdVersion` 扫描当前版本与历史的最大 vN +1;"保存为新 JD 版本"/"迭代 JD"自动生成 vN 待确认;`approveJd` 确认生效时把版本后缀改为「当前生效」。每次生成/编辑/确认都会 `appendJdHistory` 记录版本、日期、发布人、备注,JobDetail 与 JobEdit 均可查看历史并可「复制为新草稿」。小改大改都记版本(编辑保存即记)。
- 建议(🔄):已达标,无需再动;可选的增强是历史版本"回溯比对"视图
**需求 6:JD 摘要 = 一句话概述**
- 原话:「JD 摘要类似一句话概述,按 JD 生成内容放进来,Markdown 格式要能自动去掉,不用手动改」(P2/P3)
- 现状:🔄 JD 摘要字段部分存在;Markdown 清理需确认
- 建议(🆕):JD 摘要字段自动从生成内容取一句话概述;展示时剥离 Markdown 符号
- 现状:🔄 `jd` 字段即扮演"一句话概述":LLM 生成 prompt 明确要求 `jd` 为 1-2 句话的岗位说明(`llm.py llm_jd_draft`),JobDetail 概览页把它作为摘要段落展示。**但未专门做"展示时剥离 Markdown 符号"**——若手工粘贴带 `#`/`**` 的内容会原样显示
- 建议(🆕):展示处对 `jd`/`responsibilities` 等字段做轻量 Markdown 清理(去 `#``*``-` 等符号),生成侧已基本是纯文本
---
......@@ -59,22 +68,22 @@ HR 原话要点:
**需求 7:岗位详情页收敛**
- 原话:「岗位管理二级页面展示岗位历史、迭代版本、目标人数、关联简历数、匹配规则」(P8)
- 现状:✅ 岗位详情/编辑页已有(JobDetailView/JobEditView),含 JD 版本历史、匹配规则
- 建议(🔄):补充「岗位关联简历数」展示(现有多是候选人列表,需统计)。
- 现状:✅ JobDetailView 已收敛:概览(岗位说明/JD/职责/要求/招聘目标/已入职/关联候选人数)、JD 流程(草稿→用人确认→审批→生效→候选人重评 5 步)、发布(渠道台账 + 版本差异)、候选人(该岗位关联候选人)、JD 版本历史;匹配规则(硬性/加分/淘汰/关键词)在编辑页维护
- 建议(🔄):已达标,关联简历数已展示(统计口径为"绑定到该岗位的候选人去重")。
**需求 8:匹配规则 = 对内 JD 硬性指标**
- 原话:「对内版本看硬性指标;对内的要求(如竞业)在生成 JD 评级标准时起作用,用于简历评级」(P3)
- 现状:✅ 对内/对外 JD 双版本已有(`_INTERNAL_ONLY_JOB_KEYS` 剥离敏感项);匹配规则字段已有(mustHave/niceToHave/knockout/matchKeywords)。
- 建议(🔄):把对内 JD 硬性指标直接作为候选人匹配规则的输入源,生成评级标准
- 现状:✅ 对内/对外 JD 双版本已有:`_INTERNAL_ONLY_JOB_KEYS`(`sensitive/genderRestriction/ageRestriction/internalFeasibility/verification/probationPeriod/probationCriteria/nonCompete`)在发往外部 LLM 或对外 JD 前被剥离;匹配规则字段 `mustHave/niceToHave/knockout/matchKeywords/competency` 直接作为候选人匹配的输入源(`job_criteria` → 本地匹配 `runLocalMatch` + LLM 评估 criteria)。
- 建议(🔄):已基本达标;可把对内硬性指标与"寻源画像"再显式化(见需求 15)
**需求 9:需求模板(有 JD 粘贴 / 无 JD 用模板 / AI 调研)**
- 原话:「模板两个用途:已有 JD 直接粘进去生成;没有 JD 用人部门要模板,把模板发他;还有 AI 调研场景」(P9/P11)
- 现状:✅ 逼问式访谈(AI 调研)已有;「粘贴 JD 生成」入口需确认
- 建议(🔄):补齐「粘贴完整 JD → 自动结构化」入口(可走 AI 解析)
- 现状:✅「逼问式访谈(AI 调研)」已实现:`jd_grill.py` 确定式状态机逐题采集(岗位定位/职责/要求/敏感/竞业等),新建岗位的「AI 辅助创建」直接进访谈;生成完自动补齐 JD 各段。🔄 **「粘贴完整 JD → 自动结构化」入口缺失**:JD 与匹配标准 Tab 是手填 textarea,没有"粘贴整段 JD,自动拆出职责/要求/硬性/加分/关键词"的入口
- 建议(🆕):加"粘贴 JD 自动结构化"按钮(走现有 LLM 结构化解析,复用 `generate-jd` 的 JSON schema),满足"有 JD 直接粘"场景
---
## 四、候选人弹窗页(重点,现有缺失)
## 四、候选人弹窗页(重点,近期已重构)
**需求 10:候选人详情 = 弹窗/页面级展示,收敛所有信息**(核心交互改造)
- 原话:
......@@ -82,23 +91,21 @@ HR 原话要点:
- 「把简历、概览、AI 评估都收敛在弹窗页,关掉好操作,不用一页拉很长」(P13)
- 「简历可收缩,点击预览;不点就显示已提取信息(像证书那样)」(P14)
- 「页面左侧是履历/PDF 渲染,右侧是基本信息概览;或做成同级页面可返回」(P14/P17)
- 现状:🔄 候选人有详情视图但非弹窗/抽屉式;信息分散。
- 建议(🆕):候选人详情改为**抽屉/弹窗式**布局:
- 顶部:姓名、关联岗位、来源渠道
- 概览:电话/邮箱/年龄/工作年限/学历/学校标签/专业/技能(来自解析)
- 摘要:一句话总结过往经历(来自 AI 或本地)
- AI 评分区(见需求 13-17)
- 操作栏:下推流程/面试记录/评价(可上传 PDF 面试记录,P18)
- 现状:✅ **已落地(提交 `ddc5340`/`07310dd`/`ec9499f`)**。候选人详情已改为**右侧抽屉**(`ResumeDetail.vue``<el-drawer direction="rtl">`,宽 `min(1180px,96vw)`),由简历库卡片「查看档案」/点击唤起(`store.resumeViewMode='detail'`,无独立路由),抽屉内支持左右箭头/按钮在「上一个/下一个候选人」间切换:
- **双栏布局(即 HR 所描述的左履历右概览)**:左侧为可收缩的**原始简历档案栏**(默认收起,展开后以 iframe 渲染原简历 PDF/文本——经 `/api/resumes/{storedName}` 鉴权拉取 blob 预览,提供下载/新标签打开;非 PDF/无文件时回退展示已提取的 `resumeText`);右侧承载 概览(基础信息+档案编辑)/评估(AI 报告+快筛)/面试(多轮面评)/简历文本 四个 Tab,顶部另有评分环+初筛建议+当前流程节点。
- **档案编辑内嵌**:概览页可直接修正 姓名/电话/年龄/学历/学校/专业/技能 等字段,保存后自动重算快筛,无需另开页面。
- 关闭交互:按头部「返回/关闭」按钮关闭;**点击蒙层或按 ESC 均不关闭**(提交 `ec9499f` 明确改此,防误关)。
- 建议(🔄):基本达标,已覆盖"基本信息、匹配评估、当前流程、面评"四项且 PDF 可收缩;可选增强:抽屉宽度自适应、PDF 内文字高亮定位(pdf.js 嵌入)、关闭快捷键与按钮文案统一。
**需求 11:候选人与岗位「重新匹配」**
- 原话:「插件抓的岗位是候选人在平台选的期望岗位,实际匹配要用我们的沟通职位;可能抓错,要能改匹配岗位重新匹配」(P12)
- 现状:🔄 candidate 有 jobId/jobTitle,可改;「改岗位后重新匹配」需确认
- 建议(🆕):候选人详情提供「更改匹配岗位」操作,改后触发重新匹配评分
- 现状:✅ 简历库卡片「求职岗位」列为岗位下拉,改选后 `bindJob` 立即回写 `jobId/jobTitle` 并自动 `localMatch` 重新快筛;另有「批量重新匹配」可整批刷新(完成后显示 完成/缺少正文/未绑岗位/执行失败 汇总);详情抽屉「快速匹配/AI 评估」也可重跑。改岗后 AI 评估会因 `analysisInputKey` 变化而提示"需重评"(生成时若已有有效报告会提示复用)
- 建议(🔄):已达标;可选增强:改岗后自动提示并一键重生成 AI 评估(当前需手动点)
**需求 12:候选人标记(重点/待跟进)**
- 原话:「通过一面待二面的人重点标记;很匹配但难找的人标记重点关注;未来每周看提醒沟通维护;标记人工选,AI 不做」(P12)
- 现状:🔄 候选人有 tag 字段(如"新入库"),无结构化标记体系
- 建议(🆕):加候选人标记枚举(重点跟进/待二面/需维护/已淘汰等),列表可筛选;可选加"本周待跟进"提醒视图
- 现状:**结构化标签已落地(2026-09-02 补充)**:候选人带 `tags` 数组(枚举:新入库/重点跟进/待二面/待沟通/需维护/储备/已淘汰,详情概览「编辑」内多选+可自输,AI 不打标);简历库与人才池列表均支持按标签筛选(含「待跟进 = 重点跟进/待二面/待沟通/需维护」快捷项),旧版单 `tag` 字符串自动映射进 `tags`(`LEGACY_TAG_MAP`)。「移入人才池」不再用 tag 模拟,改走真正的公司总库。**残余**:独立「每周待跟进」周报视图未做(现为列表快捷筛选 + 人才池「待跟进」统计卡)
- 建议(🔄):枚举/编辑/筛选已落地;如需"每周待跟进"独立视图,可基于 `tags ∩ 待跟进集` + `updatedAt` 在 Dashboard 加提醒卡
---
......@@ -109,35 +116,39 @@ HR 原话要点:
- 「AI 每次评的都不一样;保存后点 AI 评估,过一会儿再点又是一个报告,到底以哪个为准?」(P5)
- 「所以 AI 评估需要写一套规则,这是代办」(P6)
- 「AI 说这个电力家园的人没家园经验还让问山东现货,就是没写规则」(P13)
- 现状:🔄 有 `local-structured` 本地规则回退 + LLM 评估;LLM 评估本身不稳定。
- 建议(🆕):**AI 评估改为"规则约束 + LLM 填充"**:
1. 固定评估维度(见需求 15-17),输出结构化为固定 JSON schema;
2. 评估依据限定为「对内 JD 硬性指标 + 候选人简历事实」,禁止自由发挥;
3. 同一候选人重复评估结果应基本一致(缓存或确定性提示词);
4. 每个维度给"证据引用"(来自简历原文)。
- 现状:✅ **已大幅落地**(本项是访谈后改造力度最大的点):
1. 固定评估维度 + 固定 JSON schema(`score/decision/summary/hardMatch/experienceMatch/projectEvidence/competencyFrame/risks/interviewQuestions/nextAction/scoreBreakdown`),后端 `_chat_json` 强制 `response_format=json_object``temperature=0`,报告带 `reportVersion="ai-evaluation-v2"`;
2. 评估输入限定为「对内 JD 硬性指标 + 候选人简历事实」,候选人经 `sanitize_candidate_for_llm` 脱敏(名字=候选人、电话/邮箱/年龄/城市/简历文件名剥离,简历正文再 `sanitize_resume_for_llm` 掩码手机/身份证等),LLM 不能自由发挥;
3. **结果稳定性机制**:`store.analyzeResume``analysisInputKey`(简历+岗位规则哈希)做缓存——输入未变则直接复用已有报告(抽屉内重新生成会提示"已有有效报告,正在复用"),不再每次重跑,直接回应"以哪个为准";
4. 每个维度带"证据引用"(`evidence`/`projectEvidence` 取自简历原文段落,`pick_evidence`);
5. 无 LLM Key 或调用失败时回退到**确定性的本地规则**(`local_resume_analysis`,provider=`local-structured`),同一输入结果完全一致。
- 残余缺口:LLM 报告在输入变化重新生成时,语义表述仍可能有轻微差异(无 diff 冻结);本地规则只做关键词命中,不做语义。
- 建议(🔄):已达标;可选增强:对 LLM 输出做二次"规则化规整"(如强制 `scoreBreakdown` 五维加总 = score),或把已生效报告快照存库(`candidate_match_reports` 表已就绪)防误覆盖。
**需求 14:AI 不得引用候选人自述优势**
- 原话:「不能参考候选人自己写的核心竞争力;简历里大家都写自己优势,要加约束规则」(P16)
- 建议(🆕):提示词硬规则:候选人自我评价/优势描述不作为评估依据,只作为待验证线索。
- 建议(🆕):**未实现**。复核 `llm_resume_analysis`(评估)与 `llm_resume_fields`(解析)的 system/user prompt,均未显式声明"候选人自述优势/核心竞争力/自我评价 不作为匹配依据";脱敏简历正文 `resumeText`(含自我评价段落)原样进 LLM,存在被"核心竞争力"段落带偏的风险。需在两条链路的 prompt 中加入硬规则(如"候选人自述优势/主观表述不作为匹配依据,只作为面试待验证线索"),本地规则天然不读自述,但评估侧要防 LLM 被带偏。
- 建议实施点(一行即可生效):`llm_resume_analysis``llm_resume_fields` 的 system prompt 追加该约束,评估权重以岗位硬性指标 + 简历事实(项目/经历数据)为准。
**需求 15:匹配分析 = 结合对内 JD 找"哪里匹配"**
- 原话:「匹配分析结合对内版 JD 分析,给我找人做参考(如找 SAP/金蝶/用友做 ERP 大客户销售的人)」(P16)
- 现状:✅ 匹配分析已有(matchKeywords/mustHave 等本地规则 + AI)。
- 建议(🆕):匹配分析输出"候选人符合哪些对内硬性指标"及"可去找哪类公司背景的人",直接作为寻源参考。
- 现状:✅ `hardMatch` 明确输出"候选人符合哪些对内硬性指标/关键词"(逐条引用简历原文),`missing`/`mustMissing` 输出缺口;LLM 输入即对内规则与脱敏简历。**"可去找哪类公司背景的人"的寻源建议不显式生成**(只是匹配命中隐含)。
- 建议(🔄):可选增强:评估报告加一段"可寻源画像"(行业/公司/角色关键词),直接作为找人参考。
**需求 16:核心亮点(能力拆解 + 匹配证据)**
- 原话:「能力拆解可以做匹配;候选人的匹配证据属于他简历里的事实;项目价值这块可以放弃,写得好不一定实际」(P16)
- 建议(🆕):核心亮点 = 候选人简历中的客观事实拆解(项目/职责/成就),标注证据;**不做"项目价值"评估**(HR 明确放弃)。
- 现状:✅ `projectEvidence`(简历原文证据片段)+ `competencyFrame`(胜任力拆解,仅做面试观察点,不评分)+ `strengths`(命中关键词)。**系统不评估"项目价值"**——符合 HR 明确放弃该项。
- 建议(🔄):已达标。
**需求 17:风险点评估(固定几个维度,不泛)**
- 原话:「风险点不要太泛,只评估几个点:跳槽频率、竞业协议(标准竞对高管需提示人力确认)、客户名单、中间 gap(如 2020-2024 四年 gap 要提示)、高层职业道德」(P16/P17)
- 现状:🔄 offer 有 risk 字段;候选人无结构化风险点
- 建议(🆕):候选人风险点固定维度:跳槽频率、竞业协议风险、职业空窗(gap)、职业道德提示(高层);每项输出"提示 + 依据",由 HR 确认
- 现状:🆕 **未实现为固定维度**。复核 `llm_resume_analysis` 与本地规则 `local_resume_analysis`:`risks` 仍只是"命中淘汰项 / 硬性缺口 / 缺失关键词"的文本列表(本地规则 `risks` 仅由 `knockout_hit`/`must_missing`/`missing` 拼出,`riskAdjustment` 只按是否触发淘汰/硬缺口给 -10/-5),不结构化识别跳槽频率、竞业、空窗 gap、职业道德;`offer.risk` 也只是手工备注
- 建议(🆕):候选人风险点固定维度:跳槽频率(每段任职时长推算)、竞业协议(对内 JD `nonCompete` 是否命中)、职业空窗(gap≥N 年提示)、职业道德提示(高层专属);每项输出「提示 + 依据(简历时间线)」,由 HR 确认。规则可本地算,LLM 只做补充
**需求 18:快筛评分(本地基础评分)**
- 原话:「快筛评分本地做,用基本要求/硬性要求评一下,先看一眼;一般到这边的都是看过一遍的」(P6)
- 现状:✅ 快筛评分已有(local-structured,本地关键词匹配,省 token)
- 建议(🔄):保持本地规则,可作为"是否进入 AI 评估"的前置门槛
- 现状:✅ 快筛评分已实现(`quickScore`/`quickMatchScore`,浏览器本地关键词匹配,省 token),简历库"高匹配"卡按快筛≥85 统计,卡片展示分数,可批量重匹配,可作为"是否进入 AI 评估"的前置门槛
- 建议(🔄):保持现状;可选加"快筛达标才推荐生成 AI 报告"的门槛提示
---
......@@ -145,18 +156,26 @@ HR 原话要点:
**需求 19:面试记录(多轮 + 面试官评价)**
- 原话:「一面、二面…每个面试不同面试官反馈都能记在上面;面评要有具体内容和时间;下一轮动作是淘汰还是继续二面三面;面试形式(线上/线下)」(P6/P7)
- 现状:🔄 interview 表存在(round/scheduled_at/interviewer/result/feedback),前端面试记录 UI 需确认
- 建议(🔄):候选人弹窗内提供多轮面试记录,字段:轮次/时间/面试官/形式(线上/线下)/评价内容/结果(通过/淘汰/待定)
- 现状:✅ 候选人详情「面试」Tab 已实现:轮次(一面/二面)、面试官、时间、形式(线上/线下)、结果(待反馈/通过/不通过)、下一步(继续面试/推进 Offer)、评价内容;保存后按时间倒序展示(最多 8 条),并联动更新候选人阶段(不通过→初筛未通过,通过/推进 Offer→Offer 中)。后端 `interviews` 表独立落库
- 建议(🔄):已达标;可把"下一步"扩展为显式的淘汰/继续二面/三面选项
**需求 20:下推流程 = 操作记录 + 状态联动**
- 原话:「整个下推流程叫操作记录;现在什么状态、下一步怎么操作,放操作栏;状态变了代办要同步新增」(P18/P19)
- 现状:🔄 候选人 stage 流转 + 事件日志 + 待办生成部分联动(如 Offer 已发送自动生成跟进待办)。
- 建议(🆕):**候选人操作栏**:当前状态 + 下一步操作(下推/淘汰/安排面试),操作后自动:更新 stage、写事件日志、同步生成/完成相关待办——重点保证"下推联动"不遗漏。
- 现状:✅ **联动已系统性落地**:
- 每个动作写事件日志(`eventLog`,含 `candidate_created/stage_changed/ai_evaluation_generated/interview_feedback_saved/offer_created/onboarded`),Dashboard 日历/时间线可查;
- 候选人推进面试 → 自动建「安排面试并收集面试反馈」待办并完成初筛/AI 待办;
- 面试通过 → 自动建 Offer 记录并完成面评待办;
- Offer 发送 → 自动建「跟进入职材料」待办、完成审批/材料待办;
- 确认入职 → 完成入职/材料/背调待办;
- JD 生效 → 建「跟进 JD 审批结果」;迭代 JD → 建「按新 JD 重评候选人」。
- 候选人详情抽屉操作栏:快速匹配 / 生成 AI 评估 / 推进面试 / 移入人才池,下一节点提示来自 `candidateWorkflow` 规则。
- 人才池动作联动(2026-09-02 补充):「移入人才池」记 `pool_moved` 事件、把候选人迁入公司总库并结束其关联待办与 Offer;「认领/复活」记 `candidate_claimed` 事件并重置为「未筛选」重新走流程(原「移入人才池=改 tag+阶段」的本地 hack 已移除)。
- 建议(🔄):已达标。残余:部分人工环节(如"面评催办")待办完成后不会自动生成下一轮(如需二面重排,靠 HR 手动);可做"面试不通过/继续二面"的自动分支待办。
**需求 21:面试评价表 + 入职材料三件套**
- 原话:「入职材料三个:候选人个人信息表、用人部门面试评价表(需下推给用人部门确认)、录用通知 offer 表」(P7/P8)
- 现状:✅ offer 审批材料(approvalMaterials)已有
- 建议(🔄):材料模板化:三张表模板(候选人信息/部门评价/录用通知),系统预填候选人/部门/岗位等已知字段,HR 补填后导出/下推
- 现状:🔄 已有「Offer 审批材料」(`approvalMaterials`:候选人/岗位/薪资/入职日期/工作地点/合同类型,自动预填已知字段,缺项标红阻断)+ 邮件草稿(`mailDraft`)生成。**但只有"录用通知"一张表**,候选人个人信息表、用人部门面试评价表未做成独立模板,也没有"下推给用人部门确认"的反馈回路(JD 的部门确认走的是 JD 审批,面试评价表未下推)
- 建议(🔄):三表模板化:候选人信息表(自动预填解析字段)/部门面试评价表(下推反馈收集)/录用通知;模板字段预填后导出或内嵌展示
---
......@@ -164,8 +183,8 @@ HR 原话要点:
**需求 22:简历库 = 简历存储 + 标签 + 统计**
- 原话:「简历库涵盖所有简历,几十上百份可打标签;要看总量、通过面试人数、各岗位简历量(如 java 岗多少份、人力岗多少份);面完没过的放人才池」(P19)
- 现状:✅ 简历库/候选人列表已有(candidates 表 + resume 关联),有来源/阶段筛选
- 建议(🆕):简历库增强:标签筛选(复用需求 12 标记)、岗位维简历量统计、人才池视图(未通过/待定)
- 现状:🔄 近期重构(提交 `07310dd`):简历库为**卡片网格列表**(头像/姓名/阶段/来源/快筛与 AI 分/求职岗位下拉/「查看档案·快速匹配·更多」操作),顶部 4 张卡(简历总数 / 高匹配=快筛≥85 / 待 AI 评估 / 待补全),筛选为 关键词/阶段/来源/匹配档位——**仍无标签筛选**;漏斗/来源/趋势/ROI 看板在**独立 `/dashboard` 路由**(可按岗位范围过滤),但**仍无「各岗位简历量」统计****新增「保存原简历 + 档案预览/防重复」**:后端 `/api/upload-resume` 把原文件落库(`ResumeFile` 表含 `file_hash` 唯一字段,原始文本存 `ResumeText`,结构化档案存 `ResumeProfile`),候选人带 `resumeFileDataUrl`(`/api/resumes/{name}`)与 `resumeMeta`,详情抽屉左栏可预览/下载;**批量入库增加前端防重复**(按 简历文件名+清洗后姓名 跳过并计数,后端 `IngestItem` 仍以唯一 `idempotency_key` 兜底)。**人才池(总库)独立视图已实现(2026-09-02 补充)**:左侧「人才池」页 = 公司共享总库(跨账号),卡片列表 + 关键词/标签/来源筛选 + 统计卡(总库人数/本周新入池/待跟进)+ 详情抽屉(档案/简历正文/原简历链接)+「认领到我的工作区」;简历库卡片与详情抽屉均可一键「移入人才池」。**残余**:岗位维简历量统计、批量移入/复活仍未做
- 建议(🔄):标签筛选与人才池独立视图已落地;待做:岗位维简历量统计(表格或看板)、批量放入/复活、后端按 `file_hash` 库级去重
---
......@@ -176,45 +195,49 @@ HR 原话要点:
- 「狗抓的岗位不准,抓的是平台期望岗位,要改成匹配我们的沟通职位」(P12)
- 「狗抓到名字是错的(余德宝 888 抓成童童真),但 PDF 抓得准」(P12)
- 「期望薪资没抓」(P14)
- 现状:✅ 插件采集已有(BOSS/猎聘,PDF URL + 摘要),幂等去重
- 建议(🆕):插件修正:抓「沟通职位」而非平台期望岗位;修正姓名/字段解析;补抓期望薪资;保留 PDF 下载准确性
- 现状:🔄 插件(Boss/猎聘)采集(本核对周期内 `browser-extension/` 无新提交):优先抓 `pdfUrl + cookies`(后端下载 PDF 并 LLM 优先解析,名字/字段准);抓不到 PDF 时回退 `name + summary`(面板文本);幂等去重(`lastSentHash` + 后端 `idempotency_key`)。**缺口**:① 插件 payload 不含平台"沟通职位/求职岗位"字段(`jobTitle` 为空),岗位匹配依赖 HR 在系统内改绑;② 不抓期望薪资(`raw.salary` 从未被发送,仅 ingest 入库时把 `期望薪资:{salary}` 写进 `evaluation` 备注);③ 无 PDF 时 name 启发式(`looksLikePersonName`)仍可能抓错。系统侧另在批量入库加了前端防重复(按文件名+姓名,见需求 22)
- 建议(🆕):插件修正:抓取平台页面上的"沟通职位"文本一并上传;尝试抓期望薪资字段(如实名/薪资区块);PDF 优先策略保持;无 PDF 时把名字/正文解析交给后端 LLM 修正(现有 ingest 已在 PDF 路径做了)
---
## 九、其他与交互细节
- **需求 24:候选人概览抓取准确度**:本地字段抽取(姓名/学校/专业/工作年限)有错抓(名字抓成工作经历),需改进解析(P13)。
- **需求 25:期望薪资展示**:候选人详情展示期望薪资(插件未抓,人工可补)(P14)。
- **需求 26:UI 风格**:HR 偏好「玻璃拟态 + 蓝色系 + 有色彩」的偏 fast 风格界面,会提供风格截图(P13)
- **需求 27:伯乐帮对照**:外部 AI 工具(伯乐帮)只做"履历合格情况"参考;本系统做完整档案与评估,两者分工明确(P8)。
- **需求 24:候选人概览抓取准确度**:**解析链路已重构为「LLM 优先、纯正则兜底」,且不再对 PII 本地脱敏**(提交 `6248f27`/`fc0aa5c`)。`parse_resume_path_async` 读取简历文本后把**原文(含姓名/手机/邮箱等 PII)直接交给 `llm_resume_fields`** 提取全部字段(`LLM_RESUME_FIELDS`:name/jobTitle/phone/email/birthDate/age/years/education/school/major/city/skills);仅当大模型不可用或返回全空(`_has_llm_content`)时**整体回退** `parse_resume_text` 纯正则。提交 `fc0aa5c` 移除了此前"本地脱敏给 LLM、PII 字段以正则结果为准"的双轨合并(`_merge_parse_result` 已不存在),现在结构化字段**直接采用 LLM 返回值**(`_compose_llm_result`,schoolTags 按学校本地打标),靠 prompt「只提取明确出现、绝不编造、没有返回空」约束。**注意取舍**:解析路径现会把候选人 PII 明文发送给大模型提供方(代码注释已注明"如需脱敏请走 analyze_resume 链路");AI **综合评估**路径仍走 `sanitize_candidate_for_llm` 脱敏。此前修复的"换行粘连/专业误判/名字抓成工作经历"等仍在(对应单测 `test_resume_parser_llm.py` 随新链路更新)。
- **需求 25:期望薪资展示**:🆕 无独立 `expectedSalary` 字段/展示(全库无此字段,候选人卡片/抽屉/新建表单均无薪资输入)。仅 ingest 入库时把 `raw.salary` 写进 `evaluation` 备注(`期望薪资:{salary}`),插件实际不传。建议候选人详情加"期望薪资"字段(人工可补,插件抓不到)。
- **需求 26:UI 风格**:🔄 蓝色系多色 + **已引入玻璃拟态元素**:顶部栏(`main.scss .top`)已用半透明 `rgba(255,255,255,0.86)` + `backdrop-filter: blur(14px) saturate(1.2)` 毛玻璃(sticky 顶栏,提交 `ddc5340` 一并改入);卡片/抽屉主体仍为白底实心,与 HR 整体"玻璃拟态"偏好尚有差距。待 HR 提供风格截图后对齐
- **需求 27:伯乐帮对照**:未集成,也无相关代码。本系统做完整档案与评估;如需对照可后续评估对接(外部工具只做履历合格参考)。
---
## 十、优先级建议(按访谈强调程度)
| 优先级 | 需求 | 理由 |
| --- | --- | --- |
| P0 | 需求 13 AI 评估规则化(稳定可复现) | HR 反复强调,当前最大痛点 |
| P0 | 需求 20 下推流程联动 | 全局联动是流程跑通的关键 |
| P1 | 需求 10 候选人弹窗页收敛 | 核心交互,信息分散的根治 |
| P1 | 需求 12 候选人标记 | 日常跟进刚需 |
| P1 | 需求 17 风险点评估(固定维度) | 招聘风控刚需 |
| P2 | 需求 15/16 匹配分析/核心亮点 | 寻源参考,锦上添花 |
| P2 | 需求 4/5 岗位自动关闭/JD 版本 | 减少手工维护 |
| P2 | 需求 22 简历库标签/统计 | 简历量上来后刚需 |
| P3 | 需求 23 插件字段修正 | 采集质量优化 |
| P3 | 需求 21 入职材料模板 | 流程完整性 |
## 十、优先级建议(结合访谈强调程度与当前实现缺口)
| 优先级 | 需求 | 状态 | 说明 |
| --- | --- | --- | --- |
| P0 | 需求 13 AI 评估规则化(稳定可复现) | ✅ 已大幅落地 | inputKey 缓存 + 固定 schema + temp=0 + 本地回退;残余为 LLM 重生成时表述波动 |
| P0 | 需求 20 下推流程联动 | ✅ 已落地 | 事件日志 + 待办自动增删已贯穿候选人/JD/Offer;人才池移入/认领联动已补 |
| P0 | 需求 14 AI 不得引用自述优势 | 🆕 未实现 | 两条 prompt 加一行硬规则即可,直接影响评估可信度 |
| P1 | 需求 17 风险点固定维度 | 🆕 未实现 | 跳槽/竞业/gap/职业道德四项规则,风控刚需 |
| P1 | 需求 12 候选人标记 | 🔄 基本落地 | tags 枚举 + 详情编辑 + 简历库/人才池标签筛选 + 待跟进快捷筛选;独立周报视图待做 |
| P2 | 需求 4 岗位自动关闭 | 🔄 部分 | reconcile 里加一行规则触发即可 |
| P2 | 需求 9 粘贴 JD 自动结构化 | 🔄 缺入口 | 复用现有 LLM 解析加一个按钮 |
| P2 | 需求 22 简历库标签/统计 | 🔄 部分 | 人才池独立视图 + 标签筛选已落地;岗位维简历量统计与批量移入待做 |
| P3 | 需求 23 插件字段修正 | 🔄 部分 | 补抓沟通职位/期望薪资 |
| P3 | 需求 21 入职材料三件套 | 🔄 部分 | 三张模板化 |
| P3 | 需求 3 岗位类型枚举 / 需求 25 期望薪资展示 | 🆕 | 小改动,按需做 |
| P3 | 需求 26 UI 玻璃拟态 | 🔄 部分 | 顶栏已毛玻璃,整体待 HR 截图后统一 |
**近期已补(2026-09,不再列入待办)**:需求 10 候选人详情抽屉 + PDF 档案预览(`ddc5340`/`07310dd`/`ec9499f`);需求 24 简历解析改 LLM 优先、`fc0aa5c` 移除解析链路脱敏(评估链路仍脱敏)。**同日(工作区未提交)再补**:需求 1「总库=人才池」(跨账号共享池 + 移入/认领)、需求 12 标签结构化与筛选、需求 20 人才池联动、需求 22 人才池独立视图——详见各需求条目与 docs/adr/0001-company-talent-pool.md。
---
## 附:访谈中的明确"不做/暂缓"事项
- **审批流程不做**:岗位审批走 OA,本系统不实现审批流(P1)
- **项目价值评估不做**:AI 不评估"项目价值"(P16)
- **腾讯会议总结自动同步不做**:面试总结手动复制粘贴(会议联动太复杂)(P7)
- **智能体对话查候选人信息不做**:「问智能体候选人住哪里」这类对话暂不支持,HR 自己记录(P19)
- **面试记录 PDF 上传**:可做可不做,保留文本记录即可(P18)
- **审批流程不做**:岗位审批走 OA,本系统不实现审批流(P1)——JD 审批在系统内为简化版(确认/提交/生效,无 OA 对接),符合此条。
- **项目价值评估不做**:AI 不评估"项目价值"(P16)——已实现,`projectEvidence` 只列证据不评价价值。
- **腾讯会议总结自动同步不做**:面试总结手动复制粘贴(会议联动太复杂)(P7)
- **智能体对话查候选人信息不做**:「问智能体候选人住哪里」这类对话暂不支持,HR 自己记录(P19)
- **面试记录 PDF 上传**:可做可不做,保留文本记录即可(P18)
---
*整理自 20 页访谈记录,并结合当前代码现状标注;建议按 P0 → P3 顺序逐模块开发。*
*整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。建议优先补齐缺口项:AI 自述优势约束(需求 14)、风险固定维度(需求 17)、候选人标记/标签筛选与人才池视图(需求 12/22)。*
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