前端页面嵌套

我们可能会遇到这些需求,如:页面中的一对多字段不是下拉框,而是另一个模型的表单组;页面中的步骤条表单,每一步的表单都需要界面设计器设计,同时这些表单可能属于不同模型。
这时候我们就可以采取页面嵌套的方式,在当前页面中,动态创建一个界面设计器设计的子页面。以一对多字段,动态创建表单子页面举例,以下是代码实现和原理分析。

代码实现

AddSubformWidget 动态添加表单 ts 组件

import {
  ModelFieldType,
  ViewType,
  SPI,
  BaseFieldWidget,
  Widget,
  FormO2MFieldWidget,
  ActiveRecord,
  CallChaining,
  createRuntimeContextByView,
  queryViewDslByModelAndName,
  uniqueKeyGenerator
} from '@oinone/kunlun-dependencies';
import { MyMetadataViewWidget } from './MyMetadataViewWidget';
import { watch } from 'vue';
import AddSubform from './AddSubform.vue';

@SPI.ClassFactory(
  BaseFieldWidget.Token({
    viewType: ViewType.Form,
    ttype: ModelFieldType.OneToMany,
    widget: 'AddSubform'
  })
)
export class AddSubformWidget extends FormO2MFieldWidget {
  public initialize(props) {
    super.initialize(props);
    this.setComponent(AddSubform);
    return this;
  }

  @Widget.Reactive()
  public myMetadataViewWidget: MyMetadataViewWidget[] = [];

  @Widget.Reactive()
  public myMetadataViewWidgetKeys: string[] = [];

  @Widget.Reactive()
  public myMetadataViewWidgetLength = 0;

  // region 子视图配置
  public get subviewModel() {
    return this.getDsl().subviewModel || 'clm.contractcenter.ContractSignatory';
  }
  public get subviewName() {
    return this.getDsl().subviewName || '签署方_FORM_uiViewa9c114903e104800b15e8f3749656b64';
  }

  // region 添加子视图块

  // 按钮添加点击事件
  @Widget.Method()
  public async onAddSubviewBlock() {
    const resView = await queryViewDslByModelAndName(this.subviewModel, this.subviewName);
    this.createDynamicSubviewWidget(resView);
  }

  // 创建子视图块
  public async createDynamicSubviewWidget(view, activeRecord: ActiveRecord = {}) {
    if (view) {
      // 根据视图构建上下文
      const runtimeContext = createRuntimeContextByView(
        {
          type: ViewType.Form,
          model: view.model,
          modelName: view.modelDefinition.name,
          module: view.modelDefinition.module,
          moduleName: view.modelDefinition.moduleName,
          name: view.name,
          dsl: view.template
        },
        true,
        uniqueKeyGenerator(),
        this.currentHandle
      );
      // 取得上下文唯一标识
      const runtimeContextHandle = runtimeContext.handle;

      const slotKey = `Form_${uniqueKeyGenerator()}`;

      // 创建子视图组件
      const widget = this.createWidget(new MyMetadataViewWidget(runtimeContextHandle),
        slotKey, // 插槽名
       {
        metadataHandle: runtimeContextHandle,
        rootHandle: runtimeContextHandle,
        mountedCallChaining: new CallChaining(),
        refreshCallChaining: new CallChaining(),
        inline: true,
        subIndex: this.myMetadataViewWidgetLength
      });
      widget.initContext(runtimeContext);
      // 初始化数据
      widget.setData(activeRecord);

      this.myMetadataViewWidgetLength++;
      this.myMetadataViewWidget.push(widget);
      this.myMetadataViewWidgetKeys.push(slotKey);
      return widget;
    }
  }

  // region 删除子视图块

  // 删除子视图块
  @Widget.Method()
  public async onDleteSubviewBlock(index: number) {
    if (index > -1) {
      const deleteWidget = this.myMetadataViewWidget.splice(index, 1);
      const deleteKey = this.myMetadataViewWidgetKeys.splice(index, 1);
      this.myMetadataViewWidgetLength--;
    }
  }

  // region 初始化子视图

  // mountedProcess 时,数据已经拿到,根据数据创建子视图
  protected async mountedProcess() {
    const resView = await queryViewDslByModelAndName(this.subviewModel, this.subviewName);
    if (this.value?.length) {
      this.value?.forEach(async (eachValue) => {
        this.createDynamicSubviewWidget(resView, eachValue);
      });
    } else if (!this.myMetadataViewWidgetLength) {
      // 默认给一个子视图
      this.createDynamicSubviewWidget(resView);
    }
  }

  // 监听子视图数据变化,并更新父视图数据
  public mounted(): void {
    super.mounted();
    this.mountedCallChaining?.callAfter(() => {
      // 监听子视图数据变化
      watch(
        () => this.myMetadataViewWidget.map((widget) => widget.getFormData()),
        async (newValues) => {
          this.reloadActiveRecords({
            ...this.formData,
            [this.itemName]: newValues
          });
        },
        { deep: true }
      );
    });
  }

  // 字段提交数据方法,提交所有子视图的数据
  public async submit() {
    const promises = this.myMetadataViewWidget.map(async (widget) => widget.getSubmitData());
    const submitValues = await Promise.all(promises);
    return {
      [this.itemName]: submitValues.map((submitValue) => submitValue?.records || {})
    };
  }
}

AddSubform.vue 动态添加表单 vue 组件

<template>
  <div class="AddSubviewBlockField">
    <div class="subview-block" v-for="(key, index) in myMetadataViewWidgetKeys" :key="key">
      <oio-icon @click="onDleteSubviewBlock(index)" icon="oinone-shanchu3" size="24" />
      <slot :name="key" />
    </div>
    <div class="add-icon">
      <a-divider />
      <oio-icon @click="onAddSubviewBlock" icon="oinone-add-circle" size="24" />
    </div>
  </div>
</template>

<script lang="ts" setup>
import { OioIcon } from '@oinone/kunlun-dependencies';
import { PropType } from 'vue';

const props = defineProps({
  myMetadataViewWidgetLength: {
    type: Number
  },
  myMetadataViewWidgetKeys: {
    type: Array as PropType<string[]>,
    default: () => []
  },
  onAddSubviewBlock: {
    type: Function as PropType<() => void>,
    default: () => {}
  },
  onDleteSubviewBlock: {
    type: Function as PropType<(index: number) => void>,
    default: () => {}
  },
});
</script>
<style lang="scss" scoped>
.AddSubviewBlockField {
  display: flex;
  flex-direction: column;
  gap: 48px;

  > .add-icon {
    display: flex;
    flex-direction: column;

    > .ant-divider-horizontal {
      margin: 0;
    }

    > .oio-icon {
      cursor: pointer;
      align-self: flex-end;
    }
  }

  > .subview-block {
    width: 100%;

    > .oio-icon {
      cursor: pointer;
    }
  }
}
</style>

MyMetadataViewWidget 数据隔离组件

import {
  ActiveRecord,
  ActiveRecords,
  CallChaining,
  FormWidget,
  MetadataViewWidget,
  queryDslWidget,
  Widget
} from '@oinone/kunlun-dependencies';

/**
 * 通过视图 handle 查找表单组件
 * @param viewHandle
 */
const queryFormWidgetByViewHandle = (viewHandle: string): FormWidget | null => {
  const baseViewWidget = Widget.select(viewHandle);
  const formWidget = queryDslWidget(baseViewWidget?.getChildrenInstance(), FormWidget);
  if (formWidget) {
    return formWidget as unknown as FormWidget;
  }
  return null;
};

export class MyMetadataViewWidget extends MetadataViewWidget {
  @Widget.Provide()
  public mountedCallChaining: CallChaining | undefined;

  @Widget.Provide()
  @Widget.Reactive()
  public dataSource: ActiveRecord[] = [];

  @Widget.Method()
  @Widget.Provide()
  public reloadDataSource(records: ActiveRecords | undefined) {
    if (Array.isArray(records)) {
      this.dataSource = records;
    } else {
      this.dataSource = [records || {}];
    }
  }

  @Widget.Provide()
  @Widget.Reactive()
  public activeRecords: ActiveRecord[] = [];

  @Widget.Method()
  @Widget.Provide()
  public reloadActiveRecords(records: ActiveRecords | undefined) {
    if (Array.isArray(records)) {
      this.activeRecords = records;
    } else {
      this.activeRecords = [records || {}];
    }
  }

  @Widget.Reactive()
  @Widget.Provide()
  public rootData: ActiveRecord[] | undefined;

  @Widget.Method()
  @Widget.Provide()
  public reloadRootData(records: ActiveRecords | undefined) {
    if (Array.isArray(records)) {
      this.rootData = records;
    } else {
      this.rootData = [records || {}];
    }
  }

  public initialize(props): this {
    this.mountedCallChaining = props.mountedCallChaining;
    this.subIndex = props.subIndex;
    super.initialize(props);
    return this;
  }

  protected mounted() {
    this.mountedCallChaining?.syncCall();
  }

  protected async validator() {
    const formWidget = queryFormWidgetByViewHandle(this.currentHandle);
    const res = await formWidget?.validator();
    return res;
  }

  public getFormData() {
    return this.activeRecords?.[0];
  }

  public async getSubmitData() {
    const formWidget = queryFormWidgetByViewHandle(this.currentHandle);
    return await formWidget?.submit();
  }

  protected getModelFields() {
    const formWidget = queryFormWidgetByViewHandle(this.currentHandle);
    return formWidget?.rootRuntimeContext.getRequestModelFields();
  }

  public setData(data: Record<string, unknown>) {
    if (data) {
      this.reloadDataSource(data);
      this.reloadActiveRecords(data);
      this.reloadRootData(data);
    }
  }

  /**
   * 当前子路径索引
   */
  @Widget.Reactive()
  protected subIndex: string | number | undefined;

  /**
   * 上级路径
   */
  @Widget.Reactive()
  @Widget.Inject('path')
  protected parentPath: string | undefined;

  /**
   * 完整路径
   */
  @Widget.Reactive()
  @Widget.Provide()
  public get path() {
    const { parentPath, subIndex } = this;
    let path = parentPath || '';
    return `${path}.metadata[${subIndex || ''}]`;
  }
}

原理分析

vue 插槽渲染

我们先从大家都熟悉的 vue 文件入手

<template>
  <div class="AddSubviewBlockField">
    <div class="subview-block" v-for="(key, index) in myMetadataViewWidgetKeys" :key="key">
      <oio-icon @click="onDleteSubviewBlock(index)" icon="oinone-shanchu3" size="24" />
      <slot :name="key" />
    </div>
    <div class="add-icon">
      <a-divider />
      <oio-icon @click="onAddSubviewBlock" icon="oinone-add-circle" size="24" />
    </div>
  </div>
</template>

可以看到模版的内容非常简单,上方一个循环,渲染子视图的列表,每个列表有个删除icon;下方一个 添加icon,点击触发添加子视图。
关键点是子视图循环渲染中,用到了插槽接收,也就意味着在 AddSubformWidget 中创建了插槽的内容,下面我们着重来看看这一部分的实现。

widget 创建插槽内容

核心代码,创建插槽

我们先来看看核心创建插槽的代码

      // 创建子视图组件
      const widget = this.createWidget(
        new MyMetadataViewWidget(runtimeContextHandle),
        slotKey, // 插槽名称
        {
          metadataHandle: runtimeContextHandle,
          rootHandle: runtimeContextHandle,
          mountedCallChaining: new CallChaining(),
          refreshCallChaining: new CallChaining(),
          inline: true,
          subIndex: this.myMetadataViewWidgetLength
        }
      );

核心就是 this.createWidget 方法,它会为当前 widget 创建一个子组件,即 MyMetadataViewWidget;第二个参数指定 vue 里接收的插槽名。

如果细心的你去看了 createWidget 的源码,你就会发现它只是:为子组件指定当前组件为父组件,初始化子组件,为当前组件添加子组件罢了。那么插槽是怎么创建出来的呢?
这就涉及到核心 buildWidgetComponent 方法,它会把当前 widget 渲染成 vue ,并把 this.setComponent(AddSubform) 设置的组件渲染出来。在渲染 AddSubform 时,会调用 this.resolveChildren() 为它创建插槽,前提是没有默认插槽(默认插槽是根据 DSL 渲染的,也就是说当前组件 DSL 里的子 widgets 必须为空)。

插槽内容

通过上述创建,插槽是有了,但插槽的内容是什么呢?
内容在 MyMetadataViewWidget 这个数据隔离的视图组件里,它继承了 MetadataViewWidget,会根据上下文视图最终dsl,渲染页面,以下是获取视图,为视图构建上下文的代码。

  // 按钮添加点击事件
  @Widget.Method()
  public async onAddSubviewBlock() {
    // 这里根据子视图模型,子视图名称,获取子视图
    const resView = await queryViewDslByModelAndName(this.subviewModel, this.subviewName);
    this.createDynamicSubviewWidget(resView);
  }

  // 创建子视图块
  public async createDynamicSubviewWidget(view, activeRecord: ActiveRecord = {}) {
    if (view) {
      // 根据子视图视图构建上下文
      const runtimeContext = createRuntimeContextByView(
        {
          type: ViewType.Form,
          model: view.model,
          modelName: view.modelDefinition.name,
          module: view.modelDefinition.module,
          moduleName: view.modelDefinition.moduleName,
          name: view.name,
          dsl: view.template
        },
        true,
        uniqueKeyGenerator(),
        this.currentHandle
      );
      // 取得上下文唯一标识
      const runtimeContextHandle = runtimeContext.handle;

      const slotKey = `Form_${uniqueKeyGenerator()}`;

      // 创建子视图组件
      const widget = this.createWidget(
        new MyMetadataViewWidget(runtimeContextHandle),
        slotKey, // 插槽名称
        {
          metadataHandle: runtimeContextHandle,
          rootHandle: runtimeContextHandle,
          mountedCallChaining: new CallChaining(),
          refreshCallChaining: new CallChaining(),
          inline: true,
          subIndex: this.myMetadataViewWidgetLength
        }
      );
      // 根据上下文,初始化最终 dsl
      widget.initContext(runtimeContext);
      // 初始化数据
      widget.setData(activeRecord);
      ......
      return widget;
    }
  }

Oinone社区 作者:银时原创文章,如若转载,请注明出处:https://doc.oinone.top/frontend/components/21426.html

访问Oinone官网:https://www.oinone.top获取数式Oinone低代码应用平台体验

(0)
银时的头像银时数式员工
上一篇 2025年7月18日 pm3:42
下一篇 2025年7月21日 pm4:02

相关推荐

  • OioMessage 全局提示

    全局展示操作反馈信息。 何时使用 可提供成功、警告和错误等反馈信息。 顶部居中显示并自动消失,是一种不打断用户操作的轻量级提示方式。 API 组件提供了一些静态方法,使用方式和参数如下: OioMessage.success(title, options) OioMessage.error(title, options) OioMessage.info(title, options) OioMessage.warning(title, options) options 参数如下: 参数 说明 类型 默认值 版本 duration 默认 3 秒后自动关闭 number 3 class 自定义 CSS class string –

    2023年12月18日
    90400
  • oio-button 按钮

    主按钮:用于主行动点,一个操作区域只能有一个主按钮。 默认按钮:用于没有主次之分的一组行动点。 虚线按钮:常用于添加操作。 文本按钮:用于最次级的行动点。 链接按钮:一般用于链接,即导航至某位置。 以及四种状态属性与上面配合使用。 危险:删除/移动/修改权限等危险操作,一般需要二次确认。 禁用:行动点不可用的时候,一般需要文案解释。 加载中:用于异步操作等待反馈的时候,也可以避免多次提交。 API 按钮的属性说明如下: 属性 说明 类型 默认值 版本 block 将按钮宽度调整为其父宽度的选项 boolean false disabled 按钮失效状态 boolean false icon 设置按钮的图标类型 v-slot – loading 设置按钮载入状态 boolean | { delay: number } false type 设置按钮类型 primary | ghost | dashed | link | text | default default 事件 事件名称 说明 回调参数 版本 click 点击按钮时的回调 (event) => void 支持原生 button 的其他所有属性。

    2023年12月18日
    60700
  • oio-drawer抽屉

    屏幕边缘滑出的浮层面板。 何时使用 抽屉从父窗体边缘滑入,覆盖住部分父窗体内容。用户在抽屉内操作时不必离开当前任务,操作完成后,可以平滑地回到原任务。 当需要一个附加的面板来控制父窗体内容,这个面板在需要时呼出。比如,控制界面展示样式,往界面中添加内容。 当需要在当前任务流中插入临时任务,创建或预览附加内容。比如展示协议条款,创建子对象。 API 参数 说明 类型 默认值 版本 class 对话框外层容器的类名 string – closable 是否显示左上角的关闭按钮 boolean true closeIcon 自定义关闭图标 VNode | slot destroyOnClose 关闭时销毁 Drawer 里的子元素 boolean false footer 抽屉的页脚 VNode | slot – getTriggerContainer 指定 Drawer 挂载的 HTML 节点 HTMLElement | () => HTMLElement | Selectors ‘body’ height 高度, 在 placement 为 top 或 bottom 时使用 string | number keyboard 是否支持键盘 esc 关闭 boolean true mask 是否展示遮罩 Boolean true maskClosable 点击蒙层是否允许关闭 boolean true placement 抽屉的方向 ‘top’ | ‘right’ | ‘bottom’ | ‘left’ ‘right’ style 可用于设置 Drawer 最外层容器的样式,和 drawerStyle 的区别是作用节点包括 mask CSSProperties – title 标题 string | slot – visible(v-model:visible) Drawer 是否可见 boolean – width 宽度 string | number 378 zIndex 设置 Drawer 的 z-index Number 1000 cancelCallback 点击遮罩层或右上角叉或取消按钮的回调, return true则关闭弹窗 function(e) enterCallback 点击确定回调 function(e)

    2023年12月18日
    90800
  • 前端自定义组件之多页面步骤条

    本文将讲解如何通过自定义,实现多页面的步骤条组件。其中每个步骤的元素里都对应界面设计器的一个页面。以下是代码实现和原理分析。 代码实现 NextStepWidget 多页面步骤条 ts 组件 import { CallChaining, createRuntimeContextByView, customMutation, customQuery, RuntimeView, SPI, ViewCache, Widget, DefaultTabsWidget, BasePackWidget } from '@oinone/kunlun-dependencies'; import { isEmpty } from 'lodash-es'; import { MyMetadataViewWidget } from './MyMetadataViewWidget'; import NextStep from './NextStep.vue'; import { IStepConfig, StepDirection } from './type'; @SPI.ClassFactory(BasePackWidget.Token({ widget: 'NextStep' })) export class NextStepWidget extends DefaultTabsWidget { public initialize(props) { this.titles = props.template?.widgets?.map((item) => item.title) || []; props.template && (props.template.widgets = []); super.initialize(props); this.setComponent(NextStep); return this; } @Widget.Reactive() public get invisible() { return false; } // 配置的每一步名称 @Widget.Reactive() public titles = []; // region 上一步下一步配置 // 步骤配置,切换的顺序就是数组的顺序,模型没有限制 @Widget.Reactive() public get stepJsonConfig() { let data = JSON.parse( this.getDsl().stepJsonConfig || '[{"model":"resource.ResourceCountry","viewName":"国家form"},{"model":"resource.ResourceProvince","viewName":"省form"},{"model":"resource.ResourceCity","viewName":"市form"}]' ); return data as IStepConfig[]; } // 切换上一步下一步 @Widget.Method() public async onStepChange(stepDirection: StepDirection) { // 没有激活的,说明是初始化,取第一步 if (!this.activeStepKey) { const step = this.stepJsonConfig[0]; if (step) { this.activeStepKey = `${step.model}_${step.viewName}`; await this.initStepView(step); } return; } // 获取当前步骤的索引 if (this.currentActiveKeyIndex > -1) { await this.onSave(); // 获取下一步索引 const nextStepIndex = stepDirection === StepDirection.NEXT ? this.currentActiveKeyIndex + 1 : this.currentActiveKeyIndex – 1; // 在索引范围内,则渲染视图 if (nextStepIndex >= 0 && nextStepIndex < this.stepJsonConfig.length) { const nextStep = this.stepJsonConfig[nextStepIndex];…

    2025年7月21日
    61800
  • 前端表格复制

    我们可能会遇到表格复制的需求,也就是表格填写的时候,不是增加一行数据,而是增加一个表格。以下是代码实现和原理分析。 代码实现 在 boot 工程的 main.ts 中加入以下代码 import { registerDesignerFieldWidgetCreator, selectorDesignerFieldWidgetCreator } from '@oinone/kunlun-ui-designer-dependencies'; // 注册无代码组件,将表头分组的无代码组件,注册成字段表格组件 registerDesignerFieldWidgetCreator( { widget: 'DynamicCreateTable' }, selectorDesignerFieldWidgetCreator({ widget: TABLE_WIDGET })! ); DynamicCreateTableWidget 动态添加表格 ts 组件 import { FormO2MTableFieldWidget, Widget, DslDefinition, RuntimeView, SubmitValue, BaseFieldWidget, ModelFieldType, SPI, ViewType, ActiveRecord, uniqueKeyGenerator } from '@oinone/kunlun-dependencies'; import { MyMetadataViewWidget } from './MyMetadataViewWidget'; import DynamicCreateTable from './DynamicCreateTable.vue'; @SPI.ClassFactory( BaseFieldWidget.Token({ viewType: ViewType.Form, ttype: ModelFieldType.OneToMany, widget: 'DynamicCreateTable' }) ) export class DynamicCreateTableWidget extends FormO2MTableFieldWidget { public myMetadataViewWidget: MyMetadataViewWidget[] = []; @Widget.Reactive() public myMetadataViewWidgetLength = 0; @Widget.Reactive() public myMetadataViewWidgetKeys: string[] = []; protected props: Record<string, unknown> = {}; public initialize(props) { super.initialize(props); this.props = props; this.setComponent(DynamicCreateTable); return this; } // region 创建动态表格 @Widget.Method() public async createTableWidget(record: ActiveRecord) { const index = this.myMetadataViewWidget.length; const handle = uniqueKeyGenerator(); const slotKey = `MyMetadataViewWidget_${handle}`; const widget = this.createWidget( new MyMetadataViewWidget(handle), slotKey, // 插槽名称 { subIndex: index, metadataHandle: this.metadataHandle, rootHandle: this.rootHandle, automatic: true, internal: true, inline: true } ); this.initDynamicSubview(this.props, widget); widget.setData(record); this.myMetadataViewWidgetLength++; this.myMetadataViewWidgetKeys.push(slotKey); this.myMetadataViewWidget.push(widget); } protected initDynamicSubview(props: Record<string, unknown>, widget: MyMetadataViewWidget) { const { currentViewDsl } = this; let viewDsl = currentViewDsl; if (!viewDsl) { viewDsl = this.getViewDsl(props)…

    2025年7月21日
    57200

Leave a Reply

登录后才能评论