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