From a9c9340d82b51c04c249596d0a04241eddf71769 Mon Sep 17 00:00:00 2001 From: shijing Date: Fri, 24 Jul 2026 16:02:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E5=AE=8C=E5=96=84=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E7=94=A8=E6=88=B7=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/auth.py | 3 +- backend/app/api/units.py | 28 ++- backend/app/api/users.py | 195 ++++++++++++++++-- backend/app/db.py | 1 + backend/app/models/user.py | 7 +- backend/app/schemas/auth.py | 3 +- backend/app/schemas/user.py | 7 +- backend/app/services/user_service.py | 2 +- backend/pyproject.toml | 1 + backend/run.bat | 2 +- .../components/settings/UnitManagementTab.vue | 113 ++++------ .../components/settings/UserManagementTab.vue | 54 ++++- frontend/src/views/LoginView.vue | 4 +- frontend/src/views/SettingsView.vue | 4 +- 14 files changed, 307 insertions(+), 117 deletions(-) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 5b97996..3aa4e40 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -37,10 +37,11 @@ def _build_current_user(db: Session, user: User) -> CurrentUserResponse: id=user.id, 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, + department_id=user.department_id, + department_name=user.department.name if user.department else None, status=user.status, roles=[role.code for role in user.roles], permissions=permissions, diff --git a/backend/app/api/units.py b/backend/app/api/units.py index 7f2c382..3d54a75 100644 --- a/backend/app/api/units.py +++ b/backend/app/api/units.py @@ -46,14 +46,33 @@ def _validate_parent( @router.get("", response_model=list[UnitResponse]) -def list_units(db: Session = Depends(get_db), _: User = Depends(require_admin)) -> list[UnitResponse]: - counts = dict(db.execute( +def list_units( + category: str = "", + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> list[UnitResponse]: + unit_counts = dict(db.execute( select(User.unit_id, func.count(User.id)) .where(User.unit_id.is_not(None)) .group_by(User.unit_id) ).all()) - units = db.scalars(select(Unit).order_by(Unit.id)).all() - return [_serialize(u, counts.get(u.id, 0)) for u in units] + dept_counts = dict(db.execute( + select(User.department_id, func.count(User.id)) + .where(User.department_id.is_not(None)) + .group_by(User.department_id) + ).all()) + stmt = select(Unit).order_by(Unit.id) + cat = (category or "").strip() + if cat: + stmt = stmt.where(Unit.category == cat) + units = db.scalars(stmt).all() + return [ + _serialize( + u, + (dept_counts if (u.category or "company") == "department" else unit_counts).get(u.id, 0), + ) + for u in units + ] @router.post("", response_model=UnitResponse) @@ -124,6 +143,7 @@ def delete_unit( if db.scalar(select(func.count(Unit.id)).where(Unit.parent_id == unit_id)): raise HTTPException(status_code=400, detail="请先删除或转移下级单位") db.execute(update(User).where(User.unit_id == unit_id).values(unit_id=None)) + db.execute(update(User).where(User.department_id == unit_id).values(department_id=None)) write_audit(db, "unit.delete", actor_id=admin.id, target_type="unit", target_id=str(unit_id), detail={"name": unit.name}) db.delete(unit) diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 68dbc72..85e0dda 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -2,12 +2,13 @@ import csv import io from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi.responses import StreamingResponse from sqlalchemy import delete, or_, select from sqlalchemy.orm import Session from app.db import get_db from app.deps import require_admin -from app.models import Unit, User +from app.models import JobTitle, Unit, User from app.models.chat import Attachment, Feedback, Message, MessageReference from app.models.user import user_roles from app.schemas.user import BulkImportResult, UserCreate, UserResponse, UserUpdate @@ -22,10 +23,11 @@ def serialize_user(user: User) -> UserResponse: id=user.id, 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, + department_id=user.department_id, + department_name=user.department.name if user.department else None, status=user.status, role_codes=[role.code for role in user.roles], is_superuser=user.is_superuser, @@ -33,11 +35,14 @@ def serialize_user(user: User) -> UserResponse: ) -def _validate_unit_id(db: Session, unit_id: int | None) -> None: +def _validate_unit(db: Session, unit_id: int | None, *, expected_category: str, field_label: str) -> None: if unit_id is None: return - if not db.get(Unit, unit_id): - raise HTTPException(status_code=400, detail="所选单位不存在") + unit = db.get(Unit, unit_id) + if not unit: + raise HTTPException(status_code=400, detail=f"所选{field_label}不存在") + if unit.category != expected_category: + raise HTTPException(status_code=400, detail=f"所选{field_label}分类不匹配") @router.get("", response_model=list[UserResponse]) @@ -54,7 +59,6 @@ def list_users( stmt = stmt.where(or_( User.name.ilike(like), User.phone.ilike(like), - User.department.ilike(like), )) status = (status or "").strip() if status: @@ -68,13 +72,14 @@ def create_user(payload: UserCreate, db: Session = Depends(get_db), admin: User existing = db.scalar(select(User).where(User.phone == payload.phone)) if existing: raise HTTPException(status_code=409, detail="手机号已存在") - _validate_unit_id(db, payload.unit_id) + _validate_unit(db, payload.unit_id, expected_category="company", field_label="单位") + _validate_unit(db, payload.department_id, expected_category="department", field_label="部门") user = User( phone=payload.phone, name=payload.name, - department=payload.department, job_title=payload.job_title, unit_id=payload.unit_id, + department_id=payload.department_id, is_superuser=payload.is_superuser, ) db.add(user) @@ -98,9 +103,12 @@ def update_user( raise HTTPException(status_code=404, detail="用户不存在") updates = payload.model_dump(exclude_unset=True) if "unit_id" in updates: - _validate_unit_id(db, updates["unit_id"]) + _validate_unit(db, updates["unit_id"], expected_category="company", field_label="单位") user.unit_id = updates["unit_id"] - for field in ("name", "department", "job_title", "status", "is_superuser"): + if "department_id" in updates: + _validate_unit(db, updates["department_id"], expected_category="department", field_label="部门") + user.department_id = updates["department_id"] + for field in ("name", "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: @@ -137,6 +145,127 @@ def delete_user( return {"deleted": True, "id": user_id} +@router.get("/import-template") +def download_import_template( + db: Session = Depends(get_db), + admin: User = Depends(require_admin), +) -> StreamingResponse: + try: + from openpyxl import Workbook + from openpyxl.utils import quote_sheetname + from openpyxl.worksheet.datavalidation import DataValidation + except ImportError: + raise HTTPException(status_code=500, detail="缺少 openpyxl,请运行 pip install openpyxl") + + job_titles = db.scalars( + select(JobTitle) + .where(JobTitle.is_active == True) # noqa: E712 + .order_by(JobTitle.sort_order, JobTitle.id) + ).all() + job_title_names = [jt.name for jt in job_titles] + + units_all = db.scalars(select(Unit).order_by(Unit.id)).all() + company_names = [u.name for u in units_all if u.category == "company"] + department_names = [u.name for u in units_all if u.category == "department"] + + wb = Workbook() + ws = wb.active + ws.title = "用户导入" + headers = ["姓名", "手机号", "单位", "部门", "职级", "角色", "状态"] + ws.append(headers) + sample_jt = job_title_names[0] if job_title_names else "" + sample_company = company_names[0] if company_names else "" + sample_dept = department_names[0] if department_names else "" + ws.append(["张三", "13800000001", sample_company, sample_dept, sample_jt, "普通员工", "启用"]) + ws.append(["李四", "13800000002", sample_company, sample_dept, sample_jt, "业务管理员", "启用"]) + ws.append(["王五", "13800000003", sample_company, sample_dept, sample_jt, "系统管理员", "停用"]) + + # 隐藏字典 sheet:A 列职级、B 列公司、C 列部门 —— 都从表里实时读, + # Excel 里改后台字典下次下模板就同步。 + dict_ws = wb.create_sheet("_dict") + dict_ws.sheet_state = "hidden" + for i, name in enumerate(job_title_names, start=1): + dict_ws.cell(row=i, column=1, value=name) + for i, name in enumerate(company_names, start=1): + dict_ws.cell(row=i, column=2, value=name) + for i, name in enumerate(department_names, start=1): + dict_ws.cell(row=i, column=3, value=name) + + def _range_ref(col_letter: str, count: int) -> str: + return f"{quote_sheetname(dict_ws.title)}!${col_letter}$1:${col_letter}${count}" + + if company_names: + unit_dv = DataValidation( + type="list", + formula1=f"={_range_ref('B', len(company_names))}", + allow_blank=True, + showErrorMessage=True, + errorTitle="单位无效", + error="请从下拉列表中选择单位", + ) + unit_dv.add("C2:C1048576") + ws.add_data_validation(unit_dv) + + if department_names: + dept_dv = DataValidation( + type="list", + formula1=f"={_range_ref('C', len(department_names))}", + allow_blank=True, + showErrorMessage=True, + errorTitle="部门无效", + error="请从下拉列表中选择部门", + ) + dept_dv.add("D2:D1048576") + ws.add_data_validation(dept_dv) + + if job_title_names: + job_title_dv = DataValidation( + type="list", + formula1=f"={_range_ref('A', len(job_title_names))}", + allow_blank=True, + showErrorMessage=True, + errorTitle="职级无效", + error="请从下拉列表中选择职级", + ) + job_title_dv.add("E2:E1048576") + ws.add_data_validation(job_title_dv) + + role_dv = DataValidation( + type="list", + formula1='"普通员工,业务管理员,系统管理员"', + allow_blank=True, + showErrorMessage=True, + errorTitle="角色无效", + error="请从下拉列表中选择角色", + ) + role_dv.add("F2:F1048576") + ws.add_data_validation(role_dv) + + status_dv = DataValidation( + type="list", + formula1='"启用,停用"', + allow_blank=True, + showErrorMessage=True, + errorTitle="状态无效", + error="请从下拉列表中选择状态", + ) + status_dv.add("G2:G1048576") + ws.add_data_validation(status_dv) + + for col, width in zip("ABCDEFG", [12, 16, 18, 18, 20, 14, 10]): + ws.column_dimensions[col].width = width + + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + filename = "user_import_template.xlsx" + return StreamingResponse( + buf, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @router.post("/bulk-import", response_model=BulkImportResult) def bulk_import( file: UploadFile, @@ -158,20 +287,53 @@ def bulk_import( skipped = 0 errors: list[str] = [] existing_phones = {p for p, in db.execute(select(User.phone))} + all_units = db.execute(select(Unit)).scalars().all() + company_by_name = {u.name: u.id for u in all_units if u.category == "company"} + department_by_name = {u.name: u.id for u in all_units if u.category == "department"} + status_map = { + "active": "active", "启用": "active", "正常": "active", "1": "active", "true": "active", + "inactive": "inactive", "停用": "inactive", "禁用": "inactive", "0": "inactive", "false": "inactive", + } for idx, row in enumerate(rows, start=2): # 行号 from 2 (header was 1) phone = (row.get("phone") or "").strip() u_name = (row.get("name") or "").strip() - dept = (row.get("department") or "").strip() or None + unit_name = (row.get("unit_name") or "").strip() + dept_name = (row.get("department_name") or "").strip() job_title = (row.get("job_title") or "").strip() or None roles_raw = (row.get("role_codes") or "").strip() + status_raw = (row.get("status") or "").strip().lower() if not phone or not u_name: errors.append(f"第 {idx} 行:手机号或姓名为空") continue if phone in existing_phones: 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, job_title=job_title) + unit_id = None + if unit_name: + unit_id = company_by_name.get(unit_name) + if unit_id is None: + errors.append(f"第 {idx} 行:单位「{unit_name}」不存在") + continue + department_id = None + if dept_name: + department_id = department_by_name.get(dept_name) + if department_id is None: + errors.append(f"第 {idx} 行:部门「{dept_name}」不存在") + continue + status = status_map.get(status_raw, "active") if status_raw else "active" + role_alias = { + "普通员工": "employee", "employee": "employee", + "业务管理员": "admin", "admin": "admin", + "系统管理员": "system_admin", "system_admin": "system_admin", + } + role_codes = [ + role_alias.get(c.strip(), c.strip()) + for c in roles_raw.replace(",", ",").split(",") if c.strip() + ] + user = User( + phone=phone, name=u_name, job_title=job_title, + unit_id=unit_id, department_id=department_id, status=status, + ) db.add(user) db.flush() apply_roles(db, user, role_codes) @@ -185,13 +347,16 @@ def bulk_import( def _parse_user_rows(raw: bytes, filename: str) -> list[dict]: - """支持 CSV / XLSX。列:phone, name, department(可选), role_codes(可选,多个用逗号分隔)。""" + """支持 CSV / XLSX。列:name, phone, unit_name(可选), department_name(可选), + job_title(可选), role_codes(可选,多个用逗号分隔), status(可选)。""" header_map = { "手机号": "phone", "phone": "phone", "电话": "phone", "姓名": "name", "name": "name", "用户名": "name", "用户名称": "name", - "部门": "department", "department": "department", + "单位": "unit_name", "unit": "unit_name", "unit_name": "unit_name", + "部门": "department_name", "department": "department_name", "department_name": "department_name", "职级": "job_title", "岗位": "job_title", "职位": "job_title", "job_title": "job_title", "角色": "role_codes", "role_codes": "role_codes", "角色代码": "role_codes", + "状态": "status", "status": "status", } def normalize(rows: list[list[str]]) -> list[dict]: diff --git a/backend/app/db.py b/backend/app/db.py index 83a7854..3d3170f 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", "department_id", "INTEGER"), ("users", "job_title", "VARCHAR(80)"), ("units", "parent_id", "INTEGER"), ("units", "category", "VARCHAR(20) DEFAULT 'company'"), diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 548ef33..9faf234 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -27,9 +27,9 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True) 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) + department_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) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) @@ -37,7 +37,8 @@ class User(Base): last_login_at: Mapped[datetime | None] = mapped_column(DateTime) roles: Mapped[list["Role"]] = relationship(secondary=user_roles, back_populates="users") - unit: Mapped["Unit | None"] = relationship(back_populates="users") + unit: Mapped["Unit | None"] = relationship(foreign_keys=[unit_id], back_populates="users") + department: Mapped["Unit | None"] = relationship(foreign_keys=[department_id]) class Unit(Base): @@ -50,7 +51,7 @@ class Unit(Base): category: Mapped[str] = mapped_column(String(20), default="company", index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - users: Mapped[list[User]] = relationship(back_populates="unit") + users: Mapped[list[User]] = relationship(foreign_keys="User.unit_id", back_populates="unit") parent: Mapped["Unit | None"] = relationship(remote_side="Unit.id", back_populates="children") children: Mapped[list["Unit"]] = relationship(back_populates="parent") diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 6228854..28a8e82 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -23,10 +23,11 @@ class CurrentUserResponse(BaseModel): id: int phone: str name: str - department: str | None = None job_title: str | None = None unit_id: int | None = None unit_name: str | None = None + department_id: int | None = None + department_name: str | None = None status: str roles: list[str] permissions: list[str] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index d08b005..6bd4c1b 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -6,18 +6,18 @@ from ._types import UtcDatetime 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 + department_id: int | None = None role_codes: list[str] = [] is_superuser: bool = False class UserUpdate(BaseModel): name: str | None = None - department: str | None = None job_title: str | None = None unit_id: int | None = None + department_id: int | None = None status: str | None = None role_codes: list[str] | None = None is_superuser: bool | None = None @@ -27,10 +27,11 @@ class UserResponse(BaseModel): id: int phone: str name: str - department: str | None job_title: str | None = None unit_id: int | None = None unit_name: str | None = None + department_id: int | None = None + department_name: str | None = None status: str role_codes: list[str] is_superuser: bool diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index df99004..ffa51e7 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -110,7 +110,7 @@ def ensure_default_admin(db: Session) -> User: if user: return user role = db.scalar(select(Role).where(Role.code == "system_admin")) - user = User(phone="13800000000", name="系统管理员", department="财务部", is_superuser=True) + user = User(phone="13800000000", name="系统管理员", is_superuser=True) if role: user.roles.append(role) db.add(user) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8a231e1..8ca9d45 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "python-multipart>=0.0.9", "httpx>=0.27", "tencentcloud-sdk-python-lke>=3.0", + "openpyxl>=3.1", ] [project.optional-dependencies] diff --git a/backend/run.bat b/backend/run.bat index 545e9c5..6523fc0 100644 --- a/backend/run.bat +++ b/backend/run.bat @@ -9,7 +9,7 @@ if not exist ".venv\Scripts\python.exe" ( python -m venv .venv || goto :error echo [backend] 正在安装依赖... ".venv\Scripts\python.exe" -m pip install --upgrade pip || goto :error - ".venv\Scripts\python.exe" -m pip install fastapi "uvicorn[standard]" sqlalchemy "psycopg[binary]" pydantic-settings "python-jose[cryptography]" "passlib[bcrypt]" python-multipart httpx tencentcloud-sdk-python-lke || goto :error + ".venv\Scripts\python.exe" -m pip install fastapi "uvicorn[standard]" sqlalchemy "psycopg[binary]" pydantic-settings "python-jose[cryptography]" "passlib[bcrypt]" python-multipart httpx tencentcloud-sdk-python-lke openpyxl || goto :error ) echo [backend] 启动 uvicorn app.main:app https://0.0.0.0:8775 (SSL) diff --git a/frontend/src/components/settings/UnitManagementTab.vue b/frontend/src/components/settings/UnitManagementTab.vue index bd7852b..0176526 100644 --- a/frontend/src/components/settings/UnitManagementTab.vue +++ b/frontend/src/components/settings/UnitManagementTab.vue @@ -1,23 +1,24 @@ @@ -197,7 +170,7 @@ onMounted(loadAll)