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") | width、indexMethod、fixed |
selection |
多选列(el-table-column type="selection") | reserveSelection(跨页保留选中) |
radio |
单选列(el-radio 托管) | 通过 expose.getCurrentRow() 获取当前选中行 |
actions |
多操作按钮列(自动折叠逻辑) | actions: ActionSchema[] 配置各按钮 |
action |
单操作列(link 或 button 模式) | onClick、renderAs、labelField |
| 默认(text) | 普通数据显示列 | prop、enum、formatter、slot |
这张 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 模板中把 pageIndex 和 pageSize 传给 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;复杂交互走 slot 或 formatter;操作列从平铺到收起自动适配 |
| 防御性设计 | 合计行 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 等所有"配置替代重复代码"的场景。


浙公网安备 33010602011771号