mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 08:10:25 +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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user