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 ...@@ -10,7 +10,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from backend.app.config import STATIC_ROOT, get_settings from backend.app.config import STATIC_ROOT, get_settings
from backend.app.db import SessionLocal, create_all from backend.app.db import SessionLocal, create_all
from backend.app.models import User, utc_now_iso 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.security import DeploymentAuthMiddleware
from backend.app.services.auth_security import hash_password from backend.app.services.auth_security import hash_password
...@@ -65,6 +65,7 @@ def create_app() -> FastAPI: ...@@ -65,6 +65,7 @@ def create_app() -> FastAPI:
app.include_router(resume.router, prefix="/api") app.include_router(resume.router, prefix="/api")
app.include_router(ai.router, prefix="/api") app.include_router(ai.router, prefix="/api")
app.include_router(ingest.router, prefix="/api") app.include_router(ingest.router, prefix="/api")
app.include_router(pool.router, prefix="/api")
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
async def homepage() -> RedirectResponse: 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 ...@@ -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 "")) password = _validate_password(str(payload.get("password") or ""))
if not username: if not username:
raise HTTPException(status_code=400, detail="用户名不能为空") raise HTTPException(status_code=400, detail="用户名不能为空")
if username in ("local", "_pool"):
raise HTTPException(status_code=400, detail="该用户名为系统保留,请更换")
if role not in ("admin", "user"): if role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="角色只能是 admin 或 user") raise HTTPException(status_code=400, detail="角色只能是 admin 或 user")
if db.scalar(select(User).where(User.username == username)): 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 ...@@ -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: 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 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 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 typing import Any
from fastapi import APIRouter, Depends, Response from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.app.db import get_db from backend.app.db import get_db
from backend.app.dependencies import CurrentUser, get_current_user, resolve_owner from backend.app.dependencies import CurrentUser, get_current_user, resolve_owner
from backend.app.repositories.json_utils import pretty_json 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 from backend.app.repositories.state_repository import database_is_empty, read_state, replace_state
router = APIRouter() router = APIRouter()
...@@ -28,6 +29,8 @@ def put_state( ...@@ -28,6 +29,8 @@ def put_state(
_: CurrentUser = Depends(get_current_user), _: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner), owner: str = Depends(resolve_owner),
) -> dict[str, Any]: ) -> dict[str, Any]:
if owner == POOL_OWNER:
raise HTTPException(status_code=400, detail='总库(人才池)请通过人才池接口维护,禁止整包覆盖')
state = replace_state(db, owner, payload, "api-state") state = replace_state(db, owner, payload, "api-state")
return {"ok": True, "state": state, "updatedAt": state["updatedAt"], "owner": owner} return {"ok": True, "state": state, "updatedAt": state["updatedAt"], "owner": owner}
...@@ -39,6 +42,8 @@ def import_local_state( ...@@ -39,6 +42,8 @@ def import_local_state(
_: CurrentUser = Depends(get_current_user), _: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner), owner: str = Depends(resolve_owner),
) -> dict[str, Any]: ) -> dict[str, Any]:
if owner == POOL_OWNER:
raise HTTPException(status_code=400, detail='总库(人才池)请通过人才池接口维护,禁止整包覆盖')
state = replace_state(db, owner, payload, "localStorage-import") state = replace_state(db, owner, payload, "localStorage-import")
return {"ok": True, "imported": True, "state": state, "updatedAt": state["updatedAt"], "owner": owner} return {"ok": True, "imported": True, "state": state, "updatedAt": state["updatedAt"], "owner": owner}
...@@ -56,3 +61,5 @@ def export_state( ...@@ -56,3 +61,5 @@ def export_state(
media_type="application/json; charset=utf-8", media_type="application/json; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}, headers={"Content-Disposition": f'attachment; filename="{filename}"'},
) )
...@@ -250,7 +250,7 @@ async def llm_resume_fields(text: str) -> dict[str, Any] | None: ...@@ -250,7 +250,7 @@ async def llm_resume_fields(text: str) -> dict[str, Any] | None:
user = ( user = (
"请从下面的简历文本中提取结构化字段,只返回一个 JSON 对象(不要 Markdown 围栏,不要多余解释)。\n\n" "请从下面的简历文本中提取结构化字段,只返回一个 JSON 对象(不要 Markdown 围栏,不要多余解释)。\n\n"
"字段与规则:\n" "字段与规则:\n"
'- name: 姓名(2-4 个中文字符,如"薛庆霞");没有则返回 ""。\n' '- name: 姓名(2-4 个中文字符,如"张三");没有则返回 ""。\n'
'- jobTitle: 求职意向/应聘岗位,只能是简历中明确写出的岗位名(如"销售支持专员");没有明确写出时返回 "",' '- jobTitle: 求职意向/应聘岗位,只能是简历中明确写出的岗位名(如"销售支持专员");没有明确写出时返回 "",'
"不要根据教育/技能内容推断岗位。\n" "不要根据教育/技能内容推断岗位。\n"
'- phone: 手机号(11 位,可含 +86/空格/连字符,如 13812345678);没有则返回 ""。\n' '- phone: 手机号(11 位,可含 +86/空格/连字符,如 13812345678);没有则返回 ""。\n'
...@@ -262,7 +262,7 @@ async def llm_resume_fields(text: str) -> dict[str, Any] | None: ...@@ -262,7 +262,7 @@ async def llm_resume_fields(text: str) -> dict[str, Any] | None:
'- school: 毕业院校全称(如"甘肃农业大学");没有则返回 ""。\n' '- school: 毕业院校全称(如"甘肃农业大学");没有则返回 ""。\n'
'- major: 专业名称(如"农林经济管理")。主修课程列表里的课程名不是专业,应取"专业"字段或紧邻学校/时间行后的' '- major: 专业名称(如"农林经济管理")。主修课程列表里的课程名不是专业,应取"专业"字段或紧邻学校/时间行后的'
'独立专业名;没有明确专业返回 ""。\n' '独立专业名;没有明确专业返回 ""。\n'
'- city: 现居城市或简历中的城市名(如"太原",不含"市");没有则返回 ""。\n' '- city: 现居城市或简历中的城市名(如"太原");没有则返回 ""。\n'
"- skills: 技能数组,最多 12 项,只取简历中明确提到的技能。\n\n" "- skills: 技能数组,最多 12 项,只取简历中明确提到的技能。\n\n"
"要求:只提取简历中明确出现的信息,绝不编造或猜测。\n\n" "要求:只提取简历中明确出现的信息,绝不编造或猜测。\n\n"
f"简历文本:\n{(text or '')[:16000]}" f"简历文本:\n{(text or '')[:16000]}"
......
import os
os.environ.setdefault("RECRUITMENT_SKIP_ENV_FILES", "1")
import pytest import pytest
from fastapi.testclient import TestClient 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 原话要点: ...@@ -17,7 +24,9 @@ HR 原话要点:
- 「他所有的下推逻辑是要绑定的…我做完以后发现他有些板块不会同步,代办里面也不新增」(P19)——**下推联动是全局痛点** - 「他所有的下推逻辑是要绑定的…我做完以后发现他有些板块不会同步,代办里面也不新增」(P19)——**下推联动是全局痛点**
- 开发方式:「基于你现在弄的那个系统,按模块(简历库/岗位管理)逐个完善」(P20) - 开发方式:「基于你现在弄的那个系统,按模块(简历库/岗位管理)逐个完善」(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 原话要点: ...@@ -25,33 +34,33 @@ HR 原话要点:
**需求 1:多招聘账号 + 总库** **需求 1:多招聘账号 + 总库**
- 原话:「我们两个的账号要隔离开,他的候选人在他系统里,我的候选人在我系统里,但公司还会有一个总库」(P1) - 原话:「我们两个的账号要隔离开,他的候选人在他系统里,我的候选人在我系统里,但公司还会有一个总库」(P1)
- 现状:✅ 多用户登录 + 按用户隔离工作区已实现(`owner_id`),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)
- 建议(🔄):可选加"总库"概念:admin 可将候选人标记为共享(owner 置为特殊值或加 `shared` 标志),普通用户可见共享池;或保持现状,由 admin 作为总库管理员 - 建议(🔄):已落地(共享覆盖候选人池;岗位级共享未做)。可选增强:批量移入/认领、认领前与本人池比对、岗位共享
**需求 2:OA 同步岗位 + 创建岗位双入口** **需求 2:OA 同步岗位 + 创建岗位双入口**
- 原话:「OA 审批完同步过来,已是待发布状态;系统内也要保留手动创建岗位入口」(P2) - 原话:「OA 审批完同步过来,已是待发布状态;系统内也要保留手动创建岗位入口」(P2)
- 现状:✅ 手动创建岗位已有;OA 同步入口部分存在(`/api/jd/sync-external` 接收企微 QwenPaw JD 建岗)。审批流程明确「不在本系统做,走 OA」(P1: 审批回到 OA,此处不存在审批流程) - 现状:✅ 手动创建岗位已有(JobEditView/`/jobs/new`)。✅ 外部同步已实现:`/api/jd/sync-external` 接收企微 QwenPaw JD,`sync_external_jd``external_id`/`title` upsert 岗位,同步后 `jdStatus=待确认``approvalStatus=企微智能体产出,待用人部门确认`,即"待发布"语义。审批流程明确「不在本系统做,走 OA」
- 建议(🔄):明确 OA 同步字段映射(岗位名/部门/类型/招聘人数/目标),同步后状态为「待发布」 - 建议(🔄):同步字段映射较基础(标题/部门/招聘人数/JD 各段),如需联动部门/岗位类型/目标可扩;核心链路已通
**需求 3:岗位类型细分** **需求 3:岗位类型细分**
- 原话:「岗位类型我还没细分;有研发、解决方案(售前)、销售、商务(两种定义)、产品(暂无)」(P2) - 原话:「岗位类型我还没细分;有研发、解决方案(售前)、销售、商务(两种定义)、产品(暂无)」(P2)
- 现状:🔄 无岗位类型枚举字段。 - 现状:🔄 无岗位类型枚举字段,仅 `category` 自由文本(默认"默认岗位",placeholder"例如:技术岗、职能岗"),不参与筛选(代码中无 `jobType` 字段)
- 建议(🆕):岗位加 `jobType` 枚举(研发/解决方案/销售/商务-销售口径/商务-投标口径/产品/职能/技术),可配置。 - 建议(🆕):岗位加 `jobType` 枚举(研发/解决方案/销售/商务-销售口径/商务-投标口径/产品/职能/技术),可配置,并进列表筛选
**需求 4:招聘人数联动自动关闭** **需求 4:招聘人数联动自动关闭**
- 原话:「目标招 2 个,已录用 2 个,数值相等就自动已关闭;招 1 个就够也能手动关闭」(P2) - 原话:「目标招 2 个,已录用 2 个,数值相等就自动已关闭;招 1 个就够也能手动关闭」(P2)
- 现状:🔄 岗位有 headcount/hired 字段,状态流转部分支持(录用入职后 candidate 改已入职) - 现状:🔄 `headcount`/`hired` 字段已有,且 `hired`**自动统计**:`store.reconcileRecruitmentLinks` 按"岗位下已入职候选人"实时回写 `job.hired`;`jobProgressSteps` 有"招聘完成"步骤(`hired>=headcount` 即 done)、`jobActionFor` 在缺口为 0 时提示「招聘目标已完成」;状态枚举含「已招满」「已关闭」。**但没有任何代码在 `hired>=headcount` 时自动把 `job.status` 置为「已关闭」**——目前仍需手动改状态
- 建议(🆕):**hired ≥ headcount 时自动置岗位状态「已关闭」**(规则触发,非手动) - 建议(🆕):规则触发:`hired >= headcount && headcount > 0` 时自动置 `status=已招满/已关闭`(可在 reconcile 里做,顺带写事件日志),同时保留手动关闭
**需求 5:JD 状态 + 版本历史自动迭代** **需求 5:JD 状态 + 版本历史自动迭代**
- 原话:「JD 状态保留(已生效等);版本号自动识别,确认生效后自动变 V2;改一个字也算小改也要记版本;可查历史版本(以前 JD 什么样、什么时候发布的)」(P2/P9) - 原话:「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 摘要 = 一句话概述** **需求 6:JD 摘要 = 一句话概述**
- 原话:「JD 摘要类似一句话概述,按 JD 生成内容放进来,Markdown 格式要能自动去掉,不用手动改」(P2/P3) - 原话:「JD 摘要类似一句话概述,按 JD 生成内容放进来,Markdown 格式要能自动去掉,不用手动改」(P2/P3)
- 现状:🔄 JD 摘要字段部分存在;Markdown 清理需确认 - 现状:🔄 `jd` 字段即扮演"一句话概述":LLM 生成 prompt 明确要求 `jd` 为 1-2 句话的岗位说明(`llm.py llm_jd_draft`),JobDetail 概览页把它作为摘要段落展示。**但未专门做"展示时剥离 Markdown 符号"**——若手工粘贴带 `#`/`**` 的内容会原样显示
- 建议(🆕):JD 摘要字段自动从生成内容取一句话概述;展示时剥离 Markdown 符号 - 建议(🆕):展示处对 `jd`/`responsibilities` 等字段做轻量 Markdown 清理(去 `#``*``-` 等符号),生成侧已基本是纯文本
--- ---
...@@ -59,22 +68,22 @@ HR 原话要点: ...@@ -59,22 +68,22 @@ HR 原话要点:
**需求 7:岗位详情页收敛** **需求 7:岗位详情页收敛**
- 原话:「岗位管理二级页面展示岗位历史、迭代版本、目标人数、关联简历数、匹配规则」(P8) - 原话:「岗位管理二级页面展示岗位历史、迭代版本、目标人数、关联简历数、匹配规则」(P8)
- 现状:✅ 岗位详情/编辑页已有(JobDetailView/JobEditView),含 JD 版本历史、匹配规则 - 现状:✅ JobDetailView 已收敛:概览(岗位说明/JD/职责/要求/招聘目标/已入职/关联候选人数)、JD 流程(草稿→用人确认→审批→生效→候选人重评 5 步)、发布(渠道台账 + 版本差异)、候选人(该岗位关联候选人)、JD 版本历史;匹配规则(硬性/加分/淘汰/关键词)在编辑页维护
- 建议(🔄):补充「岗位关联简历数」展示(现有多是候选人列表,需统计)。 - 建议(🔄):已达标,关联简历数已展示(统计口径为"绑定到该岗位的候选人去重")。
**需求 8:匹配规则 = 对内 JD 硬性指标** **需求 8:匹配规则 = 对内 JD 硬性指标**
- 原话:「对内版本看硬性指标;对内的要求(如竞业)在生成 JD 评级标准时起作用,用于简历评级」(P3) - 原话:「对内版本看硬性指标;对内的要求(如竞业)在生成 JD 评级标准时起作用,用于简历评级」(P3)
- 现状:✅ 对内/对外 JD 双版本已有(`_INTERNAL_ONLY_JOB_KEYS` 剥离敏感项);匹配规则字段已有(mustHave/niceToHave/knockout/matchKeywords)。 - 现状:✅ 对内/对外 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)。
- 建议(🔄):把对内 JD 硬性指标直接作为候选人匹配规则的输入源,生成评级标准 - 建议(🔄):已基本达标;可把对内硬性指标与"寻源画像"再显式化(见需求 15)
**需求 9:需求模板(有 JD 粘贴 / 无 JD 用模板 / AI 调研)** **需求 9:需求模板(有 JD 粘贴 / 无 JD 用模板 / AI 调研)**
- 原话:「模板两个用途:已有 JD 直接粘进去生成;没有 JD 用人部门要模板,把模板发他;还有 AI 调研场景」(P9/P11) - 原话:「模板两个用途:已有 JD 直接粘进去生成;没有 JD 用人部门要模板,把模板发他;还有 AI 调研场景」(P9/P11)
- 现状:✅ 逼问式访谈(AI 调研)已有;「粘贴 JD 生成」入口需确认 - 现状:✅「逼问式访谈(AI 调研)」已实现:`jd_grill.py` 确定式状态机逐题采集(岗位定位/职责/要求/敏感/竞业等),新建岗位的「AI 辅助创建」直接进访谈;生成完自动补齐 JD 各段。🔄 **「粘贴完整 JD → 自动结构化」入口缺失**:JD 与匹配标准 Tab 是手填 textarea,没有"粘贴整段 JD,自动拆出职责/要求/硬性/加分/关键词"的入口
- 建议(🔄):补齐「粘贴完整 JD → 自动结构化」入口(可走 AI 解析) - 建议(🆕):加"粘贴 JD 自动结构化"按钮(走现有 LLM 结构化解析,复用 `generate-jd` 的 JSON schema),满足"有 JD 直接粘"场景
--- ---
## 四、候选人弹窗页(重点,现有缺失) ## 四、候选人弹窗页(重点,近期已重构)
**需求 10:候选人详情 = 弹窗/页面级展示,收敛所有信息**(核心交互改造) **需求 10:候选人详情 = 弹窗/页面级展示,收敛所有信息**(核心交互改造)
- 原话: - 原话:
...@@ -82,23 +91,21 @@ HR 原话要点: ...@@ -82,23 +91,21 @@ HR 原话要点:
- 「把简历、概览、AI 评估都收敛在弹窗页,关掉好操作,不用一页拉很长」(P13) - 「把简历、概览、AI 评估都收敛在弹窗页,关掉好操作,不用一页拉很长」(P13)
- 「简历可收缩,点击预览;不点就显示已提取信息(像证书那样)」(P14) - 「简历可收缩,点击预览;不点就显示已提取信息(像证书那样)」(P14)
- 「页面左侧是履历/PDF 渲染,右侧是基本信息概览;或做成同级页面可返回」(P14/P17) - 「页面左侧是履历/PDF 渲染,右侧是基本信息概览;或做成同级页面可返回」(P14/P17)
- 现状:🔄 候选人有详情视图但非弹窗/抽屉式;信息分散。 - 现状:✅ **已落地(提交 `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` 明确改此,防误关)。
- 摘要:一句话总结过往经历(来自 AI 或本地) - 建议(🔄):基本达标,已覆盖"基本信息、匹配评估、当前流程、面评"四项且 PDF 可收缩;可选增强:抽屉宽度自适应、PDF 内文字高亮定位(pdf.js 嵌入)、关闭快捷键与按钮文案统一。
- AI 评分区(见需求 13-17)
- 操作栏:下推流程/面试记录/评价(可上传 PDF 面试记录,P18)
**需求 11:候选人与岗位「重新匹配」** **需求 11:候选人与岗位「重新匹配」**
- 原话:「插件抓的岗位是候选人在平台选的期望岗位,实际匹配要用我们的沟通职位;可能抓错,要能改匹配岗位重新匹配」(P12) - 原话:「插件抓的岗位是候选人在平台选的期望岗位,实际匹配要用我们的沟通职位;可能抓错,要能改匹配岗位重新匹配」(P12)
- 现状:🔄 candidate 有 jobId/jobTitle,可改;「改岗位后重新匹配」需确认 - 现状:✅ 简历库卡片「求职岗位」列为岗位下拉,改选后 `bindJob` 立即回写 `jobId/jobTitle` 并自动 `localMatch` 重新快筛;另有「批量重新匹配」可整批刷新(完成后显示 完成/缺少正文/未绑岗位/执行失败 汇总);详情抽屉「快速匹配/AI 评估」也可重跑。改岗后 AI 评估会因 `analysisInputKey` 变化而提示"需重评"(生成时若已有有效报告会提示复用)
- 建议(🆕):候选人详情提供「更改匹配岗位」操作,改后触发重新匹配评分 - 建议(🔄):已达标;可选增强:改岗后自动提示并一键重生成 AI 评估(当前需手动点)
**需求 12:候选人标记(重点/待跟进)** **需求 12:候选人标记(重点/待跟进)**
- 原话:「通过一面待二面的人重点标记;很匹配但难找的人标记重点关注;未来每周看提醒沟通维护;标记人工选,AI 不做」(P12) - 原话:「通过一面待二面的人重点标记;很匹配但难找的人标记重点关注;未来每周看提醒沟通维护;标记人工选,AI 不做」(P12)
- 现状:🔄 候选人有 tag 字段(如"新入库"),无结构化标记体系 - 现状:**结构化标签已落地(2026-09-02 补充)**:候选人带 `tags` 数组(枚举:新入库/重点跟进/待二面/待沟通/需维护/储备/已淘汰,详情概览「编辑」内多选+可自输,AI 不打标);简历库与人才池列表均支持按标签筛选(含「待跟进 = 重点跟进/待二面/待沟通/需维护」快捷项),旧版单 `tag` 字符串自动映射进 `tags`(`LEGACY_TAG_MAP`)。「移入人才池」不再用 tag 模拟,改走真正的公司总库。**残余**:独立「每周待跟进」周报视图未做(现为列表快捷筛选 + 人才池「待跟进」统计卡)
- 建议(🆕):加候选人标记枚举(重点跟进/待二面/需维护/已淘汰等),列表可筛选;可选加"本周待跟进"提醒视图 - 建议(🔄):枚举/编辑/筛选已落地;如需"每周待跟进"独立视图,可基于 `tags ∩ 待跟进集` + `updatedAt` 在 Dashboard 加提醒卡
--- ---
...@@ -109,35 +116,39 @@ HR 原话要点: ...@@ -109,35 +116,39 @@ HR 原话要点:
- 「AI 每次评的都不一样;保存后点 AI 评估,过一会儿再点又是一个报告,到底以哪个为准?」(P5) - 「AI 每次评的都不一样;保存后点 AI 评估,过一会儿再点又是一个报告,到底以哪个为准?」(P5)
- 「所以 AI 评估需要写一套规则,这是代办」(P6) - 「所以 AI 评估需要写一套规则,这是代办」(P6)
- 「AI 说这个电力家园的人没家园经验还让问山东现货,就是没写规则」(P13) - 「AI 说这个电力家园的人没家园经验还让问山东现货,就是没写规则」(P13)
- 现状:🔄 有 `local-structured` 本地规则回退 + LLM 评估;LLM 评估本身不稳定。 - 现状:✅ **已大幅落地**(本项是访谈后改造力度最大的点):
- 建议(🆕):**AI 评估改为"规则约束 + LLM 填充"**: 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"`;
1. 固定评估维度(见需求 15-17),输出结构化为固定 JSON schema; 2. 评估输入限定为「对内 JD 硬性指标 + 候选人简历事实」,候选人经 `sanitize_candidate_for_llm` 脱敏(名字=候选人、电话/邮箱/年龄/城市/简历文件名剥离,简历正文再 `sanitize_resume_for_llm` 掩码手机/身份证等),LLM 不能自由发挥;
2. 评估依据限定为「对内 JD 硬性指标 + 候选人简历事实」,禁止自由发挥; 3. **结果稳定性机制**:`store.analyzeResume``analysisInputKey`(简历+岗位规则哈希)做缓存——输入未变则直接复用已有报告(抽屉内重新生成会提示"已有有效报告,正在复用"),不再每次重跑,直接回应"以哪个为准";
3. 同一候选人重复评估结果应基本一致(缓存或确定性提示词); 4. 每个维度带"证据引用"(`evidence`/`projectEvidence` 取自简历原文段落,`pick_evidence`);
4. 每个维度给"证据引用"(来自简历原文)。 5. 无 LLM Key 或调用失败时回退到**确定性的本地规则**(`local_resume_analysis`,provider=`local-structured`),同一输入结果完全一致。
- 残余缺口:LLM 报告在输入变化重新生成时,语义表述仍可能有轻微差异(无 diff 冻结);本地规则只做关键词命中,不做语义。
- 建议(🔄):已达标;可选增强:对 LLM 输出做二次"规则化规整"(如强制 `scoreBreakdown` 五维加总 = score),或把已生效报告快照存库(`candidate_match_reports` 表已就绪)防误覆盖。
**需求 14:AI 不得引用候选人自述优势** **需求 14:AI 不得引用候选人自述优势**
- 原话:「不能参考候选人自己写的核心竞争力;简历里大家都写自己优势,要加约束规则」(P16) - 原话:「不能参考候选人自己写的核心竞争力;简历里大家都写自己优势,要加约束规则」(P16)
- 建议(🆕):提示词硬规则:候选人自我评价/优势描述不作为评估依据,只作为待验证线索。 - 建议(🆕):**未实现**。复核 `llm_resume_analysis`(评估)与 `llm_resume_fields`(解析)的 system/user prompt,均未显式声明"候选人自述优势/核心竞争力/自我评价 不作为匹配依据";脱敏简历正文 `resumeText`(含自我评价段落)原样进 LLM,存在被"核心竞争力"段落带偏的风险。需在两条链路的 prompt 中加入硬规则(如"候选人自述优势/主观表述不作为匹配依据,只作为面试待验证线索"),本地规则天然不读自述,但评估侧要防 LLM 被带偏。
- 建议实施点(一行即可生效):`llm_resume_analysis``llm_resume_fields` 的 system prompt 追加该约束,评估权重以岗位硬性指标 + 简历事实(项目/经历数据)为准。
**需求 15:匹配分析 = 结合对内 JD 找"哪里匹配"** **需求 15:匹配分析 = 结合对内 JD 找"哪里匹配"**
- 原话:「匹配分析结合对内版 JD 分析,给我找人做参考(如找 SAP/金蝶/用友做 ERP 大客户销售的人)」(P16) - 原话:「匹配分析结合对内版 JD 分析,给我找人做参考(如找 SAP/金蝶/用友做 ERP 大客户销售的人)」(P16)
- 现状:✅ 匹配分析已有(matchKeywords/mustHave 等本地规则 + AI)。 - 现状:✅ `hardMatch` 明确输出"候选人符合哪些对内硬性指标/关键词"(逐条引用简历原文),`missing`/`mustMissing` 输出缺口;LLM 输入即对内规则与脱敏简历。**"可去找哪类公司背景的人"的寻源建议不显式生成**(只是匹配命中隐含)。
- 建议(🆕):匹配分析输出"候选人符合哪些对内硬性指标"及"可去找哪类公司背景的人",直接作为寻源参考。 - 建议(🔄):可选增强:评估报告加一段"可寻源画像"(行业/公司/角色关键词),直接作为找人参考。
**需求 16:核心亮点(能力拆解 + 匹配证据)** **需求 16:核心亮点(能力拆解 + 匹配证据)**
- 原话:「能力拆解可以做匹配;候选人的匹配证据属于他简历里的事实;项目价值这块可以放弃,写得好不一定实际」(P16) - 原话:「能力拆解可以做匹配;候选人的匹配证据属于他简历里的事实;项目价值这块可以放弃,写得好不一定实际」(P16)
- 建议(🆕):核心亮点 = 候选人简历中的客观事实拆解(项目/职责/成就),标注证据;**不做"项目价值"评估**(HR 明确放弃)。 - 现状:✅ `projectEvidence`(简历原文证据片段)+ `competencyFrame`(胜任力拆解,仅做面试观察点,不评分)+ `strengths`(命中关键词)。**系统不评估"项目价值"**——符合 HR 明确放弃该项。
- 建议(🔄):已达标。
**需求 17:风险点评估(固定几个维度,不泛)** **需求 17:风险点评估(固定几个维度,不泛)**
- 原话:「风险点不要太泛,只评估几个点:跳槽频率、竞业协议(标准竞对高管需提示人力确认)、客户名单、中间 gap(如 2020-2024 四年 gap 要提示)、高层职业道德」(P16/P17) - 原话:「风险点不要太泛,只评估几个点:跳槽频率、竞业协议(标准竞对高管需提示人力确认)、客户名单、中间 gap(如 2020-2024 四年 gap 要提示)、高层职业道德」(P16/P17)
- 现状:🔄 offer 有 risk 字段;候选人无结构化风险点 - 现状:🆕 **未实现为固定维度**。复核 `llm_resume_analysis` 与本地规则 `local_resume_analysis`:`risks` 仍只是"命中淘汰项 / 硬性缺口 / 缺失关键词"的文本列表(本地规则 `risks` 仅由 `knockout_hit`/`must_missing`/`missing` 拼出,`riskAdjustment` 只按是否触发淘汰/硬缺口给 -10/-5),不结构化识别跳槽频率、竞业、空窗 gap、职业道德;`offer.risk` 也只是手工备注
- 建议(🆕):候选人风险点固定维度:跳槽频率、竞业协议风险、职业空窗(gap)、职业道德提示(高层);每项输出"提示 + 依据",由 HR 确认 - 建议(🆕):候选人风险点固定维度:跳槽频率(每段任职时长推算)、竞业协议(对内 JD `nonCompete` 是否命中)、职业空窗(gap≥N 年提示)、职业道德提示(高层专属);每项输出「提示 + 依据(简历时间线)」,由 HR 确认。规则可本地算,LLM 只做补充
**需求 18:快筛评分(本地基础评分)** **需求 18:快筛评分(本地基础评分)**
- 原话:「快筛评分本地做,用基本要求/硬性要求评一下,先看一眼;一般到这边的都是看过一遍的」(P6) - 原话:「快筛评分本地做,用基本要求/硬性要求评一下,先看一眼;一般到这边的都是看过一遍的」(P6)
- 现状:✅ 快筛评分已有(local-structured,本地关键词匹配,省 token) - 现状:✅ 快筛评分已实现(`quickScore`/`quickMatchScore`,浏览器本地关键词匹配,省 token),简历库"高匹配"卡按快筛≥85 统计,卡片展示分数,可批量重匹配,可作为"是否进入 AI 评估"的前置门槛
- 建议(🔄):保持本地规则,可作为"是否进入 AI 评估"的前置门槛 - 建议(🔄):保持现状;可选加"快筛达标才推荐生成 AI 报告"的门槛提示
--- ---
...@@ -145,18 +156,26 @@ HR 原话要点: ...@@ -145,18 +156,26 @@ HR 原话要点:
**需求 19:面试记录(多轮 + 面试官评价)** **需求 19:面试记录(多轮 + 面试官评价)**
- 原话:「一面、二面…每个面试不同面试官反馈都能记在上面;面评要有具体内容和时间;下一轮动作是淘汰还是继续二面三面;面试形式(线上/线下)」(P6/P7) - 原话:「一面、二面…每个面试不同面试官反馈都能记在上面;面评要有具体内容和时间;下一轮动作是淘汰还是继续二面三面;面试形式(线上/线下)」(P6/P7)
- 现状:🔄 interview 表存在(round/scheduled_at/interviewer/result/feedback),前端面试记录 UI 需确认 - 现状:✅ 候选人详情「面试」Tab 已实现:轮次(一面/二面)、面试官、时间、形式(线上/线下)、结果(待反馈/通过/不通过)、下一步(继续面试/推进 Offer)、评价内容;保存后按时间倒序展示(最多 8 条),并联动更新候选人阶段(不通过→初筛未通过,通过/推进 Offer→Offer 中)。后端 `interviews` 表独立落库
- 建议(🔄):候选人弹窗内提供多轮面试记录,字段:轮次/时间/面试官/形式(线上/线下)/评价内容/结果(通过/淘汰/待定) - 建议(🔄):已达标;可把"下一步"扩展为显式的淘汰/继续二面/三面选项
**需求 20:下推流程 = 操作记录 + 状态联动** **需求 20:下推流程 = 操作记录 + 状态联动**
- 原话:「整个下推流程叫操作记录;现在什么状态、下一步怎么操作,放操作栏;状态变了代办要同步新增」(P18/P19) - 原话:「整个下推流程叫操作记录;现在什么状态、下一步怎么操作,放操作栏;状态变了代办要同步新增」(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:面试评价表 + 入职材料三件套** **需求 21:面试评价表 + 入职材料三件套**
- 原话:「入职材料三个:候选人个人信息表、用人部门面试评价表(需下推给用人部门确认)、录用通知 offer 表」(P7/P8) - 原话:「入职材料三个:候选人个人信息表、用人部门面试评价表(需下推给用人部门确认)、录用通知 offer 表」(P7/P8)
- 现状:✅ offer 审批材料(approvalMaterials)已有 - 现状:🔄 已有「Offer 审批材料」(`approvalMaterials`:候选人/岗位/薪资/入职日期/工作地点/合同类型,自动预填已知字段,缺项标红阻断)+ 邮件草稿(`mailDraft`)生成。**但只有"录用通知"一张表**,候选人个人信息表、用人部门面试评价表未做成独立模板,也没有"下推给用人部门确认"的反馈回路(JD 的部门确认走的是 JD 审批,面试评价表未下推)
- 建议(🔄):材料模板化:三张表模板(候选人信息/部门评价/录用通知),系统预填候选人/部门/岗位等已知字段,HR 补填后导出/下推 - 建议(🔄):三表模板化:候选人信息表(自动预填解析字段)/部门面试评价表(下推反馈收集)/录用通知;模板字段预填后导出或内嵌展示
--- ---
...@@ -164,8 +183,8 @@ HR 原话要点: ...@@ -164,8 +183,8 @@ HR 原话要点:
**需求 22:简历库 = 简历存储 + 标签 + 统计** **需求 22:简历库 = 简历存储 + 标签 + 统计**
- 原话:「简历库涵盖所有简历,几十上百份可打标签;要看总量、通过面试人数、各岗位简历量(如 java 岗多少份、人力岗多少份);面完没过的放人才池」(P19) - 原话:「简历库涵盖所有简历,几十上百份可打标签;要看总量、通过面试人数、各岗位简历量(如 java 岗多少份、人力岗多少份);面完没过的放人才池」(P19)
- 现状:✅ 简历库/候选人列表已有(candidates 表 + resume 关联),有来源/阶段筛选 - 现状:🔄 近期重构(提交 `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 补充)**:左侧「人才池」页 = 公司共享总库(跨账号),卡片列表 + 关键词/标签/来源筛选 + 统计卡(总库人数/本周新入池/待跟进)+ 详情抽屉(档案/简历正文/原简历链接)+「认领到我的工作区」;简历库卡片与详情抽屉均可一键「移入人才池」。**残余**:岗位维简历量统计、批量移入/复活仍未做
- 建议(🆕):简历库增强:标签筛选(复用需求 12 标记)、岗位维简历量统计、人才池视图(未通过/待定) - 建议(🔄):标签筛选与人才池独立视图已落地;待做:岗位维简历量统计(表格或看板)、批量放入/复活、后端按 `file_hash` 库级去重
--- ---
...@@ -176,45 +195,49 @@ HR 原话要点: ...@@ -176,45 +195,49 @@ HR 原话要点:
- 「狗抓的岗位不准,抓的是平台期望岗位,要改成匹配我们的沟通职位」(P12) - 「狗抓的岗位不准,抓的是平台期望岗位,要改成匹配我们的沟通职位」(P12)
- 「狗抓到名字是错的(余德宝 888 抓成童童真),但 PDF 抓得准」(P12) - 「狗抓到名字是错的(余德宝 888 抓成童童真),但 PDF 抓得准」(P12)
- 「期望薪资没抓」(P14) - 「期望薪资没抓」(P14)
- 现状:✅ 插件采集已有(BOSS/猎聘,PDF URL + 摘要),幂等去重 - 现状:🔄 插件(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 优先策略保持;无 PDF 时把名字/正文解析交给后端 LLM 修正(现有 ingest 已在 PDF 路径做了)
--- ---
## 九、其他与交互细节 ## 九、其他与交互细节
- **需求 24:候选人概览抓取准确度**:本地字段抽取(姓名/学校/专业/工作年限)有错抓(名字抓成工作经历),需改进解析(P13)。 - **需求 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:期望薪资展示**:候选人详情展示期望薪资(插件未抓,人工可补)(P14)。 - **需求 25:期望薪资展示**:🆕 无独立 `expectedSalary` 字段/展示(全库无此字段,候选人卡片/抽屉/新建表单均无薪资输入)。仅 ingest 入库时把 `raw.salary` 写进 `evaluation` 备注(`期望薪资:{salary}`),插件实际不传。建议候选人详情加"期望薪资"字段(人工可补,插件抓不到)。
- **需求 26:UI 风格**:HR 偏好「玻璃拟态 + 蓝色系 + 有色彩」的偏 fast 风格界面,会提供风格截图(P13) - **需求 26:UI 风格**:🔄 蓝色系多色 + **已引入玻璃拟态元素**:顶部栏(`main.scss .top`)已用半透明 `rgba(255,255,255,0.86)` + `backdrop-filter: blur(14px) saturate(1.2)` 毛玻璃(sticky 顶栏,提交 `ddc5340` 一并改入);卡片/抽屉主体仍为白底实心,与 HR 整体"玻璃拟态"偏好尚有差距。待 HR 提供风格截图后对齐
- **需求 27:伯乐帮对照**:外部 AI 工具(伯乐帮)只做"履历合格情况"参考;本系统做完整档案与评估,两者分工明确(P8)。 - **需求 27:伯乐帮对照**:未集成,也无相关代码。本系统做完整档案与评估;如需对照可后续评估对接(外部工具只做履历合格参考)。
--- ---
## 十、优先级建议(按访谈强调程度) ## 十、优先级建议(结合访谈强调程度与当前实现缺口)
| 优先级 | 需求 | 理由 | | 优先级 | 需求 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- | --- |
| P0 | 需求 13 AI 评估规则化(稳定可复现) | HR 反复强调,当前最大痛点 | | P0 | 需求 13 AI 评估规则化(稳定可复现) | ✅ 已大幅落地 | inputKey 缓存 + 固定 schema + temp=0 + 本地回退;残余为 LLM 重生成时表述波动 |
| P0 | 需求 20 下推流程联动 | 全局联动是流程跑通的关键 | | P0 | 需求 20 下推流程联动 | ✅ 已落地 | 事件日志 + 待办自动增删已贯穿候选人/JD/Offer;人才池移入/认领联动已补 |
| P1 | 需求 10 候选人弹窗页收敛 | 核心交互,信息分散的根治 | | P0 | 需求 14 AI 不得引用自述优势 | 🆕 未实现 | 两条 prompt 加一行硬规则即可,直接影响评估可信度 |
| P1 | 需求 12 候选人标记 | 日常跟进刚需 | | P1 | 需求 17 风险点固定维度 | 🆕 未实现 | 跳槽/竞业/gap/职业道德四项规则,风控刚需 |
| P1 | 需求 17 风险点评估(固定维度) | 招聘风控刚需 | | P1 | 需求 12 候选人标记 | 🔄 基本落地 | tags 枚举 + 详情编辑 + 简历库/人才池标签筛选 + 待跟进快捷筛选;独立周报视图待做 |
| P2 | 需求 15/16 匹配分析/核心亮点 | 寻源参考,锦上添花 | | P2 | 需求 4 岗位自动关闭 | 🔄 部分 | reconcile 里加一行规则触发即可 |
| P2 | 需求 4/5 岗位自动关闭/JD 版本 | 减少手工维护 | | P2 | 需求 9 粘贴 JD 自动结构化 | 🔄 缺入口 | 复用现有 LLM 解析加一个按钮 |
| P2 | 需求 22 简历库标签/统计 | 简历量上来后刚需 | | P2 | 需求 22 简历库标签/统计 | 🔄 部分 | 人才池独立视图 + 标签筛选已落地;岗位维简历量统计与批量移入待做 |
| P3 | 需求 23 插件字段修正 | 采集质量优化 | | P3 | 需求 23 插件字段修正 | 🔄 部分 | 补抓沟通职位/期望薪资 |
| P3 | 需求 21 入职材料模板 | 流程完整性 | | 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) - **审批流程不做**:岗位审批走 OA,本系统不实现审批流(P1)——JD 审批在系统内为简化版(确认/提交/生效,无 OA 对接),符合此条。
- **项目价值评估不做**:AI 不评估"项目价值"(P16) - **项目价值评估不做**:AI 不评估"项目价值"(P16)——已实现,`projectEvidence` 只列证据不评价价值。
- **腾讯会议总结自动同步不做**:面试总结手动复制粘贴(会议联动太复杂)(P7) - **腾讯会议总结自动同步不做**:面试总结手动复制粘贴(会议联动太复杂)(P7)
- **智能体对话查候选人信息不做**:「问智能体候选人住哪里」这类对话暂不支持,HR 自己记录(P19) - **智能体对话查候选人信息不做**:「问智能体候选人住哪里」这类对话暂不支持,HR 自己记录(P19)
- **面试记录 PDF 上传**:可做可不做,保留文本记录即可(P18) - **面试记录 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' 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 = '') { export async function fetchState(owner = '') {
const { data } = await http.get('/api/state', { params: owner ? { owner } : {} }) const { data } = await http.get('/api/state', { params: owner ? { owner } : {} })
return data return data
......
...@@ -194,7 +194,7 @@ const importBatch = async () => { ...@@ -194,7 +194,7 @@ const importBatch = async () => {
city: parsed.city || '', city: parsed.city || '',
skills: parsed.skills?.length ? parsed.skills : extractSkills(resumeText), skills: parsed.skills?.length ? parsed.skills : extractSkills(resumeText),
parseWarning: parsed.parseWarning || '', parseWarning: parsed.parseWarning || '',
tag: '新入库', tags: ['新入库'],
evaluation: '批量上传入库', evaluation: '批量上传入库',
}) })
created.push(candidate) created.push(candidate)
......
...@@ -148,7 +148,7 @@ const submit = async () => { ...@@ -148,7 +148,7 @@ const submit = async () => {
: schoolTags(parsedResume.school || inferSchool(resumeText)), : schoolTags(parsedResume.school || inferSchool(resumeText)),
city: parsedResume.city || '', city: parsedResume.city || '',
skills: parsedResume.skills?.length ? parsedResume.skills : extractSkills(resumeText), skills: parsedResume.skills?.length ? parsedResume.skills : extractSkills(resumeText),
tag: '新入库', tags: ['新入库'],
evaluation: form.evaluation, evaluation: form.evaluation,
}) })
store.candidates.unshift(candidate) store.candidates.unshift(candidate)
...@@ -334,10 +334,14 @@ const submit = async () => { ...@@ -334,10 +334,14 @@ const submit = async () => {
} }
} }
.candidate-create-form {
position: relative;
}
.save-mask { .save-mask {
position: fixed; position: absolute;
inset: 0; inset: 0;
z-index: 3000; z-index: 20;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
......
...@@ -2,8 +2,11 @@ ...@@ -2,8 +2,11 @@
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import http from '@/api' import http from '@/api'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { CANDIDATE_TAG_OPTIONS, candidateTagTone } from '@/utils/constants'
import { candidateWorkflow, workflowStageClass } from '@/utils/task' import { candidateWorkflow, workflowStageClass } from '@/utils/task'
import { candidateScreeningConclusion, quickScore } from '@/utils/resume' import { candidateScreeningConclusion, quickScore } from '@/utils/resume'
import { isAiEvaluationReport } from '@/utils/scoring' import { isAiEvaluationReport } from '@/utils/scoring'
...@@ -334,12 +337,30 @@ const advanceStage = (candidate, nextStage) => { ...@@ -334,12 +337,30 @@ const advanceStage = (candidate, nextStage) => {
showToast('候选人阶段已更新') showToast('候选人阶段已更新')
} }
// 移入人才池:从当前工作区迁入公司共享总库(需求 1/20),而非本地改 tag/阶段。
const poolCandidate = (candidate) => { const poolCandidate = (candidate) => {
candidate.tag = '人才池' ElMessageBox.prompt(
candidate.interviewStatus = '' '候选人将从当前工作区迁入公司人才池(总库),其他招聘账号可见并可认领,原岗位流程与待办将随之结束。可在备注中记录原因。',
store.setCandidateStage(candidate, '初筛未通过', '移入人才池') `移入人才池:${candidate.name}`,
store.persist('候选人已移入人才池') {
showToast('候选人已移入人才池') 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 () => { ...@@ -405,6 +426,7 @@ const enterEditMode = async () => {
major: c.major || '', major: c.major || '',
schoolTags: (c.schoolTags || []).join('、'), schoolTags: (c.schoolTags || []).join('、'),
skills: (c.skills || []).join('、'), skills: (c.skills || []).join('、'),
tags: [...(c.tags || [])],
}) })
editMode.value = true editMode.value = true
store.resumeDetailSection = 'overview' store.resumeDetailSection = 'overview'
...@@ -429,6 +451,7 @@ const profileCorrection = reactive({ ...@@ -429,6 +451,7 @@ const profileCorrection = reactive({
major: '', major: '',
schoolTags: '', schoolTags: '',
skills: '', skills: '',
tags: [],
}) })
const saveCorrection = (candidate) => { const saveCorrection = (candidate) => {
...@@ -445,6 +468,7 @@ const saveCorrection = (candidate) => { ...@@ -445,6 +468,7 @@ const saveCorrection = (candidate) => {
major: profileCorrection.major.trim(), major: profileCorrection.major.trim(),
schoolTags: manualSchoolTags.length ? manualSchoolTags : candidate.schoolTags || [], schoolTags: manualSchoolTags.length ? manualSchoolTags : candidate.schoolTags || [],
skills: profileCorrection.skills.split(/[、,,\s]+/).filter(Boolean), skills: profileCorrection.skills.split(/[、,,\s]+/).filter(Boolean),
tags: [...new Set((profileCorrection.tags || []).map((item) => String(item).trim()).filter(Boolean))],
}) })
store.localMatch(candidate.id) store.localMatch(candidate.id)
store.persist('候选人基础字段已修正') store.persist('候选人基础字段已修正')
...@@ -731,6 +755,48 @@ const saveInterview = (candidate) => { ...@@ -731,6 +755,48 @@ const saveInterview = (candidate) => {
</span> </span>
</div> </div>
</section> </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>
<div class="resume-text-box"> <div class="resume-text-box">
...@@ -1513,6 +1579,24 @@ const saveInterview = (candidate) => { ...@@ -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-box {
.resume-text-head { .resume-text-head {
margin-bottom: 8px; margin-bottom: 8px;
......
...@@ -2,7 +2,10 @@ ...@@ -2,7 +2,10 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { CANDIDATE_TAG_OPTIONS, candidateTagTone } from '@/utils/constants'
import { jobCandidates } from '@/utils/links' import { jobCandidates } from '@/utils/links'
import { getFilteredCandidates, quickMatchNeedsRefresh } from '@/utils/matching' import { getFilteredCandidates, quickMatchNeedsRefresh } from '@/utils/matching'
import { aiScore, candidateScreeningConclusion, quickScore } from '@/utils/resume' import { aiScore, candidateScreeningConclusion, quickScore } from '@/utils/resume'
...@@ -17,6 +20,7 @@ const batchMatchRunning = ref(false) ...@@ -17,6 +20,7 @@ const batchMatchRunning = ref(false)
const batchMatchResult = ref(null) const batchMatchResult = ref(null)
const stageOptions = ['全部阶段', '未筛选', '初筛通过', '初筛未通过', '面试中', 'Offer 中', '待入职', '已入职'] const stageOptions = ['全部阶段', '未筛选', '初筛通过', '初筛未通过', '面试中', 'Offer 中', '待入职', '已入职']
const tagOptions = ['全部标签', '待跟进', ...CANDIDATE_TAG_OPTIONS]
const sourceOptions = computed(() => [ const sourceOptions = computed(() => [
'全部来源', '全部来源',
...new Set(store.candidates.map((candidate) => candidate.source).filter(Boolean)), ...new Set(store.candidates.map((candidate) => candidate.source).filter(Boolean)),
...@@ -52,12 +56,13 @@ const hasFilters = computed(() => { ...@@ -52,12 +56,13 @@ const hasFilters = computed(() => {
f.q || f.q ||
(f.stage && f.stage !== '全部阶段') || (f.stage && f.stage !== '全部阶段') ||
(f.source && f.source !== '全部来源') || (f.source && f.source !== '全部来源') ||
(f.tag && f.tag !== '全部标签') ||
(f.minMatch && f.minMatch !== '全部匹配') (f.minMatch && f.minMatch !== '全部匹配')
) )
}) })
const clearFilters = () => { 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() const candidateInitial = (candidate) => (candidate.name || '候选人').trim().slice(0, 1).toUpperCase()
...@@ -146,9 +151,35 @@ const runMatch = (candidate) => { ...@@ -146,9 +151,35 @@ const runMatch = (candidate) => {
const rowCommand = (command, candidate) => { const rowCommand = (command, candidate) => {
if (command === 'view') openDetail(candidate) if (command === 'view') openDetail(candidate)
if (command === 'match') runMatch(candidate) if (command === 'match') runMatch(candidate)
if (command === 'pool') poolCandidate(candidate)
if (command === 'delete') deleteCandidate(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 matchAll = () => {
const targets = filtered.value const targets = filtered.value
if (!targets.length) { if (!targets.length) {
...@@ -236,6 +267,9 @@ const openBatch = () => { ...@@ -236,6 +267,9 @@ const openBatch = () => {
<el-select v-model="store.resumeFilters.source" placeholder="来源"> <el-select v-model="store.resumeFilters.source" placeholder="来源">
<el-option v-for="option in sourceOptions" :key="option" :label="option" :value="option" /> <el-option v-for="option in sourceOptions" :key="option" :label="option" :value="option" />
</el-select> </el-select>
<el-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-select v-model="store.resumeFilters.minMatch" placeholder="匹配度">
<el-option label="全部匹配" value="全部匹配" /> <el-option label="全部匹配" value="全部匹配" />
<el-option label="80+" value="80+" /> <el-option label="80+" value="80+" />
...@@ -314,6 +348,17 @@ const openBatch = () => { ...@@ -314,6 +348,17 @@ const openBatch = () => {
<div class="candidate-profile" :class="{ 'is-empty': !profileSummary(candidate) }"> <div class="candidate-profile" :class="{ 'is-empty': !profileSummary(candidate) }">
{{ profileSummary(candidate) || '档案字段待补全' }} {{ profileSummary(candidate) || '档案字段待补全' }}
</div> </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>
</div> </div>
...@@ -366,6 +411,7 @@ const openBatch = () => { ...@@ -366,6 +411,7 @@ const openBatch = () => {
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="view">查看档案</el-dropdown-item> <el-dropdown-item command="view">查看档案</el-dropdown-item>
<el-dropdown-item command="match">生成快速匹配</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-item command="delete" divided>删除候选人</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
...@@ -384,6 +430,13 @@ const openBatch = () => { ...@@ -384,6 +430,13 @@ const openBatch = () => {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.candidate-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.candidate-page { .candidate-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
......
...@@ -19,6 +19,7 @@ const activeRouteKey = computed(() => { ...@@ -19,6 +19,7 @@ const activeRouteKey = computed(() => {
if (name === 'dashboard') return 'dashboard' if (name === 'dashboard') return 'dashboard'
if (['jobs', 'job-detail', 'job-edit', 'job-create'].includes(name)) return 'jobs' if (['jobs', 'job-detail', 'job-edit', 'job-create'].includes(name)) return 'jobs'
if (name === 'resumes') return 'resumes' if (name === 'resumes') return 'resumes'
if (name === 'pool') return 'pool'
if (name === 'offer') return 'offer' if (name === 'offer') return 'offer'
return 'todo' return 'todo'
}) })
...@@ -46,7 +47,7 @@ const navGroups = computed(() => { ...@@ -46,7 +47,7 @@ const navGroups = computed(() => {
const byKey = Object.fromEntries(navItems.map((item) => [item.key, item])) const byKey = Object.fromEntries(navItems.map((item) => [item.key, item]))
const groups = [ const groups = [
{ title: '工作台', items: [byKey.todo, byKey.dashboard].filter(Boolean) }, { 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) { if (isAdmin.value) {
groups.push({ title: '系统', items: [{ key: 'user-management', label: '用户管理', icon: 'Setting' }] }) groups.push({ title: '系统', items: [{ key: 'user-management', label: '用户管理', icon: 'Setting' }] })
...@@ -65,6 +66,7 @@ const navCount = (key) => { ...@@ -65,6 +66,7 @@ const navCount = (key) => {
if (key === 'dashboard') return store.jobs.length if (key === 'dashboard') return store.jobs.length
if (key === 'jobs') return store.jobs.length if (key === 'jobs') return store.jobs.length
if (key === 'resumes') return store.candidates.length if (key === 'resumes') return store.candidates.length
if (key === 'pool') return store.poolCandidates.length
if (key === 'offer') return store.offers.length if (key === 'offer') return store.offers.length
return 0 return 0
} }
...@@ -184,6 +186,7 @@ onMounted(() => { ...@@ -184,6 +186,7 @@ onMounted(() => {
// 先校正工作区归属(登录/换号后以当前用户为准),再加载数据库数据 // 先校正工作区归属(登录/换号后以当前用户为准),再加载数据库数据
store.syncOwner(userStore.workspaceOwner) store.syncOwner(userStore.workspaceOwner)
store.boot() store.boot()
store.loadPool()
loadWorkspaceOptions() loadWorkspaceOptions()
}) })
</script> </script>
......
...@@ -50,6 +50,11 @@ const routes = [ ...@@ -50,6 +50,11 @@ const routes = [
name: 'resumes', name: 'resumes',
component: () => import('@/views/ResumesView.vue'), component: () => import('@/views/ResumesView.vue'),
}, },
{
path: '/pool',
name: 'pool',
component: () => import('@/views/PoolView.vue'),
},
{ {
path: '/offer', path: '/offer',
name: 'offer', name: 'offer',
......
...@@ -9,6 +9,13 @@ export const navItems = [ ...@@ -9,6 +9,13 @@ export const navItems = [
}, },
{ key: 'jobs', label: '岗位管理', desc: '维护岗位、JD、招聘目标和岗位状态。', path: '/jobs', icon: 'Briefcase' }, { key: 'jobs', label: '岗位管理', desc: '维护岗位、JD、招聘目标和岗位状态。', path: '/jobs', icon: 'Briefcase' },
{ key: 'resumes', label: '简历库', desc: '管理候选人、简历来源、匹配度和流程状态。', path: '/resumes', icon: 'User' }, { 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' }, { key: 'offer', label: '录用入职管理', desc: '生成 Offer 审批草稿并跟进待入职。', path: '/offer', icon: 'Stamp' },
] ]
......
...@@ -230,8 +230,11 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -230,8 +230,11 @@ export const useRecruitmentStore = defineStore('recruitment', {
resumeDetailMode: 'quick', resumeDetailMode: 'quick',
resumeDetailSection: 'overview', resumeDetailSection: 'overview',
focusedTarget: null, focusedTarget: null,
poolCandidates: [],
poolLoading: false,
poolLoaded: false,
jobFilters: { q: '', department: '全部部门', status: '全部状态', scope: 'all' }, jobFilters: { q: '', department: '全部部门', status: '全部状态', scope: 'all' },
resumeFilters: { q: '', stage: '全部阶段', source: '全部来源', minMatch: '全部匹配' }, resumeFilters: { q: '', stage: '全部阶段', source: '全部来源', tag: '全部标签', minMatch: '全部匹配' },
dashboardFilters: { dashboardFilters: {
range: '月', range: '月',
period: currentPeriodKey(), period: currentPeriodKey(),
...@@ -340,6 +343,65 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -340,6 +343,65 @@ export const useRecruitmentStore = defineStore('recruitment', {
this.activePage = key 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() { reconcile() {
this.state = reconcileRecruitmentLinks(this.state) this.state = reconcileRecruitmentLinks(this.state)
}, },
......
import dictionaries from '../../../shared/resume-dictionaries.json' 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' export const STORAGE_KEY = 'recruitment-system-mvp-v1'
// 兼容旧键:开放模式(未启用登录)沿用 recruitment-system-mvp-v1,避免已有浏览器数据丢失 // 兼容旧键:开放模式(未启用登录)沿用 recruitment-system-mvp-v1,避免已有浏览器数据丢失
...@@ -68,7 +95,7 @@ export const seedState = { ...@@ -68,7 +95,7 @@ export const seedState = {
stage: '初筛通过', stage: '初筛通过',
match: 92, match: 92,
resumeName: '张晓明_高级产品经理.pdf', resumeName: '张晓明_高级产品经理.pdf',
tag: '重点关注', tags: ['重点跟进'],
resumeText: 'B 端 SaaS 产品经理,负责 AI 产品、需求分析、数据分析、方案设计和落地推进。', resumeText: 'B 端 SaaS 产品经理,负责 AI 产品、需求分析、数据分析、方案设计和落地推进。',
evaluation: 'B 端产品经验完整,建议进入一面;需确认稳定性和 AI 项目实际负责范围。', evaluation: 'B 端产品经验完整,建议进入一面;需确认稳定性和 AI 项目实际负责范围。',
}, },
...@@ -80,7 +107,7 @@ export const seedState = { ...@@ -80,7 +107,7 @@ export const seedState = {
stage: '面试中', stage: '面试中',
match: 88, match: 88,
resumeName: '李雪梅_产品总监.pdf', resumeName: '李雪梅_产品总监.pdf',
tag: '待面评', tags: ['待二面'],
resumeText: '平台产品负责人,负责产品规划、团队管理、数据分析和跨部门协作。', resumeText: '平台产品负责人,负责产品规划、团队管理、数据分析和跨部门协作。',
evaluation: '平台产品经验较强,管理跨度需要进一步验证。', evaluation: '平台产品经验较强,管理跨度需要进一步验证。',
}, },
...@@ -93,7 +120,7 @@ export const seedState = { ...@@ -93,7 +120,7 @@ export const seedState = {
stage: 'Offer 中', stage: 'Offer 中',
match: 85, match: 85,
resumeName: '王建国_销售总监.pdf', resumeName: '王建国_销售总监.pdf',
tag: '高优先级', tags: ['重点跟进'],
resumeText: '大客户销售总监,负责销售策略、重点客户拓展、团队管理和收入目标达成。', resumeText: '大客户销售总监,负责销售策略、重点客户拓展、团队管理和收入目标达成。',
evaluation: '大客户销售经验强,薪资方案超预算,需要审批说明。', evaluation: '大客户销售经验强,薪资方案超预算,需要审批说明。',
}, },
...@@ -106,7 +133,7 @@ export const seedState = { ...@@ -106,7 +133,7 @@ export const seedState = {
stage: '未筛选', stage: '未筛选',
match: 79, match: 79,
resumeName: '陈思远_前端.pdf', resumeName: '陈思远_前端.pdf',
tag: '待沟通', tags: ['待沟通'],
resumeText: '前端工程师,熟悉 React、Node、工程化、性能优化和核心业务模块开发。', resumeText: '前端工程师,熟悉 React、Node、工程化、性能优化和核心业务模块开发。',
evaluation: '技术栈匹配,项目深度需要初筛确认。', evaluation: '技术栈匹配,项目深度需要初筛确认。',
}, },
...@@ -118,7 +145,7 @@ export const seedState = { ...@@ -118,7 +145,7 @@ export const seedState = {
stage: '初筛未通过', stage: '初筛未通过',
match: 73, match: 73,
resumeName: '刘佳宁_HRBP.pdf', resumeName: '刘佳宁_HRBP.pdf',
tag: '人才池', tags: ['储备'],
resumeText: 'HRBP,熟悉招聘、员工关系和组织发展。', resumeText: 'HRBP,熟悉招聘、员工关系和组织发展。',
evaluation: '当前岗位匹配度一般,可入人才池后续激活。', evaluation: '当前岗位匹配度一般,可入人才池后续激活。',
}, },
......
import { extractKeywords } from './keywords.js' import { extractKeywords } from './keywords.js'
import { FOLLOW_UP_TAGS, normalizeCandidateTags } from './constants.js'
import { buildJobCriteria, jobSearchText } from './job.js' import { buildJobCriteria, jobSearchText } from './job.js'
import { matchedJobForCandidate } from './links.js' import { matchedJobForCandidate } from './links.js'
import { quickMatchScore } from './scoring.js' import { quickMatchScore } from './scoring.js'
...@@ -61,11 +62,19 @@ function matchingScore(matchedCount, mustMatchedCount, jobTitleMatched, resumeTe ...@@ -61,11 +62,19 @@ function matchingScore(matchedCount, mustMatchedCount, jobTitleMatched, resumeTe
return Math.max(45, Math.min(98, score)) 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 = {}) { export function getFilteredCandidates(candidates, filters = {}) {
const q = (filters.q || '').toLowerCase().trim() const q = (filters.q || '').toLowerCase().trim()
return candidates.filter((candidate) => { return candidates.filter((candidate) => {
if (filters.stage && filters.stage !== '全部阶段' && candidate.stage !== filters.stage) return false if (filters.stage && filters.stage !== '全部阶段' && candidate.stage !== filters.stage) return false
if (filters.source && filters.source !== '全部来源' && (candidate.source || '其他') !== filters.source) 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 !== '全部匹配') { if (filters.minMatch && filters.minMatch !== '全部匹配') {
const min = Number(filters.minMatch.replace('≥', '')) const min = Number(filters.minMatch.replace('≥', ''))
const score = quickMatchScore(candidate) const score = quickMatchScore(candidate)
...@@ -73,7 +82,7 @@ export function getFilteredCandidates(candidates, filters = {}) { ...@@ -73,7 +82,7 @@ export function getFilteredCandidates(candidates, filters = {}) {
} }
if (q) { if (q) {
const text = 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 if (!text.includes(q)) return false
} }
return true return true
......
import { seedState, STAGE_ORDER } from './constants.js' import { normalizeCandidateTags, seedState, STAGE_ORDER } from './constants.js'
import { enrichCandidateFields } from './candidate.js' import { enrichCandidateFields } from './candidate.js'
import { cleanCandidateName } from './naming.js' import { cleanCandidateName } from './naming.js'
...@@ -224,6 +224,7 @@ export function normalizeCandidate(candidate = {}, jobs = []) { ...@@ -224,6 +224,7 @@ export function normalizeCandidate(candidate = {}, jobs = []) {
email: enriched.email, email: enriched.email,
name: cleanCandidateName(candidate.name, candidate.resumeMeta?.name || candidate.resumeName), name: cleanCandidateName(candidate.name, candidate.resumeMeta?.name || candidate.resumeName),
resumeName: candidate.resumeMeta?.name || candidate.resumeName, resumeName: candidate.resumeMeta?.name || candidate.resumeName,
tags: normalizeCandidateTags(candidate),
evaluation: /本地匹配度为|当前只有简历文件名|已匹配到/.test(candidate.evaluation || '') ? '' : candidate.evaluation, 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