ether-admin/src/views/system/Depts.vue

523 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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