Files
AstrBot/docs/zh/dev/star/guides/plugin-pages.md
Weilong Liao cb4f941e43 feat: enhance plugin page internationalization (#7998)
* feat: enhance plugin page internationalization

- Updated PluginRoute to read initial context from JWT and set it in the bridge SDK.
- Added methods to retrieve locale and plugin metadata for better i18n support.
- Enhanced pluginI18n utility to resolve page-specific translations and added new functions for page titles and descriptions.
- Modified PluginPagePage and PluginDetailPage to utilize new i18n features for dynamic content rendering.
- Improved documentation for plugin page i18n structure and usage.
- Added tests to verify the correct integration of i18n in plugin pages and context handling.

* fix test
2026-05-04 20:15:21 +08:00

389 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 插件 Pages
AstrBot 支持插件通过 `pages/` 目录暴露 Dashboard 页面。`pages/` 下的每个一级子目录都是一个独立 Page
```text
astrbot_plugin_page_demo/
├─ main.py
└─ pages/
├─ bridge-demo/
│ ├─ index.html
│ ├─ app.js
│ ├─ style.css
│ └─ assets/
│ └─ logo.svg
└─ settings/
└─ index.html
```
AstrBot 会扫描 `pages/<page_name>/index.html`;没有 `index.html` 的目录会被忽略。
如果只是让用户填写几个配置项,优先使用 [`_conf_schema.json`](./plugin-config.md)。插件 Pages 更适合复杂表单、Dashboard、日志、文件上传下载、SSE 和自定义交互流程。
一旦注册了 Pages用户可以在AstrBot WebUI 插件页中的插件卡片中,点击插件卡片进入插件详细页面,在插件详细页面中可以看到并进入注册的 Pages。
## 最小前端示例
`pages/bridge-demo/index.html`
```html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Plugin Page Demo</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<button id="ping">Ping</button>
<pre id="output"></pre>
<script type="module" src="./app.js"></script>
</body>
</html>
```
`pages/bridge-demo/app.js`
```js
const bridge = window.AstrBotPluginPage;
const output = document.getElementById("output");
const context = await bridge.ready();
output.textContent = JSON.stringify(context, null, 2);
document.getElementById("ping").addEventListener("click", async () => {
const result = await bridge.apiGet("ping");
output.textContent = JSON.stringify(result, null, 2);
});
```
这里不需要手动引入 bridge SDK。AstrBot 会在返回的 HTML 里自动插入 `/api/plugin/page/bridge-sdk.js`
## 注册后端 API
前端调用 `bridge.apiGet("ping")`Dashboard 会转发到:
```text
/api/plug/<plugin_name>/ping
```
因此注册 Web API 时,路由必须带上插件名作为前缀:
```python
from quart import jsonify
from astrbot.api.star import Context, Star
PLUGIN_NAME = "astrbot_plugin_page_demo"
class MyPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
context.register_web_api(
f"/{PLUGIN_NAME}/ping",
self.page_ping,
["GET"],
"Page ping",
)
async def page_ping(self):
return jsonify({"message": "pong"})
```
## Bridge API
插件 Page 中可直接使用 `window.AstrBotPluginPage`
- `ready()`: 等待 bridge 就绪并返回上下文
- `getContext()`: 读取当前上下文
- `getLocale()`: 读取当前 WebUI 语言
- `getI18n()`: 读取当前插件的 i18n 资源
- `t(key, fallback)`: 从插件 i18n 资源中按 key 获取文案,缺失时返回 fallback
- `onContext(handler)`: 监听上下文变化,例如 WebUI 切换语言后重新渲染页面
- `apiGet(endpoint, params)`: 发送 GET 请求
- `apiPost(endpoint, body)`: 发送 POST 请求
- `upload(endpoint, file)`: 以 `multipart/form-data` 上传单个文件
- `download(endpoint, params, filename)`: 下载后端响应
- `subscribeSSE(endpoint, handlers, params)`: 订阅 SSE
- `unsubscribeSSE(subscriptionId)`: 取消 SSE 订阅
当前 `ready()` 上下文类似:
```json
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "zh-CN",
"i18n": {}
}
```
`endpoint` 必须是插件内相对路径,不能为空,不能包含 `\`、URL scheme、query、hash也不能包含 `.``..` 路径片段。
## Page 国际化
插件 Page 复用插件 i18n 资源文件。给 `.astrbot-plugin/i18n/<locale>.json` 增加 `pages.<page_name>` 即可:
```json
{
"pages": {
"bridge-demo": {
"title": "Bridge 演示页",
"description": "演示插件页面如何读取 WebUI 语言和翻译资源。",
"heading": "插件页面",
"refresh": "重新渲染"
}
}
}
```
`title` 会用于 WebUI 外壳标题和插件详情页的 Page 组件名称,`description` 会用于插件详情页的 Page 组件描述。
在 Page 内部使用 `t()` 渲染文案,并用 `onContext()` 响应语言切换:
```js
const bridge = window.AstrBotPluginPage;
function render() {
document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo");
document.getElementById("heading").textContent = bridge.t(
"pages.bridge-demo.heading",
"Plugin Page",
);
document.getElementById("locale").textContent = bridge.getLocale();
}
await bridge.ready();
render();
bridge.onContext(render);
```
切换 WebUI 语言后Dashboard 会把新的 `locale` 和插件 i18n 资源通过 bridge 发送给 iframe只要 Page 监听了 `onContext()`,通常不需要刷新页面。
如果你的内联脚本需要同步访问 `window.AstrBotPluginPage`,请把脚本放到外部 module 文件中,或在自己的脚本前显式引入:
```html
<script src="/api/plugin/page/bridge-sdk.js"></script>
```
## 静态资源路径规则
AstrBot 会重写相对资源路径,并自动补上短期 `asset_token`。你只需要正常写相对路径,不要自己拼接 `/api/plugin/page/content/...`
AstrBot 会重写:
- HTML `src``href`
- CSS `url(...)`
- JavaScript `import`
- JavaScript `export ... from`
- JavaScript 动态 `import()`
建议把静态资源写成 `./style.css``./assets/logo.svg` 这类相对路径。不要手动追加 `asset_token`,也不要依赖 `..` 逃逸 Page 根目录。
如果你构建 SPA建议使用 hash routing。静态资源服务按真实文件路径解析history routing 刷新页面时需要对应路径上真的存在文件。
## 安全约束
插件 Pages 运行在受限 iframe 中:
```text
allow-scripts allow-forms allow-downloads
```
Page 不能直接访问 Dashboard cookies、LocalStorage 或同源 DOM也不能绕过 bridge 复用 Dashboard auth。
AstrBot 还会给资源响应添加安全头,包括:
- `X-Frame-Options: SAMEORIGIN`
- `Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'`
- `Cache-Control: no-store`
## 调试建议
- 新增或删除 Page 目录后重载插件
- 修改 `pages/<page_name>/` 下的大多数静态资源后,刷新 Page 即可
- 如果 Page 没出现,检查 `pages/<page_name>/index.html` 是否存在,以及插件是否启用
## 附录Bridge API 详解
建议在页面脚本开始处保存 bridge 引用:
```js
const bridge = window.AstrBotPluginPage;
```
### `ready()`
等待父页面发送初始上下文,返回 `Promise<context>`。页面初始化时应先等待它,避免过早读取空上下文。
```js
const context = await bridge.ready();
console.log(context.pluginName, context.pageName, context.locale);
```
上下文通常包含:
```json
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "zh-CN",
"i18n": {}
}
```
### `getContext()`
同步读取最近一次上下文。适合在 `ready()` 之后或 `onContext()` 回调中使用。
```js
function renderHeader() {
const context = bridge.getContext();
document.getElementById("title").textContent = context.pageTitle;
}
```
### `getLocale()`
同步读取当前 WebUI 语言。没有上下文时默认返回 `zh-CN`
```js
document.documentElement.lang = bridge.getLocale();
```
### `getI18n()`
同步读取当前插件的完整 i18n 资源对象。一般优先用 `t()`,只有需要自定义遍历或调试时才直接读取。
```js
console.log(Object.keys(bridge.getI18n()));
```
### `t(key, fallback)`
按点分隔 key 从插件 i18n 中取文案。当前语言缺失时会尝试回退,仍缺失则返回 `fallback`
```js
saveButton.textContent = bridge.t("pages.settings.save", "Save");
```
### `onContext(handler)`
监听上下文变化返回取消监听函数。WebUI 切换语言时会触发该回调,所以需要响应语言切换的页面应在这里重新渲染。
```js
function render() {
document.title = bridge.t("pages.settings.title", "Settings");
}
await bridge.ready();
render();
const off = bridge.onContext(render);
window.addEventListener("beforeunload", () => {
off();
});
```
### `apiGet(endpoint, params)`
向插件后端发送 GET 请求,返回 `Promise<data>``endpoint` 是插件内相对路径Dashboard 会转发到 `/api/plug/<plugin_name>/<endpoint>`
```js
const stats = await bridge.apiGet("stats", { limit: 20 });
```
后端注册示例:
```python
context.register_web_api(
f"/{PLUGIN_NAME}/stats",
self.get_stats,
["GET"],
"Get stats",
)
```
### `apiPost(endpoint, body)`
向插件后端发送 POST 请求,`body` 会作为 JSON 请求体发送,返回 `Promise<data>`
```js
await bridge.apiPost("settings/save", {
enabled: true,
threshold: 0.8,
});
```
### `upload(endpoint, file)`
`multipart/form-data` 上传单个文件,字段名为 `file`,返回 `Promise<data>`
```js
const input = document.querySelector("input[type=file]");
const file = input.files[0];
const result = await bridge.upload("files/import", file);
```
### `download(endpoint, params, filename)`
请求插件后端文件接口并触发浏览器下载。`params` 会作为 query string`filename` 可选;不传时会尝试使用响应头里的文件名。
```js
await bridge.download("files/export", { format: "json" }, "export.json");
```
### `subscribeSSE(endpoint, handlers, params)`
订阅插件后端 SSE返回 `Promise<subscriptionId>``handlers` 可包含 `onOpen``onMessage``onError`
```js
const subscriptionId = await bridge.subscribeSSE(
"events",
{
onOpen() {
console.log("SSE opened");
},
onMessage(event) {
console.log(event.raw, event.parsed, event.lastEventId);
},
onError() {
console.warn("SSE error");
},
},
{ topic: "logs" },
);
```
`event.parsed` 会在消息内容是 JSON 字符串时自动解析,否则等于原始字符串。
### `unsubscribeSSE(subscriptionId)`
取消 SSE 订阅。
```js
await bridge.unsubscribeSSE(subscriptionId);
```
页面卸载时建议清理订阅:
```js
window.addEventListener("beforeunload", () => {
bridge.unsubscribeSSE(subscriptionId);
});
```
### endpoint 规则
`apiGet``apiPost``upload``download``subscribeSSE``endpoint` 必须是插件内相对路径:
- 允许:`"stats"``"settings/save"``"files/export"`
- 不允许:空字符串、`"/stats"``"../stats"``"https://example.com"``"stats?x=1"``"stats#top"`
query 参数请通过 `params` 传递,不要拼进 `endpoint`