修复开关状态持久化,编辑主机适配密钥认证

This commit is contained in:
user123456
2025-07-22 18:29:15 +08:00
parent babeb15b7d
commit 8f753bc015

View File

@@ -63,8 +63,11 @@ function MainPage() {
const [isAuthChecking, setIsAuthChecking] = useState(true); // 新增:认证检查状态
const [isPlaybookDialogOpen, setIsPlaybookDialogOpen] = useState(false);
const [playbookTarget, setPlaybookTarget] = useState<'selected' | 'all' | null>(null);
const [useKeyAuth, setUseKeyAuth] = useState(false);
const [useKeyAuth, setUseKeyAuth] = useState(() => {
const saved = localStorage.getItem('useKeyAuth');
return saved ? JSON.parse(saved) : false;
});
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
@@ -73,7 +76,7 @@ function MainPage() {
const checkAuth = () => {
try {
const isLocalAuth = authStorage.getAuth();
// 如果既没有React context认证也没有localStorage认证则跳转到登录页
if (!isAuthenticated && !isLocalAuth) {
navigate('/login');
@@ -87,12 +90,12 @@ function MainPage() {
// 立即检查认证状态
const isAuthed = checkAuth();
// 只有通过了认证检查,才执行后续的数据加载
if (isAuthed) {
fetchHosts();
}
// 完成认证检查
setIsAuthChecking(false);
}, [isAuthenticated, navigate]);
@@ -101,6 +104,11 @@ function MainPage() {
fetchHosts();
}, []);
// 持久化密钥认证开关状态
useEffect(() => {
localStorage.setItem('useKeyAuth', JSON.stringify(useKeyAuth));
}, [useKeyAuth]);
const fetchHosts = async () => {
setIsLoadingHosts(true);
try {
@@ -136,8 +144,8 @@ function MainPage() {
const handleAddHosts = async () => {
if (!batchInput.trim()) {
toast.error("错误", { description: "请输入主机信息" });
return;
toast.error("错误", { description: "请输入主机信息" });
return;
}
const lines = batchInput.trim().split('\n');
const hostsData: Omit<Host, 'id'>[] = [];
@@ -146,55 +154,60 @@ function MainPage() {
const formatString = useKeyAuth ? "'备注 地址 用户名 端口'" : "'备注 地址 用户名 端口 密码'";
lines.forEach((line, index) => {
if (line.trim() === '') return;
const parts = line.trim().split(/\s+/);
if (parts.length !== expectedParts) {
errors.push(`${index + 1}行:格式错误,应为 ${formatString}`);
if (line.trim() === '') return;
const parts = line.trim().split(/\s+/);
if (parts.length !== expectedParts) {
errors.push(`${index + 1}行:格式错误,应为 ${formatString}`);
} else {
const [comment, address, username, portStr, password] = parts;
const port = parseInt(portStr, 10);
if (isNaN(port)) {
errors.push(`${index + 1}行:端口号 '${portStr}' 无效`);
} else {
const [comment, address, username, portStr, password] = parts;
const port = parseInt(portStr, 10);
if (isNaN(port)) {
errors.push(`${index + 1}行:端口号 '${portStr}' 无效`);
} else {
hostsData.push({
comment,
address,
username,
port,
password: useKeyAuth ? '' : password
});
}
hostsData.push({
comment,
address,
username,
port,
password: useKeyAuth ? '' : password
});
}
}
});
if (errors.length > 0) {
errors.forEach(err => toast.error("输入错误", { description: err }));
return;
errors.forEach(err => toast.error("输入错误", { description: err }));
return;
}
if (hostsData.length === 0) {
toast.error("错误", { description: "未找到有效的主机信息" });
return;
toast.error("错误", { description: "未找到有效的主机信息" });
return;
}
setIsAddingHost(true);
try {
// 对每个主机数据进行处理
const processedHostsData = hostsData.map(host => prepareHostData(host, undefined, useKeyAuth));
const response = await api.post('/api/hosts/batch', processedHostsData);
toast.success("成功", { description: response.data.message || `成功添加 ${response.data.count} 台主机` });
setBatchInput('');
fetchHosts();
setIsBatchAddOpen(false); // Close dialog on success
// 对每个主机数据进行处理
const processedHostsData = hostsData.map(host => prepareHostData(host, undefined, useKeyAuth));
const response = await api.post('/api/hosts/batch', processedHostsData);
toast.success("成功", { description: response.data.message || `成功添加 ${response.data.count} 台主机` });
setBatchInput('');
fetchHosts();
setIsBatchAddOpen(false); // Close dialog on success
} catch (error) {
console.error('Failed to add hosts:', error);
toast.error("添加主机失败", {
description: error instanceof Error ? error.message : (error as any).response?.data?.error || "发生未知错误",
});
console.error('Failed to add hosts:', error);
toast.error("添加主机失败", {
description: error instanceof Error ? error.message : (error as any).response?.data?.error || "发生未知错误",
});
} finally {
setIsAddingHost(false);
setIsAddingHost(false);
}
};
const handleEditHost = (host: Host) => {
setEditingHost(host);
// 确保编辑时有默认的认证方式
const hostWithDefaults = {
...host,
auth_method: host.auth_method || 'password'
};
setEditingHost(hostWithDefaults);
};
const handleSaveEdit = async (editedHost: Host) => {
@@ -203,7 +216,7 @@ function MainPage() {
try {
// 处理主机数据,特别是密码字段
const dataToSend = prepareHostData(editedHost, editingHost, editedHost.auth_method === 'key');
await api.put(`/api/hosts/${editingHost.id}`, dataToSend);
toast.success("成功", { description: "主机信息已更新" });
setEditingHost(null);
@@ -308,13 +321,13 @@ function MainPage() {
const openTerminal = (hostId: number) => {
// 打开新窗口
const terminalWindow = window.open(`/terminal/${hostId}`, `terminal_${hostId}`, 'width=800,height=600');
// 确保新窗口成功打开
if (!terminalWindow) {
toast.error('无法打开终端', { description: '请允许浏览器打开弹出窗口' });
return;
}
// 等待新窗口加载完成
const sendAuthInfo = () => {
try {
@@ -322,7 +335,7 @@ function MainPage() {
const token = authStorage.getToken();
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 5); // 5小时过期时间
// 如果terminalWindow可用且已加载完成发送认证信息
if (terminalWindow && terminalWindow.document.readyState === 'complete') {
localStorage.setItem('isAuthenticated', 'true');
@@ -330,7 +343,7 @@ function MainPage() {
if (token) {
localStorage.setItem('token', token);
}
// 尝试向新窗口发送消息,以便它可以检测认证状态
terminalWindow.postMessage({
type: 'AUTH_INFO',
@@ -338,7 +351,7 @@ function MainPage() {
authExpiresAt: expiresAt.toISOString(),
token: token
}, '*');
// 移除敏感日志
} else {
// 如果窗口未完成加载,稍后再试
@@ -349,7 +362,7 @@ function MainPage() {
toast.error('无法连接到终端', { description: '认证信息传递失败' });
}
};
// 开始尝试发送认证信息
setTimeout(sendAuthInfo, 500);
};
@@ -360,8 +373,8 @@ function MainPage() {
return;
}
if (target === 'all' && hosts.length === 0) {
toast.error("错误", { description: "没有主机可供上传" });
return;
toast.error("错误", { description: "没有主机可供上传" });
return;
}
setUploadTarget(target);
setIsUploadDialogOpen(true);
@@ -397,7 +410,7 @@ function MainPage() {
setPlaybookTarget(target);
setIsPlaybookDialogOpen(true);
};
const handlePlaybookComplete = () => {
console.log("Playbook execution complete");
};
@@ -510,13 +523,13 @@ function MainPage() {
<DialogTitle></DialogTitle>
<DialogDescription>
{useKeyAuth
? "备注 地址 用户 端口"
{useKeyAuth
? "备注 地址 用户 端口"
: "备注 地址 用户 端口 SSH密码"}
<br />
<code>
{useKeyAuth
? "us 192.168.1.1 root 22"
{useKeyAuth
? "us 192.168.1.1 root 22"
: "us 192.168.1.2 root 22 yourpassword"}
</code>
</DialogDescription>
@@ -540,16 +553,25 @@ function MainPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex items-center space-x-2">
<Switch id="key-auth-switch" checked={useKeyAuth} onCheckedChange={setUseKeyAuth} />
<Label htmlFor="key-auth-switch">使</Label>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2">
<Switch id="key-auth-switch" checked={useKeyAuth} onCheckedChange={setUseKeyAuth} />
<Label htmlFor="key-auth-switch" className="cursor-pointer">
{useKeyAuth ? '密钥认证模式' : '密码认证模式'}
</Label>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{useKeyAuth ? '新添加的主机将使用SSH密钥认证' : '新添加的主机将使用密码认证'}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm"
disabled={selectedHostIds.length === 0}
<Button variant="outline" size="sm"
disabled={selectedHostIds.length === 0}
onClick={() => openPlaybookDialog('selected')}>
<PlayIcon className="mr-2 h-4 w-4" />
</Button>
@@ -558,8 +580,8 @@ function MainPage() {
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm"
disabled={selectedHostIds.length === 0}
<Button variant="outline" size="sm"
disabled={selectedHostIds.length === 0}
onClick={() => openUploadDialog('selected')}>
<UploadIcon className="mr-2 h-4 w-4" />
</Button>
@@ -568,9 +590,9 @@ function MainPage() {
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" onClick={handlePingAllHosts} disabled={isLoadingHosts || hosts.some(h => h.status === 'checking')}>
<ReloadIcon className={`mr-2 h-4 w-4 ${hosts.some(h => h.status === 'checking') ? 'animate-spin' : ''}`} /> Ping
</Button>
<Button variant="outline" size="sm" onClick={handlePingAllHosts} disabled={isLoadingHosts || hosts.some(h => h.status === 'checking')}>
<ReloadIcon className={`mr-2 h-4 w-4 ${hosts.some(h => h.status === 'checking') ? 'animate-spin' : ''}`} /> Ping
</Button>
</TooltipTrigger>
<TooltipContent><p></p></TooltipContent>
</Tooltip>
@@ -670,29 +692,42 @@ function MainPage() {
{/* Form fields remain the same */}
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-comment" className="text-right"></Label>
<Input id="edit-comment" value={editingHost.comment} onChange={(e) => setEditingHost({...editingHost, comment: e.target.value})} className="col-span-3" />
<Input id="edit-comment" value={editingHost.comment} onChange={(e) => setEditingHost({ ...editingHost, comment: e.target.value })} className="col-span-3" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-address" className="text-right"></Label>
<Input id="edit-address" value={editingHost.address} onChange={(e) => setEditingHost({...editingHost, address: e.target.value})} className="col-span-3" />
<Input id="edit-address" value={editingHost.address} onChange={(e) => setEditingHost({ ...editingHost, address: e.target.value })} className="col-span-3" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-username" className="text-right"></Label>
<Input id="edit-username" value={editingHost.username} onChange={(e) => setEditingHost({...editingHost, username: e.target.value})} className="col-span-3" />
<Input id="edit-username" value={editingHost.username} onChange={(e) => setEditingHost({ ...editingHost, username: e.target.value })} className="col-span-3" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-port" className="text-right"></Label>
<Input id="edit-port" type="number" value={editingHost.port} onChange={(e) => setEditingHost({...editingHost, port: parseInt(e.target.value, 10) || 22})} className="col-span-3" />
<Input id="edit-port" type="number" value={editingHost.port} onChange={(e) => setEditingHost({ ...editingHost, port: parseInt(e.target.value, 10) || 22 })} className="col-span-3" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-password" className="text-right"></Label>
<Input id="edit-password" type="password" placeholder="留空则不修改" onChange={(e) => setEditingHost({...editingHost, password: e.target.value})} className="col-span-3" />
<Label htmlFor="edit-auth-method" className="text-right"></Label>
<div className="col-span-3 flex items-center space-x-2">
<Switch
id="edit-auth-method"
checked={editingHost.auth_method === 'key'}
onCheckedChange={(checked) => setEditingHost({ ...editingHost, auth_method: checked ? 'key' : 'password' })}
/>
<Label htmlFor="edit-auth-method">{editingHost.auth_method === 'key' ? '密钥认证' : '密码认证'}</Label>
</div>
</div>
{editingHost.auth_method === 'password' && (
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-password" className="text-right"></Label>
<Input id="edit-password" type="password" placeholder="留空则不修改" onChange={(e) => setEditingHost({ ...editingHost, password: e.target.value })} className="col-span-3" />
</div>
)}
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline"></Button>
<Button type="button" variant="outline"></Button>
</DialogClose>
<Button type="button" onClick={() => editingHost && handleSaveEdit(editingHost)} disabled={isEditingHost}>
{isEditingHost ? '保存中...' : '保存更改'}
@@ -760,23 +795,23 @@ function MainPage() {
{/* File Upload Dialog */}
<Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{uploadTarget === 'all' ? '所有主机' : '选定主机'}
</DialogDescription>
</DialogHeader>
{uploadTarget && (
<FileUpload
targetHostIds={uploadTarget === 'all' ? 'all' : selectedHostIds}
onUploadComplete={handleUploadComplete}
onClose={() => setIsUploadDialogOpen(false)}
/>
)}
</DialogContent>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{uploadTarget === 'all' ? '所有主机' : '选定主机'}
</DialogDescription>
</DialogHeader>
{uploadTarget && (
<FileUpload
targetHostIds={uploadTarget === 'all' ? 'all' : selectedHostIds}
onUploadComplete={handleUploadComplete}
onClose={() => setIsUploadDialogOpen(false)}
/>
)}
</DialogContent>
</Dialog>
{/* Playbook Execution Dialog */}
<Dialog open={isPlaybookDialogOpen} onOpenChange={setIsPlaybookDialogOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto dialog-content-scroll-hide">
@@ -786,7 +821,7 @@ function MainPage() {
{playbookTarget === 'all' ? '所有主机' : '选定主机'} Playbook
</DialogDescription>
</DialogHeader>
{playbookTarget && (
<PlaybookExecutor
targetHostIds={playbookTarget === 'all' ? 'all' : selectedHostIds}
@@ -799,10 +834,10 @@ function MainPage() {
{/* GitHub Link */}
<div className="text-center mt-6 mb-2">
<a
href="https://github.com/sky22333/ansible-ui"
target="_blank"
rel="noopener noreferrer"
<a
href="https://github.com/sky22333/ansible-ui"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
title="GitHub仓库"
>