fix:完善批量导入用户功能,添加模板
This commit is contained in:
parent
0b6c4afb77
commit
a9c9340d82
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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'"),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,24 @@
|
|||
<script setup>
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
NButton, NDataTable, NEmpty, NInput, NModal, NPopconfirm, NSelect, NTag, NTreeSelect,
|
||||
NButton, NDataTable, NEmpty, NInput, NModal, NPopconfirm, NTreeSelect,
|
||||
} from 'naive-ui'
|
||||
import { request } from '@/api/client'
|
||||
import { notify } from '@/utils/notify'
|
||||
|
||||
const CATEGORY_LABEL = { company: '公司', department: '部门' }
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ label: '公司', value: 'company' },
|
||||
{ label: '部门', value: 'department' },
|
||||
]
|
||||
const props = defineProps({
|
||||
category: { type: String, default: 'company' }, // 'company' | '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 editing = ref(null)
|
||||
const editForm = ref({
|
||||
id: null, name: '', description: '', parent_id: null, category: 'company',
|
||||
id: null, name: '', description: '', parent_id: null,
|
||||
})
|
||||
|
||||
function buildTree(flat) {
|
||||
|
|
@ -40,15 +41,14 @@ function buildTree(flat) {
|
|||
return prune(roots)
|
||||
}
|
||||
|
||||
const treeData = computed(() => buildTree(units.value))
|
||||
const treeData = computed(() => buildTree(items.value))
|
||||
|
||||
// 收集 unit 及其所有下级的 id,用于禁止把父级设为自身的下级
|
||||
function collectDescendantIds(id) {
|
||||
const banned = new Set([id])
|
||||
let changed = true
|
||||
while (changed) {
|
||||
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)) {
|
||||
banned.add(u.id)
|
||||
changed = true
|
||||
|
|
@ -58,29 +58,23 @@ function collectDescendantIds(id) {
|
|||
return banned
|
||||
}
|
||||
|
||||
// 上级候选:公司只能挂在公司下;部门可挂在公司或部门下
|
||||
const parentOptions = computed(() => {
|
||||
const banned = editing.value === 'edit' && editForm.value.id
|
||||
? collectDescendantIds(editForm.value.id)
|
||||
: new Set()
|
||||
const currentCategory = editForm.value.category
|
||||
const build = (nodes) => nodes.map((n) => {
|
||||
const disabledByCycle = banned.has(n.id)
|
||||
const disabledByCategory = currentCategory === 'company' && n.category !== 'company'
|
||||
return {
|
||||
key: n.id,
|
||||
label: n.name,
|
||||
disabled: disabledByCycle || disabledByCategory,
|
||||
children: n.children ? build(n.children) : undefined,
|
||||
}
|
||||
})
|
||||
const build = (nodes) => nodes.map((n) => ({
|
||||
key: n.id,
|
||||
label: n.name,
|
||||
disabled: banned.has(n.id),
|
||||
children: n.children ? build(n.children) : undefined,
|
||||
}))
|
||||
return build(treeData.value)
|
||||
})
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
units.value = await request('/api/units') || []
|
||||
items.value = await request(`/api/units?category=${props.category}`) || []
|
||||
} catch (err) {
|
||||
notify.error(err.message)
|
||||
} finally {
|
||||
|
|
@ -88,14 +82,14 @@ async function loadAll() {
|
|||
}
|
||||
}
|
||||
|
||||
watch(() => props.category, () => { loadAll() })
|
||||
|
||||
function openAdd(parent) {
|
||||
editForm.value = {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
parent_id: parent ? parent.id : null,
|
||||
// 挂在别人下默认按“部门”,顶级默认“公司”
|
||||
category: parent ? 'department' : 'company',
|
||||
}
|
||||
editing.value = 'add'
|
||||
}
|
||||
|
|
@ -106,7 +100,6 @@ function openEdit(row) {
|
|||
name: row.name,
|
||||
description: row.description || '',
|
||||
parent_id: row.parent_id ?? null,
|
||||
category: row.category || 'company',
|
||||
}
|
||||
editing.value = 'edit'
|
||||
}
|
||||
|
|
@ -114,7 +107,7 @@ function openEdit(row) {
|
|||
async function onSubmit() {
|
||||
const f = editForm.value
|
||||
if (!f.name) {
|
||||
notify.warning('单位名称不能为空')
|
||||
notify.warning(`${CATEGORY_LABEL.value}名称不能为空`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
|
|
@ -122,11 +115,11 @@ async function onSubmit() {
|
|||
name: f.name,
|
||||
description: f.description || null,
|
||||
parent_id: f.parent_id ?? null,
|
||||
category: f.category,
|
||||
category: props.category,
|
||||
}
|
||||
if (editing.value === 'add') {
|
||||
await request('/api/units', { method: 'POST', body: JSON.stringify(body) })
|
||||
notify.success('已添加单位')
|
||||
notify.success(`已添加${CATEGORY_LABEL.value}`)
|
||||
} else {
|
||||
await request(`/api/units/${f.id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||
notify.success('已保存')
|
||||
|
|
@ -148,30 +141,8 @@ async function onDelete(row) {
|
|||
}
|
||||
}
|
||||
|
||||
// 切换分类时如果原父级不再合法,清空
|
||||
function onCategoryChange(value) {
|
||||
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] || '公司',
|
||||
),
|
||||
},
|
||||
const columns = computed(() => [
|
||||
{ title: `${CATEGORY_LABEL.value}名称`, key: 'name' },
|
||||
{ title: '描述', key: 'description', render: (r) => r.description || '—' },
|
||||
{ title: '成员数', key: 'user_count', width: 90 },
|
||||
{
|
||||
|
|
@ -179,17 +150,19 @@ const columns = [
|
|||
key: 'actions',
|
||||
width: 200,
|
||||
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(NPopconfirm,
|
||||
{ positiveText: '确认删除', negativeText: '取消', onPositiveClick: () => onDelete(row) },
|
||||
{
|
||||
trigger: () => h(NButton, { text: true, type: 'error', size: 'small' }, () => '删除'),
|
||||
default: () => `确定删除单位「${row.name}」吗?该单位下的用户将被移出。`,
|
||||
default: () => `确定删除${CATEGORY_LABEL.value}「${row.name}」吗?该${CATEGORY_LABEL.value}下的用户将被移出。`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
|
|
@ -197,7 +170,7 @@ onMounted(loadAll)
|
|||
<template>
|
||||
<div class="unit-tab">
|
||||
<div class="tab-toolbar">
|
||||
<NButton type="primary" @click="openAdd(null)">+ 添加单位</NButton>
|
||||
<NButton type="primary" @click="openAdd(null)">+ 添加{{ CATEGORY_LABEL }}</NButton>
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
|
|
@ -210,32 +183,24 @@ onMounted(loadAll)
|
|||
size="small"
|
||||
>
|
||||
<template #empty>
|
||||
<NEmpty description="暂无单位" />
|
||||
<NEmpty :description="`暂无${CATEGORY_LABEL}`" />
|
||||
</template>
|
||||
</NDataTable>
|
||||
|
||||
<NModal
|
||||
:show="!!editing"
|
||||
preset="card"
|
||||
:title="editing === 'add' ? '添加单位' : '编辑单位'"
|
||||
:title="`${editing === 'add' ? '添加' : '编辑'}${CATEGORY_LABEL}`"
|
||||
style="width:460px"
|
||||
@update:show="(v) => { if (!v) editing = null }"
|
||||
>
|
||||
<div class="form">
|
||||
<label>
|
||||
<span>分类</span>
|
||||
<NSelect
|
||||
:value="editForm.category"
|
||||
:options="CATEGORY_OPTIONS"
|
||||
@update:value="onCategoryChange"
|
||||
/>
|
||||
<span>{{ CATEGORY_LABEL }}名称</span>
|
||||
<NInput v-model:value="editForm.name" placeholder="请输入名称" />
|
||||
</label>
|
||||
<label>
|
||||
<span>单位名称</span>
|
||||
<NInput v-model:value="editForm.name" placeholder="例如:总院、北分、南京设计院" />
|
||||
</label>
|
||||
<label>
|
||||
<span>上级单位</span>
|
||||
<label v-if="SUPPORTS_HIERARCHY">
|
||||
<span>上级{{ CATEGORY_LABEL }}</span>
|
||||
<NTreeSelect
|
||||
v-model:value="editForm.parent_id"
|
||||
:options="parentOptions"
|
||||
|
|
@ -244,7 +209,7 @@ onMounted(loadAll)
|
|||
children-field="children"
|
||||
clearable
|
||||
default-expand-all
|
||||
:placeholder="editForm.category === 'company' ? '不选表示为顶级公司' : '请选择上级单位'"
|
||||
:placeholder="`不选表示为顶级${CATEGORY_LABEL}`"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
|
|
|
|||
|
|
@ -12,17 +12,19 @@ const store = useAppStore()
|
|||
|
||||
const users = ref([])
|
||||
const roles = ref([])
|
||||
const units = ref([])
|
||||
const companies = ref([])
|
||||
const departments = ref([])
|
||||
const jobTitles = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
|
||||
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 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 opts = jobTitles.value.map((jt) => ({ label: jt.name, value: jt.name }))
|
||||
|
|
@ -36,15 +38,17 @@ const jobTitleOptions = computed(() => {
|
|||
async function loadAll() {
|
||||
loading.value = true
|
||||
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/roles'),
|
||||
request('/api/units'),
|
||||
request('/api/units?category=company'),
|
||||
request('/api/units?category=department'),
|
||||
request('/api/job-titles'),
|
||||
])
|
||||
users.value = u || []
|
||||
roles.value = r || []
|
||||
units.value = un || []
|
||||
companies.value = co || []
|
||||
departments.value = de || []
|
||||
jobTitles.value = jt || []
|
||||
} catch (err) {
|
||||
notify.error(`加载失败:${err.message}`)
|
||||
|
|
@ -54,7 +58,7 @@ async function loadAll() {
|
|||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +67,7 @@ function openEdit(row) {
|
|||
id: row.id,
|
||||
phone: row.phone,
|
||||
name: row.name,
|
||||
department: row.department || '',
|
||||
department_id: row.department_id ?? null,
|
||||
job_title: row.job_title || '',
|
||||
unit_id: row.unit_id ?? null,
|
||||
role_codes: [...(row.role_codes || [])],
|
||||
|
|
@ -85,7 +89,7 @@ async function onSubmit() {
|
|||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
phone: form.phone, name: form.name,
|
||||
department: form.department || null,
|
||||
department_id: form.department_id ?? null,
|
||||
job_title: form.job_title || null,
|
||||
unit_id: form.unit_id ?? null,
|
||||
role_codes: form.role_codes,
|
||||
|
|
@ -98,7 +102,7 @@ async function onSubmit() {
|
|||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
department: form.department || null,
|
||||
department_id: form.department_id ?? null,
|
||||
job_title: form.job_title || null,
|
||||
unit_id: form.unit_id ?? null,
|
||||
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) {
|
||||
const file = (event.target.files || [])[0]
|
||||
event.target.value = ''
|
||||
|
|
@ -167,6 +191,7 @@ const columns = [
|
|||
{ title: '用户名称', key: 'name' },
|
||||
{ title: '账号 ID', key: 'phone' },
|
||||
{ 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: '角色',
|
||||
|
|
@ -219,6 +244,7 @@ onMounted(loadAll)
|
|||
<div class="toolbar-left">
|
||||
<NButton type="primary" @click="openAdd">+ 添加用户</NButton>
|
||||
<NButton @click="fileInput?.click()">↑ 批量导入用户</NButton>
|
||||
<NButton text type="primary" @click="downloadImportTemplate">下载模板</NButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
|
|
@ -281,7 +307,13 @@ onMounted(loadAll)
|
|||
</label>
|
||||
<label>
|
||||
<span>部门</span>
|
||||
<NInput v-model:value="editForm.department" placeholder="所在部门(可选)" />
|
||||
<NSelect
|
||||
v-model:value="editForm.department_id"
|
||||
:options="departmentOptions"
|
||||
placeholder="选择部门(可选)"
|
||||
clearable
|
||||
filterable
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>职级</span>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const store = useAppStore()
|
|||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const phone = ref('13800000000')
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
const sending = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
|
@ -288,7 +288,7 @@ async function login() {
|
|||
{{ submitting ? '登录中…' : '登录' }}
|
||||
</button>
|
||||
|
||||
<p class="login-tip">测试账号:13800000000 / 验证码 123456</p>
|
||||
<p class="login-tip">测试版验证码:123456</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const tabs = [
|
|||
{ key: 'roles', label: '角色管理' },
|
||||
{ key: 'permissions', label: '权限管理' },
|
||||
{ key: 'units', label: '单位管理' },
|
||||
{ key: 'departments', label: '部门管理' },
|
||||
]
|
||||
const active = ref('users')
|
||||
</script>
|
||||
|
|
@ -35,7 +36,8 @@ const active = ref('users')
|
|||
<UserManagementTab v-if="active === 'users'" />
|
||||
<RoleManagementTab v-else-if="active === 'roles'" />
|
||||
<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>
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Reference in New Issue