可编辑表单vue

可编辑组件

import { computed, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Check, Close, Delete, Edit, Plus } from "@element-plus/icons-vue";
import type {
  EditableCellSlotProps,
  EditableColumn,
  EditableTableExpose,
  EditableTableProps,
} from "./types";
import { validateField, validateRowFields } from "./validate";

type RowRecord = Record<string, unknown>;

const props = withDefaults(defineProps<EditableTableProps<T>>(), {
  rowKey: "id",
  border: true,
  stripe: true,
  loading: false,
  showIndex: false,
  showActions: true,
  addable: true,
  deletable: true,
  actionWidth: 180,
  addText: "新增行",
});

defineSlots<{
  [key: string]: (props: EditableCellSlotProps<T>) => unknown;
  actions?: (props: { row: T; index: number; editing: boolean }) => unknown;
}>();

const emit = defineEmits<{
  "update:modelValue": [value: T[]];
  change: [value: T[]];
  "row-add": [row: T, index: number];
  "row-delete": [row: T, index: number];
  "row-save": [row: T, index: number];
  "row-cancel": [row: T, index: number];
}>();

const editingKeys = ref<Set<string | number>>(new Set());
const rowSnapshots = ref<Map<string | number, RowRecord>>(new Map());
const cellErrors = ref<Record<string, string>>({});

const tableData = computed({
  get: () => props.modelValue,
  set: (value: T[]) => {
    emit("update:modelValue", value);
    emit("change", value);
  },
});

function resolveRowKey(row: RowRecord, index: number): string | number {
  const key = row[props.rowKey];
  if (key !== undefined && key !== null && key !== "") {
    return key as string | number;
  }
  return `__index_${index}`;
}

function resolveSlotName(column: EditableColumn<T>): string {
  return column.slot || column.prop;
}

function cellErrorKey(row: RowRecord, index: number, column: EditableColumn<T>): string {
  return `${resolveRowKey(row, index)}::${column.prop}`;
}

function getCellError(row: RowRecord, index: number, column: EditableColumn<T>): string {
  return cellErrors.value[cellErrorKey(row, index, column)] || "";
}

function setCellError(row: RowRecord, index: number, column: EditableColumn<T>, error: string | null) {
  const key = cellErrorKey(row, index, column);
  if (error) {
    cellErrors.value = { ...cellErrors.value, [key]: error };
  } else {
    const next = { ...cellErrors.value };
    delete next[key];
    cellErrors.value = next;
  }
}

function clearRowErrors(row: RowRecord, index: number) {
  const prefix = `${resolveRowKey(row, index)}::`;
  const next = { ...cellErrors.value };
  let changed = false;
  for (const key of Object.keys(next)) {
    if (key.startsWith(prefix)) {
      delete next[key];
      changed = true;
    }
  }
  if (changed) {
    cellErrors.value = next;
  }
}

function isEditing(row: RowRecord, index: number): boolean {
  return editingKeys.value.has(resolveRowKey(row, index));
}

function isColumnEditable(column: EditableColumn<T>): boolean {
  return column.editable !== false;
}

function isCellEditing(row: RowRecord, index: number, column: EditableColumn<T>): boolean {
  return isEditing(row, index) && isColumnEditable(column);
}

function validateCell(row: RowRecord, index: number, column: EditableColumn<T>): boolean {
  const error = validateField(row, column);
  setCellError(row, index, column, error);
  return !error;
}

function getCellSlotProps(row: RowRecord, index: number, column: EditableColumn<T>): EditableCellSlotProps<T> {
  const editing = isCellEditing(row, index, column);
  return {
    row: row as T,
    index,
    column,
    editing,
    value: row[column.prop],
    error: getCellError(row, index, column),
    validate: () => validateCell(row, index, column),
  };
}

function validateEditingRow(row: RowRecord, index: number): boolean {
  const errors = validateRowFields(row, props.columns, isColumnEditable);
  clearRowErrors(row, index);
  for (const [prop, message] of Object.entries(errors)) {
    const column = props.columns.find((item) => item.prop === prop);
    if (column) {
      setCellError(row, index, column, message);
    }
  }
  return Object.keys(errors).length === 0;
}

function startEdit(row: RowRecord, index: number) {
  const key = resolveRowKey(row, index);
  rowSnapshots.value.set(key, { ...row });
  editingKeys.value = new Set([...editingKeys.value, key]);
  clearRowErrors(row, index);
}

function cancelEdit(row: RowRecord, index: number) {
  const key = resolveRowKey(row, index);
  const snapshot = rowSnapshots.value.get(key);
  if (snapshot) {
    Object.keys(row).forEach((field) => {
      if (!(field in snapshot)) {
        delete row[field];
      }
    });
    Object.assign(row, snapshot);
  }
  editingKeys.value.delete(key);
  rowSnapshots.value.delete(key);
  clearRowErrors(row, index);
  emit("row-cancel", row as T, index);
}

function saveEdit(row: RowRecord, index: number) {
  if (!validateEditingRow(row, index)) {
    ElMessage.warning("请修正表单校验错误后再保存");
    return;
  }

  const key = resolveRowKey(row, index);
  editingKeys.value.delete(key);
  rowSnapshots.value.delete(key);
  clearRowErrors(row, index);
  emit("update:modelValue", [...props.modelValue]);
  emit("change", [...props.modelValue]);
  emit("row-save", row as T, index);
  ElMessage.success("保存成功");
}

function addRow() {
  const newRow = { ...(props.defaultRow || {}) } as RowRecord;
  if (newRow[props.rowKey] === undefined || newRow[props.rowKey] === null || newRow[props.rowKey] === "") {
    newRow[props.rowKey] = `new_${Date.now()}`;
  }

  const nextData = [...props.modelValue, newRow as T];
  emit("update:modelValue", nextData);
  emit("change", nextData);

  const index = nextData.length - 1;
  startEdit(newRow, index);
  emit("row-add", newRow as T, index);
}

async function deleteRow(row: RowRecord, index: number) {
  try {
    await ElMessageBox.confirm("确认删除该行?", "提示", {
      type: "warning",
      confirmButtonText: "删除",
      cancelButtonText: "取消",
    });
  } catch {
    return;
  }

  const key = resolveRowKey(row, index);
  editingKeys.value.delete(key);
  rowSnapshots.value.delete(key);
  clearRowErrors(row, index);

  const nextData = props.modelValue.filter((_, rowIndex) => rowIndex !== index);
  emit("update:modelValue", nextData);
  emit("change", nextData);
  emit("row-delete", row as T, index);
  ElMessage.success("删除成功");
}

function cancelAll() {
  props.modelValue.forEach((row, index) => {
    if (isEditing(row, index)) {
      cancelEdit(row, index);
    }
  });
}

function validateAll(): boolean {
  let valid = true;
  props.modelValue.forEach((row, index) => {
    if (!isEditing(row, index)) return;
    if (!validateEditingRow(row, index)) {
      valid = false;
    }
  });
  return valid;
}

function formatCell(row: RowRecord, column: EditableColumn<T>): string {
  const value = row[column.prop];
  if (column.formatter) {
    return column.formatter(row as T, value);
  }
  return value == null ? "" : String(value);
}

defineExpose<EditableTableExpose>({
  addRow,
  getData: () => [...props.modelValue],
  cancelAll,
  validateAll,
});
</script>

<template>
  <div class="editable-table">
    <div v-if="addable" class="editable-table__toolbar">
      <el-button type="primary" :icon="Plus" @click="addRow">{{ addText }}</el-button>
    </div>

    <el-table
      v-loading="loading"
      :data="tableData"
      :border="border"
      :stripe="stripe"
      :row-key="rowKey"
      class="editable-table__inner"
    >
      <el-table-column v-if="showIndex" type="index" label="#" width="56" align="center" />

      <el-table-column
        v-for="column in columns"
        :key="column.prop"
        :prop="column.prop"
        :min-width="column.minWidth || 120"
        :width="column.width"
        :align="column.align || 'left'"
      >
        <template #header>
          <span>
            {{ column.label }}
            <span v-if="column.required" class="editable-table__required">*</span>
          </span>
        </template>

        <template #default="{ row, $index }">
          <div
            class="editable-table__field"
            :class="{
              'is-error': isCellEditing(row, $index, column) && !!getCellError(row, $index, column),
            }"
          >
            <slot
              :name="resolveSlotName(column)"
              v-bind="getCellSlotProps(row, $index, column)"
            >
              <span class="editable-table__cell-text">{{ formatCell(row, column) }}</span>
            </slot>
            <p
              v-if="isCellEditing(row, $index, column) && getCellError(row, $index, column)"
              class="editable-table__error"
            >
              {{ getCellError(row, $index, column) }}
            </p>
          </div>
        </template>
      </el-table-column>

      <el-table-column
        v-if="showActions"
        label="操作"
        :width="actionWidth"
        fixed="right"
        align="center"
      >
        <template #default="{ row, $index }">
          <slot
            name="actions"
            :row="row as T"
            :index="$index"
            :editing="isEditing(row, $index)"
          >
            <template v-if="isEditing(row, $index)">
              <el-button type="primary" link :icon="Check" @click="saveEdit(row, $index)">保存</el-button>
              <el-button link :icon="Close" @click="cancelEdit(row, $index)">取消</el-button>
            </template>
            <template v-else>
              <el-button type="primary" link :icon="Edit" @click="startEdit(row, $index)">编辑</el-button>
              <el-button v-if="deletable" type="danger" link :icon="Delete" @click="deleteRow(row, $index)">
                删除
              </el-button>
            </template>
          </slot>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<style scoped>
.editable-table__toolbar {
  margin-bottom: 12px;
}

.editable-table__required {
  margin-left: 2px;
  color: var(--el-color-danger);
}

.editable-table__field {
  width: 100%;
}

.editable-table__field.is-error :deep(.el-input__wrapper),
.editable-table__field.is-error :deep(.el-input-number),
.editable-table__field.is-error :deep(.el-select__wrapper) {
  box-shadow: 0 0 0 1px var(--el-color-danger) inset;
}

.editable-table__error {
  margin: 4px 0 0;
  color: var(--el-color-danger);
  font-size: 12px;
  line-height: 1.4;
}
****
.editable-table__cell-text {
  display: inline-block;
  min-height: 20px;
  line-height: 20px;
}

.editable-table__inner :deep(.el-table__cell) {
  vertical-align: top;
}
</style>


index.ts
export { default as EditableTable } from "./EditableTable.vue";
export { validateField, validateRowFields } from "./validate";
export type {
  EditableCellSlotProps,
  EditableColumn,
  EditableColumnOption,
  EditableTableExpose,index.ts
  EditableTableProps,
  EditableTableSlots,
} from "./types";

types.ts

export interface EditableColumnOption {
  label: string;
  value: string | number | boolean;
}

export interface EditableColumn<T extends Record<string, unknown> = Record<string, unknown>> {
  /** 字段名,对应行数据的 key,也作为默认插槽名 */
  prop: keyof T & string;
  /** 自定义插槽名,未设置时使用 prop */
  slot?: string;
  /** 列标题 */
  label: string;
  width?: string | number;
  minWidth?: string | number;
  align?: "left" | "center" | "right";
  /** 是否可编辑,默认 true */
  editable?: boolean;
  /** 是否必填 */
  required?: boolean;
  /** 必填校验失败时的提示文案 */
  requiredMessage?: string;
  /** 展示态格式化(未提供插槽或非编辑态时的默认展示) */
  formatter?: (row: T, value: unknown) => string;
  /** 数字范围校验 */
  min?: number;
  max?: number;
  /** 字符串最小/最大长度 */
  minLength?: number;
  maxLength?: number;
  /** 字符串正则校验 */
  pattern?: RegExp;
  patternMessage?: string;
  /** 自定义校验,返回错误文案表示失败 */
  validator?: (value: unknown, row: T) => string | null | undefined;
}

export interface EditableCellSlotProps<T extends Record<string, unknown> = Record<string, unknown>> {
  row: T;
  index: number;
  column: EditableColumn<T>;
  /** 当前行是否处于编辑态,且该列可编辑 */
  editing: boolean;
  value: unknown;
  error: string;
  /** 触发当前单元格校验(通常在 blur / change 时调用) */
  validate: () => void;
}

export interface EditableTableProps<T extends Record<string, unknown> = Record<string, unknown>> {
  modelValue: T[];
  columns: EditableColumn<T>[];
  rowKey?: string;
  border?: boolean;
  stripe?: boolean;
  loading?: boolean;
  showIndex?: boolean;
  showActions?: boolean;
  addable?: boolean;
  deletable?: boolean;
  /** 新增行时的默认字段值 */
  defaultRow?: Partial<T>;
  /** 操作列宽度 */
  actionWidth?: string | number;
  /** 新增按钮文案 */
  addText?: string;
}

export interface EditableTableExpose {
  addRow: () => void;
  getData: () => Record<string, unknown>[];
  cancelAll: () => void;
  /** 校验全部行,返回是否全部通过 */
  validateAll: () => boolean;
}

export type EditableTableSlots<T extends Record<string, unknown> = Record<string, unknown>> = {
  [K in keyof T as K extends string ? K : never]?: (props: EditableCellSlotProps<T>) => unknown;
} & {
  actions?: (props: { row: T; index: number; editing: boolean }) => unknown;
};


validate.ts

import type { EditableColumn } from "./types";

type RowRecord = Record<string, unknown>;

function isEmpty(value: unknown): boolean {
  if (value === undefined || value === null) return true;
  if (typeof value === "string" && value.trim() === "") return true;
  return false;
}

/** 校验单个单元格,通过返回 null,失败返回错误文案 */
export function validateField(row: RowRecord, column: EditableColumn): string | null {
  const value = row[column.prop];

  if (column.required && isEmpty(value)) {
    return column.requiredMessage || `${column.label}不能为空`;
  }

  if (isEmpty(value)) return null;

  if (column.min !== undefined || column.max !== undefined || typeof value === "number") {
    const num = Number(value);
    if (Number.isNaN(num)) {
      return `${column.label}必须是数字`;
    }
    if (column.min !== undefined && num < column.min) {
      return `${column.label}不能小于 ${column.min}`;
    }
    if (column.max !== undefined && num > column.max) {
      return `${column.label}不能大于 ${column.max}`;
    }
  }

  if (typeof value === "string") {
    const text = value.trim();
    if (column.minLength !== undefined && text.length < column.minLength) {
      return `${column.label}长度不能少于 ${column.minLength} 个字符`;
    }
    if (column.maxLength !== undefined && text.length > column.maxLength) {
      return `${column.label}长度不能超过 ${column.maxLength} 个字符`;
    }
    if (column.pattern && !column.pattern.test(text)) {
      return column.patternMessage || `${column.label}格式不正确`;
    }
  }

  if (column.validator) {
    const result = column.validator(value, row);
    if (result) return result;
  }

  return null;
}

/** 校验一行所有可编辑列,返回 prop -> 错误文案 */
export function validateRowFields(
  row: RowRecord,
  columns: EditableColumn[],
  isEditable: (column: EditableColumn) => boolean
): Record<string, string> {
  const errors: Record<string, string> = {};
  for (const column of columns) {
    if (!isEditable(column)) continue;
    const error = validateField(row, column);
    if (error) {
      errors[column.prop] = error;
    }
  }
  return errors;
}
父组件使用
<script setup lang="ts">
import { ref } from "vue";
import { EditableTable } from "../../../components/EditableTable";
import type { EditableColumn } from "../../../components/EditableTable";

interface OrderItemRow {
  id: string;
  sku: string;
  name: string;
  quantity: number;
  price: number;
  isGift: boolean;
  status: string;
}

const statusOptions = [
  { label: "待发货", value: "pending" },
  { label: "已发货", value: "shipped" },
  { label: "已完成", value: "done" },
];

const columns: EditableColumn<OrderItemRow>[] = [
  {
    prop: "sku",
    label: "SKU",
    minWidth: 140,
    required: true,
    pattern: /^B[0-9A-Z]{8,}$/i,
    patternMessage: "SKU 需以 B 开头,后跟至少 8 位字母或数字",
  },
  { prop: "name", label: "商品名称", minWidth: 180, required: true, minLength: 2, maxLength: 50 },
  {
    prop: "quantity",
    label: "数量",
    width: 120,
    min: 1,
    required: true,
  },
  {
    prop: "price",
    label: "单价",
    width: 120,
    min: 0,
    required: true,
    formatter: (_row, value) => (value == null ? "" : `¥${Number(value).toFixed(2)}`),
  },
  { prop: "isGift", label: "赠品", width: 100, align: "center" },
  {
    prop: "status",
    label: "状态",
    width: 130,
    required: true,
  },
];

const tableData = ref<OrderItemRow[]>([
  {
    id: "1",
    sku: "B08ABC123",
    name: "无线蓝牙耳机",
    quantity: 2,
    price: 199.99,
    isGift: false,
    status: "pending",
  },
  {
    id: "2",
    sku: "B09XYZ456",
    name: "手机支架",
    quantity: 1,
    price: 29.9,
    isGift: true,
    status: "shipped",
  },
]);

const defaultRow: Partial<OrderItemRow> = {
  sku: "",
  name: "",
  quantity: 1,
  price: 0,
  isGift: false,
  status: "pending",
};

function formatStatus(value: string) {
  return statusOptions.find((item) => item.value === value)?.label ?? value;
}

function onRowSave(row: OrderItemRow) {
  console.log("保存行:", row);
}
</script>

<template>
  <section class="page-card">
    <h2>可编辑表格示例</h2>
    <p class="page-desc">
      单元格渲染由父组件通过插槽自定义;编辑态下失焦校验,保存时校验整行。带 * 号为必填列。
    </p>

    <EditableTable
      v-model="tableData"
      :columns="columns"
      row-key="id"
      :default-row="defaultRow"
      add-text="新增商品行"
      @row-save="onRowSave"
    >
      <template #sku="{ row, editing, validate }">
        <el-input
          v-if="editing"
          v-model="row.sku"
          placeholder="如 B08XXXX"
          clearable
          @blur="validate"
        />
        <span v-else>{{ row.sku }}</span>
      </template>

      <template #name="{ row, editing, validate }">
        <el-input
          v-if="editing"
          v-model="row.name"
          placeholder="请输入商品名称"
          clearable
          @blur="validate"
        />
        <span v-else>{{ row.name }}</span>
      </template>

      <template #quantity="{ row, editing, validate }">
        <el-input-number
          v-if="editing"
          v-model="row.quantity"
          :min="1"
          controls-position="right"
          style="width: 100%"
          @blur="validate"
        />
        <span v-else>{{ row.quantity }}</span>
      </template>

      <template #price="{ row, editing, validate }">
        <el-input-number
          v-if="editing"
          v-model="row.price"
          :min="0"
          :precision="2"
          :step="0.01"
          controls-position="right"
          style="width: 100%"
          @blur="validate"
        />
        <span v-else>¥{{ Number(row.price).toFixed(2) }}</span>
      </template>

      <template #isGift="{ row, editing, validate }">
        <el-switch
          v-if="editing"
          v-model="row.isGift"
          inline-prompt
          active-text="是"
          inactive-text="否"
          @change="validate"
        />
        <span v-else>{{ row.isGift ? "是" : "否" }}</span>
      </template>

      <template #status="{ row, editing, validate }">
        <el-select
          v-if="editing"
          v-model="row.status"
          placeholder="请选择状态"
          style="width: 100%"
          @blur="validate"
          @change="validate"
        >
          <el-option
            v-for="option in statusOptions"
            :key="option.value"
            :label="option.label"
            :value="option.value"
          />
        </el-select>
        <span v-else>{{ formatStatus(row.status) }}</span>
      </template>
    </EditableTable>

    <div class="preview-block">
      <h3>当前数据</h3>
      <pre>{{ JSON.stringify(tableData, null, 2) }}</pre>
    </div>
  </section>
</template>

<style scoped>
.page-desc {
  margin: 0 0 16px;
  color: #64748b;
  font-size: 14px;
}

.preview-block {
  margin-top: 20px;
  padding: 12px 16px;
  background: #f8fafc;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
}

.preview-block h3 {
  margin: 0 0 8px;
  font-size: 14px;
}

.preview-block pre {
  margin: 0;
  font-size: 12px;
  line-height: 1.5;
  white-space: pre-wrap;
  word-break: break-all;
}

:deep(.el-input),
:deep(.el-input-number),
:deep(.el-select),
:deep(.el-date-editor) {
  width: 100%;
}
</style>

posted @ 2026-06-02 20:31  coderlq  阅读(9)  评论(0)    收藏  举报