Web 端扩展接入
CoreLib 的 Web Console 前端支持由业务插件动态扩展。插件可以声明自己的配置入口、GUI/物品/自定义资源编辑器、字段说明、列表模板、物品预览兜底和翻译文本。当前实现采用两层接入:
- 服务端声明式注册:插件在
src/main/resources/web-console.yml中声明模块、文件入口、前端脚本和少量节点元信息。 - 前端 IIFE 扩展:插件在
web-console/src/main.tsx中使用emaki-web-console导出的注册函数,打包到src/main/resources/web-extensions/*.js,由 Web Console 动态加载。
这种方式可以让插件的 Java 启动入口保持很薄,同时把复杂 UI、字段说明和本地预览逻辑放在前端扩展中迭代。
接入总览
最小接入需要 4 个部分:
YourPlugin/
├── src/main/java/.../YourPlugin.java
├── src/main/resources/
│ ├── plugin.yml
│ ├── web-console.yml
│ └── web-extensions/
│ └── yourplugin-extension.js # 构建产物
└── web-console/
├── package.json
├── vite.config.ts
└── src/main.tsx启用阶段注册:
@Override
public void onEnable() {
WebConsoleRegistry.registerFromYaml(this);
}关闭阶段反注册:
@Override
public void onDisable() {
WebConsoleRegistry.unregisterModule(this);
}业务模块 reload 时也建议先反注册再重新注册,避免旧文件入口、旧 editor 或旧扩展资源残留。
服务端注册文件
在插件资源目录创建 web-console.yml:
# 仅声明结构信息与资源入口,展示文案建议放在前端 i18n 中。
module:
tone: custom
icon: '<svg viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg"><path d="M8 8h22v22H8z" fill="none" stroke="currentColor"/></svg>'
files:
- path: "config.yml"
kind: CONFIG
- path: "gui/**/*.yml"
kind: GUI
editor: "yourplugin:gui"
- path: "items/**/*.yml"
kind: ITEM
editor: "yourplugin:item"
- path: "sets/**/*.yml"
kind: SET
editor: "yourplugin:set"
extensions:
- id: "yourplugin:editor"
resource: "web-extensions/yourplugin-extension.js"
nodes:
- path: "custom_templates"
type: dynamic_map
creatableChildren: truemodule
| 字段 | 说明 |
|---|---|
name | 模块显示名。可以省略,省略时使用插件名;当前内置扩展通常交给前端 i18n 覆盖。 |
summary | 模块摘要。可以省略,前端扩展可通过翻译覆盖。 |
tone | 模块视觉色调标识。前端可用它区分模块类型。 |
icon | SVG 字符串。建议使用纯 path、stroke="currentColor",避免外链资源。 |
files
| 字段 | 必填 | 说明 |
|---|---|---|
path | 是 | 相对插件数据目录的文件路径。支持固定文件和 glob,例如 recipes/**/*.yml。 |
kind | 是 | 文件类型。内置 CONFIG、GUI、ITEM、SCRIPT,其他值会作为自定义资源类型注册。 |
title | 否 | 文件入口标题。不写时前端可通过 i18n key 覆盖。 |
comment | 否 | 文件入口说明。不写时前端可通过 i18n key 覆盖。 |
editor | 否 | 专属 editor ID,例如 emakigem:gem。前端按 editor ID 匹配最高优先级 surface。 |
CONFIG 固定文件会被结构化展开。CONFIG glob 目录不会一次性读取所有文件,点击子文件时通过 /api/registry/file 懒加载节点。GUI、ITEM 和自定义 kind 默认走源码读写接口,再由前端 surface 决定是否提供可视编辑。
extensions
| 字段 | 说明 |
|---|---|
id | 扩展唯一 ID。建议使用 插件名:功能,例如 emakigem:item-surface。 |
resource | 插件 JAR 内资源路径,例如 web-extensions/yourplugin-extension.js。 |
资源路径会被规范化,不能以 / 开头,不能包含 ..。注册成功后 /api/registry 会返回:
{
"moduleId": "YourPlugin",
"id": "yourplugin:editor",
"url": "/extensions/YourPlugin/web-extensions/yourplugin-extension.js?v=插件版本",
"apiVersion": "1.1.0"
}前端用 <script> 加载扩展脚本,因此扩展资源本身不带 Authorization header。服务端只会返回已经注册过的插件资源路径,不会开放任意文件读取。
nodes
nodes 用于补充服务端结构化配置节点元信息:
nodes:
- path: "rewards"
label: "奖励池"
comment: "按权重抽取的奖励配置。"
type: dynamic_map
creatableChildren: true常用类型:
| 类型 | 说明 |
|---|---|
text | 单行文本。 |
number | 数字输入。 |
boolean | 开关。 |
list | 通用列表。 |
stringList | 字符串列表编辑体验。 |
numberList | 数字列表编辑体验。 |
actions | CoreLib Action 列表。 |
object | 分组节点。 |
dynamic_map | 对象节点作为可动态增删键值的 map 编辑,不递归展开。 |
enum:A,B,C | 静态枚举。 |
dynamic_enum:目录 | 扫描该目录下 YAML 的 id 或文件名作为选项。 |
前端扩展也可以用 registerPluginConfig 覆盖或补充这些节点信息。复杂字段说明建议放在前端扩展中,这样可以同时提供中英文、多字段模板和列表 schema。
前端工作区
在插件目录下创建 web-console/package.json:
{
"name": "yourplugin-web-console-extension",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "tsc && vite build"
},
"dependencies": {
"emaki-web-console": "0.1.0"
}
}根目录 package.json 使用 npm workspaces 管理各模块 Web 扩展。新增插件时,把工作区加入根项目:
{
"workspaces": [
"EmakiCoreLib/web-console",
"YourPlugin/web-console"
]
}如果希望根命令能单独构建你的扩展,再添加脚本:
{
"scripts": {
"build:web:yourplugin": "npm run build -w yourplugin-web-console-extension"
}
}Vite 构建配置
业务模块扩展应构建成 IIFE,并把 React 与 CoreLib Web Console API 作为外部全局变量:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react({ jsxRuntime: 'classic' })],
build: {
outDir: '../src/main/resources/web-extensions',
emptyOutDir: false,
lib: {
entry: 'src/main.tsx',
name: 'YourPluginWebConsoleExtension',
formats: ['iife'],
fileName: () => 'yourplugin-extension.js'
},
rollupOptions: {
external: ['react', 'emaki-web-console'],
output: {
globals: {
react: 'React',
'emaki-web-console': 'EmakiWebConsole'
}
}
}
}
});关键点:
outDir必须指向src/main/resources/web-extensions,确保 Maven 打包时进入 JAR。emptyOutDir: false避免清空同目录下其他扩展资源。formats: ['iife']让浏览器通过<script>直接执行。reactexternal 到全局React,由 CoreLib 前端壳安装。emaki-web-consoleexternal 到全局EmakiWebConsole,由installWebConsoleHost()安装。jsxRuntime: 'classic'可避免自动 JSX runtime 被打进扩展或产生额外全局依赖。
Maven 构建接入
可以在插件 pom.xml 中加入 exec-maven-plugin,让 Maven 在 generate-resources 阶段构建前端扩展:
<properties>
<skip.web.extension>false</skip.web.extension>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>install-web-extension-deps</id>
<phase>generate-resources</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<workingDirectory>${project.basedir}/web-console</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>install</argument>
</arguments>
<skip>${skip.web.extension}</skip>
</configuration>
</execution>
<execution>
<id>build-web-extension</id>
<phase>generate-resources</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<workingDirectory>${project.basedir}/web-console</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>run</argument>
<argument>build</argument>
</arguments>
<skip>${skip.web.extension}</skip>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>当前 Gem、Skills、Item、Cooking 等模块已经使用类似方式构建扩展。若临时跳过前端构建,可以传入 -Dskip.web.extension=true,但要确认已有 web-extensions/*.js 产物仍是最新的。
扩展入口文件
最小 web-console/src/main.tsx:
import { registerModuleLocale, registerPluginConfig, registerPluginGuiEditor } from 'emaki-web-console';
const MODULE = 'YourPlugin';
registerModuleLocale(MODULE, 'zh-CN', {
'yourplugin.module.name': 'YourPlugin',
'yourplugin.module.summary': '自定义配置、GUI 与物品编辑',
'yourplugin.file.config.title': '主配置',
'yourplugin.file.config.comment': '插件主配置文件。',
'yourplugin.surface.gui': 'YourPlugin GUI'
});
registerModuleLocale(MODULE, 'en-US', {
'yourplugin.module.name': 'YourPlugin',
'yourplugin.module.summary': 'Custom config, GUI, and item editing',
'yourplugin.file.config.title': 'Main Config',
'yourplugin.file.config.comment': 'Main plugin configuration.',
'yourplugin.surface.gui': 'YourPlugin GUI'
});
registerPluginConfig({
moduleId: MODULE,
metaFields: [
['feature.enabled', '启用功能', '控制该功能是否启用。', 'boolean'],
['feature.cooldown', '冷却时间', '功能触发后的冷却时间,单位 tick。', 'number']
],
ruleFields: {
item_sources: ['物品来源', '支持 minecraft、CraftEngine、ItemsAdder、Nexo 等来源格式。', 'stringList'],
actions: ['动作列表', '按顺序执行的 CoreLib Action。', 'actions']
}
});
registerPluginGuiEditor({
moduleId: MODULE,
editorId: 'yourplugin:gui',
label: 'YourPlugin GUI',
fields: [
['confirm', '确认按钮', '执行核心操作的按钮槽位。', 'text'],
['preview', '预览槽位', '展示当前配置结果的槽位。', 'text']
]
});扩展脚本执行时,CoreLib 已经把 window.React 和 window.EmakiWebConsole 安装好。通过 emaki-web-console 导入的函数会在构建时映射到这个全局对象。
Surface 匹配规则
Web Console 使用 surface registry 决定文件打开时渲染哪个页面。匹配顺序:
editorId精确匹配。moduleId + kind匹配。- 仅
kind匹配。
优先级高的注册会覆盖低优先级注册。通用 GUI/ITEM surface 由 CoreLib 注册,插件可以用更高优先级覆盖指定模块或指定 editor。
注册自定义 surface
import type { SurfaceProps } from 'emaki-web-console';
import { registerSurface } from 'emaki-web-console';
function SetEditorSurface(props: SurfaceProps) {
return <div>{props.module.name} / {props.file.path}</div>;
}
registerSurface({
kind: 'SET',
moduleId: 'YourPlugin',
editorId: 'yourplugin:set',
component: SetEditorSurface,
label: '套装',
priority: 120
});SurfaceProps 提供:
| 属性 | 说明 |
|---|---|
module | 当前模块注册信息。 |
file | 当前文件入口。 |
api | ApiClient 实例,可读写配置、GUI、物品、资源和预览。 |
childPath | glob 子文件路径。 |
refreshKey | 注册表刷新标记。 |
editor | 当前 editor 描述。 |
onReload | 请求父级重新加载当前文件。 |
setToolbar | 设置顶部工具栏状态、保存按钮、源码内容和变更提示。 |
showLocalChrome | 是否需要 surface 自己显示本地外壳。 |
Editor 描述与字段
专属 editor 可以声明字段元信息,供通用 GuiEditorSurface 或 ItemEditorSurface 渲染:
import { registerEditorDescriptor, registerEditorField } from 'emaki-web-console';
registerEditorDescriptor('YourPlugin', 'yourplugin:item', {
id: 'yourplugin:item',
moduleId: 'YourPlugin',
title: '自定义物品',
kindLabel: '物品',
baseName: '<white>预览物品</white>',
baseLore: ['<gray>默认 Lore</gray>']
});
registerEditorField('YourPlugin', 'yourplugin:item', {
path: 'display_name',
label: '显示名',
comment: '物品展示名称,支持 MiniMessage。',
type: 'text'
});常用字段类型:
| type | 说明 |
|---|---|
text | 单行文本。 |
number | 数字。 |
boolean | 开关。 |
textarea | 多行文本。 |
stringList | 字符串列表。 |
numberList | 数字列表。 |
enum | 下拉选项,需要 options。 |
json | JSON/对象编辑。 |
actions | CoreLib Action 列表编辑。 |
配置节点增强
registerPluginConfig 是配置页最常用的批量注册入口:
registerPluginConfig({
moduleId: 'YourPlugin',
metaFields: [
['quality.tiers', '品质池', '按权重抽取的品质列表。', 'list']
],
ruleFields: {
enabled: ['启用', '是否启用当前功能或条目。', 'boolean'],
chance: ['概率', '成功率、触发率或抽取概率。', 'number']
},
createTemplates: [
['quality.item_meta.tiers', {
id: 'quality-tier-display',
label: '品质显示规则',
fields: [
{ path: 'name_actions', label: '名称动作链', type: 'list', defaultValue: [] },
{ path: 'lore_actions', label: 'Lore 动作链', type: 'list', defaultValue: [] }
]
}]
],
listItemSchemas: [
['quality.tiers', [
{ path: 'name', label: '品质名', type: 'text', defaultValue: '普通' },
{ path: 'weight', label: '权重', type: 'number', defaultValue: 1 },
{ path: 'multiplier', label: '倍率', type: 'number', defaultValue: 1 }
], { uniqueBy: 'name' }]
]
});可拆开使用的低层函数包括:
| 函数 | 用途 |
|---|---|
registerConfigNodeMeta(moduleId, path, meta) | 精确覆盖某个配置路径的标签、说明、类型、选项等。 |
registerConfigNodeRule(moduleId, matcher, meta) | 按 key、prefix、suffix、contains 或函数匹配一批节点。 |
registerConfigCreateTemplate(moduleId, nodePath, template) | 给 dynamic map 或可创建节点添加新建模板。 |
registerConfigListItemSchema(moduleId, listPath, fields, options) | 给列表项提供字段 schema。 |
registerUniqueListField(moduleId, listPath, fieldPath) | 指定列表项内唯一字段,避免重复键。 |
物品字段渲染器
通用物品编辑器会按字段 type 查找自定义渲染器。适合成本、效果、插槽、套装部位等复杂结构。
import { registerItemFieldRenderer, type ItemFieldRendererContext } from 'emaki-web-console';
function CostEditor({ context }: { context: ItemFieldRendererContext }) {
return (
<div>
<strong>{context.field.label}</strong>
<button onClick={() => context.setField(context.field.path, { currencies: [], materials: [] })}>
重置费用
</button>
{context.renderDefault()}
</div>
);
}
registerItemFieldRenderer(
'cost',
context => <CostEditor context={context} />,
{ moduleId: 'YourPlugin', editorId: 'yourplugin:item', priority: 100 }
);ItemFieldRendererContext 中常用属性:
| 属性 | 说明 |
|---|---|
data | 当前编辑中的完整 YAML 数据。 |
originalData | 初始数据,用于比较变更。 |
field | 当前字段描述。 |
value | 当前字段值。 |
changed | 该字段是否变更。 |
actionTypesResult | 服务端返回的 name/lore action 类型。 |
economyProviders | 当前经济提供器选项。 |
editorFields | 当前 editor 的全部字段描述。 |
setField(path, value) | 更新字段值。 |
renderDefault() | 渲染默认字段编辑器。 |
物品预览兜底
服务端 /api/items/preview 会生成真实结构的预览。前端扩展还可以注册本地兜底,在服务端预览失败或需要即时展示时使用:
import { registerItemPreviewFallback, type ItemPreviewFallbackContext } from 'emaki-web-console';
function localPreview(context: ItemPreviewFallbackContext) {
return {
kind: 'yourplugin:item',
id: String(context.data.id ?? ''),
displayName: String(context.data.display_name ?? context.baseName),
lore: Array.isArray(context.data.lore) ? context.data.lore.map(String) : context.baseLore
};
}
registerItemPreviewFallback(localPreview, {
moduleId: 'YourPlugin',
editorId: 'yourplugin:item',
priority: 100
});兜底预览应只做展示辅助,不应假装完成 PDC 写入、外部物品解析或服务端实际生成逻辑。
SourceDocumentAdapter
默认源码读写会根据 kind 分流到配置、脚本、GUI、物品或自定义资源接口。特殊格式可以注册自己的源码适配器:
import { registerSourceDocumentAdapter } from 'emaki-web-console';
registerSourceDocumentAdapter({
kind: 'SET',
moduleId: 'YourPlugin',
editorId: 'yourplugin:set',
priority: 100,
adapter: {
language: 'yaml',
read: (api, context) => api.readTextDocument({ kind: context.file.kind, moduleId: context.module.id, path: context.path }),
save: (api, context, content, revision) => api.saveTextDocument({ kind: context.file.kind, moduleId: context.module.id, path: context.path }, content, revision),
defaultContent: ({ name }) => `id: ${name}\ndisplay_name: "${name}"\n`
}
});适配器可控制:
| 字段 | 说明 |
|---|---|
read | 读取源码。 |
save | 保存源码。 |
parse | 把源码解析为对象。 |
serialize | 把对象序列化为源码。 |
defaultContent | 新建文件时生成默认内容。 |
language | 编辑器语言,例如 yaml、javascript、text。 |
翻译接入
使用 registerModuleLocale 注册模块自己的文本:
registerModuleLocale('YourPlugin', 'zh-CN', {
'yourplugin.module.name': 'YourPlugin',
'yourplugin.module.summary': '自定义模块',
'yourplugin.file.config.title': '主配置',
'yourplugin.field.feature.enabled': '启用功能'
});推荐 key 约定:
| key 形态 | 用途 |
|---|---|
yourplugin.module.name | 模块显示名。 |
yourplugin.module.summary | 模块摘要。 |
yourplugin.file.<name>.title | 文件入口标题。 |
yourplugin.file.<name>.comment | 文件入口说明。 |
yourplugin.field.<path> | 配置或 editor 字段标签。 |
yourplugin.comment.<path> | 配置或 editor 字段说明。 |
yourplugin.surface.<name> | 自定义 surface 标签。 |
CoreLib 前端内置 getLocale()、t()、registerLocale()、registerModuleLocale()。内置模块通常同时注册 zh-CN 和 en-US。
ApiClient 常用方法
扩展 surface 可以通过 props.api 调用接口:
| 方法 | 说明 |
|---|---|
registry() | 重新获取模块注册表。 |
registryFileNodes(moduleId, path) | 获取 glob 子文件结构化节点。 |
saveRegistryValue(moduleId, filePath, path, value, revision) | 保存结构化配置节点。 |
createFile(moduleId, fileId, name, content?) | 创建 glob 子文件。 |
deleteFile(moduleId, fileId, path, confirmPath) | 删除子文件。 |
readTextDocument(target) / saveTextDocument(target, content, revision?) | 按 kind 读写源码。 |
readGui() / saveGui() | 读写 GUI YAML。 |
readItem() / saveItem() | 读写物品 YAML。 |
readResource() / saveResource() | 读写自定义 kind 资源。 |
previewItem(content, previewLevel, baseName?, baseLore?) | 请求服务端物品预览。 |
actionTypes() | 获取 name/lore action 类型。 |
economyProviders() | 获取经济提供器选项。 |
写入类接口受 web_console.security.allow_config_write 控制,并使用 revision 检测并发修改。
当前内置模块示例
| 模块 | 扩展 ID | 主要接入点 |
|---|---|---|
| EmakiAttribute | emakiattribute:locale | 配置字段说明、DamageCause 运行时枚举、属性/伤害/条件/lore format 元信息。 |
| EmakiForge | emakiforge:gui-surface | 品质配置 schema、锻造 GUI editor、配方与 item adjustment 文件入口说明。 |
| EmakiStrengthen | emakistrengthen:gui-surface | 强化配置字段、配方字段、强化 GUI editor。 |
| EmakiCooking | emakicooking:gui-surface | 工位配置、展示实体配置、Cooking GUI editor。 |
| EmakiGem | emakigem:item-surface | 宝石 editor、插槽物品 editor、升级/成本/效果自定义字段、本地预览兜底、GUI editor。 |
| EmakiSkills | emakiskills:gui-surface | 技能配置、资源配置、触发器字段、技能 GUI editor。 |
| EmakiItem | emakiitem:locale | SET 文件类型、EmakiItem 物品 editor、套装 editor、套装字段渲染、源码文档适配。 |
调试流程
- 运行根目录或模块目录的 Web 构建:
npm run build:web:yourplugin或在YourPlugin/web-console下执行npm run build。 - 确认
src/main/resources/web-extensions/yourplugin-extension.js已更新。 - 构建插件 JAR,确认 JAR 内包含
web-console.yml和web-extensions/yourplugin-extension.js。 - 启动服务端,开启
web_console.enabled=true。 - 执行
/emakicorelib web获取访问地址。 - 如果模块树没有出现插件,检查插件是否启用、
allowed_modules是否放行、控制台是否有web-console.yml解析警告。 - 如果扩展脚本没有生效,打开浏览器控制台,检查
/api/registry的extensions列表和/extensions/...js请求状态。 - 临时执行
/emakicorelib webdebug frontend或/emakicorelib webdebug backend查看请求日志,排查完成后再次执行关闭。
兼容与约束
- CoreLib 当前前端 API 版本为
1.1.0,注册表返回的扩展项会带上apiVersion。 - 扩展脚本应只依赖
React和EmakiWebConsole全局,不要假设页面中存在其他全局库。 - 服务端
web-console.yml负责结构入口,前端扩展负责展示语言和复杂 UI;不要把大量展示文案塞进 YAML 注册文件。 extensions[].resource必须是插件 JAR 内资源路径,不能指向外部 URL。kind自定义值会按大写匹配;注册 surface、文件类型标签和 source adapter 时保持同一个 kind 名称。- 前端本地预览只作为编辑辅助,最终运行效果仍以服务端加载、PDC 写入和实际业务逻辑为准。