feat(4.0-P6): 前端插件后端与 plugin-ui 工作区包

- 清单 [[plugin.ui]]:五个挂载点、ab-plugin- 元素名与 web/ 入口校验
- GET /plugins/ui 列出已启用插件的挂载点;GET /plugins/{id}/web/{path} 提供静态资源(防路径穿越、JS MIME、需登录)
- SPA 文档加 CSP script-src 'self',内联深色模式脚本移到 theme-init.js
- webui/packages/plugin-ui:AbHost 类型、--ab-* 主题变量、Vite 库模式模板
- 设计文档第 0 节记录第一部分的调整
This commit is contained in:
Estrella Pan
2026-10-08 13:31:03 +02:00
parent a5defee676
commit bf1c8db08c
23 changed files with 627 additions and 28 deletions

View File

@@ -93,28 +93,38 @@ def posters(path: str):
return FileResponse(str(resolved))
if VERSION != "DEV_VERSION":
app.mount("/assets", StaticFiles(directory="dist/assets"), name="assets")
app.mount("/images", StaticFiles(directory="dist/images"), name="images")
# 前端插件组件与宿主同源(第 3.8 节),只允许加载本站脚本:远程脚本与内联脚本
# 都被拒绝。只加在 SPA 文档上,/docs 的 Swagger 页面依赖内联脚本与 CDN。
_SPA_HEADERS = {"Content-Security-Policy": "script-src 'self'"}
def mount_webui(app: FastAPI, dist: Path) -> None:
app.mount("/assets", StaticFiles(directory=dist / "assets"), name="assets")
app.mount("/images", StaticFiles(directory=dist / "images"), name="images")
# 自托管 Inter 字体:index.html 以 /fonts/*.woff2 预加载,不挂载则被下面的
# SPA 兜底路由回成 index.html,全站字体静默回退。在线更新可能覆盖进来
# 更旧的、没有 fonts/ 的 dist,故按存在性挂载。
if os.path.isdir("dist/fonts"):
app.mount("/fonts", StaticFiles(directory="dist/fonts"), name="fonts")
if (dist / "fonts").is_dir():
app.mount("/fonts", StaticFiles(directory=dist / "fonts"), name="fonts")
# app.mount("/icons", StaticFiles(directory="dist/icons"), name="icons")
templates = Jinja2Templates(directory="dist")
templates = Jinja2Templates(directory=dist)
# dist/ is immutable inside the container — snapshot once instead of
# hitting the filesystem on every request.
_DIST_FILES = frozenset(os.listdir("dist"))
dist_files = frozenset(os.listdir(dist))
@app.get("/{path:path}")
def html(request: Request, path: str):
if path in _DIST_FILES:
return FileResponse(f"dist/{path}")
if path in dist_files:
return FileResponse(dist / path, headers=_SPA_HEADERS)
else:
context = {"request": request}
return templates.TemplateResponse("index.html", context)
return templates.TemplateResponse(
request, "index.html", headers=_SPA_HEADERS
)
if VERSION != "DEV_VERSION":
mount_webui(app, Path("dist"))
else:

View File

@@ -3,12 +3,14 @@ import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, ValidationError
from ab_sdk import points
from module.conf import settings
from module.core import AppContext
from module.plugin.host import plugin_provider_ids
from module.plugin.manifest import UiSlot
from module.plugin.secrets import mask_options, restore_options
from module.security.api import get_current_user
@@ -49,6 +51,23 @@ class PluginsSettingsUpdate(BaseModel):
allow_unsigned: bool
class PluginUiSlot(BaseModel):
plugin_id: str
slot: UiSlot
element: str
# 相对插件根目录,经 GET /plugins/<plugin_id>/<entry> 获取
entry: str
title: dict[str, str]
# 模块脚本要求 JavaScript MIME 类型;mimetypes 的结果依赖系统配置,这里固定
_MEDIA_TYPES = {
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
}
def _overview(ctx: AppContext) -> PluginsOverview:
conf = settings.plugins
plugins = []
@@ -74,6 +93,39 @@ async def list_plugins(ctx: AppContext = Depends(get_context)):
return _overview(ctx)
@router.get("/ui", response_model=list[PluginUiSlot])
async def list_plugin_ui(ctx: AppContext = Depends(get_context)):
"""已启用插件声明的前端挂载点(清单中的 ``[[plugin.ui]]``)。"""
return [
PluginUiSlot(plugin_id=plugin_id, **ui.model_dump())
for plugin_id, ui in ctx.plugins.ui_slots()
]
@router.get("/{plugin_id}/web/{path:path}", include_in_schema=False)
async def plugin_web_file(
plugin_id: str, path: str, ctx: AppContext = Depends(get_context)
):
"""已启用插件 ``web/`` 目录下的静态文件(前端组件的 ES module 等)。
与其它 API 一样需要登录;浏览器的 ``<script type="module">`` / ``import()``
是同源请求,会带上会话 cookie。
"""
web_dir = ctx.plugins.web_dir(plugin_id)
if web_dir is not None:
root = web_dir.resolve()
target = (root / path).resolve()
# resolve() 之后再比较,``..`` 与指向目录外的符号链接都会被拒绝
if target.is_relative_to(root) and target.is_file():
return FileResponse(
target,
media_type=_MEDIA_TYPES.get(target.suffix),
# 插件升级后文件名不变,每次都按 ETag 重新验证
headers={"Cache-Control": "no-cache"},
)
raise HTTPException(status_code=404, detail="Not Found")
@router.put("/settings", response_model=PluginsOverview)
async def update_plugins_settings(
body: PluginsSettingsUpdate, ctx: AppContext = Depends(get_context)

View File

@@ -53,6 +53,8 @@ class PluginCandidate:
source: PluginSource
load: Callable[[], type[Plugin[Any]]]
unload: Callable[[], None] = lambda: None
# 插件根目录(web/ 静态资源从这里读取);pip 包不在文件系统上时为 None
root: Path | None = None
@property
def signed(self) -> bool:
@@ -124,6 +126,7 @@ def _directory_candidate(plugin_dir: Path, source: PluginSource) -> PluginCandid
source=source,
load=lambda: _load_directory(plugin_dir, manifest, package),
unload=lambda: _unload_package(package, plugin_dir),
root=plugin_dir,
)
@@ -186,9 +189,8 @@ def _unload_package(package: str, plugin_dir: Path) -> None:
def _entry_point_candidate(ep: importlib.metadata.EntryPoint) -> PluginCandidate:
top_package = ep.module.split(".", 1)[0]
try:
text = (importlib.resources.files(top_package) / MANIFEST_NAME).read_text(
encoding="utf-8"
)
package_files = importlib.resources.files(top_package)
text = (package_files / MANIFEST_NAME).read_text(encoding="utf-8")
except (ModuleNotFoundError, FileNotFoundError) as e:
raise PluginLoadError(f"{top_package} 包内缺少 {MANIFEST_NAME}") from e
manifest = parse_manifest(text, f"{top_package}/{MANIFEST_NAME}")
@@ -200,7 +202,13 @@ def _entry_point_candidate(ep: importlib.metadata.EntryPoint) -> PluginCandidate
raise PluginLoadError(f"加载 entry point {ep.value} 失败:{e}") from e
return _check_class(obj, manifest)
return PluginCandidate(manifest=manifest, source="pip", load=load)
return PluginCandidate(
manifest=manifest,
source="pip",
load=load,
# zip 等非文件系统安装没有可直接提供的 web/ 目录
root=package_files if isinstance(package_files, Path) else None,
)
# ---------------------------------------------------------------- helpers

View File

@@ -17,6 +17,7 @@ from ab_sdk.hooks import HOOK_ATTR, PROVIDER_ATTR, SUBSCRIBE_ATTR
from .bus import EventBus
from .context import PLUGIN_DATA_ROOT, HostPluginContext
from .loader import DiscoveryError, PluginCandidate, PluginLoadError, discover
from .manifest import PluginUi
from .registry import ExtensionRegistry, HookEntry, ProviderEntry
from .runner import CircuitBreaker, HookRunner
@@ -161,6 +162,22 @@ class PluginManager:
)
return result
def ui_slots(self) -> list[tuple[str, PluginUi]]:
"""已启用插件声明的前端挂载点,按插件 id 排序。"""
return [
(plugin_id, ui)
for plugin_id, active in sorted(self._active.items())
for ui in active.candidate.manifest.ui
]
def web_dir(self, plugin_id: str) -> Path | None:
"""已启用插件的 ``web/`` 目录;未启用或没有该目录时为 None。"""
active = self._active.get(plugin_id)
if active is None or active.candidate.root is None:
return None
web = active.candidate.root / "web"
return web if web.is_dir() else None
def validate_options(self, plugin_id: str, options: dict[str, Any]) -> None:
"""按插件的 config_model 校验配置(插件代码未加载过时不校验)。

View File

@@ -2,7 +2,8 @@
import re
import tomllib
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import Literal
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
@@ -15,12 +16,52 @@ from .host import CORE
MANIFEST_NAME = "plugin.toml"
_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_ENTRY_RE = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_]\w*$")
# 合法的 custom element 名(小写、含连字符),统一以 ab-plugin- 开头避免与宿主冲突
_ELEMENT_RE = re.compile(r"^ab-plugin-[a-z0-9]+(?:-[a-z0-9]+)*$")
# 前端挂载点(第 3.8 节)
UiSlot = Literal[
"settings.section",
"bangumi.detail.tab",
"bangumi.card.action",
"page",
"dashboard.widget",
]
class ManifestError(ValueError):
pass
class PluginUi(BaseModel):
"""``[[plugin.ui]]``:插件 ``web/`` 下的 ES module 定义的 custom element
挂到宿主的哪个位置。"""
slot: UiSlot
element: str = Field(description="custom element 名,须以 ab-plugin- 开头")
entry: str = Field(
description="定义该元素的 ES module,相对插件根目录,位于 web/ 下"
)
title: dict[str, str] = Field(
min_length=1, description="按语言的标题,如 {zh-CN = '…', en-US = '…'}"
)
@field_validator("element")
@classmethod
def _check_element(cls, value: str) -> str:
if not _ELEMENT_RE.match(value):
raise ValueError("element 须为以 ab-plugin- 开头的小写 custom element 名")
return value
@field_validator("entry")
@classmethod
def _check_entry(cls, value: str) -> str:
parts = PurePosixPath(value).parts
if "\\" in value or len(parts) < 2 or parts[0] != "web" or ".." in parts:
raise ValueError("entry 须为 web/ 下的相对路径,如 'web/index.js'")
return value
class PluginManifest(BaseModel):
id: str
name: str
@@ -33,6 +74,7 @@ class PluginManifest(BaseModel):
default_enabled: bool = Field(
True, description="未设置启用开关时是否默认启用;只对内置插件生效"
)
ui: list[PluginUi] = Field(default_factory=list)
@field_validator("id")
@classmethod

View File

@@ -31,6 +31,14 @@ sdk = "{sdk}"
entry = "{entry}"
"""
UI = """
[[plugin.ui]]
slot = "bangumi.detail.tab"
element = "ab-plugin-manual-pick"
entry = "web/index.js"
title = { zh-CN = "手动选种", en-US = "Manual pick" }
"""
def write_plugin(
root: Path,
@@ -95,6 +103,37 @@ class TestManifest:
with pytest.raises(ManifestError, match=message):
parse_manifest(text)
def test_ui_entries_parsed(self):
m = parse_manifest(MANIFEST.format(id="ok", sdk=">=0.1", entry="a:B") + UI)
[ui] = m.ui
assert (ui.slot, ui.element, ui.entry) == (
"bangumi.detail.tab",
"ab-plugin-manual-pick",
"web/index.js",
)
assert ui.title == {"zh-CN": "手动选种", "en-US": "Manual pick"}
assert manifest().ui == []
@pytest.mark.parametrize(
"old, new, message",
[
('"bangumi.detail.tab"', '"sidebar"', "slot"),
('"ab-plugin-manual-pick"', '"manual-pick"', "ab-plugin-"),
('"ab-plugin-manual-pick"', '"ab-plugin-Pick"', "ab-plugin-"),
('"ab-plugin-manual-pick"', '"ab-plugin-"', "ab-plugin-"),
('"web/index.js"', '"index.js"', "web/"),
('"web/index.js"', '"web/../plugin.toml"', "web/"),
('"web/index.js"', '"/web/index.js"', "web/"),
('"web/index.js"', '"web/"', "web/"),
('"web/index.js"', '"web\\\\index.js"', "web/"),
('{ zh-CN = "手动选种", en-US = "Manual pick" }', "{}", "title"),
],
)
def test_invalid_ui_entry(self, old, new, message):
text = MANIFEST.format(id="ok", sdk=">=0.1", entry="a:B") + UI.replace(old, new)
with pytest.raises(ManifestError, match=message):
parse_manifest(text)
# ---------------------------------------------------------------- registry

View File

@@ -0,0 +1,153 @@
"""前端插件(第 3.8 节):UI 挂载点列表、web/ 静态资源与 SPA 的 CSP。"""
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import main
from ab_sdk import Plugin
from module.api import v1
from module.api.deps import get_context
from module.models.config import Plugins
from module.plugin.loader import PluginCandidate
from module.plugin.manager import PluginManager
from module.plugin.manifest import parse_manifest
from module.security.api import get_current_user
MANIFEST = """
[plugin]
id = "{id}"
name = "Demo"
version = "1.0.0"
sdk = ">=0.1,<1"
entry = "x:Y"
[[plugin.ui]]
slot = "bangumi.detail.tab"
element = "ab-plugin-{id}"
entry = "web/index.js"
title = {{ zh-CN = "演示", en-US = "Demo" }}
"""
class DemoPlugin(Plugin):
pass
def candidate(root: Path, plugin_id: str = "demo") -> PluginCandidate:
web = root / plugin_id / "web"
web.mkdir(parents=True)
(web / "index.js").write_text("export const ok = 1;\n")
(web / "style.css").write_text(":host{}\n")
(root / plugin_id / "plugin.toml").write_text("secret = 1\n")
return PluginCandidate(
manifest=parse_manifest(MANIFEST.format(id=plugin_id)),
source="builtin",
load=lambda: DemoPlugin,
root=root / plugin_id,
)
@pytest.fixture
async def manager(tmp_path):
settings_obj = SimpleNamespace(plugins=Plugins(enabled={"off": False}))
candidates = [candidate(tmp_path, "demo"), candidate(tmp_path, "off")]
mgr = PluginManager(
settings_obj,
discover_fn=lambda: (candidates, []),
data_root=tmp_path / "data",
)
await mgr.start()
yield mgr
await mgr.stop()
@pytest.fixture
def client(manager):
app = FastAPI()
app.include_router(v1, prefix="/api")
app.dependency_overrides[get_context] = lambda: SimpleNamespace(plugins=manager)
async def user():
return "testuser"
app.dependency_overrides[get_current_user] = user
return TestClient(app)
class TestManager:
async def test_ui_slots_and_web_dir_only_for_active_plugins(
self, manager, tmp_path
):
assert [(pid, ui.element) for pid, ui in manager.ui_slots()] == [
("demo", "ab-plugin-demo")
]
assert manager.web_dir("demo") == tmp_path / "demo" / "web"
assert manager.web_dir("off") is None
await manager._deactivate("demo")
assert manager.ui_slots() == []
assert manager.web_dir("demo") is None
class TestApi:
def test_list_ui_slots(self, client):
resp = client.get("/api/v1/plugins/ui")
assert resp.status_code == 200
assert resp.json() == [
{
"plugin_id": "demo",
"slot": "bangumi.detail.tab",
"element": "ab-plugin-demo",
"entry": "web/index.js",
"title": {"zh-CN": "演示", "en-US": "Demo"},
}
]
@pytest.mark.parametrize(
"path, media_type",
[("index.js", "text/javascript"), ("style.css", "text/css")],
)
def test_serves_web_file_with_mime_type(self, client, path, media_type):
resp = client.get(f"/api/v1/plugins/demo/web/{path}")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith(media_type)
assert resp.headers["cache-control"] == "no-cache"
@pytest.mark.parametrize(
"url",
[
# httpx 会把字面的 ../ 规范化掉,用百分号编码让它原样到达路由
"/api/v1/plugins/demo/web/%2e%2e%2fplugin.toml",
# 绝对路径会让 root / path 直接变成该路径
"/api/v1/plugins/demo/web//etc/hosts",
"/api/v1/plugins/demo/web/missing.js",
"/api/v1/plugins/demo/web/",
"/api/v1/plugins/off/web/index.js",
"/api/v1/plugins/unknown/web/index.js",
],
)
def test_outside_web_dir_or_inactive_is_404(self, client, url):
assert client.get(url).status_code == 404
def test_requires_auth(self, client):
del client.app.dependency_overrides[get_current_user]
assert client.get("/api/v1/plugins/demo/web/index.js").status_code == 401
assert client.get("/api/v1/plugins/ui").status_code == 401
class TestSpaCsp:
@pytest.mark.parametrize("path", ["", "bangumi", "sw.js"])
def test_spa_responses_carry_csp(self, tmp_path, path):
for name in ("assets", "images"):
(tmp_path / name).mkdir()
(tmp_path / "index.html").write_text("<html></html>")
(tmp_path / "sw.js").write_text("")
app = FastAPI()
main.mount_webui(app, tmp_path)
resp = TestClient(app).get(f"/{path}")
assert resp.status_code == 200
assert resp.headers["content-security-policy"] == "script-src 'self'"

View File

@@ -161,6 +161,18 @@ slots 解析:
- 多实例时修改某个实例的主机地址,该实例上进行中的版本替换事务找不到(`_downloader_type` 键含主机哈希)。可选做法:键改为 `<type>:<instance_id>` 并兼容旧键查询,或按「键不属于任何已配置实例」把孤儿事务交给同类型的唯一实例。
- 删除规则(删除文件)时某个实例不可用:该实例上的种子不删,种子行却已删除,之后无法重试。可选做法:保留失败实例的种子行,或先删种子再删行(与「仅删数据库不依赖下载器」冲突)。
### 实施中的调整(P6 第一部分:后端与 `@autobangumi/plugin-ui` 包)
- **清单 `[[plugin.ui]]`** 由 `module/plugin/manifest.py` 的 `PluginUi` 校验:`slot` 取五个挂载点之一,`element` 须匹配 `^ab-plugin-[a-z0-9]+(-[a-z0-9]+)*$`,`entry` 须是 `web/` 下的相对路径(不含 `..`、反斜杠),`title` 至少一种语言。校验失败时整个清单被拒绝,与其它清单字段一致。
- **挂载点列表用新路由 `GET /api/v1/plugins/ui`**(不改 `GET /plugins` 的形状),只列已启用插件,按插件 id 排序。`PluginCandidate` 新增 `root`(目录插件与 pip 包在文件系统上时有值;zip 安装的 pip 包为 None,没有 `web/` 可提供)。
- **静态资源 `GET /api/v1/plugins/{id}/web/{path}`** 用 `FileResponse` 提供,只读已启用插件的 `web/` 目录;`resolve()` 后用 `is_relative_to` 判定,`..`、绝对路径、指向目录外的符号链接和未启用插件都返回 404。`.js` / `.mjs` 固定为 `text/javascript`(不依赖系统 `mimetypes`),带 `Cache-Control: no-cache`(插件升级后文件名不变,按 ETag 重新验证)。
- **鉴权沿用路由器级的 `get_current_user`**。WebUI 用 HttpOnly 会话 cookie(`token`,`SameSite=Strict`,路径 `/`)登录,同源的 `<script type="module">` 与 `import()` 会带上它,无需 header。此路由必须注册在 `plugin_routes_router` 的分发路由之前(`api/__init__.py` 已是这个顺序);因此插件自己的 `api_router` 不能使用 `web/` 前缀。
- **CSP `script-src 'self'` 只加在 SPA 文档上**(`index.html`、`sw.js` 等 dist 根文件),不加在 API 与 `/docs`:Swagger 页面依赖内联脚本与 CDN。`main.py` 的 SPA 挂载抽成 `mount_webui(app, dist)` 以便测试。
- **`index.html` 的内联深色模式脚本移到 `public/theme-init.js`**,否则 CSP 会拦下它。在线更新若换入旧版 dist(仍带内联脚本),只会失去首屏深色模式的预先应用(页面载入后 `useDarkMode` 仍会设置),不影响功能。构建产物 `dist/index.html` 经 headless Chrome 验证:登录页在该 CSP 下正常渲染。
- **Vite 只给 `preview` 加 CSP,不给 dev**:dev 服务器与 `vite-plugin-pwa` 的开发态都会注入内联模块脚本,加了会拦下它们。
- **`@autobangumi/plugin-ui` 是 pnpm 工作区包**(`webui/pnpm-workspace.yaml`,`private`,不发布),WebUI 以 `workspace:*` 作为 devDependency。内容:`src/index.ts`(`AbHost`、`PluginUiSlot`、各挂载点的 `AbSlotContext`、`AbPluginElement`)、`tokens.css`(`--ab-*` 变量,取值回落到宿主的 `--color-*` 等设计令牌,深浅色随宿主切换)、`template/`(Vite 库模式:单文件自包含 ES module,输出到 `../web/index.js`,把 `tokens.css` 以 `?inline` 放进 Shadow DOM)。宿主侧的挂载、`AbHost` 实现与错误边界在第二部分。
- **`AbHost.api` 的范围**:不以 `/` 开头的路径相对 `/api/v1/plugins/<id>/`;以 `/api/v1/` 开头的宿主 API 只允许 GET(第 3.8 节的「公开只读 API」)。接口形状为暂定,4.0 期间可能调整。
## 1. 背景与目标
AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。

View File

@@ -45,18 +45,7 @@
}
</style>
<title>AutoBangumi</title>
<script>
// Apply dark mode before render to prevent flash
(function () {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
</script>
<script src="/theme-init.js"></script>
</head>
<body>
<div id="app"></div>

View File

@@ -38,6 +38,7 @@
},
"devDependencies": {
"@antfu/eslint-config": "^0.38.6",
"@autobangumi/plugin-ui": "workspace:*",
"@icon-park/vue-next": "^1.4.2",
"@iconify-json/simple-icons": "1.2.89",
"@intlify/unplugin-vue-i18n": "^0.11.0",

View File

@@ -0,0 +1,12 @@
{
"name": "@autobangumi/plugin-ui",
"type": "module",
"version": "0.1.0",
"private": true,
"description": "AutoBangumi 前端插件组件的宿主接口类型与主题变量(工作区包,不发布)",
"exports": {
".": "./src/index.ts",
"./tokens.css": "./tokens.css"
},
"types": "./src/index.ts"
}

View File

@@ -0,0 +1,78 @@
/**
* AutoBangumi 前端插件组件的宿主接口(设计文档第 3.8 节)。
*
* 插件在 `web/` 下提供 ES module,定义清单 `[[plugin.ui]]` 中声明的 custom
* element。宿主创建元素后先设置 `host` 与 `context` 两个属性,再插入文档。
* 组件只通过 `host` 与宿主交互,不直接使用宿主的 axios、store 或全局变量。
*
* 4.0 期间(ab_sdk 0.x)这些接口可能调整。
*/
/** 前端挂载点。 */
export type UiSlot =
| 'settings.section'
| 'bangumi.detail.tab'
| 'bangumi.card.action'
| 'page'
| 'dashboard.widget';
/** `GET /api/v1/plugins/ui` 的一项:已启用插件声明的挂载点。 */
export interface PluginUiSlot {
plugin_id: string;
slot: UiSlot;
/** custom element 名,以 `ab-plugin-` 开头 */
element: string;
/** 相对插件根目录(`web/...`),模块地址为 `/api/v1/plugins/<plugin_id>/<entry>` */
entry: string;
/** 按语言的标题,如 `{ 'zh-CN': '手动选种', 'en-US': 'Manual pick' }` */
title: Record<string, string>;
}
/** 各挂载点传给组件的上下文。 */
export interface AbSlotContext {
'settings.section': Record<string, never>;
'bangumi.detail.tab': { bangumiId: number };
'bangumi.card.action': { bangumiId: number };
page: Record<string, never>;
'dashboard.widget': Record<string, never>;
}
/**
* 带登录态的请求。不以 `/` 开头的路径相对插件自己的路由前缀
* `/api/v1/plugins/<pluginId>/`;以 `/api/v1/` 开头的宿主 API 只允许 GET。
* 返回响应体(JSON 已解析),非 2xx 时 reject。
*/
export interface AbHostApi {
get<T = unknown>(path: string, params?: Record<string, unknown>): Promise<T>;
post<T = unknown>(path: string, body?: unknown): Promise<T>;
put<T = unknown>(path: string, body?: unknown): Promise<T>;
delete<T = unknown>(path: string): Promise<T>;
}
export interface AbHost {
pluginId: string;
api: AbHostApi;
i18n: {
/** 当前语言,如 `zh-CN`、`en-US` */
locale: string;
/** 宿主文案;找不到时返回 key 本身 */
t(key: string, params?: Record<string, unknown>): string;
};
theme: {
mode: 'light' | 'dark';
/** `--ab-*` 变量的当前值(见 tokens.css) */
tokens: Record<string, string>;
};
toast(message: string, kind?: 'info' | 'error'): void;
events: {
/** 订阅宿主事件流中的 `kind`,返回取消订阅函数 */
on(kind: string, callback: (payload: unknown) => void): () => void;
};
}
/** 插件元素在插入文档前由宿主设置的属性。 */
export interface AbPluginElement<S extends UiSlot = UiSlot>
extends HTMLElement {
host: AbHost;
context: AbSlotContext[S];
}

View File

@@ -0,0 +1,14 @@
{
"name": "ab-plugin-web",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "vite build"
},
"devDependencies": {
"@autobangumi/plugin-ui": "link:../",
"typescript": "^4.9.5",
"vite": "^4.5.5"
}
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,49 @@
import type { AbPluginElement } from '@autobangumi/plugin-ui';
import tokens from '@autobangumi/plugin-ui/tokens.css?inline';
// 元素名须与清单 [[plugin.ui]] 的 element 一致,并以 ab-plugin- 开头
const ELEMENT = 'ab-plugin-example';
class ExampleElement
extends HTMLElement
implements AbPluginElement<'settings.section'>
{
// 宿主在插入文档前设置;用 declare 不生成类字段,否则构造时会被重置为 undefined
declare host: AbPluginElement['host'];
declare context: AbPluginElement<'settings.section'>['context'];
connectedCallback() {
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>
${tokens}
button {
padding: 6px 12px;
border: none;
border-radius: var(--ab-radius-sm);
background: var(--ab-color-primary);
color: #fff;
font: inherit;
cursor: pointer;
}
</style>
<button type="button"></button>
`;
const button = root.querySelector('button')!;
button.textContent = this.host.i18n.locale.startsWith('zh')
? '调用插件路由'
: 'Call plugin route';
button.addEventListener('click', async () => {
try {
// 相对路径 → /api/v1/plugins/<plugin_id>/ping
await this.host.api.get('ping');
this.host.toast('OK');
} catch (e) {
this.host.toast(String(e), 'error');
}
});
}
}
if (!customElements.get(ELEMENT))
customElements.define(ELEMENT, ExampleElement);

View File

@@ -0,0 +1,23 @@
// 前端插件组件的 Vite 库模式模板。
//
// 把本目录复制到插件根目录下(如 my-plugin/web-src/),把 package.json 中
// @autobangumi/plugin-ui 的 link: 路径改为指向 webui/packages/plugin-ui,
// 然后 `pnpm install && pnpm build`,产物写到插件的 web/index.js,
// 清单中对应写 `entry = "web/index.js"`。
//
// 产物必须是单个自包含的 ES module:浏览器里没有裸模块名解析,CSP
// (script-src 'self')也不允许 import map 等内联脚本与远程脚本,
// 所以依赖全部打包进来,不设 external。
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: 'src/index.ts',
formats: ['es'],
fileName: () => 'index.js',
},
outDir: '../web',
emptyOutDir: true,
},
});

View File

@@ -0,0 +1,67 @@
/*
* 插件组件的主题变量:在 Shadow DOM 内引入(见 template/src/index.ts)。
* --ab-* 是对插件稳定的名字,取宿主当前的设计令牌(webui/src/style/var.scss),
* CSS 变量会穿过 Shadow DOM 继承,深浅色切换自动生效。括号中的浅色值只在
* 宿主之外单独预览组件时使用。
*/
:host {
--ab-color-primary: var(--color-primary, #6c4ab6);
--ab-color-primary-hover: var(--color-primary-hover, #563a92);
--ab-color-primary-light: var(--color-primary-light, #e8def8);
--ab-color-primary-alpha: var(--color-primary-alpha, rgba(108, 74, 182, 0.2));
--ab-color-accent: var(--color-accent, #f97316);
--ab-color-success: var(--color-success, #22c55e);
--ab-color-danger: var(--color-danger, #ef4444);
--ab-color-warning: var(--color-warning, #f59e0b);
--ab-color-bg: var(--color-bg, #fafafa);
--ab-color-surface: var(--color-surface, #ffffff);
--ab-color-surface-hover: var(--color-surface-hover, #f5f5f5);
--ab-color-surface-2: var(--color-surface-2, #f1f4f8);
--ab-color-text: var(--color-text, #1e293b);
--ab-color-text-secondary: var(--color-text-secondary, #64748b);
--ab-color-text-muted: var(--color-text-muted, #94a3b8);
--ab-color-border: var(--color-border, #e2e8f0);
--ab-color-border-hover: var(--color-border-hover, #cbd5e1);
--ab-radius-sm: var(--radius-sm, 4px);
--ab-radius-md: var(--radius-md, 8px);
--ab-radius-lg: var(--radius-lg, 12px);
--ab-radius-full: var(--radius-full, 9999px);
--ab-shadow-sm: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
--ab-shadow-md: var(
--shadow-md,
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1)
);
--ab-transition-fast: var(--transition-fast, 150ms ease-out);
--ab-transition-normal: var(--transition-normal, 200ms ease-out);
--ab-font-family: var(
--font-family,
'Inter',
-apple-system,
'Noto Sans SC',
'Microsoft YaHei',
system-ui,
sans-serif
);
--ab-font-mono: var(
--font-mono,
ui-monospace,
'SF Mono',
SFMono-Regular,
Menlo,
Consolas,
monospace
);
/* 基础样式:字体与颜色本会从宿主继承,这里写明是为了单独预览时一致 */
display: block;
font-family: var(--ab-font-family);
color: var(--ab-color-text);
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"isolatedModules": true,
"lib": ["ESNext", "DOM"],
"noEmit": true
},
"include": ["src"]
}

5
webui/pnpm-lock.yaml generated
View File

@@ -45,6 +45,9 @@ importers:
'@antfu/eslint-config':
specifier: ^0.38.6
version: 0.38.6(eslint@8.57.1)(typescript@4.9.5)
'@autobangumi/plugin-ui':
specifier: workspace:*
version: link:packages/plugin-ui
'@icon-park/vue-next':
specifier: ^1.4.2
version: 1.4.2(vue@3.5.8(typescript@4.9.5))
@@ -157,6 +160,8 @@ importers:
specifier: ^1.8.27
version: 1.8.27(typescript@4.9.5)
packages/plugin-ui: {}
packages:
'@ampproject/remapping@2.3.0':

View File

@@ -0,0 +1,2 @@
packages:
- packages/*

View File

@@ -0,0 +1,7 @@
// 渲染前应用深色模式,避免闪烁。CSP 为 script-src 'self',不能写成内联脚本。
(function () {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && prefersDark))
document.documentElement.classList.add('dark');
})();

View File

@@ -120,6 +120,7 @@ declare global {
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDarkMode: typeof import('../../src/hooks/useDarkMode')['useDarkMode']
const useDownloaderInstances: typeof import('../../src/hooks/useDownloaderInstances')['useDownloaderInstances']
const useDownloaderStore: typeof import('../../src/store/downloader')['useDownloaderStore']
const useEventStream: typeof import('../../src/hooks/useEventStream')['useEventStream']
const useI18n: typeof import('vue-i18n')['useI18n']

View File

@@ -10,6 +10,10 @@ import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';
import { VitePWA } from 'vite-plugin-pwa';
import VueJsx from '@vitejs/plugin-vue-jsx';
// 与后端给 SPA 文档加的响应头一致(backend/src/main.py)。只加在 preview:
// dev 服务器与 vite-plugin-pwa 的开发态会注入内联脚本,会被这个策略拦下
const headers = { 'Content-Security-Policy': "script-src 'self'" };
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
base: './',
@@ -115,6 +119,7 @@ export default defineConfig(({ mode }) => ({
'#': resolve(__dirname, 'types'),
},
},
preview: { headers },
server: {
proxy: {
'^/api/.*': {