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') }}
+
+
+
+
+
+
+
+ {{ 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[];