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

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