diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ab890f..ba5b9496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ - **修复(在线更新)**:在线更新包带上 `ab_sdk`,4.0 镜像启动时与 `module` 一起替换;更新包要求镜像不低于 4.0.0-beta.1,已安装 3.3 的用户在 beta 通道不会应用 4.x 更新包(缺少 `ab_sdk` 与新依赖,应用后无法启动),需要拉取 4.0 镜像 - **修复(多下载器)**:删除规则并删除文件时,若某个下载器不可用,规则与种子记录会保留并提示稍后重试;此前规则和种子记录已先删除,该下载器上的种子之后无法再删 - **修复(多下载器)**:aria2 任务与番剧的关联按下载器实例区分,两个 aria2 实例分配到相同 gid 时不再互相覆盖,同一种子也可以分别加入两个实例;插件下载器的工厂参数 `DownloaderConnection` 新增 `instance_id` +- **新增(插件目录界面)**:设置 → 插件 新增「插件目录」:浏览签名目录中的插件并安装、更新;目录安装的插件可在卡片上卸载(内置、本地、pip 与 LLM 提供商插件不提供卸载)。插件卡片标出已签名或未签名。目录不可达时在区内显示原因,不弹全局提示 +- **修复(插件设置)**:经签名目录安装的插件在插件卡片上的来源显示为未翻译的 `config.plugins_set.source_catalog` - **修复(插件安装)**:`DELETE /api/v1/plugins/{id}` 不再能卸载 LLM 提供商插件;安装通用插件后,LLM 插件列举不再反复输出 `Skipping broken plugin` 警告 - **破坏性变更(升级路径)**:只支持从 3.3.x 升级。检测到更早版本的数据(`config/version.info` 低于 3.3,或残留 2.x 的 `data/data.json`)时拒绝启动,并提示先升级到最新 3.3.x 启动一次完成迁移 - **破坏性变更(API)**:移除 3.2 兼容的 GET 控制端点(`/api/v1/restart`、`/start`、`/stop`、`/shutdown`)和 `GET /api/v1/auth/refresh_token`,请改用 POST diff --git a/docs/dev/plugins/signing.md b/docs/dev/plugins/signing.md index 39fd07fd..23d1a17a 100644 --- a/docs/dev/plugins/signing.md +++ b/docs/dev/plugins/signing.md @@ -49,8 +49,8 @@ AB 从 GitHub release `plugins` 下载 `catalog.json`。目录包含: 签名目录的插件只能依赖标准库和 AB 已有的包,不做 pip 安装。需要第三方纯 Python 库时放进 `vendor/`。 -::: warning WebUI 尚无目录界面 -4.0 预览期间,目录的浏览和安装只有 API,设置 → 插件 页还没有对应的按钮。 +::: tip 在 WebUI 中安装 +在 设置 → 插件 的「插件目录」区点击「浏览目录」,可以安装、更新目录中的插件。经目录安装的插件卡片上有「卸载」按钮;内置、本地和 pip 插件没有。 ::: API: diff --git a/docs/en/dev/plugins/signing.md b/docs/en/dev/plugins/signing.md index 124192b3..697b31e6 100644 --- a/docs/en/dev/plugins/signing.md +++ b/docs/en/dev/plugins/signing.md @@ -49,8 +49,8 @@ After these checks, AB unpacks the plugin to `config/plugins///`, w A catalog plugin can depend only on the standard library and on packages that AB already has. AB does not run pip. Put third-party pure-Python libraries in `vendor/`. -::: warning The WebUI has no catalog page yet -During the 4.0 preview, you can browse and install the catalog with the API only. The Settings → Plugins page has no buttons for it. +::: tip Install from the WebUI +In Settings → Plugins, go to the "Plugin catalog" area and select "Browse catalog". You can then install and update catalog plugins. A plugin installed from the catalog has an "Uninstall" button on its card. Built-in, local and pip plugins do not have one. ::: API: diff --git a/docs/ja/dev/plugins/signing.md b/docs/ja/dev/plugins/signing.md index d904e044..2eb95b98 100644 --- a/docs/ja/dev/plugins/signing.md +++ b/docs/ja/dev/plugins/signing.md @@ -49,8 +49,8 @@ AB は GitHub release `plugins` から `catalog.json` をダウンロードし カタログのプラグインが依存できるのは、標準ライブラリと AB に既にあるパッケージだけです。AB は pip を実行しません。サードパーティの純 Python ライブラリは `vendor/` に入れてください。 -::: warning WebUI にはまだカタログの画面がありません -4.0 のプレビュー期間中、カタログの閲覧とインストールは API だけです。設定 → プラグインのページには対応するボタンがありません。 +::: tip WebUI からインストールする +設定 → プラグインの「Plugin catalog」(プラグインカタログ)で「Browse catalog」を選ぶと、カタログのプラグインをインストール・更新できます。カタログからインストールしたプラグインのカードには「Uninstall」ボタンがあります。組み込み、ローカル、pip のプラグインにはありません。 ::: API: diff --git a/docs/plans/2026-10-08-plugin-architecture-4.0-design.md b/docs/plans/2026-10-08-plugin-architecture-4.0-design.md index 05b764c4..9ee7fa8c 100644 --- a/docs/plans/2026-10-08-plugin-architecture-4.0-design.md +++ b/docs/plans/2026-10-08-plugin-architecture-4.0-design.md @@ -248,7 +248,7 @@ bark / wecom 旧字段别名: - `docs/dev/plugins.md` 保留为总览与导航(侧边栏链接不变),旧版 529 行的内容拆为 `plugins/` 下的页面:核心概念、配置表单、事件、前端挂载点、命令行、签名与分发、内置插件、示例,以及 `points/` 下每个扩展点一页(16 页:`mcp_tool` 与 `mcp_resource` 合一页,其余一点一页)。每种语言 25 个文件,侧边栏由 `docs/.vitepress/config.ts` 的一张页面表生成。`vitepress build` 通过(含死链检查)。 - **旧文档里与现状不符的地方一并改正**:下载器不再是 `downloader.type` 而是 `plugins.instances[].provider`;`hardlink` 的 `path_map` 已有对象数组表单;补链按钮已在 P6 提供;`file.renamed` / `torrent.organized` 加上 `downloader_id`。 -- 签名与分发页如实写明:WebUI 的设置 → 插件页**还没有**目录浏览与安装按钮(只有 API),以及上架流程只有持有签名私钥的维护者能执行。「提 issue 申请上架」是文档里写的临时约定,没有对应的自动化。 +- 签名与分发页如实写明:WebUI 的设置 → 插件页**还没有**目录浏览与安装按钮(只有 API;4.0 收尾时已补上界面,文档同步改写),以及上架流程只有持有签名私钥的维护者能执行。「提 issue 申请上架」是文档里写的临时约定,没有对应的自动化。 插件作者 skill(`skills/autobangumi-plugin/`):`SKILL.md`(触发描述、流程、扩展点速查表、易错规则)加 `references/` 三份(扩展点细节、测试 / CLI / 清单、前端)。内容面向模型,英文,与文档同源但不逐字复制。 @@ -266,9 +266,16 @@ bark / wecom 旧字段别名: - **通用插件与 LLM 插件共用 `config/plugins//` 的两处隔离**:LLM 注册表扫描跳过版本目录里是 `plugin.toml` 的目录(原先每次列举都对它打 `Skipping broken plugin` 警告);通用安装器卸载时要求 `installed.json` 指向的版本目录含 `plugin.toml`,不能再通过 `DELETE /plugins/{id}` 删掉 LLM 插件(其凭据清理在 LLM 安装器里,不会被跳过)。 - **在线更新 bundle 带上 `ab_sdk`(PR 说明中的选项 2)**。`build.yml` 把 `backend/src/ab_sdk` 打进 bundle,`min_image_version` 设为 `4.0.0-beta.1`;`boot_overlay.py` 要求已验签 bundle 同时有 `module` 与 `ab_sdk` 两棵树,先换 `/app/ab_sdk` 再换 `/app/module`,缺一棵即不应用。已发布的 3.3 镜像在 beta 通道会选中最新预发布,但 3.3 的更新器在应用时检查 `min_image_version`(与 4.0 的代码相同,已有测试覆盖),拒绝后不留存 bundle,3.3 的 `boot_overlay` 因此不会应用它。两次替换不是一个事务:`ab_sdk` 换完而 `module` 失败时留下新 SDK 与旧 module。 +插件目录界面(4.0 收尾): + +- 设置 → 插件卡片底部新增「插件目录」区:点「浏览目录」才请求 `GET /plugins/catalog`(要访问 GitHub,不在打开设置页时请求),请求带 `silent`,失败时在区内显示后端给出的原因并可刷新重试,不弹全局提示。每个条目按本机已装版本显示「安装」「更新」或「已安装」;安装与更新走同一接口,成功后用返回的插件列表刷新卡片,并刷新 Provider 下拉与前端挂载点(与启停相同)。 +- 已安装插件的卡片显示「已签名」或「未签名」标记。「卸载」按钮只出现在来源为 `catalog` 的插件上,并经危险确认框。这与后端的卸载前提是同一条件:加载器只把 `installed.json` 指向含 `plugin.toml` 版本目录的插件判为 `catalog`,卸载接口也只接受这种目录。LLM 提供商插件不在 `GET /plugins` 中,仍在 LLM 设置里安装和卸载。目录插件视为已签名,与 `allow_unsigned` 无关,界面上的说明文字写明了这一点。 +- 修复:前端类型的 `PluginInfo.source` 缺少 `catalog`,两种语言都缺少 `source_catalog`,目录安装的插件在卡片上显示为未翻译的 key。 +- 版本比较只看「已装版本是否等于目录版本」,不等即显示「更新」,目录版本更低时也是如此。不在界面上预先检查 `min_ab_version`,由安装接口的 400 返回原因。 + 未做(第一部分已列出,本部分也没有做): -- **插件管理页**:设置 → 插件页没有目录浏览、安装 / 卸载按钮,也没有 `dev_mode` 提示;`GET /plugins` 仍不返回 `dev_mode`。任务清单不含它,推迟到发布阶段前补。 +- **插件管理页**(目录部分已补,见下一节):仍没有 `dev_mode` 提示,`GET /plugins` 仍不返回 `dev_mode`。 - **模板仓库**(含前端模板的独立 GitHub 仓库):前端模板已在 `webui/packages/plugin-ui/template/`,`ab-plugin new` 覆盖后端骨架,独立仓库没有创建。 - **脚手架生成的 `pyproject.toml` 依赖 `autobangumi-sdk`** 但没有 uv 源:独立作者要等轮子上了 release 才能 `uv run pytest`。文档的上手步骤用 `uv tool install` 本地轮子文件绕过。 @@ -823,7 +830,7 @@ organize: downloader.completed → media_files.classify → file_parser | **P4 流水线插件化:organize** | `media_files`、`file_parser`、`rename_strategy`(含 `template`)、`conflict_policy`、`file.renamed` 等事件 | 已完成:`renamer.py` 拆出 `revision_saga.py`;`rename_strategy` / `media_files` / `conflict_policy` 扩展点与 `file.renamed` / `torrent.organized` 事件;内置插件 `rename`(pn / advance / template,pn / advance / none 输出与 3.3 一致)、`hardlink`(默认停用)与 `media-server-refresh`。`file_parser`、`RenameStrategyContract`、补链设置按钮(P6)推迟,调整见第 0 节 | | **P5 事件与外部接口** | SSE 改订阅 bus;`api_router`、`mcp_tool` 扩展点;`message_template` | 已完成:系统事件上总线、通知中心 SSE 改为事件推送、插件路由 / MCP 工具与资源 / 通知模板;status 等快照类 SSE 仍按节拍采样。调整见第 0 节 | | **P6 前端插件** | Web Component 挂载点、`AbHost` 桥接、错误边界、`/plugins//web` 静态资源、`@autobangumi/plugin-ui` 包 | 已完成:五个挂载点、`AbHost`、错误边界与 CSP;示例插件「手动选种」(`examples/plugins/manual-pick`)以详情页标签形式可用,内置 `hardlink` 的补链按钮走 `settings.section`;未启用插件的配置表单、对象数组表单、SSE `bus` 帧。调整见第 0 节 | -| **P7 生态** | 插件管理页(安装、启停、日志、错误)、签名目录发布流程、模板仓库(含前端模板)、`ab-plugin` CLI、文档(中 / 英 / 日) | 6 个以上示例插件上架。已完成:`autobangumi-sdk` 轮子、`ab-plugin` CLI(new / validate / pack / dev)、`dev_mode` 文件监听、四个契约套件、签名目录来源(`plugins` tag、`catalog` 加载来源、安装 API、发布脚本)、bark / wecom 旧字段迁移;6 个示例插件(CI 逐个运行)、中 / 英 / 日文档(总览加 24 页)、插件作者 skill、release 附带 SDK 轮子与 skill。**管理页的目录浏览 / 安装按钮与独立模板仓库未做**,留到 P8 前补,调整见第 0 节 | +| **P7 生态** | 插件管理页(安装、启停、日志、错误)、签名目录发布流程、模板仓库(含前端模板)、`ab-plugin` CLI、文档(中 / 英 / 日) | 6 个以上示例插件上架。已完成:`autobangumi-sdk` 轮子、`ab-plugin` CLI(new / validate / pack / dev)、`dev_mode` 文件监听、四个契约套件、签名目录来源(`plugins` tag、`catalog` 加载来源、安装 API、发布脚本)、bark / wecom 旧字段迁移;6 个示例插件(CI 逐个运行)、中 / 英 / 日文档(总览加 24 页)、插件作者 skill、release 附带 SDK 轮子与 skill。管理页的目录浏览 / 安装 / 更新 / 卸载已在 4.0 收尾时补上;**独立模板仓库与 `dev_mode` 提示未做**,调整见第 0 节 | | **P8 发布** | beta 测试、性能对比(RSS 刷新耗时、内存)、升级指南、`docs/changelog/4.0.md` | `4.0.0-beta.1` → `4.0.0` | 阶段依赖:P0 → P1 → P2 → (P2.5 ∥ P3 ∥ P4) → P5 → (P6 ∥ P7) → P8。P2.5、P3、P4 可并行,P6 依赖 P5 的 `api_router` 与事件总线。 diff --git a/webui/src/api/__tests__/plugins.test.ts b/webui/src/api/__tests__/plugins.test.ts index 6f3bce95..d31d6836 100644 --- a/webui/src/api/__tests__/plugins.test.ts +++ b/webui/src/api/__tests__/plugins.test.ts @@ -50,3 +50,31 @@ describe('Plugins API contract (path + HTTP method)', () => { expect(axios.get).toHaveBeenCalledWith('api/v1/plugins/providers'); }); }); + +describe('Plugins API contract (signed catalog)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should GET api/v1/plugins/catalog silently when browsing', async () => { + (axios.get as any).mockResolvedValue({ data: [] }); + expect(await apiPlugins.catalog()).toEqual([]); + expect(axios.get).toHaveBeenCalledWith('api/v1/plugins/catalog', { + silent: true, + }); + }); + + it('should POST api/v1/plugins/:id/install with an encoded id', async () => { + (axios.post as any).mockResolvedValue({ data: overview }); + expect(await apiPlugins.install('my plugin')).toEqual(overview); + expect(axios.post).toHaveBeenCalledWith( + 'api/v1/plugins/my%20plugin/install' + ); + }); + + it('should DELETE api/v1/plugins/:id with an encoded id', async () => { + (axios.delete as any).mockResolvedValue({ data: overview }); + expect(await apiPlugins.uninstall('my plugin')).toEqual(overview); + expect(axios.delete).toHaveBeenCalledWith('api/v1/plugins/my%20plugin'); + }); +}); diff --git a/webui/src/api/plugins.ts b/webui/src/api/plugins.ts index 7dd1d476..a4a80606 100644 --- a/webui/src/api/plugins.ts +++ b/webui/src/api/plugins.ts @@ -1,5 +1,5 @@ import type { PluginUiSlot } from '@autobangumi/plugin-ui'; -import type { PluginProviders, PluginsOverview } from '#/plugins'; +import type { CatalogEntry, PluginProviders, PluginsOverview } from '#/plugins'; export const apiPlugins = { /** 已发现的插件、状态、配置 schema 与(掩码后的)当前配置 */ @@ -28,6 +28,30 @@ export const apiPlugins = { return data; }, + /** 签名目录;目录不可达(502)由调用方在页面内提示,不弹全局 toast */ + async catalog() { + const { data } = await axios.get('api/v1/plugins/catalog', { + silent: true, + }); + return data; + }, + + /** 从签名目录安装或升级插件,后端安装后即启用 */ + async install(id: string) { + const { data } = await axios.post( + `api/v1/plugins/${encodeURIComponent(id)}/install` + ); + return data; + }, + + /** 卸载经签名目录安装的插件(后端拒绝内置、本地、pip 与 LLM 插件) */ + async uninstall(id: string) { + const { data } = await axios.delete( + `api/v1/plugins/${encodeURIComponent(id)}` + ); + return data; + }, + /** 已启用插件声明的前端挂载点 */ async ui() { const { data } = await axios.get('api/v1/plugins/ui'); diff --git a/webui/src/components/setting/__tests__/config-plugins.test.ts b/webui/src/components/setting/__tests__/config-plugins.test.ts new file mode 100644 index 00000000..dd345ce2 --- /dev/null +++ b/webui/src/components/setting/__tests__/config-plugins.test.ts @@ -0,0 +1,225 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import ConfigPlugins from '../config-plugins.vue'; +import type { CatalogEntry, PluginInfo, PluginsOverview } from '#/plugins'; +import en from '@/i18n/en.json'; +import zhCN from '@/i18n/zh-CN.json'; +import { apiPlugins } from '@/api/plugins'; + +vi.mock('@/api/plugins', () => ({ + apiPlugins: { + list: vi.fn(), + catalog: vi.fn(), + install: vi.fn(), + uninstall: vi.fn(), + }, +})); + +const confirmMock = vi.fn(); +vi.mock('@/hooks/useConfirm', () => ({ + useConfirm: () => ({ confirm: confirmMock }), +})); +vi.mock('@/hooks/useMyI18n', () => ({ + useMyI18n: () => ({ t: (key: string) => key }), +})); +vi.mock('@/hooks/useMessage', () => ({ + useMessage: () => ({ success: vi.fn(), error: vi.fn() }), +})); +vi.mock('@/hooks/usePluginProviders', () => ({ + refreshPluginProviders: vi.fn(), +})); +vi.mock('@/hooks/usePluginUi', () => ({ refreshPluginUi: vi.fn() })); +vi.mock('@/store/config', () => ({ + useConfigStore: () => ({ refreshGroup: vi.fn() }), +})); + +const api = vi.mocked(apiPlugins); + +function plugin(overrides: Partial = {}): PluginInfo { + return { + id: 'ntfy', + name: 'ntfy', + version: '0.1.0', + source: 'catalog', + signed: true, + state: 'active', + enabled: true, + description: '', + permissions: [], + error: null, + config_schema: null, + options: {}, + ...overrides, + }; +} + +function overview(plugins: PluginInfo[]): PluginsOverview { + return { allow_unsigned: false, plugins }; +} + +function entry(overrides: Partial = {}): CatalogEntry { + return { + id: 'ntfy', + name: 'ntfy', + version: '0.2.0', + kind: 'plugin', + extension_points: ['notifier'], + description: '', + min_ab_version: '4.0.0', + installed_version: null, + ...overrides, + }; +} + +async function mountPage(plugins: PluginInfo[]) { + api.list.mockResolvedValue(overview(plugins)); + const wrapper = mount(ConfigPlugins, { + global: { + stubs: { + 'ab-fold-panel': { template: '
' }, + AbSwitch: true, + }, + }, + }); + await flushPromises(); + return wrapper; +} + +function button(wrapper: Awaited>, key: string) { + return wrapper.findAll('button').filter((b) => b.text() === key); +} + +const sources: PluginInfo['source'][] = ['builtin', 'catalog', 'local', 'pip']; + +describe('config-plugins', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(sources)( + 'should have a source label in both locales when source is %s', + (source) => { + const key = `source_${source}`; + expect(en.config.plugins_set).toHaveProperty(key); + expect(zhCN.config.plugins_set).toHaveProperty(key); + } + ); + + it.each(sources)( + 'should offer uninstall only for catalog plugins when source is %s', + async (source) => { + const wrapper = await mountPage([plugin({ source })]); + expect(button(wrapper, 'config.plugins_set.uninstall')).toHaveLength( + source === 'catalog' ? 1 : 0 + ); + } + ); + + it.each([true, false])( + 'should uninstall only when the confirm resolves %s', + async (confirmed) => { + confirmMock.mockResolvedValue(confirmed); + api.uninstall.mockResolvedValue(overview([])); + const wrapper = await mountPage([plugin()]); + + await button(wrapper, 'config.plugins_set.uninstall')[0].trigger('click'); + await flushPromises(); + + expect(confirmMock).toHaveBeenCalledWith( + expect.objectContaining({ danger: true }) + ); + expect(api.uninstall).toHaveBeenCalledTimes(confirmed ? 1 : 0); + expect(wrapper.text().includes('config.plugins_set.empty')).toBe( + confirmed + ); + } + ); + + it('should load the catalog only when the user browses it', async () => { + api.catalog.mockResolvedValue([]); + const wrapper = await mountPage([]); + expect(api.catalog).not.toHaveBeenCalled(); + + await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger( + 'click' + ); + await flushPromises(); + + expect(api.catalog).toHaveBeenCalledTimes(1); + expect(wrapper.text()).toContain('config.plugins_set.catalog_empty'); + }); + + it('should show the install action for each catalog state', async () => { + api.catalog.mockResolvedValue([ + entry({ id: 'fresh' }), + entry({ id: 'stale', installed_version: '0.1.0' }), + entry({ id: 'current', installed_version: '0.2.0' }), + ]); + const wrapper = await mountPage([]); + await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger( + 'click' + ); + await flushPromises(); + + const rows = wrapper.findAll('.catalog__entry'); + expect(rows.map((r) => r.find('button').exists())).toEqual([ + true, + true, + false, + ]); + expect(rows[0].find('button').text()).toBe('config.plugins_set.install'); + expect(rows[1].find('button').text()).toBe('config.plugins_set.update'); + expect(rows[2].text()).toContain('config.plugins_set.installed'); + }); + + it('should install, apply the returned overview and mark the entry installed', async () => { + api.catalog.mockResolvedValue([entry()]); + api.install.mockResolvedValue( + overview([plugin({ name: 'ntfy-installed', version: '0.2.0' })]) + ); + const wrapper = await mountPage([]); + await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger( + 'click' + ); + await flushPromises(); + + await button(wrapper, 'config.plugins_set.install')[0].trigger('click'); + await flushPromises(); + + expect(api.install).toHaveBeenCalledWith('ntfy'); + expect(wrapper.text()).toContain('ntfy-installed'); + expect(button(wrapper, 'config.plugins_set.install')).toHaveLength(0); + expect(wrapper.find('.catalog__entry').text()).toContain( + 'config.plugins_set.installed' + ); + }); + + it('should keep the entry installable when install fails', async () => { + api.catalog.mockResolvedValue([entry()]); + api.install.mockRejectedValue(new Error('signature invalid')); + const wrapper = await mountPage([]); + await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger( + 'click' + ); + await flushPromises(); + + await button(wrapper, 'config.plugins_set.install')[0].trigger('click'); + await flushPromises(); + + expect(button(wrapper, 'config.plugins_set.install')).toHaveLength(1); + }); + + it('should show the backend detail inline when the catalog is unavailable', async () => { + api.catalog.mockRejectedValue({ + response: { data: { detail: 'catalog signature verification failed' } }, + }); + const wrapper = await mountPage([]); + await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger( + 'click' + ); + await flushPromises(); + + const alert = wrapper.find('[role="alert"]'); + expect(alert.text()).toContain('config.plugins_set.catalog_failed'); + expect(alert.text()).toContain('catalog signature verification failed'); + }); +}); diff --git a/webui/src/components/setting/config-plugins.vue b/webui/src/components/setting/config-plugins.vue index 02269688..ab7fd897 100644 --- a/webui/src/components/setting/config-plugins.vue +++ b/webui/src/components/setting/config-plugins.vue @@ -2,11 +2,13 @@ import AbAlert from '../basic/ab-alert.vue'; import AbButton from '../basic/ab-button.vue'; import AbField from '../basic/ab-field.vue'; +import AbSkeleton from '../basic/ab-skeleton.vue'; import AbSwitch from '../basic/ab-switch.vue'; import AbTag from '../basic/ab-tag.vue'; import PluginSchemaForm from './plugin-schema-form.vue'; -import type { PluginInfo, PluginsOverview } from '#/plugins'; +import type { CatalogEntry, PluginInfo, PluginsOverview } from '#/plugins'; import { apiPlugins } from '@/api/plugins'; +import { useConfirm } from '@/hooks/useConfirm'; import { refreshPluginProviders } from '@/hooks/usePluginProviders'; import { refreshPluginUi } from '@/hooks/usePluginUi'; import { fillSchemaDefaults, schemaFields } from '@/utils/plugin-schema'; @@ -15,12 +17,19 @@ import { fillSchemaDefaults, schemaFields } from '@/utils/plugin-schema'; // 随后刷新 config store 的 plugins 段,避免全局保存用旧值覆盖。 const { t } = useMyI18n(); const message = useMessage(); +const { confirm } = useConfirm(); const { refreshGroup } = useConfigStore(); const overview = ref(null); const drafts = ref>>({}); const busy = ref(null); +// 签名目录要访问 GitHub,用户点「浏览目录」时才拉取; +// catalogError 为 '' 表示失败但后端没给出原因 +const catalog = ref(null); +const catalogLoading = ref(false); +const catalogError = ref(null); + const stateType = { active: 'success', disabled: 'neutral', @@ -45,7 +54,11 @@ async function load() { } } -async function run(key: string, action: () => Promise) { +async function run( + key: string, + action: () => Promise, + failKey = 'save_failed' +) { busy.value = key; try { apply(await action()); @@ -57,7 +70,8 @@ async function run(key: string, action: () => Promise) { await refreshPluginUi(); return true; } catch { - message.error(t('config.plugins_set.save_failed')); + // 后端的具体原因(如验签失败)由 axios 拦截器另行提示 + message.error(t(`config.plugins_set.${failKey}`)); return false; } finally { busy.value = null; @@ -79,6 +93,55 @@ async function saveOptions(plugin: PluginInfo) { if (ok) message.success(t('config.plugins_set.save_success')); } +async function loadCatalog() { + catalogLoading.value = true; + catalogError.value = null; + try { + catalog.value = await apiPlugins.catalog(); + } catch (e) { + const detail = (e as { response?: { data?: { detail?: unknown } } }) + .response?.data?.detail; + catalogError.value = typeof detail === 'string' ? detail : ''; + } finally { + catalogLoading.value = false; + } +} + +async function install(entry: CatalogEntry) { + const ok = await run( + `install:${entry.id}`, + () => apiPlugins.install(entry.id), + 'install_failed' + ); + if (!ok) return; + entry.installed_version = entry.version; + message.success(t('config.plugins_set.install_success')); +} + +// 只有签名目录安装的插件(source 为 catalog)显示卸载按钮:后端卸载的前提 +// (installed.json 指向含 plugin.toml 的版本目录)与加载器判定 catalog 来源 +// 是同一条件。LLM 提供商插件不在此列表中,仍在 LLM 设置里管理。 +async function uninstall(plugin: PluginInfo) { + const confirmed = await confirm({ + title: t('config.plugins_set.uninstall_confirm_title', { + name: plugin.name, + }), + body: t('config.plugins_set.uninstall_confirm_body'), + confirmText: t('config.plugins_set.uninstall'), + danger: true, + }); + if (!confirmed) return; + const ok = await run( + `uninstall:${plugin.id}`, + () => apiPlugins.uninstall(plugin.id), + 'uninstall_failed' + ); + if (!ok) return; + const entry = catalog.value?.find((e) => e.id === plugin.id); + if (entry) entry.installed_version = null; + message.success(t('config.plugins_set.uninstall_success')); +} + onMounted(load); @@ -126,7 +189,10 @@ onMounted(load); {{ $t(`config.plugins_set.state_${plugin.state}`) }} {{ $t(`config.plugins_set.source_${plugin.source}`) }} - + + {{ $t('config.plugins_set.signed') }} + + {{ $t('config.plugins_set.unsigned') }} @@ -172,6 +238,95 @@ onMounted(load); {{ $t('config.plugins_set.options_unavailable') }}

+ + + {{ $t('config.plugins_set.uninstall') }} + + + +
+
+
+ {{ $t('config.plugins_set.catalog_title') }} + + {{ $t('config.plugins_set.catalog_hint') }} + +
+ + {{ + catalog + ? $t('config.plugins_set.catalog_refresh') + : $t('config.plugins_set.catalog_browse') + }} + +
+ + + {{ catalogError || $t('config.plugins_set.catalog_failed_hint') }} + + +

+ {{ $t('config.plugins_set.catalog_empty') }} +

+ +
    +
  • +
    + {{ entry.name || entry.id }} + {{ entry.id }} · v{{ entry.version }} + + {{ entry.description }} + + + {{ + $t('config.plugins_set.extension_points', { + list: entry.extension_points.join(', '), + }) + }} + +
    + + {{ $t('config.plugins_set.installed') }} + +
    + + {{ + $t('config.plugins_set.installed_version', { + version: entry.installed_version, + }) + }} + + + {{ + entry.installed_version + ? $t('config.plugins_set.update') + : $t('config.plugins_set.install') + }} + +
    +
  • +
@@ -228,4 +383,48 @@ onMounted(load); .plugin__save { margin-top: 12px; } + +.plugin__uninstall { + align-self: flex-start; +} + +.plugin__meta { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.catalog { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 16px; + border-top: 1px solid var(--color-border); +} + +.catalog__list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.catalog__entry { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + border-radius: var(--radius-md); + background: var(--color-surface-2); +} + +.catalog__action { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; +} diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index e2a3b000..121f41bd 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -178,7 +178,7 @@ }, "plugins_set": { "title": "Plugins", - "empty": "No plugins found. Put a plugin directory (named after the plugin id) into config/plugins/local/, or install it with pip, then restart.", + "empty": "No plugins found. Install one from the plugin catalog below. Or put a plugin directory (named after the plugin id) into config/plugins/local/, or install it with pip, then restart.", "allow_unsigned": "Allow unsigned plugins", "allow_unsigned_hint": "Plugins from the local directory or pip are unsigned and only load when this is on. Plugins run inside AutoBangumi with full privileges — only enable plugins you trust.", "enabled": "Enabled", @@ -200,7 +200,28 @@ "save_success": "Plugin options saved and applied", "save_failed": "Failed to save plugin options", "load_failed": "Failed to load plugins", - "plugin_provided": "From plugin" + "plugin_provided": "From plugin", + "source_catalog": "Catalog", + "signed": "Signed", + "uninstall": "Uninstall", + "uninstall_confirm_title": "Uninstall {name}?", + "uninstall_confirm_body": "The plugin files are deleted. Its options stay in config.json and apply again if you reinstall it.", + "uninstall_success": "Plugin uninstalled", + "uninstall_failed": "Failed to uninstall the plugin", + "catalog_title": "Plugin catalog", + "catalog_hint": "AutoBangumi signs the catalog and every plugin package, and checks the signature and sha256 before install. Catalog plugins count as signed and do not need \"Allow unsigned plugins\".", + "catalog_browse": "Browse catalog", + "catalog_refresh": "Refresh", + "catalog_failed": "Catalog unavailable.", + "catalog_failed_hint": "Check the network, then select Refresh.", + "catalog_empty": "The catalog has no plugins yet.", + "extension_points": "Extension points: {list}", + "install": "Install", + "update": "Update", + "installed": "Installed", + "installed_version": "Installed v{version}", + "install_success": "Plugin installed and enabled", + "install_failed": "Failed to install the plugin" } }, "downloader": { diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index c723d1a0..a497069d 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -178,7 +178,7 @@ }, "plugins_set": { "title": "插件", - "empty": "未发现插件。把插件目录放进 config/plugins/local/(目录名与插件 id 相同)或用 pip 安装后重启,即可在此出现。", + "empty": "未发现插件。从下方的插件目录安装;或把插件目录放进 config/plugins/local/(目录名与插件 id 相同)、用 pip 安装,重启后即可在此出现。", "allow_unsigned": "允许未签名插件", "allow_unsigned_hint": "本地目录与 pip 安装的插件未经签名,开启后才会加载。插件与 AutoBangumi 同进程运行,拥有完整权限,只启用你信任的插件。", "enabled": "启用", @@ -200,7 +200,28 @@ "save_success": "插件配置已保存并应用", "save_failed": "保存插件配置失败", "load_failed": "加载插件列表失败", - "plugin_provided": "插件提供" + "plugin_provided": "插件提供", + "source_catalog": "签名目录", + "signed": "已签名", + "uninstall": "卸载", + "uninstall_confirm_title": "卸载 {name}?", + "uninstall_confirm_body": "插件文件会被删除。它的配置保留在 config.json 中,重新安装后继续生效。", + "uninstall_success": "插件已卸载", + "uninstall_failed": "卸载插件失败", + "catalog_title": "插件目录", + "catalog_hint": "目录与插件包由 AutoBangumi 签名,安装前校验签名与 sha256。从目录安装的插件视为已签名,不需要开启“允许未签名插件”。", + "catalog_browse": "浏览目录", + "catalog_refresh": "刷新", + "catalog_failed": "目录不可用。", + "catalog_failed_hint": "检查网络后点击“刷新”重试。", + "catalog_empty": "目录中暂时没有插件。", + "extension_points": "扩展点:{list}", + "install": "安装", + "update": "更新", + "installed": "已安装", + "installed_version": "已安装 v{version}", + "install_success": "插件已安装并启用", + "install_failed": "安装插件失败" } }, "downloader": { diff --git a/webui/types/plugins.ts b/webui/types/plugins.ts index f090f0ef..9caabb43 100644 --- a/webui/types/plugins.ts +++ b/webui/types/plugins.ts @@ -38,7 +38,8 @@ export interface PluginInfo { id: string; name: string; version: string; - source: 'builtin' | 'local' | 'pip'; + /** catalog:经签名目录安装,只有它能在界面上卸载 */ + source: 'builtin' | 'catalog' | 'local' | 'pip'; signed: boolean; state: PluginState; enabled: boolean; @@ -54,6 +55,18 @@ export interface PluginsOverview { plugins: PluginInfo[]; } +/** 签名目录(GitHub release `plugins`)中的插件,以及本机已安装的版本 */ +export interface CatalogEntry { + id: string; + name: string; + version: string; + kind: string; + extension_points: string[]; + description: string; + min_ab_version: string; + installed_version: string | null; +} + /** 插件提供的 Provider id,按扩展点分组 */ export interface PluginProviders { downloader: string[];