mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 00:00:26 +00:00
Introduce support for plugin icons across backend and frontend. Updates include: - napcat-onebot: add optional `icon` field to PluginPackageJson. - Backend (api/Plugin, PluginStore, router): add handlers/utilities to locate and serve plugin icons (`GetPluginIconHandler`, getPluginIconUrl, findPluginIconPath) and wire the route `/api/Plugin/Icon/:pluginId`. - Cache logic: implement `cachePluginIcon` to fetch GitHub user avatars and store as `data/icon.png` when package.json lacks an icon; invoked after plugin install (regular and SSE flows). - Frontend: add `icon` to PluginItem, prefer backend-provided icon URL in plugin card (via new getPluginIconUrl util that appends webui_token query param), and add the util to handle token-based image requests. - Plugin store UI: add a Random category (shuffled), client-side pagination, and reset page on tab/search changes. These changes let the UI display plugin icons (falling back to author/avatar or Vercel avatars) and cache icons for better UX, while handling auth by passing the token as a query parameter for img src requests.
238 lines
6.4 KiB
TypeScript
238 lines
6.4 KiB
TypeScript
import { serverRequest } from '@/utils/request';
|
||
import { PluginStoreList, PluginStoreItem } from '@/types/plugin-store';
|
||
|
||
/** 插件状态 */
|
||
export type PluginStatus = 'active' | 'disabled' | 'stopped';
|
||
|
||
/** 插件信息 */
|
||
export interface PluginItem {
|
||
/** 显示名称 (优先 package.json 的 plugin 字段) */
|
||
name: string;
|
||
/** 包名 (package name),用于 API 操作 */
|
||
id: string;
|
||
/** 版本号 */
|
||
version: string;
|
||
/** 描述 */
|
||
description: string;
|
||
/** 作者 */
|
||
author: string;
|
||
/** 状态: active-运行中, disabled-已禁用, stopped-已停止 */
|
||
status: PluginStatus;
|
||
/** 是否有配置项 */
|
||
hasConfig?: boolean;
|
||
/** 是否有扩展页面 */
|
||
hasPages?: boolean;
|
||
/** 主页链接 */
|
||
homepage?: string;
|
||
/** 仓库链接 */
|
||
repository?: string;
|
||
/** 插件图标 URL(由后端返回) */
|
||
icon?: string;
|
||
}
|
||
|
||
/** 扩展页面信息 */
|
||
export interface ExtensionPageItem {
|
||
/** 插件 ID */
|
||
pluginId: string;
|
||
/** 插件名称 */
|
||
pluginName: string;
|
||
/** 页面路径 */
|
||
path: string;
|
||
/** 页面标题 */
|
||
title: string;
|
||
/** 页面图标 */
|
||
icon?: string;
|
||
/** 页面描述 */
|
||
description?: string;
|
||
}
|
||
|
||
/** 插件列表响应 */
|
||
export interface PluginListResponse {
|
||
plugins: PluginItem[];
|
||
pluginManagerNotFound: boolean;
|
||
extensionPages: ExtensionPageItem[];
|
||
}
|
||
|
||
/** 插件配置项定义 */
|
||
export interface PluginConfigSchemaItem {
|
||
key: string;
|
||
type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text';
|
||
label: string;
|
||
description?: string;
|
||
default?: any;
|
||
options?: { label: string; value: string | number; }[];
|
||
placeholder?: string;
|
||
/** 标记此字段为响应式:值变化时触发 schema 刷新 */
|
||
reactive?: boolean;
|
||
/** 是否隐藏此字段 */
|
||
hidden?: boolean;
|
||
}
|
||
|
||
/** 插件配置响应 */
|
||
export interface PluginConfigResponse {
|
||
schema: PluginConfigSchemaItem[];
|
||
config: Record<string, unknown>;
|
||
/** 是否支持响应式更新 */
|
||
supportReactive?: boolean;
|
||
}
|
||
|
||
/** 服务端响应 */
|
||
export interface ServerResponse<T> {
|
||
code: number;
|
||
message: string;
|
||
data: T;
|
||
}
|
||
|
||
/**
|
||
* 插件管理器 API
|
||
*/
|
||
export default class PluginManager {
|
||
/**
|
||
* 获取插件列表
|
||
*/
|
||
public static async getPluginList (): Promise<PluginListResponse> {
|
||
const { data } = await serverRequest.get<ServerResponse<PluginListResponse>>('/Plugin/List');
|
||
return data.data;
|
||
}
|
||
|
||
/**
|
||
* 手动注册插件管理器到 NetworkManager
|
||
*/
|
||
public static async registerPluginManager (): Promise<{ message: string; }> {
|
||
const { data } = await serverRequest.post<ServerResponse<{ message: string; }>>('/Plugin/RegisterManager');
|
||
return data.data;
|
||
}
|
||
|
||
/**
|
||
* 设置插件状态(启用/禁用)
|
||
* @param id 插件包名
|
||
* @param enable 是否启用
|
||
*/
|
||
public static async setPluginStatus (id: string, enable: boolean): Promise<void> {
|
||
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { id, enable });
|
||
}
|
||
|
||
/**
|
||
* 卸载插件
|
||
* @param id 插件包名
|
||
* @param cleanData 是否清理数据
|
||
*/
|
||
public static async uninstallPlugin (id: string, cleanData?: boolean): Promise<void> {
|
||
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { id, cleanData });
|
||
}
|
||
|
||
/**
|
||
* 导入本地插件包
|
||
* @param file 插件 zip 文件
|
||
*/
|
||
public static async importLocalPlugin (file: File): Promise<{ message: string; pluginId: string; installPath: string; }> {
|
||
const formData = new FormData();
|
||
formData.append('plugin', file);
|
||
|
||
const { data } = await serverRequest.post<ServerResponse<{ message: string; pluginId: string; installPath: string; }>>(
|
||
'/Plugin/Import',
|
||
formData,
|
||
{
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data',
|
||
},
|
||
timeout: 60000, // 60秒超时
|
||
}
|
||
);
|
||
return data.data;
|
||
}
|
||
|
||
// ==================== 插件商店 ====================
|
||
|
||
/**
|
||
* 获取插件商店列表
|
||
* @param forceRefresh 是否强制刷新(跳过服务端缓存)
|
||
*/
|
||
public static async getPluginStoreList (forceRefresh: boolean = false): Promise<PluginStoreList> {
|
||
const params = forceRefresh ? { forceRefresh: 'true' } : {};
|
||
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List', { params });
|
||
return data.data;
|
||
}
|
||
|
||
/**
|
||
* 获取插件商店详情
|
||
* @param id 插件 ID
|
||
*/
|
||
public static async getPluginStoreDetail (id: string): Promise<PluginStoreItem> {
|
||
const { data } = await serverRequest.get<ServerResponse<PluginStoreItem>>(`/Plugin/Store/Detail/${id}`);
|
||
return data.data;
|
||
}
|
||
|
||
/**
|
||
* 从商店安装插件
|
||
* @param id 插件 ID
|
||
* @param mirror 镜像源
|
||
*/
|
||
public static async installPluginFromStore (id: string, mirror?: string): Promise<void> {
|
||
await serverRequest.post<ServerResponse<void>>(
|
||
'/Plugin/Store/Install',
|
||
{ id, mirror },
|
||
{ timeout: 300000 } // 5分钟超时
|
||
);
|
||
}
|
||
|
||
// ==================== 插件配置 ====================
|
||
|
||
/**
|
||
* 获取插件配置
|
||
* @param id 插件包名
|
||
*/
|
||
public static async getPluginConfig (id: string): Promise<PluginConfigResponse> {
|
||
const { data } = await serverRequest.get<ServerResponse<PluginConfigResponse>>('/Plugin/Config', {
|
||
params: { id }
|
||
});
|
||
return data.data;
|
||
}
|
||
|
||
/**
|
||
* 设置插件配置
|
||
* @param id 插件包名
|
||
* @param config 配置内容
|
||
*/
|
||
public static async setPluginConfig (id: string, config: Record<string, unknown>): Promise<void> {
|
||
await serverRequest.post<ServerResponse<void>>('/Plugin/Config', { id, config });
|
||
}
|
||
|
||
/**
|
||
* 通知配置字段变化
|
||
* @param id 插件包名
|
||
* @param sessionId SSE 会话 ID
|
||
* @param key 变化的字段
|
||
* @param value 新值
|
||
* @param currentConfig 当前配置
|
||
*/
|
||
public static async notifyConfigChange (
|
||
id: string,
|
||
sessionId: string,
|
||
key: string,
|
||
value: unknown,
|
||
currentConfig: Record<string, unknown>
|
||
): Promise<void> {
|
||
await serverRequest.post<ServerResponse<void>>('/Plugin/Config/Change', {
|
||
id,
|
||
sessionId,
|
||
key,
|
||
value,
|
||
currentConfig
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取配置 SSE URL
|
||
* @param id 插件包名
|
||
* @param config 初始配置
|
||
*/
|
||
public static getConfigSSEUrl (id: string, config?: Record<string, unknown>): string {
|
||
const params = new URLSearchParams({ id });
|
||
if (config) {
|
||
params.set('config', JSON.stringify(config));
|
||
}
|
||
return `/api/Plugin/Config/SSE?${params.toString()}`;
|
||
}
|
||
}
|