PmsTree.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. <script lang="ts" setup>
  2. import { ElTree } from 'element-plus'
  3. import { CaretLeft, CaretRight, Search } from '@element-plus/icons-vue'
  4. import type { IotTreeVO } from '@/api/system/tree'
  5. import { IotTreeApi } from '@/api/system/tree'
  6. // import { DataLibraryApi } from '@/api/pms/qhse/index'
  7. import { defaultProps, handleTree } from '@/utils/tree'
  8. import { CommonStatusEnum } from '@/utils/constants'
  9. defineOptions({ name: 'IotTree' })
  10. const dialogVisible = ref(false) // 弹窗的是否展示
  11. const dialogTitle = ref('') // 弹窗的标题
  12. const formRef = ref() // 搜索的表单
  13. const message = useMessage()
  14. const nodeInfo = ref({})
  15. const menuVisible = ref(false)
  16. const menuX = ref(0)
  17. const menuY = ref(0)
  18. const contextMenuRef = ref(null) // 弹窗DOM引用
  19. const { t } = useI18n()
  20. let selectedNode = null
  21. const parentId = ref()
  22. const formType = ref<'create' | 'update'>('create')
  23. const formData = ref({
  24. id: undefined,
  25. title: '',
  26. parentId: undefined,
  27. name: undefined,
  28. sort: undefined,
  29. leaderUserId: undefined,
  30. phone: undefined,
  31. email: undefined,
  32. status: CommonStatusEnum.ENABLE
  33. })
  34. const formRules = ref({
  35. name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
  36. sort: [{ required: true, message: '请输入排序', trigger: 'blur' }]
  37. })
  38. const handleRightClick = (event, data, node) => {
  39. nodeInfo.value = { ...data }
  40. event.preventDefault()
  41. const nodeContent = (event.target as HTMLElement)?.closest(
  42. '.el-tree-node__content'
  43. ) as HTMLElement | null
  44. if (nodeContent) {
  45. const rect = nodeContent.getBoundingClientRect()
  46. menuX.value = rect.right + 8
  47. menuY.value = rect.top
  48. } else {
  49. menuX.value = event.clientX
  50. menuY.value = event.clientY
  51. }
  52. selectedNode = data
  53. parentId.value = data.id
  54. menuVisible.value = true
  55. }
  56. const resetForm = () => {
  57. formData.value = {
  58. id: undefined,
  59. title: '',
  60. parentId: undefined,
  61. name: undefined,
  62. sort: undefined,
  63. leaderUserId: undefined,
  64. phone: undefined,
  65. email: undefined,
  66. status: CommonStatusEnum.ENABLE
  67. }
  68. formRef.value?.resetFields()
  69. }
  70. const handleMenuClick = async (action) => {
  71. switch (action) {
  72. case 'add':
  73. dialogVisible.value = true
  74. dialogTitle.value = '新增目录'
  75. formType.value = 'create'
  76. resetForm()
  77. break
  78. case 'edit':
  79. resetForm()
  80. dialogVisible.value = true
  81. dialogTitle.value = '编辑目录'
  82. formType.value = 'update'
  83. formData.value = { ...nodeInfo.value }
  84. break
  85. case 'delete':
  86. // 删除的二次确认
  87. await message.delConfirm()
  88. // 假设存在删除接口
  89. await IotTreeApi.deleteIotTree(nodeInfo.value.id)
  90. message.success(t('common.delSuccess'))
  91. // 刷新列表
  92. await getTreeInfo()
  93. break
  94. }
  95. menuVisible.value = false
  96. // nextTick(() => {
  97. // treeRef.value?.setCurrentKey(targetId)
  98. // treeRef.value?.getNode(targetId)?.$el?.scrollIntoView({
  99. // behavior: 'smooth',
  100. // block: 'nearest'
  101. // })
  102. // })
  103. }
  104. const submitForm = async () => {
  105. // 表单验证逻辑
  106. try {
  107. const targetParentId = formType.value === 'create' ? parentId.value : formData.value.parentId
  108. formData.value.type = 'file'
  109. if (formType.value === 'create') {
  110. formData.value.parentId = parentId.value
  111. await IotTreeApi.createIotTree(formData.value)
  112. } else {
  113. await IotTreeApi.updateIotTree(formData.value)
  114. }
  115. message.success(t(formType.value === 'create' ? '添加成功' : '修改成功'))
  116. dialogVisible.value = false
  117. menuVisible.value = false
  118. await getTreeInfo()
  119. if (targetParentId) {
  120. expandedKeys.value = [...new Set([...expandedKeys.value, targetParentId])]
  121. locateNode(targetParentId)
  122. }
  123. } catch (error) {
  124. console.error(error)
  125. } finally {
  126. }
  127. }
  128. interface TreeNode extends IotTreeVO {
  129. children?: TreeNode[]
  130. }
  131. type TreeKey = number | string
  132. const props = withDefaults(
  133. defineProps<{
  134. deviceId: number
  135. currentId?: TreeKey | null
  136. }>(),
  137. {
  138. currentId: null
  139. }
  140. )
  141. const emits = defineEmits<{
  142. (event: 'node-click', value: TreeNode): void
  143. (event: 'success', value?: number): void
  144. }>()
  145. const isCollapsed = ref(false)
  146. const treeName = ref('')
  147. const treeList = ref<TreeNode[]>([])
  148. const treeRef = ref<InstanceType<typeof ElTree>>()
  149. const expandedKeys = ref<Array<number | string>>([])
  150. const getNodeIcon = (type: string) => {
  151. if (type === 'device') return 'fa:tasks'
  152. return 'fa:folder-open'
  153. }
  154. const getNodeIconClass = (type: string) => {
  155. return type === 'device' ? 'node-icon node-icon--device' : 'node-icon node-icon--folder'
  156. }
  157. const filterDeviceNodes = (nodes: TreeNode[]): TreeNode[] => {
  158. return nodes.reduce<TreeNode[]>((result, node) => {
  159. if (node.type === 'device') {
  160. return result
  161. }
  162. const nextNode: TreeNode = { ...node }
  163. if (node.children?.length) {
  164. nextNode.children = filterDeviceNodes(node.children)
  165. }
  166. result.push(nextNode)
  167. return result
  168. }, [])
  169. }
  170. const findNodePath = (
  171. nodes: TreeNode[],
  172. targetId: number | string,
  173. path: Array<number | string> = []
  174. ): Array<number | string> | null => {
  175. for (const node of nodes) {
  176. const nextPath = [...path, node.id]
  177. if (node.id === targetId) {
  178. return nextPath
  179. }
  180. if (node.children?.length) {
  181. const result = findNodePath(node.children, targetId, nextPath)
  182. if (result) return result
  183. }
  184. }
  185. return null
  186. }
  187. const locateNode = (targetId: number | string) => {
  188. if (!targetId || !treeList.value.length) return
  189. const pathIds = findNodePath(treeList.value, targetId)
  190. if (!pathIds) return
  191. expandedKeys.value = [...new Set([...expandedKeys.value, ...pathIds.slice(0, -1)])]
  192. nextTick(() => {
  193. treeRef.value?.setCurrentKey(targetId)
  194. treeRef.value?.getNode(targetId)?.$el?.scrollIntoView({
  195. behavior: 'smooth',
  196. block: 'nearest'
  197. })
  198. })
  199. }
  200. const getTreeInfo = async () => {
  201. // const res = await DataLibraryApi.getFileTree()
  202. const res = await IotTreeApi.getSimpleTreeList()
  203. treeList.value = filterDeviceNodes(handleTree(res) as TreeNode[])
  204. expandedKeys.value = treeList.value.map((node) => node.id)
  205. if (props.currentId) {
  206. locateNode(props.currentId)
  207. }
  208. emits('success', treeList.value[0]?.id)
  209. }
  210. const filterNode = (name: string, data: TreeNode) => {
  211. return !name || data.name.includes(name)
  212. }
  213. const handleNodeClick = (row: TreeNode) => {
  214. emits('node-click', row)
  215. }
  216. watch(treeName, (val) => {
  217. treeRef.value?.filter(val)
  218. })
  219. watch(
  220. () => props.currentId,
  221. (newVal) => {
  222. if (newVal) {
  223. locateNode(newVal)
  224. }
  225. }
  226. )
  227. const handleClickOutside = (event) => {
  228. if (menuVisible.value && contextMenuRef.value && !contextMenuRef.value.contains(event.target)) {
  229. menuVisible.value = false
  230. }
  231. }
  232. watch(menuVisible, (visible) => {
  233. if (visible) {
  234. document.addEventListener('click', handleClickOutside)
  235. } else {
  236. document.removeEventListener('click', handleClickOutside)
  237. }
  238. })
  239. onMounted(getTreeInfo)
  240. onBeforeUnmount(() => {
  241. document.removeEventListener('click', handleClickOutside)
  242. })
  243. </script>
  244. <template>
  245. <div
  246. class="pms-tree-aside relative bg-white dark:bg-[#1d1e1f] shadow rounded-lg transition-all duration-300 ease-in-out overflow-visible"
  247. :class="[isCollapsed ? 'is-collapsed' : 'p-4']">
  248. <div v-show="!isCollapsed" class="h-full flex flex-col gap-4 overflow-hidden w-full">
  249. <div class="shrink-0">
  250. <el-input
  251. v-model="treeName"
  252. clearable
  253. placeholder="请输入名称"
  254. size="default"
  255. :prefix-icon="Search" />
  256. </div>
  257. <div class="tree-body">
  258. <el-auto-resizer class="absolute">
  259. <template #default="{ height }">
  260. <el-scrollbar :style="{ height: `${height}px` }">
  261. <el-tree
  262. ref="treeRef"
  263. :data="treeList"
  264. :default-expanded-keys="expandedKeys"
  265. :expand-on-click-node="false"
  266. :filter-node-method="filterNode"
  267. :props="defaultProps"
  268. highlight-current
  269. node-key="id"
  270. @node-contextmenu="handleRightClick"
  271. @node-click="handleNodeClick">
  272. <template #default="{ node }">
  273. <div class="tree-node">
  274. <Icon
  275. :class="getNodeIconClass(node.data.type)"
  276. :icon="getNodeIcon(node.data.type)" />
  277. <span class="tree-node__label">{{ node.data.name }}</span>
  278. </div>
  279. </template>
  280. </el-tree>
  281. <div
  282. v-show="menuVisible"
  283. ref="contextMenuRef"
  284. class="custom-menu"
  285. :style="{ left: menuX + 'px', top: menuY + 'px' }">
  286. <ul>
  287. <li @click="handleMenuClick('add')">新建目录</li>
  288. <li @click="handleMenuClick('edit')">编辑</li>
  289. <li @click="handleMenuClick('delete')">删除目录</li>
  290. </ul>
  291. </div>
  292. </el-scrollbar>
  293. </template>
  294. </el-auto-resizer>
  295. </div>
  296. </div>
  297. <Dialog v-model="dialogVisible" :title="dialogTitle" style="width: 40em">
  298. <el-form ref="formRef" :model="formData" :rules="formRules" label-width="80px">
  299. <div class="flex flex-col gap-4">
  300. <el-form-item label="目录名称" prop="name" label-width="110px">
  301. <el-input v-model="formData.name" placeholder="请输入目录名称" />
  302. </el-form-item>
  303. <el-form-item label="显示排序" prop="sort" label-width="110px">
  304. <el-input-number v-model="formData.sort" :min="0" controls-position="right" />
  305. </el-form-item>
  306. </div>
  307. </el-form>
  308. <template #footer>
  309. <el-button type="primary" @click="submitForm">确 定</el-button>
  310. <el-button @click="dialogVisible = false">取 消</el-button>
  311. </template>
  312. </Dialog>
  313. <div class="collapse-handle" @click="isCollapsed = !isCollapsed">
  314. <el-icon size="12">
  315. <CaretLeft v-if="!isCollapsed" />
  316. <CaretRight v-else />
  317. </el-icon>
  318. </div>
  319. </div>
  320. </template>
  321. <style lang="scss" scoped>
  322. .pms-tree-aside {
  323. width: 100%;
  324. height: calc(
  325. 100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
  326. );
  327. min-width: 240px;
  328. box-sizing: border-box;
  329. flex-shrink: 0;
  330. }
  331. .pms-tree-aside.is-collapsed {
  332. width: 0 !important;
  333. min-width: 0 !important;
  334. padding: 0 !important;
  335. overflow: visible !important;
  336. pointer-events: none;
  337. box-shadow: none;
  338. }
  339. :global(.left-tree:has(.pms-tree-aside.is-collapsed)) {
  340. width: 0 !important;
  341. overflow: visible !important;
  342. background: transparent !important;
  343. }
  344. :global(.left-tree:has(.pms-tree-aside.is-collapsed) + .divider-tree) {
  345. display: none;
  346. }
  347. .tree-body {
  348. position: relative;
  349. min-height: 0;
  350. overflow: hidden;
  351. flex: 1;
  352. }
  353. .tree-node {
  354. display: flex;
  355. width: 100%;
  356. min-width: 0;
  357. align-items: center;
  358. }
  359. .node-icon {
  360. margin-right: 6px;
  361. fill: currentcolor;
  362. flex-shrink: 0;
  363. }
  364. .node-icon--folder {
  365. color: #e6a23c;
  366. }
  367. .node-icon--device {
  368. color: #409eff;
  369. }
  370. .tree-node__label {
  371. overflow: hidden;
  372. text-overflow: ellipsis;
  373. white-space: nowrap;
  374. }
  375. .custom-menu {
  376. position: fixed;
  377. min-width: 144px;
  378. padding: 6px;
  379. background: #fff;
  380. border: 1px solid rgb(15 23 42 / 8%);
  381. border-radius: 10px;
  382. box-shadow: 0 14px 30px rgb(15 23 42 / 16%);
  383. z-index: 3000;
  384. }
  385. .custom-menu ul {
  386. list-style: none;
  387. padding: 0;
  388. margin: 0;
  389. }
  390. .custom-menu li {
  391. padding: 8px 12px;
  392. margin: 0;
  393. font-size: 13px;
  394. line-height: 1.2;
  395. color: #334155;
  396. cursor: pointer;
  397. border-radius: 8px;
  398. transition:
  399. background-color 0.18s ease,
  400. color 0.18s ease;
  401. }
  402. .custom-menu li:hover {
  403. color: #1677ff;
  404. background: #eff6ff;
  405. }
  406. .collapse-handle {
  407. position: absolute;
  408. top: 50%;
  409. right: -14px;
  410. z-index: 200;
  411. display: flex;
  412. width: 14px;
  413. height: 60px;
  414. color: var(--el-text-color-secondary);
  415. pointer-events: auto;
  416. cursor: pointer;
  417. background-color: var(--el-bg-color);
  418. border: 1px solid var(--el-border-color-light);
  419. border-left: none;
  420. border-radius: 0 12px 12px 0;
  421. transform: translateY(-50%);
  422. box-shadow: 2px 0 6px rgb(0 0 0 / 5%);
  423. transition: right 0.3s;
  424. align-items: center;
  425. justify-content: center;
  426. }
  427. .is-collapsed .collapse-handle {
  428. right: -8px;
  429. border-left: 1px solid var(--el-border-color-light);
  430. }
  431. .collapse-handle:hover {
  432. color: var(--el-color-primary);
  433. background-color: var(--el-fill-color-light);
  434. }
  435. :deep(.el-tree) {
  436. --el-tree-node-hover-bg-color: var(--el-fill-color-light);
  437. background: transparent;
  438. }
  439. :deep(.el-tree-node__content) {
  440. height: 32px;
  441. border-radius: 4px;
  442. }
  443. :deep(.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content) {
  444. font-weight: 600;
  445. color: var(--el-color-primary);
  446. background-color: var(--el-color-primary-light-9);
  447. }
  448. </style>