Skip to content

HTML 开发

XML 轨用声明式 XML 描述界面,HTML 轨则把整个页面交给插件自己控制:你写原生 HTML / CSS / JS,WebUI 用 Shadow DOM 沙箱加载,并提供一个 sys 桥接对象作为与系统交互的唯一通道。

本文从快速入门出发,介绍 HTML 轨的资源结构、注册方式、沙箱环境、sys 注入、组件用法与调试技巧。

何时选 HTML 轨

  • 需要完全自定义的 UI(图表可视化、Canvas / WebGL、复杂表单编排)
  • 想用第三方 JS 库(ECharts、Monaco、CodeMirror、Three.js 等)
  • 命令式编程习惯,不想被 XML 管道指令约束

其他场景建议优先用 XML 轨 —— 更轻量、更安全、零样板。

渲染模型

HTML 轨的渲染流程:

插件后端 (Python)                WebUI 前端 (Vue)
┌──────────────────────┐        ┌─────────────────────────────┐
│ register_ui_page     │        │  PluginUIManager            │
│   mode="html"        │──register──→  (注册表)               │
│   assets=...         │        │                             │
└──────────────────────┘        │  HtmlSandbox                │
                                │   1. host.attachShadow()    │
                                │   2. 注入 MD3 变量穿透 <style>│
                                │   3. installFetchProxy       │
                                │      (注入 Token / X-Plugin)  │
                                │   4. fetch styles → <style>  │
                                │   5. fetch entry_html        │
                                │      → shadowRoot.innerHTML  │
                                │   6. 注册 sys-* 自定义元素    │
                                │   7. createSysBridge         │
                                │      → window.__plugin_sys_* │
                                │   8. 顺序执行 scripts        │
                                └─────────────────────────────┘

每个 HTML 页面运行在独立的 Shadow DOM 沙箱里:

  • 主站样式不污染插件页面,插件 CSS 也不外溢
  • MD3 主题 CSS 变量(--md-sys-color-*天然穿透 Shadow DOM,插件可直接使用
  • 插件内 window.fetch / XMLHttpRequest 被透明重写,自动注入 Authorization / X-API-Key / X-Plugin-Name
  • 一个 sys 桥接对象挂在 window.__plugin_sys_<pageId> 上,是插件与系统交互的唯一入口

第一个 HTML 页面

1. 准备资源文件

在插件目录下建一个 ui/ 子目录(命名随意),结构如下:

my_plugin/
├── manifest.json
├── plugin.py
└── ui/
    ├── index.html      ← 入口 HTML
    ├── style.css       ← 插件样式
    ├── main.js         ← 插件脚本
    └── assets/         ← 静态资源(图片 / 字体 / SVG 等,可选)
        └── logo.png

ui/index.html

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>我的 HTML 页面</title>
</head>
<body>
  <sys-card title="Hello HTML 轨" variant="elevated">
    <sys-vbox gap="0.75rem">
      <sys-text variant="headline">欢迎使用 HTML 轨</sys-text>
      <sys-text variant="body">
        这是用原生 HTML 写的插件页面。下方按钮通过 sys.* 桥接对象调用后端 API。
      </sys-text>
      <sys-hbox gap="0.5rem" align="center">
        <sys-button id="ping-btn" variant="filled" icon="bolt">发起请求</sys-button>
        <sys-text id="result" variant="caption">尚未请求</sys-text>
      </sys-hbox>
    </sys-vbox>
  </sys-card>
</body>
</html>

ui/style.css

css
:host {
  display: block;
  padding: 1.5rem;
  max-width: 1200px;
  margin: 0 auto;
  font-family: inherit;
  color: var(--md-sys-color-on-surface, #1a1b20);
}

#result {
  min-height: 1.25rem;
}

ui/main.js

javascript
// 沙箱会在脚本前自动注入:
//   const sys = window.__plugin_sys_<pageId>;
// 因此这里可直接使用 sys.* 桥接对象。

// 沙箱 Shadow DOM 内查询元素
const host = document.querySelector('.html-sandbox-host')
const root = host?.shadowRoot
const $ = (sel) => root?.querySelector(sel)

$('#ping-btn')?.addEventListener('click', async () => {
  const result = $('#result')
  if (result) result.textContent = '请求中...'
  try {
    // sys.request 自动注入 Token + 解包 BaseResponse.data
    const data = await sys.request('/my-plugin/ping')
    if (result) result.textContent = `响应:${JSON.stringify(data)}`
    sys.ui.toast('请求成功', 'success')
  } catch (e) {
    if (result) result.textContent = `失败:${e.message ?? e}`
  }
})

console.log('[my-plugin] 脚本初始化完成')

2. 在 plugin.py 中注册页面

python
from src.app.plugin_system.base import BasePlugin, register_plugin
from src.app.plugin_system.api.service_api import get_service


@register_plugin
class MyPlugin(BasePlugin):
    plugin_name = "my_plugin"
    plugin_description = "示例 HTML 插件"
    plugin_version = "1.0.0"

    configs: list[type] = []
    dependent_components: list[str] = ["neo-mofox-webui:service:plugin_ui"]

    def get_components(self) -> list[type]:
        return []

    async def on_plugin_loaded(self) -> None:
        ui_service = get_service("neo-mofox-webui:service:plugin_ui")
        if ui_service is None:
            return

        await ui_service.register_ui_page(
            plugin_name="my_plugin",
            page_id="home",
            title="我的 HTML 页面",
            mode="html",
            icon="code",
            description="用 HTML 轨写的插件页面",
            order=100,
            assets={
                "entry_html": "ui/index.html",
                "styles": ["ui/style.css"],
                "scripts": ["ui/main.js"],
                "assets_dir": "ui/assets",   # 可选,仅当需要图片 / 字体等静态资源时声明
            },
        )

    async def on_plugin_unloaded(self) -> None:
        ui_service = get_service("neo-mofox-webui:service:plugin_ui")
        if ui_service is None:
            return
        await ui_service.unregister_plugin_pages("my_plugin")

注册完成后,刷新 WebUI 侧边栏即可看到你的页面。

assets 资源声明

mode="html"assets 字典必填,结构:

字段类型必填说明
entry_htmlstr入口 HTML 文件相对路径,必须以 .html 结尾
styleslist[str]CSS 文件路径列表,按数组顺序加载(每个必须以 .css 结尾)
scriptslist[str]JS 文件路径列表,按数组顺序加载(每个必须以 .js 结尾)
assets_dirstr | None静态资源根目录;为空时不暴露任何静态资源

所有路径以「插件根目录」为基准,由系统通过 plugin_name 解析出插件根目录后再校验。

路径校验规则

  • 必须为相对路径,不得以 /\ 开头
  • 不得包含 .. 段(防止路径穿越)
  • 单文件大小上限 5 MB
  • 允许的扩展名:
类别扩展名
网页.html .css .js
图片.png .jpg .jpeg .gif .webp .svg .ico
字体.woff .woff2 .ttf .otf

移动端资源

可为移动端单独提供一份资源,桌面端与移动端必须使用相同的 mode

python
await ui_service.register_ui_page(
    plugin_name="my_plugin",
    page_id="home",
    title="我的页面",
    mode="html",
    xml=None,                       # mode=html 时必须为 None
    assets={...},                   # 桌面端
    mobile_xml=None,
    mobile_assets={                 # 移动端(可选,空则 fallback 到桌面端)
        "entry_html": "ui/mobile/index.html",
        "styles": ["ui/mobile/style.css"],
        "scripts": ["ui/mobile/main.js"],
    },
)

沙箱环境

Shadow DOM 隔离

  • 每个页面挂载到 <div class="html-sandbox-host"> 上,宿主 attachShadow({ mode: 'open' })
  • 主站 CSS 不会影响 Shadow DOM 内部
  • 插件 CSS 写在 styles 里,会被 fetch 取回并以 <style> 注入 Shadow DOM(不是 <link>,因为 <link> 不走 window.fetch,无法注入 Token)
  • MD3 CSS 变量(--md-sys-color-*)从 :root 天然穿透 Shadow DOM,插件可直接用
  • @font-face 声明也是全局的,但 .material-symbols-rounded 类样式不穿透 Shadow DOM —— 沙箱已自动在每个 sys-* 自定义元素的 shadow root 内重声明,图标开箱可用

:host 入口

入口 HTML 不会带 <html> / <body> 标签原样进 Shadow DOM —— 沙箱取 HTML 内的所有非 <script> 节点直接 append 到 shadowRoot。<script> 会被捕获、按顺序在 sys 注入后执行。

样式里推荐用 :host 选择根容器:

css
:host {
  display: block;
  padding: 1.5rem;
  max-width: 1200px;
  margin: 0 auto;
}

MD3 变量

主题色 / 间距 / 圆角等 MD3 设计令牌都通过 CSS 变量暴露,在 Shadow DOM 内可直接引用。常用变量:

变量含义
--md-sys-color-primary主色
--md-sys-color-on-primary主色上的前景色
--md-sys-color-surface表面色
--md-sys-color-on-surface表面上的前景色
--md-sys-color-on-surface-variant次级前景色
--md-sys-color-outline / --md-sys-color-outline-variant边框色
--md-sys-color-error错误色
--md-sys-color-surface-container-*各级容器表面色(low / high / highest

Motion 变量

shared-motion.css 中定义了 MD3 motion 时长与缓动曲线变量,可直接使用:

变量说明
--md-sys-motion-duration-x-short / short / medium / long时长
--md-sys-motion-standard / emphasized / decelerated / accelerated / linear缓动函数

预设 keyframes:sys-fade-insys-scale-insys-slide-downsys-shakesys-spinsys-ripple

sys 桥接对象

每个 HTML 沙箱实例对应一个独立的 sys 对象,挂在 window.__plugin_sys_<pageId> 上。沙箱在执行你的 scripts 之前会自动在每个脚本前注入:

javascript
const sys = window.__plugin_sys_<pageId>;

所以你的脚本里直接用 sys.* 即可,无需手动取。

sys 是唯一通道

与系统的所有交互(变量池、API 调用、UI 反馈、路由、主题)都通过 sys.* 完成。裸 window.fetch 虽然也被代理(注入 Token),但不做 BaseResponse 解包,仅用于兼容第三方库。

要拿系统数据,请用 sys.request / sys.api,详见 HTML sys API

完整 API 参考:HTML sys API。这里列出主要能力:

命名空间作用
sys.varspage scope 变量池(响应式)
sys.pluginplugin scope 变量池
sys.globalglobal scope 变量池(只读)
sys.api(id, params?)调用预定义 API 模板(与 XML 轨 <api> 共用)
sys.request(url, options?)fetch 风格的统一请求方法(注入 Token + 解包 BaseResponse)
sys.buspage 级事件总线(on / off / emit
sys.uiUI 交互(notify / toast / confirm / alert / dialog.open / dialog.close
sys.theme当前主题(mode / primary,只读)
sys.route路由(current / back() / go(plugin, page)
sys.format格式化辅助(date / number / currency
sys.i18n文案翻译(t(key, params?),自动加 pluginName. 前缀命中本插件 bundle,详见下方国际化章节)

sys-* 组件构建 UI

HTML 轨与 XML 轨共享同一套 sys-* 组件源components/plugin-ui/sys-components/)。在 XML 轨里它们以裸名出现(vbox / card),在 HTML 轨里因为受 Web Component 命名约束,布局类一律加 sys- 前缀

XML 轨HTML 轨
<vbox><sys-vbox>
<hbox><sys-hbox>
<grid><sys-grid>
<card><sys-card>
<tabs><sys-tabs>
<dialog><sys-dialog>
<divider><sys-divider>
<spacer><sys-spacer>
<sys-text><sys-text> (不变)

完整属性表与用法见 HTML 组件参考

命令式 vs 声明式

HTML 轨是命令式:组件实例就是一个 DOM 元素,你可以 querySelector 取到它,直接读 / 写属性、监听事件、调方法:

javascript
// 直接读 / 写组件属性
const input = root.querySelector('#task-input')
const title = input.value         // 取值(SysInput 实现了 .value 访问器)
input.value = ''                  // 清空

// 监听 change 事件
input.addEventListener('change', (e) => {
  console.log('值变化:', e.detail)  // SysInput 的 change 事件带 detail
})

// 给 sys-table 直接赋值数组
const table = root.querySelector('#task-table')
table.data = [
  { name: '任务1', status: 'done' },
  { name: '任务2', status: 'pending' },
]
table.columns = [
  { key: 'name', label: '名称' },
  { key: 'status', label: '状态' },
]

// 给 sys-chart 直接赋值对象
const chart = root.querySelector('#metrics-chart')
chart.type = 'line'
chart.data = { xAxis: ['Mon','Tue'], series: [{ name:'访问', data: [120, 200] }] }

.value 与 .getAttribute('value')

sys-input / sys-textarea / sys-select,HTML 自定义元素的 attribute 是字符串,用户输入不会回流到 attribute。WebUI 已在 SFC 内部把这些组件的 .value 访问器重定义为实时值,始终用 .value 取用户输入,不要用 getAttribute('value')

fetch 代理

沙箱在加载资源前会重写 window.fetchXMLHttpRequest.prototype.send

  • 所有出站请求自动注入 Authorization: Bearer <token>X-API-Key: <token>
  • 自动注入 X-Plugin-Name: <plugin_name>
  • 不做 BaseResponse 解包 —— fetch 保持透传 Response 语义,方便第三方库工作
  • 沙箱销毁时自动还原原始方法

要拿到解包后的业务数据,请用 sys.request(url, options)sys.api(id, params) —— 详见 HTML sys API · sys.request

静态资源访问

声明了 assets_dir 后,里面的文件可通过 URL 访问,路径形如:

GET /webui/static/plugin-ui/{plugin_name}/{page_id}/{variant}/asset/{rel_path}

例如 assets_dir: "ui/assets" 下有 logo.png,可访问:

/webui/static/plugin-ui/my_plugin/home/desktop/asset/logo.png

推荐:通过 sys.request 拿数据

插件内部资源(图片 / 字体)可直接用上述 URL 在 HTML / CSS 中引用(fetch 代理会带 Token)。但业务数据请通过你自己的 Router 暴露 API,前端用 sys.request('/your-plugin/...') 调用,不要直接读文件。

国际化 (i18n)

HTML 轨通过 sys.i18n.t(key, params?) 引用翻译。框架会自动给 key 加上 pluginName. 前缀,所以插件作者写短 key 即可命中本插件注册的 bundle,与 WebUI 内置 messages 互不冲突。

设计要点

  • 零侵入:插件作者无需导入 WebUI 内部 i18n 模块,无需在 manifest 声明 i18n 依赖
  • 懒加载:bundle 随页面 /schema 接口一起返回,无独立请求;不打开页面不加载
  • 命名空间隔离:插件 key 自动落入 <pluginName>.<key>,不会与 WebUI 或其他插件冲突
  • 统一回退链:plugin locale → plugin DEFAULT_LOCALE → 静态 messages → DEFAULT_LOCALE → key 字面量
  • 双轨一致:XML {t('key')} 与 HTML sys.i18n.t('key') 行为一致,都自动加前缀

数据流

插件 on_plugin_loaded
    │  await service.register_ui_page(..., i18n_path='i18n/i18n.json')

后端 PluginUIService
    │  _resolve_plugin_root(plugin_name)     ← 复用 HTML 轨的根目录解析
    │  PluginUIValidators.validate_i18n(metadata, plugin_root)
    │      ├─ 路径穿越校验(resolve_safe)
    │      ├─ 文件大小 ≤ 256 KB
    │      └─ JSON 解析 + 顶层结构校验(dict[str, dict | null])

PluginUIManager.register(metadata, plugin_root, i18n_bundle)
    │  存入 RegisteredPage.i18n(内存态,进程重启清空)

GET /webui/api/plugin-ui/schema/{plugin_name}/{page_id}
    │  PageSchemaResponse.i18n = RegisteredPage.i18n

前端 PluginUIView.loadPage
    │  registerPluginI18n(pluginName, pageId, schema.i18n)
    │  → 把 bundle 在每个 locale 下包一层 {pluginName: ...} 后深合并
    │  → 写入响应式 pluginMessages store

HTML 脚本调用
    │  sys.i18n.t('welcome')
    │  → sys-bridge 的 sysI18n.t
    │  → i18nT('my_plugin.welcome')   ← 自动加 pluginName 前缀
    │  → 命中 pluginMessages['zh-CN'].my_plugin.welcome

注册 bundle

1. 准备 JSON 文件

单文件包含所有 locale,结构与 WebUI 内置 messages 一致:

json
{
  "zh-CN": {
    "title": "用户信息",
    "welcome": "欢迎使用",
    "greeting": "你好,{name}",
    "counter": { "title": "计数器", "reset": "重置" },
    "save": "保存",
    "saveSuccess": "保存成功"
  },
  "en-US": {
    "title": "User Info",
    "welcome": "Welcome",
    "greeting": "Hello, {name}",
    "counter": { "title": "Counter", "reset": "Reset" },
    "save": "Save",
    "saveSuccess": "Saved successfully"
  }
}

约定

规则
顶层键locale 名(zh-CN / en-US,与 UISettings.language 严格匹配)
locale 值嵌套 dict(支持任意深度点路径)或 null(表示该 locale 无翻译,走 fallback)
占位符{name} 语法,运行时由 t() 替换
文件大小≤ 256 KB
路径相对插件根目录,必须以 .json 结尾,禁含 .. 跨目录

2. 在 register_ui_page 中声明

i18n_pathmode 无关,XML / HTML 两种模式都可选用。声明后框架会强制解析插件根目录做路径穿越校验:

python
await ui_service.register_ui_page(
    plugin_name="my_plugin",
    page_id="home",
    title="我的页面",
    mode="html",
    assets={
        "entry_html": "ui/index.html",
        "styles": ["ui/style.css"],
        "scripts": ["ui/main.js"],
    },
    i18n_path="i18n/i18n.json",   # ← 相对插件根目录
)

3. 校验规则

PluginUIValidators.validate_i18n(metadata, plugin_root) 在注册时执行:

  1. 路径格式.json 后缀
  2. 路径穿越resolve_safe(i18n_path, plugin_root) 复用 HTML 轨的路径穿越检查
  3. 文件存在:不存在抛 AssetMissingError
  4. 大小校验:超过 256 KB 抛 AssetSizeError
  5. JSON 解析json.JSONDecodeErrorValueError
  6. 结构校验:顶层必须是 dict;每个 locale 键必须是字符串;每个 locale 值必须是 dictnull

在 HTML 脚本中引用翻译

sys.i18n.t(key, params?) 与 XML 轨的 {t('key')} 行为完全一致:自动加 pluginName. 前缀。

javascript
// 简单 key(自动加 pluginName 前缀 → 实际查找 'my_plugin.welcome')
document.querySelector('#title').textContent = sys.i18n.t('welcome')

// 嵌套 key(点路径)
document.querySelector('#counter-title').textContent = sys.i18n.t('counter.title')

// 带参数({name} 占位符会被替换)
const name = sys.vars.username
document.querySelector('#greeting').textContent = sys.i18n.t('greeting', { name })

// 在通知消息中使用
sys.ui.toast(sys.i18n.t('saveSuccess'), 'success')

// 在 confirm 对话框中使用
const ok = await sys.ui.confirm(sys.i18n.t('confirmDelete'))

函数签名

typescript
sys.i18n.t(key: string, params?: Record<string, string>): string
  • key — 翻译键,不需要pluginName. 前缀,框架自动加
  • params(可选)— 普通对象 { name: '张三' },每个 {paramKey} 占位符会被 params.paramKey 的值替换

自动加前缀

sys.i18n.t('welcome') 实际查找 my_plugin.welcome。插件作者无需手写前缀,框架自动处理。同一插件的 XML 轨 {t('welcome')} 与 HTML 轨 sys.i18n.t('welcome') 行为完全一致,命中同一份 bundle。

命名空间与回退链

自动前缀

bundle 注册时,框架会在每个 locale 的内容外包一层 {pluginName: ...} 后深合并到全局 pluginMessages store:

// 输入 bundle
{
  'zh-CN': { greeting: '你好', counter: { title: '计数器' } },
  'en-US': { greeting: 'Hello', counter: { title: 'Counter' } },
}

// 注册到 pluginMessages(pluginName='my_plugin')
{
  'zh-CN': { my_plugin: { greeting: '你好', counter: { title: '计数器' } } },
  'en-US': { my_plugin: { greeting: 'Hello', counter: { title: 'Counter' } } },
}

sys.i18n.t('greeting') → 实际查找 my_plugin.greeting → 命中 pluginMessages['zh-CN'].my_plugin.greeting = 你好

回退链

pluginMessages[currentLocale]            ← 插件 bundle(含 pluginName 前缀)
  ↓ 未命中
messages[currentLocale]                  ← WebUI 内置静态 messages
  ↓ 未命中
pluginMessages[DEFAULT_LOCALE='zh-CN']   ← 插件 bundle 默认 locale
  ↓ 未命中
messages[DEFAULT_LOCALE]                 ← WebUI 默认 locale
  ↓ 未命中
返回 key 字面量

多页面同名 key 冲突

同一插件的多个页面各自注册 bundle 时,<pluginName>.<key> 命名空间共享:

  • 不同 key → 自然不冲突
  • 同 key → 后注册覆盖先注册(deepMerge 行为:对象递归合并,叶子值后者覆盖前者)

切页时框架会先 unregisterPluginI18n(prevPlugin, prevPage) 再注册新页,避免上一页 key 残留污染当前页。

生命周期与清理

事件行为
插件加载(on_plugin_loaded插件调 register_ui_page(i18n_path=...) → 后端校验 + 解析 + 缓存到 RegisteredPage.i18n
用户打开插件页面前端 PluginUIView.loadPage 拉取 schema → registerPluginI18n(pluginName, pageId, schema.i18n)
用户切换页面unregisterPluginI18n(prevPlugin, prevPage) 再注册新页
用户离开 /plugin-ui 路由onBeforeUnmountunregisterPluginI18n
后端 unregister_ui_page内存 Registry 删除条目;前端不主动感知,下次 /list 拉取时自然消失
进程重启Registry 全部清空

后端 unregister_ui_page 不会推送给前端。如果用户已打开页面、插件被卸载,前端仍会缓存 bundle 直到下次切页或离开路由。

完整示例

目录结构

my_plugin/
├── manifest.json
├── plugin.py
├── ui/
│   ├── index.html
│   ├── style.css
│   └── main.js
└── i18n/
    └── i18n.json        ← 翻译 bundle

i18n/i18n.json

json
{
  "zh-CN": {
    "title": "用户信息",
    "welcome": "欢迎使用",
    "greeting": "你好,{name}",
    "save": "保存",
    "saveSuccess": "保存成功"
  },
  "en-US": {
    "title": "User Info",
    "welcome": "Welcome",
    "greeting": "Hello, {name}",
    "save": "Save",
    "saveSuccess": "Saved successfully"
  }
}

ui/index.html

html
<sys-card title="用户信息" variant="elevated">
  <sys-vbox gap="0.75rem">
    <sys-text id="welcome" variant="headline"></sys-text>
    <sys-text id="greeting" variant="body"></sys-text>
    <sys-input id="username" label="用户名" placeholder="请输入用户名"></sys-input>
    <sys-button id="save-btn" variant="filled" icon="save">保存</sys-button>
  </sys-vbox>
</sys-card>

ui/main.js

javascript
const host = document.querySelector('.html-sandbox-host')
const root = host?.shadowRoot
const $ = (sel) => root?.querySelector(sel)

// 用 sys.i18n.t() 填充所有翻译文本
$('#welcome').textContent = sys.i18n.t('welcome')

// 带参数的翻译
function updateGreeting() {
  const name = $('#username').value || '匿名'
  $('#greeting').textContent = sys.i18n.t('greeting', { name })
}

// 按钮文字也走翻译
$('#save-btn').textContent = sys.i18n.t('save')

$('#username').addEventListener('change', updateGreeting)
updateGreeting()

$('#save-btn').addEventListener('click', async () => {
  const name = $('#username').value
  await sys.request('/my-plugin/users', {
    method: 'POST',
    body: JSON.stringify({ name }),
  })
  sys.ui.toast(sys.i18n.t('saveSuccess'), 'success')
})

plugin.py

python
async def on_plugin_loaded(self) -> None:
    ui_service = get_service("neo-mofox-webui:service:plugin_ui")
    await ui_service.register_ui_page(
        plugin_name="my_plugin",
        page_id="home",
        title="我的页面",
        mode="html",
        assets={
            "entry_html": "ui/index.html",
            "styles": ["ui/style.css"],
            "scripts": ["ui/main.js"],
        },
        i18n_path="i18n/i18n.json",
    )

切换 WebUI 语言(设置页 → 语言 → English)即可看到全部文本切换为 en-US bundle 的内容。

限制与边界

当前限制
文件大小≤ 256 KB
文件格式JSON(YAML 暂不支持)
bundle 结构单文件多 locale
命名空间强制 pluginName. 前缀(不可自定义)
卸载推送前端不主动感知后端卸载(切页时才清理)
title 字段register_ui_pagetitle 参数不参与 i18n,直接作为列表显示名

完整示例

仓库自带的 examples/demo_html_plugin/ 是一份完整可运行的 HTML 轨示例,覆盖:

  • sys.vars 响应式变量池
  • sys.request + sys-table 数据交互
  • sys-chart 图表
  • sys.ui 通知与对话框
  • sys.bus 事件总线
  • sys.theme / sys.format / sys.i18n / sys.global
  • 自定义 <sys-dialog> 控制开关

阅读源码:

  • examples/demo_html_plugin/plugin.py — 注册逻辑
  • examples/demo_html_plugin/assets/index.html — 入口 HTML
  • examples/demo_html_plugin/assets/script.js — 交互脚本
  • examples/demo_html_plugin/assets/styles.css — 插件样式

调试技巧

  • 打开浏览器控制台:沙箱会输出 [HtmlSandbox][sys] 调试日志;插件自己的 console.log 也会出现在主控制台
  • 取到 sys 对象:在控制台执行 window.__plugin_sys_<pageId> 可手动检查当前页面的 sys 桥接对象
  • 取到 Shadow DOMdocument.querySelector('.html-sandbox-host').shadowRoot 可手动查询元素
  • 改了资源没生效:HTML 资源带 Cache-Control: max-age=300,强刷一次或重启 Neo-MoFox
  • 401 错误:通常是 Token 失效,重新登录 WebUI;或检查 assets_dir 是否声明但路径不对
  • 样式不生效:检查是否用 <link> 引入了 CSS —— 沙箱不会<link>,需要把 CSS 写在 assets.styles 里,沙箱会 fetch 后注入 <style>

常见坑

  1. 布局类标签没加 sys- 前缀 — HTML 自定义元素名必须含连字符,<vbox> 会被忽略或报 SyntaxError。必须写 <sys-vbox>
  2. getAttribute('value') 取输入值 — 永远是 attribute 的初始字符串,不是用户当前输入。改用 .value
  3. CSS 用 <link> 引入<link> 不走 window.fetch,无法带 Token,会被后端 401。把 CSS 写进 assets.styles 数组。
  4. 直接 import 主站代码 — 沙箱与主站运行在不同上下文,主站模块不可见。需要的能力都通过 sys.* 暴露。
  5. <script> 写在 <head> — 沙箱会把所有 <script> 捕获并按文档顺序执行,但执行时机是在 sys 注入之后。所有脚本里都可直接用 sys
  6. 路径以 / 开头assets 里的所有路径必须是相对路径(以插件根目录为基准),写 /ui/index.html 会被拒。
  7. assets_dir 未声明却访问资源assets_dir 为空时,/asset/... 端点会返回 404 assets_dir not declared
  8. 第三方接口返回非 BaseResponsesys.request 默认按 BaseResponse 解包,第三方接口若返回其他结构会报错。这种情况请用裸 fetch(url)(fetch 代理只注入头不解包),或用 axios 自行处理。

下一步

  • HTML 组件参考 — 所有 sys-* 自定义元素的属性、事件、命令式用法
  • HTML sys APIsys 桥接对象的完整 API 参考
  • XML 入门 — 对比 XML 轨的声明式写法
  • 国际化 — 插件自定义翻译 bundle(上方章节)

贡献者

The avatar of contributor named as minecraft1024a minecraft1024a

页面历史

Released under the GPL-3.0 License.