<template>
|
<el-card class="archive-management-card">
|
<div class="left">
|
<div class="left-content">
|
<div class="tree-header">
|
<h3>文档管理</h3>
|
<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"
|
@input="handleFilter"
|
>
|
<template #prefix>
|
<el-icon>
|
<Search/>
|
</el-icon>
|
</template>
|
</el-input>
|
</div>
|
|
<div class="tree-container">
|
<el-tree
|
ref="treeRef"
|
:data="treeData"
|
:default-expand-all="false"
|
:expand-on-click-node="false"
|
:filter-node-method="filterNode"
|
:props="props"
|
class="custom-tree"
|
node-key="id"
|
@node-click="handleNodeClick"
|
>
|
<template #default="{ node, data }">
|
<div class="tree-node-content" @dblclick="headerDbClick(node,data)">
|
<div class="node-icon">
|
<el-icon
|
v-if="!node.isLeaf"
|
:class="{ expanded: node.expanded }"
|
>
|
<Folder/>
|
</el-icon>
|
<el-icon v-else>
|
<Document/>
|
</el-icon>
|
</div>
|
|
<div class="node-label">
|
<span v-if="!data.isEdit" class="label-text">{{
|
node.label
|
}}</span>
|
<el-input
|
v-else
|
:ref="(el) => setInputRef(el, data)"
|
v-model="newName"
|
autofocus
|
class="tree-input"
|
placeholder="请输入节点名称"
|
size="small"
|
@blur="(event) => handleInputBlur(event, data, node)"
|
@keyup.enter="
|
(event) => handleInputBlur(event, data, node)
|
"
|
/>
|
</div>
|
<div v-show="!data.isEdit" class="node-actions">
|
<el-button
|
icon="Plus"
|
link
|
size="small"
|
title="新增子节点"
|
@click.stop="append(data)"
|
></el-button>
|
<el-button
|
icon="Delete"
|
link
|
size="small"
|
title="删除"
|
@click.stop="remove(node, data)"
|
></el-button>
|
</div>
|
</div>
|
</template>
|
</el-tree>
|
</div>
|
</div>
|
</div>
|
<div class="right">
|
<el-row :gutter="24">
|
<el-col :offset="20" :span="2"
|
>
|
<el-button :icon="Delete" type="danger" @click="delHandler">删除</el-button>
|
</el-col
|
>
|
<el-col :span="2"
|
>
|
<el-button
|
:disabled="!tableSwitch"
|
:icon="Plus"
|
type="primary"
|
@click="add"
|
>新增
|
</el-button
|
>
|
</el-col
|
>
|
</el-row>
|
<ETable
|
:border="true"
|
:columns="columns"
|
:loading="loading"
|
:maxHeight="1200"
|
:show-selection="true"
|
:table-data="tableData"
|
@edit="handleEdit"
|
@selection-change="handleSelectionChange"
|
>
|
</ETable>
|
<Pagination
|
:layout="'total, prev, pager, next, jumper'"
|
:limit="queryParams.pageSize"
|
:page="queryParams.current"
|
:show-total="true"
|
:total="total"
|
@pagination="handlePageChange"
|
></Pagination>
|
</div>
|
<archiveDialog
|
ref="archiveDialogs"
|
v-model:centerDialogVisible="dialogVisible"
|
:row="row"
|
@centerDialogVisible="centerDialogVisible"
|
@submitForm="submitForm"
|
|
>
|
</archiveDialog>
|
</el-card>
|
</template>
|
<script setup>
|
import {nextTick, onMounted, reactive, ref} from "vue";
|
import ETable from "@/components/Table/ETable.vue";
|
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,} from "@element-plus/icons-vue";
|
import {addOrEditTree, delArchive, delTree, getArchiveList, getTree,} from "@/api/archiveManagement";
|
|
// ===== 响应式状态管理 =====
|
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 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 queryParams = reactive({
|
searchAll: "",
|
current: 1,
|
pageSize: 10,
|
treeId: null,
|
});
|
|
const props = {
|
label: "name",
|
children: "children",
|
isLeaf: "leaf",
|
};
|
|
// ===== 工具函数 =====
|
const handleError = (error, defaultMsg = "操作失败,请稍后重试") => {
|
console.error(error);
|
ElMessage.error(defaultMsg);
|
};
|
|
const showSuccess = (msg = "操作成功") => {
|
ElMessage.success(msg);
|
};
|
|
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 filterNode = (value, data) => {
|
if (!value) return true;
|
return data.name?.toLowerCase().includes(value.toLowerCase());
|
};
|
|
const centerDialogVisible = (val) => {
|
dialogVisible.value = val;
|
};
|
// ===== 数据获取函数 =====
|
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;
|
const res = await getArchiveList({
|
treeId: queryParams.treeId,
|
current: queryParams.current,
|
size: queryParams.pageSize,
|
});
|
|
if (res.code !== 200) {
|
ElMessage.error("获取数据失败: " + res.message);
|
tableData.value = [];
|
total.value = 0;
|
return;
|
}
|
|
tableData.value = res.data?.records || res.data || [];
|
total.value = res.data?.total || 0;
|
|
if (res.data?.current) {
|
queryParams.current = res.data.current;
|
}
|
} catch (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 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.error("删除失败: " + msg);
|
return;
|
}
|
|
showSuccess("删除成功");
|
await getArchiveListData();
|
selectedRows.splice(0, selectedRows.length);
|
|
} catch (error) {
|
if (error !== 'cancel') {
|
handleError(error, "删除操作失败");
|
}
|
}
|
};
|
|
const remove = async (node, data) => {
|
if (!data?.id) {
|
ElMessage.warning("无法删除此节点");
|
return;
|
}
|
|
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, "删除节点失败");
|
}
|
}
|
};
|
// ===== 树节点编辑函数 =====
|
const setInputRef = (el, data) => {
|
if (el) {
|
inputRefs.value.set(data.id || data, el);
|
}
|
};
|
|
const headerDbClick = (node, data) => {
|
data.isEdit = true;
|
newName.value = data.name;
|
nextTick(() => {
|
const inputEl = inputRefs.value.get(data.id || data);
|
if (inputEl) {
|
inputEl.focus();
|
inputEl.select();
|
}
|
});
|
};
|
|
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 (comeTreeData.name === newValue) return;
|
|
if (!newValue) {
|
newName.value = comeTreeData.name || "新节点";
|
ElMessage.warning("节点名称不能为空");
|
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;
|
}
|
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);
|
}
|
}
|
});
|
} else {
|
comeTreeData.name = comeTreeData.name || "新节点";
|
ElMessage.error("保存失败: " + (result.msg || "未知错误"));
|
}
|
|
} catch (error) {
|
handleError(error, "保存节点失败");
|
comeTreeData.name = comeTreeData.name || "新节点";
|
}
|
};
|
|
// ===== 节点新增函数 =====
|
const createNewNode = (name, isEdit = true) => ({
|
name,
|
isEdit,
|
});
|
|
const focusInput = (nodeData, delay = 50) => {
|
setTimeout(() => {
|
const inputEl = inputRefs.value.get(nodeData.id || nodeData);
|
if (inputEl) {
|
inputEl.focus();
|
inputEl.select();
|
inputEl.$el?.scrollIntoView?.({
|
behavior: "smooth",
|
block: "nearest",
|
});
|
}
|
}, delay);
|
};
|
|
const append = async (data) => {
|
if (data === "") {
|
// 新增根节点
|
const newNode = createNewNode("新节点");
|
treeData.value.push(newNode);
|
newName.value = "新节点";
|
|
nextTick(() => focusInput(newNode));
|
} else {
|
const hasChildren = data.children;
|
const nodeKey = data.id || data;
|
const node = treeRef.value?.getNode(nodeKey);
|
const isExpanded = node?.expanded;
|
|
// 如果有子级且未展开,先展开节点
|
if (hasChildren && !isExpanded && treeRef.value?.store?.nodesMap[nodeKey]) {
|
treeRef.value.store.nodesMap[nodeKey].expanded = true;
|
}
|
|
const newNode = createNewNode("新子节点");
|
|
if (!data.children) {
|
data.children = [];
|
}
|
data.children.push(newNode);
|
newName.value = "新子节点";
|
|
const delay = hasChildren && !isExpanded ? 200 : 50;
|
nextTick(() => focusInput(newNode, delay));
|
}
|
};
|
|
// ===== 生命周期 =====
|
onMounted(getList);
|
</script>
|
<style lang="scss" scoped>
|
.custom-tree-node {
|
flex: 1;
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
font-size: 14px;
|
padding-right: 8px;
|
}
|
|
// 树形菜单样式
|
.tree-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 16px;
|
padding-bottom: 12px;
|
border-bottom: 1px solid #e4e7ed;
|
|
h3 {
|
margin: 0;
|
font-size: 16px;
|
font-weight: 600;
|
color: #303133;
|
}
|
}
|
|
.search-box {
|
margin-bottom: 16px;
|
|
.el-input {
|
border-radius: 6px;
|
|
:deep(.el-input__wrapper) {
|
border-radius: 6px;
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
&:hover {
|
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.15);
|
}
|
|
&.is-focus {
|
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
|
}
|
}
|
}
|
}
|
|
.tree-container {
|
flex: 1;
|
overflow-y: auto;
|
border: 1px solid #dcdfe6;
|
border-radius: 8px;
|
background: #fff;
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
.custom-tree {
|
padding: 8px;
|
background: transparent;
|
|
:deep(.el-tree-node) {
|
.el-tree-node__content {
|
height: 36px;
|
padding: 0 8px;
|
border-radius: 6px;
|
margin: 2px 0;
|
transition: all 0.2s ease;
|
|
&:hover {
|
background-color: #f0f9ff;
|
}
|
|
&.is-current {
|
background-color: #e6f7ff;
|
border: 1px solid #91d5ff;
|
}
|
}
|
|
.el-tree-node__expand-icon {
|
color: #606266;
|
font-size: 14px;
|
padding: 6px;
|
|
&.expanded {
|
transform: rotate(90deg);
|
}
|
|
&.is-leaf {
|
color: transparent;
|
}
|
}
|
}
|
}
|
}
|
|
.tree-node-content {
|
display: flex;
|
align-items: center;
|
width: 100%;
|
padding: 4px 0;
|
|
.node-icon {
|
margin-right: 8px;
|
color: #faad14;
|
display: flex;
|
align-items: center;
|
|
.el-icon {
|
font-size: 16px;
|
|
&.expanded {
|
color: #1890ff;
|
}
|
}
|
}
|
|
.node-label {
|
flex: 1;
|
min-width: 0;
|
|
.label-text {
|
font-size: 14px;
|
color: #303133;
|
cursor: pointer;
|
display: block;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
|
&:hover {
|
color: #1890ff;
|
}
|
}
|
}
|
|
.node-actions {
|
opacity: 0;
|
transition: opacity 0.2s ease;
|
display: flex;
|
|
.el-button {
|
padding: 4px;
|
margin-left: 4px;
|
color: #909399;
|
min-height: auto;
|
|
&:hover {
|
color: #1890ff;
|
background-color: #f0f9ff;
|
}
|
|
&.el-button--text:hover {
|
background-color: #f0f9ff;
|
}
|
}
|
}
|
|
&:hover .node-actions {
|
opacity: 1;
|
}
|
}
|
|
// 输入框样式美化
|
.tree-input {
|
flex: 1;
|
|
:deep(.el-input__wrapper) {
|
border-radius: 4px;
|
border: 1px solid #d9d9d9;
|
transition: all 0.2s ease;
|
|
&:hover {
|
border-color: #40a9ff;
|
}
|
|
&.is-focus {
|
border-color: #1890ff;
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
}
|
}
|
|
:deep(.el-input__inner) {
|
padding: 4px 8px;
|
font-size: 14px;
|
color: #303133;
|
|
&::placeholder {
|
color: #bfbfbf;
|
}
|
}
|
}
|
|
.el-card {
|
width: calc(100% - 40px);
|
height: calc(100vh - 130px);
|
margin: 20px;
|
box-sizing: border-box;
|
|
.left {
|
width: 30%;
|
height: calc(100vh - 160px);
|
background-color: #fafafa;
|
padding: 16px;
|
float: left;
|
box-sizing: border-box;
|
border-radius: 8px;
|
|
.left-content {
|
width: 100%;
|
height: 100%;
|
display: flex;
|
flex-direction: column;
|
}
|
}
|
|
.right {
|
width: 70%;
|
height: calc(100vh - 160px);
|
padding: 0 10px;
|
float: left;
|
}
|
}
|
|
.archive-management-card {
|
margin: 0;
|
}
|
</style>
|