diff --git a/backend/src/main.py b/backend/src/main.py index f5906a31..3664b17a 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -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: diff --git a/backend/src/module/api/plugins.py b/backend/src/module/api/plugins.py index d0b264fa..c4901f28 100644 --- a/backend/src/module/api/plugins.py +++ b/backend/src/module/api/plugins.py @@ -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// 获取 + 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 一样需要登录;浏览器的 `` +
diff --git a/webui/package.json b/webui/package.json index 5235a937..f1ccdfeb 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/packages/plugin-ui/package.json b/webui/packages/plugin-ui/package.json new file mode 100644 index 00000000..457275f2 --- /dev/null +++ b/webui/packages/plugin-ui/package.json @@ -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" +} diff --git a/webui/packages/plugin-ui/src/index.ts b/webui/packages/plugin-ui/src/index.ts new file mode 100644 index 00000000..81588cf9 --- /dev/null +++ b/webui/packages/plugin-ui/src/index.ts @@ -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//` */ + entry: string; + /** 按语言的标题,如 `{ 'zh-CN': '手动选种', 'en-US': 'Manual pick' }` */ + title: Record; +} + +/** 各挂载点传给组件的上下文。 */ +export interface AbSlotContext { + 'settings.section': Record; + 'bangumi.detail.tab': { bangumiId: number }; + 'bangumi.card.action': { bangumiId: number }; + page: Record; + 'dashboard.widget': Record; +} + +/** + * 带登录态的请求。不以 `/` 开头的路径相对插件自己的路由前缀 + * `/api/v1/plugins//`;以 `/api/v1/` 开头的宿主 API 只允许 GET。 + * 返回响应体(JSON 已解析),非 2xx 时 reject。 + */ +export interface AbHostApi { + get(path: string, params?: Record): Promise; + post(path: string, body?: unknown): Promise; + put(path: string, body?: unknown): Promise; + delete(path: string): Promise; +} + +export interface AbHost { + pluginId: string; + api: AbHostApi; + i18n: { + /** 当前语言,如 `zh-CN`、`en-US` */ + locale: string; + /** 宿主文案;找不到时返回 key 本身 */ + t(key: string, params?: Record): string; + }; + theme: { + mode: 'light' | 'dark'; + /** `--ab-*` 变量的当前值(见 tokens.css) */ + tokens: Record; + }; + toast(message: string, kind?: 'info' | 'error'): void; + events: { + /** 订阅宿主事件流中的 `kind`,返回取消订阅函数 */ + on(kind: string, callback: (payload: unknown) => void): () => void; + }; +} + +/** 插件元素在插入文档前由宿主设置的属性。 */ +export interface AbPluginElement + extends HTMLElement { + host: AbHost; + context: AbSlotContext[S]; +} diff --git a/webui/packages/plugin-ui/template/package.json b/webui/packages/plugin-ui/template/package.json new file mode 100644 index 00000000..f73bbbb8 --- /dev/null +++ b/webui/packages/plugin-ui/template/package.json @@ -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" + } +} diff --git a/webui/packages/plugin-ui/template/src/env.d.ts b/webui/packages/plugin-ui/template/src/env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/webui/packages/plugin-ui/template/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/webui/packages/plugin-ui/template/src/index.ts b/webui/packages/plugin-ui/template/src/index.ts new file mode 100644 index 00000000..f950950d --- /dev/null +++ b/webui/packages/plugin-ui/template/src/index.ts @@ -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 = ` + + + `; + const button = root.querySelector('button')!; + button.textContent = this.host.i18n.locale.startsWith('zh') + ? '调用插件路由' + : 'Call plugin route'; + button.addEventListener('click', async () => { + try { + // 相对路径 → /api/v1/plugins//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); diff --git a/webui/packages/plugin-ui/template/vite.config.ts b/webui/packages/plugin-ui/template/vite.config.ts new file mode 100644 index 00000000..4eb234c1 --- /dev/null +++ b/webui/packages/plugin-ui/template/vite.config.ts @@ -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, + }, +}); diff --git a/webui/packages/plugin-ui/tokens.css b/webui/packages/plugin-ui/tokens.css new file mode 100644 index 00000000..31cc4755 --- /dev/null +++ b/webui/packages/plugin-ui/tokens.css @@ -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); +} diff --git a/webui/packages/plugin-ui/tsconfig.json b/webui/packages/plugin-ui/tsconfig.json new file mode 100644 index 00000000..a5f8478e --- /dev/null +++ b/webui/packages/plugin-ui/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "isolatedModules": true, + "lib": ["ESNext", "DOM"], + "noEmit": true + }, + "include": ["src"] +} diff --git a/webui/pnpm-lock.yaml b/webui/pnpm-lock.yaml index fe959546..faa1deac 100644 --- a/webui/pnpm-lock.yaml +++ b/webui/pnpm-lock.yaml @@ -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': diff --git a/webui/pnpm-workspace.yaml b/webui/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/webui/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/webui/public/theme-init.js b/webui/public/theme-init.js new file mode 100644 index 00000000..6c6fe0ae --- /dev/null +++ b/webui/public/theme-init.js @@ -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'); +})(); diff --git a/webui/types/dts/auto-imports.d.ts b/webui/types/dts/auto-imports.d.ts index 86c63e85..88e1f0f0 100644 --- a/webui/types/dts/auto-imports.d.ts +++ b/webui/types/dts/auto-imports.d.ts @@ -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'] diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 876cf4a1..63ab0044 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -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/.*': {