From 61a16b44a4cc19f6eb3e8eea8038af41b355cc69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=B6=E7=91=BE?= <74231782+sj817@users.noreply.github.com> Date: Sat, 7 Feb 2026 13:30:50 +0800 Subject: [PATCH] =?UTF-8?q?style(webui):=20=E4=BC=98=E5=8C=96=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=95=86=E5=BA=97=E4=B8=8E=E6=8F=92=E4=BB=B6=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=95=8C=E9=9D=A2=20UI/UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构插件卡片样式,采用毛玻璃效果与主题色交互 - 优化插件商店搜索栏布局,增加对顶部搜索及 Ctrl+F 快捷键的支持 - 实现智能头像提取逻辑,支持从 GitHub、自定义域名(Favicon)及 Vercel 自动生成 - 增加插件描述溢出预览(悬停提示及点击展开功能) - 修复标签溢出处理,支持 Tooltip 完整显示 - 增强后端插件列表 API,支持返回主页及仓库信息 - 修复部分类型错误与代码规范问题 --- .../napcat-onebot/network/plugin/types.ts | 2 + packages/napcat-shell/base.ts | 2 +- .../napcat-webui-backend/src/api/Plugin.ts | 6 +- .../src/api/PluginStore.ts | 109 ++++- .../components/display_card/plugin_card.tsx | 247 +++++++---- .../display_card/plugin_store_card.tsx | 393 +++++++++++------- .../src/controllers/plugin_manager.ts | 4 + .../src/pages/dashboard/extension.tsx | 47 ++- .../src/pages/dashboard/plugin_store.tsx | 249 +++++++---- 9 files changed, 730 insertions(+), 329 deletions(-) diff --git a/packages/napcat-onebot/network/plugin/types.ts b/packages/napcat-onebot/network/plugin/types.ts index 1c0718ae..0465bffe 100644 --- a/packages/napcat-onebot/network/plugin/types.ts +++ b/packages/napcat-onebot/network/plugin/types.ts @@ -13,6 +13,8 @@ export interface PluginPackageJson { main?: string; description?: string; author?: string; + homepage?: string; + repository?: string | { type: string; url: string; }; } // ==================== 插件配置 Schema ==================== diff --git a/packages/napcat-shell/base.ts b/packages/napcat-shell/base.ts index 20b38c93..81730793 100644 --- a/packages/napcat-shell/base.ts +++ b/packages/napcat-shell/base.ts @@ -390,7 +390,7 @@ export async function NCoreInitShell () { // 初始化 FFmpeg 服务 await FFmpegService.init(pathWrapper.binaryPath, logger); - if (!(process.env['NAPCAT_DISABLE_PIPE'] == '1' || process.env['NAPCAT_WORKER_PROCESS'] == '1')) { + if (!(process.env['NAPCAT_DISABLE_PIPE'] === '1' || process.env['NAPCAT_WORKER_PROCESS'] === '1')) { await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e)); } const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); diff --git a/packages/napcat-webui-backend/src/api/Plugin.ts b/packages/napcat-webui-backend/src/api/Plugin.ts index 0367ba2b..a3f7e826 100644 --- a/packages/napcat-webui-backend/src/api/Plugin.ts +++ b/packages/napcat-webui-backend/src/api/Plugin.ts @@ -111,7 +111,11 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { author: p.packageJson?.author || '', status, hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui), - hasPages + hasPages, + homepage: p.packageJson?.homepage, + repository: typeof p.packageJson?.repository === 'string' + ? p.packageJson.repository + : p.packageJson?.repository?.url }); // 收集插件的扩展页面 diff --git a/packages/napcat-webui-backend/src/api/PluginStore.ts b/packages/napcat-webui-backend/src/api/PluginStore.ts index 5e9efe17..fb8b5679 100644 --- a/packages/napcat-webui-backend/src/api/PluginStore.ts +++ b/packages/napcat-webui-backend/src/api/PluginStore.ts @@ -40,7 +40,7 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise { +async function downloadFile ( + url: string, + destPath: string, + customMirror?: string, + onProgress?: (percent: number, downloaded: number, total: number, speed: number) => void, + timeout: number = 120000 // 默认120秒超时 +): Promise { try { let downloadUrl: string; @@ -126,7 +132,7 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin headers: { 'User-Agent': 'NapCat-WebUI', }, - signal: AbortSignal.timeout(120000), // 实际下载120秒超时 + signal: AbortSignal.timeout(timeout), // 使用传入的超时时间 }); if (!response.ok) { @@ -137,9 +143,45 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin throw new Error('Response body is null'); } + const totalLength = Number(response.headers.get('content-length')) || 0; + + // 初始进度通知 + if (onProgress) { + onProgress(0, 0, totalLength, 0); + } + + let downloaded = 0; + let lastTime = Date.now(); + let lastDownloaded = 0; + + // 进度监控流 + // eslint-disable-next-line @stylistic/generator-star-spacing + const progressMonitor = async function* (source: any) { + for await (const chunk of source) { + downloaded += chunk.length; + const now = Date.now(); + const elapsedSinceLast = now - lastTime; + + // 每隔 500ms 或完成时计算一次速度并更新进度 + if (elapsedSinceLast >= 500 || (totalLength && downloaded === totalLength)) { + const percent = totalLength ? Math.round((downloaded / totalLength) * 100) : 0; + const speed = (downloaded - lastDownloaded) / (elapsedSinceLast / 1000); // bytes/s + + if (onProgress) { + onProgress(percent, downloaded, totalLength, speed); + } + + lastTime = now; + lastDownloaded = downloaded; + } + + yield chunk; + } + }; + // 写入文件 const fileStream = createWriteStream(destPath); - await pipeline(response.body as any, fileStream); + await pipeline(progressMonitor(response.body), fileStream); console.log(`Successfully downloaded to: ${destPath}`); } catch (e: any) { @@ -210,7 +252,7 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise } catch (e) { // 解压失败时,尝试恢复 data 文件夹 if (hasDataBackup && fs.existsSync(tempDataDir)) { - console.log(`[extractPlugin] Extract failed, restoring data directory`); + console.log('[extractPlugin] Extract failed, restoring data directory'); if (!fs.existsSync(pluginDir)) { fs.mkdirSync(pluginDir, { recursive: true }); } @@ -224,7 +266,7 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise // 列出解压后的文件 const files = fs.readdirSync(pluginDir); - console.log(`[extractPlugin] Extracted files:`, files); + console.log('[extractPlugin] Extracted files:', files); } /** @@ -279,12 +321,21 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => return sendError(res, 'Plugin not found in store'); } + // 检查是否已安装相同版本 + const pm = getPluginManager(); + if (pm) { + const installedInfo = pm.getPluginInfo(id); + if (installedInfo && installedInfo.version === plugin.version) { + return sendError(res, '该插件已安装且版本相同,无需重复安装'); + } + } + // 下载插件 const PLUGINS_DIR = getPluginsDir(); const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`); try { - await downloadFile(plugin.downloadUrl, tempZipPath, mirror); + await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000); // 解压插件 await extractPlugin(tempZipPath, id); @@ -305,7 +356,7 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => return sendSuccess(res, { message: 'Plugin installed successfully', - plugin: plugin, + plugin, installPath: path.join(PLUGINS_DIR, id), }); } catch (downloadError: any) { @@ -337,8 +388,8 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); - const sendProgress = (message: string, progress?: number) => { - res.write(`data: ${JSON.stringify({ message, progress })}\n\n`); + const sendProgress = (message: string, progress?: number, detail?: any) => { + res.write(`data: ${JSON.stringify({ message, progress, ...detail })}\n\n`); }; try { @@ -355,6 +406,18 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) return; } + // 检查是否已安装相同版本 + const pm = getPluginManager(); + if (pm) { + const installedInfo = pm.getPluginInfo(id); + if (installedInfo && installedInfo.version === plugin.version) { + sendProgress('错误: 该插件已安装且版本相同', 0); + res.write(`data: ${JSON.stringify({ error: '该插件已安装且版本相同,无需重复安装' })}\n\n`); + res.end(); + return; + } + } + sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20); sendProgress(`下载地址: ${plugin.downloadUrl}`, 25); @@ -368,12 +431,28 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) try { sendProgress('正在下载插件...', 30); - await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined); + await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined, (percent, downloaded, total, speed) => { + const overallProgress = 30 + Math.round(percent * 0.5); + const downloadedMb = (downloaded / 1024 / 1024).toFixed(1); + const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?'; + const speedMb = (speed / 1024 / 1024).toFixed(2); + const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1; - sendProgress('下载完成,正在解压...', 70); + sendProgress(`正在下载插件... ${percent}%`, overallProgress, { + downloaded, + total, + speed, + eta, + downloadedStr: `${downloadedMb}MB`, + totalStr: `${totalMb}MB`, + speedStr: `${speedMb}MB/s`, + }); + }, 300000); + + sendProgress('下载完成,正在解压...', 85); await extractPlugin(tempZipPath, id); - sendProgress('解压完成,正在清理...', 90); + sendProgress('解压完成,正在清理...', 95); fs.unlinkSync(tempZipPath); // 如果 pluginManager 存在,立即注册或重载插件 @@ -393,7 +472,7 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) res.write(`data: ${JSON.stringify({ success: true, message: 'Plugin installed successfully', - plugin: plugin, + plugin, installPath: path.join(PLUGINS_DIR, id), })}\n\n`); res.end(); diff --git a/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx b/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx index e79ab221..4982d712 100644 --- a/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx +++ b/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx @@ -1,13 +1,56 @@ +import { Avatar } from '@heroui/avatar'; import { Button } from '@heroui/button'; -import { Switch } from '@heroui/switch'; +import { Card, CardBody, CardFooter } from '@heroui/card'; import { Chip } from '@heroui/chip'; - -import { useState } from 'react'; +import { Switch } from '@heroui/switch'; +import { Tooltip } from '@heroui/tooltip'; import { MdDeleteForever, MdSettings } from 'react-icons/md'; +import clsx from 'clsx'; +import { useLocalStorage } from '@uidotdev/usehooks'; +import { useState } from 'react'; -import DisplayCardContainer from './container'; +import key from '@/const/key'; import { PluginItem } from '@/controllers/plugin_manager'; +/** 提取作者头像 URL */ +function getAuthorAvatar (homepage?: string, repository?: string): string | undefined { + // 1. 尝试从 repository 提取 GitHub 用户名 + if (repository) { + try { + // 处理 git+https://github.com/... 或 https://github.com/... + const repoUrl = repository.replace(/^git\+/, '').replace(/\.git$/, ''); + const url = new URL(repoUrl); + if (url.hostname === 'github.com' || url.hostname === 'www.github.com') { + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 1) { + return `https://github.com/${parts[0]}.png`; + } + } + } catch { + // 忽略解析错误 + } + } + + // 2. 尝试从 homepage 提取 + if (homepage) { + try { + const url = new URL(homepage); + if (url.hostname === 'github.com' || url.hostname === 'www.github.com') { + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 1) { + return `https://github.com/${parts[0]}.png`; + } + } else { + // 如果是自定义域名,尝试获取 favicon + return `https://api.iowen.cn/favicon/${url.hostname}.png`; + } + } catch { + // 忽略解析错误 + } + } + return undefined; +} + export interface PluginDisplayCardProps { data: PluginItem; onToggleStatus: () => Promise; @@ -23,9 +66,15 @@ const PluginDisplayCard: React.FC = ({ onConfig, hasConfig = false, }) => { - const { name, version, author, description, status } = data; + const { name, version, author, description, status, homepage, repository } = data; const isEnabled = status === 'active'; const [processing, setProcessing] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [backgroundImage] = useLocalStorage(key.backgroundImage, ''); + const hasBackground = !!backgroundImage; + + // 综合尝试提取头像,最后兜底使用 Vercel 风格头像 + const avatarUrl = getAuthorAvatar(homepage, repository) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`; const handleToggle = () => { setProcessing(true); @@ -38,88 +87,132 @@ const PluginDisplayCard: React.FC = ({ }; return ( - -
- + color='default' + /> +
+

+ {name} +

+

+ by {author || '未知'} +

+
- {hasConfig && ( - - )} + + + {status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'} + - } - enableSwitch={ + + {/* Description */} +
setIsExpanded(!isExpanded)} + > + +

+ {description || '暂无描述'} +

+
+
+ + {/* Version Badge */} +
+ + v{version} + +
+ + + - } - title={name} - tag={ - - {status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'} - - } - > -
-
- - 版本 + + {isEnabled ? '已启用' : '已禁用'} -
- {version} -
-
-
- - 作者 - -
- {author || '未知'} -
-
-
- - 描述 - -
- {description || '暂无描述'} -
-
-
-
+ + +
+ + {hasConfig && ( + + + + )} + + + + + + ); }; diff --git a/packages/napcat-webui-frontend/src/components/display_card/plugin_store_card.tsx b/packages/napcat-webui-frontend/src/components/display_card/plugin_store_card.tsx index bfd10580..94c01647 100644 --- a/packages/napcat-webui-frontend/src/components/display_card/plugin_store_card.tsx +++ b/packages/napcat-webui-frontend/src/components/display_card/plugin_store_card.tsx @@ -1,17 +1,60 @@ +/* eslint-disable @stylistic/indent */ +import { Avatar } from '@heroui/avatar'; import { Button } from '@heroui/button'; +import { Card, CardBody, CardFooter } from '@heroui/card'; import { Chip } from '@heroui/chip'; import { Tooltip } from '@heroui/tooltip'; +import { IoMdCheckmarkCircle } from 'react-icons/io'; +import { MdUpdate, MdOutlineGetApp } from 'react-icons/md'; +import clsx from 'clsx'; +import { useLocalStorage } from '@uidotdev/usehooks'; import { useState } from 'react'; -import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io'; -import DisplayCardContainer from './container'; +import key from '@/const/key'; import { PluginStoreItem } from '@/types/plugin-store'; export type InstallStatus = 'not-installed' | 'installed' | 'update-available'; +/** 提取作者头像 URL */ +function getAuthorAvatar (homepage?: string, downloadUrl?: string): string | undefined { + // 1. 尝试从 downloadUrl 提取 GitHub 用户名 (通常是最准确的源码仓库所有者) + if (downloadUrl) { + try { + const url = new URL(downloadUrl); + if (url.hostname === 'github.com' || url.hostname === 'www.github.com') { + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 1) { + return `https://github.com/${parts[0]}.png`; + } + } + } catch { + // 忽略解析错误 + } + } + + // 2. 尝试从 homepage 提取 + if (homepage) { + try { + const url = new URL(homepage); + if (url.hostname === 'github.com' || url.hostname === 'www.github.com') { + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 1) { + return `https://github.com/${parts[0]}.png`; + } + } else { + // 如果是自定义域名,尝试获取 favicon。使用主流的镜像服务以保证国内访问速度 + return `https://api.iowen.cn/favicon/${url.hostname}.png`; + } + } catch { + // 忽略解析错误 + } + } + return undefined; +} + export interface PluginStoreCardProps { data: PluginStoreItem; - onInstall: () => Promise; + onInstall: () => void; installStatus?: InstallStatus; installedVersion?: string; } @@ -20,158 +63,222 @@ const PluginStoreCard: React.FC = ({ data, onInstall, installStatus = 'not-installed', + installedVersion, }) => { - const { name, version, author, description, tags, id, homepage } = data; - const [processing, setProcessing] = useState(false); + const { name, version, author, description, tags, homepage, downloadUrl } = data; + const [backgroundImage] = useLocalStorage(key.backgroundImage, ''); + const [isExpanded, setIsExpanded] = useState(false); + const hasBackground = !!backgroundImage; - const handleInstall = () => { - setProcessing(true); - onInstall().finally(() => setProcessing(false)); - }; + // 综合尝试提取头像,最后兜底使用 Vercel 风格头像 + const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`; - // 根据安装状态返回按钮配置 - const getButtonConfig = () => { - switch (installStatus) { - case 'installed': - return { - text: '重新安装', - icon: , - color: 'default' as const, - }; - case 'update-available': - return { - text: '更新', - icon: , - color: 'default' as const, - }; - default: - return { - text: '安装', - icon: , - color: 'primary' as const, - }; - } - }; - - const buttonConfig = getButtonConfig(); - const titleContent = homepage ? ( - - - {name} - - - ) : ( - name + // 作者链接组件 + const AuthorComponent = ( + + {author || '未知作者'} + ); return ( - - {installStatus === 'installed' && ( - } + + + {/* Header: Avatar + Name + Author */} +
+ +
+
+
+ {homepage + ? ( + + e.stopPropagation()} + > + {name} + + + ) + : ( +

+ {name} +

+ )} +
+
+ + {/* 可点击的作者名称 */} +
+ by {homepage + ? ( + e.stopPropagation()} + > + {AuthorComponent} + + ) + : AuthorComponent} +
+
+
+ + {/* Description */} +
setIsExpanded(!isExpanded)} + > + +

- 已安装 - - )} - {installStatus === 'update-available' && ( - - 可更新 - - )} + {description || '暂无描述'} +

+
+
+ + {/* Tags & Version */} +
v{version} + + {/* Tags with proper truncation and hover */} + {tags?.slice(0, 2).map((tag) => ( + + {tag} + + ))} + + {tags && tags.length > 2 && ( + + {tags.map(t => ( + + {t} + + ))} +
+ } + delay={0} + closeDelay={0} + > + + +{tags.length - 2} + + + )} + + {installStatus === 'update-available' && installedVersion && ( + + 新版本 + + )}
- } - enableSwitch={undefined} - action={ - - } - > -
-
- - 作者 - -
- {author || '未知'} -
-
-
- - 版本 - -
- v{version} -
-
-
- - 描述 - -
- {description || '暂无描述'} -
-
- {id && ( -
- - 包名 - -
- {id || '包名'} -
-
- )} - {tags && tags.length > 0 && ( -
- - 标签 - -
- {tags.slice(0, 2).join(' · ')} -
-
- )} -
- + + + + {installStatus === 'installed' + ? ( + + ) + : installStatus === 'update-available' + ? ( + + ) + : ( + + )} + + ); }; diff --git a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts index 13c8e851..cb015d54 100644 --- a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts +++ b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts @@ -22,6 +22,10 @@ export interface PluginItem { hasConfig?: boolean; /** 是否有扩展页面 */ hasPages?: boolean; + /** 主页链接 */ + homepage?: string; + /** 仓库链接 */ + repository?: string; } /** 扩展页面信息 */ diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx index a1775d83..ea004267 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx @@ -18,6 +18,7 @@ interface ExtensionPage { description?: string; } +// eslint-disable-next-line @typescript-eslint/no-redeclare export default function ExtensionPage () { const [loading, setLoading] = useState(true); const [extensionPages, setExtensionPages] = useState([]); @@ -150,28 +151,30 @@ export default function ExtensionPage () { )} - {extensionPages.length === 0 && !loading ? ( -
- -

暂无插件扩展页面

-

插件可以通过注册页面来扩展 WebUI 功能

-
- ) : ( -
- {iframeLoading && ( -
- -
- )} -