519 lines
15 KiB
Vue
Executable File
519 lines
15 KiB
Vue
Executable File
<template>
|
||
<el-form ref="formRef" :model="{ tableData }" :rules="rules" class="editable-table-form">
|
||
<div
|
||
v-if="showAddButton || showDeleteButton || searchFields.length > 0"
|
||
class="editable-table-toolbar"
|
||
>
|
||
<div class="toolbar-left">
|
||
<el-button v-if="showAddButton" type="primary" icon="Plus" @click="handleToolbarAdd">
|
||
添加行
|
||
</el-button>
|
||
<el-button
|
||
v-if="showDeleteButton"
|
||
type="danger"
|
||
icon="Delete"
|
||
:disabled="selectedRows.length === 0"
|
||
@click="handleToolbarDelete"
|
||
>
|
||
删除行
|
||
</el-button>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<el-input
|
||
v-if="searchFields.length > 0"
|
||
v-model="searchKeyword"
|
||
:placeholder="searchPlaceholder"
|
||
clearable
|
||
style="width: 300px"
|
||
@input="handleSearch"
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Search /></el-icon>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
</div>
|
||
<vxe-table
|
||
ref="tableRef"
|
||
:data="filteredTableData"
|
||
:border="border ? 'full' : false"
|
||
:stripe="stripe"
|
||
:max-height="maxHeight || undefined"
|
||
:min-height="minHeight || undefined"
|
||
:height="!maxHeight && !minHeight ? '100%' : undefined"
|
||
:row-config="{ keyField: '_etKey' }"
|
||
:scroll-x="{ enabled: true }"
|
||
:scroll-y="{ enabled: true }"
|
||
:show-overflow="true"
|
||
v-bind="$attrs"
|
||
@checkbox-change="handleSelectionChange"
|
||
@checkbox-all="handleSelectionChange"
|
||
class="editable-table-inner"
|
||
>
|
||
<vxe-column v-if="showSelection" type="checkbox" width="55" align="center" />
|
||
<vxe-column
|
||
v-if="showRowActions"
|
||
:width="rowActionsColumnWidth"
|
||
align="center"
|
||
fixed="left"
|
||
>
|
||
<template #header>
|
||
<div
|
||
v-if="showSelection && selectedRows.length > 0 && !showDeleteButton"
|
||
style="display: flex; align-items: center; justify-content: center; gap: 4px"
|
||
>
|
||
<el-button type="danger" size="small" icon="Delete" link @click="handleDeleteSelected">
|
||
删除选中({{ selectedRows.length }})
|
||
</el-button>
|
||
</div>
|
||
<span v-else></span>
|
||
</template>
|
||
<template #default="{ row, rowIndex }">
|
||
<el-button
|
||
v-if="showRowAddButton"
|
||
type="primary"
|
||
link
|
||
icon="CirclePlus"
|
||
class="action-btn"
|
||
@click="handleAdd(rowIndex)"
|
||
title="增加"
|
||
/>
|
||
<el-button
|
||
v-if="showRowDeleteButton"
|
||
type="danger"
|
||
link
|
||
icon="Delete"
|
||
class="action-btn"
|
||
@click="handleDelete(rowIndex)"
|
||
title="删除"
|
||
/>
|
||
</template>
|
||
</vxe-column>
|
||
|
||
<vxe-column
|
||
v-for="col in filteredColumns"
|
||
:key="col.prop"
|
||
:field="col.prop"
|
||
:title="col.label"
|
||
:width="col.width"
|
||
:min-width="col.minWidth"
|
||
:fixed="col.fixed"
|
||
:align="col.align || 'center'"
|
||
>
|
||
<template #default="{ row, rowIndex }">
|
||
<template v-if="col.type === 'input'">
|
||
<el-form-item
|
||
:prop="`tableData.${rowIndex}.${col.prop}`"
|
||
:rules="col.rules"
|
||
style="margin-bottom: 0"
|
||
>
|
||
<el-input
|
||
v-model="row[col.prop]"
|
||
:placeholder="col.placeholder || `请输入${col.label}`"
|
||
:disabled="col.disabled"
|
||
:clearable="col.clearable !== false"
|
||
@blur="col.onBlur && col.onBlur(row, rowIndex)"
|
||
@input="col.onInput && col.onInput(row, rowIndex)"
|
||
@change="col.onChange && col.onChange(row, rowIndex)"
|
||
>
|
||
<template v-if="col.suffix" #suffix>{{ col.suffix }}</template>
|
||
</el-input>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<template v-else-if="col.type === 'number'">
|
||
<el-form-item
|
||
:prop="`tableData.${rowIndex}.${col.prop}`"
|
||
:rules="col.rules"
|
||
style="margin-bottom: 0"
|
||
>
|
||
<el-input-number
|
||
v-model="row[col.prop]"
|
||
:placeholder="col.placeholder || `请输入${col.label}`"
|
||
:disabled="col.disabled"
|
||
:min="col.min"
|
||
:max="col.max"
|
||
:precision="col.precision"
|
||
:controls="false"
|
||
style="width: 100%"
|
||
@change="col.onChange && col.onChange(row, rowIndex)"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<template v-else-if="col.type === 'select'">
|
||
<el-form-item
|
||
:prop="`tableData.${rowIndex}.${col.prop}`"
|
||
:rules="col.rules"
|
||
style="margin-bottom: 0"
|
||
>
|
||
<el-select
|
||
v-model="row[col.prop]"
|
||
:placeholder="col.placeholder || `请选择${col.label}`"
|
||
:disabled="col.disabled"
|
||
:clearable="col.clearable !== false"
|
||
:filterable="col.filterable"
|
||
:multiple="col.multiple"
|
||
style="width: 100%"
|
||
:class="row.error ? 'error-border' : ''"
|
||
@change="
|
||
async (value) => {
|
||
const checkBeforeChange = col.extraprops?.checkBeforeChange;
|
||
if (checkBeforeChange && typeof checkBeforeChange === 'function') {
|
||
const result = await checkBeforeChange(row, rowIndex, value);
|
||
if (result === false) {
|
||
return;
|
||
}
|
||
}
|
||
if (col.onChange) {
|
||
col.onChange(row, rowIndex, value);
|
||
}
|
||
}
|
||
"
|
||
>
|
||
<el-option
|
||
v-for="option in typeof col.options === 'function'
|
||
? col.options(row, rowIndex)
|
||
: col.options || []"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
@click="option.onClick && option.onClick(row, option)"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<template v-else-if="col.type === 'date'">
|
||
<el-form-item
|
||
:prop="`tableData.${rowIndex}.${col.prop}`"
|
||
:rules="col.rules"
|
||
style="margin-bottom: 0"
|
||
>
|
||
<el-date-picker
|
||
v-model="row[col.prop]"
|
||
:type="col.dateType || 'date'"
|
||
:placeholder="col.placeholder || `请选择${col.label}`"
|
||
:disabled="col.disabled"
|
||
:clearable="col.clearable !== false"
|
||
:value-format="col.valueFormat || 'YYYY-MM-DD'"
|
||
style="width: 100%"
|
||
@change="col.onChange && col.onChange(row, rowIndex)"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<template v-else-if="col.type === 'slot'">
|
||
<el-form-item
|
||
:prop="`tableData.${rowIndex}.${col.prop}`"
|
||
:rules="col.rules"
|
||
style="margin-bottom: 0"
|
||
>
|
||
<slot :name="col.slot || col.prop" :row="row" :index="rowIndex" />
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<span>{{
|
||
col.formatter
|
||
? col.formatter(row, { property: col.prop }, row[col.prop])
|
||
: row[col.prop]
|
||
}}</span>
|
||
</template>
|
||
</template>
|
||
</vxe-column>
|
||
</vxe-table>
|
||
<div v-if="$slots.footer" class="editable-table-footer">
|
||
<slot name="footer" :tableData="tableData" />
|
||
</div>
|
||
</el-form>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import {computed, nextTick, ref, watch} from 'vue';
|
||
import {Search} from '@element-plus/icons-vue';
|
||
import type {EditableTableProps} from '../types/EditableTable.d';
|
||
|
||
defineOptions({
|
||
name: 'EditableTable',
|
||
});
|
||
|
||
const props = withDefaults(defineProps<EditableTableProps>(), {
|
||
modelValue: () => [],
|
||
rules: () => ({}),
|
||
defaultRow: () => ({}),
|
||
border: true,
|
||
stripe: false,
|
||
showSelection: false,
|
||
showAddButton: false,
|
||
showDeleteButton: false,
|
||
showRowActions: true,
|
||
showRowAddButton: true,
|
||
showRowDeleteButton: true,
|
||
searchFields: () => [],
|
||
virtualizedThreshold: 100,
|
||
});
|
||
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: Record<string, any>[]];
|
||
add: [row: Record<string, any>, index: number];
|
||
delete: [row: Record<string, any>, index: number, isClear: boolean];
|
||
'selection-change': [selection: Record<string, any>[]];
|
||
'toolbar-add': [];
|
||
'toolbar-delete': [rows: Record<string, any>[]];
|
||
}>();
|
||
|
||
const formRef = ref<any>(null);
|
||
const tableRef = ref<any>(null);
|
||
const selectedRows = ref<Record<string, any>[]>([]);
|
||
const searchKeyword = ref('');
|
||
|
||
const tableData = ref([...props.modelValue]);
|
||
|
||
// 行唯一 key(用于虚拟滚动等)
|
||
const autoRowId = ref(0);
|
||
const getRowKey = (row: Record<string, any>) => {
|
||
if (row.rowKey !== undefined && row.rowKey !== null) return row.rowKey;
|
||
if (row.id !== undefined && row.id !== null) return row.id;
|
||
if (!row._etKey) {
|
||
row._etKey = `et-${autoRowId.value++}`;
|
||
}
|
||
return row._etKey;
|
||
};
|
||
|
||
// 是否开启虚拟滚动:优先使用外部传入,其次根据数据量自动开启
|
||
const useVirtualized = computed(() => {
|
||
if (typeof props.virtualized === 'boolean') {
|
||
return props.virtualized;
|
||
}
|
||
const threshold = props.virtualizedThreshold ?? 100;
|
||
return tableData.value.length > threshold;
|
||
});
|
||
|
||
// 过滤列(支持条件显示)
|
||
const filteredColumns = computed(() => {
|
||
return props.columns.filter((col) => !col.vIf || col.vIf());
|
||
});
|
||
|
||
// 行操作列宽度:同时显示"增加+删除"则宽一点;只显示一个则缩窄
|
||
const rowActionsColumnWidth = computed(() => {
|
||
const showAdd = !!props.showRowAddButton;
|
||
const showDel = !!props.showRowDeleteButton;
|
||
if (showAdd && showDel) return 100;
|
||
if (showAdd || showDel) return 60;
|
||
return 0;
|
||
});
|
||
|
||
const searchPlaceholder = computed(() => {
|
||
if (props.searchFields.length === 0) {
|
||
return '请输入搜索关键词';
|
||
}
|
||
|
||
const fieldLabels = props.searchFields
|
||
.map((field) => {
|
||
const column = props.columns.find((col) => col.prop === field);
|
||
return column?.label || field;
|
||
})
|
||
.filter(Boolean);
|
||
|
||
if (fieldLabels.length === 0) {
|
||
return '请输入搜索关键词';
|
||
}
|
||
|
||
if (fieldLabels.length === 1) {
|
||
return `请输入${fieldLabels[0]}`;
|
||
}
|
||
|
||
return `请输入${fieldLabels.join('|')}`;
|
||
});
|
||
|
||
// 根据搜索关键词过滤表格数据
|
||
const filteredTableData = computed(() => {
|
||
if (!searchKeyword.value || props.searchFields.length === 0) {
|
||
return tableData.value;
|
||
}
|
||
|
||
const keyword = searchKeyword.value.toLowerCase();
|
||
return tableData.value.filter((row) => {
|
||
return props.searchFields.some((field) => {
|
||
const value = row[field];
|
||
if (value === null || value === undefined) {
|
||
return false;
|
||
}
|
||
return String(value).toLowerCase().includes(keyword);
|
||
});
|
||
});
|
||
});
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(newVal) => {
|
||
if (newVal !== tableData.value) {
|
||
tableData.value = [...newVal];
|
||
}
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
watch(
|
||
tableData,
|
||
(newVal) => {
|
||
emit('update:modelValue', newVal);
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
const handleAdd = (index) => {
|
||
const newRow = { ...props.defaultRow };
|
||
tableData.value.splice(index + 1, 0, newRow);
|
||
nextTick(() => {
|
||
emit('add', newRow, index + 1);
|
||
});
|
||
};
|
||
|
||
const handleDelete = (index) => {
|
||
if (tableData.value.length === 1) {
|
||
Object.keys(tableData.value[0]).forEach((key) => {
|
||
tableData.value[0][key] = '';
|
||
});
|
||
Object.assign(tableData.value[0], { ...props.defaultRow });
|
||
emit('delete', tableData.value[0], index, true);
|
||
} else {
|
||
const deletedRow = tableData.value.splice(index, 1)[0];
|
||
emit('delete', deletedRow, index, false);
|
||
}
|
||
};
|
||
|
||
const handleSelectionChange = ({ records }: { records: Record<string, any>[] }) => {
|
||
selectedRows.value = records;
|
||
emit('selection-change', records);
|
||
};
|
||
|
||
// 删除所有选中的行
|
||
const handleDeleteSelected = () => {
|
||
if (selectedRows.value.length === 0) {
|
||
return;
|
||
}
|
||
|
||
// 获取选中行的索引
|
||
const selectedIndexes = selectedRows.value.map((row) => tableData.value.indexOf(row));
|
||
// 从后往前删除,避免索引变化问题
|
||
selectedIndexes.sort((a, b) => b - a);
|
||
|
||
// 如果选中了所有行且只剩一行,清空数据而不是删除
|
||
if (tableData.value.length === selectedRows.value.length && tableData.value.length === 1) {
|
||
Object.keys(tableData.value[0]).forEach((key) => {
|
||
tableData.value[0][key] = '';
|
||
});
|
||
Object.assign(tableData.value[0], { ...props.defaultRow });
|
||
emit('delete', tableData.value[0], 0, true);
|
||
} else {
|
||
// 删除选中的行
|
||
selectedIndexes.forEach((index) => {
|
||
if (index !== -1 && tableData.value.length > 1) {
|
||
const deletedRow = tableData.value.splice(index, 1)[0];
|
||
emit('delete', deletedRow, index, false);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 清空选中状态
|
||
if (tableRef.value) {
|
||
tableRef.value.clearCheckboxRow();
|
||
}
|
||
selectedRows.value = [];
|
||
};
|
||
|
||
// 工具栏新增按钮
|
||
const handleToolbarAdd = () => {
|
||
const newRow = { ...props.defaultRow };
|
||
tableData.value.push(newRow);
|
||
nextTick(() => {
|
||
emit('toolbar-add');
|
||
emit('add', newRow, tableData.value.length - 1);
|
||
});
|
||
};
|
||
|
||
// 工具栏删除按钮
|
||
const handleToolbarDelete = () => {
|
||
if (selectedRows.value.length === 0) {
|
||
return;
|
||
}
|
||
emit('toolbar-delete', selectedRows.value);
|
||
handleDeleteSelected();
|
||
};
|
||
|
||
// 搜索处理
|
||
const handleSearch = () => {
|
||
// 搜索逻辑已在 computed 中处理
|
||
};
|
||
|
||
const validate = (callback) => {
|
||
if (formRef.value) {
|
||
return formRef.value.validate(callback);
|
||
}
|
||
};
|
||
|
||
const validateField = (props, callback) => {
|
||
if (formRef.value) {
|
||
return formRef.value.validateField(props, callback);
|
||
}
|
||
};
|
||
|
||
const resetFields = () => {
|
||
if (formRef.value) {
|
||
formRef.value.resetFields();
|
||
}
|
||
};
|
||
|
||
const clearValidate = (props) => {
|
||
if (formRef.value) {
|
||
formRef.value.clearValidate(props);
|
||
}
|
||
};
|
||
|
||
defineExpose({
|
||
formRef,
|
||
tableRef,
|
||
validate,
|
||
validateField,
|
||
resetFields,
|
||
clearValidate,
|
||
tableData,
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.editable-table-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
|
||
.editable-table-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
padding: 0 4px;
|
||
|
||
.toolbar-left {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
}
|
||
|
||
.editable-table-inner {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.editable-table-footer {
|
||
margin-top: 16px;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
.action-btn {
|
||
padding: 2px 4px;
|
||
}
|
||
}
|
||
</style> |