2.0版本测试

This commit is contained in:
qaq
2025-11-19 14:28:45 +08:00
parent a27b4d781e
commit 65ca18d649
72 changed files with 3906 additions and 6289 deletions
+348 -139
View File
@@ -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="请输入远程地址,多个地址用换行分隔&#10;例如:&#10;192.168.1.100:8080&#10;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>