前端页面嵌套

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

代码实现

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

相关推荐

  • oio-select 选择器

    API Select props 参数 说明 类型 默认值 版本 allowClear 支持清除 boolean false autofocus 默认获取焦点 boolean false clearIcon 自定义的多选框清空图标 VNode | slot – disabled 是否禁用 boolean false dropdownClassName 下拉菜单的 className 属性 string – dropdownRender 自定义下拉框内容 ({menuNode: VNode, props}) => VNode | v-slot – filterOption 是否根据输入项进行筛选。当其为一个函数时,会接收 inputValue option 两个参数,当 option 符合筛选条件时,应返回 true,反之则返回 false。 boolean | function(inputValue, option) true getTriggerContainer 菜单渲染父节点。默认渲染到 body 上,如果你遇到菜单滚动定位问题,试试修改为滚动的区域,并相对其定位。 function(triggerNode) () => document.body menuItemSelectedIcon 自定义当前选中的条目图标 VNode | slot – options options 数据,如果设置则不需要手动构造 selectOption 节点 array<{value, label, [disabled, key, title]}> [] placeholder 选择框默认文字 string|slot – removeIcon 自定义的多选框清除图标 VNode | slot – suffixIcon 自定义的选择框后缀图标 VNode | slot – value(v-model:value) 指定当前选中的条目 string|string[]|number|number[] – 注意,如果发现下拉菜单跟随页面滚动,或者需要在其他弹层中触发 Select,请尝试使用 getPopupContainer={triggerNode => triggerNode.parentNode} 将下拉弹层渲染节点固定在触发器的父元素中。 事件 事件名称 说明 回调参数 blur 失去焦点的时回调 function change 选中 option,或 input 的 value 变化(combobox 模式下)时,调用此函数 function(value, option:Option/Array<Option>) deselect 取消选中时调用,参数为选中项的 value (或 key) 值,仅在 multiple 或 tags 模式下生效 function(value,option:Option) dropdownVisibleChange 展开下拉菜单的回调 function(open) focus 获得焦点时回调 function inputKeyDown 键盘按下时回调 function mouseenter 鼠标移入时回调 function mouseleave 鼠标移出时回调 function popupScroll 下拉列表滚动时的回调 function search 文本框值变化时回调 function(value: string) select 被选中时调用,参数为选中项的 value (或 key) 值 function(value, option:Option)

    2023年12月18日
    46100
  • oio-empty-data 空数据状态

    何时使用 当目前没有数据时,用于显式的用户提示。 初始化场景时的引导创建流程。 API 参数 说明 类型 默认值 版本 description 自定义描述内容 string | v-slot – image 设置显示图片,为 string 时表示自定义图片地址 string | v-slot false imageStyle 图片样式 CSSProperties –

    2023年12月18日
    83700
  • oio-input 输入框

    代码演示 <oio-input v-model:value="value"></oio-input> API Input 参数 说明 类型 默认值 版本 addonAfter 带标签的 input,设置后置标签 string|slot addonBefore 带标签的 input,设置前置标签 string|slot allowClear 可以点击清除图标删除内容 boolean defaultValue 输入框默认内容 string disabled 是否禁用状态,默认为 false boolean false maxlength 最大长度 number prefix 带有前缀图标的 input slot showCount 是否展示字数 boolean false suffix 带有后缀图标的 input slot type 声明 input 类型,同原生 input 标签的 type 属性,见:MDN(请直接使用 <a-textarea /> 代替 type="textarea")。 string text value(v-model:value) 输入框内容 string Input 事件 事件名称 说明 回调参数 update:value 输入框内容变化时的回调 function(e) pressEnter 按下回车的回调 function(e) Input.Search 代码演示 <oio-input-search v-model:value="value"></oio-input-search> Input.Search 事件 事件名称 说明 回调参数 search 点击搜索或按下回车键时的回调 function(value, event) 其余属性和 Input 一致。

    2023年12月18日
    1.8K00
  • oio-modal 对话框

    API 参数 说明 类型 默认值 版本 cancelText 取消按钮文字 string| slot 取消 closable 是否显示右上角的关闭按钮 boolean true closeIcon 自定义关闭图标 VNode | slot – confirmLoading 确定按钮 loading boolean 无 destroyOnClose 关闭时销毁 Modal 里的子元素 boolean false footer 底部内容,当不需要默认底部按钮时,可以设为 :footerInvisible="true" slot 确定取消按钮 getTriggerContainer 指定 Modal 挂载的 HTML 节点 (instance): HTMLElement () => document.body keyboard 是否支持键盘 esc 关闭 boolean true mask 是否展示遮罩 boolean true maskClosable 点击蒙层是否允许关闭 boolean true enterText 确认按钮文字 string 确定 title 标题 string|slot 无 visible(v-model:visible) 对话框是否可见 boolean 无 width 宽度 string|number 520 wrapClassName 对话框外层容器的类名 string – zIndex 设置 Modal 的 z-index number 1000 cancelCallback 点击遮罩层或右上角叉或取消按钮的回调, return true则关闭弹窗 function(e) enterCallback 点击确定回调 function(e)

    2023年12月18日
    85800
  • oio-checkbox 对选框

    API 属性 Checkbox 参数 说明 类型 默认值 版本 autofocus 自动获取焦点 boolean false checked(v-model:checked) 指定当前是否选中 boolean false disabled 失效状态 boolean false indeterminate 设置 indeterminate 状态,只负责样式控制 boolean false value 与 CheckboxGroup 组合使用时的值 boolean | string | number – 事件 事件名称 说明 回调参数 版本 change 变化时回调函数 Function(e:Event) –

    2023年12月18日
    73200

Leave a Reply

登录后才能评论