Files
his/openhis-ui-vue3/src/components/TableLayout/EditableTable.vue

519 lines
15 KiB
Vue
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>