523 lines
15 KiB
Vue
523 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { createDept, deleteDept, getDeptMembers, getDeptTree, updateDept, type Dept, type DeptDTO } from '@/api/dept'
|
||
import { getRoles } from '@/api/role'
|
||
import { Pagination, StatusTag, TableActions } from '@/components'
|
||
import type { Role } from '@/types'
|
||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, TeamOutlined } from '@ant-design/icons-vue'
|
||
import { Button, Col, Descriptions, Drawer, Empty, Form, Input, InputNumber, Modal, Row, Select, Space, Spin, Table, Tag, Tree, TreeSelect, message } from 'ant-design-vue'
|
||
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
|
||
// 表格列定义
|
||
const columns: ColumnsType = [
|
||
{ title: '姓名', dataIndex: 'realName', key: 'realName', width: 120 },
|
||
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
|
||
{ title: '角色', dataIndex: 'roleNames', key: 'roleNames', ellipsis: true },
|
||
{ title: '用户类型', dataIndex: 'userType', key: 'userType', width: 100 },
|
||
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
|
||
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const }
|
||
]
|
||
|
||
// 部门类型选项
|
||
const deptTypeOptions = [
|
||
{ value: 'ADMIN', label: '行政管理' },
|
||
{ value: 'ENGINEERING', label: '工程部' },
|
||
{ value: 'SECURITY', label: '安保部' },
|
||
{ value: 'CS', label: '客服部' },
|
||
{ value: 'CLEANING', label: '保洁部' }
|
||
]
|
||
|
||
// 部门类型翻译
|
||
const getDeptTypeLabel = (type: string) => {
|
||
const map: Record<string, string> = {
|
||
'ADMIN': '行政管理',
|
||
'ENGINEERING': '工程部',
|
||
'SECURITY': '安保部',
|
||
'CS': '客服部',
|
||
'CLEANING': '保洁部'
|
||
}
|
||
return map[type] || type
|
||
}
|
||
|
||
// 部门类型颜色
|
||
const getDeptTypeColor = (type: string) => {
|
||
const map: Record<string, string> = {
|
||
'ADMIN': 'blue',
|
||
'ENGINEERING': 'orange',
|
||
'SECURITY': 'red',
|
||
'CS': 'green',
|
||
'CLEANING': 'cyan'
|
||
}
|
||
return map[type] || 'default'
|
||
}
|
||
|
||
// 树节点类型
|
||
interface TreeNode {
|
||
key: string
|
||
title: string
|
||
children?: TreeNode[]
|
||
deptData: Dept
|
||
}
|
||
|
||
// 数据状态
|
||
const treeData = ref<TreeNode[]>([])
|
||
const expandedKeys = ref<string[]>([])
|
||
const selectedKeys = ref<string[]>([])
|
||
const loading = ref(false)
|
||
const submitting = ref(false)
|
||
const drawerVisible = ref(false)
|
||
const drawerTitle = ref('')
|
||
const isEdit = ref(false)
|
||
const roles = ref<Role[]>([])
|
||
const members = ref<any[]>([])
|
||
const membersLoading = ref(false)
|
||
const currentDept = ref<Dept | null>(null)
|
||
const formRef = ref()
|
||
|
||
// 分页
|
||
const pagination = reactive({
|
||
current: 1,
|
||
pageSize: 10,
|
||
total: 0
|
||
})
|
||
|
||
// 分页后的数据
|
||
const paginatedData = computed(() => {
|
||
const start = (pagination.current - 1) * pagination.pageSize
|
||
const end = start + pagination.pageSize
|
||
return members.value.slice(start, end)
|
||
})
|
||
|
||
// 表单
|
||
const formState = ref<DeptDTO>({
|
||
deptName: '',
|
||
deptCode: '',
|
||
parentId: undefined,
|
||
deptType: 'ADMIN',
|
||
defaultRoleCode: '',
|
||
sortOrder: 0
|
||
})
|
||
|
||
// 获取角色列表
|
||
const fetchRoles = async () => {
|
||
try {
|
||
const res = await getRoles()
|
||
roles.value = res.data.data || []
|
||
} catch {
|
||
message.error('获取角色列表失败')
|
||
}
|
||
}
|
||
|
||
// 转换为树形结构
|
||
const convertToTree = (depts: Dept[], parentId: string | null = null): TreeNode[] => {
|
||
return depts
|
||
.filter(d => (parentId === null && !d.parentId) || d.parentId === parentId)
|
||
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
||
.map(d => ({
|
||
key: d.id!,
|
||
title: d.deptName,
|
||
deptData: d,
|
||
children: convertToTree(depts, d.id!)
|
||
}))
|
||
}
|
||
|
||
// 获取部门树
|
||
const fetchDeptTree = async () => {
|
||
loading.value = true
|
||
try {
|
||
const res = await getDeptTree()
|
||
const depts = res.data.data || []
|
||
treeData.value = convertToTree(depts)
|
||
// 默认展开第一级
|
||
if (treeData.value.length > 0) {
|
||
expandedKeys.value = treeData.value.map(n => n.key)
|
||
}
|
||
} catch {
|
||
message.error('获取部门树失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 获取所有子部门ID
|
||
const getAllChildDeptIds = (node: TreeNode): string[] => {
|
||
const ids = [node.key]
|
||
if (node.children) {
|
||
for (const child of node.children) {
|
||
ids.push(...getAllChildDeptIds(child))
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
// 根据key查找节点
|
||
const findNodeByKey = (nodes: TreeNode[], key: string): TreeNode | null => {
|
||
for (const node of nodes) {
|
||
if (node.key === key) return node
|
||
if (node.children) {
|
||
const found = findNodeByKey(node.children, key)
|
||
if (found) return found
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
// 获取部门成员(含子部门)
|
||
const fetchMembers = async (deptIds: string[]) => {
|
||
membersLoading.value = true
|
||
const allMembers: any[] = []
|
||
try {
|
||
for (const deptId of deptIds) {
|
||
const res = await getDeptMembers(deptId)
|
||
if (res.data.data) {
|
||
allMembers.push(...res.data.data)
|
||
}
|
||
}
|
||
// 去重
|
||
const seen = new Set()
|
||
members.value = allMembers.filter(m => {
|
||
if (seen.has(m.id)) return false
|
||
seen.add(m.id)
|
||
return true
|
||
})
|
||
pagination.total = members.value.length
|
||
} catch {
|
||
message.error('获取部门成员失败')
|
||
} finally {
|
||
membersLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 选择部门
|
||
const handleSelect = async (keys: any, info: any) => {
|
||
const selectedKeysValue = keys as string[]
|
||
if (selectedKeysValue.length > 0) {
|
||
selectedKeys.value = selectedKeysValue
|
||
// 从 treeData 中查找选中的节点
|
||
const selectedNode = findNodeByKey(treeData.value, selectedKeysValue[0])
|
||
if (selectedNode) {
|
||
currentDept.value = selectedNode.deptData
|
||
const deptIds = getAllChildDeptIds(selectedNode)
|
||
await fetchMembers(deptIds)
|
||
}
|
||
} else {
|
||
selectedKeys.value = []
|
||
currentDept.value = null
|
||
members.value = []
|
||
pagination.total = 0
|
||
}
|
||
}
|
||
|
||
// 分页变化
|
||
const handlePageChange = (page: number, pageSize: number) => {
|
||
pagination.current = page
|
||
pagination.pageSize = pageSize
|
||
}
|
||
|
||
// 新增
|
||
const handleAdd = () => {
|
||
isEdit.value = false
|
||
drawerTitle.value = '新增部门'
|
||
formState.value = {
|
||
deptName: '',
|
||
deptCode: '',
|
||
parentId: selectedKeys.value[0] as any,
|
||
deptType: currentDept.value?.deptType || 'ADMIN',
|
||
defaultRoleCode: '',
|
||
sortOrder: 0
|
||
}
|
||
drawerVisible.value = true
|
||
}
|
||
|
||
// 编辑
|
||
const handleEdit = () => {
|
||
if (!currentDept.value) return
|
||
isEdit.value = true
|
||
drawerTitle.value = '编辑部门'
|
||
formState.value = {
|
||
deptName: currentDept.value.deptName,
|
||
deptCode: currentDept.value.deptCode,
|
||
parentId: currentDept.value.parentId,
|
||
deptType: currentDept.value.deptType || 'ADMIN',
|
||
defaultRoleCode: currentDept.value.defaultRoleCode,
|
||
sortOrder: currentDept.value.sortOrder
|
||
}
|
||
drawerVisible.value = true
|
||
}
|
||
|
||
// 删除
|
||
const handleDelete = () => {
|
||
if (!currentDept.value) return
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: `确定要删除部门"${currentDept.value.deptName}"吗?删除后不可恢复。`,
|
||
okText: '删除',
|
||
okType: 'danger',
|
||
onOk: async () => {
|
||
try {
|
||
await deleteDept(currentDept.value!.id!)
|
||
message.success('删除成功')
|
||
selectedKeys.value = []
|
||
currentDept.value = null
|
||
members.value = []
|
||
await fetchDeptTree()
|
||
} catch (e: any) {
|
||
message.error(e.message || '删除失败')
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 提交
|
||
const handleSubmit = async () => {
|
||
try {
|
||
await formRef.value.validate()
|
||
submitting.value = true
|
||
|
||
if (isEdit.value && currentDept.value) {
|
||
await updateDept(currentDept.value.id!, formState.value)
|
||
message.success('更新成功')
|
||
} else {
|
||
await createDept(formState.value)
|
||
message.success('创建成功')
|
||
}
|
||
|
||
drawerVisible.value = false
|
||
await fetchDeptTree()
|
||
// 刷新当前选中的部门
|
||
if (selectedKeys.value.length > 0) {
|
||
const node = findNodeByKey(treeData.value, selectedKeys.value[0])
|
||
if (node) {
|
||
currentDept.value = node.deptData
|
||
const deptIds = getAllChildDeptIds(node)
|
||
await fetchMembers(deptIds)
|
||
}
|
||
}
|
||
} catch (error: any) {
|
||
if (error.errorFields) return
|
||
message.error('操作失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
// 关闭抽屉
|
||
const handleClose = () => {
|
||
formRef.value?.resetFields()
|
||
drawerVisible.value = false
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await fetchRoles()
|
||
await fetchDeptTree()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-container">
|
||
<!-- 页面标题 -->
|
||
<div class="page-header">
|
||
<h2 class="page-title">组织架构</h2>
|
||
<div class="page-header-actions">
|
||
<Button type="primary" @click="handleAdd">
|
||
<PlusOutlined /> 新增部门
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 左右布局 -->
|
||
<Row :gutter="16" class="main-content">
|
||
<!-- 左侧部门树 -->
|
||
<Col :span="6">
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<TeamOutlined />
|
||
部门列表
|
||
<Button size="small" style="margin-left: auto;" @click="fetchDeptTree">
|
||
<ReloadOutlined />
|
||
</Button>
|
||
</div>
|
||
<Spin :spinning="loading">
|
||
<Tree
|
||
v-if="treeData.length > 0"
|
||
:tree-data="treeData"
|
||
:selected-keys="selectedKeys"
|
||
:expanded-keys="expandedKeys"
|
||
:block-node="true"
|
||
show-line
|
||
@select="handleSelect"
|
||
@expand="(keys: any) => expandedKeys = keys as string[]"
|
||
>
|
||
<template #title="{ title, deptData }">
|
||
<Space>
|
||
<span>{{ title }}</span>
|
||
<Tag v-if="deptData.deptType" size="small" :color="getDeptTypeColor(deptData.deptType)">
|
||
{{ getDeptTypeLabel(deptData.deptType) }}
|
||
</Tag>
|
||
</Space>
|
||
</template>
|
||
</Tree>
|
||
<Empty v-else description="暂无部门数据" />
|
||
</Spin>
|
||
</div>
|
||
</Col>
|
||
|
||
<!-- 右侧详情 -->
|
||
<Col :span="18">
|
||
<template v-if="currentDept">
|
||
<!-- 部门信息 -->
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<span>{{ currentDept.deptName }}</span>
|
||
<StatusTag :status="currentDept.status || 'ACTIVE'" />
|
||
<Space style="margin-left: auto;">
|
||
<Button @click="handleEdit">
|
||
<EditOutlined /> 编辑
|
||
</Button>
|
||
<Button danger @click="handleDelete">
|
||
<DeleteOutlined /> 删除
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
<Descriptions :column="4" size="small">
|
||
<Descriptions.Item label="部门编码">{{ currentDept.deptCode || '-' }}</Descriptions.Item>
|
||
<Descriptions.Item label="部门类型">
|
||
<Tag :color="getDeptTypeColor(currentDept.deptType || 'ADMIN')">
|
||
{{ getDeptTypeLabel(currentDept.deptType || 'ADMIN') }}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="默认角色">{{ currentDept.defaultRoleCode || '-' }}</Descriptions.Item>
|
||
<Descriptions.Item label="排序">{{ currentDept.sortOrder || 0 }}</Descriptions.Item>
|
||
</Descriptions>
|
||
</div>
|
||
|
||
<!-- 人员清单 -->
|
||
<div class="table-card" style="margin-top: 16px;">
|
||
<div class="card-title">
|
||
<TeamOutlined />
|
||
人员清单(含子部门)
|
||
<Tag color="blue">{{ pagination.total }} 人</Tag>
|
||
</div>
|
||
<Table
|
||
:columns="columns"
|
||
:data-source="paginatedData"
|
||
:loading="membersLoading"
|
||
:row-key="(record: any) => record.id"
|
||
:pagination="false"
|
||
size="small"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'status'">
|
||
<StatusTag :status="record.status" />
|
||
</template>
|
||
<template v-else-if="column.key === 'userType'">
|
||
{{ record.userType === 'ENTERPRISE' ? '企业员工' : record.userType === 'PROJECT_STAFF' ? '项目员工' : record.userType }}
|
||
</template>
|
||
<template v-else-if="column.key === 'action'">
|
||
<TableActions
|
||
show-view
|
||
@view="() => {}"
|
||
/>
|
||
</template>
|
||
</template>
|
||
</Table>
|
||
<Pagination
|
||
v-model:current="pagination.current"
|
||
v-model:pageSize="pagination.pageSize"
|
||
:total="pagination.total"
|
||
@change="handlePageChange"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-else class="card" style="min-height: calc(100vh - 200px); display: flex; align-items: center; justify-content: center;">
|
||
<Empty description="请选择左侧部门查看详情" />
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
|
||
<!-- 新增/编辑抽屉 -->
|
||
<Drawer
|
||
v-model:open="drawerVisible"
|
||
:title="drawerTitle"
|
||
:width="480"
|
||
@close="handleClose"
|
||
>
|
||
<Form
|
||
ref="formRef"
|
||
:model="formState"
|
||
layout="vertical"
|
||
:rules="{
|
||
deptName: [{ required: true, message: '请输入部门名称', trigger: 'blur' }]
|
||
}"
|
||
>
|
||
<Form.Item label="上级部门" name="parentId">
|
||
<TreeSelect
|
||
v-model:value="formState.parentId"
|
||
:tree-data="treeData"
|
||
:field-names="{ children: 'children', label: 'title', value: 'key' }"
|
||
placeholder="请选择上级部门(留空为顶级)"
|
||
allow-clear
|
||
style="width: 100%"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item label="部门名称" name="deptName" required>
|
||
<Input v-model:value="formState.deptName" placeholder="请输入部门名称" />
|
||
</Form.Item>
|
||
<Form.Item label="部门编码" name="deptCode">
|
||
<Input v-model:value="formState.deptCode" placeholder="请输入部门编码(可选)" />
|
||
</Form.Item>
|
||
<Form.Item label="部门类型" name="deptType">
|
||
<Select v-model:value="formState.deptType" :options="deptTypeOptions" placeholder="请选择部门类型" />
|
||
</Form.Item>
|
||
<Form.Item label="默认角色" name="defaultRoleCode">
|
||
<Select
|
||
v-model:value="formState.defaultRoleCode"
|
||
:options="roles.map(r => ({ value: r.code, label: r.name }))"
|
||
placeholder="请选择默认角色"
|
||
allow-clear
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item label="排序" name="sortOrder">
|
||
<InputNumber v-model:value="formState.sortOrder" :min="0" style="width: 100%" />
|
||
</Form.Item>
|
||
</Form>
|
||
<template #footer>
|
||
<Space>
|
||
<Button @click="handleClose">取消</Button>
|
||
<Button type="primary" :loading="submitting" @click="handleSubmit">确定</Button>
|
||
</Space>
|
||
</template>
|
||
</Drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page-container {
|
||
padding: 16px;
|
||
}
|
||
|
||
.main-content {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.card {
|
||
background: #fff;
|
||
border: 1px solid #e8e8e8;
|
||
border-radius: 6px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.card-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
:deep(.ant-tree-node-content-wrapper) {
|
||
padding: 4px 8px;
|
||
}
|
||
</style>
|