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,
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,

View File

@ -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)

View File

@ -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, "系统管理员", "停用"])
# 隐藏字典 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)
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]:

View File

@ -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'"),

View File

@ -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")

View File

@ -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]

View File

@ -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

View File

@ -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)

View File

@ -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]

View File

@ -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)

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>