Merge branch 'main' into feat/flash-transfer-thumbnail

This commit is contained in:
H3CoF6 2026-01-24 13:29:48 +08:00
commit 1e6468012c
14 changed files with 572 additions and 11 deletions

View File

@ -294,8 +294,7 @@ function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLe
}
if (element.markdownElement) {
// console.log(element.markdownElement);
if (element.markdownElement.mdSummary !== undefined && element.markdownElement.mdExtInfo !== undefined && element.markdownElement.mdExtInfo.flashTransferInfo) {
if (element.markdownElement?.mdSummary) {
return element.markdownElement.mdSummary;
} else {
return '[Markdown 消息]';

View File

@ -561,7 +561,7 @@ export class OneBotMsgApi {
markdownElement: async (element) => {
// 让QQ闪传消息独立出去
if (element.mdExtInfo !== undefined && element.mdExtInfo.flashTransferInfo) {
if (element?.mdExtInfo?.flashTransferInfo?.filesetId) {
return {
type: OB11MessageDataType.flashtransfer,
data: {

View File

@ -16,7 +16,8 @@ const getPluginManager = (): OB11PluginMangerAdapter | null => {
export const GetPluginListHandler: RequestHandler = async (_req, res) => {
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
// 返回成功但带特殊标记
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true });
}
// 辅助函数:根据文件名/路径生成唯一ID作为配置键
@ -113,7 +114,7 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
}
}
return sendSuccess(res, allPlugins);
return sendSuccess(res, { plugins: allPlugins, pluginManagerNotFound: false });
};
export const ReloadPluginHandler: RequestHandler = async (req, res) => {
@ -124,7 +125,7 @@ export const ReloadPluginHandler: RequestHandler = async (req, res) => {
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
return sendError(res, '插件管理器未加载,请检查 plugins 目录是否存在');
}
const success = await pluginManager.reloadPlugin(name);

View File

@ -0,0 +1,177 @@
import { RequestHandler } from 'express';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { PluginStoreList } from '@/napcat-webui-backend/src/types/PluginStore';
// Mock数据 - 模拟远程插件列表
const mockPluginStoreData: PluginStoreList = {
version: '1.0.0',
updateTime: new Date().toISOString(),
plugins: [
{
id: 'napcat-plugin-example',
name: '示例插件',
version: '1.0.0',
description: '这是一个示例插件展示如何开发NapCat插件',
author: 'NapCat Team',
homepage: 'https://github.com/NapNeko/NapCatQQ',
repository: 'https://github.com/NapNeko/NapCatQQ',
downloadUrl: 'https://example.com/plugins/napcat-plugin-example-1.0.0.zip',
tags: ['示例', '教程'],
screenshots: ['https://picsum.photos/800/600?random=1'],
minNapCatVersion: '1.0.0',
downloads: 1234,
rating: 4.5,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-20T00:00:00Z',
},
{
id: 'napcat-plugin-auto-reply',
name: '自动回复插件',
version: '2.1.0',
description: '支持关键词匹配的自动回复功能,可配置多种回复规则',
author: 'Community',
homepage: 'https://github.com/example/auto-reply',
repository: 'https://github.com/example/auto-reply',
downloadUrl: 'https://example.com/plugins/napcat-plugin-auto-reply-2.1.0.zip',
tags: ['自动回复', '消息处理'],
minNapCatVersion: '1.0.0',
downloads: 5678,
rating: 4.8,
createdAt: '2024-01-05T00:00:00Z',
updatedAt: '2024-01-22T00:00:00Z',
},
{
id: 'napcat-plugin-welcome',
name: '入群欢迎插件',
version: '1.2.3',
description: '新成员入群时自动发送欢迎消息,支持自定义欢迎语',
author: 'Developer',
homepage: 'https://github.com/example/welcome',
repository: 'https://github.com/example/welcome',
downloadUrl: 'https://example.com/plugins/napcat-plugin-welcome-1.2.3.zip',
tags: ['欢迎', '群管理'],
minNapCatVersion: '1.0.0',
downloads: 3456,
rating: 4.3,
createdAt: '2024-01-10T00:00:00Z',
updatedAt: '2024-01-18T00:00:00Z',
},
{
id: 'napcat-plugin-music',
name: '音乐点歌插件',
version: '3.0.1',
description: '支持网易云、QQ音乐等平台的点歌功能',
author: 'Music Lover',
homepage: 'https://github.com/example/music',
repository: 'https://github.com/example/music',
downloadUrl: 'https://example.com/plugins/napcat-plugin-music-3.0.1.zip',
tags: ['音乐', '娱乐'],
screenshots: ['https://picsum.photos/800/600?random=4', 'https://picsum.photos/800/600?random=5'],
minNapCatVersion: '1.1.0',
downloads: 8901,
rating: 4.9,
createdAt: '2023-12-01T00:00:00Z',
updatedAt: '2024-01-23T00:00:00Z',
},
{
id: 'napcat-plugin-admin',
name: '群管理插件',
version: '2.5.0',
description: '提供踢人、禁言、设置管理员等群管理功能',
author: 'Admin Tools',
homepage: 'https://github.com/example/admin',
repository: 'https://github.com/example/admin',
downloadUrl: 'https://example.com/plugins/napcat-plugin-admin-2.5.0.zip',
tags: ['管理', '群管理', '工具'],
minNapCatVersion: '1.0.0',
downloads: 6789,
rating: 4.6,
createdAt: '2023-12-15T00:00:00Z',
updatedAt: '2024-01-21T00:00:00Z',
},
{
id: 'napcat-plugin-image-search',
name: '以图搜图插件',
version: '1.5.2',
description: '支持多个搜图引擎,快速找到图片来源',
author: 'Image Hunter',
homepage: 'https://github.com/example/image-search',
repository: 'https://github.com/example/image-search',
downloadUrl: 'https://example.com/plugins/napcat-plugin-image-search-1.5.2.zip',
tags: ['图片', '搜索', '工具'],
minNapCatVersion: '1.0.0',
downloads: 4567,
rating: 4.4,
createdAt: '2024-01-08T00:00:00Z',
updatedAt: '2024-01-19T00:00:00Z',
},
],
};
/**
*
* URL读取
*/
export const GetPluginStoreListHandler: RequestHandler = async (_req, res) => {
try {
// TODO: 未来从远程URL读取
// const remoteUrl = 'https://napcat.example.com/plugin-list.json';
// const response = await fetch(remoteUrl);
// const data = await response.json();
// 目前返回Mock数据
return sendSuccess(res, mockPluginStoreData);
} catch (e: any) {
return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
}
};
/**
*
*/
export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
try {
const { id } = req.params;
const plugin = mockPluginStoreData.plugins.find(p => p.id === id);
if (!plugin) {
return sendError(res, 'Plugin not found');
}
return sendSuccess(res, plugin);
} catch (e: any) {
return sendError(res, 'Failed to fetch plugin detail: ' + e.message);
}
};
/**
*
* TODO: 实现实际的下载和安装逻辑
*/
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
try {
const { id } = req.body;
if (!id) {
return sendError(res, 'Plugin ID is required');
}
const plugin = mockPluginStoreData.plugins.find(p => p.id === id);
if (!plugin) {
return sendError(res, 'Plugin not found in store');
}
// TODO: 实现实际的下载和安装逻辑
// 1. 下载插件文件
// 2. 解压到插件目录
// 3. 加载插件
return sendSuccess(res, {
message: 'Plugin installation started',
plugin: plugin
});
} catch (e: any) {
return sendError(res, 'Failed to install plugin: ' + e.message);
}
};

View File

@ -1,5 +1,6 @@
import { Router } from 'express';
import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin';
import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler } from '@/napcat-webui-backend/src/api/PluginStore';
const router: Router = Router();
@ -8,4 +9,9 @@ router.post('/Reload', ReloadPluginHandler);
router.post('/SetStatus', SetPluginStatusHandler);
router.post('/Uninstall', UninstallPluginHandler);
// 插件商店相关路由
router.get('/Store/List', GetPluginStoreListHandler);
router.get('/Store/Detail/:id', GetPluginStoreDetailHandler);
router.post('/Store/Install', InstallPluginFromStoreHandler);
export { router as PluginRouter };

View File

@ -0,0 +1,27 @@
// 插件商店相关类型定义
export interface PluginStoreItem {
id: string; // 插件唯一标识
name: string; // 插件名称
version: string; // 最新版本
description: string; // 插件描述
author: string; // 作者
homepage?: string; // 主页链接
repository?: string; // 仓库地址
downloadUrl: string; // 下载地址
tags?: string[]; // 标签
icon?: string; // 图标URL
screenshots?: string[]; // 截图
minNapCatVersion?: string; // 最低NapCat版本要求
dependencies?: Record<string, string>; // 依赖
downloads?: number; // 下载次数
rating?: number; // 评分
createdAt?: string; // 创建时间
updatedAt?: string; // 更新时间
}
export interface PluginStoreList {
version: string; // 索引版本
updateTime: string; // 更新时间
plugins: PluginStoreItem[]; // 插件列表
}

View File

@ -26,6 +26,7 @@ const LogsPage = lazy(() => import('@/pages/dashboard/logs'));
const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
const PluginPage = lazy(() => import('@/pages/dashboard/plugin'));
const PluginStorePage = lazy(() => import('@/pages/dashboard/plugin_store'));
function App () {
return (
@ -78,6 +79,7 @@ function AppRoutes () {
<Route path='file_manager' element={<FileManagerPage />} />
<Route path='terminal' element={<TerminalPage />} />
<Route path='plugins' element={<PluginPage />} />
<Route path='plugin_store' element={<PluginStorePage />} />
<Route path='about' element={<AboutPage />} />
</Route>
<Route path='/qq_login' element={<QQLoginPage />} />

View File

@ -0,0 +1,117 @@
import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip';
import { useState } from 'react';
import { IoMdStar, IoMdDownload } from 'react-icons/io';
import DisplayCardContainer from './container';
import { PluginStoreItem } from '@/types/plugin-store';
export interface PluginStoreCardProps {
data: PluginStoreItem;
onInstall: () => Promise<void>;
}
const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
data,
onInstall,
}) => {
const { name, version, author, description, tags, rating, icon } = data;
const [processing, setProcessing] = useState(false);
const handleInstall = () => {
setProcessing(true);
onInstall().finally(() => setProcessing(false));
};
return (
<DisplayCardContainer
className='w-full max-w-[420px]'
title={name}
tag={
<Chip
className="ml-auto"
color="primary"
size="sm"
variant="flat"
>
v{version}
</Chip>
}
enableSwitch={
icon ? (
<div className="flex items-center gap-2">
<img
src={icon}
alt={name}
className="w-10 h-10 rounded-lg object-cover"
/>
</div>
) : undefined
}
action={
<Button
fullWidth
radius='full'
size='sm'
color='primary'
startContent={<IoMdDownload size={16} />}
onPress={handleInstall}
isLoading={processing}
isDisabled={processing}
>
</Button>
}
>
<div className='grid grid-cols-2 gap-3'>
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{author || '未知'}
</div>
</div>
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
v{version}
</div>
</div>
<div className='col-span-2 flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2 h-10 overflow-hidden'>
{description || '暂无描述'}
</div>
</div>
{rating && (
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='flex items-center gap-1 text-sm font-medium text-default-700 dark:text-white/90'>
<IoMdStar className='text-warning' size={16} />
<span>{rating.toFixed(1)}</span>
</div>
</div>
)}
{tags && tags.length > 0 && (
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{tags.slice(0, 2).join(' · ')}
</div>
</div>
)}
</div>
</DisplayCardContainer>
);
};
export default PluginStoreCard;

View File

@ -55,7 +55,7 @@ const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
if (['httpServers', 'httpSseServers', 'websocketServers'].includes(field)) {
const serverData = data as any;
if (!serverData.token) {
if (!serverData.token && serverData.host !== '127.0.0.1') {
await new Promise<void>((resolve, reject) => {
dialog.confirm({
title: '安全警告',

View File

@ -9,6 +9,7 @@ import {
LuTerminal,
LuZap,
LuPackage,
LuStore,
} from 'react-icons/lu';
export type SiteConfig = typeof siteConfig;
@ -65,6 +66,11 @@ export const siteConfig = {
icon: <LuPackage className='w-5 h-5' />,
href: '/plugins',
},
{
label: '插件商店',
icon: <LuStore className='w-5 h-5' />,
href: '/plugin_store',
},
{
label: '系统终端',
icon: <LuTerminal className='w-5 h-5' />,

View File

@ -1,4 +1,5 @@
import { serverRequest } from '@/utils/request';
import { PluginStoreList, PluginStoreItem } from '@/types/plugin-store';
export interface PluginItem {
name: string;
@ -9,6 +10,11 @@ export interface PluginItem {
filename?: string;
}
export interface PluginListResponse {
plugins: PluginItem[];
pluginManagerNotFound: boolean;
}
export interface ServerResponse<T> {
code: number;
message: string;
@ -17,7 +23,7 @@ export interface ServerResponse<T> {
export default class PluginManager {
public static async getPluginList () {
const { data } = await serverRequest.get<ServerResponse<PluginItem[]>>('/Plugin/List');
const { data } = await serverRequest.get<ServerResponse<PluginListResponse>>('/Plugin/List');
return data.data;
}
@ -32,4 +38,19 @@ export default class PluginManager {
public static async uninstallPlugin (name: string, filename?: string) {
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { name, filename });
}
// 插件商店相关方法
public static async getPluginStoreList () {
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List');
return data.data;
}
public static async getPluginStoreDetail (id: string) {
const { data } = await serverRequest.get<ServerResponse<PluginStoreItem>>(`/Plugin/Store/Detail/${id}`);
return data.data;
}
public static async installPluginFromStore (id: string) {
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id });
}
}

View File

@ -11,13 +11,20 @@ import useDialog from '@/hooks/use-dialog';
export default function PluginPage () {
const [plugins, setPlugins] = useState<PluginItem[]>([]);
const [loading, setLoading] = useState(false);
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
const dialog = useDialog();
const loadPlugins = async () => {
setLoading(true);
setPluginManagerNotFound(false);
try {
const data = await PluginManager.getPluginList();
setPlugins(data);
const result = await PluginManager.getPluginList();
if (result.pluginManagerNotFound) {
setPluginManagerNotFound(true);
setPlugins([]);
} else {
setPlugins(result.plugins);
}
} catch (e: any) {
toast.error(e.message);
} finally {
@ -94,7 +101,17 @@ export default function PluginPage () {
</Button>
</div>
{plugins.length === 0 ? (
{pluginManagerNotFound ? (
<div className="flex flex-col items-center justify-center min-h-[400px] text-center">
<div className="text-6xl mb-4">📦</div>
<h2 className="text-xl font-semibold text-default-700 dark:text-white/90 mb-2">
</h2>
<p className="text-default-500 dark:text-white/60 max-w-md">
plugins
</p>
</div>
) : plugins.length === 0 ? (
<div className="text-default-400"></div>
) : (
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4'>

View File

@ -0,0 +1,161 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Tab, Tabs } from '@heroui/tabs';
import { useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh, IoMdSearch } from 'react-icons/io';
import clsx from 'clsx';
import PageLoading from '@/components/page_loading';
import PluginStoreCard from '@/components/display_card/plugin_store_card';
import PluginManager from '@/controllers/plugin_manager';
import { PluginStoreItem } from '@/types/plugin-store';
interface EmptySectionProps {
isEmpty: boolean;
}
const EmptySection: React.FC<EmptySectionProps> = ({ isEmpty }) => {
return (
<div
className={clsx('text-default-400', {
hidden: !isEmpty,
})}
>
</div>
);
};
export default function PluginStorePage () {
const [plugins, setPlugins] = useState<PluginStoreItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<string>('all');
const loadPlugins = async () => {
setLoading(true);
try {
const data = await PluginManager.getPluginStoreList();
setPlugins(data.plugins);
} catch (e: any) {
toast.error(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadPlugins();
}, []);
// 按标签分类和搜索
const categorizedPlugins = useMemo(() => {
let filtered = plugins;
// 搜索过滤
if (searchQuery) {
filtered = filtered.filter(
(p) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.author.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.tags?.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()))
);
}
// 按下载量排序
filtered.sort((a, b) => (b.downloads || 0) - (a.downloads || 0));
// 定义主要分类
const categories: Record<string, PluginStoreItem[]> = {
all: filtered,
popular: filtered.filter(p => (p.downloads || 0) > 5000),
tools: filtered.filter(p => p.tags?.some(t => ['工具', '管理', '群管理'].includes(t))),
entertainment: filtered.filter(p => p.tags?.some(t => ['娱乐', '音乐', '游戏'].includes(t))),
message: filtered.filter(p => p.tags?.some(t => ['消息处理', '自动回复', '欢迎'].includes(t))),
};
return categories;
}, [plugins, searchQuery]);
const tabs = useMemo(() => {
return [
{ key: 'all', title: '全部', count: categorizedPlugins.all?.length || 0 },
{ key: 'popular', title: '热门推荐', count: categorizedPlugins.popular?.length || 0 },
{ key: 'tools', title: '工具管理', count: categorizedPlugins.tools?.length || 0 },
{ key: 'entertainment', title: '娱乐功能', count: categorizedPlugins.entertainment?.length || 0 },
{ key: 'message', title: '消息处理', count: categorizedPlugins.message?.length || 0 },
];
}, [categorizedPlugins]);
const handleInstall = async () => {
toast('该功能尚未完工,敬请期待', {
icon: '🚧',
duration: 3000,
});
};
return (
<>
<title> - NapCat WebUI</title>
<div className="p-2 md:p-4 relative">
<PageLoading loading={loading} />
{/* 头部 */}
<div className="flex mb-6 items-center gap-4">
<h1 className="text-2xl font-bold"></h1>
<Button
isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius="full"
onPress={loadPlugins}
>
<IoMdRefresh size={24} />
</Button>
</div>
{/* 搜索框 */}
<div className="mb-6">
<Input
placeholder="搜索插件名称、描述、作者或标签..."
startContent={<IoMdSearch className="text-default-400" />}
value={searchQuery}
onValueChange={setSearchQuery}
className="max-w-md"
/>
</div>
{/* 标签页 */}
<Tabs
aria-label="Plugin Store Categories"
className="max-w-full"
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(String(key))}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={`${tab.title} (${tab.count})`}
>
<EmptySection isEmpty={!categorizedPlugins[tab.key]?.length} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
{categorizedPlugins[tab.key]?.map((plugin) => (
<PluginStoreCard
key={plugin.id}
data={plugin}
onInstall={handleInstall}
/>
))}
</div>
</Tab>
))}
</Tabs>
</div>
</>
);
}

View File

@ -0,0 +1,27 @@
// 插件商店相关类型定义
export interface PluginStoreItem {
id: string; // 插件唯一标识
name: string; // 插件名称
version: string; // 最新版本
description: string; // 插件描述
author: string; // 作者
homepage?: string; // 主页链接
repository?: string; // 仓库地址
downloadUrl: string; // 下载地址
tags?: string[]; // 标签
icon?: string; // 图标URL
screenshots?: string[]; // 截图
minNapCatVersion?: string; // 最低NapCat版本要求
dependencies?: Record<string, string>; // 依赖
downloads?: number; // 下载次数
rating?: number; // 评分
createdAt?: string; // 创建时间
updatedAt?: string; // 更新时间
}
export interface PluginStoreList {
version: string; // 索引版本
updateTime: string; // 更新时间
plugins: PluginStoreItem[]; // 插件列表
}