mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-10-28 19:25:55 +08:00
feat(4.0): 设置页新增签名插件目录的浏览、安装、更新与卸载
- 插件卡片下方新增「插件目录」区:点击后才请求 GET /plugins/catalog(silent),失败时在区内显示后端原因并可刷新 - 条目按已装版本显示安装 / 更新 / 已安装;安装成功后刷新卡片、Provider 下拉与前端挂载点 - 卸载只对来源为 catalog 的插件开放,经危险确认框;与后端卸载前提为同一条件,LLM 插件不受影响 - 卡片标出已签名 / 未签名 - 修复前端缺少 catalog 来源类型与 source_catalog 文案 - 同步签名与分发文档(中 / 英 / 日)、设计文档第 0 节与 CHANGELOG
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -49,8 +49,8 @@ AB 从 GitHub release `plugins` 下载 `catalog.json`。目录包含:
|
||||
|
||||
签名目录的插件只能依赖标准库和 AB 已有的包,不做 pip 安装。需要第三方纯 Python 库时放进 `vendor/`。
|
||||
|
||||
::: warning WebUI 尚无目录界面
|
||||
4.0 预览期间,目录的浏览和安装只有 API,设置 → 插件 页还没有对应的按钮。
|
||||
::: tip 在 WebUI 中安装
|
||||
在 设置 → 插件 的「插件目录」区点击「浏览目录」,可以安装、更新目录中的插件。经目录安装的插件卡片上有「卸载」按钮;内置、本地和 pip 插件没有。
|
||||
:::
|
||||
|
||||
API:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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` 与事件总线。
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
225
webui/src/components/setting/__tests__/config-plugins.test.ts
Normal file
225
webui/src/components/setting/__tests__/config-plugins.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user