数据绑定
Designer 里的数据绑定,本质上是在做一件很朴素的事:把模板里的某个属性,连到运行时数据里的某条路径上。
如果你先把“设计时字段树”和“运行时数据”分开理解,这一层就不会绕。
字段树定义
Designer 接收的是 DataSourceDescriptor[]。它决定左侧数据源面板长什么样,也决定用户能拖哪些字段。
import type { DataSourceDescriptor } from '@easyink/designer'
const dataSources: DataSourceDescriptor[] = [
{
id: 'order',
name: '订单数据',
fields: [
{ name: 'orderNo', path: 'orderNo', title: '订单号', use: 'text' },
{
name: 'customer',
path: 'customer',
title: '客户',
fields: [
{ name: 'name', path: 'customer/name', title: '客户名称', use: 'text' },
{ name: 'phone', path: 'customer/phone', title: '联系电话', use: 'text' },
],
},
{
name: 'items',
path: 'items',
title: '商品列表',
tag: 'collection',
fields: [
{ name: 'name', path: 'items/name', title: '商品名称', use: 'text' },
{ name: 'qty', path: 'items/qty', title: '数量', use: 'text' },
],
},
],
},
]再把它传给 Designer:
<EasyInkDesigner
v-model:schema="schema"
:data-sources="dataSources"
/>上面这一步只是把字段树交给 Designer。它还没有产生任何运行时值。
JSON 转数据源
如果你手上已经有一份业务 JSON,可以直接在这里转成 DataSourceDescriptor 结构。生成后把右侧结果复制到你的数据源配置里,再按业务语义补充或调整字段标题即可。
{
"id": "custom",
"name": "custom",
"title": "自定义数据",
"expand": true,
"fields": []
}哪些字段能拖拽
当前 Designer 的字段树有一个明确规则:叶子字段可以拖拽;带 union 的分组字段也可以拖拽。
const dataSources: DataSourceDescriptor[] = [
{
id: 'order',
name: '订单数据',
fields: [
{
name: 'items',
path: 'items',
title: '商品列表',
fields: [
{ name: 'name', path: 'items/name', title: '商品名称', use: 'text' },
],
},
{
name: 'summary',
path: 'summary',
title: '摘要组合',
fields: [
{ name: 'orderNo', path: 'orderNo', title: '订单号', use: 'text' },
{ name: 'grandTotal', path: 'grandTotal', title: '合计金额', use: 'text' },
],
union: [
{ path: 'orderNo', title: '订单号', use: 'text' },
{ path: 'grandTotal', title: '合计金额', use: 'text', offsetY: 8 },
],
},
],
},
]上面这个例子里,items 只是分组节点,默认负责展开子字段;summary 虽然也可以带子语义,但因为配置了 union,Designer 会允许它作为“一次拖拽生成多个绑定节点”的入口。
拖拽处理
先别急着想 Viewer。用户在画布里拖一个字段时,Designer 做的其实是把绑定信息写进模板。
可以把过程理解成这样:
字段树里的一个 field
-> 拖到元素上
-> Designer 把 binding 写进对应节点
-> schema 持久化下来这也是为什么绑定是模板的一部分,而不是运行时的一次性状态。
普通绑定与 Data Contract
大多数基础物料继续使用普通 BindingRef:
const textNode = {
type: 'text',
props: { content: '' },
binding: {
sourceId: 'order',
fieldPath: 'customer/name',
fieldLabel: '客户名称',
},
}这类绑定适合“一个字段值投影到一个 props”的场景,例如文本、图片、条码、二维码。这个字段值不要求一定是标量;物料如果声明 primaryProp: 'option',也可以让数据源直接返回对象或 JSON 字符串,并由物料在 Viewer 中解释为完整配置。普通 BindingRef schema 继续可用。
chart-bar 这类结构化物料使用 data-contract。它的重点不是“字段按第几个槽位绑定”,而是:
- 物料声明目标数据模型,例如柱状图需要
category和value。 - 用户拖拽字段时,只是在填写“目标字段从哪个 source path 来”。
- Viewer 运行时由 Resolver 判断这些字段如何组成 records。
柱状图的节点 binding 示例:
const chartNode = {
type: 'chart-bar',
props: {
barColor: '#2563eb',
backgroundColor: '#ffffff',
},
binding: {
kind: 'data-contract',
mappings: {
category: {
sourceId: 'report',
select: { path: 'monthlySales/month', label: '月份' },
},
value: {
sourceId: 'report',
select: { path: 'monthlySales/revenue', label: '销售额' },
},
},
relation: { kind: 'auto' },
},
}注意这里保存的是完整 select.path。即使 Resolver 后续要从 monthlySales 这个集合里读 month,schema 里也不会把 monthlySales/month 截成 month。
Data Contract 的运行时解析
data-contract 的默认关系是 relation: { kind: 'auto' }。它不会把解析模式写进 binding,而是在 Viewer 中根据 runtime data 推导:
viewer.open({
schema,
data: {
monthlySales: [
{ month: '1月', revenue: 98 },
{ month: '2月', revenue: 112 },
],
},
})当 category 指向 monthlySales/month,value 指向 monthlySales/revenue 时,Resolver 会发现它们共享 monthlySales 这个数组父级,于是按 record collection 解析。
如果源数据是两个顶层数组:
viewer.open({
schema,
data: {
category: ['1月', '2月'],
values: [98, 112],
},
})对应 binding 可以写成:
binding: {
kind: 'data-contract',
mappings: {
category: { sourceId: 'report', select: { path: 'category' } },
value: { sourceId: 'report', select: { path: 'values' } },
},
relation: { kind: 'auto' },
}Resolver 会按 index 对齐,生成两条目标记录。
如果你的运行时数据按 sourceId 分包,也可以这样传:
viewer.open({
schema,
data: {
report: {
monthlySales: [
{ month: '1月', revenue: 98 },
{ month: '2月', revenue: 112 },
],
},
},
})Resolver 会优先尝试 data[sourceId]。但只有当完整 path 或它的父级集合能在 data[sourceId] 下解析时,才会使用这个 source-scoped root;否则会回退到全局 data 根。这样既支持按 sourceId 分组的数据,也支持真实数据直接放在顶层的输入。
设计态不会把“同集合”“顶层数组”“不同集合”“record mode”“index mode”暴露给用户。拖拽只负责填 mapping,关系判断属于 Resolver。
fieldPath 约定
到了运行时,Viewer 会统一解析节点上的绑定。
这里最重要的结论是:Viewer 按 fieldPath 从传入的 data 根对象里取值,不会再去看 Designer 的字段树。
先看一个最小心智模型:
const data = {
customer: {
name: 'Ada',
},
}如果节点上的绑定路径是 customer/name,Viewer 就会去读这条路径对应的值。
你可能会注意到普通绑定里还有 sourceId、sourceName 之类的信息。它们有用,但作用主要在设计时元数据和界面提示,不参与普通 BindingRef 的运行时根数据选择。
fieldPath 使用 / 分隔。解析时会从运行时 data 根对象开始逐段读取,也会避开 __proto__、constructor 这类危险路径。
稳定绑定约定
写模板时,Designer 关心的是:
- 字段树怎么展示
- 字段拖到哪里
- 绑定信息怎么写回 Schema
渲染模板时,Viewer 关心的是:
- 当前节点有哪些绑定
- 这些绑定的
fieldPath指向哪里 - 运行时
data里能不能取到值
这意味着一件很重要的事:预览、打印和导出时,不要再把 dataSources 传给 Viewer。
对于 data-contract 物料也是一样:Viewer 只消费 schema 里的 DataContractBinding 和运行时 data,不读取 Designer 的字段树。字段树只帮助用户在设计时找到字段。
常用字段
第一次接入时,最常用的是这些:
| 字段 | 作用 |
|---|---|
name | 内部名称 |
path | 绑定路径 |
title | 面板显示名称 |
fields | 子字段 |
use | 推荐的物料用途 |
tag | 集合等语义提示 |
你当然还能继续往里放 format、bindIndex、union 之类更细的能力,但如果你现在只是先把绑定跑通,这些基础字段就足够了。
默认显示格式
如果某个字段每次拖出去都应该按同一种方式展示,可以直接在字段上写 format。
const dataSources: DataSourceDescriptor[] = [
{
id: 'invoice',
name: '发票数据',
fields: [
{
name: 'grandTotal',
path: 'grandTotal',
title: '合计金额',
use: 'text',
format: {
prefix: '¥',
mode: 'preset',
preset: {
type: 'number',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
},
},
},
],
},
]用户把 grandTotal 拖到文本、表格单元格或其他可绑定物料上时,Designer 会把这份格式复制到绑定里。
这里的关键词是“复制”。后面用户在属性面板里改显示格式,改的是模板里的 BindingRef.format,不会反写你的字段树。
属性面板是否显示“预设”或“自定义”格式 tab,由物料注册时的 binding.formatEditor 决定,而不是由字段树决定。例如文本可以开放 preset/custom,自定义 SVG 和 chart 类物料通常只开放 custom:
const textBinding = {
kind: 'ordinary',
primaryProp: 'content',
formatEditor: { tabs: ['preset', 'custom'], defaultTab: 'preset' },
}
const svgBinding = {
kind: 'ordinary',
primaryProp: 'content',
formatEditor: { tabs: ['custom'], defaultTab: 'custom' },
}
const chartBinding = {
kind: 'data-contract',
contract: CHART_BAR_DATA_CONTRACT,
formatEditor: { tabs: ['custom'], defaultTab: 'custom' },
}data-contract 的 custom formatter 会在 Resolver 生成目标 records 前应用;预设格式只有在物料明确声明支持时才应开放。
字段函数模板
有时你不想直接给字段写死 format,而是想改变“自定义函数”里的默认示例。比如金额字段默认给一个金额函数,日期字段默认给一个中文日期函数。
这时把模板函数放到具体字段的 displayFormat.customTemplates 里:
const dataSources: DataSourceDescriptor[] = [
{
id: 'invoice',
name: '发票数据',
fields: [
{
name: 'grandTotal',
path: 'grandTotal',
title: '合计金额',
use: 'text',
displayFormat: {
defaultCustomTemplateId: 'invoice-money',
customTemplates: [
{
id: 'invoice-money',
label: '发票金额',
hint: '金额保留两位小数并添加人民币符号',
source: `function transform(value, data) {
var num = Number(value)
if (isNaN(num)) return ''
return '¥' + num.toFixed(2)
}`,
},
{
id: 'invoice-money-total',
label: '合计金额',
hint: '金额保留两位小数并加上合计前缀',
source: `function transform(value, data) {
var num = Number(value)
if (isNaN(num)) return ''
return '合计 ¥' + num.toFixed(2)
}`,
},
],
},
},
{
name: 'date',
path: 'invoice/date',
title: '开票日期',
use: 'text',
displayFormat: {
defaultCustomTemplateId: 'invoice-date-cn',
customTemplates: [
{
id: 'invoice-date-cn',
label: '中文开票日期',
source: `function transform(value, data) {
if (value == null || value === '') return ''
var text = String(value)
var match = text.match(/^(\\d{4})-(\\d{2})-(\\d{2})/)
if (!match) return text
return match[1] + '年' + match[2] + '月' + match[3] + '日'
}`,
},
],
},
},
],
},
]用户选中 grandTotal 这个字段产生的绑定,再打开“显示格式”,切到“自定义”时,默认示例会被字段自己的函数替换。字段可以只有一个模板,也可以有多个模板;有多个时,Designer 会优先采用 defaultCustomTemplateId 指向的那一个。自定义函数接收两个参数:value 是当前字段值,data 是当前 Viewer 正在消费的完整运行时数据。
原来的内置示例不会全部消失。字段模板只替换“默认转换函数”这一类入口,后面的“原始值转字符串”“数值格式化为货币”“日期格式化 YYYY-MM-DD”等示例仍然保留,用户随时可以切回去。
你可能会有个疑问:为什么保存的是函数内容,而不是模板 ID?
原因是模板要能脱离 Designer 和字段树独立渲染。用户点确定后,最终写进 Schema 的仍然是 BindingRef.format.custom.source。之后字段树里的模板函数变了,已经保存的模板不会被悄悄改写。
在线试一下
Playground 默认示例里的“流式发票”已经给发票金额、开票日期、税率这些字段加了各自的函数模板。打开示例后选中对应绑定字段,在属性面板里进入“显示格式”并切到“自定义”,就能看到字段自己的默认示例,同时还能看到其余内置示例。
绑定判断标准
当你调试绑定问题时,可以按这个顺序查:
- Designer 里字段树是不是正确展示了。
- 拖拽之后,Schema 里是不是写进了绑定信息。
- 运行时传给 Viewer 的
data,是不是能命中相同的fieldPath。
如果第三步对不上,再怎么改 Designer 侧字段树都不会生效。