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) 部门 +