Appearance
FinFormulaEditor 公式编辑器
FinFormulaEditor 是以 AST 为唯一事实来源的可视化公式编辑器。组件负责资源选择、结构化编辑、本地校验、服务端校验、草稿导出和正式提交准备,不在浏览器中执行公式。
当前版本不提供源码编辑模式。公式文本只用于调试展示和复制,不能用于持久化或历史回显。
基础用法
loading
演示中的“接口”由本地业务数据经 Promise 返回;接入真实项目时只需把模拟查询函数替换为接口请求,保留资源映射和文档协议。
核心数据流
公式从创建到提交遵循单一流程:
- 业务方通过推荐的
sources声明资源;高级场景也可直接提供左右面板、目录和解析器。 - 用户选择资源并通过节点菜单组合 AST。
v-model输出完整MathFormulaDocument。- 历史回显时,组件通过
resolver.resolveMany()按稳定身份批量恢复资源状态。 validateLocal()执行结构、类型、资源和复杂度校验。validateServer()把完整文档、修订号、协议版本和规则版本提交给服务端。saveDraft()输出可安全持久化的草稿。prepareSubmit()仅在当前修订同时通过本地与服务端校验后输出正式文档。
编辑器交互
- 从左侧选择字段、参数、常量,或从右侧选择函数和内置运算符;先选择画布中的目标节点,再进行结构化插入。运算符由组件生成语法结构,函数按签名生成参数槽,并校验参数类型及返回类型。
- 普通选择资源不会静默覆盖已存在内容的节点。将鼠标移入非占位节点,点击节点下方的“替换”入口,再选择新字段、参数或函数;占位节点可直接填充。替换复合表达式时需要确认,替换状态可取消;只读和禁用状态不允许修改公式。
- 在画布空白或公式节点区域按住鼠标左键拖动可平移;画布获得焦点后也可使用方向键平移,横向滚轮可浏览长公式。普通纵向滚轮仍以指针为中心缩放,范围为 50%~200%。画布右侧提供放大、缩小、比例和复位视图工具;复位只还原视角,不会清空公式。节点单击选择、资源拖拽插入以及只读或禁用状态下的画布浏览保持可用。
- 下方“有效公式文本”只用于展示和复制;编辑区右上角“查看源码”展示并复制完整的公式文档 JSON。两处均不开放自由源码编辑,保存与回显必须使用完整的
MathFormulaDocument。
公式文档
modelValue 不再是裸 AST,而是版本化公式文档:
ts
import { createMathFormulaDocument } from '@axewo/fin-formula-editor'
const document = createMathFormulaDocument({
ruleVersion: 'finance-rule-v1',
expectedType: 'number',
})| 字段 | 说明 |
|---|---|
schemaVersion | 公式协议版本;未知版本会被拒绝 |
status | draft 或 committed |
root | AST 根节点;空公式为 null |
ruleVersion | 前后端共同使用的规则版本 |
expectedType | 可选的期望返回类型 |
editRevision | 语义变化时递增,用于绑定服务端校验结果 |
外部 JSON 必须先经过 decodeMathFormulaDocument(input)。解码器会检查字段、节点类型、字面量格式、资源身份、函数身份、子节点数量和全树节点标识唯一性。
资源身份
引用节点固定保存稳定身份和显式映射的展示、执行快照:
json
{
"id": "reference_1",
"type": "Reference",
"sourceId": "finance-fields",
"resourceId": "revenue",
"resourceKind": "field",
"path": "income.revenue",
"version": "2026-01",
"label": "营业收入",
"snapshot": {
"valueType": "number",
"nullable": false,
"execution": {
"tableId": "10",
"fieldId": "101",
"fieldCode": "revenue"
}
}
}sourceId + resourceKind + resourceId + version组成资源目录身份。path可表达资源内部路径,但服务端不能把客户端路径直接拼接成查询语句。label只用于展示;snapshot会随 AST 持久化,可保存经过白名单映射的表标识、字段编码和类型等业务解析线索;资源目录的metadata只用于展示,不会自动进入 AST。- 后端必须按稳定身份和当前认证上下文重新解析资源,不得把客户端
snapshot.execution中的表名、字段编码或path直接拼接到 SQL。标识符应由服务端可信目录校验并映射。 snapshot只允许普通 JSON 数据,禁止复制完整业务 DTO、令牌、请求头或权限对象。
第三方资源映射
使用 createMathResourceAdapter() 在接口边界显式映射白名单字段:
ts
const adaptFields = createMathResourceAdapter<BusinessField>({
sourceId: 'finance-fields',
resourceId: 'code',
resourceKind: 'field',
version: ({ item }) => item.version,
label: 'name',
valueType: ({ item }) => item.dataType === 'decimal' ? 'number' : 'string',
path: 'path',
snapshot: ({ item }) => ({
valueType: item.dataType === 'decimal' ? 'number' : 'string',
nullable: item.nullable,
}),
})适配器不会发送请求,也不会自动复制原始对象。
推荐:统一资源源
常规接入只需声明业务资源来源。组件会按资源类型自动生成左右面板、搜索、分页、动态目录和组合解析器:
ts
import { defineMathResourceSource } from '@axewo/fin-formula-editor'
interface BusinessField {
id: string
tableId: string
code: string
name: string
version: string
}
// 文档演示:使用内存数据模拟远程接口,接入业务时替换为真实请求。
const fieldData: BusinessField[] = [
{ id: '101', tableId: '10', code: 'revenue', name: '营业收入', version: '1' },
{ id: '102', tableId: '10', code: 'cost', name: '营业成本', version: '1' },
]
const fieldSource = defineMathResourceSource<BusinessField>({
sourceId: 'finance-fields',
resourceKind: 'field',
label: '字段',
dataSource: {
mode: 'remote',
load: ({ query, pagination, signal }) => Promise.resolve().then(() => {
signal.throwIfAborted()
const matches = fieldData.filter(item => item.name.includes(query.trim()))
if (pagination.mode !== 'page') return { items: matches, hasMore: false }
const start = (pagination.page - 1) * pagination.pageSize
return {
items: matches.slice(start, start + pagination.pageSize),
total: matches.length,
hasMore: start + pagination.pageSize < matches.length,
}
}),
},
map: ({ item }) => ({
resourceId: item.code,
version: item.version,
label: item.name,
valueType: 'number',
snapshot: { execution: { tableId: item.tableId, fieldId: item.id, fieldCode: item.code } },
}),
loadDependencies: (dependencies, signal) => Promise.resolve().then(() => {
signal.throwIfAborted()
return fieldData.filter(item => dependencies.some(dependency => dependency.resourceId === item.code))
}),
tab: { searchMode: 'remote', pagination: { mode: 'page', pageSize: 20 } },
})
const sources = [fieldSource]vue
<FinFormulaEditor v-model="document" :sources="sources" />sourceId 和 resourceKind 只在资源源声明一次,map 返回值会自动补齐这两个字段;如果映射结果显式返回了不同身份,组件会立即拒绝。列表加载和 loadDependencies 返回的业务 DTO 共用同一个 map,组件自动判断依赖是可用、缺失还是版本不兼容,业务不需要重复组装 MathResolvedResource。字段、参数、常量默认显示在左侧,函数和运算符默认显示在右侧;可通过 position 和 tab 覆盖展示配置。
历史公式回显不能依赖当前页签是否加载到对应条目。远程资源应使用 loadDependencies 按当前公式依赖批量加载原始 DTO,不能通过 pageSize: -1 扫描全部分页数据。列表加载和依赖加载共用 map,组件会判断缺失和版本不兼容;若需要正确区分版本不兼容,依赖加载应按资源标识查询并返回服务端当前版本,而不是先在客户端用历史版本过滤。后端已直接实现标准解析协议时,可改用高级 resolveMany;配置后它优先于 loadDependencies。资源缓存只存在于当前编辑器实例,默认仅缓存短期 active 结果,不跨用户、租户或组件共享。
演示中的 Promise 只模拟请求形态,不会产生真实网络延迟;真实接口需要响应取消信号。组件会取消过期请求并忽略乱序响应,业务加载器仍应处理取消和服务端错误。
高级:直接配置资源面板
需要完全控制面板结构时,可继续直接使用 MathResourcePanelConfig。显式 leftPanel、rightPanel 会分别替换 sources 生成的对应面板;显式 resolver 会整体替换组合解析器;显式 catalog 最后合并并拥有覆盖权。
左右两侧均使用 MathResourcePanelConfig。每个页签独立维护搜索词、分页、加载状态、错误和滚动位置。
ts
const leftPanel: MathResourcePanelConfig = {
panelId: 'formula-left',
position: 'left',
defaultTabId: 'fields',
tabs: [{
tabId: 'fields',
label: '字段',
resourceKind: 'field',
sourceId: 'finance-fields',
dataSource: {
mode: 'remote',
load: ({ query, pagination, signal }) =>
api.queryFields({ query, pagination }, { signal }),
},
searchMode: 'remote',
pagination: { mode: 'page', pageSize: 20 },
}],
}搜索模式:
| 模式 | 行为 |
|---|---|
local | 在本地完整数据中筛选 |
remote | 搜索词交给远程加载器 |
loaded | 只筛选当前已加载内容,不代表全量搜索 |
none | 不展示搜索入口 |
分页支持页码和游标两种模式。加载器统一接收 AbortSignal,组件会取消过期请求并丢弃乱序响应。
历史回显
历史公式回显不能依赖当前资源页签是否加载到对应条目。推荐在资源源中批量加载原始 DTO,并继续复用列表映射:
ts
const source = defineMathResourceSource<BusinessField>({
sourceId: 'finance-fields',
resourceKind: 'field',
dataSource: { mode: 'remote', load: queryFields },
map: mapField,
loadDependencies: (dependencies, signal) =>
api.queryFieldsByIds(dependencies.map(item => item.resourceId), { signal }),
})只有后端直接返回标准 MathResolvedResource[] 时,才需要高级解析器:
ts
const resolver: MathResourceResolver = {
async resolveMany(dependencies, signal) {
return api.resolveFormulaResources({ dependencies }, { signal })
},
}解析结果状态包括:
active:正常可用;disabled:已停用;missing:资源不存在;forbidden:当前用户无权限;incompatible:版本不兼容。
回显只建立只读状态和诊断,不会静默修改或删除原 AST。
本地与服务端校验
本地校验使用完整文档:
ts
const result = validateMathFormulaDocument(document, {
catalog,
complexity: {
maxNodes: 100,
maxDepth: 20,
maxFunctionCalls: 20,
maxReferences: 50,
maxScore: 300,
},
})服务端校验配置:
ts
const validation = {
serverValidation: {
scenario: 'financial-rule',
businessContext: { reportId: 'report-001' },
timeoutMs: 10_000,
async transport(request, signal) {
return api.validateFormula(request, { signal })
},
},
}请求固定包含:
document:完整公式文档;editRevision:当前编辑修订;schemaVersion:公式协议版本;ruleVersion:规则版本;scenario:业务场景;- 可选
expectedType和businessContext。
服务端响应固定为:
ts
{
validatedRevision: 3,
valid: true,
diagnostics: [],
traceId: 'trace-001',
}响应修订号必须等于请求修订号。公式变化后,旧结果立即失效;网络失败、超时、取消、未授权和协议错误属于请求状态,不能伪装成公式诊断,也不能视为验证通过。
草稿与正式提交
ts
const draft = editorRef.value?.saveDraft()
const committed = await editorRef.value?.prepareSubmit()saveDraft():允许空公式、占位节点、类型错误和失效资源,但文档必须符合协议且可安全序列化。prepareSubmit():要求当前修订通过本地校验,并具有同一修订的服务端通过结果;必要时自动发起服务端校验。- 组件只产生可提交文档,实际持久化由业务方负责。
Props
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
modelValue | MathFormulaDocument | 空草稿 | 版本化公式文档 |
ruleVersion | string | '1' | 空文档初始化和规则切换版本 |
catalog | MathCatalogInput | 内置目录 | 高级目录配置;在 sources 目录后合并并覆盖 |
sources | readonly MathResourceSourceConfig[] | — | 推荐入口;统一声明资源列表、DTO 映射和依赖解析 |
leftPanel | MathResourcePanelConfig | — | 高级左侧面板;覆盖 sources 生成的左面板 |
rightPanel | MathResourcePanelConfig | — | 高级右侧面板;覆盖 sources 生成的右面板 |
resolver | MathResourceResolver | — | 高级历史解析器;整体覆盖 sources 组合解析器 |
validation | Omit<MathValidationOptions, 'catalog'> | — | 本地与服务端校验配置 |
validateOnChange | boolean | true | 文档变化后是否执行本地校验 |
disabled / readonly | boolean | false | 禁用或只读 |
clearable | boolean | true | 是否显示清空按钮 |
maxHistory | number | 100 | 撤销历史上限 |
height / minHeight | number | string | 620 / 480 | 编辑器尺寸 |
placeholder | string | 默认引导文案 | 空画布引导提示 |
locale | string | 全局语言 | 目录展示语言 |
Events
| 事件 | 载荷 | 说明 |
|---|---|---|
update:modelValue | MathFormulaDocument | 文档语义变化 |
change | MathEditorChangePayload | 文档、原因和当前本地校验结果 |
validation-change | MathValidationResult | 本地校验结果变化 |
resource-load | MathResourceLoadEvent | 资源请求开始 |
resource-loaded | MathResourceLoadSuccessEvent | 资源请求成功 |
resource-load-error | MathResourceLoadErrorEvent | 资源请求失败 |
command-blocked | MathCommandBlockedEvent | 编辑命令或资源插入被阻止,包含 reason、可选目标和资源 |
draft | MathDraftResult | 草稿导出成功 |
submit-ready | MathSubmitResult | 当前修订可正式提交 |
focus / blur | FocusEvent | 编辑器焦点事件 |
Exposed API
| 方法 | 说明 |
|---|---|
focus() / blur() | 聚焦或移出画布 |
clear() | 清空根公式 |
undo() / redo() | 撤销或重做,返回是否发生变化 |
canUndo() / canRedo() | 查询历史状态 |
getDocument() | 获取文档深拷贝 |
setDocument(input) | 严格解码并设置外部文档 |
executeCommand(command) | 执行文档级不可变命令 |
selectNode(nodeId?) | 选择或取消选择节点 |
validateLocal() | 执行本地校验 |
validateServer() | 执行结构化服务端校验 |
saveDraft() | 导出草稿文档 |
prepareSubmit() | 准备正式提交文档 |
插槽
header:编辑器头部;left-resource-item/right-resource-item:资源项内容;left-resource-group-label/right-resource-group-label:资源分组标题;left-resource-detail/right-resource-detail:资源详情;diagnostic-item:可信诊断展示;footer:接收当前validation和document。
文本解析与序列化边界
parseMathExpression() 和 serializeMathExpression() 仍作为调试、测试与展示工具保留,但文本不携带完整资源身份。文本解析得到的引用只具有 sourceId: 'text-parser' 的调试身份,不能直接替代业务公式文档。
正式导入使用:
ts
const decoded = decodeMathFormulaDocument(input)
if (!decoded.ok) {
console.table(decoded.issues)
return
}
const stableJson = encodeMathFormulaDocument(decoded.document)持久化时始终保存完整公式文档,不要只保存公式文本。