贡献扩展开发
Contribution 用来把宿主能力挂到 Designer 上,而不修改 Designer 源码。
先看一个最小按钮:
import type { Contribution } from '@easyink/designer'
import { IconSparkles } from '@easyink/icons'
export const helloContribution: Contribution = {
id: 'demo.hello',
activate(ctx) {
ctx.registerCommand({
id: 'demo.sayHello',
handler: () => {
console.log(ctx.store.schema.version)
},
})
ctx.registerToolbarAction({
id: 'demo.hello.button',
icon: IconSparkles,
label: 'Say Hello',
onClick: () => {
void ctx.executeCommand('demo.sayHello')
},
})
},
}接入 Designer:
<EasyInkDesigner
v-model:schema="schema"
:contributions="[helloContribution]"
/>这段代码注册了一个命令和一个工具栏按钮。按钮只负责触发命令,真正动作放在命令里。
判断是否需要 Contribution
如果你要加的是这些能力,通常就用 Contribution:
ctx.registerPanel(...)
ctx.registerToolbarAction(...)
ctx.registerCommand(...)
ctx.onDiagnostic(...)它适合扩展 Designer 的外层能力:
- 增加业务面板。
- 增加工具栏动作。
- 定义可复用命令。
- 订阅诊断并转发到宿主系统。
如果你要注册新元素类型,先看 自定义物料开发。物料负责画布节点,Contribution 负责设计器外围能力。
核心接口
Contribution 的形状很小:
interface Contribution {
id: string
activate: (ctx: ContributionContext) => void
}activate() 会在 Designer 初始化 contribution registry 时执行。你应该在这里完成注册和订阅。
ContributionContext 里最常用的是这些入口:
interface ContributionContext {
store: DesignerStore
registerPanel: (panel: PanelDescriptor) => void
registerToolbarAction: (action: ToolbarActionDescriptor) => void
registerCommand: <TArgs, TResult>(command: Command<TArgs, TResult>) => void
executeCommand: <TArgs = unknown, TResult = unknown>(id: string, args?: TArgs) => Promise<TResult>
confirm: (request: DesignerConfirmRequest) => Promise<boolean>
pickAsset: (request: DesignerAssetPickRequest) => Promise<DesignerResolvedAsset | null>
onDispose: (fn: () => void) => void
onDiagnostic: (fn: (entry: Diagnostic) => void) => () => void
}如果只记一条分层原则:面板和按钮负责入口,真正可复用的动作收敛成命令。
ctx.store 还提供 contribution 文案注册能力。外部 contribution 的 UI 文案应放在 contribution 包里注册,不要追加到 @easyink/locales:
const unregister = ctx.store.registerLocaleMessages({
messages: {
designer: {
review: {
toolbar: { label: 'Review' },
},
},
},
locales: {
'zh-CN': {
designer: {
review: {
toolbar: { label: '审阅' },
},
},
},
'en-US': {
designer: {
review: {
toolbar: { label: 'Review' },
},
},
},
},
})
ctx.onDispose(unregister)注册命令
命令适合放可以被多处复用的动作:
ctx.registerCommand<{ prefix: string }, string>({
id: 'demo.renameTemplate',
handler: async (args, ctx) => {
const name = `${args.prefix}-${Date.now()}`
ctx.store.schema.meta = {
...ctx.store.schema.meta,
name,
}
return name
},
})
const name = await ctx.executeCommand<{ prefix: string }, string>(
'demo.renameTemplate',
{ prefix: 'invoice' },
)executeCommand() 会按 id 找到已注册命令。重复 id 或找不到命令都会抛错,所以 id 最好带上业务命名空间。
注册工具栏动作
工具栏动作适合显式触发:
ctx.registerToolbarAction({
id: 'demo.rename.button',
icon: IconSparkles,
label: 'designer.demo.rename',
onClick: (ctx) => {
void ctx.executeCommand('demo.renameTemplate', { prefix: 'invoice' })
},
})按钮不要承载太长业务流程。以后面板、快捷键或自动化也要复用时,命令会让这条链路更清楚。
label 可以是普通字符串,也可以是 locale key。Designer 渲染工具栏时会调用 store.t(action.label);命中翻译就显示翻译,未命中就显示原始字符串。面向产品的 contribution 推荐使用 locale key。
注册面板
持续存在的业务 UI 适合放进 panel:
import { defineAsyncComponent, ref } from 'vue'
const ReviewPanel = defineAsyncComponent(() => import('./ReviewPanel.vue'))
const open = ref(false)
ctx.registerPanel({
id: 'review.panel',
component: ReviewPanel,
props: {
get open() {
return open.value
},
'onUpdate:open': (next: boolean) => {
open.value = next
},
},
})默认情况下,面板会通过 Teleport 挂到 Designer 的 #ei-overlay-root。如果你有自己的挂载点,可以传 teleportTarget。
使用宿主交互
破坏性确认和资产选择应该走宿主交互层:
const ok = await ctx.confirm({
id: 'demo.deleteTemplate',
title: '删除模板',
message: '确认删除当前模板吗?',
severity: 'danger',
})
if (!ok)
return
const asset = await ctx.pickAsset({
id: 'demo.pickLogo',
source: 'demo-contribution',
})这样 Contribution 不需要自己实现浏览器弹窗或业务资产库。宿主可以统一处理权限、审计和 UI 风格。
注册多语言文案
Contribution 可以注册自己的语义文案:
import type { Contribution } from '@easyink/designer'
import { IconSparkles } from '@easyink/icons'
const messages = {
messages: {
designer: {
review: {
toolbar: { label: 'Review' },
panel: { title: 'Review Panel' },
action: { close: 'Close' },
},
},
},
locales: {
'zh-CN': {
designer: {
review: {
toolbar: { label: '审阅' },
panel: { title: '审阅面板' },
action: { close: '关闭' },
},
},
},
'en-US': {
designer: {
review: {
toolbar: { label: 'Review' },
panel: { title: 'Review Panel' },
action: { close: 'Close' },
},
},
},
},
}
export const reviewContribution: Contribution = {
id: 'demo.review',
activate(ctx) {
const unregisterMessages = ctx.store.registerLocaleMessages(messages)
ctx.registerToolbarAction({
id: 'review.toggle',
icon: IconSparkles,
label: 'designer.review.toolbar.label',
onClick: () => void ctx.executeCommand('review.togglePanel'),
})
ctx.onDispose(unregisterMessages)
},
}翻译解析顺序是:
- 宿主传给
<EasyInkDesigner :locale="...">的语言包。 - 当前
localeCode对应的 contribution 注册文案。 - contribution 注册的默认
messages。 - 原始 key。
宿主永远有最高优先级,所以业务接入方可以覆盖 contribution 默认文案。EasyInkDesigner 对内置 zh-CN 和 en-US 会自动识别语言代码;如果你传的是自定义语言包,可以显式传 localeCode:
<EasyInkDesigner
:locale="myLocale"
locale-code="en-US"
:contributions="[reviewContribution]"
/>这套机制的边界是:@easyink/locales 只维护 Designer 内置文案;contribution 包维护自己的文案,并在激活时注册。
转发诊断
Designer 内部的可恢复问题可以订阅出来:
const unsubscribe = ctx.onDiagnostic((entry) => {
console.warn(`[designer:${entry.severity}] ${entry.message}`)
})
ctx.onDispose(() => {
unsubscribe()
})onDiagnostic() 返回取消函数。registry 在 Designer dispose 时也会自动取消订阅;你提前取消也可以。
清理生命周期
带外部副作用的 contribution 必须清理:
activate(ctx) {
const timer = window.setInterval(() => {
console.log(ctx.store.saveStatus)
}, 1000)
ctx.onDispose(() => {
window.clearInterval(timer)
})
}需要清理的通常包括:
- DOM 事件监听。
- 轮询和定时器。
- WebSocket 或外部订阅。
- contribution 自己维护的临时状态。
嵌入式场景、路由切换和 HMR 都可能让 Designer 反复挂载。不要假设 contribution 会一直存在。
完整面板示例
这版结构接近仓库里的 AI contribution:
import type { Contribution } from '@easyink/designer'
import { IconSparkles } from '@easyink/icons'
import { defineAsyncComponent, ref } from 'vue'
const ReviewPanel = defineAsyncComponent(() => import('./ReviewPanel.vue'))
export function createReviewContribution(): Contribution {
const open = ref(false)
return {
id: 'demo.review',
activate(ctx) {
ctx.registerCommand({
id: 'review.togglePanel',
handler: () => {
open.value = !open.value
},
})
ctx.registerToolbarAction({
id: 'review.toggle.button',
icon: IconSparkles,
label: 'designer.review.toolbar.label',
onClick: () => {
void ctx.executeCommand('review.togglePanel')
},
})
ctx.registerPanel({
id: 'review.panel',
component: ReviewPanel,
props: {
get open() {
return open.value
},
'onUpdate:open': (next: boolean) => {
open.value = next
},
},
})
ctx.onDispose(() => {
open.value = false
})
},
}
}这段代码把状态、命令、按钮和面板拆开了。你调试时可以逐个确认:命令是否注册、按钮是否触发、面板 props 是否更新。
常见问题
重复 id 会直接抛错:
ctx.registerCommand({ id: 'review.togglePanel', handler: () => {} })
ctx.registerCommand({ id: 'review.togglePanel', handler: () => {} })遇到问题时先查这几项:
- 按钮无响应:确认
onClick里调用了executeCommand()。 - 命令找不到:确认 contribution 已传给
<EasyInkDesigner :contributions>。 - 面板不出现:确认
open这类状态真的传进了 panel props。 - 文案显示 key:确认 contribution 已调用
registerLocaleMessages(),或者宿主localeCode和注册的localeskey 匹配。 - 事件残留:确认外部订阅放进了
onDispose()。
关于 Contribution,目前知道这些就够用了。更完整的工程参考可以看 packages/assistant/designer-bridge/src/contribution.ts,它注册了命令、工具栏按钮和异步面板。
AI Assistant Contribution 实现参考
仓库中的 AI 助手是 Contribution 机制的典型应用。它通过 @easyink/assistant-designer-bridge 包实现,展示了如何将一个完整的业务系统接入 Designer。
注册结构
import type { Contribution } from '@easyink/designer'
import { createAssistantMaterialManifest } from './material-manifest'
export function createAssistantContribution(): Contribution {
const open = ref(false)
return {
id: 'assistant',
activate(ctx) {
// 1. 注册命令
ctx.registerCommand({ id: 'assistant.open', handler: () => { open.value = true } })
ctx.registerCommand({ id: 'assistant.close', handler: () => { open.value = false } })
ctx.registerCommand({ id: 'assistant.applyResult', handler: (args) => { /* 应用生成结果 */ } })
ctx.registerCommand({ id: 'assistant.rollback', handler: () => { /* 回滚 */ } })
// 2. 注册工具栏按钮
ctx.registerToolbarAction({
id: 'assistant.toggle',
icon: IconSparkles,
label: 'designer.assistant.toolbar.label',
onClick: () => void ctx.executeCommand('assistant.togglePanel'),
})
// 3. 注册面板(响应式 props)
ctx.registerPanel({
id: 'assistant.panel',
component: defineAsyncComponent(() => import('@easyink/assistant-ui')),
props: {
get open() { return open.value },
get currentSchema() { return ctx.store.schema },
get materialManifest() { return createAssistantMaterialManifest(ctx.store) },
onApply: (result) => void ctx.executeCommand('assistant.applyResult', result),
onRollback: () => void ctx.executeCommand('assistant.rollback'),
},
})
ctx.onDispose(() => { open.value = false })
},
}
}关键设计点
响应式 manifest 传递: materialManifest 通过 getter 实现响应式。当用户注册新物料后,面板下次读取时自动获得最新的物料列表(含 knowledge 字段)。AI 不需要重启就能感知新物料。
命令分离: 面板 UI 不直接操作 store。所有写操作(apply、rollback、applyPatch)都收敛为命令。这样其他 contribution 或自动化脚本也能调用同一套命令。
异步面板加载: defineAsyncComponent 确保 AI 面板的代码不会进入 Designer 的初始 bundle。只有用户点击按钮后才加载。
文案注册: Assistant 的中英文文案由 @easyink/assistant-designer-bridge 自己维护,并在 contribution 激活时通过 ctx.store.registerLocaleMessages() 注册。Designer 内置语言包不需要认识 Assistant。