插件 API 契约
本文给出 Breeze 插件每个 fnPath 的 TypeScript 类型定义与调用语义。
0. 通用约定
0.1 入参模型
宿主调用时总是传入一个扁平对象。顶层放业务主参数,透传字段放 extern:
// 泛型入参模型
type PluginPayload<T extends Record<string, unknown>> = T & {
extern?: Record<string, unknown>;
};
例如 searchComic 的实际签名:
import type {
SearchComicPayload,
SearchResultContract,
} from "breeze-plugin-kit";
async function searchComic(
payload: SearchComicPayload,
): Promise<SearchResultContract> {}
所有参数类型定义由 breeze-plugin-kit 提供。下文每个 fnPath 会标注其对应的 Payload 和返回类型。
0.2 返回模型
绝大部分接口返回统一信封结构:
type PluginEnvelope = {
source: string; // 插件 ID
scheme?: Record<string, unknown>; // 页面渲染协议
data?: Record<string, unknown>; // 业务数据
extern?: Record<string, unknown>; // 透传上下文
[key: string]: unknown; // 其他平铺字段(如 comicId、paging)
};
宿主机型 scheme 知道"页面长什么样",机型 data 知道"页面值是多少"。插件只需按类型填充即可。
0.3 错误处理
- 返回值必须是可 JSON 序列化的对象(非对象会触发"返回格式错误")
- 抛出的
Error字符串会直接显示在客户端 - 建议始终返回
source字段,便于排查
0.4 init(可选)
宿主可能在 runtime 启动后调用 init。如未实现则跳过。若实现,建议返回 { source, data: { ok: true } }。
注意:热更新时 QJS 实例重建,init 会重新调用。不要用 init 做一次性初始化。
0.5 类型引用
所有类型定义来自 breeze-plugin-kit。下文标注的 Payload 和 Contract 类型均可从该包导入。
1. fnPath 职责总览
下表中 Host 调用的函数是宿主按场景主动触发;回调函数是用户在 UI 上操作后宿主代调。
| fnPath | 触发场景 |
|---|---|
getInfo | 发现页加载插件卡片 |
searchComic | 搜索页输入关键词 / 翻页 / 高级搜索 |
getComicDetail | 打开漫画详情页 |
getPreview | 详情页加载漫画预览 |
getReadSnapshot | 阅读页初始化、切章 |
getChapter | 下载章节内容 |
getDownloadConcurrency | 下载章节前获取图片下载并发数 |
fetchImageBytes | 阅读/下载时获取图片二进制 |
toggleLike | 详情页点点赞 |
startFavoriteAction | 开始云端收藏工作流 |
continueFavoriteAction | 继续云端收藏工作流或处理用户输入 |
toggleFavorite | 旧版详情页收藏兼容入口,仅供兼容、不再维护,新插件请用 startFavoriteAction |
listFavoriteFolders | 旧版收藏后选择收藏夹,仅供兼容、不再维护,新插件请用 startFavoriteAction |
moveFavoriteToFolder | 旧版用户确认收藏夹,仅供兼容、不再维护,新插件请用 startFavoriteAction |
getCommentFeed | 打开评论面板 / 翻页 |
loadCommentReplies | 展开评论回复 |
postComment | 发送主评论 |
postCommentReply | 回复某条评论 |
getAdvancedSearchScheme | 搜索页打开高级筛选 |
getComicListSceneBundle | 发现页按 source 加载默认列表 |
getFunctionPage | 功能页入口点进后加载自定义页面 |
getRankingData(示例名) | 列表页 body 请求(实际以 scene.body.request.fnPath 为准) |
getRankingFilterBundle(示例名,按需配置) | 列表页点筛选按钮(仅当 scene 配置了 filter.fnPath 时) |
以下为回调函数,由插件在 setting / capability 中指定 fnPath,用户操作时宿主代调:
| fnPath | 触发条件 |
|---|---|
onAuthChanged 等 | 设置页字段变更 |
clearPluginCache | 设置页点击能力操作按钮 |
2. 核心 API
getInfo()(类型见 kit:InfoContract)
返回插件基本信息与功能入口。
import type { InfoContract } from "breeze-plugin-kit";
async function getInfo(): Promise<InfoContract> {}
type InfoContract = {
name: string;
uuid: string;
iconUrl: string;
describe: string;
version: string;
home?: string;
/** 更新通道:类似 GitHub Release API 的 latest 地址。仅 api.github.com 会走宿主加速。 */
updateUrl?: string;
/** 更新通道:npm 包名。优先用于查询 latest 与 CDN 下载。 */
npmName?: string;
function: PluginFunctionItem[];
};
// 更新行为说明见「调试与发布 → 插件更新」:
// - 在云端列表中的插件:静默更新只读列表坐标
// - 不在列表中的插件:静默更新 /「同步」读 getInfo 的 npmName / updateUrl
type PluginFunctionItem = {
/** 入口标识:发现页按钮的 key。`openPluginFunction` 的外层 `id` 与其 `payload.id` 保持一致。 */
id: string;
/** 发现页按钮文案。 */
title: string;
/** 点击后宿主执行的操作。权威定义见 breeze-plugin-kit 的 `PluginAction` / `Open*Action`。 */
action: PluginAction;
};
目前推荐使用 openComicList 作为功能入口。示例见 快速开始。
入口定义见本节;点进后列表页样式见 getRankingData(payload),
列表筛选见 getRankingFilterBundle()(示例名,按需配置),
功能页样式见 getFunctionPage(payload),调用链见 6.5。
type PluginAction =
// 打开分页漫画列表页。`scene.body.request.fnPath` 为插件导出的列表数据函数
//(如 `getRankingData`),宿主分页调用,函数名可自定。`scene.filter` 为可选的筛选配置,
// 仅当需要列表筛选时才配置:配置后必须提供真实可调用的筛选函数(如 `getRankingFilterBundle`),
// 用户点击筛选时调用。`core` 放固定参数,`extern` 放透传上下文,合并规则见 6.2 / 6.5。
| { type: "openComicList"; payload: { scene: ComicListScene } }
// 打开插件自定义功能页。需同时实现 `getFunctionPage`,且 `payload.id` 须与其
// 可处理的 `id` 对应,否则宿主报"未知功能"。`presentation` 为 `"page"` 整页
// 或 `"dialog"` 弹窗。功能页布局节点见 `FunctionPageBodyNode`
//(`chip-list` / `action-grid` / `comic-section-list` / `comic-grid`),
// 格子点击一般再跳 `openSearch` / `openComicList`。
| {
type: "openPluginFunction";
payload: {
id: string;
title?: string;
presentation?: "page" | "dialog";
source?: string;
};
}
// `openCloudFavorite` 不再维护,仅为兼容保留;新插件如需收藏入口,请使用 `openComicList` 自建列表。
| { type: "openCloudFavorite"; payload: { title: string; source?: string } }
// `openSearch` 作为 function 入口不再维护,仅为兼容保留;新插件无需配置搜索入口,搜索由 `searchComic` 提供。
// 该动作在详情页 metadata / titleMeta chip 的 `onTap` 中仍可使用。
| {
type: "openSearch";
payload: { source?: string; keyword?: string; extern?: Record<string, unknown> };
}
// 直达漫画详情页。`comicId` 为目标漫画标识,`source` 缺省为当前插件,`extern` 为透传上下文。
| {
type: "openComicInfo";
payload: {
comicId: string;
source?: string;
extern: Record<string, unknown>;
};
}
// 打开内置 WebView。`url` 为目标地址,`title` 为标题。
// 一般不作为发现页入口,多用于设置 / 能力回调中打开外部页面。
| { type: "openWeb"; payload: { title?: string; url: string } };
type ComicListScene = {
title: string;
source?: string; // 习惯上填当前插件 ID
body: {
type: "pluginPagedComicList";
request: ComicListRequest;
};
filter?: ComicListRequest;
};
type ComicListRequest = {
fnPath: string; // 列表数据函数名
core?: Record<string, unknown>; // 固定请求参数
extern?: Record<string, unknown>; // 透传上下文
};
function 字段行为说明:
- 允许空数组:发现页不展示该插件的功能按钮,搜索 / 详情链路不受影响。
- 与
getComicListSceneBundle独立:前者是"入口按钮组",后者是"发现页默认场景",可只实现其一。 - 修改
function入口需重装插件才能刷新,热更新不生效(见「快速开始 → 注意事项」)。 openSearch作为 function 入口不再维护,仅为兼容保留;新插件无需配置搜索入口,搜索由searchComic提供。openCloudFavorite不再维护,仅为兼容保留;新插件如需收藏入口,请使用openComicList自建列表。openComicDetail不再维护,请使用openComicInfo。scene.list写法不再维护,请使用scene.body.request。
searchComic(payload)(类型见 kit:SearchComicPayload / SearchResultContract)
import type {
SearchComicPayload,
SearchResultContract,
} from "breeze-plugin-kit";
async function searchComic(
payload: SearchComicPayload,
): Promise<SearchResultContract> {}
type SearchComicPayload = {
keyword?: string;
page?: number;
extern?: Record<string, unknown>;
};
type SearchResultContract = {
source: string;
extern: Record<string, unknown> | null;
scheme: {
version: "1.0.0";
type: "searchResult";
source: string;
list: string;
};
data: { paging: PagingInfo; items: ComicListItem[] };
paging: PagingInfo;
items: ComicListItem[];
};
type PagingInfo = {
page: number;
pages: number;
total: number;
hasReachedMax: boolean;
};
type ComicListItem = {
source: string;
id: string;
title: string;
subtitle: string;
finished: boolean;
likesCount: number;
viewsCount: number;
updatedAt: string;
cover: ImageItem;
metadata: MetadataListItem[];
raw: Record<string, unknown>;
extern: Record<string, unknown>;
};
extern 中会包含高级搜索选中的筛选项。
getComicDetail(payload)(类型见 kit:ComicDetailPayload / ComicDetailContract)
import type {
ComicDetailContract,
ComicDetailPayload,
} from "breeze-plugin-kit";
async function getComicDetail(
payload: ComicDetailPayload,
): Promise<ComicDetailContract> {}
type ComicDetailPayload = {
comicId?: string;
extern?: Record<string, unknown>;
};
type ComicDetailContract = {
source: string;
comicId: string;
extern: Record<string, unknown> | null;
scheme: { version: "1.0.0"; type: "comicDetail"; source: string };
data: { normal: ComicDetailNormal; raw: unknown };
};
type ComicDetailNormal = {
comicInfo: {
id: string;
title: string;
titleMeta: ActionItem[];
creator: {
id: string;
name: string;
avatar: ImageItem;
onTap: Record<string, unknown>;
extern: Record<string, unknown>;
};
description: string;
cover: ImageItem;
metadata: MetadataListItem[];
extern: Record<string, unknown>;
};
preview?: PreviewCapability;
eps: ChapterSummary[]; // 章节列表
recommend: RecommendItem[];
totalViews: number;
totalLikes: number;
totalComments: number;
isFavourite: boolean;
isLiked: boolean;
allowComments: boolean;
allowLike: boolean;
allowCollected: boolean;
allowDownload: boolean;
// 对应 allow 开关为 false 时展示给用户的原因,可选;
// 为空或缺省时宿主显示默认文案「该插件暂不支持此功能」。
allowCommentsReason?: string;
allowLikeReason?: string;
allowCollectedReason?: string;
allowDownloadReason?: string;
extern: Record<string, unknown>;
};
> `allow*` 为 `false` 时宿主会禁用对应入口,但会保留可点击的提示位:
> 点击后优先显示同名的 `allow*Reason`,为空则显示默认文案。
> 例如 `allowDownload: false` 时章节右侧仍显示下载按钮,点击后 toast 提示原因,
> 而不会直接发起下载。
> `creator` 必填,不可省略。无作者信息的图源填空值即可;`name` 与 `avatar.url`
> 同时为空时宿主隐藏作者卡片。注意 `avatar` 是唯一的例外:不要用通用图片构造
> 逻辑给它填占位 `url`(占位非空会导致卡片一直显示),直接手写全空 `ImageItem`;
> 不可点击时 `onTap` 置 `null`。
type PreviewCapability = {
enabled: boolean;
extern?: Record<string, unknown>;
};
// 基础类型
type ActionItem = {
name: string;
onTap: Record<string, unknown>;
/** 长按复制的文本。缺省或 null 时复制 name。 */
onLongPress?: string | null;
extern: Record<string, unknown>;
};
type ImageItem = {
id: string;
url: string;
name: string;
path: string;
extern: Record<string, unknown>;
};
url必须是非空占位符字符串,不能为 404 地址。宿主不会用它来下载图片,但会校验格式。图片下载完全由fetchImageBytes自行处理。
type MetadataListItem = { type: string; name: string; value: ActionItem[] };
章节字段说明
章节相关数据统一使用以下字段,会同时出现在 CommonDetail 的 eps[] 和 getReadSnapshot / getChapter 的返回中:
type ChapterSummary = {
id: string; // 章节自身标识
requestId: string; // 宿主调用 getReadSnapshot / getChapter 时用于请求章节
logicalKey: string; // 宿主内部用于识别章节(大部分时候可与 requestId 相同)
storageChapterId: string; // 下载到本地后的目录名(大部分时候可与 requestId 相同)
name: string; // 章节名
order: number; // 章节顺序
/** 章节视觉分组标题,缺省 / null / 空白 = 不分组。详见下节「章节分组」。 */
groupTitle?: string | null;
extern: Record<string, unknown>; // 插件透传数据
};
type ChapterPage = {
id: string;
name: string;
path: string;
url: string;
extern: Record<string, unknown>;
};
章节分组(groupTitle)
图源存在多种章节列表时(如"连载" / "番外" / "单行本"),把同组章节的
groupTitle 填成相同值即可。缺省 / null / 空白 = 不分组,宿主走单列扁平显示。
// 例:raw 侧两组,压平时保留分组信息
const eps: ChapterSummary[] = [
...serialChapters.map((c, i) => ({
...toSummary(c),
groupTitle: "连载",
order: i + 1, // order 必须全局唯一且刷新稳定,不能直接用 raw 侧的重叠序号
})),
...volumeChapters.map((c, i) => ({
...toSummary(c),
groupTitle: "单行本",
order: serialChapters.length + i + 1,
})),
];
约定(宿主行为):
- 数据层保持扁平:
eps仍是唯一的章节列表,order全局唯一且刷新稳定, 是唯一的排序真相;不要发嵌套分组结构。 - 组顺序按章节在
eps中的首次出现顺序推导,不另存顺序 key。 想让"连载"排第一,就先放连载的章节。 name照常填完整标题(如"连载—第1话"或裸标题"第1话"都可): 宿主在组内显示时会自动去掉"{groupTitle}—"前缀(组头已说明组), 存储的name不动;追更/通知等需要消歧义的地方会保留带组形式。- 填什么都行:
groupTitle是自由文本(string | null), 有几组、叫什么,全由插件决定,宿主不校验、不映射。 - 上一话 / 下一话、继续阅读、下载选中等逻辑按全局
order走,不受分组影响, 跨组跳转天然可用。
getReadSnapshot(payload)(类型见 kit:ReadSnapshotPayload / ReadSnapshotContract)
import type {
ReadSnapshotContract,
ReadSnapshotPayload,
} from "breeze-plugin-kit";
async function getReadSnapshot(
payload: ReadSnapshotPayload,
): Promise<ReadSnapshotContract> {}
type ReadSnapshotPayload = {
comicId?: string;
chapterId?: string | number; // 即 requestId
extern?: Record<string, unknown>;
};
type ReadSnapshotContract = {
source: string;
extern: Record<string, unknown> | null;
data: {
comic: {
id: string;
source: string;
title: string;
extern: Record<string, unknown>;
};
chapter: ChapterWithPages; // 当前章节 + 图片列表
chapters: Array<{
id: string;
name: string;
order: number;
extern: Record<string, unknown>;
}>;
};
};
type ChapterWithPages = ChapterSummary & { pages: ChapterPage[] };
chapters 是章节导航列表(精简版),chapter 是当前选中章节(含 pages)。
fetchImageBytes(payload)(类型见 kit:FetchImageBytesPayload / FetchImageBytesResult)
import type {
FetchImageBytesPayload,
FetchImageBytesResult,
} from "breeze-plugin-kit";
async function fetchImageBytes(
payload: FetchImageBytesPayload,
): Promise<FetchImageBytesResult> {}
type FetchImageBytesPayload = {
url?: string;
timeoutMs?: number;
taskGroupKey?: string; // 下载任务组标识,宿主可通过它批量取消
extern?: Record<string, unknown>;
};
type FetchImageBytesResult = Uint8Array<ArrayBufferLike>;
url 来自 ImageItem.url,宿主不会用它下载图片,下载逻辑由插件自行实现。但传入的 url 必须是有效占位符字符串,不能为空或 404 地址。
⚠️ 必须加二进制透传头:
fetchImageBytes的请求头里务必带上"x-rquickjs-host-offload-binary-v1": "1"。这会让宿主把响应作为原始二进制字节流返回,而不是做字符串化或编码转换等预处理。缺少该头时,图片数据极易出现长度不对、解码失败或显示异常。
// 实现建议:
async function fetchImageBytes({
url,
timeoutMs = 30000,
}: FetchImageBytesPayload): Promise<Uint8Array> {
const res = await fetch(url, {
headers: { "x-rquickjs-host-offload-binary-v1": "1" },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) {
throw new Error(`下载失败: ${res.status}`);
}
return new Uint8Array(await res.arrayBuffer());
}
getChapter(payload)(类型见 kit:ChapterPayload / ChapterContentContract)
下载场景使用,结构与 getReadSnapshot 类似但携带完整的 scheme + data + comicId/chapterId:
import type {
ChapterContentContract,
ChapterPayload,
} from "breeze-plugin-kit";
async function getChapter(
payload: ChapterPayload,
): Promise<ChapterContentContract> {}
type ChapterPayload = {
comicId?: string;
chapterId?: string | number;
page?: number;
extern?: Record<string, unknown>;
};
type ChapterContentContract = {
source: string;
comicId: string;
chapterId: string;
extern: Record<string, unknown> | null;
scheme: { version: "1.0.0"; type: "chapterContent"; source: string };
data: {
comic: {
id: string;
source: string;
title: string;
extern: Record<string, unknown>;
};
chapter: ChapterWithPages;
chapters: Array<{
id: string;
name: string;
order: number;
extern: Record<string, unknown>;
}>;
};
};
3. 可选能力
getPreview(payload)(类型见 kit:PreviewPayload / PreviewContentContract)
preview 是可选能力字段。支持预览的图源在 data.normal 中返回
preview: { enabled: true };不支持预览时省略该字段。
详情页确认 normal.preview.enabled 后,宿主按页调用插件的 getPreview:
import type {
PreviewContentContract,
PreviewPayload,
} from "breeze-plugin-kit";
async function getPreview(
payload: PreviewPayload,
): Promise<PreviewContentContract> {}
type PreviewPayload = {
comicId?: string;
page?: number;
extern?: Record<string, unknown>;
};
type PreviewItem = {
id: string;
name: string;
path: string;
url: string;
extern: Record<string, unknown>;
};
type PreviewContentContract = {
source: string;
comicId: string;
extern: Record<string, unknown> | null;
scheme: { version: "1.0.0"; type: "previewContent"; source: string };
data: {
preview: {
items: PreviewItem[];
paging: PagingInfo;
};
};
};
首次请求使用详情返回的 normal.preview.extern;插件返回的顶层 extern
会作为下一次请求的 extern 原样传回,可用于保存分页游标或会话状态。
当 paging.hasReachedMax 为 true 时,宿主停止继续请求。
getDownloadConcurrency()(类型见 kit:DownloadConcurrencyResult)
下载章节前,宿主调用该函数获取图片下载并发数。可选实现:
未实现、返回非法或调用失败时,宿主回落为 5。
import type { DownloadConcurrencyResult } from "breeze-plugin-kit";
async function getDownloadConcurrency(): Promise<DownloadConcurrencyResult> {
return { concurrency: 3 };
}
type DownloadConcurrencyResult = {
concurrency: number;
};
约束:宿主会将返回值取整并钳制到
1~32,每下载一章调用一次。
4. 社交 API
toggleLike(payload)(类型见 kit:ToggleLikePayload / ToggleLikeResult)
import type {
ToggleLikePayload,
ToggleLikeResult,
} from "breeze-plugin-kit";
async function toggleLike(
payload: ToggleLikePayload,
): Promise<ToggleLikeResult> {}
type ToggleLikePayload = {
comicId?: string;
currentLiked?: boolean;
extern?: Record<string, unknown>;
};
type ToggleLikeResult = { liked: boolean };
toggleFavorite(payload) ⚠️已废弃
toggleFavorite(payload)仅供兼容、不再维护。新插件请实现“云端收藏工作流”(
startFavoriteAction/continueFavoriteAction),详见《云端收藏工作流》。
import type {
ToggleFavoritePayload,
ToggleFavoriteResult,
} from "breeze-plugin-kit";
async function toggleFavorite(
payload: ToggleFavoritePayload,
): Promise<ToggleFavoriteResult> {}
type ToggleFavoritePayload = {
comicId?: string;
currentFavorite?: boolean;
extern?: Record<string, unknown>;
};
type ToggleFavoriteResult = {
favorited: boolean;
nextStep: "none" | "selectFolder";
};
nextStep 为 selectFolder 时,宿主会继续调用 listFavoriteFolders 和 moveFavoriteToFolder。
listFavoriteFolders() ⚠️已废弃
listFavoriteFolders()仅供兼容、不再维护。新插件请实现“云端收藏工作流”,详见《云端收藏工作流》。
import type { ListFavoriteFoldersResult } from "breeze-plugin-kit";
async function listFavoriteFolders(): Promise<ListFavoriteFoldersResult> {}
type ListFavoriteFoldersResult = { items: Array<{ id: string; name: string }> };
import type { MoveFavoriteToFolderPayload } from "breeze-plugin-kit";
async function moveFavoriteToFolder(
payload: MoveFavoriteToFolderPayload,
): Promise<{ ok: boolean }> {}
type MoveFavoriteToFolderPayload = {
comicId?: string;
folderId?: string;
folderName?: string;
extern?: Record<string, unknown>;
};
以上三个函数属于旧版收藏协议,已废弃:仅供兼容、不再维护,随时可能移除。新插件应实现 startFavoriteAction 和
continueFavoriteAction,以支持选择/创建收藏夹、从指定收藏夹移除、移动、取消和部分成功。
完整定义和实现示例见云端收藏工作流。
评论流(类型见 kit:CommentFeedPayload / CommentFeedContract 等)
import type {
CommentFeedContract,
CommentFeedPayload,
CommentMutationContract,
CommentPostPayload,
CommentRepliesContract,
CommentRepliesPayload,
CommentReplyPayload,
} from "breeze-plugin-kit";
async function getCommentFeed(
payload: CommentFeedPayload,
): Promise<CommentFeedContract> {}
async function loadCommentReplies(
payload: CommentRepliesPayload,
): Promise<CommentRepliesContract> {}
async function postComment(
payload: CommentPostPayload,
): Promise<CommentMutationContract> {}
async function postCommentReply(
payload: CommentReplyPayload,
): Promise<CommentMutationContract> {}
type CommentFeedPayload = {
comicId?: string;
page?: number;
extern?: Record<string, unknown>;
};
type CommentFeedContract = {
source: string;
extern: Record<string, unknown> | null;
scheme: { version: "1.0.0"; type: "commentFeed" };
data: {
topItems: CommentItem[];
items: CommentItem[];
paging: { hasReachedMax: boolean };
replyMode: "lazy" | "embedded"; // lazy: 按需加载回复, embedded: 回复内嵌
canComment: { comic: boolean; reply: boolean };
};
};
type CommentItem = {
id: string;
author: { name: string; avatar: { url: string; path: string } };
content: string;
createdAt: string;
replyCount: number;
replies: CommentItem[];
extern: Record<string, unknown>;
};
type CommentRepliesPayload = {
comicId?: string;
commentId?: string;
page?: number;
extern?: Record<string, unknown>;
};
type CommentRepliesContract = {
source: string;
extern: Record<string, unknown> | null;
scheme: { version: "1.0.0"; type: "commentReplies" };
data: {
commentId: string;
items: CommentItem[];
paging: { hasReachedMax: boolean };
};
};
type CommentPostPayload = {
comicId?: string;
content?: string;
extern?: Record<string, unknown>;
};
type CommentReplyPayload = {
comicId?: string;
commentId?: string;
content?: string;
extern?: Record<string, unknown>;
};
type CommentMutationContract = {
source: string;
scheme: { version: "1.0.0"; type: "commentMutation" };
data: {
ok: boolean;
mode: "postComment" | "postReply";
parentId?: string;
created: CommentItem | null;
insertHint: {
needsRefetch: boolean;
strategy?: "prependAfterTop" | "prepend";
targetCommentId?: string;
};
};
};
replyMode: "lazy"— 回复按需加载,宿主会调loadCommentRepliesreplyMode: "embedded"— 回复直接放在replies中,不再调loadCommentRepliesinsertHint.strategy—prependAfterTop插到列表顶部(主评论),prepend插为回复
5. 发现与列表
点进功能入口后看什么,按需直达:
- 列表页长什么样、卡片字段怎么填 →
getRankingData(payload) - 列表筛选怎么做(含二级联动示例) →
getRankingFilterBundle()(示例名,按需配置) - 功能页四种区块怎么做 →
getFunctionPage(payload) - 筛选参数如何合并进列表请求 → 6.2;完整调用链 → 6.5
getAdvancedSearchScheme()(类型见 kit:AdvancedSearchContract)
定义搜索页的高级搜索筛选项。用户选中后,筛选值会通过 extern 传入 searchComic。
import type { AdvancedSearchContract } from "breeze-plugin-kit";
async function getAdvancedSearchScheme(): Promise<AdvancedSearchContract> {}
type AdvancedSearchContract = {
source: string;
scheme: {
version: "1.0.0";
type: "advancedSearch";
title?: string;
fields: AdvancedSearchField[];
};
data: { values: Record<string, unknown> };
};
type AdvancedSearchField = {
key: string;
kind: "text" | "switch" | "choice" | "multiChoice";
label: string;
options?: Array<{ label: string; value: unknown }>;
};
getComicListSceneBundle()(类型见 kit:ComicListSceneBundleContract)
定义发现页默认列表场景,返回 data.scene,宿主据此渲染列表页和调用 body.request.fnPath / filter.fnPath。
import type { ComicListSceneBundleContract } from "breeze-plugin-kit";
async function getComicListSceneBundle(): Promise<ComicListSceneBundleContract> {}
type ComicListSceneBundleContract = {
source: string;
scheme: { version: "1.0.0"; type: "comicListSceneBundle" };
data: { scene: ComicListScene };
};
ComicListScene 类型见 getInfo 一节。
getRankingData(payload)(类型见 kit:SearchComicPayload / ComicPagedListContract)
列表数据函数,由 ComicListScene.body.request.fnPath 指定。宿主分页请求列表数据时调用。
点进 openComicList 入口后,宿主渲染"漫画卡片网格"列表页:data.items[] 的每项按
"封面 + 标题 + 副标题 + 元信息"展示为一张卡片,点击卡片打开 getComicDetail。
data.hasReachedMax 为 true 时宿主停止分页请求。
卡片字段中 id / title / cover 必填(cover.url 为占位字符串,真实图片走
fetchImageBytes);建议同时填充 subtitle / metadata / likesCount /
viewsCount / updatedAt / finished,否则对应位置留空。
import type {
ComicPagedListContract,
SearchComicPayload,
} from "breeze-plugin-kit";
async function getRankingData(
payload: SearchComicPayload,
): Promise<ComicPagedListContract> {}
type ComicPagedListContract = {
source: string;
extern?: Record<string, unknown> | null;
scheme?: Record<string, unknown>;
data: { items: ComicListItem[]; hasReachedMax: boolean };
};
getRankingFilterBundle()(示例名,按需配置,类型见 kit:FilterBundleContract)
列表筛选函数,由 ComicListScene.filter.fnPath 指定。filter 本身可选:
不需要列表筛选时直接省略 scene.filter,此时列表页不展示筛选按钮;
需要筛选时才配置 filter: { fnPath, core?, extern? },且 fnPath 必须指向插件
实际导出的可调用函数,否则用户点击筛选按钮会失败。
配置后,用户点击列表页筛选按钮时宿主调用该函数。
筛选面板按 scheme.fields[] 渲染为分组单选:每个 field 一组,label 为组标题,
options[].label 为选项文案。data.values 以 field.key 为键给出每组的默认选中
(value 语义由插件自定,宿主只做透传比对),为空时该组无默认选中。
option.children 用于二级联动选项(如选中某分类后展开子分类)。
option.result.core / result.extern 在用户确认后合并进下一次列表请求,合并规则见 6.2。
import type { FilterBundleContract } from "breeze-plugin-kit";
async function getRankingFilterBundle(): Promise<FilterBundleContract> {}
type FilterBundleContract = {
source: string;
scheme: {
version: "1.0.0";
type?: string;
title?: string;
fields: FilterField[];
};
data: { values: Record<string, unknown> };
};
type FilterField = {
key: string;
kind: "choice";
label: string;
options: FilterOption[];
};
type FilterOption = {
label: string;
value: unknown;
result?: {
core?: Record<string, unknown>; // 合并到列表请求 core
extern?: Record<string, unknown>; // 合并到列表请求 extern
params?: Record<string, unknown>; // UI 参数
[key: string]: unknown;
};
children?: FilterOption[];
};
示例(含二级联动:部分父选项有子选项,部分没有;选中带 children 的父选项后才展开子选项):
async function getRankingFilterBundle(): Promise<FilterBundleContract> {
return {
source: PLUGIN_ID,
scheme: {
version: "1.0.0",
type: "rankingFilter",
title: "筛选漫画",
fields: [
{
key: "category",
kind: "choice",
label: "分类",
options: [
// 无子选项:选中即生效
{ label: "最新", value: "latest", result: { extern: { type: "0" } } },
// 有子选项:选中父选项后展开 children 再选一项
{
label: "同人",
value: "doujin",
result: { extern: { type: "doujin" } },
children: [
{ label: "汉化", value: "doujin_chinese", result: { extern: { type: "doujin_chinese" } } },
{ label: "日语", value: "doujin_japanese", result: { extern: { type: "doujin_japanese" } } },
],
},
{
label: "单本",
value: "single",
result: { extern: { type: "single" } },
children: [
{ label: "汉化", value: "single_chinese", result: { extern: { type: "single_chinese" } } },
{ label: "日语", value: "single_japanese", result: { extern: { type: "single_japanese" } } },
],
},
],
},
{
key: "order",
kind: "choice",
label: "排序",
options: [
{ label: "最新", value: "new", result: { extern: { order: "new" } } },
{ label: "最热", value: "hot", result: { extern: { order: "hot" } } },
],
},
],
},
data: { values: { category: "latest", order: "new" } },
};
}
// 用户选中「同人 → 汉化」后,下一次列表请求的 extern 合并为 { ..., type: "doujin_chinese" };
// 选中「最新」则直接合并 { ..., type: "0" },无二级面板。合并规则见 6.2。
getFunctionPage(payload)(类型见 kit:GetFunctionPagePayload / FunctionPageContract)
功能页数据函数,由 openPluginFunction 的 payload.id 指定。点进功能页入口后宿主调用,
入参 { id, page, core, extern }(id 即入口配置的 payload.id),未知 id 应抛错。
import type {
FunctionPageContract,
GetFunctionPagePayload,
} from "breeze-plugin-kit";
async function getFunctionPage(
payload: GetFunctionPagePayload,
): Promise<FunctionPageContract> {}
页面样式由 scheme.body 决定,data 按 body 引用的 key 提供内容。scheme.body 固定为
{ type: "list", children: [...] } 容器,children 每项声明一种区块及其数据来源:
{ type: "chip-list", key }:标签条。data[key]为{ items: [{ label, action }] }, 横向排列可点击标签,点击执行action(一般为openSearch)。{ type: "action-grid", key }:图标宫格。data[key]为{ items: [{ title, cover, action }] },cover含url/path/extern, 点击格子执行action(一般为openSearch/openComicList)。{ type: "comic-section-list", key }:漫画分区列表。data[key]为{ sections: [{ title, subtitle, action, items: ComicListItem[] }] }, 每区渲染"标题 + 横滑漫画卡片",卡片字段与列表页一致。{ type: "comic-grid", key }:漫画网格。data[key]为{ items: ComicListItem[] }, 与列表页同样式,可带title/action作为区头。
hasReachedMax 为 true 时宿主停止对该 key 的分页请求。presentation: "dialog" 的
功能页适合放 chip-list 这类轻量区块,"page" 整页适合放宫格 / 分区 / 网格。
type GetFunctionPagePayload = {
id?: string;
page?: number;
core?: Record<string, unknown>;
extern?: Record<string, unknown>;
};
type FunctionPageContract = {
source: string;
scheme: {
version: "1.0.0";
type: "page";
title: string;
body: FunctionPageBodyNode;
};
data: FunctionPageData; // 按 body 引用 key 提供 items / sections,见上
};
6. 设置
打开插件设置页时宿主必调
getSettingsBundle;未导出会导致设置页报错 (target is not function: getSettingsBundle)。无配置项的插件也必须导出, 返回最小包即可:async function getSettingsBundle(): Promise<SettingsBundleContract> {return {source: PLUGIN_ID,scheme: { version: "1.0.0", type: "settings", sections: [] },data: { canShowUserInfo: false, values: {} },};}async function getCapabilitiesBundle(): Promise<CapabilitiesBundleContract> {return {source: PLUGIN_ID,scheme: { version: "1.0.0", type: "capabilities", actions: [] },data: {},};}
getSettingsBundle()(类型见 kit:SettingsBundleContract)
import type { SettingsBundleContract } from "breeze-plugin-kit";
async function getSettingsBundle(): Promise<SettingsBundleContract> {}
type SettingsBundleContract = {
source: string;
scheme: { version: "1.0.0"; type: "settings"; sections: SettingsSection[] };
data: {
canShowUserInfo: boolean;
/** 声明支持登录页登录,设置页显示登录入口。 */
canLogin?: boolean;
/** 登录入口标题,缺省用宿主默认「账号登录」。 */
loginTitle?: string | null;
/** 登录入口副标题,缺省用宿主默认「前往登录」。 */
loginSubtitle?: string | null;
/** 声明支持新详情页,设置页显示详情入口,按需调 `getPluginDetail`。 */
canShowDetail?: boolean;
values: Record<string, unknown>;
};
};
type SettingsSection = {
id?: string;
title: string;
fields: SettingsField[];
};
type SettingsField = OptionField | PlainField;
type OptionField = BaseField & {
kind: "select" | "choice" | "multiChoice";
options?: Array<{ label: string; value: unknown }>;
};
type PlainField = BaseField & { kind: "text" | "password" | "switch" };
type BaseField = {
key: string; // 配置键,会出现在 values 和回调 payload 中
kind: FieldKind; // "text" | "password" | "switch" | "select" | "choice" | "multiChoice"
label: string; // 展示标签
fnPath?: string; // 字段变更时回调
persist?: boolean; // 是否由客户端持久化,默认 true
};
当用户修改携带 fnPath 的字段值时,宿主调用该函数,入参 { extern: Record<string, unknown>, key: string, value: unknown }。
getCapabilitiesBundle()(类型见 kit:CapabilitiesBundleContract / CapabilityAction)
设置页底部"操作"区段,点击后调用对应 fnPath。
import type {
CapabilitiesBundleContract,
CapabilityAction,
} from "breeze-plugin-kit";
async function getCapabilitiesBundle(): Promise<CapabilitiesBundleContract> {}
type CapabilitiesBundleContract = {
source: string;
scheme: {
version: "1.0.0";
type: "capabilities";
actions: CapabilityAction[];
};
data: Record<string, unknown>;
};
type CapabilityAction = { key?: string; title: string; fnPath: string };
getUserInfoBundle()(类型见 kit:UserInfoBundleContract)
设置页用户信息卡片。
import type { UserInfoBundleContract } from "breeze-plugin-kit";
async function getUserInfoBundle(): Promise<UserInfoBundleContract> {}
type UserInfoBundleContract = {
source: string;
scheme: { version: "1.0.0"; type: "userInfo" };
data: {
title?: string;
avatar: ImageItem;
lines: string[];
extern?: Record<string, unknown>;
};
};
6.5 登录
登录页由插件声明表单、宿主负责渲染。流程:插件在需要登录时抛
type: "unauthorized" 错误(只带 source 与 message)→ 宿主弹确认框 →
用户确认后跳转登录页,登录页调 getLoginBundle 现取表单 → 提交调
action.fnPath,成功自动关闭,失败弹失败原因。
getLoginBundle()(类型见 kit:LoginBundleContract / LoginField)
返回登录表单。字段 kind 取 text / password / multiline,
分别对应普通输入、密码框、多行输入(cookie / apiKey 用单行或多行文本即可)。
data.values 为回填值(建议只回填账号,密码留空)。
import type {
LoginBundleContract,
LoginBundleInit,
LoginField,
} from "breeze-plugin-kit";
async function getLoginBundle(): Promise<LoginBundleContract> {}
完整形状(与 kit 类型一致,buildLoginBundle 入参为 LoginBundleInit):
type LoginBundleInit = {
title: string;
fields: LoginField[];
submitFnPath: string;
submitText?: string;
values?: Record<string, unknown>;
};
type LoginBundleContract = {
source: string;
scheme: {
version: "1.0.0";
type: "login";
title?: string;
fields: LoginField[];
action: { fnPath: string; submitText?: string; label?: string };
};
data?: { values?: Record<string, unknown> } & Record<string, unknown>;
};
type LoginField = {
key: string;
kind?: "text" | "password" | "multiline";
label?: string;
required?: boolean;
placeholder?: string;
help?: string;
};
提交时宿主调用 action.fnPath,表单值放在 core.values 中。登录态持久化
由插件自己经 pluginConfig 完成,宿主不碰。登录函数可返回 message?: string | null
作为登录成功提示(缺省/null/空白时宿主用默认文案),类型见 kit 的 LoginSubmitResult:
type LoginSubmitResult = {
source: string;
message?: string | null;
data?: { message?: string | null } & Record<string, unknown>;
} & Record<string, unknown>;
need-login 错误
需要登录时抛 JSON 字符串错误,宿主识别 type: "unauthorized" 后走登录流程。
直接用 kit 的 buildUnauthorizedError(同步函数,只带身份与文案):
import {
buildLoginBundle,
buildUnauthorizedError,
} from "breeze-plugin-kit";
import type {
LoginBundleContract,
LoginBundleInit,
} from "breeze-plugin-kit";
async function getLoginBundle(): Promise<LoginBundleContract> {
return buildLoginBundle(PLUGIN_ID, {
title: "示例登录",
fields: [
{ key: "account", kind: "text", label: "用户名", required: true },
{ key: "password", kind: "password", label: "密码", required: true },
],
submitFnPath: "loginWithPassword",
values: { account: await authConfig.load("auth.account") },
} satisfies LoginBundleInit);
}
// 鉴权失败处:
throw buildUnauthorizedError(PLUGIN_ID, "登录过期,请重新登录");
设置页登录入口
getSettingsBundle 的 data 声明 canLogin: true 后,设置页用户信息区底部
会出现"账号登录"行,点击打开登录页。推荐使用此办法进行登录或设置鉴权密钥。
标题/副标题可用 data.loginTitle / data.loginSubtitle 自定义
(如 Cookie 登录、API Key 登录),缺省/空白时用宿主默认文案。
插件详情页(类型见 kit:PluginDetailContract)
getSettingsBundle 的 data 声明 canShowDetail: true 后,设置页插件管理上方
出现"插件详情"入口,点击打开新详情页。详情内容按需调 getPluginDetail 获取,
不在列表/设置页预取。describe 支持 md 渲染(链接用外部浏览器打开):
type PluginDetailContract = {
source: string;
data: {
creator?: {
name: string;
describe?: string | null;
iconUrl?: string | null;
} | null;
describe: string;
};
};
async function getPluginDetail(): Promise<PluginDetailContract> {}
插件作者展示不再读 getInfo.creator(已删除):商店作者名由云端
repo(owner/name)取 / 前的 owner,详情页作者名读
getPluginDetail 返回的 data.creator.name。
7. 数据流与调用链
6.1 搜索流程:高级搜索 → searchComic
用户打开搜索页 → 宿主调 getAdvancedSearchScheme() → 渲染高级搜索 UI
用户选择筛选项 → 不立即请求
用户点搜索 / 翻页 → 宿主调 searchComic({ keyword, page, extern: { sortBy, categories, ... } })
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
高级搜索选中的 key-value 放入 extern
具体来说:
getAdvancedSearchScheme().scheme.fields[].key定义了筛选参数名(如sortBy、categories)- 用户选择后,选中的 key-value 会放入
searchComic的extern字段 - 插件在
searchComic中通过extern.sortBy/extern.categories读取筛选值
6.2 筛选器:filter bundle → 列表请求
列表页加载 → 宿主调 body.request.fnPath(如 getRankingData)获取初始数据
用户点筛选 → 宿主调 filter.fnPath(如 getRankingFilterBundle)获取筛选项
用户选择筛选项 → 宿主将 option.result 合并到下一次列表请求
合并规则:
result.core中的字段直接写入下一次列表请求的顶层result.extern中的字段合并进下一次列表请求的extern
// 例如 FilterOption:
{ label: "日榜", value: "day", result: { core: { type: "comic" }, extern: { rankType: "day" } } }
// 用户选择后,下一次 getRankingData 的入参变为:
{ page: 1, type: "comic", extern: { source: "ranking", rankType: "day" } }
// ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
// result.core 平铺 result.extern 合并
6.3 设置字段回调
设置字段可携带 fnPath。用户修改字段值时,宿主调用该 fnPath,入参为:
type SettingsFieldCallbackPayload = {
extern: Record<string, unknown>;
key: string; // 字段 key,如 "auth.account"
value: unknown; // 新值,可能是单值也可能是数组
};
示例:
{ extern: {}, key: "auth.remember", value: true }
{ extern: {}, key: "display.theme", value: "dark" }
{ extern: {}, key: "content.hiddenTags", value: ["tag-b", "tag-c", "tag-d"] }
- 普通字段(text / password / switch / choice)的
value为单个值。 multiChoice字段的value为数组,例如onHiddenTagsChanged接收到的就是标签数组。
示例仓库中的对应回调:
- 文本/密码变更 →
onAuthChanged - 开关变更 →
onRememberChanged、onAdultChanged - choice 变更 →
onThemeChanged、onQualityChanged - multiChoice 变更 →
onHiddenTagsChanged
插件可在回调中做校验、保存到配置等操作。
6.4 能力操作回调
getCapabilitiesBundle().scheme.actions[] 中每项的 fnPath 在用户点击时被宿主调用,无入参。
6.5 功能入口调用链
getInfo().function[] 定义了插件卡片上的功能入口按钮。以 openComicList 为例:
用户点"排行榜"按钮
→ 宿主读取对应 action.payload.scene
→ 渲染列表页(标题等;仅当 scene 配置了 filter 时才展示筛选按钮)
→ 调用 scene.body.request.fnPath(如 getRankingData)获取列表数据
→ 用户点筛选 → 调用 scene.filter.fnPath(如 getRankingFilterBundle)
scene.body.request.core 和 scene.body.request.extern 作为固定参数传入每次列表请求,与筛选器动态参数合并。
以 openPluginFunction 为例:
用户点功能按钮
→ 宿主读取 action.payload.id / presentation,打开整页或弹窗
→ 调用 getFunctionPage({ id }) 获取 scheme.body + data
→ 按 body.children 渲染 chip-list / action-grid / comic-section-list / comic-grid
→ 用户点格子 → 执行该项 action(如 openSearch / openComicList),进入对应列表或搜索页
6.6 实践建议
core和extern各司其职:业务参数(分页、类型、排序等)放core,会话/上下文透传放extern。能放进core的不要混进extern。- 筛选器
options.value保持稳定:一旦确定不要随版本改变语义,否则用户已保存的筛选值会失效。 data.values一定给默认值:避免空选项导致请求参数缺失,宿主不会补默认值。- 对外提供的
fnPath键名保持兼容:如需要重命名,建议snake_case+camelCase同时导出。