普通视图

发现新文章,点击刷新页面。
昨天以前首页

vue3.5+antdv3.2封装table自定义表头-自定义排序-自定义伸缩列

作者 灼夏无冕
2026年1月27日 13:26

业务需求:

1.表头自定义

2.表格伸缩列自定义

3.字段长度自适应伸缩列,超过宽度设置自动省略,popover气泡卡牌显示全部字段

4.表格排序自定义

5.可展开数据通过操作栏展开

自定义表头:

<template>
  <a-modal
      :visible="modelValue"
      title="自定义表头"
      :destroyOnClose="true"
      :keyboard="false"
      :maskClosable="false"
      width="800px"
      @ok="handleOk"
      @cancel="handleCancel"
  >
    <div class="column-selector-grid">
      <div class="selector-actions">
        <a-button @click="resetToDefault">恢复默认</a-button>
        <a-button @click="selectAll">全选</a-button>
        <a-button @click="clearAll">清空</a-button>
      </div>
      <div class="grid-container">
        <draggable
            v-model="internalColumns"
            group="columns"
            item-key="dataIndex"
            class="grid-layout"
            :disabled="props.disabled"
            @start="onDragStart"
            @end="onDragEnd"
            :move="onMove">
          <template #item="{ element }">
            <div
                class="grid-item"
                :class="{
                'required-item': element.required,
                'selected-item': element.visible,}">
              <div class="drag-handle" >
                <drag-outlined />
              </div>
              <div class="item-content">
                <a-checkbox
                    v-model:checked="element.visible"
                    :disabled="element.required "
                    class="content-checkbox"
                >
                  <span class="content-title">
                    {{ element.title }}
                    <a-tooltip v-if="element.tip" :title="element.tip">
                      <question-circle-outlined class="tip-icon" />
                    </a-tooltip>
                  </span>
                </a-checkbox>
              </div>
            </div>
          </template>
        </draggable>
      </div>
      <div class="fixed-columns-info">
        <p class="info-text">
          <info-circle-outlined class="info-icon" />
          <strong>固定表头申明:</strong>
          <span class="fixed-first-text">序号</span> 固定显示在第一位,
          <span class="fixed-last-text">操作</span> 固定显示在最后一位,以及其他所有被固定的表头都不能取消选择或调整位置。
        </p>
      </div>
    </div>
  </a-modal>
</template>

<script setup>
import { ref, watch, nextTick } from 'vue';
import { QuestionCircleOutlined, DragOutlined, InfoCircleOutlined } from '@ant-design/icons-vue';
import draggable from 'vuedraggable';

// 固定列配置
const FIXED_COLUMNS = {
  FIRST: 'no',      // 序号列固定在第一位
  LAST: 'action'    // 操作列固定在最后一位
};

const props = defineProps({
  allColumns: Array,        // 所有可用列,按照 tableColumns 顺序
  defaultColumns: Array,    // 当前选中的列
  modelValue: Boolean,       // 控制弹窗显示
  disabled:{
    type:Boolean,
    default:false
  }
});

const emit = defineEmits(['update:modelValue', 'confirm','closeSelector']);

// 内部维护的列数据
const internalColumns = ref([]);

// 拖拽状态
const isDragging = ref(false);
const draggedElement = ref(null);

// 拖拽开始事件
const onDragStart = (evt) => {
  const draggedItem = evt.item;
  const draggedIndex = evt.oldIndex;
  const draggedData = internalColumns.value[draggedIndex];
  // 检查是否是固定列
  if (draggedData.required ) {
    // 阻止拖拽固定列
    evt.preventDefault();
    return;
  }
  isDragging.value = true;
  draggedElement.value = draggedData;
};

// 拖拽结束事件
const onDragEnd = (evt) => {
  isDragging.value = false;
  draggedElement.value = null;
  // 延迟执行位置恢复,确保拖拽完全结束
  nextTick(() => {
    ensureFixedColumnsPosition();
  });
};

// 拖拽移动事件
const onMove = (evt) => {
  const draggedItem = evt?.dragged.__draggable_context?.element;
  const targetIndex = evt.draggedContext.futureIndex;
  // 检查被拖动列是否是固定列
  if (draggedItem?.required ) {
    // 阻止拖拽固定列
    return false;
  }
  // 检查目标是否是固定列
  const targetItem = internalColumns.value[targetIndex];
  if (targetItem && targetItem.required) {
    // 阻止拖拽到固定列
    return false;
  }
  // 智能位置调整:确保拖拽后固定列位置仍然正确
  const adjustedTargetIndex = adjustTargetIndex(targetIndex);
  if (adjustedTargetIndex !== targetIndex) {
    // 如果目标位置需要调整,更新拖拽目标
    evt.draggedContext.futureIndex = adjustedTargetIndex;
  }
  // 允许正常列在固定列之间自由拖拽
  return true;
};

// 智能调整目标位置,确保不破坏序号和操作的位置
const adjustTargetIndex = (targetIndex) => {
  const columns = internalColumns.value;
  // 找到序号和操作的位置
  const noIndex = columns.findIndex(col => col.dataIndex === FIXED_COLUMNS.FIRST);
  const actionIndex = columns.findIndex(col => col.dataIndex === FIXED_COLUMNS.LAST);
  // 如果目标位置是序号列位置,调整到序号列后面
  if (targetIndex === 0) {
    return 1;
  }
  // 如果目标位置是操作列位置,调整到操作列前面
  if (targetIndex === columns.length - 1) {
    return columns.length - 2;
  }
  // 如果目标位置在序号列之前,调整到序号列后面
  if (targetIndex < noIndex) {
    return noIndex + 1;
  }
  // 如果目标位置在操作列之后,调整到操作列前面
  if (targetIndex > actionIndex) {
    return actionIndex - 1;
  }
  return targetIndex;
};

// 确保固定列在正确位置
const ensureFixedColumnsPosition = () => {
  const columns = [...internalColumns.value];
  let hasChanges = false;

  // 确保序号列在第一位
  const noColumn = columns.find(col => col.dataIndex === FIXED_COLUMNS.FIRST);
  if (noColumn) {
    const noIndex = columns.indexOf(noColumn);
    if (noIndex !== 0) {
      columns.splice(noIndex, 1);
      columns.unshift(noColumn);
      hasChanges = true;
    }
  }

  // 确保操作列在最后一位
  const actionColumn = columns.find(col => col.dataIndex === FIXED_COLUMNS.LAST);
  if (actionColumn) {
    const actionIndex = columns.indexOf(actionColumn);
    if (actionIndex !== columns.length - 1) {
      columns.splice(actionIndex, 1);
      columns.push(actionColumn);
      hasChanges = true;
    }
  }

  // 如果有变化,更新列顺序
  if (hasChanges) {
    internalColumns.value = [...columns];
  }
};

// 初始化列数据,保持 tableColumns 的顺序
const initColumns = () => {
  // 按照 allColumns 的顺序初始化
  internalColumns.value = props.allColumns.map(col => ({
    ...col,
    visible: props.defaultColumns.includes(col.dataIndex) || col.required
  }));
  // 确保固定列在正确位置
  ensureFixedColumnsPosition();
};

// 恢复默认
const resetToDefault = () => {
  initColumns();
};

// 全选
const selectAll = () => {
  internalColumns.value.forEach(col => {
    col.visible = true;
  });
};

// 清空(除了必需列和固定列)
const clearAll = () => {
  internalColumns.value.forEach(col => {
    if (!col.required ) {
      col.visible = false;
    }
  });
};

// 确认保存
const handleOk = () => {
  // 按照当前顺序返回可见列的 dataIndex 数组
  const visibleColumns = internalColumns.value
      .filter(col => col.visible)
      .map(col => col.dataIndex);

  emit('confirm', visibleColumns);
  emit('update:modelValue', false);
};

const handleCancel = () => {
  if(props.disabled){
    internalColumns.value.forEach(col => {
      col.visible = props.defaultColumns.includes(col.dataIndex) || col.required ;
    });
  }

  emit('update:modelValue', false);
  emit('closeSelector');
};

// 监听 props 变化重新初始化
watch(() => props.allColumns, initColumns, { immediate: true });
watch(() => props.defaultColumns, () => {
  internalColumns.value.forEach(col => {
    col.visible = props.defaultColumns.includes(col.dataIndex) || col.required ;
  });
});

// 监听内部列顺序变化,确保固定列位置正确
watch(() => internalColumns.value, (newColumns) => {
  if (newColumns && newColumns.length > 0) {
    // 检查固定列位置
    const noIndex = newColumns.findIndex(col => col.dataIndex === FIXED_COLUMNS.FIRST);
    const actionIndex = newColumns.findIndex(col => col.dataIndex === FIXED_COLUMNS.LAST);

    // 如果固定列位置不正确,立即恢复
    if (noIndex !== 0 || actionIndex !== newColumns.length - 1) {
      nextTick(() => {
        ensureFixedColumnsPosition();
      });
    }
  }
}, { deep: true });
</script>

<style scoped>
.column-selector-grid {
  padding: 12px;
}

.selector-actions {
  margin-bottom: 16px;
  display: flex;
  gap: 8px;
}

.grid-container {
  border: 1px solid #f0f0f0;
  border-radius: 4px;
  padding: 12px;
  background: #fafafa;
  margin-bottom: 16px;
}

.grid-layout {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 12px;
}

.grid-item {
  position: relative;
  height: 80px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  background: #fff;
  cursor: move;
  transition: all 0.2s;
  padding: 8px;
  overflow: hidden;
}

.grid-item:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.grid-item.required-item {
  background-color: #f6ffed;
}

.grid-item.selected-item {
  background-color: #e6f7ff;
}

.drag-handle {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f0f0f0;
  border-radius: 2px;
  color: #666;
  cursor: move;
  z-index: 1;
}

.grid-item.required-item .drag-handle {
  background: #d9f7be;
}

.item-content {
  height: 100%;
  display: flex;
  align-items: center;
}

.content-checkbox {
  width: 100%;
  display: flex;
  align-items: center;
  margin: 0;
  padding-right: 20px;
}

.content-checkbox >>> .ant-checkbox {
  flex-shrink: 0;
}

.content-checkbox >>> .ant-checkbox + span {
  display: flex;
  align-items: center;
  flex: 1;
  padding-right: 4px;
  white-space: normal;
}

.content-title {
  display: inline-flex;
  align-items: center;
  font-size: 12px;
  line-height: 1.4;
  word-break: break-word;
  text-align: left;
}

.tip-icon {
  margin-left: 4px;
  color: #999;
  font-size: 12px;
}

.fixed-columns-info {
  background-color: #f6ffed;
  border: 1px solid #b7eb8f;
  border-radius: 4px;
  padding: 12px;
}

.info-text {
  margin: 0;
  font-size: 12px;
  color: #666;
  line-height: 1.5;
}

.info-icon {
  margin-right: 6px;
  color: #52c41a;
}

.fixed-first-text {
  color: #52c41a;
  font-weight: bold;
}

.fixed-last-text {
  color: #1890ff;
  font-weight: bold;
}
</style>

表格封装:

<template>
  <div class="list-page">
    <div>
      <slot name="header"/>
    </div>
    <div>
      <a-table
          bordered
          row-key="id"
          size="small"
          ref="tableRef"
          :loading="loading"
          :columns="currentTableColumns"
          :data-source="dataSource"
          :expand-row-by-click="false"
          :row-selection="rowSelection"
          :expandIconColumnIndex="-1"
          @expand="handleExpand"
          :expanded-row-keys="expandedRowKeys"
          @resizeColumn="handleResizeColumn"
          :scroll="{ x: '120%', y: 'calc(100vh - 375px)' }"
          :pagination="false">

        <!-- 动态插槽处理 -->
        <template v-for="slotName in Object.keys($slots)" #[slotName]="scope" :key="slotName">
          <slot :name="slotName" v-bind="scope" />
        </template>

        <!--通用bodyCell插槽封装-->
        <template #bodyCell="{text,record,column,index}">
          <!--  先检查是否有自定义的bodyCell插槽-->
          <template v-if="$slots.bodyCell">
            <slot name="bodyCell" :text="text" :record="record" :column="column" :index="index"></slot>
          </template>
          <!-- 无自定义的bodyCell插槽时的默认处理逻辑-->
          <template v-else>
            {{ formatCellText(text,record,column) }}
          </template>
        </template>

        <!-- 通用操作列 -->
        <template #action="{ record, index }">
          <slot name="action" :record="record" :index="index">
            <!-- 默认操作列 -->
          </slot>
        </template>

        <!-- 扩展行插槽 -->
        <template #expandedRowRender="{ record, index }">
          <slot name="expandedRowRender" :record="record" :index="index" />
        </template>

      </a-table>
      <div class="pagination-wrapper">
        <a-pagination
            show-size-changer
            size="small"
            v-model:current="pagination.page"
            v-model:page-size="pagination.pageSize"
            :show-total="(total) => `共 ${total} 条`"
            :total="pagination.total"
            :pageSizeOptions="['100', '200', '300', '500']"
            @change="onChangePage"
        >
        </a-pagination>
      </div>
    </div>

    <!--   自定义表头模态框-->
    <ColumnSelector
        :visible="showColumnSelector"
        :all-columns="enhancedColumns"
        :default-columns="selectedColumns"
        @confirm="handleColumnConfirm"
        @closeSelector="closeSelector"
    />
  </div>
</template>

<script setup>
import {computed, h, nextTick, onMounted, onUnmounted, onUpdated, reactive, ref} from "vue";
import {settingStore} from "../../store/index.js";
import {storeToRefs} from "pinia";
import ColumnSelector from "../../components/ColumnsSelector/index.vue";
import {debounce, deepClone} from "../../utils/util.js";
const settingStoreRef = settingStore();
const { tableOrderByName, tableAscOrDescOrNomal } = storeToRefs(settingStoreRef)
import customizeHeaderApi from '../../api/CustomizeHeader/index.js'
import {message} from "ant-design-vue";
import {useRoute} from "vue-router";

let tableRef = ref(null)
const props = defineProps({
  //表格加载状态
  loading:Boolean,
  showColumnSelector:Boolean,
  rowSelection: {
    type: Object
  },
  pagination:{
    type: Object,
    default: { },
  },
  //需要排序的表格标题
  sortTableList:{
    type: Array,
    default: [],
  },
  //补充必需的列
  requiredColumns:{
    type: Array,
    default: [],
  },
  expandedRowKeys:{
    type: Array,
    default: [],
  },
  columns:Array,
  dataSource:{
    type: Array,
    default: [],
  },
})
const route = useRoute();

const emit = defineEmits(['onChangePage','closeSelector','resetPage','columnResized'])

//自定义表头拉伸
const handleResizeColumn=(w, col)=>{
  col.width = w;
  // 强制表格重新渲染
  nextTick(() => {
    tableRef.value?.$forceUpdate?.();
  })
  debounceResizeColumn({
    dataIndex: col.dataIndex,
    width: w,
    routePath: route.path
  })
}
const debounceResizeColumn = debounce((payload)=>{
  emit('columnResized',payload)
  checkAllCellsOverflow();
},500)

let DEFAULT_VISIBLE_COLUMNS = props.columns.map((item)=>{
  return item.dataIndex
})
const handleExpand = (expanded, record) => {
  emit('handleExpand',expanded, record)
};

const onChangePage=(data)=>{
  emit('onChangePage', data)
}
const closeSelector=()=>{
  emit('closeSelector')
}
//排序
// 当前排序状态
const currentSorter = ref({
  field: tableOrderByName.value,
  order: tableAscOrDescOrNomal.value ?
      (tableAscOrDescOrNomal.value === '0' ? 'ascend' : 'descend') :
      null
});
// 生成带有自定义排序图标的列配置
const currentSortTableColumns = computed(() => {
  return props.columns.map(col => {
    if (props.sortTableList.includes(col.dataIndex)) {
      const isCurrentSorted = currentSorter.value.field === col.dataIndex;
      const sortUpActive = isCurrentSorted && currentSorter.value.order === 'ascend';
      const sortDownActive = isCurrentSorted && currentSorter.value.order === 'descend';
      return {
        ...col,
        // 禁用 Ant Design 的默认排序图标
        sorter: false,
        // 使用自定义标题渲染
        title: () => h('div', {
          class: 'sortable-header',
          onClick: (event) => {
            handleHeaderClick(col.dataIndex, event);
          }
        }, [
          h('span', {
            class: 'header-title',
          }, col.title),
          h('div', {
            class: 'sort-icons',
            style: 'display: flex; flex-direction: column; margin-left: 4px; line-height: 1;',
            onClick: (event) => {
              // 阻止事件冒泡,排序图标有自己的点击事件
              event.stopPropagation();
            }
          }, [
            // 升序三角形图标(正三角)
            h('div', {
              class: `sort-icon sort-up ${sortUpActive ? 'active' : ''}`,
              onClick: () => handleSortClick(col.dataIndex, 'ascend')
            }),
            // 降序三角形图标(倒三角)
            h('div', {
              class: `sort-icon sort-down ${sortDownActive ? 'active' : ''}`,
              onClick: () => handleSortClick(col.dataIndex, 'descend')
            })
          ])
        ])
      };
    }
    return col;
  });
});
// 处理排序图标点击
const handleSortClick = (field, order) => {
  // 如果点击的是当前排序字段且当前已经是该排序方式,则取消排序
  if (currentSorter.value.field === field && currentSorter.value.order === order) {
    settingStoreRef.$patch({
      tableOrderByName: undefined,
      tableAscOrDescOrNomal: undefined
    });
    currentSorter.value = {
      field: undefined,
      order: null
    };
  } else {
    // 设置新的排序
    const sortOrder = order === 'ascend' ? '0' : '1';
    settingStoreRef.$patch({
      tableOrderByName: field,
      tableAscOrDescOrNomal: sortOrder
    });
    currentSorter.value = {
      field: field,
      order: order
    };
  }
  // 重置页码并重新加载数据
  emit('resetPage')
};
// 处理列标题点击(点击标题其他位置)
// 处理表头点击
const handleHeaderClick = (field, event) => {
  const target = event.target;
  // 判断是否点击了排序图标区域
  const isSortIcon = target.closest('.sort-icons') ||
      target.classList.contains('sort-icon') ||
      target.classList.contains('sort-up') ||
      target.classList.contains('sort-down') ||
      target.tagName === 'DIV' && (
          target.style.borderLeft.includes('transparent') ||
          target.style.borderRight.includes('transparent')
      );

  // 如果点击的是排序图标区域,不处理(由 handleSortClick 处理)
  if (isSortIcon) {
    return;
  }
  // 获取当前列的排序状态
  const isCurrentColumn = currentSorter.value.field === field;
  const isAscending = currentSorter.value.order === 'ascend';
  const isDescending = currentSorter.value.order === 'descend';
  if (isCurrentColumn) {
    // 如果当前列已经是排序状态,点击表头其他区域取消排序
    if (isAscending || isDescending) {
      settingStoreRef.$patch({
        tableOrderByName: undefined,
        tableAscOrDescOrNomal: undefined
      });
      currentSorter.value = {
        field: undefined,
        order: null
      };
      emit('resetPage')
    }
  } else {
    // 如果当前列没有排序,点击表头设置为默认升序
    settingStoreRef.$patch({
      tableOrderByName: field,
      tableAscOrDescOrNomal: '0' // 0 表示升序
    });
    currentSorter.value = {
      field: field,
      order: 'ascend'
    };
    emit('resetPage')
  }
};
function initTableOrder(){
  settingStoreRef.$patch({
    tableOrderByName:undefined,
    tableAscOrDescOrNomal:undefined
  })
  currentSorter.value = {
    field: undefined,
    order: null
  };
}

//自定义表头 start
const visibleColumns = ref([...DEFAULT_VISIBLE_COLUMNS]);
let allDefaultColumns = ref([])
// 获取实际显示的表格列
const currentTableColumns = computed(() => {
  const allColumns = currentSortTableColumns.value;
  // 过滤出需要显示的列,并保持顺序
  return visibleColumns.value
      .map(dataIndex => allColumns.find(col => col.dataIndex === dataIndex))
      .filter(Boolean);
});

// 当前选中的列
const selectedColumns = ref([...DEFAULT_VISIBLE_COLUMNS]);
const selectorColumns = computed(() => {
  // 过滤出需要显示的列,并保持顺序
  return allDefaultColumns.value
      .map(dataIndex => props.columns.find(col => col.dataIndex === dataIndex))
      .filter(Boolean);
});
function getColumnTip(dataIndex) {
  let tips = {};
  props.columns.forEach((item)=>{
    tips[item.dataIndex] =  item.title;
  })
  return tips[dataIndex];
}
// 增强列信息
const enhancedColumns = computed(() => {
  let columns = selectorColumns.value;
  // 确保顺序与 tableColumns 一致
  return columns.map(col => ({
    ...col,
    tip: getColumnTip(col.dataIndex),
    required: props.requiredColumns.includes(col.dataIndex)
  }));
});
// 保存列设置
async function handleColumnConfirm(columns) {
  try {
    // 更新visibleColumns和selectedColumns
    visibleColumns.value = columns;
    selectedColumns.value = columns;
    // 接口保存
    await customizeHeaderApi.update({
      pageRoutePath: route.path,
      tableColumsNameLst: columns
    })
    emit('closeSelector');
    message.success('表头设置已保存');
  } catch (e) {
    console.error('保存列设置失败', e);
    message.error('保存表头设置失败');
  }
}
// 加载保存的设置
async function loadColumnPreference() {
  try {
    //获取已保存的设置
    const res = await customizeHeaderApi.get({pageRoutePath:route?.path});
    const saved = res.data?.tableColumsNameLst;
    // 验证并处理保存的列设置
    if (saved && saved.length > 0) {
      // 1. 过滤掉不存在的列
      const validColumns = saved.filter(dataIndex =>
          props.columns.some(col => col.dataIndex === dataIndex)
      );
      // 2. 补充必需的列
      props.requiredColumns.forEach(col => {
        if (!validColumns.includes(col)) {
          validColumns.unshift(col); // 必需列放在前面
        }
      });
      visibleColumns.value = validColumns;
      //必须列
      let requiredColumns = deepClone(props.columns);
      requiredColumns = requiredColumns.filter((col)=>props.requiredColumns.includes(col.dataIndex));
      //已保存的必须列
      let savedNonRequiredColumns = saved.filter(dataIndex => !props.requiredColumns.includes(dataIndex)).map(dataIndex=>
          props.columns.find(col=> col.dataIndex == dataIndex)).filter(Boolean);
      //未保存的列
      let unsavedCols = props.columns.filter(col=>!props.requiredColumns.includes(col.dataIndex) && !saved.includes(col.dataIndex));
      const orderColumns = [...requiredColumns,...savedNonRequiredColumns,...unsavedCols];
      allDefaultColumns.value = orderColumns.map(col=>col.dataIndex);
    } else {
      // 使用默认值
      visibleColumns.value = [...DEFAULT_VISIBLE_COLUMNS];
      allDefaultColumns.value = [...DEFAULT_VISIBLE_COLUMNS];
    }
    // 更新selectedColumns(用于ColumnSelector)
    selectedColumns.value = [...visibleColumns.value];
  } catch (e) {
    console.error('加载列设置失败', e);
    visibleColumns.value = [...DEFAULT_VISIBLE_COLUMNS];
    selectedColumns.value = [...DEFAULT_VISIBLE_COLUMNS];
  }
}
//自定义表头 end

// 格式化单元格文本
const formatCellText = (text, record, column) => {
  if (typeof text === 'object') {
    return JSON.stringify(text)
  }
  return text || ''
}
// 检查文本是否溢出
const overflowMap = reactive(new Map());
const isOverflow = (columnKey, rowKey) => {
  return overflowMap.get(`${rowKey}-${columnKey}`) || false;
};
const checkAllCellsOverflow =()=>{
  nextTick(()=>{
    const cells = document.querySelectorAll('.table-cell');
    cells.forEach((cell)=>{
      const columnKey = cell.getAttribute('data-column-key');
      const rowKye = cell.getAttribute('data-row-key');
      const key = `${rowKye}-${columnKey}`;
      const isOverFlow = cell.scrollWidth > cell.clientWidth;
      overflowMap.set(key,isOverFlow);
    })
  })
}

onMounted(()=>{
  initTableOrder();
  loadColumnPreference();
  // 监听窗口变化
  window.addEventListener('resize', checkAllCellsOverflow);
})
onUpdated(()=>{
  checkAllCellsOverflow();
})
// 清理事件监听
onUnmounted(() => {
  window.removeEventListener('resize', checkAllCellsOverflow);
});
// 暴露方法给父组件
defineExpose({
  initTableOrder,
  isOverflow,
});
</script>

<style scoped>
.pagination-wrapper {
  text-align: right;
  margin-top: 10px;
}
</style>
/**  防抖函数,规定时间内连续出发的函数只执行最后一次 */
function debounce(func, wait, immediate) {
    let timeout; // 定义一个计时器变量,用于延迟执行函数
    return function (...args) { // 返回一个包装后的函数
        const context = this; // 保存函数执行上下文对象
        const later = function () { // 定义延迟执行的函数
            timeout = null; // 清空计时器变量
            if (!immediate) func.apply(context, args); // 若非立即执行,则调用待防抖函数
        };
        const callNow = immediate && !timeout; // 是否立即调用函数的条件
        clearTimeout(timeout); // 清空计时器
        timeout = setTimeout(later, wait); // 创建新的计时器,延迟执行函数
        if (callNow) func.apply(context, args); // 如果满足立即调用条件,则立即执行函数
    };
}
import { defineStore } from "pinia";

export const columWidthStore = defineStore('columnWidthStore', {
    state: () => ({
        columnWidthList: [],
    }),
    actions: {
        setColumnList(record){
            const { routePath,dataIndex,width } = record;
            let routeConfig = this.columnWidthList.find(item=>item.routePath == routePath);
            if(routeConfig){
                const columnConfig = routeConfig?.columnList.find(item=>item.dataIndex == dataIndex);
                if(columnConfig){
                    columnConfig.width = width;
                }else{
                    routeConfig.columnList.push({dataIndex,width})
                }
            }else{
                this.columnWidthList.push({
                    routePath,columnList:[{dataIndex,width}]
                })
            }

        },
        getRouteColumnWidths(routePath) {
            const routeConfig = this.columnWidthList.find(item=>item.routePath == routePath);
            if(!routeConfig)return [];
            return routeConfig.columnList;
        },
        deleteRouteConfig(routePath) {
            let index = this.columnWidthList.findIndex((item)=>{return item.routePath == routePath});
            this.columnWidthList.splice(index,1);
        },
        deleteAllRoute(){
            this.columnWidthList = []
        }

    },
    getters: {

    },
    persist: {
        enabled: true,
        // 自定义持久化参数
        strategies: [
            {
                // 自定义key,默认就是仓库的key
                key: "columWidthStore",
                // 自定义存储方式,默认sessionStorage
                storage: localStorage,
                // 指定要持久化的数据,默认所有 state 都会进行缓存,可以通过 paths 指定要持久化的字段,其他的则不会进行持久化。
                paths: [
                    "columnWidthList",
                ],
            }
        ],
    },
})


import { defineStore } from "pinia";
import { deepClone } from "../../utils/util.js";
const customTitlesStr = sessionStorage.getItem(import.meta.env.VITE_APP_TBAS_TITLES_KEY)
const customTitles = (customTitlesStr && JSON.parse(customTitlesStr)) || []
export const settingStore = defineStore('settingStore', {
    state: () => ({
        isMobile: false,
        visible: false,
        pageMinHeight: 0,
        tableOrderByName:undefined,
        tableAscOrDescOrNomal:undefined,
        menuData: [],
        menuList: {},
        activeValue: '',
        activatedFirst: undefined,
        customTitles,
    }),
    actions: {
        reset() {
            this.isMobile = false;
            this.tableOrderByName = undefined;
            this.tableAscOrDescOrNomal = undefined;
            this.visible = false;
            this.pageMinHeight = 0;
            this.menuData = [];
            this.menuList = {};
            this.activeValue = '';
            this.activatedFirst = undefined;       
            this.customTitles = customTitles;
        },
        setDevice(state, isMobile) {
            state.isMobile = isMobile
        },
        setVisible(state, visible) {
            state.visible = visible
        },
        setMenuValue(value) {
            this.menuData.push(value)
        },

    },
    getters: {
 
    },
    persist: {
        enabled: true,
        // 自定义持久化参数
        strategies: [
            {
                // 自定义key,默认就是仓库的key
                key: "settingStore",
                // 自定义存储方式,默认sessionStorage
                storage: localStorage,
                // 指定要持久化的数据,默认所有 state 都会进行缓存,可以通过 paths 指定要持久化的字段,其他的则不会进行持久化。
                paths: [
                    "menuData",
                    "activeValue",
                    "menuList",
                    "tableOrderByName",
                    "tableAscOrDescOrNomal"
                ],
            }
        ],
    },
})

组件使用:

<template>
  <div>
    <customizeTable
        ref="customizeTableRef"
        :loading="loading"
        :columns="tableColumns"
        :pagination="pagination"
        :data-source="dataSource"
        :requiredColumns="requiredColumns"
        :sortTableList="sortTableList"
        :showColumnSelector="showColumnSelector"
        :expandedRowKeys="expandedRowKeys"
        @onChangePage="onChangePage"
        @closeSelector="closeSelector"
        @expand="handleExpand"
        @resetPage="resetPage"
        @columnResized="columnResized"
    >
      <template #header>
        <div style="display: flex;margin-bottom: 10px">
          <span>测试:</span>
          <a-input size="small" style="width:120px;margin-right: 4px" v-model:value="testValue" ></a-input>
          <a-button size="small" @click="testValue = undefined">清空</a-button>
        </div>
      </template>
      <template #bodyCell="{ text, record, column }">
        <template v-if="overFlowColumns.includes(column.dataIndex)">
          <div class="table-cell"
               :data-row-key="record.key"
               :data-column-key="column.dataIndex"
               @dblclick="handleCellDoubleClick(record, column)">
            <a-popover  placement="leftBottom" v-if="getCellOverflow(column.dataIndex, record.key)" :content="record[column.dataIndex]">
              <span> {{ text }}</span>
            </a-popover>
            <div v-else>
              {{ text }}
            </div>
          </div>
        </template>
        <template v-if="column.dataIndex === 'action'">
          <a-dropdown placement="bottom" >
            <a class="ant-dropdown-link" @click.prevent>
              操作
            </a>
            <template #overlay>
              <a-menu class="menu-active-background-color">
                <a-menu-item >
                  <a-button
                      size="small"
                      @click="toggleExpand(record.id)"
                      style="padding: 0 4px;"
                  >
                    {{ expandedRowKeys.includes(record.id) ? '收起' : '详情' }}
                  </a-button>
                </a-menu-item>
              </a-menu>
            </template>
          </a-dropdown>
        </template>
      </template>

      <template #expandedRowRender="{ record }">
        <div class="expanded-style">
          <a-descriptions class="mt-10" title="详细信息" bordered :column="2">
            <a-descriptions-item :contentStyle="contentColor" label="1">{{record.one}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="2">{{record.two}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="3">{{record.three}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="4">{{record.four}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="5">{{record.five}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="6" >{{record.six}}</a-descriptions-item>
          </a-descriptions>
        </div>
      </template>
    </customizeTable>
  </div>
</template>
<script setup>
import { onMounted,  ref} from "vue";
import {columWidthStore} from "../src/store/index.js";
import customizeTable from './components/CustomizeTable/index.vue'
import {useRoute} from "vue-router";
import {copyToClipboard} from './utils/util.js'
let route = useRoute();
let customizeTableRef = ref(null)
let loading = ref(false)
let testValue = ref('')
let contentColor = ref({
  backgroundColor:'#fff'
});
let tableColumns = ref([
  {
    title: "序号",
    dataIndex: "no",
    width: 46,
    fixed:'left',
    customRender: ({index}) => `${index + 1}`,
  },
  {
    title: "1",
    dataIndex: "one",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "2",
    dataIndex: "two",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "3",
    dataIndex: "three",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "4",
    dataIndex: "four",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "5",
    dataIndex: "five",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "6",
    dataIndex: "six",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "操作",
    dataIndex: "action",
    fixed:'right',
    key:'action',
    width: 50,
  },
])
let overFlowColumns = tableColumns.value.filter((item)=>!['no','action'].includes(item.dataIndex)).map((item)=>item.dataIndex);
let dataSource = ref([
  {one:'111',two:'2222',three:'333',four:'444444444444444444444444444444444444444',five:'555',six:'666',id:1,key:1111}
])
const pagination = ref({
  page: 1,
  pageSize: 10,
  total: 0,
  size: "default",
});
let requiredColumns = ref(['no','one','two'])
let sortTableList = ref(['one','two','three'])
let showColumnSelector = ref(false)
function handleCellDoubleClick(record, column) {
  const text = record[column.dataIndex];
  copyToClipboard(text);
}
const onChangePage = (data) => {
  pagination.value.page = data;
  // getList();
};
const resetPage=()=>{
  pagination.value.page = 1;
  // getList();
}
const closeSelector=()=>{
  showColumnSelector.value = false;
}

/* 表格详情展开关闭 start */
// 添加展开行的状态管理
let expandedRowKeys = ref([]);
// 切换展开状态的方法
const toggleExpand = (id) => {
  const index = expandedRowKeys.value.indexOf(id);
  if (index > -1) {
    // 如果已经展开,则关闭
    expandedRowKeys.value.splice(index, 1);
  } else {
    // 如果未展开,则展开并关闭其他已展开的行
    expandedRowKeys.value = [id];
  }
};
// 处理表格展开事件
const handleExpand = (expanded, record) => {
  if (expanded) {
    // 展开时关闭其他行
    expandedRowKeys.value = [record.id];
  } else {
    // 收起时移除
    const index = expandedRowKeys.value.indexOf(record.id);
    if (index > -1) {
      expandedRowKeys.value.splice(index, 1);
    }
  }
};
/* 表格详情展开关闭 end */

const columStore = columWidthStore();
const columnResized=(record)=>{
  columStore.setColumnList({
    routePath: record.routePath,
    dataIndex: record.dataIndex,
    width: record.width,
  });

}
const initColumnWidth=()=>{
  let temp = columStore.getRouteColumnWidths(route?.path)
  if(temp?.length){
    tableColumns.value.forEach((el)=>{
      let index = temp.findIndex(item=>item.dataIndex == el.dataIndex);
      if(index>=0)el.width = temp[index].width;
    })
  }
}
const getCellOverflow = (columnKey, rowKey) => {
  if (!customizeTableRef.value?.isOverflow) {
    console.warn('无法访问子组件的 isOverflow 方法');
    return false;
  }
  try {
    return customizeTableRef.value.isOverflow(columnKey, rowKey);
  } catch (error) {
    console.error('调用 isOverflow 方法出错:', error);
    return false;
  }
};
onMounted(()=>{
  initColumnWidth();
})

</script>
<style scoped>
.table-cell {
  white-space: nowrap;
  text-overflow: ellipsis;
  overflow: hidden;
}
</style>


GIF 2026-1-27 11-27-26.gif

❌
❌