mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 22:51:13 +00:00
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:
parent
699b46acbd
commit
129d63f66e
230
packages/napcat-webui-backend/src/api/Mirror.ts
Normal file
230
packages/napcat-webui-backend/src/api/Mirror.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
23
packages/napcat-webui-backend/src/router/Mirror.ts
Normal file
23
packages/napcat-webui-backend/src/router/Mirror.ts
Normal 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 };
|
||||
@ -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 };
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
109
packages/napcat-webui-frontend/src/controllers/mirror_manager.ts
Normal file
109
packages/napcat-webui-frontend/src/controllers/mirror_manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user