|
|
@@ -2,54 +2,39 @@
|
|
|
import { defaultProps, handleTree } from '@/utils/tree'
|
|
|
import { ElTree } from 'element-plus'
|
|
|
import * as DeptApi from '@/api/system/dept'
|
|
|
-import { Search } from '@element-plus/icons-vue'
|
|
|
+import { Search, CaretLeft, CaretRight } from '@element-plus/icons-vue'
|
|
|
+
|
|
|
+interface Tree {
|
|
|
+ id: number
|
|
|
+ name: string
|
|
|
+ children?: Tree[]
|
|
|
+ sort?: number
|
|
|
+}
|
|
|
|
|
|
const props = defineProps({
|
|
|
- deptId: {
|
|
|
- type: Number,
|
|
|
- required: true
|
|
|
- },
|
|
|
- modelValue: {
|
|
|
- type: Number,
|
|
|
- default: undefined
|
|
|
- },
|
|
|
- topId: {
|
|
|
- type: Number,
|
|
|
- required: true
|
|
|
- },
|
|
|
- title: {
|
|
|
- type: String,
|
|
|
- default: '部门'
|
|
|
- },
|
|
|
- initSelect: {
|
|
|
- type: Boolean,
|
|
|
- default: true
|
|
|
- },
|
|
|
- showTitle: {
|
|
|
- type: Boolean,
|
|
|
- default: true
|
|
|
- }
|
|
|
+ deptId: { type: Number, required: true },
|
|
|
+ modelValue: { type: Number, default: undefined },
|
|
|
+ topId: { type: Number, required: true },
|
|
|
+ title: { type: String, default: '部门' },
|
|
|
+ initSelect: { type: Boolean, default: true },
|
|
|
+ showTitle: { type: Boolean, default: true }
|
|
|
})
|
|
|
|
|
|
const emits = defineEmits(['update:modelValue', 'node-click'])
|
|
|
|
|
|
+// --- 状态控制 ---
|
|
|
+const isCollapsed = ref(false)
|
|
|
const deptName = ref('')
|
|
|
const deptList = ref<Tree[]>([])
|
|
|
const treeRef = ref<InstanceType<typeof ElTree>>()
|
|
|
const expandedKeys = ref<number[]>([])
|
|
|
|
|
|
+// --- 逻辑处理 (保持不变) ---
|
|
|
const sortTreeBySort = (treeNodes: Tree[]) => {
|
|
|
if (!treeNodes || !Array.isArray(treeNodes)) return treeNodes
|
|
|
- const sortedNodes = [...treeNodes].sort((a, b) => {
|
|
|
- const sortA = a.sort != null ? a.sort : 999999
|
|
|
- const sortB = b.sort != null ? b.sort : 999999
|
|
|
- return sortA - sortB
|
|
|
- })
|
|
|
-
|
|
|
+ const sortedNodes = [...treeNodes].sort((a, b) => (a.sort ?? 999999) - (b.sort ?? 999999))
|
|
|
sortedNodes.forEach((node) => {
|
|
|
- if (node.children && Array.isArray(node.children)) {
|
|
|
- node.children = sortTreeBySort(node.children)
|
|
|
- }
|
|
|
+ if (node.children) node.children = sortTreeBySort(node.children)
|
|
|
})
|
|
|
return sortedNodes
|
|
|
}
|
|
|
@@ -57,51 +42,26 @@ const sortTreeBySort = (treeNodes: Tree[]) => {
|
|
|
const loadTree = async () => {
|
|
|
try {
|
|
|
let id = props.deptId
|
|
|
-
|
|
|
- // 1. 校验 ID 范围逻辑 (保持原有逻辑:确保 deptId 在 topId 范围内)
|
|
|
if (id !== props.topId) {
|
|
|
const depts = await DeptApi.specifiedSimpleDepts(props.topId)
|
|
|
- const self = depts.find((item) => item.id === props.deptId)
|
|
|
- if (depts.length && !self) {
|
|
|
- id = props.topId
|
|
|
- }
|
|
|
+ if (depts.length && !depts.find((item) => item.id === props.deptId)) id = props.topId
|
|
|
}
|
|
|
-
|
|
|
- // 2. 获取最终 ID 对应的部门列表
|
|
|
const res = await DeptApi.specifiedSimpleDepts(id)
|
|
|
-
|
|
|
- // 3. 处理 modelValue 的赋值逻辑 (关键修改点)
|
|
|
- if (props.initSelect) {
|
|
|
- // 检查传入的 modelValue 是否存在于当前加载的树数据中
|
|
|
- const isModelValueValid = props.modelValue && res.some((item) => item.id === props.modelValue)
|
|
|
-
|
|
|
- if (!isModelValueValid) {
|
|
|
- emits('update:modelValue', id)
|
|
|
- }
|
|
|
+ if (props.initSelect && props.modelValue && !res.some((item) => item.id === props.modelValue)) {
|
|
|
+ emits('update:modelValue', id)
|
|
|
}
|
|
|
-
|
|
|
- // 4. 生成树结构
|
|
|
deptList.value = sortTreeBySort(handleTree(res))
|
|
|
-
|
|
|
- // 5. 界面交互:高亮并展开
|
|
|
nextTick(() => {
|
|
|
- // 优先使用 props.modelValue (如果刚才触发了 update,父组件可能还没传回来,所以这里取 props.modelValue 或者 id)
|
|
|
- // 但为了稳妥,我们再次检查逻辑
|
|
|
- const targetKey = props.modelValue ? props.modelValue : props.initSelect ? id : null
|
|
|
-
|
|
|
+ const targetKey = props.modelValue ?? (props.initSelect ? id : null)
|
|
|
if (targetKey && treeRef.value) {
|
|
|
treeRef.value.setCurrentKey(targetKey)
|
|
|
- // 确保该节点被展开
|
|
|
- if (!expandedKeys.value.includes(targetKey)) {
|
|
|
- expandedKeys.value.push(targetKey)
|
|
|
- }
|
|
|
- } else if (deptList.value.length > 0) {
|
|
|
- // 如果没有选中项,默认展开第一级
|
|
|
- expandedKeys.value = deptList.value.map((item) => item.id)
|
|
|
+ if (!expandedKeys.value.includes(targetKey)) expandedKeys.value.push(targetKey)
|
|
|
+ else if (deptList.value.length > 0)
|
|
|
+ expandedKeys.value = deptList.value.map((item) => item.id)
|
|
|
}
|
|
|
})
|
|
|
- } catch (error) {
|
|
|
- console.error('加载部门树失败:', error)
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -110,70 +70,128 @@ const handleNodeClick = (data: Tree) => {
|
|
|
emits('node-click', data)
|
|
|
}
|
|
|
|
|
|
-const filterNode = (value: string, data: Tree) => {
|
|
|
- if (!value) return true
|
|
|
- return data.name.includes(value)
|
|
|
-}
|
|
|
-
|
|
|
-watch(deptName, (val) => {
|
|
|
- treeRef.value?.filter(val)
|
|
|
-})
|
|
|
-
|
|
|
-watch(
|
|
|
- () => props.deptId,
|
|
|
- (newVal, oldVal) => {
|
|
|
- if (newVal !== oldVal) {
|
|
|
- loadTree()
|
|
|
- }
|
|
|
- }
|
|
|
-)
|
|
|
+const filterNode = (val: string, data: Tree) => !val || data.name.includes(val)
|
|
|
|
|
|
+watch(deptName, (val) => treeRef.value?.filter(val))
|
|
|
+watch(() => props.deptId, loadTree)
|
|
|
watch(
|
|
|
() => props.modelValue,
|
|
|
- (newVal) => {
|
|
|
- if (newVal && treeRef.value) {
|
|
|
- treeRef.value.setCurrentKey(newVal)
|
|
|
- if (!expandedKeys.value.includes(newVal)) {
|
|
|
- expandedKeys.value.push(newVal)
|
|
|
- }
|
|
|
+ (val) => {
|
|
|
+ if (val && treeRef.value) treeRef.value.setCurrentKey(val)
|
|
|
+
|
|
|
+ if (val && !expandedKeys.value.includes(val)) {
|
|
|
+ expandedKeys.value.push(val)
|
|
|
}
|
|
|
},
|
|
|
{ immediate: true }
|
|
|
)
|
|
|
|
|
|
-onMounted(() => {
|
|
|
- loadTree()
|
|
|
-})
|
|
|
+onMounted(loadTree)
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
- <div class="gap-4 flex flex-col h-full">
|
|
|
- <h1 v-if="showTitle" class="text-lg font-medium">{{ props.title }}</h1>
|
|
|
- <el-input
|
|
|
- v-model="deptName"
|
|
|
- size="default"
|
|
|
- placeholder="请输入部门名称"
|
|
|
- clearable
|
|
|
- :prefix-icon="Search"
|
|
|
- />
|
|
|
- <div class="flex-1 relative">
|
|
|
- <el-auto-resizer class="absolute">
|
|
|
- <template #default="{ height }">
|
|
|
- <el-scrollbar :style="{ height: `${height}px` }">
|
|
|
- <el-tree
|
|
|
- ref="treeRef"
|
|
|
- :data="deptList"
|
|
|
- :props="defaultProps"
|
|
|
- :expand-on-click-node="false"
|
|
|
- :filter-node-method="filterNode"
|
|
|
- node-key="id"
|
|
|
- highlight-current
|
|
|
- :default-expanded-keys="expandedKeys"
|
|
|
- @node-click="handleNodeClick"
|
|
|
- />
|
|
|
- </el-scrollbar>
|
|
|
- </template>
|
|
|
- </el-auto-resizer>
|
|
|
+ <div
|
|
|
+ class="dept-aside-container relative bg-white dark:bg-[#1d1e1f] shadow rounded-lg row-span-4 transition-all duration-300 ease-in-out overflow-visible"
|
|
|
+ :class="[isCollapsed ? 'is-collapsed' : 'p-4']"
|
|
|
+ >
|
|
|
+ <div v-show="!isCollapsed" class="h-full flex flex-col gap-4 overflow-hidden w-full">
|
|
|
+ <h1 v-if="showTitle" class="text-lg font-medium truncate shrink-0">{{ props.title }}</h1>
|
|
|
+
|
|
|
+ <div class="shrink-0">
|
|
|
+ <el-input v-model="deptName" placeholder="请输入部门名称" clearable :prefix-icon="Search" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flex-1 relative overflow-hidden">
|
|
|
+ <el-auto-resizer class="absolute">
|
|
|
+ <template #default="{ height }">
|
|
|
+ <el-scrollbar :style="{ height: `${height}px` }">
|
|
|
+ <el-tree
|
|
|
+ ref="treeRef"
|
|
|
+ :data="deptList"
|
|
|
+ :props="defaultProps"
|
|
|
+ :expand-on-click-node="false"
|
|
|
+ :filter-node-method="filterNode"
|
|
|
+ node-key="id"
|
|
|
+ highlight-current
|
|
|
+ :default-expanded-keys="expandedKeys"
|
|
|
+ @node-click="handleNodeClick"
|
|
|
+ />
|
|
|
+ </el-scrollbar>
|
|
|
+ </template>
|
|
|
+ </el-auto-resizer>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="collapse-handle" @click="isCollapsed = !isCollapsed">
|
|
|
+ <el-icon size="12">
|
|
|
+ <CaretLeft v-if="!isCollapsed" />
|
|
|
+ <CaretRight v-else />
|
|
|
+ </el-icon>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.dept-aside-container {
|
|
|
+ /* 关键点 1:初始宽度设为 15% */
|
|
|
+ width: 14vw; /* 或者使用百分比,但在 Grid 容器内使用 vw 更稳定 */
|
|
|
+ height: calc(
|
|
|
+ 100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
|
|
|
+ );
|
|
|
+ min-width: 200px; /* 防止在极小屏幕下 15% 太窄看不清 */
|
|
|
+ box-sizing: border-box;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 关键点 2:折叠状态 */
|
|
|
+.dept-aside-container.is-collapsed {
|
|
|
+ width: 0 !important;
|
|
|
+ min-width: 0 !important;
|
|
|
+ padding: 0 !important;
|
|
|
+ margin-right: -16px; /* 抵消父级 grid 的 gap-x-4,让右侧内容贴合 */
|
|
|
+ overflow: visible !important;
|
|
|
+ pointer-events: none; /* 折叠后不响应鼠标事件,除了 handle */
|
|
|
+
|
|
|
+ /* opacity: 0; */
|
|
|
+ box-shadow: none;
|
|
|
+}
|
|
|
+
|
|
|
+/* 即使父级折叠,handle 也要可见并可点击 */
|
|
|
+.collapse-handle {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ right: -14px;
|
|
|
+ z-index: 200;
|
|
|
+ display: flex;
|
|
|
+ width: 14px;
|
|
|
+ height: 60px;
|
|
|
+ color: var(--el-text-color-secondary);
|
|
|
+ pointer-events: auto;
|
|
|
+ cursor: pointer;
|
|
|
+ background-color: var(--el-bg-color);
|
|
|
+ border: 1px solid var(--el-border-color-light);
|
|
|
+ border-left: none;
|
|
|
+ border-radius: 0 12px 12px 0;
|
|
|
+ transform: translateY(-50%);
|
|
|
+ box-shadow: 2px 0 6px rgb(0 0 0 / 5%);
|
|
|
+ transition: right 0.3s;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.is-collapsed .collapse-handle {
|
|
|
+ right: -8px; /* 在边缘露出一半 */
|
|
|
+ border-left: 1px solid var(--el-border-color-light);
|
|
|
+}
|
|
|
+
|
|
|
+.collapse-handle:hover {
|
|
|
+ color: var(--el-color-primary);
|
|
|
+ background-color: var(--el-fill-color-light);
|
|
|
+}
|
|
|
+
|
|
|
+.truncate {
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+</style>
|