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 保留引用,不受影响)。
- 后续可做:岗位级共享、批量移入/认领、认领前简历去重比对。
This diff is collapsed.
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