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
+1 -1
View File
@@ -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 = () => {
+53 -25
View File
@@ -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
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>
+2
View File
@@ -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
View File
@@ -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地址或域名,例如:&#10;192.168.1.100&#10;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>
File diff suppressed because it is too large Load Diff