可编辑表单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>

浙公网安备 33010602011771号