执行价格弹窗:表头 / 表体合并与可配置表格
背景
业务需要在「执行价格」类弹窗中展示多块价格配置(基础、颜色、规格、行业等)。每块区域具备:
- 可配置标题、提示文案;
- 展开 / 收起;
- 表头「属性名称」等列在不同场景下 横向合并(colspan) 列数不同;
- 表体部分行需 纵向合并(rowspan)(如规格、行业维度)。
技术栈:Vue 3 + TypeScript + Element Plus;项目内大量列表仍使用 vxe-table / vxe-grid(本仓库 web-admin 依赖约为 vxe-table ~4.17)。
原型示意:

方案概览
采用 「配置沿用 VxeGridProps 形态 + 表头表体用原生 <table> 渲染」 的混合方案:
| 层级 | 做法 |
|---|---|
| 配置 | 扩展 VxeTableDefines.ColumnOptions,增加项目自定义字段 headerMergeCells |
| 表头 | 原生 <th :colspan="...">,由列配置驱动 |
| 表体 | 默认插槽由父组件输出 <td>,行合并通过数据字段 _cell + rowspan 控制 |
| 列表页其它表格 | 继续使用 vxe-table,不受影响 |
这样做的直接原因(与当时实现一致):
- 需要 单行表头、按列配置 colspan,并与现有
columns/data结构统一维护。 - 表体既要 rowspan,又要 按列自定义单元格内容(价格输入等),用插槽逐行输出
<td>更直观。 - 该弹窗为局部 UI,用轻量
<table>即可满足原型,避免在同一弹窗内再叠一套 vxe-grid 的表头合并、列宽、空状态等配置成本。
关于 vxe-table 能力说明
vxe-table 并非不支持表体合并:可通过span-method合并单元格。较新版本也支持表头合并(如merge-header-cells、show-custom-header),或使用vxe-colgroup做分组表头。
本文中的headerMergeCells不是 vxe-table 内置字段,而是项目自定义配置,仅在被封装的ExecutePriceDialogItem内映射为 HTMLcolspan。若新需求与全站表格强一致,可评估迁回 vxe-grid 并改用官方合并 API(需结合当前 vxe-table 版本文档逐项对照)。
flowchart LR
subgraph config [配置层]
A[CUSTOM_EXECUTE_PRICE]
B[headerMergeCells]
end
subgraph item [ExecutePriceDialogItem]
C[thead: th + colspan]
D[tbody: 默认插槽]
end
subgraph parent [父组件]
E[整理 data 含 _cell]
F[插槽内 td + rowspan]
end
A --> C
B --> C
A --> D
E --> D
F --> D
类型与表格配置
在保留 VxeGridProps 的前提下扩展列类型,使各业务块共享「全国价 / 专属价」两列,仅首列 headerMergeCells 不同:
import type { VxeGridProps, VxeTableDefines } from "vxe-table";
import { cloneDeep } from "lodash-es";
/** 项目自定义:表头该列占几格(映射为 th 的 colspan) */
interface CUSTOM_COLUMN extends VxeTableDefines.ColumnOptions {
headerMergeCells?: number;
}
export interface CUSTOM_EXECUTE_PRICE extends VxeGridProps {
columns: CUSTOM_COLUMN[];
}
const EXECUTE_PRICE_COLUMN: CUSTOM_EXECUTE_PRICE = {
columns: [
{
title: "全国价(元)",
width: 200,
slots: { default: "nationalPrice" },
},
{
title: "专属价(元)",
width: 200,
slots: { default: "exclusivePrice" },
},
],
data: [],
};
export const baseTableConfig = cloneDeep({
...EXECUTE_PRICE_COLUMN,
columns: [
{
title: "属性名称",
slots: { default: "name" },
headerMergeCells: 1,
},
...EXECUTE_PRICE_COLUMN.columns,
],
});
export const colorTableConfig = cloneDeep({
...EXECUTE_PRICE_COLUMN,
columns: [
{
title: "属性名称",
slots: { default: "name" },
headerMergeCells: 1,
},
...EXECUTE_PRICE_COLUMN.columns,
],
});
/** 表头「属性名称」占 2 列 */
export const specTableConfig = cloneDeep({
...EXECUTE_PRICE_COLUMN,
columns: [
{
title: "属性名称",
slots: { default: "name" },
headerMergeCells: 2,
},
...EXECUTE_PRICE_COLUMN.columns,
],
});
export const industryTableConfig = cloneDeep({
...EXECUTE_PRICE_COLUMN,
columns: [
{
title: "属性名称",
slots: { default: "name" },
headerMergeCells: 2,
},
...EXECUTE_PRICE_COLUMN.columns,
],
});
配置说明:
| 配置项 | 含义 |
|---|---|
headerMergeCells | 表头该列 colspan,默认 1 |
columns[].slots | 与 vxe 列插槽命名对齐,便于日后若改回 vxe-grid;当前原生表由父组件插槽渲染单元格 |
cloneDeep | 避免多处 ...EXECUTE_PRICE_COLUMN 共享同一引用导致互相污染 |
表头总列数(用于空数据 colspan):
const colspan = columns.reduce(
(sum, col) => sum + (col.headerMergeCells ?? 1),
0
);
例如 specTableConfig 为 2 + 1 + 1 = 4,与父组件中 4 个 <td> 布局一致。
子组件 ExecutePriceDialogItem
职责:标题区 + 展开收起 + 根据 gridOption 渲染表头;表体完全交给默认插槽。
<script setup lang="ts">
import type { CUSTOM_EXECUTE_PRICE } from "../config/table";
defineOptions({
name: "ExecutePriceDialogItem",
});
const props = defineProps<{
title: string;
tipText?: string;
gridOption: CUSTOM_EXECUTE_PRICE;
}>();
const expand = ref(true);
const tableConfig = ref(props.gridOption);
const colspan = computed(() =>
tableConfig.value.columns.reduce(
(prev, cur) => prev + (cur.headerMergeCells ?? 1),
0
)
);
watch(
() => props.gridOption,
(newVal) => {
tableConfig.value = newVal;
},
{ deep: true, immediate: true }
);
</script>
<template>
<div class="price-item">
<div class="header">
<h3 class="title">{{ title }}</h3>
<div class="expand-icon" @click="expand = !expand">
{{ expand ? "- 收起" : "+ 展开" }}
</div>
<div v-if="tipText" class="tip-text">{{ tipText }}</div>
</div>
<div v-if="expand" class="price__table">
<table>
<thead>
<tr>
<th
v-for="(item, colIndex) in tableConfig.columns"
:key="item.field ?? item.title ?? colIndex"
:colspan="item.headerMergeCells ?? 1"
>
{{ item.title }}
</th>
</tr>
</thead>
<tbody>
<template v-if="tableConfig.data?.length">
<tr
v-for="(row, index) in tableConfig.data"
:key="(row as any).id ?? index"
>
<slot :row="row" :row-index="index" />
</tr>
</template>
<tr v-else>
<td :colspan="colspan">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
要点:
- 表头:
headerMergeCells→<th colspan>(见 MDN:colspan)。 - 表体:使用 Vue 3 默认插槽,由父组件保证每行
<td>数量与合并规则一致。 colspan计算属性:空数据时单行「暂无数据」横跨整表。:key:优先row.id,避免仅用index在排序 / 删除时错位(示例中(row as any).id可按实际类型替换)。:width写在<th>:HTML 更推荐colgroup/ CSS;若列宽不稳,可在样式中为th:nth-child(n)设宽。
样式保持 border-collapse、边框与表头背景,与 Element Plus 弹窗内表格风格一致(完整 SCSS 见原实现,此处不重复贴出)。
父组件用法
无行合并(基础 / 颜色)
每行 3 列:属性名 + 全国价 + 专属价。
<ExecutePriceDialogItem
:grid-option="state.baseTableConfig"
title="基础价格配置"
>
<template #default="{ row }">
<td><!-- 属性名 --></td>
<td><!-- 全国价 --></td>
<td><!-- 专属价 --></td>
</template>
</ExecutePriceDialogItem>
有行合并(规格 / 行业)
约定:仅在合并组的第一行上设置 row._cell(正整数),表示该行第一列 rowspan 行数;其余行不要再输出该列的 <td>,否则会破坏表格结构。
<ExecutePriceDialogItem
:grid-option="state.specTableConfig"
title="规格价格配置"
>
<template #default="{ row }">
<td v-if="row._cell" :rowspan="row._cell">
<!-- 合并单元格内容 -->
</td>
<td></td>
<td></td>
<td></td>
</template>
</ExecutePriceDialogItem>
数据示例(逻辑行 4 列,首列纵向合并 2 行):
const data = [
{ id: 1, name: "规格 A", _cell: 2 /* ... */ },
{ id: 2 /* 第二行无 _cell,不渲染首列 td */ },
{ id: 3, name: "规格 B", _cell: 1 /* ... */ },
];
_cell 为项目约定字段,需在接口层或 transform 中按后端 / 业务规则生成;合并组行数应等于 rowspan,否则会出现空洞或重叠。
复用到其它场景
- 新增一块价格区:复制一份
cloneDeep配置,调整title、headerMergeCells、data。 - 父组件为每块提供
#default插槽,按列填内容与校验。 - 若需行合并:在数据处理阶段写入
_cell,并保证非首行少渲染一列<td>。
限制与注意
| 项 | 说明 |
|---|---|
| 列数一致性 | 有 rowspan 时,合并组内各行的 <td> 个数必须一致,否则浏览器表格布局会乱 |
| 与 vxe 列插槽 | 配置里 slots.default 在原生 <table> 下不会自动生效,内容写在父组件插槽内 |
| 可访问性 | 纯 <table> 需保证表头与数据单元格语义正确;复杂交互可考虑 scope 或补充 aria |
| 全站统一 | 若要求与 vxe-grid 列宽拖拽、虚拟滚动等一致,应改用 vxe 官方合并 API,而非长期维护双轨 |
与 vxe-table 原生能力的对照(可选迁移)
| 需求 | 本方案 | vxe-table 方向 |
|---|---|---|
| 表头 colspan | headerMergeCells → <th colspan> | merge-header-cells + show-custom-header |
| 分组表头 | 未使用 | vxe-colgroup |
| 表体 rowspan / colspan | 插槽 + _cell | span-method 返回 { rowspan, colspan } |
迁移前请在当前锁定版本下对照官方示例验证,避免 API 名称或参数随大版本变化。
参考文献
| 资料 | 链接 |
|---|---|
| vxe-table 仓库 | https://github.com/x-extends/vxe-table |
| vxe-table v4 · span-method | https://vxetable.cn/v4/#/table/api?apiKey=span-method |
| vxe-table v4 · merge-header-cells | https://vxetable.cn/v4/#/grid/api?apiKey=merge-header-cells |
| vxe-table · Issue #1602(表头合并讨论) | https://github.com/x-extends/vxe-table/issues/1602 |
| MDN · colspan | https://developer.mozilla.org/zh-CN/docs/Web/HTML/Attributes/colspan |
| MDN · rowspan | https://developer.mozilla.org/zh-CN/docs/Web/HTML/Attributes/rowspan |
| Vue 3 · 插槽 | https://cn.vuejs.org/guide/components/slots.html |
相关文章
GridView 宫格加载渲染优化
系统首页 GridView 宫格模块接口耗时不高,但首次进入总耗时接近 22s。复盘耗时分层定位过程、代码层面的瓶颈(约 2000 行、100 个 tab 重复节点)、优化手段与最终指标对比。
vxe-table 行 Hover 联动高亮
近期需求是双表格联动高亮:
属性排序需求
需要实现属性的拖拽排序; 2. 支持同级子项排序,但禁止跨容器拖拽。 例如:A 容器内的项不能拖到 B 容器,只能在 A 容器内部调整顺序(B 容器同理)。
内部插件 / 工具库开发实践
在企业前端项目中,utils、hooks、libs 这类通用能力通常分散在多个仓库,常见问题包括:
新商家系统性能优化实践
商家系统新版本上线后,团队持续针对构建效率与用户体验做了一轮系统性优化。本文记录核心方案与落地结果,供后续版本复用。
老系统升级与兼容改造实践
公司原有业务系统采用 Node + jQuery 架构,长期运行后暴露出典型遗留系统问题:
Series
team documents
1 / 22