基于Avue Crud组件 二次封装为公共表格组件以及动态配置列显隐

对 Avue Crud 组件的深度二次封装及动态列配置功能详解

在我们的前端项目中,表格是一个非常重要的组件,用于展示各种业务数据。为了提升开发效率和用户体验,我们对 Avue 的 Crud 组件进行了深度的二次封装,并实现了表格列的动态配置功能。本文将详细介绍这一技术实现过程。

为什么需要二次封装

Avue Crud 是一个功能强大的表格组件,提供了丰富的功能,如分页、排序、筛选等。但在实际项目开发中,我们发现直接使用原生组件存在以下问题:

  1. 重复代码多:每个页面都需要配置相似的选项
  2. 功能扩展困难:如需要添加全屏、列设置等通用功能
  3. 样式统一困难:不同页面的表格样式难以保持一致
  4. 业务逻辑耦合:数据请求、分页处理等逻辑分散在各个页面

因此,我们决定对 Avue Crud 进行二次封装,创建一个名为 TnTable 的组件。

TnTable 组件架构设计

我们的 TnTable 组件主要由以下几个部分组成:

1. 主组件文件 (index.vue)

主组件文件是整个封装的核心,它接收外部传入的配置参数,并处理表格的各种交互逻辑。

2. 列设置组件 (tableColumnSet.vue)

这是一个独立的抽屉组件,用于实现表格列的动态配置功能,包括:

  • 列的显示/隐藏
  • 列的固定位置设置(左固定、右固定、不固定)
  • 列的顺序调整(通过拖拽)

3. 分隔线组件 (tnDivider.vue)

用于在列设置抽屉中分隔不同区域的固定列。

核心功能实现

1. 动态列配置功能详解

动态列配置是本次封装的核心功能之一。用户可以根据自己的需求自定义表格列的显示、隐藏和位置。

实现思路

  1. 列状态管理:我们将表格列分为三类:左固定列、右固定列和普通列(中间列)
  2. 可视化配置界面:通过拖拽和按钮操作来调整列的显示状态和位置
  3. 实时预览:用户操作时实时显示调整效果
  4. 配置持久化:将用户配置保存到服务器,下次访问时自动加载

详细实现过程

首先,我们在 tableColumnSet.vue 组件中定义了三个数组来分别存储三类列:

const leftFixed = ref([]);   // 左固定列
const centerFixed = ref([]); // 普通列(中间)
const rightFixed = ref([]);  // 右固定列

 

当用户打开列设置抽屉时,会调用 initDrawer 方法初始化这些数组:

// 初始化
const initDrawer = (columns, tName) => {
  leftFixed.value = [];
  centerFixed.value = [];
  rightFixed.value = [];
  tableName.value = tName;
  cloneDeep(columns || []).forEach(item => {
    if (item.fixed === 'left') {
      leftFixed.value.push(item);
    } else if (item.fixed === 'right') {
      rightFixed.value.push(item);
    } else {
      centerFixed.value.push(item);
    }
  });
  visibleDrawer.value = true;
};

 


用户可以通过以下操作来调整列的配置:

  1. 添加左侧固定列
// 添加左侧固定列
const addLeftHandle = item => {
  item.fixed = 'left';
  leftFixed.value.push(item);
  const index = centerFixed.value.findIndex(el => {
    return el.prop === item.prop;
  });
  if (index > -1) {
    centerFixed.value.splice(index, 1);
  }
};

  

  1. 取消左侧固定列
// 取消左侧固定列
const cancelLeftHandle = item => {
  item.fixed = undefined;
  centerFixed.value.push(item);
  const index = leftFixed.value.findIndex(el => {
    return el.prop === item.prop;
  });
  if (index > -1) {
    leftFixed.value.splice(index, 1);
  }
};
  1. 拖拽排序:通过 vuedraggable 组件实现列的拖拽排序功能

列配置持久化

用户对列的配置会被保存到服务器,下次访问时会自动加载用户的个性化设置:

// 保存列设置
const saveColumn = (columns, tableName) => {
  let colInfo = columns.map(item => ({
    prop: item.prop,
    visible: item.visible,
    fixed: item.fixed,
    hide: !item.visible,
    width: item.width,
    minWidth: item.minWidth,
  }));
  
  // 参数校验
  if (!tableName?.length) return ElMessage.warning('请联系系统管理员补充表格配置参数');
  if (colInfo.filter(item => item.visible).length === 0) return ElMessage.warning('表格配置展示列不能低于一列');
  
  // 根据操作类型调用不同API
  let operateMethod, params;
  if (props.displayColParams.method === 'save') {
    operateMethod = displayColumnDetailSave;
    params = {
      bizCode: tableName,
      displayColumn: JSON.stringify(colInfo),
    };
  } else if (props.displayColParams.method === 'update') {
    operateMethod = displayColumnDetailUpdate;
    params = {
      id: props.displayColParams.id,
      bizCode: tableName,
      displayColumn: JSON.stringify(colInfo),
      version: props.displayColParams.version,
    };
  }
  
  // 发送请求并处理结果
  operateMethod &&
    operateMethod(params).then(res => {
      if (res.data.success) {
        ElMessage.success('操作成功');
        handleCancel();
        emits('updateColumn', columns);
        emits('getDisplayCols');
      }
    });
};

 

2. 主组件中的列配置处理

在主组件 index.vue 中,我们需要处理列配置的加载和应用:

// 获取显示列配置
const getDisplayCols = () => {
  displayColumnDetail({ bizCode: tableOption.value.name }).then(({ data }) => {
    if (data.success) {
      if (data.data && Object.keys(data.data).length) {
        // 已有配置,更新为修改模式
        displayColParams.value.method = 'update';
        displayColParams.value.version = data?.data?.version;
        displayColParams.value.id = data?.data?.id;
        let displayColumn = JSON.parse(data?.data?.displayColumn || '[]');
        const columns = props.tableColumns;
        const newColumns = [];
        
        // 根据配置更新列属性
        displayColumn.forEach(el => {
          const ele = columns?.find(item => item.prop === el.prop);
          if (ele) {
            ele.fixed = el.fixed || null;
            ele.hide = el.hide || false;
            ele.visible = el.visible || !el.hide;
            ele.width = el.width || null;
            ele.minWidth = el.minWidth || null;
            newColumns.push(ele);
          }
        });
        
        tableOption.value.column = newColumns;
        updateColumn();
      } else {
        // 无配置,使用默认配置并切换到保存模式
        tableOption.value.column = props.tableColumns;
        updateColumn();
        displayColParams.value.method = 'save';
      }
    }
  });
};

 

3. 响应式设计和用户体验优化

我们还添加了多项用户体验优化功能:

<template>
  <div class="tn-table-operate-btns" v-if="tableOption.needToolbar">
    <el-tooltip effect="dark" :content="getIconText(2)" v-if="tableOption.needShowFullscreenBtn">
      <svg-icon :name="isFullscreen ? 'tuichuquanping' : 'quanping'" size="20" @click="handleFullscreenChange" color="#444444"></svg-icon>
    </el-tooltip>
    <el-tooltip effect="dark" content="表格刷新" v-if="tableOption.needShowRefreshBtn">
      <svg-icon name="lieshuaxin" size="20" @click="handleRefreshTable" color="#444444"></svg-icon>
    </el-tooltip>
    <el-tooltip effect="dark" :content="getIconText(1)" v-if="tableOption.needShowTableModeBtn && !isFullscreen">
      <svg-icon name="newCard" size="20" @click="handleChangeTableMode" color="#444444"></svg-icon>
    </el-tooltip>
    <el-tooltip effect="dark" content="表格列设置" v-if="tableOption.needShowColumnSettingBtn && !isFullscreen">
      <svg-icon name="liepeizhi" size="20" @click="handleTableColumnSetting" color="#444444"></svg-icon>
    </el-tooltip>
  </div>
</template>

 

包括:

  • 全屏显示/退出
  • 表格刷新
  • 卡片/列表模式切换
  • 列设置

4. CRUD 二次封装的其他功能

除了列配置功能,我们的二次封装还包括以下特性:

  1. 统一的数据请求处理
// 获取表格数据
const getTableList = async () => {
  // 执行加载表格数据之前处理的自定义函数
  if (props.beforeLoadTable) await props.beforeLoadTable();
  tableLoading.value = true;
  try {
    const { currentPage, pageSize } = pageInfo.value;
    // 外部查询条件表单
    const _params = props.customParams;
    const {
      data: { data: reqData, success },
    } = tableOption.value.needPagination && props.isGetFn 
      ? await props.tableFn(currentPage, pageSize, _params) 
      : await props.tableFn(_params);
      
    if (success) {
      // customHandleTableData 表示部分场景需要业务层对接口返回数据做数据二次处理
      tableData.value = props.customHandleTableData 
        ? props.customHandleTableData(reqData.records) 
        : reqData.records;
      pageInfo.value.total = tableOption.value.needPagination ? reqData.total : 0;
    }
  } catch (e) {
    tableData.value = [];
    pageInfo.value.total = 0;
  } finally {
    tableLoading.value = false;
  }
};

 

  1. 分页处理
// 分页事件
const currentChange = (currentPage: number) => {
  pageInfo.value.currentPage = currentPage;
  // 部分业务场景需要这个配置,列表只展示数据,但是需要允许分页
  if (!props.initRequest && props.tableDataTotal > 0) {
    emits('currentChange', currentPage);
  }
};

// 每页条数事件
const sizeChange = (pageSize: number) => {
  pageInfo.value.pageSize = pageSize;
  // 部分业务场景需要这个配置,列表只展示数据,但是需要允许分页
  if (!props.initRequest && props.tableDataTotal > 0) {
    emits('sizeChange', pageSize);
  }
};

 

  1. 列宽拖拽调整
// 表头拖拽事件(改变列宽度)
const handleDragendHeader = (newWidth: number, oldWidth: number, column: object) => {
  tableOption.value.column = tableOption.value.column.map((item: any) => {
    if (item.prop === column.property) item.width = newWidth;
    return item;
  });
  // 保存调整后的列宽
  let _newColumnList = cloneDeep(tableOption.value.column);
  // ... 添加序号列和操作列
  _newColumnList = _newColumnList.map(item => {
    return {
      ...item,
      visible: !item.hide,
    };
  });
  tableColumnSetDrawer.value?.saveColumn(_newColumnList, tableOption.value.name);
};

总结

通过对 Avue Crud 组件的深度二次封装,我们实现了以下目标:

  1. 提高开发效率:减少了重复代码,统一了表格使用方式
  2. 增强用户体验:提供了列动态配置、全屏显示等实用功能
  3. 便于维护:将表格相关逻辑集中管理,便于后续功能扩展和bug修复
  4. 个性化定制:用户可以根据自己的需求自定义表格列的显示方式

特别是动态列配置功能,让用户能够根据自己的使用习惯调整表格显示,大大提升了用户体验。通过将配置持久化到服务器,用户在不同设备和浏览器中都能获得一致的个性化体验。

这一封装在我们的项目中得到了广泛应用,显著提升了开发效率和用户体验。未来我们还计划进一步优化,比如增加更多的表格交互功能、支持更多类型的列格式化等。

表格组件源码(index.vue)

<script setup lang="ts">
import { ref, watch, useAttrs, nextTick } from 'vue';
import TableColumnSet from '@/components/tn-table/components/tableColumnSet.vue';
import { cloneDeep } from 'lodash-es';
import { displayColumnDetail } from '@/api/system/displayColumn';

const props = defineProps({
  // 表格列配置
  tableColumns: {
    type: Array,
    default: () => [],
  },
  // 表格额外配置
  tableOption: {
    type: Object,
    default: () => {
      return {
        name: null,
      };
    },
  },
  // 表格数据请求
  tableFn: {
    type: Function,
    default: () => {},
  },
  // 外部搜索条件
  customParams: {
    type: Object,
    default: () => {},
  },
  // 是否初始化请求
  initRequest: {
    type: Boolean,
    default: true,
  },
  // 表格绑定数据
  tableData: {
    type: Array,
    default: () => [],
  },
  //  表格数据处理
  customHandleTableData: {
    type: Function,
    default: null,
  },
  // 加载表格数据之前处理的自定义函数
  beforeLoadTable: {
    type: Function,
    default: null,
  },
  // 表格列显隐组件宽度
  drawerSize: {
    type: String,
    default: '20%',
  },
  // 表格数据总数(针对手动绑定数据且需要展示分页场景)
  tableDataTotal: {
    type: Number,
    default: 0,
  },
  // 是否GET请求,部分业务场景使用POST请求
  isGetFn: {
    type: Boolean,
    default: true,
  },
});
interface PageInstance {
  currentPage: number;
  pageSize: number;
  total?: number;
}

const emits = defineEmits(['currentChange', 'sizeChange', 'refreshChange', 'expandChange']);

const attrs = useAttrs();

const tableColumnSetDrawer = ref(null); //表格列配置组件
const crudTable = ref(null); // 表格组件
const tableLoading = ref<boolean>(false); // 表格加载
const tableData = ref<any>([]); // 表格数据
const pageInfo = ref<PageInstance>({
  currentPage: 1,
  pageSize: 10,
  total: 0,
}); // 分页信息
const tableOption = ref({
  tip: false,
  search: false,
  showHeader: true,
  header: false,
  border: true,
  index: true,
  indexLabel: '序号',
  indexWidth: 60,
  addBtn: false,
  editBtn: false,
  delBtn: false,
  menu: true,
  menuWidth: 150,
  menuAlign: 'left',
  height: 'auto',
  calcHeight: 60,
  dialogClickModal: false,
  grid: false,
  needPagination: true, // 是否需要分页
  needToolbar: true, // 是否需要工具栏
  needShowRefreshBtn: true, // 是否显示刷新按钮
  needShowColumnSettingBtn: true, // 是否显示列设置
  needShowFullscreenBtn: true, // 是否显示全屏设置按钮
  needShowTableModeBtn: true, //  是否显示表格模式切换按钮
  ...props.tableOption,
  column: props.tableColumns,
}); // 表格配置
const isFullscreen = ref(false); // 是否全屏
const displayColParams = ref({}); //  列显隐参数
const originalTableColumns = ref([]);

watch(
  () => props.tableColumns,
  () => {
    // 监听列配置,自动补充最小宽度属性以适应展示
    tableOption.value.column = (props.tableColumns || []).map(item => {
      let _newItemInfo = item;
      if (!item.width && !item.minWidth) _newItemInfo.minWidth = 150;
      return _newItemInfo;
    });
    // 记录页面配置基础固定表格列配置
    originalTableColumns.value = cloneDeep(tableOption.value.column);
  },
  { immediate: true, deep: true }
);
watch(
  () => props.tableOption?.name,
  newVal => {
    if (newVal) {
      tableOption.value.name = newVal;
      // 如果有其他需要重新执行的逻辑
      nextTick(() => {
        getDisplayCols();
      });
    }
  },
  { immediate: true }
);
// 表头拖拽事件(改变列宽度)
const handleDragendHeader = (newWidth: number, oldWidth: number, column: object) => {
  tableOption.value.column = tableOption.value.column.map((item: any) => {
    if (item.prop === column.property) item.width = newWidth;
    return item;
  });
  let _newColumnList = cloneDeep(tableOption.value.column);
  if (tableOption.value.index) {
    _newColumnList.unshift({
      width: tableOption.value.indexWidth,
      label: tableOption.value.indexLabel,
      prop: 'rowNo',
      fixed: 'left',
    });
  }
  if (tableOption.value.menu) {
    _newColumnList.push({
      prop: 'edit',
      label: '操作',
      width: tableOption.value.menuWidth,
      fixed: 'right',
    });
  }
  _newColumnList = _newColumnList.map(item => {
    return {
      ...item,
      visible: !item.hide,
    };
  });
  tableColumnSetDrawer.value?.saveColumn(_newColumnList, tableOption.value.name);
};
//  分页事件
const currentChange = (currentPage: number) => {
  pageInfo.value.currentPage = currentPage;
  // 部分业务场景需要这个配置,列表只展示数据,但是需要允许分页
  if (!props.initRequest && props.tableDataTotal > 0) {
    emits('currentChange', currentPage);
  }
};
// 每页条数事件
const sizeChange = (pageSize: number) => {
  pageInfo.value.pageSize = pageSize;
  // 部分业务场景需要这个配置,列表只展示数据,但是需要允许分页
  if (!props.initRequest && props.tableDataTotal > 0) {
    emits('sizeChange', pageSize);
  }
};
// 获取表格数据
const getTableList = async () => {
  // 执行加载表格数据之前处理的自定义函数
  if (props.beforeLoadTable) await props.beforeLoadTable();
  tableLoading.value = true;
  try {
    const { currentPage, pageSize } = pageInfo.value;
    // 外部查询条件表单
    const _params = props.customParams;
    const {
      data: { data: reqData, success },
    } = tableOption.value.needPagination && props.isGetFn ? await props.tableFn(currentPage, pageSize, _params) : await props.tableFn(_params);
    if (success) {
      // customHandleTableData 表示部分场景需要业务层对接口返回数据做数据二次处理
      tableData.value = props.customHandleTableData ? props.customHandleTableData(reqData.records) : reqData.records;
      pageInfo.value.total = tableOption.value.needPagination ? reqData.total : 0;
    }
  } catch (e) {
    tableData.value = [];
    pageInfo.value.total = 0;
  } finally {
    tableLoading.value = false;
  }
};
// 表格加载事件
const tableOnload = async () => {
  if (props.initRequest) {
    await getTableList();
  } else {
    updateTableData();
  }
};

// 手动赋值修改表格绑定数据
const updateTableData = () => {
  tableData.value = props.tableData;
  // 部分业务场景需要这个配置
  if (props.tableDataTotal > 0) pageInfo.value.total = props.tableDataTotal;
};
// 表格刷新
const handleRefreshTable = (isResetPageInfo = true) => {
  if (props.initRequest) {
    if (isResetPageInfo) pageInfo.value.currentPage = 1;
    getTableList();
  } else {
    if (props.tableDataTotal > 0) emits('refreshChange');
  }
};
// 获取图标文字
const getIconText = type => {
  if (type === 1) return `${tableOption.value.grid ? '列表' : '卡片'}模式`;
  if (type === 2) return `${isFullscreen.value ? '退出全屏' : '全屏'}模式`;
};
// 切换表格模式
const handleChangeTableMode = () => {
  tableOption.value.grid = !tableOption.value.grid;
};
// 全屏切换
const handleFullscreenChange = async () => {
  if (!isFullscreen.value) {
    isFullscreen.value = true;
    tableOption.value.height = '100%';
  } else {
    isFullscreen.value = false;
    tableOption.value.height = 'auto';
  }
};
// 表格列设置
const handleTableColumnSetting = () => {
  let _newColumnList = cloneDeep(tableOption.value.column);
  _newColumnList = _newColumnList.map(item => {
    return {
      ...item,
      visible: !item.hide,
    };
  });
  tableColumnSetDrawer.value?.initDrawer(_newColumnList, tableOption.value.name);
};

// 列显隐保存之后页面重新加载
const updateColumn = () => {
  crudTable.value?.refreshTable();
};
// 重置列 恢复默认
const restDefault = () => {
  let _newColumnList = cloneDeep(originalTableColumns.value);
  if (tableOption.value.index) {
    _newColumnList.unshift({
      width: tableOption.value.indexWidth,
      label: tableOption.value.indexLabel,
      prop: 'rowNo',
      fixed: 'left',
    });
  }
  if (tableOption.value.menu) {
    _newColumnList.push({
      prop: 'edit',
      label: '操作',
      width: tableOption.value.menuWidth,
      fixed: 'right',
    });
  }
  _newColumnList = _newColumnList.map(item => {
    return {
      ...item,
      visible: !item.hide,
    };
  });
  tableColumnSetDrawer.value?.saveColumn(_newColumnList, tableOption.value.name);
  tableColumnSetDrawer.value?.handleCancel();
};
const getDisplayCols = () => {
  displayColumnDetail({ bizCode: tableOption.value.name }).then(({ data }) => {
    if (data.success) {
      if (data.data && Object.keys(data.data).length) {
        displayColParams.value.method = 'update';
        displayColParams.value.version = data?.data?.version;
        displayColParams.value.id = data?.data?.id;
        let displayColumn = JSON.parse(data?.data?.displayColumn || '[]');
        const columns = props.tableColumns;
        const newColumns: any[] = [];
        displayColumn.forEach(el => {
          const ele = columns?.find(item => item.prop === el.prop);
          if (ele) {
            ele.fixed = el.fixed || null;
            ele.hide = el.hide || false;
            ele.visible = el.visible || !el.hide;
            ele.width = el.width || null;
            ele.minWidth = el.minWidth || null;
            newColumns.push(ele);
          }
        });
        tableOption.value.column = newColumns;
        updateColumn();
      } else {
        tableOption.value.column = props.tableColumns;
        updateColumn();
        displayColParams.value.method = 'save';
      }
    }
  });
};

// 获取表格配置列展示
const getTableColumns = () => {
  return crudTable.value?.columnOption;
};
// 清除选中
const handleClearSelection = () => {
  crudTable.value?.clearSelection();
};
// 修改表格配置属性
const updateTableOption = (updateOption: any) => {
  const _newOption = cloneDeep(updateOption);
  if (_newOption.hasOwnProperty('column')) delete _newOption.column;
  tableOption.value = { ...tableOption.value, ..._newOption };
};

const getValueByDic = (dicData: any[], value: string, props: { label: 'dictValue'; value: 'dictKey' }, prop) => {
  if (value !== null && value !== undefined) {
    return dicData.find(item => value == item[props.value])?.[props.label];
  }
};

const handleExpandChange = (row, expendList) => {
  emits('expandChange', { row, expendList });
};
defineExpose({
  tableData,
  pageInfo,
  crudTable,
  getTableList,
  getTableColumns,
  handleRefreshTable,
  handleClearSelection,
  tableOnload,
  updateColumn,
  updateTableData,
  updateTableOption,
});
</script>

<template>
  <avue-crud
    class="reset-table-style"
    :class="{ 'fullscreen-table': isFullscreen }"
    :option="tableOption"
    :table-loading="tableLoading"
    :data="tableData"
    ref="crudTable"
    v-model:page="pageInfo"
    v-bind="attrs"
    @current-change="currentChange"
    @size-change="sizeChange"
    @on-load="tableOnload"
    @header-dragend="handleDragendHeader"
    @expand-change="handleExpandChange"
  >
    <template #expand="{ row, index }">
      <slot name="expand" v-bind="{ row, index }"></slot>
    </template>
    <template #page="{ row }">
      <div class="tn-table-operate-btns" v-if="tableOption.needToolbar">
        <el-tooltip effect="dark" :content="getIconText(2)" v-if="tableOption.needShowFullscreenBtn">
          <svg-icon :name="isFullscreen ? 'tuichuquanping' : 'quanping'" size="20" @click="handleFullscreenChange" color="#444444"></svg-icon>
        </el-tooltip>
        <el-tooltip effect="dark" content="表格刷新" v-if="tableOption.needShowRefreshBtn">
          <svg-icon name="lieshuaxin" size="20" @click="handleRefreshTable" color="#444444"></svg-icon>
        </el-tooltip>
        <el-tooltip effect="dark" :content="getIconText(1)" v-if="tableOption.needShowTableModeBtn && !isFullscreen">
          <svg-icon name="newCard" size="20" @click="handleChangeTableMode" color="#444444"></svg-icon>
        </el-tooltip>
        <el-tooltip effect="dark" content="表格列设置" v-if="tableOption.needShowColumnSettingBtn && !isFullscreen">
          <svg-icon name="liepeizhi" size="20" @click="handleTableColumnSetting" color="#444444"></svg-icon>
        </el-tooltip>
      </div>
    </template>
    <template #menu="{ row, index, size }">
      <slot name="menu" v-bind="{ row, index, size }"></slot>
    </template>
    <template v-for="col in tableOption.column" #[col.prop]="{ row, index }">
      <slot :name="col.prop" v-bind="{ row, index }">
        <span v-if="col.dicData?.length">{{ getValueByDic(col.dicData, row[col.prop], col.props, col.prop) }}</span>
        <span v-else>{{ row[col.prop] }}</span>
      </slot>
    </template>
    <template v-for="col in tableOption.column" #[`${col.prop}-header`]="{ column }">
      <slot :name="`${col.prop}-header`" v-bind="{ column }">
        <span>{{ column?.label || '' }}</span>
      </slot>
    </template>
  </avue-crud>
  <table-column-set
    ref="tableColumnSetDrawer"
    :drawer-size="drawerSize"
    :displayColParams="displayColParams"
    @updateColumn="updateColumn"
    @restDefault="restDefault"
    @getDisplayCols="getDisplayCols"
  />
</template>

<style scoped lang="scss">
@import 'css/tableStyle';
</style>
View Code

表格列设置源码(table-column-set.vue)

<script setup lang="ts">
import { ref } from 'vue';
import Draggable from 'vuedraggable';
import TnDivider from '@/components/tn-table/components/tnDivider.vue';
import { cloneDeep } from 'lodash-es';
import { ElMessage } from 'element-plus';
import { displayColumnDetailSave, displayColumnDetailUpdate } from '@/api/system/displayColumn';

const tableName = ref('');
const visibleDrawer = ref(false);
const leftFixed = ref<any[]>([]);
const centerFixed = ref<any[]>([]);
const rightFixed = ref<any[]>([]);
const hoverItem = ref();

const emits = defineEmits(['restDefault', 'updateColumn', 'getDisplayCols']);

const props = defineProps({
  displayColParams: { type: Object, default: () => {} },
  drawerSize: {
    type: String,
    default: '20%',
  },
});

// 鼠标悬停
const mouseOverHandle = item => {
  hoverItem.value = item;
};
// 添加左侧固定列
const addLeftHandle = item => {
  item.fixed = 'left';
  leftFixed.value.push(item);
  const index = centerFixed.value.findIndex(el => {
    return el.prop === item.prop;
  });
  if (index > -1) {
    centerFixed.value.splice(index, 1);
  }
};
// 取消左侧固定列
const cancelLeftHandle = item => {
  item.fixed = undefined;
  centerFixed.value.push(item);
  const index = leftFixed.value.findIndex(el => {
    return el.prop === item.prop;
  });
  if (index > -1) {
    leftFixed.value.splice(index, 1);
  }
};
// 添加右侧固定列
const addRightHandle = item => {
  item.fixed = 'right';
  rightFixed.value.splice(0, 0, item);
  const index = centerFixed.value.findIndex(el => {
    return el.prop === item.prop;
  });
  if (index > -1) {
    centerFixed.value.splice(index, 1);
  }
};
// 取消右侧固定列
const cancelRightHandle = item => {
  item.fixed = undefined;
  centerFixed.value.push(item);
  const index = rightFixed.value.findIndex(el => {
    return el.prop === item.prop;
  });
  if (index > -1) {
    rightFixed.value.splice(index, 1);
  }
};
// 初始化
const initDrawer = (columns, tName) => {
  leftFixed.value = [];
  centerFixed.value = [];
  rightFixed.value = [];
  tableName.value = tName;
  cloneDeep(columns || []).forEach(item => {
    if (item.fixed === 'left') {
      leftFixed.value.push(item);
    } else if (item.fixed === 'right') {
      rightFixed.value.push(item);
    } else {
      centerFixed.value.push(item);
    }
  });
  visibleDrawer.value = true;
};
// 取消
const handleCancel = () => {
  visibleDrawer.value = false;
};
// 恢复默认
const handleSetDefault = () => {
  emits('restDefault');
};
// 保存
const handleUpdate = () => {
  const columns = [...leftFixed.value, ...centerFixed.value, ...rightFixed.value];
  saveColumn(columns, tableName.value);
};
// 保存列设置
const saveColumn = (columns, tableName) => {
  let colInfo = columns.map(item => ({
    prop: item.prop,
    visible: item.visible,
    fixed: item.fixed,
    hide: !item.visible,
    width: item.width,
    minWidth: item.minWidth,
  }));
  if (!tableName?.length) return ElMessage.warning('请联系系统管理员补充表格配置参数');
  if (colInfo.filter(item => item.visible).length === 0) return ElMessage.warning('表格配置展示列不能低于一列');
  let operateMethod, params;
  if (props.displayColParams.method === 'save') {
    operateMethod = displayColumnDetailSave;
    params = {
      bizCode: tableName,
      displayColumn: JSON.stringify(colInfo),
    };
  } else if (props.displayColParams.method === 'update') {
    operateMethod = displayColumnDetailUpdate;
    params = {
      id: props.displayColParams.id,
      bizCode: tableName,
      displayColumn: JSON.stringify(colInfo),
      version: props.displayColParams.version,
    };
  }
  operateMethod &&
    operateMethod(params).then(res => {
      if (res.data.success) {
        ElMessage.success('操作成功');
        handleCancel();
        emits('updateColumn', columns);
        emits('getDisplayCols');
      }
    });
};

defineExpose({
  initDrawer,
  saveColumn,
  handleCancel,
});
</script>

<template>
  <el-drawer v-model="visibleDrawer" :show-close="false" :size="drawerSize" class="column-drawer-box">
    <template #header>
      <div class="drawer-title">
        <span>列设置</span>
        <el-tooltip content="“序号”及“操作”首尾标题列默认固定且无法取消及排序">
          <el-icon class="column-info-icon"><QuestionFilled /></el-icon>
        </el-tooltip>
      </div>
    </template>
    <div class="drawer-content">
      <div class="left-fixed-content">
        <Draggable item-key="key" ghost-class="ghost" handle=".drag-widget" :animation="200" :group="{ name: 'center' }" :list="leftFixed">
          <template #item="{ element }">
            <div v-if="element.label" class="item-content" :class="{ actived: hoverItem?.prop === element.prop }" @mouseover="mouseOverHandle(element)">
              <div class="columns-left">
                <i v-if="!['rowNo', 'edit'].includes(element.prop)" class="iconfont icon-tuozhuaipaixu drag-widget"></i>
                <span v-else class="empty-span"></span>
                <el-checkbox v-model="element.visible" :disabled="['rowNo', 'edit'].includes(element.prop)"></el-checkbox>
                <p>{{ element.label }}</p>
              </div>
              <div class="columns-right">
                <div :class="{ disabled: element.prop === 'rowNo' }">
                  <el-tooltip content="取消左侧固定" effect="dark" placement="top-start">
                    <i class="iconfont icon-zuoceguding" @click="cancelLeftHandle(element)"></i>
                  </el-tooltip>
                </div>
              </div>
            </div>
          </template>
        </Draggable>
      </div>
      <tn-divider>以上为左固定标题列</tn-divider>
      <div class="center-fixed-content">
        <Draggable item-key="key" ghost-class="ghost" handle=".drag-widget" :animation="200" :group="{ name: 'center' }" :list="centerFixed">
          <template #item="{ element }">
            <div class="item-content" :class="{ actived: hoverItem?.prop === element.prop }" @mouseover="mouseOverHandle(element)">
              <div class="columns-left">
                <i v-if="!['rowNo', 'edit'].includes(element.prop)" class="iconfont icon-tuozhuaipaixu drag-widget"></i>
                <span v-else class="empty-span"></span>
                <el-checkbox v-model="element.visible" :disabled="['rowNo', 'edit'].includes(element.prop)"></el-checkbox>
                <p>{{ element.label }}</p>
              </div>
              <div v-if="hoverItem?.prop === element.prop" class="columns-right">
                <el-tooltip content="左侧固定" effect="dark" placement="top-start">
                  <i class="iconfont icon-youceguding" @click="addLeftHandle(element)"></i>
                </el-tooltip>
                <el-tooltip content="右侧固定" effect="dark" placement="bottom-start">
                  <i class="iconfont icon-zuoceguding" @click="addRightHandle(element)"></i>
                </el-tooltip>
              </div>
            </div>
          </template>
        </Draggable>
      </div>
      <tn-divider>以下为右固定标题列</tn-divider>
      <div class="right-fixed-content">
        <Draggable item-key="key" ghost-class="ghost" handle=".drag-widget" :animation="200" :group="{ name: 'center' }" :list="rightFixed">
          <template #item="{ element }">
            <div class="item-content" :class="{ actived: hoverItem?.prop === element.prop }" @mouseover="mouseOverHandle(element)">
              <div class="columns-left">
                <i v-if="!['rowNo', 'edit'].includes(element.prop)" class="iconfont icon-tuozhuaipaixu drag-widget"></i>
                <span v-else class="empty-span"></span>
                <el-checkbox v-model="element.visible" :disabled="['rowNo', 'edit'].includes(element.prop)"></el-checkbox>
                <p>{{ element.label }}</p>
              </div>
              <div class="columns-right">
                <div :class="{ disabled: element.prop === 'edit' }">
                  <el-tooltip content="取消右侧固定" effect="dark" placement="top-start">
                    <i class="iconfont icon-zuoceguding" @click="cancelRightHandle(element)"></i>
                  </el-tooltip>
                </div>
              </div>
            </div>
          </template>
        </Draggable>
      </div>
    </div>
    <template #footer>
      <div class="drawer-footer">
        <el-button @click="handleCancel">取消</el-button>
        <el-button type="primary" @click="handleSetDefault">恢复默认</el-button>
        <el-button type="primary" @click="handleUpdate">保存</el-button>
      </div>
    </template>
  </el-drawer>
</template>

<style lang="scss">
.column-drawer-box {
  width: 350px !important;
  .el-drawer__header,
  .el-drawer__footer {
    padding: 0;
    margin: 0;
  }
  .el-drawer__body {
    padding: 20px 0 20px 20px;
    overflow-y: hidden;
  }
  .drawer-title {
    font-size: 16px;
    font-weight: 700;
    color: #000000;
    text-align: left;
    border-bottom: 1px solid #ebebeb;
    padding: 13px 15px !important;
    span {
      margin: 0 5px 0 0;
    }
    .column-info-icon {
      color: #aeafb2;
      cursor: pointer;
      font-size: 14px;
      &:hover {
        color: #1472ff;
      }
    }
  }
  .drawer-footer {
    height: 48px;
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: flex-end;
    padding: 0 10px;
    border-top: 1px solid #ebebeb;
    .el-button + .el-button {
      margin-left: 8px;
    }
  }
  .drawer-content {
    height: calc(100vh - 130px);
    overflow-y: auto;
    .left-fixed-content,
    .right-fixed-content {
      .columns-right {
        .iconfont {
          color: #1472ff;
        }
      }
    }
    .center-fixed-content {
      .columns-right {
        .iconfont {
          color: #888888;
        }
      }
    }
    .item-content {
      display: flex;
      justify-content: space-between;
      border-radius: 4px;
      align-items: center;
      --vxe-primary-color: #1472ff;
      .columns-left {
        display: flex;
        flex-wrap: wrap;
        gap: 4px;
        align-items: center;

        p {
          color: #444;
          margin-left: 5px;
          font-size: 14px;
        }

        .drag-widget {
          color: #aeafb2;
          cursor: move;
        }

        .empty-span {
          display: inline-block;
          width: 16px;
        }
      }
      .columns-right {
        line-height: 36px;
        .iconfont {
          cursor: pointer;
        }
        .iconfont:hover {
          background-color: #cadfff;
          color: var(--vxe-primary-color);
        }
        .disabled {
          cursor: not-allowed;
          .iconfont {
            pointer-events: none;
            opacity: 0.7;
          }
        }
      }
    }
  }
  .actived {
    background-color: #eef5ff;
  }
}
</style>
View Code

表格列配置区域分割线源码 (tn-divider.vue)

<template>
  <div
    :class="[
      `m-divider ${orientation}`,
      {
        dashed: dashed,
        margin24: !showText,
        marginLeft: orientationMargin !== '' && orientation === 'left',
        marginRight: orientationMargin !== '' && orientation === 'right',
      },
    ]"
    :style="`--border-width: ${borderWidth}px;`"
  >
    <span v-if="orientation === 'left'" v-show="showText" ref="text" class="u-text" :style="`margin-left: ${margin};`">
      <slot></slot>
    </span>
    <span v-else-if="orientation === 'right'" v-show="showText" ref="text" class="u-text" :style="`margin-right: ${margin};`">
      <slot></slot>
    </span>
    <span v-else v-show="showText" ref="text" class="u-text">
      <slot></slot>
    </span>
  </div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
interface Props {
  dashed?: boolean; // 是否为虚线
  orientation?: string; // 分割线标题的位置
  orientationMargin?: string | number; // 标题和最近 left/right 边框之间的距离,去除了分割线,同时 orientation 必须为 left 或 right
  borderWidth?: number; // 分割线宽度
}
const props = withDefaults(defineProps<Props>(), {
  dashed: false,
  orientation: 'center', // 可选 left center right
  orientationMargin: '',
  borderWidth: 1,
});

const text = ref();

const margin = computed(() => {
  if (props.orientationMargin !== '') {
    if (typeof props.orientationMargin === 'number') {
      return props.orientationMargin + 'px';
    }
    return props.orientationMargin;
  }
  return 0;
});
const showText = computed(() => {
  if (text.value) {
    const flag = text.value.offsetHeight;
    if (!flag) return false;
  }
  return true;
});
</script>
<style lang="scss" scoped>
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}
.m-divider {
  display: flex;
  align-items: center;
  margin: 12px 0;
  width: 100%;
  min-width: 100%;
  &:before,
  &:after {
    position: relative;
    width: 50%;
    border-top-width: 1px;
    border-top-style: solid;
    border-top-color: #ededed;
    transform: translateY(50%);
    content: '';
  }
  .u-text {
    display: inline-block;
    font-size: 12px;
    color: #96989b;
    font-weight: 500;
    line-height: 12px;
    white-space: nowrap;
    text-align: center;
    padding: 0 12px;
  }
}
.dashed {
  &:before {
    border-top-style: dashed;
  }
  &:after {
    border-top-style: dashed;
  }
}
.left {
  &:before {
    width: 5%;
  }
  &:after {
    width: 95%;
  }
}
.right {
  &:before {
    width: 95%;
  }
  &:after {
    width: 5%;
  }
}
.margin24 {
  margin: 24px 0;
}
.marginLeft {
  &:before {
    width: 0;
  }
  &:after {
    width: 100%;
  }
}
.marginRight {
  &:before {
    width: 100%;
  }
  &:after {
    width: 0;
  }
}
</style>
View Code

 

posted @ 2025-08-25 16:29  收破烂的小伙子  阅读(60)  评论(0)    收藏  举报