style(webui): 优化插件商店与插件管理界面 UI/UX

- 重构插件卡片样式,采用毛玻璃效果与主题色交互
- 优化插件商店搜索栏布局,增加对顶部搜索及 Ctrl+F 快捷键的支持
- 实现智能头像提取逻辑,支持从 GitHub、自定义域名(Favicon)及 Vercel 自动生成
- 增加插件描述溢出预览(悬停提示及点击展开功能)
- 修复标签溢出处理,支持 Tooltip 完整显示
- 增强后端插件列表 API,支持返回主页及仓库信息
- 修复部分类型错误与代码规范问题
This commit is contained in:
时瑾
2026-02-07 13:30:50 +08:00
parent 54266f97f8
commit 61a16b44a4
9 changed files with 730 additions and 329 deletions

View File

@@ -40,7 +40,7 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginSt
// 检查缓存(如果不是强制刷新)
const now = Date.now();
if (!forceRefresh && pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
//console.log('Using cached plugin list');
// console.log('Using cached plugin list');
return pluginListCache;
}
@@ -64,7 +64,7 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginSt
}
const data = await response.json();
//console.log(`Successfully fetched plugin list from: ${url}`);
// console.log(`Successfully fetched plugin list from: ${url}`);
// 更新缓存
pluginListCache = data as PluginStoreList;
@@ -86,7 +86,13 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginSt
* 下载文件,使用镜像系统
* 自动识别 GitHub Release URL 并使用镜像加速
*/
async function downloadFile (url: string, destPath: string, customMirror?: string): Promise<void> {
async function downloadFile (
url: string,
destPath: string,
customMirror?: string,
onProgress?: (percent: number, downloaded: number, total: number, speed: number) => void,
timeout: number = 120000 // 默认120秒超时
): Promise<void> {
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<void>
} 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<void>
// 列出解压后的文件
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();