| | |
| | | <div class="left-content"> |
| | | <div class="tree-header"> |
| | | <h3>文档管理</h3> |
| | | <el-button type="primary" size="small" @click="append('')" icon="Plus" |
| | | >新增</el-button |
| | | > |
| | | <el-button icon="Plus" size="small" type="primary" @click="append('')" |
| | | >新增 |
| | | </el-button> |
| | | </div> |
| | | |
| | | <!-- 搜索框 --> |
| | | <div class="search-box"> |
| | | <el-input |
| | | v-model="filterText" |
| | | clearable |
| | | placeholder="输入关键字进行搜索" |
| | | size="small" |
| | | clearable |
| | | @input="handleFilter" |
| | | > |
| | | <template #prefix> |
| | | <el-icon><Search /></el-icon> |
| | | <el-icon> |
| | | <Search /> |
| | | </el-icon> |
| | | </template> |
| | | </el-input> |
| | | </div> |
| | |
| | | <el-tree |
| | | ref="treeRef" |
| | | :data="treeData" |
| | | :props="props" |
| | | :filter-node-method="filterNode" |
| | | :expand-on-click-node="false" |
| | | :default-expand-all="false" |
| | | :expand-on-click-node="false" |
| | | :filter-node-method="filterNode" |
| | | :props="props" |
| | | :lazy="false" |
| | | :load="undefined" |
| | | :render-after-expand="true" |
| | | :auto-expand-parent="true" |
| | | :indent="20" |
| | | class="custom-tree" |
| | | node-key="id" |
| | | @node-click="handleNodeClick" |
| | | class="custom-tree" |
| | | @node-expand="handleNodeExpand" |
| | | > |
| | | <template #default="{ node, data }"> |
| | | <div class="tree-node-content" @dblclick="headerDbClick(data)"> |
| | | <div |
| | | class="tree-node-content" |
| | | :data-temp-id="data._tempId" |
| | | :data-node-id="data.id" |
| | | @dblclick="headerDbClick(node, data)" |
| | | > |
| | | <div class="node-icon"> |
| | | <el-icon |
| | | v-if="!node.isLeaf" |
| | |
| | | }}</span> |
| | | <el-input |
| | | v-else |
| | | :ref="(el) => setInputRef(el, data)" |
| | | placeholder="请输入节点名称" |
| | | :ref="(el) => setInputRef(data.id || data._tempId, el)" |
| | | v-model="newName" |
| | | @blur="($event) => handleInputBlur($event, data, node)" |
| | | @keyup.enter=" |
| | | ($event) => handleInputBlur($event, data, node) |
| | | " |
| | | size="small" |
| | | class="tree-input" |
| | | autofocus |
| | | class="tree-input" |
| | | placeholder="请输入节点名称" |
| | | size="small" |
| | | @blur="(event) => handleInputBlur(event, data, node)" |
| | | @keyup.enter="(event) => handleInputBlur(event, data, node)" |
| | | @keyup.esc="() => cancelEdit(data, node)" |
| | | /> |
| | | </div> |
| | | <div class="node-actions" v-show="!data.isEdit"> |
| | | <div v-show="!data.isEdit" class="node-actions"> |
| | | <el-button |
| | | icon="Plus" |
| | | link |
| | | size="small" |
| | | :title="getNodeDepth(data) >= 7 ? '已达到最大嵌套层级(7层)' : '新增子节点'" |
| | | :disabled="getNodeDepth(data) >= 7" |
| | | @click.stop="append(data)" |
| | | icon="Plus" |
| | | title="新增子节点" |
| | | ></el-button> |
| | | <el-button |
| | | icon="Delete" |
| | | link |
| | | size="small" |
| | | @click.stop="remove(node, data)" |
| | | icon="Delete" |
| | | title="删除" |
| | | @click.stop="remove(node, data)" |
| | | ></el-button> |
| | | </div> |
| | | </div> |
| | |
| | | </div> |
| | | <div class="right"> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="2" :offset="20" |
| | | ><el-button :icon="Delete" type="danger" @click="delHandler">删除</el-button></el-col |
| | | > |
| | | <el-col :span="2" |
| | | ><el-button |
| | | <el-col :span="10"> |
| | | <div> |
| | | <el-input |
| | | style="float: left; width: 50%" |
| | | v-model="searchText" |
| | | placeholder="请输入关键字查询文件" |
| | | clearable |
| | | @input="handleSearch" |
| | | @clear="handleSearch" |
| | | > |
| | | <template #prefix> |
| | | <el-icon> |
| | | <Search /> |
| | | </el-icon> |
| | | </template> |
| | | <template #suffix> |
| | | <el-button @click="handleSearch" link style="border: none"> |
| | | <span>搜索</span> |
| | | </el-button> |
| | | </template> |
| | | </el-input> |
| | | </div> |
| | | </el-col> |
| | | <el-col :offset="8" :span="3"> |
| | | <el-button :icon="Delete" type="danger" @click="delHandler" |
| | | >删除</el-button |
| | | > |
| | | </el-col> |
| | | <el-col :span="3"> |
| | | <el-button |
| | | :disabled="!tableSwitch" |
| | | :icon="Plus" |
| | | type="primary" |
| | | @click="add" |
| | | :disabled="!tableData.length" |
| | | >新增</el-button |
| | | ></el-col |
| | | > |
| | | >新增 |
| | | </el-button> |
| | | </el-col> |
| | | </el-row> |
| | | <ETable |
| | | :maxHeight="1200" |
| | | :loading="loading" |
| | | :table-data="tableData" |
| | | :columns="columns" |
| | | @selection-change="handleSelectionChange" |
| | | @edit="handleEdit" |
| | | :show-selection="true" |
| | | :border="true" |
| | | :columns="columns" |
| | | :loading="loading" |
| | | :maxHeight="1200" |
| | | :show-selection="true" |
| | | :table-data="tableData" |
| | | @edit="handleEdit" |
| | | @selection-change="handleSelectionChange" |
| | | style="height: calc(65vh);" |
| | | > |
| | | </ETable> |
| | | <Pagination |
| | | :total="total" |
| | | :page="queryParams.current" |
| | | :limit="queryParams.pageSize" |
| | | :show-total="true" |
| | | @pagination="handlePageChange" |
| | | :layout="'total, prev, pager, next, jumper'" |
| | | :limit="queryParams.pageSize" |
| | | :page="queryParams.current" |
| | | :show-total="true" |
| | | :total="total" |
| | | @pagination="handlePageChange" |
| | | ></Pagination> |
| | | </div> |
| | | <archiveDialog |
| | | v-model:centerDialogVisible="dialogVisible" |
| | | @centerDialogVisible="centerDialogVisible" |
| | | :row="row" |
| | | @submitForm="submitForm" |
| | | ref="archiveDialogs" |
| | | |
| | | v-model:centerDialogVisible="dialogVisible" |
| | | :row="row" |
| | | @centerDialogVisible="centerDialogVisible" |
| | | @submitForm="submitForm" |
| | | > |
| | | </archiveDialog> |
| | | </el-card> |
| | | </template> |
| | | <script setup> |
| | | import { onMounted, ref, nextTick, reactive } from "vue"; |
| | | import { computed, nextTick, onMounted, reactive, ref } from "vue"; |
| | | import ETable from "@/components/Table/ETable.vue"; |
| | | import { ElButton, ElInput, ElIcon, ElMessage } from "element-plus"; |
| | | import { |
| | | ElButton, |
| | | ElIcon, |
| | | ElInput, |
| | | ElMessage, |
| | | ElMessageBox, |
| | | } from "element-plus"; |
| | | import archiveDialog from "./mould/archiveDialog.vue"; |
| | | import Pagination from "@/components/Pagination/index.vue"; |
| | | import { |
| | | Delete, |
| | | Document, |
| | | Folder, |
| | | Plus, |
| | | Search, |
| | | Folder, |
| | | Document, |
| | | Delete, |
| | | } from "@element-plus/icons-vue"; |
| | | import { |
| | | getTree, |
| | | addOrEditTree, |
| | | delArchive, |
| | | delTree, |
| | | getArchiveList, |
| | | addOrEditArchive, |
| | | delArchive, |
| | | getTree, |
| | | } from "@/api/archiveManagement"; |
| | | const dialogVisible = ref(false); // 控制归档对话框显示 |
| | | |
| | | // ===== 响应式状态管理 ===== |
| | | const searchText = ref(""); |
| | | const dialogVisible = ref(false); |
| | | const loading = ref(false); |
| | | const tableData = ref([]); |
| | | const treeData = ref([]); |
| | | const newName = ref(""); |
| | | const inputRefs = ref(new Map()); // 存储输入框引用 |
| | | const filterText = ref(""); // 搜索关键字 |
| | | const treeRef = ref(); // 树组件引用 |
| | | const total = ref(0); // 总记录数 |
| | | const inputRefs = ref(new Map()); |
| | | const filterText = ref(""); |
| | | const treeRef = ref(); |
| | | const total = ref(0); |
| | | const row = ref({}); |
| | | const selectedRows = reactive([]); |
| | | const rowClickData = ref({}); |
| | | const tableSwitch = ref(false); |
| | | const archiveDialogs = ref(null); |
| | | |
| | | // ===== 配置常量 ===== |
| | | const columns = [ |
| | | { prop: "name", label: "名称", minWidth: 180 }, |
| | | { prop: "type", label: "类型", minWidth: 120 }, |
| | | { prop: "status", label: "状态", minWidth: 100 }, |
| | | ]; |
| | | const selectedRows = reactive([]); // 存储选中行数据 |
| | | const handleSelectionChange = (selection) => { |
| | | selectedRows.splice(0, selectedRows.length, ...selection); |
| | | }; |
| | | |
| | | const queryParams = reactive({ |
| | | searchAll: "", |
| | | current: 1, |
| | | pageSize: 10, // 固定每页10条 |
| | | treeId: null, // 当前树节点ID |
| | | pageSize: 10, |
| | | treeId: null, |
| | | }); |
| | | // 搜索过滤功能 |
| | | |
| | | const props = { |
| | | label: "name", |
| | | children: "children", |
| | | isLeaf: "leaf", |
| | | }; |
| | | |
| | | // ===== 计算属性 ===== |
| | | // 计算总节点数 |
| | | const totalNodeCount = computed(() => { |
| | | const countNodes = (nodes) => { |
| | | let count = 0; |
| | | for (const node of nodes || []) { |
| | | count += 1; |
| | | if (node.children) { |
| | | count += countNodes(node.children); |
| | | } |
| | | } |
| | | return count; |
| | | }; |
| | | return countNodes(treeData.value); |
| | | }); |
| | | |
| | | // 检查是否为大量节点(超过1000个节点时提示性能优化) |
| | | const isLargeTree = computed(() => totalNodeCount.value > 1000); |
| | | |
| | | // 获取节点深度的函数 |
| | | const getNodeDepth = (nodeData) => { |
| | | if (!nodeData || !nodeData.id) return 0; |
| | | |
| | | let depth = 1; |
| | | const node = treeRef.value?.getNode(nodeData.id); |
| | | let parentNode = node?.parent; |
| | | |
| | | while (parentNode && parentNode.data && parentNode.data.id) { |
| | | depth++; |
| | | parentNode = parentNode.parent; |
| | | } |
| | | |
| | | return depth; |
| | | }; |
| | | |
| | | // ===== 工具函数 ===== |
| | | const handleError = (error, defaultMsg = "操作失败,请稍后重试") => { |
| | | console.error(error); |
| | | ElMessage.error(defaultMsg); |
| | | }; |
| | | |
| | | const showSuccess = (msg = "操作成功") => { |
| | | ElMessage.success(msg); |
| | | }; |
| | | |
| | | // 搜索查询函数 |
| | | const handleSearch = () => { |
| | | queryParams.searchAll = searchText.value; |
| | | queryParams.current = 1; // 重置到第一页 |
| | | getArchiveListData(); |
| | | }; |
| | | |
| | | const showConfirm = (message, title = "确认操作") => { |
| | | return ElMessageBox.confirm(message, title, { |
| | | confirmButtonText: "确定", |
| | | cancelButtonText: "取消", |
| | | type: "warning", |
| | | }); |
| | | }; |
| | | |
| | | // ===== 基础功能函数 ===== |
| | | const handleSelectionChange = (selection) => { |
| | | selectedRows.splice(0, selectedRows.length, ...selection); |
| | | }; |
| | | |
| | | const handleFilter = () => { |
| | | treeRef.value?.filter(filterText.value); |
| | | }; |
| | | const row = ref({}); // 当前选中行数据 |
| | | |
| | | const filterNode = (value, data) => { |
| | | if (!value) return true; |
| | | return data.name?.toLowerCase().includes(value.toLowerCase()); |
| | | }; |
| | | const submitForm = async (res) => { |
| | | if (res && res.code === 200) { |
| | | ElMessage.success("操作成功"); |
| | | // 刷新列表数据 |
| | | await getArchiveListData(); |
| | | } else { |
| | | ElMessage.error("操作失败: " + (res?.message || "未知错误")); |
| | | } |
| | | } |
| | | |
| | | const centerDialogVisible = (val) => { |
| | | dialogVisible.value = val; |
| | | }; |
| | | // 处理节点点击 |
| | | const handleNodeClick = async (data) => { |
| | | // 切换节点时重置到第一页 |
| | | queryParams.current = 1; |
| | | queryParams.treeId = data.id; |
| | | getArchiveListData(); |
| | | }; |
| | | const archiveDialogs = ref(null); // 表格组件引用 |
| | | // add |
| | | const add = () => { |
| | | row.value = {}; // 清空行数据,确保是新增模式 |
| | | dialogVisible.value = true; |
| | | newName.value = ""; // 清空输入框 |
| | | archiveDialogs.value.initForm(); // 重置表单 |
| | | }; |
| | | // 处理分页变化 |
| | | const handlePageChange = ({ page }) => { |
| | | queryParams.current = page; |
| | | // pageSize 固定为20,不再从参数中获取 |
| | | getArchiveListData(); |
| | | // ===== 数据获取函数 ===== |
| | | const getList = async () => { |
| | | try { |
| | | const res = await getTree(); |
| | | treeData.value = |
| | | res.code === 200 ? res.data?.records || res.data || [] : []; |
| | | } catch (error) { |
| | | handleError(error, "获取树结构数据失败"); |
| | | treeData.value = []; |
| | | } |
| | | }; |
| | | |
| | | const getArchiveListData = async () => { |
| | | try { |
| | | loading.value = true; |
| | | let res = await getArchiveList({ |
| | | const res = await getArchiveList({ |
| | | treeId: queryParams.treeId, |
| | | current: queryParams.current, |
| | | size: queryParams.pageSize, |
| | | searchAll: queryParams.searchAll, |
| | | }); |
| | | |
| | | if (res.code !== 200) { |
| | |
| | | |
| | | tableData.value = res.data?.records || res.data || []; |
| | | total.value = res.data?.total || 0; |
| | | // 确保分页参数正确更新 |
| | | |
| | | if (res.data?.current) { |
| | | queryParams.current = res.data.current; |
| | | } |
| | | // pageSize 固定为20,不从后端获取 |
| | | |
| | | } catch (error) { |
| | | ElMessage.error("获取数据失败"); |
| | | handleError(error, "获取归档数据失败"); |
| | | tableData.value = []; |
| | | total.value = 0; |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | }; |
| | | |
| | | // ===== 表单提交处理 ===== |
| | | const submitForm = async (res) => { |
| | | try { |
| | | if (res?.code === 200) { |
| | | showSuccess(); |
| | | dialogVisible.value = false; |
| | | await getArchiveListData(); |
| | | } else { |
| | | ElMessage.error("操作失败: " + (res?.message || res?.msg || "未知错误")); |
| | | } |
| | | } catch (error) { |
| | | handleError(error, "提交表单失败"); |
| | | } |
| | | }; |
| | | // ===== 节点操作函数 ===== |
| | | const handleNodeClick = (data) => { |
| | | rowClickData.value = data; |
| | | tableSwitch.value = true; |
| | | queryParams.current = 1; |
| | | queryParams.treeId = data.id; |
| | | getArchiveListData(); |
| | | }; |
| | | |
| | | // 节点展开事件处理 |
| | | const handleNodeExpand = (data, node, instance) => { |
| | | // 展开后稍微延迟,确保子节点渲染完成 |
| | | setTimeout(() => { |
| | | // 如果有新添加的编辑状态节点,聚焦到它 |
| | | if (data.children && data.children.length > 0) { |
| | | const editingChild = data.children.find(child => child.isEdit && child._tempId); |
| | | if (editingChild) { |
| | | focusInput(editingChild._tempId, 200); |
| | | } |
| | | } |
| | | }, 100); |
| | | }; |
| | | |
| | | const handlePageChange = (pagination) => { |
| | | try { |
| | | const { page, limit } = pagination; |
| | | queryParams.current = page; |
| | | if (limit) queryParams.pageSize = limit; |
| | | getArchiveListData(); |
| | | } catch (error) { |
| | | handleError(error, "分页操作失败"); |
| | | } |
| | | }; |
| | | |
| | | // ===== 弹窗操作函数 ===== |
| | | const openDialog = (isEdit = false, rowData = {}) => { |
| | | try { |
| | | row.value = isEdit ? { ...rowData } : {}; |
| | | newName.value = ""; |
| | | dialogVisible.value = true; |
| | | |
| | | nextTick(() => { |
| | | if (archiveDialogs.value) { |
| | | const method = isEdit ? "editForm" : "initForm"; |
| | | if (typeof archiveDialogs.value[method] === "function") { |
| | | archiveDialogs.value[method](isEdit ? rowData : rowClickData.value); |
| | | } |
| | | } |
| | | }); |
| | | } catch (error) { |
| | | handleError(error, `打开${isEdit ? "编辑" : "新增"}界面失败`); |
| | | } |
| | | }; |
| | | |
| | | const add = () => openDialog(false); |
| | | const handleEdit = (rows) => openDialog(true, rows); |
| | | |
| | | // ===== 删除操作函数 ===== |
| | | const delHandler = async () => { |
| | | if (selectedRows.length === 0) { |
| | | ElMessage.warning("请选择要删除的数据"); |
| | | return; |
| | | } |
| | | |
| | | try { |
| | | await showConfirm( |
| | | `确定要删除选中的 ${selectedRows.length} 条记录吗?`, |
| | | "删除确认" |
| | | ); |
| | | |
| | | const ids = selectedRows.map((row) => row.id); |
| | | const { code, msg } = await delArchive(ids); |
| | | |
| | | if (code !== 200) { |
| | | ElMessage.warning("删除失败: " + msg); |
| | | } else { |
| | | ElMessage.success("删除成功"); |
| | | // 删除成功后重新获取数据 |
| | | await getArchiveListData(); |
| | | ElMessage.error("删除失败: " + msg); |
| | | return; |
| | | } |
| | | |
| | | showSuccess("删除成功"); |
| | | await getArchiveListData(); |
| | | selectedRows.splice(0, selectedRows.length); |
| | | } catch (error) { |
| | | ElMessage.error("删除归档失败"); |
| | | } |
| | | }; |
| | | // 双击编辑节点 |
| | | const headerDbClick = (comeTreeData) => { |
| | | comeTreeData.isEdit = true; |
| | | newName.value = comeTreeData.name; |
| | | nextTick(() => { |
| | | const inputEl = inputRefs.value.get(comeTreeData.id || comeTreeData); |
| | | if (inputEl) { |
| | | inputEl.focus(); |
| | | inputEl.select(); |
| | | } |
| | | }); |
| | | }; |
| | | |
| | | // 设置输入框引用的方法 |
| | | const setInputRef = (el, data) => { |
| | | if (el) { |
| | | inputRefs.value.set(data.id || data, el); |
| | | if (data.isEdit) { |
| | | nextTick(() => { |
| | | // el.focus(); |
| | | // el.select(); |
| | | }); |
| | | if (error !== "cancel") { |
| | | handleError(error, "删除操作失败"); |
| | | } |
| | | } |
| | | }; |
| | | |
| | | // 处理输入框失焦 |
| | | const handleInputBlur = async (event, comeTreeData, node) => { |
| | | if (!comeTreeData.isEdit) return; // 如果不是编辑状态,直接返回 |
| | | if (event.relatedTarget && event.relatedTarget.tagName === "BUTTON") { |
| | | return; |
| | | } |
| | | comeTreeData.isEdit = false; |
| | | const newValue = newName.value.trim(); |
| | | if (comeTreeData.name === newValue) { |
| | | return; |
| | | } |
| | | if (newValue === "") { |
| | | newName.value = comeTreeData.name || "新节点"; |
| | | return; |
| | | } |
| | | try { |
| | | comeTreeData.name = newValue; |
| | | // 获取父节点的id - 通过 node 参数更准确地获取 |
| | | let parentId = null; |
| | | if (node && node.parent && node.parent.data) { |
| | | parentId = node.parent.data.id; |
| | | } |
| | | await addOrEditTree({ |
| | | name: newValue, |
| | | parentId: parentId || null, // 如果没有父节点,则为 null |
| | | }); |
| | | } catch (error) { |
| | | comeTreeData.name = comeTreeData.name || "新节点"; |
| | | } |
| | | }; |
| | | |
| | | onMounted(async () => { |
| | | await getList(); |
| | | }); |
| | | const props = { |
| | | label: "name", |
| | | children: "children", // 改为 children 以匹配标准结构 |
| | | isLeaf: "leaf", |
| | | }; |
| | | |
| | | const remove = async (node, data) => { |
| | | if (!data || !data.id) { |
| | | if (!data?.id) { |
| | | ElMessage.warning("无法删除此节点"); |
| | | return; |
| | | } |
| | | let { code, msg } = await delTree([data.id]); |
| | | if (code !== 200) { |
| | | ElMessage.warning("删除失败, " + msg); |
| | | } else { |
| | | ElMessage.success("删除成功"); |
| | | |
| | | try { |
| | | await showConfirm(`确定要删除节点 "${data.name}" 吗?`, "删除确认"); |
| | | |
| | | const { code, msg } = await delTree([data.id]); |
| | | |
| | | if (code !== 200) { |
| | | ElMessage.error("删除失败: " + msg); |
| | | return; |
| | | } |
| | | |
| | | showSuccess("删除成功"); |
| | | await getList(); |
| | | } catch (error) { |
| | | if (error !== "cancel") { |
| | | handleError(error, "删除节点失败"); |
| | | } |
| | | } |
| | | await getList(); |
| | | }; |
| | | // ===== 树节点编辑函数 ===== |
| | | const setInputRef = (key, el) => { |
| | | if (el && key) { |
| | | inputRefs.value.set(key, el); |
| | | } |
| | | }; |
| | | |
| | | const headerDbClick = (node, data) => { |
| | | try { |
| | | data.isEdit = true; |
| | | newName.value = data.name; |
| | | |
| | | nextTick(() => { |
| | | const key = data._tempId || data.id; |
| | | if (key) { |
| | | focusInput(key, 50); |
| | | } |
| | | }); |
| | | } catch (error) { |
| | | console.error('进入编辑模式失败:', error); |
| | | ElMessage.error('进入编辑模式失败'); |
| | | } |
| | | }; |
| | | |
| | | // 取消编辑功能 |
| | | const cancelEdit = (data, node) => { |
| | | try { |
| | | data.isEdit = false; |
| | | |
| | | // 如果是新创建的临时节点,删除它 |
| | | if (data._tempId && !data.id) { |
| | | if (node.parent) { |
| | | const parent = node.parent.data; |
| | | const index = parent.children?.indexOf(data); |
| | | if (index > -1) { |
| | | parent.children.splice(index, 1); |
| | | } |
| | | } else { |
| | | // 根节点 |
| | | const index = treeData.value.indexOf(data); |
| | | if (index > -1) { |
| | | treeData.value.splice(index, 1); |
| | | } |
| | | } |
| | | |
| | | // 清理输入框引用 |
| | | const key = data._tempId; |
| | | if (inputRefs.value.has(key)) { |
| | | inputRefs.value.delete(key); |
| | | } |
| | | } |
| | | |
| | | // 重置名称 |
| | | newName.value = ""; |
| | | } catch (error) { |
| | | console.error('取消编辑失败:', error); |
| | | } |
| | | }; |
| | | |
| | | const expandParentNodes = (node) => { |
| | | if (node?.parent?.data) { |
| | | node.parent.expanded = true; |
| | | expandParentNodes(node.parent); |
| | | } |
| | | }; |
| | | |
| | | const handleInputBlur = async (event, comeTreeData, node) => { |
| | | try { |
| | | if (!comeTreeData.isEdit || event.relatedTarget?.tagName === "BUTTON") |
| | | return; |
| | | |
| | | comeTreeData.isEdit = false; |
| | | const newValue = newName.value.trim(); |
| | | |
| | | // 如果名称为空,处理空名称情况 |
| | | if (!newValue) { |
| | | // 如果是新创建的临时节点,删除它 |
| | | if (comeTreeData._tempId && !comeTreeData.id) { |
| | | cancelEdit(comeTreeData, node); |
| | | } else { |
| | | // 已存在的节点,恢复原名称 |
| | | newName.value = comeTreeData.name || "新节点"; |
| | | } |
| | | ElMessage.warning("节点名称不能为空"); |
| | | return; |
| | | } |
| | | |
| | | // 如果名称没有改变,直接返回 |
| | | if (comeTreeData.name === newValue) { |
| | | return; |
| | | } |
| | | |
| | | const parentId = node?.parent?.data?.id || null; |
| | | |
| | | const result = await addOrEditTree({ |
| | | name: newValue, |
| | | parentId, |
| | | id: comeTreeData.id || null, |
| | | }); |
| | | |
| | | if (result.code === 200) { |
| | | comeTreeData.name = newValue; |
| | | if (!comeTreeData.id && result.data) { |
| | | comeTreeData.id = result.data.id || result.data; |
| | | // 清理临时ID |
| | | if (comeTreeData._tempId) { |
| | | const tempKey = comeTreeData._tempId; |
| | | delete comeTreeData._tempId; |
| | | |
| | | // 更新引用映射 |
| | | if (inputRefs.value.has(tempKey)) { |
| | | const inputEl = inputRefs.value.get(tempKey); |
| | | inputRefs.value.delete(tempKey); |
| | | if (comeTreeData.id && inputEl) { |
| | | inputRefs.value.set(comeTreeData.id, inputEl); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | showSuccess("保存成功"); |
| | | |
| | | const currentNodeId = comeTreeData.id; |
| | | await getList(); |
| | | |
| | | nextTick(() => { |
| | | if (currentNodeId && treeRef.value) { |
| | | const targetNode = treeRef.value.getNode(currentNodeId); |
| | | if (targetNode) { |
| | | // 展开当前节点 |
| | | targetNode.expanded = true; |
| | | |
| | | // 展开所有父节点 |
| | | expandParentNodes(targetNode); |
| | | |
| | | // 滚动到节点位置 |
| | | setTimeout(() => { |
| | | const nodeElement = document.querySelector(`[data-node-id="${currentNodeId}"]`); |
| | | if (nodeElement) { |
| | | nodeElement.scrollIntoView({ |
| | | behavior: 'smooth', |
| | | block: 'center' |
| | | }); |
| | | } |
| | | }, 300); |
| | | } |
| | | } |
| | | }); |
| | | } else { |
| | | // 保存失败,恢复原名称或删除临时节点 |
| | | if (comeTreeData._tempId && !comeTreeData.id) { |
| | | cancelEdit(comeTreeData, node); |
| | | } else { |
| | | comeTreeData.name = comeTreeData.name || "新节点"; |
| | | } |
| | | ElMessage.error("保存失败: " + (result.msg || "未知错误")); |
| | | } |
| | | } catch (error) { |
| | | handleError(error, "保存节点失败"); |
| | | // 出错时处理临时节点 |
| | | if (comeTreeData._tempId && !comeTreeData.id) { |
| | | cancelEdit(comeTreeData, node); |
| | | } else { |
| | | comeTreeData.name = comeTreeData.name || "新节点"; |
| | | } |
| | | } |
| | | }; |
| | | |
| | | // ===== 节点新增函数 ===== |
| | | const createNewNode = (name, isEdit = true) => ({ |
| | | name, |
| | | isEdit, |
| | | _tempId: Date.now() + Math.random(), // 添加临时ID |
| | | }); |
| | | |
| | | const focusInput = (nodeKey, delay = 100) => { |
| | | setTimeout(() => { |
| | | const inputEl = inputRefs.value.get(nodeKey); |
| | | if (inputEl) { |
| | | try { |
| | | // 先滚动到可视区域 |
| | | inputEl.$el?.scrollIntoView?.({ |
| | | behavior: "smooth", |
| | | block: "center", |
| | | }); |
| | | |
| | | // 聚焦并选中所有文本,确保可以直接编辑 |
| | | setTimeout(() => { |
| | | inputEl.focus(); |
| | | inputEl.select(); |
| | | |
| | | // 确保光标在输入框末尾(如果select失败的话) |
| | | const inputElement = inputEl.ref || inputEl.input || inputEl.$el?.querySelector('input'); |
| | | if (inputElement) { |
| | | inputElement.setSelectionRange(0, inputElement.value.length); |
| | | |
| | | // 确保输入框在视口中央 |
| | | setTimeout(() => { |
| | | inputElement.scrollIntoView({ |
| | | behavior: 'smooth', |
| | | block: 'center' |
| | | }); |
| | | }, 100); |
| | | } |
| | | }, 100); |
| | | } catch (error) { |
| | | console.warn('聚焦输入框失败:', error); |
| | | // 备用方案:直接聚焦 |
| | | try { |
| | | inputEl.focus(); |
| | | // 尝试选中文本 |
| | | setTimeout(() => { |
| | | const inputElement = inputEl.ref || inputEl.input || inputEl.$el?.querySelector('input'); |
| | | if (inputElement) { |
| | | inputElement.select(); |
| | | } |
| | | }, 200); |
| | | } catch (e) { |
| | | console.warn('备用聚焦方案也失败:', e); |
| | | } |
| | | } |
| | | } else { |
| | | console.warn('未找到输入框元素,key:', nodeKey); |
| | | } |
| | | }, delay); |
| | | }; |
| | | |
| | | const append = async (data) => { |
| | | if (data === "") { |
| | | // 新增根节点 |
| | | const newNode = { |
| | | id: Date.now(), |
| | | name: "新节点", |
| | | isEdit: true, |
| | | }; |
| | | treeData.value.push(newNode); |
| | | newName.value = "新节点"; |
| | | |
| | | nextTick(() => { |
| | | const inputEl = inputRefs.value.get(newNode.id || newNode); |
| | | if (inputEl) { |
| | | inputEl.focus(); |
| | | inputEl.select(); |
| | | } |
| | | }); |
| | | } else { |
| | | const hasChildren = data.children && data.children.length > 0; |
| | | const nodeKey = data.id || data; |
| | | const node = treeRef.value?.getNode(nodeKey); |
| | | const isExpanded = node?.expanded; // 如果有子级且未展开,先展开节点 |
| | | if (hasChildren && !isExpanded) { |
| | | if ( |
| | | treeRef.value && |
| | | treeRef.value.store && |
| | | treeRef.value.store.nodesMap[nodeKey] |
| | | ) { |
| | | treeRef.value.store.nodesMap[nodeKey].expanded = true; |
| | | } |
| | | } |
| | | const newNode = { |
| | | id: Date.now(), |
| | | name: "新子节点", |
| | | isEdit: true, |
| | | }; |
| | | |
| | | if (!data.children) { |
| | | data.children = []; |
| | | } |
| | | data.children.push(newNode); |
| | | newName.value = "新子节点"; |
| | | |
| | | // 根据是否需要展开来决定延迟时间 |
| | | const delay = hasChildren && !isExpanded ? 200 : 50; |
| | | |
| | | // 等待DOM更新完成后再聚焦 |
| | | nextTick(() => { |
| | | setTimeout(() => { |
| | | const inputEl = inputRefs.value.get(newNode.id || newNode); |
| | | if (inputEl) { |
| | | inputEl.focus(); |
| | | inputEl.select(); |
| | | |
| | | // 滚动到新增的节点位置 |
| | | inputEl.$el?.scrollIntoView?.({ |
| | | behavior: "smooth", |
| | | block: "nearest", |
| | | }); |
| | | } |
| | | }, delay); |
| | | }); |
| | | } |
| | | }; |
| | | |
| | | const handleEdit = (rows) => { |
| | | row.value = rows; |
| | | dialogVisible.value = true; |
| | | archiveDialogs.value.editForm(rows); // 调用编辑方法 |
| | | }; |
| | | |
| | | // 移除懒加载,直接获取数据 |
| | | const getList = async () => { |
| | | try { |
| | | let res = await getTree(); |
| | | if (res.code === 200) { |
| | | treeData.value = res.data?.records || res.data || []; |
| | | // 检查嵌套层级限制 |
| | | const getNodeDepth = (nodeData, currentDepth = 1) => { |
| | | if (!nodeData || data === "") return 0; // 根节点不算层级 |
| | | |
| | | let depth = currentDepth; |
| | | let current = nodeData; |
| | | |
| | | // 通过树组件获取父节点来计算深度 |
| | | if (current.id) { |
| | | const node = treeRef.value?.getNode(current.id); |
| | | let parentNode = node?.parent; |
| | | |
| | | while (parentNode && parentNode.data && parentNode.data.id) { |
| | | depth++; |
| | | parentNode = parentNode.parent; |
| | | } |
| | | } |
| | | |
| | | return depth; |
| | | }; |
| | | |
| | | const currentDepth = getNodeDepth(data); |
| | | |
| | | // 限制最多7层嵌套 |
| | | if (currentDepth >= 7) { |
| | | ElMessage.warning('最多只能嵌套7层节点,当前已达到最大层级限制'); |
| | | return; |
| | | } |
| | | |
| | | // 在大量节点时提示性能注意事项 |
| | | if (isLargeTree.value && totalNodeCount.value > 2000) { |
| | | const confirmed = await ElMessageBox.confirm( |
| | | `当前树结构包含 ${totalNodeCount.value} 个节点,节点较多可能影响性能。建议考虑分层管理。是否继续添加?`, |
| | | '性能提示', |
| | | { |
| | | confirmButtonText: '继续添加', |
| | | cancelButtonText: '取消', |
| | | type: 'warning', |
| | | } |
| | | ).catch(() => false); |
| | | |
| | | if (!confirmed) return; |
| | | } |
| | | |
| | | if (data === "") { |
| | | // 新增根节点 |
| | | const newNode = createNewNode("新节点"); |
| | | treeData.value.push(newNode); |
| | | newName.value = "新节点"; |
| | | |
| | | await nextTick(); |
| | | focusInput(newNode._tempId, 200); |
| | | } else { |
| | | treeData.value = []; |
| | | const hasChildren = data.children && data.children.length > 0; |
| | | const nodeKey = data.id || data; |
| | | const node = treeRef.value?.getNode(nodeKey); |
| | | |
| | | // 创建新子节点 |
| | | const newNode = createNewNode("新子节点"); |
| | | |
| | | if (!data.children) { |
| | | data.children = []; |
| | | } |
| | | data.children.push(newNode); |
| | | newName.value = "新子节点"; |
| | | |
| | | // 强制更新树结构 |
| | | await nextTick(); |
| | | |
| | | // 确保父节点展开以显示新节点 |
| | | if (node) { |
| | | node.expanded = true; |
| | | |
| | | // 如果是第一次添加子节点,等待展开动画并确保可见 |
| | | if (!hasChildren) { |
| | | await new Promise(resolve => setTimeout(resolve, 300)); |
| | | |
| | | // 展开后滚动到新节点位置 |
| | | setTimeout(() => { |
| | | const newNodeElement = document.querySelector(`[data-temp-id="${newNode._tempId}"]`); |
| | | if (newNodeElement) { |
| | | newNodeElement.scrollIntoView({ |
| | | behavior: 'smooth', |
| | | block: 'center' |
| | | }); |
| | | } |
| | | }, 100); |
| | | } |
| | | |
| | | // 展开所有父节点确保完全可见 |
| | | let parentNode = node.parent; |
| | | while (parentNode && parentNode.data && parentNode.data.id) { |
| | | parentNode.expanded = true; |
| | | parentNode = parentNode.parent; |
| | | } |
| | | } |
| | | |
| | | // 聚焦到新创建的输入框 |
| | | const delay = hasChildren ? 150 : 500; // 如果之前没有子节点,延迟更长时间等展开 |
| | | focusInput(newNode._tempId, delay); |
| | | } |
| | | } catch (error) { |
| | | treeData.value = []; |
| | | console.error('新增节点失败:', error); |
| | | ElMessage.error('新增节点失败,请重试'); |
| | | } |
| | | }; |
| | | |
| | | // ===== 生命周期 ===== |
| | | onMounted(()=>{ |
| | | getList(); |
| | | getArchiveListData(); |
| | | }); |
| | | </script> |
| | | <style scoped lang="scss"> |
| | | <style lang="scss" scoped> |
| | | .custom-tree-node { |
| | | flex: 1; |
| | | display: flex; |
| | |
| | | border-radius: 8px; |
| | | background: #fff; |
| | | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); |
| | | max-height: calc(100vh - 240px); // 限制最大高度,启用滚动 |
| | | |
| | | .custom-tree { |
| | | padding: 8px; |
| | | background: transparent; |
| | | // 使用GPU加速提升滚动性能 |
| | | transform: translateZ(0); |
| | | will-change: scroll-position; |
| | | |
| | | :deep(.el-tree-node) { |
| | | // 减少不必要的重绘 |
| | | contain: layout style; |
| | | |
| | | .el-tree-node__content { |
| | | height: 36px; |
| | | padding: 0 8px; |
| | | border-radius: 6px; |
| | | margin: 2px 0; |
| | | transition: all 0.2s ease; |
| | | // 优化渲染性能 |
| | | will-change: background-color; |
| | | |
| | | &:hover { |
| | | background-color: #f0f9ff; |
| | |
| | | color: #909399; |
| | | min-height: auto; |
| | | |
| | | &:hover { |
| | | &:hover:not(:disabled) { |
| | | color: #1890ff; |
| | | background-color: #f0f9ff; |
| | | } |
| | | |
| | | &.el-button--text:hover { |
| | | &.el-button--text:hover:not(:disabled) { |
| | | background-color: #f0f9ff; |
| | | } |
| | | |
| | | &:disabled { |
| | | color: #c0c4cc; |
| | | cursor: not-allowed; |
| | | background-color: transparent; |
| | | } |
| | | } |
| | | } |
| | |
| | | |
| | | :deep(.el-input__wrapper) { |
| | | border-radius: 4px; |
| | | border: 1px solid #d9d9d9; |
| | | border: 1px solid #40a9ff; |
| | | transition: all 0.2s ease; |
| | | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1); |
| | | |
| | | &:hover { |
| | | border-color: #40a9ff; |
| | | border-color: #1890ff; |
| | | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.15); |
| | | } |
| | | |
| | | &.is-focus { |
| | |
| | | padding: 4px 8px; |
| | | font-size: 14px; |
| | | color: #303133; |
| | | background-color: #fff; |
| | | |
| | | &::placeholder { |
| | | color: #bfbfbf; |
| | | } |
| | | |
| | | &:focus { |
| | | background-color: #f8fcff; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .el-card { |
| | | width: calc(100% - 40px); |
| | | height: calc(100vh - 130px); |
| | | margin: 20px; |
| | | box-sizing: border-box; |
| | | |
| | | .left { |
| | | width: 30%; |
| | | height: calc(100vh - 160px); |
| | |
| | | flex-direction: column; |
| | | } |
| | | } |
| | | |
| | | .right { |
| | | width: 70%; |
| | | height: calc(100vh - 160px); |
| | | padding: 0px 10px; |
| | | padding: 0 10px; |
| | | float: left; |
| | | } |
| | | } |
| | | |
| | | .archive-management-card { |
| | | margin: 0; |
| | | } |