前端自定义组件之多页面步骤条

本文将讲解如何通过自定义,实现多页面的步骤条组件。其中每个步骤的元素里都对应界面设计器的一个页面。以下是代码实现和原理分析。

代码实现

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];
        if (nextStep) {
          this.activeStepKey = `${nextStep.model}_${nextStep.viewName}`;
          await this.initStepView(nextStep);
        }
      }
    }
  }

  // region 创建上一步下一步视图

  // 每个步骤视图组件
  @Widget.Reactive()
  public stepViewWidget: Record<string, MyMetadataViewWidget> = {};

  // 当前激活的步骤,用 model 和 viewName 联合作为 key
  @Widget.Reactive()
  public activeStepKey: string = '';

  // 当前激活的步骤索引,即当前是第几步,从 0 开始
  @Widget.Reactive()
  public get currentActiveKeyIndex() {
    return this.stepJsonConfig.findIndex((step) => `${step.model}_${step.viewName}` === this.activeStepKey);
  }

  // 传入 step,动态创建一个步骤视图,并初始化数据
  public async initStepView(step: IStepConfig) {
    const widget = this.stepViewWidget[`${step.model}_${step.viewName}`];
    if (widget) {
      // 命中缓存
      this.initStepViewData(step);
      return;
    }

    // 根据 stepJsonConfig 里的 model 和 viewName 获取页面配置
    const resView = await ViewCache.get(step.model, step.viewName);
    if (resView) {
      // 创建一个元数据隔离的视图组件
      const widget = this.createDynamicWidget(resView, step);
      if (!widget) {
        throw new Error('Invalid widget.');
      }
      this.stepViewWidget[`${step.model}_${step.viewName}`] = widget;
      this.initStepViewData(step);
    }
  }

  // 根据取的中间协议视图 view,动态构建视图
  public createDynamicWidget(view: RuntimeView, step: IStepConfig) {
    if (view) {
      // 中间协议构建上下文
      const runtimeContext = createRuntimeContextByView(view, true, `Form_${Math.random()}`, this.currentHandle);
      runtimeContext.parentContext = this.rootRuntimeContext;
      // 取得上下文唯一标识

      const runtimeContextHandle = runtimeContext.handle;
      const widget = this.createWidget(
        new MyMetadataViewWidget(runtimeContextHandle),
        `Form_${step.model}_${step.viewName}`,
        {
          metadataHandle: runtimeContextHandle,
          rootHandle: runtimeContextHandle,
          mountedCallChaining: new CallChaining(),
          submitCallChaining: new CallChaining(),
          refreshCallChaining: new CallChaining(),
          dataSource: [{}],
          activeRecords: [{}],
          inline: false
        }
      );
      widget.initContext(runtimeContext);
      return widget;
    }
  }

  // 每个 step 的请求数据逻辑
  private async initStepViewData(step: IStepConfig) {
    // 当前步骤的 widget
    const widget = this.stepViewWidget[`${step.model}_${step.viewName}`];
    if (!widget) {
      return;
    }

    if (this.currentActiveKeyIndex > 0) {
      // 根据上一步的数据,构造数据回填
      const lastWidget =
        this.stepViewWidget[
          `${this.stepJsonConfig[this.currentActiveKeyIndex - 1].model}_${
            this.stepJsonConfig[this.currentActiveKeyIndex - 1].viewName
          }`
        ];
      const lastWidgetData = (await lastWidget.getData()) || {};
      const data = (await customQuery(step.model, 'construct', lastWidgetData)) as Record<string, unknown>;
      widget.setData(data);
      // await widget.refreshCallChaining?.syncCall();
    } else {
      if (isEmpty(await widget.getData())) {
        const data = (await customQuery(step.model, 'construct')) as Record<string, unknown>;
        widget.setData(data);
        // await widget.refreshCallChaining?.syncCall();
      }
    }
  }

  // region  初始化上一步下一步视图

  protected async $$beforeCreated() {
    await super.$$beforeCreated();
    let steps = this.stepJsonConfig;
    if (steps && steps.length) {
      await this.onStepChange(StepDirection.NEXT);
    }
    // 浏览器空闲就先把剩下的视图初始化掉
    window.requestIdleCallback(async () => {
      (steps || []).map(async (step) => {
        if (!this.stepViewWidget[`${step.model}_${step.viewName}`]) {
          this.initStepView(step);
        }
      });
    });
  }

  // region 提交数据

  // 保存
  @Widget.Method()
  public async onSave() {
    this.loading = true;
    const step = this.stepJsonConfig[this.currentActiveKeyIndex];
    const widget = this.stepViewWidget[`${step.model}_${step.viewName}`];
    if (!widget) {
      return;
    }

    const validatorRes = await widget.validator?.();
    if (validatorRes) {
      const submitData = (await widget.getData()) as any;
      await customMutation(step.model, 'create', submitData || {});
      this.loading = false;
    }
  }
}

NextStep.vue 多页面步骤条 vue 组件

<template>
  <div class="next-step">
    <a-steps :current="current">
      <a-step v-for="title in titles" :title="title" />
    </a-steps>
    <OioSpin :loading="loading" class="oio-spin">
      <div class="setp__main">
        <template v-for="(step, index) in stepJsonConfig">
          <div class="setp__item" v-show="currentActiveKeyIndex === index">
            <slot :name="`Form_${step.model}_${step.viewName}`" />
          </div>
        </template>
      </div>
      <div class="setp__footer" v-if="stepJsonConfig.length">
        <a-button v-if="current > 0" class="oio-button" type="primary" @click="previous"> 上一步 </a-button>
        <a-button v-if="current < stepJsonConfig.length - 1" class="oio-button" type="primary" @click="next">
          下一步
        </a-button>
        <a-button v-if="current === stepJsonConfig.length - 1" class="oio-button" type="primary" @click="finish">
          完成
        </a-button>
      </div>
    </OioSpin>
  </div>
</template>
<script setup lang="ts">
import { PropType, ref } from 'vue';
import { OioSpin } from '@oinone/kunlun-vue-ui-antd';
import { IStepConfig, StepDirection } from './type';

const props = defineProps({
  loading: {
    type: Boolean,
    default: false
  },
  titles: {
    type: Array as PropType<string[]>,
    default: []
  },
  stepJsonConfig: {
    type: Array as PropType<IStepConfig[]>,
    default: []
  },
  currentActiveKeyIndex: {
    type: Number,
    default: 0
  },
  onStepChange: {
    type: Function
  },
  onSave: {
    type: Function
  }
});

const current = ref<number>(0);

const next = () => {
  current.value++;
  props.onStepChange?.(StepDirection.NEXT);
};
const previous = () => {
  current.value--;
  props.onStepChange?.(StepDirection.PREVIOUS);
};

const finish = async () => {
  await props.onSave?.();
  window.history.back();
};
</script>
<style lang="scss" scoped>
.next-step {
  height: 100%;
  background-color: #fff;
  padding: 16px;

  .setp__main {
    display: flex;
    justify-content: flex-start;

    .setp__item {
      width: 100%;
    }
  }

  .setp__footer {
    display: flex;
    justify-content: flex-start;
    align-items: center;
    gap: 16px;
    margin-top: 18px;
  }
}
</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()
  public submitCallChaining: CallChaining | undefined;

  @Widget.Provide()
  public refreshCallChaining: 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 || {}];
    }
  }
  public initialize(props): this {
    this.mountedCallChaining = props.mountedCallChaining;
    this.submitCallChaining = props.submitCallChaining;
    this.refreshCallChaining = props.refreshCallChaining;
    this.dataSource = props.dataSource;
    this.activeRecords = props.activeRecords;
    super.initialize(props);
    return this;
  }

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

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

  public async getData() {
    const formWidget = queryFormWidgetByViewHandle(this.currentHandle);
    const callResult = await formWidget?.submit();
    return callResult?.records as Record<string, unknown>;
  }

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

  public setData(data: Record<string, unknown>) {
    if (data) {
      this.dataSource = [data];
      this.activeRecords = [data];
    }
  }

  public getRuntimeModel() {
    return this.runtimeContext?.model;
  }
}

原理分析

参考 https://doc.oinone.top/frontend/components/21426.html

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

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

Like (0)
银时's avatar银时数式员工
Previous 2025年7月21日 pm4:02
Next 2025年7月21日 pm8:49

相关推荐

  • oio-grid 栅格

    24 栅格系统。 <oio-row :gutter="24"> <oio-col :span="12"></oio-col> <oio-col :span="12"></oio-col> </oio-row> 概述 布局的栅格化系统,我们是基于行(row)和列(col)来定义信息区块的外部框架,以保证页面的每个区域能够稳健地排布起来。下面简单介绍一下它的工作原理: 通过\row\在水平方向建立一组\column\(简写 col) 你的内容应当放置于\col\内,并且,只有\col\可以作为\row\的直接元素 栅格系统中的列是指 1 到 24 的值来表示其跨越的范围。例如,三个等宽的列可以使用 \<a-col :span="8" />\ 来创建 如果一个\row\中的\col\总和超过 24,那么多余的\col\会作为一个整体另起一行排列 Flex 布局 我们的栅格化系统支持 Flex 布局,允许子元素在父节点内的水平对齐方式 – 居左、居中、居右、等宽排列、分散排列。子元素与子元素之间,支持顶部对齐、垂直居中对齐、底部对齐的方式。同时,支持使用 order 来定义元素的排列顺序。 Flex 布局是基于 24 栅格来定义每一个『盒子』的宽度,但不拘泥于栅格。 API Row 成员 说明 类型 默认值 align flex 布局下的垂直对齐方式:top middle bottom string top gutter 栅格间隔,可以写成像素值或支持响应式的对象写法来设置水平间隔 { xs: 8, sm: 16, md: 24}。或者使用数组形式同时设置 [水平间距, 垂直间距](1.5.0 后支持)。 number/object/array 0 justify flex 布局下的水平排列方式:start end center space-around space-between string start wrap 是否自动换行 boolean false Col 成员 说明 类型 默认值 版本 flex flex 布局填充 string|number – offset 栅格左侧的间隔格数,间隔内不可以有栅格 number 0 order 栅格顺序,flex 布局模式下有效 number 0 pull 栅格向左移动格数 number 0 push 栅格向右移动格数 number 0 span 栅格占位格数,为 0 时相当于 display: none number – xxxl ≥2000px 响应式栅格,可为栅格数或一个包含其他属性的对象 number|object – xs <576px 响应式栅格,可为栅格数或一个包含其他属性的对象 number|object – sm ≥576px 响应式栅格,可为栅格数或一个包含其他属性的对象 number|object – md ≥768px 响应式栅格,可为栅格数或一个包含其他属性的对象 number|object – lg ≥992px 响应式栅格,可为栅格数或一个包含其他属性的对象 number|object – xl ≥1200px 响应式栅格,可为栅格数或一个包含其他属性的对象 number|object – xxl ≥1600px 响应式栅格,可为栅格数或一个包含其他属性的对象 number|object –

    2023年12月18日
    1.2K00
  • oio-cascader 级联选择

    级联选择框。 何时使用 需要从一组相关联的数据集合进行选择,例如省市区,公司层级,事物分类等。 从一个较大的数据集合中进行选择时,用多级分类进行分隔,方便选择。 比起 Select 组件,可以在同一个浮层中完成选择,有较好的体验。 API <oio-cascader :options="options" v-model:value="value" /> 参数 说明 类型 默认值 Version allowClear 是否支持清除 boolean true autofocus 自动获取焦点 boolean false changeOnSelect (单选时生效)当此项为 true 时,点选每级菜单选项值都会发生变化,具体见上面的演示 boolean false disabled 禁用 boolean false displayRender 选择后展示的渲染函数,可使用 #displayRender="{labels, selectedOptions}" ({labels, selectedOptions}) => VNode labels => labels.join(' / ') dropdownClassName 自定义浮层类名 string – getTriggerContainer 菜单渲染父节点。默认渲染到 body 上,如果你遇到菜单滚动定位问题,试试修改为滚动的区域,并相对其定位。 Function(triggerNode) () => document.body loadData 用于动态加载选项,无法与 showSearch 一起使用 (selectedOptions) => void – maxTagCount 最多显示多少个 tag,响应式模式会对性能产生损耗 number | responsive – maxTagPlaceholder 隐藏 tag 时显示的内容 v-slot | function(omittedValues) – multiple 支持多选节点 boolean – options 可选项数据源 – placeholder 输入框占位文本 string ‘请选择’ searchValue 设置搜索的值,需要与 showSearch 配合使用 string – showSearch 在选择框中显示搜索框 boolean false tagRender 自定义 tag 内容,多选时生效 slot – value(v-model:value) 指定选中项 string[] | number[] – showSearch showSearch 为对象时,其中的字段: 参数 说明 类型 默认值 filterOption 接收 inputValue path 两个参数,当 path 符合筛选条件时,应返回 true,反之则返回 false。 function(inputValue, path): boolean 事件 事件名称 说明 回调参数 版本 change 选择完成后的回调 (value, selectedOptions) => void – search 监听搜索,返回输入的值 (value) => void – Option interface Option { value: string | number; label?: any; disabled?: boolean; children?: Option[]; // 标记是否为叶子节点,设置了 `loadData` 时有效 // 设为 `false` 时会强制标记为父节点,即使当前节点没有 children,也会显示展开图标 isLeaf?: boolean; }

    2023年12月18日
    1.6K00
  • 前端表格复制

    我们可能会遇到表格复制的需求,也就是表格填写的时候,不是增加一行数据,而是增加一个表格。以下是代码实现和原理分析。 代码实现 在 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日
    98500
  • oio-dropdown 下拉菜单

    向下弹出的列表。 何时使用 当页面上的操作命令过多时,用此组件可以收纳操作元素。点击或移入触点,会出现一个下拉菜单。可在列表中进行选择,并执行相应的命令。 用于收罗一组命令操作。 Select 用于选择,而 Dropdown 是命令集合。 API 属性如下 参数 说明 类型 默认值 destroyOnHide 关闭后是否销毁 Dropdown boolean false disabled 菜单是否禁用 boolean – getTriggerContainer 菜单渲染父节点。默认渲染到 body 上,如果你遇到菜单滚动定位问题,试试修改为滚动的区域,并相对其定位。 Function(triggerNode) () => document.body overlay(v-slot) 菜单 Menu – overlayClassName 下拉根元素的类名称 string – overlayStyle 下拉根元素的样式 object – placement 菜单弹出位置 bottomLeft | bottom | bottomRight | topLeft | top | topRight bottomLeft trigger 触发下拉的行为, 移动端不支持 hover Array<click|hover|contextmenu> ['hover'] visible(v-model:visible) 菜单是否显示 boolean – 事件 事件名称 说明 回调参数 onUpdateValue 菜单显示状态改变时调用,参数为 visible。点击菜单按钮导致的消失不会触发 function(visible)

    2023年12月18日
    2.0K00
  • oio-spin 加载中

    用于页面和区块的加载中状态。 何时使用 页面局部处于等待异步数据或正在渲染过程时,合适的加载动效会有效缓解用户的焦虑。 API 参数 说明 类型 默认值 版本 delay 延迟显示加载效果的时间(防止闪烁) number (毫秒) – loading 是否为加载中状态 boolean true wrapperClassName 包装器的类属性 string –

    2023年12月18日
    1.6K00

Leave a Reply

Please Login to Comment