From 6ae697a0520f70861301944fd013154007286d8c Mon Sep 17 00:00:00 2001
From: zhf <1204297681@qq.com>
Date: Tue, 18 Nov 2025 23:53:49 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=87=8D=E5=A4=8D?=
=?UTF-8?q?=E5=99=A8=E8=A1=A8=E6=A0=BC=E7=BB=84=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 4 +
.../form-mdctrl-repeater.tsx | 9 +
.../repeater-grid/repeater-grid.scss | 84 +++++
.../repeater-grid/repeater-grid.tsx | 330 ++++++++++++++++++
src/locale/en/index.ts | 4 +
src/locale/zh-CN/index.ts | 4 +
6 files changed, 435 insertions(+)
create mode 100644 src/control/form/form-detail/form-mdctrl/form-mdctrl-repeater/repeater-grid/repeater-grid.scss
create mode 100644 src/control/form/form-detail/form-mdctrl/form-mdctrl-repeater/repeater-grid/repeater-grid.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f45c806b6c1..f2e5eef5dbd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@
## [Unreleased]
+### Added
+
+- 新增重复器表格组件
+
## [0.7.41-alpha.20] - 2025-11-17
### Added
diff --git a/src/control/form/form-detail/form-mdctrl/form-mdctrl-repeater/form-mdctrl-repeater.tsx b/src/control/form/form-detail/form-mdctrl/form-mdctrl-repeater/form-mdctrl-repeater.tsx
index 59b936b9e05..b8941cd2027 100644
--- a/src/control/form/form-detail/form-mdctrl/form-mdctrl-repeater/form-mdctrl-repeater.tsx
+++ b/src/control/form/form-detail/form-mdctrl/form-mdctrl-repeater/form-mdctrl-repeater.tsx
@@ -3,6 +3,7 @@ import { FormMDCtrlRepeaterController } from '@ibiz-template/runtime';
import { useNamespace } from '@ibiz-template/vue3-util';
import { RepeaterSingleForm } from './repeater-single-form/repeater-single-form';
import { RepeaterMultiForm } from './repeater-multi-form/repeater-multi-form';
+import { RepeaterGrid } from './repeater-grid/repeater-grid';
export const FormMDCtrlRepeater = defineComponent({
name: 'IBizFormMDCtrlRepeater',
@@ -39,6 +40,14 @@ export const FormMDCtrlRepeater = defineComponent({
onChange={this.onDataChange}
>
);
+ case 'Grid':
+ return (
+
+ );
default:
return (
diff --git a/src/control/form/form-detail/form-mdctrl/form-mdctrl-repeater/repeater-grid/repeater-grid.scss b/src/control/form/form-detail/form-mdctrl/form-mdctrl-repeater/repeater-grid/repeater-grid.scss
new file mode 100644
index 00000000000..fea74ee76a2
--- /dev/null
+++ b/src/control/form/form-detail/form-mdctrl/form-mdctrl-repeater/repeater-grid/repeater-grid.scss
@@ -0,0 +1,84 @@
+$repeater-grid: (
+ // 颜色
+ th-color: getCssVar(color, text, 2),
+ td-color: getCssVar(color, text, 0),
+ btn-color: getCssVar(color, text, 2),
+
+ // 间距
+ cell-padding: getCssVar(spacing, extra-tight) getCssVar(spacing, tight),
+ btn-margin-left: getCssVar(spacing, tight),
+
+ // 行高
+ cell-line-height: getCssVar(editor, default, line-height),
+
+ // 边框
+ cell-border: 1px solid getCssVar(color, border),
+ btn-border: 1px solid getCssVar(color, border),
+
+ // 大小
+ btn-size: getCssVar(width-icon, large),
+);
+
+@include b(repeater-grid) {
+ @include set-component-css-var(repeater-grid, $repeater-grid);
+
+ width: 100%;
+ overflow: auto;
+
+ table {
+ table-layout: fixed;
+ border-collapse: collapse;
+ }
+
+ th {
+ min-width: 0;
+ padding: getCssVar(repeater-grid, cell-padding);
+ overflow: hidden;
+ line-height: getCssVar(repeater-grid, cell-line-height);
+ color: getCssVar(repeater-grid, th-color);
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ border: getCssVar(repeater-grid, cell-border);
+ }
+
+ td {
+ min-width: 0;
+ padding: getCssVar(repeater-grid, cell-padding);
+ overflow: hidden;
+ line-height: getCssVar(repeater-grid, cell-line-height);
+ color: getCssVar(repeater-grid, td-color);
+ text-align: center;
+ border: getCssVar(repeater-grid, cell-border);
+ }
+
+ @include b(repeater-grid-action-cell) {
+ border: none;
+ }
+}
+
+@include b(repeater-grid-action-group) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: getCssVar(repeater-grid, cell-line-height);
+
+ ion-icon {
+ box-sizing: border-box;
+ font-size: getCssVar(repeater-grid, btn-size);
+ color: getCssVar(repeater-grid, btn-color);
+ cursor: pointer;
+ border: getCssVar(repeater-grid, btn-border);
+
+ + ion-icon {
+ margin-left: getCssVar(repeater-grid, btn-margin-left);
+ }
+ }
+}
+
+@include b(repeater-grid-empty-content) {
+ td {
+ color: getCssVar(repeater-grid, th-color);
+ }
+}
+
diff --git a/src/control/form/form-detail/form-mdctrl/form-mdctrl-repeater/repeater-grid/repeater-grid.tsx b/src/control/form/form-detail/form-mdctrl/form-mdctrl-repeater/repeater-grid/repeater-grid.tsx
new file mode 100644
index 00000000000..c4d1d1fe94c
--- /dev/null
+++ b/src/control/form/form-detail/form-mdctrl/form-mdctrl-repeater/repeater-grid/repeater-grid.tsx
@@ -0,0 +1,330 @@
+import {
+ defineComponent,
+ h,
+ onMounted,
+ onUnmounted,
+ reactive,
+ ref,
+ resolveComponent,
+ toRaw,
+ watch,
+} from 'vue';
+import {
+ ControlVO,
+ EditFormController,
+ EventBase,
+ FormMDCtrlRepeaterController,
+ IEditFormController,
+} from '@ibiz-template/runtime';
+import { useCtx, useNamespace } from '@ibiz-template/vue3-util';
+import { IDEFormDetail, IDEFormItem } from '@ibiz/model-core';
+import { recursiveIterate } from '@ibiz-template/core';
+import './repeater-grid.scss';
+
+export const RepeaterGrid = defineComponent({
+ name: 'IBizRepeaterGrid',
+ props: {
+ controller: {
+ type: FormMDCtrlRepeaterController,
+ required: true,
+ },
+ },
+ emits: {
+ change: (_value: IData[]) => true,
+ },
+ setup(props, { emit }) {
+ const ns = useNamespace('repeater-grid');
+
+ // 表单项
+ const formItems: IDEFormItem[] = [];
+
+ // 数据
+ const items = ref
([]);
+
+ // 上下文
+ const ctx = useCtx();
+
+ // 表单控制器
+ const formControllers = reactive([]);
+
+ // 容器
+ const container = ref();
+
+ // 表格
+ const table = ref();
+
+ // 表头
+ const thead = ref();
+
+ // 自适应宽度
+ const flexWidth = ref(0);
+
+ // 表格宽度
+ const gridWidth = ref('');
+
+ // 容器大小变化监听器
+ let resizeObserver: ResizeObserver | undefined;
+
+ recursiveIterate(
+ props.controller.repeatedForm,
+ (item: IDEFormDetail) => {
+ if (item.detailType === 'FORMITEM') {
+ // 隐藏表单项不绘制
+ if ((item as IDEFormItem).editor?.editorType !== 'HIDDEN') {
+ formItems.push(item);
+ }
+ }
+ },
+ {
+ childrenFields: ['deformPages', 'deformTabPages', 'deformDetails'],
+ },
+ );
+
+ // 单条数据改变时,更新数据
+ const onSingleValueChange = (value: IData, index: number) => {
+ const arrData = [...((props.controller.value || []) as IData[])];
+ arrData[index] = value;
+ emit('change', arrData);
+ };
+
+ // 添加表单控制器
+ const addFormController = async (data: IData = {}) => {
+ const formC = new EditFormController(
+ props.controller.repeatedForm,
+ props.controller.context,
+ props.controller.params,
+ ctx,
+ );
+ formC.state.isSimple = true;
+ await formC.created();
+ formC.setSimpleData(data);
+ formControllers.push(formC);
+ props.controller.setRepeaterController(
+ `${formControllers.length - 1}`,
+ formC,
+ );
+ formC.evt.on('onFormDataChange', (event: EventBase) => {
+ // 隔离抛出不一样的对象
+ const item = event.data[0];
+ const formData = item instanceof ControlVO ? item.clone() : { ...item };
+ const index = formControllers.indexOf(formC);
+ onSingleValueChange(formData, index);
+ });
+ };
+
+ watch(
+ () => props.controller.value,
+ newVal => {
+ if (Array.isArray(newVal)) {
+ newVal.forEach((item, index) => {
+ const formC = formControllers[index] as EditFormController;
+ if (formC) {
+ const changeVal = item || {};
+ // 找有没有不一致的属性
+ const find = Object.keys(formC.data).find(key => {
+ return changeVal[key] !== formC.data[key];
+ });
+ // 内外部数据不一致时,只能是外部修改了,这是更新数据并重走load
+ if (find) {
+ formC.setSimpleData(changeVal);
+ }
+ } else {
+ addFormController(item);
+ }
+ });
+ items.value = newVal;
+ } else {
+ items.value = [];
+ }
+ },
+ {
+ immediate: true,
+ deep: true,
+ },
+ );
+
+ onMounted(() => {
+ if (!container.value || !table.value || !thead.value) {
+ return;
+ }
+ resizeObserver = new ResizeObserver(() => {
+ if (!container.value || !table.value || !thead.value) {
+ return;
+ }
+
+ const containerWidth = container.value.offsetWidth;
+ const theadRow = thead.value.children[0];
+ if (!theadRow) return;
+ const thElements = Array.from(theadRow.children) as HTMLElement[];
+ if (thElements.length === 0) return;
+
+ let fixedWidthSum = 0;
+ const flexThElements: HTMLElement[] = [];
+
+ thElements.forEach(th => {
+ const isFlow = th.dataset.flex === 'true';
+ if (isFlow) {
+ flexThElements.push(th);
+ } else {
+ const thWidth = th.style.width
+ ? parseFloat(th.style.width)
+ : th.offsetWidth;
+ fixedWidthSum += thWidth;
+ }
+ });
+
+ const availableSpace = containerWidth - fixedWidthSum - 1;
+ const flowColumnCount = flexThElements.length;
+
+ if (flowColumnCount > 0 && availableSpace > 0) {
+ flexWidth.value = availableSpace / flowColumnCount;
+ gridWidth.value = `${containerWidth}px`;
+ } else {
+ flexWidth.value = 0;
+ gridWidth.value = `${fixedWidthSum}px`;
+ }
+ });
+ resizeObserver.observe(container.value);
+ });
+
+ onUnmounted(() => {
+ if (resizeObserver) {
+ resizeObserver.disconnect();
+ }
+ });
+
+ return {
+ ns,
+ formItems,
+ formControllers,
+ items,
+ container,
+ table,
+ thead,
+ flexWidth,
+ gridWidth,
+ };
+ },
+ render() {
+ const isEmpty = !this.items.length;
+
+ // 表头行
+ const headers = [
+
+ {ibiz.i18n.t('control.form.repeaterGrid.index')}
+ | ,
+ this.formItems.map(item => {
+ return (
+
+ {item.caption}
+ |
+ );
+ }),
+ (this.controller.enableCreate || this.controller.enableDelete) && (
+ |
+ ),
+ ];
+
+ // 表格行
+ const rows = (
+ isEmpty && this.controller.enableCreate ? [{}] : this.items
+ ).map((_row: IData, index: number) => {
+ return (
+
+ | {isEmpty ? '' : index + 1} |
+ {this.formItems.map(item => {
+ if (isEmpty) {
+ return | ;
+ }
+ const formC = toRaw(
+ this.formControllers[index],
+ ) as EditFormController;
+ if (!formC || !formC.state.isLoaded) {
+ return (
+ {ibiz.i18n.t('control.form.repeaterGrid.absentOrLoad')} |
+ );
+ }
+ const formItemC = formC.formItems.find(x => x.name === item.id)!;
+
+ let editor = null;
+ if (!formItemC.editorProvider) {
+ editor = ;
+ } else {
+ const component = resolveComponent(
+ formItemC.editorProvider.formEditor,
+ );
+ editor = h(component, {
+ value: formItemC.value,
+ data: formItemC.data,
+ controller: formItemC.editor,
+ disabled: formItemC.state.disabled,
+ readonly: formItemC.state.readonly,
+ onChange: (val: unknown, name?: string): void => {
+ formItemC.setDataValue(val, name);
+ },
+ });
+ }
+ return {editor} | ;
+ })}
+ {(this.controller.enableCreate || this.controller.enableDelete) && (
+
+
+ {this.controller.enableCreate && (
+ {
+ this.controller.create(isEmpty ? 0 : index + 1);
+ }}
+ >
+ )}
+ {this.controller.enableDelete && !isEmpty && (
+ {
+ this.controller.remove(index);
+ this.formControllers.splice(index, 1);
+ }}
+ >
+ )}
+
+ |
+ )}
+
+ );
+ });
+
+ return (
+
+
+
+ {headers}
+
+ {rows}
+ {isEmpty && !this.controller.enableCreate && (
+
+
+ {ibiz.i18n.t('control.form.repeaterGrid.noData')}
+ |
+
+ )}
+
+
+ );
+ },
+});
diff --git a/src/locale/en/index.ts b/src/locale/en/index.ts
index bb505c22cca..302a7f78840 100644
--- a/src/locale/en/index.ts
+++ b/src/locale/en/index.ts
@@ -122,6 +122,10 @@ export default {
search: 'Search',
reset: 'Reset',
},
+ repeaterGrid: {
+ index: 'Index',
+ noData: 'No data',
+ },
},
list: {
expand: 'Expand',
diff --git a/src/locale/zh-CN/index.ts b/src/locale/zh-CN/index.ts
index f01e76ede2f..cd53e400a0f 100644
--- a/src/locale/zh-CN/index.ts
+++ b/src/locale/zh-CN/index.ts
@@ -104,6 +104,10 @@ export default {
search: '查询',
reset: '重置',
},
+ repeaterGrid: {
+ index: '序号',
+ noData: '暂无数据',
+ },
},
list: {
expand: '展开',
--
Gitee