1171 lines
32 KiB
Vue
1171 lines
32 KiB
Vue
<template>
|
||
<div class="org-tree-container">
|
||
<el-row :gutter="20">
|
||
<!-- Left Column: Tree -->
|
||
<el-col :span="12">
|
||
<el-card shadow="never" class="tree-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span class="header-title"
|
||
><el-icon><Connection /></el-icon> 组织架构全景图</span
|
||
>
|
||
<el-button
|
||
type="primary"
|
||
@click="fetchTreeData"
|
||
icon="Refresh"
|
||
round
|
||
size="small"
|
||
>刷新</el-button
|
||
>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="tree-content" v-loading="loading">
|
||
<el-tree
|
||
:data="treeData"
|
||
:props="defaultProps"
|
||
node-key="key"
|
||
default-expand-all
|
||
:expand-on-click-node="false"
|
||
class="beautiful-tree"
|
||
@node-click="handleNodeClick"
|
||
highlight-current
|
||
>
|
||
<template #default="{ node, data }">
|
||
<div class="custom-tree-node" :class="`node-${data.type}`">
|
||
<div class="node-main">
|
||
<div class="node-icon-wrapper">
|
||
<el-icon v-if="data.type === 'hospital'"
|
||
><OfficeBuilding
|
||
/></el-icon>
|
||
<el-icon v-else-if="data.type === 'department'"
|
||
><Filter
|
||
/></el-icon>
|
||
<el-icon v-else-if="data.type === 'group'"
|
||
><Connection
|
||
/></el-icon>
|
||
<el-icon v-else-if="data.type === 'user'"
|
||
><UserFilled
|
||
/></el-icon>
|
||
</div>
|
||
<div class="node-text">
|
||
<span class="node-label">{{ node.label }}</span>
|
||
<span
|
||
v-if="data.type === 'hospital'"
|
||
class="node-sub-label"
|
||
>
|
||
医院管理员:{{ data.adminDisplay || '未设置' }}
|
||
</span>
|
||
<span
|
||
v-else-if="data.type === 'department'"
|
||
class="node-sub-label"
|
||
>
|
||
主任:{{ data.directorDisplay || '未设置' }}
|
||
</span>
|
||
<span
|
||
v-else-if="data.type === 'group'"
|
||
class="node-sub-label"
|
||
>
|
||
组长:{{ data.leaderDisplay || '未设置' }}
|
||
</span>
|
||
</div>
|
||
<el-tag
|
||
v-if="data.type === 'user'"
|
||
size="small"
|
||
:type="getRoleTagType(data.role)"
|
||
effect="light"
|
||
class="role-tag"
|
||
round
|
||
>
|
||
{{ getRoleName(data.role) }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<div class="node-actions" v-if="data.type !== 'user'">
|
||
<el-button
|
||
v-if="
|
||
canAssignOwner &&
|
||
(data.type === 'department' || data.type === 'group')
|
||
"
|
||
type="warning"
|
||
link
|
||
size="small"
|
||
@click.stop="openSetOwnerDialog(data)"
|
||
>
|
||
{{ data.type === 'department' ? '设主任' : '设组长' }}
|
||
</el-button>
|
||
<el-button
|
||
v-if="canEditNode(data)"
|
||
type="info"
|
||
link
|
||
size="small"
|
||
@click.stop="openEditDialog(data)"
|
||
icon="EditPen"
|
||
>
|
||
编辑
|
||
</el-button>
|
||
<el-button
|
||
v-if="canDeleteNode(data)"
|
||
type="danger"
|
||
link
|
||
size="small"
|
||
@click.stop="handleDelete(data)"
|
||
icon="Delete"
|
||
>
|
||
删除
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-tree>
|
||
<el-empty
|
||
v-if="!loading && treeData.length === 0"
|
||
description="暂无组织架构数据"
|
||
/>
|
||
</div>
|
||
</el-card>
|
||
</el-col>
|
||
|
||
<!-- Right Column: Details & Actions -->
|
||
<el-col :span="12">
|
||
<el-card shadow="never" class="detail-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span class="header-title">
|
||
<el-icon><Menu /></el-icon>
|
||
{{ activePanelTitle }}
|
||
</span>
|
||
<div
|
||
v-if="activeNode && activeNode.type !== 'user'"
|
||
class="header-actions"
|
||
>
|
||
<el-button
|
||
v-if="canCreateDepartment(activeNode)"
|
||
type="primary"
|
||
size="small"
|
||
icon="Plus"
|
||
@click="openCreateDialog('department', activeNode.id)"
|
||
>
|
||
新增科室
|
||
</el-button>
|
||
<el-button
|
||
v-if="canCreateGroup(activeNode)"
|
||
type="success"
|
||
size="small"
|
||
icon="Plus"
|
||
@click="openCreateDialog('group', activeNode.id)"
|
||
>
|
||
新增小组
|
||
</el-button>
|
||
<el-button
|
||
v-if="canAddUser(activeNode)"
|
||
type="warning"
|
||
size="small"
|
||
icon="User"
|
||
@click="goToAddUser(activeNode)"
|
||
>
|
||
新增人员
|
||
</el-button>
|
||
<el-button
|
||
v-if="
|
||
canAssignOwner &&
|
||
(activeNode.type === 'department' ||
|
||
activeNode.type === 'group')
|
||
"
|
||
type="primary"
|
||
size="small"
|
||
@click="openSetOwnerDialog(activeNode)"
|
||
>
|
||
{{
|
||
activeNode.type === 'department' ? '设置主任' : '设置组长'
|
||
}}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div
|
||
v-if="activeNode && activeNode.type !== 'user'"
|
||
class="node-detail-panel"
|
||
>
|
||
<el-alert
|
||
v-if="activeNodeMeta"
|
||
:title="activeNodeMeta"
|
||
type="info"
|
||
:closable="false"
|
||
class="mb-12"
|
||
/>
|
||
<el-table
|
||
:data="activeNodeChildren"
|
||
border
|
||
stripe
|
||
style="width: 100%"
|
||
max-height="600"
|
||
>
|
||
<el-table-column prop="name" label="名称" />
|
||
<el-table-column label="类型" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="getNodeTypeTag(row.type)">{{
|
||
getTypeName(row.type)
|
||
}}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="负责人" width="180" align="center">
|
||
<template #default="{ row }">
|
||
<span v-if="row.type === 'hospital'"
|
||
>医院管理员:{{ row.adminDisplay || '未设置' }}</span
|
||
>
|
||
<span v-else-if="row.type === 'department'"
|
||
>主任:{{ row.directorDisplay || '未设置' }}</span
|
||
>
|
||
<span v-else-if="row.type === 'group'"
|
||
>组长:{{ row.leaderDisplay || '未设置' }}</span
|
||
>
|
||
<span v-else>-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="角色" width="120" align="center">
|
||
<template #default="{ row }">
|
||
<span v-if="row.type === 'user'">{{
|
||
getRoleName(row.role)
|
||
}}</span>
|
||
<span v-else>-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="操作"
|
||
width="220"
|
||
align="center"
|
||
fixed="right"
|
||
>
|
||
<template #default="{ row }">
|
||
<el-button
|
||
v-if="
|
||
canAssignOwner &&
|
||
(row.type === 'department' || row.type === 'group')
|
||
"
|
||
type="warning"
|
||
link
|
||
size="small"
|
||
@click="openSetOwnerDialog(row)"
|
||
>
|
||
{{ row.type === 'department' ? '设主任' : '设组长' }}
|
||
</el-button>
|
||
<el-button
|
||
v-if="canEditNode(row)"
|
||
type="primary"
|
||
link
|
||
size="small"
|
||
@click="openEditDialog(row)"
|
||
>编辑</el-button
|
||
>
|
||
<el-button
|
||
v-if="canDeleteNode(row)"
|
||
type="danger"
|
||
link
|
||
size="small"
|
||
@click="handleDelete(row)"
|
||
>删除</el-button
|
||
>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<div v-else-if="selectedUserDetail" class="user-detail-panel">
|
||
<el-descriptions :column="2" border>
|
||
<el-descriptions-item label="姓名">{{
|
||
selectedUserDetail.name || '-'
|
||
}}</el-descriptions-item>
|
||
<el-descriptions-item label="角色">{{
|
||
getRoleName(selectedUserDetail.role)
|
||
}}</el-descriptions-item>
|
||
<el-descriptions-item label="手机号">{{
|
||
selectedUserDetail.phone || '-'
|
||
}}</el-descriptions-item>
|
||
<el-descriptions-item label="医院">{{
|
||
selectedUserDetail.hospitalName || '-'
|
||
}}</el-descriptions-item>
|
||
<el-descriptions-item label="科室">{{
|
||
selectedUserDetail.departmentName || '-'
|
||
}}</el-descriptions-item>
|
||
<el-descriptions-item label="小组">{{
|
||
selectedUserDetail.groupName || '-'
|
||
}}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<el-alert
|
||
title="如需修改人员角色或组织归属,请前往“用户管理”页面操作。"
|
||
type="info"
|
||
:closable="false"
|
||
class="mt-12"
|
||
/>
|
||
</div>
|
||
<el-empty v-else description="点击左侧架构树查看下级列表" />
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<!-- Dialog for Create / Edit -->
|
||
<el-dialog
|
||
:title="dialogTitle"
|
||
v-model="dialogVisible"
|
||
width="450px"
|
||
@close="resetForm"
|
||
destroy-on-close
|
||
>
|
||
<el-form
|
||
:model="form"
|
||
:rules="rules"
|
||
ref="formRef"
|
||
label-width="100px"
|
||
@submit.prevent
|
||
>
|
||
<el-form-item :label="formLabel" prop="name">
|
||
<el-input
|
||
v-model="form.name"
|
||
:placeholder="`请输入${formLabel}`"
|
||
clearable
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button
|
||
type="primary"
|
||
@click="handleSubmit"
|
||
:loading="submitLoading"
|
||
>确定</el-button
|
||
>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
:title="ownerDialogTitle"
|
||
v-model="ownerDialogVisible"
|
||
width="500px"
|
||
destroy-on-close
|
||
>
|
||
<el-form label-width="100px">
|
||
<el-form-item :label="ownerDialogLabel">
|
||
<el-select
|
||
v-model="selectedOwnerUserId"
|
||
filterable
|
||
placeholder="请选择人员"
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="user in ownerCandidates"
|
||
:key="user.id"
|
||
:label="`${user.name}(${getRoleName(user.role)})`"
|
||
:value="user.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-form>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
title="仅主任/组长角色可分配;设置后不会自动取消其他同级负责人,请按需在用户管理页调整。"
|
||
/>
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="ownerDialogVisible = false">取消</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:loading="ownerSubmitLoading"
|
||
@click="handleSetOwner"
|
||
>
|
||
确定
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted, computed } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import {
|
||
getHospitals,
|
||
getDepartments,
|
||
getGroups,
|
||
createDepartment,
|
||
updateDepartment,
|
||
deleteDepartment,
|
||
createGroup,
|
||
updateGroup,
|
||
deleteGroup,
|
||
updateHospital,
|
||
deleteHospital,
|
||
} from '../../api/organization';
|
||
import { getUsers, updateUser } from '../../api/users';
|
||
import { useUserStore } from '../../store/user';
|
||
import {
|
||
OfficeBuilding,
|
||
Filter,
|
||
Connection,
|
||
UserFilled,
|
||
Refresh,
|
||
Plus,
|
||
EditPen,
|
||
Delete,
|
||
Menu,
|
||
User,
|
||
} from '@element-plus/icons-vue';
|
||
|
||
const router = useRouter();
|
||
const userStore = useUserStore();
|
||
|
||
const loading = ref(false);
|
||
const treeData = ref([]);
|
||
const activeNode = ref(null);
|
||
const allUsers = ref([]);
|
||
const hospitalNameMap = ref({});
|
||
const departmentNameMap = ref({});
|
||
const groupNameMap = ref({});
|
||
const ownerCandidates = ref([]);
|
||
const ownerDialogVisible = ref(false);
|
||
const ownerSubmitLoading = ref(false);
|
||
const ownerTargetNode = ref(null);
|
||
const selectedOwnerUserId = ref(null);
|
||
|
||
const defaultProps = {
|
||
children: 'children',
|
||
label: 'name',
|
||
};
|
||
|
||
const roleMap = {
|
||
SYSTEM_ADMIN: '系统管理员',
|
||
HOSPITAL_ADMIN: '医院管理员',
|
||
DIRECTOR: '科室主任',
|
||
LEADER: '小组组长',
|
||
DOCTOR: '医生',
|
||
ENGINEER: '工程师',
|
||
};
|
||
|
||
const getRoleName = (role) => roleMap[role] || role;
|
||
|
||
const getRoleTagType = (role) => {
|
||
if (role === 'HOSPITAL_ADMIN') return 'danger';
|
||
if (role === 'DIRECTOR') return 'warning';
|
||
if (role === 'LEADER') return 'success';
|
||
if (role === 'DOCTOR') return 'primary';
|
||
return 'info';
|
||
};
|
||
|
||
const getTypeName = (type) => {
|
||
const map = {
|
||
hospital: '医院',
|
||
department: '科室',
|
||
group: '小组',
|
||
user: '人员',
|
||
};
|
||
return map[type] || type;
|
||
};
|
||
|
||
const getNodeTypeTag = (type) => {
|
||
const map = {
|
||
hospital: 'primary',
|
||
department: 'success',
|
||
group: 'warning',
|
||
user: 'info',
|
||
};
|
||
return map[type] || 'info';
|
||
};
|
||
|
||
const canAssignOwner = computed(() =>
|
||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
||
);
|
||
const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
|
||
const isOrgAdmin = computed(() =>
|
||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
||
);
|
||
const isDirector = computed(() => userStore.role === 'DIRECTOR');
|
||
const isLeader = computed(() => userStore.role === 'LEADER');
|
||
|
||
const canCreateDepartment = (node) =>
|
||
Boolean(node && node.type === 'hospital' && isOrgAdmin.value);
|
||
|
||
const canCreateGroup = (node) =>
|
||
Boolean(
|
||
node &&
|
||
node.type === 'department' &&
|
||
(isOrgAdmin.value || isDirector.value),
|
||
);
|
||
|
||
const canAddUser = (node) =>
|
||
Boolean(
|
||
node &&
|
||
(node.type === 'department' || node.type === 'group') &&
|
||
isOrgAdmin.value,
|
||
);
|
||
|
||
const canEditNode = (node) => {
|
||
if (!node || node.type === 'user') {
|
||
return false;
|
||
}
|
||
if (node.type === 'hospital') {
|
||
return isOrgAdmin.value;
|
||
}
|
||
if (node.type === 'department') {
|
||
return isOrgAdmin.value || isDirector.value || isLeader.value;
|
||
}
|
||
if (node.type === 'group') {
|
||
return isOrgAdmin.value || isDirector.value || isLeader.value;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const canDeleteNode = (node) => {
|
||
if (!node || node.type === 'user') {
|
||
return false;
|
||
}
|
||
if (node.type === 'hospital') {
|
||
return isSystemAdmin.value;
|
||
}
|
||
if (node.type === 'department') {
|
||
return isOrgAdmin.value;
|
||
}
|
||
if (node.type === 'group') {
|
||
return isOrgAdmin.value || isDirector.value;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const activePanelTitle = computed(() => {
|
||
if (!activeNode.value) {
|
||
return '请在左侧选择节点';
|
||
}
|
||
if (activeNode.value.type === 'user') {
|
||
return `人员详情 (${activeNode.value.name})`;
|
||
}
|
||
return `下级列表 (${activeNode.value.name})`;
|
||
});
|
||
|
||
const activeNodeChildren = computed(() => {
|
||
if (
|
||
!activeNode.value ||
|
||
activeNode.value.type === 'user' ||
|
||
!Array.isArray(activeNode.value.children)
|
||
) {
|
||
return [];
|
||
}
|
||
|
||
const list = [...activeNode.value.children];
|
||
if (userStore.role !== 'HOSPITAL_ADMIN') {
|
||
return list;
|
||
}
|
||
|
||
// 医院管理员视角下,右侧列表优先显示人员,再显示组织节点。
|
||
return list.sort((a, b) => {
|
||
const aIsUser = a.type === 'user';
|
||
const bIsUser = b.type === 'user';
|
||
if (aIsUser !== bIsUser) {
|
||
return aIsUser ? -1 : 1;
|
||
}
|
||
return `${a.name || ''}`.localeCompare(`${b.name || ''}`, 'zh-Hans-CN');
|
||
});
|
||
});
|
||
|
||
const selectedUserDetail = computed(() => {
|
||
if (!activeNode.value || activeNode.value.type !== 'user') {
|
||
return null;
|
||
}
|
||
|
||
const current = allUsers.value.find(
|
||
(user) => user.id === activeNode.value.id,
|
||
);
|
||
const userData = current || activeNode.value;
|
||
const hospitalId = userData.hospitalId || null;
|
||
const departmentId = userData.departmentId || null;
|
||
const groupId = userData.groupId || null;
|
||
|
||
return {
|
||
...userData,
|
||
hospitalName: hospitalId ? hospitalNameMap.value[hospitalId] : '',
|
||
departmentName: departmentId ? departmentNameMap.value[departmentId] : '',
|
||
groupName: groupId ? groupNameMap.value[groupId] : '',
|
||
};
|
||
});
|
||
|
||
const activeNodeMeta = computed(() => {
|
||
if (!activeNode.value) {
|
||
return '';
|
||
}
|
||
if (activeNode.value.type === 'hospital') {
|
||
return `当前医院管理员:${activeNode.value.adminDisplay || '未设置'}`;
|
||
}
|
||
if (activeNode.value.type === 'department') {
|
||
return `当前科室主任:${activeNode.value.directorDisplay || '未设置'}`;
|
||
}
|
||
if (activeNode.value.type === 'group') {
|
||
return `当前小组组长:${activeNode.value.leaderDisplay || '未设置'}`;
|
||
}
|
||
return '';
|
||
});
|
||
|
||
const ownerDialogTitle = computed(() => {
|
||
if (!ownerTargetNode.value) {
|
||
return '设置负责人';
|
||
}
|
||
return ownerTargetNode.value.type === 'department'
|
||
? `设置科室主任(${ownerTargetNode.value.name})`
|
||
: `设置小组组长(${ownerTargetNode.value.name})`;
|
||
});
|
||
|
||
const ownerDialogLabel = computed(() => {
|
||
if (!ownerTargetNode.value) {
|
||
return '负责人';
|
||
}
|
||
return ownerTargetNode.value.type === 'department' ? '科室主任' : '小组组长';
|
||
});
|
||
|
||
const fetchTreeData = async () => {
|
||
loading.value = true;
|
||
activeNode.value = null; // Reset active node on refresh
|
||
try {
|
||
const hospRes = await getHospitals({ pageSize: 100 });
|
||
const hospitals = hospRes.list || [];
|
||
|
||
const [deptRes, groupRes, userRes] = await Promise.all([
|
||
getDepartments({ pageSize: 100 }),
|
||
getGroups({ pageSize: 100 }),
|
||
getUsers(),
|
||
]);
|
||
|
||
const departments = deptRes.list || [];
|
||
const groups = groupRes.list || [];
|
||
const users = userRes.list || [];
|
||
allUsers.value = users;
|
||
hospitalNameMap.value = Object.fromEntries(
|
||
hospitals.map((item) => [item.id, item.name]),
|
||
);
|
||
departmentNameMap.value = Object.fromEntries(
|
||
departments.map((item) => [item.id, item.name]),
|
||
);
|
||
groupNameMap.value = Object.fromEntries(
|
||
groups.map((item) => [item.id, item.name]),
|
||
);
|
||
|
||
const directorNameMap = {};
|
||
const leaderNameMap = {};
|
||
const hospitalAdminNameMap = {};
|
||
users.forEach((user) => {
|
||
if (user.role === 'HOSPITAL_ADMIN' && user.hospitalId) {
|
||
if (!hospitalAdminNameMap[user.hospitalId]) {
|
||
hospitalAdminNameMap[user.hospitalId] = [];
|
||
}
|
||
hospitalAdminNameMap[user.hospitalId].push(user.name);
|
||
}
|
||
if (user.role === 'DIRECTOR' && user.departmentId) {
|
||
if (!directorNameMap[user.departmentId]) {
|
||
directorNameMap[user.departmentId] = [];
|
||
}
|
||
directorNameMap[user.departmentId].push(user.name);
|
||
}
|
||
if (user.role === 'LEADER' && user.groupId) {
|
||
if (!leaderNameMap[user.groupId]) {
|
||
leaderNameMap[user.groupId] = [];
|
||
}
|
||
leaderNameMap[user.groupId].push(user.name);
|
||
}
|
||
});
|
||
|
||
const tree = hospitals.map((h) => {
|
||
const hDepts = departments.filter((d) => d.hospitalId === h.id);
|
||
const adminDisplay =
|
||
(hospitalAdminNameMap[h.id] || []).join('、') || '未设置';
|
||
|
||
const deptNodes = hDepts.map((d) => {
|
||
const dGroups = groups.filter((g) => g.departmentId === d.id);
|
||
const directorDisplay =
|
||
(directorNameMap[d.id] || []).join('、') || '未设置';
|
||
|
||
const groupNodes = dGroups.map((g) => {
|
||
const gUsers = users.filter((u) => u.groupId === g.id);
|
||
const leaderDisplay =
|
||
(leaderNameMap[g.id] || []).join('、') || '未设置';
|
||
const userNodes = gUsers.map((u) => ({
|
||
key: `u_${u.id}`,
|
||
id: u.id,
|
||
name: u.name,
|
||
type: 'user',
|
||
role: u.role,
|
||
hospitalId: h.id,
|
||
departmentId: d.id,
|
||
groupId: g.id,
|
||
}));
|
||
|
||
return {
|
||
key: `g_${g.id}`,
|
||
id: g.id,
|
||
name: g.name,
|
||
type: 'group',
|
||
departmentId: d.id,
|
||
hospitalId: h.id,
|
||
leaderDisplay,
|
||
children: userNodes,
|
||
};
|
||
});
|
||
|
||
const dUsers = users.filter(
|
||
(u) => u.departmentId === d.id && !u.groupId,
|
||
);
|
||
const dUserNodes = dUsers.map((u) => ({
|
||
key: `u_${u.id}`,
|
||
id: u.id,
|
||
name: u.name,
|
||
type: 'user',
|
||
role: u.role,
|
||
hospitalId: h.id,
|
||
departmentId: d.id,
|
||
}));
|
||
|
||
return {
|
||
key: `d_${d.id}`,
|
||
id: d.id,
|
||
name: d.name,
|
||
type: 'department',
|
||
hospitalId: h.id,
|
||
directorDisplay,
|
||
children: [...groupNodes, ...dUserNodes],
|
||
};
|
||
});
|
||
|
||
const hUsers = users.filter(
|
||
(u) => u.hospitalId === h.id && !u.departmentId,
|
||
);
|
||
const hUserNodes = hUsers.map((u) => ({
|
||
key: `u_${u.id}`,
|
||
id: u.id,
|
||
name: u.name,
|
||
type: 'user',
|
||
role: u.role,
|
||
hospitalId: h.id,
|
||
}));
|
||
|
||
return {
|
||
key: `h_${h.id}`,
|
||
id: h.id,
|
||
name: h.name,
|
||
type: 'hospital',
|
||
adminDisplay,
|
||
children: [...deptNodes, ...hUserNodes],
|
||
};
|
||
});
|
||
|
||
treeData.value = tree;
|
||
} catch (error) {
|
||
console.error('Failed to fetch tree data', error);
|
||
ElMessage.error('获取组织架构树失败');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const handleNodeClick = (data) => {
|
||
activeNode.value = data;
|
||
};
|
||
|
||
const openSetOwnerDialog = (node) => {
|
||
if (!canAssignOwner.value) {
|
||
return;
|
||
}
|
||
if (node.type !== 'department' && node.type !== 'group') {
|
||
return;
|
||
}
|
||
|
||
ownerTargetNode.value = node;
|
||
selectedOwnerUserId.value = null;
|
||
|
||
if (node.type === 'department') {
|
||
ownerCandidates.value = allUsers.value.filter(
|
||
(user) =>
|
||
user.hospitalId === node.hospitalId &&
|
||
user.departmentId === node.id &&
|
||
user.role === 'DIRECTOR',
|
||
);
|
||
} else {
|
||
ownerCandidates.value = allUsers.value.filter(
|
||
(user) =>
|
||
user.hospitalId === node.hospitalId &&
|
||
user.departmentId === node.departmentId &&
|
||
user.groupId === node.id &&
|
||
user.role === 'LEADER',
|
||
);
|
||
}
|
||
|
||
if (ownerCandidates.value.length === 0) {
|
||
ElMessage.warning('当前节点下没有可设置候选(仅支持主任/组长角色)');
|
||
return;
|
||
}
|
||
|
||
const currentOwner = ownerCandidates.value.find((user) =>
|
||
node.type === 'department'
|
||
? user.role === 'DIRECTOR'
|
||
: user.role === 'LEADER',
|
||
);
|
||
selectedOwnerUserId.value = currentOwner?.id ?? ownerCandidates.value[0].id;
|
||
ownerDialogVisible.value = true;
|
||
};
|
||
|
||
const handleSetOwner = async () => {
|
||
if (!ownerTargetNode.value || !selectedOwnerUserId.value) {
|
||
ElMessage.warning('请选择人员');
|
||
return;
|
||
}
|
||
|
||
const targetUser = ownerCandidates.value.find(
|
||
(user) => user.id === selectedOwnerUserId.value,
|
||
);
|
||
if (!targetUser) {
|
||
ElMessage.warning('候选人员不存在');
|
||
return;
|
||
}
|
||
|
||
const isDepartment = ownerTargetNode.value.type === 'department';
|
||
const payload = isDepartment
|
||
? {
|
||
role: 'DIRECTOR',
|
||
// 后端约束:非 DOCTOR 不允许“调整”科室/小组归属。
|
||
// 这里仅做角色变更,并清空小组归属,避免触发该约束。
|
||
groupId: null,
|
||
}
|
||
: {
|
||
role: 'LEADER',
|
||
};
|
||
|
||
ownerSubmitLoading.value = true;
|
||
try {
|
||
await updateUser(targetUser.id, payload);
|
||
ElMessage.success(isDepartment ? '设置主任成功' : '设置组长成功');
|
||
ownerDialogVisible.value = false;
|
||
await fetchTreeData();
|
||
} finally {
|
||
ownerSubmitLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const goToAddUser = (nodeData) => {
|
||
// We navigate to the Users list page. In a full implementation, you could
|
||
// pass query params to pre-fill a creation form or open a dialog directly.
|
||
router.push({
|
||
path: '/users',
|
||
query: {
|
||
action: 'create',
|
||
hospitalId: nodeData.hospitalId,
|
||
departmentId:
|
||
nodeData.type === 'department' ? nodeData.id : nodeData.departmentId,
|
||
},
|
||
});
|
||
};
|
||
|
||
// --- Dialog Logic ---
|
||
const dialogVisible = ref(false);
|
||
const submitLoading = ref(false);
|
||
const formRef = ref(null);
|
||
const dialogType = ref('');
|
||
const dialogMode = ref('');
|
||
const parentId = ref(null);
|
||
const currentId = ref(null);
|
||
|
||
const form = reactive({ name: '' });
|
||
const rules = {
|
||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||
};
|
||
|
||
const dialogTitle = computed(() => {
|
||
const typeName =
|
||
dialogType.value === 'hospital'
|
||
? '医院'
|
||
: dialogType.value === 'department'
|
||
? '科室'
|
||
: '小组';
|
||
return dialogMode.value === 'create' ? `新增${typeName}` : `编辑${typeName}`;
|
||
});
|
||
const formLabel = computed(() =>
|
||
dialogType.value === 'hospital'
|
||
? '医院名称'
|
||
: dialogType.value === 'department'
|
||
? '科室名称'
|
||
: '小组名称',
|
||
);
|
||
|
||
const openCreateDialog = (type, pId) => {
|
||
dialogType.value = type;
|
||
dialogMode.value = 'create';
|
||
parentId.value = pId;
|
||
currentId.value = null;
|
||
dialogVisible.value = true;
|
||
};
|
||
const openEditDialog = (data) => {
|
||
dialogType.value = data.type;
|
||
dialogMode.value = 'edit';
|
||
currentId.value = data.id;
|
||
form.name = data.name;
|
||
dialogVisible.value = true;
|
||
};
|
||
const resetForm = () => {
|
||
if (formRef.value) formRef.value.resetFields();
|
||
form.name = '';
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!formRef.value) return;
|
||
await formRef.value.validate(async (valid) => {
|
||
if (valid) {
|
||
submitLoading.value = true;
|
||
try {
|
||
if (dialogMode.value === 'create') {
|
||
if (dialogType.value === 'department')
|
||
await createDepartment({
|
||
name: form.name,
|
||
hospitalId: parentId.value,
|
||
});
|
||
else if (dialogType.value === 'group')
|
||
await createGroup({
|
||
name: form.name,
|
||
departmentId: parentId.value,
|
||
});
|
||
ElMessage.success('创建成功');
|
||
} else {
|
||
if (dialogType.value === 'hospital')
|
||
await updateHospital(currentId.value, { name: form.name });
|
||
else if (dialogType.value === 'department')
|
||
await updateDepartment(currentId.value, { name: form.name });
|
||
else if (dialogType.value === 'group')
|
||
await updateGroup(currentId.value, { name: form.name });
|
||
ElMessage.success('更新成功');
|
||
|
||
// Update activeNode locally if it's the one edited
|
||
if (
|
||
activeNode.value &&
|
||
activeNode.value.id === currentId.value &&
|
||
activeNode.value.type === dialogType.value
|
||
) {
|
||
activeNode.value.name = form.name;
|
||
}
|
||
}
|
||
dialogVisible.value = false;
|
||
fetchTreeData();
|
||
} catch (error) {
|
||
console.error(error);
|
||
} finally {
|
||
submitLoading.value = false;
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleDelete = (data) => {
|
||
const typeName =
|
||
data.type === 'hospital'
|
||
? '医院'
|
||
: data.type === 'department'
|
||
? '科室'
|
||
: '小组';
|
||
ElMessageBox.confirm(`确定要删除${typeName} "${data.name}" 吗?`, '警告', {
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
})
|
||
.then(async () => {
|
||
try {
|
||
if (data.type === 'hospital') await deleteHospital(data.id);
|
||
else if (data.type === 'department') await deleteDepartment(data.id);
|
||
else if (data.type === 'group') await deleteGroup(data.id);
|
||
ElMessage.success('删除成功');
|
||
if (activeNode.value && activeNode.value.key === data.key) {
|
||
activeNode.value = null; // Clear active node if deleted
|
||
}
|
||
fetchTreeData();
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
onMounted(() => {
|
||
fetchTreeData();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.org-tree-container {
|
||
padding: 20px;
|
||
}
|
||
|
||
.tree-card,
|
||
.detail-card {
|
||
border-radius: 8px;
|
||
height: calc(100vh - 120px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
:deep(.el-card__body) {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.header-title {
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: #303133;
|
||
}
|
||
|
||
.tree-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.node-detail-panel {
|
||
padding: 10px;
|
||
}
|
||
|
||
.user-detail-panel {
|
||
padding: 10px;
|
||
}
|
||
|
||
.action-title {
|
||
margin-top: 20px;
|
||
margin-bottom: 15px;
|
||
color: #606266;
|
||
border-bottom: 1px solid #ebeef5;
|
||
padding-bottom: 10px;
|
||
}
|
||
|
||
.quick-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.mb-4 {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
/* Beautiful Tree Styling */
|
||
.beautiful-tree {
|
||
background: transparent;
|
||
}
|
||
|
||
:deep(.el-tree-node__content) {
|
||
height: auto;
|
||
padding: 8px 0;
|
||
margin-bottom: 4px;
|
||
border-radius: 6px;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
:deep(.el-tree-node__content:hover) {
|
||
background-color: #f5f7fa;
|
||
}
|
||
|
||
:deep(.el-tree-node.is-current > .el-tree-node__content) {
|
||
background-color: #e6f1fc;
|
||
}
|
||
|
||
/* Node specific styles */
|
||
.custom-tree-node {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
border: 1px solid transparent;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.node-main {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.node-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.node-icon-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px;
|
||
height: 24px;
|
||
margin-right: 8px;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.node-hospital .node-icon-wrapper {
|
||
color: #409eff;
|
||
}
|
||
.node-department .node-icon-wrapper {
|
||
color: #67c23a;
|
||
}
|
||
.node-group .node-icon-wrapper {
|
||
color: #e6a23c;
|
||
}
|
||
.node-user .node-icon-wrapper {
|
||
color: #909399;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.node-label {
|
||
font-size: 14px;
|
||
color: #303133;
|
||
}
|
||
.node-user .node-label {
|
||
color: #606266;
|
||
}
|
||
|
||
.node-sub-label {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.role-tag {
|
||
margin-left: 10px;
|
||
}
|
||
|
||
.mb-12 {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.mt-12 {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
/* Hover Actions */
|
||
.node-actions {
|
||
opacity: 0;
|
||
transform: translateX(10px);
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
:deep(.el-tree-node__content:hover) .node-actions {
|
||
opacity: 1;
|
||
transform: translateX(0);
|
||
}
|
||
</style>
|