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 a7e341f22b
commit f8b59cc9eb
7 changed files with 914 additions and 156 deletions

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