软件开发模板化很香,但真到复杂业务就崩了——Oinone 有它的答案
很多人第一次接触 Oinone,总会好奇地问: “你们有没有 ERP 模板?有没有现成的业务场景?” 我们常常笑着摇头。 不是因为没有,而是因为不想。 从第一天起,Oinone 就不是一个“提供现成答案”的产品。 我们更像是一把锋利的工具。 要造船?还是造飞机?我们不替你决定。我们只把最坚实的钢铁交到你手里,让你去发挥想象力。 这种选择,让我们的边界变得清晰: ——我们不去套 ERP 的模板,因为业务像潮水,永远在变化; ——我们只专注打磨工具,因为自由,才是最珍贵的价值。 我们憧憬的未来,也不是一个摆满“数式 Oinone 官方应用”的商店,而是一个热闹的集市: 无数合作伙伴把自己独特的应用摆上来,彼此碰撞,生态由此生长。 我们一直坚信: • 业务不会停留在今天,模板永远追不上变化 • 真正的价值,不是给你预设答案,而是给你自由选择 • 生态不是一家厂商的独角戏,而是伙伴们的共舞 这一理念,早已渗透进 Oinone 的每一行代码里。 比如最近上线的 自定义表格:不仅能合并单元格、分组表头,更重要的是,它证明了平台的开放性——当原生功能还未覆盖到你的业务时,你依旧能自由扩展,完成独属于自己的定制化实现。 这,才是 Oinone 一直想做的事。 ⚡ 直达演示环境 ☕ 账号:admin ☕ 密码:admin 本文将讲解如何通过自定义实现表格支持单元格合并和表头分组。 点击下载对应的代码 在学习该文章之前,你需要先了解: 1: 自定义视图 2: 自定义视图、字段只修改 UI,不修改数据和逻辑 3: 自定义视图动态渲染界面设计器配置的视图、动作 1. 自定义 widget 创建自定义的 MergeTableWidget,用于支持合并单元格和表头分组。 // MergeTableWidget.ts import { BaseElementWidget, SPI, ViewType, TableWidget, Widget, DslRender } from '@kunlun/dependencies'; import MergeTable from './MergeTable.vue'; @SPI.ClassFactory( BaseElementWidget.Token({ viewType: ViewType.Table, widget: 'MergeTableWidget' }) ) export class MergeTableWidget extends TableWidget { public initialize(props) { super.initialize(props); this.setComponent(MergeTable); return this; } /** * 表格展示字段 */ @Widget.Reactive() public get currentModelFields() { return this.metadataRuntimeContext.model.modelFields.filter((f) => !f.invisible); } /** * 渲染行内动作VNode */ @Widget.Method() protected renderRowActionVNodes() { const table = this.metadataRuntimeContext.viewDsl!; const rowAction = table?.widgets.find((w) => w.slot === 'rowActions'); if (rowAction) { return rowAction.widgets.map((w) => DslRender.render(w)); } return null; } } 2. 创建对应的 Vue 组件 定义一个支持合并单元格与表头分组的 Vue 组件。 <!-- MergeTable.vue --> <template> <vxe-table border height="500" :column-config="{ resizable: true }" :merge-cells="mergeCells" :data="showDataSource" @checkbox-change="checkboxChange" @checkbox-all="checkedAllChange" > <vxe-column type="checkbox" width="50"></vxe-column> <!-- 渲染界面设计器配置的字段 --> <vxe-column v-for="field in currentModelFields" :key="field.name" :field="field.name" :title="field.label" ></vxe-column> <!-- 表头分组 https://vxetable.cn/v4.6/#/table/base/group --> <vxe-colgroup title="更多信息"> <vxe-column field="role" title="Role"></vxe-column> <vxe-colgroup title="详细信息"> <vxe-column field="sex" title="Sex"></vxe-column> <vxe-column field="age" title="Age"></vxe-column> </vxe-colgroup> </vxe-colgroup> <vxe-column title="操作" width="120"> <template #default="{ row, $rowIndex }"> <!-- 渲染界面设计器配置的行内动作 --> <row-action-render :renderRowActionVNodes="renderRowActionVNodes" :row="row" :rowIndex="$rowIndex" :parentHandle="currentHandle" ></row-action-render> </template> </vxe-column> </vxe-table> <!-- 分页 --> <oio-pagination :pageSizeOptions="pageSizeOptions" :currentPage="pagination.current" :pageSize="pagination.pageSize" :total="pagination.total" show-total :showJumper="paginationStyle != ListPaginationStyle.SIMPLE" :showLastPage="paginationStyle != ListPaginationStyle.SIMPLE" :onChange="onPaginationChange" ></oio-pagination> </template> <script lang="ts"> import { defineComponent, PropType, ref } from 'vue'; import { CheckedChangeEvent } from '@kunlun/vue-ui'; import { ActiveRecord, ActiveRecords, ManualWidget, Pagination, RuntimeModelField } from '@kunlun/dependencies'; import { ListPaginationStyle, OioPagination, OioSpin, ReturnPromise } from '@kunlun/vue-ui-antd'; import RowActionRender from './RowActionRender.vue'; export default defineComponent({ mixins: [ManualWidget], components: { OioSpin, OioPagination, RowActionRender }, inheritAttrs: false, props: { currentHandle: { type: String, required: true }, // loading loading: { type: Boolean, default: undefined }, // 表格展示的数据 showDataSource: { type: Array as PropType<ActiveRecord[]> }, // 分页 pagination: { type: Object as PropType<Pagination>, required: true }, pageSizeOptions: { type: Array as PropType<(number | string)[]>, required: true }, paginationStyle: { type: String as PropType<ListPaginationStyle> }, // 修改分页 onPaginationChange: { type: Function as PropType<(currentPage: number, pageSize: number) => ReturnPromise<void>> }, // 表格选中 onCheckedChange: { type: Function as PropType<(data: ActiveRecords, event?: CheckedChangeEvent) => void> }, // 表格全选 onCheckedAllChange: { type: Function as PropType<(selected: boolean, data: ActiveRecord[], event?: CheckedChangeEvent) => void> }, // 展示字段 currentModelFields: { type: Array as PropType<RuntimeModelField[]> }, // 渲染行内动作 renderRowActionVNodes: { type: Function as PropType<(row: any) => any>, required: true } }, setup(props, ctx) { /** * 单元格合并 * https://vxetable.cn/v4.6/#/table/advanced/span */ const mergeCells = ref([ { row: 1, col: 1, rowspan: 3, colspan: 3 }, { row: 5, col: 0, rowspan: 2, colspan: 2 } ]); // 单选 const checkboxChange = (e) => { const { checked, record, records } = e; const event: CheckedChangeEvent = { checked, record, records, origin: e }; props.onCheckedChange?.(records, event); }; // 全选 const checkedAllChange = (e) => { const { checked, record, records } = e; const event: CheckedChangeEvent = { checked, record, records, origin: e }; props.onCheckedAllChange?.(checked, records, event); }; return { mergeCells, ListPaginationStyle, checkboxChange, checkedAllChange }; } }); </script> <style lang="scss"></style> 3. 创建行内动作 <script lang="ts"> import { ActionBar, RowActionBarWidget } from '@kunlun/dependencies'; import { debounce } from 'lodash-es'; import { createVNode, defineComponent } from 'vue'; export default defineComponent({ inheritAttrs: false, props: { row: { type: Object, required: true }, rowIndex: { type: Number, required: true }, renderRowActionVNodes: { type: Function, required: true }, parentHandle: { type: String, required: true } }, render() { const vnode = this.renderRowActionVNodes(); return createVNode( ActionBar, { widget: 'rowAction', parentHandle: this.parentHandle, inline: true, activeRecords: this.row, rowIndex: this.rowIndex, key: this.rowIndex, refreshWidgetRecord: debounce((widget?: RowActionBarWidget) => { if (widget) { widget.setCurrentActiveRecords(this.row); } }) }, { default: () => vnode } ); } }); </script> 4. 注册布局 // registry.ts import { registerLayout, ViewType } from '@kunlun/dependencies'; registerLayout( `<view type="TABLE"> <pack widget="group"> <view type="SEARCH"> <element widget="search" slot="search" slotSupport="field"> <xslot name="searchFields" slotSupport="field" /> </element> </view> </pack> <pack widget="group" slot="tableGroup"> <element widget="actionBar" slot="actionBar" slotSupport="action"> <xslot name="actions" slotSupport="action" /> </element> <element widget="MergeTableWidget" slot="table" slotSupport="field"> <element widget="expandColumn" slot="expandRow" /> <xslot name="fields" slotSupport="field" /> <element widget="rowActions" slot="rowActions" slotSupport="action" /> </element> </pack> </view>`, { model: '模型', viewType: ViewType.Table, actionName: '动作名称' } ); 通过上述步骤,自定义表格可以实现单元格合并和表头分组功能,同时支持动态渲染界面设计器配置的字段和动作。 这个布局定义包含了: 顶部搜索区域 表格操作栏 我们的自定义MergeTableWidget 行内动作插槽 技术解析 通过这个案例,我们可以看到Oinone的几个核心技术特点: 扩展性:通过SPI机制和Widget体系,可以轻松扩展原生组件功能 组合性:各个组件通过插槽(slot)机制灵活组合 动态性:视图和字段可以通过DSL动态配置 一致性:保持UI交互和数据流管理的统一模式 结语 回到最初的问题:为什么Oinone不提供现成的ERP模板?因为我们认为,真正的企业级应用需要的是可以自由组合的构建块,而非固化的模板。本文展示的自定义表格实现,只是Oinone强大扩展能力的一个缩影。 在Oinone的生态中,每个开发者都可以: • 创建自己的业务组件 • 定义领域特定的DSL • 贡献可复用的解决方案 这才是Oinone想要构建的未来——不是由我们提供所有答案,而是提供一个让所有开发者都能自由创造的工具平台。