Revert "```"

This reverts commit abc0674531.
This commit is contained in:
2025-12-26 22:21:21 +08:00
parent ae6c486114
commit 3115e38cc4
920 changed files with 14452 additions and 107025 deletions

View File

@@ -1,60 +0,0 @@
<template>
<div class="table-section" v-loading="loading">
<EditableTable ref="editableTableRef" v-bind="$attrs" class="editable-table">
<template v-for="(_, slotName) in $slots" :key="slotName" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</EditableTable>
</div>
</template>
<script setup>
import { ref } from 'vue';
import EditableTable from './EditableTable.vue';
defineOptions({
name: 'EditTable',
inheritAttrs: false,
});
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
});
const editableTableRef = ref(null);
defineExpose({
get formRef() {
return editableTableRef.value?.formRef;
},
get tableRef() {
return editableTableRef.value?.tableRef;
},
validate: (...args) => editableTableRef.value?.validate(...args),
validateField: (...args) => editableTableRef.value?.validateField(...args),
resetFields: (...args) => editableTableRef.value?.resetFields(...args),
clearValidate: (...args) => editableTableRef.value?.clearValidate(...args),
get tableData() {
return editableTableRef.value?.tableData;
},
});
</script>
<style scoped lang="scss">
.table-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.editable-table {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
</style>

View File

@@ -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 { ref, watch, nextTick, computed } 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>

View File

@@ -1,157 +0,0 @@
<template>
<div v-if="show" class="query-form-wrapper">
<el-form
ref="queryFormRef"
:model="queryParams"
:inline="true"
class="query-form"
:label-width="labelWidth"
>
<template v-for="item in displayedFormItems" :key="item.prop">
<FormItem
:item="item"
:model-value="queryParams[item.prop]"
:on-enter="handleQuery"
@update:model-value="(value) => (queryParams[item.prop] = value)"
@change="(value) => item.onChange && item.onChange(value)"
>
<template v-for="(_, slotName) in $slots" :key="slotName" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</FormItem>
</template>
<el-form-item v-if="showDefaultButtons" style="margin-left: 20px">
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
<el-button v-if="needCollapse" type="text" @click="toggleExpand" style="margin-left: 16px">
{{ isExpanded ? '收起' : '展开' }}
<el-icon class="el-icon--right">
<DArrowLeft v-if="isExpanded" class="collapse-arrow collapse-arrow--up" />
<DArrowRight v-else class="collapse-arrow collapse-arrow--down" />
</el-icon>
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import FormItem from './FormItem.vue';
import type { FilterProps } from '../types/Filter.d';
defineOptions({
name: 'Filter'
});
const props = withDefaults(defineProps<FilterProps>(), {
formItems: () => [],
show: true,
showDefaultButtons: true,
labelWidth: '120px',
showLabelColon: true,
});
const emit = defineEmits<{
query: [queryParams: Record<string, any>];
reset: [];
}>();
const queryFormRef = ref<InstanceType<typeof import('element-plus').ElForm> | null>(null);
const isExpanded = ref(true);
const itemsPerRow = 4;
const normalizedFormItems = computed(() =>
(props.formItems || []).map((item) => ({
...item,
labelSuffix: item.labelSuffix ?? (props.showLabelColon ? '' : ''),
}))
);
const needCollapse = computed(() => {
if (!normalizedFormItems.value || normalizedFormItems.value.length === 0) return false;
let totalWidth = 0;
normalizedFormItems.value.forEach((item) => {
if (item.type === 'custom' || item.type === 'daterange') {
totalWidth += 2;
} else {
totalWidth += 1;
}
});
return totalWidth > itemsPerRow * 2;
});
const displayedFormItems = computed(() => {
if (!needCollapse.value || isExpanded.value) {
return normalizedFormItems.value;
}
const maxItems = itemsPerRow * 2;
let count = 0;
const result: any[] = [];
for (const item of normalizedFormItems.value) {
const itemWidth = item.type === 'custom' || item.type === 'daterange' ? 2 : 1;
if (count + itemWidth > maxItems) {
break;
}
result.push(item);
count += itemWidth;
}
return result;
});
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
const handleQuery = () => {
emit('query', props.queryParams);
};
const resetQuery = () => {
if (queryFormRef.value) {
queryFormRef.value.resetFields();
}
if (props.queryParams && Object.prototype.hasOwnProperty.call(props.queryParams, 'pageNum')) {
props.queryParams.pageNum = 1;
}
emit('reset');
};
defineExpose({
queryFormRef,
handleQuery,
resetQuery,
});
</script>
<style scoped lang="scss">
.query-form-wrapper {
flex-shrink: 0;
width: 100%;
.query-form {
width: 100%;
}
}
.collapse-arrow {
transition: transform 0.2s ease;
&.collapse-arrow--up {
transform: rotate(90deg);
}
&.collapse-arrow--down {
transform: rotate(90deg);
}
}
</style>

View File

@@ -1,115 +0,0 @@
<template>
<el-form
ref="formRef"
:model="model"
:rules="rules"
:label-width="labelWidth"
:inline="inline"
:label-position="labelPosition"
class="table-layout-form"
>
<template v-for="item in normalizedFormItems" :key="item.prop">
<FormItem
:item="item"
:model-value="model[item.prop]"
@update:model-value="(value) => (model[item.prop] = value)"
@change="(value) => item.onChange && item.onChange(value)"
>
<template v-for="(_, slotName) in $slots" :key="slotName" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</FormItem>
</template>
</el-form>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import FormItem from './FormItem.vue';
import type { FormProps } from '../types/Form.d';
defineOptions({
name: 'Form'
});
const props = withDefaults(defineProps<FormProps>(), {
formItems: () => [],
rules: () => ({}),
labelWidth: '120px',
inline: false,
labelPosition: 'right',
showLabelColon: true,
});
const emit = defineEmits<{
validate: [callback?: (valid: boolean) => void];
}>();
const formRef = ref<InstanceType<typeof import('element-plus').ElForm> | null>(null);
const normalizedFormItems = computed(() =>
(props.formItems || []).map((item) => ({
...item,
labelSuffix: item.labelSuffix ?? (props.showLabelColon ? '' : ''),
}))
);
// 表单验证
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);
}
};
// 滚动到指定字段
const scrollToField = (prop) => {
if (formRef.value) {
formRef.value.scrollToField(prop);
}
};
defineExpose({
formRef,
validate,
validateField,
resetFields,
clearValidate,
scrollToField,
});
</script>
<style scoped lang="scss">
.table-layout-form {
width: 100%;
// 非内联表单样式
&:not(.el-form--inline) {
:deep(.el-form-item) {
display: flex;
margin-right: 0;
}
}
}
</style>

View File

@@ -1,196 +0,0 @@
<template>
<el-form-item
:label="labelWithSuffix"
:prop="item.prop"
:required="item.required"
:class="{ 'form-item-double': item.type === 'custom' || item.type === 'daterange' }"
>
<el-input
v-if="item.type === 'input'"
:model-value="modelValue"
:placeholder="item.placeholder || `请输入${item.label}`"
:clearable="item.clearable !== false"
:style="item.style || { width: item.width || '200px' }"
v-bind="item.extraprops || {}"
@keyup.enter="handleEnter"
@update:model-value="handleUpdate"
/>
<el-select
v-else-if="item.type === 'select'"
:model-value="modelValue"
:placeholder="item.placeholder || `请选择${item.label}`"
:clearable="item.clearable !== false"
:style="item.style || { width: item.width || '200px' }"
:disabled="item.disabled"
v-bind="item.extraprops || {}"
:multiple="item.multiple !== false"
:filterable="item.filterable !== false"
:collapse-tags="item.collapseTags !== false"
@change="handleChange"
@update:model-value="(value) => handleUpdateWithCheck(value, item.checkBeforeChange)"
>
<el-option
v-for="option in item.options || []"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-radio-group
v-else-if="item.type === 'radio'"
:model-value="modelValue"
v-bind="item.extraprops || {}"
@change="handleChange"
@update:model-value="handleUpdate"
>
<el-radio v-for="option in item.options || []" :key="option.value" :label="option.value">
{{ option.label }}
</el-radio>
</el-radio-group>
<!-- 单独日期 -->
<el-date-picker
v-else-if="item.type === 'date'"
:model-value="modelValue"
type="date"
:placeholder="item.placeholder || `请选择${item.label}`"
:clearable="item.clearable !== false"
:value-format="item.valueFormat || 'YYYY-MM-DD'"
:style="item.style || { width: item.width || '200px' }"
:disabled="item.disabled"
v-bind="item.extraprops || {}"
@change="handleChange"
@update:model-value="handleUpdate"
/>
<!-- 日期区间 -->
<QuickDateRange
v-else-if="item.type === 'daterange'"
:model-value="daterangeValue"
:start-placeholder="item.startPlaceholder || '开始日期'"
:end-placeholder="item.endPlaceholder || '结束日期'"
:value-format="item.valueFormat || 'YYYY-MM-DD'"
:clearable="item.clearable !== false"
:date-picker-style="daterangeStyle"
:attrs="item.extraprops || {}"
@change="handleChange"
@update:model-value="handleUpdate"
/>
<!-- 纯文本展示 -->
<span
v-else-if="item.type === 'text'"
:style="item.style || { width: item.width || '200px' }"
class="form-item-text"
>
{{ item.formatter ? item.formatter(modelValue) : modelValue ?? '' }}
</span>
<slot
v-else-if="item.type === 'custom'"
:name="item.slot || item.prop"
:item="item"
:modelValue="modelValue"
:updateModelValue="handleUpdate"
/>
</el-form-item>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { FormItemProps } from '../types/FormItem.d';
import QuickDateRange from './QuickDateRange.vue';
defineOptions({
name: 'FormItem',
});
const props = defineProps<FormItemProps>();
const emit = defineEmits<{
'update:modelValue': [value: any];
change: [value: any];
}>();
const labelWithSuffix = computed(() => {
const suffix = props.item.labelSuffix || '';
return `${props.item.label || ''}${suffix}`;
});
// 日期区间组件的值处理
const daterangeValue = computed<string[]>(() => {
if (props.item.type === 'daterange') {
if (Array.isArray(props.modelValue)) {
return props.modelValue.map((v: any) => String(v));
}
return [];
}
return [];
});
// 日期区间组件的样式处理
const daterangeStyle = computed(() => {
if (props.item.type === 'daterange') {
if (
typeof props.item.style === 'object' &&
props.item.style !== null &&
!Array.isArray(props.item.style)
) {
return props.item.style;
}
return { width: props.item.width || 'calc(316px + 7em)' };
}
return {};
});
const handleUpdate = (value: any) => {
emit('update:modelValue', value);
};
const handleUpdateWithCheck = async (value: any, shouldCheck = false) => {
if (shouldCheck) {
if (props.item.onChange && typeof props.item.onChange === 'function') {
const result = await props.item.onChange(value);
if (result === false) {
return;
}
}
}
handleUpdate(value);
};
const handleChange = (value: any) => {
emit('change', value);
};
const handleEnter = () => {
if (props.onEnter && typeof props.onEnter === 'function') {
props.onEnter();
}
};
</script>
<style scoped lang="scss">
:deep(.el-form-item) {
margin-bottom: 16px;
display: inline-flex;
align-items: flex-start;
vertical-align: top;
margin-right: 16px;
.el-form-item__label {
width: 7em !important;
min-width: 7em;
white-space: normal;
word-break: break-all;
line-height: 1.5;
padding-right: 8px;
text-align: right;
padding-top: 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.el-form-item__content {
display: flex;
align-items: center;
}
}
</style>

View File

@@ -1,143 +0,0 @@
<template>
<el-form
ref="formRef"
:model="model"
:rules="rules"
:label-width="labelWidth"
:label-position="labelPosition"
class="form-layout-form"
>
<div class="form-items-container" :class="columns > 0 ? `form-layout-${columns}col` : ''">
<template v-for="(item, index) in normalizedFormItems" :key="item.prop">
<FormItem
:item="item"
:model-value="model[item.prop]"
@update:model-value="
async (value) => {
if (item.onChange && typeof item.onChange === 'function') {
const result = await item.onChange(value);
if (result === false) {
return;
}
}
model[item.prop] = value;
}
"
>
<template v-for="(_, slotName) in $slots" :key="slotName" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</FormItem>
<span
v-if="
columns > 0 &&
index > 0 &&
(index + 1) % columns === 0 &&
index < normalizedFormItems.length - 1
"
class="form-item-break"
/>
</template>
</div>
</el-form>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import FormItem from './FormItem.vue';
import type { FormLayoutProps } from '../types/FormLayout.d';
defineOptions({
name: 'FormLayout',
});
const props = withDefaults(defineProps<FormLayoutProps>(), {
formItems: () => [],
rules: () => ({}),
labelWidth: '120px',
labelPosition: 'right',
showLabelColon: true,
columns: 0,
});
const formRef = ref<InstanceType<typeof import('element-plus').ElForm> | null>(null);
const normalizedFormItems = computed(() =>
(props.formItems || []).map((item) => ({
...item,
labelSuffix: item.labelSuffix ?? (props.showLabelColon ? '' : ''),
}))
);
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);
}
};
const scrollToField = (prop) => {
if (formRef.value) {
formRef.value.scrollToField(prop);
}
};
defineExpose({
formRef,
validate,
validateField,
resetFields,
clearValidate,
scrollToField,
});
</script>
<style scoped lang="scss">
.form-layout-form {
width: 100%;
.form-items-container {
display: flex;
flex-wrap: wrap;
column-gap: 16px;
row-gap: 16px;
.form-item-break {
flex-basis: 100%;
width: 0;
height: 0;
margin: 0;
padding: 0;
border: none;
}
}
:deep(.el-form-item) {
margin-bottom: 0;
justify-content: flex-start;
flex: 0 0 auto;
.el-form-item__content {
justify-content: flex-start;
text-align: left;
}
}
}
</style>

View File

@@ -1,18 +0,0 @@
<template>
<div class="form-section">
<slot />
</div>
</template>
<script setup>
defineOptions({
name: 'FormSection',
});
</script>
<style scoped lang="scss">
.form-section {
flex-shrink: 0;
margin-bottom: 1rem;
}
</style>

View File

@@ -1,39 +0,0 @@
<template>
<div class="form-section">
<FormLayout ref="formLayoutRef" v-bind="$attrs">
<template v-for="(_, slotName) in $slots" :key="slotName" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</FormLayout>
</div>
</template>
<script setup>
import { ref } from 'vue';
import FormLayout from './FormLayout.vue';
defineOptions({
name: 'FormSectionLayout',
inheritAttrs: false,
});
const formLayoutRef = ref(null);
defineExpose({
get formRef() {
return formLayoutRef.value?.formRef;
},
validate: (...args) => formLayoutRef.value?.validate(...args),
validateField: (...args) => formLayoutRef.value?.validateField(...args),
resetFields: (...args) => formLayoutRef.value?.resetFields(...args),
clearValidate: (...args) => formLayoutRef.value?.clearValidate(...args),
scrollToField: (...args) => formLayoutRef.value?.scrollToField(...args),
});
</script>
<style scoped lang="scss">
.form-section {
flex-shrink: 0;
margin-bottom: 1rem;
}
</style>

View File

@@ -1,136 +0,0 @@
<template>
<el-input
:model-value="displayValue"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
@input="handleInput"
@blur="handleBlur"
@change="handleChange"
>
<template v-if="suffix" #suffix>{{ suffix }}</template>
</el-input>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
const props = defineProps({
modelValue: [Number, String],
placeholder: String,
disabled: Boolean,
clearable: {
type: Boolean,
default: true,
},
suffix: String,
precision: Number, // 小数位数
min: Number,
max: Number,
});
const emit = defineEmits(['update:modelValue', 'blur', 'change']);
const displayValue = computed(() => {
if (props.modelValue === null || props.modelValue === undefined || props.modelValue === '') {
return '';
}
return String(props.modelValue);
});
const handleInput = (value) => {
// 只允许数字、小数点和负号
let newValue = value.replace(/[^\d.-]/g, '');
// 只允许一个小数点
const parts = newValue.split('.');
if (parts.length > 2) {
newValue = parts[0] + '.' + parts.slice(1).join('');
}
// 只允许一个负号,且必须在开头
if (newValue.indexOf('-') > 0) {
newValue = newValue.replace(/-/g, '');
}
if (newValue.startsWith('-') && newValue.split('-').length > 2) {
newValue = '-' + newValue.replace(/-/g, '');
}
// 如果为空,直接返回空字符串
if (newValue === '' || newValue === '-') {
emit('update:modelValue', '');
return;
}
// 转换为数字
const numValue = parseFloat(newValue);
if (isNaN(numValue)) {
emit('update:modelValue', '');
return;
}
// 限制最小值
if (props.min !== undefined && numValue < props.min) {
newValue = String(props.min);
}
// 限制最大值
if (props.max !== undefined && numValue > props.max) {
newValue = String(props.max);
}
// 处理精度
if (props.precision !== undefined && newValue.includes('.')) {
const parts = newValue.split('.');
if (parts[1] && parts[1].length > props.precision) {
parts[1] = parts[1].substring(0, props.precision);
newValue = parts.join('.');
}
}
emit('update:modelValue', newValue);
};
const handleBlur = (event) => {
const value = event.target.value;
if (value === '' || value === '-') {
emit('update:modelValue', '');
emit('blur', event);
return;
}
const numValue = parseFloat(value);
if (isNaN(numValue)) {
emit('update:modelValue', '');
emit('blur', event);
return;
}
// 应用精度
let finalValue = numValue;
if (props.precision !== undefined) {
finalValue = parseFloat(numValue.toFixed(props.precision));
}
// 限制范围
if (props.min !== undefined && finalValue < props.min) {
finalValue = props.min;
}
if (props.max !== undefined && finalValue > props.max) {
finalValue = props.max;
}
emit('update:modelValue', String(finalValue));
emit('blur', event);
};
const handleChange = (value) => {
emit('change', value);
};
</script>
<style scoped lang="scss">
:deep(.el-input__inner) {
text-align: left;
}
</style>

View File

@@ -1,29 +0,0 @@
<template>
<Layout>
<template #default>
<div class="page-wrapper">
<slot />
</div>
</template>
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</Layout>
</template>
<script setup>
import Layout from '@/components/Layout/index.vue';
defineOptions({
name: 'PageLayout',
});
</script>
<style scoped lang="scss">
.page-wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
</style>

View File

@@ -1,20 +0,0 @@
<template>
<div class="page-wrapper">
<slot />
</div>
</template>
<script setup>
defineOptions({
name: 'PageWrapper',
});
</script>
<style scoped lang="scss">
.page-wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
</style>

View File

@@ -1,171 +0,0 @@
<template>
<div class="quick-date-range">
<el-select v-model="quickType" class="quick-select" @change="handleQuickChange">
<el-option label="自定义时间段" value="custom" />
<el-option label="今天" value="today" />
<el-option label="昨天" value="yesterday" />
<el-option label="本周" value="thisWeek" />
<el-option label="上周" value="lastWeek" />
<el-option label="最近30日" value="last30Days" />
</el-select>
<el-date-picker
v-model="innerValue"
type="daterange"
range-separator="-"
:start-placeholder="startPlaceholder || '开始日期'"
:end-placeholder="endPlaceholder || '结束日期'"
:value-format="valueFormat"
:clearable="clearable"
:style="datePickerStyle"
v-bind="attrs"
@change="handleDateChange"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import type { QuickDateRangeProps } from '../types/QuickDateRange.d';
defineOptions({
name: 'QuickDateRange'
});
const props = withDefaults(defineProps<QuickDateRangeProps>(), {
modelValue: () => [],
startPlaceholder: '',
endPlaceholder: '',
valueFormat: 'YYYY-MM-DD',
clearable: true,
datePickerStyle: () => ({}),
attrs: () => ({}),
});
const emit = defineEmits<{
'update:modelValue': [value: string[]];
change: [value: string[]];
}>();
const innerValue = ref<string[]>(props.modelValue && props.modelValue.length ? [...props.modelValue] : []);
const quickType = ref<string>('custom');
watch(
() => props.modelValue,
(val) => {
if (!val || !val.length) {
innerValue.value = [];
quickType.value = 'custom';
} else {
innerValue.value = [...val];
}
},
{ deep: true }
);
const datePickerStyle = computed(() => {
return Object.assign({ width: '300px' }, props.datePickerStyle || {});
});
function formatDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function getToday() {
const today = new Date();
const d = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const s = formatDate(d);
return [s, s];
}
function getYesterday() {
const today = new Date();
const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
const s = formatDate(d);
return [s, s];
}
function getThisWeek() {
const today = new Date();
const day = today.getDay() || 7; // 周日返回 7
const monday = new Date(today);
monday.setDate(today.getDate() - day + 1);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
return [formatDate(monday), formatDate(sunday)];
}
function getLastWeek() {
const today = new Date();
const day = today.getDay() || 7;
const lastMonday = new Date(today);
lastMonday.setDate(today.getDate() - day - 6);
const lastSunday = new Date(lastMonday);
lastSunday.setDate(lastMonday.getDate() + 6);
return [formatDate(lastMonday), formatDate(lastSunday)];
}
function getLast30Days() {
const today = new Date();
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const start = new Date(end);
start.setDate(end.getDate() - 29);
return [formatDate(start), formatDate(end)];
}
function handleQuickChange(val: string) {
if (val === 'custom') {
// 自定义时间段,清空日期值
innerValue.value = [];
emit('update:modelValue', []);
emit('change', []);
return;
}
let range: string[] = [];
switch (val) {
case 'today':
range = getToday();
break;
case 'yesterday':
range = getYesterday();
break;
case 'thisWeek':
range = getThisWeek();
break;
case 'lastWeek':
range = getLastWeek();
break;
case 'last30Days':
range = getLast30Days();
break;
default:
range = [];
}
innerValue.value = range;
emit('update:modelValue', range);
emit('change', range);
}
function handleDateChange(val: string[] | null) {
// 用户手动选择时间段时,将预设切换为自定义
quickType.value = 'custom';
innerValue.value = val || [];
emit('update:modelValue', innerValue.value);
emit('change', innerValue.value);
}
</script>
<style scoped lang="scss">
.quick-date-range {
display: inline-flex;
align-items: center;
gap: 8px;
.quick-select {
width: 130px;
}
}
</style>

View File

@@ -1,373 +0,0 @@
<template>
<div class="table-container">
<div ref="tableWrapperRef" class="table-wrapper">
<el-table
ref="tableRef"
v-loading="loading"
:data="computedTableData"
:border="border"
:stripe="stripe"
:size="size"
:height="computedTableHeight"
:row-key="rowKey"
:highlight-current-row="highlightCurrentRow"
@row-click="handleRowClick"
@selection-change="handleSelectionChange"
@sort-change="handleSortChange"
style="width: 100%; height: 100%"
>
<!-- 通过配置数组生成的列 -->
<template v-for="column in tableColumns" :key="column.prop || column.type">
<el-table-column
v-if="column.type && column.type !== 'expand'"
:type="column.type"
:width="column.width"
:min-width="column.minWidth"
:align="column.align || 'center'"
:fixed="
column.type === 'selection'
? column.fixed !== undefined
? column.fixed
: 'left'
: column.fixed
"
:selectable="column.selectable"
/>
<!-- 展开列支持自定义插槽内容 -->
<el-table-column
v-else-if="column.type === 'expand'"
type="expand"
:width="column.width"
:min-width="column.minWidth"
:fixed="column.fixed"
>
<template #default="scope">
<slot :name="column.slot || 'expand'" :row="scope.row" :scope="scope" />
</template>
</el-table-column>
<!-- 普通数据列 -->
<el-table-column
v-else
:prop="column.prop"
:label="column.label"
:width="column.width"
:min-width="column.minWidth"
:align="column.align || 'left'"
:fixed="column.fixed"
:show-overflow-tooltip="column.showOverflowTooltip !== false"
>
<template v-if="column.slot" #default="scope">
<slot :name="column.slot" :row="scope.row" :scope="scope" />
</template>
<template v-else-if="column.formatter" #default="scope">
{{
column.formatter(
scope.row,
scope.column,
column.prop ? scope.row[column.prop] : undefined,
scope.$index
)
}}
</template>
</el-table-column>
</template>
<!-- 通过插槽自定义的列 -->
<slot name="table" />
</el-table>
</div>
<div v-if="showPagination" ref="paginationWrapperRef" class="pagination-wrapper">
<div
class="pagination-content"
:class="{ 'has-left-content': paginationLeftText || $slots.paginationLeft }"
>
<div v-if="paginationLeftText || $slots.paginationLeft" class="pagination-left">
<slot name="paginationLeft">
{{ paginationLeftText }}
</slot>
</div>
<pagination
v-show="computedTotal > 0"
:total="computedTotal"
:page="computedPageNo"
:limit="computedPageSize"
v-bind="paginationProps"
@pagination="handlePagination"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
import Pagination from '@/components/Pagination/index.vue';
import type { TableProps } from '../types/Table.d';
defineOptions({
name: 'Table',
});
const props = withDefaults(defineProps<TableProps>(), {
tableData: () => [],
loading: false,
border: true,
stripe: false,
size: 'default',
highlightCurrentRow: false,
tableColumns: () => [],
showPagination: false,
total: 0,
pageNo: 1,
pageSize: 20,
isAllData: false,
paginationLeftText: '',
paginationProps: () => ({}),
});
const emit = defineEmits<{
'row-click': [row: Record<string, any>, column: any, event: Event];
'selection-change': [selection: Record<string, any>[]];
'sort-change': [sortInfo: { column: any; prop: string; order: string }];
pagination: [pagination: { page: number; limit: number }];
}>();
const internalPageNo = ref(props.pageNo);
const internalPageSize = ref(props.pageSize);
watch(
() => [props.pageNo, props.pageSize],
([newPageNo, newPageSize]) => {
if (!props.isAllData) {
internalPageNo.value = newPageNo;
internalPageSize.value = newPageSize;
}
}
);
watch(
() => props.isAllData,
(isAllData) => {
if (isAllData) {
internalPageNo.value = props.pageNo;
internalPageSize.value = props.pageSize;
}
}
);
const computedPageNo = computed(() => {
return props.isAllData ? internalPageNo.value : props.pageNo;
});
const computedPageSize = computed(() => {
return props.isAllData ? internalPageSize.value : props.pageSize;
});
const computedTotal = computed(() => {
return props.isAllData ? props.tableData.length : props.total;
});
const computedTableData = computed(() => {
if (!props.isAllData) {
return props.tableData;
}
const start = (computedPageNo.value - 1) * computedPageSize.value;
const end = start + computedPageSize.value;
return props.tableData.slice(start, end);
});
const handlePagination = (pagination: { page: number; limit: number }) => {
if (props.isAllData) {
internalPageNo.value = pagination.page;
internalPageSize.value = pagination.limit;
} else {
emit('pagination', pagination);
}
nextTick(() => {
calculateTableHeight();
});
};
const tableRef = ref<InstanceType<typeof import('element-plus').ElTable> | null>(null);
const tableWrapperRef = ref<HTMLDivElement | null>(null);
const paginationWrapperRef = ref<HTMLDivElement | null>(null);
const dynamicTableHeight = ref<number | null>(null);
const paginationHeight = ref<number>(0);
const computedTableHeight = computed(() => {
if (props.tableHeight) {
return props.tableHeight;
}
if (props.maxHeight) {
return props.maxHeight;
}
if (dynamicTableHeight.value) {
const height = dynamicTableHeight.value - paginationHeight.value;
return height > 0 ? height : dynamicTableHeight.value;
}
return null;
});
const calculateTableHeight = () => {
nextTick(() => {
if (tableWrapperRef.value) {
const tableContainer = tableWrapperRef.value.parentElement;
if (tableContainer) {
const containerRect = tableContainer.getBoundingClientRect();
let height = containerRect.height;
if (props.showPagination && paginationWrapperRef.value && computedTotal.value > 0) {
const paginationRect = paginationWrapperRef.value.getBoundingClientRect();
paginationHeight.value = paginationRect.height;
height -= paginationRect.height;
} else {
paginationHeight.value = 0;
}
if (height > 0) {
dynamicTableHeight.value = height;
}
}
}
});
};
let resizeObserver: ResizeObserver | null = null;
let paginationObserver: ResizeObserver | null = null;
onMounted(() => {
calculateTableHeight();
const tableContainer = tableWrapperRef.value?.parentElement;
if (tableContainer && window.ResizeObserver) {
resizeObserver = new ResizeObserver(() => {
calculateTableHeight();
});
resizeObserver.observe(tableContainer);
} else {
window.addEventListener('resize', calculateTableHeight);
}
});
watch(
() => props.showPagination && computedTotal.value > 0 && paginationWrapperRef.value,
(shouldObserve) => {
if (shouldObserve && paginationWrapperRef.value && window.ResizeObserver) {
if (!paginationObserver) {
paginationObserver = new ResizeObserver(() => {
calculateTableHeight();
});
}
paginationObserver.observe(paginationWrapperRef.value);
} else if (paginationObserver && paginationWrapperRef.value) {
paginationObserver.unobserve(paginationWrapperRef.value);
}
},
{ immediate: true }
);
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
if (paginationObserver) {
paginationObserver.disconnect();
}
if (!resizeObserver) {
window.removeEventListener('resize', calculateTableHeight);
}
});
watch(
() => props.tableData,
() => {
calculateTableHeight();
if (props.isAllData && internalPageNo.value !== 1) {
internalPageNo.value = 1;
}
},
{ deep: true }
);
watch(
() => [props.showPagination, computedTotal.value],
() => {
calculateTableHeight();
}
);
const handleRowClick = (row: Record<string, any>, column: any, event: Event) => {
emit('row-click', row, column, event);
};
const handleSelectionChange = (selection: Record<string, any>[]) => {
emit('selection-change', selection);
};
const handleSortChange = ({
column,
prop,
order,
}: {
column: any;
prop: string;
order: string;
}) => {
emit('sort-change', { column, prop, order });
};
defineExpose({
tableRef,
tableWrapperRef,
});
</script>
<style scoped lang="scss">
.table-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
.pagination-wrapper {
flex-shrink: 0;
margin-top: 8px;
padding-bottom: 0;
overflow: visible;
}
.pagination-content {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
&.has-left-content {
justify-content: space-between;
}
}
.pagination-left {
flex-shrink: 0;
display: flex;
justify-content: flex-start;
align-items: center;
color: var(--el-text-color-regular);
font-size: 14px;
}
.pagination-content :deep(.pagination-container) {
.el-pagination {
margin-right: 16px;
display: flex;
align-items: center;
justify-content: flex-end;
}
}
</style>

View File

@@ -1,27 +0,0 @@
<template>
<div class="table-section">
<slot />
</div>
</template>
<script setup>
defineOptions({
name: 'TableSection',
});
</script>
<style scoped lang="scss">
.table-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
:deep(.editable-table) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
</style>

View File

@@ -1,411 +0,0 @@
<template>
<div class="table-layout-container">
<div class="card-content-wrapper">
<div
v-if="showSideQuery"
class="side-query-wrapper"
:class="{ collapsed: sideQueryCollapsed }"
>
<div v-if="!sideQueryCollapsed" class="side-query-header">
<el-input v-model="sideSearchKeyword" placeholder="搜索树节点" clearable size="small">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div v-if="!sideQueryCollapsed" class="side-query-content">
<el-tree
ref="treeRef"
:data="treeDataWithAll"
:props="defaultProps"
:node-key="treeNodeKey"
:expand-on-click-node="false"
default-expand-all
highlight-current
@node-click="handleNodeClick"
@current-change="handleCurrentChange"
></el-tree>
</div>
</div>
<div v-if="showSideQuery" class="collapse-divider">
<el-button
circle
size="small"
class="collapse-btn"
@click="sideQueryCollapsed = !sideQueryCollapsed"
>
<el-icon>
<ArrowRight v-if="sideQueryCollapsed" />
<ArrowLeft v-else />
</el-icon>
</el-button>
</div>
<!-- 主内容区域 -->
<div
class="main-content-wrapper"
:class="{ 'with-side-query': showSideQuery && !sideQueryCollapsed }"
>
<Filter
v-if="showTopQuery"
ref="queryFormComponentRef"
:query-params="queryParams"
:form-items="formItems"
:show-default-buttons="showDefaultButtons"
@query="handleQuery"
@reset="resetQuery"
>
<template
v-for="item in customFormItems"
:key="item.prop"
v-slot:[item.slotName]="slotProps"
>
<slot :name="item.slotName" :item="slotProps.item" :queryParams="props.queryParams" />
</template>
<template #default="{ queryParams, handleQuery, resetQuery }">
<slot
name="topQuery"
:queryParams="queryParams"
:handleQuery="handleQuery"
:resetQuery="resetQuery"
/>
</template>
</Filter>
<!-- 操作按钮区域 -->
<div class="table-operation-bar">
<slot name="operations" />
</div>
<!-- 表格区域 -->
<Table
:table-data="tableData"
:loading="loading"
:border="border"
:stripe="stripe"
:size="size"
:table-height="tableHeight"
:max-height="maxHeight"
:row-key="rowKey"
:highlight-current-row="highlightCurrentRow"
:table-columns="tableColumns"
:show-pagination="showPagination"
:total="total"
:page-no="props.queryParams.pageNo"
:page-size="props.queryParams.pageSize"
@row-click="handleRowClick"
@selection-change="handleSelectionChange"
@sort-change="handleSortChange"
@pagination="handlePagination"
>
<template v-for="(_, slotName) in $slots" :key="slotName" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</Table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import Filter from './Filter.vue';
import Table from './Table.vue';
import type { TableLayoutProps, TreeNodeData } from '../types/TableLayout.d';
defineOptions({
name: 'TableLayout',
});
const props = withDefaults(defineProps<TableLayoutProps>(), {
tableData: () => [],
loading: false,
total: 0,
queryParams: () => ({
pageNo: 1,
pageSize: 20,
}),
sideQueryParams: () => ({}),
formItems: () => [],
showTopQuery: true,
showSideQuery: false,
showPagination: true,
showDefaultButtons: true,
sideWidth: 6,
border: true,
stripe: false,
size: 'default',
highlightCurrentRow: false,
siderData: () => [],
treeNodeKey: 'id',
tableColumns: () => [],
});
const emit = defineEmits<{
query: [queryParams: Record<string, any>];
reset: [];
pagination: [pagination: { page: number; limit: number }];
'row-click': [row: Record<string, any>, column: any, event: Event];
'selection-change': [selection: Record<string, any>[]];
'sort-change': [sortInfo: { column: any; prop: string; order: string }];
'side-query': [node: TreeNodeData];
'reset-side-query': [];
}>();
const queryFormRef = ref<InstanceType<typeof import('element-plus').ElForm> | null>(null);
import type { FilterExpose } from '../types/Filter.d';
const queryFormComponentRef = ref<FilterExpose | null>(null);
const sideSearchKeyword = ref<string>('');
const treeRef = ref<InstanceType<typeof import('element-plus').ElTree> | null>(null);
const currentTreeNode = ref<TreeNodeData | null>(null);
const sideQueryCollapsed = ref<boolean>(false);
const customFormItems = computed(() => {
return props.formItems
.filter((item) => item.type === 'custom')
.map((item) => ({
...item,
slotName: item.slot || item.prop,
}));
});
const defaultProps = {
children: 'children',
label: 'label',
};
const filteredSiderData = computed(() => {
if (!sideSearchKeyword.value || !props.siderData || props.siderData.length === 0) {
return props.siderData;
}
const keyword = sideSearchKeyword.value.toLowerCase();
const filterTree = (nodes: TreeNodeData[]): TreeNodeData[] => {
if (!nodes || nodes.length === 0) return [];
return nodes
.map((node: TreeNodeData) => {
const label = (node[defaultProps.label] || '').toLowerCase();
const match = label.includes(keyword);
const children = node[defaultProps.children];
let filteredChildren: TreeNodeData[] | null = null;
if (children && children.length > 0) {
filteredChildren = filterTree(children);
}
if (match || (filteredChildren && filteredChildren.length > 0)) {
return {
...node,
[defaultProps.children]: filteredChildren,
};
}
return null;
})
.filter(Boolean) as TreeNodeData[];
};
return filterTree(props.siderData);
});
const treeDataWithAll = computed(() => {
const children = filteredSiderData.value || [];
return [
{
[props.treeNodeKey]: '__ALL__',
[defaultProps.label]: '全部',
[defaultProps.children]: children || [],
},
];
});
const handleQuery = () => {
props.queryParams.pageNo = 1;
emit('query', props.queryParams);
if (currentTreeNode.value) {
emit('side-query', currentTreeNode.value);
}
};
const handleNodeClick = (data: TreeNodeData, node: any) => {
currentTreeNode.value = data;
if (treeRef.value && data && data[props.treeNodeKey]) {
treeRef.value.setCurrentKey(data[props.treeNodeKey]);
}
handleQuery();
};
const handleCurrentChange = (data: TreeNodeData, node: any) => {
currentTreeNode.value = data;
};
const resetQuery = () => {
if (queryFormComponentRef.value?.queryFormRef) {
queryFormComponentRef.value.queryFormRef.resetFields();
}
if (props.queryParams) {
Object.keys(props.queryParams).forEach((key) => {
if (key !== 'pageNo' && key !== 'pageSize') {
if (Array.isArray(props.queryParams[key])) {
props.queryParams[key] = [];
} else if (typeof props.queryParams[key] === 'object' && props.queryParams[key] !== null) {
props.queryParams[key] = null;
} else {
props.queryParams[key] = '';
}
}
});
if (Object.prototype.hasOwnProperty.call(props.queryParams, 'pageNo')) {
props.queryParams.pageNo = 1;
}
}
emit('reset');
handleQuery();
};
const handlePagination = (pagination) => {
if (props.queryParams) {
props.queryParams.pageNo = pagination.page;
props.queryParams.pageSize = pagination.limit;
}
emit('pagination', pagination);
emit('query', props.queryParams);
if (currentTreeNode.value) {
emit('side-query', currentTreeNode.value);
}
};
const handleRowClick = (row, column, event) => {
emit('row-click', row, column, event);
};
const handleSelectionChange = (selection) => {
emit('selection-change', selection);
};
const handleSortChange = ({ column, prop, order }) => {
emit('sort-change', { column, prop, order });
};
defineExpose({
queryFormRef: computed(() => queryFormComponentRef.value?.queryFormRef),
handleQuery,
resetQuery,
});
</script>
<style scoped lang="scss">
.table-layout-container {
height: 100%;
padding: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
.main-content-card {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
min-height: 0;
overflow: visible;
:deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px 16px 8px 16px;
min-height: 0;
overflow: visible;
}
}
.card-content-wrapper {
flex: 1;
display: flex;
gap: 0;
min-height: 0;
position: relative;
}
.collapse-divider {
flex-shrink: 0;
width: 1px;
background-color: #ebeef5;
position: relative;
display: flex;
align-items: flex-start;
justify-content: center;
margin: 0 12px;
.collapse-btn {
position: absolute;
left: 50%;
top: 18px;
transform: translateX(-50%);
background-color: #fff;
border: 1px solid #ebeef5;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
z-index: 10;
width: 24px;
height: 24px;
&:hover {
background-color: #409eff;
color: #fff;
border-color: #409eff;
}
}
}
.side-query-wrapper {
flex-shrink: 0;
display: flex;
flex-direction: column;
transition: width 0.3s, opacity 0.3s;
overflow: hidden;
&.collapsed {
width: 0;
opacity: 0;
padding: 0;
border: none;
}
.side-query-header {
flex-shrink: 0;
margin-bottom: 12px;
display: flex;
align-items: center;
}
.side-query-content {
flex: 1;
min-height: 0;
overflow-y: auto;
:deep(.el-tree--highlight-current) {
background-color: #fff !important;
}
}
}
.main-content-wrapper {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.table-operation-bar {
flex-shrink: 0;
margin-bottom: 8px;
}
}
</style>