mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-04 01:06:36 +08:00
2.0版本测试
This commit is contained in:
@@ -4,7 +4,7 @@ export type SiteConfig = typeof siteConfig;
|
||||
|
||||
// 缓存相关常量
|
||||
const CACHE_PREFIX = 'vite_config_';
|
||||
const VERSION = "1.4.2";
|
||||
const VERSION = "2.0.0-beta";
|
||||
const APP_VERSION = "1.0.3";
|
||||
|
||||
const getInitialConfig = () => {
|
||||
|
||||
@@ -425,14 +425,28 @@ export default function DashboardPage() {
|
||||
};
|
||||
|
||||
const formatInAddress = (ipString: string, port: number): string => {
|
||||
if (!ipString || !port) return '';
|
||||
if (!ipString) return '';
|
||||
|
||||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
const items = ipString.split(',').map(item => item.trim()).filter(item => item);
|
||||
if (items.length === 0) return '';
|
||||
|
||||
if (ips.length === 0) return '';
|
||||
// 检查第一项是否已经包含端口(格式:IP:端口)
|
||||
const firstItem = items[0];
|
||||
const hasPort = /:\d+$/.test(firstItem);
|
||||
|
||||
if (ips.length === 1) {
|
||||
const ip = ips[0];
|
||||
if (hasPort) {
|
||||
// inIp 已经包含完整的 IP:Port 组合
|
||||
if (items.length === 1) {
|
||||
return items[0];
|
||||
}
|
||||
return `${items[0]} (+${items.length - 1}个)`;
|
||||
}
|
||||
|
||||
// inIp 只包含IP,需要添加端口(兼容旧数据)
|
||||
if (!port) return '';
|
||||
|
||||
if (items.length === 1) {
|
||||
const ip = items[0];
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
return `[${ip}]:${port}`;
|
||||
} else {
|
||||
@@ -440,16 +454,15 @@ export default function DashboardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const firstIp = ips[0];
|
||||
const firstIp = items[0];
|
||||
let formattedFirstIp;
|
||||
|
||||
if (firstIp.includes(':') && !firstIp.startsWith('[')) {
|
||||
formattedFirstIp = `[${firstIp}]`;
|
||||
} else {
|
||||
formattedFirstIp = firstIp;
|
||||
}
|
||||
|
||||
return `${formattedFirstIp}:${port} (+${ips.length - 1})`;
|
||||
return `${formattedFirstIp}:${port} (+${items.length - 1}个)`;
|
||||
};
|
||||
|
||||
const formatRemoteAddress = (remoteAddr: string): string => {
|
||||
@@ -479,32 +492,47 @@ export default function DashboardPage() {
|
||||
};
|
||||
|
||||
const showAddressModal = (ipString: string, port: number, title: string) => {
|
||||
if (!ipString || !port) return;
|
||||
if (!ipString) return;
|
||||
|
||||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
const items = ipString.split(',').map(item => item.trim()).filter(item => item);
|
||||
|
||||
if (ips.length <= 1) {
|
||||
copyToClipboard(formatInAddress(ipString, port));
|
||||
if (items.length <= 1) {
|
||||
copyToClipboard(formatInAddress(ipString, port));
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedList = ips.map((ip, index) => {
|
||||
let formattedAddress;
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
formattedAddress = `[${ip}]:${port}`;
|
||||
} else {
|
||||
formattedAddress = `${ip}:${port}`;
|
||||
}
|
||||
return {
|
||||
// 检查是否已经包含端口
|
||||
const hasPort = /:\d+$/.test(items[0]);
|
||||
|
||||
let formattedList;
|
||||
if (hasPort) {
|
||||
// 已经包含完整的 IP:Port 组合,直接使用
|
||||
formattedList = items.map((item, index) => ({
|
||||
id: index,
|
||||
ip: ip,
|
||||
address: formattedAddress,
|
||||
ip: item,
|
||||
address: item,
|
||||
copying: false
|
||||
};
|
||||
});
|
||||
}));
|
||||
} else {
|
||||
// 只包含IP,需要添加端口
|
||||
formattedList = items.map((ip, index) => {
|
||||
let formattedAddress;
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
formattedAddress = `[${ip}]:${port}`;
|
||||
} else {
|
||||
formattedAddress = `${ip}:${port}`;
|
||||
}
|
||||
return {
|
||||
id: index,
|
||||
ip: ip,
|
||||
address: formattedAddress,
|
||||
copying: false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setAddressList(formattedList);
|
||||
setAddressModalTitle(`${title} (${ips.length}个)`);
|
||||
setAddressModalTitle(`${title} (${items.length}个)`);
|
||||
setAddressModalOpen(true);
|
||||
};
|
||||
|
||||
|
||||
+348
-139
@@ -103,6 +103,10 @@ interface DiagnosisResult {
|
||||
message?: string;
|
||||
averageTime?: number;
|
||||
packetLoss?: number;
|
||||
fromChainType?: number; // 1: 入口, 2: 链, 3: 出口
|
||||
fromInx?: number;
|
||||
toChainType?: number;
|
||||
toInx?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -196,7 +200,6 @@ export default function ForwardPage() {
|
||||
|
||||
// 表单验证错误
|
||||
const [errors, setErrors] = useState<{[key: string]: string}>({});
|
||||
const [selectedTunnel, setSelectedTunnel] = useState<Tunnel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -421,16 +424,6 @@ export default function ForwardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (form.inPort !== null && (form.inPort < 1 || form.inPort > 65535)) {
|
||||
newErrors.inPort = '端口号必须在1-65535之间';
|
||||
}
|
||||
|
||||
if (selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd && form.inPort) {
|
||||
if (form.inPort < selectedTunnel.inNodePortSta || form.inPort > selectedTunnel.inNodePortEnd) {
|
||||
newErrors.inPort = `端口号必须在${selectedTunnel.inNodePortSta}-${selectedTunnel.inNodePortEnd}范围内`;
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
@@ -446,7 +439,6 @@ export default function ForwardPage() {
|
||||
interfaceName: '',
|
||||
strategy: 'fifo'
|
||||
});
|
||||
setSelectedTunnel(null);
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
};
|
||||
@@ -464,8 +456,6 @@ export default function ForwardPage() {
|
||||
interfaceName: forward.interfaceName || '',
|
||||
strategy: forward.strategy || 'fifo'
|
||||
});
|
||||
const tunnel = tunnels.find(t => t.id === forward.tunnelId);
|
||||
setSelectedTunnel(tunnel || null);
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
};
|
||||
@@ -511,8 +501,6 @@ export default function ForwardPage() {
|
||||
|
||||
// 处理隧道选择变化
|
||||
const handleTunnelChange = (tunnelId: string) => {
|
||||
const tunnel = tunnels.find(t => t.id === parseInt(tunnelId));
|
||||
setSelectedTunnel(tunnel || null);
|
||||
setForm(prev => ({ ...prev, tunnelId: parseInt(tunnelId) }));
|
||||
};
|
||||
|
||||
@@ -538,9 +526,7 @@ export default function ForwardPage() {
|
||||
userId: form.userId,
|
||||
name: form.name,
|
||||
tunnelId: form.tunnelId,
|
||||
inPort: form.inPort,
|
||||
remoteAddr: processedRemoteAddr,
|
||||
interfaceName: form.interfaceName,
|
||||
strategy: addressCount > 1 ? form.strategy : 'fifo'
|
||||
};
|
||||
res = await updateForward(updateData);
|
||||
@@ -549,9 +535,7 @@ export default function ForwardPage() {
|
||||
const createData = {
|
||||
name: form.name,
|
||||
tunnelId: form.tunnelId,
|
||||
inPort: form.inPort,
|
||||
remoteAddr: processedRemoteAddr,
|
||||
interfaceName: form.interfaceName,
|
||||
strategy: addressCount > 1 ? form.strategy : 'fifo'
|
||||
};
|
||||
res = await createForward(createData);
|
||||
@@ -694,13 +678,28 @@ export default function ForwardPage() {
|
||||
|
||||
// 格式化入口地址
|
||||
const formatInAddress = (ipString: string, port: number): string => {
|
||||
if (!ipString || !port) return '';
|
||||
if (!ipString) return '';
|
||||
|
||||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
if (ips.length === 0) return '';
|
||||
const items = ipString.split(',').map(item => item.trim()).filter(item => item);
|
||||
if (items.length === 0) return '';
|
||||
|
||||
if (ips.length === 1) {
|
||||
const ip = ips[0];
|
||||
// 检查第一项是否已经包含端口(格式:IP:端口)
|
||||
const firstItem = items[0];
|
||||
const hasPort = /:\d+$/.test(firstItem);
|
||||
|
||||
if (hasPort) {
|
||||
// inIp 已经包含完整的 IP:Port 组合
|
||||
if (items.length === 1) {
|
||||
return items[0];
|
||||
}
|
||||
return `${items[0]} (+${items.length - 1}个)`;
|
||||
}
|
||||
|
||||
// inIp 只包含IP,需要添加端口(兼容旧数据)
|
||||
if (!port) return '';
|
||||
|
||||
if (items.length === 1) {
|
||||
const ip = items[0];
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
return `[${ip}]:${port}`;
|
||||
} else {
|
||||
@@ -708,7 +707,7 @@ export default function ForwardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const firstIp = ips[0];
|
||||
const firstIp = items[0];
|
||||
let formattedFirstIp;
|
||||
if (firstIp.includes(':') && !firstIp.startsWith('[')) {
|
||||
formattedFirstIp = `[${firstIp}]`;
|
||||
@@ -716,7 +715,7 @@ export default function ForwardPage() {
|
||||
formattedFirstIp = firstIp;
|
||||
}
|
||||
|
||||
return `${formattedFirstIp}:${port} (+${ips.length - 1})`;
|
||||
return `${formattedFirstIp}:${port} (+${items.length - 1}个)`;
|
||||
};
|
||||
|
||||
// 格式化远程地址
|
||||
@@ -744,18 +743,27 @@ export default function ForwardPage() {
|
||||
let addresses: string[];
|
||||
if (port !== null) {
|
||||
// 入口地址处理
|
||||
const ips = addressString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
if (ips.length <= 1) {
|
||||
const items = addressString.split(',').map(item => item.trim()).filter(item => item);
|
||||
if (items.length <= 1) {
|
||||
copyToClipboard(formatInAddress(addressString, port), title);
|
||||
return;
|
||||
}
|
||||
addresses = ips.map(ip => {
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
return `[${ip}]:${port}`;
|
||||
} else {
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
});
|
||||
|
||||
// 检查是否已经包含端口
|
||||
const hasPort = /:\d+$/.test(items[0]);
|
||||
if (hasPort) {
|
||||
// 已经包含完整的 IP:Port 组合,直接使用
|
||||
addresses = items;
|
||||
} else {
|
||||
// 只包含IP,需要添加端口
|
||||
addresses = items.map(ip => {
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
return `[${ip}]:${port}`;
|
||||
} else {
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 远程地址处理
|
||||
addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
|
||||
@@ -1588,6 +1596,8 @@ export default function ForwardPage() {
|
||||
isInvalid={!!errors.tunnelId}
|
||||
errorMessage={errors.tunnelId}
|
||||
variant="bordered"
|
||||
isDisabled={isEdit}
|
||||
description={isEdit ? "编辑时无法修改关联隧道" : undefined}
|
||||
>
|
||||
{tunnels.map((tunnel) => (
|
||||
<SelectItem key={tunnel.id} >
|
||||
@@ -1596,25 +1606,6 @@ export default function ForwardPage() {
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
label="入口端口"
|
||||
placeholder="留空自动分配"
|
||||
type="number"
|
||||
value={form.inPort?.toString() || ''}
|
||||
onChange={(e) => setForm(prev => ({
|
||||
...prev,
|
||||
inPort: e.target.value ? parseInt(e.target.value) : null
|
||||
}))}
|
||||
isInvalid={!!errors.inPort}
|
||||
errorMessage={errors.inPort}
|
||||
variant="bordered"
|
||||
description={
|
||||
selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd
|
||||
? `允许范围: ${selectedTunnel.inNodePortSta}-${selectedTunnel.inNodePortEnd}`
|
||||
: '留空将自动分配可用端口'
|
||||
}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="远程地址"
|
||||
placeholder="请输入远程地址,多个地址用换行分隔 例如: 192.168.1.100:8080 example.com:3000"
|
||||
@@ -1628,17 +1619,6 @@ export default function ForwardPage() {
|
||||
maxRows={6}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="出口网卡名或IP"
|
||||
placeholder="请输入出口网卡名或IP"
|
||||
value={form.interfaceName}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, interfaceName: e.target.value }))}
|
||||
isInvalid={!!errors.interfaceName}
|
||||
errorMessage={errors.interfaceName}
|
||||
variant="bordered"
|
||||
description="用于多IP服务器指定使用那个IP请求远程地址,不懂的默认为空就行"
|
||||
/>
|
||||
|
||||
{getAddressCount(form.remoteAddr) > 1 && (
|
||||
<Select
|
||||
label="负载策略"
|
||||
@@ -2017,16 +1997,21 @@ export default function ForwardPage() {
|
||||
<Modal
|
||||
isOpen={diagnosisModalOpen}
|
||||
onOpenChange={setDiagnosisModalOpen}
|
||||
|
||||
size="2xl"
|
||||
scrollBehavior="outside"
|
||||
backdrop="blur"
|
||||
placement="center"
|
||||
size="4xl"
|
||||
scrollBehavior="inside"
|
||||
backdrop="blur"
|
||||
placement="center"
|
||||
classNames={{
|
||||
base: "rounded-2xl",
|
||||
header: "rounded-t-2xl",
|
||||
body: "rounded-none",
|
||||
footer: "rounded-b-2xl"
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<ModalHeader className="flex flex-col gap-1 bg-content1 border-b border-divider">
|
||||
<h2 className="text-xl font-bold">转发诊断结果</h2>
|
||||
{currentDiagnosisForward && (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -2042,89 +2027,313 @@ export default function ForwardPage() {
|
||||
</div>
|
||||
)}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<ModalBody className="bg-content1">
|
||||
{diagnosisLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在诊断转发连接...</span>
|
||||
<span className="text-default-600">正在诊断...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : diagnosisResult ? (
|
||||
<div className="space-y-4">
|
||||
{diagnosisResult.results.map((result, index) => {
|
||||
const quality = getQualityDisplay(result.averageTime, result.packetLoss);
|
||||
|
||||
return (
|
||||
<Card key={index} className={`shadow-sm border ${result.success ? 'border-success' : 'border-danger'}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{result.description}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-small text-default-500">节点: {result.nodeName}</span>
|
||||
{/* 统计摘要 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
|
||||
<div className="text-2xl font-bold text-foreground">{diagnosisResult.results.length}</div>
|
||||
<div className="text-xs text-default-500 mt-1">总测试数</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-success-50 dark:bg-success-900/20 rounded-lg border border-success-200 dark:border-success-700">
|
||||
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
||||
{diagnosisResult.results.filter(r => r.success).length}
|
||||
</div>
|
||||
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">成功</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-danger-50 dark:bg-danger-900/20 rounded-lg border border-danger-200 dark:border-danger-700">
|
||||
<div className="text-2xl font-bold text-danger-600 dark:text-danger-400">
|
||||
{diagnosisResult.results.filter(r => !r.success).length}
|
||||
</div>
|
||||
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">失败</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 桌面端表格展示 */}
|
||||
<div className="hidden md:block space-y-3">
|
||||
{(() => {
|
||||
// 使用后端返回的 chainType 和 inx 字段进行分组
|
||||
const groupedResults = {
|
||||
entry: diagnosisResult.results.filter(r => r.fromChainType === 1),
|
||||
chains: {} as Record<number, typeof diagnosisResult.results>,
|
||||
exit: diagnosisResult.results.filter(r => r.fromChainType === 3)
|
||||
};
|
||||
|
||||
// 按 inx 分组链路测试
|
||||
diagnosisResult.results.forEach(r => {
|
||||
if (r.fromChainType === 2 && r.fromInx != null) {
|
||||
if (!groupedResults.chains[r.fromInx]) {
|
||||
groupedResults.chains[r.fromInx] = [];
|
||||
}
|
||||
groupedResults.chains[r.fromInx].push(r);
|
||||
}
|
||||
});
|
||||
|
||||
const renderTableSection = (title: string, results: typeof diagnosisResult.results) => {
|
||||
if (results.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={title} className="border border-divider rounded-lg overflow-hidden bg-white dark:bg-gray-800">
|
||||
<div className="bg-primary/10 dark:bg-primary/20 px-3 py-2 border-b border-divider">
|
||||
<h3 className="text-sm font-semibold text-primary">{title}</h3>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-default-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold text-xs">路径</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-xs w-20">状态</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-xs w-24">延迟(ms)</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-xs w-24">丢包率</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-xs w-20">质量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
|
||||
{results.map((result, index) => {
|
||||
const quality = getQualityDisplay(result.averageTime, result.packetLoss);
|
||||
|
||||
return (
|
||||
<tr key={index} className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
||||
result.success ? 'bg-white dark:bg-gray-800' : 'bg-danger-50 dark:bg-danger-900/30'
|
||||
}`}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||
result.success
|
||||
? 'bg-success text-white'
|
||||
: 'bg-danger text-white'
|
||||
}`}>
|
||||
{result.success ? '✓' : '✗'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-foreground truncate">{result.description}</div>
|
||||
<div className="text-xs text-default-500 truncate">
|
||||
{result.targetIp}:{result.targetPort}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Chip
|
||||
color={result.success ? 'success' : 'danger'}
|
||||
variant="flat"
|
||||
variant="flat"
|
||||
size="sm"
|
||||
className="min-w-[50px]"
|
||||
>
|
||||
{result.success ? '连接成功' : '连接失败'}
|
||||
{result.success ? '成功' : '失败'}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success ? (
|
||||
<span className="font-semibold text-primary">
|
||||
{result.averageTime?.toFixed(0)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-default-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success ? (
|
||||
<span className={`font-semibold ${
|
||||
(result.packetLoss || 0) > 0 ? 'text-warning' : 'text-success'
|
||||
}`}>
|
||||
{result.packetLoss?.toFixed(1)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-default-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success && quality ? (
|
||||
<Chip
|
||||
color={quality.color as any}
|
||||
variant="flat"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
{quality.text}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="text-default-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 入口测试 */}
|
||||
{renderTableSection('🚪 入口测试', groupedResults.entry)}
|
||||
|
||||
{/* 链路测试(按跳数排序) */}
|
||||
{Object.keys(groupedResults.chains)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map(hop => renderTableSection(`🔗 转发链 - 第${hop}跳`, groupedResults.chains[hop]))}
|
||||
|
||||
{/* 出口测试 */}
|
||||
{renderTableSection('🚀 出口测试', groupedResults.exit)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 移动端卡片展示 */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{(() => {
|
||||
// 使用后端返回的 chainType 和 inx 字段进行分组
|
||||
const groupedResults = {
|
||||
entry: diagnosisResult.results.filter(r => r.fromChainType === 1),
|
||||
chains: {} as Record<number, typeof diagnosisResult.results>,
|
||||
exit: diagnosisResult.results.filter(r => r.fromChainType === 3)
|
||||
};
|
||||
|
||||
// 按 inx 分组链路测试
|
||||
diagnosisResult.results.forEach(r => {
|
||||
if (r.fromChainType === 2 && r.fromInx != null) {
|
||||
if (!groupedResults.chains[r.fromInx]) {
|
||||
groupedResults.chains[r.fromInx] = [];
|
||||
}
|
||||
groupedResults.chains[r.fromInx].push(r);
|
||||
}
|
||||
});
|
||||
|
||||
const renderCardSection = (title: string, results: typeof diagnosisResult.results) => {
|
||||
if (results.length === 0) return null;
|
||||
|
||||
<CardBody className="pt-0">
|
||||
{result.success ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary">{result.averageTime?.toFixed(0)}</div>
|
||||
<div className="text-small text-default-500">平均延迟(ms)</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-warning">{result.packetLoss?.toFixed(1)}</div>
|
||||
<div className="text-small text-default-500">丢包率(%)</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{quality && (
|
||||
<>
|
||||
<Chip color={quality.color as any} variant="flat" size="lg">
|
||||
{quality.text}
|
||||
</Chip>
|
||||
<div className="text-small text-default-500 mt-1">连接质量</div>
|
||||
</>
|
||||
return (
|
||||
<div key={title} className="space-y-2">
|
||||
<div className="px-2 py-1.5 bg-primary/10 dark:bg-primary/20 rounded-lg border border-primary/30">
|
||||
<h3 className="text-sm font-semibold text-primary">{title}</h3>
|
||||
</div>
|
||||
{results.map((result, index) => {
|
||||
const quality = getQualityDisplay(result.averageTime, result.packetLoss);
|
||||
|
||||
return (
|
||||
<div key={index} className={`border rounded-lg p-3 ${
|
||||
result.success
|
||||
? 'border-divider bg-white dark:bg-gray-800'
|
||||
: 'border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30'
|
||||
}`}>
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||
result.success ? 'bg-success text-white' : 'bg-danger text-white'
|
||||
}`}>
|
||||
{result.success ? '✓' : '✗'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm text-foreground break-words">
|
||||
{result.description}
|
||||
</div>
|
||||
<div className="text-xs text-default-500 mt-0.5 break-all">
|
||||
{result.targetIp}:{result.targetPort}
|
||||
</div>
|
||||
</div>
|
||||
<Chip
|
||||
color={result.success ? 'success' : 'danger'}
|
||||
variant="flat"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{result.success ? '成功' : '失败'}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{result.success ? (
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-primary">
|
||||
{result.averageTime?.toFixed(0)}
|
||||
</div>
|
||||
<div className="text-xs text-default-500">延迟(ms)</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${
|
||||
(result.packetLoss || 0) > 0 ? 'text-warning' : 'text-success'
|
||||
}`}>
|
||||
{result.packetLoss?.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-default-500">丢包率</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{quality && (
|
||||
<>
|
||||
<Chip
|
||||
color={quality.color as any}
|
||||
variant="flat"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
{quality.text}
|
||||
</Chip>
|
||||
<div className="text-xs text-default-500 mt-0.5">质量</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 pt-2 border-t border-divider">
|
||||
<div className="text-xs text-danger">
|
||||
{result.message || '连接失败'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-small text-default-500 flex items-center gap-1">
|
||||
<span className="flex-shrink-0">目标地址:</span>
|
||||
<code className="font-mono truncate min-w-0" title={`${result.targetIp}${result.targetPort ? ':' + result.targetPort : ''}`}>
|
||||
{result.targetIp}{result.targetPort ? ':' + result.targetPort : ''}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-small text-default-500 flex items-center gap-1">
|
||||
<span className="flex-shrink-0">目标地址:</span>
|
||||
<code className="font-mono truncate min-w-0" title={`${result.targetIp}${result.targetPort ? ':' + result.targetPort : ''}`}>
|
||||
{result.targetIp}{result.targetPort ? ':' + result.targetPort : ''}
|
||||
</code>
|
||||
</div>
|
||||
<Alert
|
||||
color="danger"
|
||||
variant="flat"
|
||||
title="错误详情"
|
||||
description={result.message}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 入口测试 */}
|
||||
{renderCardSection('🚪 入口测试', groupedResults.entry)}
|
||||
|
||||
{/* 链路测试(按跳数排序) */}
|
||||
{Object.keys(groupedResults.chains)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map(hop => renderCardSection(`🔗 转发链 - 第${hop}跳`, groupedResults.chains[hop]))}
|
||||
|
||||
{/* 出口测试 */}
|
||||
{renderCardSection('🚀 出口测试', groupedResults.exit)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
|
||||
{diagnosisResult.results.some(r => !r.success) && (
|
||||
<div className="space-y-2 hidden md:block">
|
||||
<h4 className="text-sm font-semibold text-danger">失败详情</h4>
|
||||
<div className="space-y-2">
|
||||
{diagnosisResult.results.filter(r => !r.success).map((result, index) => (
|
||||
<Alert
|
||||
key={index}
|
||||
color="danger"
|
||||
variant="flat"
|
||||
title={result.description}
|
||||
description={result.message || '连接失败'}
|
||||
className="text-xs"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
@@ -2137,7 +2346,7 @@ export default function ForwardPage() {
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<ModalFooter className="bg-content1 border-t border-divider">
|
||||
<Button variant="light" onPress={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
|
||||
@@ -404,6 +404,8 @@ export default function LimitPage() {
|
||||
isInvalid={!!errors.tunnelId}
|
||||
errorMessage={errors.tunnelId}
|
||||
variant="bordered"
|
||||
isDisabled={isEdit}
|
||||
description={isEdit ? "编辑时无法修改绑定隧道" : undefined}
|
||||
>
|
||||
{tunnels.map((tunnel) => (
|
||||
<SelectItem key={tunnel.id}>
|
||||
|
||||
+137
-109
@@ -26,8 +26,9 @@ interface Node {
|
||||
name: string;
|
||||
ip: string;
|
||||
serverIp: string;
|
||||
portSta: number;
|
||||
portEnd: number;
|
||||
port: string;
|
||||
tcpListenAddr?: string;
|
||||
udpListenAddr?: string;
|
||||
version?: string;
|
||||
http?: number; // 0 关 1 开
|
||||
tls?: number; // 0 关 1 开
|
||||
@@ -49,10 +50,11 @@ interface Node {
|
||||
interface NodeForm {
|
||||
id: number | null;
|
||||
name: string;
|
||||
ipString: string;
|
||||
serverIp: string;
|
||||
portSta: number;
|
||||
portEnd: number;
|
||||
port: string;
|
||||
tcpListenAddr: string;
|
||||
udpListenAddr: string;
|
||||
interfaceName: string;
|
||||
http: number; // 0 关 1 开
|
||||
tls: number; // 0 关 1 开
|
||||
socks: number; // 0 关 1 开
|
||||
@@ -73,10 +75,11 @@ export default function NodePage() {
|
||||
const [form, setForm] = useState<NodeForm>({
|
||||
id: null,
|
||||
name: '',
|
||||
ipString: '',
|
||||
serverIp: '',
|
||||
portSta: 1000,
|
||||
portEnd: 65535,
|
||||
port: '1000-65535',
|
||||
tcpListenAddr: '[::]',
|
||||
udpListenAddr: '[::]',
|
||||
interfaceName: '',
|
||||
http: 0,
|
||||
tls: 0,
|
||||
socks: 0
|
||||
@@ -362,6 +365,57 @@ export default function NodePage() {
|
||||
return domainRegex.test(trimmedIp) || singleLabelDomain.test(trimmedIp);
|
||||
};
|
||||
|
||||
// 验证端口格式:支持 80,443,100-600
|
||||
const validatePort = (portStr: string): { valid: boolean; error?: string } => {
|
||||
if (!portStr || !portStr.trim()) {
|
||||
return { valid: false, error: '请输入端口' };
|
||||
}
|
||||
|
||||
const trimmed = portStr.trim();
|
||||
const parts = trimmed.split(',').map(p => p.trim()).filter(p => p);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return { valid: false, error: '请输入有效的端口' };
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
// 检查是否是端口范围 (如 100-600)
|
||||
if (part.includes('-')) {
|
||||
const range = part.split('-').map(p => p.trim());
|
||||
if (range.length !== 2) {
|
||||
return { valid: false, error: `端口范围格式错误: ${part}` };
|
||||
}
|
||||
|
||||
const start = parseInt(range[0]);
|
||||
const end = parseInt(range[1]);
|
||||
|
||||
if (isNaN(start) || isNaN(end)) {
|
||||
return { valid: false, error: `端口必须是数字: ${part}` };
|
||||
}
|
||||
|
||||
if (start < 1 || start > 65535 || end < 1 || end > 65535) {
|
||||
return { valid: false, error: `端口范围必须在 1-65535 之间: ${part}` };
|
||||
}
|
||||
|
||||
if (start >= end) {
|
||||
return { valid: false, error: `起始端口必须小于结束端口: ${part}` };
|
||||
}
|
||||
} else {
|
||||
// 单个端口
|
||||
const port = parseInt(part);
|
||||
if (isNaN(port)) {
|
||||
return { valid: false, error: `端口必须是数字: ${part}` };
|
||||
}
|
||||
|
||||
if (port < 1 || port > 65535) {
|
||||
return { valid: false, error: `端口必须在 1-65535 之间: ${part}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
@@ -374,36 +428,15 @@ export default function NodePage() {
|
||||
newErrors.name = '节点名称长度不能超过50位';
|
||||
}
|
||||
|
||||
if (!form.ipString.trim()) {
|
||||
newErrors.ipString = '请输入入口IP地址';
|
||||
} else {
|
||||
const ips = form.ipString.split('\n').map(ip => ip.trim()).filter(ip => ip);
|
||||
if (ips.length === 0) {
|
||||
newErrors.ipString = '请输入至少一个有效IP地址';
|
||||
} else {
|
||||
for (let i = 0; i < ips.length; i++) {
|
||||
if (!validateIp(ips[i])) {
|
||||
newErrors.ipString = `第${i + 1}行IP地址格式错误: ${ips[i]}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!form.serverIp.trim()) {
|
||||
newErrors.serverIp = '请输入服务器IP地址';
|
||||
} else if (!validateIp(form.serverIp.trim())) {
|
||||
newErrors.serverIp = '请输入有效的IPv4、IPv6地址或域名';
|
||||
}
|
||||
|
||||
if (!form.portSta || form.portSta < 1 || form.portSta > 65535) {
|
||||
newErrors.portSta = '端口范围必须在1-65535之间';
|
||||
}
|
||||
|
||||
if (!form.portEnd || form.portEnd < 1 || form.portEnd > 65535) {
|
||||
newErrors.portEnd = '端口范围必须在1-65535之间';
|
||||
} else if (form.portEnd < form.portSta) {
|
||||
newErrors.portEnd = '结束端口不能小于起始端口';
|
||||
const portValidation = validatePort(form.port);
|
||||
if (!portValidation.valid) {
|
||||
newErrors.port = portValidation.error || '端口格式错误';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@@ -427,10 +460,11 @@ export default function NodePage() {
|
||||
setForm({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
ipString: node.ip ? node.ip.split(',').map(ip => ip.trim()).join('\n') : '',
|
||||
serverIp: node.serverIp || '',
|
||||
portSta: node.portSta,
|
||||
portEnd: node.portEnd,
|
||||
port: node.port || '1000-65535',
|
||||
tcpListenAddr: node.tcpListenAddr || '[::]',
|
||||
udpListenAddr: node.udpListenAddr || '[::]',
|
||||
interfaceName: (node as any).interfaceName || '',
|
||||
http: typeof node.http === 'number' ? node.http : 1,
|
||||
tls: typeof node.tls === 'number' ? node.tls : 1,
|
||||
socks: typeof node.socks === 'number' ? node.socks : 1
|
||||
@@ -516,28 +550,9 @@ export default function NodePage() {
|
||||
setSubmitLoading(true);
|
||||
|
||||
try {
|
||||
const ipString = form.ipString
|
||||
.split('\n')
|
||||
.map(ip => ip.trim())
|
||||
.filter(ip => ip)
|
||||
.join(',');
|
||||
|
||||
const submitData = {
|
||||
...form,
|
||||
ip: ipString
|
||||
};
|
||||
delete (submitData as any).ipString;
|
||||
|
||||
const apiCall = isEdit ? updateNode : createNode;
|
||||
const data = isEdit ? submitData : {
|
||||
name: form.name,
|
||||
ip: ipString,
|
||||
serverIp: form.serverIp,
|
||||
portSta: form.portSta,
|
||||
portEnd: form.portEnd,
|
||||
http: form.http,
|
||||
tls: form.tls,
|
||||
socks: form.socks
|
||||
const data = {
|
||||
...form
|
||||
};
|
||||
|
||||
const res = await apiCall(data);
|
||||
@@ -550,10 +565,11 @@ export default function NodePage() {
|
||||
n.id === form.id ? {
|
||||
...n,
|
||||
name: form.name,
|
||||
ip: ipString,
|
||||
serverIp: form.serverIp,
|
||||
portSta: form.portSta,
|
||||
portEnd: form.portEnd,
|
||||
port: form.port,
|
||||
tcpListenAddr: form.tcpListenAddr,
|
||||
udpListenAddr: form.udpListenAddr,
|
||||
interfaceName: form.interfaceName,
|
||||
http: form.http,
|
||||
tls: form.tls,
|
||||
socks: form.socks
|
||||
@@ -577,10 +593,11 @@ export default function NodePage() {
|
||||
setForm({
|
||||
id: null,
|
||||
name: '',
|
||||
ipString: '',
|
||||
serverIp: '',
|
||||
portSta: 1000,
|
||||
portEnd: 65535,
|
||||
port: '1000-65535',
|
||||
tcpListenAddr: '[::]',
|
||||
udpListenAddr: '[::]',
|
||||
interfaceName: '',
|
||||
http: 0,
|
||||
tls: 0,
|
||||
socks: 0
|
||||
@@ -643,7 +660,6 @@ export default function NodePage() {
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">{node.name}</h3>
|
||||
<p className="text-xs text-default-500 truncate">{node.serverIp}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<Chip
|
||||
@@ -662,25 +678,13 @@ export default function NodePage() {
|
||||
{/* 基础信息 */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between items-center text-sm min-w-0">
|
||||
<span className="text-default-600 flex-shrink-0">入口IP</span>
|
||||
<span className="text-default-600 flex-shrink-0">IP</span>
|
||||
<div className="text-right text-xs min-w-0 flex-1 ml-2">
|
||||
{node.ip ? (
|
||||
node.ip.split(',').length > 1 ? (
|
||||
<span className="font-mono truncate block" title={node.ip.split(',')[0].trim()}>
|
||||
{node.ip.split(',')[0].trim()} +{node.ip.split(',').length - 1}个
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono truncate block" title={node.ip.trim()}>
|
||||
{node.ip.trim()}
|
||||
</span>
|
||||
)
|
||||
) : '-'}
|
||||
<span className="font-mono truncate block" title={node.serverIp.trim()}>
|
||||
{node.serverIp.trim()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">端口</span>
|
||||
<span className="text-xs">{node.portSta}-{node.portEnd}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">版本</span>
|
||||
<span className="text-xs">{node.version || '未知'}</span>
|
||||
@@ -857,44 +861,60 @@ export default function NodePage() {
|
||||
variant="bordered"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="入口IP"
|
||||
placeholder="一行一个IP地址或域名,例如: 192.168.1.100 example.com"
|
||||
value={form.ipString}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, ipString: e.target.value }))}
|
||||
isInvalid={!!errors.ipString}
|
||||
errorMessage={errors.ipString}
|
||||
<Input
|
||||
label="可用端口"
|
||||
placeholder="例如: 80,443,1000-65535"
|
||||
value={form.port}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, port: e.target.value }))}
|
||||
isInvalid={!!errors.port}
|
||||
errorMessage={errors.port}
|
||||
variant="bordered"
|
||||
minRows={3}
|
||||
maxRows={5}
|
||||
description="支持多个IP,每行一个地址"
|
||||
description="支持单个端口(80)、多个端口(80,443)或端口范围(1000-65535),多个可用逗号分隔"
|
||||
classNames={{
|
||||
input: "font-mono"
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="出口网卡名或IP"
|
||||
placeholder="请输入出口网卡名或IP"
|
||||
value={form.interfaceName}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, interfaceName: e.target.value }))}
|
||||
isInvalid={!!errors.interfaceName}
|
||||
errorMessage={errors.interfaceName}
|
||||
variant="bordered"
|
||||
description="用于多IP服务器指定使用那个IP请求远程地址,不懂的默认为空就行"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="起始端口"
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
value={form.portSta.toString()}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, portSta: parseInt(e.target.value) || 1000 }))}
|
||||
isInvalid={!!errors.portSta}
|
||||
errorMessage={errors.portSta}
|
||||
label="TCP监听地址"
|
||||
placeholder="请输入TCP监听地址"
|
||||
value={form.tcpListenAddr}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, tcpListenAddr: e.target.value }))}
|
||||
isInvalid={!!errors.tcpListenAddr}
|
||||
errorMessage={errors.tcpListenAddr}
|
||||
variant="bordered"
|
||||
min={1}
|
||||
max={65535}
|
||||
startContent={
|
||||
<div className="pointer-events-none flex items-center">
|
||||
<span className="text-default-400 text-small">TCP</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="结束端口"
|
||||
type="number"
|
||||
placeholder="65535"
|
||||
value={form.portEnd.toString()}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, portEnd: parseInt(e.target.value) || 65535 }))}
|
||||
isInvalid={!!errors.portEnd}
|
||||
errorMessage={errors.portEnd}
|
||||
label="UDP监听地址"
|
||||
placeholder="请输入UDP监听地址"
|
||||
value={form.udpListenAddr}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, udpListenAddr: e.target.value }))}
|
||||
isInvalid={!!errors.udpListenAddr}
|
||||
errorMessage={errors.udpListenAddr}
|
||||
variant="bordered"
|
||||
min={1}
|
||||
max={65535}
|
||||
startContent={
|
||||
<div className="pointer-events-none flex items-center">
|
||||
<span className="text-default-400 text-small">UDP</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -979,7 +999,15 @@ export default function NodePage() {
|
||||
<Alert
|
||||
color="primary"
|
||||
variant="flat"
|
||||
description="服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。入口ip是用于展示在转发页面,面向用户的访问地址。实在理解不到说明你没这个需求,都填节点的服务器ip就行!"
|
||||
description="服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。"
|
||||
className="mt-4"
|
||||
/>
|
||||
|
||||
<Alert
|
||||
color="primary"
|
||||
variant="flat"
|
||||
title="TCP,UDP监听地址"
|
||||
description="V6或者双栈填写[::],V4填写0.0.0.0。不懂的就去看文档网站内的说明"
|
||||
className="mt-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+885
-338
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user