FORMA

执行价格弹窗:表头 / 表体合并与可配置表格

背景

业务需要在「执行价格」类弹窗中展示多块价格配置(基础、颜色、规格、行业等)。每块区域具备:

  • 可配置标题提示文案
  • 展开 / 收起
  • 表头「属性名称」等列在不同场景下 横向合并(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,不受影响

这样做的直接原因(与当时实现一致):

  1. 需要 单行表头、按列配置 colspan,并与现有 columns / data 结构统一维护。
  2. 表体既要 rowspan,又要 按列自定义单元格内容(价格输入等),用插槽逐行输出 <td> 更直观。
  3. 该弹窗为局部 UI,用轻量 <table> 即可满足原型,避免在同一弹窗内再叠一套 vxe-grid 的表头合并、列宽、空状态等配置成本。

关于 vxe-table 能力说明
vxe-table 并非不支持表体合并:可通过 span-method 合并单元格。较新版本也支持表头合并(如 merge-header-cellsshow-custom-header),或使用 vxe-colgroup 做分组表头。
本文中的 headerMergeCells 不是 vxe-table 内置字段,而是项目自定义配置,仅在被封装的 ExecutePriceDialogItem 内映射为 HTML colspan。若新需求与全站表格强一致,可评估迁回 vxe-grid 并改用官方合并 API(需结合当前 vxe-table 版本文档逐项对照)。

mermaid
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 不同:

ts
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

ts
const colspan = columns.reduce(
  (sum, col) => sum + (col.headerMergeCells ?? 1),
  0
);

例如 specTableConfig2 + 1 + 1 = 4,与父组件中 4 个 <td> 布局一致。

子组件 ExecutePriceDialogItem

职责:标题区 + 展开收起 + 根据 gridOption 渲染表头;表体完全交给默认插槽

vue
<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 列:属性名 + 全国价 + 专属价。

vue
<ExecutePriceDialogItem
  :grid-option="state.baseTableConfig"
  title="基础价格配置"
>
  <template #default="{ row }">
    <td><!-- 属性名 --></td>
    <td><!-- 全国价 --></td>
    <td><!-- 专属价 --></td>
  </template>
</ExecutePriceDialogItem>

有行合并(规格 / 行业)

约定:仅在合并组的第一行上设置 row._cell(正整数),表示该行第一列 rowspan 行数;其余行不要再输出该列的 <td>,否则会破坏表格结构。

vue
<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 行):

ts
const data = [
  { id: 1, name: "规格 A", _cell: 2 /* ... */ },
  { id: 2 /* 第二行无 _cell,不渲染首列 td */ },
  { id: 3, name: "规格 B", _cell: 1 /* ... */ },
];

_cell项目约定字段,需在接口层或 transform 中按后端 / 业务规则生成;合并组行数应等于 rowspan,否则会出现空洞或重叠。

复用到其它场景

  1. 新增一块价格区:复制一份 cloneDeep 配置,调整 titleheaderMergeCellsdata
  2. 父组件为每块提供 #default 插槽,按列填内容与校验。
  3. 若需行合并:在数据处理阶段写入 _cell,并保证非首行少渲染一列 <td>

限制与注意

说明
列数一致性rowspan 时,合并组内各行的 <td> 个数必须一致,否则浏览器表格布局会乱
与 vxe 列插槽配置里 slots.default 在原生 <table> 下不会自动生效,内容写在父组件插槽内
可访问性<table> 需保证表头与数据单元格语义正确;复杂交互可考虑 scope 或补充 aria
全站统一若要求与 vxe-grid 列宽拖拽、虚拟滚动等一致,应改用 vxe 官方合并 API,而非长期维护双轨

与 vxe-table 原生能力的对照(可选迁移)

需求本方案vxe-table 方向
表头 colspanheaderMergeCells<th colspan>merge-header-cells + show-custom-header
分组表头未使用vxe-colgroup
表体 rowspan / colspan插槽 + _cellspan-method 返回 { rowspan, colspan }

迁移前请在当前锁定版本下对照官方示例验证,避免 API 名称或参数随大版本变化。

参考文献

Series

team documents

1 / 22