```
docs(release-notes): 添加住院护士站划价功能说明和发版记录 - 新增住院护士站划价服务流程说明文档,详细描述了从参数预处理到结果响应的五大阶段流程 - 包含耗材类医嘱和诊疗活动类医嘱的差异化处理逻辑 - 添加完整的发版内容记录,涵盖新增菜单功能和各模块优化点 - 记录了住院相关功能的新增和门诊业务流程的修复 ```
This commit is contained in:
60
openhis-ui-vue3/src/components/TableLayout/EditTable.vue
Normal file
60
openhis-ui-vue3/src/components/TableLayout/EditTable.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<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>
|
||||
567
openhis-ui-vue3/src/components/TableLayout/EditableTable.vue
Normal file
567
openhis-ui-vue3/src/components/TableLayout/EditableTable.vue
Normal file
@@ -0,0 +1,567 @@
|
||||
<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>
|
||||
157
openhis-ui-vue3/src/components/TableLayout/Filter.vue
Normal file
157
openhis-ui-vue3/src/components/TableLayout/Filter.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<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>
|
||||
115
openhis-ui-vue3/src/components/TableLayout/Form.vue
Normal file
115
openhis-ui-vue3/src/components/TableLayout/Form.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<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>
|
||||
|
||||
196
openhis-ui-vue3/src/components/TableLayout/FormItem.vue
Normal file
196
openhis-ui-vue3/src/components/TableLayout/FormItem.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<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>
|
||||
143
openhis-ui-vue3/src/components/TableLayout/FormLayout.vue
Normal file
143
openhis-ui-vue3/src/components/TableLayout/FormLayout.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<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>
|
||||
18
openhis-ui-vue3/src/components/TableLayout/FormSection.vue
Normal file
18
openhis-ui-vue3/src/components/TableLayout/FormSection.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<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>
|
||||
@@ -0,0 +1,39 @@
|
||||
<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>
|
||||
136
openhis-ui-vue3/src/components/TableLayout/NumberInput.vue
Normal file
136
openhis-ui-vue3/src/components/TableLayout/NumberInput.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<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>
|
||||
29
openhis-ui-vue3/src/components/TableLayout/PageLayout.vue
Normal file
29
openhis-ui-vue3/src/components/TableLayout/PageLayout.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
20
openhis-ui-vue3/src/components/TableLayout/PageWrapper.vue
Normal file
20
openhis-ui-vue3/src/components/TableLayout/PageWrapper.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
171
openhis-ui-vue3/src/components/TableLayout/QuickDateRange.vue
Normal file
171
openhis-ui-vue3/src/components/TableLayout/QuickDateRange.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<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>
|
||||
|
||||
373
openhis-ui-vue3/src/components/TableLayout/Table.vue
Normal file
373
openhis-ui-vue3/src/components/TableLayout/Table.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<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>
|
||||
27
openhis-ui-vue3/src/components/TableLayout/TableSection.vue
Normal file
27
openhis-ui-vue3/src/components/TableLayout/TableSection.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<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>
|
||||
411
openhis-ui-vue3/src/components/TableLayout/index.vue
Normal file
411
openhis-ui-vue3/src/components/TableLayout/index.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user