基于 el-table 可编辑表格的二次封装

85 阅读3分钟

js部分

import { computed, nextTick, reactive, ref } from "vue";
import CellErrorWrap from "./CellErrorWrap.vue";

defineOptions({
  name: "EditableTable",
});

type OptionItem = {
  label: string;
  value: string | number;
};

type ValidateTrigger = "change" | "blur" | "submit";

type RuleItem<T = any> = {
  trigger?: ValidateTrigger;
  validator: (ctx: {
    row: T;
    value: any;
    field: string;
    rows: T[];
    rowIndex: number;
  }) => true | string | Promise<true | string>;
};

type ColumnConfig<T = any> = {
  prop: keyof T | string;
  label: string;
  width?: number | string;
  minWidth?: number | string;
  editor: "input" | "select" | "text";
  optionsKey?: string;
  rules?: RuleItem<T>[];
  validateDeps?: string[];
};

const props = defineProps<{
  modelValue: any[];
  columns: ColumnConfig[];
  options?: Record<string, OptionItem[]>;
  createRow: () => any;
}>();

const emit = defineEmits<{ (e: "update:modelValue", value: any[]): void }>();

const tableRef = ref();

const innerData = computed({
  get: () => props.modelValue,
  set: val => emit("update:modelValue", val),
});

/**
 * 错误映射:
 * key: rowKey-field
 * value: string[]
 */
const errorMap = reactive<Record<string, string[]>>({});

/**
 * tooltip 展示状态:
 * key: rowKey-field
 * value: boolean
 */
const visibleMap = reactive<Record<string, boolean>>({});

function getCellKey(rowKey: string, field: string) {
  return `${rowKey}-${field}`;
}

function getColumn(field: string) {
  return props.columns.find(item => String(item.prop) === field);
}

function getOptions(row: any, col: ColumnConfig) {
  if (!col.optionsKey) return [];
  return props.options?.[col.optionsKey] || [];
}

function getCellErrors(rowKey: string, field: string) {
  return errorMap[getCellKey(rowKey, field)] || [];
}

function getCellFirstError(rowKey: string, field: string) {
  return getCellErrors(rowKey, field)[0] || "";
}

function setCellErrors(rowKey: string, field: string, errors: string[]) {
  const key = getCellKey(rowKey, field);
  if (!errors.length) {
    delete errorMap[key];
    delete visibleMap[key];
    return;
  }

  errorMap[key] = errors;
}

function isCellTooltipVisible(rowKey: string, field: string) {
  return !!visibleMap[getCellKey(rowKey, field)];
}

function openCellTooltip(rowKey: string, field: string) {
  visibleMap[getCellKey(rowKey, field)] = true;
}

function closeCellTooltip(rowKey: string, field: string) {
  delete visibleMap[getCellKey(rowKey, field)];
}

function closeAllTooltips() {
  Object.keys(visibleMap).forEach(key => {
    delete visibleMap[key];
  });
}

function clearAllErrors() {
  Object.keys(errorMap).forEach(key => {
    delete errorMap[key];
  });
  closeAllTooltips();
}

/**
 * 业务副作用
 * 这里你后面可以独立抽到 useEffects 里
 */
async function runSideEffects(row: any, rowIndex: number, field: string, value: any) {
  if (field === "appointType") {
    // 示例:不是线下时,清空线下相关字段
    if (value !== 2) {
      row.appointNumber = "";
      row.appointGoProvince = null;
      row.appointGoCity = null;
    }
  }

  if (field === "appointGoProvince") {
    row.appointGoCity = null;
  }
}

/**
 * 单个 cell 校验
 */
async function validateCell(row: any, rowIndex: number, field: string, trigger?: ValidateTrigger) {
  const col = getColumn(field);

  if (!col?.rules?.length) {
    setCellErrors(row.rowKey, field, []);
    return true;
  }

  const errors: string[] = [];

  for (const rule of col.rules) {
    if (trigger && rule.trigger && rule.trigger !== trigger) {
      continue;
    }

    const result = await rule.validator({
      row,
      value: row[field],
      field,
      rows: innerData.value,
      rowIndex,
    });

    if (result !== true) {
      errors.push(String(result));
      break;
    }
  }

  setCellErrors(row.rowKey, field, errors);

  if (!errors.length) {
    closeCellTooltip(row.rowKey, field);
  }

  return errors.length === 0;
}

/**
 * 校验当前字段依赖的其他字段
 */
async function validateLinkedFields(row: any, rowIndex: number, field: string, trigger?: ValidateTrigger) {
  const col = getColumn(field);
  if (!col?.validateDeps?.length) return true;

  let allPassed = true;

  for (const depField of col.validateDeps) {
    const passed = await validateCell(row, rowIndex, depField, trigger);
    if (!passed) {
      allPassed = false;
    }
  }

  return allPassed;
}

/**
 * 单字段级联校验:
 * 1. 校验自己
 * 2. 再校验依赖字段
 */
async function validateFieldCascade(row: any, rowIndex: number, field: string, trigger?: ValidateTrigger) {
  const selfPassed = await validateCell(row, rowIndex, field, trigger);
  const depPassed = await validateLinkedFields(row, rowIndex, field, trigger);
  return selfPassed && depPassed;
}

/**
 * 单行校验
 * stopOnFirst=true 时,遇错即停
 */
async function validateRow(row: any, rowIndex: number, stopOnFirst = true) {
  for (const col of props.columns) {
    const field = String(col.prop);
    const passed = await validateCell(row, rowIndex, field, "submit");

    if (!passed) {
      openCellTooltip(row.rowKey, field);

      if (stopOnFirst) {
        return {
          valid: false,
          row,
          rowIndex,
          field,
        };
      }
    }
  }

  return {
    valid: true,
  };
}

/**
 * 首错即停
 */
async function validateFirst() {
  clearAllErrors();

  for (let i = 0; i < innerData.value.length; i++) {
    const row = innerData.value[i];
    const result = await validateRow(row, i, true);

    if (!result.valid) {
      await nextTick();
      return result;
    }
  }

  return {
    valid: true,
  };
}

/**
 * 全量校验
 * 所有错误都显示 tooltip
 */
async function validateAll() {
  clearAllErrors();

  let valid = true;
  const invalidCells: Array<{
    row: any;
    rowIndex: number;
    field: string;
  }> = [];

  for (let i = 0; i < innerData.value.length; i++) {
    const row = innerData.value[i];

    for (const col of props.columns) {
      const field = String(col.prop);
      const passed = await validateCell(row, i, field, "submit");

      if (!passed) {
        valid = false;
        invalidCells.push({
          row,
          rowIndex: i,
          field,
        });
        openCellTooltip(row.rowKey, field);
      }
    }
  }

  await nextTick();

  return {
    valid,
    invalidCells,
  };
}

/**
 * 单个字段 change:
 * 1. 改值
 * 2. 跑副作用
 * 3. 校验自己
 * 4. 校验联动字段
 */
async function handleFieldChange(row: any, rowIndex: number, field: string, value: any) {
  row[field] = value;

  await runSideEffects(row, rowIndex, field, value);

  await validateFieldCascade(row, rowIndex, field, "change");
}

/**
 * 单个字段 blur
 */
async function handleFieldBlur(row: any, rowIndex: number, field: string) {
  await validateFieldCascade(row, rowIndex, field, "blur");
}

/**
 * 手动校验单字段
 */
async function validateSingleField(
  row: any,
  rowIndex: number,
  field: string,
  trigger: ValidateTrigger = "submit",
  showTooltip = true
) {
  const passed = await validateFieldCascade(row, rowIndex, field, trigger);

  if (!passed && showTooltip) {
    const selfError = getCellFirstError(row.rowKey, field);
    if (selfError) {
      openCellTooltip(row.rowKey, field);
    }
  }

  return passed;
}

function handleAddRow() {
  innerData.value = [...innerData.value, props.createRow()];
}

function removeRow(index: number) {
  const next = [...innerData.value];
  next.splice(index, 1);
  innerData.value = next;
  clearAllErrors();
}

async function handleValidateFirst() {
  await validateFirst();
}

async function handleValidateAll() {
  await validateAll();
}

defineExpose({
  validateCell,
  validateSingleField,
  validateFieldCascade,
  validateFirst,
  validateAll,
  clearAllErrors,
});

(2)html部分

<el-table ref="tableRef" :data="innerData" border row-key="rowKey" style="width: 100%">
      <el-table-column type="index" label="#" width="60" fixed="left" />
      <el-table-column
        v-for="col in columns"
        :key="String(col.prop)"
        :label="col.label"
        :prop="String(col.prop)"
        :width="col.width"
        :min-width="col.minWidth"
      >
        <template #default="{ row, $index }">
          <CellErrorWrap
            :error-message="getCellFirstError(row.rowKey, String(col.prop))"
            :force-show="isCellTooltipVisible(row.rowKey, String(col.prop))"
          >
            <template v-if="col.editor === 'input'">
              <el-input
                :model-value="row[col.prop]"
                clearable
                @update:model-value="val => handleFieldChange(row, $index, String(col.prop), val)"
                @blur="handleFieldBlur(row, $index, String(col.prop))"
              />
            </template>

            <template v-else-if="col.editor === 'select'">
              <el-select
                :model-value="row[col.prop]"
                clearable
                style="width: 100%"
                @change="val => handleFieldChange(row, $index, String(col.prop), val)"
                @blur="handleFieldBlur(row, $index, String(col.prop))"
              >
                <el-option
                  v-for="item in getOptions(row, col)"
                  :key="item.value"
                  :label="item.label"
                  :value="item.value"
                />
              </el-select>
            </template>

            <template v-else>
              <span>{{ row[col.prop] }}</span>
            </template>
          </CellErrorWrap>
        </template>
      </el-table-column>

      <el-table-column label="操作" width="90" fixed="right">
        <template #default="{ $index }">
          <el-button type="danger" link @click="removeRow($index)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
    <style scoped lang="scss">
.editable-table {
  width: 100%;

  &__toolbar {
    margin-bottom: 12px;
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
  }
}
</style>

这个表单的封装,非常经典,这也是我推荐给大家看的主要原因。欢迎大家交流和点赞。