Files
schoolNews/schoolNewsWeb/src/views/admin/manage/system/DeptManageView.vue
2025-11-21 16:53:58 +08:00

755 lines
19 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.

<template>
<AdminLayout
title="系统管理"
subtitle="管理用户、角色、权限、部门等系统信息"
>
<div class="dept-manage">
<div class="header">
<h2>部门管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增部门
</el-button>
</div>
<div class="tree-container">
<el-tree
ref="treeRef"
v-loading="loading"
:data="deptList"
:props="treeProps"
node-key="deptID"
:default-expand-all="true"
:expand-on-click-node="false"
:check-on-click-node="false"
draggable
:allow-drop="allowDrop"
@node-drop="handleNodeDrop"
class="dept-tree"
>
<template #default="{ data }">
<div class="custom-tree-node">
<div class="node-label">
<el-icon class="node-icon">
<OfficeBuilding />
</el-icon>
<span class="node-name">{{ data.name }}</span>
</div>
<div class="node-info">
<span class="info-item">ID: {{ data.deptID }}</span>
<span class="info-item" v-if="data.description">{{ data.description }}</span>
<span class="info-item" v-if="data.creatorName">创建人: {{ data.creatorName }}</span>
</div>
<div class="node-actions">
<el-button size="small" type="primary" @click.stop="handleAddChild(data)">
子部门
</el-button>
<el-button size="small" type="success" @click.stop="handleEdit(data)">
编辑
</el-button>
<el-button size="small" type="warning" @click.stop="handleBindRole(data)">
角色
</el-button>
<el-button size="small" type="danger" @click.stop="handleDelete(data)">
删除
</el-button>
</div>
</div>
</template>
</el-tree>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑部门' : '新增部门'"
width="600px"
@close="resetForm"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="父部门" prop="parentID">
<el-cascader
v-model="formData.parentID"
:options="parentDeptOptions"
:props="cascaderProps"
placeholder="请选择父部门"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="部门名称" prop="name">
<el-input
v-model="formData.name"
placeholder="请输入部门名称"
clearable
/>
</el-form-item>
<el-form-item label="部门描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入部门描述"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitting"
>
确定
</el-button>
</template>
</el-dialog>
<!-- 绑定角色对话框 -->
<GenericSelector
v-model:visible="bindRoleDialogVisible"
:title="`绑定角色 - ${currentDept?.name || ''}`"
left-title="可选角色"
right-title="已选角色"
:fetch-available-api="fetchAllRoles"
:fetch-selected-api="fetchDeptRoles"
:item-config="{ id: 'roleID', label: 'name', sublabel: 'description' }"
unit-name=""
search-placeholder="搜索角色名称或描述..."
@confirm="handleRoleConfirm"
@cancel="resetBindList"
/>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { deptApi } from '@/apis/system/dept';
import { roleApi } from '@/apis/system/role';
import { SysDept, SysRole } from '@/types';
import { AdminLayout } from '@/views/admin';
import { GenericSelector } from '@/components/base';
defineOptions({
name: 'DeptManageView'
});
import { ref, onMounted, reactive, computed } from 'vue';
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
import { Plus, OfficeBuilding } from '@element-plus/icons-vue';
// 数据状态
const deptList = ref<SysDept[]>([]);
const loading = ref(false);
const submitting = ref(false);
const treeRef = ref();
// 角色绑定相关数据
const currentDept = ref<SysDept | null>(null);
// 对话框状态
const dialogVisible = ref(false);
const isEdit = ref(false);
const formRef = ref<FormInstance>();
const bindRoleDialogVisible = ref(false);
// 表单数据
const formData = reactive<SysDept>({
name: '',
description: '',
parentID: undefined
});
// 表单验证规则
const formRules: FormRules = {
name: [
{ required: true, message: '请输入部门名称', trigger: 'blur' },
{ min: 2, max: 50, message: '部门名称长度在 2 到 50 个字符', trigger: 'blur' }
],
description: [
{ max: 200, message: '部门描述不能超过 200 个字符', trigger: 'blur' }
]
};
// 级联选择器配置
const cascaderProps = {
value: 'value',
label: 'label',
children: 'children',
checkStrictly: true
};
// 树形组件配置
const treeProps = {
children: 'children',
label: 'name'
};
// 父部门选项
const parentDeptOptions = computed(() => {
return buildParentDeptOptions(deptList.value);
});
// 构建父部门选项
function buildParentDeptOptions(depts: SysDept[]): any[] {
const result: any[] = [];
function processNode(dept: SysDept): any {
const option = {
value: dept.deptID,
label: dept.name,
children: dept.children && dept.children.length > 0
? dept.children.map(processNode)
: undefined
};
return option;
}
depts.forEach(dept => {
result.push(processNode(dept));
});
return result;
}
// 加载部门列表
async function loadDeptList() {
try {
loading.value = true;
const result = await deptApi.getAllDepts();
const rawData = result.dataList || [];
// console.log('原始部门数据:', rawData);
// 将扁平数据转换为树形结构
deptList.value = buildTree(rawData);
// console.log('转换后的树形数据:', deptList.value);
} catch (error) {
console.error('加载部门列表失败:', error);
ElMessage.error('加载部门列表失败');
} finally {
loading.value = false;
}
}
// 将扁平数据转换为树形结构
function buildTree(flatData: SysDept[]): SysDept[] {
if (!flatData || flatData.length === 0) {
return [];
}
const tree: SysDept[] = [];
const map = new Map<string, SysDept>();
const maxDepth = flatData.length; // 最多遍历len层
// 初始化所有节点
flatData.forEach(item => {
if (item.deptID) {
map.set(item.deptID, { ...item, children: [] });
}
});
// 循环构建树结构最多遍历maxDepth次
for (let depth = 0; depth < maxDepth; depth++) {
let hasChanges = false;
flatData.forEach(item => {
if (!item.deptID) return;
const node = map.get(item.deptID);
if (!node) return;
// 如果节点已经在树中,跳过
if (isNodeInTree(node, tree)) {
return;
}
if (!item.parentID || item.parentID === '0' || item.parentID === '') {
// 根节点
if (!isNodeInTree(node, tree)) {
tree.push(node);
hasChanges = true;
}
} else {
// 查找父节点
const parent = map.get(item.parentID);
if (parent && isNodeInTree(parent, tree)) {
if (!parent.children) {
parent.children = [];
}
if (!parent.children.includes(node)) {
parent.children.push(node);
hasChanges = true;
}
}
}
});
// 如果没有变化,说明树构建完成
if (!hasChanges) {
break;
}
}
// 清理空的children数组
function cleanEmptyChildren(nodes: SysDept[]) {
nodes.forEach(node => {
if (node.children && node.children.length === 0) {
delete node.children;
} else if (node.children && node.children.length > 0) {
cleanEmptyChildren(node.children);
}
});
}
cleanEmptyChildren(tree);
return tree;
}
// 检查节点是否已经在树中
function isNodeInTree(node: SysDept, tree: SysDept[]): boolean {
for (const treeNode of tree) {
if (treeNode.deptID === node.deptID) {
return true;
}
if (treeNode.children && isNodeInTree(node, treeNode.children)) {
return true;
}
}
return false;
}
// 新增部门
function handleAdd() {
isEdit.value = false;
formData.parentID = undefined;
dialogVisible.value = true;
}
// 新增子部门
function handleAddChild(row: SysDept) {
isEdit.value = false;
formData.parentID = row.deptID;
dialogVisible.value = true;
}
// 编辑部门
function handleEdit(row: SysDept) {
isEdit.value = true;
Object.assign(formData, row);
dialogVisible.value = true;
}
// 删除部门
async function handleDelete(row: SysDept) {
try {
await ElMessageBox.confirm(
`确定要删除部门 "${row.name}" 吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
);
await deptApi.deleteDept(row);
ElMessage.success('删除成功');
await loadDeptList();
} catch (error) {
if (error !== 'cancel') {
console.error('删除部门失败:', error);
ElMessage.error('删除部门失败');
}
}
}
// 提交表单
async function handleSubmit() {
if (!formRef.value) return;
try {
await formRef.value.validate();
submitting.value = true;
if (isEdit.value) {
await deptApi.updateDept(formData);
ElMessage.success('更新成功');
} else {
await deptApi.createDept(formData);
ElMessage.success('新增成功');
}
dialogVisible.value = false;
await loadDeptList();
} catch (error) {
if (error !== false) { // 表单验证失败时error为false
console.error('提交失败:', error);
ElMessage.error(isEdit.value ? '更新失败' : '新增失败');
}
} finally {
submitting.value = false;
}
}
// 重置表单
function resetForm() {
if (formRef.value) {
formRef.value.resetFields();
}
Object.assign(formData, {
name: '',
description: '',
parentID: undefined
});
}
// 获取所有可选角色的接口
async function fetchAllRoles() {
const result = await roleApi.getAllRoles();
if (result.success && result.dataList) {
result.dataList = result.dataList.map(item => ({
roleID: item.roleID,
name: item.roleName, // roleName -> name
description: item.roleDescription // roleDescription -> description
}));
}
return result;
}
// 获取部门已绑定角色的接口
async function fetchDeptRoles() {
if (!currentDept.value) {
return {
success: true,
dataList: [],
code: 200,
message: '',
login: true,
auth: true
};
}
const result = await deptApi.getDeptByRole(currentDept.value);
// 转换数据格式,将 UserDeptRoleVO 转换为与 SysRole 一致的格式
if (result.success && result.dataList) {
result.dataList = result.dataList.map(item => ({
roleID: item.roleID,
name: item.roleName, // roleName -> name
description: item.roleDescription // roleDescription -> description
}));
}
return result;
}
// 查看绑定角色
async function handleBindRole(row: SysDept) {
currentDept.value = row;
bindRoleDialogVisible.value = true;
}
// 重置绑定列表
function resetBindList() {
currentDept.value = null;
}
// 角色选择确认 - 在confirm时提交请求
async function handleRoleConfirm(items: SysRole[]) {
if (!currentDept.value || !currentDept.value.deptID) {
ElMessage.error('部门信息不完整');
return;
}
try {
submitting.value = true;
// 获取当前已绑定的角色
const currentBoundResult = await deptApi.getDeptByRole(currentDept.value);
const currentBoundIds = (currentBoundResult.dataList || []).map(r => r.roleID).filter((id): id is string => !!id);
// 新选择的角色ID
const newSelectedIds = items.map(r => r.roleID).filter((id): id is string => !!id);
// 找出需要绑定的角色(新增的)
const rolesToBind = newSelectedIds.filter(id => !currentBoundIds.includes(id));
// 找出需要解绑的角色(移除的)
const rolesToUnbind = currentBoundIds.filter(id => !newSelectedIds.includes(id));
// 构建需要绑定的角色对象数组
if (rolesToBind.length > 0) {
const rolesToBindObjects = items.filter(r => r.roleID && rolesToBind.includes(r.roleID));
const bindDept = {
dept: currentDept.value,
depts: [{deptID: currentDept.value.deptID}],
roles: rolesToBindObjects,
};
await deptApi.bindDeptRole(bindDept);
}
// 构建需要解绑的角色对象数组
if (rolesToUnbind.length > 0) {
const rolesToUnbindObjects = (currentBoundResult.dataList || []).filter(r => r.roleID && rolesToUnbind.includes(r.roleID));
const unbindDept = {
dept: currentDept.value,
roles: rolesToUnbindObjects,
depts: [{deptID: currentDept.value.deptID}]
};
await deptApi.unbindDeptRole(unbindDept);
}
ElMessage.success('角色绑定保存成功');
// 刷新部门列表
await loadDeptList();
} catch (error) {
console.error('保存角色绑定失败:', error);
ElMessage.error('保存角色绑定失败');
} finally {
submitting.value = false;
}
}
// 页面加载时获取部门列表
onMounted(() => {
loadDeptList();
});
// 节点拖拽逻辑
function allowDrop(draggingNode: any, dropNode: any) {
// 禁止拖拽到自己或子节点
return !isDescendant(draggingNode.data, dropNode.data);
}
// 判断是否是后代节点
function isDescendant(ancestor: SysDept, node: SysDept): boolean {
if (ancestor.deptID === node.deptID) return true;
if (node.children) {
return node.children.some(child => isDescendant(ancestor, child));
}
return false;
}
// 处理节点拖拽
async function handleNodeDrop(draggingNode: any, dropNode: any, dropType: string) {
try {
const dragData = draggingNode.data as SysDept;
const dropData = dropNode.data as SysDept;
// 更新被拖拽节点的父ID
if (dropType === 'inner') {
dragData.parentID = dropData.deptID;
} else {
dragData.parentID = dropData.parentID;
}
// 更新到后端
await deptApi.updateDept(dragData);
ElMessage.success('部门移动成功');
// 重新加载部门列表以确保数据一致性
await loadDeptList();
} catch (error) {
console.error('移动部门失败:', error);
ElMessage.error('移动部门失败');
// 重新加载部门列表以恢复原始状态
await loadDeptList();
}
}
</script>
<style scoped lang="scss">
.dept-manage {
background: #FFFFFF;
padding: 24px;
border-radius: 14px;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
}
.tree-container {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 20px;
.dept-tree {
.custom-tree-node {
display: flex;
align-items: center;
width: 100%;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
.node-label {
display: flex;
align-items: center;
flex: 1;
min-width: 200px;
.node-icon {
margin-right: 8px;
color: #67C23A;
font-size: 16px;
}
.node-name {
font-size: 16px;
font-weight: 500;
color: #303133;
margin-right: 12px;
}
}
.node-info {
display: flex;
flex-wrap: wrap;
gap: 12px;
flex: 1;
margin: 0 20px;
.info-item {
font-size: 12px;
color: #909399;
background: #f4f4f5;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
}
.node-actions {
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.3s;
.el-button {
padding: 4px 8px;
font-size: 12px;
}
}
&:hover {
background-color: #f5f7fa;
border-radius: 4px;
.node-actions {
opacity: 1;
}
}
}
}
// Element Plus Tree 组件样式覆盖
:deep(.el-tree-node__content) {
height: auto !important;
padding: 0 !important;
}
:deep(.el-tree-node__expand-icon) {
padding: 6px;
color: #67C23A;
}
:deep(.el-tree-node) {
white-space: normal;
}
:deep(.el-tree-node__children) {
padding-left: 24px !important;
}
}
.el-table {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
}
// 对话框样式优化
:deep(.el-dialog) {
.el-dialog__header {
padding: 20px 20px 10px;
border-bottom: 1px solid #ebeef5;
.el-dialog__title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.el-dialog__body {
padding: 20px;
}
.el-dialog__footer {
padding: 10px 20px 20px;
border-top: 1px solid #ebeef5;
}
}
// 表单样式优化
:deep(.el-form) {
.el-form-item__label {
font-weight: 500;
color: #606266;
}
.el-input__wrapper {
box-shadow: 0 0 0 1px #dcdfe6 inset;
&:hover {
box-shadow: 0 0 0 1px #c0c4cc inset;
}
&.is-focus {
box-shadow: 0 0 0 1px #409eff inset;
}
}
.el-textarea__inner {
box-shadow: 0 0 0 1px #dcdfe6 inset;
&:hover {
box-shadow: 0 0 0 1px #c0c4cc inset;
}
&:focus {
box-shadow: 0 0 0 1px #409eff inset;
}
}
}
// 按钮样式优化
:deep(.el-button) {
&.is-link {
padding: 0;
margin-right: 12px;
&:last-child {
margin-right: 0;
}
}
}
</style>