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

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

代码实现

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低代码应用平台体验

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

相关推荐

  • 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.9K00
  • 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日
    74000
  • 前端表格复制

    我们可能会遇到表格复制的需求,也就是表格填写的时候,不是增加一行数据,而是增加一个表格。以下是代码实现和原理分析。 代码实现 在 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日
    50400
  • 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日
    80300
  • 前端自定义组件之左右滑动

    本文将讲解如何通过自定义,实现容器内的左右两个元素,通过左右拖拽分隔线,灵活调整宽度。其中左右元素里的内容都是界面设计器拖出来的。 实现路径 1. 界面设计器拖出页面 我们界面设计器拖个布局容器,然后在左右容器里拖拽任意元素。完成后点击右上角九宫格,选中布局容器,填入组件 api 名称,作用是把布局容器切换成我们自定义的左右滑动组件,这里的 api 名称和自定义组件的 widget 对应。最后发布页面,并绑定菜单。 2. 组件实现 widget 组件重写了布局容器,核心函数 renderLeft、renderRight,通过 DslRender.render 方法渲染界面设计器拖拽的元素。 import { BasePackWidget, DefaultContainersWidget, DslDefinition, DslRender, SPI, Widget } from '@oinone/kunlun-dependencies'; import LeftRightSlide from './LeftRightSlide.vue'; // 拿到界面设计器配置的子容器元素 function fetchContainerChildren(widgets?: DslDefinition[], level = 3): DslDefinition[] { if (!widgets) { return []; } const children: DslDefinition[] = []; for (const widget of widgets) { if (widget.widget === 'container') { children.push(widget); } else if (level >= 1) { fetchContainerChildren(widget.widgets, level – 1).forEach((child) => children.push(child)); } } return children; } @SPI.ClassFactory(BasePackWidget.Token({ widget: 'LeftRightSlide' })) export class LeftRightSlideWidget extends DefaultContainersWidget { public initialize(props) { super.initialize(props); this.setComponent(LeftRightSlide); return this; } // 获取容器的子元素 public get containerChildren(): DslDefinition[] { return fetchContainerChildren(this.template?.widgets); } // 初始宽度配置 @Widget.Reactive() public get initialLeftWidth() { return this.getDsl().initialLeftWidth || 400; } // 最小左宽度配置 @Widget.Reactive() public get minLeftWidth() { return this.getDsl().minLeftWidth || 200; } // 最小右宽度配置 @Widget.Reactive() public get minRightWidth() { return this.getDsl().minRightWidth || 200; } // 根据容器子元素渲染左侧 @Widget.Method() public renderLeft() { // 把容器的第一个元素作为左侧 const containerLeft = this.containerChildren[0]; if (containerLeft) { return DslRender.render(containerLeft); } } // 根据容器子元素渲染右侧 @Widget.Method() public renderRight() { // 把容器的第二个元素作为右侧 const containerRight = this.containerChildren[1]; if…

    2025年7月8日
    53300

Leave a Reply

登录后才能评论