二次封装了个复杂的el-table表格

最近有一个需求需要用表格来展示数据。由于重写 el-table的这种完成度的组件来不及 (自己尝试实现到一半感觉有点困难),最终决定对其进行二次封装。这个组件并非一蹴而就,而是随着多个需求的叠加,逐步迭代完善的。

虽然是个多级表头,但是常规的使用是这个组件的简单使用

上图

第一版:表头搜索

利用 <template #header>这个表头插槽,在表头中集成了搜索条件。

第二版:行内编辑

新增了行编辑功能,这次是通过改造 <template #default>插槽的内容来实现的。

第三版:多级表头

这一步比较麻烦。虽然 el-table原生支持多级表头,但其配置方式与我的后端接口数据结构无法直接匹配。我需要的多级表头只是简单的嵌套关系。最后,参考了《Vue el-table封装,支持多级表头,自动高度》这篇文章中的方法才得以实现。 本以为到此就够用了,但在调试数据时发现,事情还没完。

第四版:异步列数据与动态联动

我的数据中有三个字段存在联动关系:选择A,需要清空B和C;选择B,则需要清空C。其中一个字段在表格中平铺展示,无需特殊处理,但另外两个字段的联动意味着,

同一列中,不同行对应的下拉框选项列表可能是不同的

常见的表格下拉框实现,通常整列共享一个选项数组,但这不符合我的需求。

因此,我改为监听列数据加载,为每一行动态计算该列对应的选项数组,决了"同行同列不同数据"的问题后,又发现了新问题:表格及后端数据存储的是选项的 key,但显示时需要的是 value

你可能会说,前面动态计算数组时不是处理了吗?

是的,但那仅适用于数据已包含 key的情况。 我还需要支持Excel导入,而导入的文件中只提供了 value,没有对应的 key,这又导致了显示异常。 经过一番思考,我想到的解决方案有两个:

  1. 在导入数据时,遍历并补全 key值。
  2. 在显示时再做一层控制,若无 key则直接显示 value

不过,要彻底解决这个问题,通常还需要后端接口的配合,在数据层面保持一致性。

以上便是这个表格组件完整的演进过程。

这里多级表头的改动,

js 复制代码
<!--
  EditTreeColumn 组件用于在可编辑表格中渲染树状结构的列
  支持多种编辑器类型:普通输入框、选择器、日期选择器
  支持自定义渲染和插槽
-->
<template>
  <!--
    当前列没有子列时,渲染为普通的表格列
    使用 el-table-column 的各种属性来配置列的行为和样式
  -->
  <el-table-column v-if="!col.children" :label="col.label" :prop="col.prop || ''" v-bind="col">
    <!-- 默认输入框 -->
    <template #header>
      <div class="head-cell">
        <div>{{ col.label }}</div>
        <!-- 使用正确的 slot 绑定 -->
        <slot :name="`h-${col.prop}`" v-bind="{ row: col, index: 0 }" />
      </div>
    </template>
    <template #default="scope">
      <!--
        当单元格处于编辑状态时,根据列定义的编辑器类型显示对应的编辑组件
        支持 select、date-picker 和普通 input 三种类型
      -->
      <div
        v-if="props.isEditing(scope.$index, col.prop)"
        class="cell-editor"
        :class="{ 'has-error': getFieldError(scope.row, col.prop, scope.$index) }"
        @click.stop
      >
        <el-tooltip
          :disabled="!getFieldError(scope.row, col.prop, scope.$index)"
          :content="getFieldError(scope.row, col.prop, scope.$index)"
          placement="top"
          effect="light"
          trigger="hover"
          :show-after="150"
          :hide-after="100"
          :teleported="true"
          :enterable="false"
          popper-class="field-error-popper"
        >
          <!-- 下拉选择器 -->
          <span class="tooltip-trigger" v-if="props.getEditorType(col.prop) === 'select'">
            <el-select
              v-model="scope.row[col.prop]"
              placeholder="请选择"
              filterable
              :loading="props.loading"
              :class="getFieldError(scope.row, col.prop, scope.$index) ? 'is-error' : ''"
              @change="() => props.handleInputConfirm(col.prop)"
              @visible-change="(visible) => props.handleSelectVisibleChange(visible, col.prop, scope.row)"
              style="width: 100%"
            >
              <el-option
                v-for="opt in props.getOptions(col.prop, scope.row)"
                :key="opt.value || opt"
                :label="opt.label || opt"
                :value="opt.value || opt"
              />
            </el-select>
          </span>

          <!-- 日期选择器 -->
          <span class="tooltip-trigger" v-else-if="props.getEditorType(col.prop) === 'date-picker'">
            <el-date-picker
              v-model="scope.row[col.prop]"
              type="date"
              value-format="YYYY-MM-DD"
              placeholder="选择日期"
              :class="getFieldError(scope.row, col.prop, scope.$index) ? 'is-error' : ''"
              @change="() => props.handleInputConfirm(col.prop)"
              @blur="() => props.handleInputConfirm(col.prop)"
              style="width: 100%"
            />
          </span>

          <!-- 普通输入框 -->
          <span class="tooltip-trigger" v-else>
            <el-input
              v-model="scope.row[col.prop]"
              :type="col.prop && col.prop.includes('prop-') ? 'number' : 'text'"
              :class="getFieldError(scope.row, col.prop, scope.$index) ? 'is-error' : ''"
              @input="(val) => handleInput(scope.row, col.prop, val)"
              @blur="() => props.handleInputConfirm(col.prop)"
              @keydown.enter="() => props.handleInputConfirm(col.prop)"
              style="width: 100%"
            />
          </span>
        </el-tooltip>
      </div>

      <!--
        当单元格不处于编辑状态时,根据配置显示内容
        如果有自定义渲染函数则使用该函数,否则使用插槽或直接显示数据
      -->
      <template v-else>
        <component v-if="col.render" :is="col.render" :row="scope.row" :index="scope.$index" />
        <slot v-else :name="col.slotName || col.prop" v-bind="scope">
          <span>{{ scope.row[col.prop] }}</span>
        </slot>
      </template>
    </template>
  </el-table-column>

  <!--
    当前列有子列时,渲染为嵌套的表格列结构
    支持递归渲染多层嵌套的列结构
  -->
  <el-table-column v-else :label="col.label">
    <template #header v-if="col.header">
      <component :is="col.header" :row="col" />
    </template>
    <!-- 递归渲染子列 -->
    <EditTreeColumn
      v-for="t in col.children"
      :key="t.prop || t.label"
      :col="t"
      :is-editing="props.isEditing"
      :get-editor-type="props.getEditorType"
      :get-options="props.getOptions"
      :loading="props.loading"
      :handle-input-confirm="props.handleInputConfirm"
      :handle-select-visible-change="props.handleSelectVisibleChange"
    >
      <!-- 传递所有插槽 -->
      <template v-for="slot in Object.keys($slots)" #[slot]="scope">
        <slot :name="slot" v-bind="scope" />
      </template>
    </EditTreeColumn>
  </el-table-column>
</template>

<script>
// 组件名称定义
export default {
  name: 'EditTreeColumn'
};
</script>

<script setup>
// 定义组件接收的属性
const props = defineProps({
  col: {
    type: Object,
    default: () => {}
  },
  // 注入所有的编辑控制方法和状态
  isEditing: Function,
  getEditorType: Function,
  getOptions: Function,
  loading: Boolean,
  handleInputConfirm: Function,
  handleSelectVisibleChange: Function,
  getError: Function
});

/**
 * 处理输入事件
 * 特别处理包含 'prop-' 的字段,只允许输入数字
 */
const handleInput = (row, prop, value) => {
  if (!prop) return;
  if (prop.includes('prop-')) {
    const numeric = String(value ?? '').replace(/[^\d]/g, '');
    row[prop] = numeric;
  }
};

const getFieldError = (row, prop, index) => {
  if (typeof props.getError === 'function') {
    return props.getError(row, prop, index) || '';
  }
  return '';
};
</script>

<style scoped>
/* 编辑器容器样式 */
.cell-editor {
  width: 100%;
}

/* 表头单元格样式 */
.head-cell {
  text-align: center;
  padding: 0 4px;
}

.cell-editor {
  position: relative;
}

.cell-editor.has-error :deep(.el-input__wrapper),
.cell-editor.has-error :deep(.el-select__wrapper),
.cell-editor.has-error :deep(.el-date-editor .el-input__wrapper) {
  box-shadow: 0 0 0 1px #f56c6c inset;
  border-color: #f56c6c;
}

.field-error-popper {
  background: #000000 !important;
  color: #000000 !important;
  font-size: 12px;
  padding: 6px 8px;
  border: 1px solid #dcdfe6 !important;
  border-radius: 6px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
}

.tooltip-trigger {
  display: inline-block;
  width: 100%;
}
</style>

css的样式大家看自己的需求是否保留,插槽的命名规则不喜欢我的也可以自己改。

这部分是我的特殊需求,不用可以删除

ini 复制代码
/**
 * 处理输入事件
 * 特别处理包含 'prop-' 的字段,只允许输入数字
 */
const handleInput = (row, prop, value) => {
  if (!prop) return;
  if (prop.includes('prop-')) {
    const numeric = String(value ?? '').replace(/[^\d]/g, '');
    row[prop] = numeric;
  }
};

对eltable 的封装

ts 复制代码
<template>
  <!-- 表格外层容器,用于获取DOM引用 -->
  <div ref="tableWrapperRef">
    <!-- Element Plus表格组件,配置了基本样式和事件 -->
    <el-table ref="tableRef" :data="data" :header-cell-style="{ background: '#fafafa' }" stripe border height="100%"
      highlight-current-row v-bind="$attrs" :cell-style="handleCellStyle">
      <!-- 动态渲染表格列,使用自定义的可编辑树形列组件 -->
      <EditTreeColumn v-for="item in columns" :key="item.prop" :col="item" :is-editing="checkIsEditing"
        :get-editor-type="getEditorComponent" :get-options="getTableOptions" :loading="selectLoading"
        :handle-input-confirm="handleInputConfirm" :handle-select-visible-change="handleSelectVisibleChange"
        :get-error="getError">
        <!-- 传递所有插槽到子组件 -->
        <template v-for="slot in Object.keys($slots)" #[slot]="scope">
          <slot :name="slot" v-bind="scope" />
        </template>
      </EditTreeColumn>

      <!-- 固定操作栏列 -->
      <el-table-column label="操作" :width="120" align="center" fixed="right">
        <template #default="scope">
          <slot name="table-action" :row="scope.row" :index="scope.$index"></slot>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import EditTreeColumn from './EditTreeColumn.vue';

// 定义组件接收的属性
const props = defineProps({
  // 表格数据
  data: {
    type: Array,
    default: () => []
  },
  // 列配置
  columns: {
    type: Array,
    default: () => []
  },
  // 下拉选择加载状态
  selectLoading: {
    type: Boolean,
    default: false
  },
  // 表格选项数据(如下拉选项等)
  tableOptions: {
    type: Object,
    default: () => ({})
  },
  // 编辑器配置(决定每列使用何种编辑组件)
  editorConfig: {
    type: [Object, Function],
    default: () => ({})
  },
  // 自定义单元格样式函数
  cellStyle: {
    type: Function,
    default: null
  },
  getError: {
    type: Function,
    default: null
  }
});

// 定义组件触发的事件
const emit = defineEmits([
  'update:data',           // 数据更新事件
  'select-visible-change', // 下拉选择显隐变化事件
  'editor-start',          // 编辑器启动事件
  'editor-change',         // 编辑器值变化事件
  'row-edit-finish'        // 行编辑完成事件
]);

// 表格容器引用
const tableWrapperRef = ref<HTMLElement | null>(null);
// 正在编辑的行索引
const editingRowIndex = ref<number | null>(null);
// 是否处于编辑状态
const isEditing = ref(false);
// 编辑中的数据副本
const editData = ref<any>(null);
// 原始数据副本
const originalData = ref<any>(null);

/**
 * 获取Excel编辑组件类型
 * @param field 字段名
 */
const getEditorComponent = (field: string): string => {
  // 如果editorConfig是函数,则调用函数获取组件类型
  if (typeof props.editorConfig === 'function') {
    return props.editorConfig(field);
  }
  // 否则从对象中获取对应字段的组件类型,默认为input
  return props.editorConfig[field] || 'input';
};

/**
 * 检查指定单元格是否处于编辑状态
 * @param rowIndex 行索引
 * @param prop 属性名
 */
const checkIsEditing = (rowIndex: number, prop: string): boolean => {
  // 只需判断是否处于编辑状态且是当前行
  return isEditing.value && editingRowIndex.value === rowIndex;
};

/**
 * 处理单元格样式
 */
const handleCellStyle = ({ row, column, rowIndex, columnIndex }) => {
  // 如果提供了自定义样式函数,则调用它
  if (typeof props.cellStyle === 'function') {
    return props.cellStyle(row, column, rowIndex, columnIndex);
  }
  // 默认返回空样式对象
  return {};
};

/**
 * 获取表格选项数据(如下拉列表选项)
 * @param prop 属性名
 * @param row 行数据
 */
const getTableOptions = (prop: string, row: any) => {
  // 特殊处理colorNumber字段
  if (prop === 'colorNumber') {
    // 颜色选项应该根据当前行数据的 customerStyleNumber 动态获取
    // 优先使用 tableOptions 中传入的当前行对应的颜色列表
    // 这里需要父组件配合,在 tableOptions 中传入结构化的数据,或者通过 prop 传递当前行的颜色
    // 由于 tableOptions 是一个扁平对象,我们假设 'colorNumber' 对应的是一个默认列表,或者我们直接触发一个事件让父组件更新 options

    // 如果传递了 row,尝试使用 row 中的 customerStyleNumber 来过滤或获取特定的颜色列表
    // 但这里主要依赖父组件更新 tableOptions.colorNumber
    // 实际上,当开始编辑时,startEditCell 已经触发了 'editor-change',父组件应该已经更新了 tableOptions['colorNumber']
    return props.tableOptions[prop] || [];
  }
  // 其他字段直接从tableOptions中获取
  return props.tableOptions[prop] || [];
};

/**
 * 开始编辑整行
 * @param row 行数据
 * @param index 行索引
 */
const startEditRow = (row: any, index: number): void => {
  // 如果当前已经在编辑该行,不做处理
  if (isEditing.value && editingRowIndex.value === index) return;

  // 如果正在编辑其他地方,先保存
  if (isEditing.value && editingRowIndex.value !== null) {
    saveCurrentEdit();
  }

  // 深度克隆原始数据作为备份
  originalData.value = JSON.parse(JSON.stringify(row));
  editData.value = JSON.parse(JSON.stringify(row));

  editingRowIndex.value = index;
  isEditing.value = true;

  // 触发编辑开始事件
  emit('editor-start', null, row, index);
};

/**
 * 保存当前编辑
 */
const saveCurrentEdit = () => {
  if (isEditing.value && editingRowIndex.value !== null) {
    // 因为是直接修改的 data 中的对象引用,所以不需要从 editData 合并回 data
    // 只需要触发 update:data 事件通知父组件即可
    const newData = [...props.data];
    emit('update:data', newData);
  }
};

/**
 * 重置编辑状态
 */
const resetEditState = (): void => {
  editingRowIndex.value = null;
  originalData.value = null;
  editData.value = null;
  isEditing.value = false;
};

/**
 * 停止编辑并保存
 */
const stopEditing = () => {
  if (isEditing.value) {
    if (editingRowIndex.value !== null) {
      // 触发行编辑完成事件
      emit('row-edit-finish', props.data[editingRowIndex.value], editingRowIndex.value);
    }
    saveCurrentEdit();
    resetEditState();
  }
};

/**
 * 取消编辑并恢复原数据
 */
const cancelEditRow = () => {
  if (isEditing.value && editingRowIndex.value !== null && originalData.value) {
    const rows = [...props.data];
    rows[editingRowIndex.value] = JSON.parse(JSON.stringify(originalData.value));
    emit('update:data', rows);
  }
  resetEditState();
};
/**
 * 处理输入确认(值改变时触发)
 */
const handleInputConfirm = (changedProp?: string | Event) => {
  // 如果是事件对象,则忽略
  const prop = typeof changedProp === 'string' ? changedProp : undefined;

  // 使用 setTimeout 确保值已经更新
  setTimeout(() => {
    if (isEditing.value && editingRowIndex.value !== null && prop) {
      const currentRow = props.data[editingRowIndex.value];
      // 触发编辑变更事件,用于外部联动
      emit('editor-change', currentRow[prop], prop, currentRow);
    }
  }, 0);
};

/**
 * 处理下拉框显示变化
 */
const handleSelectVisibleChange = (visible: boolean, prop: string, currentRow: any) => {
  // 触发下拉选择显隐变化事件
  emit('select-visible-change', visible, prop, currentRow);
};

// 整行编辑模式:不使用单元格双击或外部点击自动保存

// 暴露方法给父组件使用
defineExpose({
  resetEditState,
  startEditRow,
  stopEditing,
  cancelEditRow
});
</script>

<style scoped>
/* 编辑器样式 */
.cell-editor {
  width: 100%;
}

/* 表头包装器样式 */
:deep(.el-table__header-wrapper th>.cell) {
  padding: 0;
  word-break: break-word;
  text-align: center;
}

/* 单元格包装器样式 */
:deep(td>.el-table__cell) {
  text-align: center;
  line-height: normal;
  word-break: break-word;
}

/* 单元格内容样式 */
:deep(td>.cell) {
  padding: 4px 0;
  text-align: center;
  line-height: normal;
  word-break: break-word;
}

/* 表格单元格内边距 */
:deep(.el-table__cell) {
  padding: 0;
  overflow: visible;
}
</style>

下面是我的表头mock

json 复制代码
{
   "code": 200,
   "msg": "操作成功",
   "data": [
       {
           "id": "2000393771221028865",
           "parentId": 0,
           "label": "订单编号",
           "ancestors": null,
           "prop": "orderNumber"
       },
       {
           "id": "2000393771221028866",
           "parentId": 0,
           "label": "下单日期",
           "ancestors": null,
           "prop": "orderDate"
       },
       {
           "id": "2000393771221028867",
           "parentId": 0,
           "label": "客户款号",
           "ancestors": null,
           "prop": "customerStyleNumber"
       },
       {
           "id": "2000393771221028868",
           "parentId": 0,
           "label": "颜色",
           "ancestors": null,
           "prop": "colorNumber"
       },
      
       {
           "id": "1998003174492979213",
           "parentId": 0,
           "label": "7XL",
           "ancestors": "7XL",
           "prop": null,
           "children": [
               {
                   "id": "1998003174492979230",
                   "parentId": "1998003174492979213",
                   "label": "",
                   "ancestors": "7XL",
                   "prop": null,
                   "children": [
                       {
                           "id": "1998003174555893774",
                           "parentId": "1998003174492979230",
                           "label": "",
                           "ancestors": "7XL",
                           "prop": "prop-17"
                       }
                   ]
               }
           ]
       },
       {
           "id": "2000393771221028869",
           "parentId": 0,
           "label": "总计",
           "ancestors": null,
           "prop": "total"
       },
    
       {
           "id": "2000393771221028872",
           "parentId": 0,
           "label": "发货日期",
           "ancestors": null,
           "prop": "shippingDate"
       }
   ]
}

关于表头的搜索,就贴图

表头的插槽我以h-开头,不带h-就是单元格,比如下面这个

js 复制代码
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import EditTable from '../widget/editTable/index.vue';
import headerJsonRaw from '../widget/editTable/表头json?raw';
import styleColorJsonRaw from '../widget/editTable/款号以及颜色?raw';

interface ColumnNode {
  id: string;
  parentId: number | string;
  label: string;
  ancestors: string | null;
  prop: string | null;
  children?: ColumnNode[];
}

interface HeaderResponse {
  code: number;
  msg: string;
  data: ColumnNode[];
}

interface ColorResponse {
  code: number;
  msg: string;
  data: Record<string, string>;
}

type EditorType = 'input' | 'select' | 'date-picker';

interface OrderRow {
  orderNumber: string;
  orderDate: string;
  customerStyleNumber: string;
  colorNumber: string;

  [key: string]: any;
}

const columns = ref<ColumnNode[]>([]);
const tableData = ref<OrderRow[]>([]);
const tableOptions = ref<Record<string, { label: string; value: string }[]>>({});
const selectLoading = ref(false);
const colorMap = ref<Record<string, string>>({});
const editTableRef = ref(); // Add ref for EditTable
const editingRowIndex = ref<number | null>(null);
const DEFAULT_ROW: OrderRow = {
  orderNumber: 'ORD-001',
  orderDate: '2025-01-01',
  customerStyleNumber: 'UC121',
  colorNumber: 'Black',
  shippingDate: '2025-01-31'
};

const INITIAL_ROWS: OrderRow[] = [
  {
    ...DEFAULT_ROW,
    'prop-1': 10,
    'prop-2': 5,
    total: 15,

    produced: 0,
    unproduced: 15
  },
  {
    orderNumber: 'ORD-002',
    orderDate: '2025-01-05',
    customerStyleNumber: 'UXX10',
    colorNumber: 'Charcoal',
    'prop-1': 6,
    'prop-2': 8,
    total: 14,
    produced: 4,
    unproduced: 10,
    shippingDate: '2025-02-10'
  },
  {
    orderNumber: 'ORD-003',
    orderDate: '2025-02-10',
    customerStyleNumber: 'UC601',
    colorNumber: 'Deep Grey/Black',
    'prop-1': 3,
    'prop-2': 2,
    total: 5,
    produced: 1,
    unproduced: 4,
    shippingDate: '2025-03-01'
  }
];

const parseHeaderJson = (): HeaderResponse => {
  return JSON.parse(headerJsonRaw) as HeaderResponse;
};

const parseColorJson = (): ColorResponse => {
  return JSON.parse(styleColorJsonRaw) as ColorResponse;
};

const mockRequest = <T,>(data: T, delay = 600): Promise<T> => {
  return new Promise<T>((resolve) => {
    setTimeout(() => resolve(data), delay);
  });
};

const initPage = async () => {
  const header = parseHeaderJson();
  const colors = parseColorJson();
  columns.value = header.data;
  colorMap.value = colors.data;

  const styleOptions = Object.keys(colors.data || {}).map((key) => ({
    label: key,
    value: key
  }));
  tableOptions.value = {
    ...tableOptions.value,
    customerStyleNumber: styleOptions
  };

  tableData.value = await mockRequest(INITIAL_ROWS, 800);
};
// 处理添加行
const handleAddRow = () => {
  const newRow: OrderRow = { ...DEFAULT_ROW };
  tableData.value.unshift(newRow);
};

// 模拟根据款号获取颜色列表的异步请求
const fetchColorsByStyle = async (styleNumber: string) => {
  selectLoading.value = true;
  const colorString = colorMap.value[styleNumber] || '';
  const list = colorString ? colorString.split(',').map((item) => ({ label: item.trim(), value: item.trim() })) : [];
  const result = await mockRequest(list, 800);
  selectLoading.value = false;
  return result;
};
// 定义编辑器类型配置
const editorConfig: Record<string, EditorType> = {
  orderNumber: 'input',
  orderDate: 'date-picker',
  shippingDate: 'date-picker',
  customerStyleNumber: 'select',
  colorNumber: 'select'
};
// 处理编辑器变化
const handleEditorChange = async (value: any, prop: string, row: OrderRow) => {
  if (prop === 'customerStyleNumber') {
    row.colorNumber = '';
    // 强制触发响应式更新
    tableData.value = [...tableData.value];
  }
};
// 处理下拉框显示变化
const handleSelectVisibleChange = async (visible: boolean, prop: string, row: OrderRow) => {
  if (visible && prop === 'colorNumber') {
    const options = await fetchColorsByStyle(row.customerStyleNumber);
    tableOptions.value = { ...tableOptions.value, colorNumber: options };
  }
};

// 收集所有叶子列的 prop
const collectLeafProps = (cols: ColumnNode[]): string[] => {
  const result: string[] = [];
  const walk = (nodes: ColumnNode[]) => {
    nodes.forEach((n) => {
      if (n.children && n.children.length) {
        walk(n.children);
      } else if (n.prop) {
        result.push(n.prop);
      }
    });
  };
  walk(cols);
  return result;
};

// 行内校验错误提示(供 EditTable 内部展示)
const getFieldError = (row: OrderRow, prop: string, rowIndex: number): string => {
  if (prop === 'orderNumber' && !String(row.orderNumber || '').trim()) return '请输入订单号';
  if (prop === 'orderDate') {
    const val = String(row.orderDate || '').trim();
    if (!val) return '请选择下单日期';
    if (!/^\d{4}-\d{2}-\d{2}$/.test(val)) return '日期格式 YYYY-MM-DD';
  }
  if (prop === 'shippingDate') {
    const val = String(row.shippingDate || '').trim();
    if (!val) return '请选择发货日期';
    if (!/^\d{4}-\d{2}-\d{2}$/.test(val)) return '日期格式 YYYY-MM-DD';
  }
  if (prop === 'customerStyleNumber' && !String(row.customerStyleNumber || '').trim()) return '请选择款号';
  if (prop === 'colorNumber' && String(row.customerStyleNumber || '').trim() && !String(row.colorNumber || '').trim()) return '请选择颜色';
  if (prop.startsWith('prop-')) {
    const val = row[prop];
    if (val === undefined || val === null || String(val).trim() === '') return '';
    const v = Number(val);
    if (!Number.isFinite(v)) return '请输入数字';
    if (v < 0) return '不能为负数';
  }
  return '';
};

// 校验整行
const validateRow = (row: OrderRow): boolean => {
  const props = collectLeafProps(columns.value);
  const errors = props.map((p) => getFieldError(row, p, -1)).filter((e) => e);
  if (errors.length) {
    ElMessage.error(errors[0]);
    return false;
  }
  // 校验 prop- 字段不能全部为空
  const propFields = props.filter((p) => p.startsWith('prop-'));
  if (propFields.length > 0) {
    const hasValue = propFields.some((p) => {
      const val = row[p];
      return val !== undefined && val !== null && String(val).trim() !== '';
    });
    if (!hasValue) {
      ElMessage.error('请至少填写一个尺码数量');
      return false;
    }
  }

  return true;
};

// 处理整行编辑
const handleEditRow = (row: OrderRow, index: number) => {
  if (editTableRef.value) {
    // 预加载当前行的颜色选项,确保编辑时下拉框能正确显示对应的 Label(如果是 Key-Value 模式)
    if (row.customerStyleNumber) {
      fetchColorsByStyle(row.customerStyleNumber).then((options) => {
        tableOptions.value = { ...tableOptions.value, colorNumber: options };
      });
    }
    editTableRef.value.startEditRow(row, index);
    editingRowIndex.value = index;
  }
};

// 获取颜色列的显示文本(用于非编辑状态)
const getColorLabel = (row: OrderRow) => {
  // 场景:如果每个款号对应的颜色组不一样,且存储的是 Key
  // 这里可以根据 row.customerStyleNumber 获取对应的颜色列表,再通过 row.colorNumber (Key) 查找 Label
  if (row !== null && row !== undefined) {
    const colorStr = colorMap.value[row.customerStyleNumber];
    if (colorStr) {
      console.log(colorStr);
      // 模拟查找逻辑
      const list = colorStr.split(',');
      const target = list.find((c) => c === row.colorNumber);
      return target;
    }
  }
};

// 保存当前编辑行
const handleSaveRow = (row: OrderRow) => {
  if (!editTableRef.value) return;
  if (!validateRow(row)) return;
  editTableRef.value.stopEditing();
};

// 取消当前编辑行
const handleCancelRow = () => {
  if (!editTableRef.value) return;
  editTableRef.value.cancelEditRow();
  editingRowIndex.value = null;
};

// 编辑开始/结束事件更新索引
const handleEditorStart = (_prop: string | null, row: OrderRow, index: number) => {
  editingRowIndex.value = index;
};
//编辑结束
const handleRowEditFinish = (_row: OrderRow, _index: number) => {
  editingRowIndex.value = null;
};

// 删除当前行
const handleDeleteRow = (index: number) => {
  const rows = [...tableData.value];
  rows.splice(index, 1);
  tableData.value = rows;
  if (editingRowIndex.value !== null) {
    if (editingRowIndex.value === index) {
      editingRowIndex.value = null;
    } else if (editingRowIndex.value > index) {
      editingRowIndex.value = editingRowIndex.value - 1;
    }
  }
};
onMounted(() => {
  initPage();
});
</script>

<template>
  <div class="container">
    <div class="main">
      <div class="main-toolbar">
        <el-button type="primary" @click="handleAddRow">添加</el-button>
      </div>
      <EditTable
        ref="editTableRef"
        v-if="columns.length"
        v-model:data="tableData"
        :columns="columns"
        :table-options="tableOptions"
        :editor-config="editorConfig"
        :select-loading="selectLoading"
        :get-error="getFieldError"
        @editor-start="handleEditorStart"
        @editor-change="handleEditorChange"
        @select-visible-change="handleSelectVisibleChange"
        @row-edit-finish="handleRowEditFinish"
        style="width: 100%; height: 100%"
      >

        <template #h-colorNumber="{ row }">
          <el-select>
            <el-option v-for="item in 10" :key="item.value" :label="item.label" :value />
          </el-select>
        </template>
        <template #h-customerStyleNumber="{ row }">
          <el-select>
            <el-option v-for="item in styleOptions" :key="item.value" :label="item.label" :value />
          </el-select>
        </template>
        <template #h-orderDate="{ row }">
          <el-date-picker></el-date-picker>
        </template>
        <template #h-orderNumber="{ row }">
          <el-input ></el-input>
        </template>
        <template #table-action="{ row, index }">
          <template v-if="editingRowIndex === index">
            <el-button type="success" link @click="handleSaveRow(row)">保存</el-button>
            <el-button type="warning" link @click="handleCancelRow">取消</el-button>
          </template>
          <template v-else>
            <el-button type="primary" link @click="handleEditRow(row, index)">编辑</el-button>
            <el-button type="danger" link @click="handleDeleteRow(index)">删除</el-button>
          </template>
        </template>
        <template #colorNumber="{ row }">
          {{ getColorLabel(row) }}
        </template>
      </EditTable>
    </div>
    <div>底部</div>
  </div>
</template>

<style scoped lang="less">
.container {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.main {
  flex: 1;
  padding: 16px;
  box-sizing: border-box;
}

.main-toolbar {
  margin-bottom: 8px;
}
</style>
相关推荐
渔_2 小时前
uni-app 页面传参总丢值?3 种方法稳如狗!
前端
用户93816912553602 小时前
在TypeScript中,可选属性(?)与null类型的区别
前端
eason_fan2 小时前
Resize 事件导致的二进制内存泄漏:隐式闭包的 “隐形陷阱”
前端·性能优化
一只叫煤球的猫2 小时前
我做了一个“慢慢来”的开源任务管理工具:蜗牛待办(React + Supabase + Tauri)
前端·react.js·程序员
LYFlied2 小时前
AI时代下的规范驱动开发:重塑前端工程实践
前端·人工智能·驱动开发·ai编程
汉得数字平台2 小时前
汉得H-AI飞码——前端编码助手V1.1.2正式发布:融业务知识,提开发效能
前端·人工智能·智能编码
前端小万2 小时前
Jenkins 打包崩了?罪魁是 package.json 里的 ^
前端·jenkins
编程小白gogogo2 小时前
苍穹外卖前端环境搭建
前端
光影少年3 小时前
web端安全问题有哪些?
前端·安全