基于AntD树形组织结构支持删除、重命名等

效果图

image

Code

<template>
	<div class="file-side-bar">
		<div class="header">
			<span class="title">文档分组</span>
			<img alt="add" class="cursor-pointer" src="@/assets/plus.svg" @click="onModalVisibleChange(true)" />
		</div>
		<a-input
			v-model:value="searchValue"
			placeholder="请输入分类名称"
			style="margin-bottom: 8px"
			@change="throttledLoadTreeData"
			@pressEnter="throttledLoadTreeData"
		>
			<template #suffix>
				<img alt="" class="search-icon" src="@/assets/images/search-grey.svg" @click="throttledLoadTreeData" />
			</template>
		</a-input>
		<div class="tree-box">
			<a-spin :spinning="spinning">
				<a-tree
					v-model:expandedKeys="expandedKeys"
					v-model:selectedKeys="selectedKeys"
					:tree-data="treeData"
					style="background: transparent; overflow: hidden;"
					@select="onSelect"
				>
					<template #title="{ title, value, key }">
						<div class="title-box tree-title">
							<div v-if="editingNodeId !== key" class="single-text">{{ title }}</div>
							<div
								v-else
							>
								<a-input
									v-model:value="editingTitle"
									size="small"
									class="rename-input"
								/>
								<CheckOutlined style="color: lightgreen;" class="rename-icon confirm-icon" @click="confirmRename(key)" />
								<CloseOutlined class="rename-icon cancel-icon" title="取消" @click="cancelRename" />
							</div>
							<a-dropdown>
								<img alt class="cursor-pointer" src="@/assets/opt.svg" @click.stop />
								<template #overlay>
									<a-menu @click="({ key }) => onMenuClick(key,  title, value )">
										<a-menu-item key="rename">重命名</a-menu-item>
										<a-menu-item key="move">移动至</a-menu-item>
										<a-menu-item key="delete">删除</a-menu-item>
									</a-menu>
								</template>
							</a-dropdown>
						</div>
					</template>
				</a-tree>
			</a-spin>
		</div>
	</div>

	<HzModal
		:is-show-footer="true"
		:loading="modalLoading"
		:title="modalTitle"
		:visible="isModalVisible"
		:width="520"
		@cancel="onModalVisibleChange(false)"
		@ok="handleOk"
	>
		<a-form ref="formRef" :label-col="{span: 4}" :model="modalState" :rules="rules">
			<a-form-item label="所属分类" name="parentId">
				<a-tree-select
					v-model:value="modalState.parentId"
					:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
					:tree-data="treeData"
					allow-clear
					class="api-ant-select full-width"
					placeholder="请选择上级分类"
					show-search
					tree-default-expand-all
				>
				</a-tree-select>
			</a-form-item>
			<a-form-item label="分类名称" name="name">
				<a-input v-model:value="modalState.name" class="modal-input" placeholder="请输入分类名称" />
			</a-form-item>
		</a-form>
	</HzModal>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { findParentKey, isResponseSuccess } from '@/utils/bean'
import { isEmpty, throttle } from 'lodash'
import { useStore } from 'vuex'
import mitt from '@/utils/mitt'
import { addCatalogue, deleteCatalogue, getCatalogueTree, updateCatalogue } from '@/api/biz/common'
import { useHzModal } from '@/views/pages/archive/components/ModalComs/AddUpdateComs/extends/useHzModal'
import HzModal from '@/components/common/HzModal.vue'
import { message } from 'ant-design-vue'
import { CheckOutlined, CloseOutlined } from '@ant-design/icons-vue'


const store = useStore()
const searchValue = ref('')
const expandedKeys = ref([])
const selectedKeys = ref([])
const treeData = ref([])
const isExpanded = ref(true)

const formRef = ref()
const resetForm = () => {
	formRef.value?.resetFields()
}
const rules = {
	// parentId: [{ required: true, message: '请选择所属分类', trigger: 'change' }],
	name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }]
}

const modalState = ref({
	parentId: null,
	name: ''
})

const modalTitle = ref('新增分类')
const submitForm = () => {
	formRef.value?.validate().then(async () => {
		console.log('✅ 表单验证通过,提交数据:', modalState.value)
		modalLoading.value = true
		let payload = {
			...modalState.value
		}
		let execFun;
		if(modalTitle.value.includes("更新分类")) {
			execFun = updateCatalogue
			payload.id = curUpdateId.value
		}else {
			execFun = addCatalogue
		}
		const res = await execFun(payload)
		if (res.status === 200) {
			modalLoading.value = false
			message.success('操作成功')
			isModalVisible.value = false
			resetForm()
			await loadTreeData()
		}
		// 提交逻辑
	}).catch(err => {
		console.log('❌ 表单验证失败:', err)
	})
}
const onCancel = () => {
	handleCancel()
}
const {
	isModalVisible,
	onModalVisibleChange,
	handleOk,
	handleCancel,
	modalLoading,
	onModalLoadingChange
} = useHzModal(submitForm, onCancel)
const onSelect = (value) => {
	store.dispatch('param/setFileDirectorySelectedIdAction', value[0]).then(() => {
		mitt.emit('load-list-file')
	})
}

// 提取第一层
const getExpandedKeys = (tree) => {
	const keys = []
	for (const node of tree) {
		keys.push(node.id)
	}
	return keys
}
const spinning = ref(false)
const loadTreeData = async () => {
	try {
		spinning.value = true
		const res = await getCatalogueTree({ name: searchValue.value })
		if (isResponseSuccess(res) && Array.isArray(res?.data?.data)) {
			spinning.value = false
			const rawTree = res.data.data
			if (isEmpty(rawTree)) return
			treeData.value = recursive(rawTree)
			expandedKeys.value = getExpandedKeys(rawTree)
			isExpanded.value = true
		} else {
			console.warn('🚨 deptTree response invalid:', res)
		}
	} catch (err) {
		console.error('❌ Error loading tree data:', err)
	}
}

const throttledLoadTreeData = throttle(loadTreeData, 600)

const recursive = (arr) => {
	if (isEmpty(arr)) return []
	return arr.map(item => ({
		key: item.id,
		title: item.name,
		value: item.id,
		children: recursive(item.children || [])
	}))
}
const doDelete = async (id) => {
	const res = await deleteCatalogue({ id })
	if (res.status === 200) {
		message.success('删除成功')
		await loadTreeData()
	}
}
const curUpdateId = ref(null)
const handleMove = (id, title) => {
	modalTitle.value = '更新分类'
	let parentId = findParentKey(treeData.value, id)
	console.log('output-> parentId::: ❤️‍🔥❤️‍🔥', parentId)
	modalState.value.name = title
	modalState.value.parentId = parentId
	curUpdateId.value = id
	isModalVisible.value = true
}


const editingNodeId = ref(null)
const editingTitle = ref('')

const startRename = (key, title) => {
	editingNodeId.value = key
	editingTitle.value = title
}

const cancelRename = () => {
	editingNodeId.value = null
	editingTitle.value = ''
}

const confirmRename = async (id) => {
	if (!editingTitle.value.trim()) {
		message.warning('分类名称不能为空')
		return
	}
	const res = await updateCatalogue({ id, name: editingTitle.value })
	if (res.status === 200) {
		message.success('重命名成功')
		cancelRename()
		await loadTreeData() // 确保你定义了 loadTreeData 用于刷新树数据
	}
}

function onMenuClick(key, title, value) {
	console.log('output-> {title,key,value}', title, key, value)
	switch (key) {
		case 'rename':
			startRename(value, title)
			console.log('执行重命名逻辑')
			break
		case 'move':
			handleMove(value, title)
			console.log('执行移动逻辑')
			break
		case 'delete':
			doDelete(value)
			console.log('执行删除逻辑')
			break
	}
}

onMounted(async () => {
	await loadTreeData()
})

onBeforeUnmount(() => {
	mitt.off('reset-selectedKeys')
})
</script>

<style lang="scss" scoped>
.file-side-bar {
	width: 100%;
	height: 100%;
	background: rgba(0, 76, 198, 0.3);
	padding: 8px 12px;
	position: relative;

	.header {
		width: 100%;
		display: flex;
		justify-content: space-between;
		margin-bottom: 12px;

		.title {
			font-family: PingFang SC;
			font-weight: 500;
			font-size: 16px;
			line-height: 22px;
			vertical-align: middle;
			color: rgba(255, 255, 255, 1);
		}
	}

	.search-icon {
		cursor: pointer;
	}

	.toggle-icon {
		width: 50px;
		height: 50px;
	}

	.expand-toggle {
		position: absolute;
		right: -23px;
		top: 30%;
		transform: translateY(-30%);
		z-index: 999;
		padding: 4px;
		border-radius: 4px;
		cursor: pointer;
	}

	.tree-box {
		position: relative;
		margin-top: 8px;
		max-height: 610px;
		overflow-y: scroll;

		&::-webkit-scrollbar {
			width: 6px;
		}

		&::-webkit-scrollbar-thumb {
			background: rgba(255, 255, 255, 0.3);
			border-radius: 3px;
		}

		&::-webkit-scrollbar-track {
			background: transparent;
		}

		//// 隐藏滚动条(兼容 Chrome、Edge)
		//&::-webkit-scrollbar {
		//	width: 0;
		//	height: 0;
		//}
		//
		//// Firefox
		//scrollbar-width: none;
		//
		//// IE/Edge 旧版本
		//-ms-overflow-style: none;


	}

	span {
		border: 2px solid var(--light-mono-a500, rgba(15, 34, 67, 0.2));
	}
}

.title-box {
	display: flex;
	justify-content: space-between;

	span {
		font-family: Microsoft YaHei;
		font-weight: 400;
		font-size: 16px;
		line-height: 24px;
		letter-spacing: 0px;
		text-transform: capitalize;
		color: rgba(255, 255, 255, 1);
	}
}

:deep(.ant-tree .ant-tree-node-selected) {
	background: transparent !important;
}

:deep(.ant-tree-treenode-selected) {
	background: linear-gradient(90deg, rgba(61, 133, 255, 0.6) 0%, rgba(61, 133, 255, 0.382973) 36.17%, rgba(61, 133, 255, 0.144) 100%);

	:deep(.ant-tree-treenode-selected::after) {
		content: "";
		position: absolute;
		left: 0;
		top: 50%;
		transform: translateY(-50%);
		width: 4px;
		height: 28px;
		background: rgba(1, 150, 255, 1);
		border-radius: 2px;
		z-index: 1;
	}
}

:deep(.ant-tree.ant-tree-directory .ant-tree-treenode-selected::before) {
	background: linear-gradient(90deg, #146BFD 0%, rgba(20, 107, 253, 0.638288) 36.17%, rgba(20, 107, 253, 0.2) 100%) !important;
}

:deep(.ant-tree-list-holder),
:deep(.ant-tree) {
	background-color: transparent !important;
	color: #fff;
}

:deep(.ant-input-affix-wrapper),
:deep(.ant-input) {
	border: 1px solid rgba(204, 219, 255, 0.24);
	background-color: #103b6c;
	color: rgba(255, 255, 255, 0.48) !important;
}

:deep(.ant-input::placeholder) {
	color: rgba(255, 255, 255, 0.48) !important;
}

:deep(.ant-input-affix-wrapper) {
	border: 1px solid rgba(0, 117, 255, 1) !important;
}

:deep(.anticon) {
	color: rgba(71, 179, 255, 1);
}

.modal-input {
	background: #FFFFFF;
	color: #000 !important;
}

:deep(.ant-input::placeholder) {
	color: grey !important;
}

.single-text {
	width: 140px;
	overflow: hidden; //超出的文本隐藏
	text-overflow: ellipsis; //用省略号显示
	white-space: nowrap; //不换行(文字不允许换行,单行文本)
}

.tree-title {
	min-width: 210px;
	display: flex;
	align-items: center;
}

.rename-input {
	width: 120px;
	margin-right: 4px;
}

.rename-icon {
	cursor: pointer;
	margin-left: 8px;
}

.confirm-icon {
	color: lightgreen;
}

.cancel-icon {
	color: #fff;
}
</style>

posted @ 2025-07-03 21:29  Felix_Openmind  阅读(20)  评论(0)    收藏  举报
*{cursor: url(https://files-cdn.cnblogs.com/files/morango/fish-cursor.ico),auto;}