很多人第一次接触 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想要构建的未来——不是由我们提供所有答案,而是提供一个让所有开发者都能自由创造的工具平台。
</div>