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,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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, "系统管理员", "停用"])
|
||||||
|
|
||||||
|
# 隐藏字典 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)
|
@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]:
|
||||||
|
|
|
||||||
|
|
@ -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'"),
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
|
||||||
const disabledByCycle = banned.has(n.id)
|
|
||||||
const disabledByCategory = currentCategory === 'company' && n.category !== 'company'
|
|
||||||
return {
|
|
||||||
key: n.id,
|
key: n.id,
|
||||||
label: n.name,
|
label: n.name,
|
||||||
disabled: disabledByCycle || disabledByCategory,
|
disabled: banned.has(n.id),
|
||||||
children: n.children ? build(n.children) : undefined,
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue