fix:完善批量导入用户功能,添加模板

This commit is contained in:
shijing 2026-07-24 16:02:02 +08:00
parent 0b6c4afb77
commit a9c9340d82
14 changed files with 307 additions and 117 deletions

View File

@ -37,10 +37,11 @@ def _build_current_user(db: Session, user: User) -> CurrentUserResponse:
id=user.id, id=user.id,
phone=user.phone, phone=user.phone,
name=user.name, name=user.name,
department=user.department,
job_title=user.job_title, job_title=user.job_title,
unit_id=user.unit_id, unit_id=user.unit_id,
unit_name=user.unit.name if user.unit else None, 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, status=user.status,
roles=[role.code for role in user.roles], roles=[role.code for role in user.roles],
permissions=permissions, permissions=permissions,

View File

@ -46,14 +46,33 @@ def _validate_parent(
@router.get("", response_model=list[UnitResponse]) @router.get("", response_model=list[UnitResponse])
def list_units(db: Session = Depends(get_db), _: User = Depends(require_admin)) -> list[UnitResponse]: def list_units(
counts = dict(db.execute( 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)) select(User.unit_id, func.count(User.id))
.where(User.unit_id.is_not(None)) .where(User.unit_id.is_not(None))
.group_by(User.unit_id) .group_by(User.unit_id)
).all()) ).all())
units = db.scalars(select(Unit).order_by(Unit.id)).all() dept_counts = dict(db.execute(
return [_serialize(u, counts.get(u.id, 0)) for u in units] 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) @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)): if db.scalar(select(func.count(Unit.id)).where(Unit.parent_id == unit_id)):
raise HTTPException(status_code=400, detail="请先删除或转移下级单位") 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.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), write_audit(db, "unit.delete", actor_id=admin.id, target_type="unit", target_id=str(unit_id),
detail={"name": unit.name}) detail={"name": unit.name})
db.delete(unit) db.delete(unit)

View File

@ -2,12 +2,13 @@ import csv
import io import io
from fastapi import APIRouter, Depends, HTTPException, UploadFile from fastapi import APIRouter, Depends, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy import delete, or_, select from sqlalchemy import delete, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db import get_db from app.db import get_db
from app.deps import require_admin 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.chat import Attachment, Feedback, Message, MessageReference
from app.models.user import user_roles from app.models.user import user_roles
from app.schemas.user import BulkImportResult, UserCreate, UserResponse, UserUpdate from app.schemas.user import BulkImportResult, UserCreate, UserResponse, UserUpdate
@ -22,10 +23,11 @@ def serialize_user(user: User) -> UserResponse:
id=user.id, id=user.id,
phone=user.phone, phone=user.phone,
name=user.name, name=user.name,
department=user.department,
job_title=user.job_title, job_title=user.job_title,
unit_id=user.unit_id, unit_id=user.unit_id,
unit_name=user.unit.name if user.unit else None, 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, status=user.status,
role_codes=[role.code for role in user.roles], role_codes=[role.code for role in user.roles],
is_superuser=user.is_superuser, 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: if unit_id is None:
return return
if not db.get(Unit, unit_id): unit = db.get(Unit, unit_id)
raise HTTPException(status_code=400, detail="所选单位不存在") 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]) @router.get("", response_model=list[UserResponse])
@ -54,7 +59,6 @@ def list_users(
stmt = stmt.where(or_( stmt = stmt.where(or_(
User.name.ilike(like), User.name.ilike(like),
User.phone.ilike(like), User.phone.ilike(like),
User.department.ilike(like),
)) ))
status = (status or "").strip() status = (status or "").strip()
if status: 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)) existing = db.scalar(select(User).where(User.phone == payload.phone))
if existing: if existing:
raise HTTPException(status_code=409, detail="手机号已存在") 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( user = User(
phone=payload.phone, phone=payload.phone,
name=payload.name, name=payload.name,
department=payload.department,
job_title=payload.job_title, job_title=payload.job_title,
unit_id=payload.unit_id, unit_id=payload.unit_id,
department_id=payload.department_id,
is_superuser=payload.is_superuser, is_superuser=payload.is_superuser,
) )
db.add(user) db.add(user)
@ -98,9 +103,12 @@ def update_user(
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
updates = payload.model_dump(exclude_unset=True) updates = payload.model_dump(exclude_unset=True)
if "unit_id" in updates: 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"] 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: if field in updates and updates[field] is not None:
setattr(user, field, updates[field]) setattr(user, field, updates[field])
if payload.role_codes is not None: if payload.role_codes is not None:
@ -137,6 +145,127 @@ def delete_user(
return {"deleted": True, "id": user_id} 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, "系统管理员", "停用"])
# 隐藏字典 sheetA 列职级、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) @router.post("/bulk-import", response_model=BulkImportResult)
def bulk_import( def bulk_import(
file: UploadFile, file: UploadFile,
@ -158,20 +287,53 @@ def bulk_import(
skipped = 0 skipped = 0
errors: list[str] = [] errors: list[str] = []
existing_phones = {p for p, in db.execute(select(User.phone))} 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) for idx, row in enumerate(rows, start=2): # 行号 from 2 (header was 1)
phone = (row.get("phone") or "").strip() phone = (row.get("phone") or "").strip()
u_name = (row.get("name") 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 job_title = (row.get("job_title") or "").strip() or None
roles_raw = (row.get("role_codes") or "").strip() roles_raw = (row.get("role_codes") or "").strip()
status_raw = (row.get("status") or "").strip().lower()
if not phone or not u_name: if not phone or not u_name:
errors.append(f"{idx} 行:手机号或姓名为空") errors.append(f"{idx} 行:手机号或姓名为空")
continue continue
if phone in existing_phones: if phone in existing_phones:
skipped += 1 skipped += 1
continue continue
role_codes = [c.strip() for c in roles_raw.replace("", ",").split(",") if c.strip()] unit_id = None
user = User(phone=phone, name=u_name, department=dept, job_title=job_title) 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.add(user)
db.flush() db.flush()
apply_roles(db, user, role_codes) apply_roles(db, user, role_codes)
@ -185,13 +347,16 @@ def bulk_import(
def _parse_user_rows(raw: bytes, filename: str) -> list[dict]: 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 = { header_map = {
"手机号": "phone", "phone": "phone", "电话": "phone", "手机号": "phone", "phone": "phone", "电话": "phone",
"姓名": "name", "name": "name", "用户名": "name", "用户名称": "name", "姓名": "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", "职级": "job_title", "岗位": "job_title", "职位": "job_title", "job_title": "job_title",
"角色": "role_codes", "role_codes": "role_codes", "角色代码": "role_codes", "角色": "role_codes", "role_codes": "role_codes", "角色代码": "role_codes",
"状态": "status", "status": "status",
} }
def normalize(rows: list[list[str]]) -> list[dict]: def normalize(rows: list[list[str]]) -> list[dict]:

View File

@ -27,6 +27,7 @@ def get_db() -> Generator[Session, None, None]:
_LIGHTWEIGHT_ALTERS: list[tuple[str, str, str]] = [ _LIGHTWEIGHT_ALTERS: list[tuple[str, str, str]] = [
("attachments", "remote_url", "VARCHAR(1000)"), ("attachments", "remote_url", "VARCHAR(1000)"),
("users", "unit_id", "INTEGER"), ("users", "unit_id", "INTEGER"),
("users", "department_id", "INTEGER"),
("users", "job_title", "VARCHAR(80)"), ("users", "job_title", "VARCHAR(80)"),
("units", "parent_id", "INTEGER"), ("units", "parent_id", "INTEGER"),
("units", "category", "VARCHAR(20) DEFAULT 'company'"), ("units", "category", "VARCHAR(20) DEFAULT 'company'"),

View File

@ -27,9 +27,9 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
phone: Mapped[str] = mapped_column(String(32), unique=True, index=True) phone: Mapped[str] = mapped_column(String(32), unique=True, index=True)
name: Mapped[str] = mapped_column(String(80)) name: Mapped[str] = mapped_column(String(80))
department: Mapped[str | None] = mapped_column(String(120))
job_title: Mapped[str | None] = mapped_column(String(80)) job_title: Mapped[str | None] = mapped_column(String(80))
unit_id: Mapped[int | None] = mapped_column(ForeignKey("units.id"), index=True) 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) status: Mapped[str] = mapped_column(String(20), default="active", index=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 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) last_login_at: Mapped[datetime | None] = mapped_column(DateTime)
roles: Mapped[list["Role"]] = relationship(secondary=user_roles, back_populates="users") 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): class Unit(Base):
@ -50,7 +51,7 @@ class Unit(Base):
category: Mapped[str] = mapped_column(String(20), default="company", index=True) category: Mapped[str] = mapped_column(String(20), default="company", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 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") parent: Mapped["Unit | None"] = relationship(remote_side="Unit.id", back_populates="children")
children: Mapped[list["Unit"]] = relationship(back_populates="parent") children: Mapped[list["Unit"]] = relationship(back_populates="parent")

View File

@ -23,10 +23,11 @@ class CurrentUserResponse(BaseModel):
id: int id: int
phone: str phone: str
name: str name: str
department: str | None = None
job_title: str | None = None job_title: str | None = None
unit_id: int | None = None unit_id: int | None = None
unit_name: str | None = None unit_name: str | None = None
department_id: int | None = None
department_name: str | None = None
status: str status: str
roles: list[str] roles: list[str]
permissions: list[str] permissions: list[str]

View File

@ -6,18 +6,18 @@ from ._types import UtcDatetime
class UserCreate(BaseModel): class UserCreate(BaseModel):
phone: str = Field(min_length=6, max_length=32) phone: str = Field(min_length=6, max_length=32)
name: str = Field(min_length=1, max_length=80) name: str = Field(min_length=1, max_length=80)
department: str | None = None
job_title: str | None = None job_title: str | None = None
unit_id: int | None = None unit_id: int | None = None
department_id: int | None = None
role_codes: list[str] = [] role_codes: list[str] = []
is_superuser: bool = False is_superuser: bool = False
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
name: str | None = None name: str | None = None
department: str | None = None
job_title: str | None = None job_title: str | None = None
unit_id: int | None = None unit_id: int | None = None
department_id: int | None = None
status: str | None = None status: str | None = None
role_codes: list[str] | None = None role_codes: list[str] | None = None
is_superuser: bool | None = None is_superuser: bool | None = None
@ -27,10 +27,11 @@ class UserResponse(BaseModel):
id: int id: int
phone: str phone: str
name: str name: str
department: str | None
job_title: str | None = None job_title: str | None = None
unit_id: int | None = None unit_id: int | None = None
unit_name: str | None = None unit_name: str | None = None
department_id: int | None = None
department_name: str | None = None
status: str status: str
role_codes: list[str] role_codes: list[str]
is_superuser: bool is_superuser: bool

View File

@ -110,7 +110,7 @@ def ensure_default_admin(db: Session) -> User:
if user: if user:
return user return user
role = db.scalar(select(Role).where(Role.code == "system_admin")) 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: if role:
user.roles.append(role) user.roles.append(role)
db.add(user) db.add(user)

View File

@ -14,6 +14,7 @@ dependencies = [
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"httpx>=0.27", "httpx>=0.27",
"tencentcloud-sdk-python-lke>=3.0", "tencentcloud-sdk-python-lke>=3.0",
"openpyxl>=3.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@ -9,7 +9,7 @@ if not exist ".venv\Scripts\python.exe" (
python -m venv .venv || goto :error python -m venv .venv || goto :error
echo [backend] 正在安装依赖... echo [backend] 正在安装依赖...
".venv\Scripts\python.exe" -m pip install --upgrade pip || goto :error ".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) echo [backend] 启动 uvicorn app.main:app https://0.0.0.0:8775 (SSL)

View File

@ -1,23 +1,24 @@
<script setup> <script setup>
import { computed, h, onMounted, ref } from 'vue' import { computed, h, onMounted, ref, watch } from 'vue'
import { import {
NButton, NDataTable, NEmpty, NInput, NModal, NPopconfirm, NSelect, NTag, NTreeSelect, NButton, NDataTable, NEmpty, NInput, NModal, NPopconfirm, NTreeSelect,
} from 'naive-ui' } from 'naive-ui'
import { request } from '@/api/client' import { request } from '@/api/client'
import { notify } from '@/utils/notify' import { notify } from '@/utils/notify'
const CATEGORY_LABEL = { company: '公司', department: '部门' } const props = defineProps({
const CATEGORY_OPTIONS = [ category: { type: String, default: 'company' }, // 'company' | 'department'
{ label: '公司', value: 'company' }, })
{ label: '部门', value: 'department' },
]
const units = ref([]) const CATEGORY_LABEL = computed(() => (props.category === 'department' ? '部门' : '单位'))
const SUPPORTS_HIERARCHY = computed(() => props.category !== 'department')
const items = ref([])
const loading = ref(false) const loading = ref(false)
const editing = ref(null) const editing = ref(null)
const editForm = ref({ const editForm = ref({
id: null, name: '', description: '', parent_id: null, category: 'company', id: null, name: '', description: '', parent_id: null,
}) })
function buildTree(flat) { function buildTree(flat) {
@ -40,15 +41,14 @@ function buildTree(flat) {
return prune(roots) return prune(roots)
} }
const treeData = computed(() => buildTree(units.value)) const treeData = computed(() => buildTree(items.value))
// unit id
function collectDescendantIds(id) { function collectDescendantIds(id) {
const banned = new Set([id]) const banned = new Set([id])
let changed = true let changed = true
while (changed) { while (changed) {
changed = false changed = false
for (const u of units.value) { for (const u of items.value) {
if (!banned.has(u.id) && u.parent_id != null && banned.has(u.parent_id)) { if (!banned.has(u.id) && u.parent_id != null && banned.has(u.parent_id)) {
banned.add(u.id) banned.add(u.id)
changed = true changed = true
@ -58,29 +58,23 @@ function collectDescendantIds(id) {
return banned return banned
} }
//
const parentOptions = computed(() => { const parentOptions = computed(() => {
const banned = editing.value === 'edit' && editForm.value.id const banned = editing.value === 'edit' && editForm.value.id
? collectDescendantIds(editForm.value.id) ? collectDescendantIds(editForm.value.id)
: new Set() : new Set()
const currentCategory = editForm.value.category const build = (nodes) => nodes.map((n) => ({
const build = (nodes) => nodes.map((n) => { key: n.id,
const disabledByCycle = banned.has(n.id) label: n.name,
const disabledByCategory = currentCategory === 'company' && n.category !== 'company' disabled: banned.has(n.id),
return { children: n.children ? build(n.children) : undefined,
key: n.id, }))
label: n.name,
disabled: disabledByCycle || disabledByCategory,
children: n.children ? build(n.children) : undefined,
}
})
return build(treeData.value) return build(treeData.value)
}) })
async function loadAll() { async function loadAll() {
loading.value = true loading.value = true
try { try {
units.value = await request('/api/units') || [] items.value = await request(`/api/units?category=${props.category}`) || []
} catch (err) { } catch (err) {
notify.error(err.message) notify.error(err.message)
} finally { } finally {
@ -88,14 +82,14 @@ async function loadAll() {
} }
} }
watch(() => props.category, () => { loadAll() })
function openAdd(parent) { function openAdd(parent) {
editForm.value = { editForm.value = {
id: null, id: null,
name: '', name: '',
description: '', description: '',
parent_id: parent ? parent.id : null, parent_id: parent ? parent.id : null,
//
category: parent ? 'department' : 'company',
} }
editing.value = 'add' editing.value = 'add'
} }
@ -106,7 +100,6 @@ function openEdit(row) {
name: row.name, name: row.name,
description: row.description || '', description: row.description || '',
parent_id: row.parent_id ?? null, parent_id: row.parent_id ?? null,
category: row.category || 'company',
} }
editing.value = 'edit' editing.value = 'edit'
} }
@ -114,7 +107,7 @@ function openEdit(row) {
async function onSubmit() { async function onSubmit() {
const f = editForm.value const f = editForm.value
if (!f.name) { if (!f.name) {
notify.warning('单位名称不能为空') notify.warning(`${CATEGORY_LABEL.value}名称不能为空`)
return return
} }
try { try {
@ -122,11 +115,11 @@ async function onSubmit() {
name: f.name, name: f.name,
description: f.description || null, description: f.description || null,
parent_id: f.parent_id ?? null, parent_id: f.parent_id ?? null,
category: f.category, category: props.category,
} }
if (editing.value === 'add') { if (editing.value === 'add') {
await request('/api/units', { method: 'POST', body: JSON.stringify(body) }) await request('/api/units', { method: 'POST', body: JSON.stringify(body) })
notify.success('已添加单位') notify.success(`已添加${CATEGORY_LABEL.value}`)
} else { } else {
await request(`/api/units/${f.id}`, { method: 'PATCH', body: JSON.stringify(body) }) await request(`/api/units/${f.id}`, { method: 'PATCH', body: JSON.stringify(body) })
notify.success('已保存') notify.success('已保存')
@ -148,30 +141,8 @@ async function onDelete(row) {
} }
} }
// const columns = computed(() => [
function onCategoryChange(value) { { title: `${CATEGORY_LABEL.value}名称`, key: 'name' },
editForm.value.category = value
const pid = editForm.value.parent_id
if (value === 'company' && pid != null) {
const parent = units.value.find((u) => u.id === pid)
if (parent && parent.category !== 'company') {
editForm.value.parent_id = null
}
}
}
const columns = [
{ title: '单位名称', key: 'name' },
{
title: '分类',
key: 'category',
width: 90,
render: (r) => h(
NTag,
{ size: 'small', type: r.category === 'company' ? 'info' : 'default', bordered: false },
() => CATEGORY_LABEL[r.category] || '公司',
),
},
{ title: '描述', key: 'description', render: (r) => r.description || '—' }, { title: '描述', key: 'description', render: (r) => r.description || '—' },
{ title: '成员数', key: 'user_count', width: 90 }, { title: '成员数', key: 'user_count', width: 90 },
{ {
@ -179,17 +150,19 @@ const columns = [
key: 'actions', key: 'actions',
width: 200, width: 200,
render: (row) => h('div', { style: 'display:flex;gap:6px' }, [ render: (row) => h('div', { style: 'display:flex;gap:6px' }, [
h(NButton, { text: true, type: 'primary', size: 'small', onClick: () => openAdd(row) }, () => '添加下级'), SUPPORTS_HIERARCHY.value
? h(NButton, { text: true, type: 'primary', size: 'small', onClick: () => openAdd(row) }, () => '添加下级')
: null,
h(NButton, { text: true, type: 'primary', size: 'small', onClick: () => openEdit(row) }, () => '编辑'), h(NButton, { text: true, type: 'primary', size: 'small', onClick: () => openEdit(row) }, () => '编辑'),
h(NPopconfirm, h(NPopconfirm,
{ positiveText: '确认删除', negativeText: '取消', onPositiveClick: () => onDelete(row) }, { positiveText: '确认删除', negativeText: '取消', onPositiveClick: () => onDelete(row) },
{ {
trigger: () => h(NButton, { text: true, type: 'error', size: 'small' }, () => '删除'), trigger: () => h(NButton, { text: true, type: 'error', size: 'small' }, () => '删除'),
default: () => `确定删除单位「${row.name}」吗?该单位下的用户将被移出。`, default: () => `确定删除${CATEGORY_LABEL.value}${row.name}」吗?该${CATEGORY_LABEL.value}下的用户将被移出。`,
}), }),
]), ]),
}, },
] ])
onMounted(loadAll) onMounted(loadAll)
</script> </script>
@ -197,7 +170,7 @@ onMounted(loadAll)
<template> <template>
<div class="unit-tab"> <div class="unit-tab">
<div class="tab-toolbar"> <div class="tab-toolbar">
<NButton type="primary" @click="openAdd(null)">+ 添加单位</NButton> <NButton type="primary" @click="openAdd(null)">+ 添加{{ CATEGORY_LABEL }}</NButton>
</div> </div>
<NDataTable <NDataTable
@ -210,32 +183,24 @@ onMounted(loadAll)
size="small" size="small"
> >
<template #empty> <template #empty>
<NEmpty description="暂无单位" /> <NEmpty :description="`暂无${CATEGORY_LABEL}`" />
</template> </template>
</NDataTable> </NDataTable>
<NModal <NModal
:show="!!editing" :show="!!editing"
preset="card" preset="card"
:title="editing === 'add' ? '添加单位' : '编辑单位'" :title="`${editing === 'add' ? '添加' : '编辑'}${CATEGORY_LABEL}`"
style="width:460px" style="width:460px"
@update:show="(v) => { if (!v) editing = null }" @update:show="(v) => { if (!v) editing = null }"
> >
<div class="form"> <div class="form">
<label> <label>
<span>分类</span> <span>{{ CATEGORY_LABEL }}名称</span>
<NSelect <NInput v-model:value="editForm.name" placeholder="请输入名称" />
:value="editForm.category"
:options="CATEGORY_OPTIONS"
@update:value="onCategoryChange"
/>
</label> </label>
<label> <label v-if="SUPPORTS_HIERARCHY">
<span>单位名称</span> <span>上级{{ CATEGORY_LABEL }}</span>
<NInput v-model:value="editForm.name" placeholder="例如:总院、北分、南京设计院" />
</label>
<label>
<span>上级单位</span>
<NTreeSelect <NTreeSelect
v-model:value="editForm.parent_id" v-model:value="editForm.parent_id"
:options="parentOptions" :options="parentOptions"
@ -244,7 +209,7 @@ onMounted(loadAll)
children-field="children" children-field="children"
clearable clearable
default-expand-all default-expand-all
:placeholder="editForm.category === 'company' ? '不选表示为顶级公司' : '请选择上级单位'" :placeholder="`不选表示为顶级${CATEGORY_LABEL}`"
/> />
</label> </label>
<label> <label>

View File

@ -12,17 +12,19 @@ const store = useAppStore()
const users = ref([]) const users = ref([])
const roles = ref([]) const roles = ref([])
const units = ref([]) const companies = ref([])
const departments = ref([])
const jobTitles = ref([]) const jobTitles = ref([])
const loading = ref(false) const loading = ref(false)
const keyword = ref('') const keyword = ref('')
const editing = ref(null) const editing = ref(null)
const editForm = ref({ id: null, phone: '', name: '', department: '', job_title: '', unit_id: null, role_codes: [], is_superuser: false, status: 'active' }) const editForm = ref({ id: null, phone: '', name: '', department_id: null, job_title: '', unit_id: null, role_codes: [], is_superuser: false, status: 'active' })
const fileInput = ref(null) const fileInput = ref(null)
const roleOptions = computed(() => roles.value.map((r) => ({ label: r.name, value: r.code }))) 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 unitOptions = computed(() => companies.value.map((u) => ({ label: u.name, value: u.id })))
const departmentOptions = computed(() => departments.value.map((u) => ({ label: u.name, value: u.id })))
// + // +
const jobTitleOptions = computed(() => { const jobTitleOptions = computed(() => {
const opts = jobTitles.value.map((jt) => ({ label: jt.name, value: jt.name })) const opts = jobTitles.value.map((jt) => ({ label: jt.name, value: jt.name }))
@ -36,15 +38,17 @@ const jobTitleOptions = computed(() => {
async function loadAll() { async function loadAll() {
loading.value = true loading.value = true
try { try {
const [u, r, un, jt] = await Promise.all([ const [u, r, co, de, jt] = await Promise.all([
request(`/api/users${keyword.value ? `?q=${encodeURIComponent(keyword.value)}` : ''}`), request(`/api/users${keyword.value ? `?q=${encodeURIComponent(keyword.value)}` : ''}`),
request('/api/roles'), request('/api/roles'),
request('/api/units'), request('/api/units?category=company'),
request('/api/units?category=department'),
request('/api/job-titles'), request('/api/job-titles'),
]) ])
users.value = u || [] users.value = u || []
roles.value = r || [] roles.value = r || []
units.value = un || [] companies.value = co || []
departments.value = de || []
jobTitles.value = jt || [] jobTitles.value = jt || []
} catch (err) { } catch (err) {
notify.error(`加载失败:${err.message}`) notify.error(`加载失败:${err.message}`)
@ -54,7 +58,7 @@ async function loadAll() {
} }
function openAdd() { function openAdd() {
editForm.value = { id: null, phone: '', name: '', department: '', job_title: '', unit_id: null, role_codes: [], is_superuser: false, status: 'active' } editForm.value = { id: null, phone: '', name: '', department_id: null, job_title: '', unit_id: null, role_codes: [], is_superuser: false, status: 'active' }
editing.value = 'add' editing.value = 'add'
} }
@ -63,7 +67,7 @@ function openEdit(row) {
id: row.id, id: row.id,
phone: row.phone, phone: row.phone,
name: row.name, name: row.name,
department: row.department || '', department_id: row.department_id ?? null,
job_title: row.job_title || '', job_title: row.job_title || '',
unit_id: row.unit_id ?? null, unit_id: row.unit_id ?? null,
role_codes: [...(row.role_codes || [])], role_codes: [...(row.role_codes || [])],
@ -85,7 +89,7 @@ async function onSubmit() {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
phone: form.phone, name: form.name, phone: form.phone, name: form.name,
department: form.department || null, department_id: form.department_id ?? null,
job_title: form.job_title || null, job_title: form.job_title || null,
unit_id: form.unit_id ?? null, unit_id: form.unit_id ?? null,
role_codes: form.role_codes, role_codes: form.role_codes,
@ -98,7 +102,7 @@ async function onSubmit() {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ body: JSON.stringify({
name: form.name, name: form.name,
department: form.department || null, department_id: form.department_id ?? null,
job_title: form.job_title || null, job_title: form.job_title || null,
unit_id: form.unit_id ?? null, unit_id: form.unit_id ?? null,
role_codes: form.role_codes, role_codes: form.role_codes,
@ -139,6 +143,26 @@ async function toggleStatus(row) {
} }
} }
async function downloadImportTemplate() {
try {
const resp = await fetch(apiUrl('/api/users/import-template'), {
headers: authHeaders(),
})
if (!resp.ok) throw new Error(await resp.text() || resp.statusText)
const blob = await resp.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '用户批量导入模板.xlsx'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err) {
notify.error(`下载模板失败:${err.message}`)
}
}
async function onImportFile(event) { async function onImportFile(event) {
const file = (event.target.files || [])[0] const file = (event.target.files || [])[0]
event.target.value = '' event.target.value = ''
@ -167,6 +191,7 @@ const columns = [
{ title: '用户名称', key: 'name' }, { title: '用户名称', key: 'name' },
{ title: '账号 ID', key: 'phone' }, { title: '账号 ID', key: 'phone' },
{ title: '单位', key: 'unit_name', render: (row) => row.unit_name || '—' }, { title: '单位', key: 'unit_name', render: (row) => row.unit_name || '—' },
{ title: '部门', key: 'department_name', render: (row) => row.department_name || '—' },
{ title: '职级', key: 'job_title', render: (row) => row.job_title || '—' }, { title: '职级', key: 'job_title', render: (row) => row.job_title || '—' },
{ {
title: '角色', title: '角色',
@ -219,6 +244,7 @@ onMounted(loadAll)
<div class="toolbar-left"> <div class="toolbar-left">
<NButton type="primary" @click="openAdd">+ 添加用户</NButton> <NButton type="primary" @click="openAdd">+ 添加用户</NButton>
<NButton @click="fileInput?.click()"> 批量导入用户</NButton> <NButton @click="fileInput?.click()"> 批量导入用户</NButton>
<NButton text type="primary" @click="downloadImportTemplate">下载模板</NButton>
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
@ -281,7 +307,13 @@ onMounted(loadAll)
</label> </label>
<label> <label>
<span>部门</span> <span>部门</span>
<NInput v-model:value="editForm.department" placeholder="所在部门(可选)" /> <NSelect
v-model:value="editForm.department_id"
:options="departmentOptions"
placeholder="选择部门(可选)"
clearable
filterable
/>
</label> </label>
<label> <label>
<span>职级</span> <span>职级</span>

View File

@ -9,7 +9,7 @@ const store = useAppStore()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const phone = ref('13800000000') const phone = ref('')
const code = ref('') const code = ref('')
const sending = ref(false) const sending = ref(false)
const submitting = ref(false) const submitting = ref(false)
@ -288,7 +288,7 @@ async function login() {
{{ submitting ? '登录中…' : '登录' }} {{ submitting ? '登录中…' : '登录' }}
</button> </button>
<p class="login-tip">测试账号13800000000 / 验证码 123456</p> <p class="login-tip">测试版验证码123456</p>
</form> </form>
</div> </div>
</div> </div>

View File

@ -10,6 +10,7 @@ const tabs = [
{ key: 'roles', label: '角色管理' }, { key: 'roles', label: '角色管理' },
{ key: 'permissions', label: '权限管理' }, { key: 'permissions', label: '权限管理' },
{ key: 'units', label: '单位管理' }, { key: 'units', label: '单位管理' },
{ key: 'departments', label: '部门管理' },
] ]
const active = ref('users') const active = ref('users')
</script> </script>
@ -35,7 +36,8 @@ const active = ref('users')
<UserManagementTab v-if="active === 'users'" /> <UserManagementTab v-if="active === 'users'" />
<RoleManagementTab v-else-if="active === 'roles'" /> <RoleManagementTab v-else-if="active === 'roles'" />
<PermissionManagementTab v-else-if="active === 'permissions'" /> <PermissionManagementTab v-else-if="active === 'permissions'" />
<UnitManagementTab v-else-if="active === 'units'" /> <UnitManagementTab v-else-if="active === 'units'" category="company" />
<UnitManagementTab v-else-if="active === 'departments'" category="department" />
</div> </div>
</div> </div>
</section> </section>