【界面设计器】自定义字段组件实战——表格字段内嵌表格

阅读之前

此文章为实战教程,已假定你熟悉了【界面设计器】较为完整的【自定义组件】相关内容。

如果在阅读过程中出现的部分概念无法理解,请自行学习相关内容。【前端】文章目录

业务背景

表格中的一对多(O2M)多对多(M2M)字段使用表格展开。

演示内容:在【商品】的表格中存在【库存信息】这一列,这一列的内容通过表格展示【尺码】和【数量】两列。

业务分析及实现思路

从需求来看,我们需要实现一个【内嵌表格】组件,并且该组件允许在【表格】视图中使用。与之前不同的是,这里我们需要支持两个业务类型一对多(O2M)多对多(M2M),即一个组件中包含两个元件。

在【内嵌表格】组件的属性面板中,我们需要再定义一个【内嵌表格配置】组件,用来选择内嵌表格中需要哪些字段进行组合,以及为每个组合提供一些基础配置。

这里需要理解一个基本概念,即【内嵌表格】的属性面板是【内嵌表格配置】的【执行页面】。所有组件的属性面板在【执行页面】时都是【表单】视图。

因此我们可以实现一个【内嵌表格配置】组件,并且该组件允许在【表单】视图中使用。其业务类型使用【文本】,我们在保存配置数据时,可以使用JSON数据结构来存储复杂结构。(这里的实现思路并非是最符合协议设定的,但可以满足绝大多数组件场景)

在【内嵌表格配置】组件中,我们可以允许用户添加/移除组合,并且每个组合有两个属性,【标题】和【字段】。

一些解释

看过【界面设计器】自定义字段组件实战——表格字段组合展示文章的读者可能很熟悉这一实现思路,会想当然的尝试将两个组件进行合并。这里我觉得有必要作出一些实现思路上的解释。

虽然在表面上看起来【组合列配置】和【内嵌表格配置】用到的属性完全一样,但在实现上,由于关联关系的查询需要在组件中特殊处理【透出字段(选项字段列表)】字段(【界面设计器】组件开发常见问题
中对该属性进行了解释),才能查询到相应的关联数据。

不仅如此,这两个组件所代表的含义也完全不同。【组合列配置】是在一列中配置需要展示的字段,它在未来可能会增加【颜色(根据条件判断展示不同的颜色)】、【动作(可点击的行为)】等等诸多与之相关的属性。【内嵌表格配置】是在一列中配置表格中的多列,它在未来可能会增加【行高(控制表格行高)】、【支持排序(表格列支持排序)】等等诸多与之相关的属性。

在这里希望读者可以理解一点:相似并不代表相关。组件的抽象与归纳整理的不同点在于,抽象更需要关心其本身所代表的含义,而不是仅关注其相似程度。将多个相似度高但含义不同的组件进行归纳整理得到的只是一个含义不明,无法适应变化的组件。

因此,我们仍然使用两个不同的组件进行实现。

准备工作

此处你应该已经准备好了【商品】和【库存】两个模型,并且可以完整执行【商品】模型的全部【增删改查】操作。

业务模型定义(此处模型定义并非业务中正常使用的模型定义,仅作为演示使用)

(以下仅展示本文章用到的模型字段,忽略其他无关字段。)

关联字段:-左侧表示当前模型中的字段API名称,右侧表示关联模型中的字段API名称。

商品(Item)
名称 API名称 业务类型 是否多值 长度(单值长度) 关联模型 关联字段
ID id 整数 - - -
编码 code 文本 128 - -
名称 name 文本 128 - -
库存信息 inventoryInfo 一对多 - 库存(Inventory) id - itemId
库存(Inventory)
名称 API名称 业务类型 是否多值 长度(单值长度) 关联模型 关联字段
ID id 整数 128 - -
商品 item 多对一 - 商品(Item) itemId - id
商品ID itemId 整数 - - -
尺码 size 文本 128 - -
库存 count 整数 - - -

PS:如果是使用【模型设计器】创建这两个模型,在创建关联关系字段时必须使用双向关联,才能正确建立关联关系。

实现页面效果展示

表格视图

image.png

表单视图

image.png

库存信息配置

image.png

创建组件、元件

准备工作完成后,我们需要根据【业务背景】确定【组件】以及【元件】相关信息,并在【界面设计器】中进行创建。

以下操作过程将省略详细步骤,仅展示可能需要确认的关键页面。

创建内嵌表格组件

image.png

创建内嵌表格元件(一对多)

image.png

创建内嵌表格元件(多对多)

image.png

创建内嵌表格配置组件

image.png

创建内嵌表格配置元件

image.png

设计内嵌表格元件属性面板

(两个元件的属性面板可以完全一致)

创建tableConfig字段,并切换至【内嵌表格配置】组件。

image.png

image.png

再拖入【透出字段(选项字段列表)】,并设置为隐藏。

image.png

设计内嵌表格配置元件属性面板

image.png

启动SDK工程进行组件基本功能开发

开发步骤参考

  • 打开【表格】视图,将【库存信息】字段的组件切换为【内嵌表格】
  • 在属性面板中看到【内嵌表格配置】组件,并优先实现【内嵌表格配置】组件。这里的属性面板就是【内嵌表格配置】组件对应的【执行页面】。
  • 当【内嵌表格配置】组件可以按照预先设计的数据结构正确保存tableConfig属性时,可以在【内嵌表格】组件中的props定义中直接获取该属性,接下来就可以进行【内嵌表格】组件的开发。

代码实现参考

工程结构

image.png

typing.ts
export interface InlineTableConfig {
  key: string;
  label?: string;
  field?: string;
}
FieldService.ts
import { GenericFunctionService, IModelField, QueryPageResult } from '@kunlun/dependencies';

export class FieldService {
  private static readonly FIELD_MODEL = 'base.Field';

  public static async fetchFieldsByModel(model: string, filter?: string): Promise<IModelField[]> {
    let rsql = `model == "${model}"`;
    if (filter) {
      rsql = `${rsql} and ${filter}`;
    }
    const res = await GenericFunctionService.INSTANCE.simpleExecuteByName<QueryPageResult<IModelField>>(
      FieldService.FIELD_MODEL,
      'queryPage',
      {
        size: -1
      },
      {
        rsql
      }
    );
    return res?.content || [];
  }
}
InlineTableConfig.vue
<template>
  <div class="inline-table-config">
    <oio-form v-for="item in list" :data="item" :key="item.key">
      <oio-form-item label="标题" name="label">
        <oio-input v-model:value="item.label" />
      </oio-form-item>
      <oio-form-item label="字段" name="field">
        <a-select
          class="oio-select"
          dropdownClassName="oio-select-dropdown"
          v-model:value="item.field"
          :options="fields"
        />
      </oio-form-item>
      <oio-button type="link" @click="() => removeItem(item)">移除</oio-button>
    </oio-form>
    <oio-button type="primary" block @click="addItem">添加</oio-button>
  </div>
</template>
<script lang="ts">
import { uniqueKeyGenerator } from '@kunlun/dependencies';
import { OioButton, OioForm, OioFormItem, OioInput } from '@kunlun/vue-ui-antd';
import { Select as ASelect } from 'ant-design-vue';
import { defineComponent, onMounted, PropType, ref, watch } from 'vue';
import { InlineTableConfig } from '../../typing';
import { FieldService } from './service/FieldService';

interface SelectItem {
  label: string;
  value: string;
}

export default defineComponent({
  name: 'InlineTableConfig',
  components: {
    OioForm,
    OioFormItem,
    OioInput,
    OioButton,
    ASelect
  },
  props: {
    referenceModel: {
      type: String
    },
    value: {
      type: String
    },
    change: {
      type: Function
    },
    setOptionFields: {
      type: Function as PropType<(optionFields: string[]) => void>
    }
  },
  setup(props) {
    const list = ref<InlineTableConfig[]>([]);
    if (props.value) {
      list.value = JSON.parse(props.value);
    }

    const addItem = () => {
      list.value = [...list.value, { key: uniqueKeyGenerator() }];
    };

    const removeItem = (item: InlineTableConfig) => {
      const { key } = item;
      if (!key) {
        return;
      }
      const index = list.value.findIndex((v) => v.key === key);
      if (index >= 0) {
        list.value.splice(index, 1);
      }
    };

    const fields = ref<SelectItem[]>([]);

    onMounted(() => {
      if (props.referenceModel) {
        FieldService.fetchFieldsByModel(props.referenceModel, 'ttype =out= (O2O, O2M, M2O, M2M)').then((res) => {
          fields.value = res.map((v) => ({
            label: v.displayName || v.field!,
            value: v.field!
          }));
        });
      }
    });

    watch(
      list,
      (val) => {
        if (props.referenceModel) {
          props.setOptionFields?.(val.filter((v) => !!v.field).map((v) => v.field) as string[]);
        }
        props.change?.(JSON.stringify(val));
      },
      { deep: true }
    );

    return {
      list,
      addItem,
      removeItem,
      fields
    };
  }
});
</script>
FormStringInlineTableConfigFieldWidget.ts
import { FormFieldWidget, ModelFieldType, SPI, ViewType, Widget } from '@kunlun/dependencies';
import InlineTableConfig from './InlineTableConfig.vue';

interface InternalMetadata {
  modelReferences?: {
    model?: string;
  };
}

@SPI.ClassFactory(
  FormFieldWidget.Token({
    viewType: ViewType.Form,
    ttype: ModelFieldType.String,
    widget: 'InlineTableConfig',
    multi: false
  })
)
export class FormStringInlineTableConfigFieldWidget extends FormFieldWidget {
  public initialize(props) {
    super.initialize(props);
    this.setComponent(InlineTableConfig);
    return this;
  }

  @Widget.Reactive()
  protected get referenceModel(): string | undefined {
    return (this.formData._metadata as InternalMetadata)?.modelReferences?.model;
  }

  @Widget.Method()
  public setOptionFields(optionFields: string[]) {
    this.formData.optionFields = optionFields;
  }
}
InlineTable.vue(需新增文件)
<template>
  <a-table
    class="column-inline-table"
    :data-source="dataSource"
    :columns="columns"
    :pagination="false"
    size="small"
    :scroll="{ y: 140 }"
  />
</template>
<script lang="ts">
import { ActiveRecord } from '@kunlun/dependencies';
import { Table as ATable } from 'ant-design-vue';
import { computed, defineComponent, PropType } from 'vue';
import { InlineTableConfig } from '../../typing';

export default defineComponent({
  name: 'InlineTable',
  inheritAttrs: false,
  components: {
    ATable
  },
  props: {
    dataSource: {
      type: Array as PropType<ActiveRecord[]>
    },
    fields: {
      type: Array as PropType<InlineTableConfig[]>
    }
  },
  setup(props) {
    const dataSource = computed(() => props.dataSource || []);

    const columns = computed(() =>
      (props.fields || []).map((v) => ({
        key: v.key,
        title: v.label,
        dataIndex: v.field
      }))
    );

    return {
      dataSource,
      columns
    };
  }
});
</script>
<style lang="scss">
.column-inline-table.ant-table-wrapper {
  height: 100%;
}
</style>

PS:此处由于平台使用了inline-table的class名称,为了避免冲突,使用column-inline-table

AbstractInlineTableFieldWidget.ts
import { RowContext, TableListFieldWidget, Widget } from '@kunlun/dependencies';
import { createVNode, VNode } from 'vue';
import { InlineTableConfig } from '../../typing';
import InlineTable from './InlineTable.vue';

export class AbstractInlineTableFieldWidget extends TableListFieldWidget {
  @Widget.Reactive()
  protected get tableConfig(): InlineTableConfig[] {
    const { tableConfig } = this.getDsl();
    if (tableConfig) {
      return JSON.parse(tableConfig);
    }
    return [];
  }

  protected getFields() {
    return this.tableConfig.filter((v) => !!v.field);
  }

  @Widget.Method()
  public renderDefaultSlot(context: RowContext): VNode[] | string {
    return [
      createVNode(InlineTable, {
        dataSource: this.compute(context),
        fields: this.getFields()
      })
    ];
  }
}
TableO2mMultiInlineTableFieldWidget.ts
import { BaseFieldWidget, ModelFieldType, SPI, ViewType } from '@kunlun/dependencies';
import { AbstractInlineTableFieldWidget } from './AbstractInlineTableFieldWidget';

@SPI.ClassFactory(
  BaseFieldWidget.Token({
    viewType: ViewType.Table,
    ttype: ModelFieldType.OneToMany,
    widget: 'InlineTable',
    multi: true
  })
)
export class TableO2mMultiInlineTableFieldWidget extends AbstractInlineTableFieldWidget {}
TableM2mMultiInlineTableFieldWidget.ts
import { BaseFieldWidget, ModelFieldType, SPI, ViewType } from '@kunlun/dependencies';
import { AbstractInlineTableFieldWidget } from './AbstractInlineTableFieldWidget';

@SPI.ClassFactory(
  BaseFieldWidget.Token({
    viewType: ViewType.Table,
    ttype: ModelFieldType.ManyToMany,
    widget: 'InlineTable',
    multi: true
  })
)
export class TableM2mMultiInlineTableFieldWidget extends AbstractInlineTableFieldWidget {}

实现效果展示

配置内嵌表格

image.png

表格展示

image.png

内嵌表格属性面板

image.png

Oinone社区 作者:数式-海波原创文章,如若转载,请注明出处:https://doc.oinone.top/frontend/59.html

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

Like (0)
数式-海波's avatar数式-海波数式管理员
Previous 2023年6月20日 pm4:07
Next 2023年11月2日 pm1:58

相关推荐

  • 前端元数据介绍

    模型 属性名 类型 描述 id string 模型id model string 模型编码 name string 技术名称 modelFields RuntimeModelField[] 模型字段 modelActions RuntimeAction[] 模型动作 type ModelType 模型类型 module string 模块编码 moduleName string 模块名称 moduleDefinition RuntimeModule 模块定义 pks string[] 主键 uniques string[][] 唯一键 indexes string[][] 索引 sorting string 排序 label string 显示标题 labelFields string[] 标题字段 模型字段 属性名 类型 描述 model string 模型编码 modelName string 模型名称 data string 属性名称 name string API名称 ttype ModelFieldType 字段业务类型 multi boolean (可选) 是否多值 store boolean 是否存储 displayName string (可选) 字段显示名称 label string (可选) 字段页面显示名称(优先于displayName) required boolean | string (可选) 必填规则 readonly boolean | string (可选) 只读规则 invisible boolean | string (可选) 隐藏规则 disabled boolean | string (可选) 禁用规则 字段业务类型 字段类型 值 描述 String ‘STRING’ 文本 Text ‘TEXT’ 多行文本 HTML ‘HTML’ 富文本 Phone ‘PHONE’ 手机 Email ‘EMAIL’ 邮箱 Integer ‘INTEGER’ 整数 Long ‘LONG’ 长整型 Float ‘FLOAT’ 浮点数 Currency ‘MONEY’ 金额 DateTime ‘DATETIME’ 时间日期 Date ‘DATE’ 日期 Time ‘TIME’ 时间 Year ‘YEAR’ 年份 Boolean ‘BOOLEAN’ 布尔型 Enum ‘ENUM’ 数据字典 Map ‘MAP’ 键值对 Related ‘RELATED’ 引用类型 OneToOne ‘O2O’ 一对一 OneToMany ‘O2M’ 一对多 ManyToOne ‘M2O’ 多对一 ManyToMany ‘M2M’ 多对多 模型动作 属性名 类型 描述 name string…

    2024年9月21日
    2.0K00
  • 如何在多标签页切换时自动刷新视图

    在日常项目中,常常会遇到多视图(Multi-View)标签的场景,用户在切换不同视图时,可能需要刷新当前活动标签内的视图数据或状态。本文将详细解析下面这段代码,并说明如何利用它在视图切换时刷新对应的视图。 下列代码写在ss-boot里面的main.ts import { VueOioProvider } from '@kunlun/dependencies'; import { delay } from 'lodash-es'; VueOioProvider( { … 自己的配置 }, [ () => { setTimeout(() => { subscribeRoute( (route) => { const page = route.segmentParams.page || {}; // 如果不是表格类型,则不刷新(根据自己的需求判断) if (page.viewType !== ViewType.Table) { return; } const { model, action } = page; const multiTabsManager = MultiTabsManager.INSTANCE; delay(() => { const tab = multiTabsManager.getActiveTab(); if (tab?.key && tab.stack.some((s) => s.parameters?.model === model && s.parameters?.action === action)) { multiTabsManager.refresh(tab.key); } }, 200); }, { distinct: true } ); }, 1000); } ] ); 1. VueOioProvider 及其作用 首先,代码通过 VueOioProvider 初始化应用程序或组件,并传入两部分参数: 配置对象:可以根据实际业务需求进行自定义配置; 回调函数数组:这里传入了一个匿名函数,用于在应用初始化后执行额外的逻辑 2. 延时执行与路由监听 在回调函数中,使用了 setTimeout 延时 1000 毫秒执行,目的通常是为了确保其他组件或全局状态已经初始化完毕,再开始进行路由监听。 随后,代码调用 subscribeRoute 来监听路由的变化。subscribeRoute 接收两个参数: 回调函数:每次路由变化时都会触发该函数,并将最新的 route 对象传递给它; 配置对象:此处使用 { distinct: true } 来避免重复的触发,提高性能。 3. 判断视图类型 在路由回调函数内部,首先通过 route.segmentParams.page 获取当前页面的配置信息。通过判断 page.viewType 是否等于 ViewType.Table,代码可以确定当前视图是否为“表格类型”: 如果不是表格类型:则直接返回,不做刷新操作; 如果是表格类型:则继续执行后续刷新逻辑。 这种判断机制保证了只有特定类型的视图(例如表格)在切换时才会触发刷新,避免了不必要的操作 4. 多视图标签的刷新逻辑 当确认当前视图为表格类型后,从 MultiTabsManager 中获取当前活动标签: MultiTabsManager.INSTANCE.getActiveTab() 返回当前活动的标签对象; 如果 key 存在,并且激活的标签内部存储的action跟url一致, 就调用 multiTabsManager.refresh(key) 方法来刷新当前标签内的视图。

    2025年3月13日
    1.5K00
  • 【路由】浏览器地址栏url参数介绍

    介绍 浏览器地址栏url为路由类型的视图动作(viewAction)的访问url 详情页示例url https://one.oinone.top/page;module=resource;viewType=DETAIL;model=resource.ResourceDistrict;action=redirectDetailPage;scene=redirectDetailPage;target=ROUTER;menu=%7B%22selectedKeys%22:%5B%22%E5%8C%BA%22%5D,%22openKeys%22:%5B%22%E5%9C%B0%E5%9D%80%E5%BA%93%22,%22%E5%9C%B0%E5%8C%BA%22%5D%7D;id=575733837679260950;path=%2Fresource%2F%E5%8C%BA%2FACTION%23resource.ResourceDistrict%23redirectDetailPage 通过调试工具查看解析后的信息 参数介绍 module 动作所在模块名称 viewType 视图类型 model 动作所在模型的编码 action 动作名称 target 动作打开方式,ROUTER为当前路由打开,OPEN_WINDOW为新窗口打开 menu 【选填】菜单栏控制参数,该参数不影响页面的业务逻辑,仅影响菜单栏展开哪些菜单项(通过openKeys属性),选中哪些菜单项(通过selectedKeys属性)),该参数经过JSON.stringify(menu)方式处理过 # 示例参数 { "selectedKeys": ["区"], "openKeys": ["地址库", "地区"] } id 【选填】详情、编辑等单行数据页面的数据id searchBody 列表页搜索区域的搜索条件,该参数在前端经过encodeURIComponent(JSON.stringify(searchBody))方式处理过 # 示例参数 { "code": "11" } searchConditions 列表页高级搜索条件,用于处理searchBody之外的复杂搜索条件,日常开发中无需关心该参数encodeURIComponent(JSON.stringify(searchConditions))方式处理过 # 示例参数 [ { "leftValue":["sourceType"], "operator":"==", "right":"GD" } ] context 上下文参数,该参数经过JSON.stringify(menu)方式处理过 列表页的此参数会填充到搜索区域的字段中作为默认的查询条件,详情 详情页和表单页此参数会作为页面加载函数的入参 # 示例参数 { “cateId”: “61723712399821” } path 权限验证路径,父页面编译的时候自动加上该参数,在父页面点击当前动作的时候会自动拼该参数 scene 【选填】动作场景值 代码中如何获取 这里介绍在组件内如何获取 import { BaseElementWidget } from ‘@kunlun/dependencies’; export class DemoElementWidget extends BaseElementWidget { protected test() { const { module, model, action } = this.urlParameters; } } 推荐阅读相关文档 上下文在字段和动作中的应用 如何实现页面间的跳转

    2024年8月19日
    4.4K00
  • oinone的rsql与传统sql语法对照表

    rsql sql 描述 field01 == "name" field01 = "name" 等于 field01 != "name" field01 != "name" 不等于 field01 =gt= 1 field01 > 1 大于 field01 =ge= 1 field01 >= 1 大于等于 field01 =lt= 1 field01 < 1 小于 field01 =le= 1 field01 <= 1 小于等于 field01 =isnull=true field01 is null 字段为null field01 =notnull= 1 field01 is not null 字段不为null field01 =in= ("foo") field01 in ("foo") 多条件 field01 =out= ("foo") field01 not in ("foo") 不在多条件中 field01 =cole= field02 field01 = field02 字段作为查询参数 field01 =colnt= field02 field01 != field02 字段作为查询参数 field01 =like="foo" field01 like "%foo%" 全模糊匹配,rsql语法中无需拼接通配符”%“ field01 =starts="foo" field01 like "foo%" 前缀模糊匹配,rsql语法中无需拼接通配符”%“ field01 =ends="foo" field01 like “%foo" 后缀模糊匹配,rsql语法中无需拼接通配符”%“ field01 =notlike="foo" field01 not like "%foo%" 全模糊不匹配,rsql语法中无需拼接通配符”%“ field01 =notstarts="foo" field01 not like "foo%" 前缀模糊不匹配,rsql语法中无需拼接通配符”%“ field01 =notends="foo" field01 not like “%foo" 后缀模糊不匹配,rsql语法中无需拼接通配符”%“ field01 =has=(ENUM_NAME1, ENUM_NAME2) 有多值枚举中的几个值 field01 =hasnt=(ENUM_NAME1,ENUM_NAME2) 没有多值枚举中的几个值 field01 =bit=ENUM_NAME1 有二进制枚举中的单个值 field01 =notbit=ENUM_NAME1 没有二进制枚举中的单个值 前端代码中使用工具类拼接rsql 该工具类在oinone的前端基础框架中提供 import { Condition } from '@kunlun/dependencies'; const rsqlCondition = new Condition('field01').equal('foo') .and(new Condition('field02').in(['bar'])) .and(new Condition('field03').notIn(['foo'])) .or(new Condition('field04').greaterThanOrEuqalTo(12)) .or(new Condition('field05').like('foo')) .or(new Condition('field06').notStarts('bar')) .or(new Condition('field07').isNull()) .or(new Condition('field08').notNull()) .and(new Condition('field09').bitEqual('BIT_ENUM_1')) .and(new Condition('field10').bitNotEqual('BIT_ENUM_2')) .and(new Condition('field11').has('ENUM_NAME_1')) .and(new Condition('field12').hasNot(['ENUM_NAME_2', 'ENUM_NAME_3'])); const rsqlStr = rsqlCondition.toString();…

    2023年11月1日
    4.5K00
  • OioNotification 通知提醒框

    全局展示通知提醒信息。 何时使用 在系统四个角显示通知提醒信息。经常用于以下情况: 较为复杂的通知内容。 带有交互的通知,给出用户下一步的行动点。 系统主动推送。 API OioNotification.success(title,message, config) OioNotification.error(title,message, config) OioNotification.info(title,message, config) OioNotification.warning(title,message, config) config 参数如下: 参数 说明 类型 默认值 版本 duration 默认 3 秒后自动关闭 number 3 class 自定义 CSS class string –

    2023年12月18日
    1.1K00

Leave a Reply

Please Login to Comment