diff --git a/packages/napcat-webui-backend/src/api/Mirror.ts b/packages/napcat-webui-backend/src/api/Mirror.ts new file mode 100644 index 00000000..4480fff0 --- /dev/null +++ b/packages/napcat-webui-backend/src/api/Mirror.ts @@ -0,0 +1,230 @@ +import { RequestHandler } from 'express'; +import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response'; +import { + GITHUB_FILE_MIRRORS, + GITHUB_RAW_MIRRORS, + buildMirrorUrl, + getMirrorConfig, + setCustomMirror, + clearMirrorCache +} from 'napcat-common/src/mirror'; +import https from 'https'; +import http from 'http'; + +export interface MirrorTestResult { + mirror: string; + latency: number; + success: boolean; + error?: string; +} + +/** + * 测试单个镜像的延迟 + */ +async function testMirrorLatency (mirror: string, testUrl: string, timeout: number = 5000): Promise { + const url = mirror ? buildMirrorUrl(testUrl, mirror) : testUrl; + const start = Date.now(); + + return new Promise((resolve) => { + try { + const urlObj = new URL(url); + const isHttps = urlObj.protocol === 'https:'; + const client = isHttps ? https : http; + + const req = client.request({ + hostname: urlObj.hostname, + port: urlObj.port || (isHttps ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: 'HEAD', + timeout, + headers: { + 'User-Agent': 'NapCat-Mirror-Test', + }, + }, (res) => { + const statusCode = res.statusCode || 0; + const isValid = statusCode >= 200 && statusCode < 400; + resolve({ + mirror: mirror || 'https://github.com', + latency: Date.now() - start, + success: isValid, + }); + }); + + req.on('error', (e) => { + resolve({ + mirror: mirror || 'https://github.com', + latency: Date.now() - start, + success: false, + error: e.message, + }); + }); + + req.on('timeout', () => { + req.destroy(); + resolve({ + mirror: mirror || 'https://github.com', + latency: timeout, + success: false, + error: 'Timeout', + }); + }); + + req.end(); + } catch (e: any) { + resolve({ + mirror: mirror || 'https://github.com', + latency: Date.now() - start, + success: false, + error: e.message, + }); + } + }); +} + +/** + * 获取所有可用的镜像列表 + */ +export const GetMirrorListHandler: RequestHandler = async (_req, res) => { + try { + const config = getMirrorConfig(); + return sendSuccess(res, { + fileMirrors: GITHUB_FILE_MIRRORS.filter(m => m), + rawMirrors: GITHUB_RAW_MIRRORS, + customMirror: config.customMirror, + timeout: config.timeout, + }); + } catch (e: any) { + return sendError(res, e.message); + } +}; + +/** + * 设置自定义镜像 + */ +export const SetCustomMirrorHandler: RequestHandler = async (req, res) => { + try { + const { mirror } = req.body; + setCustomMirror(mirror || ''); + clearMirrorCache(); + return sendSuccess(res, { message: 'Mirror set successfully' }); + } catch (e: any) { + return sendError(res, e.message); + } +}; + +/** + * SSE 实时测速所有镜像 + */ +export const TestMirrorsSSEHandler: RequestHandler = async (req, res) => { + const { type = 'file' } = req.query; + + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const sendProgress = (data: any) => { + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + try { + // 选择镜像列表 + let mirrors: string[]; + let testUrl: string; + + if (type === 'raw') { + mirrors = GITHUB_RAW_MIRRORS; + testUrl = 'https://raw.githubusercontent.com/NapNeko/NapCatQQ/main/README.md'; + } else { + mirrors = GITHUB_FILE_MIRRORS.filter(m => m); + testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest'; + } + + // 添加原始 URL 测试 + if (!mirrors.includes('')) { + mirrors = ['', ...mirrors]; + } + + sendProgress({ + type: 'start', + total: mirrors.length, + message: `开始测试 ${mirrors.length} 个镜像源...`, + }); + + const results: MirrorTestResult[] = []; + const timeout = 5000; + + // 逐个测试并实时推送结果 + for (let i = 0; i < mirrors.length; i++) { + const mirror = mirrors[i] ?? ''; + const displayName = mirror || 'https://github.com (原始)'; + + sendProgress({ + type: 'testing', + index: i, + total: mirrors.length, + mirror: displayName, + message: `正在测试: ${displayName}`, + }); + + const result = await testMirrorLatency(mirror, testUrl, timeout); + results.push(result); + + sendProgress({ + type: 'result', + index: i, + total: mirrors.length, + result: { + ...result, + mirror: result.mirror || 'https://github.com (原始)', + }, + }); + } + + // 按延迟排序 + const sortedResults = results + .filter(r => r.success) + .sort((a, b) => a.latency - b.latency); + + const failedResults = results.filter(r => !r.success); + + sendProgress({ + type: 'complete', + results: sortedResults, + failed: failedResults, + fastest: sortedResults[0] || null, + message: `测试完成!${sortedResults.length} 个可用,${failedResults.length} 个失败`, + }); + + res.end(); + } catch (e: any) { + sendProgress({ + type: 'error', + error: e.message, + }); + res.end(); + } +}; + +/** + * 快速测试单个镜像 + */ +export const TestSingleMirrorHandler: RequestHandler = async (req, res) => { + try { + const { mirror, type = 'file' } = req.body; + + let testUrl: string; + if (type === 'raw') { + testUrl = 'https://raw.githubusercontent.com/NapNeko/NapCatQQ/main/README.md'; + } else { + testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest'; + } + + const result = await testMirrorLatency(mirror || '', testUrl, 5000); + + return sendSuccess(res, result); + } catch (e: any) { + return sendError(res, e.message); + } +}; diff --git a/packages/napcat-webui-backend/src/router/Mirror.ts b/packages/napcat-webui-backend/src/router/Mirror.ts new file mode 100644 index 00000000..d1bee4cb --- /dev/null +++ b/packages/napcat-webui-backend/src/router/Mirror.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { + GetMirrorListHandler, + SetCustomMirrorHandler, + TestMirrorsSSEHandler, + TestSingleMirrorHandler +} from '@/napcat-webui-backend/src/api/Mirror'; + +const router: Router = Router(); + +// 获取镜像列表 +router.get('/List', GetMirrorListHandler); + +// 设置自定义镜像 +router.post('/SetCustom', SetCustomMirrorHandler); + +// SSE 实时测速 +router.get('/Test/SSE', TestMirrorsSSEHandler); + +// 测试单个镜像 +router.post('/Test', TestSingleMirrorHandler); + +export { router as MirrorRouter }; diff --git a/packages/napcat-webui-backend/src/router/index.ts b/packages/napcat-webui-backend/src/router/index.ts index 49e2521d..8c98ff9f 100644 --- a/packages/napcat-webui-backend/src/router/index.ts +++ b/packages/napcat-webui-backend/src/router/index.ts @@ -18,6 +18,7 @@ import { UpdateNapCatRouter } from './UpdateNapCat'; import DebugRouter from '@/napcat-webui-backend/src/api/Debug'; import { ProcessRouter } from './Process'; import { PluginRouter } from './Plugin'; +import { MirrorRouter } from './Mirror'; const router: Router = Router(); @@ -50,5 +51,7 @@ router.use('/Debug', DebugRouter); router.use('/Process', ProcessRouter); // router:插件管理相关路由 router.use('/Plugin', PluginRouter); +// router:镜像管理相关路由 +router.use('/Mirror', MirrorRouter); export { router as ALLRouter }; diff --git a/packages/napcat-webui-frontend/src/components/mirror_selector_modal.tsx b/packages/napcat-webui-frontend/src/components/mirror_selector_modal.tsx new file mode 100644 index 00000000..abf2db2f --- /dev/null +++ b/packages/napcat-webui-frontend/src/components/mirror_selector_modal.tsx @@ -0,0 +1,283 @@ +import { useState, useEffect } from 'react'; +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal'; +import { Button } from '@heroui/button'; +import { Chip } from '@heroui/chip'; +import { Tooltip } from '@heroui/tooltip'; +import { IoMdFlash, IoMdCheckmark, IoMdClose } from 'react-icons/io'; +import clsx from 'clsx'; + +import MirrorManager, { MirrorTestResult } from '@/controllers/mirror_manager'; + +interface MirrorSelectorModalProps { + isOpen: boolean; + onClose: () => void; + onSelect: (mirror: string | undefined) => void; + currentMirror?: string; + type?: 'file' | 'raw'; +} + +export default function MirrorSelectorModal ({ + isOpen, + onClose, + onSelect, + currentMirror, + type = 'file', +}: MirrorSelectorModalProps) { + const [mirrors, setMirrors] = useState([]); + const [selectedMirror, setSelectedMirror] = useState(currentMirror || 'auto'); + const [testResults, setTestResults] = useState>(new Map()); + const [isTesting, setIsTesting] = useState(false); + const [testProgress, setTestProgress] = useState(0); + const [testMessage, setTestMessage] = useState(''); + const [fastestMirror, setFastestMirror] = useState(null); + + // 加载镜像列表 + useEffect(() => { + if (isOpen) { + loadMirrors(); + } + }, [isOpen]); + + const loadMirrors = async () => { + try { + const data = await MirrorManager.getMirrorList(); + const mirrorList = type === 'raw' ? data.rawMirrors : data.fileMirrors; + setMirrors(mirrorList); + if (data.customMirror) { + setSelectedMirror(data.customMirror); + } + } catch (e) { + console.error('Failed to load mirrors:', e); + } + }; + + const startSpeedTest = () => { + setIsTesting(true); + setTestProgress(0); + setTestResults(new Map()); + setFastestMirror(null); + setTestMessage('准备测速...'); + + MirrorManager.testMirrorsSSE(type, { + onStart: (data) => { + setTestMessage(data.message); + }, + onTesting: (data) => { + setTestProgress((data.index / data.total) * 100); + setTestMessage(data.message); + }, + onResult: (data) => { + setTestResults((prev) => { + const newMap = new Map(prev); + newMap.set(data.result.mirror, data.result); + return newMap; + }); + setTestProgress(((data.index + 1) / data.total) * 100); + }, + onComplete: (data) => { + setIsTesting(false); + setTestProgress(100); + setTestMessage(data.message); + if (data.fastest) { + setFastestMirror(data.fastest.mirror); + } + }, + onError: (error) => { + setIsTesting(false); + setTestMessage(`测速失败: ${error}`); + }, + }); + }; + + const handleConfirm = () => { + const mirror = selectedMirror === 'auto' ? undefined : selectedMirror; + onSelect(mirror); + onClose(); + }; + + return ( + + + +
+ 选择镜像源 + +
+ {isTesting && ( +
+
+
+
+

{testMessage}

+
+ )} + + +
+ {/* 自动选择选项 */} + setSelectedMirror('auto')} + badge={推荐} + /> + + {/* 原始 GitHub */} + setSelectedMirror('https://github.com')} + testResult={testResults.get('https://github.com (原始)')} + isFastest={fastestMirror === 'https://github.com (原始)'} + /> + + {/* 镜像列表 */} + {mirrors.map((mirror) => { + if (!mirror) return null; + const result = testResults.get(mirror); + const isFastest = fastestMirror === mirror; + + let hostname = mirror; + try { + hostname = new URL(mirror).hostname; + } catch { } + + return ( + setSelectedMirror(mirror)} + testResult={result} + isFastest={isFastest} + /> + ); + })} +
+
+ + + + + + + ); +} + +// 镜像选项组件 +interface MirrorOptionProps { + value: string; + label: string; + description: string; + isSelected: boolean; + onSelect: () => void; + testResult?: MirrorTestResult; + isFastest?: boolean; + badge?: React.ReactNode; +} + +function MirrorOption ({ + label, + description, + isSelected, + onSelect, + testResult, + isFastest, + badge, +}: MirrorOptionProps) { + return ( +
+
+

{label}

+

{description}

+
+
+ {badge} + {isFastest && !badge && ( + 最快 + )} + {testResult && } +
+
+ ); +} + +// 镜像状态显示组件 +function MirrorStatus ({ result }: { result: MirrorTestResult; }) { + const formatLatency = (latency: number) => { + if (latency >= 5000) return '>5s'; + if (latency >= 1000) return `${(latency / 1000).toFixed(1)}s`; + return `${latency}ms`; + }; + + if (!result.success) { + return ( + + } + > + 失败 + + + ); + } + + const getColor = (): 'success' | 'warning' | 'danger' => { + if (result.latency < 300) return 'success'; + if (result.latency < 1000) return 'warning'; + return 'danger'; + }; + + return ( + } + > + {formatLatency(result.latency)} + + ); +} diff --git a/packages/napcat-webui-frontend/src/components/system_info.tsx b/packages/napcat-webui-frontend/src/components/system_info.tsx index 8bb120a3..bfe509fc 100644 --- a/packages/napcat-webui-frontend/src/components/system_info.tsx +++ b/packages/napcat-webui-frontend/src/components/system_info.tsx @@ -8,18 +8,22 @@ import { Switch } from '@heroui/switch'; import { Pagination } from '@heroui/pagination'; import { Tabs, Tab } from '@heroui/tabs'; import { Input } from '@heroui/input'; +import { Button } from '@heroui/button'; import { useLocalStorage, useDebounce } from '@uidotdev/usehooks'; import { useRequest } from 'ahooks'; import clsx from 'clsx'; import { FaCircleInfo, FaQq } from 'react-icons/fa6'; import { IoLogoChrome, IoLogoOctocat, IoSearch } from 'react-icons/io5'; +import { IoMdFlash, IoMdCheckmark, IoMdSettings } from 'react-icons/io'; import { RiMacFill } from 'react-icons/ri'; import { useState, useCallback } from 'react'; import key from '@/const/key'; import WebUIManager from '@/controllers/webui_manager'; +import MirrorManager from '@/controllers/mirror_manager'; import useDialog from '@/hooks/use-dialog'; import Modal from '@/components/modal'; +import MirrorSelectorModal from '@/components/mirror_selector_modal'; import { hasNewVersion, compareVersion } from '@/utils/version'; @@ -304,17 +308,54 @@ const VersionSelectDialogContent: React.FC = ({ const [searchQuery, setSearchQuery] = useState(''); const debouncedSearch = useDebounce(searchQuery, 300); + // 镜像相关状态 const [selectedMirror, setSelectedMirror] = useState(undefined); - const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, { - cacheKey: 'napcat-mirrors', - staleTime: 60 * 60 * 1000, - }); - const mirrors = mirrorsData?.mirrors || []; + const [mirrorLatency, setMirrorLatency] = useState(null); + const [mirrorTesting, setMirrorTesting] = useState(false); + const [mirrorModalOpen, setMirrorModalOpen] = useState(false); - const pageSize = 15; + // 测试当前镜像速度 + const testCurrentMirror = async () => { + setMirrorTesting(true); + try { + const result = await MirrorManager.testSingleMirror(selectedMirror || '', 'file'); + if (result.success) { + setMirrorLatency(result.latency); + } else { + setMirrorLatency(null); + } + } catch (e) { + setMirrorLatency(null); + } finally { + setMirrorTesting(false); + } + }; + + const formatLatency = (latency: number) => { + if (latency >= 5000) return '>5s'; + if (latency >= 1000) return `${(latency / 1000).toFixed(1)}s`; + return `${latency}ms`; + }; + + const getLatencyColor = (latency: number | null): 'success' | 'warning' | 'danger' | 'default' => { + if (latency === null) return 'default'; + if (latency < 300) return 'success'; + if (latency < 1000) return 'warning'; + return 'danger'; + }; + + const getMirrorDisplayName = () => { + if (!selectedMirror) return '自动选择'; + try { + return new URL(selectedMirror).hostname; + } catch { + return selectedMirror; + } + }; // 获取所有可用版本(带分页、过滤和搜索) // 懒加载:根据 activeTab 只获取对应类型的版本 + const pageSize = 15; const { data: releasesData, loading: releasesLoading, error: releasesError } = useRequest( () => WebUIManager.getAllReleases({ page: currentPage, @@ -502,46 +543,78 @@ const VersionSelectDialogContent: React.FC = ({ -
- {/* 搜索框 */} - { - setSearchQuery(value); - setCurrentPage(1); - setSelectedVersion(null); - }} - startContent={} - isClearable - onClear={() => setSearchQuery('')} - classNames={{ - inputWrapper: 'h-9', - base: 'flex-1' - }} - /> + {/* 下载镜像状态卡片 */} + + +
+
+ 镜像源: + {getMirrorDisplayName()} + {mirrorLatency !== null && ( + } + > + {formatLatency(mirrorLatency)} + + )} + {mirrorLatency === null && !mirrorTesting && ( + + 未测试 + + )} + {mirrorTesting && ( + + 测速中... + + )} +
+
+ + + + + + +
+
+
+
- {/* 镜像选择 */} - -
+ {/* 搜索框 */} + { + setSearchQuery(value); + setCurrentPage(1); + setSelectedVersion(null); + }} + startContent={} + isClearable + onClear={() => setSearchQuery('')} + classNames={{ + inputWrapper: 'h-9', + }} + /> {/* 版本选择 */}
@@ -703,6 +776,18 @@ const VersionSelectDialogContent: React.FC = ({ {isSelectedDowngrade ? '确认降级更新' : '更新到此版本'}
+ + {/* 镜像选择弹窗 */} + setMirrorModalOpen(false)} + currentMirror={selectedMirror} + onSelect={(mirror) => { + setSelectedMirror(mirror || undefined); + setMirrorLatency(null); + }} + type="file" + />
); }; diff --git a/packages/napcat-webui-frontend/src/controllers/mirror_manager.ts b/packages/napcat-webui-frontend/src/controllers/mirror_manager.ts new file mode 100644 index 00000000..4d84179c --- /dev/null +++ b/packages/napcat-webui-frontend/src/controllers/mirror_manager.ts @@ -0,0 +1,109 @@ +import { EventSourcePolyfill } from 'event-source-polyfill'; +import { serverRequest } from '@/utils/request'; +import key from '@/const/key'; + +export interface MirrorTestResult { + mirror: string; + latency: number; + success: boolean; + error?: string; +} + +export interface MirrorListResponse { + fileMirrors: string[]; + rawMirrors: string[]; + customMirror?: string; + timeout: number; +} + +export default class MirrorManager { + /** + * 获取镜像列表 + */ + public static async getMirrorList (): Promise { + const { data } = await serverRequest.get>('/Mirror/List'); + return data.data; + } + + /** + * 设置自定义镜像 + */ + public static async setCustomMirror (mirror: string): Promise { + await serverRequest.post('/Mirror/SetCustom', { mirror }); + } + + /** + * 测试单个镜像 + */ + public static async testSingleMirror (mirror: string, type: 'file' | 'raw' = 'file'): Promise { + const { data } = await serverRequest.post>('/Mirror/Test', { mirror, type }); + return data.data; + } + + /** + * SSE 实时测速所有镜像 + */ + public static testMirrorsSSE ( + type: 'file' | 'raw' = 'file', + callbacks: { + onStart?: (data: { total: number; message: string; }) => void; + onTesting?: (data: { index: number; total: number; mirror: string; message: string; }) => void; + onResult?: (data: { index: number; total: number; result: MirrorTestResult; }) => void; + onComplete?: (data: { results: MirrorTestResult[]; failed: MirrorTestResult[]; fastest: MirrorTestResult | null; message: string; }) => void; + onError?: (error: string) => void; + } + ): EventSourcePolyfill { + const token = localStorage.getItem(key.token); + if (!token) { + throw new Error('未登录'); + } + const _token = JSON.parse(token); + + const eventSource = new EventSourcePolyfill( + `/api/Mirror/Test/SSE?type=${type}`, + { + headers: { + Authorization: `Bearer ${_token}`, + Accept: 'text/event-stream', + }, + withCredentials: true, + } + ); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + switch (data.type) { + case 'start': + callbacks.onStart?.(data); + break; + case 'testing': + callbacks.onTesting?.(data); + break; + case 'result': + callbacks.onResult?.(data); + break; + case 'complete': + callbacks.onComplete?.(data); + eventSource.close(); + break; + case 'error': + callbacks.onError?.(data.error); + eventSource.close(); + break; + } + } catch (e) { + console.error('Failed to parse SSE message:', e); + } + }; + + eventSource.onerror = (error) => { + console.error('SSE连接出错:', error); + callbacks.onError?.('连接中断'); + eventSource.close(); + }; + + return eventSource; + } +} diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx index f4d4c59c..6ef0561b 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx @@ -1,18 +1,18 @@ import { Button } from '@heroui/button'; import { Input } from '@heroui/input'; -import { Select, SelectItem } from '@heroui/select'; import { Tab, Tabs } from '@heroui/tabs'; +import { Card, CardBody } from '@heroui/card'; +import { Tooltip } from '@heroui/tooltip'; +import { Spinner } from '@heroui/spinner'; import { useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; -import { IoMdRefresh, IoMdSearch } from 'react-icons/io'; +import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io'; import clsx from 'clsx'; -import { useRequest } from 'ahooks'; import { EventSourcePolyfill } from 'event-source-polyfill'; -import PageLoading from '@/components/page_loading'; import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card'; import PluginManager, { PluginItem } from '@/controllers/plugin_manager'; -import WebUIManager from '@/controllers/webui_manager'; +import MirrorSelectorModal from '@/components/mirror_selector_modal'; import { PluginStoreItem } from '@/types/plugin-store'; import useDialog from '@/hooks/use-dialog'; import key from '@/const/key'; @@ -42,12 +42,14 @@ export default function PluginStorePage () { const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false); const dialog = useDialog(); - // 获取镜像列表 - const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, { - cacheKey: 'napcat-mirrors', - staleTime: 60 * 60 * 1000, - }); - const mirrors = mirrorsData?.mirrors || []; + // 商店列表源相关状态 + const [storeSourceModalOpen, setStoreSourceModalOpen] = useState(false); + const [currentStoreSource, setCurrentStoreSource] = useState(undefined); + + // 下载镜像弹窗状态(安装时使用) + const [downloadMirrorModalOpen, setDownloadMirrorModalOpen] = useState(false); + const [pendingInstallPlugin, setPendingInstallPlugin] = useState(null); + const [selectedDownloadMirror, setSelectedDownloadMirror] = useState(undefined); const loadPlugins = async () => { setLoading(true); @@ -68,7 +70,7 @@ export default function PluginStorePage () { useEffect(() => { loadPlugins(); - }, []); + }, [currentStoreSource]); // 按标签分类和搜索 const categorizedPlugins = useMemo(() => { @@ -125,60 +127,9 @@ export default function PluginStorePage () { }, [categorizedPlugins]); const handleInstall = async (plugin: PluginStoreItem) => { - // 检测是否是 GitHub 下载链接 - const githubPattern = /^https:\/\/github\.com\//; - const isGitHubUrl = githubPattern.test(plugin.downloadUrl); - - // 如果是 GitHub 链接,弹出镜像选择对话框 - if (isGitHubUrl) { - let selectedMirror: string | undefined = undefined; - - dialog.confirm({ - title: '安装插件', - content: ( -
-
-

- 插件名称: {plugin.name} -

-

- 版本: v{plugin.version} -

-

- {plugin.description} -

-
-
- - -
-
- ), - confirmText: '开始安装', - cancelText: '取消', - onConfirm: async () => { - await installPluginWithSSE(plugin.id, selectedMirror); - }, - }); - } else { - // 非 GitHub 链接,直接安装 - await installPluginWithSSE(plugin.id); - } + // 弹窗选择下载镜像 + setPendingInstallPlugin(plugin); + setDownloadMirrorModalOpen(true); }; const installPluginWithSSE = async (pluginId: string, mirror?: string) => { @@ -219,6 +170,8 @@ export default function PluginStorePage () { } else if (data.success) { toast.success('插件安装成功!', { id: loadingToast }); eventSource.close(); + // 刷新插件列表 + loadPlugins(); // 安装成功后检查插件管理器状态 if (pluginManagerNotFound) { dialog.confirm({ @@ -264,23 +217,55 @@ export default function PluginStorePage () { } }; + const getStoreSourceDisplayName = () => { + if (!currentStoreSource) return '默认源'; + try { + return new URL(currentStoreSource).hostname; + } catch { + return currentStoreSource; + } + }; + return ( <> 插件商店 - NapCat WebUI
- - {/* 头部 */} -
-

插件商店

- +
+
+

插件商店

+ +
+ + {/* 商店列表源卡片 */} + + +
+
+ 列表源: + {getStoreSourceDisplayName()} +
+ + + +
+
+
{/* 搜索框 */} @@ -295,40 +280,80 @@ export default function PluginStorePage () {
{/* 标签页 */} - 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) => ( - - -
- {categorizedPlugins[tab.key]?.map((plugin) => { - const installInfo = getPluginInstallInfo(plugin); - return ( - handleInstall(plugin)} - /> - ); - })} -
-
- ))} -
+
+ {/* 加载遮罩 - 只遮住插件列表区域 */} + {loading && ( +
+ +
+ )} + + 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) => ( + + +
+ {categorizedPlugins[tab.key]?.map((plugin) => { + const installInfo = getPluginInstallInfo(plugin); + return ( + handleInstall(plugin)} + /> + ); + })} +
+
+ ))} +
+
+ + {/* 商店列表源选择弹窗 */} + setStoreSourceModalOpen(false)} + onSelect={(mirror) => { + setCurrentStoreSource(mirror); + }} + currentMirror={currentStoreSource} + type="raw" + /> + + {/* 下载镜像选择弹窗 */} + { + setDownloadMirrorModalOpen(false); + setPendingInstallPlugin(null); + }} + onSelect={(mirror) => { + setSelectedDownloadMirror(mirror); + // 选择后立即开始安装 + if (pendingInstallPlugin) { + setDownloadMirrorModalOpen(false); + installPluginWithSSE(pendingInstallPlugin.id, mirror); + setPendingInstallPlugin(null); + } + }} + currentMirror={selectedDownloadMirror} + type="file" + /> ); }