🚀 Vue3 + Element Plus 实战:封装一个“可配置列 + 拖拽 + 固定 + 全屏”的 TableSetting 组件

在企业后台系统里,表格几乎是最核心的组件之一。

但随着业务复杂度提升,传统 el-table 往往会遇到几个典型问题:

  • 列太多,用户想隐藏/显示列
  • 列顺序无法调整
  • 需要支持固定列(left / right)
  • 表格需要全屏查看
  • 需要统一工具栏能力(刷新、密度、斑马纹等)

这篇文章带你实现一个 Vue3 + Element Plus 的 TableSetting 表格增强组件,支持:

✅ 列显示/隐藏

✅ 拖拽排序列

✅ 左右固定列

✅ 表格密度切换

✅ 斑马纹开关

✅ 全屏模式

✅ 刷新事件

✅ slot 驱动表格渲染

📦 一、组件设计思路

这个组件的核心思想是:

TableSetting 不直接渲染表格,而是"控制表格行为 + 提供配置能力"

真正的表格(如 gi-table / el-table)通过 slot 注入:

ruby 复制代码
<slot :setting-columns="settingColumns" :table-props="tableProps" />

也就是说:

  • TableSetting = "控制器"
  • gi-table = "渲染器"

使用示例

xml 复制代码
<template>
  <gi-page-layout bordered style="height: 520px">
    <template #tool>
      <el-row justify="space-between" class="g-tool g-w-full">
        <el-space wrap>
          <gi-button type="add"></gi-button>
          <gi-button type="delete"></gi-button>
        </el-space>
        <el-space wrap>
          <el-input v-model="queryParams.keyword" placeholder="搜索姓名或地址" clearable style="width: 200px" />
          <ElButton type="primary" @click="search">搜索</ElButton>
        </el-space>
      </el-row>
    </template>
    <TableSetting title="表格工具栏" :columns="columns" :disabled-column-keys="disabledColumnKeys" @refresh="refresh">
      <template #default="{ settingColumns, tableProps }">
        <gi-table v-loading="loading" v-bind="tableProps" :columns="settingColumns" :data="tableData"
          :pagination="pagination">
          <template #action="scope">
            <el-space>
              <ElButton type="primary" size="small" @click="onEdit(scope.row)">编辑</ElButton>
              <ElButton type="danger" size="small">删除</ElButton>
            </el-space>
          </template>
        </gi-table>
      </template>
    </TableSetting>
  </gi-page-layout>
</template>

<script lang="ts" setup>
import type { UserItem } from '@docs/_apis/mockTable'
import type { TableColumnItem } from 'gi-component'
import { getUserList } from '@docs/_apis/mockTable'
import { useTable } from '@docs/_hooks'
import { ElButton, ElMessage, ElTag } from 'element-plus'
import { h, reactive } from 'vue'
import TableSetting from './components/TableSetting.vue'

const columns: TableColumnItem[] = [
  { type: 'selection', width: 55, align: 'center', fixed: 'left' },
  { type: 'index', label: '序号', width: 60, align: 'center' },
  {
    prop: 'name',
    label: '姓名',
    width: 100,
    align: 'center',
    showOverflowTooltip: true
  },
  { prop: 'age', label: '年龄', width: 60, align: 'center' },
  {
    prop: 'sex',
    label: '性别',
    width: 80,
    align: 'center',
    render: ({ row }) => {
      return h(
        ElTag,
        { type: row.sex === '男' ? 'primary' : 'danger', size: 'small' },
        { default: () => row.sex }
      )
    }
  },
  {
    prop: 'address',
    label: '地址'
  },
  { prop: 'remark', label: '描述', width: 150, showOverflowTooltip: true },
  {
    prop: 'action',
    label: '操作',
    width: 140,
    align: 'center',
    slotName: 'action',
    fixed: 'right'
  }
]

/** 与 TableSetting 内 getColumnKey 规则一致:无 prop 的列用 __type_{type}_{index}__(多选列不在列设置中) */
const disabledColumnKeys = ['__type_index_1__', 'name']

const queryParams = reactive({
  keyword: ''
})

const { tableData, pagination, search, refresh, loading } = useTable(
  (p) => getUserList({ ...p, ...queryParams }),
  {
    onSuccess: () => { }
  }
)

function onEdit(row: UserItem) {
  ElMessage.success(`编辑 ${row.name}`)
}
</script>

<style lang="scss" scoped>
.demo-table-setting__title {
  font-size: 14px;
  font-weight: 500;
  line-height: 32px;
  color: var(--el-text-color-primary);
}
</style>

组件源码

xml 复制代码
<template>
  <div class="table-setting" :class="{ 'table-setting--fullscreen': isFullscreen }">
    <div class="table-setting__toolbar">
      <div class="table-setting__toolbar-left">
        <slot name="toolbar-left">
          <span class="table-setting__title">{{ title }}</span>
        </slot>
      </div>
      <el-space wrap :size="8">
        <el-tooltip content="斑马纹" placement="top">
          <el-switch v-model="stripe" size="small" />
        </el-tooltip>
        <el-tooltip content="刷新" placement="top">
          <el-button class="table-setting__icon-btn" bg text circle @click="emit('refresh')">
            <el-icon :size="14">
              <RefreshRight />
            </el-icon>
          </el-button>
        </el-tooltip>
        <el-tooltip :content="isFullscreen ? '退出全屏' : '全屏'" placement="top">
          <el-button class="table-setting__icon-btn" bg text circle @click="toggleFullscreen">
            <el-icon :size="14">
              <ScaleToOriginal v-if="isFullscreen" />
              <FullScreen v-else />
            </el-icon>
          </el-button>
        </el-tooltip>
        <el-tooltip content="显示边框" placement="top">
          <el-button class="table-setting__icon-btn" bg text circle @click="toggleBorder">
            <el-icon :size="14">
              <Grid />
            </el-icon>
          </el-button>
        </el-tooltip>
        <el-dropdown trigger="click" @command="handleSizeCommand">
          <span class="el-dropdown-link">
            <el-tooltip content="表格尺寸" placement="top">
              <el-button class="table-setting__icon-btn" bg text circle>
                <el-icon :size="14">
                  <Switch />
                </el-icon>
              </el-button>
            </el-tooltip>
          </span>
          <template #dropdown>
            <el-dropdown-menu>
              <el-dropdown-item v-for="item in TABLE_SIZE_OPTIONS" :key="item.label" :command="item.value"
                :class="{ 'is-active': item.value === size }">
                {{ item.label }}
              </el-dropdown-item>
            </el-dropdown-menu>
          </template>
        </el-dropdown>
        <el-popover v-if="showColumnSetting" placement="bottom-end" :width="180" trigger="click"
          transition="el-zoom-in-top">
          <template #reference>
            <el-button type="primary" bg text circle>
              <el-icon :size="14">
                <Setting />
              </el-icon>
            </el-button>
          </template>
          <div class="table-setting__popover">
            <el-scrollbar class="table-setting__draggable" height="200px" :wrap-style="{ overflowX: 'hidden' }">
              <VueDraggable v-model="settingColumnList" :animation="150" handle=".table-setting__drag-handle">
                <div v-for="item in settingColumnList" :key="item.key" class="table-setting__draggable-item">
                  <span class="table-setting__drag-handle">
                    <el-icon :size="14">
                      <Rank />
                    </el-icon>
                  </span>
                  <el-checkbox v-model="item.show" :disabled="item.disabled" class="table-setting__checkbox">
                    {{ item.title }}
                  </el-checkbox>
                  <div class="table-setting__pins">
                    <span class="table-setting__pin-btn" :class="{ 'is-active': item.fixedLeft }"
                      @click.stop="toggleFixedLeft(item.key)">
                      <el-icon :size="14">
                        <LocationFilled />
                      </el-icon>
                    </span>
                    <span class="table-setting__pin-btn table-setting__pin-btn--right"
                      :class="{ 'is-active': item.fixedRight }" @click.stop="toggleFixedRight(item.key)">
                      <el-icon :size="14">
                        <LocationFilled />
                      </el-icon>
                    </span>
                  </div>
                </div>
              </VueDraggable>
            </el-scrollbar>
            <el-divider style="margin: 8px 0" />
            <el-button type="primary" size="small" style="width: 100%" @click="resetSettingColumns">
              <el-icon class="el-icon--left">
                <RefreshRight />
              </el-icon>
              重置
            </el-button>
          </div>
        </el-popover>
      </el-space>
    </div>
    <div class="table-setting__body">
      <slot :setting-columns="settingColumns" :is-fullscreen="isFullscreen" :table-props="tableProps" />
    </div>
  </div>
</template>

<script lang="ts" setup>
import type { TableColumnItem } from 'gi-component'
import {
  FullScreen,
  Grid,
  LocationFilled,
  Rank,
  RefreshRight,
  ScaleToOriginal,
  Setting,
  Switch
} from '@element-plus/icons-vue'
import { computed, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'

export interface TableSettingColumnItem {
  key: string
  title: string
  show: boolean
  disabled: boolean
  fixedLeft: boolean
  fixedRight: boolean
}

/** Element Plus 表格 size */
export type TableSettingSize = 'small' | 'default' | 'large'

interface TableSettingProps {
  title?: string
  columns?: TableColumnItem[]
  /** 不允许在列设置中切换显示/隐藏的列 key(仍可拖拽、固定) */
  disabledColumnKeys?: string[]
}

const props = withDefaults(defineProps<TableSettingProps>(), {
  title: '',
  columns: () => [],
  disabledColumnKeys: () => []
})

const emit = defineEmits<{
  refresh: []
}>()

const TABLE_SIZE_OPTIONS: { label: string, value: TableSettingSize }[] = [
  { label: '迷你', value: 'small' },
  { label: '中等', value: 'default' },
  { label: '大型', value: 'large' }
]

const stripe = ref(false)
const size = ref<TableSettingSize>('default')
const border = ref(true)
const isFullscreen = ref(false)

const tableProps = computed(() => ({
  stripe: stripe.value,
  border: border.value,
  size: size.value
}))

function toggleFullscreen() {
  isFullscreen.value = !isFullscreen.value
}

function toggleBorder() {
  border.value = !border.value
}

function handleSizeCommand(value: string | number | object) {
  size.value = value as TableSettingSize
}

/** 参与列设置(可拖拽 / 显隐 / 固定)的列;多选列不参与 */
function isColumnInSettingList(column: TableColumnItem): boolean {
  return column.type !== 'selection'
}

const showColumnSetting = computed(() => {
  const cols = props.columns ?? []
  return cols.some((c) => isColumnInSettingList(c))
})

function getColumnKey(column: TableColumnItem, index: number): string {
  if (column.prop != null && column.prop !== '')
    return String(column.prop)
  if (column.type)
    return `__type_${String(column.type)}_${index}__`
  if (typeof column.label === 'string' && column.label)
    return column.label
  return `__column_${index}__`
}

function columnTitle(column: TableColumnItem): string {
  const lab = column.label
  return typeof lab === 'string' ? lab : ''
}

const initialSettingColumns = computed<TableSettingColumnItem[]>(() => {
  const list = props.columns ?? []
  const out: TableSettingColumnItem[] = []
  list.forEach((column, index) => {
    if (!isColumnInSettingList(column))
      return
    const key = getColumnKey(column, index)
    const fixed = column.fixed
    out.push({
      key,
      title: columnTitle(column),
      show: true,
      disabled: props.disabledColumnKeys.includes(key),
      fixedLeft: fixed === 'left',
      fixedRight: fixed === 'right'
    })
  })
  return out
})

const settingColumnList = ref<TableSettingColumnItem[]>([])

function isColumnStructureMatch(
  user: TableSettingColumnItem[],
  initial: TableSettingColumnItem[]
): boolean {
  if (user.length === 0 || user.length !== initial.length)
    return false
  const initialKeys = new Set(initial.map((i) => i.key))
  const userKeys = new Set(user.map((i) => i.key))
  return initialKeys.size === userKeys.size && [...initialKeys].every((k) => userKeys.has(k))
}

const columnMap = computed(() => {
  const list = props.columns ?? []
  return new Map(list.map((col, index) => [getColumnKey(col, index), col]))
})

function resetSettingColumns() {
  settingColumnList.value = initialSettingColumns.value.map((i) => ({ ...i }))
}

function ensureSettingColumnList() {
  if (settingColumnList.value.length === 0 && initialSettingColumns.value.length > 0)
    settingColumnList.value = initialSettingColumns.value.map((i) => ({ ...i }))
}

function toggleFixedLeft(key: string) {
  ensureSettingColumnList()
  settingColumnList.value = settingColumnList.value.map((item) =>
    item.key === key ? { ...item, fixedLeft: !item.fixedLeft, fixedRight: false } : item
  )
}

function toggleFixedRight(key: string) {
  ensureSettingColumnList()
  settingColumnList.value = settingColumnList.value.map((item) =>
    item.key === key ? { ...item, fixedRight: !item.fixedRight, fixedLeft: false } : item
  )
}

watch(
  initialSettingColumns,
  (next) => {
    if (next.length === 0) {
      settingColumnList.value = []
      return
    }
    if (!isColumnStructureMatch(settingColumnList.value, next))
      settingColumnList.value = next.map((i) => ({ ...i }))
  },
  { immediate: true }
)

/** 多选列始终保留在表格中,且不参与列设置列表;按原始 columns 顺序排在最前 */
const selectionColumnsPrefix = computed(() => {
  const cols = props.columns ?? []
  return cols.filter((c) => c.type === 'selection') as TableColumnItem[]
})

const settingColumns = computed<TableColumnItem[]>(() => {
  const cols = props.columns ?? []
  if (!cols.length)
    return []

  const prefix = selectionColumnsPrefix.value

  if (!settingColumnList.value.length)
    return prefix.length ? [...prefix] : []

  const shown = settingColumnList.value.filter((item) => item.show)
  const leftFixed: typeof shown = []
  const noFixed: typeof shown = []
  const rightFixed: typeof shown = []
  for (const item of shown) {
    if (item.fixedLeft)
      leftFixed.push(item)
    else if (item.fixedRight)
      rightFixed.push(item)
    else
      noFixed.push(item)
  }
  const ordered = [...leftFixed, ...noFixed, ...rightFixed]

  const body = ordered
    .map((item) => {
      const col = columnMap.value.get(item.key)
      if (!col)
        return null
      const fixed = item.fixedRight ? 'right' : item.fixedLeft ? 'left' : undefined
      return { ...col, fixed } as TableColumnItem
    })
    .filter(Boolean) as TableColumnItem[]

  return [...prefix, ...body]
})
</script>

<style lang="scss" scoped>
.table-setting {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow: hidden;
  background: var(--el-bg-color);

  &__title {
    font-size: 15px;
    font-weight: 600;
    line-height: 32px;
    color: var(--el-text-color-primary);
  }

  &--fullscreen {
    position: fixed;
    inset: 0;
    z-index: 2000;
    padding: 12px;
    box-sizing: border-box;
  }

  &__toolbar {
    display: flex;
    flex-shrink: 0;
    flex-wrap: wrap;
    gap: 8px;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 8px;
  }

  &__toolbar-left {
    flex: 1;
    min-width: 0;
  }

  &__body {
    flex: 1;
    min-height: 0;
    overflow: hidden;
  }

  &__draggable {
    box-sizing: border-box;
    padding: 2px 0;
    box-sizing: border-box;

    :deep(.el-scrollbar__wrap) {
      overflow-x: hidden !important;
    }

    :deep(.el-scrollbar__view) {
      box-sizing: border-box;
      min-width: 0;
      overflow-x: hidden;
    }
  }

  &__draggable-item {
    display: flex;
    align-items: center;
    padding: 2px 4px;
    cursor: pointer;
    border-radius: var(--el-border-radius-small);
    box-sizing: border-box;

    &:hover {
      background-color: var(--el-fill-color-light);
    }
  }

  &__drag-handle {
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    padding: 0 4px;
    box-sizing: border-box;
    color: var(--el-text-color-secondary);
    cursor: move;
  }

  &__checkbox {
    flex: 1;
    min-width: 0;
    margin-right: 4px;
    font-size: 12px;

    :deep(.el-checkbox__label) {
      font-size: 12px;
      color: var(--el-text-color-regular);
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
  }

  :deep(.el-checkbox.is-disabled .el-checkbox__label) {
    color: var(--el-text-color-placeholder);
  }

  &__pins {
    display: flex;
    flex-shrink: 0;
    gap: 2px;
    align-items: center;
    margin-left: auto;
    padding-right: 10px;
  }

  &__pin-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 2px;
    color: var(--el-text-color-placeholder);
    cursor: pointer;
    transition: color 0.2s;

    &:hover {
      color: var(--el-text-color-secondary);
    }

    &.is-active {
      color: var(--el-color-primary);
    }

    &--right {
      transform: scaleX(-1);
    }
  }
}

:deep(.el-dropdown-menu__item.is-active) {
  font-weight: 600;
  color: var(--el-color-primary);
}
</style>

🧱 二、核心能力拆解

1️⃣ 列结构标准化

先定义统一的列配置结构:

typescript 复制代码
export interface TableSettingColumnItem {  key: string  title: string  show: boolean  disabled: boolean  fixedLeft: boolean  fixedRight: boolean}

它是 TableSetting 内部"中间态"。

2️⃣ Column Key 生成策略(非常关键)

为了支持各种 column(index / selection / prop),设计统一 key:

javascript 复制代码
function getColumnKey(column, index) {  if (column.prop) return String(column.prop)  if (column.type) return `__type_${column.type}_${index}__`  if (column.label) return column.label  return `__column_${index}__`}

👉 这样可以保证:

  • selection / index 不冲突
  • render column 可识别
  • 外部 disabledColumnKeys 可控制

3️⃣ 表格配置状态(核心)

内部状态:

csharp 复制代码
const stripe = ref(false)const size = ref<'small' | 'default' | 'large'>('default')const border = ref(true)const isFullscreen = ref(false)

统一导出:

arduino 复制代码
const tableProps = computed(() => ({  stripe: stripe.value,  border: border.value,  size: size.value}))

👉 外部只需要:

ini 复制代码
v-bind="tableProps"

🎛 三、工具栏功能实现

1️⃣ 全屏切换

csharp 复制代码
function toggleFullscreen() {  isFullscreen.value = !isFullscreen.value}

配合样式:

css 复制代码
.table-setting--fullscreen {  position: fixed;  inset: 0;  z-index: 2000;}

2️⃣ 表格密度切换

ini 复制代码
const TABLE_SIZE_OPTIONS = [  { label: '迷你', value: 'small' },  { label: '中等', value: 'default' },  { label: '大型', value: 'large' }]

3️⃣ 刷新事件(外部控制)

ini 复制代码
const emit = defineEmits<{  refresh: []}>()

@refresh="refresh"

🧩 四、列设置系统(核心亮点)

这是整个组件最复杂的部分。

1️⃣ 初始化列数据

less 复制代码
const initialSettingColumns = computed(() => {  return props.columns.map((column, index) => ({    key: getColumnKey(column, index),    title: column.label,    show: true,    disabled: props.disabledColumnKeys.includes(key),    fixedLeft: column.fixed === 'left',    fixedRight: column.fixed === 'right'  }))})

2️⃣ 响应式列状态

csharp 复制代码
const settingColumnList = ref<TableSettingColumnItem[]>([])

watch 自动同步:

javascript 复制代码
watch(initialSettingColumns, (next) => {  settingColumnList.value = next.map(i => ({ ...i }))})

3️⃣ 拖拽排序(VueDraggable)

ini 复制代码
<VueDraggable v-model="settingColumnList" :animation="150">

实现:

  • 拖拽调整顺序
  • 实时更新列渲染顺序

4️⃣ 固定列逻辑

php 复制代码
function toggleFixedLeft(key) {  settingColumnList.value = settingColumnList.value.map(item =>    item.key === key      ? { ...item, fixedLeft: !item.fixedLeft, fixedRight: false }      : item  )}

👉 左右互斥设计,避免冲突

🧮 五、最终输出列(核心 computed)

这是 TableSetting 的"最终结果"。

scss 复制代码
const settingColumns = computed(() => {  const shown = settingColumnList.value.filter(i => i.show)  const left = []  const center = []  const right = []  for (const item of shown) {    if (item.fixedLeft) left.push(item)    else if (item.fixedRight) right.push(item)    else center.push(item)  }  const ordered = [...left, ...center, ...right]  return ordered.map(item => {    const col = columnMap.value.get(item.key)    return {      ...col,      fixed: item.fixedLeft ? 'left' : item.fixedRight ? 'right' : undefined    }  })})

🧪 六、使用方式(非常清晰)

1️⃣ 定义 columns

css 复制代码
const columns: TableColumnItem[] = [  { type: 'selection', width: 55 },  { type: 'index', label: '序号', width: 60 },  { prop: 'name', label: '姓名' },  { prop: 'age', label: '年龄' },  { prop: 'address', label: '地址' },]

2️⃣ 使用 TableSetting

ini 复制代码
<TableSetting  title="表格工具栏"  :columns="columns"  @refresh="refresh">

3️⃣ 接收 settingColumns

xml 复制代码
<template #default="{ settingColumns, tableProps }">  <gi-table    v-bind="tableProps"    :columns="settingColumns"    :data="tableData"  /></template>

💡 七、这个组件的设计亮点

1️⃣ "控制器 vs 渲染器"解耦

TableSetting 不关心表格实现,只负责控制。

2️⃣ 列系统完全可扩展

支持:

  • selection
  • index
  • render column
  • slot column

3️⃣ 状态完全可组合

所有能力都是 ref + computed:

  • stripe
  • border
  • size
  • columns

4️⃣ 插槽驱动设计

完全支持:

ruby 复制代码
<slot :setting-columns :table-props />

👉 非常适合组件库设计

🚀 八、可以继续优化的方向

如果要继续增强,可以考虑:

1️⃣ 持久化列配置(localStorage)

2️⃣ 列宽拖拽调整

3️⃣ 列权限控制(RBAC)

4️⃣ 列配置导入导出

5️⃣ 服务端列配置同步

🧾 总结

这个 TableSetting 本质上做了三件事:

让一个普通 Table 变成"可配置数据面板"

它的价值在于:

  • 提升后台系统体验
  • 降低重复开发成本
  • 统一表格交互规范
  • 提升组件库能力边界
相关推荐
前端小蜗1 小时前
转生到 AI 时代,我不再相信一键生成代码的传说
前端·人工智能·架构
文心快码BaiduComate2 小时前
520,Comate Mission模式跨越界限,和你达成最「深」联动
前端·数据库·后端
来恩10032 小时前
Java Web三大作用域对象
java·开发语言·前端
在繁华处2 小时前
轻棋局(四):前端 SPA 实战
前端
不是山谷.:.2 小时前
前端性能优化全解析:从原理到落地,覆盖全领域与多技术栈
前端·笔记·性能优化·状态模式
sakana2 小时前
我开源了我的cgzskill,帮Claude装上长期记忆
前端
用户223586218202 小时前
如何在超大型的工程中使用 Claude Code?
前端·ios·claude
Amos_Web3 小时前
Rspack 源码解析 (2) —— 从 rspack build 到输出 dist,完整编译链路详解
前端·javascript
漓漾li3 小时前
每日面试题(2026-05-20)- 前端
前端·react.js