vue-表格组件封装

表格组件

<!-- glpTable.vue - 表格组件 -->
<template>
  <div class="table-content">
    <div
      v-if="toolbar"
      class="table-toolbar flex justify-between items-center mb-10px"
    >
      <div class="flex-center">
        <XButton
          v-for="btn in toolBtnList"
          :key="btn.label"
          :preIcon="btn.preIcon"
          v-bind="toolBtnAttrs(btn)"
          :title="btn.label"
          @click="btn.onClick(row)"
        />
      </div>

      <!-- 控制列显隐按钮 -->
      <TableHeaderSetting
        v-if="isShowHeaderSetting && displayMainColumns.length > 7"
        :defaultColumn="displayMainColumns"
        @submit="setColumnConfig"
      />
    </div>
    <div
      v-loading="loading"
      class="flex-1 flex-column gap-20px overflow-hidden"
    >
      <ElTable
        ref="elTableRef"
        :data="data"
        @selection-change="handleSelectionChange"
        stripe
        highlight-current-row
        show-overflow-tooltip
        v-bind="tableAttrs"
      >
        <template #empty>
          <div class="flex-column flex-center h-210px">
            <img src="@/assets/imgs/notData.png" class="h-120px w-120px" />
            <span>暂无数据</span>
          </div>
        </template>
        <!-- 展开行 -->
        <ElTableColumn
          v-if="expand"
          type="expand"
          :align="align"
          :header-align="headerAlign"
        >
          <template #default="data">
            <slot name="expand" v-bind="data" />
          </template>
        </ElTableColumn>

        <!-- 多选框 -->
        <ElTableColumn
          v-if="selection"
          type="selection"
          :reserve-selection="reserveSelection"
          :align="align"
          :header-align="headerAlign"
          width="50"
        />
        <el-table-column v-if="!expand" label="序号" type="index" width="60" />

        <!-- 主表列 -->
        <template
          v-for="column in tableHeader"
          :key="`main-${column.field || column.prop || column.label}`"
        >

          <!-- 普通列 -->
          <ElTableColumn
            :prop="column.field || column.prop"
            :label="column.label"
            :width="column.width"
            :min-width="column.minWidth"
            :fixed="column.fixed"
            :sortable="column.sortable"
            :show-overflow-tooltip="
              column.showOverflowTooltip ?? showOverflowTooltip
            "
            :align="column.align || align"
            :header-align="column.headerAlign || headerAlign"
            v-bind="getColumnProps(column)"
          >
            <!-- 表头插槽 -->
            <template #header>
              <slot
                :name="column.field || column.prop + '-header'"
                v-if="hasSlot(column.field || column.prop + '-header')"
              >
                {{ column.label }}
              </slot>
              <template v-else>{{ column.label }}</template>
            </template>

            <!-- 列内容 -->
            <template #default="data">
              <slot
                :name="column.field || column.prop"
                v-if="hasSlot(column.field || column.prop)"
                v-bind="data"
              >
                {{ formatCellValue(column, data) }}
              </slot>
              <template v-else>{{ formatCellValue(column, data) }}</template>
            </template>
          </ElTableColumn>
        </template>

        <!-- 扩展表列 -->
        <template
          v-for="column in finalExtendColumns"
          :key="`extend-${column.field || column.prop || column.label}`"
        >
          <ElTableColumn
            :prop="column.field || column.prop"
            :label="column.label"
            :width="column.width"
            :min-width="column.minWidth"
            :fixed="column.fixed"
            :sortable="column.sortable"
            :show-overflow-tooltip="
              column.showOverflowTooltip ?? showOverflowTooltip
            "
            :align="column.align || align"
            :header-align="column.headerAlign || headerAlign"
            v-bind="getColumnProps(column)"
          >
            <!-- 表头插槽 -->
            <template #header>
              <slot
                :name="`extend-${column.field || column.prop}` + '-header'"
                v-if="
                  hasSlot(`extend-${column.field || column.prop}` + '-header')
                "
              >
                {{ column.label }}
              </slot>
              <template v-else>{{ column.label }}</template>
            </template>

            <!-- 列内容 -->
            <template #default="data">
              <slot
                :name="`extend-${column.field || column.prop}`"
                v-if="hasSlot(`extend-${column.field || column.prop}`)"
                v-bind="data"
              >
                {{ formatCellValue(column, data) }}
              </slot>
              <template v-else>{{ formatCellValue(column, data) }}</template>
            </template>
          </ElTableColumn>
        </template>

        <!-- 操作列 - 使用新的操作按钮组件 -->
        <ElTableColumn
          v-if="hasOperationColumn"
          :label="operationColumn.label || '操作'"
          :width="operationColumn.width || '200px'"
          :fixed="operationColumn.fixed || 'right'"
          :align="operationColumn.align || align"
          :header-align="operationColumn.headerAlign || headerAlign"
          v-bind="operationColumn"
        >
          <template #default="data">
            <!-- 使用自定义操作按钮组件 -->
            <OperationButtons
              :list-btns="getOperationButtons(data.row)"
              :row="data.row"
            />
          </template>
        </ElTableColumn>
      </ElTable>

      <!-- 追加内容 -->
      <template v-if="hasSlot('append')">
        <slot name="append" />
      </template>

      <!-- 分页 -->
      <ElPagination
        v-if="pagination"
        v-model:page-size="internalPageSize"
        v-model:current-page="internalCurrentPage"
        v-bind="mergedPagination"
      />
    </div>
  </div>
</template>

<script setup>
import {
  ref,
  computed,
  watch,
  onMounted,
  useAttrs,
  useSlots,
  defineProps,
  defineEmits,
  defineOptions,
} from 'vue'
import { ElTable, ElTableColumn, ElPagination } from 'element-plus'
import OperationButtons from '@/components/OperationButtons/index.vue' // 引入操作按钮组件
import TableHeaderSetting from '@/components/CustomCommon/CommonTableHeaderSetting.vue'

// 定义组件名称
defineOptions({
  name: 'ProTable',
})

// 主表默认字段(硬编码)
const DEFAULT_MAIN_COLUMNS = [
  { type: 'index', label: '序号', width: '65px' },
  { field: 'id', label: 'ID', width: '80px', sortable: true },
  { field: 'name', label: '名称', minWidth: '120px' },
  { field: 'code', label: '编码', minWidth: '100px' },
  {
    field: 'status',
    label: '状态',
    width: '100px',
    formatter: (row) => {
      return row.status === 'active' ? '启用' : '禁用'
    },
  },
]

// 定义Props
const props = defineProps({
  // 基础配置
  pageSize: {
    type: Number,
    default: 10,
  },
  currentPage: {
    type: Number,
    default: 1,
  },
  total: {
    type: Number,
    default: 0,
  },
  selection: {
    type: Boolean,
    default: false,
  },
  showOverflowTooltip: {
    type: Boolean,
    default: true,
  },
  expand: {
    type: Boolean,
    default: false,
  },
  pagination: {
    type: [Object, Boolean],
    default: undefined,
  },
  reserveSelection: {
    type: Boolean,
    default: false,
  },
  loading: {
    type: Boolean,
    default: false,
  },
  extendLoading: {
    type: Boolean,
    default: false,
  },
  reserveIndex: {
    type: Boolean,
    default: false,
  },
  align: {
    type: String,
    validator: (v) => ['left', 'center', 'right'].includes(v),
    default: 'left',
  },
  headerAlign: {
    type: String,
    validator: (v) => ['left', 'center', 'right'].includes(v),
    default: 'left',
  },
  toolbar: {
    type: Boolean,
    default: true,
  },
  toolBtnList: {
    type: Array,
    default: () => [],
  },
  isShowHeaderSetting: {
    type: Boolean,
    default: true,
  },
  // 数据源配置
  data: {
    type: Array,
    default: () => [],
  },

  // 列配置
  mainColumns: {
    type: Array,
    default: () => [],
  },

  // 扩展字段
  extendColumns: {
    type: Array,
    default: () => [],
  },
  // 扩展字段加载配置
  autoLoadExtendColumns: {
    type: Boolean,
    default: false,
  },
  // 扩展字段接口名称
  fetchExtendColumnsApi: {
    type: Function,
    default: null,
  },
  // 扩展字段接口入参
  extendColumnsParams: {
    type: Object,
    default: () => ({}),
  },

  // 操作列配置
  operationColumn: {
    type: Object,
    default: () => ({
      label: '操作',
      width: '200px',
      fixed: 'right',
      buttons: [], // 操作按钮配置
    }),
  },
})

// 定义Emits
const emit = defineEmits([
  'update:pageSize',
  'update:currentPage',
  'register',
  'selection-change',
  'action-click',
  'extend-columns-loaded',
  'update:extendLoading',
  'page-change',
  'size-change',
  'pagination-change',
])

// 获取插槽
const slots = useSlots()

// 表格Ref
const elTableRef = ref(null)

// 内部分页状态
const internalPageSize = ref(props.pageSize)
const internalCurrentPage = ref(props.currentPage)

// 选中的数据
const selections = ref([])

// 内部扩展列状态
const internalExtendColumns = ref([])

// 属性继承
const attrs = useAttrs()

// 表格属性(排除特定配置项)
const tableAttrs = computed(() => {
  const attr = { ...attrs }
  const excludeKeys = [
    'data',
    'mainColumns',
    'extendColumns',
    'operationColumn',
    'pagination',
    'total',
  ]
  Object.keys(props).forEach((key) => {
    if (!excludeKeys.includes(key)) {
      attr[key] = props[key]
    }
  })
  return attr
})
const toolBtnAttrs = (btn) => {
  const delArr = ['title', 'preIcon', 'postIcon', 'onClick']
  const obj = { ...btn }
  for (const key in obj) {
    if (delArr.indexOf(key) !== -1) {
      delete obj[key]
    }
  }
  return obj
}
// 最终的主表列
const finalMainColumns = computed(() => {
  return props.mainColumns.length > 0 ? props.mainColumns : DEFAULT_MAIN_COLUMNS
})

// 最终的扩展列
const finalExtendColumns = computed(() => {
  return [...props.extendColumns, ...internalExtendColumns.value]
})

// 是否显示主表列
const displayMainColumns = computed(() => {
  if (finalExtendColumns.value.length > 0 && props.mainColumns.length === 0) {
    return finalMainColumns.value.filter(
      (col) => col.type === 'index' || ['id', 'name'].includes(col.field),
    )
  }
  return finalMainColumns.value
})
const tableHeader = ref([...displayMainColumns.value])

const setColumnConfig = (data) => {
  if (data && data.length) {
    tableHeader.value = data.filter((ele) => ele.checked)
  } else {
    tableHeader.value = [...displayMainColumns]
  }
}
// 分页配置合并
const mergedPagination = computed(() => {
  const defaultPagination = {
    small: false,
    background: true,
    pagerCount: document.body.clientWidth < 992 ? 5 : 7,
    layout: 'total, sizes,->, prev, pager, next, jumper',
    pageSizes: [10, 20, 30, 50, 100],
    disabled: false,
    hideOnSinglePage: false,
    total: props.total,
  }

  if (props.pagination === true) {
    return defaultPagination
  } else if (typeof props.pagination === 'object') {
    return {
      ...defaultPagination,
      ...props.pagination,
      total:
        props.pagination.total !== undefined
          ? props.pagination.total
          : props.total,
    }
  }

  return defaultPagination
})

// 是否有操作列
const hasOperationColumn = computed(() => {
  // 如果有操作列插槽
  if (hasSlot('operation')) return true

  // 如果operationColumn配置了buttons
  return (
    props.operationColumn &&
    props.operationColumn.buttons &&
    props.operationColumn.buttons.length > 0
  )
})

// 获取操作按钮配置
const getOperationButtons = (row) => {
  if (!props.operationColumn || !props.operationColumn.buttons) return []

  // 处理动态按钮配置(支持函数)
  return props.operationColumn.buttons
    .map((btn) => {
      // 如果是函数,执行获取配置
      if (typeof btn === 'function') {
        return btn(row)
      }

      // 如果有hidden条件,处理显示隐藏
      if (
        btn.hidden &&
        (typeof btn.hidden === 'function' ? btn.hidden(row) : btn.hidden)
      ) {
        return null
      }

      // 包装点击事件,触发action-click事件
      const onClick = (...args) => {
        if (btn.onClick) {
          btn.onClick(...args)
        }
        emit('action-click', btn, row, ...args)
      }

      return {
        ...btn,
        onClick,
      }
    })
    .filter(Boolean) // 过滤掉null项
}

// 格式化单元格值
const formatCellValue = (column, data) => {
  if (column.formatter) {
    return column.formatter(
      data.row,
      data.column,
      data.row[column.field || column.prop],
      data.$index,
    )
  }
  return data.row[column.field || column.prop]
}

// 获取列属性(排除children)
const getColumnProps = (column) => {
  const props = { ...column }
  delete props.children
  return props
}

// 检查是否存在插槽
const hasSlot = (slotName) => {
  return !!slots[slotName]
}

// 加载扩展字段
const loadExtendColumns = async () => {
  if (!props.fetchExtendColumnsApi || !props.autoLoadExtendColumns) return

  try {
    emit('update:extendLoading', true)
    const response = await props.fetchExtendColumnsApi(
      props.extendColumnsParams,
    )

    if (response && response.code === 200 && Array.isArray(response.data)) {
      internalExtendColumns.value = response.data.map((col) => ({
        ...col,
        showOverflowTooltip:
          col.showOverflowTooltip ?? props.showOverflowTooltip,
        align: col.align ?? props.align,
        headerAlign: col.headerAlign ?? props.headerAlign,
      }))

      emit('extend-columns-loaded', internalExtendColumns.value)
    }
  } catch (error) {
    console.error('加载扩展字段失败:', error)
    internalExtendColumns.value = []
  } finally {
    emit('update:extendLoading', false)
  }
}

// 监听Props变化
watch(
  () => props.pageSize,
  (val) => {
    internalPageSize.value = val
  },
  { immediate: true },
)

watch(
  () => props.currentPage,
  (val) => {
    internalCurrentPage.value = val
  },
  { immediate: true },
)

// 监听total变化
watch(
  () => props.total,
  () => {
    // total变化时会自动更新mergedPagination
  },
)

// 监听每页条数变化
watch(
  () => internalPageSize.value,
  (val) => {
    emit('update:pageSize', val)
    emit('size-change', val)
    emit('pagination-change', {
      pageSize: val,
      currentPage: internalCurrentPage.value,
    })

    if (internalCurrentPage.value > 1) {
      internalCurrentPage.value = 1
    }
  },
)

// 监听当前页码变化
watch(
  () => internalCurrentPage.value,
  (val) => {
    emit('update:currentPage', val)
    emit('page-change', val)
    emit('pagination-change', {
      pageSize: internalPageSize.value,
      currentPage: val,
    })
  },
)

// 注册表格实例并加载扩展字段
onMounted(() => {
  if (elTableRef.value) {
    emit('register', elTableRef.value.$parent, elTableRef.value)
  }
  console.log(props.pageSize)

  if (props.autoLoadExtendColumns) {
    loadExtendColumns()
  }
})

// 处理选择变化
const handleSelectionChange = (selection) => {
  selections.value = selection
  emit('selection-change', selection)
}

// 暴露方法
defineExpose({
  elTableRef,
  selections,
  loadExtendColumns,
  internalPageSize,
  internalCurrentPage,
})
</script>

<style lang="scss" scoped>
.table-toolbar {
  .el-button {
    min-width: 86px;
  }
}
.table-content {
  flex: 1;
  background-color: #fff;
  box-shadow: 0px 3px 8px 0px rgba(0, 0, 0, 0.07);
  border-radius: 4px;
  padding: 20px 24px;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
</style>

操作列组件

<template>
  <div class="operation-btns">
    <template v-for="(btn, index) in preList" :key="btn.label">
      <el-divider direction="vertical" v-if="index > 0" />
      <XTextButton type="primary" :preIcon="btn.icon" :title="btn.label" @click="btn.onClick(row)" />
    </template>
    <el-divider direction="vertical" v-if="isShowMore" />
 
    <el-dropdown placement="bottom-end" trigger="click" v-if="isShowMore">
      <el-icon style="cursor: pointer" color="#999"><MoreFilled /></el-icon>
      <template #dropdown>
        <el-dropdown-menu>
          <el-dropdown-item command="license" v-for="btn in postList" :key="btn.label">
            <XTextButton
              :type="btn.type"
              :preIcon="btn.icon"
              :title="btn.label"
              @click="btn.onClick(row)"
            />
          </el-dropdown-item>
        </el-dropdown-menu>
      </template>
    </el-dropdown>
  </div>
</template>
 
<script setup>
const props = defineProps({
  listBtns: {
    type: Array,
    default: () => [
      // 数据源示例
      // {
      //   label: '编辑',
      //   icon: 'ep:edit',
      //   type: 'primary',
      //   onClick: () => {
      //     console.log(11111)
      //   }
      // },
      // {
      //   label: '新增',
      //   icon: 'ep:circle-plus',
      //   type: 'primary',
      //   onClick: () => {
      //     console.log(11111)
      //   }
      // },
      // {
      //   label: '角色授权',
      //   icon: 'svg-icon:roleLicensing',
      //   type: '',
      //   onClick: () => {
      //     console.log(11111)
      //   }
      // },
      // {
      //   label: '删除',
      //   icon: 'ep:delete',
      //   type: 'danger',
      //   onClick: () => {
      //     console.log(11111)
      //   }
      // }
    ]
  },
  row: Object
})
// 是否显示更多按钮
const isShowMore = computed(() => {
  return props.listBtns.length > 3
})
const preList = computed(() => {
  return isShowMore.value ? props.listBtns.slice(0, 2) : props.listBtns
})
const postList = computed(() => {
  return isShowMore.value ? props.listBtns.slice(2, props.listBtns.length) : []
})
</script>
 
<style lang="scss" scoped>
.operation-btns {
  display: flex;
  align-items: center;
}
</style>

控制操作列显隐组件

<template>
  <div class="config" @click="handleheaderSettingClick">
    <Icon icon="svg-icon:setting" :size="16" />
    <ElDialog
      class="header-setting-dialog-class"
      v-model="dialogVisible"
      width="500px"
      :scroll="true"
      append-to-body
      top="10vh"
      maxHeight="580px"
    >
      <template #header>
        <div class="header">列表字段定制</div>
      </template>
      <div>
        <div class="groupTitle"> 基本属性 <span>(可拖动协调顺序)</span></div>
        <el-checkbox-group v-model="checkedItems" class="checkbox-group">
          <div class="draggable-list" @dragover.prevent @drop="handleDrop">
            <div
              v-for="(item, index) in listItems"
              :key="item.prop"
              class="draggable-item"
              :draggable="!item.disabled"
              @dragstart="handleDragStart(index)"
              @dragend="handleDragEnd"
            >
              <el-checkbox
                :label="item.prop"
                :disabled="item.disabled"
                class="item-checkbox"
              >
                <span class="item-label">{{ item.label }}</span>
              </el-checkbox>
            </div>
          </div>
        </el-checkbox-group>
      </div>
      <template #footer>
        <el-button @click="handleSave" type="primary">确定</el-button>
      </template>
    </ElDialog>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const { wsCache } = useCache()
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'

const $emit = defineEmits(['submit'])
const props = defineProps({
  // 页面表格编码,需系统唯一
  pageCode: {
    type: String,
    default: '',
  },
  defaultColumn: {
    type: Array,
    default: () => [],
    required: true,
  },
  modelValue: {
    type: Array,
    default: () => [],
  },
})
const dialogVisible = ref(false)
const listItems = ref([])
const checkedItems = ref([])
const draggedIndex = ref(null)

// 拖拽排序相关方法
const handleDragStart = (index) => {
  draggedIndex.value = index
  // 添加拖拽时的视觉效果类
  setTimeout(() => {
    const items = document.querySelectorAll('.draggable-item')
    if (items[index]) {
      items[index].classList.add('dragging')
    }
  }, 0)
}

const handleDragEnd = () => {
  draggedIndex.value = null
  // 移除拖拽时的视觉效果类
  document.querySelectorAll('.draggable-item').forEach((item) => {
    item.classList.remove('dragging')
  })
}

const handleDrop = (e) => {
  e.preventDefault()
  if (draggedIndex.value === null) {
    return
  }

  // 获取放置目标的索引
  const dropTarget = e.target.closest('.draggable-item')
  if (!dropTarget) {
    return
  }

  const dropIndex = Array.from(dropTarget.parentElement.children).indexOf(
    dropTarget,
  )

  // 避免在同一位置放置
  if (draggedIndex.value !== dropIndex) {
    // 复制数组并重新排序
    const newItems = [...listItems.value]
    const [movedItem] = newItems.splice(draggedIndex.value, 1)
    newItems.splice(dropIndex, 0, movedItem)

    // 更新列表
    listItems.value = newItems
  }
}

const handleheaderSettingClick = () => {
  dialogVisible.value = true
}

const handleSave = async () => {
  console.log(checkedItems.value)

  listItems.value.forEach((ele) => {
    ele.checked = checkedItems.value.includes(ele.prop)
  })
  if (props.pageCode) {
    const headerSetting = wsCache.get(CACHE_KEY.TABLE_HEADER_SETTING) || {}
    headerSetting[props.pageCode] = listItems.value
    wsCache.set(CACHE_KEY.TABLE_HEADER_SETTING, headerSetting, { exp: 600 })
  }

  initColumn()
  dialogVisible.value = false
}

const initColumn = () => {
  if (props.pageCode) {
    const headerSetting = wsCache.get(CACHE_KEY.TABLE_HEADER_SETTING) || {}
    if (headerSetting[props.pageCode]?.length) {
      let list = [...headerSetting[props.pageCode]]
      handleData(list)
    } else {
      handleData(props.defaultColumn)
    }
  } else {
    handleData(props.defaultColumn)
  }
}

const handleData = (newVal) => {
  listItems.value = newVal
  const checkedList = listItems.value
    .filter((s) => s.checked)
    .map((e) => e.prop)
  checkedItems.value = [...checkedList, ...props.modelValue]
  console.log(checkedItems.value, 111111111111, listItems.value)

  $emit('submit', listItems.value)
}

initColumn()
</script>

<style lang="scss">
.header-setting-dialog-class {
  .el-dialog__body {
    height: 400px;
    overflow: hidden;
    overflow-y: auto;
    font-size: 16px;
    color: #333333;
    line-height: 28px;
    display: flex;
    padding: 30px 50px;

    .groupTitle {
      font-size: 14px;
      color: #333333;
      font-weight: 700;
      margin-left: 15px;
      margin-bottom: 10px;
      span {
        font-size: 14px;
        color: #999999;
        font-weight: 400;
      }
    }
    .el-checkbox-group {
      display: flex;
      flex-direction: column;
      .el-checkbox__label {
        color: #333333;
      }
      .el-checkbox {
        margin-left: 15px;
      }
    }
  }

  /* 仅在拖动状态添加边框 */
  .draggable-item.dragging {
    background: rgba(48, 126, 255, 0.07);
    border: 1px dashed rgba(48, 126, 255, 0.7);
    border-radius: 2px;
  }
}
</style>
<style scoped>
.config {
  width: 30px;
  height: 30px;
  background: #ffffff;
  border: 1px solid rgba(217, 217, 217, 1);
  border-radius: 3px;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
}
</style>
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容