优化ui 添加转发诊断 移除vue前端

This commit is contained in:
qaq
2025-07-07 11:52:52 +08:00
parent 0adc09f5de
commit ac4f078014
59 changed files with 1458 additions and 30089 deletions
+36 -31
View File
@@ -1,5 +1,5 @@
import { Route, Routes, Navigate, useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { Route, Routes } from "react-router-dom";
import { useEffect } from "react";
import IndexPage from "@/pages/index";
import ChangePasswordPage from "@/pages/change-password";
@@ -12,49 +12,54 @@ import LimitPage from "@/pages/limit";
import { isLoggedIn } from "@/utils/auth";
// 路由保护组件 - 使用useNavigate避免循环渲染
// 简化的路由保护组件 - 使用浏览器重定向避免React循环
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const [checking, setChecking] = useState(true);
const [authenticated, setAuthenticated] = useState(false);
const navigate = useNavigate();
const authenticated = isLoggedIn();
useEffect(() => {
const checkAuth = () => {
const loggedIn = isLoggedIn();
if (!loggedIn) {
// 如果未登录,直接导航到登录页
navigate('/', { replace: true });
return;
}
setAuthenticated(true);
setChecking(false);
};
if (!authenticated) {
// 使用浏览器原生重定向,避免React渲染循环
window.location.replace('/');
}
}, [authenticated]);
checkAuth();
}, [navigate]);
if (checking) {
if (!authenticated) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">检查登陆状态...</div>
<div className="text-lg">重定向中...</div>
</div>
);
}
return authenticated ? <>{children}</> : null;
return <>{children}</>;
};
// 登录页面路由组件 - 已登录则重定向到dashboard
const LoginRoute = () => {
const authenticated = isLoggedIn();
useEffect(() => {
if (authenticated) {
// 使用浏览器原生重定向
window.location.replace('/dashboard');
}
}, [authenticated]);
if (authenticated) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">重定向中...</div>
</div>
);
}
return <IndexPage />;
};
function App() {
return (
<Routes>
<Route
path="/"
element={
isLoggedIn() ? <Navigate to="/dashboard" replace /> : <IndexPage />
}
/>
<Route path="/" element={<LoginRoute />} />
<Route
path="/change-password"
element={
+7
View File
@@ -28,6 +28,10 @@ export const getNodeList = () => Network.post("/node/list");
export const updateNode = (data: any) => Network.post("/node/update", data);
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { id });
export const checkNodeStatus = (nodeId?: number) => {
const params = nodeId ? { nodeId } : {};
return Network.post("/node/check-status", params);
};
// 隧道CRUD操作 - 全部使用POST请求
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
@@ -56,6 +60,9 @@ export const forceDeleteForward = (id: number) => Network.post("/forward/force-d
export const pauseForwardService = (forwardId: number) => Network.post("/forward/pause", { id: forwardId });
export const resumeForwardService = (forwardId: number) => Network.post("/forward/resume", { id: forwardId });
// 转发诊断操作
export const diagnoseForward = (forwardId: number) => Network.post("/forward/diagnose", { forwardId });
// 限速规则CRUD操作 - 全部使用POST请求
export const createSpeedLimit = (data: any) => Network.post("/speed-limit/create", data);
export const getSpeedLimitList = () => Network.post("/speed-limit/list");
+11 -11
View File
@@ -86,16 +86,6 @@ export default function AdminLayout({
),
adminOnly: true
},
{
path: '/user',
label: '用户管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
),
adminOnly: true
},
{
path: '/limit',
label: '限速管理',
@@ -105,6 +95,16 @@ export default function AdminLayout({
</svg>
),
adminOnly: true
},
{
path: '/user',
label: '用户管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
),
adminOnly: true
}
];
@@ -260,7 +260,7 @@ export default function AdminLayout({
<Logo size={28} />
<div>
<h1 className="text-base font-bold text-foreground">哆啦A梦</h1>
<p className="text-xs text-default-500">v1.0.3</p>
<p className="text-xs text-default-500">v1.0.4</p>
</div>
</div>
</div>
+247 -10
View File
@@ -8,6 +8,7 @@ import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@herou
import { Chip } from "@heroui/chip";
import { Spinner } from "@heroui/spinner";
import { Switch } from "@heroui/switch";
import { Alert } from "@heroui/alert";
import toast from 'react-hot-toast';
import AdminLayout from "@/layouts/admin";
@@ -19,7 +20,8 @@ import {
forceDeleteForward,
userTunnel,
pauseForwardService,
resumeForwardService
resumeForwardService,
diagnoseForward
} from "@/api";
interface Forward {
@@ -63,6 +65,21 @@ interface AddressItem {
copying: boolean;
}
interface DiagnosisResult {
forwardName: string;
timestamp: number;
results: Array<{
success: boolean;
description: string;
nodeName: string;
nodeId: string;
targetIp: string;
message?: string;
averageTime?: number;
packetLoss?: number;
}>;
}
export default function ForwardPage() {
const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState<Forward[]>([]);
@@ -72,10 +89,14 @@ export default function ForwardPage() {
const [modalOpen, setModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [addressModalOpen, setAddressModalOpen] = useState(false);
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [forwardToDelete, setForwardToDelete] = useState<Forward | null>(null);
const [currentDiagnosisForward, setCurrentDiagnosisForward] = useState<Forward | null>(null);
const [diagnosisResult, setDiagnosisResult] = useState<DiagnosisResult | null>(null);
const [addressModalTitle, setAddressModalTitle] = useState('');
const [addressList, setAddressList] = useState<AddressItem[]>([]);
@@ -359,6 +380,64 @@ export default function ForwardPage() {
}
};
// 诊断转发
const handleDiagnose = async (forward: Forward) => {
setCurrentDiagnosisForward(forward);
setDiagnosisModalOpen(true);
setDiagnosisLoading(true);
setDiagnosisResult(null);
try {
const response = await diagnoseForward(forward.id);
if (response.code === 0) {
setDiagnosisResult(response.data);
} else {
toast.error(response.msg || '诊断失败');
setDiagnosisResult({
forwardName: forward.name,
timestamp: Date.now(),
results: [{
success: false,
description: '诊断失败',
nodeName: '-',
nodeId: '-',
targetIp: forward.remoteAddr.split(',')[0] || '-',
message: response.msg || '诊断过程中发生错误'
}]
});
}
} catch (error) {
console.error('诊断失败:', error);
toast.error('网络错误,请重试');
setDiagnosisResult({
forwardName: forward.name,
timestamp: Date.now(),
results: [{
success: false,
description: '网络错误',
nodeName: '-',
nodeId: '-',
targetIp: forward.remoteAddr.split(',')[0] || '-',
message: '无法连接到服务器'
}]
});
} finally {
setDiagnosisLoading(false);
}
};
// 获取连接质量
const getQualityDisplay = (averageTime?: number, packetLoss?: number) => {
if (averageTime === undefined || packetLoss === undefined) return null;
if (averageTime < 30 && packetLoss === 0) return { text: '🚀 优秀', color: 'success' };
if (averageTime < 50 && packetLoss === 0) return { text: '✨ 很好', color: 'success' };
if (averageTime < 100 && packetLoss < 1) return { text: '👍 良好', color: 'primary' };
if (averageTime < 150 && packetLoss < 2) return { text: '😐 一般', color: 'warning' };
if (averageTime < 200 && packetLoss < 5) return { text: '😟 较差', color: 'warning' };
return { text: '😵 很差', color: 'danger' };
};
// 格式化流量
const formatFlow = (value: number): string => {
if (value === 0) return '0 B';
@@ -518,6 +597,13 @@ export default function ForwardPage() {
return addresses.length;
};
// 格式化时间
const formatTime = (timestamp: string | number): string => {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString('zh-CN');
};
if (loading) {
return (
<AdminLayout>
@@ -596,9 +682,9 @@ export default function ForwardPage() {
title={formatInAddress(forward.inIp, forward.inPort)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-default-600">入口:</span>
<code className="text-xs font-mono text-foreground truncate">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-medium text-default-600 flex-shrink-0">入口:</span>
<code className="text-xs font-mono text-foreground truncate min-w-0">
{formatInAddress(forward.inIp, forward.inPort)}
</code>
</div>
@@ -620,9 +706,9 @@ export default function ForwardPage() {
title={formatRemoteAddress(forward.remoteAddr)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-default-600">目标:</span>
<code className="text-xs font-mono text-foreground truncate">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-medium text-default-600 flex-shrink-0">目标:</span>
<code className="text-xs font-mono text-foreground truncate min-w-0">
{formatRemoteAddress(forward.remoteAddr)}
</code>
</div>
@@ -666,6 +752,20 @@ export default function ForwardPage() {
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="warning"
onPress={() => handleDiagnose(forward)}
className="flex-1 min-h-8"
startContent={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
}
>
诊断
</Button>
<Button
size="sm"
variant="flat"
@@ -711,7 +811,7 @@ export default function ForwardPage() {
isOpen={modalOpen}
onOpenChange={setModalOpen}
size="lg"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
@@ -725,7 +825,7 @@ export default function ForwardPage() {
</p>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<div className="space-y-4 pb-4">
<Input
label="转发名称"
placeholder="请输入转发名称"
@@ -830,6 +930,7 @@ export default function ForwardPage() {
isOpen={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
size="sm"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
@@ -863,7 +964,7 @@ export default function ForwardPage() {
</Modal>
{/* 地址列表弹窗 */}
<Modal isOpen={addressModalOpen} onClose={() => setAddressModalOpen(false)} size="lg">
<Modal isOpen={addressModalOpen} onClose={() => setAddressModalOpen(false)} size="lg" scrollBehavior="outside">
<ModalContent>
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
<ModalBody className="pb-6">
@@ -891,6 +992,142 @@ export default function ForwardPage() {
</ModalBody>
</ModalContent>
</Modal>
{/* 诊断结果模态框 */}
<Modal
isOpen={diagnosisModalOpen}
onOpenChange={setDiagnosisModalOpen}
size="3xl"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">转发诊断结果</h2>
{currentDiagnosisForward && (
<div className="flex items-center gap-2 min-w-0">
<span className="text-small text-default-500 truncate flex-1 min-w-0">{currentDiagnosisForward.name}</span>
<Chip
color="primary"
variant="flat"
size="sm"
className="flex-shrink-0"
>
转发服务
</Chip>
</div>
)}
</ModalHeader>
<ModalBody>
{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>
</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>
<Chip
color={result.success ? 'success' : 'danger'}
variant="flat"
size="sm"
>
{result.success ? '连接成功' : '连接失败'}
</Chip>
</div>
</div>
</div>
</CardHeader>
<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>
</>
)}
</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.targetIp}</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.targetIp}</code>
</div>
<Alert
color="danger"
variant="flat"
title="错误详情"
description={result.message}
/>
</div>
)}
</CardBody>
</Card>
);
})}
</div>
) : (
<div className="text-center py-16">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-foreground">暂无诊断数据</h3>
</div>
)}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
关闭
</Button>
{currentDiagnosisForward && (
<Button
color="primary"
onPress={() => handleDiagnose(currentDiagnosisForward)}
isLoading={diagnosisLoading}
>
重新诊断
</Button>
)}
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
+2 -1
View File
@@ -337,7 +337,7 @@ export default function LimitPage() {
isOpen={modalOpen}
onOpenChange={setModalOpen}
size="lg"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
@@ -433,6 +433,7 @@ export default function LimitPage() {
isOpen={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
size="sm"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
+235 -48
View File
@@ -15,7 +15,8 @@ import {
getNodeList,
updateNode,
deleteNode,
getNodeInstallCommand
getNodeInstallCommand,
checkNodeStatus
} from "@/api";
interface Node {
@@ -71,6 +72,11 @@ export default function NodePage() {
const [installCommand, setInstallCommand] = useState('');
const [currentNodeName, setCurrentNodeName] = useState('');
// 状态检查相关状态
const [statusCheckLoading, setStatusCheckLoading] = useState(false);
const [statusCheckResult, setStatusCheckResult] = useState<any>(null);
const [statusCheckModal, setStatusCheckModal] = useState(false);
const websocketRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
@@ -446,6 +452,55 @@ export default function NodePage() {
}
};
// 状态检查处理函数
const handleCheckAllStatus = async () => {
setStatusCheckLoading(true);
try {
const res = await checkNodeStatus();
if (res.code === 0) {
setStatusCheckResult(res.data);
setStatusCheckModal(true);
// 刷新节点列表以显示最新状态
await loadNodes();
const { totalNodes, connectedNodes, inconsistentNodes } = res.data;
if (inconsistentNodes > 0) {
toast.success(`状态检查完成!修复了 ${inconsistentNodes} 个节点的状态不一致问题`);
} else {
toast.success(`状态检查完成!${connectedNodes}/${totalNodes} 个节点在线`);
}
} else {
toast.error(res.msg || '状态检查失败');
}
} catch (error) {
toast.error('网络错误,请重试');
} finally {
setStatusCheckLoading(false);
}
};
const handleCheckSingleNodeStatus = async (nodeId: number) => {
try {
const res = await checkNodeStatus(nodeId);
if (res.code === 0) {
// 刷新节点列表
await loadNodes();
const result = res.data.details[0];
if (result.fixed) {
toast.success(`节点状态已修复:${result.message}`);
} else {
toast.success(result.message);
}
} else {
toast.error(res.msg || '状态检查失败');
}
} catch (error) {
toast.error('网络错误,请重试');
}
};
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
@@ -522,17 +577,32 @@ export default function NodePage() {
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-foreground">节点管理</h1>
<Button
color="primary"
onPress={handleAdd}
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
}
>
新增节点
</Button>
<div className="flex gap-2">
<Button
color="secondary"
variant="flat"
onPress={handleCheckAllStatus}
isLoading={statusCheckLoading}
startContent={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
>
状态检查
</Button>
<Button
color="primary"
onPress={handleAdd}
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
}
>
新增节点
</Button>
</div>
</div>
{/* 节点列表 */}
@@ -588,14 +658,18 @@ export default function NodePage() {
<CardBody className="pt-0 pb-3">
{/* 基础信息 */}
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-default-600">入口IP</span>
<div className="text-right text-xs">
<div className="flex justify-between items-center text-sm min-w-0">
<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">{node.ip.split(',')[0].trim()} +{node.ip.split(',').length - 1}个</span>
<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">{node.ip.trim()}</span>
<span className="font-mono truncate block" title={node.ip.trim()}>
{node.ip.trim()}
</span>
)
) : '-'}
</div>
@@ -678,35 +752,48 @@ export default function NodePage() {
</div>
{/* 操作按钮 */}
<div className="flex gap-1.5">
<Button
size="sm"
variant="flat"
color="success"
onPress={() => handleCopyInstallCommand(node)}
isLoading={node.copyLoading}
className="flex-1 min-h-8"
>
复制
</Button>
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(node)}
className="flex-1 min-h-8"
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(node)}
className="flex-1 min-h-8"
>
删除
</Button>
<div className="space-y-1.5">
<div className="flex gap-1.5">
<Button
size="sm"
variant="flat"
color="success"
onPress={() => handleCopyInstallCommand(node)}
isLoading={node.copyLoading}
className="flex-1 min-h-8"
>
复制
</Button>
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(node)}
className="flex-1 min-h-8"
>
编辑
</Button>
</div>
<div className="flex gap-1.5">
<Button
size="sm"
variant="flat"
color="secondary"
onPress={() => handleCheckSingleNodeStatus(node.id)}
className="flex-1 min-h-8"
>
检查
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(node)}
className="flex-1 min-h-8"
>
删除
</Button>
</div>
</div>
</CardBody>
</Card>
@@ -719,7 +806,7 @@ export default function NodePage() {
isOpen={dialogVisible}
onClose={() => setDialogVisible(false)}
size="2xl"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader>{dialogTitle}</ModalHeader>
@@ -810,7 +897,7 @@ export default function NodePage() {
isOpen={installCommandModal}
onClose={() => setInstallCommandModal(false)}
size="2xl"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader>安装命令 - {currentNodeName}</ModalHeader>
@@ -856,6 +943,106 @@ export default function NodePage() {
</ModalFooter>
</ModalContent>
</Modal>
{/* 状态检查结果模态框 */}
<Modal
isOpen={statusCheckModal}
onClose={() => setStatusCheckModal(false)}
size="3xl"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
<span>节点状态检查结果</span>
{statusCheckResult && (
<div className="flex gap-4 text-sm font-normal">
<span className="text-default-600">
总节点: <span className="font-semibold">{statusCheckResult.totalNodes}</span>
</span>
<span className="text-success-600">
在线: <span className="font-semibold">{statusCheckResult.connectedNodes}</span>
</span>
<span className="text-warning-600">
修复: <span className="font-semibold">{statusCheckResult.inconsistentNodes}</span>
</span>
</div>
)}
</ModalHeader>
<ModalBody>
{statusCheckResult && statusCheckResult.details && (
<div className="space-y-3">
{statusCheckResult.details.map((detail: any) => (
<Card key={detail.nodeId} className="border border-divider">
<CardBody className="py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex flex-col">
<span className="font-semibold text-foreground">
{detail.nodeName || `节点 ${detail.nodeId}`}
</span>
<span className="text-xs text-default-500">
ID: {detail.nodeId}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Chip
size="sm"
color={detail.connected ? 'success' : 'danger'}
variant="flat"
>
{detail.connected ? '在线' : '离线'}
</Chip>
<Chip
size="sm"
color={detail.fixed ? 'warning' : 'default'}
variant={detail.fixed ? 'flat' : 'bordered'}
>
{detail.fixed ? '已修复' : '正常'}
</Chip>
</div>
</div>
<div className="mt-2 text-sm">
<div className="flex justify-between items-center text-xs">
<span className="text-default-600">数据库状态:</span>
<span className={`font-mono ${detail.databaseStatus === 1 ? 'text-success-600' : 'text-danger-600'}`}>
{detail.databaseStatus === 1 ? '在线' : '离线'}
</span>
</div>
<div className="flex justify-between items-center text-xs mt-1">
<span className="text-default-600">实际状态:</span>
<span className={`font-mono ${detail.actualStatus ? 'text-success-600' : 'text-danger-600'}`}>
{detail.actualStatus ? '连接中' : '未连接'}
</span>
</div>
{detail.fixed && (
<div className="flex justify-between items-center text-xs mt-1">
<span className="text-default-600">最终状态:</span>
<span className={`font-mono ${detail.finalStatus === 1 ? 'text-success-600' : 'text-danger-600'}`}>
{detail.finalStatus === 1 ? '在线' : '离线'}
</span>
</div>
)}
<div className="mt-2 p-2 bg-default-50 dark:bg-default-100 rounded text-xs">
{detail.message}
</div>
</div>
</CardBody>
</Card>
))}
</div>
)}
</ModalBody>
<ModalFooter>
<Button
variant="flat"
onPress={() => setStatusCheckModal(false)}
>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
+2 -2
View File
@@ -608,7 +608,7 @@ export default function TunnelPage() {
isOpen={modalOpen}
onOpenChange={setModalOpen}
size="2xl"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
@@ -896,7 +896,7 @@ export default function TunnelPage() {
isOpen={diagnosisModalOpen}
onOpenChange={setDiagnosisModalOpen}
size="3xl"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
+137 -8
View File
@@ -152,6 +152,14 @@ export default function UserPage() {
const [editTunnelForm, setEditTunnelForm] = useState<UserTunnel | null>(null);
const [editTunnelLoading, setEditTunnelLoading] = useState(false);
// 删除确认相关状态
const { isOpen: isDeleteModalOpen, onOpen: onDeleteModalOpen, onClose: onDeleteModalClose } = useDisclosure();
const [userToDelete, setUserToDelete] = useState<User | null>(null);
// 删除隧道权限确认相关状态
const { isOpen: isDeleteTunnelModalOpen, onOpen: onDeleteTunnelModalOpen, onClose: onDeleteTunnelModalClose } = useDisclosure();
const [tunnelToDelete, setTunnelToDelete] = useState<UserTunnel | null>(null);
// 其他数据
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
@@ -266,12 +274,21 @@ export default function UserPage() {
onUserModalOpen();
};
const handleDelete = async (user: User) => {
const handleDelete = (user: User) => {
setUserToDelete(user);
onDeleteModalOpen();
};
const handleConfirmDelete = async () => {
if (!userToDelete) return;
try {
const response = await deleteUser(user.id);
const response = await deleteUser(userToDelete.id);
if (response.code === 0) {
toast.success('删除成功');
loadUsers();
onDeleteModalClose();
setUserToDelete(null);
} else {
toast.error(response.msg || '删除失败');
}
@@ -405,14 +422,23 @@ export default function UserPage() {
}
};
const handleRemoveTunnel = async (userTunnel: UserTunnel) => {
const handleRemoveTunnel = (userTunnel: UserTunnel) => {
setTunnelToDelete(userTunnel);
onDeleteTunnelModalOpen();
};
const handleConfirmRemoveTunnel = async () => {
if (!tunnelToDelete) return;
try {
const response = await removeUserTunnel({ id: userTunnel.id });
const response = await removeUserTunnel({ id: tunnelToDelete.id });
if (response.code === 0) {
toast.success('删除成功');
if (currentUser) {
loadUserTunnels(currentUser.id);
}
onDeleteTunnelModalClose();
setTunnelToDelete(null);
} else {
toast.error(response.msg || '删除失败');
}
@@ -646,7 +672,7 @@ export default function UserPage() {
isOpen={isUserModalOpen}
onClose={onUserModalClose}
size="2xl"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader>
@@ -701,7 +727,7 @@ export default function UserPage() {
value={userForm.expTime ? parseDate(userForm.expTime.toISOString().split('T')[0]) : null}
onChange={(date) => {
if (date) {
const jsDate = new Date(date.year, date.month - 1, date.day);
const jsDate = new Date(date.year, date.month - 1, date.day, 23, 59, 59);
setUserForm(prev => ({ ...prev, expTime: jsDate }));
} else {
setUserForm(prev => ({ ...prev, expTime: null }));
@@ -743,7 +769,7 @@ export default function UserPage() {
isOpen={isTunnelModalOpen}
onClose={onTunnelModalClose}
size="5xl"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader>
@@ -822,7 +848,7 @@ export default function UserPage() {
value={tunnelForm.expTime ? parseDate(tunnelForm.expTime.toISOString().split('T')[0]) : null}
onChange={(date) => {
if (date) {
const jsDate = new Date(date.year, date.month - 1, date.day);
const jsDate = new Date(date.year, date.month - 1, date.day, 23, 59, 59);
setTunnelForm(prev => ({ ...prev, expTime: jsDate }));
} else {
setTunnelForm(prev => ({ ...prev, expTime: null }));
@@ -937,6 +963,7 @@ export default function UserPage() {
isOpen={isEditTunnelModalOpen}
onClose={onEditTunnelModalClose}
size="2xl"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader>
@@ -991,6 +1018,22 @@ export default function UserPage() {
</SelectItem>
))}
</Select>
<DatePicker
label="到期时间"
value={editTunnelForm.expTime ? parseDate(new Date(editTunnelForm.expTime).toISOString().split('T')[0]) : null}
onChange={(date) => {
if (date) {
const jsDate = new Date(date.year, date.month - 1, date.day, 23, 59, 59);
setEditTunnelForm(prev => prev ? { ...prev, expTime: jsDate.getTime() } : null);
} else {
setEditTunnelForm(prev => prev ? { ...prev, expTime: Date.now() } : null);
}
}}
showMonthAndYearPickers
className="cursor-pointer"
isRequired
/>
</div>
)}
</ModalBody>
@@ -1008,6 +1051,92 @@ export default function UserPage() {
</ModalFooter>
</ModalContent>
</Modal>
{/* 删除确认对话框 */}
<Modal
isOpen={isDeleteModalOpen}
onClose={onDeleteModalClose}
size="md"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
确认删除用户
</ModalHeader>
<ModalBody>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-danger-100 rounded-full flex items-center justify-center">
<DeleteIcon className="w-6 h-6 text-danger" />
</div>
<div className="flex-1">
<p className="text-foreground">
确定要删除用户 <span className="font-semibold text-danger">"{userToDelete?.user}"</span> 吗?
</p>
<p className="text-small text-default-500 mt-1">
此操作不可撤销,用户的所有数据将被永久删除。
</p>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant="light"
onPress={onDeleteModalClose}
>
取消
</Button>
<Button
color="danger"
onPress={handleConfirmDelete}
>
确认删除
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 删除隧道权限确认对话框 */}
<Modal
isOpen={isDeleteTunnelModalOpen}
onClose={onDeleteTunnelModalClose}
size="md"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
确认删除隧道权限
</ModalHeader>
<ModalBody>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-danger-100 rounded-full flex items-center justify-center">
<DeleteIcon className="w-6 h-6 text-danger" />
</div>
<div className="flex-1">
<p className="text-foreground">
确定要删除用户 <span className="font-semibold">{currentUser?.user}</span> 对隧道 <span className="font-semibold text-danger">"{tunnelToDelete?.tunnelName}"</span> 的权限吗?
</p>
<p className="text-small text-default-500 mt-1">
删除后该用户将无法使用此隧道创建转发,此操作不可撤销。
</p>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant="light"
onPress={onDeleteTunnelModalClose}
>
取消
</Button>
<Button
color="danger"
onPress={handleConfirmRemoveTunnel}
>
确认删除
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</AdminLayout>
);