PmsTree.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. <template>
  2. <div class="head-container">
  3. <el-input v-model="deptName" class="mb-15px" clearable placeholder="请输入名称">
  4. <template #prefix>
  5. <Icon icon="ep:search" />
  6. </template>
  7. </el-input>
  8. </div>
  9. <div ref="treeContainer" class="tree-container">
  10. <el-tree
  11. ref="treeRef"
  12. :data="treeList"
  13. :expand-on-click-node="false"
  14. :filter-node-method="filterNode"
  15. :props="defaultProps"
  16. :default-expanded-keys="expandedKeys"
  17. highlight-current
  18. node-key="id"
  19. @node-click="handleNodeClick"
  20. @node-contextmenu="handleRightClick"
  21. style="height: 52em"
  22. >
  23. <template #default="{ node }">
  24. <div
  25. style="display: flex; justify-content: space-between; align-items: center; width: 100%"
  26. >
  27. <div>
  28. <Icon
  29. style="vertical-align: middle;fill: currentColor;color: orange"
  30. v-if="node.data.type === 'dept'"
  31. icon="fa:folder-open"
  32. />
  33. <Icon
  34. style="vertical-align: middle;fill: currentColor;color:orange"
  35. v-if="node.data.type === 'device'"
  36. icon="fa:folder-open"
  37. />
  38. <Icon icon="fa:folder-open" v-if="node.data.type === 'file'" style="vertical-align: middle;color: orange;fill: currentColor;"/>
  39. <span style="vertical-align: middle; margin-left: 3px">{{ node.data.name }}</span>
  40. </div>
  41. </div>
  42. </template>
  43. </el-tree>
  44. </div>
  45. <div v-show="deviceVisible" ref="contextMenuRef" class="custom-menu" :style="{ left: menuX + 'px', top: menuY + 'px' }">
  46. <ul>
  47. <li style="border-bottom: 1px solid #ccc;" @click="handleDeviceClick('add')">设备详情</li>
  48. <li style="border-bottom: 0px solid #ccc;" @click="handleMenuClick('add')">新建目录</li>
  49. </ul>
  50. </div>
  51. <div v-show="menuVisible" ref="contextMenuRef" class="custom-menu" :style="{ left: menuX + 'px', top: menuY + 'px' }">
  52. <ul>
  53. <li style="border-bottom: 1px solid #ccc;" @click="handleMenuClick('add')">新建目录</li>
  54. <li style="border-bottom: 1px solid #ccc;" @click="handleMenuClick('edit')">编辑</li>
  55. <li @click="handleMenuClick('delete')">删除目录</li>
  56. </ul>
  57. </div>
  58. <Dialog v-model="dialogVisible" :title="dialogTitle" style="width: 40em">
  59. <el-form
  60. ref="formRef"
  61. v-loading="formLoading"
  62. :model="formData"
  63. :rules="formRules"
  64. label-width="80px"
  65. >
  66. <el-form-item label="目录名称" prop="name" label-width="110px">
  67. <el-input v-model="formData.name" placeholder="请输入目录名称" />
  68. </el-form-item>
  69. <el-form-item label="显示排序" prop="sort" label-width="110px">
  70. <el-input-number v-model="formData.sort" :min="0" controls-position="right" />
  71. </el-form-item>
  72. <!-- <el-form-item label="备注" prop="remark" label-width="110px">-->
  73. <!-- <el-input-->
  74. <!-- v-model="formData.remark"-->
  75. <!-- maxlength="11"-->
  76. <!-- placeholder="请输入备注"-->
  77. <!-- type="textarea"-->
  78. <!-- />-->
  79. <!-- </el-form-item>-->
  80. </el-form>
  81. <template #footer>
  82. <el-button type="primary" @click="submitForm">确 定</el-button>
  83. <el-button @click="dialogVisible = false">取 消</el-button>
  84. </template>
  85. </Dialog>
  86. </template>
  87. <script lang="ts" setup>
  88. import { ElTree } from 'element-plus'
  89. import { defaultProps, handleTree } from '@/utils/tree'
  90. import { CommonStatusEnum } from '@/utils/constants'
  91. import { IotTreeApi } from '@/api/system/tree'
  92. import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
  93. import { useI18n } from 'vue-i18n'
  94. import { useRouter } from 'vue-router'
  95. import {IotInfoClassifyApi} from "@/api/pms/info";
  96. // 类型定义
  97. interface Tree {
  98. id: number | string
  99. name: string
  100. type: string
  101. children?: Tree[]
  102. [key: string]: any
  103. }
  104. const { t } = useI18n() // 国际化
  105. const message = useMessage() // 消息弹窗
  106. const dialogVisible = ref(false) // 弹窗的是否展示
  107. const dialogTitle = ref('') // 弹窗的标题
  108. const formRef = ref() // 搜索的表单
  109. const firstLevelKeys = ref<(number | string)[]>([])
  110. const expandedKeys = ref<(number | string)[]>([]) // 用于展开节点的路径
  111. // 表单相关
  112. const formLoading = ref(false)
  113. const formType = ref<'create' | 'update'>('create')
  114. const formData = ref({
  115. id: undefined,
  116. title: '',
  117. parentId: undefined,
  118. name: undefined,
  119. sort: undefined,
  120. leaderUserId: undefined,
  121. phone: undefined,
  122. email: undefined,
  123. status: CommonStatusEnum.ENABLE
  124. })
  125. const formRules = ref({
  126. name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
  127. sort: [{ required: true, message: '请输入排序', trigger: 'blur' }]
  128. })
  129. const openForm = (type: string, id?: number) => {
  130. formRef.value.open(type, id)
  131. }
  132. /** 重置表单 */
  133. const resetForm = () => {
  134. formData.value = {
  135. id: undefined,
  136. title: '',
  137. parentId: undefined,
  138. name: undefined,
  139. sort: undefined,
  140. leaderUserId: undefined,
  141. phone: undefined,
  142. email: undefined,
  143. status: CommonStatusEnum.ENABLE
  144. }
  145. formRef.value?.resetFields()
  146. }
  147. defineOptions({ name: 'IotTree' })
  148. // 接收外部参数
  149. const props = defineProps({
  150. deviceId: { type: Number, required: true },
  151. currentId: { type: [Number, String], default: null } // 新增:接收需要定位的节点ID
  152. })
  153. const deptName = ref('')
  154. const nodeInfo = ref({})
  155. const treeList = ref<Tree[]>([]) // 树形结构
  156. const treeRef = ref<InstanceType<typeof ElTree>>()
  157. const menuVisible = ref(false)
  158. const deviceVisible = ref(false)
  159. const menuX = ref(0)
  160. const menuY = ref(0)
  161. const contextMenuRef = ref(null) // 弹窗DOM引用
  162. let selectedNode = null
  163. const parentId = ref()
  164. const { push } = useRouter() // 路由跳转
  165. // 动态高度计算
  166. const treeContainer = ref(null)
  167. const setHeight = () => {
  168. if (!treeContainer.value) return
  169. const windowHeight = window.innerHeight
  170. const containerTop = treeContainer.value.offsetTop
  171. treeContainer.value.style.height = `${windowHeight * 0.78}px` // 60px 底部预留
  172. }
  173. // 新增:查找节点路径的递归方法
  174. const findNodePath = (nodes: Tree[], targetId: number | string, path: (number | string)[] = []): (number | string)[] | null => {
  175. for (const node of nodes) {
  176. path.push(node.id)
  177. if (node.id === targetId) {
  178. return [...path]
  179. }
  180. if (node.children && node.children.length) {
  181. const result = findNodePath(node.children, targetId, path)
  182. if (result) {
  183. return result
  184. }
  185. }
  186. path.pop()
  187. }
  188. return null
  189. }
  190. // 新增:定位节点并高亮
  191. const locateNode = (targetId: number | string) => {
  192. if (!targetId || !treeList.value.length) return
  193. const pathIds = findNodePath(treeList.value, targetId)
  194. if (pathIds) {
  195. // 展开所有父节点
  196. expandedKeys.value = pathIds.slice(0, -1)
  197. // 等待DOM更新后设置当前节点
  198. nextTick(() => {
  199. if (treeRef.value) {
  200. treeRef.value.setCurrentKey(targetId)
  201. // 滚动到节点位置
  202. const node = treeRef.value.getNode(targetId)
  203. if (node && node.$el) {
  204. node.$el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
  205. }
  206. }
  207. })
  208. }
  209. }
  210. onMounted(async () => {
  211. await getTreeInfo()
  212. setHeight()
  213. window.addEventListener('resize', setHeight)
  214. })
  215. onUnmounted(() => {
  216. window.removeEventListener('resize', setHeight)
  217. })
  218. const handleRightClick = (event, node, data) => {
  219. nodeInfo.value = node
  220. event.preventDefault()
  221. menuX.value = event.clientX
  222. menuY.value = event.clientY
  223. selectedNode = data
  224. debugger
  225. parentId.value = data.data.id
  226. if (nodeInfo.value.type === 'device') {
  227. deviceVisible.value = true
  228. } else if (nodeInfo.value.type === 'file') {
  229. menuVisible.value = true
  230. }
  231. }
  232. const handleDeviceClick = async () => {
  233. const id = nodeInfo.value.originId
  234. push({ name: 'DeviceDetailInfo', params: { id } })
  235. deviceVisible.value = false
  236. menuVisible.value = false
  237. }
  238. const handleMenuClick = async (action) => {
  239. switch (action) {
  240. case 'add':
  241. dialogVisible.value = true
  242. dialogTitle.value = '新增目录'
  243. formType.value = 'create'
  244. resetForm()
  245. break
  246. case 'edit':
  247. resetForm()
  248. dialogVisible.value = true
  249. dialogTitle.value = '编辑目录'
  250. formType.value = 'update'
  251. formData.value = { ...nodeInfo.value }
  252. debugger
  253. break
  254. case 'delete':
  255. // 删除的二次确认
  256. await message.delConfirm()
  257. // 假设存在删除接口
  258. await IotTreeApi.deleteIotTree(nodeInfo.value.id)
  259. message.success(t('common.delSuccess'))
  260. // 刷新列表
  261. await getTreeInfo()
  262. break
  263. }
  264. deviceVisible.value = false
  265. menuVisible.value = false
  266. }
  267. /** 获得部门树 */
  268. const getTreeInfo = async () => {
  269. const res = await IotTreeApi.getSimpleTreeList()
  270. treeList.value = []
  271. treeList.value.push(...handleTree(res))
  272. // 处理展开逻辑:有currentId则展开对应路径,否则展开一级节点
  273. if (props.currentId) {
  274. locateNode(props.currentId)
  275. } else {
  276. firstLevelKeys.value = treeList.value.map(node => node.id)
  277. expandedKeys.value = [...firstLevelKeys.value]
  278. }
  279. emits('success', treeList.value[0]?.id)
  280. }
  281. /** 基于名字过滤 */
  282. const filterNode = (name: string, data: Tree) => {
  283. if (!name) return true
  284. return data.name.includes(name)
  285. }
  286. /** 处理节点被点击 */
  287. const handleNodeClick = async (row: { [key: string]: any }) => {
  288. deviceVisible.value = false
  289. menuVisible.value = false
  290. emits('node-click', row)
  291. }
  292. /** 提交表单 */
  293. const submitForm = async () => {
  294. // 表单验证逻辑
  295. formLoading.value = true
  296. try {
  297. formData.value.deviceId = props.deviceId
  298. debugger
  299. // formData.value.parentId = parentId.value
  300. if (formData.value.parentId===undefined||formData.value.parentId===null) {
  301. formData.value.parentId = props.currentId
  302. }
  303. formData.value.type = 'file'
  304. if (formType.value === 'create') {
  305. debugger
  306. await IotTreeApi.createIotTree(formData.value)
  307. } else {
  308. await IotTreeApi.updateIotTree(formData.value)
  309. }
  310. message.success(t(formType.value === 'create' ? 'common.addSuccess' : 'common.updateSuccess'))
  311. dialogVisible.value = false
  312. await getTreeInfo()
  313. } catch (error) {
  314. console.error(error)
  315. } finally {
  316. formLoading.value = false
  317. }
  318. }
  319. const emits = defineEmits(['node-click', 'success'])
  320. /** 监听搜索框变化 */
  321. watch(deptName, (val) => {
  322. treeRef.value!.filter(val)
  323. })
  324. /** 监听currentId变化,重新定位 */
  325. watch(
  326. () => props.currentId,
  327. (newVal) => {
  328. if (newVal && treeList.value.length) {
  329. locateNode(newVal)
  330. }
  331. },
  332. { immediate: true }
  333. )
  334. </script>
  335. <style lang="scss" scoped>
  336. .custom-menu {
  337. position: fixed;
  338. background: white;
  339. border: 1px solid #ccc;
  340. box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
  341. z-index: 1000;
  342. }
  343. .custom-menu ul {
  344. list-style: none;
  345. padding: 0;
  346. margin: 0;
  347. }
  348. .custom-menu li {
  349. padding: 2px 10px;
  350. cursor: pointer;
  351. font-size: 12px;
  352. margin: 2px;
  353. }
  354. .custom-menu li:hover {
  355. background: #77a0ec;
  356. }
  357. .tree-container {
  358. overflow-y: auto;
  359. min-width: 100%;
  360. border: 1px solid #e4e7ed;
  361. border-radius: 4px;
  362. }
  363. /* 自定义滚动条 */
  364. .tree-container::-webkit-scrollbar {
  365. width: 6px;
  366. }
  367. .tree-container::-webkit-scrollbar-thumb {
  368. background: #c0c4cc;
  369. border-radius: 3px;
  370. }
  371. /* 自定义高亮样式 */
  372. ::v-deep .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
  373. background-color: #eaf5ff !important;
  374. color: #1890ff !important;
  375. font-weight: bold;
  376. }
  377. </style>