feat(4.0): 设置页新增签名插件目录的浏览、安装、更新与卸载

- 插件卡片下方新增「插件目录」区:点击后才请求 GET /plugins/catalog(silent),失败时在区内显示后端原因并可刷新
- 条目按已装版本显示安装 / 更新 / 已安装;安装成功后刷新卡片、Provider 下拉与前端挂载点
- 卸载只对来源为 catalog 的插件开放,经危险确认框;与后端卸载前提为同一条件,LLM 插件不受影响
- 卡片标出已签名 / 未签名
- 修复前端缺少 catalog 来源类型与 source_catalog 文案
- 同步签名与分发文档(中 / 英 / 日)、设计文档第 0 节与 CHANGELOG
This commit is contained in:
Estrella Pan
2026-10-08 15:38:52 +02:00
parent f01e1e1358
commit ee5704eef0
12 changed files with 559 additions and 19 deletions

View File

@@ -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

View File

@@ -49,8 +49,8 @@ AB 从 GitHub release `plugins` 下载 `catalog.json`。目录包含:
签名目录的插件只能依赖标准库和 AB 已有的包,不做 pip 安装。需要第三方纯 Python 库时放进 `vendor/`。
::: warning WebUI 尚无目录界面
4.0 预览期间,目录的浏览和安装只有 API,设置 → 插件 页还没有对应的按钮。
::: tip 在 WebUI 中安装
在 设置 → 插件 的「插件目录」区点击「浏览目录」,可以安装、更新目录中的插件。经目录安装的插件卡片上有「卸载」按钮;内置、本地和 pip 插件没有。
:::
API:

View File

@@ -49,8 +49,8 @@ After these checks, AB unpacks the plugin to `config/plugins/<id>/<version>/`, 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:

View File

@@ -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:

View File

@@ -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/<id>/` 的两处隔离**: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/<id>/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` 与事件总线。

View File

@@ -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');
});
});

View File

@@ -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<CatalogEntry[]>('api/v1/plugins/catalog', {
silent: true,
});
return data;
},
/** 从签名目录安装或升级插件,后端安装后即启用 */
async install(id: string) {
const { data } = await axios.post<PluginsOverview>(
`api/v1/plugins/${encodeURIComponent(id)}/install`
);
return data;
},
/** 卸载经签名目录安装的插件(后端拒绝内置、本地、pip 与 LLM 插件) */
async uninstall(id: string) {
const { data } = await axios.delete<PluginsOverview>(
`api/v1/plugins/${encodeURIComponent(id)}`
);
return data;
},
/** 已启用插件声明的前端挂载点 */
async ui() {
const { data } = await axios.get<PluginUiSlot[]>('api/v1/plugins/ui');

View File

@@ -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> = {}): 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> = {}): 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: '<section><slot /></section>' },
AbSwitch: true,
},
},
});
await flushPromises();
return wrapper;
}
function button(wrapper: Awaited<ReturnType<typeof mountPage>>, 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');
});
});

View File

@@ -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<PluginsOverview | null>(null);
const drafts = ref<Record<string, Record<string, unknown>>>({});
const busy = ref<string | null>(null);
// 签名目录要访问 GitHub,用户点「浏览目录」时才拉取;
// catalogError 为 '' 表示失败但后端没给出原因
const catalog = ref<CatalogEntry[] | null>(null);
const catalogLoading = ref(false);
const catalogError = ref<string | null>(null);
const stateType = {
active: 'success',
disabled: 'neutral',
@@ -45,7 +54,11 @@ async function load() {
}
}
async function run(key: string, action: () => Promise<PluginsOverview>) {
async function run(
key: string,
action: () => Promise<PluginsOverview>,
failKey = 'save_failed'
) {
busy.value = key;
try {
apply(await action());
@@ -57,7 +70,8 @@ async function run(key: string, action: () => Promise<PluginsOverview>) {
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);
</script>
@@ -126,7 +189,10 @@ onMounted(load);
{{ $t(`config.plugins_set.state_${plugin.state}`) }}
</AbTag>
<AbTag>{{ $t(`config.plugins_set.source_${plugin.source}`) }}</AbTag>
<AbTag v-if="!plugin.signed" type="warning">
<AbTag v-if="plugin.signed" type="success">
{{ $t('config.plugins_set.signed') }}
</AbTag>
<AbTag v-else type="warning">
{{ $t('config.plugins_set.unsigned') }}
</AbTag>
</div>
@@ -172,6 +238,95 @@ onMounted(load);
{{ $t('config.plugins_set.options_unavailable') }}
</p>
</details>
<AbButton
v-if="plugin.source === 'catalog'"
size="sm"
variant="danger"
class="plugin__uninstall"
:loading="busy === `uninstall:${plugin.id}`"
@click="uninstall(plugin)"
>
{{ $t('config.plugins_set.uninstall') }}
</AbButton>
</section>
<section class="catalog">
<header class="plugin__header">
<div class="plugin__title">
<strong>{{ $t('config.plugins_set.catalog_title') }}</strong>
<span class="plugin__desc">
{{ $t('config.plugins_set.catalog_hint') }}
</span>
</div>
<AbButton size="sm" :loading="catalogLoading" @click="loadCatalog">
{{
catalog
? $t('config.plugins_set.catalog_refresh')
: $t('config.plugins_set.catalog_browse')
}}
</AbButton>
</header>
<AbAlert
v-if="catalogError !== null"
type="danger"
:title="$t('config.plugins_set.catalog_failed')"
>
{{ catalogError || $t('config.plugins_set.catalog_failed_hint') }}
</AbAlert>
<AbSkeleton v-else-if="catalogLoading && !catalog" preset="row" />
<p v-else-if="catalog && !catalog.length" class="plugins__empty">
{{ $t('config.plugins_set.catalog_empty') }}
</p>
<ul v-if="catalog?.length" class="catalog__list">
<li v-for="entry in catalog" :key="entry.id" class="catalog__entry">
<div class="plugin__title">
<strong>{{ entry.name || entry.id }}</strong>
<span class="plugin__meta"
>{{ entry.id }} · v{{ entry.version }}</span
>
<span v-if="entry.description" class="plugin__desc">
{{ entry.description }}
</span>
<span v-if="entry.extension_points.length" class="plugin__desc">
{{
$t('config.plugins_set.extension_points', {
list: entry.extension_points.join(', '),
})
}}
</span>
</div>
<span
v-if="entry.installed_version === entry.version"
class="plugin__desc"
>
{{ $t('config.plugins_set.installed') }}
</span>
<div v-else class="catalog__action">
<span v-if="entry.installed_version" class="plugin__desc">
{{
$t('config.plugins_set.installed_version', {
version: entry.installed_version,
})
}}
</span>
<AbButton
size="sm"
variant="primary"
:loading="busy === `install:${entry.id}`"
@click="install(entry)"
>
{{
entry.installed_version
? $t('config.plugins_set.update')
: $t('config.plugins_set.install')
}}
</AbButton>
</div>
</li>
</ul>
</section>
</div>
</ab-fold-panel>
@@ -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;
}
</style>

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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[];