Fix Bug #550: AI修复
This commit is contained in:
@@ -1,567 +0,0 @@
|
||||
<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>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="filteredTableData"
|
||||
:border="border"
|
||||
:stripe="stripe"
|
||||
:max-height="maxHeight || undefined"
|
||||
:min-height="minHeight || undefined"
|
||||
:height="!maxHeight && !minHeight ? '100%' : undefined"
|
||||
:row-key="getRowKey"
|
||||
:virtualized="useVirtualized"
|
||||
v-bind="$attrs"
|
||||
@selection-change="handleSelectionChange"
|
||||
class="editable-table-inner"
|
||||
>
|
||||
<el-table-column v-if="showSelection" type="selection" width="55" align="center" />
|
||||
<el-table-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="scope">
|
||||
<el-button
|
||||
v-if="showRowAddButton"
|
||||
type="primary"
|
||||
link
|
||||
icon="CirclePlus"
|
||||
class="action-btn"
|
||||
@click="handleAdd(scope.$index)"
|
||||
title="增加"
|
||||
/>
|
||||
<el-button
|
||||
v-if="showRowDeleteButton"
|
||||
type="danger"
|
||||
link
|
||||
icon="Delete"
|
||||
class="action-btn"
|
||||
@click="handleDelete(scope.$index)"
|
||||
title="删除"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
v-for="col in filteredColumns"
|
||||
:key="col.prop"
|
||||
:prop="col.prop"
|
||||
:label="col.label"
|
||||
:width="col.width"
|
||||
:min-width="col.minWidth"
|
||||
:fixed="col.fixed"
|
||||
:align="col.align || 'center'"
|
||||
:formatter="col.formatter"
|
||||
>
|
||||
<template #default="scope">
|
||||
<template v-if="col.type === 'input'">
|
||||
<el-form-item
|
||||
:prop="`tableData.${scope.$index}.${col.prop}`"
|
||||
:rules="col.rules"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
<el-input
|
||||
v-model="scope.row[col.prop]"
|
||||
:placeholder="col.placeholder || `请输入${col.label}`"
|
||||
:disabled="col.disabled"
|
||||
:clearable="col.clearable !== false"
|
||||
@blur="col.onBlur && col.onBlur(scope.row, scope.$index)"
|
||||
@input="col.onInput && col.onInput(scope.row, scope.$index)"
|
||||
@change="col.onChange && col.onChange(scope.row, scope.$index)"
|
||||
>
|
||||
<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.${scope.$index}.${col.prop}`"
|
||||
:rules="col.rules"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="scope.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(scope.row, scope.$index)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="col.type === 'select'">
|
||||
<el-form-item
|
||||
:prop="`tableData.${scope.$index}.${col.prop}`"
|
||||
:rules="col.rules"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
<el-select
|
||||
v-model="scope.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="scope.row.error ? 'error-border' : ''"
|
||||
@change="
|
||||
async (value) => {
|
||||
const checkBeforeChange = col.extraprops?.checkBeforeChange;
|
||||
if (checkBeforeChange && typeof checkBeforeChange === 'function') {
|
||||
const result = await checkBeforeChange(scope.row, scope.$index, value);
|
||||
if (result === false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (col.onChange) {
|
||||
col.onChange(scope.row, scope.$index, value);
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in typeof col.options === 'function'
|
||||
? col.options(scope.row, scope.$index)
|
||||
: col.options || []"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
@click="option.onClick && option.onClick(scope.row, option)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="col.type === 'date'">
|
||||
<el-form-item
|
||||
:prop="`tableData.${scope.$index}.${col.prop}`"
|
||||
:rules="col.rules"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
<el-date-picker
|
||||
v-model="scope.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(scope.row, scope.$index)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="col.type === 'slot'">
|
||||
<el-form-item
|
||||
:prop="`tableData.${scope.$index}.${col.prop}`"
|
||||
:rules="col.rules"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
<slot :name="col.slot || col.prop" :row="scope.row" :index="scope.$index" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span>{{
|
||||
col.formatter
|
||||
? col.formatter(scope.row, scope.column, scope.row[col.prop])
|
||||
: scope.row[col.prop]
|
||||
}}</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-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<InstanceType<typeof import('element-plus').ElForm> | null>(null);
|
||||
const tableRef = ref<InstanceType<typeof import('element-plus').ElTable> | null>(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 = (selection) => {
|
||||
selectedRows.value = selection;
|
||||
emit('selection-change', selection);
|
||||
};
|
||||
|
||||
// 删除所有选中的行
|
||||
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.clearSelection();
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table.editable-table-inner) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.el-table__body-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.el-table__cell {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
vertical-align: top;
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table__cell) {
|
||||
overflow: visible;
|
||||
vertical-align: top;
|
||||
|
||||
.cell {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
// 错误信息往下撑开行高,不影响上面布局
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
|
||||
.el-form-item__error {
|
||||
position: static;
|
||||
line-height: 1.5;
|
||||
padding-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-danger);
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
margin: 4px;
|
||||
:deep(.el-icon) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editable-table-footer {
|
||||
flex-shrink: 0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user