Commit de329be8 authored by 李文光's avatar 李文光

feat(需求3): 岗位类型细分——jobType 枚举替换 category + 公司级 admin 可配置 + 列表筛选

- 后端: services/job_types.py(data/job-types.json 读写/引用禁删/未分类兜底)+ GET|PUT /api/job-types
- 后端: 启动一次性幂等回填旧 category->jobType; main.py 注册路由
- 前端: constants 默认枚举 + store jobTypeOptions + JobEdit 下拉/JobList 筛选与徽标/JobDetail 展示
- 前端: normalize/search 文本改用 jobType 取代 category; admin 页新增岗位类型配置
- 文档: CONTEXT 词表(岗位类型/商务两口径, category 标 _Avoid)+ ADR 0006; requirements 需求3 标记已实现
- 测试: backend/tests/test_job_types.py(6 例); 前端 build/lint/test 通过
parent 2b995a08
...@@ -6,7 +6,9 @@ ...@@ -6,7 +6,9 @@
- **招聘账号(User)**:可登录系统的招聘人员,分管理员与招聘专员。 - **招聘账号(User)**:可登录系统的招聘人员,分管理员与招聘专员。
- **工作区(Workspace)**:单个招聘账号名下、与其他账号隔离的业务数据集合(岗位 / 候选人 / Offer / 待办 / 事件)。 - **工作区(Workspace)**:单个招聘账号名下、与其他账号隔离的业务数据集合(岗位 / 候选人 / Offer / 待办 / 事件)。
- **开放模式(Open Mode)**:未启用登录时,所有数据归属本地工作区 `local`,行为与单机版一致。 - **会话令牌(Session Token)**:登录成功后由 `/api/auth/login` 签发的无状态 Bearer 凭证(HMAC 签名,约 7 天有效),用于标识请求者身份;采集接口的归属即来自它。_Avoid_: 采集令牌(已废弃)。
- **采集归属(Ingest Owner)**`/api/ingest` 写入的工作区 = 请求携带的有效会话令牌对应的账号工作区;无有效身份 → 401 拒绝,绝不回落默认账号。_Avoid_: 全局采集令牌(`INGEST_TOKEN`,已移除)。
- **开放模式(Open Mode)**`_Avoid`(已移除)。ADR 0007 起系统强制登录,不再有未启用登录时的本地工作区 `local` 形态。
## 招聘对象 ## 招聘对象
...@@ -45,6 +47,10 @@ ...@@ -45,6 +47,10 @@
- **粘贴 JD 结构化(Paste-to-Structured)**:把整段 JD 或填好的需求模板粘进岗位编辑页,自动拆成结构化岗位字段并回填(可仅补空或覆盖);AI 优先,无 AI 时按小节标题本地切分,不依赖手工逐段粘贴。 - **粘贴 JD 结构化(Paste-to-Structured)**:把整段 JD 或填好的需求模板粘进岗位编辑页,自动拆成结构化岗位字段并回填(可仅补空或覆盖);AI 优先,无 AI 时按小节标题本地切分,不依赖手工逐段粘贴。
- **对内约束(Internal Constraints)**:岗位对内执行的要求——性别 / 年龄 / 试用期与转正标准 / 竞业协议 / 入职验证方式。属于用人部门需求,但严禁进入对外 JD 或 LLM prompt。 - **对内约束(Internal Constraints)**:岗位对内执行的要求——性别 / 年龄 / 试用期与转正标准 / 竞业协议 / 入职验证方式。属于用人部门需求,但严禁进入对外 JD 或 LLM prompt。
- **对外 JD(External JD)**:仅供招聘平台展示、面向候选人的岗位说明;由系统从对外字段组装并自动剥离一切对内约束(同「对内约束」)。 - **对外 JD(External JD)**:仅供招聘平台展示、面向候选人的岗位说明;由系统从对外字段组装并自动剥离一切对内约束(同「对内约束」)。
- **岗位类型(Job Type)**:岗位的职能/业务分类枚举,公司级 admin 可配置(`data/job-types.json`),用于信息展示与列表筛选;一个岗位恰好一个类型,岗位行存稳定 `value`(如 `rd`/`sales`/`commerce-sales`),配置维护 `value → label/order/active`。系统内置不可删/不可归档的兜底类型「未分类」(`unclassified`)。_Avoid_: 岗位类别(`category`,已被本字段取代,自由文本不再展示/筛选)、招聘需求(需求本身,非类型)。
- **商务-销售口径(Business-Sales)**:面向销售客户的商务岗位,按销售结果/回款考核,区别于投标口径。
- **商务-投标口径(Business-Bid)**:面向招投标/项目投标的商务岗位,按投标过程与中标负责,区别于销售口径。二者是两种不同岗位,不共用「商务」这一模糊词。
## 外部岗位与审批 ## 外部岗位与审批
...@@ -75,3 +81,4 @@ ...@@ -75,3 +81,4 @@
- **事件日志(Event Log)**:每个关键动作留下的操作记录,用于时间线与追溯。 - **事件日志(Event Log)**:每个关键动作留下的操作记录,用于时间线与追溯。
- **待办(Task)**:需要 HR 跟进的事项,随候选人 / 岗位 / Offer 状态自动增删。 - **待办(Task)**:需要 HR 跟进的事项,随候选人 / 岗位 / Offer 状态自动增删。
- **下推联动**:候选人 / 岗位 / Offer 的状态变化与事件日志、待办自动保持一致。 - **下推联动**:候选人 / 岗位 / Offer 的状态变化与事件日志、待办自动保持一致。
...@@ -17,6 +17,7 @@ from backend.app.routers import ( ...@@ -17,6 +17,7 @@ from backend.app.routers import (
external_claims, external_claims,
health, health,
ingest, ingest,
job_types,
pool, pool,
recruiting_rules, recruiting_rules,
reports, reports,
...@@ -55,11 +56,25 @@ def _ensure_admin() -> None: ...@@ -55,11 +56,25 @@ def _ensure_admin() -> None:
session.close() session.close()
def _backfill_job_types() -> None:
"""启动时把旧 category 自由文本一次性幂等回填为 jobType 枚举值(ADR 0006)。"""
from backend.app.services.job_types import migrate_category_to_job_type
session = SessionLocal()
try:
migrated = migrate_category_to_job_type(session)
if migrated:
print(f"[startup] 岗位类型回填: {migrated} 个岗位") # noqa: T201
finally:
session.close()
def create_app() -> FastAPI: def create_app() -> FastAPI:
settings = get_settings() settings = get_settings()
settings.ensure_runtime_dirs() settings.ensure_runtime_dirs()
create_all() create_all()
_ensure_admin() _ensure_admin()
_backfill_job_types()
app = FastAPI(title="招聘系统", version="0.6.0") app = FastAPI(title="招聘系统", version="0.6.0")
app.add_middleware( app.add_middleware(
...@@ -78,6 +93,7 @@ def create_app() -> FastAPI: ...@@ -78,6 +93,7 @@ def create_app() -> FastAPI:
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.include_router(pool.router, prefix="/api")
app.include_router(job_types.router, prefix="/api")
app.include_router(external_claims.router, prefix="/api") app.include_router(external_claims.router, prefix="/api")
app.include_router(recruiting_rules.router, prefix="/api") app.include_router(recruiting_rules.router, prefix="/api")
app.include_router(agent.router, prefix="/api") app.include_router(agent.router, prefix="/api")
......
"""岗位类型配置路由(ADR 0006)。
- GET /api/job-types 读配置(任何已登录用户;开放模式下合成本地面板即 admin)
- PUT /api/job-types 整档覆盖(仅 admin)
"""
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, require_admin
from backend.app.services.job_types import JobTypeError, read_job_types, write_job_types
router = APIRouter()
@router.get("/job-types")
def get_job_types(_: CurrentUser = Depends(get_current_user)) -> dict[str, Any]:
return {"ok": True, "jobTypes": read_job_types()}
@router.put("/job-types")
def put_job_types(
payload: dict[str, Any],
db: Session = Depends(get_db),
admin: CurrentUser = Depends(require_admin),
) -> dict[str, Any]:
document = payload.get("jobTypes", payload)
try:
updated = write_job_types(db, document, admin.username)
except JobTypeError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
except (ValueError, OSError) as exc:
raise HTTPException(status_code=400, detail=f"岗位类型保存失败: {exc}") from exc
return {"ok": True, "jobTypes": updated}
"""岗位类型配置服务(ADR 0006):公司级 admin 可配置的枚举列表。
沿用「招聘规则库」先例:data/job-types.json 单文件 + 读写 API,不进业务表、
不进整包 state、无 owner_id。岗位行只存稳定 value(jobs.data.jobType),
配置维护 value -> {label, order, active},改名不破坏存量。
- GET /api/job-types 任何已登录用户可读
- PUT /api/job-types 仅 admin 整档覆盖(校验:value 唯一、unclassified 保留、被引用类型禁删)
"""
from __future__ import annotations
import json
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from backend.app.config import get_settings
from backend.app.models import Job
from backend.app.repositories.json_utils import parse_json
JOB_TYPES_FILENAME = "job-types.json"
UNCLASSIFIED = "unclassified"
UNCLASSIFIED_LABEL = "未分类"
# 初始种子(含系统内置兜底 unclassified,不可删/不可归档)
DEFAULT_JOB_TYPES: list[dict[str, Any]] = [
{"value": UNCLASSIFIED, "label": UNCLASSIFIED_LABEL, "order": 0, "active": True},
{"value": "rd", "label": "研发", "order": 1, "active": True},
{"value": "solution", "label": "解决方案", "order": 2, "active": True},
{"value": "sales", "label": "销售", "order": 3, "active": True},
{"value": "commerce-sales", "label": "商务-销售口径", "order": 4, "active": True},
{"value": "commerce-bid", "label": "商务-投标口径", "order": 5, "active": True},
{"value": "product", "label": "产品", "order": 6, "active": True},
{"value": "function", "label": "职能", "order": 7, "active": True},
{"value": "tech", "label": "技术", "order": 8, "active": True},
]
# 旧自由文本 category -> jobType value 的迁移映射(ADR 0006,一次性幂等回填)
_CATEGORY_TO_JOB_TYPE: dict[str, str] = {
"": UNCLASSIFIED,
"默认岗位": UNCLASSIFIED,
"未分类": UNCLASSIFIED,
"技术岗": "tech",
"职能岗": "function",
"研发": "rd",
"解决方案": "solution",
"售前": "solution",
"销售": "sales",
"产品": "product",
"商务": "commerce-sales", # 旧「商务」未分口径,默认落到销售口径,由 HR 后续修正
}
class JobTypeError(Exception):
"""岗位类型配置操作失败,携带 HTTP 状态与面向用户的中文提示。"""
def __init__(self, status_code: int, detail: str) -> None:
super().__init__(detail)
self.status_code = status_code
self.detail = detail
def job_types_path():
settings = get_settings()
settings.data_dir.mkdir(parents=True, exist_ok=True)
return settings.data_dir / JOB_TYPES_FILENAME
def _normalize_item(item: dict[str, Any]) -> dict[str, Any]:
value = str(item.get("value") or "").strip()
if not value:
raise JobTypeError(400, "岗位类型缺少 value")
label = str(item.get("label") or value).strip()
try:
order = int(item.get("order") or 0)
except (TypeError, ValueError):
order = 0
active = bool(item.get("active", True))
return {"value": value, "label": label, "order": order, "active": active}
def _serialize(types: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{"value": t["value"], "label": t["label"], "order": t["order"], "active": t["active"]}
for t in types
]
def _ensure_unclassified(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""保证系统兜底「未分类」恒存在、恒启用。"""
normalized = [_normalize_item(item) for item in items]
if not any(item["value"] == UNCLASSIFIED for item in normalized):
normalized.insert(0, dict(DEFAULT_JOB_TYPES[0]))
else:
for item in normalized:
if item["value"] == UNCLASSIFIED:
item["active"] = True
return sorted(normalized, key=lambda item: (item["order"], item["value"]))
def read_job_types() -> list[dict[str, Any]]:
"""读取配置;文件缺失/损坏时返回默认档。unclassified 恒存在并启用。"""
path = job_types_path()
if not path.exists():
return _serialize(DEFAULT_JOB_TYPES)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return _serialize(DEFAULT_JOB_TYPES)
items = data if isinstance(data, list) else data.get("jobTypes", []) if isinstance(data, dict) else []
items = [item for item in items if isinstance(item, dict)]
return _serialize(_ensure_unclassified(items))
def _referenced_job_type_values(session: Session) -> set[str]:
"""扫描全公司岗位行(跨所有 owner),返回 data.jobType 中被引用的 value 集合。"""
values: set[str] = set()
for item in session.scalars(select(Job)).all():
data = parse_json(item.data, {}) or {}
job_type = str(data.get("jobType") or "").strip()
if job_type:
values.add(job_type)
return values
def write_job_types(session: Session, document: Any, updated_by: str = "") -> list[dict[str, Any]]:
"""整档覆盖配置,记录更新人与时间。
校验与约束:
- document 必须是 list[dict];每个 dict 必须有唯一、非空 value。
- 系统兜底 `unclassified` 必须存在且 active(不可被移除/归档)。
- 删除(从列表移除)仍被岗位引用的类型会返回 409,需先迁移岗位;归档(active=False)允许,
仅对新建岗位不可选。
"""
if not isinstance(document, list):
raise JobTypeError(400, "岗位类型必须是列表")
new_types = _ensure_unclassified([item for item in document if isinstance(item, dict)])
# value 唯一性
seen: set[str] = set()
for item in new_types:
if item["value"] in seen:
raise JobTypeError(400, f"岗位类型 value 重复: {item['value']}")
seen.add(item["value"])
if not any(item["value"] == UNCLASSIFIED and item["active"] for item in new_types):
raise JobTypeError(400, "系统兜底类型「未分类」不可被移除或归档")
# 引用禁删:被岗位引用的类型不能从列表移除(软归档允许)
old_types = read_job_types()
removed = {item["value"] for item in old_types} - seen
if removed:
referenced = _referenced_job_type_values(session)
blocked = sorted(removed & referenced)
if blocked:
raise JobTypeError(409, f"类型 {', '.join(blocked)} 仍被岗位引用,请先迁移这些岗位")
updated = _serialize(
_ensure_unclassified(
[
{"value": item["value"], "label": item["label"], "order": item["order"], "active": item["active"]}
for item in new_types
]
)
)
# 提前序列化校验,避免把不可序列化内容写进磁盘
payload = {"jobTypes": updated, "updatedAt": _now(), "updatedBy": updated_by or ""}
json.dumps(payload, ensure_ascii=False)
job_types_path().write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return updated
def migrate_category_to_job_type(session: Session) -> int:
"""一次性幂等回填:把现有 jobs.data.category 映射写入 data.jobType(ADR 0006)。
只对缺失 jobType 的岗位回填;已写入的不动。返回回填条数。
"""
from backend.app.models import utc_now_iso
count = 0
now = utc_now_iso()
for item in session.scalars(select(Job)).all():
data = parse_json(item.data, {}) or {}
if str(data.get("jobType") or "").strip():
continue
category = str(data.get("category") or "").strip()
data["jobType"] = _CATEGORY_TO_JOB_TYPE.get(category, UNCLASSIFIED)
data["updatedAt"] = data.get("updatedAt") or now
item.data = json.dumps(data, ensure_ascii=False)
count += 1
if count:
session.commit()
return count
def _now() -> str:
from backend.app.models import utc_now_iso
return utc_now_iso()
"""需求 3 / ADR 0006:岗位类型配置(公司级 admin 可配置)。
- GET /api/job-types 登录可读,默认含内置兜底「未分类」(unclassified)。
- PUT /api/job-types 仅 admin;校验 value 唯一、unclassified 保留、被引用类型禁删。
"""
from __future__ import annotations
def _job(job_id: str, job_type: str = "sales") -> dict:
return {
"id": job_id,
"title": f"测试岗位-{job_id}",
"department": "测试部",
"status": "招聘中",
"owner": "local",
"priority": "中",
"headcount": 1,
"hired": 0,
"jdStatus": "待确认",
"approvalStatus": "未发起",
"jdVersion": "v1",
"matchRuleStatus": "未配置",
"jobType": job_type,
"createdAt": "2026-09-07T00:00:00.000Z",
"updatedAt": "2026-09-07T00:00:00.000Z",
}
def _state(jobs: list[dict]) -> dict:
return {"jobs": jobs, "candidates": [], "offers": [], "tasks": [], "eventLog": []}
def test_get_default_job_types(client):
resp = client.get("/api/job-types")
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
values = [item["value"] for item in body["jobTypes"]]
assert "unclassified" in values
unclassified = next(item for item in body["jobTypes"] if item["value"] == "unclassified")
assert unclassified["active"] is True and unclassified["label"] == "未分类"
def test_put_job_types_roundtrip(client):
new_types = [
{"value": "unclassified", "label": "未分类", "order": 0, "active": True},
{"value": "custom", "label": "自定义岗", "order": 1, "active": True},
]
resp = client.put("/api/job-types", json={"jobTypes": new_types})
assert resp.status_code == 200
assert [item["value"] for item in resp.json()["jobTypes"]] == ["unclassified", "custom"]
# 持久化:重新读(模拟新会话;open 模式同一 data_dir)
got = client.get("/api/job-types").json()["jobTypes"]
assert [item["value"] for item in got] == ["unclassified", "custom"]
def test_unclassified_always_preserved(client):
# 即便提交列表不含未分类,服务也会自动补回(系统兜底,不可被移除/归档)
resp = client.put("/api/job-types", json={"jobTypes": [{"value": "rd", "label": "研发", "order": 1, "active": True}]})
assert resp.status_code == 200
values = [item["value"] for item in resp.json()["jobTypes"]]
assert "unclassified" in values
def test_duplicate_value_rejected(client):
resp = client.put(
"/api/job-types",
json={
"jobTypes": [
{"value": "unclassified", "label": "未分类", "order": 0, "active": True},
{"value": "rd", "label": "研发", "order": 1, "active": True},
{"value": "rd", "label": "研发2", "order": 2, "active": True},
]
},
)
assert resp.status_code == 400
assert "重复" in resp.json()["error"]
def test_cannot_delete_referenced_type(client):
# 先建一个 jobType=sales 的岗位
assert client.put("/api/state", json=_state([_job("job-sales-1", "sales")])).status_code == 200
# 尝试从配置中删除 sales(不包含 sales) -> 409
resp = client.put(
"/api/job-types",
json={
"jobTypes": [
{"value": "unclassified", "label": "未分类", "order": 0, "active": True},
{"value": "rd", "label": "研发", "order": 1, "active": True},
]
},
)
assert resp.status_code == 409
assert "sales" in resp.json()["error"]
def test_archive_referenced_type_allowed(client):
# 建一个 jobType=sales 的岗位
assert client.put("/api/state", json=_state([_job("job-sales-2", "sales")])).status_code == 200
# 软归档 sales(active=False) 但不删除 -> 200
resp = client.put(
"/api/job-types",
json={
"jobTypes": [
{"value": "unclassified", "label": "未分类", "order": 0, "active": True},
{"value": "sales", "label": "销售", "order": 1, "active": False},
]
},
)
assert resp.status_code == 200
sales = next(item for item in resp.json()["jobTypes"] if item["value"] == "sales")
assert sales["active"] is False
# ADR 0006:岗位类型枚举(替换岗位类别 + 公司级 admin 可配置)
- 状态:已接受(2026-09-07)
- 关联需求:需求 3(岗位类型细分);关联 CONTEXT 词表「岗位类型 / 商务-销售口径 / 商务-投标口径」。
## 背景 / Context
HR 访谈(P2):「岗位类型我还没细分;有研发、解决方案(售前)、销售、商务(两种定义)、产品(暂无)」。
现状核对:岗位无类型枚举字段,仅 `category` 自由文本(默认「默认岗位」,placeholder「例如:技术岗、职能岗」),**不参与筛选**,代码中无 `jobType` 字段;岗位业务数据以整包 JSON 存于 `jobs.data`,无独立 `jobType` 列。
需求:岗位加 `jobType` 枚举(研发/解决方案/销售/商务-销售口径/商务-投标口径/产品/职能/技术),**可配置**,并进列表筛选。
## 决策 / Decision
1. **`jobType` 取代 `category`**:岗位只保留一个类型字段(`data.jobType`,存稳定 `value`),不再展示/筛选自由文本 `category`;旧 `category` 仅留在 `data` 作历史存档(不再写入、不参与业务)。术语统一为「岗位类型」,「岗位类别(category)」废弃(CONTEXT 标 `_Avoid`)。
2. **扁平单值枚举,无层级**:一个岗位恰好一个类型,不引入 type/subtype 两级。「商务」的两义直接拆成两个枚举值。初始值:`rd`(研发)/`solution`(解决方案)/`sales`(销售)/`commerce-sales`(商务-销售口径)/`commerce-bid`(商务-投标口径)/`product`(产品)/`function`(职能)/`tech`(技术),外加系统内置兜底 `unclassified`(未分类)。
3. **公司级 admin 可配置**:沿用「招聘规则库」先例——`data/job-types.json` 单文件 + `GET /api/job-types`(登录可读)+ `PUT /api/job-types`(admin 整档覆盖)。**不进业务表、不进整包 state、无 owner_id**。配置项 `{value, label, order, active}`;岗位行只存 `value`,配置维护 `value → label/order/active`,改名不破坏存量。
4. **生命周期**:用 `active=false` 软归档,不做硬删;**禁止删除/归档仍被岗位引用的类型**(返回 409 及提示);`unclassified` 内置不可删/不可归档,且为新岗位默认类型;新建岗位下拉只出 `active` 类型,已归档类型对在用岗位仍按原 label 展示。
5. **迁移(一次性、幂等)**:后端启动时把现有 `jobs.data.category` 按映射表写入 `data.jobType`——`默认岗位`/空/`未分类``unclassified``技术岗``tech``职能岗``function``研发``rd`;未知自由文本 → `unclassified`
## 影响 / Consequences
- 正向:岗位类型结构化,可在列表按类型筛选;admin 可增删改/排序/归档;历史 `category` 保留在 `data` 不丢信息。
- 代价/注意:`jobType``jobs.data` JSON(无独立列),前后端靠 `normalizeJob``state_repository` 的 JSON 透传保持一致;列表筛选在前端内存完成,后端不提供按类型查询;`unclassified` 为系统兜底、不可删;旧库依赖启动回填(幂等,重复执行安全)。
- 后续可做:若后端需按类型生成 JD 模板或做统计,再升级为业务表或抽成共享词表(复用共享词典机制);可做按大类(如 `commerce-` 前缀)聚合的看板。
...@@ -14,6 +14,8 @@ ...@@ -14,6 +14,8 @@
> >
> **2026-09-04 增量(需求 25/23/11:期望薪资 + 沟通职位)**:候选人新增独立 **`expectedSalary`(期望薪资)** 字段——简历解析(LLM+正则)、采集入库、新建/批量表单与详情抽屉均可写入,旧数据从入库备注「期望薪资:…」自动回填;简历库卡片/人才池/详情/新建与批量导入全链路展示与手填。采集插件升 **v1.3** 补抓「期望薪资」与平台「沟通职位」文本上传;沟通职位仅在候选人自身求职岗位为空时兜底为 `jobTitle`,不覆盖已有值。详见需求 11/23/25 条目。 > **2026-09-04 增量(需求 25/23/11:期望薪资 + 沟通职位)**:候选人新增独立 **`expectedSalary`(期望薪资)** 字段——简历解析(LLM+正则)、采集入库、新建/批量表单与详情抽屉均可写入,旧数据从入库备注「期望薪资:…」自动回填;简历库卡片/人才池/详情/新建与批量导入全链路展示与手填。采集插件升 **v1.3** 补抓「期望薪资」与平台「沟通职位」文本上传;沟通职位仅在候选人自身求职岗位为空时兜底为 `jobTitle`,不覆盖已有值。详见需求 11/23/25 条目。
> >
> **2026-09-07 增量(需求 3:岗位类型细分)**:岗位新增 `jobType` 枚举(扁平单值 + 公司级 admin 可配置),替换自由文本 `category` —— 后端 `services/job_types.py`(data/job-types.json 读写 + 引用禁删 + 内置「未分类」兜底)+ `GET/PUT /api/job-types`;岗位行 `data.jobType` 存稳定 value,旧 `category` 启动一次性幂等回填(ADR 0006)。前端:JobEdit 下拉、JobList 按类型筛选 + 类型徽标、JobDetail 展示「岗位类型」;admin 页「用户与权限」内可增删改/排序/软归档。
>
> 本次复核相对上一版(810e280)新增提交:`ddc5340`(候选人详情抽屉+档案编辑)、`07310dd`(简历库重构+保存原简历+防重复)、`6248f27`/`fc0aa5c`(简历解析真正 LLM 优先并移除脱敏)、`ec9499f`(弹窗蒙层/ESC 不关闭)。 > 本次复核相对上一版(810e280)新增提交:`ddc5340`(候选人详情抽屉+档案编辑)、`07310dd`(简历库重构+保存原简历+防重复)、`6248f27`/`fc0aa5c`(简历解析真正 LLM 优先并移除脱敏)、`ec9499f`(弹窗蒙层/ESC 不关闭)。
> >
> 依据代码现状核实: > 依据代码现状核实:
...@@ -54,8 +56,8 @@ HR 原话要点: ...@@ -54,8 +56,8 @@ HR 原话要点:
**需求 3:岗位类型细分** **需求 3:岗位类型细分**
- 原话:「岗位类型我还没细分;有研发、解决方案(售前)、销售、商务(两种定义)、产品(暂无)」(P2) - 原话:「岗位类型我还没细分;有研发、解决方案(售前)、销售、商务(两种定义)、产品(暂无)」(P2)
- 现状:🔄 无岗位类型枚举字段,仅 `category` 自由文本(默认"默认岗位",placeholder"例如:技术岗、职能岗"),不参与筛选(代码中无 `jobType` 字段) - 现状:**已实现(2026-09-07)**。岗位新增 `jobType` 枚举(扁平单值,公司级 admin 可配置)——`backend/app/services/job_types.py`(data/job-types.json 读写 + 引用禁删 + 内置「未分类」兜底)+ `GET/PUT /api/job-types`;岗位行 `data.jobType` 存稳定 value,旧 `category` 启动一次性幂等回填(ADR 0006)。前端:JobEdit 下拉、JobList 按类型筛选 + 类型徽标、JobDetail 展示「岗位类型」;`normalizeJob``jobType` 取代 `category`
- 建议(🆕):岗位加 `jobType` 枚举(研发/解决方案/销售/商务-销售口径/商务-投标口径/产品/职能/技术),可配置,并进列表筛选 - 说明:类型初始值 研发/解决方案/销售/商务-销售口径/商务-投标口径/产品/职能/技术 + 内置「未分类」;admin 可在「用户与权限」页「岗位类型配置」增删改/排序/软归档(被引用类型禁删)
**需求 4:招聘人数联动自动关闭** **需求 4:招聘人数联动自动关闭**
- 原话:「目标招 2 个,已录用 2 个,数值相等就自动已关闭;招 1 个就够也能手动关闭」(P2) - 原话:「目标招 2 个,已录用 2 个,数值相等就自动已关闭;招 1 个就够也能手动关闭」(P2)
...@@ -241,7 +243,7 @@ HR 原话要点: ...@@ -241,7 +243,7 @@ HR 原话要点:
| P2 | 需求 22 简历库标签/统计/批量 | ✅ 已实现(2026-09-04) | 批量移入/打标签/批量认领 + file_hash 库级去重 | | P2 | 需求 22 简历库标签/统计/批量 | ✅ 已实现(2026-09-04) | 批量移入/打标签/批量认领 + file_hash 库级去重 |
| P3 | 需求 23 插件字段修正 | ✅ 已补(2026-09-04, v1.3) | 补抓沟通职位/期望薪资;DOM 选择器待真站人工验收 | | P3 | 需求 23 插件字段修正 | ✅ 已补(2026-09-04, v1.3) | 补抓沟通职位/期望薪资;DOM 选择器待真站人工验收 |
| P3 | 需求 21 入职材料三件套 | 🔄 部分 | 三张模板化 | | P3 | 需求 21 入职材料三件套 | 🔄 部分 | 三张模板化 |
| P3 | 需求 3 岗位类型枚举 / 需求 25 期望薪资展示 | ✅ 25 已实现(2026-09-04) | 3 待 HR 定义岗位类型枚举后再做 | | P3 | 需求 3 岗位类型枚举 / 需求 25 期望薪资展示 | ✅ 均已实现(需求 3:2026-09-07;需求 25:2026-09-04) | 岗位类型=扁平枚举+公司级 admin 可配置+列表筛选 |
| P3 | 需求 26 UI 玻璃拟态 | 🔄 部分 | 顶栏已毛玻璃,整体待 HR 截图后统一 | | 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。**同日(工作区)续补**:需求 17 风险点固定维度亦已实现(risk_profile.py 四维本地规则 + LLM prompt 限定 + 前端「风险点评估」面板),见需求 17 条目。 **近期已补(2026-09,不再列入待办)**:需求 10 候选人详情抽屉 + PDF 档案预览(`ddc5340`/`07310dd`/`ec9499f`);需求 24 简历解析改 LLM 优先、`fc0aa5c` 移除解析链路脱敏(评估链路仍脱敏)。**同日(工作区未提交)再补**:需求 1「总库=人才池」(跨账号共享池 + 移入/认领)、需求 12 标签结构化与筛选、需求 20 人才池联动、需求 22 人才池独立视图——详见各需求条目与 docs/adr/0001-company-talent-pool.md。**同日(工作区)续补**:需求 17 风险点固定维度亦已实现(risk_profile.py 四维本地规则 + LLM prompt 限定 + 前端「风险点评估」面板),见需求 17 条目。
...@@ -258,4 +260,4 @@ HR 原话要点: ...@@ -258,4 +260,4 @@ HR 原话要点:
--- ---
*整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。需求 14/17(自述优势约束、风险点固定维度)、需求 4 自动招满、岗位维简历量统计已于 2026-09-03 落地;需求 9(粘贴 JD 自动结构化 + 招聘需求模板)、需求 22 补全(批量移入/复活、file_hash 库级去重)与需求 25/23(期望薪资独立字段+插件补抓沟通职位、插件 v1.3)均已于 2026-09-04 落地;后续按需做需求 3(岗位类型枚举,待 HR 定义)与需求 12 周报视图等小改动。* *整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。需求 14/17(自述优势约束、风险点固定维度)、需求 4 自动招满、岗位维简历量统计已于 2026-09-03 落地;需求 9(粘贴 JD 自动结构化 + 招聘需求模板)、需求 22 补全(批量移入/复活、file_hash 库级去重)与需求 25/23(期望薪资独立字段+插件补抓沟通职位、插件 v1.3)均已于 2026-09-04 落地;需求 3(岗位类型枚举,扁平单值 + 公司级 admin 可配置)已于 2026-09-07 落地(ADR 0006);剩余需求 12 周报视图等小改动。*
import http, { readToken } from '@/api' import http, { readToken } from '@/api'
export async function fetchJobTypes() {
const { data } = await http.get('/api/job-types')
if (data.error) throw new Error(data.error)
return data
}
export async function saveJobTypes(jobTypes) {
const { data } = await http.put('/api/job-types', { jobTypes })
if (data.error) throw new Error(data.error)
return data
}
export async function fetchPool() { export async function fetchPool() {
const { data } = await http.get('/api/pool') const { data } = await http.get('/api/pool')
if (data.error) throw new Error(data.error) if (data.error) throw new Error(data.error)
......
...@@ -6,6 +6,7 @@ import { ElMessageBox } from 'element-plus' ...@@ -6,6 +6,7 @@ import { ElMessageBox } from 'element-plus'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { jobActionFor, jobWorkflow, buildJobCriteria, jobStateBadge } from '@/utils/jobFlow' import { jobActionFor, jobWorkflow, buildJobCriteria, jobStateBadge } from '@/utils/jobFlow'
import { resolveChannelStatus } from '@/utils/job' import { resolveChannelStatus } from '@/utils/job'
import { jobTypeLabel } from '@/utils/constants'
import { jobCandidates } from '@/utils/links' import { jobCandidates } from '@/utils/links'
import { workflowStageClass } from '@/utils/task' import { workflowStageClass } from '@/utils/task'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
...@@ -678,7 +679,7 @@ const mainAction = async () => { ...@@ -678,7 +679,7 @@ const mainAction = async () => {
<span>负责人</span><strong>{{ job.owner || '待指定' }}</strong> <span>负责人</span><strong>{{ job.owner || '待指定' }}</strong>
</div> </div>
<div> <div>
<span>岗位类</span><strong>{{ job.category || '默认岗位' }}</strong> <span>岗位类</span><strong>{{ jobTypeLabel(job.jobType, store.jobTypeOptions) }}</strong>
</div> </div>
<div> <div>
<span>招聘人数</span><strong>{{ Number(job.headcount || 0) }} 人</strong> <span>招聘人数</span><strong>{{ Number(job.headcount || 0) }} 人</strong>
......
...@@ -5,6 +5,7 @@ import { ElMessageBox } from 'element-plus' ...@@ -5,6 +5,7 @@ import { ElMessageBox } from 'element-plus'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { normalizeJob, uid } from '@/utils/normalize' import { normalizeJob, uid } from '@/utils/normalize'
import { JOB_TYPE_UNCLASSIFIED } from '@/utils/constants'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { jdQualityCheck, qualityPromptText } from '@/utils/jdQuality' import { jdQualityCheck, qualityPromptText } from '@/utils/jdQuality'
import { import {
...@@ -33,11 +34,19 @@ const existing = computed(() => (props.jobId ? store.state.jobs.find((item) => i ...@@ -33,11 +34,19 @@ const existing = computed(() => (props.jobId ? store.state.jobs.find((item) => i
const isNew = computed(() => !existing.value) const isNew = computed(() => !existing.value)
// 岗位类型下拉:显示启用类型;若当前值已被归档(active=false),补一个只读选项,避免下拉「显示原始值/丢标签」
const jobTypeSelectOptions = computed(() => {
const active = store.activeJobTypeOptions
if (active.some((t) => t.value === form.jobType)) return active
const current = store.jobTypeOptions.find((t) => t.value === form.jobType)
return current ? [...active, { ...current, active: true }] : active
})
const form = reactive({ const form = reactive({
jobId: '', jobId: '',
title: '', title: '',
department: '', department: '',
category: '默认岗位', jobType: JOB_TYPE_UNCLASSIFIED,
headcount: 1, headcount: 1,
owner: 'Charie', owner: 'Charie',
salaryRange: '', salaryRange: '',
...@@ -85,7 +94,7 @@ const syncForm = () => { ...@@ -85,7 +94,7 @@ const syncForm = () => {
form.jobId = job?.id || '' form.jobId = job?.id || ''
form.title = job?.title || '' form.title = job?.title || ''
form.department = job?.department || '' form.department = job?.department || ''
form.category = job?.category || '默认岗位' form.jobType = job?.jobType || JOB_TYPE_UNCLASSIFIED
form.headcount = Number(job?.headcount || 1) form.headcount = Number(job?.headcount || 1)
form.owner = job?.owner || 'Charie' form.owner = job?.owner || 'Charie'
form.salaryRange = job?.salaryRange || '' form.salaryRange = job?.salaryRange || ''
...@@ -134,7 +143,7 @@ const aiAssistCreate = async () => { ...@@ -134,7 +143,7 @@ const aiAssistCreate = async () => {
const baseJob = { const baseJob = {
title: form.title, title: form.title,
department: form.department, department: form.department,
category: form.category, jobType: form.jobType,
headcount: form.headcount, headcount: form.headcount,
owner: form.owner, owner: form.owner,
salaryRange: form.salaryRange, salaryRange: form.salaryRange,
...@@ -232,7 +241,7 @@ const submit = async (saveMode = 'update') => { ...@@ -232,7 +241,7 @@ const submit = async (saveMode = 'update') => {
createdAt: existing.value?.createdAt || new Date().toISOString(), createdAt: existing.value?.createdAt || new Date().toISOString(),
title: form.title, title: form.title,
department: form.department, department: form.department,
category: form.category || '默认岗位', jobType: form.jobType || JOB_TYPE_UNCLASSIFIED,
headcount: Number(form.headcount || 1), headcount: Number(form.headcount || 1),
owner: form.owner || 'Charie', owner: form.owner || 'Charie',
salaryRange: form.salaryRange || '', salaryRange: form.salaryRange || '',
...@@ -417,8 +426,10 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || ' ...@@ -417,8 +426,10 @@ if (isNew.value && ['paste', 'template'].includes(String(route.query.import || '
<el-form-item label="所属部门" required> <el-form-item label="所属部门" required>
<el-input v-model="form.department" /> <el-input v-model="form.department" />
</el-form-item> </el-form-item>
<el-form-item label="岗位类别"> <el-form-item label="岗位类型">
<el-input v-model="form.category" placeholder="例如:技术岗、职能岗" /> <el-select v-model="form.jobType" placeholder="选择岗位类型" style="width: 100%">
<el-option v-for="t in jobTypeSelectOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="职级"> <el-form-item label="职级">
<el-input v-model="form.level" placeholder="如 P7、总监" /> <el-input v-model="form.level" placeholder="如 P7、总监" />
......
...@@ -8,6 +8,7 @@ import { jobActionFor, jobNeedsAttention, jobStateBadge } from '@/utils/jobFlow' ...@@ -8,6 +8,7 @@ import { jobActionFor, jobNeedsAttention, jobStateBadge } from '@/utils/jobFlow'
import { jobCandidates } from '@/utils/links' import { jobCandidates } from '@/utils/links'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { normalizeJob, uid } from '@/utils/normalize' import { normalizeJob, uid } from '@/utils/normalize'
import { jobTypeLabel } from '@/utils/constants'
import { JOB_STATUS_OPTIONS, JD_STATUS } from '@/utils/jobStatus' import { JOB_STATUS_OPTIONS, JD_STATUS } from '@/utils/jobStatus'
const store = useRecruitmentStore() const store = useRecruitmentStore()
...@@ -20,12 +21,13 @@ const filteredJobs = computed(() => { ...@@ -20,12 +21,13 @@ const filteredJobs = computed(() => {
const q = (filters.q || '').trim().toLowerCase() const q = (filters.q || '').trim().toLowerCase()
return jobs.value return jobs.value
.filter((job) => { .filter((job) => {
const fields = [job.title, job.department, job.owner, job.category, job.jd, job.matchKeywords] const fields = [job.title, job.department, job.owner, jobTypeLabel(job.jobType), job.jd, job.matchKeywords]
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()
if (q && !fields.includes(q)) return false if (q && !fields.includes(q)) return false
if (filters.department !== '全部部门' && job.department !== filters.department) return false if (filters.department !== '全部部门' && job.department !== filters.department) return false
if (filters.status !== '全部状态' && job.status !== filters.status) return false if (filters.status !== '全部状态' && job.status !== filters.status) return false
if (filters.jobType !== '全部类型' && job.jobType !== filters.jobType) return false
if (filters.scope === 'attention' && !jobNeedsAttention(job)) return false if (filters.scope === 'attention' && !jobNeedsAttention(job)) return false
if (filters.scope === 'recruiting' && job.status !== '招聘中') return false if (filters.scope === 'recruiting' && job.status !== '招聘中') return false
if (filters.scope === 'high' && job.priority !== '高') return false if (filters.scope === 'high' && job.priority !== '高') return false
...@@ -286,6 +288,15 @@ const deleteJob = async (job) => { ...@@ -286,6 +288,15 @@ const deleteJob = async (job) => {
<el-select v-model="store.jobFilters.status" placeholder="招聘状态" style="width: 140px"> <el-select v-model="store.jobFilters.status" placeholder="招聘状态" style="width: 140px">
<el-option v-for="status in statusOptions" :key="status" :label="status" :value="status" /> <el-option v-for="status in statusOptions" :key="status" :label="status" :value="status" />
</el-select> </el-select>
<el-select v-model="store.jobFilters.jobType" placeholder="岗位类型" style="width: 150px">
<el-option label="全部类型" value="全部类型" />
<el-option
v-for="t in store.jobTypeOptions.filter((item) => item.active)"
:key="t.value"
:label="t.label"
:value="t.value"
/>
</el-select>
</div> </div>
</div> </div>
...@@ -301,6 +312,7 @@ const deleteJob = async (job) => { ...@@ -301,6 +312,7 @@ const deleteJob = async (job) => {
{{ job.salaryRange || '薪资待补充' }} {{ job.salaryRange || '薪资待补充' }}
</p> </p>
<div class="job-list-item-tags"> <div class="job-list-item-tags">
<span class="job-type-badge">{{ jobTypeLabel(job.jobType, store.jobTypeOptions) }}</span>
<span class="job-state-badge" :class="jobStateBadge(job.status).tone">{{ <span class="job-state-badge" :class="jobStateBadge(job.status).tone">{{
jobStateBadge(job.status).text jobStateBadge(job.status).text
}}</span> }}</span>
...@@ -629,6 +641,14 @@ const deleteJob = async (job) => { ...@@ -629,6 +641,14 @@ const deleteJob = async (job) => {
font-size: 12px; font-size: 12px;
} }
.job-type-badge {
padding: 2px 8px;
border-radius: 6px;
background: var(--blue);
color: #fff;
font-size: 12px;
}
.job-list-progress { .job-list-progress {
> div:first-child { > div:first-child {
display: flex; display: flex;
......
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRecruitmentStore } from '@/stores/recruitment'
import { JOB_TYPE_UNCLASSIFIED } from '@/utils/constants'
const store = useRecruitmentStore()
const items = ref([])
const original = ref([])
const saving = ref(false)
function load() {
items.value = store.jobTypeOptions.map((t) => ({ ...t }))
original.value = store.jobTypeOptions.map((t) => ({ ...t }))
}
function isNew(item) {
return !original.value.some((t) => t.value === item.value)
}
function addRow() {
items.value.push({ value: '', label: '', order: items.value.length, active: true })
}
function removeRow(item) {
if (item.value === JOB_TYPE_UNCLASSIFIED) {
ElMessage.warning('系统兜底类型「未分类」不可删除')
return
}
const index = items.value.indexOf(item)
if (index >= 0) items.value.splice(index, 1)
}
function toggleActive(item) {
// 兜底类型恒启用;其余走 active 软归档
if (item.value === JOB_TYPE_UNCLASSIFIED) {
item.active = true
ElMessage.warning('系统兜底类型「未分类」不可归档')
}
}
async function save() {
const seen = new Set()
for (const item of items.value) {
const value = String(item.value || '').trim()
if (!value) {
ElMessage.warning('存在未填写 value 的岗位类型')
return
}
if (seen.has(value)) {
ElMessage.warning(`岗位类型 value 重复:${value}`)
return
}
seen.add(value)
item.value = value
item.label = String(item.label || value).trim()
item.order = Number(item.order || 0)
item.active = item.value === JOB_TYPE_UNCLASSIFIED ? true : Boolean(item.active)
}
saving.value = true
try {
await store.saveJobTypes(items.value)
ElMessage.success('岗位类型已保存')
load()
} catch (error) {
ElMessage.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(async () => {
// 若 store 未加载(直接进入本页),先拉取
if (!store.jobTypeOptions.length) await store.loadJobTypes()
load()
})
</script>
<template>
<div class="job-types-config">
<div class="config-head">
<div>
<h3>岗位类型配置</h3>
<p>公司级共享枚举(ADR 0006),用于岗位列表筛选与信息展示;改动需 admin 权限。</p>
</div>
<div class="config-actions">
<el-button @click="addRow">新增类型</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</div>
</div>
<el-table :data="items" border stripe size="small">
<el-table-column prop="value" label="value(稳定键)" min-width="160">
<template #default="{ row }">
<el-input v-model="row.value" :disabled="!isNew(row)" placeholder="如 rd / sales" />
</template>
</el-table-column>
<el-table-column label="label(展示)" min-width="160">
<template #default="{ row }">
<el-input v-model="row.label" placeholder="如 研发 / 销售" />
</template>
</el-table-column>
<el-table-column label="排序" width="100">
<template #default="{ row }">
<el-input-number v-model="row.order" :min="0" :controls="false" />
</template>
</el-table-column>
<el-table-column label="启用" width="90">
<template #default="{ row }">
<el-switch v-model="row.active" :disabled="row.value === JOB_TYPE_UNCLASSIFIED" @change="toggleActive(row)" />
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="danger" :disabled="row.value === JOB_TYPE_UNCLASSIFIED" @click="removeRow(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style lang="scss" scoped>
.job-types-config {
margin-top: 28px;
.config-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 12px;
h3 {
margin: 0 0 4px;
}
p {
margin: 0;
color: var(--text-secondary, #909399);
font-size: 13px;
}
}
.config-actions {
display: flex;
gap: 8px;
}
}
</style>
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { AUTH_STORAGE_KEY, seedState } from '@/utils/constants' import { AUTH_STORAGE_KEY, seedState, DEFAULT_JOB_TYPES } from '@/utils/constants'
import { JOB_STATUS } from '@/utils/jobStatus' import { JOB_STATUS } from '@/utils/jobStatus'
import * as api from '@/api/recruitment' import * as api from '@/api/recruitment'
import { import {
...@@ -261,10 +261,11 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -261,10 +261,11 @@ export const useRecruitmentStore = defineStore('recruitment', {
resumeDetailMode: 'quick', resumeDetailMode: 'quick',
resumeDetailSection: 'overview', resumeDetailSection: 'overview',
focusedTarget: null, focusedTarget: null,
jobTypeOptions: DEFAULT_JOB_TYPES.map((t) => ({ ...t })),
poolCandidates: [], poolCandidates: [],
poolLoading: false, poolLoading: false,
poolLoaded: false, poolLoaded: false,
jobFilters: { q: '', department: '全部部门', status: '全部状态', scope: 'all' }, jobFilters: { q: '', department: '全部部门', status: '全部状态', jobType: '全部类型', scope: 'all' },
resumeFilters: { q: '', stage: '全部阶段', source: '全部来源', tag: '全部标签', minMatch: '全部匹配' }, resumeFilters: { q: '', stage: '全部阶段', source: '全部来源', tag: '全部标签', minMatch: '全部匹配' },
dashboardFilters: { dashboardFilters: {
range: '月', range: '月',
...@@ -301,6 +302,7 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -301,6 +302,7 @@ export const useRecruitmentStore = defineStore('recruitment', {
selectedOffer: (s) => s.state.offers.find((o) => o.id === s.selectedOfferId) || null, selectedOffer: (s) => s.state.offers.find((o) => o.id === s.selectedOfferId) || null,
selectedTask: (s) => s.state.tasks.find((t) => t.id === s.selectedTaskId) || null, selectedTask: (s) => s.state.tasks.find((t) => t.id === s.selectedTaskId) || null,
filteredCandidates: (s) => getFilteredCandidates(s.state.candidates, s.resumeFilters), filteredCandidates: (s) => getFilteredCandidates(s.state.candidates, s.resumeFilters),
activeJobTypeOptions: (s) => s.jobTypeOptions.filter((t) => t.active),
}, },
actions: { actions: {
...@@ -315,10 +317,27 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -315,10 +317,27 @@ export const useRecruitmentStore = defineStore('recruitment', {
this.applyLoadedState(EMPTY_STATE) this.applyLoadedState(EMPTY_STATE)
this.saveStateText = '数据库连接失败' this.saveStateText = '数据库连接失败'
} finally { } finally {
await this.loadJobTypes()
this.loading = false this.loading = false
} }
}, },
async loadJobTypes() {
try {
const payload = await api.fetchJobTypes()
this.jobTypeOptions = (payload.jobTypes || []).map((t) => ({ value: String(t.value), label: String(t.label || t.value), order: Number(t.order || 0), active: Boolean(t.active) }))
} catch {
// 后端不可用/开放模式回退到内置兜底枚举,保证岗位编辑/筛选可用
this.jobTypeOptions = DEFAULT_JOB_TYPES.map((t) => ({ ...t }))
}
},
async saveJobTypes(list) {
const payload = await api.saveJobTypes(list || this.jobTypeOptions)
this.jobTypeOptions = (payload.jobTypes || []).map((t) => ({ ...t }))
return payload
},
// 登录/挂载后校正工作区归属:以当前登录用户为准,避免退出后换号登录残留旧工作区 // 登录/挂载后校正工作区归属:以当前登录用户为准,避免退出后换号登录残留旧工作区
syncOwner(owner = '') { syncOwner(owner = '') {
if (!owner || owner === this.workspaceOwner) return if (!owner || owner === this.workspaceOwner) return
......
import { candidateYears, resumeDisplayName } from './candidate.js' import { candidateYears, resumeDisplayName } from './candidate.js'
import { jobTypeLabel } from './constants.js'
import { buildJobSnapshot } from './job.js' import { buildJobSnapshot } from './job.js'
import { quickMatchScore } from './scoring.js' import { quickMatchScore } from './scoring.js'
...@@ -13,7 +14,7 @@ export function jobSearchText(job = {}) { ...@@ -13,7 +14,7 @@ export function jobSearchText(job = {}) {
job.matchKeywords, job.matchKeywords,
job.title, job.title,
job.department, job.department,
job.category, jobTypeLabel(job.jobType),
job.salaryRange, job.salaryRange,
] ]
.filter(Boolean) .filter(Boolean)
......
...@@ -13,6 +13,28 @@ export const FOLLOW_UP_TAGS = ['重点跟进', '待二面', '待沟通', '需维 ...@@ -13,6 +13,28 @@ export const FOLLOW_UP_TAGS = ['重点跟进', '待二面', '待沟通', '需维
// 人才池(总库)相关常量:与后端 pool_repository.POOL_OWNER 对应 // 人才池(总库)相关常量:与后端 pool_repository.POOL_OWNER 对应
export const POOL_OWNER_LABEL = '公司总库' export const POOL_OWNER_LABEL = '公司总库'
// 岗位类型(需求 3 / ADR 0006):公司级 admin 可配置枚举。此后端 data/job-types.json 为准,
// 此处为内置兜底/初始值;store 加载失败时回退到 DEFAULT_JOB_TYPES。
export const JOB_TYPE_UNCLASSIFIED = 'unclassified'
export const DEFAULT_JOB_TYPES = [
{ value: JOB_TYPE_UNCLASSIFIED, label: '未分类', order: 0, active: true },
{ value: 'rd', label: '研发', order: 1, active: true },
{ value: 'solution', label: '解决方案', order: 2, active: true },
{ value: 'sales', label: '销售', order: 3, active: true },
{ value: 'commerce-sales', label: '商务-销售口径', order: 4, active: true },
{ value: 'commerce-bid', label: '商务-投标口径', order: 5, active: true },
{ value: 'product', label: '产品', order: 6, active: true },
{ value: 'function', label: '职能', order: 7, active: true },
{ value: 'tech', label: '技术', order: 8, active: true },
]
export function jobTypeLabel(jobType = '', options = DEFAULT_JOB_TYPES) {
if (!jobType) return '未分类'
const found = (options || []).find((item) => item.value === jobType)
return found ? found.label : String(jobType)
}
export function normalizeCandidateTags(candidate = {}) { export function normalizeCandidateTags(candidate = {}) {
const rawTags = Array.isArray(candidate.tags) ? candidate.tags : [] const rawTags = Array.isArray(candidate.tags) ? candidate.tags : []
const legacy = candidate.tag || '' const legacy = candidate.tag || ''
...@@ -50,6 +72,7 @@ export const seedState = { ...@@ -50,6 +72,7 @@ export const seedState = {
jobs: [ jobs: [
{ {
id: 'job-1', id: 'job-1',
jobType: 'product',
title: '高级产品经理', title: '高级产品经理',
department: '产品部', department: '产品部',
headcount: 2, headcount: 2,
...@@ -62,6 +85,7 @@ export const seedState = { ...@@ -62,6 +85,7 @@ export const seedState = {
}, },
{ {
id: 'job-2', id: 'job-2',
jobType: 'sales',
title: '销售总监', title: '销售总监',
department: '销售部', department: '销售部',
headcount: 1, headcount: 1,
...@@ -74,6 +98,7 @@ export const seedState = { ...@@ -74,6 +98,7 @@ export const seedState = {
}, },
{ {
id: 'job-3', id: 'job-3',
jobType: 'tech',
title: '高级前端工程师', title: '高级前端工程师',
department: '技术部', department: '技术部',
headcount: 3, headcount: 3,
......
import { extractKeywords } from './keywords.js' import { extractKeywords } from './keywords.js'
import { jobTypeLabel } from './constants.js'
export function splitRuleLines(value = '') { export function splitRuleLines(value = '') {
return String(value || '') return String(value || '')
...@@ -44,7 +45,7 @@ export function jobSearchText(job = {}) { ...@@ -44,7 +45,7 @@ export function jobSearchText(job = {}) {
job.matchKeywords, job.matchKeywords,
job.title, job.title,
job.department, job.department,
job.category, jobTypeLabel(job.jobType),
job.salaryRange, job.salaryRange,
] ]
.filter(Boolean) .filter(Boolean)
......
import { extractKeywords } from './keywords.js' import { extractKeywords } from './keywords.js'
import { jobTypeLabel } from './constants.js'
import { jobCandidates } from './links.js' import { jobCandidates } from './links.js'
import { analysisNeedsRefresh, quickMatchNeedsRefresh } from './analysis.js' import { analysisNeedsRefresh, quickMatchNeedsRefresh } from './analysis.js'
import { JOB_STATUS, JD_STATUS, APPROVAL_STATUS, CHANNEL_STATUS } from './jobStatus.js' import { JOB_STATUS, JD_STATUS, APPROVAL_STATUS, CHANNEL_STATUS } from './jobStatus.js'
...@@ -46,7 +47,7 @@ export function jobSearchText(job = {}) { ...@@ -46,7 +47,7 @@ export function jobSearchText(job = {}) {
job.matchKeywords, job.matchKeywords,
job.title, job.title,
job.department, job.department,
job.category, jobTypeLabel(job.jobType),
job.salaryRange, job.salaryRange,
] ]
.filter(Boolean) .filter(Boolean)
......
import { normalizeCandidateTags, seedState, STAGE_ORDER } from './constants.js' import { normalizeCandidateTags, seedState, STAGE_ORDER, JOB_TYPE_UNCLASSIFIED } from './constants.js'
import { enrichCandidateFields } from './candidate.js' import { enrichCandidateFields } from './candidate.js'
import { cleanCandidateName } from './naming.js' import { cleanCandidateName } from './naming.js'
...@@ -99,6 +99,20 @@ export function createOfferRecord(candidate, overrides = {}) { ...@@ -99,6 +99,20 @@ export function createOfferRecord(candidate, overrides = {}) {
} }
} }
// 旧自由文本「岗位类别」→ 岗位类型枚举值的迁移映射(需求 3 / ADR 0006)
const LEGACY_CATEGORY_TO_JOB_TYPE = {
默认岗位: JOB_TYPE_UNCLASSIFIED,
未分类: JOB_TYPE_UNCLASSIFIED,
技术岗: 'tech',
职能岗: 'function',
研发: 'rd',
解决方案: 'solution',
售前: 'solution',
销售: 'sales',
产品: 'product',
商务: 'commerce-sales',
}
export function normalizeJob(job = {}) { export function normalizeJob(job = {}) {
const jd = job.jd || '' const jd = job.jd || ''
// 兼容旧数据:无 channels 明细时,由 publishedChannels 反推 // 兼容旧数据:无 channels 明细时,由 publishedChannels 反推
...@@ -113,7 +127,7 @@ export function normalizeJob(job = {}) { ...@@ -113,7 +127,7 @@ export function normalizeJob(job = {}) {
jdVersion: job.jdStatus === '已生效' ? job.jdVersion || 'v1' : '', jdVersion: job.jdStatus === '已生效' ? job.jdVersion || 'v1' : '',
})) }))
return { return {
category: '默认岗位', jobType: job.jobType || LEGACY_CATEGORY_TO_JOB_TYPE[job.category] || JOB_TYPE_UNCLASSIFIED,
createdAt: job.createdAt || job.created_at || '', createdAt: job.createdAt || job.created_at || '',
salaryRange: '', salaryRange: '',
jdVersion: job.jdStatus === '已生效' ? 'v1 当前生效' : 'v1 草稿', jdVersion: job.jdStatus === '已生效' ? 'v1 当前生效' : 'v1 草稿',
......
...@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue' ...@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import JobTypesConfig from '@/components/settings/JobTypesConfig.vue'
const userStore = useUserStore() const userStore = useUserStore()
...@@ -161,6 +162,8 @@ onMounted(loadUsers) ...@@ -161,6 +162,8 @@ onMounted(loadUsers)
<el-button type="primary" @click="handleSubmit">保存</el-button> <el-button type="primary" @click="handleSubmit">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<JobTypesConfig />
</div> </div>
</template> </template>
......
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