|
|
@@ -4,6 +4,7 @@ import { CaretLeft, CaretRight, Search } from '@element-plus/icons-vue'
|
|
|
import type { IotTreeVO } from '@/api/system/tree'
|
|
|
import { IotTreeApi } from '@/api/system/tree'
|
|
|
import { defaultProps, handleTree } from '@/utils/tree'
|
|
|
+import { CommonStatusEnum } from '@/utils/constants'
|
|
|
|
|
|
defineOptions({ name: 'IotTree' })
|
|
|
|
|
|
@@ -28,11 +29,41 @@ const emits = defineEmits<{
|
|
|
(event: 'success', value?: number): void
|
|
|
}>()
|
|
|
|
|
|
+const { t } = useI18n()
|
|
|
+const message = useMessage()
|
|
|
const isCollapsed = ref(false)
|
|
|
const treeName = ref('')
|
|
|
const treeList = ref<TreeNode[]>([])
|
|
|
const treeRef = ref<InstanceType<typeof ElTree>>()
|
|
|
const expandedKeys = ref<Array<number | string>>([])
|
|
|
+const formLoading = ref(false)
|
|
|
+const dialogVisible = ref(false)
|
|
|
+const dialogTitle = ref('')
|
|
|
+const formRef = ref()
|
|
|
+const menuVisible = ref(false)
|
|
|
+const menuX = ref(0)
|
|
|
+const menuY = ref(0)
|
|
|
+const contextMenuRef = ref<HTMLElement | null>(null)
|
|
|
+const nodeInfo = ref<Partial<TreeNode>>({})
|
|
|
+const formType = ref<'create' | 'update'>('create')
|
|
|
+const selectedNode = ref<TreeNode | null>(null)
|
|
|
+
|
|
|
+const createDefaultFormData = () => ({
|
|
|
+ id: undefined as number | undefined,
|
|
|
+ originId: undefined as number | undefined,
|
|
|
+ parentId: undefined as number | undefined,
|
|
|
+ name: '',
|
|
|
+ type: 'folder',
|
|
|
+ sort: 0,
|
|
|
+ status: CommonStatusEnum.ENABLE,
|
|
|
+ remark: ''
|
|
|
+})
|
|
|
+
|
|
|
+const formData = ref(createDefaultFormData())
|
|
|
+const formRules = reactive({
|
|
|
+ name: [{ required: true, message: '请输入目录名称', trigger: 'blur' }],
|
|
|
+ sort: [{ required: true, message: '请输入显示排序', trigger: 'blur' }]
|
|
|
+})
|
|
|
|
|
|
const getNodeIcon = (type: string) => {
|
|
|
if (type === 'device') return 'fa:tasks'
|
|
|
@@ -64,6 +95,24 @@ const findNodePath = (
|
|
|
return null
|
|
|
}
|
|
|
|
|
|
+const findNodeById = (
|
|
|
+ nodes: TreeNode[],
|
|
|
+ targetId: number | string
|
|
|
+): TreeNode | null => {
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (node.id === targetId) {
|
|
|
+ return node
|
|
|
+ }
|
|
|
+
|
|
|
+ if (node.children?.length) {
|
|
|
+ const result = findNodeById(node.children, targetId)
|
|
|
+ if (result) return result
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return null
|
|
|
+}
|
|
|
+
|
|
|
const locateNode = (targetId: number | string) => {
|
|
|
if (!targetId || !treeList.value.length) return
|
|
|
|
|
|
@@ -81,6 +130,32 @@ const locateNode = (targetId: number | string) => {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
+const resetForm = () => {
|
|
|
+ formData.value = createDefaultFormData()
|
|
|
+ formRef.value?.resetFields()
|
|
|
+}
|
|
|
+
|
|
|
+const handleRightClick = (event: MouseEvent, data: TreeNode) => {
|
|
|
+ nodeInfo.value = { ...data }
|
|
|
+ selectedNode.value = data
|
|
|
+ event.preventDefault()
|
|
|
+
|
|
|
+ const nodeContent = (event.target as HTMLElement)?.closest(
|
|
|
+ '.el-tree-node__content'
|
|
|
+ ) as HTMLElement | null
|
|
|
+
|
|
|
+ if (nodeContent) {
|
|
|
+ const rect = nodeContent.getBoundingClientRect()
|
|
|
+ menuX.value = rect.right + 8
|
|
|
+ menuY.value = rect.top
|
|
|
+ } else {
|
|
|
+ menuX.value = event.clientX
|
|
|
+ menuY.value = event.clientY
|
|
|
+ }
|
|
|
+
|
|
|
+ menuVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
const getTreeInfo = async () => {
|
|
|
const res = await IotTreeApi.getSimpleTreeList()
|
|
|
treeList.value = handleTree(res) as TreeNode[]
|
|
|
@@ -101,6 +176,89 @@ const handleNodeClick = (row: TreeNode) => {
|
|
|
emits('node-click', row)
|
|
|
}
|
|
|
|
|
|
+const handleMenuClick = async (action: 'add' | 'edit' | 'delete') => {
|
|
|
+ switch (action) {
|
|
|
+ case 'add':
|
|
|
+ resetForm()
|
|
|
+ formType.value = 'create'
|
|
|
+ dialogTitle.value = '新增目录'
|
|
|
+ formData.value.parentId = selectedNode.value?.id
|
|
|
+ formData.value.originId = selectedNode.value?.id
|
|
|
+ formData.value.type = selectedNode.value?.type || 'folder'
|
|
|
+ dialogVisible.value = true
|
|
|
+ break
|
|
|
+ case 'edit':
|
|
|
+ if (!selectedNode.value) return
|
|
|
+ resetForm()
|
|
|
+ formType.value = 'update'
|
|
|
+ dialogTitle.value = '编辑目录'
|
|
|
+ formData.value = {
|
|
|
+ ...createDefaultFormData(),
|
|
|
+ ...selectedNode.value
|
|
|
+ }
|
|
|
+ dialogVisible.value = true
|
|
|
+ break
|
|
|
+ case 'delete':
|
|
|
+ if (!selectedNode.value?.id) return
|
|
|
+ const parentId = selectedNode.value.parentId
|
|
|
+ await message.delConfirm()
|
|
|
+ await IotTreeApi.deleteIotTree(selectedNode.value.id)
|
|
|
+ message.success(t('common.delSuccess'))
|
|
|
+ await getTreeInfo()
|
|
|
+ if (parentId) {
|
|
|
+ locateNode(parentId)
|
|
|
+ const parentNode = findNodeById(treeList.value, parentId)
|
|
|
+ if (parentNode) {
|
|
|
+ emits('node-click', parentNode)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ menuVisible.value = false
|
|
|
+}
|
|
|
+
|
|
|
+const submitForm = async () => {
|
|
|
+ await formRef.value?.validate()
|
|
|
+ formLoading.value = true
|
|
|
+ try {
|
|
|
+ const targetParentId =
|
|
|
+ formType.value === 'create' ? selectedNode.value?.id : formData.value.parentId
|
|
|
+ const payload = {
|
|
|
+ ...formData.value,
|
|
|
+ parentId: formType.value === 'create' ? selectedNode.value?.id : formData.value.parentId,
|
|
|
+ originId:
|
|
|
+ formType.value === 'create'
|
|
|
+ ? selectedNode.value?.originId ?? selectedNode.value?.id ?? 0
|
|
|
+ : formData.value.originId,
|
|
|
+ type: formData.value.type || selectedNode.value?.type || 'folder'
|
|
|
+ } as IotTreeVO
|
|
|
+
|
|
|
+ if (formType.value === 'create') {
|
|
|
+ await IotTreeApi.createIotTree(payload)
|
|
|
+ message.success(t('common.createSuccess'))
|
|
|
+ } else {
|
|
|
+ await IotTreeApi.updateIotTree(payload)
|
|
|
+ message.success(t('common.updateSuccess'))
|
|
|
+ }
|
|
|
+
|
|
|
+ dialogVisible.value = false
|
|
|
+ await getTreeInfo()
|
|
|
+ if (targetParentId) {
|
|
|
+ expandedKeys.value = [...new Set([...expandedKeys.value, targetParentId])]
|
|
|
+ locateNode(targetParentId)
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ formLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleClickOutside = (event: MouseEvent) => {
|
|
|
+ if (menuVisible.value && contextMenuRef.value && !contextMenuRef.value.contains(event.target as Node)) {
|
|
|
+ menuVisible.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
watch(treeName, (val) => {
|
|
|
treeRef.value?.filter(val)
|
|
|
})
|
|
|
@@ -114,7 +272,18 @@ watch(
|
|
|
}
|
|
|
)
|
|
|
|
|
|
+watch(menuVisible, (visible) => {
|
|
|
+ if (visible) {
|
|
|
+ document.addEventListener('click', handleClickOutside)
|
|
|
+ } else {
|
|
|
+ document.removeEventListener('click', handleClickOutside)
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
onMounted(getTreeInfo)
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ document.removeEventListener('click', handleClickOutside)
|
|
|
+})
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
@@ -144,6 +313,7 @@ onMounted(getTreeInfo)
|
|
|
:props="defaultProps"
|
|
|
highlight-current
|
|
|
node-key="id"
|
|
|
+ @node-contextmenu="handleRightClick"
|
|
|
@node-click="handleNodeClick">
|
|
|
<template #default="{ node }">
|
|
|
<div class="tree-node">
|
|
|
@@ -154,12 +324,59 @@ onMounted(getTreeInfo)
|
|
|
</div>
|
|
|
</template>
|
|
|
</el-tree>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-show="menuVisible"
|
|
|
+ ref="contextMenuRef"
|
|
|
+ class="custom-menu"
|
|
|
+ :style="{ left: menuX + 'px', top: menuY + 'px' }">
|
|
|
+ <ul>
|
|
|
+ <li @click="handleMenuClick('add')" class="flex items-center gap-1">
|
|
|
+ <Icon icon="ep:plus" /> 新建目录
|
|
|
+ </li>
|
|
|
+ <li @click="handleMenuClick('edit')" class="flex items-center gap-1">
|
|
|
+ <Icon icon="ep:edit" /> 编辑目录
|
|
|
+ </li>
|
|
|
+ <li @click="handleMenuClick('delete')" class="flex items-center gap-1">
|
|
|
+ <Icon icon="ep:delete" /> 删除目录
|
|
|
+ </li>
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
</el-scrollbar>
|
|
|
</template>
|
|
|
</el-auto-resizer>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
+ <Dialog v-model="dialogVisible" :title="dialogTitle" style="width: 40em">
|
|
|
+ <el-form
|
|
|
+ ref="formRef"
|
|
|
+ v-loading="formLoading"
|
|
|
+ :model="formData"
|
|
|
+ :rules="formRules"
|
|
|
+ label-width="96px">
|
|
|
+ <div class="flex flex-col gap-4">
|
|
|
+ <el-form-item label="目录名称" prop="name">
|
|
|
+ <el-input v-model="formData.name" placeholder="请输入目录名称" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="显示排序" prop="sort">
|
|
|
+ <el-input-number v-model="formData.sort" :min="0" controls-position="right" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="备注" prop="remark">
|
|
|
+ <el-input
|
|
|
+ v-model="formData.remark"
|
|
|
+ type="textarea"
|
|
|
+ :rows="3"
|
|
|
+ placeholder="请输入备注" />
|
|
|
+ </el-form-item>
|
|
|
+ </div>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <el-button type="primary" @click="submitForm">确定</el-button>
|
|
|
+ <el-button @click="dialogVisible = false">取消</el-button>
|
|
|
+ </template>
|
|
|
+ </Dialog>
|
|
|
+
|
|
|
<div class="collapse-handle" @click="isCollapsed = !isCollapsed">
|
|
|
<el-icon size="12">
|
|
|
<CaretLeft v-if="!isCollapsed" />
|
|
|
@@ -233,6 +450,41 @@ onMounted(getTreeInfo)
|
|
|
white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
+.custom-menu {
|
|
|
+ position: fixed;
|
|
|
+ min-width: 144px;
|
|
|
+ padding: 6px;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid rgb(15 23 42 / 8%);
|
|
|
+ border-radius: 10px;
|
|
|
+ box-shadow: 0 14px 30px rgb(15 23 42 / 16%);
|
|
|
+ z-index: 3000;
|
|
|
+}
|
|
|
+
|
|
|
+.custom-menu ul {
|
|
|
+ list-style: none;
|
|
|
+ padding: 0;
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.custom-menu li {
|
|
|
+ padding: 8px 12px;
|
|
|
+ margin: 0;
|
|
|
+ font-size: 13px;
|
|
|
+ line-height: 1.2;
|
|
|
+ color: #334155;
|
|
|
+ cursor: pointer;
|
|
|
+ border-radius: 8px;
|
|
|
+ transition:
|
|
|
+ background-color 0.18s ease,
|
|
|
+ color 0.18s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.custom-menu li:hover {
|
|
|
+ color: #1677ff;
|
|
|
+ background: #eff6ff;
|
|
|
+}
|
|
|
+
|
|
|
.collapse-handle {
|
|
|
position: absolute;
|
|
|
top: 50%;
|