Fork me on GitHub

SchemaTable:配置驱动表格的设计与实现

本文从「为什么要做」「怎么设计的」「内部怎么跑的」「怎么用」四个维度,完整剖析项目中 workspace/components/common/SchemaTable 组件的实现思路。它与 SchemaSearch 同属一套 Schema-Driven UI 体系:页面用一份 JSON 描述表格结构,组件负责把这份描述渲染成具备序号、多选、枚举映射、操作按钮、列设置等能力的完整表格。


一、它解决什么问题?

1.1 痛点:每个列表页都在重复写表格

企业级后台里,列表页无处不在。如果每个页面都手写 <el-table>

<!-- ❌ 每个页面重复写这些 -->
<el-table :data="data" v-loading="loading">
  <el-table-column type="index" label="序号" width="60" />
  <el-table-column prop="contractNo" label="合同编号" min-width="150" />
  <el-table-column prop="status" label="状态" width="100">
    <template #default="{ row }">
      <el-tag :type="statusMap[row.status]?.type">
        {{ statusMap[row.status]?.label }}
      </el-tag>
    </template>
  </el-table-column>
  <el-table-column label="操作" width="200" fixed="right">
    <template #default="{ row }">
      <el-button link type="primary" size="small" @click="handleEdit(row)">修改</el-button>
      <el-button link type="primary" size="small" @click="handleView(row)">查看</el-button>
      <el-dropdown v-if="showMore(row)">
        <el-button link size="small"><el-icon><MoreFilled /></el-icon></el-button>
        <template #dropdown>
          <el-dropdown-menu>
            <el-dropdown-item @click="handleDelete(row)">删除</el-dropdown-item>
          </el-dropdown-menu>
        </template>
      </el-dropdown>
    </template>
  </el-table-column>
</el-table>

你会遇到三个问题:

问题 具体表现
重复代码 50 个列表页 × 平均 8 列 = 400 段模板,列头样式、空状态、操作按钮布局各自为政
行为不统一 有的页面操作列用 el-button link,有的用 el-link;有的空状态用默认,有的自定义;有的分页序号错页后不从 1 开始
维护成本高 产品说"所有表格的操作按钮超过 3 个要收进下拉"——你得改 50 个文件

1.2 解决思路

把"表格长什么样"从模板代码中抽离,变成一份数据描述(Schema)。

页面只需声明:

const columns = [
  { type: 'index', label: '序号', width: 60 },
  { prop: 'contractNo', label: '合同编号', minWidth: 150 },
  {
    prop: 'status', label: '状态', width: 100,
    enum: { '1': { label: '启用', type: 'success' }, '0': { label: '禁用', type: 'danger' } }
  },
  {
    type: 'actions', label: '操作', width: 200, fixed: 'right',
    actions: [
      { key: 'edit', label: '修改', onClick: handleEdit },
      { key: 'view', label: '查看', onClick: handleView },
      { key: 'delete', label: '删除', onClick: handleDelete, type: 'danger', confirm: true }
    ]
  }
]

组件拿到这份"说明书"后,自动完成:渲染列结构 → 映射枚举值 → 处理操作列智能收起 → 支持列设置(显隐/排序/固定)。

这就是 Schema-Driven Table:用配置描述表格,而不是用模板堆砌表格。


二、封装思想与设计原理

2.1 核心思想:数据描述 UI

SchemaSearch 一样,SchemaTable 把"命令式模板"变成"声明式配置"。

┌─────────────────────────────────────────────────────┐
│                  传统写法(命令式)                    │
│                                                     │
│  页面 template 里逐个写 el-table-column               │
│  序号/多选/单选/操作列逻辑分散在每个页面                  │
│  枚举映射、格式化逻辑和模板混在一起                       │
│  操作列收起/展开逻辑每个页面都要重新实现                   │
└─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│                  Schema 写法(声明式)                  │
│                                                     │
│  页面只提供一份 columns 配置数组(Schema)               │
│          ↓                                          │
│  SchemaTable 组件读取配置                              │
│          ↓                                          │
│  根据 type 自动选择列类型渲染                           │
│  自动处理枚举映射 / formatter / 操作列智能收起          │
│  内置列设置面板(显隐 / 排序 / 固定左右)                 │
└─────────────────────────────────────────────────────┘

2.2 列类型的策略模式

SchemaTable 支持多种列类型,每种通过 type 字段声明,组件内部自动选择对应的渲染策略:

type 渲染内容 关键属性
index 序号列(el-table-column type="index") widthindexMethodfixed
selection 多选列(el-table-column type="selection") reserveSelection(跨页保留选中)
radio 单选列(el-radio 托管) 通过 expose.getCurrentRow() 获取当前选中行
actions 多操作按钮列(自动折叠逻辑) actions: ActionSchema[] 配置各按钮
action 单操作列(link 或 button 模式) onClickrenderAslabelField
默认(text) 普通数据显示列 propenumformatterslot

这张 type 对照表本质是一张策略注册表——ColumnCell 作为分派器,根据 type 选用对应的渲染策略。和 SchemaSearch 中的策略模式一脉相承:那边用 inputMap.ts + <component :is> 做"映射表 + 动态组件"分发,这里用 ColumnCell 内的 type 分支做"分派器"分发。落地方式不同,但都是策略模式的体现。

新增一种列类型,只需:1. 在 ColumnCell 加一个渲染分支;2.(可选)在 ColumnSchema 扩展对应字段——不必改动任何页面配置或其他列的渲染逻辑。这就是开放封闭原则(OCP)

2.3 操作列智能收起机制(核心亮点)

这是 SchemaTable 中"不读源码无法推断"的精细逻辑。

设计目标: 操作列中的按钮按需展示——少于 3 个时全部平铺,3 个及以上时只展示第 1 个,其余收入 "..." 下拉菜单。

核心逻辑位于 ColumnCell.vue,分两步:

第一步:动态计算可见操作(逐行过滤)

const visibleActions = computed(() => {
  if (!props.column.actions) return []
  return props.column.actions.filter((action) => {
    // 1. visible 控制:支持函数 (row) => boolean 或静态 boolean
    const visible = typeof action.visible === 'function'
      ? action.visible(props.row)
      : action.visible !== false
    if (!visible) return false
    // 2. 权限控制
    return hasPermission(action.permission)
  })
})

第二步:根据数量分两路渲染

<!-- 情况1: 可见按钮 < 3 个,全部直接展示 -->
<template v-if="visibleActions.length < 3">
  <el-button
    v-for="action in visibleActions"
    :key="action.key"
    size="small" link
    :type="action.type || 'primary'"
    :disabled="isActionDisabled(action)"
    @click="handleActionClickWithConfirm(action, row)"
  >
    {{ getActionLabel(action) }}
  </el-button>
</template>

<!-- 情况2: 可见按钮 >= 3 个,第1个直接展示,剩余放入更多下拉 -->
<template v-else>
  <!-- 第1个按钮直接展示 -->
  <el-button ...>{{ getActionLabel(visibleActions[0]) }}</el-button>
  <!-- 第2~N个收入 el-dropdown -->
  <el-dropdown size="small" trigger="click">
    <el-button link size="small" type="primary">
      <el-icon size="15"><MoreFilled /></el-icon>
    </el-button>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item
          v-for="moreAction in visibleActions.slice(1)"
          :key="moreAction.key"
          @click="handleActionClickWithConfirm(moreAction, row)"
        >
          {{ getActionLabel(moreAction) }}
        </el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

交互效果示意:

                   visibleActions.length < 3 ?
┌──────────────────────────────────────────────┐
│  [修改]  [查看]                              │  ← 全部平铺
└──────────────────────────────────────────────┘

                   visibleActions.length >= 3 ?
┌──────────────────────────────────────────────┐
│  [修改]  [ ⋮ ]                              │  ← 第1个直接展示,其余收起
└──────────────────────────────────────────────┘
               ↓ 点击 ⋮ 展开
        ┌──────────────┐
        │  查看         │
        │  删除         │  ← el-dropdown-menu
        │  ...          │
        └──────────────┘

设计要点:

要点 说明
逐行动态计算 visibleActions 依赖 props.row,同一列不同行的可见按钮数可以不同——有的行 3 个按钮触发收起,有的行只有 2 个按钮全部平铺
阈值 = 2 判定条件是 length < 3,即 1~2 个按钮平铺,3 个及以上触发收起
第一个按钮始终外露 收起时固定保留第 1 个在外面(通常是"查看"或"修改"——最高频操作),减少一次点击
label 支持函数 getActionLabel 支持 (row) => string,按钮文字可以随行数据动态变化
内置确认弹窗 action.confirm 支持 string/boolean/function,点击后自动弹出 ElMessageBox.confirm,无需调用方自行处理
disabled 逐行控制 action.disabled 支持 (row) => boolean,如"只有待审核状态才能编辑"

2.4 数据列的渲染策略链

对于普通数据列(非 index/selection/radio/actions/action),ColumnCell 按以下优先级链决定单元格内渲染什么:

① slot 插槽(最高优先级,完全自定义)
    ↓ 没有 slot
② enum 枚举映射(如状态 → el-tag 彩色标签)
    ↓ 没有 enum
③ meta.idExpiry(证件过期日期 → ExpiryDateCell 带警告图标)
    ↓ 没有 idExpiry
④ formatter(自定义格式化函数,支持返回 string/number/VNode)
    ↓ 没有 formatter
⑤ 兜底:直接展示 cellValue(空值显示 "-")

这条渲染链确保了灵活性和一致性的平衡——最常用的枚举映射只需一行配置,复杂场景走 slot 或 formatter。

2.5 列设置面板(ColumnSetting)

SchemaTable 内置了一个列设置面板(通过表格右上角的齿轮图标打开),支持:

┌─────────────────────────────┐
│  列展示                      │
│                             │
│  ≡ ☑ 合同编号     [←] [→]   │  ← 拖拽排序 + 显隐复选框 + 固定左右
│  ≡ ☑ 客户名称     [←] [→]   │
│  ≡ ☑ 状态         [←] [→]   │
│  ≡ ☐ 创建时间     [←] [→]   │  ← 取消勾选可隐藏列
│  ≡ ☑ 备注         [←] [→]   │
│                             │
│        [取消]  [确定]         │
└─────────────────────────────┘

关键设计:

设计 说明
系统列锁定 selection / radio / index / actions 四类系统列不在设置面板中显示,始终分别固定在最左或最右
排序通过 vuedraggable 拖拽手柄调整列顺序,normalizeBusinessColumns 将"固定左 → 不固定 → 固定右"重新排布
固定切换 toggleFixed 点击左侧按钮固定到左,点击右侧按钮固定到右,再次点击取消固定
visible 双向持久 列显隐状态存储在 columns 数组中,通过 emit('update:columns') 回传给父组件

2.6 架构分层:三层职责隔离

┌─────────────────────────────────────────────────────────────┐
│                        页面调用层                             │
│  只关心:传 columns 配置、传 data、监听事件                     │
└────────────────────────────┬────────────────────────────────┘
                             │ props: data / columns / loading / height...
┌────────────────────────────▼────────────────────────────────┐
│                    index.vue(编排层)                         │
│  职责:状态管理、列过滤、事件协调                                │
│  · renderColumns:按 visible + permission 过滤列               │
│  · 管理 el-table 实例(rowKey、defaultSelection)              │
│  · 合计行 tooltip(MutationObserver + data-summary)          │
│  · 暴露 toggleRowSelection / clearSelection 等方法              │
└──┬──────────┬──────────┬──────────┬─────────────────────────┘
   │          │          │          │
   ▼          ▼          ▼          ▼
ColumnCell  ColumnSetting  Empty  ExpiryDateCell    ← UI 子组件层
(单元格渲染)  (列设置面板)   (空状态) (证件日期过期)

ColumnCell 内部再按类型分发:
  · isActions  → 操作列智能收起
  · isAction   → 单操作 link/button
  · enum       → el-tag 枚举标签
  · idExpiry   → 过期日期单元格
  · formatter  → 自定义格式化
  · 兜底       → 纯文本展示

每一层只做自己该做的事:

层级 职责 不做什么
页面 定义 columns schema、响应操作回调 不关心单元格内部怎么渲染
编排层 (index.vue) 管理 el-table 实例、列过滤、暴露方法 不关心具体某列渲染成什么
ColumnCell 根据列类型分发到对应渲染策略 不知道表格的全局状态
ColumnSetting 管理列的显隐/排序/固定 不关心列的渲染内容

2.7 关键设计决策

决策 为什么这样做
操作列阈值 = 2(1~2 平铺 / 3 个及以上收起) 实际统计下绝大多数行的可用操作不超过 2 个;阈值定在 3 让收起只在高频行触发,避免常态化的"⋮"二次点击
收起时第 1 个按钮始终外露 保留最高频操作(通常是"查看/修改")直接可达,减少一次点击
逐行计算 visibleActions 同一列不同行的按钮数、权限、状态各异,逐行过滤才能让每行只展示它真正可用的操作
合计行 tooltip 用双保险 el-table footer 的 show-overflow-tooltip 不生效;且它可能只改 textContent 不触发 childList,需 watch deep 兜底
tooltip 渲染在 body 级 position:fixed 避免被表格 overflow:hidden 裁剪,跨表格通用
系统列不出现在列设置面板 selection / radio / index / actions 有固定语义和位置(最左/最右),允许用户拖动会破坏表格结构
不设 indexMethod 则按 pageIndex/pageSize 自动偏移 约定优于配置——PageTable 已持有分页信息,透传即可让序号跨页连续
rowKey 缺失时 defaultSelection 静默不生效 回显依赖 rowKey 唯一标识行,缺失时跳过而非报错,防御性降级
permission 未配置默认允许 多数列/操作无权限要求,默认放行降低配置成本,需要时再细控

三、内部工作流程

3.1 列渲染总流程

页面传入 columns 配置数组
        │
        ▼
renderColumns (computed):过滤 visible=false 和无权限的列
        │
        ▼
v-for 遍历 renderColumns,逐列渲染
        │
        ▼
    ┌─ type === 'index'     → el-table-column type="index"
    ├─ type === 'selection' → el-table-column type="selection"
    ├─ type === 'radio'     → el-table-column + el-radio
    ├─ type === 'actions'   → el-table-column + ColumnCell (isActions 分支)
    ├─ type === 'action'    → el-table-column + ColumnCell (isAction 分支)
    └─ 默认 (数据列)         → el-table-column + ColumnCell (渲染策略链)

3.2 操作列点击 → 确认 → 执行

① 用户点击操作列中的 [删除] 按钮
        │
        ▼
② handleActionClickWithConfirm(action, row):
   · isActionDisabled(action) → 如果禁用,直接 return
   · 检查 action.confirm:
     - string  → 作为确认文案
     - function(row) → 返回 string(确认文案)或 false(跳过确认)
     - true/undefined → 使用兜底文案 "确定要执行此操作吗?"
        │
        ▼
③ 如果需要确认 → ElMessageBox.confirm(message)
   · 用户点「取消」→ return(终止)
   · 用户点「确定」→ 继续
        │
        ▼
④ action.onClick(row, index) ← 执行真正的业务逻辑
   (页面中的 handleDelete、handleEdit 等)

3.3 列设置面板工作流

① 用户点击表格右上角齿轮图标
        │
        ▼
② ColumnSetting Popover 打开
   · 从 props.columns 中提取可配置的业务列(排除 selection/radio/index/actions)
   · 每个列初始 visible 默认为 true
        │
        ▼
③ 用户可以:
   · 拖拽排序(vuedraggable drag-handle)
   · 勾选/取消勾选(el-checkbox 控制 visible)
   · 固定到左/右(toggleFixed)
        │
        ▼
④ 用户点击「确定」→ onConfirm():
   · normalizeBusinessColumns:按 fixed 值排序(left → 无 → right)
   · 组装最终列顺序:系统左 → 业务列 → 系统右
   · emit('update:columns', result)
   · index.vue 接收 → renderColumns 自动重新计算 → 表格刷新

3.4 分页序号自动计算

// index.vue 中的 resolveIndexMethod
const resolveIndexMethod = (col: ColumnSchema) => {
  if (col.indexMethod) return col.indexMethod          // 优先使用自定义方法
  if (props.pageIndex !== undefined && props.pageSize !== undefined) {
    const { pageIndex, pageSize } = props
    return (index: number) => (pageIndex - 1) * pageSize + index + 1
  }
  return undefined  // 兜底:el-table 默认从 1 开始
}

只需在 PageTable 模板中把 pageIndexpageSize 传给 SchemaTable,序号列自动处理跨页偏移——这是"约定优于配置"的体现。

3.5 合计行 tooltip(双保险机制)

表格的合计行 footer 中,el-table 默认的 show-overflow-tooltip 在 footer 中不生效。SchemaTable 自己实现了一套 tooltip:

双保险设计:
  ① MutationObserver 监听 footer DOM 变化 → 给合计行 td 加 data-summary 属性
  ② watch(data, deep) 兜底 → el-table 可能只更新 cell textContent 而不触发 childList mutation

事件委托:
  mouseenter 到 td[data-summary] → 在 body 上渲染 position:fixed 的 tooltip
  为什么是 body 级 tooltip? → 避免被表格 overflow:hidden 裁剪

3.6 默认选中回显

当表格用于编辑场景(如弹窗中打开已选中的数据),需要回显之前勾选的行:

watch(() => props.data, (newData) => {
  if (!props.defaultSelection?.length || !props.rowKey || !tableRef.value) return

  // 创建 rowKey → 是否选中的映射
  const selectedKeyMap = new Map()
  props.defaultSelection.forEach(item => {
    const keyValue = item[rowKey]
    if (keyValue !== undefined) selectedKeyMap.set(String(keyValue), true)
  })

  nextTick(() => {
    tableRef.value?.clearSelection()
    newData.forEach(row => {
      const keyValue = row[rowKey]
      if (selectedKeyMap.has(String(keyValue))) {
        tableRef.value?.toggleRowSelection(row, true)
      }
    })
  })
}, { immediate: true })

3.7 RowKey 与 Table Ref 透出

SchemaTable 通过 defineExpose 向父组件暴露以下方法,供外部调用:

defineExpose({
  toggleRowSelection: (row, selected) => tableRef.value?.toggleRowSelection(row, selected),
  clearSelection:     () => tableRef.value?.clearSelection(),
  getSelectionRows:   () => tableRef.value?.getSelectionRows?.() || [],
  getTableRef:        () => tableRef.value,
  // 单选列专属
  clearRadioSelection: () => { currentRadioValue.value = ''; currentRadioRow.value = null },
  getCurrentRow:       () => currentRadioRow.value
})

四、配置定义详解

4.1 ColumnSchema 完整字段

interface ColumnSchema {
  // ──────── 基础标识 ────────
  prop?: string           // 数据字段名(数据列必填)
  label?: string | ((row?: any) => string)  // 列标题,支持函数动态解析
  type?: 'index' | 'selection' | 'radio' | 'actions' | 'action' | 'custom'

  // ──────── 布局 ────────
  width?: number | string
  minWidth?: number | string
  fixed?: 'left' | 'right'
  align?: 'left' | 'center' | 'right'

  // ──────── 序号列专属 ────────
  indexMethod?: (index: number) => number  // 自定义序号计算

  // ──────── 数据展示 ────────
  formatter?: (row: any) => string | number | VNode  // 自定义格式化
  enum?: Record<string | number, string | { label: string; type?: string; effect?: string }>  // 枚举映射
  slot?: string          // 自定义单元格插槽
  slotHeader?: string    // 自定义表头插槽
  headerPrefix?: string  // 表头前缀(如红色 *)
  wrapText?: boolean     // 是否换行展示

  // ──────── 操作列专属 (type='actions') ────────
  actions?: ActionSchema[]

  // ──────── 单操作列专属 (type='action') ────────
  onClick?: (row: any) => any
  labelField?: string | ((row: any) => string)
  renderAs?: 'link' | 'button'   // 渲染为 link 还是 button

  // ──────── 权限 & 配置 ────────
  permission?: string | string[] | boolean | ((row: any) => boolean)
  visible?: boolean | (() => boolean)    // 是否显示
  hideable?: boolean                     // 是否允许在列设置中隐藏
  order?: number                         // 排序权重

  // ──────── 扩展 ────────
  meta?: Record<string, any>     // 扩展字段(如 idExpiry)
  reserveSelection?: boolean     // selection 列跨页保留选中
  confirm?: boolean | string | ((row: any) => boolean)  // action 类点击确认
}

4.2 ActionSchema 完整字段

interface ActionSchema {
  key: string              // 唯一标识
  label: string | ((row: any) => string)  // 按钮文字,支持动态函数

  // ──────── 样式 ────────
  type?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
  nativeText?: boolean     // true 时用 <span> 渲染而非 el-button(避免继承 el-form disabled)

  // ──────── 行为控制 ────────
  visible?: boolean | ((row: any) => boolean)   // 逐行控制是否显示
  disabled?: boolean | ((row: any) => boolean)  // 逐行控制是否禁用
  onClick: (row: any, index?: number) => void   // 点击回调
  confirm?: boolean | string | ((row: any) => boolean | string)  // 确认弹窗
  permission?: string | string[] | boolean | ((row: any) => boolean)
}

4.3 配置映射对照表

把"配置写法 → 实际渲染结果"集中对照,方便按需选择最简方案:

配置写法 单元格数据 渲染结果
{ prop: 'name' } '张三' 纯文本"张三"
{ prop: 'name' } '' / null 空值显示"-"
{ prop: 'status', enum: {'1': {label:'启用',type:'success'}} } '1' 绿色 el-tag"启用"
{ prop: 'date', formatter: r => r.date.split(' ')[0] } '2026-07-25 10:30' "2026-07-25"(自定义格式化)
{ prop: 'op', slot: 'customCell' } 任意 完全自定义(走具名插槽)
{ type: 'index' } 跨页连续序号(需传 pageIndex/pageSize)
{ type: 'actions', actions: [...] } row 可见按钮 ❤️ 全平铺;≥3 第 1 个外露 + 其余收入 ⋮
{ type: 'action', onClick, renderAs: 'link' } row 单个 link/button

4.4 Props 一览

Prop 类型 默认值 说明
data any[] 表格数据
columns ColumnSchema[] 列配置
loading boolean 加载状态
stripe boolean true 斑马纹
border boolean 边框
height number 表格高度
setting boolean false 是否显示列设置齿轮图标
selectable Function selection 列可选判断 (row) => boolean
rowKey string 行唯一键(selection 回显必传)
defaultSelection any[] [] 默认选中行数组
enableActionTooltip boolean false 操作列按钮 tooltip
pageIndex number 当前页码(序号列自动偏移用)
pageSize number 每页条数(序号列自动偏移用)

4.5 Expose 方法一览

方法 说明
toggleRowSelection(row, selected) 切换某行的选中状态
clearSelection() 清除所有选中行
getSelectionRows() 获取当前所有选中行
getTableRef() 获取底层 el-table 实例
clearRadioSelection() 清除单选选中
getCurrentRow() 获取当前单选行

五、使用方式示例

5.1 标准列表页(最常见模式)

<template>
  <PageTable ref="pageTableRef" :request="getListData" :params="query">
    <template #search>
      <SchemaSearch :items="searchItems" @change="handleSearchChange" />
    </template>

    <template #toolbar>
      <OperateGroup>
        <template #operate-item>
          <el-button type="primary" @click="handleAdd">新增</el-button>
        </template>
      </OperateGroup>
    </template>

    <template #table="{ data, loading, height }">
      <SchemaTable
        :data="data"
        :loading="loading"
        :height="height"
        :columns="columns"
      />
    </template>
  </PageTable>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { ColumnSchema } from '@workspace/components/common/SchemaTable/type'
import PageTable from '@workspace/components/common/PageTable.vue'
import SchemaTable from '@workspace/components/common/SchemaTable/index.vue'

const pageTableRef = ref()
const query = ref({})

const columns: ColumnSchema[] = [
  { type: 'index', label: '序号', width: 60 },
  { prop: 'contractNo', label: '合同编号', minWidth: 150 },
  { prop: 'customerName', label: '客户名称', minWidth: 120 },
  {
    prop: 'status',
    label: '状态',
    width: 100,
    // 枚举映射:值 → tag 样式
    enum: {
      '1': { label: '启用', type: 'success' },
      '0': { label: '禁用', type: 'danger' }
    }
  },
  {
    prop: 'createTime',
    label: '创建时间',
    width: 180,
    formatter: (row) => row.createTime?.split(' ')[0] || '-'  // 只展示日期部分
  },
  {
    type: 'actions',
    label: '操作',
    width: 200,
    fixed: 'right',
    actions: [
      { key: 'edit', label: '修改', onClick: handleEdit },
      { key: 'view', label: '查看', onClick: handleView },
      {
        key: 'delete',
        label: '删除',
        type: 'danger',
        confirm: (row) => `确定要删除合同「${row.contractNo}」吗?`,
        onClick: handleDelete
      }
    ]
  }
]
</script>

5.2 逐行控制操作按钮

const columns: ColumnSchema[] = [
  // ...其他列
  {
    type: 'actions',
    label: '操作',
    width: 200,
    fixed: 'right',
    actions: [
      {
        key: 'edit',
        label: '修改',
        onClick: handleEdit,
        // 只有"待审核"或"已驳回"状态才能编辑
        disabled: (row) => !['PENDING', 'REJECTED'].includes(row.status)
      },
      {
        key: 'submit',
        label: '提交',
        onClick: handleSubmit,
        // "已提交"状态隐藏此按钮
        visible: (row) => row.status !== 'SUBMITTED'
      },
      {
        key: 'delete',
        label: '删除',
        type: 'danger',
        onClick: handleDelete,
        // 确认文案随行数据动态变化
        confirm: (row) => `确定要删除「${row.name}」吗?此操作不可撤销。`
      }
    ]
  }
]

如果某行 status='PENDING',该行的操作列展示 3 个按钮——触发智能收起:[修改][⋮](查看和删除在展开菜单中)。

如果某行 status='SUBMITTED',该行只有 2 个可见按钮——没有收起:[修改][删除]。

5.3 带列设置面板

<SchemaTable
  :data="data"
  :columns="columns"
  :setting="true"
  @update:columns="handleColumnsUpdate"
/>
const handleColumnsUpdate = (newColumns: ColumnSchema[]) => {
  // 用户调整了列显隐/排序/固定后回调
  // 可将 newColumns 持久化到 localStorage 实现列配置记忆
  localStorage.setItem('tableColumns', JSON.stringify(newColumns))
  Object.assign(columns, newColumns)
}

5.4 带 selection + 分页序号

<SchemaTable
  ref="tableRef"
  :data="data"
  :columns="columns"
  :page-index="pageIndex"
  :page-size="pageSize"
  row-key="id"
  :default-selection="preSelectedRows"
/>
const columns: ColumnSchema[] = [
  { type: 'selection', reserveSelection: true },  // 跨页保留选中
  { type: 'index', label: '序号', width: 60 },     // 自动 (pageIndex-1)*pageSize + index + 1
  // ...数据列
]

// 外部操作选中行
const handleBatchDelete = () => {
  const selectedRows = tableRef.value?.getSelectionRows()
  // ...
}

5.5 证件过期日期列(idExpiryColumn)

import { createIdExpiryDateColumn } from '@workspace/components/common/SchemaTable/idExpiryColumn'

const columns: ColumnSchema[] = [
  { prop: 'customerName', label: '客户名称', minWidth: 120 },
  // 使用工厂函数创建带过期警告的日期列
  createIdExpiryDateColumn({
    prop: 'iddtExpiration',
    label: '有效证件到期日',
    minWidth: 170,
    longEffectiveProp: 'longEffective',  // 对应的长期有效字段
    tip: '证件即将过期,请及时更新'
  })
]

该列会自动判断:长期有效 → 显示"长期";已过期 → 文字标红 + 警告图标 tooltip。

5.6 自定义 slot 列

<SchemaTable :data="data" :columns="columns">
  <!-- 自定义单元格 -->
  <template #customCell="{ row }">
    <el-button link type="primary" @click="handleCustom(row)">
      {{ row.someField }}
    </el-button>
  </template>

  <!-- 自定义表头 -->
  <template #customHeader>
    <span>自定义表头 <el-tooltip content="提示信息"><el-icon><QuestionFilled /></el-icon></el-tooltip></span>
  </template>
</SchemaTable>
const columns: ColumnSchema[] = [
  {
    prop: 'someField',
    label: '自定义列',
    slot: 'customCell',        // 插槽名
    slotHeader: 'customHeader' // 表头插槽名
  }
]

六、文件结构与职责总结

SchemaTable/
│
├── index.vue              主组件(编排层)
│                          · 管理 el-table 实例
│                          · renderColumns:按 visible + permission 过滤
│                          · 合计行 tooltip(MutationObserver + data watch 双保险)
│                          · defaultSelection 回显
│                          · 分页序号自动计算(resolveIndexMethod)
│                          · expose 方法供父组件调用
│
├── ColumnCell.vue         单元格渲染器(核心分派器)
│                          · isActions → 操作列智能收起(< 3 平铺 / >= 3 收起)
│                          · isAction → 单操作 link/button
│                          · enum → el-tag 枚举标签
│                          · idExpiry → ExpiryDateCell 过期日期
│                          · formatter → 自定义格式化(支持 VNode 返回)
│                          · 兜底 → 纯文本(空值显示 "-")
│                          · handleActionClickWithConfirm:内置确认弹窗
│
├── ColumnSetting.vue      列设置面板
│                          · vuedraggable 拖拽排序
│                          · el-checkbox 显隐控制
│                          · toggleFixed 固定左右
│                          · normalizeBusinessColumns 排序重组
│
├── type.d.ts              类型定义
│                          · ColumnSchema:列的完整类型
│                          · ActionSchema:操作按钮类型
│                          · ColumnType:列类型枚举值
│                          · EnumMap / EnumItem / PermissionContext
│
├── setColumns.ts           列数组更新工具
│                          · 就地 splice 更新,避免 Rolldown 构建报错
│
├── resolveColumnLabel.ts   列标题解析工具
│                          · 支持 string | (row) => string
│
└── idExpiryColumn.ts       证件过期日期列工厂
                           · createIdExpiryDateColumn(options) 生成带警告的列配置

七、设计思想提炼

原则 在 SchemaTable 中的体现
配置驱动 页面只写 columns JSON 配置,不写 el-table-column 模板
策略模式 根据 type 字段分发到不同的列渲染策略(index/selection/radio/actions/action/数据列)
开放封闭 新增列类型只需在 ColumnCell 中加一个分支;新增操作列需求通过 ActionSchema 扩展
关注点分离 编排层 (index.vue) / 渲染分派层 (ColumnCell) / 设置面板 (ColumnSetting) 各司其职
约定优于配置 不设 indexMethod 则传 pageIndex/pageSize 自动计算分页序号;操作列默认 link 样式;空值默认显示 "-"
渐进增强 简单列只需 prop + label;枚举列加 enum;复杂交互走 slotformatter;操作列从平铺到收起自动适配
防御性设计 合计行 tooltip 双保险(Observer + watch deep);rowKey 缺失时 defaultSelection 不生效;permission 未配置默认允许

八、与 SchemaSearch 的关系

SchemaTable 和 SchemaSearch 共享同一套 Schema-Driven UI 设计哲学:

                    Schema-Driven UI 范式
                           │
          ┌────────────────┴────────────────┐
          ▼                                  ▼
   SchemaSearch                        SchemaTable
   (配置驱动搜索栏)                      (配置驱动表格)
          │                                  │
   items: SearchItemSchema[]           columns: ColumnSchema[]
   type → 输入组件映射表               type → 列渲染策略分发
   buildSearchParams → 参数转换         ColumnCell → 单元格渲染策略链
   Popover/Drawer 双编辑模式            actions/<3 vs >=3 智能收起
维度 SchemaSearch SchemaTable
配置入口 items: SearchItemSchema[] columns: ColumnSchema[]
策略映射 inputMap.ts(type → 输入组件) ColumnCell 内 if/else 分支(type → 渲染策略)
核心输出 emit('change', params) → 请求参数 el-table 渲染 → 可视化数据
智能行为 空值自动过滤、Popover 自动聚焦 操作列智能收起、序号自动偏移、枚举/tag 自动映射
设置面板 搜索项显隐 + 拖拽排序 列显隐 + 拖拽排序 + 固定左右

掌握了 Schema-Driven 的思路,你就掌握了企业级组件封装的核心方法论——它不止于搜索和表格,可以推广到表单、详情、Dashboard 等所有"配置替代重复代码"的场景。

posted @ 2026-08-01 09:32  极度恐慌_JG  阅读(8)  评论(0)    收藏  举报