diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
index d351a8e..892f35a 100644
--- a/backend/app/api/__init__.py
+++ b/backend/app/api/__init__.py
@@ -1,6 +1,6 @@
from fastapi import APIRouter
-from app.api import audit, auth, chat, documents, files, permissions, roles, stats, units, users
+from app.api import audit, auth, chat, documents, files, job_titles, permissions, roles, stats, units, users
api_router = APIRouter(prefix="/api")
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -8,6 +8,7 @@ api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(roles.router, prefix="/roles", tags=["roles"])
api_router.include_router(permissions.router, prefix="/permissions", tags=["permissions"])
api_router.include_router(units.router, prefix="/units", tags=["units"])
+api_router.include_router(job_titles.router, prefix="/job-titles", tags=["job-titles"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(documents.router, prefix="/documents", tags=["documents"])
api_router.include_router(audit.router, prefix="/audit-logs", tags=["audit"])
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index a788a66..5b97996 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -38,6 +38,7 @@ def _build_current_user(db: Session, user: User) -> CurrentUserResponse:
phone=user.phone,
name=user.name,
department=user.department,
+ job_title=user.job_title,
unit_id=user.unit_id,
unit_name=user.unit.name if user.unit else None,
status=user.status,
diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py
index 2985fb7..7b08ef6 100644
--- a/backend/app/api/chat.py
+++ b/backend/app/api/chat.py
@@ -25,6 +25,7 @@ from app.schemas.chat import (
)
from app.services.audit_service import write_audit
from app.services.document_service import resolve_chat_doc_scope
+from app.services.user_service import JOB_TITLE_GROUP_ALIASES
def _attachment_data_url(att: Attachment) -> str | None:
@@ -179,11 +180,10 @@ def stream_chat(
)
allowed_tags = [] if current_user.is_superuser else [role.code for role in current_user.roles]
- if current_user.is_superuser:
- role_labels: list[str] = ["系统管理员"]
- else:
- role_labels = [role.name for role in current_user.roles if role.name]
- role_hint = "、".join(role_labels)
+ # role_hint 必须是业务岗位(如"总院中层副职""员工"),用来匹配制度里按职级分组的条款。
+ # 只取 User.job_title;权限角色(employee/admin/system_admin)和 is_superuser 不参与,
+ # 否则会把"系统管理员"塞进制度职级匹配, LLM 找不到对应分组就反问澄清。
+ role_hint = (current_user.job_title or "").strip()
# 按用户所属单位(含逐级向上回退)计算允许引用的文档 title 集合。
# 超管/管理员或用户没绑定单位 -> 不做过滤(allowed_titles=None)。
@@ -249,14 +249,24 @@ def stream_chat(
raw_question = payload.question or ""
hint_lines: list[str] = []
+ # 引用格式规则始终注入,与提问人职位无关。
+ # finance_assistant.md 系统 prompt 里写着"引用由系统自动展示,不用手写",这里覆盖它;
+ # 独立成条防止 job_title=None 的账号(如系统管理员)拿不到规则,导致回答漏"依据文档"。
+ hint_lines.append(
+ "【引用格式】不要在正文开头写\"根据《XX规定》...\"这类引用开场;"
+ "把制度出处放在整段回答的最末尾,另起一行以\"依据文档:《文档全名》(文号,如有)\"的格式列出;"
+ "引用多份文档时每份单独一行。"
+ "此规则最高优先级:即使命中 FAQ/标准答案,或已在正文其他位置提到制度名,末尾仍必须补上此行;"
+ "若知识库无对应制度可引(纯操作步骤、报错处置、闲聊等),则写\"依据文档:无\"。"
+ )
if role_hint:
+ aliases = JOB_TITLE_GROUP_ALIASES.get(role_hint, [role_hint])
+ alias_list = "、".join(f"\"{a}\"" for a in aliases)
hint_lines.append(
f"【提问人职位:{role_hint}】请严格遵守以下输出规则:\n"
f"1) 若制度按职级/岗位分组列条款(如\"总院领导班子正职\"\"总院中层副职\"\"总助级\"等),"
- f"只输出分组名中包含\"{role_hint}\"字样的分组细则;不匹配的分组一律不要列出,也不要用于对比。\n"
- f"2) 若制度不按职级分组,或该职位不涉及相关业务,请直接输出通用条款或明确说明\"该职位无相关规定\"。\n"
- f"3) 不要在正文开头写\"根据《XX规定》...\"这类引用开场;把制度出处放在整段回答的最末尾,"
- f"另起一行以\"依据文档:《文档全名》(文号,如有)\"的格式列出;引用多份文档时每份单独一行。"
+ f"只输出分组名中包含以下任一关键词的分组细则: {alias_list};不匹配的分组一律不要列出,也不要用于对比。\n"
+ f"2) 若制度不按职级分组,或该职位不涉及相关业务,请直接输出通用条款或明确说明\"该职位无相关规定\"。"
)
own_unit = scope.get("own_unit") or ""
chosen_unit = scope.get("chosen_unit") or ""
diff --git a/backend/app/api/job_titles.py b/backend/app/api/job_titles.py
new file mode 100644
index 0000000..8a9b69f
--- /dev/null
+++ b/backend/app/api/job_titles.py
@@ -0,0 +1,24 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.db import get_db
+from app.deps import get_current_user
+from app.models import JobTitle, User
+from app.schemas.job_title import JobTitleResponse
+
+router = APIRouter()
+
+
+@router.get("", response_model=list[JobTitleResponse])
+def list_job_titles(
+ _: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> list[JobTitleResponse]:
+ items = db.scalars(
+ select(JobTitle).where(JobTitle.is_active == True).order_by(JobTitle.sort_order, JobTitle.id) # noqa: E712
+ ).all()
+ return [
+ JobTitleResponse(id=jt.id, name=jt.name, sort_order=jt.sort_order, is_active=jt.is_active)
+ for jt in items
+ ]
diff --git a/backend/app/api/users.py b/backend/app/api/users.py
index 97d4628..68dbc72 100644
--- a/backend/app/api/users.py
+++ b/backend/app/api/users.py
@@ -23,6 +23,7 @@ def serialize_user(user: User) -> UserResponse:
phone=user.phone,
name=user.name,
department=user.department,
+ job_title=user.job_title,
unit_id=user.unit_id,
unit_name=user.unit.name if user.unit else None,
status=user.status,
@@ -72,6 +73,7 @@ def create_user(payload: UserCreate, db: Session = Depends(get_db), admin: User
phone=payload.phone,
name=payload.name,
department=payload.department,
+ job_title=payload.job_title,
unit_id=payload.unit_id,
is_superuser=payload.is_superuser,
)
@@ -98,7 +100,7 @@ def update_user(
if "unit_id" in updates:
_validate_unit_id(db, updates["unit_id"])
user.unit_id = updates["unit_id"]
- for field in ("name", "department", "status", "is_superuser"):
+ for field in ("name", "department", "job_title", "status", "is_superuser"):
if field in updates and updates[field] is not None:
setattr(user, field, updates[field])
if payload.role_codes is not None:
@@ -160,6 +162,7 @@ def bulk_import(
phone = (row.get("phone") or "").strip()
u_name = (row.get("name") or "").strip()
dept = (row.get("department") or "").strip() or None
+ job_title = (row.get("job_title") or "").strip() or None
roles_raw = (row.get("role_codes") or "").strip()
if not phone or not u_name:
errors.append(f"第 {idx} 行:手机号或姓名为空")
@@ -168,7 +171,7 @@ def bulk_import(
skipped += 1
continue
role_codes = [c.strip() for c in roles_raw.replace(",", ",").split(",") if c.strip()]
- user = User(phone=phone, name=u_name, department=dept)
+ user = User(phone=phone, name=u_name, department=dept, job_title=job_title)
db.add(user)
db.flush()
apply_roles(db, user, role_codes)
@@ -187,6 +190,7 @@ def _parse_user_rows(raw: bytes, filename: str) -> list[dict]:
"手机号": "phone", "phone": "phone", "电话": "phone",
"姓名": "name", "name": "name", "用户名": "name", "用户名称": "name",
"部门": "department", "department": "department",
+ "职级": "job_title", "岗位": "job_title", "职位": "job_title", "job_title": "job_title",
"角色": "role_codes", "role_codes": "role_codes", "角色代码": "role_codes",
}
diff --git a/backend/app/db.py b/backend/app/db.py
index 8778a14..83a7854 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -27,6 +27,7 @@ def get_db() -> Generator[Session, None, None]:
_LIGHTWEIGHT_ALTERS: list[tuple[str, str, str]] = [
("attachments", "remote_url", "VARCHAR(1000)"),
("users", "unit_id", "INTEGER"),
+ ("users", "job_title", "VARCHAR(80)"),
("units", "parent_id", "INTEGER"),
("units", "category", "VARCHAR(20) DEFAULT 'company'"),
]
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 7ffcd49..fdaaef2 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -1,13 +1,14 @@
from app.models.audit import AuditLog, ApiCallLog, UsageDaily
from app.models.chat import Attachment, Conversation, Feedback, Message, MessageReference
from app.models.document import Document, DocumentCategory, DocumentTag, document_tag_relations
-from app.models.user import Permission, Role, Unit, User, role_permissions, user_roles
+from app.models.user import JobTitle, Permission, Role, Unit, User, role_permissions, user_roles
all_models = (
User,
Role,
Permission,
Unit,
+ JobTitle,
DocumentCategory,
Document,
DocumentTag,
@@ -30,6 +31,7 @@ __all__ = [
"DocumentCategory",
"DocumentTag",
"Feedback",
+ "JobTitle",
"Message",
"MessageReference",
"Permission",
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 5a73215..548ef33 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -1,6 +1,6 @@
from datetime import datetime
-from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Table
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
@@ -28,6 +28,7 @@ class User(Base):
phone: Mapped[str] = mapped_column(String(32), unique=True, index=True)
name: Mapped[str] = mapped_column(String(80))
department: Mapped[str | None] = mapped_column(String(120))
+ job_title: Mapped[str | None] = mapped_column(String(80))
unit_id: Mapped[int | None] = mapped_column(ForeignKey("units.id"), index=True)
status: Mapped[str] = mapped_column(String(20), default="active", index=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -75,3 +76,16 @@ class Permission(Base):
module: Mapped[str] = mapped_column(String(60), index=True)
roles: Mapped[list[Role]] = relationship(secondary=role_permissions, back_populates="permissions")
+
+
+class JobTitle(Base):
+ """业务职级字典。User.job_title 存的是这里的 name(自由文本外键),
+ 字典改名/删除不影响历史用户数据。"""
+
+ __tablename__ = "job_titles"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String(80), unique=True, index=True)
+ sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
index 5a9183b..6228854 100644
--- a/backend/app/schemas/auth.py
+++ b/backend/app/schemas/auth.py
@@ -24,6 +24,7 @@ class CurrentUserResponse(BaseModel):
phone: str
name: str
department: str | None = None
+ job_title: str | None = None
unit_id: int | None = None
unit_name: str | None = None
status: str
diff --git a/backend/app/schemas/job_title.py b/backend/app/schemas/job_title.py
new file mode 100644
index 0000000..41bf65e
--- /dev/null
+++ b/backend/app/schemas/job_title.py
@@ -0,0 +1,8 @@
+from pydantic import BaseModel
+
+
+class JobTitleResponse(BaseModel):
+ id: int
+ name: str
+ sort_order: int
+ is_active: bool
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index 6dbe946..4f079a7 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -7,6 +7,7 @@ class UserCreate(BaseModel):
phone: str = Field(min_length=6, max_length=32)
name: str = Field(min_length=1, max_length=80)
department: str | None = None
+ job_title: str | None = None
unit_id: int | None = None
role_codes: list[str] = []
is_superuser: bool = False
@@ -15,6 +16,7 @@ class UserCreate(BaseModel):
class UserUpdate(BaseModel):
name: str | None = None
department: str | None = None
+ job_title: str | None = None
unit_id: int | None = None
status: str | None = None
role_codes: list[str] | None = None
@@ -26,6 +28,7 @@ class UserResponse(BaseModel):
phone: str
name: str
department: str | None
+ job_title: str | None = None
unit_id: int | None = None
unit_name: str | None = None
status: str
diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py
index 2bab4f5..df99004 100644
--- a/backend/app/services/user_service.py
+++ b/backend/app/services/user_service.py
@@ -3,7 +3,7 @@ from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
-from app.models import Permission, Role, User
+from app.models import JobTitle, Permission, Role, User
DEFAULT_ROLES = {
@@ -12,6 +12,37 @@ DEFAULT_ROLES = {
"system_admin": "系统管理员",
}
+# 业务职级预设。用户在个人资料里选的头衔。跟制度里的分组名不一定字面一致,
+# 字面不一致时必须在 JOB_TITLE_GROUP_ALIASES 里加映射,否则 chat.py 拿 job_title
+# 做子串匹配会全部落空,LLM 反问澄清。
+DEFAULT_JOB_TITLES: list[str] = [
+ "董事长",
+ "总经理",
+ "领导班子副职",
+ "总助级",
+ "中层干部正职及相当职级人员",
+ "中层干部副职及相当职级人员",
+ "教授级高工及相当职称人员",
+ "普通员工",
+]
+
+# 业务职级 -> 制度里"按职级分组"分组名的关键词列表。
+# 制度里分组常写作"总院领导班子正职"这种复合词,用户选的却是"董事长"这种具体头衔,
+# chat.py 会拿这里的关键词代替原 job_title 做子串规则("包含任一关键词")。
+# 未在此表的 job_title 回退成拿它本身做子串匹配(老行为)。
+# 加/改字典项时,同步维护此表;关键词要够特异,避免误命中相邻分组
+# (例如"正职"太泛,会同时命中"领导班子正职"和"中层正职")。
+JOB_TITLE_GROUP_ALIASES: dict[str, list[str]] = {
+ "董事长": ["领导班子正职"],
+ "总经理": ["领导班子正职"],
+ "领导班子副职": ["领导班子副职"],
+ "总助级": ["总助级"],
+ "中层干部正职及相当职级人员": ["中层正职", "中层干部正职"],
+ "中层干部副职及相当职级人员": ["中层副职", "中层干部副职"],
+ "教授级高工及相当职称人员": ["教授级高工"],
+ "普通员工": ["普通员工"],
+}
+
# 页面级权限:code -> (显示名, 模块)
DEFAULT_PAGE_PERMISSIONS: dict[str, tuple[str, str]] = {
"page.chat": ("智能问答", "page"),
@@ -62,8 +93,19 @@ def ensure_roles(db: Session) -> None:
db.flush()
+def ensure_job_titles(db: Session) -> None:
+ """把 DEFAULT_JOB_TITLES 里没入库的补进去;已存在的保留,不覆盖启用状态/顺序。"""
+ existing = {jt.name for jt in db.scalars(select(JobTitle)).all()}
+ for idx, name in enumerate(DEFAULT_JOB_TITLES):
+ if name in existing:
+ continue
+ db.add(JobTitle(name=name, sort_order=idx, is_active=True))
+ db.flush()
+
+
def ensure_default_admin(db: Session) -> User:
ensure_roles(db)
+ ensure_job_titles(db)
user = db.scalar(select(User).where(User.phone == "13800000000"))
if user:
return user
diff --git a/frontend/src/components/settings/UserManagementTab.vue b/frontend/src/components/settings/UserManagementTab.vue
index 8b95d1a..213eaa1 100644
--- a/frontend/src/components/settings/UserManagementTab.vue
+++ b/frontend/src/components/settings/UserManagementTab.vue
@@ -13,27 +13,39 @@ const store = useAppStore()
const users = ref([])
const roles = ref([])
const units = ref([])
+const jobTitles = ref([])
const loading = ref(false)
const keyword = ref('')
const editing = ref(null)
-const editForm = ref({ id: null, phone: '', name: '', department: '', unit_id: null, role_codes: [], is_superuser: false, status: 'active' })
+const editForm = ref({ id: null, phone: '', name: '', department: '', job_title: '', unit_id: null, role_codes: [], is_superuser: false, status: 'active' })
const fileInput = ref(null)
const roleOptions = computed(() => roles.value.map((r) => ({ label: r.name, value: r.code })))
const unitOptions = computed(() => units.value.map((u) => ({ label: u.name, value: u.id })))
+// 字典里已有的项 + 当前编辑行里的历史值(如果不在字典里,避免下拉显示为空)
+const jobTitleOptions = computed(() => {
+ const opts = jobTitles.value.map((jt) => ({ label: jt.name, value: jt.name }))
+ const cur = (editForm.value.job_title || '').trim()
+ if (cur && !opts.some((o) => o.value === cur)) {
+ opts.push({ label: `${cur}(历史值,不在字典中)`, value: cur })
+ }
+ return opts
+})
async function loadAll() {
loading.value = true
try {
- const [u, r, un] = await Promise.all([
+ const [u, r, un, jt] = await Promise.all([
request(`/api/users${keyword.value ? `?q=${encodeURIComponent(keyword.value)}` : ''}`),
request('/api/roles'),
request('/api/units'),
+ request('/api/job-titles'),
])
users.value = u || []
roles.value = r || []
units.value = un || []
+ jobTitles.value = jt || []
} catch (err) {
notify.error(`加载失败:${err.message}`)
} finally {
@@ -42,7 +54,7 @@ async function loadAll() {
}
function openAdd() {
- editForm.value = { id: null, phone: '', name: '', department: '', unit_id: null, role_codes: [], is_superuser: false, status: 'active' }
+ editForm.value = { id: null, phone: '', name: '', department: '', job_title: '', unit_id: null, role_codes: [], is_superuser: false, status: 'active' }
editing.value = 'add'
}
@@ -52,6 +64,7 @@ function openEdit(row) {
phone: row.phone,
name: row.name,
department: row.department || '',
+ job_title: row.job_title || '',
unit_id: row.unit_id ?? null,
role_codes: [...(row.role_codes || [])],
is_superuser: !!row.is_superuser,
@@ -73,6 +86,7 @@ async function onSubmit() {
body: JSON.stringify({
phone: form.phone, name: form.name,
department: form.department || null,
+ job_title: form.job_title || null,
unit_id: form.unit_id ?? null,
role_codes: form.role_codes,
is_superuser: form.is_superuser,
@@ -85,6 +99,7 @@ async function onSubmit() {
body: JSON.stringify({
name: form.name,
department: form.department || null,
+ job_title: form.job_title || null,
unit_id: form.unit_id ?? null,
role_codes: form.role_codes,
is_superuser: form.is_superuser,
@@ -152,6 +167,7 @@ const columns = [
{ title: '用户名称', key: 'name' },
{ title: '账号 ID', key: 'phone' },
{ title: '单位', key: 'unit_name', render: (row) => row.unit_name || '—' },
+ { title: '职级', key: 'job_title', render: (row) => row.job_title || '—' },
{
title: '角色',
key: 'role_codes',
@@ -267,6 +283,16 @@ onMounted(loadAll)
部门
+