表格组件封装详解(含完整代码)

表格组件封装详解(含完整代码)

本文详细解析 布局容器 + 动态搜索栏 + 智能表格 三组件的封装逻辑、实现细节与标准用法,附带完整可运行代码。


一、整体架构与协作关系

🧩 组件职责划分

组件 职责 关键能力
LayoutContainer.vue 布局骨架 统一结构、操作按钮(刷新/显隐列/折叠搜索)
DynamicSearchBar.vue 动态表单 根据配置生成 input/select/date 等控件
SmartTable.vue 数据展示 自动请求、分页、字典转换、时间格式化

🔗 数据流图

scss 复制代码
[LayoutContainer]
   │
   ├─ #search → [DynamicSearchBar] ←→ params (v-model)
   │                │
   │                └── tableRef.getList() ←──┐
   │                                          │
   └─ #default → [SmartTable] ←───────────────┘
                   ↑
             columns (响应式数组)
             tableRef (expose 方法)

✅ 关键设计:

  • columns 是共享状态 :LayoutContainer 修改 .hide → SmartTable 自动响应
  • tableRef 是方法通道:SearchBar 和 LayoutContainer 通过它触发表格刷新

二、智能表格组件(SmartTable.vue)

💡 封装目标

  • 自动处理分页、排序、加载状态
  • 支持字典、时间、链接等常见字段类型
  • 提供插槽覆盖默认渲染

📄 完整代码

vue 复制代码
<!-- SmartTable.vue -->
<template>
  <div class="smart-table">
    <el-table
      ref="tableRef"
      v-loading="loading"
      :data="tableData"
      v-bind="mergedConfig.table"
      @sort-change="handleSortChange"
    >
      <!-- 遍历 columns 渲染列 -->
      <template v-for="column in visibleColumns" :key="column.prop">
        <!-- selection / index 列 -->
        <el-table-column
          v-if="column.type === 'selection'"
          type="selection"
          :width="column.width || 55"
        />
        <el-table-column
          v-else-if="column.type === 'index'"
          type="index"
          :label="column.label"
          :width="column.width || 60"
        />

        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :sortable="column.sortable || false"
          :show-overflow-tooltip="true"
        >
          <template #default="{ row }">
            <!-- 插槽优先 -->
            <slot
              :name="column.slot"
              :row="row"
              :column="column"
              v-if="column.slot"
            />
            <!-- 字典标签 -->
            <dict-tag
              v-else-if="column.dict"
              :value="row[column.prop]"
              :dict-key="column.dict"
            />
            <!-- 时间格式化 -->
            <span v-else-if="column.date">
              {{ formatDate(row[column.prop], column.dateFormat) }}
            </span>
            <!-- 链接 -->
            <el-link
              v-else-if="column.link"
              type="primary"
              @click="handleLinkClick(column, row)"
            >
              {{ row[column.prop] }}
            </el-link>
            <!-- 默认文本 -->
            <span v-else>{{ row[column.prop] }}</span>
          </template>

          <!-- 表头提示 -->
          <template #header>
            <span>{{ column.label }}</span>
            <el-tooltip
              v-if="column.tip"
              :content="column.tip.content"
              placement="top"
            >
              <i class="el-icon-question" style="margin-left: 4px; color: #999"></i>
            </el-tooltip>
          </template>
        </el-table-column>
      </template>

      <!-- 空状态 -->
      <template #empty>
        <div class="no-data">
          <img src="@/assets/images/no-data.png" alt="无数据" />
          <p>暂无数据</p>
        </div>
      </template>
    </el-table>

    <!-- 分页 -->
    <el-pagination
      v-if="!mergedConfig.notPagination && total > 0"
      v-model:current-page="queryParams.pageNum"
      v-model:page-size="queryParams.pageSize"
      :total="total"
      layout="total, sizes, prev, pager, next, jumper"
      @size-change="handleSizeChange"
      @current-change="getList"
      v-bind="mergedConfig.pagination"
    />
  </div>
</template>

<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { parseTime } from '@/utils'

// Props
const props = defineProps({
  columns: {
    type: Array,
    required: true
  },
  func: {
    type: Function,
    required: true
  },
  params: {
    type: Object,
    default: () => ({})
  },
  config: {
    type: Object,
    default: () => ({})
  },
  events: {
    type: Object,
    default: () => ({})
  }
})

// Expose
const tableRef = ref(null)
defineExpose({
  getList,
  resetQuery,
  reload
})

// 响应式数据
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const queryParams = reactive({
  pageNum: 1,
  pageSize: 10,
  ...props.params
})

// 合并配置
const mergedConfig = computed(() => {
  return {
    table: {
      border: true,
      stripe: true,
      ...props.config.table
    },
    pagination: {
      background: true,
      pageSizes: [10, 20, 50, 100],
      ...props.config.pagination
    },
    sort: props.config.sort ?? false,
    notPagination: props.config.notPagination ?? false,
    autoPagination: props.config.autoPagination ?? false,
    initResquest: props.config.initResquest ?? true
  }
})

// 可见列(过滤 hide = true 的列)
const visibleColumns = computed(() => {
  return props.columns.filter(col => !col.hide)
})

// 格式化时间
function formatDate(value, format = '{y}-{m}-{d}') {
  if (!value) return ''
  return parseTime(value, format)
}

// 获取数据
async function getList() {
  try {
    loading.value = true

    // 触发 formatParams 事件
    let finalParams = { ...queryParams }
    if (props.events?.formatParams) {
      finalParams = props.events.formatParams(finalParams) || finalParams
    }

    const res = await props.func(finalParams)

    // 触发 formatData 事件
    let finalData = res
    if (props.events?.formatData) {
      finalData = props.events.formatData(res) || res
    }

    // 处理分页数据
    if (mergedConfig.value.autoPagination) {
      // 前端分页
      tableData.value = finalData.data || []
      total.value = tableData.value.length
    } else {
      // 后端分页
      tableData.value = finalData.data?.rows || []
      total.value = finalData.data?.total || 0
    }
  } catch (error) {
    console.error('表格请求失败:', error)
    tableData.value = []
    total.value = 0
  } finally {
    loading.value = false
  }
}

// 重置查询
function resetQuery() {
  queryParams.pageNum = 1
  getList()
}

// 强制重绘
function reload() {
  tableRef.value?.doLayout()
}

// 排序变更
function handleSortChange({ prop, order }) {
  if (mergedConfig.value.sort) {
    const sort = order ? { prop, order: order === 'ascending' ? 'asc' : 'desc' } : null
    if (props.events?.onSortChange) {
      props.events.onSortChange(queryParams, sort)
    }
    getList()
  }
}

// 分页大小变更
function handleSizeChange(val) {
  queryParams.pageSize = val
  getList()
}

// 链接点击
function handleLinkClick(column, row) {
  if (props.events?.onLinkClick) {
    props.events.onLinkClick(column, row)
  } else if (column.link?.name) {
    // 路由跳转
    router.push({
      name: column.link.name,
      params: typeof column.link.params === 'function'
        ? column.link.params(row)
        : column.link.params
    })
  }
}

// 初始化
onMounted(() => {
  if (mergedConfig.value.initResquest) {
    getList()
  }
})

// 监听外部 params 变更
watch(() => props.params, (newVal) => {
  Object.assign(queryParams, newVal)
}, { deep: true })
</script>

<style scoped>
.smart-table {
  width: 100%;
}
.no-data {
  text-align: center;
  padding: 40px 0;
}
.no-data img {
  width: 120px;
  opacity: 0.6;
}
</style>

三、动态搜索栏组件(DynamicSearchBar.vue)

💡 封装目标

  • 根据配置动态生成不同类型的输入控件
  • 自动绑定参数,支持回车查询
  • 超过3项自动折叠

📄 完整代码

vue 复制代码
<!-- DynamicSearchBar.vue -->
<template>
  <el-form
    ref="formRef"
    :model="localParams"
    :inline="true"
    label-width="80px"
    size="small"
  >
    <!-- 显示项 -->
    <el-form-item
      v-for="(item, index) in displayedItems"
      :key="item.prop"
      :label="item.label"
      v-has-permi="item.permi"
    >
      <!-- input -->
      <el-input
        v-if="item.component.is === 'input'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @keyup.enter="handleQuery"
        clearable
      />
      <!-- select -->
      <el-select
        v-else-if="item.component.is === 'select'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @change="handleQuery"
        clearable
      >
        <el-option
          v-for="opt in item.component.options"
          :key="opt.value"
          :label="opt.label"
          :value="opt.value"
        />
      </el-select>
      <!-- date-picker -->
      <el-date-picker
        v-else-if="item.component.is === 'date-picker'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @change="handleQuery"
      />
      <!-- tree-select -->
      <el-tree-select
        v-else-if="item.component.is === 'tree-select'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @change="handleQuery"
      />
    </el-form-item>

    <!-- 操作按钮 -->
    <el-form-item>
      <el-button type="primary" @click="handleQuery">查询</el-button>
      <el-button @click="handleReset">重置</el-button>
      <el-button
        v-if="items.length > 3"
        type="text"
        @click="toggleExpand"
      >
        {{ isExpanded ? '收起' : '展开' }}<i :class="`el-icon-arrow-${isExpanded ? 'up' : 'down'}`"></i>
      </el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive, computed, watch } from 'vue'
const props = defineProps({
  items: {
    type: Array,
    required: true
  },
  params: {
    type: Object,
    required: true
  },
  config: {
    type: Object,
    default: () => ({})
  },
  tableRef: {
    type: Object,
    default: () => ({})
  }
})

// 响应式数据
const localParams = reactive({})
const isExpanded = ref(false)

// 计算显示项(折叠逻辑)
const displayedItems = computed(() => {
  if (isExpanded.value || props.items.length <= 3) {
    return props.items
  }
  return props.items.slice(0, 3)
})

// 同步外部 params
watch(() => props.params, (newVal) => {
  Object.assign(localParams, newVal)
}, { immediate: true, deep: true })

// 同步到外部
watch(localParams, (newVal) => {
  Object.assign(props.params, newVal)
}, { deep: true })

// 查询
function handleQuery() {
  if (props.tableRef?.getList) {
    props.tableRef.getList()
  }
  // 触发事件
  emit('query', { ...localParams })
}

// 重置
function handleReset() {
  // 重置为初始值
  for (const key in localParams) {
    localParams[key] = ''
  }
  handleQuery()
  emit('reset')
}

// 切换展开
function toggleExpand() {
  isExpanded.value = !isExpanded.value
}

// 权限指令(示例)
const vHasPermi = {
  mounted(el, binding) {
    const { value } = binding
    if (value && !checkPermission(value)) {
      el.style.display = 'none'
    }
  }
}

// 模拟权限检查
function checkPermission(permi) {
  // 实际项目中从 store 或全局状态获取用户权限
  const userPermi = ['user:query', 'role:edit'] // 示例
  if (Array.isArray(permi)) {
    return permi.some(p => userPermi.includes(p))
  }
  return userPermi.includes(permi)
}

const emit = defineEmits(['query', 'reset'])
</script>

四、布局容器组件(LayoutContainer.vue)

💡 封装目标

  • 提供标准化布局结构
  • 集成常用操作(刷新/显隐列/折叠搜索)
  • 控制内容区高度自适应

📄 完整代码

vue 复制代码
<!-- LayoutContainer.vue -->
<template>
  <div class="layout-container">
    <!-- 搜索区 -->
    <div class="search-area" v-if="$slots.search" v-show="store.search">
      <slot name="search"></slot>
    </div>

    <!-- 内容区 -->
    <div :class="['content-area', config.fullContent ? 'full' : '']">
      <!-- 操作栏 -->
      <div class="action-bar" v-if="config.actions.show">
        <div class="left-actions">
          <slot name="actions-data"></slot>
        </div>
        <div class="right-actions" v-if="config.actions.table.show">
          <!-- 折叠搜索 -->
          <el-tooltip content="隐藏搜索" placement="top">
            <el-button
              circle
              @click="store.search = !store.search"
              v-show="config.actions.table.search"
            >
              <i class="el-icon-search"></i>
            </el-button>
          </el-tooltip>

          <!-- 刷新 -->
          <el-tooltip content="刷新" placement="top">
            <el-button
              circle
              v-show="config.actions.table.refresh"
              @click="handleRefresh"
            >
              <i class="el-icon-refresh"></i>
            </el-button>
          </el-tooltip>

          <!-- 显隐列 -->
          <el-tooltip content="显隐列" placement="top">
            <el-dropdown
              trigger="click"
              :hide-on-click="false"
              v-show="config.actions.table.columns"
              popper-class="column-toggle-popper"
            >
              <el-button circle>
                <i class="el-icon-menu"></i>
              </el-button>
              <template #dropdown>
                <el-dropdown-menu>
                  <el-dropdown-item
                    v-for="col in props.columns"
                    :key="col.prop"
                  >
                    <el-checkbox
                      v-if="col.type !== 'selection'"
                      :model-value="!col.hide"
                      @update:model-value="(val) => toggleColumn(col, val)"
                    >
                      {{ col.label }}
                    </el-checkbox>
                  </el-dropdown-item>
                </el-dropdown-menu>
              </template>
            </el-dropdown>
          </el-tooltip>
        </div>
      </div>

      <!-- 主体内容 -->
      <div class="main-content" v-if="$slots.default">
        <slot></slot>
      </div>
    </div>
  </div>
</template>

<script setup>
import { reactive, computed } from 'vue'
import { merge } from 'lodash-es'

// 默认配置
const DEFAULT_CONFIG = {
  fullContent: true,
  actions: {
    show: true,
    table: {
      show: true,
      search: true,
      refresh: true,
      columns: true
    }
  }
}

const props = defineProps({
  columns: {
    type: Array,
    default: () => []
  },
  config: {
    type: Object,
    default: () => ({})
  },
  tableRef: {
    type: Object,
    default: () => ({})
  }
})

const config = computed(() => {
  return merge({}, DEFAULT_CONFIG, props.config)
})

const store = reactive({
  search: true
})

// 刷新
function handleRefresh() {
  if (props.tableRef?.getList) {
    props.tableRef.getList()
  }
}

// 切换列显隐
function toggleColumn(column, visible) {
  column.hide = !visible
}
</script>

<style scoped>
.layout-container {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.search-area {
  padding: 15px;
  background: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  margin-bottom: 16px;
}

.content-area {
  background: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  flex: none;
}

.content-area.full {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.action-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px 0;
  gap: 12px;
}

.left-actions,
.right-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

.main-content {
  flex: 1;
  padding: 16px;
  overflow: auto;
}
</style>

<style>
/* 全局样式(非 scoped) */
.column-toggle-popper .el-dropdown-menu__item {
  line-height: 32px;
  padding: 0 16px;
}
</style>

五、标准使用示例

📄 父组件(业务页面)

vue 复制代码
<template>
  <layout-container 
    :columns="columns" 
    :config="wrapConfig" 
    :table-ref="tableRef"
  >
    <!-- 搜索区 -->
    <template #search>
      <dynamic-search-bar
        :items="searchItems"
        :params="queryParams"
        :table-ref="tableRef"
      />
    </template>

    <!-- 左侧操作 -->
    <template #actions-data>
      <el-button type="primary" @click="handleAdd">新增用户</el-button>
    </template>

    <!-- 表格 -->
    <smart-table
      ref="tableRef"
      :columns="columns"
      :func="getUserList"
      :params="queryParams"
      :config="tableConfig"
      :events="tableEvents"
    >
      <!-- 自定义操作列 -->
      <template #operation="{ row }">
        <el-button size="small" @click="handleEdit(row)">编辑</el-button>
        <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
      </template>
    </smart-table>
  </layout-container>
</template>

<script setup>
import { reactive, ref } from 'vue'
import { getUserListAPI } from '@/api/user'

// 查询参数
const queryParams = reactive({
  username: '',
  status: '',
  createTime: []
})

// 表格列
const columns = reactive([
  { label: '用户名', prop: 'username' },
  { label: '状态', prop: 'status', dict: 'sys_normal_disable' },
  { label: '创建时间', prop: 'createTime', date: true },
  { label: '操作', prop: 'operation', slot: 'operation', width: 180 }
])

// 配置
const wrapConfig = {
  fullContent: true
}

const searchItems = [
  { label: '用户名', prop: 'username', component: { is: 'input', placeholder: '请输入' } },
  { 
    label: '状态', 
    prop: 'status',
    component: { 
      is: 'select',
      options: [
        { value: '1', label: '启用' },
        { value: '0', label: '禁用' }
      ]
    }
  },
  {
    label: '创建时间',
    prop: 'createTime',
    component: { is: 'date-picker', type: 'daterange', rangeSeparator: '-' }
  }
]

const tableConfig = {
  sort: true
}

const tableEvents = {
  formatParams(params) {
    // 处理日期范围
    if (params.createTime?.length) {
      params.beginTime = params.createTime[0]
      params.endTime = params.createTime[1]
      delete params.createTime
    }
    return params
  }
}

const tableRef = ref(null)

// API 方法
async function getUserList(params) {
  const res = await getUserListAPI(params)
  return { data: { rows: res.list, total: res.total } }
}

// 操作方法
function handleAdd() { /* ... */ }
function handleEdit(row) { /* ... */ }
function handleDelete(row) { /* ... */ }
</script>

六、关键设计总结

✅ 为什么这样设计?

问题 解决方案 优势
每页重复写表格结构 SmartTable 封装 减少 70% 模板代码
搜索表单千奇百怪 DynamicSearchBar 配置驱动 统一体验,快速开发
刷新/显隐列位置不一 LayoutContainer 标准化 全系统交互一致
列显隐状态难管理 直接修改 columns.hide 无需 emit,天然响应式
业务逻辑耦合 UI events 解耦 + slot 覆盖 高内聚低耦合

⚠️ 使用注意事项

  1. columns 必须是响应式对象

    js 复制代码
    // ✅ 正确
    const columns = reactive([...])
    
    // ❌ 错误
    :columns="[{ label: 'ID', prop: 'id' }]"
  2. 不要在组件内部写业务 API

    • 所有数据请求通过 func prop 传入
    • 参数处理通过 events.formatParams
  3. 复杂 UI 用插槽覆盖

    • 操作列 → slot
    • 自定义单元格 → slot
  4. 权限控制统一接入

    • 搜索项权限 → v-hasPermi
    • 按钮权限 → 父组件控制

💬 结语 :这套组件体系已在多个大型后台项目中验证,显著提升开发效率与代码质量。核心思想是 "配置驱动 UI,事件解耦逻辑,插槽覆盖特例",在规范性与灵活性之间取得平衡。

相关推荐
lovingsoft9 分钟前
Chrome Lighthouse 详解
前端·chrome
Qlly20 分钟前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝23 分钟前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦24 分钟前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡1 小时前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI1 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95271 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端
看浪的路人1 小时前
第9讲:大模型应用监控与可观测性——OpenTelemetry + Prometheus + Grafana 全链路追踪
java·前端·spring
内存过载1 小时前
用表格模拟甘特图:项目排期的土办法
前端·低代码
葡萄城技术团队1 小时前
用 SpreadJS 数据验证实现省市区三级联动
前端