Add mirror management and selection UI

Introduces backend API and router for mirror management, including latency testing and custom mirror setting. Adds frontend components and controllers for mirror selection, speed testing, and integration into system info and plugin store pages, allowing users to select and test download/list mirrors interactively.
This commit is contained in:
手瓜一十雪 2026-01-29 17:11:59 +08:00
parent 699b46acbd
commit 129d63f66e
7 changed files with 914 additions and 156 deletions

View File

@ -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<MirrorTestResult> {
const url = mirror ? buildMirrorUrl(testUrl, mirror) : testUrl;
const start = Date.now();
return new Promise<MirrorTestResult>((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);
}
};

View File

@ -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 };

View File

@ -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 };

View File

@ -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<string[]>([]);
const [selectedMirror, setSelectedMirror] = useState<string>(currentMirror || 'auto');
const [testResults, setTestResults] = useState<Map<string, MirrorTestResult>>(new Map());
const [isTesting, setIsTesting] = useState(false);
const [testProgress, setTestProgress] = useState(0);
const [testMessage, setTestMessage] = useState('');
const [fastestMirror, setFastestMirror] = useState<string | null>(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 (
<Modal
isOpen={isOpen}
onClose={onClose}
size="2xl"
scrollBehavior="inside"
classNames={{
backdrop: 'z-[200]',
wrapper: 'z-[200]',
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
<div className="flex items-center justify-between pr-8">
<span></span>
<Button
size="sm"
color="primary"
variant="flat"
startContent={!isTesting && <IoMdFlash />}
onPress={startSpeedTest}
isLoading={isTesting}
>
{isTesting ? '测速中...' : '一键测速'}
</Button>
</div>
{isTesting && (
<div className="mt-2">
<div className="w-full bg-default-200 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${testProgress}%` }}
/>
</div>
<p className="text-xs text-default-500 mt-1">{testMessage}</p>
</div>
)}
</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-2">
{/* 自动选择选项 */}
<MirrorOption
value="auto"
label="自动选择"
description="系统自动选择最快的镜像源"
isSelected={selectedMirror === 'auto'}
onSelect={() => setSelectedMirror('auto')}
badge={<Chip size="sm" color="primary" variant="flat"></Chip>}
/>
{/* 原始 GitHub */}
<MirrorOption
value="https://github.com"
label="GitHub 原始"
description="直连 GitHub可能较慢"
isSelected={selectedMirror === 'https://github.com'}
onSelect={() => 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 (
<MirrorOption
key={mirror}
value={mirror}
label={hostname}
description={mirror}
isSelected={selectedMirror === mirror}
onSelect={() => setSelectedMirror(mirror)}
testResult={result}
isFastest={isFastest}
/>
);
})}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
</Button>
<Button color="primary" onPress={handleConfirm}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
// 镜像选项组件
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 (
<div
className={clsx(
'flex items-center justify-between p-3 rounded-lg cursor-pointer transition-all',
'bg-content1 hover:bg-content2 border-2',
isSelected ? 'border-primary' : 'border-transparent',
isFastest && 'ring-2 ring-success'
)}
onClick={onSelect}
>
<div className="flex-1 min-w-0">
<p className="font-medium">{label}</p>
<p className="text-xs text-default-500 truncate">{description}</p>
</div>
<div className="flex items-center gap-2 ml-2">
{badge}
{isFastest && !badge && (
<Chip size="sm" color="success" variant="flat"></Chip>
)}
{testResult && <MirrorStatus result={testResult} />}
</div>
</div>
);
}
// 镜像状态显示组件
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 (
<Tooltip content={result.error || '连接失败'}>
<Chip
size="sm"
color="danger"
variant="flat"
startContent={<IoMdClose size={14} />}
>
</Chip>
</Tooltip>
);
}
const getColor = (): 'success' | 'warning' | 'danger' => {
if (result.latency < 300) return 'success';
if (result.latency < 1000) return 'warning';
return 'danger';
};
return (
<Chip
size="sm"
color={getColor()}
variant="flat"
startContent={<IoMdCheckmark size={14} />}
>
{formatLatency(result.latency)}
</Chip>
);
}

View File

@ -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<VersionSelectDialogProps> = ({
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 300);
// 镜像相关状态
const [selectedMirror, setSelectedMirror] = useState<string | undefined>(undefined);
const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, {
cacheKey: 'napcat-mirrors',
staleTime: 60 * 60 * 1000,
});
const mirrors = mirrorsData?.mirrors || [];
const [mirrorLatency, setMirrorLatency] = useState<number | null>(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<VersionSelectDialogProps> = ({
<Tab key='action' title='临时版本 (Action)' />
</Tabs>
<div className="flex gap-2">
{/* 搜索框 */}
<Input
placeholder='搜索版本号...'
size='sm'
value={searchQuery}
onValueChange={(value) => {
setSearchQuery(value);
setCurrentPage(1);
setSelectedVersion(null);
}}
startContent={<IoSearch className='text-default-400' />}
isClearable
onClear={() => setSearchQuery('')}
classNames={{
inputWrapper: 'h-9',
base: 'flex-1'
}}
/>
{/* 下载镜像状态卡片 */}
<Card className="bg-default-100/50 shadow-sm">
<CardBody className="py-2 px-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-default-500">:</span>
<span className="text-sm font-medium">{getMirrorDisplayName()}</span>
{mirrorLatency !== null && (
<Chip
size="sm"
color={getLatencyColor(mirrorLatency)}
variant="flat"
startContent={<IoMdCheckmark size={12} />}
>
{formatLatency(mirrorLatency)}
</Chip>
)}
{mirrorLatency === null && !mirrorTesting && (
<Chip size="sm" color="default" variant="flat">
</Chip>
)}
{mirrorTesting && (
<Chip size="sm" color="primary" variant="flat">
...
</Chip>
)}
</div>
<div className="flex items-center gap-1">
<Tooltip content="测速">
<Button
isIconOnly
size="sm"
variant="light"
onPress={testCurrentMirror}
isLoading={mirrorTesting}
>
<IoMdFlash size={16} />
</Button>
</Tooltip>
<Tooltip content="切换镜像">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setMirrorModalOpen(true)}
>
<IoMdSettings size={16} />
</Button>
</Tooltip>
</div>
</div>
</CardBody>
</Card>
{/* 镜像选择 */}
<Select
placeholder="自动选择 (默认)"
selectedKeys={selectedMirror ? [selectedMirror] : ['default']}
onSelectionChange={(keys) => {
const m = Array.from(keys)[0] as string;
setSelectedMirror(m === 'default' ? undefined : m);
}}
size="sm"
className="w-48"
classNames={{ trigger: 'h-9 min-h-9' }}
aria-label="选择镜像源"
>
{['default', ...mirrors].map(m => (
<SelectItem key={m} textValue={m === 'default' ? '自动选择' : m}>
{m === 'default' ? '自动选择 (默认)' : m}
</SelectItem>
))}
</Select>
</div>
{/* 搜索框 */}
<Input
placeholder='搜索版本号...'
size='sm'
value={searchQuery}
onValueChange={(value) => {
setSearchQuery(value);
setCurrentPage(1);
setSelectedVersion(null);
}}
startContent={<IoSearch className='text-default-400' />}
isClearable
onClear={() => setSearchQuery('')}
classNames={{
inputWrapper: 'h-9',
}}
/>
{/* 版本选择 */}
<div className='space-y-2'>
@ -703,6 +776,18 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
{isSelectedDowngrade ? '确认降级更新' : '更新到此版本'}
</button>
</div>
{/* 镜像选择弹窗 */}
<MirrorSelectorModal
isOpen={mirrorModalOpen}
onClose={() => setMirrorModalOpen(false)}
currentMirror={selectedMirror}
onSelect={(mirror) => {
setSelectedMirror(mirror || undefined);
setMirrorLatency(null);
}}
type="file"
/>
</div>
);
};

View File

@ -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<MirrorListResponse> {
const { data } = await serverRequest.get<ServerResponse<MirrorListResponse>>('/Mirror/List');
return data.data;
}
/**
*
*/
public static async setCustomMirror (mirror: string): Promise<void> {
await serverRequest.post('/Mirror/SetCustom', { mirror });
}
/**
*
*/
public static async testSingleMirror (mirror: string, type: 'file' | 'raw' = 'file'): Promise<MirrorTestResult> {
const { data } = await serverRequest.post<ServerResponse<MirrorTestResult>>('/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;
}
}

View File

@ -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<string | undefined>(undefined);
// 下载镜像弹窗状态(安装时使用)
const [downloadMirrorModalOpen, setDownloadMirrorModalOpen] = useState(false);
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(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: (
<div className="space-y-4">
<div>
<p className="text-sm mb-2">
: <span className="font-semibold">{plugin.name}</span>
</p>
<p className="text-sm mb-2">
: <span className="font-semibold">v{plugin.version}</span>
</p>
<p className="text-sm text-default-500 mb-4">
{plugin.description}
</p>
</div>
<div>
<label className="text-sm font-medium mb-2 block"></label>
<Select
placeholder="自动选择 (默认)"
defaultSelectedKeys={['default']}
onSelectionChange={(keys) => {
const m = Array.from(keys)[0] as string;
selectedMirror = m === 'default' ? undefined : m;
}}
size="sm"
aria-label="选择镜像源"
>
{['default', ...mirrors].map(m => (
<SelectItem key={m} textValue={m === 'default' ? '自动选择' : m}>
{m === 'default' ? '自动选择 (默认)' : m}
</SelectItem>
))}
</Select>
</div>
</div>
),
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 (
<>
<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 className="flex mb-6 items-center justify-between flex-wrap gap-4">
<div className="flex 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}
isLoading={loading}
>
<IoMdRefresh size={24} />
</Button>
</div>
{/* 商店列表源卡片 */}
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
<CardBody className="py-2 px-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-default-500">:</span>
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
</div>
<Tooltip content="切换列表源">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setStoreSourceModalOpen(true)}
>
<IoMdSettings size={16} />
</Button>
</Tooltip>
</div>
</CardBody>
</Card>
</div>
{/* 搜索框 */}
@ -295,40 +280,80 @@ export default function PluginStorePage () {
</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) => {
const installInfo = getPluginInstallInfo(plugin);
return (
<PluginStoreCard
key={plugin.id}
data={plugin}
installStatus={installInfo.status}
installedVersion={installInfo.installedVersion}
onInstall={() => handleInstall(plugin)}
/>
);
})}
</div>
</Tab>
))}
</Tabs>
<div className="relative">
{/* 加载遮罩 - 只遮住插件列表区域 */}
{loading && (
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
<Spinner size='lg' />
</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) => {
const installInfo = getPluginInstallInfo(plugin);
return (
<PluginStoreCard
key={plugin.id}
data={plugin}
installStatus={installInfo.status}
installedVersion={installInfo.installedVersion}
onInstall={() => handleInstall(plugin)}
/>
);
})}
</div>
</Tab>
))}
</Tabs>
</div>
</div>
{/* 商店列表源选择弹窗 */}
<MirrorSelectorModal
isOpen={storeSourceModalOpen}
onClose={() => setStoreSourceModalOpen(false)}
onSelect={(mirror) => {
setCurrentStoreSource(mirror);
}}
currentMirror={currentStoreSource}
type="raw"
/>
{/* 下载镜像选择弹窗 */}
<MirrorSelectorModal
isOpen={downloadMirrorModalOpen}
onClose={() => {
setDownloadMirrorModalOpen(false);
setPendingInstallPlugin(null);
}}
onSelect={(mirror) => {
setSelectedDownloadMirror(mirror);
// 选择后立即开始安装
if (pendingInstallPlugin) {
setDownloadMirrorModalOpen(false);
installPluginWithSSE(pendingInstallPlugin.id, mirror);
setPendingInstallPlugin(null);
}
}}
currentMirror={selectedDownloadMirror}
type="file"
/>
</>
);
}